diff --git a/pom.xml b/pom.xml
index 39227dd..d339ea3 100644
--- a/pom.xml
+++ b/pom.xml
@@ -41,6 +41,13 @@
26.1-R0.1-SNAPSHOT-build2096-tfmc-822e034c64c8
provided
+
+
+ net.luckperms
+ api
+ 5.4
+ provided
+
me.plugins
tlibs
diff --git a/src/main/java/net/tfminecraft/tfmcweb/Cache.java b/src/main/java/net/tfminecraft/tfmcweb/Cache.java
index d0e9aff..e82298a 100644
--- a/src/main/java/net/tfminecraft/tfmcweb/Cache.java
+++ b/src/main/java/net/tfminecraft/tfmcweb/Cache.java
@@ -3,6 +3,7 @@
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
+import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
@@ -62,8 +63,26 @@ public final class Cache {
public static boolean drinksMetaDefaultAllowMessage = false;
public static List drinksMetaGroups = MetaGroupDefinition.emptyList();
+ /** Master switch for /patreon and the rank writer. */
+ public static boolean patreonEnabled = false;
+ /** When true with {@link #patreonEnabled}, this server writes LuckPerms ranks. */
+ public static boolean patreonApplyRanks = false;
+ public static int patreonPollSeconds = 60;
+ public static int patreonReconcileMinutes = 30;
+ /** Tier key → LuckPerms group. Only these groups are ever added or removed. */
+ public static Map patreonGroups = Map.copyOf(patreonGroupDefaults());
+
private Cache() {}
+ /** Mutable noble/gilded/ascended → same-name group map. */
+ public static Map patreonGroupDefaults() {
+ Map groups = new LinkedHashMap<>();
+ groups.put("noble", "noble");
+ groups.put("gilded", "gilded");
+ groups.put("ascended", "ascended");
+ return groups;
+ }
+
/** One LP-gated cooldown row from token-cooldowns.groups. */
public static final class TokenCooldownGroup {
public final String permission;
diff --git a/src/main/java/net/tfminecraft/tfmcweb/TFMCWeb.java b/src/main/java/net/tfminecraft/tfmcweb/TFMCWeb.java
index 6553b0f..a44964c 100644
--- a/src/main/java/net/tfminecraft/tfmcweb/TFMCWeb.java
+++ b/src/main/java/net/tfminecraft/tfmcweb/TFMCWeb.java
@@ -13,11 +13,13 @@
import net.tfminecraft.tfmcweb.loaders.ConfigLoader;
import net.tfminecraft.tfmcweb.entitlements.PlayerMetaSyncService;
import net.tfminecraft.tfmcweb.managers.LinkDiscordCommand;
+import net.tfminecraft.tfmcweb.managers.PatreonCommand;
import net.tfminecraft.tfmcweb.managers.PluginNoticePoller;
import net.tfminecraft.tfmcweb.managers.TokenCommand;
import net.tfminecraft.tfmcweb.managers.UnlinkDiscordCommand;
import net.tfminecraft.tfmcweb.managers.WarningCommand;
import net.tfminecraft.tfmcweb.managers.WebCommand;
+import net.tfminecraft.tfmcweb.patreon.PatreonRankWriter;
public class TFMCWeb extends JavaPlugin {
@@ -27,6 +29,7 @@ public class TFMCWeb extends JavaPlugin {
private final LinkCache linkCache = new LinkCache();
private DiscordGateService gateService;
private PluginNoticePoller noticePoller;
+ private PatreonRankWriter rankWriter;
@Override
public void onEnable() {
@@ -46,6 +49,8 @@ public void onEnable() {
noticePoller = new PluginNoticePoller(this, linkCache, gateService);
noticePoller.start();
+ rankWriter = new PatreonRankWriter(this);
+ rankWriter.refresh();
if (getCommand("linkdiscord") != null) {
getCommand("linkdiscord").setExecutor(new LinkDiscordCommand(this, linkCache, gateService));
@@ -60,7 +65,7 @@ public void onEnable() {
getLogger().severe("Command unlinkdiscord missing from plugin.yml");
}
if (getCommand("web") != null) {
- WebCommand web = new WebCommand(this, linkCache, gateService);
+ WebCommand web = new WebCommand(this, linkCache, gateService, rankWriter);
getCommand("web").setExecutor(web);
getCommand("web").setTabCompleter(web);
} else {
@@ -78,6 +83,13 @@ public void onEnable() {
} else {
getLogger().severe("Command warning missing from plugin.yml");
}
+ if (getCommand("patreon") != null) {
+ PatreonCommand patreon = new PatreonCommand(this);
+ getCommand("patreon").setExecutor(patreon);
+ getCommand("patreon").setTabCompleter(patreon);
+ } else {
+ getLogger().severe("Command patreon missing from plugin.yml");
+ }
Bukkit.getPluginManager().registerEvents(
new PlayerJoinListener(this, gateService),
@@ -99,6 +111,9 @@ public void onDisable() {
if (noticePoller != null) {
noticePoller.stop();
}
+ if (rankWriter != null) {
+ rankWriter.stop();
+ }
}
public LinkCache getLinkCache() {
@@ -111,6 +126,9 @@ public DiscordGateService getGateService() {
public void reloadLocalConfig() {
configLoader.load(new File(getDataFolder(), "config.yml"));
+ if (rankWriter != null) {
+ rankWriter.refresh();
+ }
PlayerMetaSyncService.pushAllOnlineAsync();
}
diff --git a/src/main/java/net/tfminecraft/tfmcweb/api/PatreonClient.java b/src/main/java/net/tfminecraft/tfmcweb/api/PatreonClient.java
new file mode 100644
index 0000000..48928ab
--- /dev/null
+++ b/src/main/java/net/tfminecraft/tfmcweb/api/PatreonClient.java
@@ -0,0 +1,443 @@
+package net.tfminecraft.tfmcweb.api;
+
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParseException;
+import com.google.gson.JsonParser;
+import com.google.gson.JsonPrimitive;
+
+import net.tfminecraft.tfmcweb.api.ProvinceSystemGateway.GatewayResult;
+
+/**
+ * ProvinceSystem Patreon routes. HTTP goes through {@link ProvinceSystemGateway}.
+ */
+public final class PatreonClient {
+
+ private PatreonClient() {}
+
+ public static final class StatusResult {
+ public final boolean ok;
+ public final boolean linked;
+ public final String tierKey;
+ public final String tierName;
+ public final String graceUntil;
+ public final String patreonName;
+ public final String error;
+
+ private StatusResult(
+ boolean ok,
+ boolean linked,
+ String tierKey,
+ String tierName,
+ String graceUntil,
+ String patreonName,
+ String error
+ ) {
+ this.ok = ok;
+ this.linked = linked;
+ this.tierKey = tierKey;
+ this.tierName = tierName;
+ this.graceUntil = graceUntil;
+ this.patreonName = patreonName;
+ this.error = error;
+ }
+
+ public static StatusResult success(
+ boolean linked,
+ String tierKey,
+ String tierName,
+ String graceUntil,
+ String patreonName
+ ) {
+ return new StatusResult(true, linked, tierKey, tierName, graceUntil, patreonName, null);
+ }
+
+ public static StatusResult fail(String error) {
+ return new StatusResult(false, false, null, null, null, null, error);
+ }
+ }
+
+ public static final class LinkStartResult {
+ public final boolean ok;
+ public final String authorizeUrl;
+ public final String expiresAt;
+ public final String error;
+
+ private LinkStartResult(boolean ok, String authorizeUrl, String expiresAt, String error) {
+ this.ok = ok;
+ this.authorizeUrl = authorizeUrl;
+ this.expiresAt = expiresAt;
+ this.error = error;
+ }
+
+ public static LinkStartResult success(String authorizeUrl, String expiresAt) {
+ return new LinkStartResult(true, authorizeUrl, expiresAt, null);
+ }
+
+ public static LinkStartResult fail(String error) {
+ return new LinkStartResult(false, null, null, error);
+ }
+ }
+
+ public static final class UnlinkResult {
+ public final boolean ok;
+ public final boolean unlinked;
+ public final String error;
+
+ private UnlinkResult(boolean ok, boolean unlinked, String error) {
+ this.ok = ok;
+ this.unlinked = unlinked;
+ this.error = error;
+ }
+
+ public static UnlinkResult success(boolean unlinked) {
+ return new UnlinkResult(true, unlinked, null);
+ }
+
+ public static UnlinkResult fail(String error) {
+ return new UnlinkResult(false, false, error);
+ }
+ }
+
+ public static final class RankChange {
+ public final int id;
+ public final String playerUuid;
+ public final String addTier;
+ public final List removeTiers;
+
+ public RankChange(int id, String playerUuid, String addTier, List removeTiers) {
+ this.id = id;
+ this.playerUuid = playerUuid;
+ this.addTier = addTier;
+ this.removeTiers = removeTiers == null ? List.of() : List.copyOf(removeTiers);
+ }
+ }
+
+ public static final class RankChangesResult {
+ public final boolean ok;
+ public final List changes;
+ public final String error;
+
+ private RankChangesResult(boolean ok, List changes, String error) {
+ this.ok = ok;
+ this.changes = changes == null ? List.of() : List.copyOf(changes);
+ this.error = error;
+ }
+
+ public static RankChangesResult success(List changes) {
+ return new RankChangesResult(true, changes, null);
+ }
+
+ public static RankChangesResult fail(String error) {
+ return new RankChangesResult(false, null, error);
+ }
+ }
+
+ public static final class RosterMember {
+ public final String playerUuid;
+ public final String tierKey;
+
+ public RosterMember(String playerUuid, String tierKey) {
+ this.playerUuid = playerUuid;
+ this.tierKey = tierKey;
+ }
+ }
+
+ public static final class RosterResult {
+ public final boolean ok;
+ public final List members;
+ public final String error;
+
+ private RosterResult(boolean ok, List members, String error) {
+ this.ok = ok;
+ this.members = members == null ? List.of() : List.copyOf(members);
+ this.error = error;
+ }
+
+ public static RosterResult success(List members) {
+ return new RosterResult(true, members, null);
+ }
+
+ public static RosterResult fail(String error) {
+ return new RosterResult(false, null, error);
+ }
+ }
+
+ public static final class AckResult {
+ public final boolean ok;
+ public final String error;
+
+ private AckResult(boolean ok, String error) {
+ this.ok = ok;
+ this.error = error;
+ }
+
+ public static AckResult success() {
+ return new AckResult(true, null);
+ }
+
+ public static AckResult fail(String error) {
+ return new AckResult(false, error);
+ }
+ }
+
+ public static StatusResult status(String playerUuid) {
+ String uuid = trim(playerUuid);
+ if (uuid.isEmpty()) {
+ return StatusResult.fail("player_uuid is required");
+ }
+ String path = "/patreon/status?player_uuid="
+ + URLEncoder.encode(uuid, StandardCharsets.UTF_8);
+ GatewayResult raw = ProvinceSystemGateway.request("GET", path, null);
+ if (!raw.ok) {
+ return StatusResult.fail(raw.error);
+ }
+ JsonObject root = object(raw.body);
+ if (root == null) {
+ return StatusResult.fail("Malformed Patreon status");
+ }
+ return StatusResult.success(
+ isTrue(root.get("linked")),
+ jsonText(root, "tier_key"),
+ jsonText(root, "tier_name"),
+ jsonText(root, "grace_until"),
+ jsonText(root, "patreon_name")
+ );
+ }
+
+ public static LinkStartResult startLink(String playerUuid, String minecraftName) {
+ String uuid = trim(playerUuid);
+ if (uuid.isEmpty()) {
+ return LinkStartResult.fail("player_uuid is required");
+ }
+ JsonObject body = new JsonObject();
+ body.addProperty("player_uuid", uuid);
+ body.addProperty("minecraft_name", minecraftName == null ? "" : minecraftName);
+ GatewayResult raw = ProvinceSystemGateway.request(
+ "POST",
+ "/patreon/link/start",
+ body.toString()
+ );
+ if (!raw.ok) {
+ return LinkStartResult.fail(raw.error);
+ }
+ JsonObject root = object(raw.body);
+ if (root == null) {
+ return LinkStartResult.fail("Malformed Patreon link response");
+ }
+ String url = jsonText(root, "authorize_url");
+ if (url == null) {
+ return LinkStartResult.fail("API returned OK but no authorize URL.");
+ }
+ return LinkStartResult.success(url, jsonText(root, "expires_at"));
+ }
+
+ public static UnlinkResult unlink(String playerUuid) {
+ String uuid = trim(playerUuid);
+ if (uuid.isEmpty()) {
+ return UnlinkResult.fail("player_uuid is required");
+ }
+ JsonObject body = new JsonObject();
+ body.addProperty("player_uuid", uuid);
+ GatewayResult raw = ProvinceSystemGateway.request(
+ "POST",
+ "/patreon/link/unlink",
+ body.toString()
+ );
+ if (!raw.ok) {
+ return UnlinkResult.fail(raw.error);
+ }
+ JsonObject root = object(raw.body);
+ if (root == null) {
+ return UnlinkResult.fail("Malformed Patreon unlink response");
+ }
+ return UnlinkResult.success(isTrue(root.get("unlinked")));
+ }
+
+ public static RankChangesResult listRankChanges() {
+ GatewayResult raw = ProvinceSystemGateway.request(
+ "GET",
+ "/patreon/plugin/rank-changes",
+ null
+ );
+ if (!raw.ok) {
+ return RankChangesResult.fail(raw.error);
+ }
+ JsonObject root = object(raw.body);
+ if (root == null) {
+ return RankChangesResult.fail("Malformed rank-changes response");
+ }
+ JsonElement changes = root.get("changes");
+ if (changes == null || changes.isJsonNull()) {
+ return RankChangesResult.success(List.of());
+ }
+ if (!changes.isJsonArray()) {
+ return RankChangesResult.fail("Malformed rank-changes response");
+ }
+ List parsed = new ArrayList<>();
+ for (JsonElement element : changes.getAsJsonArray()) {
+ RankChange change = parseChange(element);
+ if (change != null) {
+ parsed.add(change);
+ }
+ }
+ return RankChangesResult.success(parsed);
+ }
+
+ public static AckResult ackRankChanges(List ids) {
+ if (ids == null || ids.isEmpty()) {
+ return AckResult.success();
+ }
+ StringBuilder sb = new StringBuilder("{\"ids\":[");
+ for (int i = 0; i < ids.size(); i++) {
+ if (i > 0) {
+ sb.append(',');
+ }
+ sb.append(ids.get(i).intValue());
+ }
+ sb.append("]}");
+ GatewayResult raw = ProvinceSystemGateway.request(
+ "POST",
+ "/patreon/plugin/rank-changes/ack",
+ sb.toString()
+ );
+ if (!raw.ok) {
+ return AckResult.fail(raw.error);
+ }
+ return AckResult.success();
+ }
+
+ public static RosterResult listRoster() {
+ GatewayResult raw = ProvinceSystemGateway.request("GET", "/patreon/plugin/roster", null);
+ if (!raw.ok) {
+ return RosterResult.fail(raw.error);
+ }
+ JsonObject root = object(raw.body);
+ if (root == null) {
+ return RosterResult.fail("Malformed roster response");
+ }
+ JsonElement members = root.get("members");
+ if (members == null || members.isJsonNull()) {
+ return RosterResult.success(List.of());
+ }
+ if (!members.isJsonArray()) {
+ return RosterResult.fail("Malformed roster response");
+ }
+ List parsed = new ArrayList<>();
+ for (JsonElement element : members.getAsJsonArray()) {
+ RosterMember member = parseMember(element);
+ if (member != null) {
+ parsed.add(member);
+ }
+ }
+ return RosterResult.success(parsed);
+ }
+
+ private static RankChange parseChange(JsonElement element) {
+ if (element == null || !element.isJsonObject()) {
+ return null;
+ }
+ JsonObject obj = element.getAsJsonObject();
+ Integer id = jsonId(obj);
+ String uuid = jsonText(obj, "player_uuid");
+ if (id == null || uuid == null) {
+ return null;
+ }
+ return new RankChange(id.intValue(), uuid, jsonText(obj, "add_tier"), jsonStrings(obj.get("remove_tiers")));
+ }
+
+ private static RosterMember parseMember(JsonElement element) {
+ if (element == null || !element.isJsonObject()) {
+ return null;
+ }
+ JsonObject obj = element.getAsJsonObject();
+ String uuid = jsonText(obj, "player_uuid");
+ if (uuid == null) {
+ return null;
+ }
+ return new RosterMember(uuid, jsonText(obj, "tier_key"));
+ }
+
+ private static List jsonStrings(JsonElement element) {
+ if (element == null || element.isJsonNull() || !element.isJsonArray()) {
+ return List.of();
+ }
+ List values = new ArrayList<>();
+ JsonArray array = element.getAsJsonArray();
+ for (JsonElement item : array) {
+ if (item == null || !item.isJsonPrimitive()) {
+ continue;
+ }
+ String text = item.getAsString().trim();
+ if (!text.isEmpty()) {
+ values.add(text);
+ }
+ }
+ return values;
+ }
+
+ private static Integer jsonId(JsonObject obj) {
+ JsonElement element = obj.get("id");
+ if (element == null || element.isJsonNull() || !element.isJsonPrimitive()) {
+ return null;
+ }
+ try {
+ JsonPrimitive primitive = element.getAsJsonPrimitive();
+ if (primitive.isNumber()) {
+ long value = primitive.getAsLong();
+ if (value < Integer.MIN_VALUE || value > Integer.MAX_VALUE) {
+ return null;
+ }
+ return Integer.valueOf((int) value);
+ }
+ return Integer.valueOf(primitive.getAsString().trim());
+ } catch (NumberFormatException e) {
+ return null;
+ }
+ }
+
+ private static String jsonText(JsonObject obj, String key) {
+ JsonElement element = obj.get(key);
+ if (element == null || element.isJsonNull() || !element.isJsonPrimitive()) {
+ return null;
+ }
+ String text = element.getAsString().trim();
+ return text.isEmpty() ? null : text;
+ }
+
+ private static boolean isTrue(JsonElement element) {
+ if (element == null || element.isJsonNull() || !element.isJsonPrimitive()) {
+ return false;
+ }
+ JsonPrimitive primitive = element.getAsJsonPrimitive();
+ if (primitive.isBoolean()) {
+ return primitive.getAsBoolean();
+ }
+ return "true".equalsIgnoreCase(primitive.getAsString());
+ }
+
+ private static JsonObject object(String body) {
+ if (body == null || body.isBlank()) {
+ return null;
+ }
+ try {
+ JsonElement element = JsonParser.parseString(body);
+ if (!element.isJsonObject()) {
+ return null;
+ }
+ return element.getAsJsonObject();
+ } catch (JsonParseException e) {
+ return null;
+ }
+ }
+
+ private static String trim(String value) {
+ return value == null ? "" : value.trim();
+ }
+}
diff --git a/src/main/java/net/tfminecraft/tfmcweb/loaders/ConfigLoader.java b/src/main/java/net/tfminecraft/tfmcweb/loaders/ConfigLoader.java
index 8710186..ec000b5 100644
--- a/src/main/java/net/tfminecraft/tfmcweb/loaders/ConfigLoader.java
+++ b/src/main/java/net/tfminecraft/tfmcweb/loaders/ConfigLoader.java
@@ -4,6 +4,7 @@
import java.io.IOException;
import java.util.List;
import java.util.Locale;
+import java.util.Map;
import org.bukkit.configuration.ConfigurationSection;
import org.bukkit.configuration.InvalidConfigurationException;
@@ -37,6 +38,47 @@ public void load(File configFile) {
loadRealmAndTokens(config);
loadTokenCooldowns(config);
PlayerMetaConfigLoader.load(config);
+ loadPatreon(config);
+ }
+
+ private void loadPatreon(FileConfiguration config) {
+ Cache.patreonEnabled = config.getBoolean("patreon.enabled", false);
+ Cache.patreonApplyRanks = config.getBoolean("patreon.apply-ranks", false);
+ Cache.patreonPollSeconds = positiveOrDefault(
+ config.getInt("patreon.poll-seconds", 60),
+ 60
+ );
+ Cache.patreonReconcileMinutes = positiveOrDefault(
+ config.getInt("patreon.reconcile-minutes", 30),
+ 30
+ );
+ Cache.patreonGroups = Map.copyOf(readPatreonGroups(
+ config.getConfigurationSection("patreon.groups")
+ ));
+ }
+
+ private static int positiveOrDefault(int value, int fallback) {
+ return value < 1 ? fallback : value;
+ }
+
+ private static Map readPatreonGroups(ConfigurationSection section) {
+ Map groups = Cache.patreonGroupDefaults();
+ if (section == null) {
+ return groups;
+ }
+ for (String rawKey : section.getKeys(false)) {
+ String tier = rawKey.trim().toLowerCase(Locale.ROOT);
+ if (!"noble".equals(tier) && !"gilded".equals(tier) && !"ascended".equals(tier)) {
+ continue;
+ }
+ String value = section.getString(rawKey);
+ if (value == null || value.isBlank()) {
+ groups.remove(tier);
+ continue;
+ }
+ groups.put(tier, value.trim());
+ }
+ return groups;
}
private void loadRealmAndTokens(FileConfiguration config) {
diff --git a/src/main/java/net/tfminecraft/tfmcweb/managers/PatreonCommand.java b/src/main/java/net/tfminecraft/tfmcweb/managers/PatreonCommand.java
new file mode 100644
index 0000000..14bac34
--- /dev/null
+++ b/src/main/java/net/tfminecraft/tfmcweb/managers/PatreonCommand.java
@@ -0,0 +1,153 @@
+package net.tfminecraft.tfmcweb.managers;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.Locale;
+
+import org.bukkit.Bukkit;
+import org.bukkit.ChatColor;
+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 org.bukkit.plugin.java.JavaPlugin;
+
+import net.tfminecraft.tfmcweb.api.PatreonClient;
+import net.tfminecraft.tfmcweb.api.PatreonClient.LinkStartResult;
+import net.tfminecraft.tfmcweb.api.PatreonClient.StatusResult;
+import net.tfminecraft.tfmcweb.api.PatreonClient.UnlinkResult;
+import net.tfminecraft.tfmcweb.utils.ChatMessages;
+import net.tfminecraft.tfmcweb.utils.ExpiryFormat;
+
+/**
+ * /patreon — supporter status and unlink. HTTP stays off the main thread.
+ */
+public final class PatreonCommand implements CommandExecutor, TabCompleter {
+
+ private final JavaPlugin plugin;
+
+ public PatreonCommand(JavaPlugin plugin) {
+ this.plugin = plugin;
+ }
+
+ @SuppressWarnings("deprecation")
+ @Override
+ public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
+ if (!(sender instanceof Player player)) {
+ sender.sendMessage(ChatColor.RED + "Players only.");
+ return true;
+ }
+ if (args.length == 1 && "unlink".equals(args[0].toLowerCase(Locale.ROOT))) {
+ unlink(player);
+ return true;
+ }
+ if (args.length != 0) {
+ ChatMessages.error(player, "Usage: /patreon [unlink]");
+ return true;
+ }
+ status(player);
+ return true;
+ }
+
+ private void status(Player player) {
+ String uuid = player.getUniqueId().toString();
+ String name = player.getName();
+ Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
+ StatusResult result = PatreonClient.status(uuid);
+ LinkStartResult link = null;
+ if (result.ok && !result.linked) {
+ link = PatreonClient.startLink(uuid, name);
+ }
+ LinkStartResult started = link;
+ Bukkit.getScheduler().runTask(plugin, () -> deliverStatus(player, result, started));
+ });
+ }
+
+ @SuppressWarnings("deprecation")
+ private void deliverStatus(Player player, StatusResult result, LinkStartResult link) {
+ if (!player.isOnline()) {
+ return;
+ }
+ if (!result.ok) {
+ ChatMessages.error(
+ player,
+ result.error != null ? result.error : "Could not check Patreon status."
+ );
+ return;
+ }
+ if (!result.linked) {
+ ChatMessages.info(player, "Patreon: " + ChatColor.YELLOW + "not linked");
+ if (link == null || !link.ok) {
+ String error = link == null || link.error == null
+ ? "Could not start Patreon link."
+ : link.error;
+ ChatMessages.error(player, error);
+ return;
+ }
+ ChatMessages.sendOpenUrl(player, "Link your Patreon account:", link.authorizeUrl);
+ String expiry = ExpiryFormat.relativeLabel(link.expiresAt);
+ if (expiry != null) {
+ ChatMessages.info(player, expiry);
+ }
+ return;
+ }
+ ChatMessages.info(player, "Patreon: " + ChatColor.GREEN + "linked");
+ if (result.patreonName != null) {
+ ChatMessages.info(player, "Patreon name: " + ChatColor.AQUA + result.patreonName);
+ }
+ ChatMessages.info(player, "Tier: " + ChatColor.AQUA + tierLabel(result));
+ if (result.graceUntil != null) {
+ ChatMessages.info(player, "Grace until: " + ChatColor.AQUA + result.graceUntil);
+ }
+ }
+
+ private static String tierLabel(StatusResult result) {
+ if (result.tierName != null && result.tierKey != null) {
+ return result.tierName + " (" + result.tierKey + ")";
+ }
+ if (result.tierName != null) {
+ return result.tierName;
+ }
+ if (result.tierKey != null) {
+ return result.tierKey;
+ }
+ return "none";
+ }
+
+ private void unlink(Player player) {
+ String uuid = player.getUniqueId().toString();
+ Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
+ UnlinkResult result = PatreonClient.unlink(uuid);
+ Bukkit.getScheduler().runTask(plugin, () -> deliverUnlink(player, result));
+ });
+ }
+
+ private void deliverUnlink(Player player, UnlinkResult result) {
+ if (!player.isOnline()) {
+ return;
+ }
+ if (!result.ok) {
+ ChatMessages.error(player, result.error != null ? result.error : "Unlink failed.");
+ return;
+ }
+ if (!result.unlinked) {
+ ChatMessages.info(player, "No Patreon link on this account.");
+ return;
+ }
+ ChatMessages.info(player, "Patreon unlinked.");
+ }
+
+ @Override
+ public List onTabComplete(
+ CommandSender sender,
+ Command command,
+ String alias,
+ String[] args
+ ) {
+ if (args.length == 1 && "unlink".startsWith(args[0].toLowerCase(Locale.ROOT))) {
+ return List.of("unlink");
+ }
+ return Collections.emptyList();
+ }
+}
diff --git a/src/main/java/net/tfminecraft/tfmcweb/managers/WebCommand.java b/src/main/java/net/tfminecraft/tfmcweb/managers/WebCommand.java
index 4d0afaa..35427b8 100644
--- a/src/main/java/net/tfminecraft/tfmcweb/managers/WebCommand.java
+++ b/src/main/java/net/tfminecraft/tfmcweb/managers/WebCommand.java
@@ -26,6 +26,7 @@
import net.tfminecraft.tfmcweb.cache.LinkCache;
import net.tfminecraft.tfmcweb.entitlements.PlayerMetaSyncService;
import net.tfminecraft.tfmcweb.gate.DiscordGateService;
+import net.tfminecraft.tfmcweb.patreon.PatreonRankWriter;
/**
* Admin /web status|reload|lookup|unlink|reconcile|syncmeta.
@@ -39,11 +40,22 @@ public final class WebCommand implements CommandExecutor, TabCompleter {
private final JavaPlugin plugin;
private final LinkCache linkCache;
private final DiscordGateService gateService;
+ private final PatreonRankWriter rankWriter;
public WebCommand(JavaPlugin plugin, LinkCache linkCache, DiscordGateService gateService) {
+ this(plugin, linkCache, gateService, null);
+ }
+
+ public WebCommand(
+ JavaPlugin plugin,
+ LinkCache linkCache,
+ DiscordGateService gateService,
+ PatreonRankWriter rankWriter
+ ) {
this.plugin = plugin;
this.linkCache = linkCache;
this.gateService = gateService;
+ this.rankWriter = rankWriter;
}
// Keep the existing legacy text representation, formatting, and exact-string comparisons.
@@ -94,6 +106,7 @@ private boolean cmdStatus(CommandSender sender) {
sender.sendMessage(ChatColor.GRAY + "link cache entries: " + linkCache.size());
sender.sendMessage(ChatColor.GRAY + "RPCharacters gate: "
+ (gateService.isRpcAvailable() ? "yes" : "no"));
+ sender.sendMessage(ChatColor.GRAY + "patreon: " + patreonStatus());
if (ping.ok) {
sender.sendMessage(ChatColor.GREEN + "API reachable.");
} else {
@@ -116,10 +129,16 @@ private boolean cmdReload(CommandSender sender) {
sender.sendMessage(ChatColor.GREEN + "[TFMCWeb] Config reloaded.");
sender.sendMessage(ChatColor.GRAY + "api.base-url="
+ (Cache.apiBaseUrl.isEmpty() ? "(unset)" : Cache.apiBaseUrl));
+ sender.sendMessage(ChatColor.GRAY + "patreon: " + patreonStatus());
sender.sendMessage(ChatColor.GRAY + "Pushing player-meta for online players…");
return true;
}
+ private String patreonStatus() {
+ boolean running = rankWriter != null && rankWriter.isRunning();
+ return PatreonRankWriter.statusText(running);
+ }
+
// Keep the existing legacy text representation, formatting, and exact-string comparisons.
@SuppressWarnings("deprecation")
private boolean cmdSyncMeta(CommandSender sender, String[] args) {
diff --git a/src/main/java/net/tfminecraft/tfmcweb/patreon/LuckPermsPatreonGroupStore.java b/src/main/java/net/tfminecraft/tfmcweb/patreon/LuckPermsPatreonGroupStore.java
new file mode 100644
index 0000000..ddfb13b
--- /dev/null
+++ b/src/main/java/net/tfminecraft/tfmcweb/patreon/LuckPermsPatreonGroupStore.java
@@ -0,0 +1,211 @@
+package net.tfminecraft.tfmcweb.patreon;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Set;
+import java.util.UUID;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+import net.luckperms.api.LuckPerms;
+import net.luckperms.api.LuckPermsProvider;
+import net.luckperms.api.messaging.MessagingService;
+import net.luckperms.api.model.data.DataMutateResult;
+import net.luckperms.api.model.data.NodeMap;
+import net.luckperms.api.model.user.User;
+import net.luckperms.api.model.user.UserManager;
+import net.luckperms.api.node.Node;
+import net.luckperms.api.node.NodeType;
+import net.luckperms.api.node.types.InheritanceNode;
+
+/**
+ * Applies supporter inheritance nodes through the LuckPerms API, including offline players.
+ * It never changes a user's primary group and only removes global permanent nodes.
+ * A failed save is reported as false so the caller can leave the outbox row unacked.
+ * The same request applied twice does not duplicate nodes.
+ */
+public final class LuckPermsPatreonGroupStore implements PatreonGroupStore {
+
+ private final UserManager users;
+ private final MessagingService messaging;
+ private final Logger logger;
+ private final Set pendingSaves = ConcurrentHashMap.newKeySet();
+
+ public LuckPermsPatreonGroupStore(UserManager users, MessagingService messaging, Logger logger) {
+ this.users = users;
+ this.messaging = messaging;
+ this.logger = logger;
+ }
+
+ public static PatreonGroupStore open() {
+ LuckPerms luckPerms = LuckPermsProvider.get();
+ return new LuckPermsPatreonGroupStore(
+ luckPerms.getUserManager(),
+ luckPerms.getMessagingService().orElse(null),
+ Logger.getLogger("TFMCWeb")
+ );
+ }
+
+ @Override
+ public boolean setGroups(UUID player, String ensureGroup, Set removeGroups) {
+ if (player == null) {
+ return false;
+ }
+ String ensure = normalize(ensureGroup);
+ Set remove = normalizeRemove(removeGroups, ensure);
+ if (ensure == null && remove.isEmpty()) {
+ return true;
+ }
+ boolean loaded = false;
+ User user = null;
+ try {
+ loaded = users.isLoaded(player);
+ user = users.loadUser(player).join();
+ if (user == null) {
+ return false;
+ }
+ NodeMap data = user.data();
+ if (data == null) {
+ return false;
+ }
+ boolean changed = mutate(data, ensure, remove);
+ boolean retryPending = pendingSaves.contains(player);
+ if (changed || loaded || retryPending) {
+ try {
+ users.saveUser(user).join();
+ } catch (RuntimeException e) {
+ pendingSaves.add(player);
+ throw e;
+ }
+ pendingSaves.remove(player);
+ if (changed || retryPending) {
+ pushUpdate(user, player);
+ }
+ }
+ return true;
+ } catch (RuntimeException e) {
+ logger.log(Level.WARNING, "[patreon] LuckPerms update failed for " + player, e);
+ return false;
+ } finally {
+ if (!loaded && user != null) {
+ try {
+ users.cleanupUser(user);
+ } catch (RuntimeException e) {
+ logger.log(Level.WARNING, "[patreon] LuckPerms cleanup failed for " + player, e);
+ }
+ }
+ }
+ }
+
+ private void pushUpdate(User user, UUID player) {
+ if (messaging == null) {
+ return;
+ }
+ try {
+ messaging.pushUserUpdate(user);
+ } catch (RuntimeException e) {
+ logger.log(Level.WARNING, "[patreon] LuckPerms messaging update failed for " + player, e);
+ }
+ }
+
+ private static boolean mutate(NodeMap data, String ensure, Set remove) {
+ boolean changed = false;
+ if (ensure != null && !hasGlobal(data, ensure)) {
+ InheritanceNode created = InheritanceNode.builder(ensure).build();
+ DataMutateResult added = data.add(created);
+ if (added == DataMutateResult.FAIL) {
+ throw new IllegalStateException("LuckPerms refused group " + ensure);
+ }
+ changed |= added == DataMutateResult.SUCCESS;
+ }
+ for (InheritanceNode node : inheritance(data)) {
+ if (node.getGroupName() == null || !node.getValue()) {
+ continue;
+ }
+ if (!remove.contains(node.getGroupName().toLowerCase(Locale.ROOT))) {
+ continue;
+ }
+ if (node.hasExpiry()) {
+ continue;
+ }
+ if (node.getContexts() != null && !node.getContexts().isEmpty()) {
+ continue;
+ }
+ changed |= data.remove(node) == DataMutateResult.SUCCESS;
+ }
+ for (String group : remove) {
+ if (hasGlobal(data, group)) {
+ throw new IllegalStateException("LuckPerms refused to remove group " + group);
+ }
+ }
+ return changed;
+ }
+
+ private static boolean hasGlobal(NodeMap data, String group) {
+ String key = group.toLowerCase(Locale.ROOT);
+ for (InheritanceNode node : inheritance(data)) {
+ if (node.getGroupName() == null || !node.getValue() || node.hasExpiry()) {
+ continue;
+ }
+ if (!key.equals(node.getGroupName().toLowerCase(Locale.ROOT))) {
+ continue;
+ }
+ if (node.getContexts() != null && !node.getContexts().isEmpty()) {
+ continue;
+ }
+ return true;
+ }
+ return false;
+ }
+
+ private static List inheritance(NodeMap data) {
+ List nodes = new ArrayList<>();
+ Collection all = data.toCollection();
+ if (all == null) {
+ return nodes;
+ }
+ for (Node node : all) {
+ if (node != null && NodeType.INHERITANCE.matches(node)) {
+ nodes.add(NodeType.INHERITANCE.cast(node));
+ }
+ }
+ return nodes;
+ }
+
+ private static boolean sameGroup(String left, String right) {
+ if (left == null || right == null) {
+ return false;
+ }
+ return left.toLowerCase(Locale.ROOT).equals(right.toLowerCase(Locale.ROOT));
+ }
+
+ private static String normalize(String group) {
+ if (group == null) {
+ return null;
+ }
+ String trimmed = group.trim();
+ return trimmed.isEmpty() ? null : trimmed;
+ }
+
+ private static Set normalizeRemove(Set groups, String ensure) {
+ Set remove = new LinkedHashSet<>();
+ if (groups == null) {
+ return remove;
+ }
+ for (String group : groups) {
+ String normalized = normalize(group);
+ if (normalized == null) {
+ continue;
+ }
+ if (sameGroup(ensure, normalized)) {
+ continue;
+ }
+ remove.add(normalized.toLowerCase(Locale.ROOT));
+ }
+ return remove;
+ }
+}
diff --git a/src/main/java/net/tfminecraft/tfmcweb/patreon/PatreonGroupStore.java b/src/main/java/net/tfminecraft/tfmcweb/patreon/PatreonGroupStore.java
new file mode 100644
index 0000000..f6e2db3
--- /dev/null
+++ b/src/main/java/net/tfminecraft/tfmcweb/patreon/PatreonGroupStore.java
@@ -0,0 +1,20 @@
+package net.tfminecraft.tfmcweb.patreon;
+
+import java.util.Set;
+import java.util.UUID;
+
+/**
+ * LuckPerms mutation used by the rank writer. Only the group named by
+ * {@code ensureGroup} is added, and only groups in {@code removeGroups} are
+ * removed. Every other group, including staff grants, stays.
+ */
+public interface PatreonGroupStore {
+
+ /**
+ * @param ensureGroup LuckPerms group to grant, or null to grant nothing
+ * @param removeGroups LuckPerms groups whose positive, global, permanent inheritance nodes are removed
+ * @return false when the user could not be loaded or saved; true when the
+ * stored nodes match the request, including when they already did
+ */
+ boolean setGroups(UUID player, String ensureGroup, Set removeGroups);
+}
diff --git a/src/main/java/net/tfminecraft/tfmcweb/patreon/PatreonRankWriter.java b/src/main/java/net/tfminecraft/tfmcweb/patreon/PatreonRankWriter.java
new file mode 100644
index 0000000..8604d09
--- /dev/null
+++ b/src/main/java/net/tfminecraft/tfmcweb/patreon/PatreonRankWriter.java
@@ -0,0 +1,333 @@
+package net.tfminecraft.tfmcweb.patreon;
+
+import java.util.ArrayList;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Set;
+import java.util.UUID;
+import java.util.function.LongSupplier;
+import java.util.logging.Level;
+
+import org.bukkit.Bukkit;
+import org.bukkit.plugin.Plugin;
+import org.bukkit.plugin.java.JavaPlugin;
+import org.bukkit.scheduler.BukkitTask;
+
+import net.tfminecraft.tfmcweb.Cache;
+import net.tfminecraft.tfmcweb.api.PatreonClient;
+import net.tfminecraft.tfmcweb.api.PatreonClient.AckResult;
+import net.tfminecraft.tfmcweb.api.PatreonClient.RankChange;
+import net.tfminecraft.tfmcweb.api.PatreonClient.RankChangesResult;
+import net.tfminecraft.tfmcweb.api.PatreonClient.RosterMember;
+import net.tfminecraft.tfmcweb.api.PatreonClient.RosterResult;
+
+/**
+ * Polls the LuckPerms outbox on the one server configured to apply ranks.
+ * Other servers leave this idle. Groups outside the configured mapping are never touched.
+ */
+public final class PatreonRankWriter {
+
+ @FunctionalInterface
+ public interface StoreOpener {
+ PatreonGroupStore open();
+ }
+
+ private final JavaPlugin plugin;
+ private final StoreOpener opener;
+ private final LongSupplier clock;
+ private BukkitTask task;
+ private PatreonGroupStore store;
+ private boolean active;
+ private boolean loggedLuckPermsMissing;
+ private long nextReconcileAt;
+
+ public PatreonRankWriter(JavaPlugin plugin) {
+ this(plugin, PatreonRankWriter::openLuckPerms, System::currentTimeMillis);
+ }
+
+ PatreonRankWriter(JavaPlugin plugin, StoreOpener opener, LongSupplier clock) {
+ this.plugin = plugin;
+ this.opener = opener;
+ this.clock = clock;
+ }
+
+ /**
+ * Loads the LuckPerms store by name so this class does not reference the API.
+ * Absent LuckPerms then disables only the writer.
+ */
+ static PatreonGroupStore openLuckPerms() {
+ try {
+ Class> type = Class.forName("net.tfminecraft.tfmcweb.patreon.LuckPermsPatreonGroupStore");
+ return (PatreonGroupStore) type.getMethod("open").invoke(null);
+ } catch (ReflectiveOperationException | NoClassDefFoundError e) {
+ throw new IllegalStateException("LuckPerms API is not available", e);
+ }
+ }
+
+ public void refresh() {
+ stop();
+ if (!Cache.patreonEnabled || !Cache.patreonApplyRanks) {
+ return;
+ }
+ if (!luckPermsPresent()) {
+ if (!loggedLuckPermsMissing) {
+ plugin.getLogger().warning(
+ "LuckPerms is not installed; Patreon rank writer disabled."
+ );
+ loggedLuckPermsMissing = true;
+ }
+ return;
+ }
+ try {
+ store = opener.open();
+ } catch (RuntimeException e) {
+ plugin.getLogger().log(
+ Level.WARNING,
+ "[patreon] LuckPerms is unavailable; rank writer disabled",
+ e
+ );
+ return;
+ }
+ if (store == null) {
+ plugin.getLogger().warning("[patreon] LuckPerms is unavailable; rank writer disabled");
+ return;
+ }
+ long ticks = Math.max(1, Cache.patreonPollSeconds) * 20L;
+ task = Bukkit.getScheduler().runTaskTimerAsynchronously(plugin, this::tick, ticks, ticks);
+ if (task == null) {
+ store = null;
+ plugin.getLogger().warning("[patreon] Could not schedule the rank writer");
+ return;
+ }
+ active = true;
+ nextReconcileAt = clock.getAsLong();
+ }
+
+ public void stop() {
+ active = false;
+ store = null;
+ if (task != null) {
+ task.cancel();
+ task = null;
+ }
+ }
+
+ public boolean isRunning() {
+ return active && task != null;
+ }
+
+ public String statusDetail() {
+ return statusText(isRunning());
+ }
+
+ public static String statusText(boolean running) {
+ String text = "enabled=" + Cache.patreonEnabled
+ + " apply-ranks=" + Cache.patreonApplyRanks
+ + " poll=" + Cache.patreonPollSeconds + "s"
+ + " reconcile=" + Cache.patreonReconcileMinutes + "m"
+ + " writer=";
+ if (running) {
+ return text + "on";
+ }
+ if (Cache.patreonEnabled && Cache.patreonApplyRanks) {
+ return text + "off (LuckPerms unavailable)";
+ }
+ return text + "off";
+ }
+
+ private void tick() {
+ PatreonGroupStore current = store;
+ if (!active || current == null) {
+ return;
+ }
+ try {
+ pollChanges(current);
+ } catch (RuntimeException e) {
+ plugin.getLogger().log(Level.WARNING, "[patreon] rank poll failed", e);
+ }
+ current = store;
+ if (!active || current == null) {
+ return;
+ }
+ long now = clock.getAsLong();
+ if (now < nextReconcileAt) {
+ return;
+ }
+ try {
+ if (reconcile(current)) {
+ nextReconcileAt = now + reconcileIntervalMillis();
+ }
+ } catch (RuntimeException e) {
+ plugin.getLogger().log(Level.WARNING, "[patreon] roster reconcile failed", e);
+ }
+ }
+
+ private void pollChanges(PatreonGroupStore current) {
+ RankChangesResult result = PatreonClient.listRankChanges();
+ if (!result.ok) {
+ plugin.getLogger().warning("[patreon] rank-changes: " + result.error);
+ return;
+ }
+ if (result.changes.isEmpty()) {
+ return;
+ }
+ List applied = new ArrayList<>();
+ for (RankChange change : result.changes) {
+ try {
+ if (applyChange(current, change)) {
+ applied.add(Integer.valueOf(change.id));
+ }
+ } catch (RuntimeException e) {
+ plugin.getLogger().log(
+ Level.WARNING,
+ "[patreon] rank change " + change.id + " failed",
+ e
+ );
+ }
+ }
+ if (applied.isEmpty()) {
+ return;
+ }
+ AckResult ack = PatreonClient.ackRankChanges(applied);
+ if (!ack.ok) {
+ plugin.getLogger().warning("[patreon] rank-changes ack failed: " + ack.error);
+ return;
+ }
+ plugin.getLogger().info("[patreon] applied " + applied.size() + " rank change(s)");
+ }
+
+ private boolean applyChange(PatreonGroupStore current, RankChange change) {
+ UUID player = parseUuid(change.playerUuid);
+ if (player == null) {
+ plugin.getLogger().warning("[patreon] rank change " + change.id + " has no player UUID");
+ return false;
+ }
+ String addGroup = null;
+ if (change.addTier != null) {
+ addGroup = mappedGroup(change.addTier);
+ if (addGroup == null) {
+ plugin.getLogger().warning(
+ "[patreon] rank change " + change.id + " has unmapped tier " + change.addTier
+ );
+ return false;
+ }
+ }
+ Set remove = new LinkedHashSet<>();
+ for (String tier : change.removeTiers) {
+ if (tier == null || tier.isBlank()) {
+ continue;
+ }
+ String group = mappedGroup(tier);
+ if (group == null) {
+ plugin.getLogger().warning(
+ "[patreon] rank change " + change.id + " skipped unmapped removal " + tier
+ );
+ continue;
+ }
+ if (sameGroup(addGroup, group)) {
+ continue;
+ }
+ remove.add(group);
+ }
+ if (!current.setGroups(player, addGroup, remove)) {
+ plugin.getLogger().warning("[patreon] rank change " + change.id + " was not saved");
+ return false;
+ }
+ return true;
+ }
+
+ private boolean reconcile(PatreonGroupStore current) {
+ RosterResult result = PatreonClient.listRoster();
+ if (!result.ok) {
+ plugin.getLogger().warning("[patreon] roster: " + result.error);
+ return false;
+ }
+ boolean saved = true;
+ for (RosterMember member : result.members) {
+ if (!reconcileMember(current, member)) {
+ saved = false;
+ }
+ }
+ if (saved) {
+ plugin.getLogger().info("[patreon] reconciled " + result.members.size() + " player(s)");
+ }
+ return saved;
+ }
+
+ private boolean reconcileMember(PatreonGroupStore current, RosterMember member) {
+ UUID player = parseUuid(member.playerUuid);
+ if (player == null) {
+ plugin.getLogger().warning("[patreon] roster skipped an invalid player UUID");
+ return true;
+ }
+ String desired = null;
+ if (member.tierKey != null) {
+ desired = mappedGroup(member.tierKey);
+ if (desired == null) {
+ plugin.getLogger().warning(
+ "[patreon] roster skipped unmapped tier " + member.tierKey + " for " + player
+ );
+ return true;
+ }
+ }
+ Set remove = new LinkedHashSet<>();
+ for (String group : Cache.patreonGroups.values()) {
+ if (sameGroup(desired, group)) {
+ continue;
+ }
+ remove.add(group);
+ }
+ if (!current.setGroups(player, desired, remove)) {
+ plugin.getLogger().warning("[patreon] roster save failed for " + player);
+ return false;
+ }
+ return true;
+ }
+
+ private static UUID parseUuid(String raw) {
+ if (raw == null || raw.isBlank()) {
+ return null;
+ }
+ try {
+ return UUID.fromString(raw.trim());
+ } catch (IllegalArgumentException e) {
+ return null;
+ }
+ }
+
+ private static boolean sameGroup(String left, String right) {
+ if (left == null || right == null) {
+ return false;
+ }
+ return left.toLowerCase(Locale.ROOT).equals(right.toLowerCase(Locale.ROOT));
+ }
+
+ /** @return the configured LuckPerms group, or null when the tier is not mapped */
+ private static String mappedGroup(String tierKey) {
+ if (tierKey == null || tierKey.isBlank()) {
+ return null;
+ }
+ String group = Cache.patreonGroups.get(tierKey.trim().toLowerCase(Locale.ROOT));
+ if (group == null || group.isBlank()) {
+ return null;
+ }
+ return group.trim();
+ }
+
+ private static long reconcileIntervalMillis() {
+ int minutes = Cache.patreonReconcileMinutes;
+ if (minutes < 1) {
+ minutes = 30;
+ }
+ return minutes * 60_000L;
+ }
+
+ private static boolean luckPermsPresent() {
+ if (Bukkit.getPluginManager() == null) {
+ return false;
+ }
+ Plugin luckPerms = Bukkit.getPluginManager().getPlugin("LuckPerms");
+ return luckPerms != null && luckPerms.isEnabled();
+ }
+}
diff --git a/src/main/java/net/tfminecraft/tfmcweb/utils/ChatMessages.java b/src/main/java/net/tfminecraft/tfmcweb/utils/ChatMessages.java
index abb123b..20b68b0 100644
--- a/src/main/java/net/tfminecraft/tfmcweb/utils/ChatMessages.java
+++ b/src/main/java/net/tfminecraft/tfmcweb/utils/ChatMessages.java
@@ -1,5 +1,8 @@
package net.tfminecraft.tfmcweb.utils;
+import java.net.URI;
+import java.util.Locale;
+
import org.bukkit.entity.Player;
import net.kyori.adventure.text.Component;
@@ -25,6 +28,39 @@ public static void error(Player player, String message) {
player.sendMessage(PREFIX + "\u00A7c" + message);
}
+ public static void sendOpenUrl(Player player, String label, String url) {
+ if (player == null || url == null || url.isBlank()) {
+ return;
+ }
+ if (label != null && !label.isEmpty()) {
+ player.sendMessage(PREFIX + label);
+ }
+ if (!httpUrl(url)) {
+ player.sendMessage(PREFIX + url);
+ return;
+ }
+ Component link = Component.text(url, NamedTextColor.AQUA)
+ .decorate(TextDecoration.UNDERLINED)
+ .clickEvent(ClickEvent.openUrl(url))
+ .hoverEvent(HoverEvent.showText(Component.text("Click to open")));
+ player.sendMessage(link);
+ }
+
+ private static boolean httpUrl(String url) {
+ try {
+ URI uri = URI.create(url);
+ String scheme = uri.getScheme();
+ if (scheme == null) {
+ return false;
+ }
+ String normalized = scheme.toLowerCase(Locale.ROOT);
+ return uri.getHost() != null
+ && ("http".equals(normalized) || "https".equals(normalized));
+ } catch (IllegalArgumentException e) {
+ return false;
+ }
+ }
+
public static void sendCopyableCode(Player player, String intro, String code) {
if (intro != null && !intro.isEmpty()) {
player.sendMessage(PREFIX + intro);
diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml
index 55980a1..bcc0d72 100644
--- a/src/main/resources/config.yml
+++ b/src/main/resources/config.yml
@@ -143,3 +143,15 @@ player-meta:
name-colour-stops: 8
allow-drink-texture: true
allow-drink-message: true
+
+# Patreon supporter ranks. LuckPerms storage is shared across servers, so
+# set apply-ranks true on exactly one server. /patreon still works everywhere.
+patreon:
+ enabled: false
+ apply-ranks: false
+ poll-seconds: 60
+ reconcile-minutes: 30
+ groups:
+ noble: noble
+ gilded: gilded
+ ascended: ascended
diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml
index 0d527e6..95bc628 100644
--- a/src/main/resources/plugin.yml
+++ b/src/main/resources/plugin.yml
@@ -6,7 +6,7 @@ author: TFMC
depend: [TLibs]
# RPCharacters depends on TFMCWeb; its optional gate API is resolved at runtime.
-softdepend: [Essentials]
+softdepend: [Essentials, LuckPerms]
commands:
linkdiscord:
@@ -32,6 +32,11 @@ commands:
description: Warn a player in-game and mirror to Discord if linked
permission: tfmcweb.warning
permission-message: You do not have permission to use /warning.
+ patreon:
+ usage: /patreon [unlink]
+ description: Check your Patreon supporter status or unlink
+ permission: tfmcweb.patreon
+ permission-message: You do not have permission to use /patreon.
permissions:
tfmcweb.linkdiscord:
@@ -55,3 +60,6 @@ permissions:
tfmcweb.warning:
description: Issue in-game warnings via /warning
default: false
+ tfmcweb.patreon:
+ description: Check and unlink your Patreon supporter status
+ default: true
diff --git a/src/test/java/net/tfminecraft/tfmcweb/RuntimeUtilitiesTest.java b/src/test/java/net/tfminecraft/tfmcweb/RuntimeUtilitiesTest.java
index 231808c..09b0289 100644
--- a/src/test/java/net/tfminecraft/tfmcweb/RuntimeUtilitiesTest.java
+++ b/src/test/java/net/tfminecraft/tfmcweb/RuntimeUtilitiesTest.java
@@ -48,6 +48,16 @@ class RuntimeUtilitiesTest {
ArgumentCaptor message=ArgumentCaptor.forClass(Component.class);verify(player).sendMessage(message.capture());
assertEquals(ClickEvent.copyToClipboard("ABC"),message.getValue().children().get(1).clickEvent());
ChatMessages.sendCopyableCode(player,null,null);ChatMessages.sendCopyableCode(player,"","");verify(player,times(1)).sendMessage(any(Component.class));
+ ChatMessages.sendOpenUrl(null,"label","https://example.test");ChatMessages.sendOpenUrl(player,null," ");ChatMessages.sendOpenUrl(player,""," ");
+ ChatMessages.sendOpenUrl(player,null,"https://example.test/link");ChatMessages.sendOpenUrl(player,"","https://example.test/link");
+ ChatMessages.sendOpenUrl(player,"Link your Patreon account:","https://example.test/link");
+ verify(player).sendMessage(ChatMessages.PREFIX+"Link your Patreon account:");
+ ArgumentCaptor link=ArgumentCaptor.forClass(Component.class);verify(player,atLeastOnce()).sendMessage(link.capture());
+ assertTrue(link.getAllValues().stream().anyMatch(component->ClickEvent.openUrl("https://example.test/link").equals(component.clickEvent())));
+ ChatMessages.sendOpenUrl(player,"broken","not a url");verify(player).sendMessage(ChatMessages.PREFIX+"not a url");
+ ChatMessages.sendOpenUrl(player,"plain","not-a-url");verify(player).sendMessage(ChatMessages.PREFIX+"not-a-url");
+ ChatMessages.sendOpenUrl(player,"mail","mailto:a@b.test");verify(player).sendMessage(ChatMessages.PREFIX+"mailto:a@b.test");
+ ChatMessages.sendOpenUrl(player,"missing host","https:authorize");verify(player).sendMessage(ChatMessages.PREFIX+"https:authorize");
}
@Test void cooldownPermissionOrderAndStatuses() {
Player player=mock(Player.class);UUID id=UUID.randomUUID();when(player.getUniqueId()).thenReturn(id);
diff --git a/src/test/java/net/tfminecraft/tfmcweb/TFMCWebLifecycleTest.java b/src/test/java/net/tfminecraft/tfmcweb/TFMCWebLifecycleTest.java
index 4a762b8..4349b98 100644
--- a/src/test/java/net/tfminecraft/tfmcweb/TFMCWebLifecycleTest.java
+++ b/src/test/java/net/tfminecraft/tfmcweb/TFMCWebLifecycleTest.java
@@ -22,8 +22,8 @@ class TFMCWebLifecycleTest {
@Test void enableRegistersCommandsAndReloadsConfigurationAndDisableStopsPoller() throws Exception {
try(var pollers=mockConstruction(PluginNoticePoller.class);var sync=mockStatic(PlayerMetaSyncService.class)){
TFMCWeb plugin=MockBukkit.load(TFMCWeb.class);assertSame(plugin,TFMCWeb.plugin);assertNotNull(plugin.getLinkCache());assertNotNull(plugin.getGateService());
- for(String name:new String[]{"linkdiscord","unlinkdiscord","web","token","warning"})assertNotNull(plugin.getCommand(name).getExecutor());
- assertNotNull(plugin.getCommand("web").getTabCompleter());assertNotNull(plugin.getCommand("token").getTabCompleter());
+ for(String name:new String[]{"linkdiscord","unlinkdiscord","web","token","warning","patreon"})assertNotNull(plugin.getCommand(name).getExecutor());
+ assertNotNull(plugin.getCommand("web").getTabCompleter());assertNotNull(plugin.getCommand("token").getTabCompleter());assertNotNull(plugin.getCommand("patreon").getTabCompleter());
verify(pollers.constructed().getFirst()).start();assertTrue(TFMCWeb.isPresent());
Files.writeString(plugin.getDataFolder().toPath().resolve("config.yml"),"realm:\n id: dev\n");plugin.reloadLocalConfig();assertEquals("dev",TFMCWeb.getRealmId());
plugin.onDisable();verify(pollers.constructed().getFirst()).stop();
@@ -32,7 +32,7 @@ class TFMCWebLifecycleTest {
@Test void enableCreatesMissingDataDirectoryAndLogsMissingCommands() throws Exception {
try(var pollers=mockConstruction(PluginNoticePoller.class);var sync=mockStatic(PlayerMetaSyncService.class);var gates=mockConstruction(DiscordGateService.class,(mock,context)->when(mock.isRpcAvailable()).thenReturn(true))){
TFMCWeb plugin=MockBukkit.load(TFMCWeb.class);TFMCWeb spy=spy(plugin);
- for(String name:new String[]{"linkdiscord","unlinkdiscord","web","token","warning"})doReturn(null).when(spy).getCommand(name);
+ for(String name:new String[]{"linkdiscord","unlinkdiscord","web","token","warning","patreon"})doReturn(null).when(spy).getCommand(name);
try(var paths=Files.walk(plugin.getDataFolder().toPath())){for(Path path:paths.sorted(Comparator.reverseOrder()).toList())Files.delete(path);}
spy.onEnable();assertTrue(Files.isDirectory(plugin.getDataFolder().toPath()));assertTrue(Files.exists(plugin.getDataFolder().toPath().resolve("config.yml")));
spy.onDisable();verify(pollers.constructed().getLast()).stop();
diff --git a/src/test/java/net/tfminecraft/tfmcweb/api/PatreonClientTest.java b/src/test/java/net/tfminecraft/tfmcweb/api/PatreonClientTest.java
new file mode 100644
index 0000000..bb68f5b
--- /dev/null
+++ b/src/test/java/net/tfminecraft/tfmcweb/api/PatreonClientTest.java
@@ -0,0 +1,146 @@
+package net.tfminecraft.tfmcweb.api;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import com.google.gson.JsonParser;
+import java.util.ArrayList;
+import java.util.List;
+import net.tfminecraft.tfmcweb.Cache;
+import net.tfminecraft.tfmcweb.TestState;
+import net.tfminecraft.tfmcweb.api.PatreonClient.AckResult;
+import net.tfminecraft.tfmcweb.api.PatreonClient.LinkStartResult;
+import net.tfminecraft.tfmcweb.api.PatreonClient.RankChange;
+import net.tfminecraft.tfmcweb.api.PatreonClient.RankChangesResult;
+import net.tfminecraft.tfmcweb.api.PatreonClient.RosterResult;
+import net.tfminecraft.tfmcweb.api.PatreonClient.StatusResult;
+import net.tfminecraft.tfmcweb.api.PatreonClient.UnlinkResult;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+class PatreonClientTest {
+ TestState state;
+ HttpFixture http;
+
+ @BeforeEach void setup() throws Exception { state = new TestState(); http = new HttpFixture(); }
+ @AfterEach void cleanup() throws Exception { http.close(); state.close(); }
+
+ @Test void statusLinkAndUnlinkUseTheGateway() throws Exception {
+ assertEquals("player_uuid is required", PatreonClient.status(null).error);
+ assertEquals("player_uuid is required", PatreonClient.status(" ").error);
+ assertEquals("player_uuid is required", PatreonClient.startLink(null, "Ada").error);
+ assertEquals("player_uuid is required", PatreonClient.unlink(" ").error);
+ http.reply(200, "{\"linked\":true,\"tier_key\":\"ascended\",\"tier_name\":\"Ascended\",\"grace_until\":\"2026-10-10T00:00:00Z\",\"patreon_name\":\"Ada\",\"method\":\"oauth\"}");
+ StatusResult status = PatreonClient.status(" id ");
+ assertTrue(status.ok && status.linked);
+ assertEquals("ascended", status.tierKey); assertEquals("Ascended", status.tierName);
+ assertEquals("2026-10-10T00:00:00Z", status.graceUntil); assertEquals("Ada", status.patreonName);
+ assertEquals("/patreon/status?player_uuid=id", http.request().path());
+ http.reply(200, "{\"linked\":false,\"tier_key\":null,\"tier_name\":\"\",\"grace_until\":null,\"patreon_name\":null}");
+ status = PatreonClient.status("id");
+ assertTrue(status.ok); assertFalse(status.linked);
+ assertNull(status.tierKey); assertNull(status.tierName); assertNull(status.graceUntil); assertNull(status.patreonName);
+ http.request();
+ http.reply(200, "{\"linked\":\"TRUE\",\"tier_key\":1}");
+ status = PatreonClient.status("id");
+ assertTrue(status.linked); assertEquals("1", status.tierKey); http.request();
+ for (String body : List.of("{\"linked\":false}", "{\"linked\":null}", "{\"linked\":{\"x\":1}}", "{\"linked\":\"no\"}")) {
+ http.reply(200, body);
+ assertFalse(PatreonClient.status("id").linked);
+ http.request();
+ }
+ http.reply(200, "[]"); assertEquals("Malformed Patreon status", PatreonClient.status("id").error); http.request();
+ http.reply(200, ""); assertEquals("Malformed Patreon status", PatreonClient.status("id").error); http.request();
+ http.reply(200, "{bad"); assertEquals("Malformed Patreon status", PatreonClient.status("id").error); http.request();
+ http.reply(401, "{\"detail\":\"denied\"}");
+ assertTrue(PatreonClient.status("id").error.contains("denied")); http.request();
+
+ http.reply(200, "{\"authorize_url\":\"https://www.patreon.com/oauth2/authorize?x=1\",\"expires_at\":\"soon\"}");
+ LinkStartResult link = PatreonClient.startLink("id", null);
+ assertTrue(link.ok); assertEquals("https://www.patreon.com/oauth2/authorize?x=1", link.authorizeUrl); assertEquals("soon", link.expiresAt);
+ var body = JsonParser.parseString(http.request().body()).getAsJsonObject();
+ assertEquals("id", body.get("player_uuid").getAsString()); assertEquals("", body.get("minecraft_name").getAsString());
+ http.reply(200, "{\"authorize_url\":\" https://example.test/link \",\"expires_at\":\"\"}");
+ link = PatreonClient.startLink("id", "Ada");
+ assertEquals("https://example.test/link", link.authorizeUrl); assertNull(link.expiresAt);
+ assertEquals("Ada", JsonParser.parseString(http.request().body()).getAsJsonObject().get("minecraft_name").getAsString());
+ http.reply(200, "{}"); assertEquals("API returned OK but no authorize URL.", PatreonClient.startLink("id", "Ada").error); http.request();
+ http.reply(200, "nope"); assertEquals("Malformed Patreon link response", PatreonClient.startLink("id", "Ada").error); http.request();
+ http.reply(503, "{\"detail\":\"patreon_disabled\"}");
+ assertEquals("patreon_disabled", PatreonClient.startLink("id", "Ada").error); http.request();
+
+ http.reply(200, "{\"unlinked\":true}");
+ UnlinkResult unlink = PatreonClient.unlink(" id ");
+ assertTrue(unlink.ok && unlink.unlinked);
+ var unlinkRequest = http.request();
+ assertEquals("{\"player_uuid\":\"id\"}", unlinkRequest.body());
+ assertEquals("/patreon/link/unlink", unlinkRequest.path());
+ http.reply(200, "{\"unlinked\":false}");
+ unlink = PatreonClient.unlink("id");
+ assertTrue(unlink.ok); assertFalse(unlink.unlinked); http.request();
+ http.reply(200, "{\"unlinked\":\"false\"}"); assertFalse(PatreonClient.unlink("id").unlinked); http.request();
+ http.reply(200, "[]"); assertEquals("Malformed Patreon unlink response", PatreonClient.unlink("id").error); http.request();
+ http.reply(400, "{\"detail\":\"nope\"}"); assertEquals("nope", PatreonClient.unlink("id").error); http.request();
+ Cache.pluginKey = "";
+ assertTrue(PatreonClient.status("id").error.contains("not configured"));
+ assertTrue(PatreonClient.startLink("id", "Ada").error.contains("not configured"));
+ assertTrue(PatreonClient.unlink("id").error.contains("not configured"));
+ }
+
+ @Test void rankChangesRosterAndAckParseTheOutbox() throws Exception {
+ assertTrue(PatreonClient.ackRankChanges(null).ok);
+ assertTrue(PatreonClient.ackRankChanges(List.of()).ok);
+ http.reply(200, "{}");
+ assertTrue(PatreonClient.ackRankChanges(List.of(7, 8)).ok);
+ var ack = http.request();
+ assertEquals("POST", ack.method());
+ assertEquals("/patreon/plugin/rank-changes/ack", ack.path());
+ assertEquals("{\"ids\":[7,8]}", ack.body());
+ assertEquals("unit-test-key", ack.key());
+ http.reply(500, ""); assertEquals("HTTP 500", PatreonClient.ackRankChanges(List.of(1)).error); http.request();
+
+ String change = "{\"id\":7,\"player_uuid\":\"uuid-1\",\"add_tier\":\"gilded\",\"remove_tiers\":[\"noble\",\" \",\"\",null,{\"x\":1},1]}";
+ http.reply(200, "{\"changes\":[" + change + ",null,\"bad\",{\"id\":\"bad\"},{\"id\":1},{\"player_uuid\":\"x\"},{\"id\":\" 12 \",\"player_uuid\":\"uuid-2\",\"add_tier\":\"\",\"remove_tiers\":null},{\"id\":false,\"player_uuid\":\"uuid-3\"},{\"id\":3000000000,\"player_uuid\":\"uuid-4\"},{\"id\":{\"n\":1},\"player_uuid\":\"uuid-5\"}]}");
+ RankChangesResult changes = PatreonClient.listRankChanges();
+ assertTrue(changes.ok); assertEquals(2, changes.changes.size());
+ RankChange first = changes.changes.getFirst();
+ assertEquals(7, first.id); assertEquals("uuid-1", first.playerUuid); assertEquals("gilded", first.addTier);
+ assertEquals(List.of("noble", "1"), first.removeTiers);
+ assertThrows(UnsupportedOperationException.class, () -> changes.changes.clear());
+ RankChange second = changes.changes.get(1);
+ assertEquals(12, second.id); assertNull(second.addTier); assertTrue(second.removeTiers.isEmpty());
+ assertEquals("GET", http.request().method());
+ List tiers = new ArrayList<>();
+ tiers.add("noble");
+ RankChange copied = new RankChange(1, "u", "noble", tiers);
+ tiers.clear();
+ assertEquals(List.of("noble"), copied.removeTiers);
+ assertTrue(new RankChange(3, "u", null, null).removeTiers.isEmpty());
+ assertTrue(RankChangesResult.success(null).changes.isEmpty());
+ assertTrue(RankChangesResult.fail("x").changes.isEmpty());
+ http.reply(200, "{}"); assertTrue(PatreonClient.listRankChanges().changes.isEmpty()); http.request();
+ http.reply(200, "{\"changes\":null}"); assertTrue(PatreonClient.listRankChanges().changes.isEmpty()); http.request();
+ http.reply(200, "{\"changes\":{}}"); assertEquals("Malformed rank-changes response", PatreonClient.listRankChanges().error); http.request();
+ http.reply(200, "nope"); assertEquals("Malformed rank-changes response", PatreonClient.listRankChanges().error); http.request();
+ http.reply(401, "{\"detail\":\"denied\"}"); assertTrue(PatreonClient.listRankChanges().error.contains("Unauthorized")); http.request();
+
+ http.reply(200, "{\"tier_keys\":[\"noble\"],\"members\":[{\"player_uuid\":\"uuid-1\",\"tier_key\":\"ascended\"},{\"player_uuid\":\"uuid-2\",\"tier_key\":null},{\"player_uuid\":\"uuid-3\",\"tier_key\":\"\"},null,\"x\",{\"tier_key\":\"noble\"}]}");
+ RosterResult roster = PatreonClient.listRoster();
+ assertTrue(roster.ok); assertEquals(3, roster.members.size());
+ assertEquals("ascended", roster.members.getFirst().tierKey); assertNull(roster.members.get(1).tierKey); assertNull(roster.members.get(2).tierKey);
+ assertEquals("/patreon/plugin/roster", http.request().path());
+ assertThrows(UnsupportedOperationException.class, () -> roster.members.clear());
+ assertTrue(RosterResult.success(null).members.isEmpty());
+ http.reply(200, "{}"); assertTrue(PatreonClient.listRoster().members.isEmpty()); http.request();
+ http.reply(200, "{\"members\":null}"); assertTrue(PatreonClient.listRoster().members.isEmpty()); http.request();
+ http.reply(200, "{\"members\":{}}"); assertEquals("Malformed roster response", PatreonClient.listRoster().error); http.request();
+ http.reply(200, ""); assertEquals("Malformed roster response", PatreonClient.listRoster().error); http.request();
+ http.reply(400, "{\"detail\":\"nope\"}"); assertEquals("nope", PatreonClient.listRoster().error); http.request();
+ Cache.apiBaseUrl = null;
+ assertTrue(PatreonClient.listRankChanges().error.contains("not configured"));
+ assertTrue(PatreonClient.listRoster().error.contains("not configured"));
+ assertTrue(PatreonClient.ackRankChanges(List.of(1)).error.contains("not configured"));
+ AckResult failed = AckResult.fail(null);
+ assertFalse(failed.ok); assertNull(failed.error);
+ }
+}
diff --git a/src/test/java/net/tfminecraft/tfmcweb/loaders/ConfigurationTest.java b/src/test/java/net/tfminecraft/tfmcweb/loaders/ConfigurationTest.java
index 5987bf8..fab7b15 100644
--- a/src/test/java/net/tfminecraft/tfmcweb/loaders/ConfigurationTest.java
+++ b/src/test/java/net/tfminecraft/tfmcweb/loaders/ConfigurationTest.java
@@ -19,11 +19,14 @@ void load(String yaml) throws Exception {
}
@Test void defaultsAndMissingOrInvalidFiles() throws Exception {
load("");assertEquals("",Cache.apiBaseUrl);assertEquals("",Cache.pluginKey);assertEquals("main",Cache.realmId);
+ assertFalse(Cache.patreonEnabled);assertFalse(Cache.patreonApplyRanks);assertEquals(60,Cache.patreonPollSeconds);
+ assertEquals(30,Cache.patreonReconcileMinutes);assertEquals(Map.of("noble","noble","gilded","gilded","ascended","ascended"),Cache.patreonGroups);
assertEquals(List.of("skin","drink","profile","skin_staff"),Cache.tokenEnabledScopes);
assertEquals(List.of("skin","drink"),Cache.tokenCooldownSharedScopes);assertEquals(-1,Cache.tokenCooldownDefaultDays);
assertEquals(3,Cache.rpcMetaDefaults.get("max-alive-characters"));assertTrue(Cache.rpcMetaGroups.isEmpty());
- Cache.apiBaseUrl="preserved";new ConfigLoader().load(temp.resolve("missing.yml").toFile());assertEquals("preserved",Cache.apiBaseUrl);
- load("bad: [");assertEquals("preserved",Cache.apiBaseUrl);
+ Cache.apiBaseUrl="preserved";Cache.patreonEnabled=true;new ConfigLoader().load(temp.resolve("missing.yml").toFile());
+ assertEquals("preserved",Cache.apiBaseUrl);assertTrue(Cache.patreonEnabled);
+ load("bad: [");assertEquals("preserved",Cache.apiBaseUrl);assertTrue(Cache.patreonEnabled);
load("realm:\n id: ' '\ntokens:\n enabled-scopes: []\n");assertEquals("main",Cache.realmId);assertTrue(Cache.tokenEnabledScopes.isEmpty());
}
@Test void normalizesApiTokensAndCooldownRows() throws Exception {
@@ -114,6 +117,36 @@ void load(String yaml) throws Exception {
config.set("player-meta.by-realm.dev.skins.defaults.name-colour-stops",3);
PlayerMetaConfigLoader.load(config);assertEquals(List.of("item","book"),Cache.skinsMetaDefaultKinds);
}
+ @Test void patreonGroupsIntervalsAndLocale() throws Exception {
+ Locale.setDefault(Locale.forLanguageTag("tr-TR"));
+ load("""
+ patreon:
+ enabled: true
+ apply-ranks: true
+ poll-seconds: 0
+ reconcile-minutes: -4
+ groups:
+ NOBLE: ""
+ gilded: " "
+ ascended: " High "
+ legacy: legacy
+ VIP: vip
+ """);
+ assertTrue(Cache.patreonEnabled && Cache.patreonApplyRanks);
+ assertEquals(60, Cache.patreonPollSeconds); assertEquals(30, Cache.patreonReconcileMinutes);
+ assertEquals(Map.of("ascended", "High"), Cache.patreonGroups);
+ load("""
+ patreon:
+ poll-seconds: 15
+ reconcile-minutes: 2
+ groups:
+ noble: " donator "
+ """);
+ assertEquals(15, Cache.patreonPollSeconds); assertEquals(2, Cache.patreonReconcileMinutes);
+ assertEquals("donator", Cache.patreonGroups.get("noble"));
+ assertEquals("gilded", Cache.patreonGroups.get("gilded"));
+ assertEquals("ascended", Cache.patreonGroups.get("ascended"));
+ }
@Test void cooldownConfigurationSectionsAreSupported() throws Exception {
YamlConfiguration source=new YamlConfiguration();var row=new YamlConfiguration();row.set("permission","rank");row.set("cooldown-days",4);
source.set("token-cooldowns.groups",List.of(row));
diff --git a/src/test/java/net/tfminecraft/tfmcweb/managers/PatreonCommandTest.java b/src/test/java/net/tfminecraft/tfmcweb/managers/PatreonCommandTest.java
new file mode 100644
index 0000000..0bc3059
--- /dev/null
+++ b/src/test/java/net/tfminecraft/tfmcweb/managers/PatreonCommandTest.java
@@ -0,0 +1,132 @@
+package net.tfminecraft.tfmcweb.managers;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+import java.time.Instant;
+import java.util.List;
+import java.util.Locale;
+
+import net.kyori.adventure.text.Component;
+import net.kyori.adventure.text.event.ClickEvent;
+import net.tfminecraft.tfmcweb.api.PatreonClient;
+import net.tfminecraft.tfmcweb.api.PatreonClient.LinkStartResult;
+import net.tfminecraft.tfmcweb.api.PatreonClient.StatusResult;
+import net.tfminecraft.tfmcweb.api.PatreonClient.UnlinkResult;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.MockedStatic;
+
+class PatreonCommandTest {
+ CommandFixture f;
+ MockedStatic api;
+ PatreonCommand command;
+
+ @BeforeEach void setup() throws Exception {
+ f = new CommandFixture();
+ api = mockStatic(PatreonClient.class);
+ command = new PatreonCommand(f.plugin);
+ }
+
+ @AfterEach void cleanup() throws Exception {
+ api.close();
+ f.close();
+ }
+
+ void run(org.bukkit.command.CommandSender sender, String... args) {
+ assertTrue(command.onCommand(sender, null, "patreon", args));
+ }
+
+ @Test void playersCanReadStatusLinkAndUnlink() {
+ run(f.console);
+ assertTrue(f.contains("Players only"));
+ api.when(() -> PatreonClient.status(f.id.toString())).thenReturn(StatusResult.fail(null));
+ run(f.player);
+ assertTrue(f.contains("Could not check Patreon status."));
+ api.when(() -> PatreonClient.status(f.id.toString())).thenReturn(StatusResult.fail("down"));
+ run(f.player);
+ assertTrue(f.contains("down"));
+ api.verify(() -> PatreonClient.startLink(anyString(), anyString()), never());
+
+ api.when(() -> PatreonClient.status(f.id.toString())).thenReturn(
+ StatusResult.success(true, "ascended", "Ascended", "2026-10-10T00:00:00Z", "Ada Patron")
+ );
+ run(f.player);
+ assertTrue(f.contains("linked"));
+ assertTrue(f.contains("Ada Patron"));
+ assertTrue(f.contains("Ascended (ascended)"));
+ assertTrue(f.contains("Grace until:"));
+ assertTrue(f.contains("2026-10-10T00:00:00Z"));
+
+ api.when(() -> PatreonClient.status(f.id.toString())).thenReturn(StatusResult.success(true, "noble", null, null, null));
+ run(f.player);
+ assertTrue(f.contains("noble"));
+ api.when(() -> PatreonClient.status(f.id.toString())).thenReturn(StatusResult.success(true, null, "Knight", null, null));
+ run(f.player);
+ assertTrue(f.contains("Knight"));
+ api.when(() -> PatreonClient.status(f.id.toString())).thenReturn(StatusResult.success(true, null, null, null, null));
+ run(f.player);
+ assertTrue(f.contains("Tier:"));
+ assertTrue(f.contains("none"));
+
+ api.when(() -> PatreonClient.status(f.id.toString())).thenReturn(StatusResult.success(false, null, null, null, null));
+ api.when(() -> PatreonClient.startLink(f.id.toString(), "Ada")).thenReturn(LinkStartResult.fail(null));
+ run(f.player);
+ assertTrue(f.contains("not linked"));
+ assertTrue(f.contains("Could not start Patreon link."));
+ api.when(() -> PatreonClient.startLink(f.id.toString(), "Ada")).thenReturn(LinkStartResult.fail("denied"));
+ run(f.player);
+ assertTrue(f.contains("denied"));
+
+ String url = "https://www.patreon.com/oauth2/authorize?x=1";
+ api.when(() -> PatreonClient.startLink(f.id.toString(), "Ada")).thenReturn(
+ LinkStartResult.success(url, Instant.now().plusSeconds(3700).toString())
+ );
+ run(f.player);
+ assertTrue(f.contains("Link your Patreon account:"));
+ assertTrue(f.contains("Expires in ~"));
+ ArgumentCaptor link = ArgumentCaptor.forClass(Component.class);
+ verify(f.player, atLeastOnce()).sendMessage(link.capture());
+ assertTrue(link.getAllValues().stream().anyMatch(component -> ClickEvent.openUrl(url).equals(component.clickEvent())));
+
+ api.when(() -> PatreonClient.startLink(f.id.toString(), "Ada")).thenReturn(LinkStartResult.success(url, null));
+ f.messages.clear();
+ run(f.player);
+ assertTrue(f.messages.stream().noneMatch(message -> message.contains("Expires")));
+
+ api.when(() -> PatreonClient.status(f.id.toString())).thenReturn(StatusResult.success(false, null, null, null, null));
+ when(f.player.isOnline()).thenReturn(false);
+ f.messages.clear();
+ run(f.player);
+ assertTrue(f.messages.isEmpty());
+ when(f.player.isOnline()).thenReturn(true);
+
+ run(f.player, "nope");
+ assertTrue(f.contains("Usage:"));
+ Locale.setDefault(Locale.forLanguageTag("tr-TR"));
+ api.when(() -> PatreonClient.unlink(f.id.toString())).thenReturn(UnlinkResult.fail(null));
+ run(f.player, "UNLINK");
+ assertTrue(f.contains("Unlink failed."));
+ api.when(() -> PatreonClient.unlink(f.id.toString())).thenReturn(UnlinkResult.fail("down"));
+ run(f.player, "unlink");
+ assertTrue(f.contains("down"));
+ api.when(() -> PatreonClient.unlink(f.id.toString())).thenReturn(UnlinkResult.success(false));
+ run(f.player, "unlink");
+ assertTrue(f.contains("No Patreon link"));
+ api.when(() -> PatreonClient.unlink(f.id.toString())).thenReturn(UnlinkResult.success(true));
+ run(f.player, "unlink");
+ assertTrue(f.contains("Patreon unlinked."));
+ when(f.player.isOnline()).thenReturn(false);
+ f.messages.clear();
+ run(f.player, "unlink");
+ assertTrue(f.messages.isEmpty());
+
+ assertEquals(List.of("unlink"), command.onTabComplete(f.player, null, "patreon", new String[] { "" }));
+ assertEquals(List.of("unlink"), command.onTabComplete(f.player, null, "patreon", new String[] { "UN" }));
+ assertTrue(command.onTabComplete(f.player, null, "patreon", new String[] { "x" }).isEmpty());
+ assertTrue(command.onTabComplete(f.player, null, "patreon", new String[] { "unlink", "" }).isEmpty());
+ assertTrue(command.onTabComplete(f.player, null, "patreon", new String[0]).isEmpty());
+ }
+}
diff --git a/src/test/java/net/tfminecraft/tfmcweb/managers/PlayerAndAdminCommandsTest.java b/src/test/java/net/tfminecraft/tfmcweb/managers/PlayerAndAdminCommandsTest.java
index e045fc9..9cb111f 100644
--- a/src/test/java/net/tfminecraft/tfmcweb/managers/PlayerAndAdminCommandsTest.java
+++ b/src/test/java/net/tfminecraft/tfmcweb/managers/PlayerAndAdminCommandsTest.java
@@ -56,10 +56,10 @@ class PlayerAndAdminCommandsTest {
@Test void webValidatesCommandsReloadsBothPluginKindsAndReportsStatus() {
var cmd=new WebCommand(f.plugin,f.cache,f.gate);run(cmd,f.console,"status");assertTrue(f.contains("No permission"));f.permissions(f.console,true);
run(cmd,f.console);run(cmd,f.console,"unknown");assertTrue(f.contains("Usage:"));assertTrue(f.contains("Unknown subcommand"));
- Cache.apiBaseUrl="";Cache.pluginKey="";f.api.when(ProvinceSystemClient::ping).thenReturn(SimpleResult.success());run(cmd,f.console,"status");assertTrue(f.contains("API reachable"));
+ Cache.apiBaseUrl="";Cache.pluginKey="";f.api.when(ProvinceSystemClient::ping).thenReturn(SimpleResult.success());run(cmd,f.console,"status");assertTrue(f.contains("API reachable"));assertTrue(f.contains("writer=off"));
Cache.apiBaseUrl="local";Cache.pluginKey="key";when(f.gate.isRpcAvailable()).thenReturn(true);
for(String error:Arrays.asList(null,"down")){f.api.when(ProvinceSystemClient::ping).thenReturn(SimpleResult.fail(error));run(cmd,f.console,"status");assertTrue(f.contains(error==null?"unreachable":"down"));}
- run(cmd,f.console,"reload");verify(f.plugin).reloadConfig();
+ run(cmd,f.console,"reload");verify(f.plugin).reloadConfig();assertTrue(f.contains("patreon:"));
TFMCWeb plugin=mock(TFMCWeb.class);run(new WebCommand(plugin,f.cache,f.gate),f.console,"reload");verify(plugin).reloadLocalConfig();
}
@Test void webLookupAndUnlinkHandleOfflineUnknownFailuresAndGating() {
diff --git a/src/test/java/net/tfminecraft/tfmcweb/patreon/LuckPermsPatreonGroupStoreTest.java b/src/test/java/net/tfminecraft/tfmcweb/patreon/LuckPermsPatreonGroupStoreTest.java
new file mode 100644
index 0000000..a8277e5
--- /dev/null
+++ b/src/test/java/net/tfminecraft/tfmcweb/patreon/LuckPermsPatreonGroupStoreTest.java
@@ -0,0 +1,307 @@
+package net.tfminecraft.tfmcweb.patreon;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Set;
+import java.util.UUID;
+import java.util.concurrent.CompletableFuture;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+import net.luckperms.api.LuckPerms;
+import net.luckperms.api.LuckPermsProvider;
+import net.luckperms.api.messaging.MessagingService;
+import net.luckperms.api.model.data.DataMutateResult;
+import net.luckperms.api.model.data.NodeMap;
+import net.luckperms.api.model.user.User;
+import net.luckperms.api.model.user.UserManager;
+import net.luckperms.api.node.Node;
+import net.luckperms.api.node.NodeBuilderRegistry;
+import net.luckperms.api.node.types.InheritanceNode;
+import net.luckperms.api.context.ImmutableContextSet;
+import net.tfminecraft.tfmcweb.TestState;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+
+class LuckPermsPatreonGroupStoreTest {
+ private TestState state;
+ private final UUID id = UUID.randomUUID();
+ private Logger logger;
+ private UserManager users;
+ private User user;
+ private MessagingService messaging;
+ private NodeMap data;
+ private final List live = new ArrayList<>();
+ private final List removed = new ArrayList<>();
+ private MockedStatic provider;
+ private final String[] builtGroup = new String[1];
+
+ @BeforeEach void setup() throws Exception {
+ state = new TestState();
+ logger = mock(Logger.class);
+ users = mock(UserManager.class);
+ messaging = mock(MessagingService.class);
+ user = mock(User.class);
+ data = mock(NodeMap.class);
+ when(user.data()).thenReturn(data);
+ when(users.isLoaded(id)).thenReturn(false);
+ when(users.loadUser(id)).thenReturn(CompletableFuture.completedFuture(user));
+ when(users.saveUser(user)).thenReturn(CompletableFuture.completedFuture(null));
+ when(data.toCollection()).thenAnswer(call -> new ArrayList<>(live));
+ when(data.remove(any())).thenAnswer(call -> {
+ Node node = call.getArgument(0);
+ removed.add(node);
+ live.remove(node);
+ return DataMutateResult.SUCCESS;
+ });
+ when(data.add(any())).thenAnswer(call -> {
+ live.add(call.getArgument(0));
+ return DataMutateResult.SUCCESS;
+ });
+ InheritanceNode.Builder builder = mock(InheritanceNode.Builder.class);
+ when(builder.group(anyString())).thenAnswer(call -> {
+ builtGroup[0] = call.getArgument(0);
+ return builder;
+ });
+ when(builder.build()).thenAnswer(call -> node(builtGroup[0], true, false, true));
+ LuckPerms api = mock(LuckPerms.class);
+ NodeBuilderRegistry registry = mock(NodeBuilderRegistry.class);
+ when(api.getNodeBuilderRegistry()).thenReturn(registry);
+ when(registry.forInheritance()).thenReturn(builder);
+ provider = mockStatic(LuckPermsProvider.class);
+ provider.when(LuckPermsProvider::get).thenReturn(api);
+ }
+
+ @AfterEach void cleanup() throws Exception { provider.close(); state.close(); }
+
+ LuckPermsPatreonGroupStore store() { return new LuckPermsPatreonGroupStore(users, messaging, logger); }
+
+ InheritanceNode node(String group, boolean value, boolean expired, boolean global) {
+ InheritanceNode node = mock(InheritanceNode.class);
+ when(node.getGroupName()).thenReturn(group);
+ when(node.getValue()).thenReturn(value);
+ when(node.hasExpired()).thenReturn(expired);
+ if (global) {
+ when(node.getContexts()).thenReturn(null);
+ } else {
+ ImmutableContextSet contexts = mock(ImmutableContextSet.class);
+ when(contexts.isEmpty()).thenReturn(false);
+ when(node.getContexts()).thenReturn(contexts);
+ }
+ return node;
+ }
+
+ @Test void openReadsTheProvider() {
+ LuckPerms api = mock(LuckPerms.class);
+ when(api.getUserManager()).thenReturn(users);
+ when(api.getMessagingService()).thenReturn(java.util.Optional.of(messaging));
+ provider.when(LuckPermsProvider::get).thenReturn(api);
+ assertNotNull(LuckPermsPatreonGroupStore.open());
+ }
+
+ @Test void emptyAndNullRequestsDoNotTouchLuckPerms() {
+ assertFalse(store().setGroups(null, "noble", Set.of()));
+ assertTrue(store().setGroups(id, null, null));
+ assertTrue(store().setGroups(id, " ", Set.of()));
+ assertTrue(store().setGroups(id, null, Set.of(" ", "")));
+ verify(users, never()).loadUser(any());
+ }
+
+ @Test void addsAGlobalNodeOnceAndSkipsUnchangedSave() {
+ Locale.setDefault(Locale.forLanguageTag("tr-TR"));
+ assertTrue(store().setGroups(id, " Gilded ", Set.of("gilded", "noble")));
+ assertEquals("Gilded", builtGroup[0]);
+ assertEquals(1, live.size());
+ assertTrue(store().setGroups(id, "Gilded", Set.of("GILDED")));
+ assertEquals(1, live.size());
+ verify(data, times(1)).add(any());
+ verify(users, times(1)).saveUser(user);
+ verify(users, times(2)).cleanupUser(user);
+ verify(messaging, times(1)).pushUserUpdate(user);
+ verify(user, never()).setPrimaryGroup(anyString());
+ }
+
+ @Test void removesOnlyPositiveGlobalPermanentMappedGroupsAndLeavesPrimaryGroupAlone() {
+ InheritanceNode noble = node("Noble", true, false, true);
+ InheritanceNode negated = node("noble", false, false, true);
+ InheritanceNode temporary = node("noble", true, false, true);
+ when(temporary.hasExpiry()).thenReturn(true);
+ InheritanceNode contextual = node("gilded", true, false, false);
+ InheritanceNode legacy = node("legacy", true, false, true);
+ InheritanceNode unnamed = node(null, true, false, true);
+ Node permission = mock(Node.class);
+ live.addAll(Arrays.asList(null, permission, noble, negated, temporary, contextual, legacy, unnamed));
+ when(user.getPrimaryGroup()).thenReturn("Noble");
+ Set remove = new LinkedHashSet<>();
+ remove.add("noble");
+ remove.add(" ");
+ remove.add(null);
+ assertTrue(store().setGroups(id, null, remove));
+ assertEquals(List.of(noble), removed);
+ assertFalse(live.contains(noble));
+ assertTrue(live.contains(negated));
+ assertTrue(live.contains(legacy));
+ assertTrue(live.contains(contextual));
+ assertTrue(live.contains(temporary));
+ verify(user, never()).setPrimaryGroup(anyString());
+ verify(users).cleanupUser(user);
+ }
+
+ @Test void grantAndRemovalNeverChangeThePrimaryGroup() {
+ live.add(node("noble", true, false, true));
+ when(user.getPrimaryGroup()).thenReturn("noble");
+ assertTrue(store().setGroups(id, "gilded", Set.of("noble")));
+ verify(user, never()).setPrimaryGroup(anyString());
+ verify(data, times(1)).add(any());
+ }
+
+ @Test void pushesSuccessfulSavesThroughLuckPermsMessaging() {
+ assertTrue(store().setGroups(id, "noble", Set.of()));
+ verify(users).saveUser(user);
+ verify(messaging).pushUserUpdate(user);
+ }
+
+ @Test void missingOrFailingMessagingDoesNotFailSavedChange() {
+ assertTrue(new LuckPermsPatreonGroupStore(users, null, logger).setGroups(id, "noble", Set.of()));
+ doThrow(new IllegalStateException("messaging down")).when(messaging).pushUserUpdate(user);
+ assertTrue(store().setGroups(id, null, Set.of("noble")));
+ verify(logger).log(eq(Level.WARNING), contains("messaging update failed"), any(RuntimeException.class));
+ }
+
+ @Test void staffPrimaryGroupAndLoadedUsersAreLeftAlone() {
+ when(users.isLoaded(id)).thenReturn(true);
+ when(user.getPrimaryGroup()).thenReturn("legacy");
+ live.add(node("noble", true, false, false));
+ ImmutableContextSet empty = mock(ImmutableContextSet.class);
+ when(empty.isEmpty()).thenReturn(true);
+ InheritanceNode global = node("gilded", true, false, true);
+ when(global.getContexts()).thenReturn(empty);
+ live.add(global);
+ assertTrue(store().setGroups(id, "gilded", Set.of("noble")));
+ verify(user, never()).setPrimaryGroup(anyString());
+ verify(data, never()).add(any());
+ verify(users, never()).cleanupUser(any());
+ verify(users).saveUser(user);
+ }
+
+ @Test void blankPrimaryGroupIsNotReadOrRewritten() {
+ when(user.getPrimaryGroup()).thenReturn(" ");
+ assertTrue(store().setGroups(id, null, Set.of("noble")));
+ verify(user, never()).setPrimaryGroup(anyString());
+ verify(user, never()).getPrimaryGroup();
+ }
+
+ @Test void expiredOrContextualGroupIsGrantedAgain() {
+ InheritanceNode expired = node("noble", true, true, true);
+ when(expired.hasExpiry()).thenReturn(true);
+ live.add(expired);
+ assertTrue(store().setGroups(id, "noble", Set.of()));
+ verify(data).add(any());
+ live.clear();
+ live.add(node("noble", true, false, false));
+ assertTrue(store().setGroups(id, "noble", Set.of()));
+ verify(data, times(2)).add(any());
+ }
+
+ @Test void temporaryGlobalGroupDoesNotSatisfyPermanentGrant() {
+ InheritanceNode temporary = node("noble", true, false, true);
+ when(temporary.hasExpiry()).thenReturn(true);
+ live.add(temporary);
+ assertTrue(store().setGroups(id, "noble", Set.of()));
+ verify(data).add(any());
+ verify(users).saveUser(user);
+ }
+
+ @Test void unchangedUnloadedUserIsNotSavedOrPushed() {
+ live.add(node("noble", true, false, true));
+ assertTrue(store().setGroups(id, "noble", Set.of()));
+ verify(users, never()).saveUser(any());
+ verify(messaging, never()).pushUserUpdate(any());
+ verify(users).cleanupUser(user);
+ }
+
+ @Test void unchangedLoadedUserIsSavedWithoutPushingUpdate() {
+ when(users.isLoaded(id)).thenReturn(true);
+ live.add(node("noble", true, false, true));
+ assertTrue(store().setGroups(id, "noble", Set.of()));
+ verify(users).saveUser(user);
+ verify(messaging, never()).pushUserUpdate(any());
+ }
+
+ @Test void nullNodeCollectionStillSavesTheGrant() {
+ when(data.toCollection()).thenReturn(null);
+ assertTrue(store().setGroups(id, "noble", Set.of("gilded")));
+ verify(data).add(any());
+ verify(users).saveUser(user);
+ }
+
+ @Test void alreadyPresentAddDoesNotSaveUnchangedData() {
+ when(data.add(any())).thenReturn(DataMutateResult.FAIL_ALREADY_HAS);
+ assertTrue(store().setGroups(id, "noble", Set.of()));
+ verify(users, never()).saveUser(any());
+ }
+
+ @Test void refusedNodeDoesNotSave() {
+ when(data.add(any())).thenReturn(DataMutateResult.FAIL);
+ assertFalse(store().setGroups(id, "noble", Set.of("gilded")));
+ verify(users, never()).saveUser(any());
+ verify(logger).log(eq(Level.WARNING), contains("update failed"), any(RuntimeException.class));
+ verify(users).cleanupUser(user);
+ }
+
+ @Test void refusedRemovalDoesNotReportOrPushASavedChange() {
+ InheritanceNode noble = node("noble", true, false, true);
+ live.add(noble);
+ doReturn(DataMutateResult.FAIL).when(data).remove(noble);
+
+ assertFalse(store().setGroups(id, null, Set.of("noble")));
+
+ assertTrue(live.contains(noble));
+ verify(data).remove(noble);
+ verify(users, never()).saveUser(any());
+ verify(messaging, never()).pushUserUpdate(any());
+ verify(logger).log(eq(Level.WARNING), contains("update failed"), any(RuntimeException.class));
+ verify(users).cleanupUser(user);
+ }
+
+ @Test void failedSaveIsRetriedAndCleanupFailuresAreSwallowed() {
+ LuckPermsPatreonGroupStore store = store();
+ CompletableFuture failed = new CompletableFuture<>();
+ failed.completeExceptionally(new IllegalStateException("db down"));
+ when(users.saveUser(user)).thenReturn(failed);
+ assertFalse(store.setGroups(id, "noble", Set.of()));
+ assertEquals(1, live.size());
+ when(users.saveUser(user)).thenReturn(CompletableFuture.completedFuture(null));
+ doThrow(new IllegalStateException("cleanup")).when(users).cleanupUser(user);
+ assertTrue(store.setGroups(id, "noble", Set.of()));
+ verify(data, times(1)).add(any());
+ verify(users, times(2)).saveUser(user);
+ verify(messaging).pushUserUpdate(user);
+ verify(logger).log(eq(Level.WARNING), contains("cleanup failed"), any(RuntimeException.class));
+ }
+
+ @Test void loadAndLookupFailuresLeaveTheChangeUnsaved() {
+ when(users.loadUser(id)).thenReturn(CompletableFuture.failedFuture(new IllegalStateException("down")));
+ assertFalse(store().setGroups(id, "noble", Set.of()));
+ verify(users, never()).saveUser(any());
+ verify(users, never()).cleanupUser(any());
+ when(users.loadUser(id)).thenReturn(CompletableFuture.completedFuture(null));
+ assertFalse(store().setGroups(id, "noble", Set.of()));
+ when(user.data()).thenReturn(null);
+ when(users.loadUser(id)).thenReturn(CompletableFuture.completedFuture(user));
+ assertFalse(store().setGroups(id, "noble", Set.of()));
+ verify(users).cleanupUser(user);
+ when(users.isLoaded(id)).thenThrow(new IllegalStateException("offline"));
+ assertFalse(store().setGroups(id, "noble", Set.of()));
+ verify(logger, atLeastOnce()).log(eq(Level.WARNING), contains("update failed"), any(RuntimeException.class));
+ }
+}
diff --git a/src/test/java/net/tfminecraft/tfmcweb/patreon/PatreonRankWriterTest.java b/src/test/java/net/tfminecraft/tfmcweb/patreon/PatreonRankWriterTest.java
new file mode 100644
index 0000000..8b943c4
--- /dev/null
+++ b/src/test/java/net/tfminecraft/tfmcweb/patreon/PatreonRankWriterTest.java
@@ -0,0 +1,360 @@
+package net.tfminecraft.tfmcweb.patreon;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import java.util.UUID;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+import net.luckperms.api.LuckPerms;
+import net.luckperms.api.LuckPermsProvider;
+import net.luckperms.api.messaging.MessagingService;
+import net.luckperms.api.model.user.UserManager;
+import net.tfminecraft.tfmcweb.Cache;
+import net.tfminecraft.tfmcweb.TestState;
+import net.tfminecraft.tfmcweb.api.PatreonClient;
+import net.tfminecraft.tfmcweb.api.PatreonClient.AckResult;
+import net.tfminecraft.tfmcweb.api.PatreonClient.RankChange;
+import net.tfminecraft.tfmcweb.api.PatreonClient.RankChangesResult;
+import net.tfminecraft.tfmcweb.api.PatreonClient.RosterMember;
+import net.tfminecraft.tfmcweb.api.PatreonClient.RosterResult;
+import net.tfminecraft.tfmcweb.managers.WebCommand;
+import net.tfminecraft.tfmcweb.cache.LinkCache;
+import net.tfminecraft.tfmcweb.gate.DiscordGateService;
+import org.bukkit.Bukkit;
+import org.bukkit.command.CommandSender;
+import org.bukkit.plugin.Plugin;
+import org.bukkit.plugin.PluginManager;
+import org.bukkit.plugin.java.JavaPlugin;
+import org.bukkit.scheduler.BukkitScheduler;
+import org.bukkit.scheduler.BukkitTask;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+
+class PatreonRankWriterTest {
+ TestState state;
+ JavaPlugin plugin;
+ Logger logger;
+ BukkitScheduler scheduler;
+ PluginManager plugins;
+ Plugin luckPerms;
+ BukkitTask task;
+ MockedStatic bukkit;
+ MockedStatic api;
+ RecordingStore store;
+ AtomicLong clock;
+ List ticks;
+ PatreonRankWriter writer;
+
+ static final class RecordingStore implements PatreonGroupStore {
+ final List calls = new ArrayList<>();
+ final Set failed = new java.util.HashSet<>();
+ final Set boom = new java.util.HashSet<>();
+ record Call(UUID player, String ensure, Set remove) {}
+ @Override public boolean setGroups(UUID player, String ensure, Set remove) {
+ if (boom.contains(player)) {
+ throw new IllegalStateException("boom");
+ }
+ calls.add(new Call(player, ensure, remove == null ? Set.of() : Set.copyOf(remove)));
+ return !failed.contains(player);
+ }
+ }
+
+ @BeforeEach void setup() throws Exception {
+ state = new TestState();
+ plugin = mock(JavaPlugin.class);
+ logger = mock(Logger.class);
+ when(plugin.getLogger()).thenReturn(logger);
+ scheduler = mock(BukkitScheduler.class);
+ plugins = mock(PluginManager.class);
+ luckPerms = mock(Plugin.class);
+ when(luckPerms.isEnabled()).thenReturn(true);
+ when(plugins.getPlugin("LuckPerms")).thenReturn(luckPerms);
+ task = mock(BukkitTask.class);
+ ticks = new ArrayList<>();
+ when(scheduler.runTaskTimerAsynchronously(eq(plugin), any(Runnable.class), anyLong(), anyLong()))
+ .thenAnswer(call -> {
+ ticks.add(call.getArgument(1));
+ return task;
+ });
+ bukkit = mockStatic(Bukkit.class);
+ bukkit.when(Bukkit::getScheduler).thenReturn(scheduler);
+ bukkit.when(Bukkit::getPluginManager).thenReturn(plugins);
+ api = mockStatic(PatreonClient.class);
+ api.when(PatreonClient::listRankChanges).thenReturn(RankChangesResult.success(List.of()));
+ api.when(PatreonClient::listRoster).thenReturn(RosterResult.success(List.of()));
+ api.when(() -> PatreonClient.ackRankChanges(anyList())).thenReturn(AckResult.success());
+ store = new RecordingStore();
+ clock = new AtomicLong(1_000_000L);
+ writer = new PatreonRankWriter(plugin, () -> store, clock::get);
+ Cache.patreonEnabled = true;
+ Cache.patreonApplyRanks = true;
+ Cache.patreonPollSeconds = 15;
+ Cache.patreonReconcileMinutes = 30;
+ Cache.patreonGroups = Map.of("noble", "noble", "gilded", "gilded", "ascended", "ascended");
+ }
+
+ @AfterEach void cleanup() throws Exception {
+ api.close();
+ bukkit.close();
+ state.close();
+ }
+
+ void start() {
+ writer.refresh();
+ assertTrue(writer.isRunning());
+ }
+
+ void tick() { ticks.getLast().run(); }
+
+ @Test void staysIdleUntilBothFlagsAndLuckPermsAreAvailable() {
+ Cache.patreonEnabled = false;
+ writer.refresh();
+ Cache.patreonEnabled = true;
+ Cache.patreonApplyRanks = false;
+ writer.refresh();
+ verify(scheduler, never()).runTaskTimerAsynchronously(any(), any(Runnable.class), anyLong(), anyLong());
+ assertFalse(writer.isRunning());
+ assertTrue(writer.statusDetail().contains("writer=off"));
+ assertFalse(writer.statusDetail().contains("LuckPerms"));
+ Cache.patreonApplyRanks = true;
+ when(plugins.getPlugin("LuckPerms")).thenReturn(null);
+ writer.refresh();
+ writer.refresh();
+ verify(logger, times(1)).warning(contains("not installed"));
+ when(plugins.getPlugin("LuckPerms")).thenReturn(luckPerms);
+ when(luckPerms.isEnabled()).thenReturn(false);
+ writer.refresh();
+ verify(logger, times(1)).warning(contains("not installed"));
+ bukkit.when(Bukkit::getPluginManager).thenReturn(null);
+ new PatreonRankWriter(plugin, () -> store, clock::get).refresh();
+ verify(scheduler, never()).runTaskTimerAsynchronously(any(), any(Runnable.class), anyLong(), anyLong());
+ }
+
+ @Test void schedulesPollsAndStopsCleanly() {
+ start();
+ verify(scheduler).runTaskTimerAsynchronously(plugin, ticks.getFirst(), 300L, 300L);
+ assertTrue(writer.statusDetail().endsWith("writer=on"));
+ assertTrue(PatreonRankWriter.statusText(true).endsWith("writer=on"));
+ writer.refresh();
+ verify(task).cancel();
+ writer.stop();
+ writer.stop();
+ verify(task, times(2)).cancel();
+ assertFalse(writer.isRunning());
+ tick();
+ api.verify(PatreonClient::listRankChanges, never());
+ }
+
+ @Test void missingStoreOrSchedulerDisablesTheWriter() {
+ writer = new PatreonRankWriter(plugin, () -> null, clock::get);
+ writer.refresh();
+ assertFalse(writer.isRunning());
+ verify(logger).warning(contains("unavailable"));
+ when(scheduler.runTaskTimerAsynchronously(eq(plugin), any(Runnable.class), anyLong(), anyLong())).thenReturn(null);
+ writer = new PatreonRankWriter(plugin, () -> store, clock::get);
+ writer.refresh();
+ assertFalse(writer.isRunning());
+ verify(logger).warning(contains("Could not schedule"));
+ assertThrows(IllegalStateException.class, PatreonRankWriter::openLuckPerms);
+ LuckPerms luckPermsApi = mock(LuckPerms.class);
+ when(luckPermsApi.getUserManager()).thenReturn(mock(UserManager.class));
+ when(luckPermsApi.getMessagingService()).thenReturn(java.util.Optional.of(mock(MessagingService.class)));
+ try (MockedStatic provider = mockStatic(LuckPermsProvider.class)) {
+ provider.when(LuckPermsProvider::get).thenReturn(luckPermsApi);
+ assertNotNull(PatreonRankWriter.openLuckPerms());
+ }
+ PatreonRankWriter hooked = new PatreonRankWriter(plugin);
+ hooked.refresh();
+ assertFalse(hooked.isRunning());
+ verify(logger).log(eq(Level.WARNING), contains("unavailable"), any(Throwable.class));
+ }
+
+ @Test void appliesMappedChangesAndAcksOnlySavedRows() {
+ Locale.setDefault(Locale.forLanguageTag("tr-TR"));
+ Map groups = new LinkedHashMap<>();
+ groups.put("noble", " donator ");
+ groups.put("gilded", "gilded");
+ groups.put("ascended", " ");
+ Cache.patreonGroups = groups;
+ UUID saved = UUID.randomUUID();
+ UUID failed = UUID.randomUUID();
+ UUID exploded = UUID.randomUUID();
+ store.failed.add(failed);
+ store.boom.add(exploded);
+ api.when(PatreonClient::listRankChanges).thenReturn(RankChangesResult.success(List.of(
+ new RankChange(7, saved.toString(), " GILDED ", List.of("noble", "gilded", "legacy", " ")),
+ new RankChange(8, failed.toString(), "gilded", List.of()),
+ new RankChange(9, "not-a-uuid", "gilded", List.of()),
+ new RankChange(10, saved.toString(), "ascended", List.of()),
+ new RankChange(11, exploded.toString(), null, List.of("noble")),
+ new RankChange(12, saved.toString(), null, List.of("noble"))
+ )));
+ start();
+ tick();
+ assertEquals("gilded", store.calls.getFirst().ensure());
+ assertEquals(Set.of("donator"), store.calls.getFirst().remove());
+ assertTrue(store.calls.stream().noneMatch(call -> call.remove().contains("legacy") || call.remove().contains("ascended")));
+ assertEquals(Set.of("donator"), store.calls.get(2).remove());
+ assertNull(store.calls.get(2).ensure());
+ api.verify(() -> PatreonClient.ackRankChanges(List.of(7, 12)));
+ verify(logger).info(contains("applied 2"));
+ verify(logger).warning(contains("was not saved"));
+ verify(logger).warning(contains("no player UUID"));
+ verify(logger).warning(contains("unmapped tier"));
+ verify(logger).warning(contains("skipped unmapped removal"));
+ verify(logger).log(eq(Level.WARNING), contains("rank change 11"), any(RuntimeException.class));
+ }
+
+ @Test void ackFailureDoesNotClaimTheChangeWasApplied() {
+ UUID player = UUID.randomUUID();
+ api.when(PatreonClient::listRankChanges).thenReturn(RankChangesResult.success(List.of(
+ new RankChange(4, player.toString(), "noble", List.of())
+ )));
+ api.when(() -> PatreonClient.ackRankChanges(anyList())).thenReturn(AckResult.fail("down"));
+ start();
+ tick();
+ verify(logger).warning(contains("ack failed"));
+ verify(logger, never()).info(contains("applied"));
+ }
+
+ @Test void emptyOrFailedPollsDoNotAck() {
+ start();
+ tick();
+ api.verify(() -> PatreonClient.ackRankChanges(anyList()), never());
+ api.when(PatreonClient::listRankChanges).thenReturn(RankChangesResult.fail("down"));
+ tick();
+ verify(logger).warning(contains("rank-changes: down"));
+ api.when(PatreonClient::listRankChanges).thenThrow(new IllegalStateException("boom"));
+ api.when(PatreonClient::listRoster).thenReturn(RosterResult.success(List.of()));
+ tick();
+ verify(logger).log(eq(Level.WARNING), contains("rank poll failed"), any(Exception.class));
+ }
+
+ @Test void blankPlayerUuidAndTierAreRejectedWithoutAckingTheBatch() {
+ api.when(PatreonClient::listRankChanges).thenReturn(RankChangesResult.success(List.of(
+ new RankChange(20, null, "noble", List.of()),
+ new RankChange(21, UUID.randomUUID().toString(), " ", List.of())
+ )));
+ start();
+ tick();
+ assertTrue(store.calls.isEmpty());
+ api.verify(() -> PatreonClient.ackRankChanges(anyList()), never());
+ verify(logger).warning(contains("no player UUID"));
+ verify(logger).warning(contains("unmapped tier"));
+ }
+
+ @Test void reconcileCorrectsRosterDriftAndRetriesFailures() {
+ UUID keep = UUID.randomUUID();
+ UUID clear = UUID.randomUUID();
+ UUID broken = UUID.randomUUID();
+ store.failed.add(broken);
+ api.when(PatreonClient::listRoster).thenReturn(RosterResult.success(List.of(
+ new RosterMember(keep.toString(), "gilded"),
+ new RosterMember(clear.toString(), null),
+ new RosterMember("bad", "noble"),
+ new RosterMember(keep.toString(), "knight"),
+ new RosterMember(broken.toString(), "noble")
+ )));
+ Cache.patreonPollSeconds = 0;
+ Cache.patreonReconcileMinutes = 0;
+ start();
+ verify(scheduler).runTaskTimerAsynchronously(plugin, ticks.getFirst(), 20L, 20L);
+ tick();
+ RecordingStore.Call granted = store.calls.getFirst();
+ assertEquals(keep, granted.player());
+ assertEquals("gilded", granted.ensure());
+ assertEquals(Set.of("noble", "ascended"), granted.remove());
+ assertFalse(granted.remove().contains("legacy"));
+ RecordingStore.Call cleared = store.calls.get(1);
+ assertNull(cleared.ensure());
+ assertEquals(Set.of("noble", "gilded", "ascended"), cleared.remove());
+ assertEquals(broken, store.calls.get(2).player());
+ verify(logger).warning(contains("invalid player UUID"));
+ verify(logger).warning(contains("unmapped tier"));
+ verify(logger).warning(contains("roster save failed"));
+ api.verify(PatreonClient::listRoster, times(1));
+ tick();
+ api.verify(PatreonClient::listRoster, times(2));
+ store.failed.clear();
+ tick();
+ verify(logger).info(contains("reconciled"));
+ api.verify(PatreonClient::listRoster, times(3));
+ tick();
+ api.verify(PatreonClient::listRoster, times(3));
+ clock.addAndGet(30 * 60_000L);
+ tick();
+ api.verify(PatreonClient::listRoster, times(4));
+ }
+
+ @Test void rosterHttpAndUnexpectedFailuresStayDue() {
+ api.when(PatreonClient::listRoster).thenReturn(RosterResult.fail("down"));
+ start();
+ tick();
+ tick();
+ verify(logger, atLeastOnce()).warning(contains("roster: down"));
+ api.verify(PatreonClient::listRoster, times(2));
+ UUID player = UUID.randomUUID();
+ store.boom.add(player);
+ api.when(PatreonClient::listRoster).thenReturn(RosterResult.success(List.of(new RosterMember(player.toString(), "noble"))));
+ tick();
+ verify(logger).log(eq(Level.WARNING), contains("roster reconcile failed"), any(Exception.class));
+ Cache.patreonReconcileMinutes = 1;
+ store.boom.clear();
+ writer.refresh();
+ tick();
+ clock.addAndGet(59_999L);
+ tick();
+ api.verify(PatreonClient::listRoster, times(4));
+ clock.addAndGet(1L);
+ tick();
+ api.verify(PatreonClient::listRoster, times(5));
+ }
+
+ @Test void stoppingDuringAPollSkipsThatReconcile() {
+ api.when(PatreonClient::listRankChanges).thenAnswer(call -> {
+ writer.stop();
+ return RankChangesResult.success(List.of());
+ });
+ start();
+ tick();
+ api.verify(PatreonClient::listRoster, never());
+ }
+
+ @Test void webStatusUsesTheRunningWriter() {
+ CommandSender sender = mock(CommandSender.class);
+ when(sender.hasPermission(anyString())).thenReturn(true);
+ List messages = new ArrayList<>();
+ doAnswer(call -> { messages.add(call.getArgument(0)); return null; }).when(sender).sendMessage(anyString());
+ when(scheduler.runTaskAsynchronously(eq(plugin), any(Runnable.class))).thenAnswer(call -> {
+ call.getArgument(1, Runnable.class).run();
+ return null;
+ });
+ when(scheduler.runTask(eq(plugin), any(Runnable.class))).thenAnswer(call -> {
+ call.getArgument(1, Runnable.class).run();
+ return null;
+ });
+ try (var client = mockStatic(net.tfminecraft.tfmcweb.api.ProvinceSystemClient.class)) {
+ client.when(net.tfminecraft.tfmcweb.api.ProvinceSystemClient::ping)
+ .thenReturn(net.tfminecraft.tfmcweb.api.ProvinceSystemClient.SimpleResult.success());
+ WebCommand idle = new WebCommand(plugin, new LinkCache(), mock(DiscordGateService.class), writer);
+ assertTrue(idle.onCommand(sender, null, "web", new String[] { "status" }));
+ start();
+ WebCommand live = new WebCommand(plugin, new LinkCache(), mock(DiscordGateService.class), writer);
+ assertTrue(live.onCommand(sender, null, "web", new String[] { "reload" }));
+ }
+ assertTrue(messages.stream().anyMatch(line -> line.contains("writer=off (LuckPerms unavailable)")));
+ assertTrue(messages.stream().anyMatch(line -> line.contains("writer=on")));
+ }
+}