2 Commits
Author SHA1 Message Date
dmg 8a9640d992 fix(teleport): require same dimension for visits
Release / release (push) Successful in 2m24s
CI / build (push) Successful in 1m2s
2026-08-21 22:36:10 -04:00
dmg 3be3e43072 fix(teleport): enforce visitor warm-up
Release / release (push) Successful in 2m28s
CI / build (push) Successful in 1m3s
2026-08-21 22:28:34 -04:00
5 changed files with 93 additions and 3 deletions
@@ -21,7 +21,8 @@ As a **player with Base III**, I want to open my base to visitors so that other
- [x] `/gotobase <owner>` autocompletes bases that the requesting player is currently eligible to visit.
- [x] `/visit <owner>` aliases `/gotobase <owner>` with identical autocomplete, including eligible bases whose owners are offline.
- [x] Enabled bases remain visitable while their owners are offline.
- [x] A visitor teleport uses the destination owner's current warm-up tier.
- [x] `/visit` and `/gotobase` require the visitor to be in the destination base's recorded world; cross-dimension requests are rejected before warm-up with a clear message and without consuming cooldown.
- [x] A visitor teleport uses the destination owner's current warm-up tier, but always requires at least a one-second stationary warm-up; `/visit` and `/gotobase` never teleport instantly.
- [x] Looking around is permitted, while movement between block coordinates, damage, teleportation, world change, death, logout, or a conflicting teleport cancels the visitor warm-up.
- [x] Cancellation or destination failure does not consume a visitor cooldown.
- [x] A safe destination is resolved at or near the owner's recorded base center.
@@ -83,6 +83,13 @@ final class BaseTeleportManager implements Listener {
visitor.sendMessage(ChatColor.RED + "That base is not accepting visitors.");
return;
}
BaseLocation destination = owner.base().orElseThrow();
if (!visitor.getWorld().getUID().equals(destination.worldId())) {
visitor.sendMessage(
ChatColor.RED + "You must be in the same dimension as that base to visit it."
);
return;
}
PlayerState visitorState = stateManager.player(visitor.getUniqueId(), visitor.getName());
Optional<Duration> remaining = visitorPolicy.remaining(
visitorState, owner.playerId(), clock.instant()
@@ -94,8 +101,8 @@ final class BaseTeleportManager implements Listener {
}
begin(
visitor,
owner.base().orElseThrow(),
policy.warmup(owner),
destination,
policy.visitorWarmup(owner),
owner.playerId(),
policy.cooldown(owner),
"visit " + owner.latestName() + "'s base"
@@ -25,6 +25,13 @@ public final class TeleportPolicy {
};
}
public Duration visitorWarmup(PlayerState owner) {
Duration warmup = warmup(owner);
return warmup.compareTo(Duration.ofSeconds(1)) < 0
? Duration.ofSeconds(1)
: warmup;
}
public Duration cooldown(PlayerState player) {
return switch (player.cooldownLevel()) {
case 0 -> Duration.ofSeconds(settings.current().initialTeleportCooldownSeconds());
@@ -0,0 +1,64 @@
package games.dmg.spigotbase;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.Optional;
import java.util.UUID;
import org.bukkit.ChatColor;
import org.bukkit.World;
import org.bukkit.entity.Player;
import org.bukkit.plugin.Plugin;
import org.junit.jupiter.api.Test;
final class BaseTeleportManagerTest {
@Test
void visitFromAnotherWorldIsRejectedBeforeWarmupAndCooldownChecks() {
UUID visitorId = UUID.randomUUID();
UUID ownerId = UUID.randomUUID();
UUID baseWorldId = UUID.randomUUID();
BaseStateManager stateManager = mock(BaseStateManager.class);
TeleportPolicy teleportPolicy = mock(TeleportPolicy.class);
VisitorPolicy visitorPolicy = mock(VisitorPolicy.class);
Player visitor = mock(Player.class);
World visitorWorld = mock(World.class);
PlayerState owner = PlayerState.newPlayer(ownerId, "Owner")
.withGrassAndDirtProgress(500, 4)
.withBase(
new BaseLocation(baseWorldId, "world", 0, 64, 0, 0, 0),
Instant.EPOCH
)
.withVisitorsEnabled(true);
PlayerState visitorState = PlayerState.newPlayer(visitorId, "Visitor");
BaseTeleportManager manager = new BaseTeleportManager(
mock(Plugin.class),
stateManager,
teleportPolicy,
visitorPolicy,
mock(SafeBaseDestination.class),
Clock.systemUTC()
);
when(visitor.getUniqueId()).thenReturn(visitorId);
when(visitor.getName()).thenReturn("Visitor");
when(visitor.getWorld()).thenReturn(visitorWorld);
when(visitorWorld.getUID()).thenReturn(UUID.randomUUID());
when(stateManager.player(visitorId, "Visitor")).thenReturn(visitorState);
when(visitorPolicy.remaining(eq(visitorState), eq(ownerId), any()))
.thenReturn(Optional.of(Duration.ofSeconds(1)));
manager.startVisit(visitor, owner);
verify(visitor).sendMessage(
ChatColor.RED + "You must be in the same dimension as that base to visit it."
);
verifyNoInteractions(stateManager, teleportPolicy, visitorPolicy);
}
}
@@ -22,6 +22,17 @@ final class TeleportPolicyTest {
assertEquals(Duration.ZERO, policy.warmup(player.withTeleportLevels(3, 0)));
}
@Test
void visitorWarmupHasOneSecondMinimum() {
PlayerState player = baseThreePlayer();
assertEquals(Duration.ofSeconds(30), policy.visitorWarmup(player));
assertEquals(
Duration.ofSeconds(1),
policy.visitorWarmup(player.withTeleportLevels(3, 0))
);
}
@Test
void cooldownLevelsUseApprovedDurations() {
PlayerState player = baseThreePlayer();