Configurable essentials: home tiers by permission, cooldowns, warp cap, RTP radii; rewards config

This commit is contained in:
Claude committed 2026-10-01 01:01:54 +00:00
1 parent 0dc948caa4
commit c859628e93
11 files changed
+276 -16

No files matched your search

@@ -12,6 +12,7 @@ public final class CommandSet {
private final GuildCommands guildCommands; private final GuildCommands guildCommands;
private final EconomyCommands economyCommands; private final EconomyCommands economyCommands;
private final List<CoreCommand> commands = new ArrayList<>(); private final List<CoreCommand> commands = new ArrayList<>();
private final Limits.Cooldowns cooldowns = new Limits.Cooldowns();
public CommandSet(Env env, EssentialsConfig essentialsConfig) { public CommandSet(Env env, EssentialsConfig essentialsConfig) {
Path dir = env.dataDir; 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")); GuildCommands guilds = new GuildCommands(env, jobs, economy, essentials, dir.resolve("guild-homes.json"));
economyCommands = economy; economyCommands = economy;
guildCommands = guilds; guildCommands = guilds;
add(env, EssentialsCommands.build(env, essentials), "Essentials", () -> env.features.essentials()); add(env, EssentialsCommands.build(env, essentials).stream().<CoreCommand>map(c -> new Cooled(c, essentials, cooldowns, env.clock)).toList(),
"Essentials", () -> env.features.essentials());
add(env, economy.build(), "Economy", () -> env.features.economy()); add(env, economy.build(), "Economy", () -> env.features.economy());
add(env, guilds.build(), "Guilds", () -> env.features.guilds()); 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. */ /** Call about once a second from the server thread. */
public void tick() { jobs.tick(); } 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<String> 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<String> complete(CorePlayer p, String[] args) { return inner.complete(p, args); }
}
/** Checks the feature switch first, then the permission node, so every platform behaves identically. */ /** 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 { private record Gated(CoreCommand inner, String system, java.util.function.BooleanSupplier enabled) implements CoreCommand {
@Override public String name() { return inner.name(); } @Override public String name() { return inner.name(); }
@@ -1,5 +1,21 @@
package net.scopenet.core; 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.<number>}, 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<String, Integer> 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 static EssentialsConfig defaults() { return new EssentialsConfig(5, 2500, 60_000, 15); }
public int cooldown(String command) { return cooldowns == null ? 0 : cooldowns.getOrDefault(command, 0); }
} }
@@ -47,6 +47,8 @@ public final class EssentialsService {
public void configure(EssentialsConfig next) { this.config = next; } public void configure(EssentialsConfig next) { this.config = next; }
public int cooldownSeconds(String command) { return config.cooldown(command); }
// ---- persistence ------------------------------------------------------- // ---- persistence -------------------------------------------------------
private void load() { private void load() {
@@ -113,8 +115,10 @@ public final class EssentialsService {
public void setHome(CorePlayer p, String[] args) { public void setHome(CorePlayer p, String[] args) {
String name = args.length > 0 ? args[0].toLowerCase(Locale.ROOT) : "home"; String name = args.length > 0 ? args[0].toLowerCase(Locale.ROOT) : "home";
Map<String, Pos> mine = homes.computeIfAbsent(p.uuid(), k -> new HashMap<>()); Map<String, Pos> mine = homes.computeIfAbsent(p.uuid(), k -> new HashMap<>());
if (!mine.containsKey(name) && mine.size() >= config.maxHomes()) { if (name.length() > config.homeNameMax()) { p.send(RED + "Home names can be at most " + config.homeNameMax() + " characters."); return; }
p.send(RED + "You have reached your limit of " + config.maxHomes() + " homes."); 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; return;
} }
mine.put(name, p.pos()); mine.put(name, p.pos());
@@ -161,6 +165,10 @@ public final class EssentialsService {
} }
public void setWarp(CorePlayer p, String name) { 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()); warps.put(name.toLowerCase(Locale.ROOT), p.pos());
save(); save();
p.send(GREEN + "Warp " + YELLOW + name.toLowerCase(Locale.ROOT) + GREEN + " set."); p.send(GREEN + "Warp " + YELLOW + name.toLowerCase(Locale.ROOT) + GREEN + " set.");
@@ -190,6 +198,7 @@ public final class EssentialsService {
CorePlayer target = found.get(); CorePlayer target = found.get();
if (target.uuid().equals(p.uuid())) { p.send(RED + "You cannot teleport to yourself."); return; } 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())); 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 + "."); 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(YELLOW + p.name() + GREEN + " has sent you a teleport request.");
target.send(GRAY + "Type " + YELLOW + "/tpaccept" + GRAY + " to accept or " + RED + "/tpdeny" + GRAY 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. */ /** Must run on the server thread, because it reads chunks. */
public void rtp(CorePlayer p) { public void rtp(CorePlayer p) {
int radius = config.rtpRadius(); int radius = config.rtpRadius();
int min = Math.min(config.rtpMinRadius(), radius - 1);
String world = p.pos().world(); String world = p.pos().world();
p.send(GRAY + "Searching for a safe wilderness destination..."); p.send(GRAY + "Searching for a safe wilderness destination...");
for (int i = 0; i < config.rtpAttempts(); i++) { for (int i = 0; i < config.rtpAttempts(); i++) {
int x = rng.nextInt(radius * 2) - radius; int x = rng.nextInt(radius * 2) - radius;
int z = 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<Pos> spot = platform.safeSurface(world, x, z); Optional<Pos> spot = platform.safeSurface(world, x, z);
if (spot.isPresent()) { if (spot.isPresent()) {
Pos at = spot.get(); Pos at = spot.get();
@@ -242,7 +253,13 @@ public final class EssentialsService {
/** Teleport and remember where they were for /back. */ /** Teleport and remember where they were for /back. */
public void teleportTo(CorePlayer p, Pos destination) { go(p, destination); } 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<UUID, Long> lastAction = new ConcurrentHashMap<>();
private void go(CorePlayer p, Pos destination) { private void go(CorePlayer p, Pos destination) {
lastAction.put(p.uuid(), clock.getAsLong());
remember(p.uuid(), p.pos()); remember(p.uuid(), p.pos());
p.teleport(destination); p.teleport(destination);
} }
@@ -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 <prefix>unlimited}
* means no limit. Only values above {@code base} are looked for, up to {@code cap}.
*/
public static int tier(Predicate<String> 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<String, Long> 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"; }
}
}
@@ -22,7 +22,7 @@ class EssentialsServiceTest {
public Pos pos() { return pos; } public Pos pos() { return pos; }
public void send(String m) { inbox.add(m.replaceAll("§.", "")); } public void send(String m) { inbox.add(m.replaceAll("§.", "")); }
public void teleport(Pos p) { pos = p; } 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 long playtimeSeconds() { return 0; }
public java.util.Optional<Item> heldItem() { return java.util.Optional.empty(); } public java.util.Optional<Item> heldItem() { return java.util.Optional.empty(); }
public void clearHeld() { } public void clearHeld() { }
@@ -17,6 +17,8 @@ final class Kit {
Pos pos = new Pos("minecraft:overworld", 0, 64, 0, 0, 0); Pos pos = new Pos("minecraft:overworld", 0, 64, 0, 0, 0);
final List<String> inbox = new ArrayList<>(); final List<String> inbox = new ArrayList<>();
final Set<String> denied = new HashSet<>(); final Set<String> denied = new HashSet<>();
/** Nodes that grant extras (limit tiers, cooldown bypass) are off unless given here. */
final Set<String> granted = new HashSet<>();
Item held; Item held;
final List<Item> inventory = new ArrayList<>(); final List<Item> inventory = new ArrayList<>();
long playtime = 3700; long playtime = 3700;
@@ -26,7 +28,7 @@ final class Kit {
public Pos pos() { return pos; } public Pos pos() { return pos; }
public void send(String m) { inbox.add(Format.plain(m)); } public void send(String m) { inbox.add(Format.plain(m)); }
public void teleport(Pos p) { pos = p; } 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 long playtimeSeconds() { return playtime; }
public Optional<Item> heldItem() { return Optional.ofNullable(held); } public Optional<Item> heldItem() { return Optional.ofNullable(held); }
public void clearHeld() { held = null; } public void clearHeld() { held = null; }
@@ -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);
}
}
}
@@ -33,8 +33,12 @@ final class FabricConfig {
String text(String key, String fallback) { return p.getProperty(key, fallback).trim(); } String text(String key, String fallback) { return p.getProperty(key, fallback).trim(); }
EssentialsConfig essentials() { EssentialsConfig essentials() {
java.util.Map<String, Integer> 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)), 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) { Features features(Settings s) {
@@ -16,8 +16,11 @@ final class Perms {
static boolean luckPermsPresent() { return LUCKPERMS; } 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) { 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"); || 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. // 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); if (isAdminNode(node)) return player.hasPermissions(2);
return everyoneByDefault || player.hasPermissions(2); return everyoneByDefault || player.hasPermissions(2);
} }
@@ -133,7 +133,9 @@ public final class EssentialsHandler implements CommandExecutor, Listener {
Map<String, Location> playerHomes = homes.get(player.getUniqueId()); Map<String, Location> playerHomes = homes.get(player.getUniqueId());
if (playerHomes != null && playerHomes.containsKey(homeName)) { if (playerHomes != null && playerHomes.containsKey(homeName)) {
player.closeInventory(); player.closeInventory();
if (coolingDown(player, "home")) return;
player.teleport(playerHomes.get(homeName)); player.teleport(playerHomes.get(homeName));
startCooldown(player, "home");
player.sendMessage(ChatColor.GREEN + "Teleported to home: " + ChatColor.YELLOW + homeName); player.sendMessage(ChatColor.GREEN + "Teleported to home: " + ChatColor.YELLOW + homeName);
} }
} else if (title.equals(GUI_WARPS_TITLE)) { } 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(); String warpName = ChatColor.stripColor(item.getItemMeta().getDisplayName()).replace("Warp: ", "").trim().toLowerCase();
if (warps.containsKey(warpName)) { if (warps.containsKey(warpName)) {
player.closeInventory(); player.closeInventory();
if (coolingDown(player, "warp")) return;
player.teleport(warps.get(warpName)); player.teleport(warps.get(warpName));
startCooldown(player, "warp");
player.sendMessage(ChatColor.GREEN + "Teleported to warp: " + ChatColor.YELLOW + warpName); 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; } if (!plugin.getConfig().getBoolean("essentials.enabled", true)) { player.sendMessage(ChatColor.RED + "Essentials are disabled."); return true; }
String cmd = command.getName().toLowerCase(); 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) { switch (cmd) {
case "spawn" -> handleSpawn(player); case "spawn" -> handleSpawn(player);
case "home" -> handleHome(player, args); case "home" -> handleHome(player, args);
@@ -178,6 +185,7 @@ public final class EssentialsHandler implements CommandExecutor, Listener {
case "playtime" -> handlePlaytime(player, args); case "playtime" -> handlePlaytime(player, args);
default -> { return false; } default -> { return false; }
} }
if (cooled && ((cmd.equals("tpa") && args.length > 0) || cmd.equals("rtp") || !before.equals(player.getLocation()))) startCooldown(player, cmd);
return true; return true;
} }
@@ -263,13 +271,36 @@ public final class EssentialsHandler implements CommandExecutor, Listener {
player.openInventory(inv); 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) { private void handleSetHome(Player player, String[] args) {
String homeName = args.length > 0 ? args[0].toLowerCase() : "home"; 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<String, Location> playerHomes = homes.computeIfAbsent(player.getUniqueId(), k -> new HashMap<>()); Map<String, Location> playerHomes = homes.computeIfAbsent(player.getUniqueId(), k -> new HashMap<>());
if (!playerHomes.containsKey(homeName) && playerHomes.size() >= maxHomes) { 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; 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 + "."); 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.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) { private void handleTpAccept(Player player) {
UUID requesterId = pendingTpa.get(player.getUniqueId()); UUID requesterId = pendingTpa.get(player.getUniqueId());
Long time = tpaTimestamp.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()); pendingTpa.remove(player.getUniqueId());
tpaTimestamp.remove(player.getUniqueId()); tpaTimestamp.remove(player.getUniqueId());
player.sendMessage(ChatColor.RED + "You have no active teleport requests."); 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) { 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(); World world = player.getWorld();
Random rng = new Random(); Random rng = new Random();
player.sendMessage(ChatColor.GRAY + "Searching for a safe wilderness destination..."); player.sendMessage(ChatColor.GRAY + "Searching for a safe wilderness destination...");
Bukkit.getScheduler().runTask(plugin, () -> { 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 x = rng.nextInt(radius * 2) - radius;
int z = 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); int y = world.getHighestBlockYAt(x, z);
Block block = world.getBlockAt(x, y - 1, z); Block block = world.getBlockAt(x, y - 1, z);
if (block.getType().isSolid() && block.getType() != Material.LAVA && block.getType() != Material.WATER) { 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; if (!warpAdmin(player)) return;
String name = warpName(player, args, "/setwarp <name>"); String name = warpName(player, args, "/setwarp <name>");
if (name == null) return; 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()); warps.put(name, player.getLocation());
saveWarps(); saveWarps();
player.sendMessage(ChatColor.GREEN + "Warp set: " + name); player.sendMessage(ChatColor.GREEN + "Warp set: " + name);
@@ -46,10 +46,30 @@ social:
essentials: essentials:
# Essentials commands (/spawn, /home, /sethome, /delhome, /back, /tpa, /tpaccept, /tpdeny, /rtp, /warp, /playtime) # Essentials commands (/spawn, /home, /sethome, /delhome, /back, /tpa, /tpaccept, /tpdeny, /rtp, /warp, /playtime)
enabled: true 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 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-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: map:
# The SCOPENET Map: this server renders its world to map tiles in the background and sends only what changed, # 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 enabled: true
report-interval-seconds: 30 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 # Client mod link
# Sends balance, level, guild and claim info to players who have the optional SCOPENET # Sends balance, level, guild and claim info to players who have the optional SCOPENET