Fix restore permissions
This commit is contained in:
1 parent
3fe8454038
commit
06c50c2a8e
15 files changed
+466
-44
No files matched your search
@@ -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
@@ -1,4 +1,4 @@
|
||||
#!/usr/bin/bash
|
||||
|
||||
# Fetch all submodule content.
|
||||
git submodule update --force --recursive --init
|
||||
git submodule update --recursive --init
|
||||
@@ -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
Submodule libs/NoClip-OG updated: c3a5c1f5a0...e1a928d90a.
@@ -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) {
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)));
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
Reference in new issue
Block a user