Fix re-entry persistence after /spawnback
This commit is contained in:
1 parent
06c50c2a8e
commit
3db76e00b8
8 files changed
+267
-6
No files matched your search
@@ -7,13 +7,13 @@ Safe player entry, spawn management, and WorldGuard regional flight for [TrueOG
|
||||
- **Spawn Management:** Global, per-group (LuckPerms), and first-join spawns. The global spawn is synced into MyWorlds.
|
||||
- **Teleport Warmup:** `/spawn` on yourself has a five-second countdown that moving cancels; `/spawn <player>` pulls the target instantly.
|
||||
- **Login Safety Migration:** Players logging in at dangerous spots, in unsanctioned gamemodes, or unauthorized mid-flight are rescued to spawn. Every rescue records a way back before the move and explains why it happened.
|
||||
- **Rescue Undo:** `/spawnback` (warn, then confirm) returns the player, lifting obstructed or hazardous spots and refusing sealed ones. Prior flight or gamemode is restored where policy permits; otherwise the player lands in survival under fall protection.
|
||||
- **Rescue Undo:** `/spawnback` (warn, then confirm) returns the player, lifting obstructed or hazardous spots and refusing sealed ones. Prior flight or gamemode is restored where policy permits; a survival flyer whose spot no longer allows it keeps flight until they land, and everyone else lands in survival under fall protection. What the return restores is read from the record, so a toggle flipped after the offer cannot cost the player their state.
|
||||
- **Fall Protection:** One-shot immunity covering exactly the fall the plugin caused.
|
||||
- **Gamemode Authority:** GameModeInventories-OG decides all gamemode questions; Spawn-OG owns flight. Built-in exemption rules are a fallback only.
|
||||
- **Regional Flight:** `/fly` inside WorldGuard regions marked `fly`; higher priority wins, ties are deny-first. The toggle survives restarts, and plugin-granted flight survives reloads without escaping tracking.
|
||||
- **Compatibility:** LuckPerms, WorldEdit, WorldGuard, GameModeInventories-OG, NoClip-OG, MyWorlds, Essentials-OG.
|
||||
|
||||
NoClip sessions are recorded even while grounded. In managed worlds with login safety enabled, saved `/gmic`, `/nc`, and `/fly` modes are restored according to their own permissions and location rules. NoClip requires GameModeInventories-OG to permit creative and the player to have `noclip.use`; `/fly` requires flight permission and an allowed flight region. Configured join teleports are honored, and restoration checks the final destination. `/spawnback` applies the same rules. NoClip's temporary spectator phase uses creative's rules, and restoration preserves whether the player was flying. Install the updated NoClip-OG alongside Spawn-OG for the persistent-toggle API.
|
||||
NoClip sessions are recorded even while grounded, and a rescue with no mid-flight snapshot records the `/fly` and `/nc` toggles the player still holds. In managed worlds with login safety enabled, saved `/gmic`, `/nc`, and `/fly` modes are restored according to their own permissions and location rules. NoClip requires GameModeInventories-OG to permit creative and the player to have `noclip.use`; `/fly` requires flight permission and an allowed flight region. Configured join teleports are honored, and restoration checks the final destination. `/spawnback` applies the same rules. NoClip's temporary spectator phase uses creative's rules, and restoration preserves whether the player was flying. Install the updated NoClip-OG alongside Spawn-OG for the persistent-toggle API.
|
||||
|
||||
## Commands
|
||||
|
||||
|
||||
@@ -114,6 +114,15 @@ public final class SpawnBackCommand implements CommandExecutor, TabCompleter, Li
|
||||
// interpretation to old records in both the warning and the return.
|
||||
final GameMode returnGamemode = point.mode() == FlightMode.NOCLIP ? GameMode.CREATIVE : point.gamemode();
|
||||
|
||||
// What the record proves they held where they left, whatever they have
|
||||
// toggled since: the return answers to the record, not to live state.
|
||||
final boolean heldFlight = point.mode() != FlightMode.NONE || point.allowFlight() || point.flying();
|
||||
// Survival flight is theirs to switch on again, so a lapsed toggle may be
|
||||
// lent back. Creative and spectator answer to policy, which already spoke.
|
||||
final boolean heldRegionalFlight = (point.mode() == FlightMode.FLY
|
||||
|| point.mode() == FlightMode.NONE && point.allowFlight()) && returnGamemode != GameMode.CREATIVE
|
||||
&& returnGamemode != GameMode.SPECTATOR;
|
||||
|
||||
// A plain return may be built over since the rescue, so it is lifted to
|
||||
// the nearest clear spot above; flight returns keep the exact position
|
||||
// unless it is now inside solid or damaging blocks. Judged before the
|
||||
@@ -152,11 +161,19 @@ public final class SpawnBackCommand implements CommandExecutor, TabCompleter, Li
|
||||
Placeholder.unparsed("seconds", String.valueOf(confirmSeconds)));
|
||||
// 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())
|
||||
if (heldFlight && !flightRestorer.canResumeInPlace(point.mode(), player, point.location())
|
||||
&& !flightRestorer.canPromoteGamemode(player, returnGamemode, point.location()))
|
||||
{
|
||||
|
||||
if (heldRegionalFlight && flightRestorer.canLoanFlight(player, point.location()))
|
||||
send(player, "locale.spawnbackLoanWarning",
|
||||
"<gold>You can no longer hold flight there, so you keep it only until you land.</gold>");
|
||||
else
|
||||
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;
|
||||
|
||||
}
|
||||
@@ -206,7 +223,14 @@ public final class SpawnBackCommand implements CommandExecutor, TabCompleter, Li
|
||||
|
||||
}
|
||||
|
||||
if (!restored) {
|
||||
// A toggle flipped after the offer must not turn the way back
|
||||
// into a drop: a proven flyer is lent flight until they land.
|
||||
boolean gamemodeFlight = player.getGameMode() == GameMode.CREATIVE
|
||||
|| player.getGameMode() == GameMode.SPECTATOR;
|
||||
boolean loaned = !restored && heldRegionalFlight && !gamemodeFlight && flightRestorer.loanFlight(player)
|
||||
&& player.getAllowFlight();
|
||||
|
||||
if (!restored && !loaned) {
|
||||
|
||||
// Inert while another authority still allows flight;
|
||||
// granted only when the landing can actually hurt.
|
||||
@@ -220,8 +244,18 @@ public final class SpawnBackCommand implements CommandExecutor, TabCompleter, Li
|
||||
send(player, "locale.returnFlightDenied",
|
||||
"<gold>You don't have the authority to fly here.</gold>");
|
||||
|
||||
} else if (loaned) {
|
||||
|
||||
send(player, "locale.returnFlightLoaned",
|
||||
"<gold>You cannot hold flight here, so you keep it only until you land.</gold>");
|
||||
|
||||
}
|
||||
|
||||
// The toggle is state too: a return that handed flight back
|
||||
// re-arms /fly, so the next flight region behaves as before.
|
||||
if (point.mode() == FlightMode.FLY && (restored || loaned))
|
||||
flightRestorer.rememberFlightIntent(player);
|
||||
|
||||
// Cleared only after the restore attempt, so the one-shot record
|
||||
// is never burned before its flight decision has been made.
|
||||
returnLocationStore.clear(playerId);
|
||||
|
||||
@@ -144,6 +144,40 @@ public final class FlightRestorer {
|
||||
|
||||
}
|
||||
|
||||
// Whether that loan could be handed out at the destination: flight
|
||||
// permission, and a spot no nofly rule denies them.
|
||||
public boolean canLoanFlight(Player player, Location destination) {
|
||||
|
||||
if (regionFlightService == null || player == null || !regionFlightService.hasFlightPermission(player))
|
||||
return false;
|
||||
|
||||
return regionFlightService.ruleKindAt(destination) != RegionFlightService.RuleKind.NOFLY
|
||||
|| regionFlightService.hasBypass(player);
|
||||
|
||||
}
|
||||
|
||||
// The mode a player's persisted toggles amount to right now. A rescue with
|
||||
// no airborne snapshot records this, so a grounded /fly is still remembered.
|
||||
public FlightMode liveMode(Player player, GameMode gamemode) {
|
||||
|
||||
if (player == null)
|
||||
return FlightMode.NONE;
|
||||
|
||||
boolean noclip = noClipAuthority != null && noClipAuthority.hasNoClipIntent(player);
|
||||
return FlightMode.classify(gamemode, noclip,
|
||||
regionFlightService != null && regionFlightService.hasFlightIntent(player));
|
||||
|
||||
}
|
||||
|
||||
// Puts the /fly toggle itself back after a restore honored it, so the
|
||||
// choice survives a return that landed outside a flight region.
|
||||
public void rememberFlightIntent(Player player) {
|
||||
|
||||
if (regionFlightService != null)
|
||||
regionFlightService.rememberFlightIntent(player);
|
||||
|
||||
}
|
||||
|
||||
private boolean flyPermitted(Player player, Location destination) {
|
||||
|
||||
return regionFlightService != null && regionFlightService.hasFlightPermission(player)
|
||||
|
||||
@@ -666,6 +666,23 @@ public final class RegionFlightService implements Listener {
|
||||
|
||||
}
|
||||
|
||||
// Whether /fly is switched on for this player, live ability aside: a quit
|
||||
// or a rescue reads it to record which kind of flight they were holding.
|
||||
public boolean hasFlightIntent(Player player) {
|
||||
|
||||
return player != null && flightIntentStore.contains(player.getUniqueId());
|
||||
|
||||
}
|
||||
|
||||
// Re-arms the /fly toggle a restore just honored, so flight comes back on
|
||||
// the next flight region even when this spot could not grant it.
|
||||
public void rememberFlightIntent(Player player) {
|
||||
|
||||
if (player != null && hasFlightPermission(player))
|
||||
flightIntentStore.record(player);
|
||||
|
||||
}
|
||||
|
||||
private void revokeLandingGrant(Player player, org.bukkit.Location at) {
|
||||
|
||||
if (!landingGrants.remove(player.getUniqueId()))
|
||||
|
||||
@@ -574,7 +574,15 @@ public final class LoginMigrationService {
|
||||
else
|
||||
reason = pending.safetyIssue().description();
|
||||
|
||||
FlightMode mode = pending.snapshot() == null ? FlightMode.NONE : pending.snapshot().mode();
|
||||
// Without an airborne snapshot the toggles the player still holds are
|
||||
// the record: a grounded /fly or NoClip is state /spawnback must return.
|
||||
FlightMode mode;
|
||||
if (pending.snapshot() != null)
|
||||
mode = pending.snapshot().mode();
|
||||
else
|
||||
mode = flightRestorer == null ? FlightMode.NONE
|
||||
: flightRestorer.liveMode(player, pending.originalGamemode());
|
||||
|
||||
return returnLocationStore.record(player.getUniqueId(), player.getName(), pending.originalLocation(), reason,
|
||||
pending.originalGamemode(), pending.originalAllowFlight(), pending.originalFlying(), mode);
|
||||
|
||||
|
||||
@@ -86,12 +86,16 @@ locale:
|
||||
spawnbackWarning: "<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>"
|
||||
# Appended to the warning when the player's flight cannot be restored there.
|
||||
spawnbackFallWarning: "<gold>You can no longer fly there, so you will fall; a one-time fall protection will cover the landing.</gold>"
|
||||
# Appended instead when flight can only be lent for the descent.
|
||||
spawnbackLoanWarning: "<gold>You can no longer hold flight there, so you keep it only until you land.</gold>"
|
||||
spawnbackExecuted: "<gold>Returning you to <red><x>, <y>, <z></red> in <world>. Good luck.</gold>"
|
||||
# Sent when a plain return had to move up to the nearest clear spot above the recorded location.
|
||||
spawnbackAdjusted: "<gold>The spot you left was no longer safe to stand in, so you were placed at the nearest safe location above it.</gold>"
|
||||
# Sent when the return point forbids the player's prior mode of flight.
|
||||
# Fall damage is cancelled once on any return that restores no flight.
|
||||
returnFlightDenied: "<gold>You don't have the authority to fly here.</gold>"
|
||||
# Sent when the return point lends flight for the descent instead of restoring it.
|
||||
returnFlightLoaned: "<gold>You cannot hold flight here, so you keep it only until you land.</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>"
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
package spawnog.commands;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.logging.Logger;
|
||||
import org.bukkit.GameMode;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.command.Command;
|
||||
import org.bukkit.configuration.file.YamlConfiguration;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.scheduler.BukkitScheduler;
|
||||
import org.bukkit.scheduler.BukkitTask;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.MockedStatic;
|
||||
import spawnog.SpawnOG;
|
||||
import spawnog.flight.FlightMode;
|
||||
import spawnog.flight.FlightRestorer;
|
||||
import spawnog.login.LocationSafety;
|
||||
import spawnog.login.LoginMigrationService;
|
||||
import spawnog.login.ReturnLocationStore;
|
||||
import spawnog.login.ReturnLocationStore.ReturnPoint;
|
||||
import spawnog.teleport.FallProtection;
|
||||
|
||||
class SpawnBackReturnTest {
|
||||
|
||||
private final SpawnOG plugin = mock(SpawnOG.class);
|
||||
private final Player player = mock(Player.class);
|
||||
private final Command command = mock(Command.class);
|
||||
private final World world = mock(World.class);
|
||||
private final Location origin = new Location(world, 1, 200, 1);
|
||||
private final ReturnLocationStore returns = mock(ReturnLocationStore.class);
|
||||
private final LoginMigrationService migration = mock(LoginMigrationService.class);
|
||||
private final FlightRestorer restorer = mock(FlightRestorer.class);
|
||||
private final FallProtection fallProtection = mock(FallProtection.class);
|
||||
private final List<Runnable> tasks = new ArrayList<>();
|
||||
private final CompletableFuture<Boolean> teleport = new CompletableFuture<>();
|
||||
private final UUID playerId = UUID.randomUUID();
|
||||
private SpawnBackCommand spawnBack;
|
||||
private MockedStatic<LocationSafety> safety;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
|
||||
safety = mockStatic(LocationSafety.class);
|
||||
safety.when(() -> LocationSafety.check(any(Location.class))).thenReturn(LocationSafety.Issue.NONE);
|
||||
when(plugin.getConfig()).thenReturn(new YamlConfiguration());
|
||||
when(plugin.getLogger()).thenReturn(Logger.getLogger("SpawnBackReturnTest"));
|
||||
when(plugin.isEnabled()).thenReturn(true);
|
||||
final Server server = mock(Server.class);
|
||||
final BukkitScheduler scheduler = mock(BukkitScheduler.class);
|
||||
when(plugin.getServer()).thenReturn(server);
|
||||
when(server.getScheduler()).thenReturn(scheduler);
|
||||
when(scheduler.runTask(eq(plugin), any(Runnable.class))).thenAnswer(invocation -> {
|
||||
|
||||
tasks.add(invocation.getArgument(1));
|
||||
return mock(BukkitTask.class);
|
||||
|
||||
});
|
||||
when(world.getName()).thenReturn("world");
|
||||
when(player.getUniqueId()).thenReturn(playerId);
|
||||
when(player.getName()).thenReturn("Builder");
|
||||
when(player.isOnline()).thenReturn(true);
|
||||
when(player.getLocation()).thenReturn(origin);
|
||||
when(player.getGameMode()).thenReturn(GameMode.SURVIVAL);
|
||||
when(player.teleportAsync(any(Location.class))).thenReturn(teleport);
|
||||
when(command.getPermission()).thenReturn("spawnog.spawnback");
|
||||
when(player.hasPermission("spawnog.spawnback")).thenReturn(true);
|
||||
when(migration.isPending(player)).thenReturn(false);
|
||||
when(returns.get(playerId)).thenReturn(new ReturnPoint(origin, "you logged out mid-flight", "world",
|
||||
GameMode.SURVIVAL, true, true, FlightMode.FLY, "token"));
|
||||
spawnBack = new SpawnBackCommand(plugin, returns, migration, restorer, fallProtection);
|
||||
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
|
||||
safety.close();
|
||||
|
||||
}
|
||||
|
||||
@Test
|
||||
void lapsedFlightToggleIsLentBackInsteadOfDropped() {
|
||||
|
||||
when(restorer.canResumeInPlace(FlightMode.FLY, player, origin)).thenReturn(false);
|
||||
when(restorer.canPromoteGamemode(player, GameMode.SURVIVAL, origin)).thenReturn(false);
|
||||
when(restorer.loanFlight(player)).thenAnswer(invocation -> {
|
||||
|
||||
when(player.getAllowFlight()).thenReturn(true);
|
||||
return true;
|
||||
|
||||
});
|
||||
|
||||
run();
|
||||
run();
|
||||
teleport.complete(true);
|
||||
runTasks();
|
||||
|
||||
verify(restorer).loanFlight(player);
|
||||
verify(restorer).rememberFlightIntent(player);
|
||||
verify(fallProtection, never()).grant(player);
|
||||
verify(returns).clear(playerId);
|
||||
|
||||
}
|
||||
|
||||
@Test
|
||||
void aGroundedReturnWithNothingToRestoreStillCoversTheLanding() {
|
||||
|
||||
when(returns.get(playerId)).thenReturn(new ReturnPoint(origin, "it is a lethal drop above the ground", "world",
|
||||
GameMode.SURVIVAL, false, false, FlightMode.NONE, "token"));
|
||||
safety.when(() -> LocationSafety.clearAbove(any(Location.class))).thenReturn(origin);
|
||||
|
||||
run();
|
||||
run();
|
||||
teleport.complete(true);
|
||||
runTasks();
|
||||
|
||||
verify(restorer, never()).loanFlight(player);
|
||||
verify(fallProtection).grant(player);
|
||||
verify(returns).clear(playerId);
|
||||
|
||||
}
|
||||
|
||||
private void run() {
|
||||
|
||||
assertTrue(spawnBack.onCommand(player, command, "spawnback", new String[0]));
|
||||
|
||||
}
|
||||
|
||||
private void runTasks() {
|
||||
|
||||
while (!tasks.isEmpty())
|
||||
tasks.remove(0).run();
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -202,6 +202,18 @@ class FlightLoginTest {
|
||||
|
||||
}
|
||||
|
||||
@Test
|
||||
void groundedFlightToggleIsRecordedForTheReturn() {
|
||||
|
||||
when(snapshots.peek(player.getUniqueId())).thenReturn(null);
|
||||
when(player.getGameMode()).thenReturn(GameMode.SURVIVAL);
|
||||
when(restorer.liveMode(player, GameMode.SURVIVAL)).thenReturn(FlightMode.FLY);
|
||||
assertTrue(service.handleLogin(player, null));
|
||||
verify(returns).record(player.getUniqueId(), "Builder", origin, "it is a lethal drop above the ground",
|
||||
GameMode.SURVIVAL, false, false, FlightMode.FLY);
|
||||
|
||||
}
|
||||
|
||||
@Test
|
||||
void spectatorPhaseIsRecordedAsCreativeForReturnPolicy() {
|
||||
|
||||
|
||||
Reference in new issue
Block a user