From c859628e933e3fd1766f038c58bcdd42ae3a8dae Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 01:01:54 +0000 Subject: [PATCH] Configurable essentials: home tiers by permission, cooldowns, warp cap, RTP radii; rewards config --- .../java/net/scopenet/core/CommandSet.java | 27 ++++++- .../net/scopenet/core/EssentialsConfig.java | 18 ++++- .../net/scopenet/core/EssentialsService.java | 21 ++++- .../main/java/net/scopenet/core/Limits.java | 40 ++++++++++ .../scopenet/core/EssentialsServiceTest.java | 2 +- .../src/test/java/net/scopenet/core/Kit.java | 4 +- .../java/net/scopenet/core/LimitsTest.java | 76 +++++++++++++++++++ .../fabric/features/FabricConfig.java | 6 +- .../net/scopenet/fabric/features/Perms.java | 6 +- .../paper/commands/EssentialsHandler.java | 51 +++++++++++-- .../paper/src/main/resources/config.yml | 41 +++++++++- 11 files changed, 276 insertions(+), 16 deletions(-) create mode 100644 integrations/common/src/main/java/net/scopenet/core/Limits.java create mode 100644 integrations/common/src/test/java/net/scopenet/core/LimitsTest.java diff --git a/integrations/common/src/main/java/net/scopenet/core/CommandSet.java b/integrations/common/src/main/java/net/scopenet/core/CommandSet.java index da5d758..c3e292f 100644 --- a/integrations/common/src/main/java/net/scopenet/core/CommandSet.java +++ b/integrations/common/src/main/java/net/scopenet/core/CommandSet.java @@ -12,6 +12,7 @@ public final class CommandSet { private final GuildCommands guildCommands; private final EconomyCommands economyCommands; private final List commands = new ArrayList<>(); + private final Limits.Cooldowns cooldowns = new Limits.Cooldowns(); public CommandSet(Env env, EssentialsConfig essentialsConfig) { Path dir = env.dataDir; @@ -22,7 +23,8 @@ public final class CommandSet { GuildCommands guilds = new GuildCommands(env, jobs, economy, essentials, dir.resolve("guild-homes.json")); economyCommands = economy; guildCommands = guilds; - add(env, EssentialsCommands.build(env, essentials), "Essentials", () -> env.features.essentials()); + add(env, EssentialsCommands.build(env, essentials).stream().map(c -> new Cooled(c, essentials, cooldowns, env.clock)).toList(), + "Essentials", () -> env.features.essentials()); add(env, economy.build(), "Economy", () -> env.features.economy()); add(env, guilds.build(), "Guilds", () -> env.features.guilds()); } @@ -55,6 +57,29 @@ public final class CommandSet { /** Call about once a second from the server thread. */ public void tick() { jobs.tick(); } + /** + * Teleport commands can have a cooldown ({@code essentials.cooldowns}). It starts when the command actually teleports (or sends a + * request), so a typo doesn't cost the player anything. Players with {@code scopenet.cooldown.bypass} skip it. + */ + private record Cooled(CoreCommand inner, EssentialsService essentials, Limits.Cooldowns cooldowns, java.util.function.LongSupplier clock) implements CoreCommand { + @Override public String name() { return inner.name(); } + @Override public List aliases() { return inner.aliases(); } + @Override public String permission() { return inner.permission(); } + @Override public void run(CorePlayer p, String[] args) { + int seconds = essentials.cooldownSeconds(inner.name()); + boolean cooled = seconds > 0 && !p.hasPermission("scopenet.cooldown.bypass"); + long now = clock.getAsLong(); + if (cooled) { + long wait = cooldowns.remainingMs(p.uuid(), inner.name(), now); + if (wait > 0) { p.send(Format.RED + "Please wait " + Limits.Cooldowns.waitText(wait) + " before using /" + inner.name() + " again."); return; } + } + long before = essentials.lastAction(p.uuid()); + inner.run(p, args); + if (cooled && essentials.lastAction(p.uuid()) != before) cooldowns.start(p.uuid(), inner.name(), now, seconds); + } + @Override public List complete(CorePlayer p, String[] args) { return inner.complete(p, args); } + } + /** Checks the feature switch first, then the permission node, so every platform behaves identically. */ private record Gated(CoreCommand inner, String system, java.util.function.BooleanSupplier enabled) implements CoreCommand { @Override public String name() { return inner.name(); } diff --git a/integrations/common/src/main/java/net/scopenet/core/EssentialsConfig.java b/integrations/common/src/main/java/net/scopenet/core/EssentialsConfig.java index 8044839..22146d9 100644 --- a/integrations/common/src/main/java/net/scopenet/core/EssentialsConfig.java +++ b/integrations/common/src/main/java/net/scopenet/core/EssentialsConfig.java @@ -1,5 +1,21 @@ package net.scopenet.core; -public record EssentialsConfig(int maxHomes, int rtpRadius, long tpaTimeoutMs, int rtpAttempts) { +import java.util.Map; + +/** + * Every tunable of homes, warps, TPA and RTP. Players can exceed {@code maxHomes} with a permission node + * ({@code scopenet.homes.}, or {@code scopenet.homes.unlimited}); {@code cooldowns} are seconds per command + * (0 = none) and are skipped for anyone with {@code scopenet.cooldown.bypass}. + */ +public record EssentialsConfig(int maxHomes, int rtpRadius, long tpaTimeoutMs, int rtpAttempts, int rtpMinRadius, int maxWarps, + int homeNameMax, Map cooldowns) { + public static final int TIER_CAP = 500; + + public EssentialsConfig(int maxHomes, int rtpRadius, long tpaTimeoutMs, int rtpAttempts) { + this(maxHomes, rtpRadius, tpaTimeoutMs, rtpAttempts, 0, 0, 32, Map.of()); + } + public static EssentialsConfig defaults() { return new EssentialsConfig(5, 2500, 60_000, 15); } + + public int cooldown(String command) { return cooldowns == null ? 0 : cooldowns.getOrDefault(command, 0); } } diff --git a/integrations/common/src/main/java/net/scopenet/core/EssentialsService.java b/integrations/common/src/main/java/net/scopenet/core/EssentialsService.java index c2ac09a..8095c3f 100644 --- a/integrations/common/src/main/java/net/scopenet/core/EssentialsService.java +++ b/integrations/common/src/main/java/net/scopenet/core/EssentialsService.java @@ -47,6 +47,8 @@ public final class EssentialsService { public void configure(EssentialsConfig next) { this.config = next; } + public int cooldownSeconds(String command) { return config.cooldown(command); } + // ---- persistence ------------------------------------------------------- private void load() { @@ -113,8 +115,10 @@ public final class EssentialsService { public void setHome(CorePlayer p, String[] args) { String name = args.length > 0 ? args[0].toLowerCase(Locale.ROOT) : "home"; Map mine = homes.computeIfAbsent(p.uuid(), k -> new HashMap<>()); - if (!mine.containsKey(name) && mine.size() >= config.maxHomes()) { - p.send(RED + "You have reached your limit of " + config.maxHomes() + " homes."); + if (name.length() > config.homeNameMax()) { p.send(RED + "Home names can be at most " + config.homeNameMax() + " characters."); return; } + int limit = Limits.tier(p::hasPermission, "scopenet.homes.", config.maxHomes(), EssentialsConfig.TIER_CAP); + if (!mine.containsKey(name) && mine.size() >= limit) { + p.send(RED + "You have reached your limit of " + Limits.show(limit) + " homes."); return; } mine.put(name, p.pos()); @@ -161,6 +165,10 @@ public final class EssentialsService { } public void setWarp(CorePlayer p, String name) { + if (config.maxWarps() > 0 && !warps.containsKey(name.toLowerCase(Locale.ROOT)) && warps.size() >= config.maxWarps()) { + p.send(RED + "The server already has " + config.maxWarps() + " warps. Delete one first."); + return; + } warps.put(name.toLowerCase(Locale.ROOT), p.pos()); save(); p.send(GREEN + "Warp " + YELLOW + name.toLowerCase(Locale.ROOT) + GREEN + " set."); @@ -190,6 +198,7 @@ public final class EssentialsService { CorePlayer target = found.get(); if (target.uuid().equals(p.uuid())) { p.send(RED + "You cannot teleport to yourself."); return; } pendingTpa.put(target.uuid(), Map.entry(p.uuid(), clock.getAsLong())); + lastAction.put(p.uuid(), clock.getAsLong()); p.send(GREEN + "Teleport request sent to " + YELLOW + target.name() + GREEN + "."); target.send(YELLOW + p.name() + GREEN + " has sent you a teleport request."); target.send(GRAY + "Type " + YELLOW + "/tpaccept" + GRAY + " to accept or " + RED + "/tpdeny" + GRAY @@ -223,11 +232,13 @@ public final class EssentialsService { /** Must run on the server thread, because it reads chunks. */ public void rtp(CorePlayer p) { int radius = config.rtpRadius(); + int min = Math.min(config.rtpMinRadius(), radius - 1); String world = p.pos().world(); p.send(GRAY + "Searching for a safe wilderness destination..."); for (int i = 0; i < config.rtpAttempts(); i++) { int x = rng.nextInt(radius * 2) - radius; int z = rng.nextInt(radius * 2) - radius; + if (Math.max(Math.abs(x), Math.abs(z)) < min) continue; // too close to the centre Optional spot = platform.safeSurface(world, x, z); if (spot.isPresent()) { Pos at = spot.get(); @@ -242,7 +253,13 @@ public final class EssentialsService { /** Teleport and remember where they were for /back. */ public void teleportTo(CorePlayer p, Pos destination) { go(p, destination); } + /** When a player last teleported or sent a teleport request (for cooldowns). */ + public long lastAction(UUID id) { return lastAction.getOrDefault(id, -1L); } + + private final Map lastAction = new ConcurrentHashMap<>(); + private void go(CorePlayer p, Pos destination) { + lastAction.put(p.uuid(), clock.getAsLong()); remember(p.uuid(), p.pos()); p.teleport(destination); } diff --git a/integrations/common/src/main/java/net/scopenet/core/Limits.java b/integrations/common/src/main/java/net/scopenet/core/Limits.java new file mode 100644 index 0000000..eb5dc78 --- /dev/null +++ b/integrations/common/src/main/java/net/scopenet/core/Limits.java @@ -0,0 +1,40 @@ +package net.scopenet.core; + +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import java.util.function.Predicate; + +/** Per-player limits that permission nodes can raise, and command cooldowns. */ +public final class Limits { + private Limits() {} + + /** + * The highest tier a player has, e.g. {@code scopenet.homes.10}, otherwise {@code base}. {@code unlimited} + * means no limit. Only values above {@code base} are looked for, up to {@code cap}. + */ + public static int tier(Predicate has, String prefix, int base, int cap) { + if (has.test(prefix + "unlimited")) return Integer.MAX_VALUE; + for (int n = cap; n > base; n--) if (has.test(prefix + n)) return n; + return base; + } + + public static String show(int limit) { return limit == Integer.MAX_VALUE ? "unlimited" : Integer.toString(limit); } + + /** When each player may use each command again. Not thread-safe: use from the server thread. */ + public static final class Cooldowns { + private final Map until = new HashMap<>(); + + public long remainingMs(UUID player, String command, long now) { + Long end = until.get(player + ":" + command); + return end == null ? 0 : Math.max(0, end - now); + } + + public void start(UUID player, String command, long now, int seconds) { + if (seconds > 0) until.put(player + ":" + command, now + seconds * 1000L); + if (until.size() > 4000) until.values().removeIf(end -> end < now); + } + + public static String waitText(long ms) { return Math.max(1, (ms + 999) / 1000) + "s"; } + } +} diff --git a/integrations/common/src/test/java/net/scopenet/core/EssentialsServiceTest.java b/integrations/common/src/test/java/net/scopenet/core/EssentialsServiceTest.java index 2276ebd..f8b913d 100644 --- a/integrations/common/src/test/java/net/scopenet/core/EssentialsServiceTest.java +++ b/integrations/common/src/test/java/net/scopenet/core/EssentialsServiceTest.java @@ -22,7 +22,7 @@ class EssentialsServiceTest { public Pos pos() { return pos; } public void send(String m) { inbox.add(m.replaceAll("ยง.", "")); } public void teleport(Pos p) { pos = p; } - public boolean hasPermission(String n) { return true; } + public boolean hasPermission(String n) { return !n.startsWith("scopenet.homes.") && !n.equals("scopenet.cooldown.bypass"); } public long playtimeSeconds() { return 0; } public java.util.Optional heldItem() { return java.util.Optional.empty(); } public void clearHeld() { } diff --git a/integrations/common/src/test/java/net/scopenet/core/Kit.java b/integrations/common/src/test/java/net/scopenet/core/Kit.java index 99059da..80ad94b 100644 --- a/integrations/common/src/test/java/net/scopenet/core/Kit.java +++ b/integrations/common/src/test/java/net/scopenet/core/Kit.java @@ -17,6 +17,8 @@ final class Kit { Pos pos = new Pos("minecraft:overworld", 0, 64, 0, 0, 0); final List inbox = new ArrayList<>(); final Set denied = new HashSet<>(); + /** Nodes that grant extras (limit tiers, cooldown bypass) are off unless given here. */ + final Set granted = new HashSet<>(); Item held; final List inventory = new ArrayList<>(); long playtime = 3700; @@ -26,7 +28,7 @@ final class Kit { public Pos pos() { return pos; } public void send(String m) { inbox.add(Format.plain(m)); } public void teleport(Pos p) { pos = p; } - public boolean hasPermission(String n) { return !denied.contains(n); } + public boolean hasPermission(String n) { return granted.contains(n) || (!n.startsWith("scopenet.homes.") && !n.equals("scopenet.cooldown.bypass") && !denied.contains(n)); } public long playtimeSeconds() { return playtime; } public Optional heldItem() { return Optional.ofNullable(held); } public void clearHeld() { held = null; } diff --git a/integrations/common/src/test/java/net/scopenet/core/LimitsTest.java b/integrations/common/src/test/java/net/scopenet/core/LimitsTest.java new file mode 100644 index 0000000..89b605b --- /dev/null +++ b/integrations/common/src/test/java/net/scopenet/core/LimitsTest.java @@ -0,0 +1,76 @@ +package net.scopenet.core; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Path; +import java.util.Map; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; + +class LimitsTest { + @Test void permissionTiersRaiseALimit() { + assertEquals(5, Limits.tier(n -> false, "scopenet.homes.", 5, 500)); + assertEquals(10, Limits.tier(Set.of("scopenet.homes.10")::contains, "scopenet.homes.", 5, 500)); + assertEquals(25, Limits.tier(Set.of("scopenet.homes.10", "scopenet.homes.25", "scopenet.homes.3")::contains, "scopenet.homes.", 5, 500), "highest wins"); + assertEquals(5, Limits.tier(Set.of("scopenet.homes.3")::contains, "scopenet.homes.", 5, 500), "a lower tier never reduces the base"); + assertEquals(Integer.MAX_VALUE, Limits.tier(Set.of("scopenet.homes.unlimited")::contains, "scopenet.homes.", 5, 500)); + assertEquals("unlimited", Limits.show(Integer.MAX_VALUE)); + } + + @Test void homesFollowConfigAndPermissions(@TempDir Path dir) { + Kit kit = new Kit(dir); + Kit.Player steve = kit.platform.add("Steve"); + EssentialsConfig cfg = new EssentialsConfig(2, 2500, 60_000, 15, 0, 1, 8, Map.of()); + EssentialsService svc = new EssentialsService(kit.platform, dir.resolve("e.json"), cfg, kit.now::get, kit.env.rng); + svc.setHome(steve, new String[]{"a"}); + svc.setHome(steve, new String[]{"b"}); + svc.setHome(steve, new String[]{"c"}); + assertTrue(steve.last().contains("limit of 2")); + steve.granted.add("scopenet.homes.4"); + svc.setHome(steve, new String[]{"c"}); + svc.setHome(steve, new String[]{"d"}); + svc.setHome(steve, new String[]{"e"}); + assertTrue(steve.last().contains("limit of 4"), steve.last()); + svc.setHome(steve, new String[]{"waytoolongname"}); + assertTrue(steve.last().contains("at most 8"), steve.last()); + svc.setWarp(steve, "spawn"); + svc.setWarp(steve, "pvp"); + assertTrue(steve.last().contains("already has 1 warps"), steve.last()); + } + + @Test void teleportCommandsCoolDownOnlyAfterTeleporting(@TempDir Path dir) { + Kit kit = new Kit(dir); + Kit.Player steve = kit.platform.add("Steve"); + EssentialsConfig cfg = new EssentialsConfig(5, 2500, 60_000, 15, 0, 0, 32, Map.of("home", 30, "warp", 10)); + CommandSet set = new CommandSet(kit.env, cfg); + set.essentials.setHome(steve, new String[]{"base"}); + set.run("home", steve, "nope"); // unknown home: nothing happened, no cooldown + set.run("home", steve, "base"); + assertTrue(steve.last().contains("Teleported to home"), steve.last()); + set.run("home", steve, "base"); + assertTrue(steve.last().contains("Please wait 30s"), steve.last()); + kit.now.addAndGet(31_000); + set.run("home", steve, "base"); + assertTrue(steve.last().contains("Teleported to home"), steve.last()); + set.run("warp", steve, "missing"); + set.run("warp", steve, "missing"); + assertFalse(steve.last().contains("Please wait"), "a failed warp costs nothing"); + steve.granted.add("scopenet.cooldown.bypass"); + set.run("home", steve, "base"); + assertTrue(steve.last().contains("Teleported to home"), "bypass skips cooldowns"); + } + + @Test void rtpKeepsAwayFromTheCentre(@TempDir Path dir) { + Kit kit = new Kit(dir); + Kit.Player steve = kit.platform.add("Steve"); + EssentialsConfig cfg = new EssentialsConfig(5, 400, 60_000, 50, 300, 0, 32, Map.of()); + EssentialsService svc = new EssentialsService(kit.platform, dir.resolve("e.json"), cfg, kit.now::get, new java.util.Random(7)); + for (int i = 0; i < 20; i++) { + svc.rtp(steve); + int x = (int) steve.pos.x(), z = (int) steve.pos.z(); + assertTrue(Math.max(Math.abs(x), Math.abs(z)) >= 300, "landed at " + x + "," + z); + } + } +} diff --git a/integrations/fabric/src/mc1.20.1/java/net/scopenet/fabric/features/FabricConfig.java b/integrations/fabric/src/mc1.20.1/java/net/scopenet/fabric/features/FabricConfig.java index c2fdf7f..585e99d 100644 --- a/integrations/fabric/src/mc1.20.1/java/net/scopenet/fabric/features/FabricConfig.java +++ b/integrations/fabric/src/mc1.20.1/java/net/scopenet/fabric/features/FabricConfig.java @@ -33,8 +33,12 @@ final class FabricConfig { String text(String key, String fallback) { return p.getProperty(key, fallback).trim(); } EssentialsConfig essentials() { + java.util.Map cooldowns = new java.util.HashMap<>(); + for (String cmd : new String[]{"spawn", "home", "back", "tpa", "rtp", "warp"}) cooldowns.put(cmd, Math.max(0, integer("essentials.cooldown." + cmd, 0))); return new EssentialsConfig(Math.max(0, integer("essentials.max_homes", 5)), Math.max(10, integer("essentials.rtp_radius", 2500)), - Math.max(5, integer("essentials.tpa_timeout_seconds", 60)) * 1000L, 15); + Math.max(5, integer("essentials.tpa_timeout_seconds", 60)) * 1000L, Math.max(1, integer("essentials.rtp_attempts", 15)), + Math.max(0, integer("essentials.rtp_min_radius", 0)), Math.max(0, integer("essentials.max_warps", 0)), + Math.max(1, integer("essentials.home_name_max_length", 32)), cooldowns); } Features features(Settings s) { diff --git a/integrations/fabric/src/mc1.20.1/java/net/scopenet/fabric/features/Perms.java b/integrations/fabric/src/mc1.20.1/java/net/scopenet/fabric/features/Perms.java index c0c3df4..6b7befd 100644 --- a/integrations/fabric/src/mc1.20.1/java/net/scopenet/fabric/features/Perms.java +++ b/integrations/fabric/src/mc1.20.1/java/net/scopenet/fabric/features/Perms.java @@ -16,8 +16,11 @@ final class Perms { static boolean luckPermsPresent() { return LUCKPERMS; } + /** Nodes that grant extras (limit tiers like scopenet.homes.10): nobody has them unless a permissions system says so. */ + static boolean isGrantNode(String node) { return node.startsWith("scopenet.homes.") || node.startsWith("scopenet.limit."); } + static boolean isAdminNode(String node) { - return node.equals("scopenet.claims.bypass") || node.startsWith("scopenet.admin") + return node.equals("scopenet.claims.bypass") || node.equals("scopenet.cooldown.bypass") || node.startsWith("scopenet.admin") || node.equals("scopenet.command.scopenet.status") || node.equals("scopenet.command.scopenet.reload"); } @@ -30,6 +33,7 @@ final class Perms { // LuckPerms not ready yet or an API change: fall back to the defaults below. } } + if (isGrantNode(node)) return false; if (isAdminNode(node)) return player.hasPermissions(2); return everyoneByDefault || player.hasPermissions(2); } diff --git a/integrations/paper/src/main/java/net/scopenet/paper/commands/EssentialsHandler.java b/integrations/paper/src/main/java/net/scopenet/paper/commands/EssentialsHandler.java index 9a01edc..830f63e 100644 --- a/integrations/paper/src/main/java/net/scopenet/paper/commands/EssentialsHandler.java +++ b/integrations/paper/src/main/java/net/scopenet/paper/commands/EssentialsHandler.java @@ -133,7 +133,9 @@ public final class EssentialsHandler implements CommandExecutor, Listener { Map playerHomes = homes.get(player.getUniqueId()); if (playerHomes != null && playerHomes.containsKey(homeName)) { player.closeInventory(); + if (coolingDown(player, "home")) return; player.teleport(playerHomes.get(homeName)); + startCooldown(player, "home"); player.sendMessage(ChatColor.GREEN + "Teleported to home: " + ChatColor.YELLOW + homeName); } } else if (title.equals(GUI_WARPS_TITLE)) { @@ -143,7 +145,9 @@ public final class EssentialsHandler implements CommandExecutor, Listener { String warpName = ChatColor.stripColor(item.getItemMeta().getDisplayName()).replace("Warp: ", "").trim().toLowerCase(); if (warps.containsKey(warpName)) { player.closeInventory(); + if (coolingDown(player, "warp")) return; player.teleport(warps.get(warpName)); + startCooldown(player, "warp"); player.sendMessage(ChatColor.GREEN + "Teleported to warp: " + ChatColor.YELLOW + warpName); } } @@ -158,6 +162,9 @@ public final class EssentialsHandler implements CommandExecutor, Listener { if (!plugin.getConfig().getBoolean("essentials.enabled", true)) { player.sendMessage(ChatColor.RED + "Essentials are disabled."); return true; } String cmd = command.getName().toLowerCase(); + boolean cooled = cooldownSeconds(cmd) > 0 && !player.hasPermission("scopenet.cooldown.bypass"); + if (cooled && coolingDown(player, cmd)) return true; + Location before = player.getLocation(); switch (cmd) { case "spawn" -> handleSpawn(player); case "home" -> handleHome(player, args); @@ -178,6 +185,7 @@ public final class EssentialsHandler implements CommandExecutor, Listener { case "playtime" -> handlePlaytime(player, args); default -> { return false; } } + if (cooled && ((cmd.equals("tpa") && args.length > 0) || cmd.equals("rtp") || !before.equals(player.getLocation()))) startCooldown(player, cmd); return true; } @@ -263,13 +271,36 @@ public final class EssentialsHandler implements CommandExecutor, Listener { player.openInventory(inv); } + // ---- limits and cooldowns (the same rules as the Fabric mod; see config.yml) ---- + + private final net.scopenet.core.Limits.Cooldowns cooldowns = new net.scopenet.core.Limits.Cooldowns(); + + private int tpaTimeoutSeconds() { return Math.max(5, plugin.getConfig().getInt("essentials.tpa-timeout-seconds", 60)); } + + private int cooldownSeconds(String cmd) { return Math.max(0, plugin.getConfig().getInt("essentials.cooldowns." + cmd, 0)); } + + /** True (and tells the player) if they must still wait before using this command. */ + private boolean coolingDown(Player player, String cmd) { + if (cooldownSeconds(cmd) <= 0 || player.hasPermission("scopenet.cooldown.bypass")) return false; + long wait = cooldowns.remainingMs(player.getUniqueId(), cmd, System.currentTimeMillis()); + if (wait <= 0) return false; + player.sendMessage(ChatColor.RED + "Please wait " + net.scopenet.core.Limits.Cooldowns.waitText(wait) + " before using /" + cmd + " again."); + return true; + } + + private void startCooldown(Player player, String cmd) { + if (!player.hasPermission("scopenet.cooldown.bypass")) cooldowns.start(player.getUniqueId(), cmd, System.currentTimeMillis(), cooldownSeconds(cmd)); + } + private void handleSetHome(Player player, String[] args) { String homeName = args.length > 0 ? args[0].toLowerCase() : "home"; - int maxHomes = plugin.getConfig().getInt("essentials.max-homes", 5); + int nameMax = Math.max(1, plugin.getConfig().getInt("essentials.home-name-max-length", 32)); + if (homeName.length() > nameMax) { player.sendMessage(ChatColor.RED + "Home names can be at most " + nameMax + " characters."); return; } + int maxHomes = net.scopenet.core.Limits.tier(player::hasPermission, "scopenet.homes.", plugin.getConfig().getInt("essentials.max-homes", 5), 500); Map playerHomes = homes.computeIfAbsent(player.getUniqueId(), k -> new HashMap<>()); if (!playerHomes.containsKey(homeName) && playerHomes.size() >= maxHomes) { - player.sendMessage(ChatColor.RED + "You have reached your limit of " + maxHomes + " homes."); + player.sendMessage(ChatColor.RED + "You have reached your limit of " + net.scopenet.core.Limits.show(maxHomes) + " homes."); return; } @@ -324,13 +355,13 @@ public final class EssentialsHandler implements CommandExecutor, Listener { player.sendMessage(ChatColor.GREEN + "Teleport request sent to " + ChatColor.YELLOW + target.getName() + ChatColor.GREEN + "."); target.sendMessage(ChatColor.YELLOW + player.getName() + ChatColor.GREEN + " has sent you a teleport request."); - target.sendMessage(ChatColor.GRAY + "Type " + ChatColor.YELLOW + "/tpaccept" + ChatColor.GRAY + " to accept or " + ChatColor.RED + "/tpdeny" + ChatColor.GRAY + " to deny (expires in 60s)."); + target.sendMessage(ChatColor.GRAY + "Type " + ChatColor.YELLOW + "/tpaccept" + ChatColor.GRAY + " to accept or " + ChatColor.RED + "/tpdeny" + ChatColor.GRAY + " to deny (expires in " + tpaTimeoutSeconds() + "s)."); } private void handleTpAccept(Player player) { UUID requesterId = pendingTpa.get(player.getUniqueId()); Long time = tpaTimestamp.get(player.getUniqueId()); - if (requesterId == null || time == null || System.currentTimeMillis() - time > 60_000) { + if (requesterId == null || time == null || System.currentTimeMillis() - time > tpaTimeoutSeconds() * 1000L) { pendingTpa.remove(player.getUniqueId()); tpaTimestamp.remove(player.getUniqueId()); player.sendMessage(ChatColor.RED + "You have no active teleport requests."); @@ -366,15 +397,18 @@ public final class EssentialsHandler implements CommandExecutor, Listener { } private void handleRtp(Player player) { - int radius = plugin.getConfig().getInt("essentials.rtp-radius", 2500); + int radius = Math.max(10, plugin.getConfig().getInt("essentials.rtp-radius", 2500)); + int minRadius = Math.min(plugin.getConfig().getInt("essentials.rtp-min-radius", 0), radius - 1); + int attempts = Math.max(1, plugin.getConfig().getInt("essentials.rtp-attempts", 15)); World world = player.getWorld(); Random rng = new Random(); player.sendMessage(ChatColor.GRAY + "Searching for a safe wilderness destination..."); Bukkit.getScheduler().runTask(plugin, () -> { - for (int i = 0; i < 15; i++) { + for (int i = 0; i < attempts; i++) { int x = rng.nextInt(radius * 2) - radius; int z = rng.nextInt(radius * 2) - radius; + if (Math.max(Math.abs(x), Math.abs(z)) < minRadius) continue; int y = world.getHighestBlockYAt(x, z); Block block = world.getBlockAt(x, y - 1, z); if (block.getType().isSolid() && block.getType() != Material.LAVA && block.getType() != Material.WATER) { @@ -471,6 +505,11 @@ public final class EssentialsHandler implements CommandExecutor, Listener { if (!warpAdmin(player)) return; String name = warpName(player, args, "/setwarp "); if (name == null) return; + int maxWarps = plugin.getConfig().getInt("essentials.max-warps", 0); + if (maxWarps > 0 && !warps.containsKey(name) && warps.size() >= maxWarps) { + player.sendMessage(ChatColor.RED + "The server already has " + maxWarps + " warps. Delete one first."); + return; + } warps.put(name, player.getLocation()); saveWarps(); player.sendMessage(ChatColor.GREEN + "Warp set: " + name); diff --git a/integrations/paper/src/main/resources/config.yml b/integrations/paper/src/main/resources/config.yml index 4c90bbe..ecc6350 100644 --- a/integrations/paper/src/main/resources/config.yml +++ b/integrations/paper/src/main/resources/config.yml @@ -46,10 +46,30 @@ social: essentials: # Essentials commands (/spawn, /home, /sethome, /delhome, /back, /tpa, /tpaccept, /tpdeny, /rtp, /warp, /playtime) enabled: true - # Maximum homes per player + # Homes every player can set. Raise it for a rank with a permission node: scopenet.homes.10 gives 10 homes, + # scopenet.homes.25 gives 25 (the highest one a player has wins), scopenet.homes.unlimited removes the limit. + # Operators and anyone with "*" get the top tier. max-homes: 5 - # RTP search radius + # Longest allowed home name. + home-name-max-length: 32 + # Most warps the server may have (0 = no limit). + max-warps: 0 + # How long a /tpa request stays open. + tpa-timeout-seconds: 60 + # /rtp picks a random spot between rtp-min-radius and rtp-radius blocks from the centre (0,0). rtp-radius: 2500 + rtp-min-radius: 0 + # How many random spots /rtp tries before giving up. + rtp-attempts: 15 + # Seconds a player must wait between uses (0 = no cooldown). The wait starts when the command actually + # teleports, so a typo costs nothing. Players with scopenet.cooldown.bypass skip all of these. + cooldowns: + spawn: 0 + home: 0 + back: 0 + tpa: 0 + rtp: 0 + warp: 0 map: # The SCOPENET Map: this server renders its world to map tiles in the background and sends only what changed, @@ -96,6 +116,23 @@ integrations: enabled: true report-interval-seconds: 30 +# ============================================================================== +# Rewards +# Quests, achievements and rank milestones can give more than XP: money, items, permissions, groups, claim +# chunks, badges and messages (set them in the admin panel). The panel hands items, permissions and commands +# to this server when the player is online. Permissions and groups are applied with console commands, so any +# permissions plugin works; the defaults are LuckPerms'. Placeholders: {player} {uuid} {node} {value} {duration} {group}. +# ============================================================================== +rewards: + enabled: true + # Let the panel run its own console commands as rewards. Off by default: anyone who can edit rewards in the panel + # could otherwise run commands on this server. Only switch it on if you trust everyone with panel admin access. + allow-commands: false + permission-command: "lp user {player} permission set {node} true" + permission-deny-command: "lp user {player} permission set {node} false" + permission-temp-command: "lp user {player} permission settemp {node} {value} {duration}" + group-command: "lp user {player} parent add {group}" + # ============================================================================== # Client mod link # Sends balance, level, guild and claim info to players who have the optional SCOPENET