diff --git a/README.md b/README.md index 24e30c2..ba3728e 100644 --- a/README.md +++ b/README.md @@ -1,58 +1,64 @@ # Spawn-OG -**Spawn-OG** is a Minecraft server plugin that allows server administrators to set world spawn points and manage player teleportation to those spawns. It provides functionality similar to EssentialsXSpawn – such as setting and teleporting to spawn points, and first-join spawn control, with additional configuration options from TrueOG Network. Spawn-OG works in conjunction with the [Essentials-OG](https://github.com/true-og/Essentials-OG) fork of EssentialsX. It handles the spawn-related features originally handled by EssentialsSpawn. This is one small step towards a post-Essentials TrueOG Network. +Spawn-OG owns safe player entry, spawn and respawn behavior, and TrueOG's +WorldGuard-based regional flight policy. It replaces EssentialsXSpawn and the +flight and gamemode management portions of legacy WGamemode-OG on Purpur 1.19.4. -## Features +## Login safety -- **Set Spawn Points:** -```/setspawn``` -- **Teleport to Spawn:** -```/spawn``` -- **Teleport Others to Spawn:** -```/spawn `. + +This replaces WGamemode-OG's `stopItemDrop` behavior with an explicit region +policy that applies consistently whether or not the player has toggled flight. ## Commands -- **`/spawn`** -Teleport yourself to the defined spawn location. If used by a player, it sends that player to the spawn (global or their group’s spawn):contentReference[oaicite:21]{index=21}. If used from console or by an admin with a target, see below. -- **`/spawn `** -Teleport the specified player to spawn. The target player will be sent to the spawn point, provided the command executor has permission to send others. The target will receive a message (e.g. “<*Player*> teleported you to spawn.”). -- **`/setspawn`** -Set the spawn point for the current world/group to your current location. With no arguments, this sets the “default” global spawn point:contentReference[oaicite:22]{index=22}. This is typically the main spawn for your server (and is used for all players unless group-specific spawns are set). -- **`/setspawn `** -Set a spawn point for a specific group name. For example, `/setspawn newbie` will set the spawn location used for new players (if your config’s `newbies.spawnpoint` is “newbie”), or `/setspawn builders` could set a special spawn for the “builders” permission group. +- `/spawn` — Teleport to the global or LuckPerms-group spawn after a five-second + warmup. +- `/spawn ` — Teleport another player to their resolved spawn. +- `/setspawn [group] [normalize-view] [normalize-position]` — Configure a spawn. +- `/fly` — Toggle flight inside a configured `fly` region. ## Permissions -- **`essentials.spawn`** – Allows use of the `/spawn` command to teleport oneself to spawn. *Default:* true. -- **`essentials.spawn.others`** – Allows sending *other players* to spawn with `/spawn `. *Default:* false. -- **`essentials.setspawn`** – Allows use of `/setspawn` to define spawn points. *Default:* false. - -## Prerequisites - -[Essentials-OG](https://github.com/true-og/Essentials-OG) +- `spawnog.spawn` — Use `/spawn`. +- `spawnog.spawn.others` — Use `/spawn `. +- `spawnog.setspawn` — Configure spawns. +- `spawnog.login-migration.bypass` — Explicitly exempt staff from login + migration and normalization. +- `spawnog.flight` — Use regional `/fly`; granted by default. +- `spawnog.flight.bypass` — Ignore `nofly` regions; not granted by default. ## Building -Use the TrueOG Bootstrap to build automatically, or run: +Use the TrueOG bootstrap, or run: -```./gradlew clean build eclipse``` - -## TODO: - -- Cancel teleport if a player is in combat - -- Cancel teleport if a player moves - -## Licese - -[MIT](https://raw.githubusercontent.com/true-og/Spawn-OG/refs/heads/main/LICENSE) +```sh +./gradlew clean build eclipse --warning-mode all +``` diff --git a/build.gradle.kts b/build.gradle.kts index 591f4a9..870f610 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -21,7 +21,7 @@ java { /* ----------------------------- Metadata ------------------------------ */ group = "net.trueog.spawn-og" // Declare bundle identifier. -version = "1.0" // Declare plugin version (will be in .jar). +version = "1.1" // Declare plugin version (will be in .jar). val apiVersion = "1.19" // Declare minecraft server target version. @@ -39,12 +39,15 @@ repositories { gradlePluginPortal() // Import the Gradle Plugin Portal Maven Repository. maven { url = uri("https://repo.purpurmc.org/snapshots") } // Import the PurpurMC Maven Repository. maven { url = uri("https://repo.codemc.io/repository/maven-public/") } // Import the CodeMC Maven Repository. + maven { url = uri("https://maven.enginehub.org/repo/") } // Import the EngineHub Maven Repository. } /* ---------------------- Java project deps ---------------------------- */ dependencies { compileOnly("org.purpurmc.purpur:purpur-api:1.19.4-R0.1-SNAPSHOT") // Declare Purpur API version to be packaged. compileOnly("net.luckperms:api:5.4") // Import LuckPerms API. + compileOnly("com.sk89q.worldedit:worldedit-bukkit:7.3.0-SNAPSHOT") + compileOnly("com.sk89q.worldguard:worldguard-bukkit:7.0.8") { exclude(group = "com.sk89q.worldedit") } } /* ---------------------- Reproducible jars ---------------------------- */ diff --git a/src/main/java/spawnog/SpawnOG.java b/src/main/java/spawnog/SpawnOG.java index 638f969..8a7fbe1 100644 --- a/src/main/java/spawnog/SpawnOG.java +++ b/src/main/java/spawnog/SpawnOG.java @@ -7,6 +7,10 @@ import org.bukkit.plugin.RegisteredServiceProvider; import org.bukkit.plugin.java.JavaPlugin; import spawnog.commands.SetSpawnCommand; import spawnog.commands.SpawnCommand; +import spawnog.flight.RegionFlightService; +import spawnog.flight.ToggleRegionFlightCommand; +import spawnog.login.AutopsyMigrationStore; +import spawnog.login.LoginMigrationService; import spawnog.listener.SpawnListener; @Slf4j @@ -18,6 +22,9 @@ public final class SpawnOG extends JavaPlugin { @Getter private LuckPerms luckPerms; + private LoginMigrationService loginMigrationService; + private RegionFlightService regionFlightService; + @Override public void onEnable() { @@ -28,10 +35,37 @@ public final class SpawnOG extends JavaPlugin { if (rsp != null) luckPerms = rsp.getProvider(); - register("spawn", new SpawnCommand(), "essentials.spawn"); - register("setspawn", new SetSpawnCommand(), "essentials.setspawn"); + register("spawn", new SpawnCommand(), "spawnog.spawn"); + register("setspawn", new SetSpawnCommand(), "spawnog.setspawn"); - getServer().getPluginManager().registerEvents(new SpawnListener(), this); + AutopsyMigrationStore migrationStore = new AutopsyMigrationStore(this); + loginMigrationService = new LoginMigrationService(this, migrationStore); + getServer().getPluginManager().registerEvents(new SpawnListener(this, loginMigrationService), this); + + if (getConfig().getBoolean("flight.enabled", true) + && getServer().getPluginManager().isPluginEnabled("WorldGuard") + && getServer().getPluginManager().isPluginEnabled("WorldEdit")) + { + + regionFlightService = new RegionFlightService(this, loginMigrationService); + getServer().getPluginManager().registerEvents(regionFlightService, this); + getCommand("fly").setExecutor(new ToggleRegionFlightCommand(regionFlightService)); + + } else if (getConfig().getBoolean("flight.enabled", true)) { + + getLogger().warning("WorldGuard or WorldEdit is unavailable; regional flight management is disabled."); + + } + + } + + @Override + public void onDisable() { + + if (loginMigrationService != null) + loginMigrationService.close(); + if (regionFlightService != null) + regionFlightService.close(); } diff --git a/src/main/java/spawnog/commands/SpawnCommand.java b/src/main/java/spawnog/commands/SpawnCommand.java index 8ebc526..e77b5ff 100644 --- a/src/main/java/spawnog/commands/SpawnCommand.java +++ b/src/main/java/spawnog/commands/SpawnCommand.java @@ -51,7 +51,7 @@ public class SpawnCommand implements CommandExecutor, TabCompleter { } - if (!sender.hasPermission("essentials.spawn.others")) { + if (!sender.hasPermission("spawnog.spawn.others")) { sender.sendMessage(cfg.getString("locale.missingPermission", "You are lacking the required permissions.")); return true; @@ -84,7 +84,7 @@ public class SpawnCommand implements CommandExecutor, TabCompleter { @NotNull String lbl, @NotNull String[] args) { - if (args.length == 1 && sender.hasPermission("essentials.spawn.others")) { + if (args.length == 1 && sender.hasPermission("spawnog.spawn.others")) { return Bukkit.getOnlinePlayers().stream().map(Player::getName) .filter(n -> n.toLowerCase().startsWith(args[0].toLowerCase())).collect(Collectors.toList()); diff --git a/src/main/java/spawnog/flight/RegionFlightService.java b/src/main/java/spawnog/flight/RegionFlightService.java new file mode 100644 index 0000000..c48a228 --- /dev/null +++ b/src/main/java/spawnog/flight/RegionFlightService.java @@ -0,0 +1,491 @@ +package spawnog.flight; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +import com.sk89q.worldedit.bukkit.BukkitAdapter; +import com.sk89q.worldedit.util.Location; +import com.sk89q.worldguard.WorldGuard; +import com.sk89q.worldguard.protection.ApplicableRegionSet; +import com.sk89q.worldguard.protection.regions.ProtectedRegion; +import com.sk89q.worldguard.protection.regions.RegionContainer; +import com.sk89q.worldguard.protection.regions.RegionQuery; +import org.bukkit.Color; +import org.bukkit.Particle; +import org.bukkit.block.Block; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.entity.EntityDamageEvent; +import org.bukkit.event.player.PlayerChangedWorldEvent; +import org.bukkit.event.player.PlayerJoinEvent; +import org.bukkit.event.player.PlayerKickEvent; +import org.bukkit.event.player.PlayerMoveEvent; +import org.bukkit.event.player.PlayerQuitEvent; +import org.bukkit.event.player.PlayerRespawnEvent; +import org.bukkit.event.player.PlayerTeleportEvent; +import org.bukkit.util.Vector; + +import spawnog.SpawnOG; +import spawnog.login.LoginMigrationService; + +public final class RegionFlightService implements Listener { + + public static final String FLIGHT_PERMISSION = "spawnog.flight"; + public static final String LEGACY_FLIGHT_PERMISSION = "wgamemode.fly"; + public static final String BYPASS_PERMISSION = "spawnog.flight.bypass"; + public static final String LEGACY_BYPASS_PERMISSION = "wgamemode.fly.bypass"; + + private static final long PARTICLE_INITIAL_DELAY_TICKS = 10L; + private static final long PARTICLE_PERIOD_TICKS = 6L; + private static final double PARTICLE_RADIUS = 0.12D; + private static final double PARTICLE_Y_OFFSET = -0.12D; + private static final double PARTICLE_BACK_OFFSET = 0.45D; + private static final Color[] RAINBOW_TRAIL_COLORS = { Color.fromRGB(255, 82, 82), Color.fromRGB(255, 171, 64), + Color.fromRGB(255, 238, 88), Color.fromRGB(105, 240, 174), Color.fromRGB(64, 196, 255), + Color.fromRGB(124, 77, 255), Color.fromRGB(255, 64, 129) }; + + private final SpawnOG plugin; + private final LoginMigrationService loginMigrationService; + private final Map regionRules = new HashMap<>(); + private final Map flightOverrides = new HashMap<>(); + private final Set fallImmunity = new HashSet<>(); + private final Set scheduledRefreshes = new HashSet<>(); + private int particleStep; + + public RegionFlightService(SpawnOG plugin, LoginMigrationService loginMigrationService) { + + this.plugin = plugin; + this.loginMigrationService = loginMigrationService; + loadRules(); + plugin.getServer().getScheduler().runTaskTimer(plugin, this::spawnFlightParticles, PARTICLE_INITIAL_DELAY_TICKS, + PARTICLE_PERIOD_TICKS); + + } + + public boolean canToggleFlight(Player player) { + + if (player == null + || !(player.hasPermission(FLIGHT_PERMISSION) || player.hasPermission(LEGACY_FLIGHT_PERMISSION))) + return false; + RegionRule rule = currentRule(player); + return rule != null && rule.kind() == RuleKind.FLY; + + } + + public boolean toggleFlight(Player player) { + + RegionRule rule = currentRule(player); + if (rule == null || rule.kind() != RuleKind.FLY) + return player.getAllowFlight(); + + UUID playerId = player.getUniqueId(); + FlightOverride previous = flightOverrides.get(playerId); + boolean originalAllowFlight; + if (previous == null || previous.kind() != RuleKind.FLY) { + + restoreOverride(player, previous); + originalAllowFlight = player.getAllowFlight(); + + } else { + + originalAllowFlight = previous.originalAllowFlight(); + + } + + boolean enable = !player.getAllowFlight(); + if (enable) { + + player.setAllowFlight(true); + + } else { + + stopFlyingSafely(player); + player.setAllowFlight(false); + + } + + if (player.getAllowFlight() == originalAllowFlight) { + + flightOverrides.remove(playerId); + + } else { + + flightOverrides.put(playerId, + new FlightOverride(originalAllowFlight, player.getAllowFlight(), rule.regionId(), RuleKind.FLY)); + + } + + return player.getAllowFlight(); + + } + + public void refresh(Player player) { + + if (player == null || !player.isOnline()) + return; + if (loginMigrationService.isPending(player)) { + + scheduleRefresh(player); + return; + + } + + UUID playerId = player.getUniqueId(); + RegionRule rule = currentRule(player); + FlightOverride previous = flightOverrides.get(playerId); + + if (rule == null) { + + restoreOverride(player, previous); + return; + + } + + if (rule.kind() == RuleKind.FLY) { + + if (previous != null && previous.kind() == RuleKind.NOFLY) + restoreOverride(player, previous); + previous = flightOverrides.get(playerId); + if (previous != null && previous.kind() == RuleKind.FLY) { + + if (player.getAllowFlight() != previous.appliedAllowFlight()) { + + if (!previous.appliedAllowFlight()) + stopFlyingSafely(player); + player.setAllowFlight(previous.appliedAllowFlight()); + + } + + if (!previous.regionId().equals(rule.regionId())) + flightOverrides.put(playerId, new FlightOverride(previous.originalAllowFlight(), + previous.appliedAllowFlight(), rule.regionId(), RuleKind.FLY)); + + } + + return; + + } + + if (hasBypass(player)) { + + restoreOverride(player, previous); + return; + + } + + if (previous != null && previous.kind() == RuleKind.FLY) { + + restoreOverride(player, previous); + previous = null; + + } + + if (previous == null) { + + previous = new FlightOverride(player.getAllowFlight(), false, rule.regionId(), RuleKind.NOFLY); + flightOverrides.put(playerId, previous); + + } else if (previous != null && previous.kind() == RuleKind.NOFLY + && !previous.regionId().equals(rule.regionId())) + { + + flightOverrides.put(playerId, + new FlightOverride(previous.originalAllowFlight(), false, rule.regionId(), RuleKind.NOFLY)); + + } + + if (player.getAllowFlight()) { + + stopFlyingSafely(player); + player.setAllowFlight(false); + + } + + } + + public void restore(Player player) { + + if (player == null) + return; + restoreOverride(player, flightOverrides.get(player.getUniqueId())); + fallImmunity.remove(player.getUniqueId()); + + } + + public void close() { + + Set.copyOf(flightOverrides.keySet()).forEach(playerId -> { + + Player player = plugin.getServer().getPlayer(playerId); + if (player != null) + restoreOverride(player, flightOverrides.get(playerId)); + + }); + flightOverrides.clear(); + fallImmunity.clear(); + scheduledRefreshes.clear(); + + } + + @EventHandler + public void onJoin(PlayerJoinEvent event) { + + scheduleRefresh(event.getPlayer()); + + } + + @EventHandler + public void onTeleport(PlayerTeleportEvent event) { + + scheduleRefresh(event.getPlayer()); + + } + + @EventHandler + public void onChangedWorld(PlayerChangedWorldEvent event) { + + // Essentials-OG resets flight during this same event. Reconcile one tick + // later so Spawn-OG remains the final authority for regional flight. + scheduleRefresh(event.getPlayer()); + + } + + @EventHandler + public void onRespawn(PlayerRespawnEvent event) { + + scheduleRefresh(event.getPlayer()); + + } + + @EventHandler + public void onMove(PlayerMoveEvent event) { + + if (event.getTo() == null) + return; + + Player player = event.getPlayer(); + boolean sameBlock = event.getFrom().getWorld() == event.getTo().getWorld() + && event.getFrom().getBlockX() == event.getTo().getBlockX() + && event.getFrom().getBlockY() == event.getTo().getBlockY() + && event.getFrom().getBlockZ() == event.getTo().getBlockZ(); + if (!sameBlock) + refresh(player); + + if (fallImmunity.contains(player.getUniqueId()) && isOnSafeSurface(player) && !player.isFlying()) + fallImmunity.remove(player.getUniqueId()); + + } + + @EventHandler + public void onQuit(PlayerQuitEvent event) { + + restore(event.getPlayer()); + + } + + @EventHandler + public void onKick(PlayerKickEvent event) { + + restore(event.getPlayer()); + + } + + @EventHandler + public void onDamage(EntityDamageEvent event) { + + if (event.getCause() != EntityDamageEvent.DamageCause.FALL || !(event.getEntity() instanceof Player player)) + return; + if (fallImmunity.remove(player.getUniqueId())) + event.setCancelled(true); + + } + + private void loadRules() { + + if (plugin.getConfig().getConfigurationSection("flight.regions") == null) { + + regionRules.put("spawn", RuleKind.FLY); + regionRules.put("warzone", RuleKind.NOFLY); + return; + + } + + plugin.getConfig().getConfigurationSection("flight.regions").getValues(false).forEach((regionId, value) -> { + + RuleKind kind; + try { + + kind = RuleKind.valueOf(String.valueOf(value).toUpperCase(Locale.ROOT)); + + } catch (IllegalArgumentException error) { + + throw new IllegalArgumentException( + "Invalid flight rule for region '" + regionId + "'. Expected fly or nofly.", error); + + } + + regionRules.put(regionId.toLowerCase(Locale.ROOT), kind); + + }); + + } + + private RegionRule currentRule(Player player) { + + RegionContainer container = WorldGuard.getInstance().getPlatform().getRegionContainer(); + RegionQuery query = container.createQuery(); + Location location = BukkitAdapter.adapt(player.getLocation()); + ApplicableRegionSet applicable = query.getApplicableRegions(location); + RegionRule best = null; + + for (ProtectedRegion region : applicable.getRegions()) { + + String regionId = region.getId().toLowerCase(Locale.ROOT); + RuleKind kind = regionRules.get(regionId); + if (kind == null) + continue; + + RegionRule candidate = new RegionRule(regionId, kind, region.getPriority()); + if (best == null || candidate.priority() > best.priority() + || candidate.priority() == best.priority() && candidate.regionId().compareTo(best.regionId()) < 0) + best = candidate; + + } + + return best; + + } + + private boolean hasBypass(Player player) { + + return player.hasPermission(BYPASS_PERMISSION) || player.hasPermission(LEGACY_BYPASS_PERMISSION); + + } + + private void restoreOverride(Player player, FlightOverride state) { + + if (state == null) + return; + if (player.isFlying() && !state.originalAllowFlight()) + stopFlyingSafely(player); + if (player.getAllowFlight() != state.originalAllowFlight()) + player.setAllowFlight(state.originalAllowFlight()); + flightOverrides.remove(player.getUniqueId()); + + } + + private void stopFlyingSafely(Player player) { + + if (player.isFlying()) { + + fallImmunity.add(player.getUniqueId()); + player.setFlying(false); + + } + + } + + private boolean isOnSafeSurface(Player player) { + + org.bukkit.Location feet = player.getLocation(); + return isSafeSurface(feet.clone().subtract(0.0D, 0.08D, 0.0D)) + || isSafeSurface(feet.clone().subtract(0.0D, 0.51D, 0.0D)); + + } + + private boolean isSafeSurface(org.bukkit.Location location) { + + Block block = location.getBlock(); + return block.isLiquid() || !block.isPassable(); + + } + + private void scheduleRefresh(Player player) { + + UUID playerId = player.getUniqueId(); + if (!scheduledRefreshes.add(playerId)) + return; + plugin.getServer().getScheduler().runTask(plugin, () -> { + + scheduledRefreshes.remove(playerId); + refresh(player); + + }); + + } + + private void spawnFlightParticles() { + + particleStep++; + plugin.getServer().getOnlinePlayers().forEach(player -> { + + FlightOverride state = flightOverrides.get(player.getUniqueId()); + if (state == null || state.kind() != RuleKind.FLY || !player.getAllowFlight() || !player.isFlying()) + return; + + org.bukkit.Location location = flightParticleLocation(player); + if (hasBypass(player)) + spawnRainbowParticles(player, location); + else + player.getWorld().spawnParticle(Particle.CLOUD, location, 4, PARTICLE_RADIUS, 0.025D, PARTICLE_RADIUS, + 0.004D); + + }); + + } + + private org.bukkit.Location flightParticleLocation(Player player) { + + org.bukkit.Location feet = player.getLocation(); + Vector behind = feet.getDirection(); + behind.setY(0.0D); + if (behind.lengthSquared() > 0.0D) + feet.add(behind.normalize().multiply(-PARTICLE_BACK_OFFSET)); + return feet.add(0.0D, PARTICLE_Y_OFFSET, 0.0D); + + } + + private void spawnRainbowParticles(Player player, org.bukkit.Location feet) { + + int count = 12; + double radius = 0.18D; + Vector trailDirection = player.getLocation().getDirection(); + trailDirection.setY(0.0D); + if (trailDirection.lengthSquared() > 0.0D) + trailDirection.normalize().multiply(-0.14D); + else + trailDirection = null; + + for (int i = 0; i < count; i++) { + + int colorIndex = Math.floorMod(particleStep + i, RAINBOW_TRAIL_COLORS.length); + double angle = particleStep * 0.42D + i * Math.PI * 2.0D / count; + org.bukkit.Location particleLocation = feet.clone().add(Math.cos(angle) * radius, + Math.sin(angle * 2.0D) * 0.04D, Math.sin(angle) * radius); + if (trailDirection != null) + particleLocation.add(trailDirection.clone().multiply(i % 4)); + Particle.DustOptions dust = new Particle.DustOptions(RAINBOW_TRAIL_COLORS[colorIndex], 0.85F); + player.getWorld().spawnParticle(Particle.REDSTONE, particleLocation, 1, 0.015D, 0.015D, 0.015D, 0.0D, dust); + + } + + } + + private enum RuleKind { + FLY, NOFLY + } + + private record RegionRule(String regionId, RuleKind kind, int priority) { + } + + private record FlightOverride(boolean originalAllowFlight, boolean appliedAllowFlight, String regionId, + RuleKind kind) + { + } + +} diff --git a/src/main/java/spawnog/flight/ToggleRegionFlightCommand.java b/src/main/java/spawnog/flight/ToggleRegionFlightCommand.java new file mode 100644 index 0000000..b27faaa --- /dev/null +++ b/src/main/java/spawnog/flight/ToggleRegionFlightCommand.java @@ -0,0 +1,50 @@ +package spawnog.flight; + +import net.kyori.adventure.text.minimessage.MiniMessage; +import org.bukkit.command.Command; +import org.bukkit.command.CommandExecutor; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import org.jetbrains.annotations.NotNull; + +public final class ToggleRegionFlightCommand implements CommandExecutor { + + private final RegionFlightService flightService; + private final MiniMessage miniMessage = MiniMessage.miniMessage(); + + public ToggleRegionFlightCommand(RegionFlightService flightService) { + + this.flightService = flightService; + + } + + @Override + public boolean onCommand(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, + @NotNull String[] args) + { + + if (args.length != 0) + return false; + if (!(sender instanceof Player player)) { + + sender.sendMessage("Only players can use /fly."); + return true; + + } + + if (!flightService.canToggleFlight(player)) { + + player.sendMessage(miniMessage + .deserialize("Flight can only be toggled inside a region that allows flight.")); + return true; + + } + + boolean enabled = flightService.toggleFlight(player); + player.sendMessage(miniMessage + .deserialize(enabled ? "Flight enabled." : "Flight disabled.")); + return true; + + } + +} diff --git a/src/main/java/spawnog/listener/SpawnListener.java b/src/main/java/spawnog/listener/SpawnListener.java index 145b28f..ca4291b 100644 --- a/src/main/java/spawnog/listener/SpawnListener.java +++ b/src/main/java/spawnog/listener/SpawnListener.java @@ -1,20 +1,26 @@ package spawnog.listener; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.minimessage.MiniMessage; -import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; import org.bukkit.Location; -import org.bukkit.block.Block; -import org.bukkit.block.BlockFace; import org.bukkit.entity.Player; import org.bukkit.event.*; import org.bukkit.event.player.PlayerJoinEvent; +import org.bukkit.event.player.PlayerKickEvent; +import org.bukkit.event.player.PlayerQuitEvent; import org.bukkit.event.player.PlayerRespawnEvent; import spawnog.SpawnOG; +import spawnog.login.LoginMigrationService; public class SpawnListener implements Listener { - private final SpawnOG plugin = SpawnOG.getInstance(); + private final SpawnOG plugin; + private final LoginMigrationService loginMigration; + + public SpawnListener(SpawnOG plugin, LoginMigrationService loginMigration) { + + this.plugin = plugin; + this.loginMigration = loginMigration; + + } @EventHandler public void onJoin(PlayerJoinEvent e) { @@ -23,15 +29,7 @@ public class SpawnListener implements Listener { boolean first = !p.hasPlayedBefore(); boolean force = plugin.getConfig().getBoolean("spawn-on-join", false); - if (!first && !force) { - - // Returning players keep their logout position; rescue them if it is unsafe. - rescueIfUnsafe(p); - return; - - } - - Location dest; + Location dest = null; if (first) { String nb = plugin.getConfig().getString("newbies.spawnpoint", "newbie").toLowerCase(); @@ -39,17 +37,31 @@ public class SpawnListener implements Listener { if (dest == null) dest = plugin.getConfig().getLocation("spawns.global.location"); - } else { + } else if (force) { dest = plugin.getConfig().getLocation("spawns.global.location"); } - if (dest != null) + if (!loginMigration.handleLogin(p, dest) && dest != null) p.teleportAsync(dest.clone()); } + @EventHandler + public void onQuit(PlayerQuitEvent e) { + + loginMigration.cancel(e.getPlayer()); + + } + + @EventHandler + public void onKick(PlayerKickEvent e) { + + loginMigration.cancel(e.getPlayer()); + + } + @EventHandler public void onRespawn(PlayerRespawnEvent e) { @@ -59,11 +71,17 @@ public class SpawnListener implements Listener { if (atHome && (p.getBedSpawnLocation() != null)) return; - if (atHome && plugin.getServer().getPluginManager().isPluginEnabled("Essentials")) { + String essentialsName = null; + if (plugin.getServer().getPluginManager().isPluginEnabled("Essentials-OG")) + essentialsName = "Essentials-OG"; + else if (plugin.getServer().getPluginManager().isPluginEnabled("Essentials")) + essentialsName = "Essentials"; + + if (atHome && essentialsName != null) { try { - Object ess = plugin.getServer().getPluginManager().getPlugin("Essentials"); + Object ess = plugin.getServer().getPluginManager().getPlugin(essentialsName); Object user = ess.getClass().getMethod("getUser", Player.class).invoke(ess, p); Location home = (Location) user.getClass().getMethod("getHome", String.class).invoke(user, "home"); if (home != null) { @@ -85,46 +103,4 @@ public class SpawnListener implements Listener { } - // Teleport the player to spawn and report their old coordinates when their - // login location is unsafe. - private void rescueIfUnsafe(Player p) { - - Location from = p.getLocation(); - if (!isUnsafe(from)) - return; - - Location spawn = plugin.getConfig().getLocation("spawns.global.location"); - if (spawn == null) - return; - - String raw = plugin.getConfig().getString("locale.unsafeLogin", - "You logged in at an unsafe location (, , in ) and were teleported to spawn."); - Component msg = MiniMessage.miniMessage().deserialize(raw, - Placeholder.unparsed("x", String.valueOf(from.getBlockX())), - Placeholder.unparsed("y", String.valueOf(from.getBlockY())), - Placeholder.unparsed("z", String.valueOf(from.getBlockZ())), - Placeholder.unparsed("world", from.getWorld() != null ? from.getWorld().getName() : "unknown")); - p.sendMessage(msg); - - p.teleportAsync(spawn.clone()); - - } - - // Unsafe when an occluding block overlaps the player's body (suffocation) or - // there is nothing solid underfoot (fall). - private boolean isUnsafe(Location loc) { - - Block feet = loc.getBlock(); - Block head = feet.getRelative(BlockFace.UP); - Block ground = feet.getRelative(BlockFace.DOWN); - - // Suffocation: head or feet inside a full solid block. - if (feet.getType().isOccluding() || head.getType().isOccluding()) - return true; - - // Air underfoot: nothing to stand on. - return ground.isEmpty(); - - } - } diff --git a/src/main/java/spawnog/login/AutopsyMigrationStore.java b/src/main/java/spawnog/login/AutopsyMigrationStore.java new file mode 100644 index 0000000..0b29395 --- /dev/null +++ b/src/main/java/spawnog/login/AutopsyMigrationStore.java @@ -0,0 +1,96 @@ +package spawnog.login; + +import java.io.File; +import java.io.IOException; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.StandardCopyOption; +import java.time.Instant; +import java.util.UUID; + +import org.bukkit.GameMode; +import org.bukkit.Location; +import org.bukkit.configuration.file.YamlConfiguration; + +import spawnog.SpawnOG; + +public final class AutopsyMigrationStore { + + private final SpawnOG plugin; + private final File migrationFile; + private final YamlConfiguration migrations; + + public AutopsyMigrationStore(SpawnOG plugin) { + + this.plugin = plugin; + this.migrationFile = new File(plugin.getDataFolder(), "autopsy-migrations.yml"); + this.migrations = YamlConfiguration.loadConfiguration(migrationFile); + + } + + public boolean hasMigrated(UUID playerId) { + + return migrations.getBoolean(path(playerId) + ".complete", false); + + } + + public boolean record(UUID playerId, String playerName, GameMode originalGamemode, Location originalLocation) { + + String path = path(playerId); + migrations.set(path + ".complete", true); + migrations.set(path + ".player-name", playerName); + migrations.set(path + ".migrated-at", Instant.now().toString()); + migrations.set(path + ".original-gamemode", originalGamemode.name()); + migrations.set(path + ".original-location.world", + originalLocation.getWorld() == null ? null : originalLocation.getWorld().getName()); + migrations.set(path + ".original-location.x", originalLocation.getX()); + migrations.set(path + ".original-location.y", originalLocation.getY()); + migrations.set(path + ".original-location.z", originalLocation.getZ()); + migrations.set(path + ".original-location.yaw", originalLocation.getYaw()); + migrations.set(path + ".original-location.pitch", originalLocation.getPitch()); + + try { + + saveAtomically(); + return true; + + } catch (IOException error) { + + migrations.set(path, null); + plugin.getLogger() + .severe("Unable to save the autopsy migration for " + playerName + ": " + error.getMessage()); + return false; + + } + + } + + private void saveAtomically() throws IOException { + + File dataFolder = plugin.getDataFolder(); + if (!dataFolder.exists() && !dataFolder.mkdirs()) + throw new IOException("Unable to create " + dataFolder); + + File temporaryFile = new File(dataFolder, migrationFile.getName() + ".tmp"); + migrations.save(temporaryFile); + + try { + + Files.move(temporaryFile.toPath(), migrationFile.toPath(), StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING); + + } catch (AtomicMoveNotSupportedException error) { + + Files.move(temporaryFile.toPath(), migrationFile.toPath(), StandardCopyOption.REPLACE_EXISTING); + + } + + } + + private String path(UUID playerId) { + + return "players." + playerId; + + } + +} diff --git a/src/main/java/spawnog/login/LoginMigrationService.java b/src/main/java/spawnog/login/LoginMigrationService.java new file mode 100644 index 0000000..20c4197 --- /dev/null +++ b/src/main/java/spawnog/login/LoginMigrationService.java @@ -0,0 +1,323 @@ +package spawnog.login; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.minimessage.MiniMessage; +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; +import net.kyori.adventure.text.minimessage.tag.resolver.TagResolver; +import org.bukkit.GameMode; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.block.Block; +import org.bukkit.block.BlockFace; +import org.bukkit.entity.Player; +import org.bukkit.util.Vector; + +import spawnog.SpawnOG; + +public final class LoginMigrationService { + + private static final List DEFAULT_STAFF_PERMISSIONS = List.of("spawnog.login-migration.bypass", + "staffog.seebroadcast", "chat-og.staff", "sv.use"); + private static final Set DEFAULT_WORLDS = Set.of("world", "world_nether", "world_the_end"); + private static final Set HAZARDOUS_BLOCKS = Set.of(Material.LAVA, Material.FIRE, Material.SOUL_FIRE, + Material.POWDER_SNOW, Material.CACTUS, Material.SWEET_BERRY_BUSH, Material.MAGMA_BLOCK, Material.CAMPFIRE, + Material.SOUL_CAMPFIRE, Material.WITHER_ROSE); + + private final SpawnOG plugin; + private final AutopsyMigrationStore migrationStore; + private final MiniMessage miniMessage = MiniMessage.miniMessage(); + private final Map pendingLogins = new HashMap<>(); + + public LoginMigrationService(SpawnOG plugin, AutopsyMigrationStore migrationStore) { + + this.plugin = plugin; + this.migrationStore = migrationStore; + + } + + public boolean handleLogin(Player player, Location plannedDestination) { + + if (!plugin.getConfig().getBoolean("login-safety.enabled", true)) + return false; + + boolean managedWorld = isManagedWorld(player); + GameMode originalGamemode = player.getGameMode(); + boolean staff = isStaff(player); + boolean normalizeGamemode = managedWorld && !staff + && plugin.getConfig().getBoolean("login-safety.normalize-non-staff-gamemode", true) + && originalGamemode != GameMode.SURVIVAL; + boolean autopsyMigration = managedWorld && !staff && originalGamemode == GameMode.SPECTATOR + && plugin.getConfig().getBoolean("login-safety.autopsy-migration.enabled", true) + && !migrationStore.hasMigrated(player.getUniqueId()); + boolean vulnerableAfterLogin = normalizeGamemode || originalGamemode == GameMode.SURVIVAL + || originalGamemode == GameMode.ADVENTURE; + boolean unsafe = vulnerableAfterLogin && isUnsafe(player.getLocation()); + + Location destination = plannedDestination; + if (destination == null && (autopsyMigration || unsafe)) + destination = globalSpawn(); + + boolean teleportRequired = destination != null; + if (!teleportRequired && !normalizeGamemode) + return false; + + if ((autopsyMigration || unsafe) && destination == null) { + + protectAfterFailedRescue(player, originalGamemode, unsafe); + send(player, "locale.loginSafetyFailed", + "Your login location could not be made safe because the global spawn is not configured. Staff have been notified."); + plugin.getLogger().severe("Unable to safely migrate " + player.getName() + + " because spawns.global.location is not configured."); + return true; + + } + + beginTransaction(player, destination, originalGamemode, normalizeGamemode, autopsyMigration, unsafe); + return true; + + } + + public void cancel(Player player) { + + PendingLogin pending = pendingLogins.remove(player.getUniqueId()); + if (pending != null) + player.setInvulnerable(pending.originalInvulnerable()); + + } + + public boolean isPending(Player player) { + + return player != null && pendingLogins.containsKey(player.getUniqueId()); + + } + + public void close() { + + pendingLogins.forEach((playerId, pending) -> { + + Player player = plugin.getServer().getPlayer(playerId); + if (player != null) + player.setInvulnerable(pending.originalInvulnerable()); + + }); + pendingLogins.clear(); + + } + + private void beginTransaction(Player player, Location destination, GameMode originalGamemode, + boolean normalizeGamemode, boolean autopsyMigration, boolean unsafe) + { + + UUID playerId = player.getUniqueId(); + if (pendingLogins.containsKey(playerId)) + return; + + PendingLogin pending = new PendingLogin(player.isInvulnerable(), player.getAllowFlight(), player.isFlying(), + originalGamemode, player.getLocation().clone(), normalizeGamemode, autopsyMigration, unsafe); + pendingLogins.put(playerId, pending); + + player.setInvulnerable(true); + player.setFallDistance(0.0F); + player.setVelocity(new Vector()); + + if (destination == null) { + + plugin.getServer().getScheduler().runTask(plugin, () -> complete(player, true, null)); + return; + + } + + CompletableFuture teleport = player.teleportAsync(destination.clone()); + teleport.whenComplete((success, error) -> { + + if (!plugin.isEnabled()) + return; + plugin.getServer().getScheduler().runTask(plugin, + () -> complete(player, Boolean.TRUE.equals(success), error)); + + }); + + } + + private void complete(Player player, boolean teleportSucceeded, Throwable teleportError) { + + PendingLogin pending = pendingLogins.remove(player.getUniqueId()); + if (pending == null) + return; + + if (!player.isOnline()) { + + player.setInvulnerable(pending.originalInvulnerable()); + return; + + } + + if (!teleportSucceeded || teleportError != null || isUnsafe(player.getLocation())) { + + protectAfterFailedRescue(player, pending.originalGamemode(), true); + player.setInvulnerable(pending.originalInvulnerable()); + send(player, "locale.loginSafetyFailed", + "Your login safety migration could not finish. You were left in a non-damaging gamemode; please contact staff."); + plugin.getLogger().severe("Login safety transaction failed for " + player.getName() + + (teleportError == null ? "." : ": " + teleportError.getMessage())); + return; + + } + + if (pending.normalizeGamemode()) { + + if (player.isFlying()) + player.setFlying(false); + player.setFallDistance(0.0F); + player.setGameMode(GameMode.SURVIVAL); + if (player.getGameMode() != GameMode.SURVIVAL) { + + player.setInvulnerable(pending.originalInvulnerable()); + send(player, "locale.loginSafetyFailed", + "Another plugin prevented your safe switch to survival. Staff have been notified."); + plugin.getLogger().severe( + "Another plugin prevented Spawn-OG from normalizing " + player.getName() + " to survival."); + return; + + } + + if (plugin.getConfig().getBoolean("login-safety.remove-flight-on-normalize", true)) + player.setAllowFlight(false); + + } + + if (pending.autopsyMigration()) { + + boolean recorded = migrationStore.record(player.getUniqueId(), player.getName(), pending.originalGamemode(), + pending.originalLocation()); + if (!recorded) { + + rollbackAutopsyCommit(player, pending); + player.setInvulnerable(pending.originalInvulnerable()); + send(player, "locale.loginSafetyFailed", + "Your autopsy migration could not be recorded. You remain safely in spectator mode; please contact staff."); + return; + + } + + Location from = pending.originalLocation(); + send(player, "locale.autopsyMigration", + "Your OG:SMP autopsy state was safely migrated from , , in . You were moved to spawn and returned to survival.", + Placeholder.unparsed("x", String.valueOf(from.getBlockX())), + Placeholder.unparsed("y", String.valueOf(from.getBlockY())), + Placeholder.unparsed("z", String.valueOf(from.getBlockZ())), + Placeholder.unparsed("world", from.getWorld() == null ? "unknown" : from.getWorld().getName())); + + } else if (pending.normalizeGamemode()) { + + send(player, "locale.gamemodeNormalized", + "Your login gamemode was safely returned to survival."); + + } else if (pending.unsafe()) { + + Location from = pending.originalLocation(); + send(player, "locale.unsafeLogin", + "You logged in at an unsafe location (, , in ) and were teleported to spawn.", + Placeholder.unparsed("x", String.valueOf(from.getBlockX())), + Placeholder.unparsed("y", String.valueOf(from.getBlockY())), + Placeholder.unparsed("z", String.valueOf(from.getBlockZ())), + Placeholder.unparsed("world", from.getWorld() == null ? "unknown" : from.getWorld().getName())); + + } + + player.setInvulnerable(pending.originalInvulnerable()); + + } + + private void protectAfterFailedRescue(Player player, GameMode originalGamemode, boolean unsafe) { + + if (unsafe && originalGamemode != GameMode.CREATIVE && originalGamemode != GameMode.SPECTATOR) { + + player.setGameMode(GameMode.SPECTATOR); + player.setFallDistance(0.0F); + + } + + } + + private void rollbackAutopsyCommit(Player player, PendingLogin pending) { + + if (player.isFlying()) + player.setFlying(false); + player.setGameMode(pending.originalGamemode()); + player.setAllowFlight(pending.originalAllowFlight()); + if (pending.originalFlying() && player.getAllowFlight()) + player.setFlying(true); + player.setFallDistance(0.0F); + + } + + private boolean isManagedWorld(Player player) { + + List configuredWorlds = plugin.getConfig().getStringList("login-safety.worlds"); + if (configuredWorlds.isEmpty()) + return DEFAULT_WORLDS.contains(player.getWorld().getName()); + return configuredWorlds.stream().anyMatch(world -> world.equalsIgnoreCase(player.getWorld().getName())); + + } + + private boolean isStaff(Player player) { + + List permissions = plugin.getConfig().getStringList("login-safety.staff-bypass-permissions"); + if (permissions.isEmpty()) + permissions = DEFAULT_STAFF_PERMISSIONS; + return permissions.stream().anyMatch(player::hasPermission); + + } + + private Location globalSpawn() { + + Location spawn = plugin.getConfig().getLocation("spawns.global.location"); + return spawn == null ? null : spawn.clone(); + + } + + private boolean isUnsafe(Location location) { + + if (location.getWorld() == null) + return true; + if (location.getY() <= location.getWorld().getMinHeight() + || location.getY() >= location.getWorld().getMaxHeight()) + return true; + if (!location.getWorld().getWorldBorder().isInside(location)) + return true; + + Block feet = location.getBlock(); + Block head = feet.getRelative(BlockFace.UP); + Block ground = feet.getRelative(BlockFace.DOWN); + if (feet.getType().isOccluding() || head.getType().isOccluding()) + return true; + if (HAZARDOUS_BLOCKS.contains(feet.getType()) || HAZARDOUS_BLOCKS.contains(head.getType()) + || HAZARDOUS_BLOCKS.contains(ground.getType())) + return true; + return ground.isEmpty(); + + } + + private void send(Player player, String path, String fallback, TagResolver... placeholders) { + + String message = plugin.getConfig().getString(path, fallback); + Component component = miniMessage.deserialize(message, placeholders); + player.sendMessage(component); + + } + + private record PendingLogin(boolean originalInvulnerable, boolean originalAllowFlight, boolean originalFlying, + GameMode originalGamemode, Location originalLocation, boolean normalizeGamemode, boolean autopsyMigration, + boolean unsafe) + { + } + +} diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 0c94133..4d36feb 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -1,8 +1,35 @@ -spawn-on-join: default +spawn-on-join: false +respawn-at-home: true newbies: spawnpoint: newbie +login-safety: + enabled: true + # Only these SMP dimensions are normalized. Add other survival worlds explicitly. + worlds: + - world + - world_nether + - world_the_end + autopsy-migration: + enabled: true + normalize-non-staff-gamemode: true + remove-flight-on-normalize: true + # LuckPerms-backed permissions. Matching any entry exempts a player from + # spectator migration and creative/adventure-to-survival normalization. + staff-bypass-permissions: + - spawnog.login-migration.bypass + - staffog.seebroadcast + - chat-og.staff + - sv.use +flight: + enabled: true + regions: + spawn: fly + warzone: nofly locale: unsafeLogin: "You logged in at an unsafe location (, , in ) and were teleported to spawn." + autopsyMigration: "Your OG:SMP autopsy state was safely migrated from , , in . You were moved to spawn and returned to survival." + gamemodeNormalized: "Your login gamemode was safely returned to survival." + loginSafetyFailed: "Your login safety migration could not finish safely. Please contact staff." spawns: global: location: diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 3a65aa4..cadc1a5 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -1,28 +1,59 @@ name: Spawn-OG -description: A Minecraft Plugin simplifies teleporting to the spawn location. -description: A drop-in replacement for EssentialsSpawn +description: Safe player entry, spawn management, and WorldGuard regional flight for TrueOG. version: '${version}' api-version: '${apiVersion}' authors: [ kasumabalidps, NotAlexNoyle ] website: https://github.com/true-og/Spawn-OG main: spawnog.SpawnOG -api-version: 1.19 +softdepend: [LuckPerms, WorldEdit, WorldGuard] +loadbefore: [Essentials, Essentials-OG] commands: spawn: description: Teleport yourself (or another player) to the server spawn usage: /spawn [player] - permission: spawnog.teleport + permission: spawnog.spawn setspawn: description: Set or clear the server spawn point usage: /setspawn [normalize-view] [normalize-position] | /setspawn unset - permission: spawnog.admin + permission: spawnog.setspawn + fly: + description: Toggle flight inside a WorldGuard region configured to allow it + usage: /fly + permission: spawnog.flight permissions: - spawnog.teleport: + spawnog.spawn: description: Use /spawn on yourself default: true - spawn.others: + spawnog.spawn.others: description: Use /spawn - default: op - spawnog.admin: + default: false + spawnog.setspawn: description: Use /setspawn - default: op + default: false + spawnog.login-migration.bypass: + description: Exempt a staff member from autopsy migration and login gamemode normalization + default: false + spawnog.flight: + description: Use /fly inside regions configured to allow flight + default: true + spawnog.flight.bypass: + description: Exempt a player from regional no-flight rules + default: false + spawnog.flight.*: + description: Grant regional flight use and bypass + default: false + children: + spawnog.flight: true + spawnog.flight.bypass: true + wgamemode.fly: + description: Legacy alias for spawnog.flight + default: true + wgamemode.fly.bypass: + description: Legacy alias for spawnog.flight.bypass + default: false + wgamemode.*: + description: Legacy aggregate permission for WGamemode-OG compatibility + default: false + children: + wgamemode.fly: true + wgamemode.fly.bypass: true