Fix logic

This commit is contained in:
NotAlexNoyle committed 2026-08-17 16:09:20 -04:00
1 parent 976d627169
commit 530a04d82b
9 files changed
+130 -72

No files matched your search

+4 -2
View File
@@ -5,7 +5,9 @@ Safe player entry, spawn management, and WorldGuard regional flight for TrueOG.
## Features
- **Spawn Management:** Allows setting and teleporting to the server spawn point.
- **Login Safety Migration:** Relocates players safely to spawn upon login if they are in a dangerous location, and allows returning via `/spawnback`.
- **Login Safety Migration:** Relocates players safely to spawn upon login if they are in a dangerous location or an unsanctioned gamemode. Every rescue teleport records a way back, and both effects are applied and announced when a rescue and a gamemode normalization coincide.
- **Rescue Undo:** `/spawnback` (warn, then confirm within a configurable window) returns the player to the recorded spot, lifting plain returns above obstructions and refusing spots sealed in solid blocks. Prior regional flight, creative, spectator, or NoClip state is restored where policy still permits it; otherwise the player lands in survival under one-time fall protection.
- **Gamemode Authority Integration:** GameModeInventories-OG is asked first for every gamemode decision; Spawn-OG's own exemption rules are a fallback used only when it is absent.
- **Regional Flight:** Integrates with WorldGuard to allow players to toggle flight (`/fly`) inside designated regions.
- **Broad Compatibility:** Works seamlessly with LuckPerms, WorldEdit, WorldGuard, GameModeInventories-OG, NoClip-OG, MyWorlds, and Essentials-OG.
@@ -23,7 +25,7 @@ Safe player entry, spawn management, and WorldGuard regional flight for TrueOG.
- `spawnog.spawn` - Use `/spawn` on yourself (default: true)
- `spawnog.spawn.others` - Use `/spawn <player>`
- `spawnog.setspawn` - Use `/setspawn`
- `spawnog.spawnback` - Use `/spawnback` to return to your previous location (default: true)
- `spawnog.spawnback` - Use `/spawnback` to return to the location a login safety migration moved you from (default: true)
- `spawnog.login-migration.bypass` - Resume flight in place on relog; exempt from autopsy migration
- `spawnog.flight` - Use `/fly` inside regions configured to allow flight (default: true)
- `spawnog.flight.bypass` - Exempt a player from regional no-flight rules
+10 -10
View File
@@ -1,4 +1,4 @@
/* ------------------------------ Plugins ------------------------------ */
// ------------------------------ Plugins ------------------------------
plugins {
id("java") // Import Java plugin.
id("java-library") // Import Java Library plugin.
@@ -9,7 +9,7 @@ plugins {
id("io.freefair.lombok") version "8.13.1" // Automatic lombok support.
}
/* --------------------------- JDK / Kotlin ---------------------------- */
// --------------------------- JDK / Kotlin ----------------------------
java {
sourceCompatibility = JavaVersion.VERSION_17 // Compile with JDK 17 compatibility.
toolchain { // Select Java toolchain.
@@ -18,14 +18,14 @@ java {
}
}
/* ----------------------------- Metadata ------------------------------ */
// ----------------------------- Metadata ------------------------------
group = "net.trueog.spawn-og" // Declare bundle identifier.
version = "1.2" // Declare plugin version (will be in .jar).
val apiVersion = "1.19" // Declare minecraft server target version.
/* ----------------------------- Resources ----------------------------- */
// ----------------------------- Resources -----------------------------
tasks.named<ProcessResources>("processResources") {
val props = mapOf("version" to version, "apiVersion" to apiVersion)
inputs.properties(props) // Indicates to rerun if version changes.
@@ -33,7 +33,7 @@ tasks.named<ProcessResources>("processResources") {
from("LICENSE") { into("/") } // Bundle licenses into jarfiles.
}
/* ---------------------------- Repos ---------------------------------- */
// ---------------------------- Repos ----------------------------------
repositories {
mavenCentral() // Import the Maven Central Maven Repository.
gradlePluginPortal() // Import the Gradle Plugin Portal Maven Repository.
@@ -42,7 +42,7 @@ repositories {
maven { url = uri("https://maven.enginehub.org/repo/") } // Import the EngineHub Maven Repository.
}
/* ---------------------- Java project deps ---------------------------- */
// ---------------------- 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.
@@ -54,13 +54,13 @@ dependencies {
compileOnlyApi(project(":libs:NoClip-OG")) // Import NoClip-OG gamemode policy (from source).
}
/* ---------------------- Reproducible jars ---------------------------- */
// ---------------------- Reproducible jars ----------------------------
tasks.withType<AbstractArchiveTask>().configureEach { // Ensure reproducible .jars
isPreserveFileTimestamps = false
isReproducibleFileOrder = true
}
/* ----------------------------- Shadow -------------------------------- */
// ----------------------------- Shadow --------------------------------
tasks.shadowJar {
exclude("io.github.miniplaceholders.*") // Exclude the MiniPlaceholders package from being shadowed.
isEnableRelocation = true
@@ -73,7 +73,7 @@ tasks.jar { archiveClassifier.set("part") } // Applies to root jarfile only.
tasks.build { dependsOn(tasks.spotlessApply, tasks.shadowJar) } // Build depends on spotless and shadow.
/* --------------------------- Javac opts ------------------------------- */
// --------------------------- Javac opts -------------------------------
tasks.withType<JavaCompile>().configureEach {
options.compilerArgs.add("-parameters") // Enable reflection for java code.
options.isFork = true // Run javac in its own process.
@@ -81,7 +81,7 @@ tasks.withType<JavaCompile>().configureEach {
options.encoding = "UTF-8" // Use UTF-8 file encoding.
}
/* ----------------------------- Auto Formatting ------------------------ */
// ----------------------------- Auto Formatting ------------------------
spotless {
java {
eclipse().configFile("config/formatter/eclipse-java-formatter.xml") // Eclipse java formatting.
+1 -1
View File
@@ -88,7 +88,7 @@ public final class SpawnOG extends JavaPlugin {
GamemodeAuthority gamemodeAuthority = GameModeInventoriesAuthority.find(this);
GamemodePolicy gamemodePolicy = new GamemodePolicy(this, regionLookup(), gamemodeAuthority);
loginMigrationService = new LoginMigrationService(this, migrationStore, returnLocationStore,
flightSnapshotStore, gamemodePolicy, gamemodeAuthority, managedWorlds, fallProtection);
flightSnapshotStore, gamemodePolicy, managedWorlds, fallProtection);
getServer().getPluginManager().registerEvents(new SpawnListener(this, loginMigrationService, managedWorlds),
this);
@@ -1,12 +1,14 @@
package spawnog.commands;
import java.util.List;
import java.util.Locale;
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.GameMode;
import org.bukkit.Location;
import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
@@ -103,10 +105,11 @@ public final class SpawnBackCommand implements CommandExecutor, TabCompleter, Li
"<gold>You were moved from <red><x>, <y>, <z></red> in <world> because <red><reason></red>. Run <click:run_command:'/spawnback'><hover:show_text:'<gold>Click to run <red>/spawnback</red> again</gold>'><red>/spawnback</red></hover></click> again 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(confirmSeconds)));
// A flyer headed for a spot that no longer permits their flight is
// warned of the drop; a sanctioned live spectator cannot fall.
// A flyer is warned of the drop unless flight or their gamemode comes
// back at the return point; a sanctioned live spectator cannot fall.
if (point.mode() != FlightMode.NONE
&& !flightRestorer.canResumeInPlace(point.mode(), player, point.location()))
&& !flightRestorer.canResumeInPlace(point.mode(), player, point.location())
&& !flightRestorer.canPromoteGamemode(player, point.gamemode(), point.location()))
send(player, "locale.spawnbackFallWarning",
"<gold>You can no longer fly there, so you will fall; a one-time fall protection will cover the landing.</gold>");
return true;
@@ -120,8 +123,17 @@ public final class SpawnBackCommand implements CommandExecutor, TabCompleter, Li
if (point.mode() == FlightMode.NONE) {
Location cleared = LocationSafety.clearAbove(recorded);
if (cleared != null)
destination = cleared;
// A fully sealed column has no survivable spot: refuse and keep the
// record rather than teleporting the player into solid blocks.
if (cleared == null) {
send(player, "locale.returnBlocked",
"<red>The spot you left is sealed inside solid blocks, so you cannot return there right now.</red>");
return true;
}
destination = cleared;
}
@@ -136,6 +148,11 @@ public final class SpawnBackCommand implements CommandExecutor, TabCompleter, Li
plugin.getServer().getScheduler().runTask(plugin, () -> {
// Gone mid-teleport: keep the one-shot record and grant nothing,
// or the fall immunity cleared on quit would leak to the next join.
if (!player.isOnline())
return;
if (!Boolean.TRUE.equals(success) || error != null) {
send(player, "locale.returnFailed", "<red>Your return teleport failed.</red>");
@@ -147,10 +164,29 @@ public final class SpawnBackCommand implements CommandExecutor, TabCompleter, Li
// warning means protection; a sanctioned spectator is left as is.
boolean restored = flightRestorer.canResumeInPlace(point.mode(), player, player.getLocation())
&& flightRestorer.resumeInPlace(point.mode(), player);
// Flight could not come back, but the recorded gamemode still
// might: a standing creative or spectator is promoted where allowed.
GameMode gamemode = point.gamemode();
boolean wantsGamemode = gamemode == GameMode.CREATIVE || gamemode == GameMode.SPECTATOR;
if (!restored && wantsGamemode && flightRestorer.promoteGamemode(player, gamemode)) {
restored = true;
if (gamemode == GameMode.CREATIVE && point.allowFlight() && !player.getAllowFlight())
player.setAllowFlight(true);
if (gamemode == GameMode.CREATIVE && point.flying() && player.getAllowFlight())
player.setFlying(true);
}
if (!restored) {
fallProtection.grant(player);
if (point.mode() != FlightMode.NONE)
if (wantsGamemode)
send(player, "locale.returnGamemodeDenied",
"<gold>You don't have the authority to be in <red><gamemode></red> here, so you stay in survival.</gold>",
Placeholder.unparsed("gamemode", gamemode.name().toLowerCase(Locale.ROOT)));
else if (point.mode() != FlightMode.NONE)
send(player, "locale.returnFlightDenied",
"<gold>You don't have the authority to fly here.</gold>");
@@ -9,13 +9,13 @@ import spawnog.login.GamemodePolicy;
import spawnog.login.NoClipAuthority;
// The single decision table for handing a rescued flyer flight back, shared by
// /spawnback and login so they never disagree; a nofly region outranks all but bypass.
// /spawnback and login so they never disagree; gamemode questions go to policy.
public final class FlightRestorer {
public static final String NOCLIP_PERMISSION = "noclip.use";
// Null when regional flight is off or WorldGuard is absent; every mode is
// then refused, because nothing could enforce a nofly region afterwards.
// Null when regional flight is off or WorldGuard is absent; regional
// survival flight and landing loans are then refused.
private final RegionFlightService regionFlightService;
private final GamemodePolicy gamemodePolicy;
// Null without GameModeInventories-OG; creative is then never restored,
@@ -95,6 +95,35 @@ public final class FlightRestorer {
}
// Whether the gamemode would be handed back at the destination: policy must
// sanction it, and creative needs the authority's inventory swap.
public boolean canPromoteGamemode(Player player, GameMode target, Location destination) {
if (target != GameMode.CREATIVE && target != GameMode.SPECTATOR)
return false;
if (target == GameMode.CREATIVE && gamemodeAuthority == null)
return false;
return gamemodePolicy.mayUse(player, target, destination);
}
// Hands a rescued player back the gamemode they left with, judged live where
// they stand. Creative only through the authority; spectator may go raw.
public boolean promoteGamemode(Player player, GameMode target) {
if (!canPromoteGamemode(player, target, player.getLocation()))
return false;
if (player.getGameMode() == target)
return true;
if (gamemodeAuthority != null)
return gamemodeAuthority.changeGameMode(player, target);
player.setGameMode(target);
return player.getGameMode() == target;
}
// The narrowest flight covering a descent when nothing can be restored: a
// landing loan the flight service revokes on touchdown.
public boolean loanFlight(Player player) {
@@ -110,17 +139,11 @@ public final class FlightRestorer {
}
// Creative flight needs GMI's sanction for the gamemode and the region
// rules' for the flight: anywhere licenses creative, a nofly still grounds it.
// Creative flight is gamemode derived: the flight service never grounds a
// creative flyer, so region rules and their bypass have no say here.
private boolean creativeFlightPermitted(Player player, Location destination) {
if (regionFlightService == null || gamemodeAuthority == null)
return false;
if (!gamemodePolicy.mayUse(player, GameMode.CREATIVE, destination))
return false;
return regionFlightService.ruleKindAt(destination) != RegionFlightService.RuleKind.NOFLY
|| regionFlightService.hasBypass(player);
return gamemodeAuthority != null && gamemodePolicy.mayUse(player, GameMode.CREATIVE, destination);
}
@@ -303,9 +303,8 @@ public final class RegionFlightService implements Listener {
if (releaseGamemodeFlyer(player))
return;
// The LOWEST-priority quit snapshot already holds the airborne evidence,
// so grounding loses nothing. Loan before override: a loan is never saved as
// owned.
// The LOWEST quit snapshot already holds the airborne evidence, so grounding
// loses nothing. Loan before override: a loan is never saved as owned.
revokeLandingGrant(player);
restoreOverride(player, flightOverrides.get(player.getUniqueId()));
@@ -41,9 +41,6 @@ public final class LoginMigrationService {
private final ReturnLocationStore returnLocationStore;
private final FlightSnapshotStore flightSnapshotStore;
private final GamemodePolicy gamemodePolicy;
// Null without GameModeInventories-OG; creative is then never promoted back
// after a rescue, because only its machinery swaps inventories safely.
private final GamemodeAuthority gamemodeAuthority;
private final ManagedWorlds managedWorlds;
private final FallProtection fallProtection;
private final MiniMessage miniMessage = MiniMessage.miniMessage();
@@ -55,8 +52,7 @@ public final class LoginMigrationService {
public LoginMigrationService(SpawnOG plugin, AutopsyMigrationStore migrationStore,
ReturnLocationStore returnLocationStore, FlightSnapshotStore flightSnapshotStore,
GamemodePolicy gamemodePolicy, GamemodeAuthority gamemodeAuthority, ManagedWorlds managedWorlds,
FallProtection fallProtection)
GamemodePolicy gamemodePolicy, ManagedWorlds managedWorlds, FallProtection fallProtection)
{
this.plugin = plugin;
@@ -64,7 +60,6 @@ public final class LoginMigrationService {
this.returnLocationStore = returnLocationStore;
this.flightSnapshotStore = flightSnapshotStore;
this.gamemodePolicy = gamemodePolicy;
this.gamemodeAuthority = gamemodeAuthority;
this.managedWorlds = managedWorlds;
this.fallProtection = fallProtection;
@@ -183,6 +178,10 @@ public final class LoginMigrationService {
normalizeGamemode = false;
autopsyMigration = false;
// With the rescue reasons gone and the spot safe, a rescue-only
// destination has nothing left to justify the teleport.
if (plannedDestination == null && !unsafe)
destination = null;
}
@@ -358,6 +357,9 @@ public final class LoginMigrationService {
// The snapshot is only spent once the way back is safely on disk.
if (offerReturn(player, pending))
flightSnapshotStore.clear(player.getUniqueId());
if (pending.normalizeGamemode())
send(player, "locale.gamemodeNormalized",
"<gold>Your login gamemode was safely returned to survival.</gold>");
} else if (pending.autopsyMigration()) {
@@ -382,21 +384,26 @@ public final class LoginMigrationService {
Placeholder.unparsed("world", from.getWorld() == null ? "unknown" : from.getWorld().getName()));
offerReturn(player, pending);
} else if (pending.normalizeGamemode()) {
} else {
send(player, "locale.gamemodeNormalized",
"<gold>Your login gamemode was safely returned to survival.</gold>");
// Not exclusive: a rescue away from an unsafe spot and a gamemode
// normalization can both apply, and each deserves its own notice.
if (pending.unsafe()) {
} 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()));
offerReturn(player, pending);
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()));
offerReturn(player, pending);
}
if (pending.normalizeGamemode())
send(player, "locale.gamemodeNormalized",
"<gold>Your login gamemode was safely returned to survival.</gold>");
}
@@ -416,18 +423,7 @@ public final class LoginMigrationService {
if (!gamemodePolicy.mayUse(player, original, player.getLocation()))
return;
boolean promoted;
if (gamemodeAuthority != null)
promoted = gamemodeAuthority.changeGameMode(player, original);
else if (original == GameMode.SPECTATOR) {
player.setGameMode(original);
promoted = player.getGameMode() == original;
} else
promoted = false;
if (!promoted)
if (flightRestorer == null || !flightRestorer.promoteGamemode(player, original))
plugin.getLogger().warning("Unable to return " + player.getName() + " to " + original.name()
+ " after their rescue; they remain in " + player.getGameMode().name() + ".");
@@ -44,8 +44,8 @@ public final class ReturnLocationStore extends YamlStore {
data.set(path + ".location.z", location.getZ());
data.set(path + ".location.yaw", location.getYaw());
data.set(path + ".location.pitch", location.getPitch());
// The mode decides what /spawnback hands back; raw gamemode and ability
// are kept alongside so staff can see what a player actually lost.
// The mode decides what flight /spawnback hands back; gamemode and the
// ability bits let it re-promote a standing creative or spectator.
data.set(path + ".mode", mode == null ? FlightMode.NONE.name() : mode.name());
data.set(path + ".gamemode", gamemode == null ? null : gamemode.name());
data.set(path + ".allow-flight", allowFlight);
@@ -152,8 +152,8 @@ public final class ReturnLocationStore extends YamlStore {
}
// mode is what /spawnback restores; gamemode/allowFlight are diagnostic.
// token ties a warning and its confirmation to the same rescue.
// mode is the flight /spawnback restores; gamemode/allowFlight drive the
// gamemode promotion. token ties a warning and its confirmation together.
public record ReturnPoint(Location location, String reason, String worldName, GameMode gamemode,
boolean allowFlight, boolean flying, FlightMode mode, String token)
{
+6 -4
View File
@@ -18,10 +18,8 @@ login-safety:
remove-flight-on-normalize: true
# Seconds of invulnerability when a rescue cannot run, so a failure never leaves a player stuck or dead.
failed-rescue-grace-seconds: 30
# 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.
# Fallback rules, used only when GameModeInventories-OG (the gamemode authority) is absent.
# Holding one of these keeps a creative or spectator login inside the creative regions below (or with gamemodeinventories.anywhere); adventure is never kept.
gamemode-exemption-permissions:
creative:
- noclip.use
@@ -75,6 +73,10 @@ locale:
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>"
# Sent when the recorded gamemode is refused at the return point, so the player stays in survival.
returnGamemodeDenied: "<gold>You don't have the authority to be in <red><gamemode></red> here, so you stay in survival.</gold>"
# Sent when the recorded spot is sealed by solid blocks all the way up; the return is refused and kept.
returnBlocked: "<red>The spot you left is sealed inside solid blocks, so you cannot return there right now.</red>"
spawns:
global:
location: