Cancel spawn warmup on movement, relax login safety, defer gamemode rules

Movement out of the block the /spawn countdown started in cancels the
teleport with a chat notice; damage does not, so a player hit while
standing still is still pulled to spawn.

Login relocation was firing on ordinary survival play. Water at the feet,
tall grass, slabs, stairs, and carpet are safe now; only suffocation,
damaging blocks, lava, a drop longer than five blocks with no water, the
void, and out of bounds relocate a player. Anyone moved keeps a one shot
return point, and /spawnback confirm sends them back at their own risk.

Non-survival logins are normalized without exception, and the rule for who
may hold creative or spectator comes from GameModeInventories-OG's
published GameModePolicy rather than a second copy that could drift. It
arrives as a submodule under libs/, compiled from source and never bundled;
Spawn-OG's own rules stand in when the plugin is absent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
NotAlexNoyleandClaude Opus 5 committed 2026-07-28 08:55:48 -04:00
1 parent 3ffe84d2ae
commit d145c3ebeb
22 files changed
+1044 -126

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
[submodule "libs/GameModeInventories-OG"]
path = libs/GameModeInventories-OG
url = https://github.com/true-og/GameModeInventories-OG
+47 -11
View File
@@ -6,15 +6,44 @@ flight and gamemode management portions of legacy WGamemode-OG on Purpur 1.19.4.
## Login safety
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.
1. Make the player invulnerable while repair is in progress, restoring their own
state afterwards.
2. Teleport to the global spawn when relocation is needed.
3. Return unsanctioned spectator, creative, and adventure logins to survival.
4. Tell rescued players the world and block coordinates they came from.
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.
Limited to `world`, `world_nether`, and `world_the_end`. Add other survival
worlds explicitly, so minigame and build worlds are not caught by accident.
### Who may log in outside survival
Non-survival logins become survival unless the player may use that gamemode
where they logged in. GameModeInventories-OG owns that rule; Spawn-OG asks its
`GameModePolicy` rather than keeping a second copy. There is no bypass.
Its rules: creative needs `gamemodeinventories.anywhere` or a creative region;
spectator needs `gamemodeinventories.spectator` or `noclip.use` while
`restrict_spectator` is on; adventure is never granted.
Without GameModeInventories-OG, Spawn-OG falls back to
`login-safety.gamemode-exemption-permissions` and `login-safety.creative-regions`,
which mirror the same rules. A creative region is a WorldGuard region matched by
id, not a flag; with no WorldGuard only `gamemodeinventories.anywhere` exempts
anyone.
### What counts as unsafe
Water, tall grass, slabs, stairs, and carpet are ordinary survival play and are
left alone. Only these are relocated: inside a full solid block, inside or on
top of a damaging block, in or above lava, above a drop longer than five blocks
with no water to break it, over the void, outside the world border, or outside
the build limits.
### Returning to an unsafe location
`/spawnback` warns why the position was flagged; `/spawnback confirm` within
thirty seconds sends the player back at their own risk. One shot per migration,
stored in `return-locations.yml`.
## Regional flight
@@ -40,9 +69,11 @@ policy that applies consistently whether or not the player has toggled flight.
## Commands
- `/spawn` — Teleport to the global or LuckPerms-group spawn after a five-second
warmup.
warmup. Moving cancels it; damage does not.
- `/spawn <player>` — Teleport another player to their resolved spawn.
- `/setspawn [group] [normalize-view] [normalize-position]` — Configure a spawn.
- `/spawnback [confirm]` — Return to where a login safety migration moved you
from.
- `/fly` — Toggle flight inside a configured `fly` region.
## Permissions
@@ -50,13 +81,18 @@ policy that applies consistently whether or not the player has toggled flight.
- `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.spawnback` — Use `/spawnback`; granted by default.
- `spawnog.login-migration.bypass` — Exempt staff from autopsy migration only.
- `spawnog.flight` — Use regional `/fly`; granted by default.
- `spawnog.flight.bypass` — Ignore `nofly` regions; not granted by default.
## Building
GameModeInventories-OG is a submodule under `libs/`, compiled from source and
never bundled. `bootstrap.sh` fetches it, and `settings.gradle.kts` runs that on
every configure. Take a newer version with
`git submodule update --remote libs/GameModeInventories-OG`.
Use the TrueOG bootstrap, or run:
```sh
Executable
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/bash
# Fetch all submodule content.
git submodule update --force --recursive --init
+3
View File
@@ -48,6 +48,9 @@ dependencies {
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") }
compileOnlyApi(
project(":libs:GameModeInventories-OG")
) // Import GameModeInventories-OG gamemode policy (from source).
}
/* ---------------------- Reproducible jars ---------------------------- */
+12
View File
@@ -1 +1,13 @@
rootProject.name = "Spawn-OG"
ProcessBuilder("sh", "bootstrap.sh").directory(rootDir).inheritIO().start().let {
if (it.waitFor() != 0) throw GradleException("bootstrap.sh failed")
}
file("libs")
.listFiles()
?.filter { it.isDirectory && !it.name.startsWith(".") }
?.forEach { dir ->
include(":libs:${dir.name}")
project(":libs:${dir.name}").projectDir = dir
}
+35 -2
View File
@@ -6,12 +6,19 @@ import net.luckperms.api.LuckPerms;
import org.bukkit.plugin.RegisteredServiceProvider;
import org.bukkit.plugin.java.JavaPlugin;
import spawnog.commands.SetSpawnCommand;
import spawnog.commands.SpawnBackCommand;
import spawnog.commands.SpawnCommand;
import spawnog.flight.RegionFlightService;
import spawnog.flight.ToggleRegionFlightCommand;
import spawnog.login.AutopsyMigrationStore;
import spawnog.login.GameModeInventoriesAuthority;
import spawnog.login.GamemodePolicy;
import spawnog.login.LoginMigrationService;
import spawnog.login.ReturnLocationStore;
import spawnog.listener.SpawnListener;
import spawnog.region.RegionLookup;
import spawnog.region.WorldGuardRegionLookup;
import spawnog.teleport.SpawnWarmupService;
@Slf4j
public final class SpawnOG extends JavaPlugin {
@@ -24,6 +31,7 @@ public final class SpawnOG extends JavaPlugin {
private LoginMigrationService loginMigrationService;
private RegionFlightService regionFlightService;
private SpawnWarmupService spawnWarmupService;
@Override
public void onEnable() {
@@ -35,13 +43,23 @@ public final class SpawnOG extends JavaPlugin {
if (rsp != null)
luckPerms = rsp.getProvider();
register("spawn", new SpawnCommand(), "spawnog.spawn");
spawnWarmupService = new SpawnWarmupService(this);
getServer().getPluginManager().registerEvents(spawnWarmupService, this);
register("spawn", new SpawnCommand(spawnWarmupService), "spawnog.spawn");
register("setspawn", new SetSpawnCommand(), "spawnog.setspawn");
AutopsyMigrationStore migrationStore = new AutopsyMigrationStore(this);
loginMigrationService = new LoginMigrationService(this, migrationStore);
ReturnLocationStore returnLocationStore = new ReturnLocationStore(this);
GamemodePolicy gamemodePolicy = new GamemodePolicy(this, regionLookup(),
GameModeInventoriesAuthority.find(this));
loginMigrationService = new LoginMigrationService(this, migrationStore, returnLocationStore, gamemodePolicy);
getServer().getPluginManager().registerEvents(new SpawnListener(this, loginMigrationService), this);
SpawnBackCommand spawnBackCommand = new SpawnBackCommand(this, returnLocationStore, loginMigrationService);
register("spawnback", spawnBackCommand, "spawnog.spawnback");
getServer().getPluginManager().registerEvents(spawnBackCommand, this);
if (getConfig().getBoolean("flight.enabled", true)
&& getServer().getPluginManager().isPluginEnabled("WorldGuard")
&& getServer().getPluginManager().isPluginEnabled("WorldEdit"))
@@ -62,6 +80,8 @@ public final class SpawnOG extends JavaPlugin {
@Override
public void onDisable() {
if (spawnWarmupService != null)
spawnWarmupService.close();
if (loginMigrationService != null)
loginMigrationService.close();
if (regionFlightService != null)
@@ -69,6 +89,19 @@ public final class SpawnOG extends JavaPlugin {
}
// WorldGuard-backed when both it and WorldEdit are present, so the WorldGuard
// classes are never resolved on servers running without them.
private RegionLookup regionLookup() {
if (getServer().getPluginManager().isPluginEnabled("WorldGuard")
&& getServer().getPluginManager().isPluginEnabled("WorldEdit"))
return new WorldGuardRegionLookup();
getLogger().warning("WorldGuard or WorldEdit is unavailable; region-scoped gamemode exemptions are disabled.");
return RegionLookup.NONE;
}
private void register(String name, Object exec, String perm) {
var cmd = getCommand(name);
@@ -0,0 +1,181 @@
package spawnog.commands;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
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.Location;
import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender;
import org.bukkit.command.TabCompleter;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerQuitEvent;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import spawnog.SpawnOG;
import spawnog.login.LoginMigrationService;
import spawnog.login.ReturnLocationStore;
import spawnog.login.ReturnLocationStore.ReturnPoint;
// Sends a player back to wherever a login safety migration took them from. The
// position is knowingly unsafe, so the first call only warns and the teleport
// waits for an explicit confirmation.
public final class SpawnBackCommand implements CommandExecutor, TabCompleter, Listener {
private static final int CONFIRM_SECONDS = 30;
private final SpawnOG plugin;
private final ReturnLocationStore returnLocationStore;
private final LoginMigrationService loginMigrationService;
private final MiniMessage miniMessage = MiniMessage.miniMessage();
private final Map<UUID, Long> pendingConfirmations = new HashMap<>();
public SpawnBackCommand(SpawnOG plugin, ReturnLocationStore returnLocationStore,
LoginMigrationService loginMigrationService)
{
this.plugin = plugin;
this.returnLocationStore = returnLocationStore;
this.loginMigrationService = loginMigrationService;
}
@Override
public boolean onCommand(@NotNull CommandSender sender, @NotNull Command cmd, @NotNull String lbl,
@NotNull String[] args)
{
if (!(sender instanceof Player player))
return false;
if (!player.hasPermission(cmd.getPermission())) {
send(player, "locale.missingPermission", "<red>You are lacking the required permissions.</red>");
return true;
}
UUID playerId = player.getUniqueId();
// The migration owns the player's position until it resolves; returning
// mid-flight would race the teleport it is already performing.
if (loginMigrationService.isPending(player)) {
send(player, "locale.returnBusy",
"<red>Your login is still being made safe. Try again in a few seconds.</red>");
return true;
}
ReturnPoint point = returnLocationStore.get(playerId);
if (point == null) {
if (returnLocationStore.has(playerId))
send(player, "locale.returnWorldMissing",
"<red>The world you were in (<world>) is not loaded, so you cannot return there.</red>",
Placeholder.unparsed("world", returnLocationStore.worldName(playerId)));
else
send(player, "locale.returnNone", "<red>You have no location to return to.</red>");
pendingConfirmations.remove(playerId);
return true;
}
if (!isConfirmed(player, args)) {
pendingConfirmations.put(playerId, System.currentTimeMillis() + CONFIRM_SECONDS * 1000L);
send(player, "locale.returnWarning",
"<gold>You were moved from <red><x>, <y>, <z></red> in <world> because <red><reason></red>. Run <red>/spawnback confirm</red> within <seconds> seconds to go back anyway; you may take damage or die.</gold>",
coordinates(point), Placeholder.unparsed("reason", point.reason()),
Placeholder.unparsed("seconds", String.valueOf(CONFIRM_SECONDS)));
return true;
}
pendingConfirmations.remove(playerId);
// The record is only consumed once the player is actually standing there
// again, so a failed teleport does not cost them the way back.
player.teleportAsync(point.location().clone()).whenComplete((success, error) -> {
if (!plugin.isEnabled())
return;
plugin.getServer().getScheduler().runTask(plugin, () -> {
if (!Boolean.TRUE.equals(success) || error != null) {
send(player, "locale.returnFailed", "<red>Your return teleport failed.</red>");
return;
}
returnLocationStore.clear(playerId);
send(player, "locale.returnConfirmed",
"<gold>Returning you to <red><x>, <y>, <z></red> in <world>. Good luck.</gold>",
coordinates(point));
});
});
return true;
}
@Override
public @Nullable List<String> onTabComplete(@NotNull CommandSender sender, @NotNull Command cmd,
@NotNull String lbl, @NotNull String[] args)
{
if (args.length == 1 && "confirm".startsWith(args[0].toLowerCase()))
return List.of("confirm");
return List.of();
}
@EventHandler
public void onQuit(PlayerQuitEvent event) {
pendingConfirmations.remove(event.getPlayer().getUniqueId());
}
private boolean isConfirmed(Player player, String[] args) {
Long expiry = pendingConfirmations.get(player.getUniqueId());
if (expiry == null || expiry < System.currentTimeMillis())
return false;
return args.length > 0 && args[0].equalsIgnoreCase("confirm");
}
private TagResolver coordinates(ReturnPoint point) {
Location location = point.location();
return TagResolver.resolver(Placeholder.unparsed("x", String.valueOf(location.getBlockX())),
Placeholder.unparsed("y", String.valueOf(location.getBlockY())),
Placeholder.unparsed("z", String.valueOf(location.getBlockZ())),
Placeholder.unparsed("world", point.worldName()));
}
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);
}
}
@@ -2,8 +2,6 @@ package spawnog.commands;
import java.util.*;
import java.util.stream.Collectors;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.minimessage.MiniMessage;
import net.luckperms.api.LuckPerms;
import net.luckperms.api.model.user.User;
import org.bukkit.Bukkit;
@@ -14,12 +12,19 @@ import org.bukkit.entity.Player;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import spawnog.SpawnOG;
import spawnog.teleport.SpawnWarmupService;
public class SpawnCommand implements CommandExecutor, TabCompleter {
private final SpawnOG plugin = SpawnOG.getInstance();
private final FileConfiguration cfg = plugin.getConfig();
private final MiniMessage mm = MiniMessage.miniMessage();
private final SpawnWarmupService warmupService;
public SpawnCommand(SpawnWarmupService warmupService) {
this.warmupService = warmupService;
}
@Override
public boolean onCommand(@NotNull CommandSender sender, @NotNull Command cmd, @NotNull String lbl,
@@ -97,16 +102,7 @@ public class SpawnCommand implements CommandExecutor, TabCompleter {
private void scheduleTeleport(Player player, Location dest) {
String raw = cfg.getString("locale.spawnWarmup",
"<gold>Teleporting to spawn in <red>5 seconds...</red></gold>");
Component msg = mm.deserialize(raw);
player.sendMessage(msg);
Bukkit.getScheduler().runTaskLater(plugin, () -> {
if (player.isOnline())
player.teleportAsync(dest);
}, 20L * 5);
warmupService.schedule(player, dest);
}
@@ -1,95 +1,47 @@
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 final class AutopsyMigrationStore extends YamlStore {
public AutopsyMigrationStore(SpawnOG plugin) {
this.plugin = plugin;
this.migrationFile = new File(plugin.getDataFolder(), "autopsy-migrations.yml");
this.migrations = YamlConfiguration.loadConfiguration(migrationFile);
super(plugin, "autopsy-migrations.yml");
}
public boolean hasMigrated(UUID playerId) {
return migrations.getBoolean(path(playerId) + ".complete", false);
return data.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",
data.set(path + ".complete", true);
data.set(path + ".player-name", playerName);
data.set(path + ".migrated-at", Instant.now().toString());
data.set(path + ".original-gamemode", originalGamemode.name());
data.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());
data.set(path + ".original-location.x", originalLocation.getX());
data.set(path + ".original-location.y", originalLocation.getY());
data.set(path + ".original-location.z", originalLocation.getZ());
data.set(path + ".original-location.yaw", originalLocation.getYaw());
data.set(path + ".original-location.pitch", originalLocation.getPitch());
try {
saveAtomically();
if (save("autopsy migration for " + playerName))
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;
data.set(path, null);
return false;
}
@@ -0,0 +1,49 @@
package spawnog.login;
import me.eccentric_nz.gamemodeinventories.api.GameModePolicy;
import org.bukkit.GameMode;
import org.bukkit.entity.Player;
import org.bukkit.plugin.RegisteredServiceProvider;
import spawnog.SpawnOG;
// GameModeInventories-OG's published policy, taken from the services manager it
// registers on enable. Only constructed when that plugin is enabled, so its
// classes are never resolved on servers running without it.
public final class GameModeInventoriesAuthority implements GamemodeAuthority {
public static final String PLUGIN_NAME = "GameModeInventories-OG";
private final SpawnOG plugin;
private GameModeInventoriesAuthority(SpawnOG plugin) {
this.plugin = plugin;
}
// The authority, or null when GameModeInventories-OG is not running.
public static GamemodeAuthority find(SpawnOG plugin) {
if (!plugin.getServer().getPluginManager().isPluginEnabled(PLUGIN_NAME))
return null;
return new GameModeInventoriesAuthority(plugin);
}
@Override
public Boolean mayUse(Player player, GameMode gamemode) {
// Looked up per call rather than cached: reloading GameModeInventories-OG
// republishes a new policy, and disabling it must hand the rules back.
RegisteredServiceProvider<GameModePolicy> registration = plugin.getServer().getServicesManager()
.getRegistration(GameModePolicy.class);
if (registration == null)
return null;
return registration.getProvider().mayUse(player, gamemode, player.getLocation());
}
}
@@ -0,0 +1,13 @@
package spawnog.login;
import org.bukkit.GameMode;
import org.bukkit.entity.Player;
// Whoever decides which gamemodes a player may hold. GameModeInventories-OG owns
// that rule when it is installed; Spawn-OG's own rules stand in when it is not.
public interface GamemodeAuthority {
// Null when the authority cannot answer right now, so the caller falls back.
Boolean mayUse(Player player, GameMode gamemode);
}
@@ -0,0 +1,93 @@
package spawnog.login;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import org.bukkit.GameMode;
import org.bukkit.entity.Player;
import spawnog.SpawnOG;
import spawnog.region.RegionLookup;
// Decides who may log in outside survival. Everyone else is returned to
// survival, because a non-survival login in an SMP world is either a leftover
// autopsy state or an escape from survival rules.
//
// GameModeInventories-OG owns that rule at runtime, so this defers to the
// authority it publishes rather than keeping a second copy that can drift. The
// local rules below stand in on servers running Spawn-OG without it.
public final class GamemodePolicy {
private static final String ANYWHERE_PERMISSION = "gamemodeinventories.anywhere";
private static final String NOCLIP_PERMISSION = "noclip.use";
private static final List<String> DEFAULT_CREATIVE_PERMISSIONS = List.of(NOCLIP_PERMISSION,
"gamemodeinventories.toggle", ANYWHERE_PERMISSION);
// GameModeInventories-OG gates spectator on these two, not on its creative
// toggle, so a creative permission never sanctions a spectator login.
private static final List<String> DEFAULT_SPECTATOR_PERMISSIONS = List.of(NOCLIP_PERMISSION,
"gamemodeinventories.spectator");
private static final List<String> DEFAULT_CREATIVE_REGIONS = List.of("spawn");
private final SpawnOG plugin;
private final RegionLookup regionLookup;
private final GamemodeAuthority authority;
public GamemodePolicy(SpawnOG plugin, RegionLookup regionLookup, GamemodeAuthority authority) {
this.plugin = plugin;
this.regionLookup = regionLookup;
this.authority = authority;
}
// True when the player is allowed to stay in the gamemode they logged in with.
public boolean mayUse(Player player, GameMode gamemode) {
if (gamemode == GameMode.SURVIVAL)
return true;
Boolean answer = authority == null ? null : authority.mayUse(player, gamemode);
if (answer != null)
return answer;
// Neither NoClip-OG nor GameModeInventories-OG hands out adventure, so an
// adventure login is always a leftover state rather than someone at work.
if (gamemode != GameMode.CREATIVE && gamemode != GameMode.SPECTATOR)
return false;
if (!hasTooling(player, gamemode))
return false;
if (player.hasPermission(ANYWHERE_PERMISSION))
return true;
Set<String> creativeRegions = creativeRegions();
return regionLookup.regionsAt(player.getLocation()).stream().anyMatch(creativeRegions::contains);
}
private boolean hasTooling(Player player, GameMode gamemode) {
boolean creative = gamemode == GameMode.CREATIVE;
List<String> permissions = plugin.getConfig()
.getStringList("login-safety.gamemode-exemption-permissions." + (creative ? "creative" : "spectator"));
if (permissions.isEmpty())
permissions = creative ? DEFAULT_CREATIVE_PERMISSIONS : DEFAULT_SPECTATOR_PERMISSIONS;
return permissions.stream().anyMatch(player::hasPermission);
}
private Set<String> creativeRegions() {
Set<String> regions = new HashSet<>();
plugin.getConfig().getStringList("login-safety.creative-regions").stream()
.map(region -> region.toLowerCase(Locale.ROOT)).forEach(regions::add);
if (regions.isEmpty())
regions.addAll(DEFAULT_CREATIVE_REGIONS);
return regions;
}
}
@@ -0,0 +1,132 @@
package spawnog.login;
import java.util.Set;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.Tag;
import org.bukkit.World;
import org.bukkit.block.Block;
import org.bukkit.block.BlockFace;
// Decides whether a login location would hurt or kill the player who logs in
// there. The bar is deliberately low: only positions that damage the player on
// arrival count as unsafe. Standing in water, tall grass, on a slab, or on any
// other block that is not a full cube is normal survival play and stays safe.
public final class LocationSafety {
// A drop this long or shorter is survivable, so a player suspended above one is
// left where they logged out.
private static final int MAX_SAFE_FALL_BLOCKS = 5;
// Blocks that damage a player occupying them.
private static final Set<Material> CONTACT_HAZARDS = Set.of(Material.LAVA, Material.FIRE, Material.SOUL_FIRE,
Material.WITHER_ROSE);
// Blocks that damage a player standing on top of them.
private static final Set<Material> SURFACE_HAZARDS = Set.of(Material.MAGMA_BLOCK, Material.CAMPFIRE,
Material.SOUL_CAMPFIRE, Material.CACTUS);
private LocationSafety() {
}
public static Issue check(Location location) {
if (location == null)
return Issue.MISSING_WORLD;
World world = location.getWorld();
if (world == null)
return Issue.MISSING_WORLD;
if (location.getY() < world.getMinHeight() || location.getY() >= world.getMaxHeight())
return Issue.OUT_OF_BOUNDS;
if (!world.getWorldBorder().isInside(location))
return Issue.OUTSIDE_BORDER;
Block feet = location.getBlock();
Block head = feet.getRelative(BlockFace.UP);
// Only full opaque cubes suffocate. Slabs, stairs, fences, carpets, snow
// layers, and plants are all passable enough to stand inside.
if (feet.getType().isOccluding() || head.getType().isOccluding())
return Issue.SUFFOCATION;
if (CONTACT_HAZARDS.contains(feet.getType()) || CONTACT_HAZARDS.contains(head.getType()))
return Issue.HAZARD;
return checkLanding(feet, world);
}
public static boolean isUnsafe(Location location) {
return check(location).unsafe();
}
private static Issue checkLanding(Block feet, World world) {
Block block = feet;
int drop = 0;
while (block.getY() >= world.getMinHeight()) {
Material type = block.getType();
// Water breaks a fall of any height; lava ends it.
if (block.isLiquid())
return type == Material.LAVA ? Issue.HAZARD : Issue.NONE;
// Ladders, vines, and scaffolding hold the player in place.
if (Tag.CLIMBABLE.isTagged(type))
return Issue.NONE;
// Anything with collision carries the player: full blocks, slabs, stairs,
// trapdoors, fences, and everything else they can stand on.
if (!block.isPassable()) {
if (SURFACE_HAZARDS.contains(type))
return Issue.HAZARD;
return drop <= MAX_SAFE_FALL_BLOCKS ? Issue.NONE : Issue.LONG_FALL;
}
block = block.getRelative(BlockFace.DOWN);
drop++;
}
return Issue.VOID;
}
public enum Issue {
NONE("safe"), MISSING_WORLD("its world is not loaded"), OUT_OF_BOUNDS("it is outside the world's build limits"),
OUTSIDE_BORDER("it is outside the world border"), SUFFOCATION("it is inside a solid block"),
HAZARD("it is inside or on top of a damaging block"), LONG_FALL("it is a lethal drop above the ground"),
VOID("there is nothing below it");
private final String description;
Issue(String description) {
this.description = description;
}
public String description() {
return description;
}
public boolean unsafe() {
return this != NONE;
}
}
}
@@ -15,9 +15,6 @@ 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;
@@ -28,20 +25,24 @@ 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 static final String AUTOPSY_REASON = "you were left in spectator mode by the OG:SMP autopsy";
private final SpawnOG plugin;
private final AutopsyMigrationStore migrationStore;
private final ReturnLocationStore returnLocationStore;
private final GamemodePolicy gamemodePolicy;
private final MiniMessage miniMessage = MiniMessage.miniMessage();
private final Map<UUID, PendingLogin> pendingLogins = new HashMap<>();
private final List<Consumer<Player>> completionListeners = new ArrayList<>();
public LoginMigrationService(SpawnOG plugin, AutopsyMigrationStore migrationStore) {
public LoginMigrationService(SpawnOG plugin, AutopsyMigrationStore migrationStore,
ReturnLocationStore returnLocationStore, GamemodePolicy gamemodePolicy)
{
this.plugin = plugin;
this.migrationStore = migrationStore;
this.returnLocationStore = returnLocationStore;
this.gamemodePolicy = gamemodePolicy;
}
@@ -53,15 +54,20 @@ public final class LoginMigrationService {
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
// Any non-survival login is normalized unless the player is entitled to
// that gamemode where they logged in; being staff is not enough on its own.
boolean mayKeepGamemode = gamemodePolicy.mayUse(player, originalGamemode);
boolean normalizeGamemode = managedWorld && !mayKeepGamemode
&& plugin.getConfig().getBoolean("login-safety.normalize-non-staff-gamemode", true);
// A sanctioned spectator is working, not an abandoned autopsy state.
boolean autopsyMigration = managedWorld && !staff && !mayKeepGamemode && 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());
LocationSafety.Issue issue = vulnerableAfterLogin ? LocationSafety.check(player.getLocation())
: LocationSafety.Issue.NONE;
boolean unsafe = issue.unsafe();
Location destination = plannedDestination;
if (destination == null && (autopsyMigration || unsafe))
@@ -82,7 +88,7 @@ public final class LoginMigrationService {
}
beginTransaction(player, destination, originalGamemode, normalizeGamemode, autopsyMigration, unsafe);
beginTransaction(player, destination, originalGamemode, normalizeGamemode, autopsyMigration, issue);
return true;
}
@@ -121,7 +127,7 @@ public final class LoginMigrationService {
}
private void beginTransaction(Player player, Location destination, GameMode originalGamemode,
boolean normalizeGamemode, boolean autopsyMigration, boolean unsafe)
boolean normalizeGamemode, boolean autopsyMigration, LocationSafety.Issue issue)
{
UUID playerId = player.getUniqueId();
@@ -129,7 +135,7 @@ public final class LoginMigrationService {
return;
PendingLogin pending = new PendingLogin(player.isInvulnerable(), player.getAllowFlight(), player.isFlying(),
originalGamemode, player.getLocation().clone(), normalizeGamemode, autopsyMigration, unsafe);
originalGamemode, player.getLocation().clone(), normalizeGamemode, autopsyMigration, issue);
pendingLogins.put(playerId, pending);
player.setInvulnerable(true);
@@ -239,6 +245,7 @@ public final class LoginMigrationService {
Placeholder.unparsed("y", String.valueOf(from.getBlockY())),
Placeholder.unparsed("z", String.valueOf(from.getBlockZ())),
Placeholder.unparsed("world", from.getWorld() == null ? "unknown" : from.getWorld().getName()));
offerReturn(player, pending);
} else if (pending.normalizeGamemode()) {
@@ -254,6 +261,7 @@ public final class LoginMigrationService {
Placeholder.unparsed("y", String.valueOf(from.getBlockY())),
Placeholder.unparsed("z", String.valueOf(from.getBlockZ())),
Placeholder.unparsed("world", from.getWorld() == null ? "unknown" : from.getWorld().getName()));
offerReturn(player, pending);
}
@@ -261,6 +269,17 @@ public final class LoginMigrationService {
}
private void offerReturn(Player player, PendingLogin pending) {
String reason = pending.autopsyMigration() ? AUTOPSY_REASON : pending.safetyIssue().description();
if (!returnLocationStore.record(player.getUniqueId(), player.getName(), pending.originalLocation(), reason))
return;
send(player, "locale.returnAvailable",
"<gold>Run <red>/spawnback</red> if you want to go back there anyway.</gold>");
}
private void protectAfterFailedRescue(Player player, GameMode originalGamemode, boolean unsafe) {
if (unsafe && originalGamemode != GameMode.CREATIVE && originalGamemode != GameMode.SPECTATOR) {
@@ -311,23 +330,7 @@ public final class LoginMigrationService {
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();
return LocationSafety.isUnsafe(location);
}
@@ -341,8 +344,15 @@ public final class LoginMigrationService {
private record PendingLogin(boolean originalInvulnerable, boolean originalAllowFlight, boolean originalFlying,
GameMode originalGamemode, Location originalLocation, boolean normalizeGamemode, boolean autopsyMigration,
boolean unsafe)
LocationSafety.Issue safetyIssue)
{
boolean unsafe() {
return safetyIssue.unsafe();
}
}
}
@@ -0,0 +1,104 @@
package spawnog.login;
import java.time.Instant;
import java.util.UUID;
import org.bukkit.Location;
import org.bukkit.World;
import spawnog.SpawnOG;
// Remembers where a player stood before Spawn-OG pulled them to spawn, so they
// can walk it back with /spawnback even though the position was flagged unsafe.
// Persisted, because players usually read the migration message a session or
// two later.
//
// <p>
// Coordinates are stored as plain numbers rather than serialized Locations:
// deserializing a Location whose world is unloaded throws, and that would take
// every other player's record down with it.
public final class ReturnLocationStore extends YamlStore {
public ReturnLocationStore(SpawnOG plugin) {
super(plugin, "return-locations.yml");
}
public boolean record(UUID playerId, String playerName, Location location, String reason) {
if (location == null || location.getWorld() == null)
return false;
String path = path(playerId);
data.set(path + ".player-name", playerName);
data.set(path + ".recorded-at", Instant.now().toString());
data.set(path + ".reason", reason);
data.set(path + ".location.world", location.getWorld().getName());
data.set(path + ".location.x", location.getX());
data.set(path + ".location.y", location.getY());
data.set(path + ".location.z", location.getZ());
data.set(path + ".location.yaw", location.getYaw());
data.set(path + ".location.pitch", location.getPitch());
if (save("return location for " + playerName))
return true;
data.set(path, null);
return false;
}
// True when a record exists, whether or not its world is still loaded.
public boolean has(UUID playerId) {
return data.contains(path(playerId) + ".location");
}
// The stored point, or null when nothing is stored or its world is unloaded.
public ReturnPoint get(UUID playerId) {
String path = path(playerId);
if (!has(playerId))
return null;
World world = plugin.getServer().getWorld(worldName(path));
if (world == null)
return null;
Location location = new Location(world, data.getDouble(path + ".location.x"),
data.getDouble(path + ".location.y"), data.getDouble(path + ".location.z"),
(float) data.getDouble(path + ".location.yaw"), (float) data.getDouble(path + ".location.pitch"));
return new ReturnPoint(location, data.getString(path + ".reason", "it was flagged unsafe"), world.getName());
}
// The world a record points at, readable even when that world is unloaded.
public String worldName(UUID playerId) {
return worldName(path(playerId));
}
public void clear(UUID playerId) {
if (!data.contains(path(playerId)))
return;
data.set(path(playerId), null);
save("cleared return location");
}
private String worldName(String path) {
return data.getString(path + ".location.world", "unknown");
}
public record ReturnPoint(Location location, String reason, String worldName) {
}
}
@@ -0,0 +1,74 @@
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.util.UUID;
import org.bukkit.configuration.file.YamlConfiguration;
import spawnog.SpawnOG;
// Crash-safe YAML file backing the login stores.
abstract class YamlStore {
protected final SpawnOG plugin;
protected final YamlConfiguration data;
private final File file;
protected YamlStore(SpawnOG plugin, String fileName) {
this.plugin = plugin;
this.file = new File(plugin.getDataFolder(), fileName);
this.data = YamlConfiguration.loadConfiguration(file);
}
protected boolean save(String description) {
try {
saveAtomically();
return true;
} catch (IOException error) {
plugin.getLogger().severe("Unable to save the " + description + ": " + error.getMessage());
return false;
}
}
protected String path(UUID playerId) {
return "players." + playerId;
}
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, file.getName() + ".tmp");
data.save(temporaryFile);
try {
Files.move(temporaryFile.toPath(), file.toPath(), StandardCopyOption.ATOMIC_MOVE,
StandardCopyOption.REPLACE_EXISTING);
} catch (AtomicMoveNotSupportedException error) {
Files.move(temporaryFile.toPath(), file.toPath(), StandardCopyOption.REPLACE_EXISTING);
}
}
}
@@ -0,0 +1,17 @@
package spawnog.region;
import java.util.Set;
import org.bukkit.Location;
// Region membership at a location. Kept behind an interface so Spawn-OG can
// answer gamemode questions on servers without WorldGuard installed.
public interface RegionLookup {
// Used when WorldGuard is unavailable: no location is inside any region.
RegionLookup NONE = location -> Set.of();
// Lower-case ids of every region containing the location.
Set<String> regionsAt(Location location);
}
@@ -0,0 +1,29 @@
package spawnog.region;
import java.util.Locale;
import java.util.Set;
import java.util.stream.Collectors;
import com.sk89q.worldedit.bukkit.BukkitAdapter;
import com.sk89q.worldguard.WorldGuard;
import com.sk89q.worldguard.protection.regions.ProtectedRegion;
import com.sk89q.worldguard.protection.regions.RegionContainer;
import org.bukkit.Location;
// WorldGuard-backed lookup. Only instantiated when WorldGuard and WorldEdit are
// both enabled, so its classes are never resolved on servers without them.
public final class WorldGuardRegionLookup implements RegionLookup {
@Override
public Set<String> regionsAt(Location location) {
if (location == null || location.getWorld() == null)
return Set.of();
RegionContainer container = WorldGuard.getInstance().getPlatform().getRegionContainer();
return container.createQuery().getApplicableRegions(BukkitAdapter.adapt(location)).getRegions().stream()
.map(ProtectedRegion::getId).map(id -> id.toLowerCase(Locale.ROOT)).collect(Collectors.toSet());
}
}
@@ -0,0 +1,139 @@
package spawnog.teleport;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
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.Location;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerKickEvent;
import org.bukkit.event.player.PlayerMoveEvent;
import org.bukkit.event.player.PlayerQuitEvent;
import org.bukkit.scheduler.BukkitTask;
import spawnog.SpawnOG;
// Owns the countdown between /spawn and the teleport that follows it. Moving
// out of the block the countdown started in cancels the teleport; taking damage
// does not, so a player being hit while standing still still gets pulled to
// spawn.
public final class SpawnWarmupService implements Listener {
public static final int WARMUP_SECONDS = 5;
private static final long WARMUP_TICKS = 20L * WARMUP_SECONDS;
private final SpawnOG plugin;
private final MiniMessage miniMessage = MiniMessage.miniMessage();
private final Map<UUID, PendingTeleport> pendingTeleports = new HashMap<>();
public SpawnWarmupService(SpawnOG plugin) {
this.plugin = plugin;
}
public void schedule(Player player, Location destination) {
UUID playerId = player.getUniqueId();
PendingTeleport replaced = pendingTeleports.remove(playerId);
if (replaced != null)
replaced.task().cancel();
send(player, "locale.spawnWarmup", "<gold>Teleporting to spawn in <red><seconds> seconds...</red></gold>",
Placeholder.unparsed("seconds", String.valueOf(WARMUP_SECONDS)));
BukkitTask task = plugin.getServer().getScheduler().runTaskLater(plugin, () -> {
// Drop the pending entry before teleporting: PlayerTeleportEvent extends
// PlayerMoveEvent, so leaving it in place would make the teleport cancel
// itself the moment it fires.
pendingTeleports.remove(playerId);
if (player.isOnline())
player.teleportAsync(destination);
}, WARMUP_TICKS);
pendingTeleports.put(playerId, new PendingTeleport(task, player.getLocation().clone()));
}
public boolean isPending(Player player) {
return player != null && pendingTeleports.containsKey(player.getUniqueId());
}
public void cancelSilently(Player player) {
PendingTeleport pending = pendingTeleports.remove(player.getUniqueId());
if (pending != null)
pending.task().cancel();
}
public void close() {
pendingTeleports.values().forEach(pending -> pending.task().cancel());
pendingTeleports.clear();
}
@EventHandler
public void onMove(PlayerMoveEvent event) {
if (pendingTeleports.isEmpty() || event.getTo() == null)
return;
Player player = event.getPlayer();
PendingTeleport pending = pendingTeleports.get(player.getUniqueId());
if (pending == null || !leftOrigin(pending.origin(), event.getTo()))
return;
pendingTeleports.remove(player.getUniqueId());
pending.task().cancel();
send(player, "locale.spawnCancelled", "<red>Teleport to spawn cancelled because you moved.</red>");
}
@EventHandler
public void onQuit(PlayerQuitEvent event) {
cancelSilently(event.getPlayer());
}
@EventHandler
public void onKick(PlayerKickEvent event) {
cancelSilently(event.getPlayer());
}
private boolean leftOrigin(Location origin, Location current) {
// Block granularity, not exact coordinates: looking around or the sub-block
// drift the client sends while standing still must not cancel the teleport.
return origin.getWorld() != current.getWorld() || origin.getBlockX() != current.getBlockX()
|| origin.getBlockY() != current.getBlockY() || origin.getBlockZ() != current.getBlockZ();
}
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 PendingTeleport(BukkitTask task, Location origin) {
}
}
+31 -1
View File
@@ -11,10 +11,28 @@ login-safety:
- world_the_end
autopsy-migration:
enabled: true
# Return every non-survival login to survival unless the player is exempt below.
normalize-non-staff-gamemode: true
remove-flight-on-normalize: true
# Fallback rules, used only when GameModeInventories-OG is absent: it is the
# authority on gamemodes and Spawn-OG asks it first. Holding one of these keeps
# a creative or spectator login, and only inside the creative regions below (or
# with gamemodeinventories.anywhere). Adventure is never kept.
gamemode-exemption-permissions:
creative:
- noclip.use
- gamemodeinventories.toggle
- gamemodeinventories.anywhere
spectator:
- noclip.use
- gamemodeinventories.spectator
# Also fallback only. With GameModeInventories-OG loaded, its creative_regions
# list is what counts.
creative-regions:
- spawn
# LuckPerms-backed permissions. Matching any entry exempts a player from
# spectator migration and creative/adventure-to-survival normalization.
# autopsy migration, so a staff spectator is never recorded as an OG:SMP
# autopsy. Gamemode normalization is governed by the exemptions above.
staff-bypass-permissions:
- spawnog.login-migration.bypass
- staffog.seebroadcast
@@ -26,10 +44,22 @@ flight:
spawn: fly
warzone: nofly
locale:
# <seconds> is replaced with the teleport warmup length.
spawnWarmup: "<gold>Teleporting to spawn in <red><seconds> seconds...</red></gold>"
# Movement cancels the warmup. Taking damage does not.
spawnCancelled: "<red>Teleport to spawn cancelled because you moved.</red>"
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>"
returnAvailable: "<gold>Run <red>/spawnback</red> if you want to go back there anyway.</gold>"
# <reason> explains why the location was flagged. <seconds> is the confirmation window.
returnWarning: "<gold>You were moved from <red><x>, <y>, <z></red> in <world> because <red><reason></red>. Run <red>/spawnback confirm</red> within <seconds> seconds to go back anyway; you may take damage or die.</gold>"
returnConfirmed: "<gold>Returning you to <red><x>, <y>, <z></red> in <world>. Good luck.</gold>"
returnNone: "<red>You have no location to return to.</red>"
returnWorldMissing: "<red>The world you were in (<world>) is not loaded, so you cannot return there.</red>"
returnBusy: "<red>Your login is still being made safe. Try again in a few seconds.</red>"
returnFailed: "<red>Your return teleport failed.</red>"
spawns:
global:
location:
+9 -2
View File
@@ -5,7 +5,7 @@ api-version: '${apiVersion}'
authors: [ kasumabalidps, NotAlexNoyle ]
website: https://github.com/true-og/Spawn-OG
main: spawnog.SpawnOG
softdepend: [LuckPerms, WorldEdit, WorldGuard]
softdepend: [LuckPerms, WorldEdit, WorldGuard, GameModeInventories-OG]
loadbefore: [Essentials, Essentials-OG]
commands:
spawn:
@@ -16,6 +16,10 @@ commands:
description: Set or clear the server spawn point
usage: /setspawn [normalize-view] [normalize-position] | /setspawn unset
permission: spawnog.setspawn
spawnback:
description: Return to the location a login safety migration moved you from
usage: /spawnback [confirm]
permission: spawnog.spawnback
fly:
description: Toggle flight inside a WorldGuard region configured to allow it
usage: /fly
@@ -30,8 +34,11 @@ permissions:
spawnog.setspawn:
description: Use /setspawn
default: false
spawnog.spawnback:
description: Use /spawnback to return to a location a login safety migration moved you from
default: true
spawnog.login-migration.bypass:
description: Exempt a staff member from autopsy migration and login gamemode normalization
description: Exempt a staff member from autopsy migration; gamemode normalization still applies
default: false
spawnog.flight:
description: Use /fly inside regions configured to allow flight