diff --git a/README.md b/README.md index b153d78..fe4882f 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ Tasks only start earning activity credit once revealed, giving players a reason - **Activities across the server** — supported integrations track actions such as voting, cooking, archaeology, instrument playing, market sales, and vehicle building. - **Varied progression limits** — daily caps and a voting share of the daily allowance shape how points are earned. - **Task rerolls** — eligible players can redraw their daily selection within the server's limits. +- **Referrals** — a new player names who invited them (`/referral`); both claim rewards as the new player's AFK-free playtime passes each milestone, and the inviter earns a rare track for invitees who reach the last one. Staff can link, adjust, hold or exclude accounts. Available activities depend on the gameplay plugins and activity definitions in use. 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..8920652 --- /dev/null +++ b/src/main/java/net/tfminecraft/activitytf/commands/ReferralCommand.java @@ -0,0 +1,458 @@ +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.config.ReferralConfiguration.Act; +import net.tfminecraft.activitytf.config.ReferralConfiguration.Milestone; +import net.tfminecraft.activitytf.config.ReferralConfiguration.RareStep; +import net.tfminecraft.activitytf.gui.ReferralGui; +import net.tfminecraft.activitytf.managers.ActivityManager; +import net.tfminecraft.activitytf.managers.ReferralManager; +import net.tfminecraft.activitytf.managers.ReferralManager.Link; +import net.tfminecraft.activitytf.managers.ReferralManager.Side; +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 the staff moderation subcommands (see /referral help). */ +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("help", "info", "link", "unlink", "addtime", + "settime", "unclaim", "markclaimed", "rare", "hold", "release", "allowip"); + private static final List RARE_ACTIONS = List.of("unclaim", "markclaimed", "exclude", "include"); + private static final List SIDES = List.of("new", "inviter", "both"); + private static final int MAX_ADD_MINUTES = 100_000; + private static final int MAX_SET_MINUTES = 1_000_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 (sub.equals("help") && !sender.hasPermission(ADMIN)) { + sender.sendMessage(messages().get("referral.usage")); + return true; + } + if (ADMIN_SUBCOMMANDS.contains(sub)) { + if (!sender.hasPermission(ADMIN)) { + sender.sendMessage(messages().get("admin.no-permission")); + return true; + } + switch (sub) { + case "help" -> messages().lines("referral.admin.help").forEach(sender::sendMessage); + case "info" -> info(sender, args); + case "link" -> link(sender, args); + case "unlink" -> unlink(sender, args); + case "settime" -> setTime(sender, args); + case "unclaim", "markclaimed" -> claims(sender, args, sub.equals("unclaim")); + case "rare" -> rare(sender, args); + case "hold", "release" -> hold(sender, args, sub.equals("hold")); + case "allowip" -> allowIp(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) { + return target(sender, args, length, 1); + } + + private UUID target(CommandSender sender, String[] args, int length, int index) { + if (args.length != length) { + sender.sendMessage(messages().get("referral.usage-admin")); + return null; + } + return account(sender, args[index]); + } + + private UUID account(CommandSender sender, String name) { + UUID uuid = referrals().find(name); + if (uuid == null) { + sender.sendMessage(messages().get("referral.admin.unknown", "%name%", name)); + } + 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, claimed " + other.inviterClaimed() + ")"); + } + List counted = new ArrayList<>(); + for (UUID invitee : referrals().completedInvitees(uuid)) { + counted.add(referrals().getStore().peek(invitee).name()); + } + List flags = new ArrayList<>(); + if (data.held()) { + flags.add("held"); + } + if (data.rareExcluded()) { + flags.add("rare-excluded"); + } + 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(), "%inviter-claimed%", data.inviterClaimed(), + "%invited%", invited.isEmpty() ? "-" : String.join(", ", invited), + "%completed%", counted.size(), "%counted%", counted.isEmpty() ? "-" : String.join(", ", counted), + "%rare%", data.rareClaimed(), "%counted-for%", name(data.rareCountedFor()), + "%flags%", flags.isEmpty() ? "-" : String.join(", ", flags))); + } + + private String name(UUID uuid) { + ReferralData data = uuid == null ? null : referrals().getStore().peek(uuid); + return data == null ? "-" : data.name(); + } + + private void link(CommandSender sender, String[] args) { + UUID uuid = target(sender, args, 3); + if (uuid == null) { + return; + } + UUID inviter = account(sender, args[2]); + if (inviter == null) { + return; + } + UUID before = referrals().getStore().peek(uuid).invitedBy(); + Link result = referrals().staffLink(uuid, inviter); + String key = switch (result) { + case LINKED -> "referral.admin.linked"; + case ALREADY_LINKED -> "referral.admin.already-linked"; + case SELF -> "referral.admin.link-self"; + case LOOP -> "referral.admin.link-loop"; + default -> "referral.admin.link-failed"; + }; + sender.sendMessage(messages().get(key, "%name%", args[1], "%inviter%", args[2])); + audit(sender, "action=staff-link target=" + uuid + " from=" + before + " inviter=" + inviter + + " result=" + (result == Link.FAILED ? "save-failed" : result.name().toLowerCase(Locale.ROOT))); + } + + private void unlink(CommandSender sender, String[] args) { + UUID uuid = target(sender, args, 2); + if (uuid == null) { + return; + } + UUID before = referrals().getStore().peek(uuid).invitedBy(); + int done = referrals().unlink(uuid); + String result = done == ReferralManager.SAVE_FAILED ? "save-failed" : done > 0 ? "done" : "not-linked"; + sender.sendMessage(messages().get(done == ReferralManager.SAVE_FAILED ? "referral.admin.save-failed" + : done > 0 ? "referral.admin.unlinked" : "referral.admin.not-linked", "%name%", args[1])); + audit(sender, "action=unlink target=" + uuid + " from=" + before + " result=" + result); + } + + private void allowIp(CommandSender sender, String[] args) { + UUID one = target(sender, args, 3); + if (one == null) { + return; + } + UUID other = account(sender, args[2]); + if (other == null) { + return; + } + if (one.equals(other)) { + sender.sendMessage(messages().get("referral.usage-admin")); + return; + } + int allowed = referrals().toggleIpPair(one, other); + if (allowed == ReferralManager.SAVE_FAILED) { + sender.sendMessage(messages().get("referral.admin.save-failed")); + audit(sender, "action=allowip target=" + one + " other=" + other + " result=save-failed"); + return; + } + sender.sendMessage(messages().get(allowed > 0 ? "referral.admin.ip-pair-on" : "referral.admin.ip-pair-off", + "%name%", args[1], "%other%", args[2])); + audit(sender, "action=allowip target=" + one + " other=" + other + " allowed=" + (allowed > 0) + + " result=done"); + } + + private void addTime(CommandSender sender, String[] args) { + UUID uuid = target(sender, args, 3); + if (uuid == null) { + return; + } + int minutes = number(args[2]); + if (minutes == 0 || Math.abs(minutes) > MAX_ADD_MINUTES) { + sender.sendMessage(messages().get("referral.usage-admin")); + return; + } + int before = referrals().getStore().peek(uuid).minutes(); + timeSet(sender, args[1], uuid, "addtime", before, + referrals().setMinutes(uuid, (int) Math.min(Integer.MAX_VALUE, (long) before + minutes))); + } + + private void setTime(CommandSender sender, String[] args) { + UUID uuid = target(sender, args, 3); + if (uuid == null) { + return; + } + int minutes = number(args[2]); + if (minutes < 0 || minutes > MAX_SET_MINUTES || minutes == 0 && !args[2].strip().equals("0")) { + sender.sendMessage(messages().get("referral.usage-admin")); + return; + } + int before = referrals().getStore().peek(uuid).minutes(); + timeSet(sender, args[1], uuid, "settime", before, referrals().setMinutes(uuid, minutes)); + } + + private void timeSet(CommandSender sender, String name, UUID uuid, String action, int before, int after) { + if (after == ReferralManager.SAVE_FAILED) { + sender.sendMessage(messages().get("referral.admin.save-failed")); + audit(sender, "action=" + action + " target=" + uuid + " result=save-failed"); + return; + } + sender.sendMessage(messages().get("referral.admin.time-added", "%name%", name, "%minutes%", after)); + audit(sender, "action=" + action + " target=" + uuid + " minutes=" + before + "->" + after + " result=done"); + } + + // The staff answer for a change count: an error code's message, or {@code done} with its placeholders. + private String result(int changed, String missing) { + return changed == ReferralManager.SAVE_FAILED ? "save-failed" : changed < 0 ? missing : "done"; + } + + /** unclaim|markclaimed */ + private void claims(CommandSender sender, String[] args, boolean unclaim) { + UUID uuid = target(sender, args, 4); + if (uuid == null) { + return; + } + Integer hours = which(args[2]); + Side side = side(args[3]); + if (hours != null && hours <= 0 || side == null) { + sender.sendMessage(messages().get("referral.usage-admin")); + return; + } + ReferralData data = referrals().getStore().peek(uuid); + String before = "new=" + data.claimed() + " inviter=" + data.inviterClaimed(); + int changed = unclaim ? referrals().unclaim(uuid, hours, side) : referrals().markClaimed(uuid, hours, side); + String what = hours == null ? "all" : hours + "h"; + String result = result(changed, "no-milestone"); + String key = !result.equals("done") ? "referral.admin." + result + : unclaim ? "referral.admin.unclaimed" : "referral.admin.marked"; + sender.sendMessage(messages().get(key, "%name%", args[1], "%which%", what, + "%side%", side.name().toLowerCase(Locale.ROOT), "%count%", Math.max(0, changed))); + audit(sender, "action=" + (unclaim ? "unclaim" : "markclaimed") + " target=" + uuid + " milestone=" + what + + " side=" + side.name().toLowerCase(Locale.ROOT) + " changed=" + Math.max(0, changed) + + " before=" + Utils.quotedForLog(before) + " after=" + + Utils.quotedForLog("new=" + data.claimed() + " inviter=" + data.inviterClaimed()) + " result=" + result); + } + + /** rare unclaim|markclaimed , rare exclude|include */ + private void rare(CommandSender sender, String[] args) { + String action = args.length < 2 ? "" : args[1].toLowerCase(Locale.ROOT); + if (action.equals("exclude") || action.equals("include")) { + UUID uuid = target(sender, args, 3, 2); + if (uuid == null) { + return; + } + boolean exclude = action.equals("exclude"); + flag(sender, args[2], uuid, "rare-" + action, referrals().setRareExcluded(uuid, exclude), + exclude ? "referral.admin.rare-excluded" : "referral.admin.rare-included"); + return; + } + if (!action.equals("unclaim") && !action.equals("markclaimed")) { + sender.sendMessage(messages().get("referral.usage-admin")); + return; + } + UUID uuid = target(sender, args, 4, 2); + if (uuid == null) { + return; + } + Integer players = which(args[3]); + if (players != null && players <= 0) { + sender.sendMessage(messages().get("referral.usage-admin")); + return; + } + boolean unclaim = action.equals("unclaim"); + ReferralData data = referrals().getStore().peek(uuid); + String before = String.valueOf(data.rareClaimed()); + int changed = unclaim ? referrals().rareUnclaim(uuid, players) : referrals().rareMarkClaimed(uuid, players); + String what = players == null ? "all" : String.valueOf(players); + String result = result(changed, "no-rare-step"); + String key = !result.equals("done") ? "referral.admin." + result + : unclaim ? "referral.admin.rare-unclaimed" : "referral.admin.rare-marked"; + sender.sendMessage(messages().get(key, "%name%", args[2], "%which%", what, "%count%", Math.max(0, changed))); + audit(sender, "action=rare-" + action + " target=" + uuid + " step=" + what + " changed=" + Math.max(0, changed) + + " before=" + before + " after=" + data.rareClaimed() + " result=" + result); + } + + private void hold(CommandSender sender, String[] args, boolean hold) { + UUID uuid = target(sender, args, 2); + if (uuid == null) { + return; + } + flag(sender, args[1], uuid, hold ? "hold" : "release", referrals().setHeld(uuid, hold), + hold ? "referral.admin.held" : "referral.admin.released"); + } + + private void flag(CommandSender sender, String name, UUID uuid, String action, int changed, String done) { + String result = changed == 0 ? "unchanged" : result(changed, "unchanged"); + sender.sendMessage(messages().get(result.equals("done") ? done : "referral.admin." + result, "%name%", name)); + audit(sender, "action=" + action + " target=" + uuid + " result=" + result); + } + + // "all" = null; anything that is not a whole number = -1 (refused by the callers). + private static Integer which(String value) { + if (value.equalsIgnoreCase("all")) { + return null; + } + int number = number(value); + return number == 0 ? -1 : number; + } + + private static int number(String value) { + try { + return Integer.parseInt(value.strip()); + } catch (NumberFormatException e) { + return 0; + } + } + + private static Side side(String value) { + return switch (value.toLowerCase(Locale.ROOT)) { + case "new" -> Side.NEW; + case "inviter" -> Side.INVITER; + case "both" -> Side.BOTH; + default -> null; + }; + } + + 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<>(); + boolean admin = sender.hasPermission(ADMIN); + String sub = args.length == 0 ? "" : args[0].toLowerCase(Locale.ROOT); + if (args.length == 1) { + options.addAll(PLAYER_SUBCOMMANDS); + if (admin) { + options.addAll(ADMIN_SUBCOMMANDS); + } + } else if (args.length == 2 && sub.equals("invitedby")) { + options.addAll(players(sender)); + } else if (admin && ADMIN_SUBCOMMANDS.contains(sub)) { + options.addAll(adminOptions(sender, sub, args)); + } + String typed = args.length == 0 ? "" : args[args.length - 1].toLowerCase(Locale.ROOT); + options.removeIf(option -> !option.toLowerCase(Locale.ROOT).startsWith(typed)); + return options; + } + + private List adminOptions(CommandSender sender, String sub, String[] args) { + int at = args.length; + switch (sub) { + case "help" -> { + return List.of(); + } + case "rare" -> { + if (at == 2) { + return RARE_ACTIONS; + } + if (at == 3) { + return players(sender); + } + if (at == 4 && !args[1].equalsIgnoreCase("exclude") && !args[1].equalsIgnoreCase("include")) { + List steps = new ArrayList<>(); + Act act = referrals().act(); + if (act != null) { + for (RareStep step : act.rareTrack()) { + steps.add(String.valueOf(step.players())); + } + } + steps.add("all"); + return steps; + } + return List.of(); + } + case "unclaim", "markclaimed" -> { + if (at == 2) { + return players(sender); + } + if (at == 3) { + List hours = new ArrayList<>(); + Act act = referrals().act(); + if (act != null) { + for (Milestone milestone : act.milestones()) { + hours.add(String.valueOf(milestone.hours())); + } + } + hours.add("all"); + return hours; + } + return at == 4 ? SIDES : List.of(); + } + case "link", "allowip" -> { + return at == 2 || at == 3 ? players(sender) : List.of(); + } + default -> { + return at == 2 ? players(sender) : List.of(); + } + } + } + + private static List players(CommandSender sender) { + List names = new ArrayList<>(); + for (Player online : Bukkit.getOnlinePlayers()) { + if (!(sender instanceof Player player) || player.canSee(online)) { + names.add(online.getName()); + } + } + return names; + } +} diff --git a/src/main/java/net/tfminecraft/activitytf/config/ActivityConfiguration.java b/src/main/java/net/tfminecraft/activitytf/config/ActivityConfiguration.java index 071ee0b..a86e57a 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) { @@ -874,16 +880,31 @@ private List loadRewardItems(Object raw, String where) { if (!validItemPath(value, at)) { continue; } - items.add(new RewardEntry.Item(value, rewardAmount(map.get("amount"), at))); + Object name = map.get("name"); + String label = name == null || String.valueOf(name).isBlank() ? null : String.valueOf(name).strip(); + items.add(new RewardEntry.Item(value, rewardAmount(map.get("amount"), at), label)); } return items; } private boolean validItemPath(String value, String at) { + if (ItemPath.isCookingPath(value)) { + if (ItemPath.cookingPath(value) == null) { + log().warning("Malformed item path '" + Utils.safeForLog(value) + "' at " + at + + " - expected c.(type=;...) - ignored."); + return false; + } + pluginPathConfigured = true; + if (!pluginEnabled("Cooking")) { + log().warning("Cooking item path '" + Utils.safeForLog(value) + "' at " + at + + " but Cooking is not enabled - it hands over nothing."); + } + return true; + } if (ItemPath.isUnsupportedPath(value)) { log().warning("Unsupported item path '" + Utils.safeForLog(value) + "' at " + at - + " - only bare Material names, v., m.. and ia." - + " are supported - ignored."); + + " - only bare Material names, v., m.., ia. and" + + " c.(...) are supported - ignored."); return false; } if (ItemPath.isItemsAdderPath(value)) { @@ -1132,8 +1153,15 @@ 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..8e044ea --- /dev/null +++ b/src/main/java/net/tfminecraft/activitytf/config/ReferralConfiguration.java @@ -0,0 +1,428 @@ +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, the timed milestones (the same for every act, each + * claimed once ever), the inviter's rare track, and per act the pools those rewards draw from. + */ +public class ReferralConfiguration { + + public static final String FILE = "referral.yml"; + + /** The rare-track menu has four rows of seven steps. */ + public static final int MAX_RARE_STEPS = 28; + + /** 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) { + public int minutes() { + return hours * 60; + } + } + + /** A rare-track step: the inviter claims it once {@code players} invitees have reached the last milestone. */ + public record RareStep(int players, Reward reward) { + } + + /** + * {@code milestones} and {@code rareTrack} are the shared lists; {@code pools} holds this act's loot for + * the pool names they draw from. + */ + public record Act(String id, String display, LocalDateTime starts, List milestones, + List rareTrack, Map> pools) { + } + + 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 List milestones = List.of(); + private volatile List rareTrack = 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); + + // A season.start that does not parse keeps the previous one, so a typo never starts a new season. + String start = config.getString("season.start", "").strip(); + LocalDateTime parsed = start.isEmpty() ? null : dateTime(start, "season.start"); + if (start.isEmpty() || parsed != null) { + seasonStart = parsed; + seasonKey = parsed == null ? "" : parsed.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(1, 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"), "pools"); + milestones = loadMilestones(config); + rareTrack = loadRareTrack(config); + acts = loadActs(config.getConfigurationSection("acts")); + if (enabled && milestones.isEmpty()) { + log().warning(FILE + " has no usable entry under 'milestones' - nothing can be claimed."); + } + if (enabled && acts.isEmpty()) { + log().warning(FILE + " has no usable act under 'acts' - nothing can be claimed."); + } + if (enabled && maxInvitees > 0) { + for (RareStep step : rareTrack) { + if (step.players() > maxInvitees) { + log().warning(FILE + " rare-track step for " + step.players() + " players can never be" + + " claimed: link.max-invitees is " + maxInvitees + "."); + } + } + } + if (enabled) { + Set warned = new HashSet<>(); + for (Act act : acts) { + warnMissingPools(act, warned); + } + } + } + + // Every pool a reward draws from must exist for every act, or that reward can't be claimed then. + private void warnMissingPools(Act act, Set warned) { + for (Milestone milestone : milestones) { + warnMissingPool(act, milestone.newPlayer(), "the " + milestone.hours() + "h milestone", warned); + warnMissingPool(act, milestone.inviter(), "the " + milestone.hours() + "h milestone", warned); + } + for (RareStep step : rareTrack) { + warnMissingPool(act, step.reward(), "the " + step.players() + "-player rare step", warned); + } + } + + private void warnMissingPool(Act act, Reward reward, String what, Set warned) { + if (reward != null && reward.pool() != null && pool(act, reward.pool()).isEmpty() + && warned.add(act.id() + "/" + reward.pool())) { + log().warning(FILE + " acts." + Utils.safeForLog(act.id()) + " has no usable pool '" + + Utils.safeForLog(reward.pool()) + "' (nor does pools or the activity rewards) - " + what + + " can't be claimed during that act."); + } + } + + 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, String at) { + if (section == null) { + return Map.of(); + } + Map> loaded = new LinkedHashMap<>(); + for (String key : section.getKeys(false)) { + if (!ActivityConfiguration.isPoolName(key)) { + log().warning(FILE + " " + at + "." + 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; + } + if (act.contains("milestones")) { + log().warning(FILE + " " + at + ".milestones is the old layout and is ignored - milestones are" + + " now one top-level list shared by every act; an act only sets its pools."); + } + if (act.contains("rare-limit")) { + log().warning(FILE + " " + at + ".rare-limit is no longer used and is ignored - rare rewards are" + + " the top-level rare-track list now."); + } + 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; + } + loaded.add(new Act(id, act.getString("display", id), starts, milestones, rareTrack, + loadPools(act.getConfigurationSection("pools"), at + ".pools"))); + } + loaded.sort(Comparator.comparing(Act::starts)); + return List.copyOf(loaded); + } + + private List loadMilestones(ConfigurationSection config) { + List milestones = new ArrayList<>(); + Set seen = new HashSet<>(); + int index = 0; + for (Map raw : config.getMapList("milestones")) { + String where = "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; + } + if (raw.containsKey("rare")) { + log().warning(FILE + " " + where + ".rare is no longer used and is ignored - rare rewards are" + + " the top-level rare-track list now."); + } + seen.add(hours); + milestones.add(new Milestone(hours, newPlayer, inviter)); + } + milestones.sort(Comparator.comparingInt(Milestone::hours)); + return List.copyOf(milestones); + } + + private List loadRareTrack(ConfigurationSection config) { + List steps = new ArrayList<>(); + Set seen = new HashSet<>(); + int index = 0; + for (Map raw : config.getMapList("rare-track")) { + String where = "rare-track[" + index++ + "]"; + int players = raw.get("players") instanceof Number number ? number.intValue() : 0; + if (players < 1 || players > 1_000) { + log().warning(FILE + " " + where + " needs 'players' between 1 and 1000 - ignored."); + continue; + } + if (seen.contains(players)) { + log().warning(FILE + " " + where + " repeats " + players + " players - ignored."); + continue; + } + Reward reward = reward(raw.get("reward"), where + ".reward"); + if (reward == null) { + log().warning(FILE + " " + where + " has no usable 'reward' - ignored."); + continue; + } + seen.add(players); + steps.add(new RareStep(players, reward)); + } + steps.sort(Comparator.comparingInt(RareStep::players)); + if (steps.size() > MAX_RARE_STEPS) { + log().warning(FILE + " has " + steps.size() + " rare-track steps - only the first " + + MAX_RARE_STEPS + " fit in the menu, the rest are ignored."); + return List.copyOf(steps.subList(0, MAX_RARE_STEPS)); + } + return List.copyOf(steps); + } + + 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); + } + 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); + } + 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 static Reward poolReward(String pool, String display, String icon) { + 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); + } + + /** + * The act's own pool of that name, else referral.yml's shared {@code pools}, else the activity's + * (week-locked) reward pool. {@code act} may be null. + */ + public List pool(Act act, String name) { + List own = act == null ? null : act.pools().get(name); + if (own == null || own.isEmpty()) { + own = pools.get(name); + } + return own != null && !own.isEmpty() ? own : activity.rewardPool(name); + } + + /** The milestones every act shares; each is claimed once ever (per season). */ + public List milestones() { + return milestones; + } + + /** The inviter's rare track, by how many invitees reached the last milestone; each step claimed once. */ + public List rareTrack() { + return rareTrack; + } + + /** Counted minutes an invitee needs to count on the rare track: the last milestone's (0 without any). */ + public int completionMinutes() { + List all = milestones; + return all.isEmpty() ? 0 : all.get(all.size() - 1).minutes(); + } + + /** + * The act whose start is the latest one not after {@code now}; null before the first act, and + * while there are no milestones (nothing could be claimed in it anyway). + */ + public Act act(LocalDateTime now) { + if (milestones.isEmpty()) { + return null; + } + 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; + } + + /** Puts back the season start from before a reload, when the stored links could not be saved first. */ + public void keepSeason(LocalDateTime start) { + seasonStart = start; + seasonKey = start == null ? "" : start.toString(); + } + + /** 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..98b98f1 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,16 @@ public void onClick(InventoryClickEvent event) { return; } + if (event.getRawSlot() == REFERRAL_SLOT && referralShown(player)) { + clickSound(player); + manager.referrals().runNextTick(() -> { + if (player.isOnline()) { + 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..76a1b5a --- /dev/null +++ b/src/main/java/net/tfminecraft/activitytf/gui/ReferralGui.java @@ -0,0 +1,850 @@ +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.RareStep; +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.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.UUID; +import java.util.function.Consumer; +import java.util.function.Supplier; + +/** /referral: your own milestones, the players you invited, your rare track, 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 MILESTONES_PER_PAGE = 7; + static final int MILESTONE_PREVIOUS_SLOT = 9; + static final int MILESTONE_NEXT_SLOT = 17; + 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 RARE_HEADER_SLOT = 4; + static final int RARE_STEPS_PER_ROW = 7; + private static final int RARE_NAMES_SHOWN = 10; + + 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, + RARE + } + + public static final class Marker implements InventoryHolder { + + private final View view; + private int page; + private boolean previous; + private boolean next; + private int milestonePage; + private boolean milestonePrevious; + private boolean milestoneNext; + private final UUID target; + private final List shown = new ArrayList<>(); + private final Map hours = new HashMap<>(); + 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; + } + + int milestonePage() { + return milestonePage; + } + + 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 --------------------------------------------------------------------------- + + public Inventory build(Player player, int page) { + return build(player, page, 0); + } + + /** {@code milestonePage} scrolls the milestone row when there are more than seven milestones. */ + @SuppressWarnings("deprecation") + public Inventory build(Player player, int page, int milestonePage) { + 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 milestonePages = Math.max(1, (milestones.size() + MILESTONES_PER_PAGE - 1) / MILESTONES_PER_PAGE); + int shownMilestones = Math.max(0, Math.min(milestonePage, milestonePages - 1)); + marker.milestonePage = shownMilestones; + List row = milestones.subList(shownMilestones * MILESTONES_PER_PAGE, + Math.min(milestones.size(), (shownMilestones + 1) * MILESTONES_PER_PAGE)); + int[] slots = milestoneSlots(milestonePages > 1 ? MILESTONES_PER_PAGE : row.size()); + for (int index = 0; index < row.size(); index++) { + Milestone milestone = row.get(index); + if (milestone.newPlayer() != null) { + marker.hours.put(slots[index], milestone.hours()); + } + inventory.setItem(slots[index], ownMilestoneItem(data, milestone)); + } + marker.milestonePrevious = shownMilestones > 0; + marker.milestoneNext = shownMilestones < milestonePages - 1; + if (marker.milestonePrevious) { + inventory.setItem(MILESTONE_PREVIOUS_SLOT, item(Material.ARROW, + messages.get("referral.gui.milestones-previous"), List.of())); + } + if (marker.milestoneNext) { + inventory.setItem(MILESTONE_NEXT_SLOT, item(Material.ARROW, + messages.get("referral.gui.milestones-next"), List.of())); + } + 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()); + List completed = referrals.completedInvitees(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)); + marker.page = shownPage; + 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(slice.get(index), act, completed)); + } + + inventory.setItem(BACK_SLOT, item(Material.EXPERIENCE_BOTTLE, messages.get("referral.gui.back"), List.of())); + pageArrows(inventory, marker, shownPage, pages); + inventory.setItem(RARE_SLOT, rareButton(player.getUniqueId(), 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(); + List lore = new ArrayList<>(messages.lines("referral.gui.rules-lore", "%open-days%", config.openDays(), + "%new-days%", config.newAccountDays(), "%afk%", config.afkMinutes())); + if (!config.rareTrack().isEmpty() && config.completionMinutes() > 0) { + lore.addAll(messages.lines("referral.gui.rules-lore-rare", "%hours%", duration(config.completionMinutes()))); + } + return item(Material.BOOK, messages.get("referral.gui.rules"), lore); + } + + // Slot 49: how far along the rare track you are, and the way into it. + private ItemStack rareButton(UUID uuid, ReferralData data, Act act) { + Messages messages = messages(); + if (act == null || act.rareTrack().isEmpty()) { + ItemStack star = item(Material.NETHER_STAR, + messages.get(act == null ? "referral.gui.no-act" : "referral.gui.rare-empty"), List.of()); + glint(star, false); + return star; + } + int completed = referrals().completedInvitees(uuid).size(); + String goal = duration(referrals().getConfiguration().completionMinutes()); + List lore = new ArrayList<>(messages.lines("referral.gui.rare-track-lore", "%hours%", goal)); + lore.add(" "); + RareStep next = null; + boolean claimable = false; + for (RareStep step : act.rareTrack()) { + State state = referrals().rareState(data, completed, step); + claimable |= state == State.CLAIMABLE; + if (next == null && state != State.CLAIMED) { + next = step; + } + } + lore.add(next == null ? messages.get("referral.gui.rare-all-claimed") + : messages.get("referral.gui.rare-next", "%players%", players(next.players()), + "%reward%", Utils.colorize(next.reward().display()))); + lore.add(messages.get("referral.gui.rare-open")); + ItemStack star = item(Material.NETHER_STAR, messages.get("referral.gui.rare-track", "%count%", players(completed), + "%hours%", goal), lore); + // A nether star glints by default, so its glint is set either way. + glint(star, claimable && !data.held()); + return star; + } + + /** The rare track: one step per row slot, claimed by the inviter as their invitees reach the last milestone. */ + @SuppressWarnings("deprecation") + Inventory buildRare(Player player) { + Messages messages = messages(); + ReferralManager referrals = referrals(); + ReferralData data = referrals.getStore().get(player.getUniqueId(), player.getName()); + Act act = referrals.act(); + String goal = duration(referrals.getConfiguration().completionMinutes()); + List completed = referrals.completedInvitees(player.getUniqueId()); + + Marker marker = new Marker(View.RARE, 0, null); + Inventory inventory = Bukkit.createInventory(marker, SIZE, messages.get("referral.gui.rare-title")); + marker.inventory = inventory; + + List header = new ArrayList<>(messages.lines("referral.gui.rare-track-lore", "%hours%", goal)); + header.add(" "); + if (completed.isEmpty()) { + header.add(messages.get("referral.gui.rare-nobody", "%hours%", goal)); + } else { + List names = new ArrayList<>(); + for (UUID id : completed.subList(0, Math.min(RARE_NAMES_SHOWN, completed.size()))) { + names.add(referrals.getStore().peek(id).name()); + } + if (completed.size() > RARE_NAMES_SHOWN) { + names.add("+" + (completed.size() - RARE_NAMES_SHOWN)); + } + header.add(messages.get("referral.gui.rare-reached", "%hours%", goal, "%names%", String.join(", ", names))); + } + ItemStack star = item(Material.NETHER_STAR, messages.get("referral.gui.rare-track", + "%count%", players(completed.size()), "%hours%", goal), header); + glint(star, false); + inventory.setItem(RARE_HEADER_SLOT, star); + + List steps = act == null ? List.of() : act.rareTrack(); + for (int index = 0; index < steps.size(); index++) { + int slot = rareStepSlot(index); + marker.hours.put(slot, steps.get(index).players()); + inventory.setItem(slot, rareStepItem(data, completed.size(), steps.get(index), goal)); + } + if (steps.isEmpty()) { + inventory.setItem(MILESTONE_ROW + 13, item(Material.BARRIER, + messages.get(act == null ? "referral.gui.no-act" : "referral.gui.rare-empty"), List.of())); + } + inventory.setItem(BACK_SLOT, item(Material.OAK_DOOR, messages.get("referral.gui.pick-back"), List.of())); + fill(inventory); + return inventory; + } + + private ItemStack rareStepItem(ReferralData data, int completed, RareStep step, String goal) { + Messages messages = messages(); + State state = referrals().rareState(data, completed, step); + boolean held = data.held() && state == State.CLAIMABLE; + if (held) { + state = State.LOCKED; + } + List lore = rewardLore(step.reward()); + lore.add(" "); + lore.add(held ? messages.get("referral.gui.status-held") : switch (state) { + case CLAIMED -> messages.get("referral.gui.status-claimed"); + case CLAIMABLE -> messages.get("referral.gui.status-claim"); + default -> messages.get("referral.gui.rare-status-locked", "%left%", players(step.players() - completed), + "%hours%", goal); + }); + String key = switch (state) { + case CLAIMED -> "referral.gui.rare-step-claimed"; + case CLAIMABLE -> "referral.gui.rare-step-claimable"; + default -> "referral.gui.rare-step-locked"; + }; + String name = messages.get(key, "%players%", players(step.players()), + "%reward%", Utils.colorize(step.reward().display())); + return rewardItem(step.reward(), name, lore, state == State.CLAIMABLE); + } + + private String players(int count) { + return messages().get(count == 1 ? "referral.gui.player-one" : "referral.gui.player-many", "%count%", count); + } + + 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); + boolean held = data.held() && state == State.CLAIMABLE; + if (held) { + state = State.LOCKED; + } + List lore = rewardLore(reward); + lore.add(" "); + lore.add(held ? messages.get("referral.gui.status-held") : 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(UUID inviteeId, Act act, List completed) { + Messages messages = messages(); + ReferralData invitee = referrals().getStore().peek(inviteeId); + ReferralData inviter = invitee.invitedBy() == null ? null : referrals().getStore().peek(invitee.invitedBy()); + boolean held = invitee.held() || inviter != null && inviter.held(); + 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(invitee, milestone); + if (state == State.CLAIMABLE && next == null) { + next = milestone.hours(); + } + String line = held && state == State.CLAIMABLE ? "held" : state.name().toLowerCase(Locale.ROOT); + lore.add(messages.get("referral.gui.invitee-line-" + line, + "%hours%", milestone.hours(), "%reward%", Utils.colorize(reward.display()), + "%left%", duration(milestone.minutes() - invitee.minutes()))); + } + if (!act.rareTrack().isEmpty()) { + int goal = referrals().getConfiguration().completionMinutes(); + String key = completed.contains(inviteeId) ? "referral.gui.invitee-rare-counts" + : invitee.held() || invitee.rareExcluded() ? "referral.gui.invitee-rare-barred" + : invitee.minutes() >= goal ? "referral.gui.invitee-rare-not-counted" + : "referral.gui.invitee-rare-pending"; + lore.add(messages.get(key, "%hours%", duration(goal))); + } + } + lore.add(" "); + if (held && next != null) { + next = null; + lore.add(messages.get("referral.gui.invitee-held")); + } else { + 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) { + Messages messages = messages(); + List lore = new ArrayList<>(); + if (reward.fixed() != null) { + contents(lore, reward.fixed()); + return lore; + } + List pool = referrals().getConfiguration().pool(referrals().act(), reward.pool()); + if (pool.isEmpty()) { + lore.add(messages.get("gui.reward-preview-unavailable")); + return lore; + } + if (pool.size() == 1) { + contents(lore, pool.get(0)); + return lore; + } + lore.add(messages.get("gui.reward-preview-one")); + for (String option : RewardPreview.summarize(pool)) { + lore.add(Utils.colorize(option)); + } + return lore; + } + + // A reward with only one possible outcome lists every item in it. + private void contents(List lore, RewardEntry entry) { + lore.add(messages().get("referral.gui.contents")); + for (RewardEntry.Item item : entry.items()) { + lore.add(Utils.colorize("#50d990x" + item.amount() + " &f" + ActivityConfiguration.itemName(item))); + } + if (!entry.commands().isEmpty() && !entry.display().isBlank()) { + lore.add(Utils.colorize("&f" + entry.display())); + } + } + + private ItemStack rewardItem(Reward reward, String name, List lore, boolean glint) { + ItemStack stack = rewardIcon(reward); + item(stack, name, lore); + // Set either way: some icons (nether stars, enchanted books, enchanted custom items) glint by default. + glint(stack, glint); + 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())); + pageArrows(inventory, marker, shownPage, pages); + 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; + } + + private void pageArrows(Inventory inventory, Marker marker, int page, int pages) { + Messages messages = messages(); + marker.previous = page > 0; + marker.next = page < pages - 1; + if (marker.previous) { + inventory.setItem(PREVIOUS_SLOT, item(Material.ARROW, messages.get("referral.gui.previous"), List.of())); + } + if (marker.next) { + inventory.setItem(NEXT_SLOT, item(Material.ARROW, messages.get("referral.gui.next"), List.of())); + } + } + + /** 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); + case RARE -> clickRare(player, marker, slot); + } + } + + // Opening another inventory from inside its click event is left to the next tick. + private void show(Player player, Supplier next) { + referrals().runNextTick(() -> { + if (player.isOnline()) { + player.openInventory(next.get()); + } + }); + } + + private static boolean pageClick(Marker marker, int slot) { + return slot == PREVIOUS_SLOT && marker.previous || slot == NEXT_SLOT && marker.next; + } + + private void clickMain(Player player, Marker marker, int slot) { + if (slot == BACK_SLOT) { + if (!player.hasPermission("activity.use")) { + player.sendMessage(messages().get("admin.no-permission")); + return; + } + sound(player); + referrals().runNextTick(() -> { + if (player.isOnline()) { + openActivity.accept(player); + } + }); + return; + } + if (pageClick(marker, slot)) { + sound(player); + show(player, () -> build(player, marker.page() + (slot == NEXT_SLOT ? 1 : -1), marker.milestonePage)); + return; + } + if (slot == MILESTONE_PREVIOUS_SLOT && marker.milestonePrevious + || slot == MILESTONE_NEXT_SLOT && marker.milestoneNext) { + sound(player); + show(player, () -> build(player, marker.page(), + marker.milestonePage + (slot == MILESTONE_NEXT_SLOT ? 1 : -1))); + return; + } + if (slot == RARE_SLOT) { + sound(player); + show(player, () -> buildRare(player)); + return; + } + if (slot == INVITER_SLOT) { + Link refused = referrals().linkable(player.getUniqueId()); + if (refused != null) { + player.sendMessage(messages().get(linkKey(refused), "%name%", "")); + return; + } + sound(player); + show(player, () -> buildPicker(player, 0)); + return; + } + Integer hours = marker.hours.get(slot); + if (hours != null) { + Claim claim = referrals().claimOwn(player, hours); + report(player, claim); + show(player, () -> build(player, marker.page(), marker.milestonePage)); + 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)); + } + show(player, () -> build(player, marker.page(), marker.milestonePage)); + } + } + + private void clickRare(Player player, Marker marker, int slot) { + if (slot == BACK_SLOT) { + sound(player); + show(player, () -> build(player, 0)); + return; + } + Integer players = marker.hours.get(slot); + if (players != null) { + report(player, referrals().claimRare(player, players)); + show(player, () -> buildRare(player)); + } + } + + private void clickPicker(Player player, Marker marker, int slot) { + if (slot == BACK_SLOT) { + sound(player); + show(player, () -> build(player, 0)); + } else if (pageClick(marker, slot)) { + sound(player); + show(player, () -> buildPicker(player, marker.page() + (slot == NEXT_SLOT ? 1 : -1))); + } else if (slot == TYPE_NAME_SLOT) { + sound(player); + referrals().runNextTick(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); + show(player, () -> buildConfirm(marker.shown.get(slot))); + } + } + + private void clickConfirm(Player player, Marker marker, int slot) { + if (slot == CANCEL_SLOT) { + sound(player); + show(player, () -> 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())); + } + } + show(player, () -> 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 ------------------------------------------------------------------------------- + + /** Rare steps fill rows 1-4, seven to a row, leaving the outer columns free. */ + static int rareStepSlot(int index) { + return MILESTONE_ROW + 1 + (index / RARE_STEPS_PER_ROW) * 9 + index % RARE_STEPS_PER_ROW; + } + + /** Up to four milestones sit one slot apart, centred; more fill the row side by side. */ + static int[] milestoneSlots(int count) { + int step = count <= 4 ? 2 : 1; + int first = MILESTONE_ROW + (9 - (step * (count - 1) + 1)) / 2; + int[] slots = new int[count]; + for (int index = 0; index < count; index++) { + slots[index] = first + index * step; + } + return slots; + } + + 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) { + glint(stack, true); + } + + private static void glint(ItemStack stack, boolean on) { + ItemMeta meta = stack.getItemMeta(); + meta.setEnchantmentGlintOverride(on); + 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..561664f --- /dev/null +++ b/src/main/java/net/tfminecraft/activitytf/listeners/ReferralListener.java @@ -0,0 +1,58 @@ +package net.tfminecraft.activitytf.listeners; + +import io.papermc.paper.event.player.AsyncChatEvent; +import org.bukkit.Bukkit; +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()); + } + + // Only a real command that went through: a macro typing /x every few minutes is not activity. + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onCommand(PlayerCommandPreprocessEvent event) { + String label = event.getMessage().substring(1).split(" ", 2)[0]; + if (!label.isEmpty() && Bukkit.getCommandMap().getCommand(label) != null) { + 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..fb8e14f 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,10 +631,10 @@ 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)) { + if (ItemPath.isPluginPath(path) || ItemPath.isCookingPath(path)) { return tlibs ? tlibsItems.apply(path) : null; } if (ItemPath.isItemsAdderPath(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..a03038e --- /dev/null +++ b/src/main/java/net/tfminecraft/activitytf/managers/ReferralManager.java @@ -0,0 +1,1032 @@ +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.RareStep; +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.TreeSet; +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, the inviter's rare track, and the claims both of them make from the referral menu. + */ +public class ReferralManager { + + private static final long MINUTE_TICKS = 60L * 20L; + // Movement is sampled every second: once a minute misses someone pacing back to the same spot. + private static final long SAMPLE_TICKS = 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, + TOO_FEW, + HELD, + INVITEE_HELD, + WRONG_WORLD, + SAME_IP, + UNCONFIGURED + } + + /** Whose claims a staff command changes: the new player's own, their inviter's for them, or both. */ + public enum Side { + NEW, + INVITER, + BOTH + } + + public enum State { + LOCKED, + CLAIMABLE, + CLAIMED + } + + 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 sampler; + 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()); + seedOnline(); + noteCompletions(); + stopTimers(); + timer = plugin.getServer().getScheduler().runTaskTimer(plugin, this::tick, MINUTE_TICKS, MINUTE_TICKS); + sampler = plugin.getServer().getScheduler().runTaskTimer(plugin, this::sample, SAMPLE_TICKS, SAMPLE_TICKS); + long save = activity.getConfiguration().saveIntervalMinutes() * MINUTE_TICKS; + autoSave = plugin.getServer().getScheduler().runTaskTimer(plugin, () -> { + if (store.isDirty()) { + store.saveSoon(); + } + }, save, save); + } + + public void reload() { + // Whoever reached the goal before a reload keeps counting if the reload raises it. + noteCompletions(); + LocalDateTime start = config.seasonStart(); + config.load(); + warnedPools.clear(); + // A file that failed to load is read again, so fixing it by hand needs no restart. + if (!store.isLoaded() || !Objects.equals(store.season(), config.seasonKey())) { + if (store.isLoaded() && !store.saveNow()) { + // Loading would drop what is not on disk yet, so the old season stays until a save works. + config.keepSeason(start); + plugin.getLogger().severe(ReferralStore.FILE + " could not be saved, so the referral season did" + + " not change - the next /activity reload tries again."); + } else { + store.load(config.seasonKey()); + seedOnline(); + } + } + noteCompletions(); + } + + /** + * Notes, for every linked account that has reached the rare-track goal, the inviter it reached it + * with: a later milestone that raises the goal doesn't take that back. Unlinking clears it. + */ + public void noteCompletions() { + int goal = config.completionMinutes(); + if (goal <= 0 || !store.isLoaded()) { + return; + } + for (ReferralData data : store.all().values()) { + if (data.invitedBy() != null && data.rareReachedWith() == null && data.minutes() >= goal) { + data.setRareReachedWith(data.invitedBy()); + data.setRareReachedGoal(goal); + store.markDirty(); + } + } + } + + // Players already online when the store is (re)loaded never fire a join, so record them now. + private void seedOnline() { + for (Player player : Bukkit.getOnlinePlayers()) { + seed(player); + anchors.putIfAbsent(player.getUniqueId(), player.getLocation()); + } + } + + /** Runs GUI work on the next tick, outside the inventory event that asked for it. */ + public void runNextTick(Runnable work) { + plugin.getServer().getScheduler().runTask(plugin, work); + } + + public void shutdown() { + stopTimers(); + store.shutdown(); + } + + private void stopTimers() { + if (sampler != null) { + sampler.cancel(); + sampler = null; + } + 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 sample() { + long now = clock.getAsLong(); + for (Player player : Bukkit.getOnlinePlayers()) { + sampleMovement(player, now); + } + } + + 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(); + } + } + noteCompletions(); + } + + // A move of at least playtime.move-blocks since the last one counts as being at the keyboard. + // The first sample only sets the anchor: joining (or a reconnect loop) is not activity. + void sampleMovement(Player player, long now) { + Location here = player.getLocation(); + Location anchor = anchors.get(player.getUniqueId()); + double move = config.moveBlocks(); + if (anchor == null) { + anchors.put(player.getUniqueId(), here); + } else if (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) { + seed(player); + anchors.put(player.getUniqueId(), player.getLocation()); + lastActive.remove(player.getUniqueId()); + if (config.enabled() && hasClaimable(player.getUniqueId())) { + player.sendMessage(messages().get("referral.reward-ready")); + } + } + + // Name, first join, last seen and connection of an online account. + private void seed(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); + } + data.setLastSeen(now); + InetSocketAddress address = player.getAddress(); + if (address != null && address.getAddress() != null) { + data.addIp(store.ipHash(address.getAddress().getHostAddress())); + } + store.markDirty(); + } + + /** True when the IP check is on, staff have not paired the two, and they have shared a connection. */ + boolean sharedConnection(UUID oneId, ReferralData one, UUID otherId, ReferralData other) { + return config.ipCheck() && !one.ipPartners().contains(otherId) && !other.ipPartners().contains(oneId) + && one.sharesIp(other); + } + + /** + * Staff override for two players who really share a connection (family, a shared house). It covers + * only that pair, so neither can use it for alts of their own. Returns 1 when now allowed, 0 when no + * longer allowed, or {@link #SAVE_FAILED} (nothing changed). + */ + public int toggleIpPair(UUID one, UUID other) { + ReferralData first = store.peek(one); + ReferralData second = store.peek(other); + boolean allow = !first.ipPartners().contains(other); + pair(first, one, second, other, allow); + return staffSaved(1, () -> pair(first, one, second, other, !allow)) == SAVE_FAILED ? SAVE_FAILED + : allow ? 1 : 0; + } + + private static void pair(ReferralData first, UUID one, ReferralData second, UUID other, boolean allow) { + if (allow) { + first.ipPartners().add(other); + second.ipPartners().add(one); + } else { + first.ipPartners().remove(other); + second.ipPartners().remove(one); + } + } + + 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; + } + noteCompletions(); + 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 (data != null && sharedConnection(uuid, data, inviterId, 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); + } + + /** Staff unlink: 1 when unlinked, 0 when it wasn't linked, or {@link #SAVE_FAILED} (still linked). */ + public int unlink(UUID uuid) { + ReferralData data = store.peek(uuid); + if (data == null || data.invitedBy() == null) { + return 0; + } + Runnable undo = relink(data); + data.link(null, 0); + return staffSaved(1, undo); + } + + // Puts back the link (and the rare-goal note that goes with it) as it is now. + private static Runnable relink(ReferralData data) { + UUID inviter = data.invitedBy(); + long linkedAt = data.linkedAt(); + UUID reached = data.rareReachedWith(); + int goal = data.rareReachedGoal(); + return () -> { + data.link(inviter, linkedAt); + data.setRareReachedWith(reached); + data.setRareReachedGoal(goal); + }; + } + + /** + * Staff link or re-link, for a wrong or missed link: no window, connection or per-inviter limits, + * and it doesn't count towards the inviter's links per day, but never to themselves or into a loop. + * Claims already made stay made. + */ + public Link staffLink(UUID uuid, UUID inviterId) { + if (!store.isLoaded()) { + return Link.FAILED; + } + if (uuid.equals(inviterId)) { + return Link.SELF; + } + ReferralData data = store.peek(uuid); + if (data == null || store.peek(inviterId) == null) { + return Link.UNKNOWN; + } + if (inviterId.equals(data.invitedBy())) { + return Link.ALREADY_LINKED; + } + if (leadsTo(inviterId, uuid)) { + return Link.LOOP; + } + Runnable undo = relink(data); + data.link(null, 0); + data.link(inviterId, 0); + if (staffSaved(1, undo) == SAVE_FAILED) { + return Link.FAILED; + } + noteCompletions(); + return Link.LINKED; + } + + // ---- staff moderation -------------------------------------------------------------------- + + /** A staff change refused because the account (or the milestone or step) doesn't exist. */ + public static final int NOT_FOUND = -1; + /** A staff change that could not be saved; it was undone. */ + public static final int SAVE_FAILED = -2; + + /** + * Makes milestones claimable again ({@code hours} null = all) on the given side. Returns how many + * claims were removed, or {@link #SAVE_FAILED}. + */ + public int unclaim(UUID uuid, Integer hours, Side side) { + ReferralData data = store.peek(uuid); + if (data == null) { + return NOT_FOUND; + } + Set own = new TreeSet<>(data.claimed()); + Set theirs = new TreeSet<>(data.inviterClaimed()); + int removed = 0; + if (side != Side.INVITER) { + removed += removeClaims(data.claimed(), hours); + } + if (side != Side.NEW) { + removed += removeClaims(data.inviterClaimed(), hours); + } + return staffSaved(removed, () -> restore(data, own, theirs)); + } + + /** + * Marks milestones as claimed without paying them ({@code hours} null = every milestone of the act in + * use), on the given side. Returns how many were newly marked, {@link #NOT_FOUND} when no such + * milestone exists, or {@link #SAVE_FAILED}. + */ + public int markClaimed(UUID uuid, Integer hours, Side side) { + ReferralData data = store.peek(uuid); + Act act = act(); + if (data == null || act == null || hours != null && milestone(act, hours) == null) { + return NOT_FOUND; + } + Set own = new TreeSet<>(data.claimed()); + Set theirs = new TreeSet<>(data.inviterClaimed()); + int added = 0; + for (Milestone milestone : act.milestones()) { + if (hours != null && milestone.hours() != hours) { + continue; + } + if (side != Side.INVITER && milestone.newPlayer() != null && data.claimed().add(milestone.hours())) { + added++; + } + if (side != Side.NEW && milestone.inviter() != null && data.inviterClaimed().add(milestone.hours())) { + added++; + } + } + return staffSaved(added, () -> restore(data, own, theirs)); + } + + private static void restore(ReferralData data, Set own, Set theirs) { + data.claimed().clear(); + data.claimed().addAll(own); + data.inviterClaimed().clear(); + data.inviterClaimed().addAll(theirs); + } + + /** + * Makes rare-track steps claimable again ({@code players} null = all). Clearing every step also frees + * the invitees tied to this inviter's track, so staff can move them. Returns how many steps were + * removed plus how many invitees were freed, or {@link #SAVE_FAILED}. + */ + public int rareUnclaim(UUID inviter, Integer players) { + ReferralData data = store.peek(inviter); + if (data == null) { + return NOT_FOUND; + } + Set steps = new TreeSet<>(data.rareClaimed()); + int removed = removeClaims(data.rareClaimed(), players); + List freed = new ArrayList<>(); + if (players == null) { + for (ReferralData other : store.all().values()) { + if (inviter.equals(other.rareCountedFor())) { + other.setRareCountedFor(null); + freed.add(other); + } + } + } + return staffSaved(removed + freed.size(), () -> { + data.rareClaimed().clear(); + data.rareClaimed().addAll(steps); + freed.forEach(other -> other.setRareCountedFor(inviter)); + }); + } + + /** + * Marks rare-track steps as claimed without paying them ({@code players} null = every step), and ties + * the invitees it stands on as a real claim does. Returns how many steps were newly marked, + * {@link #NOT_FOUND} when no such step exists, or {@link #SAVE_FAILED}. + */ + public int rareMarkClaimed(UUID inviter, Integer players) { + ReferralData data = store.peek(inviter); + Act act = act(); + if (data == null || act == null || players != null && rareStep(act, players) == null) { + return NOT_FOUND; + } + int added = 0; + Set steps = new TreeSet<>(data.rareClaimed()); + for (RareStep step : act.rareTrack()) { + if ((players == null || step.players() == players) && data.rareClaimed().add(step.players())) { + added++; + } + } + List tied = new ArrayList<>(); + if (added > 0) { + for (UUID id : completedInvitees(inviter)) { + ReferralData invitee = store.peek(id); + if (invitee.rareCountedFor() == null) { + invitee.setRareCountedFor(inviter); + tied.add(invitee); + } + } + } + return staffSaved(added, () -> { + data.rareClaimed().clear(); + data.rareClaimed().addAll(steps); + tied.forEach(invitee -> invitee.setRareCountedFor(null)); + }); + } + + /** Puts an account on hold or releases it: 1 when changed, 0 when it already was, or an error code. */ + public int setHeld(UUID uuid, boolean held) { + ReferralData data = store.peek(uuid); + if (data == null) { + return NOT_FOUND; + } + if (data.held() == held) { + return 0; + } + data.setHeld(held); + return staffSaved(1, () -> data.setHeld(!held)); + } + + /** Excludes an account from every rare track or lets it count again, like {@link #setHeld}. */ + public int setRareExcluded(UUID uuid, boolean excluded) { + ReferralData data = store.peek(uuid); + if (data == null) { + return NOT_FOUND; + } + if (data.rareExcluded() == excluded) { + return 0; + } + data.setRareExcluded(excluded); + return staffSaved(1, () -> data.setRareExcluded(!excluded)); + } + + /** + * Sets counted playtime (0 or more) and returns the new value, or {@link #SAVE_FAILED}. Taking an + * account back below the goal it reached the rare track at also takes back that note, unless a claim + * already counted it. + */ + public int setMinutes(UUID uuid, int minutes) { + ReferralData data = store.peek(uuid); + if (data == null) { + return NOT_FOUND; + } + int before = data.minutes(); + UUID reachedBefore = data.rareReachedWith(); + int goalBefore = data.rareReachedGoal(); + data.setMinutes(minutes); + int reachedAt = goalBefore > 0 ? goalBefore : config.completionMinutes(); + if (data.minutes() < before && data.minutes() < reachedAt && data.rareCountedFor() == null) { + data.setRareReachedWith(null); + data.setRareReachedGoal(0); + } + if (staffSaved(1, () -> { + data.setMinutes(before); + data.setRareReachedWith(reachedBefore); + data.setRareReachedGoal(goalBefore); + }) == SAVE_FAILED) { + return SAVE_FAILED; + } + noteCompletions(); + return data.minutes(); + } + + private static int removeClaims(Set claims, Integer which) { + if (which == null) { + int size = claims.size(); + claims.clear(); + return size; + } + return claims.remove(which) ? 1 : 0; + } + + // Saves a staff change at once; one that can't be saved is undone, so staff are never told a change + // stuck that a restart would lose. + private int staffSaved(int changed, Runnable undo) { + if (changed <= 0) { + return changed; + } + store.markDirty(); + if (store.saveNow()) { + return changed; + } + undo.run(); + store.markDirty(); + return SAVE_FAILED; + } + + // ---- 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 invitee, Milestone milestone) { + if (invitee.inviterClaimed().contains(milestone.hours())) { + return State.CLAIMED; + } + return invitee.minutes() < milestone.minutes() ? State.LOCKED : State.CLAIMABLE; + } + + public static RareStep rareStep(Act act, int players) { + if (act != null) { + for (RareStep step : act.rareTrack()) { + if (step.players() == players) { + return step; + } + } + } + return null; + } + + /** + * The players this inviter brought in who reached the last milestone, skipping any that share a + * connection with the inviter or already counted for another inviter's rare track (before staff + * re-linked them), or that staff put on hold or excluded from rare tracks: they move the inviter + * along the rare track. One that reached the goal while + * linked to this inviter, or already counted towards its claimed steps, keeps counting even if a + * later milestone raised the goal since. + */ + public List completedInvitees(UUID inviter) { + ReferralData mine = store.peek(inviter); + int goal = config.completionMinutes(); + List done = new ArrayList<>(); + if (mine == null || goal <= 0) { + return done; + } + for (UUID id : invitees(inviter)) { + ReferralData invitee = store.peek(id); + UUID countedFor = invitee.rareCountedFor(); + boolean usedElsewhere = countedFor != null && !countedFor.equals(inviter); + boolean reached = inviter.equals(countedFor) || inviter.equals(invitee.rareReachedWith()) + || invitee.minutes() >= goal; + boolean barred = invitee.held() || invitee.rareExcluded(); + if (!usedElsewhere && !barred && reached && !sharedConnection(inviter, mine, id, invitee)) { + done.add(id); + } + } + return done; + } + + public State rareState(ReferralData inviter, int completed, RareStep step) { + if (inviter.rareClaimed().contains(step.players())) { + return State.CLAIMED; + } + return completed >= step.players() ? State.CLAIMABLE : State.LOCKED; + } + + /** 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 || data.held()) { + return false; + } + for (Milestone milestone : act.milestones()) { + if (milestone.newPlayer() != null && ownState(data, milestone) == State.CLAIMABLE) { + return true; + } + } + for (UUID invitee : invitees(uuid)) { + ReferralData other = store.peek(invitee); + for (Milestone milestone : act.milestones()) { + if (milestone.inviter() != null && !other.held() && inviterState(other, milestone) == State.CLAIMABLE) { + return true; + } + } + } + int completed = completedInvitees(uuid).size(); + for (RareStep step : act.rareTrack()) { + if (rareState(data, completed, step) == 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; + } + Act act = act(); + 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; + } + if (data.held()) { + return Claim.HELD; + } + Claim refused = placeAndConnection(player, data, data.invitedBy(), store.peek(data.invitedBy())); + if (refused != null) { + return refused; + } + return pay(player, act, 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()); + State state = inviterState(invitee, milestone); + if (state != State.CLAIMABLE) { + return state == State.CLAIMED ? Claim.ALREADY_CLAIMED : Claim.LOCKED; + } + if (data.held()) { + return Claim.HELD; + } + if (invitee.held()) { + return Claim.INVITEE_HELD; + } + Claim refused = placeAndConnection(player, data, inviteeId, invitee); + if (refused != null) { + return refused; + } + return pay(player, act, milestone.inviter(), "referral " + hours + "h for " + inviteeId, + () -> invitee.inviterClaimed().add(hours), () -> invitee.inviterClaimed().remove(hours)); + } + + /** The inviter claims a rare-track step once enough of their invitees have reached the last milestone. */ + public Claim claimRare(Player player, int players) { + if (!config.enabled()) { + return Claim.DISABLED; + } + if (!store.isLoaded()) { + return Claim.FAILED; + } + Act act = act(); + RareStep step = rareStep(act, players); + if (step == null) { + return Claim.NO_REWARD; + } + ReferralData data = store.get(player.getUniqueId(), player.getName()); + List completed = completedInvitees(player.getUniqueId()); + State state = rareState(data, completed.size(), step); + if (state != State.CLAIMABLE) { + return state == State.CLAIMED ? Claim.ALREADY_CLAIMED : Claim.TOO_FEW; + } + if (data.held()) { + return Claim.HELD; + } + if (config.ignoredWorld(player.getWorld().getName())) { + return Claim.WRONG_WORLD; + } + // The invitees this claim stands on are tied to this inviter's track from now on. + List newlyCounted = new ArrayList<>(); + for (UUID id : completed) { + ReferralData invitee = store.peek(id); + if (invitee.rareCountedFor() == null) { + newlyCounted.add(invitee); + } + } + UUID inviter = player.getUniqueId(); + return pay(player, act, step.reward(), "referral rare " + players + " players", + () -> { + data.rareClaimed().add(players); + newlyCounted.forEach(invitee -> invitee.setRareCountedFor(inviter)); + }, + () -> { + data.rareClaimed().remove(players); + newlyCounted.forEach(invitee -> invitee.setRareCountedFor(null)); + }); + } + + /** 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; + } + for (Milestone milestone : act.milestones()) { + if (milestone.inviter() != null && inviterState(invitee, 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, UUID otherId, ReferralData other) { + if (config.ignoredWorld(player.getWorld().getName())) { + return Claim.WRONG_WORLD; + } + if (other != null && sharedConnection(player.getUniqueId(), mine, otherId, other)) { + return Claim.SAME_IP; + } + return null; + } + + // One act for the whole claim, so a claim at an act's start minute counts and pays in the same act. + private Claim pay(Player player, Act act, Reward reward, String at, Runnable apply, Runnable revert) { + RewardEntry entry = resolve(act, 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(plainText(shown)) + + " result=done"); + return Claim.CLAIMED; + } + + RewardEntry resolve(Act act, Reward reward, String name) { + if (reward.fixed() != null) { + return reward.fixed(); + } + List pool = config.pool(act, reward.pool()); + RewardEntry drawn = ActivityManager.draw(ActivityManager.runnableEntries(pool, name)); + if (drawn == null && warnedPools.add(act.id() + "/" + 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; + } + + static String plainText(String text) { + return text.replaceAll("#[0-9a-fA-F]{6}|[&\u00a7][0-9a-fk-orA-FK-OR]", ""); + } + + 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..24d7ccd --- /dev/null +++ b/src/main/java/net/tfminecraft/activitytf/models/ReferralData.java @@ -0,0 +1,194 @@ +package net.tfminecraft.activitytf.models; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +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 UUID rareCountedFor; + private UUID rareReachedWith; + private int rareReachedGoal; + private boolean held; + private boolean rareExcluded; + private long lastSeen; + private final Set ipPartners = new HashSet<>(); + private final Set claimed = new TreeSet<>(); + private final Set inviterClaimed = new TreeSet<>(); + private final LinkedHashSet ips = new LinkedHashSet<>(); + private final Set rareClaimed = new TreeSet<>(); + + 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 long lastSeen() { + return lastSeen; + } + + public void setLastSeen(long lastSeen) { + this.lastSeen = lastSeen; + } + + /** Accounts staff let this one link and claim with despite a shared connection (one household). */ + public Set ipPartners() { + return ipPartners; + } + + 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; + if (inviter == null) { + rareReachedWith = null; + rareReachedGoal = 0; + } + } + + /** + * The inviter whose rare-track claim this account already counted towards; after that it never + * counts for anyone else, even if staff link it to another inviter. + */ + public UUID rareCountedFor() { + return rareCountedFor; + } + + public void setRareCountedFor(UUID inviter) { + this.rareCountedFor = inviter; + } + + /** + * The inviter this account was linked to when it reached the rare-track goal: it keeps counting for + * them if a later milestone raises the goal. Unlinking clears it. + */ + public UUID rareReachedWith() { + return rareReachedWith; + } + + public void setRareReachedWith(UUID inviter) { + this.rareReachedWith = inviter; + } + + /** The rare-track goal, in counted minutes, this account reached it at (0 = not noted). */ + public int rareReachedGoal() { + return rareReachedGoal; + } + + public void setRareReachedGoal(int minutes) { + this.rareReachedGoal = Math.max(0, minutes); + } + + /** + * Staff hold: this account claims nothing, and its inviter claims nothing for it, until staff + * release it. Playtime still counts. + */ + public boolean held() { + return held; + } + + public void setHeld(boolean held) { + this.held = held; + } + + /** Staff exclusion: this account never counts on anyone's rare track. */ + public boolean rareExcluded() { + return rareExcluded; + } + + public void setRareExcluded(boolean rareExcluded) { + this.rareExcluded = rareExcluded; + } + + /** 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-track steps this account has claimed as an inviter, by their player count. */ + public Set rareClaimed() { + return rareClaimed; + } + + public List ipList() { + return new ArrayList<>(ips); + } +} diff --git a/src/main/java/net/tfminecraft/activitytf/models/RewardEntry.java b/src/main/java/net/tfminecraft/activitytf/models/RewardEntry.java index ed5972b..097cd7c 100644 --- a/src/main/java/net/tfminecraft/activitytf/models/RewardEntry.java +++ b/src/main/java/net/tfminecraft/activitytf/models/RewardEntry.java @@ -4,7 +4,11 @@ public record RewardEntry(int weight, String display, List commands, List items) { - public record Item(String path, int amount) { + /** One handed-over item; name, when set, is what menus call it instead of a name built from the path. */ + public record Item(String path, int amount, String name) { + public Item(String path, int amount) { + this(path, amount, null); + } } public static int totalWeight(List pool) { 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..a9254d3 --- /dev/null +++ b/src/main/java/net/tfminecraft/activitytf/store/ReferralStore.java @@ -0,0 +1,317 @@ +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 lastStart = ""; + 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; + lastStart = 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", ""); + String last = yaml.getString("last-start", stored); + // Only a change from one real start date to another starts over; setting or clearing + // season.start keeps what was counted, and a blank one in between does not hide the change. + if (!last.isEmpty() && !currentSeason.isEmpty() && !last.equals(currentSeason)) { + if (!archive(last)) { + return; + } + } else { + read(yaml.getConfigurationSection("players")); + lastStart = currentSeason.isEmpty() ? last : currentSeason; + dirty |= !stored.equals(currentSeason); + } + } + 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.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")); + data.setLastSeen(entry.getLong("last-seen")); + for (String partner : entry.getStringList("ip-partners")) { + UUID id = uuid(partner); + if (id != null) { + data.ipPartners().add(id); + } + } + UUID inviter = uuid(entry.getString("invited-by", "")); + if (inviter != null) { + data.link(inviter, entry.getLong("linked-at")); + } + data.setRareCountedFor(uuid(entry.getString("rare-counted-for", ""))); + data.setRareReachedWith(uuid(entry.getString("rare-reached-with", ""))); + data.setRareReachedGoal(entry.getInt("rare-reached-goal")); + data.setHeld(entry.getBoolean("held")); + data.setRareExcluded(entry.getBoolean("rare-excluded")); + data.claimed().addAll(entry.getIntegerList("claimed")); + data.inviterClaimed().addAll(entry.getIntegerList("inviter-claimed")); + for (String ip : entry.getStringList("ips")) { + data.addIp(ip); + } + data.rareClaimed().addAll(entry.getIntegerList("rare-claimed")); + 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 most recently seen on, ignoring case; null when nobody this season used it. */ + public UUID byName(String name) { + UUID found = null; + long seen = Long.MIN_VALUE; + for (Map.Entry entry : players.entrySet()) { + ReferralData data = entry.getValue(); + if (data.name() != null && data.name().equalsIgnoreCase(name) && data.lastSeen() >= seen) { + found = entry.getKey(); + seen = data.lastSeen(); + } + } + return found; + } + + /** Salted hash of a connection address, so the file holds no raw IP (not proof against someone who can read it and guess). */ + 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("last-start", lastStart); + 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()); + yaml.set(path + ".last-seen", data.lastSeen()); + if (!data.ipPartners().isEmpty()) { + yaml.set(path + ".ip-partners", data.ipPartners().stream().map(UUID::toString).sorted().toList()); + } + if (data.invitedBy() != null) { + yaml.set(path + ".invited-by", data.invitedBy().toString()); + yaml.set(path + ".linked-at", data.linkedAt()); + } + if (data.rareCountedFor() != null) { + yaml.set(path + ".rare-counted-for", data.rareCountedFor().toString()); + } + if (data.rareReachedWith() != null) { + yaml.set(path + ".rare-reached-with", data.rareReachedWith().toString()); + if (data.rareReachedGoal() > 0) { + yaml.set(path + ".rare-reached-goal", data.rareReachedGoal()); + } + } + if (data.held()) { + yaml.set(path + ".held", true); + } + if (data.rareExcluded()) { + yaml.set(path + ".rare-excluded", true); + } + 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.rareClaimed().isEmpty()) { + yaml.set(path + ".rare-claimed", new ArrayList<>(data.rareClaimed())); + } + } + 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/java/net/tfminecraft/activitytf/utils/ItemPath.java b/src/main/java/net/tfminecraft/activitytf/utils/ItemPath.java index 0c90546..5a45335 100644 --- a/src/main/java/net/tfminecraft/activitytf/utils/ItemPath.java +++ b/src/main/java/net/tfminecraft/activitytf/utils/ItemPath.java @@ -27,6 +27,51 @@ public static String pluginPath(String value) { return v; } + /** + * A Cooking food, made through TLibs: {@code c.(type=;quality=)}. Only reward + * items take it; everywhere else it is still an unsupported path. + */ + public static boolean isCookingPath(String value) { + if (value == null) { + return false; + } + String v = value.strip(); + return v.length() >= 2 && v.regionMatches(true, 0, "c.", 0, 2); + } + + public static String cookingPath(String value) { + if (!isCookingPath(value)) { + return null; + } + String v = value.strip(); + int open = v.indexOf('('); + if (open <= 2 || !v.endsWith(")") || hasWhitespace(v) + || !v.substring(2, open).chars().allMatch(c -> Character.isLetterOrDigit(c) || c == '_')) { + return null; + } + return cookingType(v) == null ? null : v; + } + + /** The food id in a Cooking path's {@code type=} field, or null when it has none. */ + public static String cookingType(String value) { + if (!isCookingPath(value)) { + return null; + } + String v = value.strip(); + int open = v.indexOf('('); + if (open < 0 || !v.endsWith(")")) { + return null; + } + for (String field : v.substring(open + 1, v.length() - 1).split(";")) { + int equals = field.indexOf('='); + if (equals > 0 && field.substring(0, equals).strip().equalsIgnoreCase("type")) { + String type = field.substring(equals + 1).strip(); + return type.isEmpty() ? null : type; + } + } + return null; + } + public static boolean isItemsAdderPath(String value) { if (value == null) { return false; diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 0ee7f02..febb9c8 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -794,6 +794,8 @@ rewards: # items: # - item: NETHERITE_INGOT # amount: 1 + # An item may also set 'name:', what menus call it instead of a name + # built from its path. # Direct rewards.pool_ lists are also supported. Nested pools win # if both locations define the same name. Names are case-insensitive. # Missing/empty pools pay nothing and leave the claim available. diff --git a/src/main/resources/messages.yml b/src/main/resources/messages.yml index 0a1b091..164158e 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,161 @@ 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 help &7lists the staff commands." + 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." + too-few: "&cNot enough of the players you invited have reached the last milestone for that one yet." + held: "&cYour referral rewards are on hold for now. Ask staff." + invitee-held: "&cThat player's referral rewards are on hold for now, so yours for them are too. Ask staff." + 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, inviter claimed &f%inviter-claimed%&7, invited: &f%invited%&7, rare track: &f%completed% &7(&f%counted%&7), rare claimed: &f%rare%&7, counted for: &f%counted-for%&7, flags: &f%flags%" + help: + - "&e/referral info &7- links, playtime, claims and flags" + - "&e/referral link &7- set or fix a link (no window, connection or limit checks)" + - "&e/referral unlink &7- remove a link" + - "&e/referral addtime &7- add (or with -, take) counted playtime" + - "&e/referral settime &7- set counted playtime" + - "&e/referral unclaim &7- make milestones claimable again" + - "&e/referral markclaimed &7- mark milestones claimed without paying" + - "&7 new = the player's own claim, inviter = their inviter's claim for them" + - "&e/referral rare unclaim|markclaimed &7- the same for rare-track steps" + - "&e/referral rare exclude|include &7- stop or restart them counting on any rare track" + - "&e/referral hold|release &7- freeze their claims and their inviter's claims for them" + - "&e/referral allowip &7- toggle the same-connection exception for one pair" + linked: "&aLinked %name% to %inviter%." + already-linked: "&e%name% is already linked to %inviter%." + link-self: "&cAn account can't invite itself." + link-loop: "&c%name% invited %inviter% (or someone above them), so that would be a loop." + link-failed: "&cThe link could not be saved - check the console." + unclaimed: "&a%name%: &f%count% &aclaim(s) removed (%which%, %side%) - claimable again." + marked: "&a%name%: &f%count% &amilestone claim(s) marked (%which%, %side%) without paying." + no-milestone: "&cThere is no %which% milestone in the act in use." + rare-unclaimed: "&a%name%: &f%count% &arare-track step(s) and ties removed (%which%)." + rare-marked: "&a%name%: &f%count% &arare-track step(s) marked (%which%) without paying." + no-rare-step: "&cThere is no rare-track step for %which% players in the act in use." + rare-excluded: "&a%name% no longer counts on any rare track." + rare-included: "&a%name% counts on rare tracks again." + held: "&a%name% is on hold: no referral claims by or for them until released." + released: "&a%name% is released - their referral claims work again." + unchanged: "&e%name% was already like that - nothing changed." + save-failed: "&cThat change could not be saved, so it was undone - check the console." + 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." + ip-pair-on: "&a%name% and %other% may now link and claim with each other despite a shared connection." + ip-pair-off: "&e%name% and %other% are held to the same-connection check again." + 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 reward is claimed once; what it holds" + - "&7depends on the act." + rules-lore-rare: + - "&7Every player you invite who reaches &f%hours%" + - "&7moves you one step along your rare track." + 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" + milestones-previous: "&7Earlier milestones" + milestones-next: "&7Later milestones" + player-one: "%count% player" + player-many: "%count% players" + rare-track: "#c26ef0&lRare track&e: &6%count% &7counted" + rare-track-lore: + - "&7Every player you invited who reaches &f%hours%" + - "&7of counted playtime moves you one step along." + - "&7Each step is claimed once." + rare-next: "&7Next step&e: &f%players% &7- %reward%" + rare-all-claimed: "&aEvery rare reward claimed." + rare-open: "#6b9c68[#fafa16Click to open#6b9c68]" + rare-empty: "&7No rare track is set up" + rare-title: "&8Referral - rare track" + rare-nobody: "&8Nobody you invited has reached %hours% yet." + rare-reached: "&7Counted&e: &f%names%" + rare-step-claimed: "&a✔ %players% &7- %reward%" + rare-step-claimable: "&a&l%players% &a- %reward%" + rare-step-locked: "&7%players% - %reward%" + rare-status-locked: "&8Locked - &7%left% &8more need to reach %hours%" + 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-held: "&cOn hold - ask staff" + 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-held: "&8» %hours%h &7%reward% &c(on hold)" + invitee-line-locked: "&8✖ %hours%h &7%reward% &8(%left% to go)" + invitee-rare-counts: "#c26ef0★ Counts for your rare track" + invitee-rare-pending: "&8★ Counts for your rare track at %hours%" + invitee-rare-not-counted: "&8★ Doesn't count for your rare track (shared connection, or counted for another inviter)" + invitee-rare-barred: "&8★ Doesn't count for your rare track for now (staff decision)" + invitee-held: "&cOn hold - ask staff" + 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..80b31fa 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 |help|info|link|unlink|addtime|settime|unclaim|markclaimed|rare|hold|release|allowip] + 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: Referral staff commands - inspect, link and unlink, adjust playtime and claims, hold accounts, rare-track exclusions, same-connection exceptions + 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..0ce710c --- /dev/null +++ b/src/main/resources/referral.yml @@ -0,0 +1,236 @@ +# 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. Changing it from one date to another + # starts a new season: referrals.yml is moved aside + # (referrals--