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
4 changes: 4 additions & 0 deletions buildsystem-core/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,10 @@ bukkit {
description = "Permission for creating world types."
default = BukkitPluginDescription.Permission.Default.TRUE
}
register("buildsystem.download") {
description = "Download a world as a single-player save."
default = BukkitPluginDescription.Permission.Default.OP
}
register("buildsystem.create.category") {
description =
"Create a world in a navigator category. Category ids are dynamic; grant buildsystem.create.category.<id> to allow a specific category."
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@ public void onEnable() {
services.settings().displayScoreboard(pl);
});

services.worldDownload().start();

new BuildSystemMetrics(this, services.config(), services.player()).register();

this.configSaveTask = Bukkit.getScheduler()
Expand All @@ -114,6 +116,7 @@ public void onDisable() {
});
services.navigatorEditor().restoreAll();

services.worldDownload().stop();
services.backup().close();
services.world().cancelAllUnloadTasks();

Expand Down Expand Up @@ -218,6 +221,7 @@ public void reloadConfigData(boolean init) {
services.config().load();
if (isEnabled()) {
services.backup().reload();
services.worldDownload().reload();
}

if (init) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
import de.eintosti.buildsystem.world.data.WorldStatusRegistryImpl;
import de.eintosti.buildsystem.world.display.CustomizableIcons;
import de.eintosti.buildsystem.world.display.NavigatorCategoryRegistryImpl;
import de.eintosti.buildsystem.world.download.WorldDownloadService;
import de.eintosti.buildsystem.world.spawn.SpawnService;
import org.bukkit.NamespacedKey;
import org.jspecify.annotations.NullMarked;
Expand Down Expand Up @@ -66,6 +67,7 @@ public final class Services {
private @Nullable SpawnService spawnService;
private @Nullable WorldServiceImpl worldService;
private @Nullable BackupServiceImpl backupService;
private @Nullable WorldDownloadService worldDownloadService;
private @Nullable CustomizableIcons customizableIcons;
private @Nullable NavigatorCategoryRegistryImpl navigatorCategoryRegistry;
private @Nullable WorldStatusRegistryImpl worldStatusRegistry;
Expand Down Expand Up @@ -122,6 +124,8 @@ void initClasses() {
this.noClipService = new NoClipService(plugin);
this.worldService = new WorldServiceImpl(plugin, this);
this.backupService = new BackupServiceImpl(plugin, config(), messages(), world(), this::spawn);
this.worldDownloadService =
new WorldDownloadService(config(), taskScheduler, plugin.getLogger(), plugin.getDataFolder());
this.settingsService = new SettingsService(plugin, config(), messages(), player(), world());
this.spawnService = new SpawnService(plugin, world(), taskScheduler);
this.menuItems = new MenuItems(plugin, messages(), settings());
Expand Down Expand Up @@ -194,6 +198,10 @@ public BackupServiceImpl backup() {
return checkNotNull(backupService, "BackupServiceImpl");
}

public WorldDownloadService worldDownload() {
return checkNotNull(worldDownloadService, "WorldDownloadService");
}

public CustomizableIcons customizableIcons() {
return checkNotNull(customizableIcons, "CustomizableIcons");
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
import de.eintosti.buildsystem.world.WorldServiceImpl;
import de.eintosti.buildsystem.world.backup.BackupServiceImpl;
import de.eintosti.buildsystem.world.display.NavigatorCategoryRegistryImpl;
import de.eintosti.buildsystem.world.download.WorldDownloadService;
import java.io.File;
import java.util.List;
import java.util.logging.Logger;
Expand Down Expand Up @@ -61,6 +62,7 @@ public WorldsCommand(BuildSystemPlugin plugin, Services services) {
PlayerLookupService playerLookupService = services.playerLookup();
NavigatorCategoryRegistryImpl navigatorCategoryRegistry = services.navigatorCategoryRegistry();
BackupServiceImpl backupService = services.backup();
WorldDownloadService downloadService = services.worldDownload();
Logger logger = plugin.getLogger();
File dataFolder = plugin.getDataFolder();
TaskScheduler scheduler = services.scheduler();
Expand All @@ -73,6 +75,7 @@ public WorldsCommand(BuildSystemPlugin plugin, Services services) {
new BackupsSubCommand(messages, worldService, backupService, menus),
new BuildersSubCommand(messages, worldService, menus),
new DeleteSubCommand(messages, worldService, configService, menus),
new DownloadSubCommand(messages, worldService, downloadService, scheduler, logger),
new EditSubCommand(messages, worldService, menus),
new FolderSubCommand(messages, worldService, navigatorCategoryRegistry, prompts),
new HelpSubCommand(messages, logger),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,257 @@
/*
* Copyright (c) 2018-2026, Thomas Meaney
* Copyright (c) contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package de.eintosti.buildsystem.command.subcommand.worlds;

import com.cryptomorin.xseries.XSound;
import de.eintosti.buildsystem.api.storage.WorldStorage;
import de.eintosti.buildsystem.api.world.BuildWorld;
import de.eintosti.buildsystem.command.subcommand.AbstractSubCommand;
import de.eintosti.buildsystem.command.subcommand.Argument;
import de.eintosti.buildsystem.i18n.Messages;
import de.eintosti.buildsystem.i18n.Placeholders;
import de.eintosti.buildsystem.util.TaskScheduler;
import de.eintosti.buildsystem.util.WorldFlush;
import de.eintosti.buildsystem.world.WorldServiceImpl;
import de.eintosti.buildsystem.world.download.ExportProgressBar;
import de.eintosti.buildsystem.world.download.WorldDownloadService;
import de.eintosti.buildsystem.world.download.WorldExporter;
import java.io.UncheckedIOException;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CompletionException;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.regex.Pattern;
import net.md_5.bungee.api.ChatMessageType;
import net.md_5.bungee.api.chat.BaseComponent;
import net.md_5.bungee.api.chat.ClickEvent;
import net.md_5.bungee.api.chat.HoverEvent;
import net.md_5.bungee.api.chat.TextComponent;
import net.md_5.bungee.api.chat.hover.content.Text;
import org.bukkit.entity.Player;
import org.bukkit.scheduler.BukkitTask;
import org.jspecify.annotations.NullMarked;

@NullMarked
public class DownloadSubCommand extends AbstractSubCommand {

/**
* How often the action bar is redrawn. Four frames a second reads as motion without spamming packets, and the
* action bar itself fades after about three seconds, so it must be refreshed well inside that.
*/
private static final long ANIMATION_PERIOD_TICKS = 5L;

/**
* Where the clickable button goes in the finished message, quoted because {@link String#split} takes a regex.
*/
private static final String BUTTON_PLACEHOLDER = Pattern.quote("%button%");

private final WorldDownloadService downloadService;
private final TaskScheduler scheduler;
private final Logger logger;

/**
* Players with an export in flight. Zipping a world is expensive, so a player cannot stack up exports by spamming
* the command.
*/
private final Set<UUID> preparing = ConcurrentHashMap.newKeySet();

public DownloadSubCommand(
Messages messages,
WorldServiceImpl worldService,
WorldDownloadService downloadService,
TaskScheduler scheduler,
Logger logger) {
super(messages, worldService);
this.downloadService = downloadService;
this.scheduler = scheduler;
this.logger = logger;
}

@Override
public void execute(Player player, String worldName, String[] args) {
BuildWorld buildWorld = requireWorld(player, worldName, args, 2, "worlds_download");
if (buildWorld == null) {
return;
}

if (!downloadService.isEnabled()) {
messages.sendMessage(player, "worlds_download_disabled");
return;
}

UUID playerId = player.getUniqueId();
if (!preparing.add(playerId)) {
messages.sendMessage(player, "worlds_download_in_progress");
return;
}

Placeholders worldPlaceholder = Placeholders.of("%world%", buildWorld.getName());
messages.sendMessage(player, "worlds_download_preparing", worldPlaceholder);
buildWorld.getWorld().ifPresent(WorldFlush::saveAndFlush);

AtomicLong packedBytes = new AtomicLong();
AtomicLong totalBytes = new AtomicLong();
BukkitTask animation = startProgressAnimation(player, buildWorld, packedBytes, totalBytes);

downloadService
.prepare(buildWorld, (packed, total) -> {
packedBytes.set(packed);
totalBytes.set(total);
})
.whenCompleteAsync(
(url, throwable) -> {
preparing.remove(playerId);
animation.cancel();
clearActionBar(player);
if (throwable != null) {
sendFailure(player, buildWorld, worldPlaceholder, throwable);
return;
}
if (player.isOnline()) {
sendLink(player, buildWorld, url);
}
},
scheduler.mainThread());
}

/**
* Drives the action bar while the export runs. The frame counter advances every tick of this task, so the bar
* keeps moving even while a single large region file is being packed.
*/
private BukkitTask startProgressAnimation(
Player player, BuildWorld buildWorld, AtomicLong packedBytes, AtomicLong totalBytes) {
AtomicInteger frame = new AtomicInteger();
return scheduler.runTimer(
() -> {
if (!player.isOnline()) {
return;
}
long total = totalBytes.get();
double fraction = total <= 0 ? 0 : (double) packedBytes.get() / total;
int currentFrame = frame.getAndIncrement();

sendActionBar(
player,
messages.getString(
"worlds_download_progress",
player,
Placeholders.of()
.add("%world%", buildWorld.getName())
.add("%bar%", ExportProgressBar.bar(fraction, currentFrame))
.add("%percent%", ExportProgressBar.percentText(fraction))
.add("%spinner%", ExportProgressBar.spinner(currentFrame))
.build()));
},
0L,
ANIMATION_PERIOD_TICKS);
}

private static void sendActionBar(Player player, String message) {
player.spigot().sendMessage(ChatMessageType.ACTION_BAR, TextComponent.fromLegacyText(message));
}

private static void clearActionBar(Player player) {
if (player.isOnline()) {
sendActionBar(player, "");
}
}

/**
* Reports the failure in the player's terms. A world that outgrows its limit or a full storage budget is an
* operator-tunable condition rather than a bug, so neither is logged as one.
*/
private void sendFailure(Player player, BuildWorld buildWorld, Placeholders worldPlaceholder, Throwable throwable) {
Throwable cause = throwable instanceof CompletionException && throwable.getCause() != null
? throwable.getCause()
: throwable;
if (cause instanceof UncheckedIOException uncheckedIoException) {
cause = uncheckedIoException.getCause();
}

switch (cause) {
case WorldExporter.WorldTooLargeException ignored ->
messages.sendMessage(player, "worlds_download_too_large", worldPlaceholder);
case WorldDownloadService.StorageFullException ignored ->
messages.sendMessage(player, "worlds_download_storage_full", worldPlaceholder);
default -> {
logger.log(Level.SEVERE, "Failed to export world " + buildWorld.getName(), throwable);
messages.sendMessage(player, "worlds_download_failed", worldPlaceholder);
}
}
}

/**
* Sends the finished message with only its {@code %button%} segment carrying the link, so the rest of the line
* cannot be clicked by accident.
*
* <p>A messages.yml written before the button existed has no {@code %button%} in it — an upgrade keeps the old
* line, since only missing keys are filled in. Such a message becomes clickable as a whole, the way it used to be:
* appending a button instead would print the words twice.
*/
private void sendLink(Player player, BuildWorld buildWorld, String url) {
Placeholders placeholders = Placeholders.of()
.add("%world%", buildWorld.getName())
.add("%minutes%", downloadService.getExpirationMinutes())
.build();
String text = messages.getString("worlds_download_finished", player, placeholders);

String[] parts = text.split(BUTTON_PLACEHOLDER, 2);
if (parts.length == 1) {
player.spigot().sendMessage(linked(TextComponent.fromLegacyText(text), url));
XSound.ENTITY_PLAYER_LEVELUP.play(player);
return;
}

TextComponent message = new TextComponent();
message.addExtra(new TextComponent(TextComponent.fromLegacyText(parts[0])));
message.addExtra(linked(
TextComponent.fromLegacyText(messages.getString("worlds_download_button", player, placeholders)), url));
message.addExtra(new TextComponent(TextComponent.fromLegacyText(parts[1])));

player.spigot().sendMessage(message);
XSound.ENTITY_PLAYER_LEVELUP.play(player);
}

private static TextComponent linked(BaseComponent[] text, String url) {
TextComponent component = new TextComponent(text);
component.setClickEvent(new ClickEvent(ClickEvent.Action.OPEN_URL, url));
component.setHoverEvent(new HoverEvent(HoverEvent.Action.SHOW_TEXT, new Text(url)));
return component;
}

@Override
public List<String> complete(Player player, String[] args) {
if (args.length != 2) {
return List.of();
}

WorldStorage worldStorage = worldService.getWorldStorage();
return WorldsCompletions.permittedWorldNames(
player, worldStorage, getArgument().getPermission(), args[1]);
}

@Override
public Argument getArgument() {
return WorldsArgument.DOWNLOAD;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,12 @@ protected List<TextComponent> getCommands(Player player) {
Permissions.REMOVESPAWN),
createComponent(
player, "/worlds delete <world>", "worlds_help_delete", "/worlds delete ", Permissions.DELETE),
createComponent(
player,
"/worlds download <world>",
"worlds_help_download",
"/worlds download ",
Permissions.DOWNLOAD),
createComponent(
player, "/worlds import <world>", "worlds_help_import", "/worlds import ", Permissions.IMPORT),
createComponent(
Expand Down
Loading