Shared essentials core (homes, warps, back, TPA, RTP) with tests; example configs
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DjMbLQujBHunCCu5GpsHaT
This commit is contained in:
10 files changed
+603
No files matched your search
@@ -0,0 +1,11 @@
|
|||||||
|
# Example configuration
|
||||||
|
|
||||||
|
Copy the file for your setup and fill in `panel-url` and `token` (create the token under **Servers** in the admin panel).
|
||||||
|
|
||||||
|
| Folder | Goes in | For |
|
||||||
|
|---|---|---|
|
||||||
|
| `paper/config.yml` | `plugins/SCOPENET/config.yml` | The Paper plugin. Includes the `integrations:` section for LuckPerms, PlaceholderAPI, Vault, CoreProtect, WorldGuard and Spark. |
|
||||||
|
| `fabric-server/scopenet.properties` | `config/scopenet.properties` | The Fabric server mod (Fabric 1.20.1, 1.21.1). |
|
||||||
|
| `fabric-client/scopenet-client.json` | `config/scopenet-client.json` | The optional Fabric client mod: HUD widgets, notifications and keybinds. |
|
||||||
|
|
||||||
|
The client mod is display-only. The server mod or plugin stays in charge of claims, money and permissions, so a missing or edited client mod can't change anything.
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"_about": "SCOPENET client mod (Fabric 1.20.1) -> config/scopenet-client.json. The in-game config screen edits this file; you rarely need to change it by hand. The mod is in development and this format may grow.",
|
||||||
|
"enabled": true,
|
||||||
|
"hud": {
|
||||||
|
"scale": 1.0,
|
||||||
|
"opacity": 0.85,
|
||||||
|
"widgets": {
|
||||||
|
"balance": { "enabled": true, "anchor": "top_right", "x": -8, "y": 8, "compact": false },
|
||||||
|
"level": { "enabled": true, "anchor": "top_left", "x": 8, "y": 8, "showXpBar": true },
|
||||||
|
"guild": { "enabled": true, "anchor": "bottom_left", "x": 8, "y": -8, "showClaimOwner": true },
|
||||||
|
"quests": { "enabled": false, "anchor": "middle_right", "x": -8, "y": 0, "maxShown": 3 },
|
||||||
|
"claimBorders": { "enabled": true, "showOnlyWhenSneaking": false }
|
||||||
|
},
|
||||||
|
"hideInF3": true,
|
||||||
|
"hideWhileChatOpen": false
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"levelUp": true,
|
||||||
|
"achievements": true,
|
||||||
|
"friendRequests": true,
|
||||||
|
"gameInvites": true
|
||||||
|
},
|
||||||
|
"keybinds": {
|
||||||
|
"openMenu": "key.keyboard.k",
|
||||||
|
"toggleHud": "key.keyboard.h"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# SCOPENET for Fabric (server side) -> config/scopenet.properties
|
||||||
|
# Works with the same panel as the Paper plugin. Keys marked [planned] are read once the Fabric
|
||||||
|
# feature-parity work lands; unknown keys are ignored, so it is safe to keep them here.
|
||||||
|
|
||||||
|
# --- Connection (required) ---------------------------------------------------
|
||||||
|
# Your panel address and the token from Servers -> this server in the panel.
|
||||||
|
panel-url=https://panel.example.com
|
||||||
|
token=
|
||||||
|
|
||||||
|
# --- Feature toggles (same meaning as the Paper plugin) ----------------------
|
||||||
|
leveling.enabled=true
|
||||||
|
leveling.global_xp_multiplier=1.0
|
||||||
|
leveling.server_xp_multiplier=1.5
|
||||||
|
quests.enabled=true
|
||||||
|
achievements.enabled=true
|
||||||
|
guilds.enabled=true
|
||||||
|
guilds.land_claiming=true
|
||||||
|
social.enabled=true
|
||||||
|
social.chat_prefixes=true
|
||||||
|
essentials.enabled=true
|
||||||
|
economy.enabled=true
|
||||||
|
|
||||||
|
# Send world chunks and player positions to the panel's Live Map
|
||||||
|
# (also needs Live Map switched on for this server in the panel).
|
||||||
|
livemap.enabled=true
|
||||||
|
|
||||||
|
# --- Essentials [planned] ----------------------------------------------------
|
||||||
|
# Homes, warps, /back, /tpa and /rtp. Same defaults as Paper.
|
||||||
|
essentials.max_homes=5
|
||||||
|
essentials.rtp_radius=2500
|
||||||
|
essentials.tpa_timeout_seconds=60
|
||||||
|
|
||||||
|
# --- Permissions [planned] ---------------------------------------------------
|
||||||
|
# Uses the LuckPerms mod when it is installed (nodes are scopenet.command.*, same as Paper).
|
||||||
|
# Without it, these defaults apply: "all" lets every player use the command, "op" limits to operators.
|
||||||
|
permissions.default_level=all
|
||||||
|
permissions.admin_commands=op
|
||||||
|
|
||||||
|
# --- Economy mods [planned] --------------------------------------------------
|
||||||
|
# auto : use the first supported economy mod found (Impactor, Common Economy API), else SCOPENET's own balances
|
||||||
|
# scopenet: always use SCOPENET balances (other mods can still read them through the SCOPENET API)
|
||||||
|
economy.provider=auto
|
||||||
|
economy.currency_symbol=$
|
||||||
|
|
||||||
|
# --- Placeholder API [planned] -----------------------------------------------
|
||||||
|
# Registers %scopenet:level%, %scopenet:guild%, %scopenet:balance%, %scopenet:playtime%,
|
||||||
|
# %scopenet:quest_progress% when Placeholder API (Fabric) is installed.
|
||||||
|
placeholders.enabled=true
|
||||||
|
|
||||||
|
# --- Client mod link [planned] -----------------------------------------------
|
||||||
|
# Send balance, level and guild info to players who have the SCOPENET client mod.
|
||||||
|
clientlink.enabled=true
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
# SCOPENET Paper plugin -> plugins/SCOPENET/config.yml (this is a copy of the file the plugin creates on first start)
|
||||||
|
# ==============================================================================
|
||||||
|
# SCOPENET Minecraft Server Configuration
|
||||||
|
# ==============================================================================
|
||||||
|
# Connect to your SCOPENET Panel instance by pasting your server token below.
|
||||||
|
# Create a server under 'Servers' in the web panel to generate a token.
|
||||||
|
|
||||||
|
panel-url: "https://panel.example.com"
|
||||||
|
token: ""
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# Feature Toggles
|
||||||
|
# Every system below is fully toggleable. You do not need to force all features
|
||||||
|
# on every server—turn on only what makes sense for your server's gameplay style.
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
leveling:
|
||||||
|
# Global & Server leveling progression (tracks playtime, kills, mining, etc.)
|
||||||
|
enabled: true
|
||||||
|
# Multiplier for account-wide Global Level XP (1.0 = standard speed)
|
||||||
|
global-xp-multiplier: 1.0
|
||||||
|
# Multiplier for this server's specific Server Level XP (1.5 = 1.5x speed)
|
||||||
|
server-xp-multiplier: 1.5
|
||||||
|
|
||||||
|
quests:
|
||||||
|
# Daily and Weekly quests progression tracking
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
achievements:
|
||||||
|
# Minecraft-themed achievements and milestone unlock notifications
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
guilds:
|
||||||
|
# Guilds partying system and /guild commands
|
||||||
|
enabled: true
|
||||||
|
# FTB Chunks-style chunk land-claiming and protection against other guilds
|
||||||
|
land-claiming: true
|
||||||
|
# Allow guild members to damage each other (PvP friendly fire)
|
||||||
|
friendly-fire: false
|
||||||
|
|
||||||
|
social:
|
||||||
|
# Friends status, direct messaging bridge, and server game invites
|
||||||
|
enabled: true
|
||||||
|
# Show player guild tag and level title in chat formatting
|
||||||
|
chat-prefixes: true
|
||||||
|
|
||||||
|
essentials:
|
||||||
|
# Essentials commands (/spawn, /home, /sethome, /delhome, /back, /tpa, /tpaccept, /tpdeny, /rtp, /warp, /playtime)
|
||||||
|
enabled: true
|
||||||
|
# Maximum homes per player
|
||||||
|
max-homes: 5
|
||||||
|
# RTP search radius
|
||||||
|
rtp-radius: 2500
|
||||||
|
|
||||||
|
livemap:
|
||||||
|
# Send world chunks and live player positions to the panel's central Live Map.
|
||||||
|
# Players open it from the launcher; you don't run Vantage on this server.
|
||||||
|
# It also needs "Live Map" switched on for this server in the panel.
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
economy:
|
||||||
|
# Server Economy commands (/balance, /pay, /baltop, /shop, /sell, /market, /orders, /trade, /transactions)
|
||||||
|
enabled: true
|
||||||
|
# Currency symbol
|
||||||
|
currency-symbol: "$"
|
||||||
|
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# Plugin integrations
|
||||||
|
# SCOPENET detects these plugins by itself; switch any of them off here.
|
||||||
|
# ==============================================================================
|
||||||
|
integrations:
|
||||||
|
luckperms:
|
||||||
|
enabled: true
|
||||||
|
# Send each online player's LuckPerms rank to the panel (profiles, admin view).
|
||||||
|
report-interval-seconds: 60
|
||||||
|
# Let the panel add/remove LuckPerms group membership for groups an admin has mapped to a panel
|
||||||
|
# group (Panel > Players > Groups). LuckPerms stays in charge of permissions; this is off unless you opt in.
|
||||||
|
apply-panel-groups: false
|
||||||
|
placeholderapi:
|
||||||
|
# %scopenet_level%, %scopenet_guild%, %scopenet_balance%, %scopenet_playtime%, %scopenet_quest_progress% ...
|
||||||
|
enabled: true
|
||||||
|
vault:
|
||||||
|
# Use the SCOPENET economy as Vault's economy, so other plugins' shops and jobs pay into it.
|
||||||
|
enabled: true
|
||||||
|
coreprotect:
|
||||||
|
enabled: true
|
||||||
|
# Folders scanned for world backups shown in the panel.
|
||||||
|
backup-folders:
|
||||||
|
- backups
|
||||||
|
worldguard:
|
||||||
|
enabled: true
|
||||||
|
report-interval-minutes: 5
|
||||||
|
spark:
|
||||||
|
enabled: true
|
||||||
|
report-interval-seconds: 30
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package net.scopenet.core;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/** What the shared logic needs from an online player. Each platform provides an adapter. */
|
||||||
|
public interface CorePlayer {
|
||||||
|
UUID uuid();
|
||||||
|
String name();
|
||||||
|
Pos pos();
|
||||||
|
/** Send a chat message. Legacy section-sign colour codes are allowed. */
|
||||||
|
void send(String message);
|
||||||
|
void teleport(Pos destination);
|
||||||
|
/** True if the player may use {@code node}. Platforms map this onto LuckPerms or their own default. */
|
||||||
|
boolean hasPermission(String node);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package net.scopenet.core;
|
||||||
|
|
||||||
|
public record EssentialsConfig(int maxHomes, int rtpRadius, long tpaTimeoutMs, int rtpAttempts) {
|
||||||
|
public static EssentialsConfig defaults() { return new EssentialsConfig(5, 2500, 60_000, 15); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
package net.scopenet.core;
|
||||||
|
|
||||||
|
import com.google.gson.Gson;
|
||||||
|
import com.google.gson.GsonBuilder;
|
||||||
|
import com.google.gson.reflect.TypeToken;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.Reader;
|
||||||
|
import java.io.Writer;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.StandardCopyOption;
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.function.LongSupplier;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Homes, warps, /back, TPA and RTP, the same on every platform. Behaviour and wording match the Paper
|
||||||
|
* plugin. Call from the server thread; state is saved to one JSON file after every change.
|
||||||
|
*/
|
||||||
|
public final class EssentialsService {
|
||||||
|
private static final String GREEN = "§a", RED = "§c", YELLOW = "§e", GRAY = "§7";
|
||||||
|
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
|
||||||
|
|
||||||
|
private record Data(Map<String, Map<String, Pos>> homes, Map<String, Pos> warps) {}
|
||||||
|
|
||||||
|
private final Platform platform;
|
||||||
|
private final Path file;
|
||||||
|
private final LongSupplier clock;
|
||||||
|
private final Random rng;
|
||||||
|
private volatile EssentialsConfig config;
|
||||||
|
|
||||||
|
private final Map<UUID, Map<String, Pos>> homes = new ConcurrentHashMap<>();
|
||||||
|
private final Map<String, Pos> warps = new ConcurrentHashMap<>();
|
||||||
|
private final Map<UUID, Pos> lastLocations = new ConcurrentHashMap<>();
|
||||||
|
/** target -> (requester, sent at) */
|
||||||
|
private final Map<UUID, Map.Entry<UUID, Long>> pendingTpa = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
public EssentialsService(Platform platform, Path file, EssentialsConfig config, LongSupplier clock, Random rng) {
|
||||||
|
this.platform = platform;
|
||||||
|
this.file = file;
|
||||||
|
this.config = config;
|
||||||
|
this.clock = clock;
|
||||||
|
this.rng = rng;
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void configure(EssentialsConfig next) { this.config = next; }
|
||||||
|
|
||||||
|
// ---- persistence -------------------------------------------------------
|
||||||
|
|
||||||
|
private void load() {
|
||||||
|
if (!Files.exists(file)) return;
|
||||||
|
try (Reader in = Files.newBufferedReader(file)) {
|
||||||
|
Data data = GSON.fromJson(in, new TypeToken<Data>() {}.getType());
|
||||||
|
if (data == null) return;
|
||||||
|
if (data.homes() != null) data.homes().forEach((id, h) -> {
|
||||||
|
try { homes.put(UUID.fromString(id), new HashMap<>(h)); } catch (IllegalArgumentException ignored) { }
|
||||||
|
});
|
||||||
|
if (data.warps() != null) warps.putAll(data.warps());
|
||||||
|
} catch (IOException | RuntimeException e) {
|
||||||
|
throw new IllegalStateException("Could not read " + file + ": " + e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void save() {
|
||||||
|
Map<String, Map<String, Pos>> out = new TreeMap<>();
|
||||||
|
homes.forEach((id, h) -> { if (!h.isEmpty()) out.put(id.toString(), new TreeMap<>(h)); });
|
||||||
|
try {
|
||||||
|
Path tmp = file.resolveSibling(file.getFileName() + ".tmp");
|
||||||
|
if (file.getParent() != null) Files.createDirectories(file.getParent());
|
||||||
|
try (Writer w = Files.newBufferedWriter(tmp)) { GSON.toJson(new Data(out, new TreeMap<>(warps)), w); }
|
||||||
|
Files.move(tmp, file, StandardCopyOption.REPLACE_EXISTING);
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new IllegalStateException("Could not save " + file + ": " + e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- location memory for /back ----------------------------------------
|
||||||
|
|
||||||
|
/** Platforms call this before a teleport and on death. */
|
||||||
|
public void remember(UUID player, Pos where) { lastLocations.put(player, where); }
|
||||||
|
|
||||||
|
// ---- homes -------------------------------------------------------------
|
||||||
|
|
||||||
|
public List<String> homeNames(UUID player) {
|
||||||
|
return new ArrayList<>(new TreeSet<>(homes.getOrDefault(player, Map.of()).keySet()));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Optional<Pos> homeAt(UUID player, String name) {
|
||||||
|
return Optional.ofNullable(homes.getOrDefault(player, Map.of()).get(name.toLowerCase(Locale.ROOT)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** {@code /home [name]}. Returns the homes to choose from when several exist and no name was given. */
|
||||||
|
public List<String> home(CorePlayer p, String[] args) {
|
||||||
|
Map<String, Pos> mine = homes.getOrDefault(p.uuid(), Map.of());
|
||||||
|
if (args.length == 0) {
|
||||||
|
if (mine.isEmpty()) { p.send(RED + "You have no homes set! Set one with " + YELLOW + "/sethome [name]"); return List.of(); }
|
||||||
|
if (mine.size() == 1) { go(p, mine.values().iterator().next()); p.send(GREEN + "Teleported to home."); return List.of(); }
|
||||||
|
return homeNames(p.uuid());
|
||||||
|
}
|
||||||
|
String name = args[0].toLowerCase(Locale.ROOT);
|
||||||
|
Pos pos = mine.get(name);
|
||||||
|
if (pos == null) {
|
||||||
|
p.send(RED + "Home '" + name + "' not found. Your homes: " + YELLOW + String.join(", ", homeNames(p.uuid())));
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
go(p, pos);
|
||||||
|
p.send(GREEN + "Teleported to home " + YELLOW + name + GREEN + ".");
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
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.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
mine.put(name, p.pos());
|
||||||
|
save();
|
||||||
|
p.send(GREEN + "Home " + YELLOW + name + GREEN + " set successfully.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void delHome(CorePlayer p, String[] args) {
|
||||||
|
String name = args.length > 0 ? args[0].toLowerCase(Locale.ROOT) : "home";
|
||||||
|
Map<String, Pos> mine = homes.get(p.uuid());
|
||||||
|
if (mine == null || mine.remove(name) == null) { p.send(RED + "Home '" + name + "' does not exist."); return; }
|
||||||
|
save();
|
||||||
|
p.send(GREEN + "Home " + YELLOW + name + GREEN + " deleted.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- warps -------------------------------------------------------------
|
||||||
|
|
||||||
|
public List<String> warpNames() { return new ArrayList<>(new TreeSet<>(warps.keySet())); }
|
||||||
|
|
||||||
|
public Optional<Pos> warpAt(String name) { return Optional.ofNullable(warps.get(name.toLowerCase(Locale.ROOT))); }
|
||||||
|
|
||||||
|
/** {@code /warp [name]}. With no name, returns the list for a picker (seeding "spawn" if empty). */
|
||||||
|
public List<String> warp(CorePlayer p, String[] args) {
|
||||||
|
if (args.length == 0) {
|
||||||
|
if (warps.isEmpty()) { warps.put("spawn", platform.worldSpawn(p.pos().world())); save(); }
|
||||||
|
return warpNames();
|
||||||
|
}
|
||||||
|
String name = args[0].toLowerCase(Locale.ROOT);
|
||||||
|
Pos pos = warps.get(name);
|
||||||
|
if (pos == null) { p.send(RED + "Warp '" + name + "' does not exist."); return List.of(); }
|
||||||
|
go(p, pos);
|
||||||
|
p.send(GREEN + "Teleported to warp " + YELLOW + name + GREEN + ".");
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setWarp(CorePlayer p, String name) {
|
||||||
|
warps.put(name.toLowerCase(Locale.ROOT), p.pos());
|
||||||
|
save();
|
||||||
|
p.send(GREEN + "Warp " + YELLOW + name.toLowerCase(Locale.ROOT) + GREEN + " set.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void delWarp(CorePlayer p, String name) {
|
||||||
|
if (warps.remove(name.toLowerCase(Locale.ROOT)) == null) { p.send(RED + "Warp '" + name + "' does not exist."); return; }
|
||||||
|
save();
|
||||||
|
p.send(GREEN + "Warp " + YELLOW + name.toLowerCase(Locale.ROOT) + GREEN + " deleted.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- back --------------------------------------------------------------
|
||||||
|
|
||||||
|
public void back(CorePlayer p) {
|
||||||
|
Pos last = lastLocations.get(p.uuid());
|
||||||
|
if (last == null) { p.send(RED + "No previous location found to return to."); return; }
|
||||||
|
go(p, last);
|
||||||
|
p.send(GREEN + "Teleported back to your previous location.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- tpa ---------------------------------------------------------------
|
||||||
|
|
||||||
|
public void tpa(CorePlayer p, String[] args) {
|
||||||
|
if (args.length < 1) { p.send(RED + "Usage: /tpa <player>"); return; }
|
||||||
|
Optional<CorePlayer> found = platform.playerByName(args[0]);
|
||||||
|
if (found.isEmpty()) { p.send(RED + "Player '" + args[0] + "' is not online."); return; }
|
||||||
|
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()));
|
||||||
|
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
|
||||||
|
+ " to deny (expires in " + config.tpaTimeoutMs() / 1000 + "s).");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void tpAccept(CorePlayer p) {
|
||||||
|
Map.Entry<UUID, Long> req = pendingTpa.get(p.uuid());
|
||||||
|
if (req == null || clock.getAsLong() - req.getValue() > config.tpaTimeoutMs()) {
|
||||||
|
pendingTpa.remove(p.uuid());
|
||||||
|
p.send(RED + "You have no active teleport requests.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pendingTpa.remove(p.uuid());
|
||||||
|
Optional<CorePlayer> requester = platform.player(req.getKey());
|
||||||
|
if (requester.isEmpty()) { p.send(RED + "The player who requested to teleport is no longer online."); return; }
|
||||||
|
go(requester.get(), p.pos());
|
||||||
|
requester.get().send(GREEN + "Teleport request accepted by " + YELLOW + p.name() + GREEN + "!");
|
||||||
|
p.send(GREEN + "Accepted teleport request from " + YELLOW + requester.get().name() + GREEN + ".");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void tpDeny(CorePlayer p) {
|
||||||
|
Map.Entry<UUID, Long> req = pendingTpa.remove(p.uuid());
|
||||||
|
if (req == null) { p.send(RED + "You have no active teleport requests."); return; }
|
||||||
|
platform.player(req.getKey()).ifPresent(r -> r.send(RED + p.name() + " denied your teleport request."));
|
||||||
|
p.send(YELLOW + "Teleport request denied.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- rtp ---------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Must run on the server thread, because it reads chunks. */
|
||||||
|
public void rtp(CorePlayer p) {
|
||||||
|
int radius = config.rtpRadius();
|
||||||
|
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;
|
||||||
|
Optional<Pos> spot = platform.safeSurface(world, x, z);
|
||||||
|
if (spot.isPresent()) {
|
||||||
|
Pos at = spot.get();
|
||||||
|
go(p, new Pos(world, x + 0.5, at.y(), z + 0.5, p.pos().yaw(), p.pos().pitch()));
|
||||||
|
p.send(GREEN + "Wilderness RTP: Teleported to " + YELLOW + x + ", " + at.blockY() + ", " + z + GREEN + "!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.send(RED + "Could not find a safe RTP spot. Please try again.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void go(CorePlayer p, Pos destination) {
|
||||||
|
remember(p.uuid(), p.pos());
|
||||||
|
p.teleport(destination);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package net.scopenet.core;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/** The server side of the adapter: who is online and what the world looks like. */
|
||||||
|
public interface Platform {
|
||||||
|
Optional<CorePlayer> player(UUID uuid);
|
||||||
|
Optional<CorePlayer> playerByName(String name);
|
||||||
|
/**
|
||||||
|
* The spot to stand on at (x, z): the top solid, non-liquid block. Empty if unsafe or unloadable.
|
||||||
|
* Called on the server thread.
|
||||||
|
*/
|
||||||
|
Optional<Pos> safeSurface(String world, int x, int z);
|
||||||
|
Pos worldSpawn(String world);
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package net.scopenet.core;
|
||||||
|
|
||||||
|
/** A spot in a world, independent of any server software. {@code world} is the dimension id, e.g. "minecraft:overworld". */
|
||||||
|
public record Pos(String world, double x, double y, double z, float yaw, float pitch) {
|
||||||
|
public int blockX() { return (int) Math.floor(x); }
|
||||||
|
public int blockY() { return (int) Math.floor(y); }
|
||||||
|
public int blockZ() { return (int) Math.floor(z); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
package net.scopenet.core;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
class EssentialsServiceTest {
|
||||||
|
static final class Fake implements CorePlayer {
|
||||||
|
final UUID id = UUID.randomUUID();
|
||||||
|
final String name;
|
||||||
|
Pos pos = new Pos("overworld", 0, 64, 0, 0, 0);
|
||||||
|
final List<String> inbox = new ArrayList<>();
|
||||||
|
Fake(String name) { this.name = name; }
|
||||||
|
public UUID uuid() { return id; }
|
||||||
|
public String name() { return name; }
|
||||||
|
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; }
|
||||||
|
String last() { return inbox.get(inbox.size() - 1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
final Map<UUID, Fake> online = new HashMap<>();
|
||||||
|
final AtomicLong now = new AtomicLong(1_000);
|
||||||
|
boolean safe = true;
|
||||||
|
@TempDir Path dir;
|
||||||
|
Platform platform = new Platform() {
|
||||||
|
public Optional<CorePlayer> player(UUID u) { return Optional.ofNullable(online.get(u)); }
|
||||||
|
public Optional<CorePlayer> playerByName(String n) { return online.values().stream().filter(p -> p.name.equalsIgnoreCase(n)).map(p -> (CorePlayer) p).findFirst(); }
|
||||||
|
public Optional<Pos> safeSurface(String w, int x, int z) { return safe ? Optional.of(new Pos(w, x, 70, z, 0, 0)) : Optional.empty(); }
|
||||||
|
public Pos worldSpawn(String w) { return new Pos(w, 0, 80, 0, 0, 0); }
|
||||||
|
};
|
||||||
|
Fake alex, steve;
|
||||||
|
EssentialsService svc;
|
||||||
|
|
||||||
|
@BeforeEach void setup() {
|
||||||
|
alex = new Fake("Alex"); steve = new Fake("Steve");
|
||||||
|
online.put(alex.id, alex); online.put(steve.id, steve);
|
||||||
|
svc = new EssentialsService(platform, dir.resolve("essentials.json"), EssentialsConfig.defaults(), now::get, new Random(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test void homesAreLimitedAndPersisted() {
|
||||||
|
svc.configure(new EssentialsConfig(2, 2500, 60_000, 15));
|
||||||
|
alex.pos = new Pos("overworld", 10, 64, 20, 90, 0);
|
||||||
|
svc.setHome(alex, new String[]{"Base"});
|
||||||
|
svc.setHome(alex, new String[]{"mine"});
|
||||||
|
svc.setHome(alex, new String[]{"third"});
|
||||||
|
assertTrue(alex.last().contains("limit of 2"));
|
||||||
|
svc.setHome(alex, new String[]{"base"}); // overwriting is allowed at the limit
|
||||||
|
assertEquals(List.of("base", "mine"), svc.homeNames(alex.id));
|
||||||
|
|
||||||
|
EssentialsService reloaded = new EssentialsService(platform, dir.resolve("essentials.json"), EssentialsConfig.defaults(), now::get, new Random(1));
|
||||||
|
assertEquals(10, reloaded.homeAt(alex.id, "BASE").orElseThrow().x());
|
||||||
|
alex.pos = new Pos("overworld", 0, 0, 0, 0, 0);
|
||||||
|
assertEquals(List.of("base", "mine"), reloaded.home(alex, new String[0]), "several homes need a choice");
|
||||||
|
reloaded.home(alex, new String[]{"base"});
|
||||||
|
assertEquals(10, alex.pos.x());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test void singleHomeTeleportsDirectlyAndDeleteWorks() {
|
||||||
|
svc.home(alex, new String[0]);
|
||||||
|
assertTrue(alex.last().contains("no homes"));
|
||||||
|
svc.setHome(alex, new String[0]);
|
||||||
|
alex.pos = new Pos("overworld", 5, 5, 5, 0, 0);
|
||||||
|
assertTrue(svc.home(alex, new String[0]).isEmpty());
|
||||||
|
assertEquals(0, alex.pos.x());
|
||||||
|
svc.delHome(alex, new String[]{"home"});
|
||||||
|
svc.delHome(alex, new String[]{"home"});
|
||||||
|
assertTrue(alex.last().contains("does not exist"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test void warpsSeedSpawnAndTeleport() {
|
||||||
|
assertEquals(List.of("spawn"), svc.warp(alex, new String[0]));
|
||||||
|
svc.warp(alex, new String[]{"spawn"});
|
||||||
|
assertEquals(80, alex.pos.y());
|
||||||
|
svc.warp(alex, new String[]{"nope"});
|
||||||
|
assertTrue(alex.last().contains("does not exist"));
|
||||||
|
svc.setWarp(alex, "Arena");
|
||||||
|
assertTrue(svc.warpAt("arena").isPresent());
|
||||||
|
svc.delWarp(alex, "arena");
|
||||||
|
assertTrue(svc.warpAt("arena").isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test void backReturnsToWhereYouWere() {
|
||||||
|
svc.back(alex);
|
||||||
|
assertTrue(alex.last().contains("No previous"));
|
||||||
|
alex.pos = new Pos("overworld", 1, 2, 3, 0, 0);
|
||||||
|
svc.setWarp(steve, "x"); // unrelated
|
||||||
|
svc.warp(alex, new String[]{"x"});
|
||||||
|
svc.back(alex);
|
||||||
|
assertEquals(1, alex.pos.x());
|
||||||
|
svc.remember(alex.id, new Pos("overworld", 9, 9, 9, 0, 0)); // death point
|
||||||
|
svc.back(alex);
|
||||||
|
assertEquals(9, alex.pos.x());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test void tpaFlowAndExpiry() {
|
||||||
|
steve.pos = new Pos("overworld", 100, 64, 100, 0, 0);
|
||||||
|
svc.tpa(alex, new String[]{"steve"});
|
||||||
|
assertTrue(steve.last().contains("expires in 60s"));
|
||||||
|
svc.tpAccept(steve);
|
||||||
|
assertEquals(100, alex.pos.x());
|
||||||
|
|
||||||
|
svc.tpa(alex, new String[]{"steve"});
|
||||||
|
now.addAndGet(61_000);
|
||||||
|
svc.tpAccept(steve);
|
||||||
|
assertTrue(steve.last().contains("no active"));
|
||||||
|
|
||||||
|
svc.tpa(alex, new String[]{"alex"});
|
||||||
|
assertTrue(alex.last().contains("yourself"));
|
||||||
|
svc.tpa(alex, new String[]{"ghost"});
|
||||||
|
assertTrue(alex.last().contains("not online"));
|
||||||
|
svc.tpa(alex, new String[]{"steve"});
|
||||||
|
svc.tpDeny(steve);
|
||||||
|
assertTrue(alex.last().contains("denied"));
|
||||||
|
svc.tpDeny(steve);
|
||||||
|
assertTrue(steve.last().contains("no active"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test void rtpStaysInRadiusAndFailsGracefully() {
|
||||||
|
svc.configure(new EssentialsConfig(5, 100, 60_000, 15));
|
||||||
|
svc.rtp(alex);
|
||||||
|
assertTrue(Math.abs(alex.pos.x()) <= 101 && Math.abs(alex.pos.z()) <= 101);
|
||||||
|
assertEquals(70, alex.pos.y());
|
||||||
|
Pos before = alex.pos;
|
||||||
|
safe = false;
|
||||||
|
svc.rtp(alex);
|
||||||
|
assertEquals(before, alex.pos);
|
||||||
|
assertTrue(alex.last().contains("Could not find"));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user