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
6 changes: 6 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,12 @@ mvn verify + SonarCloud), Jenkins at ci.codemc.org builds release jars on master

- Locale keys live under `chunkblock.chunks.*` for gating messages (en-US.yml is the
source of truth; other locales lag until synced).
- **New locale text is MiniMessage** (`<green>`, `<aqua>`); the old `&`-codes still parse,
so files are mixed. Never build colored text yourself and splice it into a translation —
go through `User#getTranslationAsComponent` / `sendMessage(Component)` and keep colors in
Components. Where a formatted fragment really must reach a `[variable]` (the chat map's
`[row]`), serialize it to MiniMessage with `Util.getMiniMessage()` rather than emitting
`&` codes into what may be a MiniMessage line.
- Public API for other plugins: `ChunkUnlockEvent`/`ChunkRelockEvent` (per chunk, carry
claim-order index), request handler `unlocked-chunks`.
- Tests extend `CommonTestSetup` (mocked Bukkit/BentoBox); `ChunkManagerTest` and the
Expand Down
135 changes: 135 additions & 0 deletions src/main/java/world/bentobox/chunkblock/chunks/ChunkMap.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
package world.bentobox.chunkblock.chunks;

import java.util.ArrayList;
import java.util.List;

import org.bukkit.Location;
import org.eclipse.jdt.annotation.NonNull;
import org.eclipse.jdt.annotation.Nullable;

import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import world.bentobox.bentobox.database.objects.Island;
import world.bentobox.bentobox.util.Util;
import world.bentobox.chunkblock.ChunkBlock;
import world.bentobox.chunkblock.chunks.ChunkManager.ClaimResult;

/**
* The island's territory as a grid of squares, shared by the two things that draw it: the
* dialog map of {@code /ch chunks}, which makes a button of every chunk, and the chat map
* it falls back to.
* <p>
* Glyphs are handed out as {@link Component Components} rather than colored text. A
* component can be put in a dialog button as it is and serialized to MiniMessage for the
* chat map, whereas a string of color codes has to be spliced into translated text, which
* is where formatting quietly breaks.
*
* @author tastybento
*/
public final class ChunkMap {

/** What a chunk is to an island, which decides its glyph and what the map says about it */
public enum Kind {
/** The chunk holding the magic block, which can never lock */
CENTER,
/** Already claimed */
OWNED,
/** Locked, but adjacent to the island and inside the protection range */
CLAIMABLE,
/** Locked and not claimable yet */
LOCKED
}

/**
* One square of the map.
*
* @param dx chunk offset east of the center chunk
* @param dz chunk offset south of the center chunk
* @param kind what this chunk is to the island
* @param here true if the viewer is standing in this chunk
*/
public record Cell(int dx, int dz, Kind kind, boolean here) {
}

private ChunkMap() {
// Utility class
}

/**
* Maps the territory around an island, row by row from north to south and west to east
* within a row — the order both maps draw in.
*
* @param addon the addon
* @param island the island whose territory is mapped
* @param viewer where the player is standing, or null if they are nowhere on the map
* @param radius how many chunks out from the center the map reaches
* @return the cells of a square map (2 * radius + 1) chunks across
*/
public static List<Cell> cells(@NonNull ChunkBlock addon, @NonNull Island island, @Nullable Location viewer,
int radius) {
ChunkManager cm = addon.getChunkManager();
int centerChunkX = island.getCenter().getBlockX() >> 4;
int centerChunkZ = island.getCenter().getBlockZ() >> 4;
// A player who is not in this world stands on no chunk of the map
boolean sameWorld = viewer != null && Util.sameWorld(island.getWorld(), viewer.getWorld());
int playerDx = sameWorld ? (viewer.getBlockX() >> 4) - centerChunkX : Integer.MIN_VALUE;
int playerDz = sameWorld ? (viewer.getBlockZ() >> 4) - centerChunkZ : Integer.MIN_VALUE;
List<Cell> cells = new ArrayList<>();
for (int dz = -radius; dz <= radius; dz++) {
for (int dx = -radius; dx <= radius; dx++) {
Kind kind;
if (dx == 0 && dz == 0) {
kind = Kind.CENTER;
} else if (addon.getOneBlocksIsland(island).isChunkUnlocked(dx, dz)) {
kind = Kind.OWNED;
} else if (cm.checkGeometry(island, centerChunkX + dx, centerChunkZ + dz) == ClaimResult.OK) {
kind = Kind.CLAIMABLE;
} else {
kind = Kind.LOCKED;
}
cells.add(new Cell(dx, dz, kind, dx == playerDx && dz == playerDz));
}
}
return cells;
}

/**
* The glyph standing for a chunk. The chunk the player is on keeps its own outline but
* takes the marker color, so the map still says what that chunk is.
*
* @param cell the chunk
* @return the glyph, colored
*/
@NonNull
public static Component glyph(@NonNull Cell cell) {
String mark = switch (cell.kind()) {
case CENTER -> cell.here() ? "◉" : "◎";
case OWNED -> cell.here() ? "◆" : "■";
case CLAIMABLE -> cell.here() ? "◆" : "▣";
case LOCKED -> cell.here() ? "◇" : "□";
};
return Component.text(mark, cell.here() ? NamedTextColor.AQUA : color(cell.kind()));
}

/**
* The glyph as MiniMessage text, for the chat map: a whole row of these goes into the
* {@code [row]} variable of a translation, so the row has to be text by the time it
* gets there — MiniMessage text, matching the locale files, never color codes.
*
* @param cell the chunk
* @return the glyph as MiniMessage
*/
@NonNull
public static String glyphText(@NonNull Cell cell) {
return Util.getMiniMessage().serialize(glyph(cell));
}

private static NamedTextColor color(Kind kind) {
return switch (kind) {
case CENTER -> NamedTextColor.GOLD;
case OWNED -> NamedTextColor.GREEN;
case CLAIMABLE -> NamedTextColor.YELLOW;
case LOCKED -> NamedTextColor.GRAY;
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,24 @@
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.stream.Collectors;

import net.kyori.adventure.key.Key;
import world.bentobox.bentobox.api.commands.CompositeCommand;
import world.bentobox.bentobox.api.dialogs.Dialogs;
import world.bentobox.bentobox.api.user.User;
import world.bentobox.bentobox.database.objects.Island;
import world.bentobox.bentobox.util.Util;
import world.bentobox.chunkblock.ChunkBlock;
import world.bentobox.chunkblock.chunks.ChunkManager;
import world.bentobox.chunkblock.chunks.ChunkManager.ClaimResult;
import world.bentobox.chunkblock.chunks.ChunkMap;
import world.bentobox.chunkblock.chunks.ChunkMap.Cell;
import world.bentobox.chunkblock.panels.ChunksDialog;

/**
* /ch chunks — shows how big your island is, how much level credit you can spend, and a
* little chat map of your territory with the chunks you could claim next.
* map of your territory with the chunks you could claim next: a dialog of one button per
* chunk where the server supports dialogs, a chat map of glyphs where it does not.
*
* @author tastybento
*/
Expand Down Expand Up @@ -63,6 +68,11 @@ public boolean execute(User user, String label, List<String> args) {
return false;
}
Island island = optionalIsland.get();
// The dialog map is the good one: buttons are the same size on every client. The
// chat map is what servers too old for dialogs get instead.
if (Dialogs.isSupported() && ChunksDialog.show(addon, user, island)) {
return true;
}
ChunkManager cm = addon.getChunkManager();
int unlocked = cm.getUnlockedChunkCount(island);
int max = cm.getMaxChunks(island);
Expand All @@ -84,30 +94,18 @@ public boolean execute(User user, String label, List<String> args) {
private void showMap(User user, Island island, int unlocked, int max) {
ChunkManager cm = addon.getChunkManager();
int radius = Math.min(MAX_MAP_RADIUS, cm.currentRing(island) + 1);
int centerChunkX = island.getCenter().getBlockX() >> 4;
int centerChunkZ = island.getCenter().getBlockZ() >> 4;
int playerDx = (user.getLocation().getBlockX() >> 4) - centerChunkX;
int playerDz = (user.getLocation().getBlockZ() >> 4) - centerChunkZ;
int width = 2 * radius + 1;
List<Cell> cells = ChunkMap.cells(addon, island, user.getLocation(), radius);
user.sendMessage("chunkblock.chunks.map.title", "[unlocked]", String.valueOf(unlocked), "[max]",
String.valueOf(max));
for (int dz = -radius; dz <= radius; dz++) {
StringBuilder row = new StringBuilder();
for (int dx = -radius; dx <= radius; dx++) {
boolean here = dx == playerDx && dz == playerDz;
if (dx == 0 && dz == 0) {
// The center chunk holds the magic block and can never lock, so it is
// marked in its own right — without it the grid has nothing to orient by
row.append(here ? "&b◉" : "&6◎");
} else if (addon.getOneBlocksIsland(island).isChunkUnlocked(dx, dz)) {
row.append(here ? "&b◆" : "&a■");
} else if (cm.checkGeometry(island, centerChunkX + dx, centerChunkZ + dz) == ClaimResult.OK) {
row.append(here ? "&b◆" : "&e▣");
} else {
row.append(here ? "&b◇" : "&7□");
}
}
user.sendMessage(user.getTranslationAsComponent("chunkblock.chunks.map.row", "[row]", row.toString())
.font(MONOSPACE_FONT));
for (int row = 0; row < width; row++) {
// A row goes into the [row] variable of a translation, so it has to be text by
// then. It is MiniMessage text, the same format the locale files are written
// in — color codes spliced into a MiniMessage line would show up raw.
String glyphs = cells.subList(row * width, (row + 1) * width).stream().map(ChunkMap::glyphText)
.collect(Collectors.joining());
user.sendMessage(
user.getTranslationAsComponent("chunkblock.chunks.map.row", "[row]", glyphs).font(MONOSPACE_FONT));
}
user.sendMessage("chunkblock.chunks.map.legend", "[cost]", String.valueOf(cm.getChunkCost()));
}
Expand Down
Loading
Loading