Fix restore permissions

This commit is contained in:
NotAlexNoyle committed 2026-09-10 13:14:21 -04:00
1 parent 3fe8454038
commit 06c50c2a8e
15 files changed
+466 -44

No files matched your search

+3 -1
View File
@@ -13,6 +13,8 @@ Safe player entry, spawn management, and WorldGuard regional flight for [TrueOG
- **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.
## Commands
| Command | Description | Permission |
@@ -26,7 +28,7 @@ Safe player entry, spawn management, and WorldGuard regional flight for [TrueOG
- `spawnog.spawn` (default: true), `spawnog.spawn.others`, `spawnog.setspawn`
- `spawnog.spawnback` (default: true)
- `spawnog.login-migration.bypass` - Resume flight in place on relog; exempt from autopsy migration
- `spawnog.login-migration.bypass` - Exempt from the one-time autopsy migration; independent of mode and flight restoration
- `spawnog.flight` (default: true), `spawnog.flight.bypass`, `spawnog.flight.*`
- *(Legacy aliases: `wgamemode.fly`, `wgamemode.fly.bypass`, `wgamemode.*`)*
+1 -1
View File
@@ -1,4 +1,4 @@
#!/usr/bin/bash
# Fetch all submodule content.
git submodule update --force --recursive --init
git submodule update --recursive --init
+8
View File
@@ -52,8 +52,16 @@ dependencies {
project(":libs:GameModeInventories-OG")
) // Import GameModeInventories-OG gamemode policy (from source).
compileOnlyApi(project(":libs:NoClip-OG")) // Import NoClip-OG gamemode policy (from source).
testImplementation("org.purpurmc.purpur:purpur-api:1.19.4-R0.1-SNAPSHOT")
testImplementation(project(":libs:NoClip-OG"))
testImplementation("org.junit.jupiter:junit-jupiter:5.10.2")
testImplementation("org.mockito:mockito-core:5.14.2")
testRuntimeOnly("org.junit.platform:junit-platform-launcher:1.10.2")
testRuntimeOnly("net.luckperms:api:5.4")
}
tasks.test { useJUnitPlatform() }
// ---------------------- Reproducible jars ----------------------------
tasks.withType<AbstractArchiveTask>().configureEach { // Ensure reproducible .jars
isPreserveFileTimestamps = false
+1 -1
View File
@@ -132,7 +132,7 @@ public final class SpawnOG extends JavaPlugin {
+ spawnog.login.LocationSafety.check(globalSpawn).description() + ".");
// One decision table for handing flight back, shared by the login
// bypass path and /spawnback so warning and outcome cannot disagree.
// restore path and /spawnback so warning and outcome cannot disagree.
FlightRestorer flightRestorer = new FlightRestorer(regionFlightService, gamemodePolicy, gamemodeAuthority,
noClipAuthority);
loginMigrationService.setFlightRestorer(flightRestorer);
@@ -110,6 +110,10 @@ public final class SpawnBackCommand implements CommandExecutor, TabCompleter, Li
}
// A NoClip spectator phase still needs /gmic authority. Apply the same
// interpretation to old records in both the warning and the return.
final GameMode returnGamemode = point.mode() == FlightMode.NOCLIP ? GameMode.CREATIVE : point.gamemode();
// 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
@@ -150,7 +154,7 @@ public final class SpawnBackCommand implements CommandExecutor, TabCompleter, Li
// back at the return point; a sanctioned live spectator cannot fall.
if (point.mode() != FlightMode.NONE
&& !flightRestorer.canResumeInPlace(point.mode(), player, point.location())
&& !flightRestorer.canPromoteGamemode(player, point.gamemode(), point.location()))
&& !flightRestorer.canPromoteGamemode(player, returnGamemode, 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;
@@ -186,11 +190,11 @@ public final class SpawnBackCommand implements CommandExecutor, TabCompleter, Li
// Re-checked live at the destination: a right lost since the
// warning means protection; a sanctioned spectator is left as is.
boolean restored = flightRestorer.canResumeInPlace(point.mode(), player, player.getLocation())
&& flightRestorer.resumeInPlace(point.mode(), player);
&& flightRestorer.resumeInPlace(point.mode(), player, point.flying());
// Flight could not come back, but the recorded gamemode still
// might: a standing creative or spectator is promoted where allowed.
GameMode gamemode = point.gamemode();
GameMode gamemode = returnGamemode;
boolean wantsGamemode = gamemode == GameMode.CREATIVE || gamemode == GameMode.SPECTATOR;
if (!restored && wantsGamemode && flightRestorer.promoteGamemode(player, gamemode)) {
@@ -1,5 +1,6 @@
package spawnog.flight;
import org.bukkit.GameMode;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
@@ -40,13 +41,15 @@ public final class FlightQuitListener implements Listener {
}
// Last quit wins: a flying player writes a snapshot, a grounded one erases
// any leftover, so one record can never answer for two sessions.
// Last quit wins. NoClip has a toggle even while grounded; its persisted
// intent also survives a NoClip plugin reload before this capture.
public void capture(Player player) {
if (player.isFlying())
snapshotStore.record(player, noClipAuthority != null && noClipAuthority.isNoClipping(player.getUniqueId()),
flightIntentStore.contains(player.getUniqueId()));
final boolean noclip = noClipAuthority != null
&& (player.getGameMode() == GameMode.CREATIVE || player.getGameMode() == GameMode.SPECTATOR)
&& (noClipAuthority.isNoClipping(player.getUniqueId()) || noClipAuthority.hasNoClipIntent(player));
if (player.isFlying() || noclip)
snapshotStore.record(player, noclip, flightIntentStore.contains(player.getUniqueId()));
// A pending rescue holds the airborne evidence: the player is not
// flying mid-transaction, and clearing would erase the retry.
else if (loginMigrationService == null || !loginMigrationService.isPending(player))
@@ -60,14 +60,20 @@ public final class FlightRestorer {
// Rights re-checked live; false means the caller owes fall protection.
public boolean restore(FlightMode mode, Player player) {
return restore(mode, player, true);
}
public boolean restore(FlightMode mode, Player player, boolean flying) {
if (player == null || !canRestore(mode, player, player.getLocation()))
return false;
return switch (mode) {
case FLY -> regionFlightService.grantRegionFlight(player);
case GMIC -> restoreCreative(player);
case NOCLIP -> restoreCreative(player) && noClipAuthority.enterNoClip(player);
case GMIC -> restoreCreative(player, flying);
case NOCLIP -> restoreCreative(player, flying) && noClipAuthority.enterNoClip(player);
case SPECTATOR, NONE -> false;
};
@@ -88,10 +94,16 @@ public final class FlightRestorer {
public boolean resumeInPlace(FlightMode mode, Player player) {
return resumeInPlace(mode, player, true);
}
public boolean resumeInPlace(FlightMode mode, Player player, boolean flying) {
if (mode == FlightMode.SPECTATOR)
return true;
return restore(mode, player);
return restore(mode, player, flying);
}
@@ -149,13 +161,13 @@ public final class FlightRestorer {
// Creative through the authority so the inventory swap runs. Abilities are
// re-asserted, not trusted; setFlying throws while allow-flight is off.
private boolean restoreCreative(Player player) {
private boolean restoreCreative(Player player, boolean flying) {
if (!gamemodeAuthority.changeGameMode(player, GameMode.CREATIVE))
return false;
player.setAllowFlight(true);
player.setFlying(true);
player.setFlying(flying);
return true;
}
@@ -11,8 +11,8 @@ import org.bukkit.entity.Player;
import spawnog.SpawnOG;
import spawnog.login.YamlStore;
// Persistent record of who left mid-flight: where, gamemode, and kind of
// flight. Last quit wins: a grounded quit erases any stale airborne record.
// Persistent record of who left mid-flight or with NoClip enabled: where,
// gamemode, and kind of flight. Grounded NoClip retains flying=false.
public final class FlightSnapshotStore extends YamlStore {
public FlightSnapshotStore(SpawnOG plugin) {
@@ -33,6 +33,7 @@ public final class LoginMigrationService {
private static final String AUTOPSY_REASON = "you were left in spectator mode by the OG:SMP autopsy";
private static final String AIRBORNE_REASON = "you logged out mid-flight";
private static final String NOCLIP_REASON = "you logged out with NoClip enabled";
private static final String GAMEMODE_REASON = "your login gamemode is not permitted there";
private static final int DEFAULT_GRACE_SECONDS = 30;
// Logins restore the exact quit position; this slack only absorbs
@@ -105,7 +106,7 @@ public final class LoginMigrationService {
} else
return handleAirborneLogin(player, plannedDestination, snapshot);
return handleRecordedLogin(player, plannedDestination, snapshot);
}
@@ -113,15 +114,15 @@ public final class LoginMigrationService {
}
// A relog in flight: resumed on the spot when still permitted there and the
// bypass is held, otherwise a pull to spawn with /spawnback as the way back.
private boolean handleAirborneLogin(Player player, Location plannedDestination, FlightSnapshot snapshot) {
// Restore from the mode's own permissions and location rules. A configured
// join destination still takes precedence; restoration then happens there.
private boolean handleRecordedLogin(Player player, Location plannedDestination, FlightSnapshot snapshot) {
FlightMode mode = snapshot.mode();
if (player.hasPermission(BYPASS_PERMISSION) && flightRestorer != null
if (plannedDestination == null && flightRestorer != null
&& flightRestorer.canResumeInPlace(mode, player, player.getLocation())
&& flightRestorer.resumeInPlace(mode, player))
&& flightRestorer.resumeInPlace(mode, player, snapshot.flying()))
{
player.setFallDistance(0.0F);
@@ -134,7 +135,7 @@ public final class LoginMigrationService {
if (player.getAllowFlight()) {
if (!player.isFlying() && !LocationSafety.isSupported(player.getLocation()))
if (snapshot.flying() && !player.isFlying() && !LocationSafety.isSupported(player.getLocation()))
player.setFlying(true);
// Spent only once the resume has actually held for a tick.
flightSnapshotStore.clear(player.getUniqueId());
@@ -149,7 +150,11 @@ public final class LoginMigrationService {
}
});
send(player, "locale.flightResumed", "<gold>Welcome back. Your flight was resumed where you left.</gold>");
if (mode == FlightMode.NOCLIP)
send(player, "locale.noclipResumed", "<gold>Welcome back. Your NoClip mode was restored.</gold>");
else
send(player, "locale.flightResumed",
"<gold>Welcome back. Your flight was resumed where you left.</gold>");
return true;
}
@@ -160,7 +165,9 @@ public final class LoginMigrationService {
// over water would evade gamemode normalization entirely.
GameMode liveGamemode = player.getGameMode();
boolean gamemodeFlyer = liveGamemode == GameMode.CREATIVE || liveGamemode == GameMode.SPECTATOR;
if (plannedDestination == null && (keepsGamemodeFlight(player)
// If NoClip could not resume, retain its toggle through the rescue and
// return record instead of discarding it just because the player can land.
if (mode != FlightMode.NOCLIP && plannedDestination == null && (keepsGamemodeFlight(player)
|| !gamemodeFlyer && !LocationSafety.check(player.getLocation()).unsafe()))
{
@@ -186,7 +193,9 @@ public final class LoginMigrationService {
// Corroboration pinned the snapshot to this login: the recorded spot and
// gamemode are the ones the player carries right now.
GameMode originalGamemode = snapshot.gamemode();
// Spectator during /nc is an implementation detail, not independent
// spectator authority. The underlying /gmic creative policy still applies.
GameMode originalGamemode = mode == FlightMode.NOCLIP ? GameMode.CREATIVE : snapshot.gamemode();
Location origin = snapshot.location().clone();
// Judged at the rescue destination, same as a grounded login: a creative
// flyer rescued into the spawn creative region keeps creative.
@@ -435,17 +444,24 @@ public final class LoginMigrationService {
}
// Force-gamemode demotes non-survival quitters before login, so skipping
// normalization is not enough: the gamemode must be actively handed back.
promoteGamemode(player, pending);
// A configured join teleport or rescue can land where the saved mode
// is allowed. Re-check there and restore through its own authority.
final boolean restoredMode = pending.snapshot() != null && flightRestorer != null
&& flightRestorer.restore(pending.snapshot().mode(), player, pending.originalFlying());
// Force-gamemode can demote quitters before login. If the full mode is
// unavailable, the underlying gamemode may still be permitted here.
if (!restoredMode)
promoteGamemode(player, pending);
if (pending.snapshot() != null) {
// A rescued flyer may be dropped a few blocks at spawn; cover it.
fallProtection.grant(player);
Location from = pending.originalLocation();
send(player, "locale.flyingRescue",
"<gold>You logged out mid-flight at <red><x>, <y>, <z></red> in <world> and were brought to spawn.</gold>",
final boolean noclip = pending.snapshot().noclip();
send(player, noclip ? "locale.noclipRescue" : "locale.flyingRescue", noclip
? "<gold>You logged out with NoClip enabled at <red><x>, <y>, <z></red> in <world> and were brought to spawn.</gold>"
: "<gold>You logged out mid-flight at <red><x>, <y>, <z></red> in <world> and were brought to spawn.</gold>",
Placeholder.unparsed("x", String.valueOf(from.getBlockX())),
Placeholder.unparsed("y", String.valueOf(from.getBlockY())),
Placeholder.unparsed("z", String.valueOf(from.getBlockZ())),
@@ -550,7 +566,7 @@ public final class LoginMigrationService {
String reason;
if (pending.snapshot() != null)
reason = AIRBORNE_REASON;
reason = pending.snapshot().noclip() ? NOCLIP_REASON : AIRBORNE_REASON;
else if (pending.autopsyMigration())
reason = AUTOPSY_REASON;
else if (pending.gamemodeRelocation())
@@ -663,8 +679,8 @@ public final class LoginMigrationService {
}
// Same spot and still off the ground, or stale. Gamemode equality is NOT
// required (force-gamemode), nor live flight bits (/fly is revoked on quit).
// Require the same spot and either flight evidence or a saved NoClip toggle.
// Force-gamemode can change the mode; /fly revocation can change abilities.
private boolean corroborates(Player player, FlightSnapshot snapshot) {
Location recorded = snapshot.location();
@@ -676,7 +692,8 @@ public final class LoginMigrationService {
if (snapshot.gamemode() == null)
return false;
return player.isFlying() || player.getAllowFlight() || !LocationSafety.isSupported(current);
return snapshot.noclip() || player.isFlying() || player.getAllowFlight()
|| !LocationSafety.isSupported(current);
}
@@ -709,7 +726,7 @@ public final class LoginMigrationService {
{
}
// snapshot is non-null only for an airborne rescue, and then carries the
// snapshot is non-null for an airborne or NoClip rescue, and carries the
// state the quit captured; the original* fields describe the same moment.
private record PendingLogin(boolean originalAllowFlight, boolean originalFlying, GameMode originalGamemode,
Location originalLocation, boolean normalizeGamemode, boolean autopsyMigration, boolean gamemodeRelocation,
@@ -38,7 +38,14 @@ public final class NoClipAuthority {
}
// Re-engages /nc for a player already in creative or spectator. NoClip-OG
public boolean hasNoClipIntent(Player player) {
final NoClip noClip = running();
return noClip != null && noClip.getManager().hasNoClipIntent(player);
}
// Re-engages /nc for a player already in creative. NoClip-OG
// checks the gamemode and the permission itself.
public boolean enterNoClip(Player player) {
+7 -3
View File
@@ -32,8 +32,10 @@ login-safety:
# list is what counts.
creative-regions:
- spawn
# Holders of spawnog.login-migration.bypass resume flight in place on relog when
# still permitted to fly there, and are exempt from autopsy migration.
# spawnog.login-migration.bypass exempts players from autopsy migration only.
# Saved /gmic, /nc (including grounded toggles), and /fly are restored according
# to their own permissions and location rules, independently of that bypass.
# A configured join teleport is honored; modes are re-checked at the destination.
flight:
enabled: true
regions:
@@ -59,8 +61,10 @@ locale:
unsafeLogin: "<gold>You logged in at an unsafe location (<red><x>, <y>, <z></red> in <world>) and were teleported to spawn.</gold>"
# Sent to a player rescued to spawn because they logged out mid-flight.
flyingRescue: "<gold>You logged out mid-flight at <red><x>, <y>, <z></red> in <world> and were brought to spawn.</gold>"
# Sent when a bypass holder's flight is resumed in place instead of a rescue.
# Sent when permitted flight is resumed in place instead of a rescue.
flightResumed: "<gold>Welcome back. Your flight was resumed where you left.</gold>"
noclipResumed: "<gold>Welcome back. Your NoClip mode was restored.</gold>"
noclipRescue: "<gold>You logged out with NoClip enabled at <red><x>, <y>, <z></red> in <world> and were brought 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>"
# Sent when a login gamemode is refused where the player logged in but permitted at spawn, so they are pulled there and keep it.
+1 -1
View File
@@ -38,7 +38,7 @@ permissions:
description: Use /spawnback to return to a location a login safety migration moved you from
default: true
spawnog.login-migration.bypass:
description: Resume flight in place on relog when still permitted there; exempt from autopsy migration
description: Exempt from the one-time autopsy login migration
default: false
spawnog.flight:
description: Use /fly inside regions configured to allow flight
@@ -0,0 +1,122 @@
package spawnog.flight;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import java.util.UUID;
import org.bukkit.GameMode;
import org.bukkit.Location;
import org.bukkit.entity.Player;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.InOrder;
import spawnog.SpawnOG;
import spawnog.login.GamemodeAuthority;
import spawnog.login.GamemodePolicy;
import spawnog.login.LoginMigrationService;
import spawnog.login.NoClipAuthority;
class NoClipRestorationTest {
private final Player player = mock(Player.class);
private final Location location = new Location(null, 1, 64, 1);
private final GamemodePolicy policy = mock(GamemodePolicy.class);
private final GamemodeAuthority gmi = mock(GamemodeAuthority.class);
private final NoClipAuthority noclip = mock(NoClipAuthority.class);
private final FlightRestorer restorer = new FlightRestorer(null, policy, gmi, noclip);
@BeforeEach
void setUp() {
when(player.getUniqueId()).thenReturn(UUID.randomUUID());
when(player.getLocation()).thenReturn(location);
when(player.hasPermission("noclip.use")).thenReturn(true);
when(policy.mayUse(player, GameMode.CREATIVE, location)).thenReturn(true);
when(gmi.changeGameMode(player, GameMode.CREATIVE)).thenReturn(true);
when(noclip.enterNoClip(player)).thenReturn(true);
}
@Test
void groundedNoClipRestoresCreativeThroughGmiBeforeEnablingToggle() {
assertTrue(restorer.resumeInPlace(FlightMode.NOCLIP, player, false));
final InOrder order = inOrder(gmi, noclip);
order.verify(gmi).changeGameMode(player, GameMode.CREATIVE);
order.verify(noclip).enterNoClip(player);
verify(player).setAllowFlight(true);
verify(player).setFlying(false);
verify(player, never()).setFlying(true);
}
@Test
void lostCreativeAuthorityCannotBeReplacedBySpectatorPermission() {
when(player.getGameMode()).thenReturn(GameMode.SPECTATOR);
when(policy.mayUse(player, GameMode.CREATIVE, location)).thenReturn(false);
when(policy.mayUse(player, GameMode.SPECTATOR, location)).thenReturn(true);
assertFalse(restorer.canResumeInPlace(FlightMode.NOCLIP, player, location));
assertFalse(restorer.restore(FlightMode.NOCLIP, player));
verifyNoInteractions(gmi, noclip);
}
@Test
void missingNoclipPermissionOrPluginRefusesRestoration() {
when(player.hasPermission("noclip.use")).thenReturn(false);
assertFalse(restorer.restore(FlightMode.NOCLIP, player));
when(player.hasPermission("noclip.use")).thenReturn(true);
assertFalse(new FlightRestorer(null, policy, gmi, null).restore(FlightMode.NOCLIP, player));
assertFalse(new FlightRestorer(null, policy, null, noclip).restore(FlightMode.NOCLIP, player));
verifyNoInteractions(gmi, noclip);
}
@Test
void cancelledCreativeChangeCannotActivateNoClip() {
when(gmi.changeGameMode(player, GameMode.CREATIVE)).thenReturn(false);
assertFalse(restorer.restore(FlightMode.NOCLIP, player));
verifyNoInteractions(noclip);
verify(player, never()).setAllowFlight(true);
}
@Test
void groundedIntentSurvivingReloadIsCapturedForNextLogin() {
when(player.getGameMode()).thenReturn(GameMode.CREATIVE);
when(noclip.hasNoClipIntent(player)).thenReturn(true);
final FlightSnapshotStore snapshots = mock(FlightSnapshotStore.class);
final FlightIntentStore flightIntent = mock(FlightIntentStore.class);
final FlightQuitListener listener = new FlightQuitListener(mock(SpawnOG.class), snapshots, flightIntent,
mock(LoginMigrationService.class), noclip);
listener.capture(player);
verify(snapshots).record(player, true, false);
verify(snapshots, never()).clear(player.getUniqueId());
}
@Test
void staleIntentInSurvivalDoesNotBecomeNoClipSnapshot() {
when(player.getGameMode()).thenReturn(GameMode.SURVIVAL);
when(noclip.hasNoClipIntent(player)).thenReturn(true);
final FlightSnapshotStore snapshots = mock(FlightSnapshotStore.class);
final FlightQuitListener listener = new FlightQuitListener(mock(SpawnOG.class), snapshots,
mock(FlightIntentStore.class), mock(LoginMigrationService.class), noclip);
listener.capture(player);
verify(snapshots).clear(player.getUniqueId());
verify(snapshots, never()).record(player, true, false);
}
}
@@ -0,0 +1,243 @@
package spawnog.login;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.anyString;
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 java.util.stream.Stream;
import org.bukkit.GameMode;
import org.bukkit.Location;
import org.bukkit.Server;
import org.bukkit.World;
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.BeforeEach;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.mockito.MockedStatic;
import spawnog.SpawnOG;
import spawnog.flight.FlightMode;
import spawnog.flight.FlightRestorer;
import spawnog.flight.FlightSnapshotStore;
import spawnog.flight.FlightSnapshotStore.FlightSnapshot;
import spawnog.teleport.FallProtection;
import spawnog.world.ManagedWorlds;
class FlightLoginTest {
private final SpawnOG plugin = mock(SpawnOG.class);
private final Player player = mock(Player.class);
private final World world = mock(World.class);
private final Location origin = new Location(world, 1, 64, 1);
private final Location spawn = new Location(world, 100, 64, 100);
private final FlightSnapshotStore snapshots = mock(FlightSnapshotStore.class);
private final ReturnLocationStore returns = mock(ReturnLocationStore.class);
private final FlightRestorer restorer = mock(FlightRestorer.class);
private final GamemodePolicy policy = mock(GamemodePolicy.class);
private final BukkitScheduler scheduler = mock(BukkitScheduler.class);
private final List<Runnable> tasks = new ArrayList<>();
private final CompletableFuture<Boolean> teleport = new CompletableFuture<>();
private LoginMigrationService service;
private MockedStatic<MinigameArenas> arenas;
private MockedStatic<LocationSafety> safety;
@BeforeEach
void setUp() {
arenas = mockStatic(MinigameArenas.class);
safety = mockStatic(LocationSafety.class);
safety.when(() -> LocationSafety.check(origin)).thenReturn(LocationSafety.Issue.LONG_FALL);
safety.when(() -> LocationSafety.check(spawn)).thenReturn(LocationSafety.Issue.NONE);
final YamlConfiguration config = new YamlConfiguration();
config.set("spawns.global.location", spawn);
when(plugin.getConfig()).thenReturn(config);
when(plugin.getLogger()).thenReturn(Logger.getLogger("FlightLoginTest"));
when(plugin.isEnabled()).thenReturn(true);
final Server server = mock(Server.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(player.getUniqueId()).thenReturn(UUID.randomUUID());
when(player.getName()).thenReturn("Builder");
when(player.getWorld()).thenReturn(world);
when(world.getName()).thenReturn("world");
when(player.getLocation()).thenReturn(origin);
when(player.isOnline()).thenReturn(true);
when(player.getGameMode()).thenReturn(GameMode.CREATIVE);
when(player.teleportAsync(any(Location.class))).thenReturn(teleport);
when(snapshots.peek(player.getUniqueId()))
.thenReturn(new FlightSnapshot(GameMode.CREATIVE, origin, true, false, true, false));
when(policy.mayUse(eq(player), eq(GameMode.CREATIVE), any(Location.class))).thenReturn(true);
when(returns.record(any(UUID.class), anyString(), any(Location.class), anyString(), any(GameMode.class),
anyBoolean(), anyBoolean(), any(FlightMode.class))).thenReturn(true);
final ManagedWorlds managed = mock(ManagedWorlds.class);
when(managed.contains(world)).thenReturn(true);
service = new LoginMigrationService(plugin, mock(AutopsyMigrationStore.class), returns, snapshots, policy,
mock(GamemodeAuthority.class), managed, mock(FallProtection.class), mock(LoginShield.class));
service.setFlightRestorer(restorer);
}
@AfterEach
void tearDown() {
arenas.close();
safety.close();
}
@ParameterizedTest
@MethodSource("restorationCases")
void permittedModeResumesRegardlessOfMigrationBypass(FlightMode mode, boolean flying, boolean bypass) {
snapshot(mode, flying);
when(player.getGameMode()).thenReturn(GameMode.SURVIVAL);
when(player.hasPermission(LoginMigrationService.BYPASS_PERMISSION)).thenReturn(bypass);
when(restorer.canResumeInPlace(mode, player, origin)).thenReturn(true);
when(restorer.resumeInPlace(mode, player, flying)).thenReturn(true);
assertTrue(service.handleLogin(player, null));
verify(restorer).resumeInPlace(mode, player, flying);
verify(player, never()).teleportAsync(any(Location.class));
verify(player, never()).hasPermission(LoginMigrationService.BYPASS_PERMISSION);
// The snapshot must survive until the scheduled verification of abilities.
verify(snapshots, never()).clear(player.getUniqueId());
when(player.getAllowFlight()).thenReturn(true);
runTasks();
verify(snapshots).clear(player.getUniqueId());
}
@Test
void unavailableGroundedNoClipRescuesAndRecordsTheToggle() {
assertTrue(service.handleLogin(player, null));
verify(player).teleportAsync(spawn);
verify(returns).record(player.getUniqueId(), "Builder", origin, "you logged out with NoClip enabled",
GameMode.CREATIVE, true, false, FlightMode.NOCLIP);
verify(restorer, never()).resumeInPlace(any(FlightMode.class), eq(player), anyBoolean());
}
@ParameterizedTest
@MethodSource("restorationCases")
void deniedModeCannotResumeRegardlessOfMigrationBypass(FlightMode mode, boolean flying, boolean bypass) {
snapshot(mode, flying);
when(player.hasPermission(LoginMigrationService.BYPASS_PERMISSION)).thenReturn(bypass);
when(policy.mayUse(player, GameMode.CREATIVE, origin)).thenReturn(false);
assertTrue(service.handleLogin(player, null));
verify(player).teleportAsync(spawn);
verify(restorer, never()).resumeInPlace(any(FlightMode.class), eq(player), anyBoolean());
verify(player, never()).hasPermission(LoginMigrationService.BYPASS_PERMISSION);
}
@ParameterizedTest
@MethodSource("restorationCases")
void configuredJoinTeleportRestoresPermittedModeAtDestination(FlightMode mode, boolean flying, boolean bypass) {
snapshot(mode, flying);
when(player.getGameMode()).thenReturn(GameMode.SURVIVAL);
when(player.hasPermission(LoginMigrationService.BYPASS_PERMISSION)).thenReturn(bypass);
when(restorer.canResumeInPlace(mode, player, origin)).thenReturn(true);
when(restorer.restore(mode, player, flying)).thenAnswer(invocation -> {
org.junit.jupiter.api.Assertions.assertEquals(spawn, player.getLocation());
return true;
});
assertTrue(service.handleLogin(player, spawn));
verify(player).teleportAsync(spawn);
verify(restorer, never()).resumeInPlace(any(FlightMode.class), eq(player), anyBoolean());
verify(restorer, never()).restore(mode, player, flying);
when(player.getLocation()).thenReturn(spawn);
teleport.complete(true);
runTasks();
verify(restorer).restore(mode, player, flying);
verify(player, never()).hasPermission(LoginMigrationService.BYPASS_PERMISSION);
verify(snapshots).clear(player.getUniqueId());
}
@ParameterizedTest
@MethodSource("restorationCases")
void destinationDenialCannotBeOverriddenByMigrationBypass(FlightMode mode, boolean flying, boolean bypass) {
snapshot(mode, flying);
when(player.getGameMode()).thenReturn(GameMode.SURVIVAL);
when(player.hasPermission(LoginMigrationService.BYPASS_PERMISSION)).thenReturn(bypass);
when(policy.mayUse(player, GameMode.CREATIVE, spawn)).thenReturn(false);
when(restorer.canResumeInPlace(mode, player, origin)).thenReturn(true);
assertTrue(service.handleLogin(player, spawn));
when(player.getLocation()).thenReturn(spawn);
teleport.complete(true);
runTasks();
verify(restorer).restore(mode, player, flying);
verify(restorer, never()).resumeInPlace(any(FlightMode.class), eq(player), anyBoolean());
verify(player, never()).hasPermission(LoginMigrationService.BYPASS_PERMISSION);
verify(player, never()).setAllowFlight(true);
verify(player, never()).setFlying(true);
}
@Test
void spectatorPhaseIsRecordedAsCreativeForReturnPolicy() {
when(player.getGameMode()).thenReturn(GameMode.SPECTATOR);
when(snapshots.peek(player.getUniqueId()))
.thenReturn(new FlightSnapshot(GameMode.SPECTATOR, origin, true, true, true, false));
assertTrue(service.handleLogin(player, null));
verify(returns).record(player.getUniqueId(), "Builder", origin, "you logged out with NoClip enabled",
GameMode.CREATIVE, true, true, FlightMode.NOCLIP);
verify(policy).mayUse(player, GameMode.CREATIVE, spawn);
}
private void snapshot(FlightMode mode, boolean flying) {
final GameMode gamemode = mode == FlightMode.FLY ? GameMode.SURVIVAL : GameMode.CREATIVE;
when(player.getGameMode()).thenReturn(gamemode);
when(snapshots.peek(player.getUniqueId())).thenReturn(
new FlightSnapshot(gamemode, origin, true, flying, mode == FlightMode.NOCLIP, mode == FlightMode.FLY));
}
private void runTasks() {
while (!tasks.isEmpty())
tasks.remove(0).run();
}
private static Stream<Arguments> restorationCases() {
return Stream.of(false, true)
.flatMap(bypass -> Stream.of(Arguments.of(FlightMode.NOCLIP, false, bypass),
Arguments.of(FlightMode.NOCLIP, true, bypass), Arguments.of(FlightMode.GMIC, true, bypass),
Arguments.of(FlightMode.FLY, true, bypass)));
}
}