Skip to content
Open
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
46 changes: 23 additions & 23 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ A powerful and intuitive WorldEdit-like plugin for the Endstone Minecraft server
- **Advanced Clipboard**: Copy (`/copy`), cut (`/cut`), and paste (`/paste`) complex structures relative to your position.
- **Reliable History**: Easily undo (`/undo`) and redo (`/redo`) your actions to correct mistakes without hassle.
- **Procedural Generation**: Create perfect solid (`/sphere`, `/cyl`) and hollow (`/hsphere`, `/hcyl`) shapes like spheres and cylinders.
- **Cross-Edition Schematics**: Save and load structures to and from `.schem` files. The plugin is designed to handle compatibility between Java Edition and Bedrock Edition formats.
- **Flexible Configuration**: Customize plugin behavior and, most importantly, add custom block name translations via `config.json` to resolve schematic compatibility issues on the fly.
- **Cross-Edition Schematics & Structures**: Save and load structures to and from `.schem` and `.mcstructure` files.
- **Flexible Configuration**: Customize plugin behavior and add custom block name translations via `config.yml` to resolve compatibility issues.
- **Granular Permissions**: Fine-grained permission nodes for every command (e.g., `worldedit.command.set`) for precise server management.

## Installation
Expand All @@ -34,30 +34,28 @@ A powerful and intuitive WorldEdit-like plugin for the Endstone Minecraft server

## Configuration

Upon first launch, the plugin will create a `config.json` file in `plugins/WorldEdit/`. This file allows you to customize the plugin's behavior.

**Note:** When updating the plugin to a new version, it is recommended to delete your existing `config.json` file. This allows the plugin to generate a new one with the latest default settings and translation rules.

```json
{
"async-threshold": 5000,
"particle-type": "minecraft:endrod",
"particle-density-step": 5,
"schematic-path": "plugins/WorldEdit/schematics",
"block_translation_map": {
"cobblestone_stairs": "stone_stairs",
"rooted_dirt": "dirt",
"sugar_cane": "reeds",
"slime_block": "slime",
"oak_sign": "standing_sign",
"oak_wall_sign": "wall_sign"
}
}
Upon first launch, the plugin will create a `config.yml` file in `plugins/WorldEdit/`. This file allows you to customize the plugin's behavior.

**Note:** When updating the plugin to a new version, it is recommended to delete your existing `config.yml` file to let the plugin generate a new one with the latest default settings.

```yaml
async-threshold: 5000
particle-type: minecraft:endrod
particle-density-step: 5
schematic-path: plugins/WorldEdit/schematics
mcstructures-path: plugins/WorldEdit/mcstructures
block_translation_map:
cobblestone_stairs: stone_stairs
rooted_dirt: dirt
sugar_cane: reeds
slime_block: slime
oak_sign: standing_sign
oak_wall_sign: wall_sign
```

- **`async-threshold`**: The number of blocks at which an operation will be processed in smaller chunks to prevent server lag.
- **`particle-type`**: The particle used to visualize the selection box.
- **`block_translation_map`**: A powerful feature for resolving compatibility issues when loading Java Edition schematics. If a schematic fails to load because a block name isn't found (e.g., Java's `minecraft:slime_block`), you can add an entry here to translate it to the correct Bedrock name (e.g., `"slime_block": "slime"`). You can add, remove, or modify these rules at any time to handle new or custom block types.
- **`block_translation_map`**: A powerful feature for resolving compatibility issues when loading Java Edition schematics. If a schematic fails to load because a block name isn't found, add an entry to translate it to the correct Bedrock name.

## Usage Guide

Expand Down Expand Up @@ -98,11 +96,13 @@ Create perfect geometric shapes.
- **/cyl `<block>` `<radius>` `[height]`**: Creates a solid cylinder.
- **/hcyl `<block>` `<radius>` `[height]`**: Creates a hollow cylinder.

### 6. Schematics
### 6. Schematics & Structures
Save and load your creations.
- **/schem save `<name>`**: Saves the selection to `<name>.schem`.
- **/schem load `<name>`**: Loads a schematic file at your location.
- **/schem list**: Lists all available schematics.
- **/mcs load `<name>`**: Loads a Bedrock `.mcstructure` file at your location.
- **/mcs list**: Lists all available mcstructures.

## Contributing

Expand Down
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ authors = [
]
dependencies = [
"endstone",
"nbtlib"
"nbtlib",
"ruamel.yaml"
]

[project.entry-points."endstone"]
Expand Down
284 changes: 284 additions & 0 deletions src/endstone_worldedit/commands/brush.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,284 @@
from endstone.inventory import ItemStack
from endstone.nbt import StringTag, CompoundTag, IntTag
from endstone_worldedit.utils import command_executor
import math

command = {
"brush": {
"description": "Binds a brush to the current item player is holding.",
"usages": [
"/brush sphere <radius: int> <block: block> [-h: bool]",
"/brush cylinder <radius: int> <height: int> <block: block> [-h: bool]",
"/brush cube <radius: int> <block: block> [-h: bool]",
"/brush none"
],
"permissions": ["worldedit.command.brush"]
}
}

@command_executor("brush")
def handler(plugin, sender, args):
if len(args) < 1:
sender.send_message("Usage: /brush <sphere|cylinder|cube|none> [args...]")
return False

brush_type = args[0].lower()
if brush_type == "none":
item = sender.inventory.item_in_main_hand
if item and item.nbt and "worldedit" in item.nbt and item.nbt["worldedit"].value == "brush":
item.nbt = CompoundTag()
sender.send_message("Brush unbound from current item.")
else:
sender.send_message("No brush bound to current item.")
return True
Comment on lines +26 to +33

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

2. Item nbt overwritten 🐞 Bug ☼ Reliability

/brush (bind/unbind) and /mask replace the entire item.nbt CompoundTag, which can wipe unrelated
item data (e.g., enchantments/durability/custom NBT) and cause irreversible inventory data loss.
Unbinding with CompoundTag() also clears everything rather than removing only WorldEdit keys.
Agent Prompt
### Issue description
WorldEdit brush/mask operations currently overwrite the entire `ItemStack.nbt`, which can delete unrelated NBT data.

### Issue Context
- `/brush none` sets `item.nbt = CompoundTag()`.
- Brush binding sets `item.nbt = CompoundTag({"worldedit": ..., "brush": ...})`.
- `/mask` similarly overwrites `item.nbt`.

### Fix Focus Areas
- src/endstone_worldedit/commands/brush.py[26-33]
- src/endstone_worldedit/commands/brush.py[101-117]
- src/endstone_worldedit/commands/mask.py[48-55]

### What to change
- When binding/updating, start from existing NBT:
  - `nbt = item.nbt or CompoundTag()`
  - set/update only `nbt["worldedit"]` and `nbt["brush"]`
  - assign back `item.nbt = nbt`
- When unbinding, delete only WorldEdit keys:
  - `del nbt["worldedit"]` / `del nbt["brush"]` if present (or set them to neutral values)
  - do **not** replace the entire tag with an empty CompoundTag.
- Consider also restoring/clearing display name/lore when unbinding to avoid leaving misleading metadata.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


if brush_type not in ("sphere", "cylinder", "cube"):
sender.send_message("Invalid brush type. Use: sphere, cylinder, cube, or none.")
return False

hollow = False
filtered_args = []
for arg in args[1:]:
if arg.lower() == "true":
hollow = True
elif arg.lower() == "false":
continue
else:
filtered_args.append(arg)

if brush_type == "sphere":
if len(filtered_args) < 2:
sender.send_message("Usage: /brush sphere <radius: int> <block: string> [-h]")
return False
try:
radius = int(filtered_args[0])
except ValueError:
sender.send_message("Radius must be an integer.")
return False
if radius < 1:
sender.send_message("Radius must be a positive integer.")
return False
block_type = filtered_args[1]
_bind_brush_to_item(sender, brush_type, radius, block_type, hollow)

elif brush_type == "cylinder":
if len(filtered_args) < 3:
sender.send_message("Usage: /brush cylinder <radius: int> <height: int> <block: string> [-h]")
return False
try:
radius = int(filtered_args[0])
height = int(filtered_args[1])
except ValueError:
sender.send_message("Radius and height must be integers.")
return False
if radius < 1 or height < 1:
sender.send_message("Radius and height must be positive integers.")
return False
block_type = filtered_args[2]
_bind_brush_to_item(sender, brush_type, radius, block_type, hollow, height)

elif brush_type == "cube":
if len(filtered_args) < 2:
sender.send_message("Usage: /brush cube <radius: int> <block: string> [-h]")
return False
try:
radius = int(filtered_args[0])
except ValueError:
sender.send_message("Radius must be an integer.")
return False
if radius < 1:
sender.send_message("Radius must be a positive integer.")
return False
block_type = filtered_args[1]
_bind_brush_to_item(sender, brush_type, radius, block_type, hollow)

return True

def _bind_brush_to_item(sender, brush_type, radius, block_type, hollow, height=None):
item = sender.inventory.item_in_main_hand
if not item or item.type == "minecraft:air":
sender.send_message("You must be holding an item.")
return False

if not block_type.startswith("minecraft:"):
block_type = f"minecraft:{block_type}"

brush_compound = CompoundTag({
"type": StringTag(brush_type),
"radius": IntTag(radius),
"block": StringTag(block_type),
"hollow": StringTag("true" if hollow else "false"),
"mask": StringTag("none")
})
if height is not None:
brush_compound["height"] = IntTag(height)

nbt = CompoundTag({
"worldedit": StringTag("brush"),
"brush": brush_compound
})

item.nbt = nbt

meta = item.item_meta
meta.display_name = f"§bBrush ({brush_type})"
lore = [
f"§7Type: {brush_type}",
f"§7Radius: {radius}",
f"§7Block: {block_type}",
f"§7Hollow: {hollow}",
f"§7Mask: none"
]
if height is not None:
lore.insert(2, f"§7Height: {height}")
meta.lore = lore
item.set_item_meta(meta)
slot = sender.inventory.held_item_slot
sender.inventory.set_item(slot, None)
sender.inventory.set_item(slot, item)

msg = f"{brush_type.capitalize()} brush bound to current item (radius: {radius}, block: {block_type}, hollow: {hollow})"
if height is not None:
msg = f"{brush_type.capitalize()} brush bound to current item (radius: {radius}, height: {height}, block: {block_type}, hollow: {hollow})"
sender.send_message(msg)
return True

def execute_brush(plugin, player, block, brush_data):
brush_type = brush_data["type"].value
radius = brush_data["radius"].value
block_type = brush_data["block"].value
hollow = brush_data["hollow"].value == "true"
mask = None
if "mask" in brush_data and brush_data["mask"].value != "none":
mask = brush_data["mask"].value
if "height" in brush_data:
height = brush_data["height"].value
else:
height = 1

player_uuid = player.unique_id
dimension = player.dimension
cx, cy, cz = block.x, block.y, block.z
blocks_to_change = []
if brush_type == "sphere":
for x in range(cx - radius, cx + radius + 1):
for y in range(cy - radius, cy + radius + 1):
for z in range(cz - radius, cz + radius + 1):
distance = math.sqrt((x - cx) ** 2 + (y - cy) ** 2 + (z - cz) ** 2)
if hollow:
if not (radius - 1 < distance <= radius):
continue
elif distance > radius:
continue
if mask:
try:
existing = dimension.get_block_at(x, y, z)
if str(existing.type) != mask:
continue
except RuntimeError:
continue
if block_type.startswith("minecraft:"):
blocks_to_change.append((x, y, z, block_type, None))
else:
blocks_to_change.append((x, y, z, f"minecraft:{block_type}", None))

Comment thread
qodo-code-review[bot] marked this conversation as resolved.
elif brush_type == "cylinder":
for y in range(cy, cy + height):
for x in range(cx - radius, cx + radius + 1):
for z in range(cz - radius, cz + radius + 1):
distance = math.sqrt((x - cx) ** 2 + (z - cz) ** 2)
if hollow:
if not (radius - 1 < distance <= radius):
continue
elif distance > radius:
continue

if mask:
try:
existing = dimension.get_block_at(x, y, z)
if existing.type != mask:
continue
except RuntimeError:
continue

if block_type.startswith("minecraft:"):
blocks_to_change.append((x, y, z, block_type, None))
else:
blocks_to_change.append((x, y, z, f"minecraft:{block_type}", None))

elif brush_type == "cube":
for x in range(cx - radius, cx + radius + 1):
for y in range(cy - radius, cy + radius + 1):
for z in range(cz - radius, cz + radius + 1):
if hollow:
is_surface = (x == cx - radius or x == cx + radius or
y == cy - radius or y == cy + radius or
z == cz - radius or z == cz + radius)
if not is_surface:
continue

if mask:
try:
existing = dimension.get_block_at(x, y, z)
if existing.type != mask:
continue
except RuntimeError:
continue

if block_type.startswith("minecraft:"):
blocks_to_change.append((x, y, z, block_type, None))
else:
blocks_to_change.append((x, y, z, f"minecraft:{block_type}", None))

if not blocks_to_change:
player.send_message("No blocks to change.")
return

undo_entry = []
for x, y, z, _, _ in blocks_to_change:
try:
b = dimension.get_block_at(x, y, z)
undo_entry.append((x, y, z, b.type, b.data))
except RuntimeError:
continue

if player_uuid not in plugin.undo_history:
plugin.undo_history[player_uuid] = []
plugin.undo_history[player_uuid].append(undo_entry)
plugin.redo_history[player_uuid] = []
if len(blocks_to_change) > plugin.plugin_config["async-threshold"]:
plugin.tasks[player_uuid] = {"dimension": dimension, "blocks": blocks_to_change}
player.send_message(f"Placing {len(blocks_to_change)} blocks asynchronously...")
else:
for x, y, z, bt, _ in blocks_to_change:
try:
b = dimension.get_block_at(x, y, z)
b.set_type(bt)
except RuntimeError:
continue
player.send_message(f"Brush placed {len(blocks_to_change)} blocks.")

def _get_target_block(player, max_distance=100):
"""Raycast from player's eyes to find the first solid block they're looking at."""
loc = player.location
direction = loc.direction
dimension = player.dimension
x, y, z = loc.x, loc.y + 1.62, loc.z
dx = direction.x
dy = direction.y
dz = direction.z
step = 0.5
for i in range(int(max_distance / step)):
x += dx * step
y += dy * step
z += dz * step
block_x = int(math.floor(x))
block_y = int(math.floor(y))
block_z = int(math.floor(z))
try:
block = dimension.get_block_at(block_x, block_y, block_z)
if block.type != "minecraft:air":
return block
except RuntimeError:
return None
return None
Loading