Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions src/main/java/world/bentobox/chunkblock/Settings.java
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,18 @@ public class Settings implements WorldSettings {
@ConfigEntry(path = "chunkblock.max-chunks")
private int maxChunks = 441;

@ConfigComment("Require confirmation before level credit is spent on a chunk. When true, the")
@ConfigComment("first hit on the border previews the target chunk and its cost; the player")
@ConfigComment("must then sneak and hit the border again to actually claim it. This stops a")
@ConfigComment("stray swing near the border from spending levels by accident.")
@ConfigEntry(path = "chunkblock.claim.require-confirmation")
private boolean requireClaimConfirmation = true;

@ConfigComment("How long, in seconds, a previewed chunk stays confirmable. After this the")
@ConfigComment("player has to hit the border again to preview it afresh. Minimum 1.")
@ConfigEntry(path = "chunkblock.claim.confirmation-timeout")
private int claimConfirmationTimeout = 15;

@ConfigComment("If true, losing island levels below what has been spent re-locks chunks in")
@ConfigComment("reverse claim order (the most recently claimed chunks are lost first). Builds")
@ConfigComment("inside re-locked chunks are untouched but cannot be reached until the levels")
Expand Down Expand Up @@ -2569,6 +2581,34 @@ public void setMaxChunks(int maxChunks) {
this.maxChunks = maxChunks;
}

/**
* @return true if a chunk must be previewed and confirmed before credit is spent
*/
public boolean isRequireClaimConfirmation() {
return requireClaimConfirmation;
}

/**
* @param requireClaimConfirmation the requireClaimConfirmation to set
*/
public void setRequireClaimConfirmation(boolean requireClaimConfirmation) {
this.requireClaimConfirmation = requireClaimConfirmation;
}

/**
* @return how long a previewed chunk stays confirmable in seconds, never less than 1
*/
public int getClaimConfirmationTimeout() {
return Math.max(1, claimConfirmationTimeout);
}

/**
* @param claimConfirmationTimeout the claimConfirmationTimeout to set
*/
public void setClaimConfirmationTimeout(int claimConfirmationTimeout) {
this.claimConfirmationTimeout = claimConfirmationTimeout;
}

/**
* @return true if chunks re-lock when island level drops
*/
Expand Down
69 changes: 69 additions & 0 deletions src/main/java/world/bentobox/chunkblock/chunks/BorderDisplay.java
Original file line number Diff line number Diff line change
Expand Up @@ -49,15 +49,25 @@ public class BorderDisplay implements Listener {
private static final int OUT_OF_WORLD_DEPTH = 16;
/** Dust color when the curtain is beyond the world height limits */
private static final Color OUT_OF_WORLD_COLOR = Color.ORANGE;
/** Dust color for a chunk a player has lined up to claim but not yet confirmed */
private static final Color PREVIEW_COLOR = Color.YELLOW;
/** Heights above the viewer's feet at which the preview outline is drawn */
private static final int[] PREVIEW_HEIGHTS = { 0, 3, 6 };

private final ChunkBlock addon;
/** Client-side barrier blocks sent per player, with the original data for restore */
private final Map<UUID, Set<BarrierBlock>> barrierBlocks = new HashMap<>();
/** Chunks outlined for a player pending claim confirmation */
private final Map<UUID, Preview> previews = new HashMap<>();
private BukkitTask task;

private record BarrierBlock(Location location, BlockData oldData) {
}

/** A pending claim outline: world chunk coordinates and when to stop drawing it */
private record Preview(int chunkX, int chunkZ, long expiry) {
}

public BorderDisplay(ChunkBlock addon) {
this.addon = addon;
}
Expand All @@ -80,13 +90,15 @@ public void stop() {
barrierBlocks.keySet().stream().map(Bukkit::getPlayer).filter(java.util.Objects::nonNull)
.toList().forEach(this::hideBorder);
barrierBlocks.clear();
previews.clear();
}

private void redrawAll() {
for (World world : Bukkit.getWorlds()) {
if (addon.inWorld(world)) {
for (Player player : world.getPlayers()) {
showBorder(player);
drawPreview(player);
}
}
}
Expand Down Expand Up @@ -227,6 +239,61 @@ public void hideBorder(Player player) {
}
}

/**
* Outlines a chunk in yellow for one player until the given time, marking it as lined
* up for claiming but not yet paid for. Only that player sees it, and the outline
* disappears by itself when the confirmation window closes.
*
* @param player the player about to spend credit
* @param chunkX world chunk x coordinate of the previewed chunk
* @param chunkZ world chunk z coordinate of the previewed chunk
* @param expiry when to stop drawing, in {@link System#currentTimeMillis()} terms
*/
public void showPreview(Player player, int chunkX, int chunkZ, long expiry) {
previews.put(player.getUniqueId(), new Preview(chunkX, chunkZ, expiry));
drawPreview(player);
}

/**
* Stops outlining whatever chunk this player had lined up.
*
* @param uuid the player's UUID
*/
public void clearPreview(UUID uuid) {
previews.remove(uuid);
}

/**
* Draws the pending claim outline for one player, if they have one that has not run out
* of time. The box is drawn around the player's own height so it reads as a wall even
* when the terrain beyond the border is far above or below them.
*/
private void drawPreview(Player player) {
Preview preview = previews.get(player.getUniqueId());
if (preview == null) {
return;
}
if (System.currentTimeMillis() > preview.expiry()) {
previews.remove(player.getUniqueId());
return;
}
// The outline traces the chunk's boundary planes, so the corners meet exactly where
// the four walls of the locked-chunk curtain would
double minX = preview.chunkX() << 4;
double minZ = preview.chunkZ() << 4;
double baseY = player.getLocation().getBlockY() + 0.5D;
Particle.DustOptions dust = new Particle.DustOptions(PREVIEW_COLOR, 1.5F);
for (int height : PREVIEW_HEIGHTS) {
double y = baseY + height;
for (int i = 0; i <= 16; i += 2) {
player.spawnParticle(Particle.DUST, minX + i, y, minZ, 1, 0, 0, 0, 0, dust);
player.spawnParticle(Particle.DUST, minX + i, y, minZ + 16, 1, 0, 0, 0, 0, dust);
player.spawnParticle(Particle.DUST, minX, y, minZ + i, 1, 0, 0, 0, 0, dust);
player.spawnParticle(Particle.DUST, minX + 16, y, minZ + i, 1, 0, 0, 0, 0, dust);
}
}
}

/**
* One-shot green particle celebration along freshly unlocked chunks, visible to
* everyone in the world near the island.
Expand Down Expand Up @@ -264,6 +331,7 @@ public void celebrate(Island island, List<Vector> gained) {
@EventHandler(priority = EventPriority.MONITOR)
public void onQuit(PlayerQuitEvent e) {
barrierBlocks.remove(e.getPlayer().getUniqueId());
previews.remove(e.getPlayer().getUniqueId());
}

@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
Expand All @@ -279,5 +347,6 @@ public void onRespawn(PlayerRespawnEvent e) {
@EventHandler(priority = EventPriority.MONITOR)
public void onChangedWorld(PlayerChangedWorldEvent e) {
barrierBlocks.remove(e.getPlayer().getUniqueId());
previews.remove(e.getPlayer().getUniqueId());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@

import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
import java.util.function.LongSupplier;

import org.bukkit.Location;
import org.bukkit.Sound;
Expand All @@ -14,6 +16,7 @@
import org.bukkit.event.Listener;
import org.bukkit.event.block.Action;
import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.event.player.PlayerQuitEvent;
import org.bukkit.inventory.EquipmentSlot;
import org.bukkit.util.Vector;

Expand All @@ -27,6 +30,11 @@
* Lets the island owner spend level credit by hitting the border: when they punch (or
* right-click) toward the locked chunk blocking them, that chunk is claimed and opens up.
* Expansion is the owner's choice, in any direction, up to the island's protection range.
* <p>
* Levels are hard-won, so by default a claim takes two deliberate gestures: the first hit
* outlines the target chunk and quotes the price, and only a second hit made while sneaking
* spends the credit. Servers that prefer the old one-hit claim can switch confirmation off
* in the config.
*
* @author tastybento
*/
Expand All @@ -38,14 +46,46 @@ public class ChunkClaimListener implements Listener {
private static final double STEP = 0.25;
/** Minimum time between failure nags per player, so mining swings can't spam */
private static final long FEEDBACK_COOLDOWN_MS = 2000;
/**
* How long a preview must have been showing before a sneaking hit can confirm it. One
* physical swing can fire both LEFT_CLICK_AIR and LEFT_CLICK_BLOCK on the same tick, and
* without this gap that single swing would preview and pay in one go.
*/
private static final long CONFIRM_ARM_MS = 250;

private final ChunkBlock addon;
private final Map<UUID, Long> lastFeedback = new HashMap<>();
/** Chunks each player has lined up but not yet paid for */
private final Map<UUID, PendingClaim> pending = new HashMap<>();

/**
* A chunk a player has been quoted a price for, waiting on their confirming hit.
*
* @param islandId the island the chunk would join, so a preview cannot be confirmed
* on someone else's island
* @param chunkX world chunk x coordinate
* @param chunkZ world chunk z coordinate
* @param shownAt when the preview was raised
*/
private record PendingClaim(String islandId, int chunkX, int chunkZ, long shownAt) {
}

/** Time source, overridable so tests need not sleep */
private LongSupplier clock = System::currentTimeMillis;

public ChunkClaimListener(ChunkBlock addon) {
this.addon = addon;
}

/**
* Replaces the time source used for confirmation windows and feedback throttling.
*
* @param clock supplier of the current time in milliseconds
*/
void setClock(LongSupplier clock) {
this.clock = clock;
}

/**
* A left or right click aimed through the border claims the locked chunk on the other
* side. Runs regardless of event cancellation because the protection listeners rightly
Expand Down Expand Up @@ -96,6 +136,20 @@ public void onBorderHit(PlayerInteractEvent e) {
attemptClaim(User.getInstance(player), island, target[0], target[1]);
}

/**
* Drops a player's pending preview when they log out, so it cannot be confirmed by
* whoever next holds that session.
*/
@EventHandler(priority = EventPriority.MONITOR)
public void onQuit(PlayerQuitEvent e) {
UUID uuid = e.getPlayer().getUniqueId();
pending.remove(uuid);
lastFeedback.remove(uuid);
if (addon.getBorderDisplay() != null) {
addon.getBorderDisplay().clearPreview(uuid);
}
}

/**
* Walks a short ray along the player's line of sight and returns the first locked
* chunk it enters, or null if the player is not aiming through the border.
Expand Down Expand Up @@ -127,7 +181,9 @@ private int[] findTargetLockedChunk(Player player, Island island) {
}

/**
* Runs the claim and gives the player the appropriate feedback.
* Runs the claim and gives the player the appropriate feedback. With confirmation
* enabled a claimable chunk is only previewed the first time round; the credit is spent
* on the confirming hit.
*
* @param user the island owner
* @param island the island
Expand All @@ -136,9 +192,20 @@ private int[] findTargetLockedChunk(Player player, Island island) {
*/
public void attemptClaim(User user, Island island, int chunkX, int chunkZ) {
ChunkManager cm = addon.getChunkManager();
// Price the chunk before spending anything: only a claim that would actually go
// through is worth asking the player to confirm
if (addon.getSettings().isRequireClaimConfirmation()
&& cm.checkGeometry(island, chunkX, chunkZ) == ClaimResult.OK
&& cm.getCredit(island) >= cm.getChunkCost() && !confirming(user, island, chunkX, chunkZ)) {
preview(user, island, chunkX, chunkZ);
return;
}
ClaimResult result = cm.claim(island, chunkX, chunkZ);
switch (result) {
case OK -> addon.getLevelListener().celebrateClaim(island, chunkX, chunkZ);
case OK -> {
clearPending(user.getUniqueId());
addon.getLevelListener().celebrateClaim(island, chunkX, chunkZ);
}
case NO_CREDIT -> {
if (feedbackReady(user.getUniqueId())) {
long needed = cm.getChunkCost() - cm.getCredit(island);
Expand All @@ -157,12 +224,71 @@ public void attemptClaim(User user, Island island, int chunkX, int chunkZ) {
}
}

/**
* Decides whether this hit is the confirming one: the player must be sneaking and
* aiming at the very chunk they were quoted for, on the same island, inside the
* confirmation window and no longer in the same swing that raised the preview.
*/
private boolean confirming(User user, Island island, int chunkX, int chunkZ) {
PendingClaim quote = pending.get(user.getUniqueId());
if (quote == null || !user.getPlayer().isSneaking()
|| !Objects.equals(quote.islandId(), island.getUniqueId()) || quote.chunkX() != chunkX
|| quote.chunkZ() != chunkZ) {
return false;
}
long age = clock.getAsLong() - quote.shownAt();
if (age >= timeoutMillis()) {
// The window closed; this hit re-prices the chunk instead of paying for it
pending.remove(user.getUniqueId());
return false;
}
return age >= CONFIRM_ARM_MS;
}

/**
* Quotes the price of a chunk and outlines it, arming the confirming hit.
*/
private void preview(User user, Island island, int chunkX, int chunkZ) {
ChunkManager cm = addon.getChunkManager();
long now = clock.getAsLong();
PendingClaim previous = pending.get(user.getUniqueId());
pending.put(user.getUniqueId(), new PendingClaim(island.getUniqueId(), chunkX, chunkZ, now));
if (addon.getBorderDisplay() != null) {
addon.getBorderDisplay().showPreview(user.getPlayer(), chunkX, chunkZ, now + timeoutMillis());
}
// Swinging at the same chunk repeatedly keeps the outline alive but must not
// re-announce the price on every blow
boolean sameChunk = previous != null && previous.chunkX() == chunkX && previous.chunkZ() == chunkZ;
if (sameChunk && !feedbackReady(user.getUniqueId())) {
return;
}
// Aiming somewhere new always earns a fresh quote, but it still resets the throttle
// so the follow-up swings at that chunk stay quiet
lastFeedback.put(user.getUniqueId(), now);
long cost = cm.getChunkCost();
user.notify("chunkblock.chunks.claim-confirm", "[cost]", String.valueOf(cost), "[after]",
String.valueOf(cm.getCredit(island) - cost), "[seconds]",
String.valueOf(addon.getSettings().getClaimConfirmationTimeout()));
user.getPlayer().playSound(user.getLocation(), Sound.BLOCK_NOTE_BLOCK_PLING, 1F, 1.4F);
}

private void clearPending(UUID uuid) {
pending.remove(uuid);
if (addon.getBorderDisplay() != null) {
addon.getBorderDisplay().clearPreview(uuid);
}
}

private long timeoutMillis() {
return addon.getSettings().getClaimConfirmationTimeout() * 1000L;
}

/**
* Rate-limits failure feedback: repeated swings while mining should not turn every
* failed claim probe into a chat message and a sound.
*/
private boolean feedbackReady(UUID uuid) {
long now = System.currentTimeMillis();
long now = clock.getAsLong();
Long last = lastFeedback.get(uuid);
if (last != null && now - last < FEEDBACK_COOLDOWN_MS) {
return false;
Expand Down
9 changes: 9 additions & 0 deletions src/main/resources/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,15 @@ chunkblock:
# island protection range can hold. The effective maximum is always capped so
# claimed chunks fit inside the protection range.
max-chunks: 441
claim:
# Require confirmation before level credit is spent on a chunk. When true, the
# first hit on the border previews the target chunk and its cost; the player
# must then sneak and hit the border again to actually claim it. This stops a
# stray swing near the border from spending levels by accident.
require-confirmation: true
# How long, in seconds, a previewed chunk stays confirmable. After this the
# player has to hit the border again to preview it afresh. Minimum 1.
confirmation-timeout: 15
# If true, losing island levels below what has been spent re-locks chunks in
# reverse claim order (the most recently claimed chunks are lost first). Builds
# inside re-locked chunks are untouched but cannot be reached until the levels
Expand Down
Loading
Loading