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 EconomyCommands economyCommands;
private final List<CoreCommand> 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().<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, 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<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. */
private record Gated(CoreCommand inner, String system, java.util.function.BooleanSupplier enabled) implements CoreCommand {
@Override public String name() { return inner.name(); }
@@ -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.<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 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 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<String, Pos> 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<Pos> 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<UUID, Long> lastAction = new ConcurrentHashMap<>();
private void go(CorePlayer p, Pos destination) {
lastAction.put(p.uuid(), clock.getAsLong());
remember(p.uuid(), p.pos());
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"; }
}
}