Replace WGamemode-OG

This commit is contained in:
NotAlexNoyle committed 2026-07-15 10:24:31 -04:00
1 parent 9dc8b9fa5e
commit 9ee50d6cf1
11 files changed
+1159 -122

No files matched your search

+50 -44
View File
@@ -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 <player```
- **Async teleportation:**
Minimal impact on server performance
- **Teleport delay:**
5 seconds
- **First Join Spawn (Newbie Spawn):**
- **Respawn Handling:**
By default, if `respawn-at-home` is set to false, players will respawn at the spawn point. If `respawn-at-home` is true, players will respawn at their bed or home location (if defined) instead of spawn. The plugin respects beds and Essentials homes according to this setting.
- **Spawn-on-Join:**
Optionally, the plugin can force players to go to spawn every time they log in, by enabling `spawn-on-join` in the config.
1. Preserve the player's current invulnerability state and make them
invulnerable while repair is in progress.
2. Load and teleport to the configured global spawn when relocation is needed.
3. Change non-staff spectator, creative, or adventure players to survival.
4. Successfully rescued autopsy players are told their original world and block coordinates in chat so they can identify the location they were browsing.
By default, login normalization is limited to `world`, `world_nether`, and
`world_the_end`. Add other survival worlds explicitly rather than applying the
rule to minigame or build worlds accidentally.
## Regional flight
Inside a `fly` region, players with `spawnog.flight` can toggle flight using
`/fly`. Inside a `nofly` region, flight is suspended unless the player has
`spawnog.flight.bypass`. Previous flight permission is restored on region exit
or disconnect, and fall damage caused by forced landing is cancelled once.
## Regional item drops
```text
/rg flag -w world spawn item-drop deny
```
Repeat the above command with each additional world and region that should deny
drops. Remove the explicit policy with `item-drop` and no value, or restore
drops with `item-drop allow`. Staff bypass, when genuinely required, is the
LuckPerms permission `worldguard.region.bypass.<world>`.
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 <player>`**
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 <group>`**
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 <player>` — 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 <player>`. *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 <player>`.
- `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
```
+4 -1
View File
@@ -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 ---------------------------- */
+37 -3
View File
@@ -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();
}
@@ -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());
@@ -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<String, RuleKind> regionRules = new HashMap<>();
private final Map<UUID, FlightOverride> flightOverrides = new HashMap<>();
private final Set<UUID> fallImmunity = new HashSet<>();
private final Set<UUID> 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)
{
}
}
@@ -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("<red>Flight can only be toggled inside a region that allows flight.</red>"));
return true;
}
boolean enabled = flightService.toggleFlight(player);
player.sendMessage(miniMessage
.deserialize(enabled ? "<green>Flight enabled.</green>" : "<yellow>Flight disabled.</yellow>"));
return true;
}
}
@@ -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",
"<gold>You logged in at an unsafe location (<red><x>, <y>, <z></red> in <world>) and were teleported to spawn.</gold>");
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();
}
}
@@ -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;
}
}
@@ -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<String> DEFAULT_STAFF_PERMISSIONS = List.of("spawnog.login-migration.bypass",
"staffog.seebroadcast", "chat-og.staff", "sv.use");
private static final Set<String> DEFAULT_WORLDS = Set.of("world", "world_nether", "world_the_end");
private static final Set<Material> 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<UUID, PendingLogin> 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",
"<red>Your login location could not be made safe because the global spawn is not configured. Staff have been notified.</red>");
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<Boolean> 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",
"<red>Your login safety migration could not finish. You were left in a non-damaging gamemode; please contact staff.</red>");
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",
"<red>Another plugin prevented your safe switch to survival. Staff have been notified.</red>");
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",
"<red>Your autopsy migration could not be recorded. You remain safely in spectator mode; please contact staff.</red>");
return;
}
Location from = pending.originalLocation();
send(player, "locale.autopsyMigration",
"<gold>Your OG:SMP autopsy state was safely migrated from <red><x>, <y>, <z></red> in <world>. You were moved to spawn and returned to survival.</gold>",
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",
"<gold>Your login gamemode was safely returned to survival.</gold>");
} else if (pending.unsafe()) {
Location from = pending.originalLocation();
send(player, "locale.unsafeLogin",
"<gold>You logged in at an unsafe location (<red><x>, <y>, <z></red> in <world>) and were teleported to spawn.</gold>",
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<String> 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<String> 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)
{
}
}
+28 -1
View File
@@ -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: "<gold>You logged in at an unsafe location (<red><x>, <y>, <z></red> in <world>) and were teleported to spawn.</gold>"
autopsyMigration: "<gold>Your OG:SMP autopsy state was safely migrated from <red><x>, <y>, <z></red> in <world>. You were moved to spawn and returned to survival.</gold>"
gamemodeNormalized: "<gold>Your login gamemode was safely returned to survival.</gold>"
loginSafetyFailed: "<red>Your login safety migration could not finish safely. Please contact staff.</red>"
spawns:
global:
location:
+41 -10
View File
@@ -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 <player>
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