diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..aaea8f5 --- /dev/null +++ b/examples/README.md @@ -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. diff --git a/examples/fabric-client/scopenet-client.json b/examples/fabric-client/scopenet-client.json new file mode 100644 index 0000000..387042f --- /dev/null +++ b/examples/fabric-client/scopenet-client.json @@ -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" + } +} diff --git a/examples/fabric-server/scopenet.properties b/examples/fabric-server/scopenet.properties new file mode 100644 index 0000000..c1fa5d2 --- /dev/null +++ b/examples/fabric-server/scopenet.properties @@ -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 diff --git a/examples/paper/config.yml b/examples/paper/config.yml new file mode 100644 index 0000000..c903c58 --- /dev/null +++ b/examples/paper/config.yml @@ -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 diff --git a/integrations/common/src/main/java/net/scopenet/core/CorePlayer.java b/integrations/common/src/main/java/net/scopenet/core/CorePlayer.java new file mode 100644 index 0000000..0266609 --- /dev/null +++ b/integrations/common/src/main/java/net/scopenet/core/CorePlayer.java @@ -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); +} diff --git a/integrations/common/src/main/java/net/scopenet/core/EssentialsConfig.java b/integrations/common/src/main/java/net/scopenet/core/EssentialsConfig.java new file mode 100644 index 0000000..8044839 --- /dev/null +++ b/integrations/common/src/main/java/net/scopenet/core/EssentialsConfig.java @@ -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); } +} diff --git a/integrations/common/src/main/java/net/scopenet/core/EssentialsService.java b/integrations/common/src/main/java/net/scopenet/core/EssentialsService.java new file mode 100644 index 0000000..45d1cf6 --- /dev/null +++ b/integrations/common/src/main/java/net/scopenet/core/EssentialsService.java @@ -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> homes, Map warps) {} + + private final Platform platform; + private final Path file; + private final LongSupplier clock; + private final Random rng; + private volatile EssentialsConfig config; + + private final Map> homes = new ConcurrentHashMap<>(); + private final Map warps = new ConcurrentHashMap<>(); + private final Map lastLocations = new ConcurrentHashMap<>(); + /** target -> (requester, sent at) */ + private final Map> 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() {}.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> 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 homeNames(UUID player) { + return new ArrayList<>(new TreeSet<>(homes.getOrDefault(player, Map.of()).keySet())); + } + + public Optional 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 home(CorePlayer p, String[] args) { + Map 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 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 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 warpNames() { return new ArrayList<>(new TreeSet<>(warps.keySet())); } + + public Optional 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 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 "); return; } + Optional 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 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 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 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 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); + } +} diff --git a/integrations/common/src/main/java/net/scopenet/core/Platform.java b/integrations/common/src/main/java/net/scopenet/core/Platform.java new file mode 100644 index 0000000..52e91f6 --- /dev/null +++ b/integrations/common/src/main/java/net/scopenet/core/Platform.java @@ -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 player(UUID uuid); + Optional 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 safeSurface(String world, int x, int z); + Pos worldSpawn(String world); +} diff --git a/integrations/common/src/main/java/net/scopenet/core/Pos.java b/integrations/common/src/main/java/net/scopenet/core/Pos.java new file mode 100644 index 0000000..8b67a0a --- /dev/null +++ b/integrations/common/src/main/java/net/scopenet/core/Pos.java @@ -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); } +} diff --git a/integrations/common/src/test/java/net/scopenet/core/EssentialsServiceTest.java b/integrations/common/src/test/java/net/scopenet/core/EssentialsServiceTest.java new file mode 100644 index 0000000..251b958 --- /dev/null +++ b/integrations/common/src/test/java/net/scopenet/core/EssentialsServiceTest.java @@ -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 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 online = new HashMap<>(); + final AtomicLong now = new AtomicLong(1_000); + boolean safe = true; + @TempDir Path dir; + Platform platform = new Platform() { + public Optional player(UUID u) { return Optional.ofNullable(online.get(u)); } + public Optional playerByName(String n) { return online.values().stream().filter(p -> p.name.equalsIgnoreCase(n)).map(p -> (CorePlayer) p).findFirst(); } + public Optional 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")); + } +}