From ba7c477cbc34ae1a6a8f73c5a666beb238fe2b99 Mon Sep 17 00:00:00 2001 From: M_Viper Date: Thu, 5 Feb 2026 14:37:34 +0100 Subject: [PATCH] Update from Git Manager GUI --- .../mviper/bomberman/BmPlaceholder.java | 71 ++ .../io/github/mviper/bomberman/Bomberman.java | 132 ++++ .../bomberman/commands/BaseCommand.java | 234 +++++++ .../github/mviper/bomberman/commands/Cmd.java | 134 ++++ .../bomberman/commands/GameCommand.java | 38 + .../mviper/bomberman/commands/Permission.java | 9 + .../bomberman/commands/Permissions.java | 36 + .../bomberman/commands/game/Configure.java | 652 ++++++++++++++++++ .../bomberman/commands/game/DevInfo.java | 189 +++++ .../bomberman/commands/game/GameCreate.java | 291 ++++++++ .../bomberman/commands/game/GameDelete.java | 64 ++ .../bomberman/commands/game/GameInfo.java | 62 ++ .../bomberman/commands/game/GameJoin.java | 140 ++++ .../bomberman/commands/game/GameLeave.java | 124 ++++ .../bomberman/commands/game/GameList.java | 62 ++ .../bomberman/commands/game/GameReload.java | 76 ++ .../bomberman/commands/game/GuiBuilder.java | 244 +++++++ .../bomberman/commands/game/RunStart.java | 116 ++++ .../bomberman/commands/game/RunStop.java | 73 ++ .../bomberman/commands/game/UndoBuild.java | 161 +++++ .../bomberman/events/BmCancellable.java | 20 + .../bomberman/events/BmDropLootEvent.java | 52 ++ .../mviper/bomberman/events/BmEvent.java | 9 + .../bomberman/events/BmExplosionEvent.java | 47 ++ .../bomberman/events/BmGameBuildIntent.java | 49 ++ .../bomberman/events/BmGameDeletedIntent.java | 62 ++ .../bomberman/events/BmGameListIntent.java | 32 + .../bomberman/events/BmGameLookupIntent.java | 34 + .../events/BmGameTerminatedIntent.java | 51 ++ .../mviper/bomberman/events/BmIntent.java | 22 + .../bomberman/events/BmIntentCancellable.java | 33 + .../events/BmIntentCancellableReasoned.java | 18 + .../bomberman/events/BmPlayerHitIntent.java | 63 ++ .../bomberman/events/BmPlayerHurtIntent.java | 65 ++ .../events/BmPlayerJoinGameIntent.java | 76 ++ .../events/BmPlayerKilledIntent.java | 55 ++ .../events/BmPlayerLeaveGameIntent.java | 58 ++ .../bomberman/events/BmPlayerMovedEvent.java | 42 ++ .../events/BmPlayerPlacedBombEvent.java | 50 ++ .../bomberman/events/BmPlayerWonEvent.java | 26 + .../events/BmRunStartCountDownIntent.java | 76 ++ .../bomberman/events/BmRunStartedIntent.java | 49 ++ .../bomberman/events/BmRunStoppedIntent.java | 72 ++ .../bomberman/events/BmTimerCountedEvent.java | 28 + .../mviper/bomberman/events/Intent.java | 9 + .../bomberman/events/IntentCancellable.java | 6 + .../events/IntentCancellableReasoned.java | 14 + .../io/github/mviper/bomberman/game/Bomb.java | 75 ++ .../mviper/bomberman/game/Explosion.java | 243 +++++++ .../io/github/mviper/bomberman/game/Game.java | 648 +++++++++++++++++ .../mviper/bomberman/game/GamePlayer.java | 465 +++++++++++++ .../mviper/bomberman/game/GameProtection.java | 65 ++ .../mviper/bomberman/game/GameSave.java | 255 +++++++ .../mviper/bomberman/game/GameSettings.java | 426 ++++++++++++ .../bomberman/game/GameSettingsBuilder.java | 71 ++ .../mviper/bomberman/game/StartTimer.java | 81 +++ .../bomberman/messaging/AdditionalArgs.java | 23 + .../messaging/CollectionWrapper.java | 129 ++++ .../bomberman/messaging/ColorWrapper.java | 21 + .../mviper/bomberman/messaging/Context.java | 95 +++ .../bomberman/messaging/ContextArg.java | 28 + .../bomberman/messaging/CustomPath.java | 30 + .../bomberman/messaging/DefaultArg.java | 27 + .../mviper/bomberman/messaging/Equation.java | 118 ++++ .../mviper/bomberman/messaging/Execute.java | 24 + .../mviper/bomberman/messaging/Expander.java | 173 +++++ .../bomberman/messaging/ExtraArgsHolder.java | 28 + .../bomberman/messaging/Formattable.java | 21 + .../bomberman/messaging/ItemWrapper.java | 29 + .../bomberman/messaging/LengthExpander.java | 15 + .../mviper/bomberman/messaging/Message.java | 475 +++++++++++++ .../bomberman/messaging/PadExpander.java | 48 ++ .../bomberman/messaging/PadLeftExpander.java | 7 + .../bomberman/messaging/PadRightExpander.java | 7 + .../bomberman/messaging/RandomExpander.java | 23 + .../bomberman/messaging/RegexExpander.java | 24 + .../bomberman/messaging/RequiredArg.java | 21 + .../bomberman/messaging/SenderWrapper.java | 47 ++ .../messaging/SubstringExpander.java | 50 ++ .../mviper/bomberman/messaging/Switch.java | 35 + .../mviper/bomberman/messaging/Text.java | 215 ++++++ .../bomberman/messaging/TitleExpander.java | 28 + .../github/mviper/bomberman/package-info.java | 4 + .../io/github/mviper/bomberman/utils/Box.java | 104 +++ .../mviper/bomberman/utils/BukkitUtils.java | 65 ++ .../mviper/bomberman/utils/DataRestorer.java | 28 + .../io/github/mviper/bomberman/utils/Dim.java | 43 ++ .../mviper/bomberman/utils/RefectAccess.java | 11 + .../bomberman/utils/WorldEditUtils.java | 47 ++ src/main/resources/config.yml | 14 + src/main/resources/default_messages.yml | 356 ++++++++++ src/main/resources/games/README.yml | 203 ++++++ src/main/resources/games/templates/README.txt | 19 + .../games/templates/experimental.game.zip | Bin 0 -> 7184 bytes .../resources/games/templates/purple.game.zip | Bin 0 -> 2984 bytes src/main/resources/messages.yml | 14 + src/main/resources/plugin.yml | 101 +++ src/main/resources/temp/README.txt | 21 + .../bomberman/commands/BaseCommandTest.java | 102 +++ .../mviper/bomberman/game/ExplosionTest.java | 50 ++ .../bomberman/messaging/ExpanderTest.java | 244 +++++++ .../messaging/FormatWrapperTest.java | 440 ++++++++++++ .../bomberman/messaging/MessageTest.java | 42 ++ .../mviper/bomberman/utils/BoxTest.java | 70 ++ 104 files changed, 10074 insertions(+) create mode 100644 src/main/java/io/github/mviper/bomberman/BmPlaceholder.java create mode 100644 src/main/java/io/github/mviper/bomberman/Bomberman.java create mode 100644 src/main/java/io/github/mviper/bomberman/commands/BaseCommand.java create mode 100644 src/main/java/io/github/mviper/bomberman/commands/Cmd.java create mode 100644 src/main/java/io/github/mviper/bomberman/commands/GameCommand.java create mode 100644 src/main/java/io/github/mviper/bomberman/commands/Permission.java create mode 100644 src/main/java/io/github/mviper/bomberman/commands/Permissions.java create mode 100644 src/main/java/io/github/mviper/bomberman/commands/game/Configure.java create mode 100644 src/main/java/io/github/mviper/bomberman/commands/game/DevInfo.java create mode 100644 src/main/java/io/github/mviper/bomberman/commands/game/GameCreate.java create mode 100644 src/main/java/io/github/mviper/bomberman/commands/game/GameDelete.java create mode 100644 src/main/java/io/github/mviper/bomberman/commands/game/GameInfo.java create mode 100644 src/main/java/io/github/mviper/bomberman/commands/game/GameJoin.java create mode 100644 src/main/java/io/github/mviper/bomberman/commands/game/GameLeave.java create mode 100644 src/main/java/io/github/mviper/bomberman/commands/game/GameList.java create mode 100644 src/main/java/io/github/mviper/bomberman/commands/game/GameReload.java create mode 100644 src/main/java/io/github/mviper/bomberman/commands/game/GuiBuilder.java create mode 100644 src/main/java/io/github/mviper/bomberman/commands/game/RunStart.java create mode 100644 src/main/java/io/github/mviper/bomberman/commands/game/RunStop.java create mode 100644 src/main/java/io/github/mviper/bomberman/commands/game/UndoBuild.java create mode 100644 src/main/java/io/github/mviper/bomberman/events/BmCancellable.java create mode 100644 src/main/java/io/github/mviper/bomberman/events/BmDropLootEvent.java create mode 100644 src/main/java/io/github/mviper/bomberman/events/BmEvent.java create mode 100644 src/main/java/io/github/mviper/bomberman/events/BmExplosionEvent.java create mode 100644 src/main/java/io/github/mviper/bomberman/events/BmGameBuildIntent.java create mode 100644 src/main/java/io/github/mviper/bomberman/events/BmGameDeletedIntent.java create mode 100644 src/main/java/io/github/mviper/bomberman/events/BmGameListIntent.java create mode 100644 src/main/java/io/github/mviper/bomberman/events/BmGameLookupIntent.java create mode 100644 src/main/java/io/github/mviper/bomberman/events/BmGameTerminatedIntent.java create mode 100644 src/main/java/io/github/mviper/bomberman/events/BmIntent.java create mode 100644 src/main/java/io/github/mviper/bomberman/events/BmIntentCancellable.java create mode 100644 src/main/java/io/github/mviper/bomberman/events/BmIntentCancellableReasoned.java create mode 100644 src/main/java/io/github/mviper/bomberman/events/BmPlayerHitIntent.java create mode 100644 src/main/java/io/github/mviper/bomberman/events/BmPlayerHurtIntent.java create mode 100644 src/main/java/io/github/mviper/bomberman/events/BmPlayerJoinGameIntent.java create mode 100644 src/main/java/io/github/mviper/bomberman/events/BmPlayerKilledIntent.java create mode 100644 src/main/java/io/github/mviper/bomberman/events/BmPlayerLeaveGameIntent.java create mode 100644 src/main/java/io/github/mviper/bomberman/events/BmPlayerMovedEvent.java create mode 100644 src/main/java/io/github/mviper/bomberman/events/BmPlayerPlacedBombEvent.java create mode 100644 src/main/java/io/github/mviper/bomberman/events/BmPlayerWonEvent.java create mode 100644 src/main/java/io/github/mviper/bomberman/events/BmRunStartCountDownIntent.java create mode 100644 src/main/java/io/github/mviper/bomberman/events/BmRunStartedIntent.java create mode 100644 src/main/java/io/github/mviper/bomberman/events/BmRunStoppedIntent.java create mode 100644 src/main/java/io/github/mviper/bomberman/events/BmTimerCountedEvent.java create mode 100644 src/main/java/io/github/mviper/bomberman/events/Intent.java create mode 100644 src/main/java/io/github/mviper/bomberman/events/IntentCancellable.java create mode 100644 src/main/java/io/github/mviper/bomberman/events/IntentCancellableReasoned.java create mode 100644 src/main/java/io/github/mviper/bomberman/game/Bomb.java create mode 100644 src/main/java/io/github/mviper/bomberman/game/Explosion.java create mode 100644 src/main/java/io/github/mviper/bomberman/game/Game.java create mode 100644 src/main/java/io/github/mviper/bomberman/game/GamePlayer.java create mode 100644 src/main/java/io/github/mviper/bomberman/game/GameProtection.java create mode 100644 src/main/java/io/github/mviper/bomberman/game/GameSave.java create mode 100644 src/main/java/io/github/mviper/bomberman/game/GameSettings.java create mode 100644 src/main/java/io/github/mviper/bomberman/game/GameSettingsBuilder.java create mode 100644 src/main/java/io/github/mviper/bomberman/game/StartTimer.java create mode 100644 src/main/java/io/github/mviper/bomberman/messaging/AdditionalArgs.java create mode 100644 src/main/java/io/github/mviper/bomberman/messaging/CollectionWrapper.java create mode 100644 src/main/java/io/github/mviper/bomberman/messaging/ColorWrapper.java create mode 100644 src/main/java/io/github/mviper/bomberman/messaging/Context.java create mode 100644 src/main/java/io/github/mviper/bomberman/messaging/ContextArg.java create mode 100644 src/main/java/io/github/mviper/bomberman/messaging/CustomPath.java create mode 100644 src/main/java/io/github/mviper/bomberman/messaging/DefaultArg.java create mode 100644 src/main/java/io/github/mviper/bomberman/messaging/Equation.java create mode 100644 src/main/java/io/github/mviper/bomberman/messaging/Execute.java create mode 100644 src/main/java/io/github/mviper/bomberman/messaging/Expander.java create mode 100644 src/main/java/io/github/mviper/bomberman/messaging/ExtraArgsHolder.java create mode 100644 src/main/java/io/github/mviper/bomberman/messaging/Formattable.java create mode 100644 src/main/java/io/github/mviper/bomberman/messaging/ItemWrapper.java create mode 100644 src/main/java/io/github/mviper/bomberman/messaging/LengthExpander.java create mode 100644 src/main/java/io/github/mviper/bomberman/messaging/Message.java create mode 100644 src/main/java/io/github/mviper/bomberman/messaging/PadExpander.java create mode 100644 src/main/java/io/github/mviper/bomberman/messaging/PadLeftExpander.java create mode 100644 src/main/java/io/github/mviper/bomberman/messaging/PadRightExpander.java create mode 100644 src/main/java/io/github/mviper/bomberman/messaging/RandomExpander.java create mode 100644 src/main/java/io/github/mviper/bomberman/messaging/RegexExpander.java create mode 100644 src/main/java/io/github/mviper/bomberman/messaging/RequiredArg.java create mode 100644 src/main/java/io/github/mviper/bomberman/messaging/SenderWrapper.java create mode 100644 src/main/java/io/github/mviper/bomberman/messaging/SubstringExpander.java create mode 100644 src/main/java/io/github/mviper/bomberman/messaging/Switch.java create mode 100644 src/main/java/io/github/mviper/bomberman/messaging/Text.java create mode 100644 src/main/java/io/github/mviper/bomberman/messaging/TitleExpander.java create mode 100644 src/main/java/io/github/mviper/bomberman/package-info.java create mode 100644 src/main/java/io/github/mviper/bomberman/utils/Box.java create mode 100644 src/main/java/io/github/mviper/bomberman/utils/BukkitUtils.java create mode 100644 src/main/java/io/github/mviper/bomberman/utils/DataRestorer.java create mode 100644 src/main/java/io/github/mviper/bomberman/utils/Dim.java create mode 100644 src/main/java/io/github/mviper/bomberman/utils/RefectAccess.java create mode 100644 src/main/java/io/github/mviper/bomberman/utils/WorldEditUtils.java create mode 100644 src/main/resources/config.yml create mode 100644 src/main/resources/default_messages.yml create mode 100644 src/main/resources/games/README.yml create mode 100644 src/main/resources/games/templates/README.txt create mode 100644 src/main/resources/games/templates/experimental.game.zip create mode 100644 src/main/resources/games/templates/purple.game.zip create mode 100644 src/main/resources/messages.yml create mode 100644 src/main/resources/plugin.yml create mode 100644 src/main/resources/temp/README.txt create mode 100644 src/test/java/io/github/mviper/bomberman/commands/BaseCommandTest.java create mode 100644 src/test/java/io/github/mviper/bomberman/game/ExplosionTest.java create mode 100644 src/test/java/io/github/mviper/bomberman/messaging/ExpanderTest.java create mode 100644 src/test/java/io/github/mviper/bomberman/messaging/FormatWrapperTest.java create mode 100644 src/test/java/io/github/mviper/bomberman/messaging/MessageTest.java create mode 100644 src/test/java/io/github/mviper/bomberman/utils/BoxTest.java diff --git a/src/main/java/io/github/mviper/bomberman/BmPlaceholder.java b/src/main/java/io/github/mviper/bomberman/BmPlaceholder.java new file mode 100644 index 0000000..0baff51 --- /dev/null +++ b/src/main/java/io/github/mviper/bomberman/BmPlaceholder.java @@ -0,0 +1,71 @@ +package io.github.mviper.bomberman; + +import io.github.mviper.bomberman.events.BmGameListIntent; +import io.github.mviper.bomberman.events.BmGameLookupIntent; +import io.github.mviper.bomberman.messaging.Context; +import io.github.mviper.bomberman.messaging.Expander; +import io.github.mviper.bomberman.messaging.Formattable; +import io.github.mviper.bomberman.messaging.Message; +import io.github.mviper.bomberman.messaging.SenderWrapper; +import me.clip.placeholderapi.expansion.PlaceholderExpansion; +import org.bukkit.entity.Player; + +public class BmPlaceholder extends PlaceholderExpansion { + @Override + public String getIdentifier() { + return "bomberman"; + } + + @Override + public String getAuthor() { + return "mviper"; + } + + @Override + public String getVersion() { + return "internal"; + } + + @Override + public boolean persist() { + return true; + } + + @Override + public String onPlaceholderRequest(Player player, String params) { + String[] content = params.split("_"); + String command = content.length > 0 ? content[0] : ""; + switch (command) { + case "info": { + if (content.length < 2) { + return "info "; + } + String gameName = content[1]; + Formattable game = BmGameLookupIntent.find(gameName); + if (game == null) { + return ""; + } + Formattable format = game; + for (int i = 2; i < content.length; i++) { + format = format.applyModifier(Message.of(content[i])); + } + return format.format(new Context()).toString(); + } + case "msg": { + try { + Context context = new Context(false) + .plus("games", BmGameListIntent.listGames()); + if (player != null) { + context = context.plus("player", new SenderWrapper(player)); + } + return Expander.expand(params.substring("msg_".length()), context).toString(); + } catch (RuntimeException e) { + String message = e.getMessage() != null ? e.getMessage() : "Error"; + return Message.error(message).toString(); + } + } + default: + return " ..."; + } + } +} diff --git a/src/main/java/io/github/mviper/bomberman/Bomberman.java b/src/main/java/io/github/mviper/bomberman/Bomberman.java new file mode 100644 index 0000000..45db2be --- /dev/null +++ b/src/main/java/io/github/mviper/bomberman/Bomberman.java @@ -0,0 +1,132 @@ +package io.github.mviper.bomberman; + +import io.github.mviper.bomberman.commands.BaseCommand; +import io.github.mviper.bomberman.game.GamePlayer; +import io.github.mviper.bomberman.game.GameSave; +import io.github.mviper.bomberman.game.GameSettings; +import io.github.mviper.bomberman.utils.DataRestorer; +import org.bukkit.Bukkit; +import org.bukkit.command.PluginCommand; +import org.bukkit.configuration.file.FileConfiguration; +import org.bukkit.configuration.serialization.ConfigurationSerialization; +import org.bukkit.event.Listener; +import org.bukkit.plugin.java.JavaPlugin; + +import javax.annotation.Nonnull; +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Objects; + +public class Bomberman extends JavaPlugin implements Listener { + + @SuppressWarnings("NotNullFieldNotInitialized") + @Nonnull + public static Bomberman instance; + + @Override + public void onEnable() { + instance = this; + + ConfigurationSerialization.registerClass(GameSettings.class); + ConfigurationSerialization.registerClass(DataRestorer.class, "io.github.mviper.bomberman.game.Game$BuildFlags"); + + getDataFolder().mkdirs(); + + BaseCommand bmCmd = new BaseCommand(); + PluginCommand bukkitBmCmd = Objects.requireNonNull(getCommand("bomberman")); + bukkitBmCmd.setExecutor(bmCmd); + bukkitBmCmd.setTabCompleter(bmCmd); + + if (Bukkit.getPluginManager().getPlugin("PlaceholderAPI") != null) { + new BmPlaceholder().register(); + } + + // Update the old file system + GameSave.updatePre080Saves(); + + // Archive the old schematics folder + File schematics = new File(getDataFolder(), "schematics"); + if (schematics.exists()) { + File schemPath = new File(getDataFolder(), "old/schematics"); + schemPath.getParentFile().mkdirs(); + schematics.renameTo(schemPath); + } + + // Archive the old config + FileConfiguration config = getConfig(); + if (config.contains("default-game-settings")) { + File configFile = new File(getDataFolder(), "config.yml"); + File configOut = new File(getDataFolder(), "old/config.yml"); + configOut.getParentFile().mkdirs(); + configFile.renameTo(configOut); + } + new File(getDataFolder(), "sample_config.yml").delete(); + + // Copy resources + saveResource("config.yml", true); + saveResource("messages.yml", false); + saveResource("default_messages.yml", true); + saveResource("games/templates/purple.game.zip", true); + saveResource("games/templates/experimental.game.zip", true); + saveResource("games/README.yml", true); + saveResource("games/templates/README.txt", true); + saveResource("temp/README.txt", true); + + GameSave.loadGames(); + GamePlayer.setupLoginWatcher(); + } + + public Path gameSaves() { + try { + Path dir = getDataFolder().toPath().resolve("games"); + if (!Files.exists(dir)) { + Files.createDirectories(dir); + } + return dir; + } catch (IOException e) { + throw new RuntimeException("No write access", e); + } + } + + public Path templates() { + try { + Path dir = getDataFolder().toPath().resolve("games/templates"); + if (!Files.exists(dir)) { + Files.createDirectories(dir); + } + return dir; + } catch (IOException e) { + throw new RuntimeException("No write access", e); + } + } + + public Path tempGameData() { + try { + Path dir = getDataFolder().toPath().resolve("temp/game"); + if (!Files.exists(dir)) { + Files.createDirectories(dir); + } + return dir; + } catch (IOException e) { + throw new RuntimeException("No write access", e); + } + } + + public Path tempPlayerData() { + try { + Path dir = getDataFolder().toPath().resolve("temp/player"); + if (!Files.exists(dir)) { + Files.createDirectories(dir); + } + return dir; + } catch (IOException e) { + throw new RuntimeException("No write access", e); + } + } + + public Path language() { + return getDataFolder().toPath().resolve("messages.yml"); + } +} diff --git a/src/main/java/io/github/mviper/bomberman/commands/BaseCommand.java b/src/main/java/io/github/mviper/bomberman/commands/BaseCommand.java new file mode 100644 index 0000000..04c26f3 --- /dev/null +++ b/src/main/java/io/github/mviper/bomberman/commands/BaseCommand.java @@ -0,0 +1,234 @@ +package io.github.mviper.bomberman.commands; + +import io.github.mviper.bomberman.commands.game.Configure; +import io.github.mviper.bomberman.commands.game.GameCreate; +import io.github.mviper.bomberman.commands.game.GameDelete; +import io.github.mviper.bomberman.commands.game.GameInfo; +import io.github.mviper.bomberman.commands.game.GameJoin; +import io.github.mviper.bomberman.commands.game.GameLeave; +import io.github.mviper.bomberman.commands.game.GameList; +import io.github.mviper.bomberman.commands.game.GameReload; +import io.github.mviper.bomberman.commands.game.RunStart; +import io.github.mviper.bomberman.commands.game.RunStop; +import io.github.mviper.bomberman.commands.game.UndoBuild; +import io.github.mviper.bomberman.messaging.CollectionWrapper; +import io.github.mviper.bomberman.messaging.Context; +import io.github.mviper.bomberman.messaging.Formattable; +import io.github.mviper.bomberman.messaging.Message; +import io.github.mviper.bomberman.messaging.Text; +import org.bukkit.command.Command; +import org.bukkit.command.CommandExecutor; +import org.bukkit.command.CommandSender; +import org.bukkit.command.TabCompleter; +import org.bukkit.util.StringUtil; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; + +public class BaseCommand extends Cmd implements TabCompleter, CommandExecutor { + private static final String F_HELP = "?"; + + private final List children = new ArrayList<>(); + + public BaseCommand() { + this(true); + } + + public BaseCommand(boolean addChildren) { + super(null); + if (addChildren) { + addChildren( + new Configure(this), + new GameCreate(this), + new GameInfo(this), + new GameJoin(this), + new GameLeave(this), + new GameDelete(this), + new RunStart(this), + new RunStop(this), + new GameList(this), + new GameReload(this), + new UndoBuild(this) + ); + } + } + + public void addChildren(Cmd... children) { + this.children.addAll(Arrays.asList(children)); + } + + @Override + public Formattable name() { + return Message.of("bomberman"); + } + + @Override + public boolean onCommand(CommandSender sender, Command command, String label, String[] args) { + FlagSplit split = separateFlags(args); + run(sender, split.arguments, split.flags); + return true; + } + + @Override + public boolean run(CommandSender sender, List args, Map flags) { + if (args.isEmpty()) { + Text.COMMAND_GROUP_HELP.format(cmdContext().plus("sender", sender)).sendTo(sender); + return true; + } + + Cmd child = children.stream() + .filter(c -> c.name().format(c.cmdContext()).toString().equalsIgnoreCase(args.get(0))) + .findFirst() + .orElse(null); + + if (child == null) { + Text.UNKNOWN_COMMAND.format(cmdContext().plus("attempt", args.get(0))).sendTo(sender); + return true; + } + + if (!child.permission().isAllowedBy(sender)) { + Text.DENY_PERMISSION.format(child.cmdContext()).sendTo(sender); + return true; + } + + if (flags.containsKey(F_HELP)) { + Text.COMMAND_HELP.format(child.cmdContext().plus("sender", sender)).sendTo(sender); + return true; + } + + boolean result = child.run(sender, args.subList(1, args.size()), flags); + if (!result) { + List attempt = args.subList(1, args.size()).stream() + .map(Message::of) + .collect(Collectors.toList()); + Text.INCORRECT_USAGE.format(child.cmdContext().plus("attempt", new CollectionWrapper<>(attempt))).sendTo(sender); + } + return result; + } + + @Override + public List onTabComplete(CommandSender sender, Command command, String alias, String[] args) { + FlagSplit split = separateFlags(args); + List arguments = split.arguments; + String currentlyTyping = args[args.length - 1]; + + Cmd cmd = children.stream() + .filter(c -> c.name().format(c.cmdContext()).toString().equalsIgnoreCase(arguments.isEmpty() ? "" : arguments.get(0))) + .findFirst() + .orElse(this); + if (!cmd.permission().isAllowedBy(sender)) { + return List.of(); + } + + List allOptions; + if (currentlyTyping.startsWith("-")) { + int splitIndex = currentlyTyping.indexOf('='); + if (splitIndex == -1) { + allOptions = cmd.flags(sender).stream() + .map(flag -> "-" + flag) + .collect(Collectors.toList()); + allOptions.add("-" + F_HELP); + } else { + String key = currentlyTyping.substring(1, splitIndex); + allOptions = cmd.flagOptions(key).stream() + .map(option -> "-" + key + "=" + option) + .toList(); + } + } else { + allOptions = cmd.options(sender, arguments.size() > 0 ? arguments.subList(1, arguments.size()) : List.of()); + } + + return allOptions.stream() + .filter(option -> StringUtil.startsWithIgnoreCase(option, currentlyTyping)) + .toList(); + } + + private FlagSplit separateFlags(String[] args) { + List flagStrings = new ArrayList<>(); + List arguments = new ArrayList<>(); + for (String arg : args) { + if (arg.startsWith("-")) { + flagStrings.add(arg); + } else { + arguments.add(arg); + } + } + Map flags = flagStrings.stream().collect(Collectors.toMap( + flag -> { + int separator = flag.indexOf('='); + if (separator == -1) { + return flag.substring(1); + } + return flag.substring(1, separator); + }, + flag -> { + int separator = flag.indexOf('='); + if (separator == -1) { + return ""; + } + return flag.substring(separator + 1); + }, + (a, b) -> b + )); + return new FlagSplit(arguments, flags); + } + + @Override + public List options(CommandSender sender, List args) { + if (args.size() <= 1) { + return children.stream() + .filter(cmd -> cmd.permission().isAllowedBy(sender)) + .map(cmd -> cmd.name().format(cmd.cmdContext()).toString()) + .collect(Collectors.toList()); + } + return List.of(); + } + + @Override + public Permission permission() { + return Permissions.BASE; + } + + @Override + public Formattable description() { + return Text.BOMBERMAN_DESCRIPTION; + } + + @Override + public Formattable extra() { + return Text.COMMAND_GROUP_EXTRA; + } + + @Override + public Formattable example() { + return Text.COMMAND_GROUP_EXAMPLE; + } + + @Override + public Formattable usage() { + return Text.COMMAND_GROUP_USAGE; + } + + @Override + public Formattable applyModifier(Message arg) { + if ("children".equalsIgnoreCase(arg.toString())) { + return new CollectionWrapper<>(children); + } + return super.applyModifier(arg); + } + + private static final class FlagSplit { + private final List arguments; + private final Map flags; + + private FlagSplit(List arguments, Map flags) { + this.arguments = arguments; + this.flags = flags; + } + } +} diff --git a/src/main/java/io/github/mviper/bomberman/commands/Cmd.java b/src/main/java/io/github/mviper/bomberman/commands/Cmd.java new file mode 100644 index 0000000..71d513a --- /dev/null +++ b/src/main/java/io/github/mviper/bomberman/commands/Cmd.java @@ -0,0 +1,134 @@ +package io.github.mviper.bomberman.commands; + +import io.github.mviper.bomberman.messaging.CollectionWrapper; +import io.github.mviper.bomberman.messaging.Context; +import io.github.mviper.bomberman.messaging.Formattable; +import io.github.mviper.bomberman.messaging.Message; +import org.bukkit.Bukkit; +import org.bukkit.command.CommandSender; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.Set; + +public abstract class Cmd implements Formattable { + protected Cmd parent; + + protected Cmd(Cmd parent) { + this.parent = parent; + } + + public abstract Formattable name(); + + public abstract List options(CommandSender sender, List args); + + public Set flags(CommandSender sender) { + return Collections.emptySet(); + } + + public Set flagOptions(String flag) { + return Collections.emptySet(); + } + + public Formattable flagExtension(String flag) { + return Message.empty; + } + + public Formattable flagDescription(String flag) { + return Message.empty; + } + + public abstract boolean run(CommandSender sender, List args, java.util.Map flags); + + public abstract Formattable extra(); + + public abstract Formattable example(); + + public abstract Formattable description(); + + public abstract Formattable usage(); + + public abstract Permission permission(); + + private String path(String separator) { + StringBuilder path = new StringBuilder(); + if (parent != null) { + path.append(parent.path(separator)).append(separator); + } + path.append(name().format(new Context()).toString()); + return path.toString(); + } + + private String path() { + return path(" "); + } + + public Context cmdContext() { + return new Context(false).plus("command", this); + } + + @Override + public Message format(Context context) { + return applyModifier(Message.of("name")).format(context); + } + + @Override + public Formattable applyModifier(Message arg) { + String key = arg.toString().toLowerCase(Locale.ROOT); + switch (key) { + case "name": + return name(); + case "path": + return Message.of(path()); + case "usage": + return usage(); + case "extra": + return extra(); + case "example": + return example(); + case "description": + return description(); + case "permission": + return Message.of(permission().value()); + case "flags": { + List wrapped = new ArrayList<>(); + for (String flag : flags(Bukkit.getConsoleSender())) { + wrapped.add(new FlagWrapper(flag)); + } + return new CollectionWrapper<>(wrapped); + } + default: + throw new IllegalArgumentException("Unknown command value '" + arg + "'"); + } + } + + private final class FlagWrapper implements Formattable { + private final String flag; + + private FlagWrapper(String flag) { + this.flag = flag; + } + + @Override + public Formattable applyModifier(Message arg) { + String key = arg.toString().toLowerCase(Locale.ROOT); + switch (key) { + case "name": + return Message.of(flag); + case "ext": + return flagExtension(flag); + case "description": + return flagDescription(flag); + default: + throw new IllegalArgumentException("Unknown flag value '" + arg + "'`"); + } + } + + @Override + public Message format(Context context) { + return applyModifier(Message.of("name")).format(context); + } + } +} diff --git a/src/main/java/io/github/mviper/bomberman/commands/GameCommand.java b/src/main/java/io/github/mviper/bomberman/commands/GameCommand.java new file mode 100644 index 0000000..39c86ba --- /dev/null +++ b/src/main/java/io/github/mviper/bomberman/commands/GameCommand.java @@ -0,0 +1,38 @@ +package io.github.mviper.bomberman.commands; + +import io.github.mviper.bomberman.events.BmGameListIntent; +import io.github.mviper.bomberman.events.BmGameLookupIntent; +import io.github.mviper.bomberman.game.Game; +import org.bukkit.command.CommandSender; + +import java.util.List; + +public abstract class GameCommand extends Cmd { + protected GameCommand(Cmd parent) { + super(parent); + } + + @Override + public List options(CommandSender sender, List args) { + if (args.size() <= 1) { + return BmGameListIntent.listGames().stream().map(Game::getName).toList(); + } + return gameOptions(args.subList(1, args.size())); + } + + protected abstract List gameOptions(List args); + + @Override + public boolean run(CommandSender sender, List args, java.util.Map flags) { + if (args.isEmpty()) { + return false; + } + Game game = BmGameLookupIntent.find(args.get(0)); + if (game == null) { + return false; + } + return gameRun(sender, args.subList(1, args.size()), flags, game); + } + + protected abstract boolean gameRun(CommandSender sender, List args, java.util.Map flags, Game game); +} diff --git a/src/main/java/io/github/mviper/bomberman/commands/Permission.java b/src/main/java/io/github/mviper/bomberman/commands/Permission.java new file mode 100644 index 0000000..d84ac3a --- /dev/null +++ b/src/main/java/io/github/mviper/bomberman/commands/Permission.java @@ -0,0 +1,9 @@ +package io.github.mviper.bomberman.commands; + +import org.bukkit.command.CommandSender; + +public interface Permission { + boolean isAllowedBy(CommandSender sender); + + String value(); +} diff --git a/src/main/java/io/github/mviper/bomberman/commands/Permissions.java b/src/main/java/io/github/mviper/bomberman/commands/Permissions.java new file mode 100644 index 0000000..102cf3a --- /dev/null +++ b/src/main/java/io/github/mviper/bomberman/commands/Permissions.java @@ -0,0 +1,36 @@ +package io.github.mviper.bomberman.commands; + +import org.bukkit.command.CommandSender; + +public enum Permissions implements Permission { + BASE("bomberman.bm"), + CREATE("bomberman.create"), + DELETE("bomberman.delete"), + UNDO("bomberman.undo"), + RELOAD("bomberman.reload"), + CONFIGURE("bomberman.configure"), + START("bomberman.start"), + STOP("bomberman.stop"), + INFO("bomberman.info"), + LIST("bomberman.list"), + JOIN("bomberman.join"), + JOIN_REMOTE("bomberman.join.remote"), + LEAVE("bomberman.leave"), + LEAVE_REMOTE("bomberman.leave.remote"); + + private final String permission; + + Permissions(String permission) { + this.permission = permission; + } + + @Override + public boolean isAllowedBy(CommandSender sender) { + return sender.hasPermission(permission); + } + + @Override + public String value() { + return permission; + } +} diff --git a/src/main/java/io/github/mviper/bomberman/commands/game/Configure.java b/src/main/java/io/github/mviper/bomberman/commands/game/Configure.java new file mode 100644 index 0000000..36ce902 --- /dev/null +++ b/src/main/java/io/github/mviper/bomberman/commands/game/Configure.java @@ -0,0 +1,652 @@ +package io.github.mviper.bomberman.commands.game; + +import io.github.mviper.bomberman.commands.Cmd; +import io.github.mviper.bomberman.commands.GameCommand; +import io.github.mviper.bomberman.commands.Permission; +import io.github.mviper.bomberman.commands.Permissions; +import io.github.mviper.bomberman.game.Game; +import io.github.mviper.bomberman.game.GameSettingsBuilder; +import io.github.mviper.bomberman.messaging.Formattable; +import io.github.mviper.bomberman.messaging.Text; +import org.bukkit.GameMode; +import org.bukkit.Material; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; + +public class Configure extends GameCommand { + public Configure(Cmd parent) { + super(parent); + } + + @Override + public Formattable name() { + return Text.CONFIGURE_NAME; + } + + @Override + protected List gameOptions(List args) { + return List.of(); + } + + @Override + public Permission permission() { + return Permissions.CONFIGURE; + } + + @Override + public Formattable extra() { + return Text.CONFIGURE_EXTRA; + } + + @Override + public Formattable example() { + return Text.CONFIGURE_EXAMPLE; + } + + @Override + public Formattable description() { + return Text.CONFIGURE_DESCRIPTION; + } + + @Override + public Formattable usage() { + return Text.CONFIGURE_USAGE; + } + + @Override + protected boolean gameRun(CommandSender sender, List args, Map flags, Game game) { + if (!args.isEmpty()) { + return false; + } + if (!(sender instanceof Player player)) { + Text.MUST_BE_PLAYER.format(cmdContext()).sendTo(sender); + return true; + } + if (player.getGameMode() != GameMode.CREATIVE) { + Text.CONFIGURE_PROMPT_CREATIVE.format(cmdContext()).sendTo(sender); + return true; + } + + showMainMenu(player, game); + return true; + } + + private void showMainMenu(Player player, Game game) { + GuiBuilder.show( + player, + Text.CONFIGURE_TITLE_MAIN.format(cmdContext()).toString(), + new CharSequence[]{ + " ", + " s b l i ", + " " + }, + index -> { + return switch (index.getSection()) { + case 's' -> new GuiBuilder.ItemSlot(Material.REDSTONE) + .unMovable() + .displayName(Text.CONFIGURE_TITLE_GENERAL.format(cmdContext()).toString()); + case 'b' -> new GuiBuilder.ItemSlot(Material.DIRT) + .unMovable() + .displayName(Text.CONFIGURE_TITLE_BLOCKS.format(cmdContext()).toString()); + case 'l' -> new GuiBuilder.ItemSlot(Material.GOLDEN_APPLE) + .unMovable() + .displayName(Text.CONFIGURE_TITLE_LOOT.format(cmdContext()).toString()); + case 'i' -> new GuiBuilder.ItemSlot(Material.CHEST) + .unMovable() + .displayName(Text.CONFIGURE_TITLE_INVENTORY.format(cmdContext()).toString()); + default -> GuiBuilder.blank; + }; + }, + (index, currentItem, cursorItem) -> { + switch (index.getSection()) { + case 's' -> showGeneralConfig(player, game); + case 'b' -> showBlockSettings(player, game); + case 'l' -> showLootSettings(player, game); + case 'i' -> showInventorySettings(player, game); + default -> { + } + } + }, + slots -> { + } + ); + } + + private void showGeneralConfig(Player player, Game game) { + GameSettingsBuilder settings = new GameSettingsBuilder(game.getSettings()); + GuiBuilder.show( + player, + Text.CONFIGURE_TITLE_GENERAL.format(cmdContext()).toString(), + new CharSequence[]{ + " ^^^^ ", + "< lfbitg ", + " vvvv " + }, + index -> { + index.getInventory().setMaxStackSize(100000); + return switch (index.getSection()) { + case '<' -> new GuiBuilder.ItemSlot(Material.PAPER) + .unMovable() + .displayName(stringify(Text.CONFIGURE_BACK)); + case 'l' -> new GuiBuilder.ItemSlot(setQty(new ItemStack(Material.PLAYER_HEAD), settings.lives)) + .unMovable() + .displayName(stringify(Text.CONFIGURE_LIVES)); + case 'f' -> new GuiBuilder.ItemSlot(setQty(new ItemStack(Material.TNT), settings.fuseTicks)) + .unMovable() + .displayName(stringify(Text.CONFIGURE_FUSE_TICKS)); + case 'b' -> new GuiBuilder.ItemSlot(setQty(new ItemStack(Material.FLINT_AND_STEEL), settings.fireTicks)) + .unMovable() + .displayName(stringify(Text.CONFIGURE_FIRE_TICKS)); + case 'i' -> new GuiBuilder.ItemSlot(setQty(new ItemStack(Material.MILK_BUCKET), settings.immunityTicks)) + .unMovable() + .displayName(stringify(Text.CONFIGURE_IMMUNITY_TICKS)); + case 't' -> new GuiBuilder.ItemSlot(settings.bombItem) + .unMovable() + .displayName(stringify(Text.CONFIGURE_TNT_BLOCK)); + case 'g' -> new GuiBuilder.ItemSlot(settings.powerItem) + .unMovable() + .displayName(stringify(Text.CONFIGURE_FIRE_ITEM)); + case '^' -> new GuiBuilder.ItemSlot(Material.STONE_BUTTON) + .unMovable() + .displayName("+"); + case 'v' -> new GuiBuilder.ItemSlot(Material.STONE_BUTTON) + .unMovable() + .displayName("-"); + default -> GuiBuilder.blank; + }; + }, + (index, slot, cursor) -> { + index.getInventory().setMaxStackSize(100000); + switch (index.getSection()) { + case '<' -> showMainMenu(player, game); + case '^' -> { + ItemStack item = index.getInventory().getItem(index.getInvIndex() + 9); + if (item != null) { + setQty(item, item.getAmount() + 1); + } + } + case 'v' -> { + ItemStack item = index.getInventory().getItem(index.getInvIndex() - 9); + if (item != null) { + setQty(item, Math.max(item.getAmount() - 1, 1)); + } + } + case 't', 'g' -> { + if (cursor != null && cursor.getAmount() != 0 && slot != null) { + slot.setType(cursor.getType()); + } + } + default -> { + } + } + }, + slots -> { + for (GuiBuilder.SlotItem slotItem : slots) { + GuiBuilder.Index index = slotItem.getIndex(); + ItemStack item = slotItem.getItem(); + if (item == null) { + continue; + } + switch (index.getSection()) { + case 'l' -> settings.lives = item.getAmount(); + case 'f' -> settings.fuseTicks = item.getAmount(); + case 'b' -> settings.fireTicks = item.getAmount(); + case 'i' -> settings.immunityTicks = item.getAmount(); + case 't' -> settings.bombItem = item.getType(); + case 'g' -> settings.powerItem = item.getType(); + default -> { + } + } + } + game.setSettings(settings.build()); + } + ); + } + + private void showBlockSettings(Player player, Game game) { + GameSettingsBuilder builder = new GameSettingsBuilder(game.getSettings()); + showBlockSettings( + player, + game, + builder, + 0, + stringify(Text.CONFIGURE_DESTRUCTIBLE_DESC), + game.getSettings().getDestructible(), + materials -> builder.destructible = materials + ); + } + + private void showBlockSettings( + Player player, + Game game, + GameSettingsBuilder builder, + int selected, + String description, + Set types, + Consumer> result + ) { + List typesList = types.stream() + .filter(Material::isItem) + .filter(Material::isBlock) + .toList(); + + GuiBuilder.show( + player, + Text.CONFIGURE_TITLE_BLOCKS.format(cmdContext()).toString(), + new CharSequence[]{ + " { + return switch (index.getSection()) { + case 'E' -> new GuiBuilder.ItemSlot(Material.EMERALD) + .unMovable() + .displayName(description); + case '<' -> new GuiBuilder.ItemSlot(Material.PAPER) + .unMovable() + .displayName(stringify(Text.CONFIGURE_BACK)); + case 'd' -> new GuiBuilder.ItemSlot(Material.DIRT) + .unMovable() + .displayName(stringify(Text.CONFIGURE_DESTRUCTIBLE)); + case 's' -> new GuiBuilder.ItemSlot(Material.OBSIDIAN) + .unMovable() + .displayName(stringify(Text.CONFIGURE_INDESTRUCTIBLE)); + case 'p' -> new GuiBuilder.ItemSlot(Material.OXEYE_DAISY) + .unMovable() + .displayName(stringify(Text.CONFIGURE_PASS_DESTROY)); + case 'n' -> new GuiBuilder.ItemSlot(Material.OAK_SIGN) + .unMovable() + .displayName(stringify(Text.CONFIGURE_PASS_KEEP)); + case 'c' -> { + if (index.getSecIndex() == selected) { + yield new GuiBuilder.ItemSlot(Material.YELLOW_STAINED_GLASS_PANE) + .unMovable() + .displayName(" "); + } + yield GuiBuilder.blank; + } + case 'i' -> { + Material mat = index.getSecIndex() < typesList.size() ? typesList.get(index.getSecIndex()) : null; + if (mat == null) { + yield new GuiBuilder.ItemSlot((ItemStack) null); + } + yield new GuiBuilder.ItemSlot(mat); + } + default -> GuiBuilder.blank; + }; + }, + (index, currentItem, cursorItem) -> { + switch (index.getSection()) { + case '<' -> showMainMenu(player, game); + case 'd' -> { + if (selected != 0) { + showBlockSettings( + player, + game, + builder, + 0, + stringify(Text.CONFIGURE_DESTRUCTIBLE_DESC), + builder.destructible, + materials -> builder.destructible = materials + ); + } + } + case 's' -> { + if (selected != 1) { + showBlockSettings( + player, + game, + builder, + 1, + stringify(Text.CONFIGURE_INDESTRUCTIBLE_DESC), + builder.indestructible, + materials -> builder.indestructible = materials + ); + } + } + case 'p' -> { + if (selected != 2) { + showBlockSettings( + player, + game, + builder, + 2, + stringify(Text.CONFIGURE_PASS_DESTROY_DESC), + builder.passDestroy, + materials -> builder.passDestroy = materials + ); + } + } + case 'n' -> { + if (selected != 3) { + showBlockSettings( + player, + game, + builder, + 3, + stringify(Text.CONFIGURE_PASS_KEEP_DESC), + builder.passKeep, + materials -> builder.passKeep = materials + ); + } + } + default -> { + } + } + }, + slots -> { + Set blocks = new HashSet<>(); + for (GuiBuilder.SlotItem slotItem : slots) { + GuiBuilder.Index index = slotItem.getIndex(); + ItemStack stack = slotItem.getItem(); + if (index.getSection() == 'i' && stack != null) { + blocks.addAll(expandSimilarMaterials(stack.getType())); + } + } + result.accept(blocks); + game.setSettings(builder.build()); + } + ); + } + + private List expandSimilarMaterials(Material mat) { + String wallVariant = mat.getKey().getKey() + .replace("sign", "wall_sign") + .replace("banner", "wall_banner") + .replace("fan", "wall_fan") + .replace("torch", "wall_torch") + .replace("head", "wall_head") + .replace("skull", "skull_head"); + Material wallType = Material.matchMaterial(wallVariant); + if (wallType == null) { + return List.of(mat); + } + return List.of(mat, wallType); + } + + private void showInventorySettings(Player player, Game game) { + GuiBuilder.show( + player, + stringify(Text.CONFIGURE_TITLE_INVENTORY), + new CharSequence[]{ + "< HCLBS ", + " aaaas ", + "iiiiiiiii", + "iiiiiiiii", + "iiiiiiiii", + "hhhhhhhhh" + }, + index -> { + List initialItems = new ArrayList<>(game.getSettings().getInitialItems()); + while (initialItems.size() < 9 * 4 + 5) { + initialItems.add(null); + } + return switch (index.getSection()) { + case '<' -> new GuiBuilder.ItemSlot(Material.PAPER) + .unMovable() + .displayName(stringify(Text.CONFIGURE_BACK)); + case 'a' -> new GuiBuilder.ItemSlot(initialItems.get((3 - index.getSecIndex()) + 9 * 4)); + case 's' -> new GuiBuilder.ItemSlot(initialItems.get(9 * 4 + 4)); + case 'h' -> new GuiBuilder.ItemSlot(initialItems.get(index.getSecIndex())); + case 'i' -> new GuiBuilder.ItemSlot(initialItems.get(index.getSecIndex() + 9)); + case 'H' -> new GuiBuilder.ItemSlot(Material.IRON_HELMET) + .unMovable() + .hideAttributes(); + case 'C' -> new GuiBuilder.ItemSlot(Material.IRON_CHESTPLATE) + .unMovable() + .hideAttributes(); + case 'L' -> new GuiBuilder.ItemSlot(Material.IRON_LEGGINGS) + .unMovable() + .hideAttributes(); + case 'B' -> new GuiBuilder.ItemSlot(Material.IRON_BOOTS) + .unMovable() + .hideAttributes(); + case 'S' -> new GuiBuilder.ItemSlot(Material.SHIELD) + .unMovable() + .hideAttributes(); + default -> GuiBuilder.blank; + }; + }, + (index, currentItem, cursorItem) -> { + if (index.getSection() == '<') { + showMainMenu(player, game); + } + }, + slots -> { + ItemStack[] closingItems = new ItemStack[9 * 4 + 5]; + for (GuiBuilder.SlotItem slotItem : slots) { + GuiBuilder.Index index = slotItem.getIndex(); + ItemStack item = slotItem.getItem(); + switch (index.getSection()) { + case 'a' -> closingItems[9 * 4 + (3 - index.getSecIndex())] = item; + case 's' -> closingItems[9 * 4 + 4] = item; + case 'h' -> closingItems[index.getSecIndex()] = item; + case 'i' -> closingItems[9 + index.getSecIndex()] = item; + default -> { + } + } + } + GameSettingsBuilder builder = new GameSettingsBuilder(game.getSettings()); + builder.initialItems = Arrays.asList(closingItems); + game.setSettings(builder.build()); + } + ); + } + + private void showLootSettings(Player player, Game game) { + Map> gameLoot = game.getSettings().getBlockLoot(); + + Map, Set> lootBlock = new HashMap<>(); + for (Map.Entry> entry : gameLoot.entrySet()) { + lootBlock.computeIfAbsent(entry.getValue(), key -> new HashSet<>()).add(entry.getKey()); + } + + List blockLoot = new ArrayList<>(); + for (Map.Entry, Set> entry : lootBlock.entrySet()) { + Map loot = entry.getKey(); + Set matList = entry.getValue(); + List lootList = new ArrayList<>(); + for (Map.Entry lootEntry : loot.entrySet()) { + ItemStack stack = lootEntry.getKey(); + int weight = lootEntry.getValue(); + int brokenWeight = weight; + do { + lootList.add(new WeightedItem(stack, Math.min(brokenWeight, 64))); + brokenWeight -= 64; + } while (brokenWeight > 0); + } + blockLoot.add(new LootSlot(new ArrayList<>(matList), lootList)); + } + + showLootSettings(player, game, 0, blockLoot); + } + + private void showLootSettings(Player player, Game game, int slot, List loot) { + GuiBuilder.show( + player, + stringify(Text.CONFIGURE_TITLE_LOOT), + new CharSequence[]{ + " { + LootSlot selected = slot < loot.size() ? loot.get(slot) : null; + return switch (index.getSection()) { + case '<' -> new GuiBuilder.ItemSlot(Material.PAPER) + .unMovable() + .displayName(stringify(Text.CONFIGURE_BACK)); + case 'S' -> { + Material icon; + if (index.getSecIndex() == slot) { + icon = Material.YELLOW_CONCRETE; + } else if (index.getSecIndex() >= loot.size()) { + icon = Material.GRAY_CONCRETE; + } else { + LootSlot candidate = loot.get(index.getSecIndex()); + if (candidate.materials.isEmpty() && candidate.items.isEmpty()) { + icon = Material.GRAY_CONCRETE; + } else { + icon = Material.WHITE_CONCRETE; + } + } + yield new GuiBuilder.ItemSlot(icon) + .unMovable() + .displayName(Text.CONFIGURE_LOOT_SLOT.format(cmdContext().plus("slot", index.getSecIndex())).toString()); + } + case 'K' -> new GuiBuilder.ItemSlot(Material.EMERALD) + .unMovable() + .displayName(Text.CONFIGURE_LOOT_BLOCK.format(cmdContext().plus("slot", slot)).toString()); + case 'k' -> { + ItemStack stack = null; + if (selected != null && index.getSecIndex() < selected.materials.size()) { + stack = new ItemStack(selected.materials.get(index.getSecIndex())); + } + yield new GuiBuilder.ItemSlot(stack); + } + case 'V' -> new GuiBuilder.ItemSlot(Material.EMERALD) + .unMovable() + .displayName(Text.CONFIGURE_LOOT_ITEM.format(cmdContext().plus("slot", slot)).toString()); + case 'v' -> { + ItemStack stack = null; + if (selected != null && index.getSecIndex() < selected.items.size()) { + stack = selected.items.get(index.getSecIndex()).item; + } + yield new GuiBuilder.ItemSlot(stack); + } + case 'W' -> new GuiBuilder.ItemSlot(Material.EMERALD) + .unMovable() + .displayName(Text.CONFIGURE_LOOT_WEIGHT.format(cmdContext().plus("slot", slot)).toString()); + case 'w' -> { + int weight = 0; + if (selected != null && index.getSecIndex() < selected.items.size()) { + weight = selected.items.get(index.getSecIndex()).weight; + } + yield new GuiBuilder.ItemSlot(Material.GOLD_NUGGET, weight); + } + default -> GuiBuilder.blank; + }; + }, + (index, currentItem, cursorItem) -> { + if (index.getSection() == '<') { + showMainMenu(player, game); + } else if (index.getSection() == 'S') { + showLootSettings(player, game, index.getSecIndex(), loot); + } + }, + slots -> { + List matsSaved = new ArrayList<>(); + Map itemsSaved = new HashMap<>(); + + for (GuiBuilder.SlotItem slotItem : slots) { + GuiBuilder.Index index = slotItem.getIndex(); + ItemStack item = slotItem.getItem(); + switch (index.getSection()) { + case 'k' -> { + if (item != null) { + matsSaved.add(item.getType()); + } + } + case 'v' -> { + int weight = 0; + ItemStack weightItem = index.getInventory().getItem(index.getInvIndex() + 9); + if (weightItem != null) { + weight = weightItem.getAmount(); + } + itemsSaved.computeIfAbsent(item, key -> new AtomicInteger(0)).addAndGet(weight); + } + default -> { + } + } + } + + while (loot.size() <= slot) { + loot.add(new LootSlot(new ArrayList<>(), new ArrayList<>())); + } + + List itemsList = new ArrayList<>(); + for (Map.Entry entry : itemsSaved.entrySet()) { + ItemStack stack = entry.getKey(); + int weight = entry.getValue().get(); + if (weight == 0 && stack == null) { + continue; + } + ItemStack resolved = stack != null ? stack : new ItemStack(Material.AIR, 0); + itemsList.add(new WeightedItem(resolved, weight)); + } + + loot.set(slot, new LootSlot(matsSaved, itemsList)); + + GameSettingsBuilder builder = new GameSettingsBuilder(game.getSettings()); + builder.blockLoot = toBlockLootMap(loot); + game.setSettings(builder.build()); + } + ); + } + + private Map> toBlockLootMap(List loot) { + Map> result = new HashMap<>(); + for (LootSlot slot : loot) { + Map itemWeights = new HashMap<>(); + for (WeightedItem item : slot.items) { + itemWeights.put(item.item, item.weight); + } + for (Material mat : slot.materials) { + for (Material expanded : expandSimilarMaterials(mat)) { + result.put(expanded, new HashMap<>(itemWeights)); + } + } + } + return result; + } + + private ItemStack setQty(ItemStack itemStack, int amount) { + itemStack.setAmount(amount); + ItemMeta meta = itemStack.getItemMeta(); + if (meta != null) { + meta.setLore(List.of(String.valueOf(amount))); + itemStack.setItemMeta(meta); + } + return itemStack; + } + + private String stringify(Text text) { + return text.format(cmdContext()).toString(); + } + + private static final class WeightedItem { + private final ItemStack item; + private final int weight; + + private WeightedItem(ItemStack item, int weight) { + this.item = item; + this.weight = weight; + } + } + + private static final class LootSlot { + private final List materials; + private final List items; + + private LootSlot(List materials, List items) { + this.materials = materials; + this.items = items; + } + } +} diff --git a/src/main/java/io/github/mviper/bomberman/commands/game/DevInfo.java b/src/main/java/io/github/mviper/bomberman/commands/game/DevInfo.java new file mode 100644 index 0000000..17703cd --- /dev/null +++ b/src/main/java/io/github/mviper/bomberman/commands/game/DevInfo.java @@ -0,0 +1,189 @@ +package io.github.mviper.bomberman.commands.game; + +import io.github.mviper.bomberman.Bomberman; +import io.github.mviper.bomberman.commands.Cmd; +import io.github.mviper.bomberman.commands.Permission; +import io.github.mviper.bomberman.commands.Permissions; +import io.github.mviper.bomberman.messaging.Formattable; +import io.github.mviper.bomberman.messaging.Message; +import org.bukkit.Bukkit; +import org.bukkit.ChatColor; +import org.bukkit.command.CommandSender; +import org.bukkit.event.HandlerList; +import org.bukkit.plugin.RegisteredListener; +import org.bukkit.scheduler.BukkitTask; + +import java.util.List; +import java.util.Locale; +import java.util.concurrent.atomic.AtomicReference; + +public class DevInfo extends Cmd { + public DevInfo(Cmd parent) { + super(parent); + } + + @Override + public Formattable name() { + return Message.of("dev"); + } + + @Override + public List options(CommandSender sender, List args) { + return List.of( + "handlerlist", + "handlercount", + "handlerwatch", + "nocancelled", + "tasklist", + "taskcount", + "taskwatch", + "watch", + "permissions" + ); + } + + @Override + public boolean run(CommandSender sender, List args, java.util.Map flags) { + String action = args.isEmpty() ? "" : args.get(0).toLowerCase(Locale.ROOT); + switch (action) { + case "watch" -> { + run(sender, List.of("nocancelled"), flags); + run(sender, List.of("handlerwatch"), flags); + run(sender, List.of("taskwatch"), flags); + run(sender, List.of("taskcount"), flags); + run(sender, List.of("handlercount"), flags); + return true; + } + case "handlerlist" -> { + String filter = flags.getOrDefault("class", ""); + var handlers = HandlerList.getRegisteredListeners(Bomberman.instance).stream() + .map(listener -> listener.getListener()) + .filter(listener -> listener.getClass().getName().contains(filter)) + .toList(); + sender.sendMessage(handlers.toString()); + return true; + } + case "handlercount" -> { + var handlers = HandlerList.getRegisteredListeners(Bomberman.instance); + sender.sendMessage("Handlers: " + handlers.size()); + return true; + } + case "nocancelled" -> { + Bukkit.getScheduler().scheduleSyncRepeatingTask(Bomberman.instance, () -> { + HandlerList.getRegisteredListeners(Bomberman.instance).forEach(listener -> { + if (!listener.isIgnoringCancelled()) { + sender.sendMessage("Not watching for cancelled: " + listener.getListener()); + } + }); + }, 1L, 1L); + sender.sendMessage("Watching for handlers watching for ignored events"); + return true; + } + case "handlerwatch" -> { + AtomicReference> handlersRef = new AtomicReference<>( + HandlerList.getRegisteredListeners(Bomberman.instance).stream().toList() + ); + int startCount = handlersRef.get().size(); + Bukkit.getScheduler().scheduleSyncRepeatingTask(Bomberman.instance, () -> { + List updated = HandlerList.getRegisteredListeners(Bomberman.instance).stream().toList(); + List handlers = handlersRef.get(); + var added = updated.stream().filter(h -> !handlers.contains(h)).toList(); + var removed = handlers.stream().filter(h -> !updated.contains(h)).toList(); + for (var handler : added) { + sender.sendMessage(" " + ChatColor.RED + "+" + ChatColor.RESET + " " + handler.getListener()); + } + for (var handler : removed) { + sender.sendMessage(" " + ChatColor.GREEN + "-" + ChatColor.RESET + " " + handler.getListener()); + } + if (handlers.size() != updated.size()) { + int countDiff = updated.size() - startCount; + sender.sendMessage((countDiff > 0 ? ChatColor.RED : ChatColor.GREEN) + " " + countDiff + " handlers"); + } + handlersRef.set(updated); + }, 1L, 1L); + sender.sendMessage("Watching for new handlers"); + return true; + } + case "tasklist" -> { + Bukkit.getScheduler().getPendingTasks().forEach(task -> { + if (task.getOwner() == Bomberman.instance) { + sender.sendMessage("Task with id: " + task.getTaskId()); + } + }); + return true; + } + case "taskcount" -> { + long count = Bukkit.getScheduler().getPendingTasks().stream() + .filter(task -> task.getOwner() == Bomberman.instance) + .count(); + sender.sendMessage("Tasks: " + count); + return true; + } + case "taskwatch" -> { + AtomicReference> tasksRef = new AtomicReference<>( + Bukkit.getScheduler().getPendingTasks().stream() + .filter(task -> task.getOwner() == Bomberman.instance) + .toList() + ); + int startCount = tasksRef.get().size() + 1; + Bukkit.getScheduler().scheduleSyncRepeatingTask(Bomberman.instance, () -> { + List updated = Bukkit.getScheduler().getPendingTasks().stream() + .filter(task -> task.getOwner() == Bomberman.instance) + .toList(); + List tasks = tasksRef.get(); + var added = updated.stream().filter(t -> !tasks.contains(t)).toList(); + var removed = tasks.stream().filter(t -> !updated.contains(t)).toList(); + for (var task : added) { + sender.sendMessage(" " + ChatColor.RED + "+" + ChatColor.RESET + " " + task.getTaskId()); + } + for (var task : removed) { + sender.sendMessage(" " + ChatColor.GREEN + "-" + ChatColor.RESET + " " + task.getTaskId()); + } + if (tasks.size() != updated.size()) { + int countDiff = updated.size() - startCount; + sender.sendMessage((countDiff > 0 ? ChatColor.RED : ChatColor.GREEN) + " " + countDiff + " tasks"); + } + tasksRef.set(updated); + }, 1L, 1L); + sender.sendMessage("Watching for new tasks"); + return true; + } + case "permissions" -> { + if (args.size() == 1) { + sender.getEffectivePermissions().forEach(perm -> sender.sendMessage(" - " + perm.getPermission())); + } else { + sender.sendMessage(args.get(1) + " : " + sender.hasPermission(args.get(1))); + } + return true; + } + default -> { + return false; + } + } + } + + @Override + public Permission permission() { + return Permissions.BASE; + } + + @Override + public Formattable example() { + return Message.empty; + } + + @Override + public Formattable extra() { + return Message.empty; + } + + @Override + public Formattable description() { + return Message.of("Dev commands. Was meant to remove this before shipping"); + } + + @Override + public Formattable usage() { + return Message.empty; + } +} diff --git a/src/main/java/io/github/mviper/bomberman/commands/game/GameCreate.java b/src/main/java/io/github/mviper/bomberman/commands/game/GameCreate.java new file mode 100644 index 0000000..81615f5 --- /dev/null +++ b/src/main/java/io/github/mviper/bomberman/commands/game/GameCreate.java @@ -0,0 +1,291 @@ +package io.github.mviper.bomberman.commands.game; + +import com.sk89q.worldedit.WorldEdit; +import com.sk89q.worldedit.bukkit.BukkitAdapter; +import com.sk89q.worldedit.extent.clipboard.io.BuiltInClipboardFormat; +import com.sk89q.worldedit.extent.clipboard.io.ClipboardFormats; +import com.sk89q.worldedit.session.SessionOwner; +import io.github.mviper.bomberman.Bomberman; +import io.github.mviper.bomberman.commands.Cmd; +import io.github.mviper.bomberman.commands.Permission; +import io.github.mviper.bomberman.commands.Permissions; +import io.github.mviper.bomberman.events.BmGameListIntent; +import io.github.mviper.bomberman.events.BmGameLookupIntent; +import io.github.mviper.bomberman.game.Game; +import io.github.mviper.bomberman.game.GameSave; +import io.github.mviper.bomberman.game.GameSettings; +import io.github.mviper.bomberman.game.GameSettingsBuilder; +import io.github.mviper.bomberman.messaging.Formattable; +import io.github.mviper.bomberman.messaging.Message; +import io.github.mviper.bomberman.messaging.Text; +import io.github.mviper.bomberman.utils.WorldEditUtils; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; + +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.logging.Level; +import java.util.stream.Collectors; + +public class GameCreate extends Cmd { + private static final Bomberman PLUGIN = Bomberman.instance; + private static final WorldEdit WE = WorldEdit.getInstance(); + + private static final String F_SCHEMA = "s"; + private static final String F_TEMPLATE = "t"; + private static final String F_GAME = "g"; + private static final String F_WAND = "w"; + private static final String F_PLUGIN = "p"; + + public GameCreate(Cmd parent) { + super(parent); + } + + @Override + public Formattable name() { + return Text.CREATE_NAME; + } + + @Override + public List options(CommandSender sender, List args) { + if (args.size() == 1) { + return BmGameListIntent.listGames().stream().map(Game::getName).toList(); + } + return List.of(); + } + + @Override + public Set flags(CommandSender sender) { + return Set.of(F_SCHEMA, F_WAND, F_GAME, F_TEMPLATE); + } + + @Override + public Set flagOptions(String flag) { + return switch (flag) { + case F_SCHEMA -> { + Path weDir = WE.getWorkingDirectoryPath(WE.getConfiguration().saveDir); + List schemaExtensions = new ArrayList<>(); + for (BuiltInClipboardFormat format : BuiltInClipboardFormat.values()) { + schemaExtensions.addAll(format.getFileExtensions()); + } + yield allFiles(weDir, weDir).stream() + .map(Path::toString) + .filter(fileName -> schemaExtensions.stream().anyMatch(fileName::endsWith)) + .collect(Collectors.toSet()); + } + case F_TEMPLATE -> { + Path templatesDir = PLUGIN.templates(); + yield allFiles(templatesDir, templatesDir).stream() + .map(Path::toString) + .filter(fileName -> fileName.endsWith(".game.zip")) + .map(fileName -> fileName.replaceAll("(.*)\\.game\\.zip", "$1")) + .collect(Collectors.toSet()); + } + case F_GAME -> BmGameListIntent.listGames().stream().map(Game::getName).collect(Collectors.toSet()); + default -> Set.of(); + }; + } + + @Override + public Formattable flagDescription(String flag) { + return switch (flag) { + case F_SCHEMA -> Text.CREATE_FLAG_SCHEMA; + case F_TEMPLATE -> Text.CREATE_FLAG_TEMPLATE; + case F_GAME -> Text.CREATE_FLAG_GAME; + case F_WAND -> Text.CREATE_FLAG_WAND; + default -> Message.empty; + }; + } + + @Override + public Formattable flagExtension(String flag) { + return switch (flag) { + case F_SCHEMA -> Text.CREATE_FLAG_SCHEMA_EXT; + case F_TEMPLATE -> Text.CREATE_FLAG_TEMPLATE_EXT; + case F_GAME -> Text.CREATE_FLAG_GAME_EXT; + default -> Message.empty; + }; + } + + @Override + public boolean run(CommandSender sender, List args, java.util.Map flags) { + try { + if (args.size() != 1) { + return false; + } + if (!(sender instanceof Player player)) { + Text.MUST_BE_PLAYER.format(cmdContext()).sendTo(sender); + return true; + } + String gameName = args.get(0); + Game existing = BmGameLookupIntent.find(gameName); + if (existing != null) { + Text.CREATE_GAME_EXISTS.format(cmdContext().plus("game", existing)).sendTo(sender); + return true; + } + if (Files.exists(PLUGIN.gameSaves().resolve(GameSave.sanitize(gameName + ".game.zip")))) { + Text.CREATE_GAME_FILE_CONFLICT.format(cmdContext() + .plus("game", gameName) + .plus("file", GameSave.sanitize(gameName + ".game.zip"))) + .sendTo(sender); + return true; + } + + int flagCount = (flags.containsKey(F_WAND) ? 1 : 0) + + (flags.containsKey(F_GAME) ? 1 : 0) + + (flags.containsKey(F_TEMPLATE) ? 1 : 0) + + (flags.containsKey(F_SCHEMA) ? 1 : 0) + + (flags.containsKey(F_PLUGIN) ? 1 : 0); + if (flagCount != 1) { + return false; + } + + if (flags.containsKey(F_WAND)) { + makeFromSelection(gameName, player, new GameSettingsBuilder().build()); + return true; + } + + var schemaAndSettings = flags.containsKey(F_SCHEMA) + ? fromSchema(flags.get(F_SCHEMA), sender) + : flags.containsKey(F_GAME) + ? fromGame(flags.get(F_GAME)) + : flags.containsKey(F_TEMPLATE) + ? fromTemplate(flags.get(F_TEMPLATE), sender) + : fromPlugin(flags.get(F_PLUGIN)); + + if (schemaAndSettings == null) { + return true; + } + + Game game = Game.buildGameFromSchema(gameName, player.getLocation(), schemaAndSettings.schema, schemaAndSettings.settings); + Text.CREATE_SUCCESS.format(cmdContext().plus("game", game)).sendTo(sender); + return true; + } catch (Exception e) { + Text.CREATE_ERROR.format(cmdContext().plus("error", e.getMessage() != null ? e.getMessage() : "")) + .sendTo(sender); + PLUGIN.getLogger().log(Level.WARNING, "Error creating game", e); + } + return true; + } + + private SchemaSettings fromSchema(String file, CommandSender sender) throws Exception { + if (file == null || file.isEmpty()) { + return null; + } + Path path = WE.getWorkingDirectoryPath(WE.getConfiguration().saveDir).resolve(file); + if (!Files.exists(path)) { + Text.CREATE_GAME_FILE_NOT_FOUND.format(cmdContext() + .plus("file", path.toString()) + .plus("filename", path.getFileName().toString())) + .sendTo(sender); + return null; + } + var format = ClipboardFormats.findByFile(path.toFile()); + if (format == null) { + throw new IllegalArgumentException("Unknown file format: '" + file + "'"); + } + var clipboard = format.getReader(Files.newInputStream(path)).read(); + return new SchemaSettings(clipboard, new GameSettingsBuilder().build()); + } + + private SchemaSettings fromGame(String name) throws Exception { + Game existing = BmGameLookupIntent.find(name); + if (existing == null) { + return null; + } + return new SchemaSettings(existing.getClipboard(), existing.getSettings()); + } + + private SchemaSettings fromTemplate(String file, CommandSender sender) throws Exception { + if (file == null || file.isEmpty()) { + return null; + } + String fullFileName = file.toLowerCase(Locale.ROOT).endsWith(".game.zip") ? file : file + ".game.zip"; + Path path = PLUGIN.templates().resolve(fullFileName); + if (!Files.exists(path)) { + Text.CREATE_GAME_FILE_NOT_FOUND.format(cmdContext() + .plus("file", path.toString()) + .plus("filename", path.getFileName().toString())) + .sendTo(sender); + return null; + } + GameSave save = GameSave.loadSave(path); + return new SchemaSettings(save.getSchematic(), save.getSettings()); + } + + private SchemaSettings fromPlugin(String value) throws Exception { + if (value != null && value.toLowerCase(Locale.ROOT).equals("bm")) { + Path path = PLUGIN.templates().resolve("purple.game.zip"); + GameSave save = GameSave.loadSave(path); + return new SchemaSettings(save.getSchematic(), save.getSettings()); + } + return null; + } + + private void makeFromSelection(String gameName, Player sender, GameSettings settings) { + SessionOwner owner = BukkitAdapter.adapt(sender); + var session = WorldEdit.getInstance().getSessionManager().getIfPresent(owner); + if (session == null || session.getSelectionWorld() == null || !session.isSelectionDefined(session.getSelectionWorld())) { + Text.CREATE_NEED_SELECTION.format(cmdContext()).sendTo(sender); + } else { + try { + var region = session.getSelection(session.getSelectionWorld()); + var box = WorldEditUtils.selectionBounds(region); + Game game = Game.buildGameFromRegion(gameName, box, settings); + Text.CREATE_SUCCESS.format(cmdContext().plus("game", game)).sendTo(sender); + } catch (com.sk89q.worldedit.IncompleteRegionException e) { + Text.CREATE_NEED_SELECTION.format(cmdContext()).sendTo(sender); + } + } + } + + @Override + public Permission permission() { + return Permissions.CREATE; + } + + @Override + public Formattable example() { + return Text.CREATE_EXAMPLE; + } + + @Override + public Formattable extra() { + return Text.CREATE_EXTRA; + } + + @Override + public Formattable description() { + return Text.CREATE_DESCRIPTION; + } + + @Override + public Formattable usage() { + return Text.CREATE_USAGE; + } + + private static List allFiles(Path root, Path relative) { + if (!Files.isDirectory(root, LinkOption.NOFOLLOW_LINKS)) { + return List.of(); + } + List fileList = new ArrayList<>(); + try (var stream = Files.newDirectoryStream(root)) { + for (Path file : stream) { + if (Files.isDirectory(file, LinkOption.NOFOLLOW_LINKS)) { + fileList.addAll(allFiles(file, relative)); + } else { + fileList.add(relative != null ? relative.relativize(file) : file); + } + } + } catch (Exception ignored) { + } + return fileList; + } + + private record SchemaSettings(com.sk89q.worldedit.extent.clipboard.Clipboard schema, GameSettings settings) {} +} diff --git a/src/main/java/io/github/mviper/bomberman/commands/game/GameDelete.java b/src/main/java/io/github/mviper/bomberman/commands/game/GameDelete.java new file mode 100644 index 0000000..a117480 --- /dev/null +++ b/src/main/java/io/github/mviper/bomberman/commands/game/GameDelete.java @@ -0,0 +1,64 @@ +package io.github.mviper.bomberman.commands.game; + +import io.github.mviper.bomberman.commands.Cmd; +import io.github.mviper.bomberman.commands.GameCommand; +import io.github.mviper.bomberman.commands.Permission; +import io.github.mviper.bomberman.commands.Permissions; +import io.github.mviper.bomberman.events.BmGameDeletedIntent; +import io.github.mviper.bomberman.game.Game; +import io.github.mviper.bomberman.messaging.Formattable; +import io.github.mviper.bomberman.messaging.Text; +import org.bukkit.command.CommandSender; + +import java.util.List; + +public class GameDelete extends GameCommand { + public GameDelete(Cmd parent) { + super(parent); + } + + @Override + public Formattable name() { + return Text.DELETE_NAME; + } + + @Override + protected List gameOptions(List args) { + return List.of(); + } + + @Override + protected boolean gameRun(CommandSender sender, List args, java.util.Map flags, Game game) { + if (!args.isEmpty()) { + return false; + } + BmGameDeletedIntent.delete(game, true); + Text.DELETE_SUCCESS.format(cmdContext().plus("game", game)).sendTo(sender); + return true; + } + + @Override + public Permission permission() { + return Permissions.DELETE; + } + + @Override + public Formattable example() { + return Text.DELETE_EXAMPLE; + } + + @Override + public Formattable extra() { + return Text.DELETE_EXTRA; + } + + @Override + public Formattable description() { + return Text.DELETE_DESCRIPTION; + } + + @Override + public Formattable usage() { + return Text.DELETE_USAGE; + } +} diff --git a/src/main/java/io/github/mviper/bomberman/commands/game/GameInfo.java b/src/main/java/io/github/mviper/bomberman/commands/game/GameInfo.java new file mode 100644 index 0000000..dbc4f07 --- /dev/null +++ b/src/main/java/io/github/mviper/bomberman/commands/game/GameInfo.java @@ -0,0 +1,62 @@ +package io.github.mviper.bomberman.commands.game; + +import io.github.mviper.bomberman.commands.Cmd; +import io.github.mviper.bomberman.commands.GameCommand; +import io.github.mviper.bomberman.commands.Permission; +import io.github.mviper.bomberman.commands.Permissions; +import io.github.mviper.bomberman.game.Game; +import io.github.mviper.bomberman.messaging.Formattable; +import io.github.mviper.bomberman.messaging.Text; +import org.bukkit.command.CommandSender; + +import java.util.List; + +public class GameInfo extends GameCommand { + public GameInfo(Cmd parent) { + super(parent); + } + + @Override + public Formattable name() { + return Text.INFO_NAME; + } + + @Override + public Permission permission() { + return Permissions.INFO; + } + + @Override + protected List gameOptions(List args) { + return List.of(); + } + + @Override + protected boolean gameRun(CommandSender sender, List args, java.util.Map flags, Game game) { + if (!args.isEmpty()) { + return false; + } + Text.INFO_DETAILS.format(cmdContext().plus("game", game)).sendTo(sender); + return true; + } + + @Override + public Formattable extra() { + return Text.INFO_EXTRA; + } + + @Override + public Formattable example() { + return Text.INFO_EXAMPLE; + } + + @Override + public Formattable description() { + return Text.INFO_DESCRIPTION; + } + + @Override + public Formattable usage() { + return Text.INFO_USAGE; + } +} diff --git a/src/main/java/io/github/mviper/bomberman/commands/game/GameJoin.java b/src/main/java/io/github/mviper/bomberman/commands/game/GameJoin.java new file mode 100644 index 0000000..30f99f9 --- /dev/null +++ b/src/main/java/io/github/mviper/bomberman/commands/game/GameJoin.java @@ -0,0 +1,140 @@ +package io.github.mviper.bomberman.commands.game; + +import io.github.mviper.bomberman.commands.Cmd; +import io.github.mviper.bomberman.commands.GameCommand; +import io.github.mviper.bomberman.commands.Permission; +import io.github.mviper.bomberman.commands.Permissions; +import io.github.mviper.bomberman.events.BmPlayerJoinGameIntent; +import io.github.mviper.bomberman.game.Game; +import io.github.mviper.bomberman.messaging.Formattable; +import io.github.mviper.bomberman.messaging.Message; +import io.github.mviper.bomberman.messaging.Text; +import org.bukkit.Bukkit; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; + +import java.util.List; +import java.util.Set; + +public class GameJoin extends GameCommand { + private static final String F_TARGET = "t"; + + public GameJoin(Cmd parent) { + super(parent); + } + + public static List select(String target, CommandSender source) throws IllegalArgumentException { + return Bukkit.selectEntities(source, target).stream() + .filter(entity -> entity instanceof Player) + .map(entity -> (Player) entity) + .toList(); + } + + @Override + public Formattable name() { + return Text.JOIN_NAME; + } + + @Override + protected List gameOptions(List args) { + return List.of(); + } + + @Override + public Set flags(CommandSender sender) { + return Set.of(F_TARGET); + } + + @Override + public Set flagOptions(String flag) { + if (F_TARGET.equals(flag)) { + Set options = Bukkit.getOnlinePlayers().stream().map(Player::getName).collect(java.util.stream.Collectors.toSet()); + options.addAll(List.of("@a", "@p", "@r", "@s")); + return options; + } + return Set.of(); + } + + @Override + public Formattable flagDescription(String flag) { + return F_TARGET.equals(flag) ? Text.JOIN_FLAG_TARGET : Message.empty; + } + + @Override + public Formattable flagExtension(String flag) { + return F_TARGET.equals(flag) ? Text.JOIN_FLAG_TARGET_EXT : Message.empty; + } + + @Override + protected boolean gameRun(CommandSender sender, List args, java.util.Map flags, Game game) { + if (!args.isEmpty()) { + return false; + } + + List targets; + if (flags.get(F_TARGET) != null) { + String selection = flags.get(F_TARGET); + if (!Permissions.JOIN_REMOTE.isAllowedBy(sender)) { + Text.DENY_PERMISSION.format(cmdContext()).sendTo(sender); + return true; + } + try { + targets = select(selection, sender); + } catch (IllegalArgumentException e) { + Text.INVALID_TARGET_SELECTOR.format(cmdContext().plus("selector", selection)).sendTo(sender); + return true; + } + } else { + if (!(sender instanceof Player player)) { + Text.MUST_BE_PLAYER.format(cmdContext()).sendTo(sender); + return true; + } + targets = List.of(player); + } + + for (Player target : targets) { + var event = BmPlayerJoinGameIntent.join(game, target); + if (event.isCancelled()) { + if (event.cancelledReason() != null) { + event.cancelledReason().sendTo(sender); + } else { + Text.COMMAND_CANCELLED.format(cmdContext() + .plus("game", game) + .plus("player", target)) + .sendTo(target); + } + } else { + Text.JOIN_SUCCESS.format(cmdContext() + .plus("game", game) + .plus("player", target)) + .sendTo(target); + } + } + return true; + } + + @Override + public Permission permission() { + return Permissions.JOIN; + } + + @Override + public Formattable extra() { + return Text.JOIN_EXTRA; + } + + @Override + public Formattable example() { + return Text.JOIN_EXAMPLE; + } + + @Override + public Formattable description() { + return Text.JOIN_DESCRIPTION; + } + + @Override + public Formattable usage() { + return Text.JOIN_USAGE; + } +} diff --git a/src/main/java/io/github/mviper/bomberman/commands/game/GameLeave.java b/src/main/java/io/github/mviper/bomberman/commands/game/GameLeave.java new file mode 100644 index 0000000..670e467 --- /dev/null +++ b/src/main/java/io/github/mviper/bomberman/commands/game/GameLeave.java @@ -0,0 +1,124 @@ +package io.github.mviper.bomberman.commands.game; + +import io.github.mviper.bomberman.commands.Cmd; +import io.github.mviper.bomberman.commands.Permission; +import io.github.mviper.bomberman.commands.Permissions; +import io.github.mviper.bomberman.events.BmPlayerLeaveGameIntent; +import io.github.mviper.bomberman.messaging.Formattable; +import io.github.mviper.bomberman.messaging.Message; +import io.github.mviper.bomberman.messaging.Text; +import org.bukkit.Bukkit; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; + +import java.util.List; +import java.util.Set; + +public class GameLeave extends Cmd { + private static final String F_TARGET = "t"; + + public GameLeave(Cmd parent) { + super(parent); + } + + @Override + public Formattable name() { + return Text.LEAVE_NAME; + } + + @Override + public List options(CommandSender sender, List args) { + return List.of(); + } + + @Override + public Set flags(CommandSender sender) { + return Set.of(F_TARGET); + } + + @Override + public Set flagOptions(String flag) { + if (F_TARGET.equals(flag)) { + Set options = Bukkit.getOnlinePlayers().stream().map(Player::getName).collect(java.util.stream.Collectors.toSet()); + options.addAll(List.of("@a", "@p", "@r", "@s")); + return options; + } + return Set.of(); + } + + @Override + public Formattable flagDescription(String flag) { + return F_TARGET.equals(flag) ? Text.LEAVE_FLAG_TARGET : Message.empty; + } + + @Override + public Formattable flagExtension(String flag) { + return F_TARGET.equals(flag) ? Text.LEAVE_FLAG_TARGET_EXT : Message.empty; + } + + @Override + public boolean run(CommandSender sender, List args, java.util.Map flags) { + if (!args.isEmpty()) { + return false; + } + + List targets; + if (flags.get(F_TARGET) != null) { + String selection = flags.get(F_TARGET); + if (!Permissions.LEAVE_REMOTE.isAllowedBy(sender)) { + Text.DENY_PERMISSION.format(cmdContext()).sendTo(sender); + return true; + } + try { + targets = GameJoin.select(selection, sender); + } catch (IllegalArgumentException e) { + Text.INVALID_TARGET_SELECTOR.format(cmdContext().plus("selector", selection)).sendTo(sender); + return true; + } + } else { + if (!(sender instanceof Player player)) { + Text.MUST_BE_PLAYER.format(cmdContext()).sendTo(sender); + return true; + } + targets = List.of(player); + } + + for (Player target : targets) { + var event = BmPlayerLeaveGameIntent.leave(target); + if (event.isHandled()) { + Text.LEAVE_SUCCESS.format(cmdContext() + .plus("player", target) + .plus("game", event.getGame() != null ? event.getGame() : Message.error("none"))) + .sendTo(target); + } else { + Text.LEAVE_NOT_JOINED.format(cmdContext().plus("player", target)).sendTo(target); + } + } + return true; + } + + @Override + public Permission permission() { + return Permissions.LEAVE; + } + + @Override + public Formattable extra() { + return Text.LEAVE_EXTRA; + } + + @Override + public Formattable description() { + return Text.LEAVE_DESCRIPTION; + } + + @Override + public Formattable usage() { + return Text.LEAVE_USAGE; + } + + @Override + public Formattable example() { + return Text.JOIN_EXAMPLE; + } +} diff --git a/src/main/java/io/github/mviper/bomberman/commands/game/GameList.java b/src/main/java/io/github/mviper/bomberman/commands/game/GameList.java new file mode 100644 index 0000000..117e34b --- /dev/null +++ b/src/main/java/io/github/mviper/bomberman/commands/game/GameList.java @@ -0,0 +1,62 @@ +package io.github.mviper.bomberman.commands.game; + +import io.github.mviper.bomberman.commands.Cmd; +import io.github.mviper.bomberman.commands.Permission; +import io.github.mviper.bomberman.commands.Permissions; +import io.github.mviper.bomberman.events.BmGameListIntent; +import io.github.mviper.bomberman.messaging.Formattable; +import io.github.mviper.bomberman.messaging.Text; +import org.bukkit.command.CommandSender; + +import java.util.List; + +public class GameList extends Cmd { + public GameList(Cmd parent) { + super(parent); + } + + @Override + public Formattable name() { + return Text.GAMELIST_NAME; + } + + @Override + public List options(CommandSender sender, List args) { + return List.of(); + } + + @Override + public boolean run(CommandSender sender, List args, java.util.Map flags) { + if (!args.isEmpty()) { + return false; + } + var games = BmGameListIntent.listGames(); + Text.GAMELIST_GAMES.format(cmdContext().plus("games", games)).sendTo(sender); + return true; + } + + @Override + public Permission permission() { + return Permissions.LIST; + } + + @Override + public Formattable example() { + return Text.GAMELIST_EXAMPLE; + } + + @Override + public Formattable extra() { + return Text.GAMELIST_EXTRA; + } + + @Override + public Formattable description() { + return Text.GAMELIST_DESCRIPTION; + } + + @Override + public Formattable usage() { + return Text.GAMELIST_USAGE; + } +} diff --git a/src/main/java/io/github/mviper/bomberman/commands/game/GameReload.java b/src/main/java/io/github/mviper/bomberman/commands/game/GameReload.java new file mode 100644 index 0000000..28233dc --- /dev/null +++ b/src/main/java/io/github/mviper/bomberman/commands/game/GameReload.java @@ -0,0 +1,76 @@ +package io.github.mviper.bomberman.commands.game; + +import io.github.mviper.bomberman.Bomberman; +import io.github.mviper.bomberman.commands.Cmd; +import io.github.mviper.bomberman.commands.GameCommand; +import io.github.mviper.bomberman.commands.Permission; +import io.github.mviper.bomberman.commands.Permissions; +import io.github.mviper.bomberman.events.BmGameBuildIntent; +import io.github.mviper.bomberman.events.BmGameDeletedIntent; +import io.github.mviper.bomberman.game.Game; +import io.github.mviper.bomberman.game.GameSave; +import io.github.mviper.bomberman.messaging.Formattable; +import io.github.mviper.bomberman.messaging.Text; +import org.bukkit.command.CommandSender; + +import java.io.IOException; +import java.util.List; + +public class GameReload extends GameCommand { + public GameReload(Cmd parent) { + super(parent); + } + + @Override + public Formattable name() { + return Text.RELOAD_NAME; + } + + @Override + protected boolean gameRun(CommandSender sender, List args, java.util.Map flags, Game game) { + if (!args.isEmpty()) { + return false; + } + BmGameDeletedIntent.delete(game, false); + try { + Game newGame = GameSave.loadGame( + Bomberman.instance.gameSaves().resolve(GameSave.sanitize(game.getName() + ".game.zip")) + ); + BmGameBuildIntent.build(newGame); + Text.RELOAD_SUCCESS.format(cmdContext().plus("game", newGame)).sendTo(sender); + } catch (IOException e) { + Text.RELOAD_CANNOT_LOAD.format(cmdContext().plus("game", game.getName())).sendTo(sender); + } + return true; + } + + @Override + public Permission permission() { + return Permissions.RELOAD; + } + + @Override + protected List gameOptions(List args) { + return List.of(); + } + + @Override + public Formattable extra() { + return Text.RELOAD_EXTRA; + } + + @Override + public Formattable example() { + return Text.RELOAD_EXAMPLE; + } + + @Override + public Formattable description() { + return Text.RELOAD_DESCRIPTION; + } + + @Override + public Formattable usage() { + return Text.RELOAD_USAGE; + } +} diff --git a/src/main/java/io/github/mviper/bomberman/commands/game/GuiBuilder.java b/src/main/java/io/github/mviper/bomberman/commands/game/GuiBuilder.java new file mode 100644 index 0000000..08e76ac --- /dev/null +++ b/src/main/java/io/github/mviper/bomberman/commands/game/GuiBuilder.java @@ -0,0 +1,244 @@ +package io.github.mviper.bomberman.commands.game; + +import io.github.mviper.bomberman.Bomberman; +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.NamespacedKey; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.event.inventory.InventoryCloseEvent; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.InventoryView; +import org.bukkit.inventory.ItemFlag; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; +import org.bukkit.inventory.meta.tags.ItemTagType; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; +import java.util.function.Function; + +public class GuiBuilder implements Listener { + public static final class Index { + private final int x; + private final int y; + private final int invIndex; + private final char section; + private final int secIndex; + private final Inventory inventory; + + public Index(int x, int y, int invIndex, char section, int secIndex, Inventory inventory) { + this.x = x; + this.y = y; + this.invIndex = invIndex; + this.section = section; + this.secIndex = secIndex; + this.inventory = inventory; + } + + public int getX() { + return x; + } + + public int getY() { + return y; + } + + public int getInvIndex() { + return invIndex; + } + + public char getSection() { + return section; + } + + public int getSecIndex() { + return secIndex; + } + + public Inventory getInventory() { + return inventory; + } + } + + public static final class ItemSlot { + private final ItemStack item; + + public ItemSlot(ItemStack item) { + this.item = item; + } + + public ItemSlot(Material type, int qty) { + this(new ItemStack(type, qty)); + } + + public ItemSlot(Material type) { + this(new ItemStack(type)); + } + + public ItemStack getItem() { + return item; + } + + private ItemSlot alterMeta(Consumer mod) { + if (item == null) { + return this; + } + ItemStack newItem = item.clone(); + ItemMeta itemMeta = newItem.getItemMeta(); + if (itemMeta == null) { + return new ItemSlot(newItem); + } + mod.accept(itemMeta); + newItem.setItemMeta(itemMeta); + return new ItemSlot(newItem); + } + + public ItemSlot unMovable() { + return alterMeta(meta -> meta.getCustomTagContainer().setCustomTag(NO_MOVE_KEY, ItemTagType.BYTE, (byte) 1)); + } + + public ItemSlot hideAttributes() { + return alterMeta(meta -> meta.addItemFlags(ItemFlag.HIDE_ATTRIBUTES)); + } + + public ItemSlot displayName(String title) { + return alterMeta(meta -> meta.setDisplayName(title)); + } + } + + public static final class SlotItem { + private final Index index; + private final ItemStack item; + + public SlotItem(Index index, ItemStack item) { + this.index = index; + this.item = item; + } + + public Index getIndex() { + return index; + } + + public ItemStack getItem() { + return item; + } + } + + @FunctionalInterface + public interface ClickHandler { + void handle(Index index, ItemStack currentItem, ItemStack cursorItem); + } + + public static void show( + Player player, + String name, + CharSequence[] contents, + Function onInit + ) { + show(player, name, contents, onInit, (index, currentItem, cursorItem) -> { + }, slots -> { + }); + } + + public static void show( + Player player, + String name, + CharSequence[] contents, + Function onInit, + ClickHandler onClick, + Consumer> onClose + ) { + int size = contents.length * 9; + Inventory inventory = Bukkit.createInventory(null, size, name); + InventoryView view = player.openInventory(inventory); + if (view == null) { + return; + } + + List slotLookup = new ArrayList<>(); + Map sectionCount = new HashMap<>(); + for (int i = 0; i < size; i++) { + int x = i % 9; + int y = i / 9; + char c = contents[y].charAt(x); + AtomicInteger count = sectionCount.computeIfAbsent(c, key -> new AtomicInteger(0)); + Index index = new Index(x, y, i, c, count.getAndIncrement(), inventory); + slotLookup.add(index); + ItemSlot slot = onInit.apply(index); + inventory.setItem(i, slot != null ? slot.getItem() : null); + } + LOOKUP.put(view, new InvMemory(slotLookup, onClick, onClose)); + } + + private static final Bomberman PLUGIN = Bomberman.instance; + private static final NamespacedKey NO_MOVE_KEY = new NamespacedKey(PLUGIN, "no-move"); + private static final Map LOOKUP = new HashMap<>(); + public static final ItemSlot blank = new ItemSlot(Material.BLACK_STAINED_GLASS_PANE) + .hideAttributes() + .displayName(" ") + .unMovable(); + + static { + Bukkit.getPluginManager().registerEvents(new GuiBuilder(), PLUGIN); + } + + private static boolean isNotMovable(ItemStack item) { + ItemMeta meta = item.getItemMeta(); + if (meta == null) { + return false; + } + Byte value = meta.getCustomTagContainer().getCustomTag(NO_MOVE_KEY, ItemTagType.BYTE); + return value != null && value == (byte) 1; + } + + private static final class InvMemory { + private final List slots; + private final ClickHandler onClick; + private final Consumer> onClose; + + private InvMemory(List slots, ClickHandler onClick, Consumer> onClose) { + this.slots = slots; + this.onClick = onClick; + this.onClose = onClose; + } + } + + @EventHandler + public void onInventoryClosed(InventoryCloseEvent event) { + InvMemory memory = LOOKUP.remove(event.getView()); + if (memory == null) { + return; + } + Inventory inventory = event.getInventory(); + List items = new ArrayList<>(); + for (Index index : memory.slots) { + items.add(new SlotItem(index, inventory.getItem(index.getInvIndex()))); + } + memory.onClose.accept(items); + } + + @EventHandler + public void onInventoryItemClicked(InventoryClickEvent event) { + InvMemory memory = LOOKUP.get(event.getView()); + if (memory == null) { + return; + } + if (event.getClickedInventory() != event.getInventory()) { + return; + } + Index index = memory.slots.get(event.getSlot()); + ItemStack currentItem = event.getCurrentItem(); + ItemStack cursorItem = event.getCursor(); + memory.onClick.handle(index, currentItem, cursorItem); + if ((currentItem != null && isNotMovable(currentItem)) || (cursorItem != null && isNotMovable(cursorItem))) { + event.setCancelled(true); + } + } +} diff --git a/src/main/java/io/github/mviper/bomberman/commands/game/RunStart.java b/src/main/java/io/github/mviper/bomberman/commands/game/RunStart.java new file mode 100644 index 0000000..7ac2887 --- /dev/null +++ b/src/main/java/io/github/mviper/bomberman/commands/game/RunStart.java @@ -0,0 +1,116 @@ +package io.github.mviper.bomberman.commands.game; + +import io.github.mviper.bomberman.commands.Cmd; +import io.github.mviper.bomberman.commands.GameCommand; +import io.github.mviper.bomberman.commands.Permission; +import io.github.mviper.bomberman.commands.Permissions; +import io.github.mviper.bomberman.events.BmRunStartCountDownIntent; +import io.github.mviper.bomberman.game.Game; +import io.github.mviper.bomberman.messaging.Formattable; +import io.github.mviper.bomberman.messaging.Message; +import io.github.mviper.bomberman.messaging.Text; +import org.bukkit.command.CommandSender; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +public class RunStart extends GameCommand { + public RunStart(Cmd parent) { + super(parent); + } + + @Override + public Formattable name() { + return Text.START_NAME; + } + + @Override + protected List gameOptions(List args) { + return List.of(); + } + + @Override + public Set flags(CommandSender sender) { + return Set.of("d", "o"); + } + + @Override + public Formattable flagExtension(String flag) { + return switch (flag) { + case "d" -> Text.START_FLAG_DELAY_EXT; + default -> Message.empty; + }; + } + + @Override + public Formattable flagDescription(String flag) { + return switch (flag) { + case "d" -> Text.START_FLAG_DELAY_DESC; + case "o" -> Text.START_FLAG_OVERRIDE_DESC; + default -> Message.empty; + }; + } + + @Override + protected boolean gameRun(CommandSender sender, List args, Map flags, Game game) { + if (!args.isEmpty()) { + return false; + } + + String delayValue = flags.containsKey("d") ? flags.get("d") : flags.get("delay"); + int delay; + if (delayValue == null) { + delay = 3; + } else { + try { + delay = Integer.parseInt(delayValue); + } catch (NumberFormatException ex) { + Text.INVALID_NUMBER.format(cmdContext().plus("number", delayValue)).sendTo(sender); + return true; + } + if (delay < 0) { + Text.INVALID_NUMBER.format(cmdContext().plus("number", delayValue)).sendTo(sender); + return true; + } + } + + BmRunStartCountDownIntent event = BmRunStartCountDownIntent.startGame(game, delay, flags.containsKey("o")); + if (event.isCancelled()) { + Formattable reason = event.getCancelledReason(); + if (reason == null) { + Text.COMMAND_CANCELLED.format(cmdContext().plus("game", game)).sendTo(sender); + } else { + reason.format(cmdContext()).sendTo(sender); + } + } else { + Text.GAME_START_SUCCESS.format(cmdContext().plus("game", game)).sendTo(sender); + } + return true; + } + + @Override + public Permission permission() { + return Permissions.START; + } + + @Override + public Formattable extra() { + return Text.START_EXTRA; + } + + @Override + public Formattable example() { + return Text.START_EXAMPLE; + } + + @Override + public Formattable description() { + return Text.START_DESCRIPTION; + } + + @Override + public Formattable usage() { + return Text.START_USAGE; + } +} diff --git a/src/main/java/io/github/mviper/bomberman/commands/game/RunStop.java b/src/main/java/io/github/mviper/bomberman/commands/game/RunStop.java new file mode 100644 index 0000000..eaf8e9a --- /dev/null +++ b/src/main/java/io/github/mviper/bomberman/commands/game/RunStop.java @@ -0,0 +1,73 @@ +package io.github.mviper.bomberman.commands.game; + +import io.github.mviper.bomberman.commands.Cmd; +import io.github.mviper.bomberman.commands.GameCommand; +import io.github.mviper.bomberman.commands.Permission; +import io.github.mviper.bomberman.commands.Permissions; +import io.github.mviper.bomberman.events.BmRunStoppedIntent; +import io.github.mviper.bomberman.game.Game; +import io.github.mviper.bomberman.messaging.Formattable; +import io.github.mviper.bomberman.messaging.Text; +import org.bukkit.command.CommandSender; + +import java.util.List; + +public class RunStop extends GameCommand { + public RunStop(Cmd parent) { + super(parent); + } + + @Override + public Formattable name() { + return Text.STOP_NAME; + } + + @Override + protected boolean gameRun(CommandSender sender, List args, java.util.Map flags, Game game) { + if (!args.isEmpty()) { + return false; + } + BmRunStoppedIntent event = BmRunStoppedIntent.stopGame(game); + if (!event.isCancelled()) { + Text.STOP_SUCCESS.format(cmdContext().plus("game", game)).sendTo(sender); + } else { + Formattable reason = event.cancelledReason(); + if (reason == null) { + Text.COMMAND_CANCELLED.format(cmdContext().plus("command", this)).sendTo(sender); + } else { + reason.format(cmdContext()).sendTo(sender); + } + } + return true; + } + + @Override + public Permission permission() { + return Permissions.STOP; + } + + @Override + protected List gameOptions(List args) { + return List.of(); + } + + @Override + public Formattable extra() { + return Text.STOP_EXTRA; + } + + @Override + public Formattable example() { + return Text.STOP_EXAMPLE; + } + + @Override + public Formattable description() { + return Text.STOP_DESCRIPTION; + } + + @Override + public Formattable usage() { + return Text.STOP_USAGE; + } +} diff --git a/src/main/java/io/github/mviper/bomberman/commands/game/UndoBuild.java b/src/main/java/io/github/mviper/bomberman/commands/game/UndoBuild.java new file mode 100644 index 0000000..15279c1 --- /dev/null +++ b/src/main/java/io/github/mviper/bomberman/commands/game/UndoBuild.java @@ -0,0 +1,161 @@ +package io.github.mviper.bomberman.commands.game; + +import com.sk89q.worldedit.WorldEdit; +import com.sk89q.worldedit.bukkit.BukkitAdapter; +import com.sk89q.worldedit.extent.clipboard.BlockArrayClipboard; +import com.sk89q.worldedit.extent.clipboard.Clipboard; +import com.sk89q.worldedit.function.operation.ForwardExtentCopy; +import com.sk89q.worldedit.function.operation.Operations; +import com.sk89q.worldedit.math.BlockVector3; +import com.sk89q.worldedit.session.ClipboardHolder; +import io.github.mviper.bomberman.Bomberman; +import io.github.mviper.bomberman.commands.Cmd; +import io.github.mviper.bomberman.commands.Permission; +import io.github.mviper.bomberman.commands.Permissions; +import io.github.mviper.bomberman.events.BmGameDeletedIntent; +import io.github.mviper.bomberman.events.BmGameLookupIntent; +import io.github.mviper.bomberman.game.Game; +import io.github.mviper.bomberman.messaging.Formattable; +import io.github.mviper.bomberman.messaging.Text; +import io.github.mviper.bomberman.utils.Box; +import io.github.mviper.bomberman.utils.BukkitUtils; +import io.github.mviper.bomberman.utils.WorldEditUtils; +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.command.CommandSender; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class UndoBuild extends Cmd { + private static final Map GAME_MEMORY = new HashMap<>(); + + public UndoBuild(Cmd parent) { + super(parent); + } + + public static void retainHistory(String name, Box box) { + var region = WorldEditUtils.convert(box); + Clipboard clipboard = new BlockArrayClipboard(region); + try (var editSession = WorldEdit.getInstance().newEditSession(BukkitAdapter.adapt(box.world))) { + ForwardExtentCopy forwardExtentCopy = new ForwardExtentCopy( + editSession, + region, + clipboard, + region.getMinimumPoint() + ); + try { + Operations.complete(forwardExtentCopy); + } catch (com.sk89q.worldedit.WorldEditException e) { + throw new RuntimeException("Failed to copy region", e); + } + } + + int[] handle = new int[1]; + handle[0] = Bukkit.getScheduler().scheduleSyncDelayedTask(Bomberman.instance, () -> { + UndoHistory memory = GAME_MEMORY.get(name); + if (memory != null && memory.taskId == handle[0]) { + GAME_MEMORY.remove(name); + Bomberman.instance.getLogger().info("Game '" + name + "' undo history expired"); + } + }, 10 * 60 * 20L); + + GAME_MEMORY.put(name, new UndoHistory(BukkitUtils.boxLoc1(box), clipboard, handle[0])); + } + + public static void removeHistory(String name) { + GAME_MEMORY.remove(name); + } + + @Override + public Formattable name() { + return Text.UNDO_NAME; + } + + @Override + public List options(CommandSender sender, List args) { + if (args.size() == 1) { + List names = new ArrayList<>(GAME_MEMORY.keySet()); + names.sort(String::compareTo); + return names; + } + return List.of(); + } + + @Override + public boolean run(CommandSender sender, List args, java.util.Map flags) { + if (args.size() != 1) { + return false; + } + String gameName = args.get(0); + + UndoHistory memory = GAME_MEMORY.get(gameName); + if (memory == null || memory.clipboard == null || memory.origin == null) { + Text.UNDO_UNKNOWN_GAME.format(cmdContext().plus("game", gameName)).sendTo(sender); + return true; + } + + Game game = BmGameLookupIntent.find(gameName); + if (game != null) { + BmGameDeletedIntent.delete(game, true); + Text.UNDO_DELETED.format(cmdContext().plus("game", game)).sendTo(sender); + } + + try (var editSession = WorldEdit.getInstance().newEditSession(BukkitAdapter.adapt(memory.origin.getWorld()))) { + var operation = new ClipboardHolder(memory.clipboard) + .createPaste(editSession) + .to(BlockVector3.at(memory.origin.getBlockX(), memory.origin.getBlockY(), memory.origin.getBlockZ())) + .copyEntities(true) + .build(); + try { + Operations.complete(operation); + } catch (com.sk89q.worldedit.WorldEditException e) { + throw new RuntimeException("Failed to paste clipboard", e); + } + } + + GAME_MEMORY.remove(gameName); + + Text.UNDO_SUCCESS.format(cmdContext().plus("game", gameName)).sendTo(sender); + return true; + } + + @Override + public Permission permission() { + return Permissions.UNDO; + } + + @Override + public Formattable example() { + return Text.UNDO_EXAMPLE; + } + + @Override + public Formattable extra() { + return Text.UNDO_EXTRA; + } + + @Override + public Formattable description() { + return Text.UNDO_DESCRIPTION; + } + + @Override + public Formattable usage() { + return Text.UNDO_USAGE; + } + + private static final class UndoHistory { + private final Location origin; + private final Clipboard clipboard; + private final int taskId; + + private UndoHistory(Location origin, Clipboard clipboard, int taskId) { + this.origin = origin; + this.clipboard = clipboard; + this.taskId = taskId; + } + } +} diff --git a/src/main/java/io/github/mviper/bomberman/events/BmCancellable.java b/src/main/java/io/github/mviper/bomberman/events/BmCancellable.java new file mode 100644 index 0000000..4826dc3 --- /dev/null +++ b/src/main/java/io/github/mviper/bomberman/events/BmCancellable.java @@ -0,0 +1,20 @@ +package io.github.mviper.bomberman.events; + +import org.bukkit.event.Cancellable; + +/** + * Simple implementation of Cancellable interface. + */ +public class BmCancellable implements Cancellable { + private boolean cancelled = false; + + @Override + public boolean isCancelled() { + return cancelled; + } + + @Override + public void setCancelled(boolean cancel) { + cancelled = cancel; + } +} diff --git a/src/main/java/io/github/mviper/bomberman/events/BmDropLootEvent.java b/src/main/java/io/github/mviper/bomberman/events/BmDropLootEvent.java new file mode 100644 index 0000000..708f67b --- /dev/null +++ b/src/main/java/io/github/mviper/bomberman/events/BmDropLootEvent.java @@ -0,0 +1,52 @@ +package io.github.mviper.bomberman.events; + +import io.github.mviper.bomberman.game.Explosion.BlockPlan; +import io.github.mviper.bomberman.game.Game; +import org.bukkit.Location; +import org.bukkit.entity.Player; +import org.bukkit.event.HandlerList; +import org.bukkit.inventory.ItemStack; + +import java.util.HashMap; +import java.util.Map; +import java.util.Set; + +/** + * Called when a bomb turns from a tnt block into a fire '+'. + */ +public class BmDropLootEvent extends BmEvent implements org.bukkit.event.Cancellable { + private static final HandlerList HANDLER_LIST = new HandlerList(); + + public final Game game; + public final Player cause; + public final Set ignited; + public final Map> drops; + + private final BmCancellable delegate = new BmCancellable(); + + public BmDropLootEvent(Game game, Player cause, Set ignited, Map> drops) { + this.game = game; + this.cause = cause; + this.ignited = ignited; + this.drops = new HashMap<>(drops); + } + + @Override + public HandlerList getHandlers() { + return HANDLER_LIST; + } + + public static HandlerList getHandlerList() { + return HANDLER_LIST; + } + + @Override + public boolean isCancelled() { + return delegate.isCancelled(); + } + + @Override + public void setCancelled(boolean cancel) { + delegate.setCancelled(cancel); + } +} diff --git a/src/main/java/io/github/mviper/bomberman/events/BmEvent.java b/src/main/java/io/github/mviper/bomberman/events/BmEvent.java new file mode 100644 index 0000000..b4ed2be --- /dev/null +++ b/src/main/java/io/github/mviper/bomberman/events/BmEvent.java @@ -0,0 +1,9 @@ +package io.github.mviper.bomberman.events; + +import org.bukkit.event.Event; + +/** + * All Bomberman events extend this. + */ +public abstract class BmEvent extends Event { +} diff --git a/src/main/java/io/github/mviper/bomberman/events/BmExplosionEvent.java b/src/main/java/io/github/mviper/bomberman/events/BmExplosionEvent.java new file mode 100644 index 0000000..3ebcc9a --- /dev/null +++ b/src/main/java/io/github/mviper/bomberman/events/BmExplosionEvent.java @@ -0,0 +1,47 @@ +package io.github.mviper.bomberman.events; + +import io.github.mviper.bomberman.game.Explosion.BlockPlan; +import io.github.mviper.bomberman.game.Game; +import org.bukkit.entity.Player; +import org.bukkit.event.HandlerList; + +import java.util.HashSet; +import java.util.Set; + +/** + * Called when a bomb turns from a tnt block into a fire '+'. + */ +public class BmExplosionEvent extends BmEvent implements org.bukkit.event.Cancellable { + private static final HandlerList HANDLER_LIST = new HandlerList(); + + public final Game game; + public final Player cause; + public final Set igniting; + + private final BmCancellable delegate = new BmCancellable(); + + public BmExplosionEvent(Game game, Player cause, Set igniting) { + this.game = game; + this.cause = cause; + this.igniting = new HashSet<>(igniting); + } + + @Override + public HandlerList getHandlers() { + return HANDLER_LIST; + } + + public static HandlerList getHandlerList() { + return HANDLER_LIST; + } + + @Override + public boolean isCancelled() { + return delegate.isCancelled(); + } + + @Override + public void setCancelled(boolean cancel) { + delegate.setCancelled(cancel); + } +} diff --git a/src/main/java/io/github/mviper/bomberman/events/BmGameBuildIntent.java b/src/main/java/io/github/mviper/bomberman/events/BmGameBuildIntent.java new file mode 100644 index 0000000..d759c5a --- /dev/null +++ b/src/main/java/io/github/mviper/bomberman/events/BmGameBuildIntent.java @@ -0,0 +1,49 @@ +package io.github.mviper.bomberman.events; + +import io.github.mviper.bomberman.game.Game; +import org.bukkit.Bukkit; +import org.bukkit.event.HandlerList; + +/** + * Called when a game is built. + */ +public class BmGameBuildIntent extends BmEvent implements Intent { + private static final HandlerList HANDLER_LIST = new HandlerList(); + + public final Game game; + private final BmIntent delegate = new BmIntent(); + + private BmGameBuildIntent(Game game) { + this.game = game; + } + + public static void build(Game game) { + BmGameBuildIntent event = new BmGameBuildIntent(game); + Bukkit.getPluginManager().callEvent(event); + event.verifyHandled(); + } + + @Override + public HandlerList getHandlers() { + return HANDLER_LIST; + } + + public static HandlerList getHandlerList() { + return HANDLER_LIST; + } + + @Override + public boolean isHandled() { + return delegate.isHandled(); + } + + @Override + public void setHandled() { + delegate.setHandled(); + } + + @Override + public void verifyHandled() { + delegate.verifyHandled(); + } +} diff --git a/src/main/java/io/github/mviper/bomberman/events/BmGameDeletedIntent.java b/src/main/java/io/github/mviper/bomberman/events/BmGameDeletedIntent.java new file mode 100644 index 0000000..c2458c7 --- /dev/null +++ b/src/main/java/io/github/mviper/bomberman/events/BmGameDeletedIntent.java @@ -0,0 +1,62 @@ +package io.github.mviper.bomberman.events; + +import io.github.mviper.bomberman.game.Game; +import org.bukkit.Bukkit; +import org.bukkit.event.HandlerList; + +/** + * Called when a game is completely deleted from the server. + */ +public class BmGameDeletedIntent extends BmEvent implements IntentCancellable { + private static final HandlerList HANDLER_LIST = new HandlerList(); + + public final Game game; + public boolean isDeletingSave = false; + + private final BmIntentCancellable delegate = new BmIntentCancellable(); + + private BmGameDeletedIntent(Game game) { + this.game = game; + } + + public static void delete(Game game, boolean deleteSave) { + BmGameDeletedIntent event = new BmGameDeletedIntent(game); + event.isDeletingSave = deleteSave; + Bukkit.getPluginManager().callEvent(event); + event.verifyHandled(); + } + + @Override + public HandlerList getHandlers() { + return HANDLER_LIST; + } + + public static HandlerList getHandlerList() { + return HANDLER_LIST; + } + + @Override + public boolean isHandled() { + return delegate.isHandled(); + } + + @Override + public void setHandled() { + delegate.setHandled(); + } + + @Override + public void verifyHandled() { + delegate.verifyHandled(); + } + + @Override + public boolean isCancelled() { + return delegate.isCancelled(); + } + + @Override + public void setCancelled(boolean cancel) { + delegate.setCancelled(cancel); + } +} diff --git a/src/main/java/io/github/mviper/bomberman/events/BmGameListIntent.java b/src/main/java/io/github/mviper/bomberman/events/BmGameListIntent.java new file mode 100644 index 0000000..d89645b --- /dev/null +++ b/src/main/java/io/github/mviper/bomberman/events/BmGameListIntent.java @@ -0,0 +1,32 @@ +package io.github.mviper.bomberman.events; + +import io.github.mviper.bomberman.game.Game; +import org.bukkit.Bukkit; +import org.bukkit.event.HandlerList; + +import java.util.HashSet; +import java.util.Set; + +/** + * Called to find a listing of every active game. + */ +public class BmGameListIntent extends BmEvent { + private static final HandlerList HANDLER_LIST = new HandlerList(); + + public final Set games = new HashSet<>(); + + public static Set listGames() { + BmGameListIntent event = new BmGameListIntent(); + Bukkit.getPluginManager().callEvent(event); + return event.games; + } + + @Override + public HandlerList getHandlers() { + return HANDLER_LIST; + } + + public static HandlerList getHandlerList() { + return HANDLER_LIST; + } +} diff --git a/src/main/java/io/github/mviper/bomberman/events/BmGameLookupIntent.java b/src/main/java/io/github/mviper/bomberman/events/BmGameLookupIntent.java new file mode 100644 index 0000000..e1545e5 --- /dev/null +++ b/src/main/java/io/github/mviper/bomberman/events/BmGameLookupIntent.java @@ -0,0 +1,34 @@ +package io.github.mviper.bomberman.events; + +import io.github.mviper.bomberman.game.Game; +import org.bukkit.Bukkit; +import org.bukkit.event.HandlerList; + +/** + * Called to find a game instance. + */ +public class BmGameLookupIntent extends BmEvent { + private static final HandlerList HANDLER_LIST = new HandlerList(); + + public final String name; + public Game game; + + public BmGameLookupIntent(String name) { + this.name = name; + } + + public static Game find(String name) { + BmGameLookupIntent event = new BmGameLookupIntent(name); + Bukkit.getPluginManager().callEvent(event); + return event.game; + } + + @Override + public HandlerList getHandlers() { + return HANDLER_LIST; + } + + public static HandlerList getHandlerList() { + return HANDLER_LIST; + } +} diff --git a/src/main/java/io/github/mviper/bomberman/events/BmGameTerminatedIntent.java b/src/main/java/io/github/mviper/bomberman/events/BmGameTerminatedIntent.java new file mode 100644 index 0000000..4d6f10e --- /dev/null +++ b/src/main/java/io/github/mviper/bomberman/events/BmGameTerminatedIntent.java @@ -0,0 +1,51 @@ +package io.github.mviper.bomberman.events; + +import io.github.mviper.bomberman.game.Game; +import org.bukkit.Bukkit; +import org.bukkit.event.HandlerList; + +/** + * Called whenever a game is being removed - may be because game deleted or because + * server is shutting down. Is possible game starts back when server starts back. + * All event listeners for the game should destroy themselves on this event. + */ +public class BmGameTerminatedIntent extends BmEvent implements Intent { + private static final HandlerList HANDLER_LIST = new HandlerList(); + + public final Game game; + private final BmIntent delegate = new BmIntent(); + + private BmGameTerminatedIntent(Game game) { + this.game = game; + } + + public static void terminateGame(Game game) { + BmGameTerminatedIntent event = new BmGameTerminatedIntent(game); + Bukkit.getPluginManager().callEvent(event); + event.verifyHandled(); + } + + @Override + public HandlerList getHandlers() { + return HANDLER_LIST; + } + + public static HandlerList getHandlerList() { + return HANDLER_LIST; + } + + @Override + public boolean isHandled() { + return delegate.isHandled(); + } + + @Override + public void setHandled() { + delegate.setHandled(); + } + + @Override + public void verifyHandled() { + delegate.verifyHandled(); + } +} diff --git a/src/main/java/io/github/mviper/bomberman/events/BmIntent.java b/src/main/java/io/github/mviper/bomberman/events/BmIntent.java new file mode 100644 index 0000000..7832852 --- /dev/null +++ b/src/main/java/io/github/mviper/bomberman/events/BmIntent.java @@ -0,0 +1,22 @@ +package io.github.mviper.bomberman.events; + +public class BmIntent implements Intent { + private boolean handled = false; + + @Override + public boolean isHandled() { + return handled; + } + + @Override + public void setHandled() { + handled = true; + } + + @Override + public void verifyHandled() { + if (!handled) { + throw new RuntimeException("Event not handled: " + this); + } + } +} diff --git a/src/main/java/io/github/mviper/bomberman/events/BmIntentCancellable.java b/src/main/java/io/github/mviper/bomberman/events/BmIntentCancellable.java new file mode 100644 index 0000000..4d24032 --- /dev/null +++ b/src/main/java/io/github/mviper/bomberman/events/BmIntentCancellable.java @@ -0,0 +1,33 @@ +package io.github.mviper.bomberman.events; + +public class BmIntentCancellable implements IntentCancellable { + private boolean cancelled = false; + private boolean handled = false; + + @Override + public void verifyHandled() { + if (!handled && !isCancelled()) { + throw new RuntimeException("Event not handled: " + this); + } + } + + @Override + public boolean isCancelled() { + return cancelled; + } + + @Override + public void setCancelled(boolean cancel) { + cancelled = cancel; + } + + @Override + public boolean isHandled() { + return handled; + } + + @Override + public void setHandled() { + handled = true; + } +} diff --git a/src/main/java/io/github/mviper/bomberman/events/BmIntentCancellableReasoned.java b/src/main/java/io/github/mviper/bomberman/events/BmIntentCancellableReasoned.java new file mode 100644 index 0000000..bc2d91b --- /dev/null +++ b/src/main/java/io/github/mviper/bomberman/events/BmIntentCancellableReasoned.java @@ -0,0 +1,18 @@ +package io.github.mviper.bomberman.events; + +import io.github.mviper.bomberman.messaging.Message; + +public class BmIntentCancellableReasoned extends BmIntentCancellable implements IntentCancellableReasoned { + private Message reason; + + @Override + public void cancelFor(Message reason) { + this.reason = reason; + setCancelled(true); + } + + @Override + public Message cancelledReason() { + return reason; + } +} diff --git a/src/main/java/io/github/mviper/bomberman/events/BmPlayerHitIntent.java b/src/main/java/io/github/mviper/bomberman/events/BmPlayerHitIntent.java new file mode 100644 index 0000000..fbb7936 --- /dev/null +++ b/src/main/java/io/github/mviper/bomberman/events/BmPlayerHitIntent.java @@ -0,0 +1,63 @@ +package io.github.mviper.bomberman.events; + +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import org.bukkit.event.HandlerList; + +/** + * Event that occurs whenever a player is standing on a bomb. Will be called every tick that the player remains on the + * bomb. + */ +public class BmPlayerHitIntent extends BmEvent implements IntentCancellable { + private static final HandlerList HANDLER_LIST = new HandlerList(); + + public final Player player; + public final Player cause; + + private final BmIntentCancellable delegate = new BmIntentCancellable(); + + private BmPlayerHitIntent(Player player, Player cause) { + this.player = player; + this.cause = cause; + } + + public static void hit(Player player, Player cause) { + BmPlayerHitIntent event = new BmPlayerHitIntent(player, cause); + Bukkit.getPluginManager().callEvent(event); + event.verifyHandled(); + } + + @Override + public HandlerList getHandlers() { + return HANDLER_LIST; + } + + public static HandlerList getHandlerList() { + return HANDLER_LIST; + } + + @Override + public boolean isHandled() { + return delegate.isHandled(); + } + + @Override + public void setHandled() { + delegate.setHandled(); + } + + @Override + public void verifyHandled() { + delegate.verifyHandled(); + } + + @Override + public boolean isCancelled() { + return delegate.isCancelled(); + } + + @Override + public void setCancelled(boolean cancel) { + delegate.setCancelled(cancel); + } +} diff --git a/src/main/java/io/github/mviper/bomberman/events/BmPlayerHurtIntent.java b/src/main/java/io/github/mviper/bomberman/events/BmPlayerHurtIntent.java new file mode 100644 index 0000000..a5b7028 --- /dev/null +++ b/src/main/java/io/github/mviper/bomberman/events/BmPlayerHurtIntent.java @@ -0,0 +1,65 @@ +package io.github.mviper.bomberman.events; + +import io.github.mviper.bomberman.game.Game; +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import org.bukkit.event.HandlerList; + +/** + * Called whenever a bm player takes damage. + */ +public class BmPlayerHurtIntent extends BmEvent implements IntentCancellable { + private static final HandlerList HANDLER_LIST = new HandlerList(); + + public final Game game; + public final Player player; + public final Player attacker; + + private final BmIntentCancellable delegate = new BmIntentCancellable(); + + public BmPlayerHurtIntent(Game game, Player player, Player attacker) { + this.game = game; + this.player = player; + this.attacker = attacker; + } + + public static void run(Game game, Player player, Player cause) { + BmPlayerHurtIntent hurtEvent = new BmPlayerHurtIntent(game, player, cause); + Bukkit.getPluginManager().callEvent(hurtEvent); + hurtEvent.verifyHandled(); + } + + @Override + public HandlerList getHandlers() { + return HANDLER_LIST; + } + + public static HandlerList getHandlerList() { + return HANDLER_LIST; + } + + @Override + public boolean isHandled() { + return delegate.isHandled(); + } + + @Override + public void setHandled() { + delegate.setHandled(); + } + + @Override + public void verifyHandled() { + delegate.verifyHandled(); + } + + @Override + public boolean isCancelled() { + return delegate.isCancelled(); + } + + @Override + public void setCancelled(boolean cancel) { + delegate.setCancelled(cancel); + } +} diff --git a/src/main/java/io/github/mviper/bomberman/events/BmPlayerJoinGameIntent.java b/src/main/java/io/github/mviper/bomberman/events/BmPlayerJoinGameIntent.java new file mode 100644 index 0000000..15ac0d7 --- /dev/null +++ b/src/main/java/io/github/mviper/bomberman/events/BmPlayerJoinGameIntent.java @@ -0,0 +1,76 @@ +package io.github.mviper.bomberman.events; + +import io.github.mviper.bomberman.game.Game; +import io.github.mviper.bomberman.messaging.Message; +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import org.bukkit.event.HandlerList; + +/** + * Called whenever a player attempts to join a game. If there are not enough spawns in the game or the player cannot + * afford entry, or ..., the event will be cancelled. + */ +public class BmPlayerJoinGameIntent extends BmEvent implements IntentCancellableReasoned { + private static final HandlerList HANDLER_LIST = new HandlerList(); + + public final Game game; + public final Player player; + private final BmIntentCancellableReasoned delegate = new BmIntentCancellableReasoned(); + + private BmPlayerJoinGameIntent(Game game, Player player) { + this.game = game; + this.player = player; + } + + public static BmPlayerJoinGameIntent join(Game game, Player player) { + BmPlayerJoinGameIntent event = new BmPlayerJoinGameIntent(game, player); + Bukkit.getPluginManager().callEvent(event); + event.verifyHandled(); + return event; + } + + @Override + public HandlerList getHandlers() { + return HANDLER_LIST; + } + + public static HandlerList getHandlerList() { + return HANDLER_LIST; + } + + @Override + public boolean isHandled() { + return delegate.isHandled(); + } + + @Override + public void setHandled() { + delegate.setHandled(); + } + + @Override + public void verifyHandled() { + delegate.verifyHandled(); + } + + @Override + public boolean isCancelled() { + return delegate.isCancelled(); + } + + @Override + @Deprecated + public void setCancelled(boolean cancel) { + delegate.setCancelled(cancel); + } + + @Override + public void cancelFor(Message reason) { + delegate.cancelFor(reason); + } + + @Override + public Message cancelledReason() { + return delegate.cancelledReason(); + } +} diff --git a/src/main/java/io/github/mviper/bomberman/events/BmPlayerKilledIntent.java b/src/main/java/io/github/mviper/bomberman/events/BmPlayerKilledIntent.java new file mode 100644 index 0000000..79a0b6c --- /dev/null +++ b/src/main/java/io/github/mviper/bomberman/events/BmPlayerKilledIntent.java @@ -0,0 +1,55 @@ +package io.github.mviper.bomberman.events; + +import io.github.mviper.bomberman.game.Game; +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import org.bukkit.event.HandlerList; + +/** + * Called whenever a bm player is killed. + */ +public class BmPlayerKilledIntent extends BmEvent implements Intent { + private static final HandlerList HANDLER_LIST = new HandlerList(); + + public final Game game; + public final Player player; + public final Player attacker; + + private final BmIntent delegate = new BmIntent(); + + private BmPlayerKilledIntent(Game game, Player player, Player attacker) { + this.game = game; + this.player = player; + this.attacker = attacker; + } + + public static void kill(Game game, Player player, Player cause) { + BmPlayerKilledIntent event = new BmPlayerKilledIntent(game, player, cause); + Bukkit.getPluginManager().callEvent(event); + event.verifyHandled(); + } + + @Override + public HandlerList getHandlers() { + return HANDLER_LIST; + } + + public static HandlerList getHandlerList() { + return HANDLER_LIST; + } + + @Override + public boolean isHandled() { + return delegate.isHandled(); + } + + @Override + public void setHandled() { + delegate.setHandled(); + } + + @Override + public void verifyHandled() { + delegate.verifyHandled(); + } +} diff --git a/src/main/java/io/github/mviper/bomberman/events/BmPlayerLeaveGameIntent.java b/src/main/java/io/github/mviper/bomberman/events/BmPlayerLeaveGameIntent.java new file mode 100644 index 0000000..bf3af27 --- /dev/null +++ b/src/main/java/io/github/mviper/bomberman/events/BmPlayerLeaveGameIntent.java @@ -0,0 +1,58 @@ +package io.github.mviper.bomberman.events; + +import io.github.mviper.bomberman.game.Game; +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import org.bukkit.event.HandlerList; + +public class BmPlayerLeaveGameIntent extends BmEvent implements Intent { + private static final HandlerList HANDLER_LIST = new HandlerList(); + + public final Player player; + private Game game; + private final BmIntent delegate = new BmIntent(); + + private BmPlayerLeaveGameIntent(Player player) { + this.player = player; + } + + public static BmPlayerLeaveGameIntent leave(Player player) { + BmPlayerLeaveGameIntent leave = new BmPlayerLeaveGameIntent(player); + Bukkit.getPluginManager().callEvent(leave); + // Leave event may not be handled if player was not joined + return leave; + } + + public Game getGame() { + return game; + } + + public void setHandled(Game game) { + this.game = game; + setHandled(); + } + + @Override + public HandlerList getHandlers() { + return HANDLER_LIST; + } + + public static HandlerList getHandlerList() { + return HANDLER_LIST; + } + + @Override + public boolean isHandled() { + return delegate.isHandled(); + } + + @Override + public void setHandled() { + delegate.setHandled(); + } + + @Override + public void verifyHandled() { + delegate.verifyHandled(); + } +} diff --git a/src/main/java/io/github/mviper/bomberman/events/BmPlayerMovedEvent.java b/src/main/java/io/github/mviper/bomberman/events/BmPlayerMovedEvent.java new file mode 100644 index 0000000..6bac209 --- /dev/null +++ b/src/main/java/io/github/mviper/bomberman/events/BmPlayerMovedEvent.java @@ -0,0 +1,42 @@ +package io.github.mviper.bomberman.events; + +import io.github.mviper.bomberman.game.Game; +import org.bukkit.Location; +import org.bukkit.entity.Player; +import org.bukkit.event.HandlerList; + +/** + * Called whenever a bm player moves. Cannot modify the event. + */ +public class BmPlayerMovedEvent extends BmEvent { + private static final HandlerList HANDLER_LIST = new HandlerList(); + + public final Game game; + public final Player player; + private final Location from; + private final Location to; + + public BmPlayerMovedEvent(Game game, Player player, Location from, Location to) { + this.game = game; + this.player = player; + this.from = from; + this.to = to; + } + + public Location getFrom() { + return from.clone(); + } + + public Location getTo() { + return to.clone(); + } + + @Override + public HandlerList getHandlers() { + return HANDLER_LIST; + } + + public static HandlerList getHandlerList() { + return HANDLER_LIST; + } +} diff --git a/src/main/java/io/github/mviper/bomberman/events/BmPlayerPlacedBombEvent.java b/src/main/java/io/github/mviper/bomberman/events/BmPlayerPlacedBombEvent.java new file mode 100644 index 0000000..60018aa --- /dev/null +++ b/src/main/java/io/github/mviper/bomberman/events/BmPlayerPlacedBombEvent.java @@ -0,0 +1,50 @@ +package io.github.mviper.bomberman.events; + +import io.github.mviper.bomberman.game.Game; +import io.github.mviper.bomberman.game.GamePlayer; +import org.bukkit.block.Block; +import org.bukkit.entity.Player; +import org.bukkit.event.HandlerList; + +/** + * Called when a player places down a block of TNT (or whatever the game configured as the tnt block). Cancelling the + * event will remove the tnt from the ground as if the player never clicked. + */ +public class BmPlayerPlacedBombEvent extends BmEvent implements org.bukkit.event.Cancellable { + private static final HandlerList HANDLER_LIST = new HandlerList(); + + public final Game game; + public final Player player; + public final Block block; + public int fuse; + public final int strength; + + private final BmCancellable delegate = new BmCancellable(); + + public BmPlayerPlacedBombEvent(Game game, Player player, Block block, int fuse) { + this.game = game; + this.player = player; + this.block = block; + this.fuse = fuse; + this.strength = GamePlayer.bombStrength(game, player); + } + + @Override + public HandlerList getHandlers() { + return HANDLER_LIST; + } + + public static HandlerList getHandlerList() { + return HANDLER_LIST; + } + + @Override + public boolean isCancelled() { + return delegate.isCancelled(); + } + + @Override + public void setCancelled(boolean cancel) { + delegate.setCancelled(cancel); + } +} diff --git a/src/main/java/io/github/mviper/bomberman/events/BmPlayerWonEvent.java b/src/main/java/io/github/mviper/bomberman/events/BmPlayerWonEvent.java new file mode 100644 index 0000000..5c457f3 --- /dev/null +++ b/src/main/java/io/github/mviper/bomberman/events/BmPlayerWonEvent.java @@ -0,0 +1,26 @@ +package io.github.mviper.bomberman.events; + +import io.github.mviper.bomberman.game.Game; +import org.bukkit.entity.Player; +import org.bukkit.event.HandlerList; + +public class BmPlayerWonEvent extends BmEvent { + private static final HandlerList HANDLER_LIST = new HandlerList(); + + public final Game game; + public final Player player; + + public BmPlayerWonEvent(Game game, Player player) { + this.game = game; + this.player = player; + } + + @Override + public HandlerList getHandlers() { + return HANDLER_LIST; + } + + public static HandlerList getHandlerList() { + return HANDLER_LIST; + } +} diff --git a/src/main/java/io/github/mviper/bomberman/events/BmRunStartCountDownIntent.java b/src/main/java/io/github/mviper/bomberman/events/BmRunStartCountDownIntent.java new file mode 100644 index 0000000..e829597 --- /dev/null +++ b/src/main/java/io/github/mviper/bomberman/events/BmRunStartCountDownIntent.java @@ -0,0 +1,76 @@ +package io.github.mviper.bomberman.events; + +import io.github.mviper.bomberman.game.Game; +import io.github.mviper.bomberman.messaging.Message; +import org.bukkit.Bukkit; +import org.bukkit.event.HandlerList; + +/** + * Called whenever a run is attempted to be started. + */ +public class BmRunStartCountDownIntent extends BmEvent implements IntentCancellable { + private static final HandlerList HANDLER_LIST = new HandlerList(); + + public final Game game; + public int delay; + public final boolean override; + + private final BmIntentCancellable delegate = new BmIntentCancellable(); + private Message cancelReason; + + private BmRunStartCountDownIntent(Game game, int delay, boolean override) { + this.game = game; + this.delay = delay; + this.override = override; + } + + public static BmRunStartCountDownIntent startGame(Game game, int delay, boolean override) { + BmRunStartCountDownIntent event = new BmRunStartCountDownIntent(game, delay, override); + Bukkit.getPluginManager().callEvent(event); + event.verifyHandled(); + return event; + } + + @Override + public HandlerList getHandlers() { + return HANDLER_LIST; + } + + public static HandlerList getHandlerList() { + return HANDLER_LIST; + } + + public Message getCancelledReason() { + return cancelReason; + } + + public void cancelBecause(Message cancelReason) { + this.cancelReason = cancelReason; + setCancelled(true); + } + + @Override + public boolean isHandled() { + return delegate.isHandled(); + } + + @Override + public void setHandled() { + delegate.setHandled(); + } + + @Override + public void verifyHandled() { + delegate.verifyHandled(); + } + + @Override + public boolean isCancelled() { + return delegate.isCancelled(); + } + + @Override + public void setCancelled(boolean cancel) { + delegate.setCancelled(cancel); + } +} diff --git a/src/main/java/io/github/mviper/bomberman/events/BmRunStartedIntent.java b/src/main/java/io/github/mviper/bomberman/events/BmRunStartedIntent.java new file mode 100644 index 0000000..0903cba --- /dev/null +++ b/src/main/java/io/github/mviper/bomberman/events/BmRunStartedIntent.java @@ -0,0 +1,49 @@ +package io.github.mviper.bomberman.events; + +import io.github.mviper.bomberman.game.Game; +import org.bukkit.Bukkit; +import org.bukkit.event.HandlerList; + +/** + * Called whenever a run is attempted to be started. + */ +public class BmRunStartedIntent extends BmEvent implements Intent { + private static final HandlerList HANDLER_LIST = new HandlerList(); + + public final Game game; + private final BmIntent delegate = new BmIntent(); + + private BmRunStartedIntent(Game game) { + this.game = game; + } + + public static void startRun(Game game) { + BmRunStartedIntent event = new BmRunStartedIntent(game); + Bukkit.getPluginManager().callEvent(event); + event.verifyHandled(); + } + + @Override + public HandlerList getHandlers() { + return HANDLER_LIST; + } + + public static HandlerList getHandlerList() { + return HANDLER_LIST; + } + + @Override + public boolean isHandled() { + return delegate.isHandled(); + } + + @Override + public void setHandled() { + delegate.setHandled(); + } + + @Override + public void verifyHandled() { + delegate.verifyHandled(); + } +} diff --git a/src/main/java/io/github/mviper/bomberman/events/BmRunStoppedIntent.java b/src/main/java/io/github/mviper/bomberman/events/BmRunStoppedIntent.java new file mode 100644 index 0000000..f68c823 --- /dev/null +++ b/src/main/java/io/github/mviper/bomberman/events/BmRunStoppedIntent.java @@ -0,0 +1,72 @@ +package io.github.mviper.bomberman.events; + +import io.github.mviper.bomberman.game.Game; +import io.github.mviper.bomberman.messaging.Message; +import org.bukkit.Bukkit; +import org.bukkit.event.HandlerList; + +/** + * Called whenever a game run is stopped. May be due to game finishing, game forcefully stopped, server shutdown, etc. + */ +public class BmRunStoppedIntent extends BmEvent implements IntentCancellableReasoned { + private static final HandlerList HANDLER_LIST = new HandlerList(); + + public final Game game; + private final BmIntentCancellableReasoned delegate = new BmIntentCancellableReasoned(); + + private BmRunStoppedIntent(Game game) { + this.game = game; + } + + public static BmRunStoppedIntent stopGame(Game game) { + BmRunStoppedIntent event = new BmRunStoppedIntent(game); + Bukkit.getPluginManager().callEvent(event); + event.verifyHandled(); + return event; + } + + @Override + public HandlerList getHandlers() { + return HANDLER_LIST; + } + + public static HandlerList getHandlerList() { + return HANDLER_LIST; + } + + @Override + public boolean isHandled() { + return delegate.isHandled(); + } + + @Override + public void setHandled() { + delegate.setHandled(); + } + + @Override + public void verifyHandled() { + delegate.verifyHandled(); + } + + @Override + public boolean isCancelled() { + return delegate.isCancelled(); + } + + @Override + @Deprecated + public void setCancelled(boolean cancel) { + delegate.setCancelled(cancel); + } + + @Override + public void cancelFor(Message reason) { + delegate.cancelFor(reason); + } + + @Override + public Message cancelledReason() { + return delegate.cancelledReason(); + } +} diff --git a/src/main/java/io/github/mviper/bomberman/events/BmTimerCountedEvent.java b/src/main/java/io/github/mviper/bomberman/events/BmTimerCountedEvent.java new file mode 100644 index 0000000..9ccf1c9 --- /dev/null +++ b/src/main/java/io/github/mviper/bomberman/events/BmTimerCountedEvent.java @@ -0,0 +1,28 @@ +package io.github.mviper.bomberman.events; + +import io.github.mviper.bomberman.game.Game; +import org.bukkit.event.HandlerList; + +/** + * Called whenever a game run is stopped. May be due to game finishing, game forcefully stopped, server shutdown, etc. + */ +public class BmTimerCountedEvent extends BmEvent { + private static final HandlerList HANDLER_LIST = new HandlerList(); + + public final Game game; + public int count; + + public BmTimerCountedEvent(Game game, int count) { + this.game = game; + this.count = count; + } + + @Override + public HandlerList getHandlers() { + return HANDLER_LIST; + } + + public static HandlerList getHandlerList() { + return HANDLER_LIST; + } +} diff --git a/src/main/java/io/github/mviper/bomberman/events/Intent.java b/src/main/java/io/github/mviper/bomberman/events/Intent.java new file mode 100644 index 0000000..a411536 --- /dev/null +++ b/src/main/java/io/github/mviper/bomberman/events/Intent.java @@ -0,0 +1,9 @@ +package io.github.mviper.bomberman.events; + +public interface Intent { + boolean isHandled(); + + void setHandled(); + + void verifyHandled(); +} diff --git a/src/main/java/io/github/mviper/bomberman/events/IntentCancellable.java b/src/main/java/io/github/mviper/bomberman/events/IntentCancellable.java new file mode 100644 index 0000000..39532d9 --- /dev/null +++ b/src/main/java/io/github/mviper/bomberman/events/IntentCancellable.java @@ -0,0 +1,6 @@ +package io.github.mviper.bomberman.events; + +import org.bukkit.event.Cancellable; + +public interface IntentCancellable extends Intent, Cancellable { +} diff --git a/src/main/java/io/github/mviper/bomberman/events/IntentCancellableReasoned.java b/src/main/java/io/github/mviper/bomberman/events/IntentCancellableReasoned.java new file mode 100644 index 0000000..3735cae --- /dev/null +++ b/src/main/java/io/github/mviper/bomberman/events/IntentCancellableReasoned.java @@ -0,0 +1,14 @@ +package io.github.mviper.bomberman.events; + +import io.github.mviper.bomberman.messaging.Message; +import org.bukkit.event.Cancellable; + +public interface IntentCancellableReasoned extends Cancellable, Intent { + @Deprecated + @Override + void setCancelled(boolean cancel); + + void cancelFor(Message reason); + + Message cancelledReason(); +} diff --git a/src/main/java/io/github/mviper/bomberman/game/Bomb.java b/src/main/java/io/github/mviper/bomberman/game/Bomb.java new file mode 100644 index 0000000..e36d222 --- /dev/null +++ b/src/main/java/io/github/mviper/bomberman/game/Bomb.java @@ -0,0 +1,75 @@ +package io.github.mviper.bomberman.game; + +import io.github.mviper.bomberman.Bomberman; +import io.github.mviper.bomberman.events.BmExplosionEvent; +import io.github.mviper.bomberman.events.BmPlayerPlacedBombEvent; +import io.github.mviper.bomberman.events.BmRunStoppedIntent; +import org.bukkit.Bukkit; +import org.bukkit.block.Block; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.HandlerList; +import org.bukkit.event.Listener; +import org.bukkit.plugin.Plugin; + +public class Bomb implements Listener { + private static final Plugin PLUGIN = Bomberman.instance; + + private final Game game; + private final Player player; + private final Block block; + private final int strength; + private final int taskId; + private boolean noExplode = false; + + private Bomb(Game game, Player player, Block block, int strength, long fuse) { + this.game = game; + this.player = player; + this.block = block; + this.strength = strength; + this.taskId = Bukkit.getScheduler().scheduleSyncDelayedTask(PLUGIN, this::explode, fuse); + } + + @EventHandler(ignoreCancelled = true, priority = EventPriority.HIGH) + public void onExplosion(BmExplosionEvent event) { + if (event.game != game) { + return; + } + + if (!noExplode && event.igniting.stream().anyMatch(plan -> plan.block.equals(block))) { + Bukkit.getScheduler().cancelTask(taskId); + explode(); + } + } + + @EventHandler(ignoreCancelled = true, priority = EventPriority.HIGH) + public void onRunStopped(BmRunStoppedIntent event) { + if (event.game != game) { + return; + } + HandlerList.unregisterAll(this); + Bukkit.getScheduler().cancelTask(taskId); + noExplode = true; + } + + private void explode() { + HandlerList.unregisterAll(this); + if (noExplode) { + return; + } + noExplode = true; + Explosion.spawnExplosion(game, block.getLocation(), player, strength); + } + + public static boolean spawnBomb(Game game, Player player, Block block) { + BmPlayerPlacedBombEvent event = new BmPlayerPlacedBombEvent(game, player, block, game.getSettings().getFuseTicks()); + Bukkit.getPluginManager().callEvent(event); + if (event.isCancelled()) { + return false; + } + Bomb bomb = new Bomb(game, player, block, event.strength, event.fuse); + Bukkit.getPluginManager().registerEvents(bomb, PLUGIN); + return true; + } +} diff --git a/src/main/java/io/github/mviper/bomberman/game/Explosion.java b/src/main/java/io/github/mviper/bomberman/game/Explosion.java new file mode 100644 index 0000000..d104589 --- /dev/null +++ b/src/main/java/io/github/mviper/bomberman/game/Explosion.java @@ -0,0 +1,243 @@ +package io.github.mviper.bomberman.game; + +import io.github.mviper.bomberman.Bomberman; +import io.github.mviper.bomberman.events.BmDropLootEvent; +import io.github.mviper.bomberman.events.BmExplosionEvent; +import io.github.mviper.bomberman.events.BmPlayerHitIntent; +import io.github.mviper.bomberman.events.BmPlayerMovedEvent; +import io.github.mviper.bomberman.events.BmRunStoppedIntent; +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.Sound; +import org.bukkit.block.Block; +import org.bukkit.block.BlockState; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.HandlerList; +import org.bukkit.event.Listener; +import org.bukkit.inventory.ItemStack; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +public class Explosion implements Listener { + public static class BlockPlan { + public final Block block; + public final BlockState prior; + public final BlockState ignited; + public final BlockState destroyed; + + public BlockPlan(Block block, BlockState prior, BlockState ignited, BlockState destroyed) { + this.block = block; + this.prior = prior; + this.ignited = ignited; + this.destroyed = destroyed; + } + } + + private final Game game; + private final Set blocks; + private final Player cause; + private final int taskId; + private boolean noExplode = false; + + private Explosion(Game game, Set blocks, Player cause) { + this.game = game; + this.blocks = blocks; + this.cause = cause; + this.taskId = Bukkit.getScheduler().scheduleSyncDelayedTask( + Bomberman.instance, + this::cleanup, + game.getSettings().getFireTicks() + ); + } + + private void cleanup() { + if (noExplode) { + HandlerList.unregisterAll(this); + return; + } + + for (BlockPlan plan : blocks) { + if (plan.ignited.getType() == plan.block.getType()) { + plan.destroyed.update(true); + } + } + + if (cause.getScoreboardTags().contains("bm_player")) { + cause.getInventory().addItem(new ItemStack(game.getSettings().getBombItem(), 1)); + } + + Map> dropsPlanned = planDrops(); + BmDropLootEvent lootEvent = new BmDropLootEvent(game, cause, blocks, dropsPlanned); + Bukkit.getPluginManager().callEvent(lootEvent); + if (!lootEvent.isCancelled()) { + lootEvent.drops.forEach((location, items) -> { + for (ItemStack item : items) { + if (item.getAmount() > 0) { + if (location.getWorld() != null) { + location.getWorld().dropItem(location.clone().add(0.5, 0.5, 0.5), item); + } + } + } + }); + } + + HandlerList.unregisterAll(this); + } + + private Map> planDrops() { + Map> loot = game.getSettings().getBlockLoot(); + Map> planned = new HashMap<>(); + for (BlockPlan plan : blocks) { + Map table = loot.getOrDefault(plan.prior.getType(), Map.of()); + planned.put(plan.block.getLocation(), lootSelect(table)); + } + return planned; + } + + @EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = true) + public void onPlayerMove(BmPlayerMovedEvent event) { + if (event.game != game) { + return; + } + Set fireBlocks = new HashSet<>(); + for (BlockPlan plan : blocks) { + fireBlocks.add(plan.block); + } + if (isTouching(event.player, fireBlocks)) { + BmPlayerHitIntent.hit(event.player, cause); + } + } + + @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true) + public void onAnotherExplosion(BmExplosionEvent event) { + blocks.removeIf(thisBlock -> event.igniting.stream().anyMatch(plan -> plan.block.equals(thisBlock.block))); + } + + @EventHandler(ignoreCancelled = true, priority = EventPriority.HIGH) + public void onRunStopped(BmRunStoppedIntent event) { + if (event.game != game) { + return; + } + Bukkit.getScheduler().cancelTask(taskId); + HandlerList.unregisterAll(this); + noExplode = true; + } + + public static boolean spawnExplosion(Game game, Location center, Player cause, int strength) { + Set firePlanned = planFire(center, game, strength); + Set plannedTypes = new HashSet<>(); + for (Block block : firePlanned) { + BlockState prior = block.getState(); + BlockState ignited = block.getState(); + if (!game.getSettings().getPassKeep().contains(block.getType())) { + ignited.setType(game.getSettings().getFireType()); + } + BlockState converted = block.getState(); + if (!game.getSettings().getPassKeep().contains(block.getType())) { + converted.setType(Material.AIR); + } + plannedTypes.add(new BlockPlan(block, prior, ignited, converted)); + } + + BmExplosionEvent event = new BmExplosionEvent(game, cause, plannedTypes); + Bukkit.getPluginManager().callEvent(event); + if (event.isCancelled()) { + return false; + } + + for (BlockPlan plan : event.igniting) { + plan.ignited.update(true); + } + if (center.getWorld() != null) { + center.getWorld().playSound(center, Sound.ENTITY_GENERIC_EXPLODE, 1f, (float) Math.random() + 0.5f); + } + + Explosion explosion = new Explosion(game, event.igniting, cause); + Bukkit.getPluginManager().registerEvents(explosion, Bomberman.instance); + return true; + } + + public static boolean isTouching(Player player, Set blocks) { + for (Block block : blocks) { + double margin = 0.295; + Location playerLocation = player.getLocation(); + Location min = block.getLocation().add(0.0, -1.0, 0.0); + Location max = block.getLocation().add(1.0, 2.0, 1.0); + if (playerLocation.getX() >= min.getX() - margin && playerLocation.getX() <= max.getX() + margin + && playerLocation.getY() >= min.getY() - margin && playerLocation.getY() <= max.getY() + margin + && playerLocation.getZ() >= min.getZ() - margin && playerLocation.getZ() <= max.getZ() + margin) { + return true; + } + } + return false; + } + + private static Set planFire(Location center, Game game, int strength) { + Set blocks = new HashSet<>(); + blocks.addAll(planFire(center, game, strength, 0, 1)); + blocks.addAll(planFire(center, game, strength, 0, -1)); + blocks.addAll(planFire(center, game, strength, 1, 0)); + blocks.addAll(planFire(center, game, strength, -1, 0)); + + planFire(center, game, 0, -1, 0, blocks); + planFire(center, game, 0, 1, 0, blocks); + blocks.add(center.getBlock()); + + return blocks; + } + + private static Set planFire(Location center, Game game, int strength, int xstep, int zstep) { + Set blocks = new HashSet<>(); + for (int i = 1; i <= strength; i++) { + planFire(center, game, i * xstep, 1, i * zstep, blocks); + planFire(center, game, i * xstep, -1, i * zstep, blocks); + if (planFire(center, game, i * xstep, 0, i * zstep, blocks)) { + return blocks; + } + } + return blocks; + } + + private static boolean planFire(Location center, Game game, int x, int y, int z, Set blocks) { + Location location = center.clone().add((double) z, (double) y, (double) x); + Block block = location.getBlock(); + + if (isPassing(block, game.getSettings())) { + blocks.add(block); + return false; + } + + if (game.getSettings().getDestructible().contains(block.getType())) { + blocks.add(block); + } + return true; + } + + private static boolean isPassing(Block block, GameSettings settings) { + Material type = block.getType(); + return type == Material.AIR || type == settings.getFireType() + || (block.isPassable() && !(settings.getIndestructible().contains(type) || settings.getDestructible().contains(type))) + || settings.getPassDestroy().contains(type) + || settings.getPassKeep().contains(type); + } + + public static Set lootSelect(Map loot) { + int sum = loot.values().stream().mapToInt(Integer::intValue).sum(); + for (Map.Entry entry : loot.entrySet()) { + if (sum * Math.random() <= entry.getValue()) { + return Set.of(entry.getKey()); + } + sum -= entry.getValue(); + } + if (sum == 0) { + return Set.of(); + } + throw new RuntimeException("Explosion.drop didn't select (should never happen)"); + } +} diff --git a/src/main/java/io/github/mviper/bomberman/game/Game.java b/src/main/java/io/github/mviper/bomberman/game/Game.java new file mode 100644 index 0000000..d515f82 --- /dev/null +++ b/src/main/java/io/github/mviper/bomberman/game/Game.java @@ -0,0 +1,648 @@ +package io.github.mviper.bomberman.game; + +import com.sk89q.worldedit.WorldEdit; +import com.sk89q.worldedit.bukkit.BukkitAdapter; +import com.sk89q.worldedit.extent.clipboard.BlockArrayClipboard; +import com.sk89q.worldedit.extent.clipboard.Clipboard; +import com.sk89q.worldedit.function.mask.BlockTypeMask; +import com.sk89q.worldedit.function.mask.Masks; +import com.sk89q.worldedit.function.operation.ForwardExtentCopy; +import com.sk89q.worldedit.function.operation.Operations; +import com.sk89q.worldedit.math.BlockVector3; +import com.sk89q.worldedit.session.ClipboardHolder; +import com.sk89q.worldedit.world.block.BlockTypes; +import io.github.mviper.bomberman.Bomberman; +import io.github.mviper.bomberman.commands.game.UndoBuild; +import io.github.mviper.bomberman.events.BmGameBuildIntent; +import io.github.mviper.bomberman.events.BmGameDeletedIntent; +import io.github.mviper.bomberman.events.BmGameListIntent; +import io.github.mviper.bomberman.events.BmGameLookupIntent; +import io.github.mviper.bomberman.events.BmGameTerminatedIntent; +import io.github.mviper.bomberman.events.BmPlayerJoinGameIntent; +import io.github.mviper.bomberman.events.BmPlayerLeaveGameIntent; +import io.github.mviper.bomberman.events.BmPlayerMovedEvent; +import io.github.mviper.bomberman.events.BmPlayerWonEvent; +import io.github.mviper.bomberman.events.BmRunStartCountDownIntent; +import io.github.mviper.bomberman.events.BmRunStartedIntent; +import io.github.mviper.bomberman.events.BmRunStoppedIntent; +import io.github.mviper.bomberman.messaging.CollectionWrapper; +import io.github.mviper.bomberman.messaging.Context; +import io.github.mviper.bomberman.messaging.Expander; +import io.github.mviper.bomberman.messaging.Formattable; +import io.github.mviper.bomberman.messaging.Message; +import io.github.mviper.bomberman.messaging.RequiredArg; +import io.github.mviper.bomberman.messaging.SenderWrapper; +import io.github.mviper.bomberman.messaging.Text; +import io.github.mviper.bomberman.utils.Box; +import io.github.mviper.bomberman.utils.BukkitUtils; +import io.github.mviper.bomberman.utils.WorldEditUtils; +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.HandlerList; +import org.bukkit.event.Listener; +import org.bukkit.event.entity.EntityDamageEvent; +import org.bukkit.event.server.PluginDisableEvent; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.regex.Pattern; + +public class Game implements Formattable, Listener { + private static final Bomberman PLUGIN = Bomberman.instance; + + private final GameSave save; + public final String name; + private final Set players = new HashSet<>(); + private boolean running = false; + private final YamlConfiguration tempData; + private Box box; + private Set spawns; + + public Game(GameSave save) { + this.save = save; + this.name = save.name; + + Path tempPath = tempDataFile(this); + if (Files.exists(tempPath)) { + try (var reader = Files.newBufferedReader(tempPath)) { + tempData = YamlConfiguration.loadConfiguration(reader); + } catch (IOException e) { + throw new RuntimeException("Failed to read temp data", e); + } + } else { + tempData = new YamlConfiguration(); + } + + if (tempData.getBoolean("rebuild-needed", false)) { + Bukkit.getScheduler().scheduleSyncDelayedTask(PLUGIN, () -> BmGameBuildIntent.build(this)); + } + + Bukkit.getPluginManager().registerEvents(this, PLUGIN); + } + + public static Game buildGameFromRegion(String name, Box box, GameSettings settings) { + var region = WorldEditUtils.convert(box); + Clipboard clipboard = new BlockArrayClipboard(region); + try (var editSession = WorldEdit.getInstance().newEditSession(BukkitAdapter.adapt(box.world))) { + ForwardExtentCopy copy = new ForwardExtentCopy(editSession, region, clipboard, region.getMinimumPoint()); + try { + Operations.complete(copy); + } catch (com.sk89q.worldedit.WorldEditException e) { + throw new RuntimeException("Failed to copy region", e); + } + } + + GameSave save = GameSave.createNewSave(name, BukkitUtils.boxLoc1(box), settings, clipboard); + UndoBuild.removeHistory(name); + + Game game = new Game(save); + game.makeCages(); + return game; + } + + public static Game buildGameFromSchema(String name, Location loc, Clipboard clipboard, GameSettings settings) { + GameSave save = GameSave.createNewSave(name, loc, settings, clipboard); + Game game = new Game(save); + UndoBuild.retainHistory(game.name, game.getBox()); + BmGameBuildIntent.build(game); + return game; + } + + private static Path tempDataFile(Game game) { + return PLUGIN.tempGameData().resolve(GameSave.sanitize(game.name + ".yml")); + } + + public GameSettings getSettings() { + try { + return save.getSettings(); + } catch (IOException e) { + throw new RuntimeException("Failed to load settings", e); + } + } + + public String getName() { + return name; + } + + public void setSettings(GameSettings value) { + try { + save.updateSettings(value); + } catch (IOException e) { + throw new RuntimeException("Failed to update settings", e); + } + } + + public Location getOrigin() { + return save.origin; + } + + public Clipboard getClipboard() throws IOException { + return save.getSchematic(); + } + + private Box getBox() { + if (box == null) { + try { + box = WorldEditUtils.pastedBounds(getOrigin(), getClipboard()); + } catch (IOException e) { + throw new RuntimeException("Failed to read schematic", e); + } + } + return box; + } + + private Set getSpawns() { + if (spawns == null) { + List list = tempData.getList("spawn-points"); + if (list != null) { + Set result = new HashSet<>(); + for (Object item : list) { + if (item instanceof Location) { + result.add((Location) item); + } + } + spawns = result; + } else { + spawns = searchSpawns(); + writeTempData("spawns", new ArrayList<>(spawns)); + } + } + return spawns; + } + + private void writeTempData(String path, Object obj) { + tempData.set(path, obj); + try (var writer = Files.newBufferedWriter(tempDataFile(this))) { + writer.write(tempData.saveToString()); + } catch (IOException e) { + throw new RuntimeException("Failed to write temp data", e); + } + } + + private Set searchSpawns() { + PLUGIN.getLogger().info("Searching for spawns..."); + Set result = new HashSet<>(); + try { + Clipboard clipboard = getClipboard(); + for (BlockVector3 loc : clipboard.getRegion()) { + var block = clipboard.getFullBlock(loc); + var nbt = block.getNbtData(); + if (nbt != null) { + boolean isSpawn = nbt.getString("Text1").contains("[spawn]") + || nbt.getString("Text2").contains("[spawn]") + || nbt.getString("Text3").contains("[spawn]") + || nbt.getString("Text4").contains("[spawn]"); + if (isSpawn) { + Location worldLocation = BukkitAdapter.adapt(getBox().world, loc.subtract(clipboard.getOrigin())) + .add(getOrigin()).getBlock().getLocation(); + result.add(worldLocation); + } + } + } + } catch (IOException e) { + throw new RuntimeException("Failed to search spawns", e); + } + PLUGIN.getLogger().info(" " + result.size() + " spawns found"); + return result; + } + + private Location findSpareSpawn() { + for (Location spawn : getSpawns()) { + boolean occupied = false; + for (Player player : players) { + Location loc = player.getLocation(); + if (spawn.getBlockX() == loc.getBlockX() + && spawn.getBlockY() == loc.getBlockY() + && spawn.getBlockZ() == loc.getBlockZ()) { + occupied = true; + break; + } + } + if (!occupied) { + return spawn; + } + } + return null; + } + + private void removeCages() { + try { + try (var editSession = WorldEdit.getInstance().newEditSession(BukkitAdapter.adapt(save.origin.getWorld()))) { + BlockVector3 offset = BlockVector3.at(save.origin.getX(), save.origin.getY(), save.origin.getZ()) + .subtract(save.getSchematic().getOrigin()); + for (SpawnBlock entry : spawnBlocks()) { + if (!entry.isSpawn) { + Location loc = entry.block.getLocation(); + BlockVector3 blockVec = BlockVector3.at(loc.getX(), loc.getY(), loc.getZ()); + BlockVector3 clipLocation = blockVec.subtract(offset); + var blockState = save.getSchematic().getFullBlock(clipLocation); + editSession.setBlock(blockVec, blockState); + } + } + } + } catch (IOException | com.sk89q.worldedit.WorldEditException e) { + throw new RuntimeException("Failed to remove cages", e); + } + } + + private void makeCages() { + for (SpawnBlock entry : spawnBlocks()) { + if (entry.isSpawn) { + entry.block.setType(Material.AIR); + } else if (entry.block.isPassable()) { + entry.block.setType(getSettings().getCageBlock()); + } + } + } + + private List spawnBlocks() { + List blocks = new ArrayList<>(); + for (Location location : getSpawns()) { + for (int i = -1; i <= 1; i++) { + for (int j = -1; j <= 2; j++) { + for (int k = -1; k <= 1; k++) { + Location blockLoc = location.clone().add(i, j, k); + if (!getBox().contains(blockLoc)) { + continue; + } + var block = blockLoc.getBlock(); + if ((j == 0 || j == 1) && (i == 0 && k == 0)) { + blocks.add(new SpawnBlock(true, block)); + } else if (((j == 0 || j == 1) && (i == 0 || k == 0)) || (i == 0 && k == 0)) { + blocks.add(new SpawnBlock(false, block)); + } + } + } + } + } + return blocks; + } + + @EventHandler(ignoreCancelled = true, priority = EventPriority.LOW) + public void onGameListing(BmGameListIntent event) { + event.games.add(this); + } + + @EventHandler(ignoreCancelled = true, priority = EventPriority.LOW) + public void onGameLookup(BmGameLookupIntent event) { + if (event.name.equalsIgnoreCase(name)) { + event.game = this; + } + } + + @EventHandler(ignoreCancelled = true, priority = EventPriority.HIGHEST) + public void onPlayerJoinGame(BmPlayerJoinGameIntent event) { + if (event.game != this) { + return; + } + + if (running) { + event.cancelFor(Text.GAME_ALREADY_STARTED.format(new Context(false) + .plus("game", this) + .plus("player", event.player))); + return; + } + + Location gameSpawn = findSpareSpawn(); + if (gameSpawn == null) { + event.cancelFor(Text.JOIN_GAME_FULL.format(new Context(false) + .plus("game", this) + .plus("player", event.player))); + return; + } + + GamePlayer.spawnGamePlayer(event.player, this, gameSpawn); + players.add(event.player); + event.setHandled(); + } + + @EventHandler(ignoreCancelled = true, priority = EventPriority.LOWEST) + public void onPlayerDamaged(EntityDamageEvent event) { + if (!players.contains(event.getEntity())) { + return; + } + + Map> damageSources = getSettings().getDamageSources(); + List> damageChanges = new ArrayList<>(); + for (Map.Entry> entry : damageSources.entrySet()) { + if (Pattern.compile(entry.getKey(), Pattern.CASE_INSENSITIVE) + .matcher(event.getCause().toString()).matches()) { + damageChanges.add(entry.getValue()); + } + } + + if (damageChanges.isEmpty() && event.getCause() != EntityDamageEvent.DamageCause.CUSTOM) { + event.setCancelled(true); + return; + } + + for (Map change : damageChanges) { + String cancelExpression = null; + for (Map.Entry rule : change.entrySet()) { + if (rule.getKey().equalsIgnoreCase("cancel")) { + cancelExpression = rule.getValue(); + break; + } + } + if (cancelExpression == null) { + continue; + } + String result = Expander.expand(cancelExpression, new Context(false) + .plus("base", Message.of(event.getDamage())) + .plus("final", Message.of(event.getFinalDamage())) + .plus("cause", Message.of(event.getCause().toString())) + .plus("player", new SenderWrapper((Player) event.getEntity())) + .plus("game", this)).toString(); + try { + double asDouble = Double.parseDouble(result); + if (asDouble > 0.000001 || asDouble < -0.000001) { + event.setCancelled(true); + return; + } + } catch (NumberFormatException ignored) { + } + } + + for (Map rules : damageChanges) { + for (EntityDamageEvent.DamageModifier modifier : EntityDamageEvent.DamageModifier.values()) { + if (!event.isApplicable(modifier)) { + continue; + } + for (Map.Entry rule : rules.entrySet()) { + if (!Pattern.compile(rule.getKey(), Pattern.CASE_INSENSITIVE) + .matcher(modifier.toString()).matches()) { + continue; + } + var result = Expander.expand(rule.getValue(), new Context(false) + .plus("base", Message.of(event.getDamage())) + .plus("damage", Message.of(event.getDamage(modifier))) + .plus("final", Message.of(event.getFinalDamage())) + .plus("cause", Message.of(event.getCause().toString())) + .plus("player", new SenderWrapper((Player) event.getEntity())) + .plus("game", this) + .plus("modifier", Message.of(modifier.toString()))); + try { + double damage = Double.parseDouble(result.toString()); + event.setDamage(modifier, damage); + } catch (NumberFormatException ignored) { + } + } + } + } + } + + @EventHandler(ignoreCancelled = true, priority = EventPriority.HIGHEST) + public void onRunStartCountDown(BmRunStartCountDownIntent event) { + if (event.game != this) { + return; + } + + if (running) { + event.cancelBecause(Text.GAME_ALREADY_STARTED.format(new Context(false).plus("game", this))); + return; + } + + if (players.isEmpty()) { + event.cancelBecause(Text.GAME_NO_PLAYERS.format(new Context(false).plus("game", this))); + return; + } + + StartTimer.createTimer(this, event.delay); + event.setHandled(); + } + + @EventHandler(ignoreCancelled = true, priority = EventPriority.HIGH) + public void onRunStarted(BmRunStartedIntent event) { + if (event.game != this) { + return; + } + running = true; + removeCages(); + GameProtection.protect(this, getBox()); + writeTempData("rebuild-needed", true); + event.setHandled(); + } + + @EventHandler(ignoreCancelled = true, priority = EventPriority.NORMAL) + public void onPlayerMoveOutOfArena(BmPlayerMovedEvent event) { + if (event.game != this) { + return; + } + if (!getBox().contains(event.getTo())) { + BmPlayerLeaveGameIntent.leave(event.player); + } + } + + @EventHandler(ignoreCancelled = true, priority = EventPriority.HIGHEST) + public void onPlayerLeave(BmPlayerLeaveGameIntent event) { + if (players.contains(event.player)) { + players.remove(event.player); + if (players.size() < 1 || !running) { + Bukkit.getScheduler().scheduleSyncDelayedTask(PLUGIN, () -> BmRunStoppedIntent.stopGame(this)); + } else if (players.size() == 1) { + for (Player player : players) { + Bukkit.getPluginManager().callEvent(new BmPlayerWonEvent(this, player)); + } + Bukkit.getScheduler().scheduleSyncDelayedTask(PLUGIN, () -> BmRunStoppedIntent.stopGame(this), 5 * 20L); + } + } + } + + @EventHandler(ignoreCancelled = true, priority = EventPriority.NORMAL) + public void onRunStoppedWhileRunning(BmRunStoppedIntent event) { + if (event.game != this) { + return; + } + if (!running) { + event.cancelFor(Text.STOP_NOT_STARTED.format(new Context(false).plus("game", this))); + } + } + + @EventHandler(ignoreCancelled = true, priority = EventPriority.HIGH) + public void onRunStopped(BmRunStoppedIntent event) { + if (event.game != this) { + return; + } + if (running) { + running = false; + BmGameBuildIntent.build(this); + } + event.setHandled(); + } + + @EventHandler(ignoreCancelled = true, priority = EventPriority.HIGH) + public void onGameRebuild(BmGameBuildIntent event) { + if (event.game != this) { + return; + } + + PLUGIN.getLogger().info("Building schematic ..."); + + getBox().world.getNearbyEntities(BukkitUtils.convert(getBox())) + .stream() + .filter(entity -> !(entity instanceof Player)) + .forEach(entity -> entity.remove()); + + try (var editSession = WorldEdit.getInstance().newEditSession(BukkitAdapter.adapt(getBox().world))) { + var operation = new ClipboardHolder(getClipboard()) + .createPaste(editSession) + .to(BlockVector3.at(getOrigin().getBlockX(), getOrigin().getBlockY(), getOrigin().getBlockZ())) + .copyEntities(true) + .maskSource(Masks.negate(new BlockTypeMask(editSession, + getSettings().getSourceMask().stream() + .map(mat -> BlockTypes.get(mat.getKey().toString())) + .filter(obj -> obj != null) + .toList()))) + .build(); + Operations.complete(operation); + } catch (IOException | com.sk89q.worldedit.WorldEditException e) { + throw new RuntimeException("Failed to rebuild game", e); + } + makeCages(); + + PLUGIN.getLogger().info("Rebuild done"); + writeTempData("rebuild-needed", false); + event.setHandled(); + } + + @EventHandler(ignoreCancelled = true, priority = EventPriority.HIGH) + public void onGameTerminated(BmGameTerminatedIntent event) { + if (event.game != this) { + return; + } + BmRunStoppedIntent.stopGame(this); + HandlerList.unregisterAll(this); + event.setHandled(); + } + + @EventHandler(ignoreCancelled = true, priority = EventPriority.HIGH) + public void onGameDeleted(BmGameDeletedIntent event) { + if (event.game != this) { + return; + } + PLUGIN.getLogger().info("Deleting " + name + (event.isDeletingSave ? "" : " (keeping data)")); + BmGameTerminatedIntent.terminateGame(this); + try { + Files.deleteIfExists(tempDataFile(this)); + } catch (IOException e) { + PLUGIN.getLogger().warning("Unable to delete temp data for " + name); + } + if (event.isDeletingSave) { + try { + Files.deleteIfExists(PLUGIN.gameSaves().resolve(GameSave.sanitize(name + ".game.zip"))); + } catch (IOException e) { + PLUGIN.getLogger().warning("Unable to delete save for " + name); + } + } + event.setHandled(); + } + + @EventHandler(ignoreCancelled = true, priority = EventPriority.HIGH) + public void onServerStop(PluginDisableEvent event) { + if (event.getPlugin() != PLUGIN) { + return; + } + BmGameTerminatedIntent.terminateGame(this); + } + + @Override + public Formattable applyModifier(Message arg) { + String key = arg.toString().toLowerCase(Locale.ROOT); + switch (key) { + case "name": + return Message.of(name); + case "spawns": { + List spawnList = new ArrayList<>(); + for (Location location : getSpawns()) { + spawnList.add(new RequiredArg(spawnArg -> { + String spawnKey = spawnArg.toString().toLowerCase(Locale.ROOT); + return switch (spawnKey) { + case "world", "w" -> Message.of(location.getWorld() != null ? location.getWorld().getName() : "unknown"); + case "x" -> Message.of((int) location.getX()); + case "y" -> Message.of((int) location.getY()); + case "z" -> Message.of((int) location.getZ()); + default -> throw new IllegalArgumentException("Unknown spawn format " + spawnArg); + }; + })); + } + return new CollectionWrapper<>(spawnList); + } + case "players": { + List playerList = new ArrayList<>(); + for (Player player : players) { + playerList.add(new SenderWrapper(player)); + } + return new CollectionWrapper<>(playerList); + } + case "power": { + int sum = 0; + for (var item : getSettings().getInitialItems()) { + if (item != null && item.getType() == getSettings().getBombItem()) { + sum += item.getAmount(); + } + } + return Message.of(sum); + } + case "bombs": { + int sum = 0; + for (var item : getSettings().getInitialItems()) { + if (item != null && item.getType() == getSettings().getPowerItem()) { + sum += item.getAmount(); + } + } + return Message.of(sum); + } + case "lives": + return Message.of(Integer.toString(getSettings().getLives())); + case "w": + case "world": + return Message.of(getOrigin().getWorld() != null ? getOrigin().getWorld().getName() : "unknown"); + case "x": + return Message.of((int) getOrigin().getX()); + case "y": + return Message.of((int) getOrigin().getY()); + case "z": + return Message.of((int) getOrigin().getZ()); + case "xsize": + return Message.of(getBox().getSize().x); + case "ysize": + return Message.of(getBox().getSize().y); + case "zsize": + return Message.of(getBox().getSize().z); + case "running": + return Message.of(running ? "true" : "false"); + case "schema": + return this; + default: + return Message.empty; + } + } + + @Override + public Message format(Context context) { + return applyModifier(Message.of("name")).format(context); + } + + private static final class SpawnBlock { + private final boolean isSpawn; + private final org.bukkit.block.Block block; + + private SpawnBlock(boolean isSpawn, org.bukkit.block.Block block) { + this.isSpawn = isSpawn; + this.block = block; + } + } +} diff --git a/src/main/java/io/github/mviper/bomberman/game/GamePlayer.java b/src/main/java/io/github/mviper/bomberman/game/GamePlayer.java new file mode 100644 index 0000000..ad21789 --- /dev/null +++ b/src/main/java/io/github/mviper/bomberman/game/GamePlayer.java @@ -0,0 +1,465 @@ +package io.github.mviper.bomberman.game; + +import com.sk89q.jnbt.StringTag; +import com.sk89q.worldedit.bukkit.BukkitAdapter; +import io.github.mviper.bomberman.Bomberman; +import io.github.mviper.bomberman.events.BmGameTerminatedIntent; +import io.github.mviper.bomberman.events.BmPlayerHitIntent; +import io.github.mviper.bomberman.events.BmPlayerHurtIntent; +import io.github.mviper.bomberman.events.BmPlayerJoinGameIntent; +import io.github.mviper.bomberman.events.BmPlayerKilledIntent; +import io.github.mviper.bomberman.events.BmPlayerLeaveGameIntent; +import io.github.mviper.bomberman.events.BmPlayerMovedEvent; +import io.github.mviper.bomberman.events.BmPlayerWonEvent; +import io.github.mviper.bomberman.events.BmRunStartedIntent; +import io.github.mviper.bomberman.events.BmRunStoppedIntent; +import io.github.mviper.bomberman.events.BmTimerCountedEvent; +import io.github.mviper.bomberman.messaging.Context; +import io.github.mviper.bomberman.messaging.Text; +import org.bukkit.Bukkit; +import org.bukkit.GameMode; +import org.bukkit.Location; +import org.bukkit.attribute.Attribute; +import org.bukkit.block.Block; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.entity.Item; +import org.bukkit.entity.Player; +import org.bukkit.event.Event; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.HandlerList; +import org.bukkit.event.Listener; +import org.bukkit.event.block.Action; +import org.bukkit.event.block.BlockPlaceEvent; +import org.bukkit.event.entity.EntityRegainHealthEvent; +import org.bukkit.event.player.PlayerInteractEvent; +import org.bukkit.event.player.PlayerJoinEvent; +import org.bukkit.event.player.PlayerMoveEvent; +import org.bukkit.event.player.PlayerQuitEvent; +import org.bukkit.event.player.PlayerRespawnEvent; +import org.bukkit.inventory.ItemStack; +import org.bukkit.potion.PotionEffect; +import org.bukkit.potion.PotionEffectType; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.logging.Level; + +public class GamePlayer implements Listener { + private static final Bomberman PLUGIN = Bomberman.instance; + + public static void spawnGamePlayer(Player player, Game game, Location start) { + GamePlayer gamePlayer = new GamePlayer(player, game); + PLUGIN.getServer().getPluginManager().registerEvents(gamePlayer, PLUGIN); + + YamlConfiguration dataFile = new YamlConfiguration(); + dataFile.set("location", player.getLocation()); + dataFile.set("gamemode", player.getGameMode().name().toLowerCase(Locale.ROOT)); + dataFile.set("health", player.getHealth()); + dataFile.set("health-scale", player.getHealthScale()); + dataFile.set("health-max", player.getAttribute(Attribute.GENERIC_MAX_HEALTH).getBaseValue()); + dataFile.set("food-level", player.getFoodLevel()); + dataFile.set("inventory", java.util.Arrays.asList(player.getInventory().getContents())); + dataFile.set("is-flying", player.isFlying()); + try (var writer = Files.newBufferedWriter(tempDataFile(player))) { + writer.write(dataFile.saveToString()); + } catch (Exception e) { + PLUGIN.getLogger().log(Level.WARNING, "Unable to store player data", e); + } + + try { + player.addAttachment(PLUGIN, "group.bomberman", true); + } catch (Exception e) { + PLUGIN.getLogger().log(Level.WARNING, "Unable to add permissions", e); + } + + start.getWorld().getNearbyEntities(start, 2.0, 3.0, 2.0).stream() + .filter(entity -> entity instanceof Item) + .forEach(entity -> entity.remove()); + + var maxHealth = player.getAttribute(Attribute.GENERIC_MAX_HEALTH); + maxHealth.setBaseValue(game.getSettings().getLives()); + maxHealth.getModifiers().forEach(maxHealth::removeModifier); + player.setHealth(game.getSettings().getLives()); + Bukkit.getScheduler().scheduleSyncDelayedTask(PLUGIN, () -> + player.setHealthScale(game.getSettings().getLives() * 2.0) + ); + + if (!player.teleport(start.clone().add(0.5, 0.01, 0.5))) { + Bukkit.getScheduler().scheduleSyncDelayedTask(PLUGIN, () -> + BmPlayerLeaveGameIntent.leave(player) + ); + } + player.setGameMode(GameMode.SURVIVAL); + player.setExhaustion(0f); + player.setFoodLevel(100000); + player.setFlying(false); + player.getInventory().clear(); + List initialItems = game.getSettings().getInitialItems(); + for (int i = 0; i < initialItems.size() && i < player.getInventory().getSize(); i++) { + ItemStack stack = initialItems.get(i); + player.getInventory().setItem(i, stack != null ? stack.clone() : null); + } + removePotionEffects(player); + + player.addScoreboardTag("bm_player"); + } + + public static int bombStrength(Game game, Player player) { + int strength = 1; + for (ItemStack stack : player.getInventory().getContents()) { + if (stack != null && stack.getType() == game.getSettings().getPowerItem()) { + strength += stack.getAmount(); + } + } + return Math.max(strength, 1); + } + + public static void setupLoginWatcher() { + Bukkit.getPluginManager().registerEvents(new Listener() { + @EventHandler(ignoreCancelled = true, priority = EventPriority.LOW) + public void onPlayerLogin(PlayerJoinEvent event) { + Player player = event.getPlayer(); + if (player.isDead()) { + return; + } + Path save = tempDataFile(player); + if (Files.exists(save)) { + reset(player); + } + } + + @EventHandler(ignoreCancelled = true, priority = EventPriority.LOW) + public void onPlayerRespawn(PlayerRespawnEvent event) { + Player player = event.getPlayer(); + Path save = tempDataFile(player); + if (Files.exists(save)) { + event.setRespawnLocation(reset(player)); + } + } + }, PLUGIN); + } + + private static Location reset(Player player) { + Path file = tempDataFile(player); + YamlConfiguration dataFile; + try (var reader = Files.newBufferedReader(file)) { + dataFile = YamlConfiguration.loadConfiguration(reader); + } catch (Exception e) { + throw new RuntimeException("Unable to load player data", e); + } + + String gamemode = dataFile.getString("gamemode"); + GameMode mode = GameMode.SURVIVAL; + if (gamemode != null) { + try { + mode = GameMode.valueOf(gamemode.toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException ignored) { + } + } + player.setGameMode(mode); + player.setHealthScale(dataFile.getDouble("health-scale", 20)); + + var maxHealth = player.getAttribute(Attribute.GENERIC_MAX_HEALTH); + maxHealth.setBaseValue(dataFile.getDouble("health-max", 20.0)); + maxHealth.getModifiers().forEach(maxHealth::removeModifier); + double health = dataFile.getDouble("health", 20.0); + player.setHealth(Math.min(health, maxHealth.getValue())); + + player.setFoodLevel(dataFile.getInt("food-level", 20)); + + List inventory = dataFile.getList("inventory", new ArrayList<>()); + ItemStack[] contents = new ItemStack[inventory.size()]; + for (int i = 0; i < inventory.size(); i++) { + Object item = inventory.get(i); + contents[i] = item instanceof ItemStack ? (ItemStack) item : null; + } + player.getInventory().setContents(contents); + player.setFlying(dataFile.getBoolean("is-flying", false)); + + Location location = dataFile.getLocation("location"); + if (location == null && !Bukkit.getServer().getWorlds().isEmpty()) { + location = Bukkit.getServer().getWorlds().get(0).getSpawnLocation(); + } + if (location != null) { + player.teleport(location); + } + + player.removeScoreboardTag("bm_player"); + + try { + player.addAttachment(PLUGIN, "group.bomberman", false); + } catch (Exception e) { + PLUGIN.getLogger().log(Level.WARNING, "Unable to remove permissions", e); + } + + try { + Files.deleteIfExists(file); + } catch (Exception e) { + PLUGIN.getLogger().log(Level.WARNING, "Unable to delete player data", e); + } + + removePotionEffects(player); + return location; + } + + private static void removePotionEffects(Player player) { + Bukkit.getScheduler().scheduleSyncDelayedTask(PLUGIN, () -> { + player.setFireTicks(0); + for (PotionEffect effect : player.getActivePotionEffects()) { + player.removePotionEffect(effect.getType()); + } + }); + } + + private static Path tempDataFile(Player player) { + return PLUGIN.tempPlayerData().resolve(player.getName() + ".yml"); + } + + private final Player player; + private final Game game; + private boolean immunity = false; + + private GamePlayer(Player player, Game game) { + this.player = player; + this.game = game; + } + + private void resetStuffAndUnregister() { + player.getWorld().getNearbyEntities(player.getLocation(), 1.0, 2.0, 1.0).stream() + .filter(entity -> entity instanceof Item) + .forEach(entity -> entity.remove()); + + reset(player); + HandlerList.unregisterAll(this); + } + + @EventHandler(ignoreCancelled = true, priority = EventPriority.LOW) + public void onPlayerJoinGame(BmPlayerJoinGameIntent event) { + if (event.player == player) { + event.cancelFor(Text.JOIN_ALREADY_JOINED.format(new Context(false) + .plus("game", event.game) + .plus("player", player))); + } + } + + @EventHandler(ignoreCancelled = true, priority = EventPriority.HIGH) + public void onCount(BmTimerCountedEvent event) { + if (event.game != game) { + return; + } + if (event.count > 0) { + Text.GAME_COUNT.format(new Context(false) + .plus("time", event.count) + .plus("game", game)) + .sendTo(player); + } + } + + @EventHandler(ignoreCancelled = true, priority = EventPriority.HIGH) + public void onRunStarted(BmRunStartedIntent event) { + if (event.game != game) { + return; + } + Text.GAME_STARTED.format(new Context(false).plus("game", game)).sendTo(player); + event.setHandled(); + } + + @EventHandler(ignoreCancelled = true, priority = EventPriority.LOWEST) + public void onPlayerRegen(EntityRegainHealthEvent event) { + if (event.getEntity() != player) { + return; + } + if (event.getRegainReason() == EntityRegainHealthEvent.RegainReason.MAGIC) { + event.setAmount(1.0); + } else { + event.setCancelled(true); + } + } + + @EventHandler(ignoreCancelled = true, priority = EventPriority.LOW) + public void onPlayerBreakBlockWithWrongTool(PlayerInteractEvent event) { + if (event.getPlayer() != player) { + return; + } + if (event.getAction() != Action.LEFT_CLICK_BLOCK || !event.hasBlock()) { + return; + } + + String key = event.getClickedBlock() != null + ? event.getClickedBlock().getBlockData().getMaterial().getKey().toString() + : null; + if (event.getItem() != null && key != null) { + var nbt = BukkitAdapter.adapt(event.getItem()).getNbtData(); + var list = nbt != null ? nbt.getList("CanDestroy", StringTag.class) : null; + if (list != null && !list.isEmpty()) { + boolean allowed = list.stream().anyMatch(tag -> key.equalsIgnoreCase(tag.getValue())); + if (allowed) { + return; + } + } + } + + event.setCancelled(true); + event.setUseInteractedBlock(Event.Result.DENY); + event.setUseItemInHand(Event.Result.DENY); + event.getPlayer().addPotionEffect(new PotionEffect(PotionEffectType.MINING_FATIGUE, 20, 1)); + } + + @EventHandler(ignoreCancelled = true, priority = EventPriority.LOW) + public void onPlayerPlaceBlock(BlockPlaceEvent event) { + if (event.getPlayer() != player) { + return; + } + + String key = event.getBlockAgainst().getBlockData().getMaterial().getKey().toString(); + var nbt = BukkitAdapter.adapt(event.getItemInHand()).getNbtData(); + var list = nbt != null ? nbt.getList("CanPlaceOn", StringTag.class) : null; + if (list != null && !list.isEmpty()) { + boolean allowed = list.stream().anyMatch(tag -> key.equalsIgnoreCase(tag.getValue())); + if (!allowed) { + event.setCancelled(true); + } + } + } + + @EventHandler(ignoreCancelled = true, priority = EventPriority.HIGHEST) + public void onPlayerPlaceTNT(BlockPlaceEvent event) { + if (event.getPlayer() != player) { + return; + } + var block = event.getBlock(); + if (block.getType() == game.getSettings().getBombItem()) { + if (!Bomb.spawnBomb(game, player, block)) { + event.setCancelled(true); + } + } + } + + @EventHandler(ignoreCancelled = true, priority = EventPriority.LOW) + public void onPlayerMoved(PlayerMoveEvent event) { + if (event.getPlayer() != player) { + return; + } + Location from = event.getFrom(); + Location to = event.getTo() != null ? event.getTo() : event.getFrom(); + Bukkit.getPluginManager().callEvent(new BmPlayerMovedEvent(game, player, from, to)); + } + + @EventHandler(ignoreCancelled = true, priority = EventPriority.HIGH) + public void onExplosion(io.github.mviper.bomberman.events.BmExplosionEvent event) { + if (event.game != game) { + return; + } + Set blocks = new java.util.HashSet<>(); + for (Explosion.BlockPlan plan : event.igniting) { + blocks.add(plan.block); + } + if (Explosion.isTouching(player, blocks)) { + BmPlayerHitIntent.hit(player, event.cause); + } + } + + @EventHandler(ignoreCancelled = true, priority = EventPriority.HIGH) + public void onPlayerHit(BmPlayerHitIntent event) { + if (event.player != player) { + return; + } + BmPlayerHurtIntent.run(game, player, event.cause); + event.setHandled(); + } + + @EventHandler(ignoreCancelled = true, priority = EventPriority.LOW) + public void onPlayerHurtWithImmunity(BmPlayerHurtIntent event) { + if (event.player != player) { + return; + } + if (immunity) { + event.setCancelled(true); + } + } + + @EventHandler(ignoreCancelled = true, priority = EventPriority.HIGH) + public void onPlayerDamaged(BmPlayerHurtIntent event) { + if (event.player != player) { + return; + } + if (player.getHealth() > 1) { + player.damage(1.0); + immunity = true; + player.setFireTicks(game.getSettings().getImmunityTicks()); + Bukkit.getScheduler().scheduleSyncDelayedTask(PLUGIN, () -> { + immunity = false; + player.setFireTicks(0); + Bukkit.getPluginManager().callEvent(new BmPlayerMovedEvent(game, player, player.getLocation(), player.getLocation())); + }, game.getSettings().getImmunityTicks()); + } else { + BmPlayerKilledIntent.kill(game, player, event.attacker); + } + event.setHandled(); + } + + @EventHandler(ignoreCancelled = true, priority = EventPriority.HIGH) + public void onPlayerKilledInGame(BmPlayerKilledIntent event) { + if (event.player != player) { + return; + } + player.setHealth(0.0); + BmPlayerLeaveGameIntent.leave(player); + event.setHandled(); + } + + @EventHandler(ignoreCancelled = true, priority = EventPriority.HIGH) + public void onPlayerWon(BmPlayerWonEvent event) { + if (event.player != player) { + return; + } + Text.PLAYER_WON.format(new Context(false).plus("player", player)).sendTo(player); + immunity = true; + } + + @EventHandler(ignoreCancelled = true, priority = EventPriority.HIGH) + public void onPlayerLeaveGameEvent(BmPlayerLeaveGameIntent event) { + if (event.player != player) { + return; + } + + player.getWorld().getNearbyEntities(player.getLocation(), 2.0, 3.0, 2.0).stream() + .filter(entity -> entity instanceof Item) + .forEach(entity -> entity.remove()); + + if (player.isDead()) { + HandlerList.unregisterAll(this); + } else { + resetStuffAndUnregister(); + } + event.setHandled(game); + } + + @EventHandler(ignoreCancelled = true, priority = EventPriority.HIGHEST) + public void onGameStopped(BmRunStoppedIntent event) { + if (event.game != game) { + return; + } + BmPlayerLeaveGameIntent.leave(player); + } + + @EventHandler(ignoreCancelled = true, priority = EventPriority.HIGHEST) + public void onGameTerminated(BmGameTerminatedIntent event) { + if (event.game != game) { + return; + } + BmPlayerLeaveGameIntent.leave(player); + } + + @EventHandler(ignoreCancelled = true, priority = EventPriority.LOW) + public void onPlayerLogout(PlayerQuitEvent event) { + if (event.getPlayer() == player) { + BmPlayerLeaveGameIntent.leave(player); + } + } +} diff --git a/src/main/java/io/github/mviper/bomberman/game/GameProtection.java b/src/main/java/io/github/mviper/bomberman/game/GameProtection.java new file mode 100644 index 0000000..3b418e2 --- /dev/null +++ b/src/main/java/io/github/mviper/bomberman/game/GameProtection.java @@ -0,0 +1,65 @@ +package io.github.mviper.bomberman.game; + +import io.github.mviper.bomberman.Bomberman; +import io.github.mviper.bomberman.events.BmRunStoppedIntent; +import io.github.mviper.bomberman.utils.Box; +import org.bukkit.Bukkit; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.HandlerList; +import org.bukkit.event.Listener; +import org.bukkit.event.block.BlockBurnEvent; +import org.bukkit.event.block.BlockIgniteEvent; +import org.bukkit.event.block.BlockSpreadEvent; +import org.bukkit.plugin.Plugin; + +/** + * Protects an arena from getting damaged from the game. + * + * It is up to Server Owners to protect the arena from griefers. + */ +public class GameProtection implements Listener { + private static final Plugin PLUGIN = Bomberman.instance; + + private final Game game; + private final Box bounds; + + private GameProtection(Game game, Box bounds) { + this.game = game; + this.bounds = bounds; + } + + @EventHandler(ignoreCancelled = true, priority = EventPriority.HIGH) + public void onRunStopped(BmRunStoppedIntent event) { + if (event.game == game) { + HandlerList.unregisterAll(this); + } + } + + @EventHandler(ignoreCancelled = true, priority = EventPriority.LOWEST) + public void onBlockBurn(BlockBurnEvent event) { + if (bounds.contains(event.getBlock().getLocation())) { + event.setCancelled(true); + } + } + + @EventHandler(ignoreCancelled = true, priority = EventPriority.LOWEST) + public void onBlockIgnite(BlockIgniteEvent event) { + if (event.getCause() == BlockIgniteEvent.IgniteCause.SPREAD + && bounds.contains(event.getBlock().getLocation())) { + event.setCancelled(true); + } + } + + @EventHandler(ignoreCancelled = true, priority = EventPriority.LOW) + public void onFireSpread(BlockSpreadEvent event) { + if (bounds.contains(event.getBlock().getLocation())) { + event.setCancelled(true); + } + } + + public static void protect(Game game, Box bounds) { + GameProtection protection = new GameProtection(game, bounds); + Bukkit.getPluginManager().registerEvents(protection, PLUGIN); + } +} diff --git a/src/main/java/io/github/mviper/bomberman/game/GameSave.java b/src/main/java/io/github/mviper/bomberman/game/GameSave.java new file mode 100644 index 0000000..37b89bd --- /dev/null +++ b/src/main/java/io/github/mviper/bomberman/game/GameSave.java @@ -0,0 +1,255 @@ +package io.github.mviper.bomberman.game; + +import com.sk89q.worldedit.extent.clipboard.Clipboard; +import com.sk89q.worldedit.extent.clipboard.io.BuiltInClipboardFormat; +import com.sk89q.worldedit.extent.clipboard.io.ClipboardFormat; +import com.sk89q.worldedit.extent.clipboard.io.ClipboardFormats; +import io.github.mviper.bomberman.Bomberman; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.configuration.file.YamlConfiguration; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.lang.ref.WeakReference; +import java.net.URI; +import java.nio.file.DirectoryStream; +import java.nio.file.FileSystem; +import java.nio.file.FileSystems; +import java.nio.file.Files; +import java.nio.file.NoSuchFileException; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +import java.util.logging.Level; + +/** + * Handles reading/writing a game's data to disk. + */ +public class GameSave { + private static final Bomberman PLUGIN = Bomberman.instance; + + public final String name; + public final Location origin; + private final Path zipPath; + + private WeakReference schematicCache; + private GameSettings settingsCache; + + private GameSave(String name, Location origin, Path zipPath) { + this.name = name; + this.origin = origin; + this.zipPath = zipPath; + } + + public static GameSave createNewSave(String name, Location origin, GameSettings settings, Clipboard schematic) { + Path zipPath = PLUGIN.gameSaves().resolve(sanitize(name + ".game.zip")); + URI fileUri = zipPath.toUri(); + URI zipUri = URI.create("jar:" + fileUri); + Map env = new HashMap<>(); + if (!Files.exists(zipPath)) { + env.put("create", "true"); + } + try (FileSystem fs = FileSystems.newFileSystem(zipUri, env)) { + Path arenaPath = fs.getPath("arena.schem"); + try (var os = Files.newOutputStream(arenaPath)) { + try (var writer = BuiltInClipboardFormat.SPONGE_SCHEMATIC.getWriter(os)) { + writer.write(schematic); + } + } + + Path settingsPath = fs.getPath("settings.yml"); + YamlConfiguration settingsYml = new YamlConfiguration(); + settingsYml.set("settings", settings); + Files.write(settingsPath, java.util.List.of(settingsYml.saveToString())); + + Path configPath = fs.getPath("config.yml"); + YamlConfiguration configYml = new YamlConfiguration(); + configYml.set("name", name); + configYml.set("origin", origin); + Files.write(configPath, java.util.List.of(configYml.saveToString())); + + Path readmePath = fs.getPath("README.txt"); + try (var in = PLUGIN.getResource("zip README.txt")) { + if (in != null) { + Files.copy(in, readmePath, StandardCopyOption.REPLACE_EXISTING); + } + } + } catch (IOException e) { + throw new RuntimeException("Failed to create save file", e); + } + + GameSave save = new GameSave(name, origin, zipPath); + save.schematicCache = new WeakReference<>(schematic); + save.settingsCache = settings; + return save; + } + + public static void loadGames() { + Path data = PLUGIN.gameSaves(); + try (DirectoryStream files = Files.newDirectoryStream(data, "*.game.zip")) { + for (Path file : files) { + try { + loadGame(file); + } catch (Exception e) { + PLUGIN.getLogger().log(Level.WARNING, "Exception occurred while loading: " + file, e); + } + } + } catch (IOException e) { + PLUGIN.getLogger().log(Level.WARNING, "Exception occurred while listing saves", e); + } + } + + public static GameSave loadSave(Path zipFile) throws IOException { + PLUGIN.getLogger().info("Reading " + zipFile); + try (FileSystem fs = FileSystems.newFileSystem(zipFile, (ClassLoader) null)) { + Path configPath = fs.getPath("config.yml"); + try (var reader = Files.newBufferedReader(configPath)) { + YamlConfiguration config = YamlConfiguration.loadConfiguration(reader); + String name = config.getString("name"); + Location origin = config.getSerializable("origin", Location.class); + if (name == null || origin == null) { + throw new IOException("Cannot read 'config.yml' in '" + zipFile + "'"); + } + PLUGIN.getLogger().info(" Data read"); + return new GameSave(name, origin, zipFile); + } + } + } + + public static Game loadGame(Path zipFile) throws IOException { + return new Game(loadSave(zipFile)); + } + + public static void updatePre080Saves() { + try (DirectoryStream files = Files.newDirectoryStream(PLUGIN.gameSaves(), "*.yml")) { + for (Path file : files) { + try { + PLUGIN.getLogger().info("Updating old save: " + file); + + YamlConfiguration config; + try (var reader = Files.newBufferedReader(file)) { + config = YamlConfiguration.loadConfiguration(reader); + } + + String name = config.getString("name"); + String schema = config.getString("schema"); + Location origin = config.getSerializable("origin", Location.class); + GameSettings settings = config.getSerializable("settings", GameSettings.class); + if (settings == null) { + settings = new GameSettingsBuilder().build(); + } + + GameSettingsBuilder builder = new GameSettingsBuilder(settings); + if (config.getBoolean("build-flags.skip-air", false)) { + builder.sourceMask = Set.of(Material.AIR); + } else { + builder.sourceMask = Set.of(); + } + settings = builder.build(); + + if (name == null || schema == null || origin == null) { + PLUGIN.getLogger().info(" Skipping update as file missing data"); + continue; + } + + File schemaFile = new File(schema); + PLUGIN.getLogger().info(" Loading schematic: " + schemaFile.getPath()); + ClipboardFormat format = ClipboardFormats.findByFile(schemaFile); + if (format == null) { + throw new IllegalArgumentException("Unknown file format: '" + schemaFile.getPath() + "'"); + } + Clipboard clipboard; + try (var input = new FileInputStream(schemaFile)) { + clipboard = format.getReader(input).read(); + } + + createNewSave(name, origin, settings, clipboard); + Files.deleteIfExists(file); + PLUGIN.getLogger().info(" Save Updated"); + } catch (Exception e) { + PLUGIN.getLogger().log(Level.WARNING, "Exception occurred while updating " + file, e); + } + } + } catch (IOException e) { + PLUGIN.getLogger().log(Level.WARNING, "Exception occurred while listing old saves", e); + } + } + + public static String sanitize(String filename) { + return filename.toLowerCase().replaceAll("[^a-z0-9._-]", "_"); + } + + public Clipboard getSchematic() throws NoSuchFileException { + Clipboard cached = schematicCache != null ? schematicCache.get() : null; + if (cached != null) { + return cached; + } + + PLUGIN.getLogger().info("Reading schematic data: " + zipPath.getFileName()); + try (FileSystem fs = FileSystems.newFileSystem(zipPath, (ClassLoader) null)) { + Path arenaPath = fs.getPath("arena.schem"); + Clipboard schematic; + try (var reader = Files.newInputStream(arenaPath)) { + schematic = BuiltInClipboardFormat.SPONGE_SCHEMATIC.getReader(reader).read(); + } + PLUGIN.getLogger().info("Data read"); + schematicCache = new WeakReference<>(schematic); + return schematic; + } catch (IOException e) { + if (e instanceof NoSuchFileException) { + throw (NoSuchFileException) e; + } + throw new RuntimeException("Failed to read schematic", e); + } + } + + public GameSettings getSettings() throws NoSuchFileException { + if (settingsCache != null) { + return settingsCache; + } + + PLUGIN.getLogger().info("Reading game settings: " + zipPath.getFileName()); + try (FileSystem fs = FileSystems.newFileSystem(zipPath, (ClassLoader) null)) { + Path settingsPath = fs.getPath("settings.yml"); + try (var reader = Files.newBufferedReader(settingsPath)) { + YamlConfiguration settingsYml = YamlConfiguration.loadConfiguration(reader); + GameSettings settings = settingsYml.getSerializable("settings", GameSettings.class); + if (settings == null) { + settings = new GameSettingsBuilder().build(); + } + settingsCache = settings; + PLUGIN.getLogger().info("Data read"); + return settings; + } + } catch (IOException e) { + if (e instanceof NoSuchFileException) { + throw (NoSuchFileException) e; + } + throw new RuntimeException("Failed to read game settings", e); + } + } + + public void updateSettings(GameSettings settings) throws IOException { + settingsCache = null; + + YamlConfiguration yml = new YamlConfiguration(); + yml.set("settings", settings); + String ymlString = yml.saveToString(); + + try (FileSystem fs = FileSystems.newFileSystem(zipPath, (ClassLoader) null)) { + Path zipConfigPath = fs.getPath("settings.yml"); + Files.copy( + new ByteArrayInputStream(ymlString.getBytes()), + zipConfigPath, + StandardCopyOption.REPLACE_EXISTING + ); + } + + settingsCache = settings; + } +} diff --git a/src/main/java/io/github/mviper/bomberman/game/GameSettings.java b/src/main/java/io/github/mviper/bomberman/game/GameSettings.java new file mode 100644 index 0000000..4574ad9 --- /dev/null +++ b/src/main/java/io/github/mviper/bomberman/game/GameSettings.java @@ -0,0 +1,426 @@ +package io.github.mviper.bomberman.game; + +import io.github.mviper.bomberman.Bomberman; +import io.github.mviper.bomberman.utils.RefectAccess; +import org.bukkit.Material; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.configuration.serialization.ConfigurationSerializable; +import org.bukkit.inventory.ItemStack; + +import java.io.InputStreamReader; +import java.io.Reader; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +/** + * Defines the settings for how a game operates. Class is immutable. + */ +public class GameSettings implements ConfigurationSerializable { + private final Material bombItem; + private final Material powerItem; + private final Material fireType; + private final Map> blockLoot; + private final Set destructible; + private final Set indestructible; + private final Set passKeep; + private final Set passDestroy; + private final List initialItems; + private final int lives; + private final int fuseTicks; + private final int fireTicks; + private final int immunityTicks; + private final Map> damageSources; + private final Set sourceMask; + private final Material cageBlock; + + private static volatile boolean loadingDefault = false; + private static volatile GameSettings defaultSettings; + + public GameSettings( + Material bombItem, + Material powerItem, + Material fireType, + Map> blockLoot, + Set destructible, + Set indestructible, + Set passKeep, + Set passDestroy, + List initialItems, + int lives, + int fuseTicks, + int fireTicks, + int immunityTicks, + Map> damageSources, + Set sourceMask, + Material cageBlock + ) { + this.bombItem = bombItem; + this.powerItem = powerItem; + this.fireType = fireType; + this.blockLoot = blockLoot; + this.destructible = destructible; + this.indestructible = indestructible; + this.passKeep = passKeep; + this.passDestroy = passDestroy; + this.initialItems = initialItems; + this.lives = lives; + this.fuseTicks = fuseTicks; + this.fireTicks = fireTicks; + this.immunityTicks = immunityTicks; + this.damageSources = damageSources; + this.sourceMask = sourceMask; + this.cageBlock = cageBlock; + } + + public Material getBombItem() { + return bombItem; + } + + public Material getPowerItem() { + return powerItem; + } + + public Material getFireType() { + return fireType; + } + + public Map> getBlockLoot() { + return blockLoot; + } + + public Set getDestructible() { + return destructible; + } + + public Set getIndestructible() { + return indestructible; + } + + public Set getPassKeep() { + return passKeep; + } + + public Set getPassDestroy() { + return passDestroy; + } + + public List getInitialItems() { + return initialItems; + } + + public int getLives() { + return lives; + } + + public int getFuseTicks() { + return fuseTicks; + } + + public int getFireTicks() { + return fireTicks; + } + + public int getImmunityTicks() { + return immunityTicks; + } + + public Map> getDamageSources() { + return damageSources; + } + + public Set getSourceMask() { + return sourceMask; + } + + public Material getCageBlock() { + return cageBlock; + } + + @RefectAccess + public static GameSettings deserialize(Map data) { + GameSettings defaults = loadingDefault ? null : getDefaultSettings(); + + Material bombItem = readMaterial(data.get("bomb"), defaults != null ? defaults.bombItem : null); + Material powerItem = readMaterial(data.get("power"), defaults != null ? defaults.powerItem : null); + Material fireType = readMaterial(data.get("fire"), defaults != null ? defaults.fireType : null); + + Map> blockLoot = readLootTable(data.get("loot-table")); + if (blockLoot == null && defaults != null) { + blockLoot = defaults.blockLoot; + } + + Set destructible = readMaterials(data.get("destructible")); + if (destructible == null && defaults != null) { + destructible = defaults.destructible; + } + + Set indestructible = readMaterials(data.get("indestructible")); + if (indestructible == null && defaults != null) { + indestructible = defaults.indestructible; + } + + Set passKeep = readMaterials(data.get("pass-keep")); + if (passKeep == null && defaults != null) { + passKeep = defaults.passKeep; + } + + Set passDestroy = readMaterials(data.get("pass-destroy")); + if (passDestroy == null && defaults != null) { + passDestroy = defaults.passDestroy; + } + + List initialItems = readItemList(data.get("initial-items")); + if (initialItems == null && defaults != null) { + initialItems = defaults.initialItems; + } + + int lives = readInt(data.get("lives"), defaults != null ? defaults.lives : 0); + int fuseTicks = Math.max(0, readInt(data.get("fuse-ticks"), defaults != null ? defaults.fuseTicks : 0)); + int fireTicks = Math.max(0, readInt(data.get("fire-ticks"), defaults != null ? defaults.fireTicks : 0)); + int immunityTicks = Math.max(0, readInt(data.get("immunity-ticks"), defaults != null ? defaults.immunityTicks : 0)); + + Map> damageSources = readDamageSources(data.get("damage-source")); + if (damageSources == null && defaults != null) { + damageSources = defaults.damageSources; + } + + Set sourceMask = readMaterials(data.get("source-mask")); + if (sourceMask == null && defaults != null) { + sourceMask = defaults.sourceMask; + } + + Material cageBlock = readMaterial(data.get("cage-block"), defaults != null ? defaults.cageBlock : null); + + return new GameSettings( + bombItem, + powerItem, + fireType, + blockLoot, + destructible, + indestructible, + passKeep, + passDestroy, + initialItems, + lives, + fuseTicks, + fireTicks, + immunityTicks, + damageSources, + sourceMask, + cageBlock + ); + } + + private static int readInt(Object value, int fallback) { + if (value instanceof Number) { + return ((Number) value).intValue(); + } + return fallback; + } + + private static Material readMaterial(Object value, Material fallback) { + if (value instanceof String) { + Material material = Material.matchMaterial((String) value); + if (material != null) { + return material; + } + } + return fallback; + } + + private static Set readMaterials(Object value) { + if (!(value instanceof List)) { + return null; + } + Set result = new HashSet<>(); + for (Object item : (List) value) { + if (item instanceof String) { + Material material = Material.matchMaterial((String) item); + if (material != null) { + result.add(material); + } + } + } + return result; + } + + private static List readItemList(Object value) { + if (!(value instanceof List)) { + return null; + } + List result = new ArrayList<>(); + for (Object item : (List) value) { + if (item instanceof ItemStack) { + result.add((ItemStack) item); + } else { + result.add(null); + } + } + return result; + } + + @SuppressWarnings("unchecked") + private static Map> readLootTable(Object value) { + if (!(value instanceof List)) { + return null; + } + Map> result = new HashMap<>(); + for (Object sectionObj : (List) value) { + if (!(sectionObj instanceof Map)) { + continue; + } + Map section = (Map) sectionObj; + List blocksRaw = (List) section.get("blocks"); + List lootRaw = (List) section.get("loot"); + if (blocksRaw == null || lootRaw == null) { + continue; + } + + List blocks = new ArrayList<>(); + for (Object blockObj : blocksRaw) { + if (blockObj instanceof String) { + Material material = Material.matchMaterial((String) blockObj); + if (material != null) { + blocks.add(material); + } + } + } + + Map loot = new HashMap<>(); + for (Object lootObj : lootRaw) { + if (!(lootObj instanceof Map)) { + continue; + } + Map lootEntry = (Map) lootObj; + Object weightObj = lootEntry.get("weight"); + Object itemObj = lootEntry.get("item"); + if (weightObj instanceof Number && itemObj instanceof ItemStack) { + int weight = ((Number) weightObj).intValue(); + if (weight > 0) { + loot.put((ItemStack) itemObj, weight); + } + } + } + + if (!blocks.isEmpty() && !loot.isEmpty()) { + for (Material block : blocks) { + result.put(block, loot); + } + } + } + return result; + } + + @SuppressWarnings("unchecked") + private static Map> readDamageSources(Object value) { + if (!(value instanceof Map)) { + return null; + } + Map> result = new HashMap<>(); + for (Map.Entry entry : ((Map) value).entrySet()) { + String key = Objects.toString(entry.getKey()); + Object cause = entry.getValue(); + if (cause instanceof Map) { + Map nested = new HashMap<>(); + for (Map.Entry inner : ((Map) cause).entrySet()) { + nested.put(Objects.toString(inner.getKey()), Objects.toString(inner.getValue())); + } + result.put(key, nested); + } else if (cause == null) { + result.put(key, new HashMap<>()); + } else { + result.put(key, Map.of("base", Objects.toString(cause))); + } + } + return result; + } + + @Override + public Map serialize() { + Map objs = new HashMap<>(); + objs.put("bomb", bombItem.getKey().toString()); + objs.put("power", powerItem.getKey().toString()); + objs.put("fire", fireType.getKey().toString()); + + Map, Set> lootBlock = new HashMap<>(); + for (Map.Entry> entry : blockLoot.entrySet()) { + lootBlock.computeIfAbsent(entry.getValue(), key -> new HashSet<>()).add(entry.getKey()); + } + List> lootTable = new ArrayList<>(); + for (Map.Entry, Set> entry : lootBlock.entrySet()) { + Map section = new HashMap<>(); + List blocks = new ArrayList<>(); + for (Material material : entry.getValue()) { + blocks.add(material.getKey().toString()); + } + List> loot = new ArrayList<>(); + for (Map.Entry lootEntry : entry.getKey().entrySet()) { + Map lootItem = new HashMap<>(); + lootItem.put("item", lootEntry.getKey()); + lootItem.put("weight", lootEntry.getValue()); + loot.add(lootItem); + } + section.put("blocks", blocks); + section.put("loot", loot); + lootTable.add(section); + } + objs.put("loot-table", lootTable); + objs.put("destructible", destructible.stream().map(material -> material.getKey().toString()).toList()); + objs.put("indestructible", indestructible.stream().map(material -> material.getKey().toString()).toList()); + objs.put("pass-keep", passKeep.stream().map(material -> material.getKey().toString()).toList()); + objs.put("pass-destroy", passDestroy.stream().map(material -> material.getKey().toString()).toList()); + objs.put("initial-items", trimTrailingNulls(initialItems)); + objs.put("lives", lives); + objs.put("fuse-ticks", fuseTicks); + objs.put("fire-ticks", fireTicks); + objs.put("immunity-ticks", immunityTicks); + objs.put("damage-source", damageSources); + objs.put("source-mask", sourceMask.stream().map(material -> material.getKey().toString()).toList()); + objs.put("cage-block", cageBlock.getKey().toString()); + + return objs; + } + + private static List trimTrailingNulls(List items) { + List result = new ArrayList<>(items); + for (int i = result.size() - 1; i >= 0; i--) { + if (result.get(i) != null) { + break; + } + result.remove(i); + } + return result; + } + + public static GameSettings getDefaultSettings() { + if (defaultSettings == null) { + synchronized (GameSettings.class) { + if (defaultSettings == null) { + defaultSettings = loadDefaultSettings(); + } + } + } + return defaultSettings; + } + + private static GameSettings loadDefaultSettings() { + try (Reader reader = new InputStreamReader( + Objects.requireNonNull(Bomberman.instance.getResource("games/README.yml")) + )) { + loadingDefault = true; + GameSettings result = YamlConfiguration.loadConfiguration(reader) + .getSerializable("settings", GameSettings.class); + loadingDefault = false; + return Objects.requireNonNull(result); + } catch (Exception e) { + loadingDefault = false; + throw new RuntimeException("Failed to load default settings", e); + } + } +} diff --git a/src/main/java/io/github/mviper/bomberman/game/GameSettingsBuilder.java b/src/main/java/io/github/mviper/bomberman/game/GameSettingsBuilder.java new file mode 100644 index 0000000..05625bf --- /dev/null +++ b/src/main/java/io/github/mviper/bomberman/game/GameSettingsBuilder.java @@ -0,0 +1,71 @@ +package io.github.mviper.bomberman.game; + +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +public class GameSettingsBuilder { + public Material bombItem; + public Material powerItem; + public Material fireType; + public Map> blockLoot; + public Set destructible; + public Set indestructible; + public Set passKeep; + public Set passDestroy; + public List initialItems; + public int lives; + public int fuseTicks; + public int fireTicks; + public int immunityTicks; + public Map> damageSources; + public Set sourceMask; + public Material cageBlock; + + public GameSettingsBuilder() { + this(GameSettings.getDefaultSettings()); + } + + public GameSettingsBuilder(GameSettings settings) { + this.bombItem = settings.getBombItem(); + this.powerItem = settings.getPowerItem(); + this.fireType = settings.getFireType(); + this.blockLoot = settings.getBlockLoot(); + this.destructible = settings.getDestructible(); + this.indestructible = settings.getIndestructible(); + this.passKeep = settings.getPassKeep(); + this.passDestroy = settings.getPassDestroy(); + this.initialItems = settings.getInitialItems(); + this.lives = settings.getLives(); + this.fuseTicks = settings.getFuseTicks(); + this.fireTicks = settings.getFireTicks(); + this.immunityTicks = settings.getImmunityTicks(); + this.damageSources = settings.getDamageSources(); + this.sourceMask = settings.getSourceMask(); + this.cageBlock = settings.getCageBlock(); + } + + public GameSettings build() { + return new GameSettings( + bombItem, + powerItem, + fireType, + blockLoot, + destructible, + indestructible, + passKeep, + passDestroy, + initialItems, + lives, + fuseTicks, + fireTicks, + immunityTicks, + damageSources, + sourceMask, + cageBlock + ); + } +} diff --git a/src/main/java/io/github/mviper/bomberman/game/StartTimer.java b/src/main/java/io/github/mviper/bomberman/game/StartTimer.java new file mode 100644 index 0000000..bbc5554 --- /dev/null +++ b/src/main/java/io/github/mviper/bomberman/game/StartTimer.java @@ -0,0 +1,81 @@ +package io.github.mviper.bomberman.game; + +import io.github.mviper.bomberman.Bomberman; +import io.github.mviper.bomberman.events.BmRunStartCountDownIntent; +import io.github.mviper.bomberman.events.BmRunStartedIntent; +import io.github.mviper.bomberman.events.BmRunStoppedIntent; +import io.github.mviper.bomberman.events.BmTimerCountedEvent; +import io.github.mviper.bomberman.messaging.Context; +import io.github.mviper.bomberman.messaging.Text; +import org.bukkit.Bukkit; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.HandlerList; +import org.bukkit.event.Listener; + +public class StartTimer implements Runnable, Listener { + private static final Bomberman PLUGIN = Bomberman.instance; + + public static void createTimer(Game game, int time) { + Bukkit.getPluginManager().registerEvents(new StartTimer(game, time), PLUGIN); + } + + private final Game game; + private int time; + private boolean killed = false; + private final int taskId; + + private StartTimer(Game game, int time) { + this.game = game; + this.time = time; + this.taskId = PLUGIN.getServer().getScheduler().scheduleSyncRepeatingTask(PLUGIN, this, 1, 20); + } + + @Override + public void run() { + if (killed) { + return; + } + + BmTimerCountedEvent event = new BmTimerCountedEvent(game, time); + Bukkit.getPluginManager().callEvent(event); + time = event.count; + if (time > 0) { + time--; + } else { + BmRunStartedIntent.startRun(game); + killSelf(); + } + } + + private void killSelf() { + killed = true; + Bukkit.getScheduler().cancelTask(taskId); + HandlerList.unregisterAll(this); + } + + @EventHandler(ignoreCancelled = true, priority = EventPriority.LOW) + public void onTimerStarted(BmRunStartCountDownIntent event) { + if (event.game != game) { + return; + } + if (event.override) { + killSelf(); + } else { + event.cancelBecause(Text.GAME_ALREADY_COUNTING.format(new Context(false) + .plus("game", game) + .plus("time", time))); + } + } + + @EventHandler(ignoreCancelled = true, priority = EventPriority.LOW) + public void onGameStop(BmRunStoppedIntent event) { + if (event.game != game) { + return; + } + killSelf(); + event.cancelFor(Text.STOP_TIMER_STOPPED.format(new Context(false) + .plus("time", time) + .plus("game", game))); + } +} diff --git a/src/main/java/io/github/mviper/bomberman/messaging/AdditionalArgs.java b/src/main/java/io/github/mviper/bomberman/messaging/AdditionalArgs.java new file mode 100644 index 0000000..944234d --- /dev/null +++ b/src/main/java/io/github/mviper/bomberman/messaging/AdditionalArgs.java @@ -0,0 +1,23 @@ +package io.github.mviper.bomberman.messaging; + +import java.util.Collections; +import java.util.List; +import java.util.function.Function; + +public class AdditionalArgs implements Formattable { + private final Function, Formattable> function; + + public AdditionalArgs(Function, Formattable> function) { + this.function = function; + } + + @Override + public Formattable applyModifier(Message arg) { + return new ExtraArgsHolder((context, args) -> function.apply(args), arg); + } + + @Override + public Message format(Context context) { + return function.apply(Collections.emptyList()).format(context); + } +} diff --git a/src/main/java/io/github/mviper/bomberman/messaging/CollectionWrapper.java b/src/main/java/io/github/mviper/bomberman/messaging/CollectionWrapper.java new file mode 100644 index 0000000..9d2c909 --- /dev/null +++ b/src/main/java/io/github/mviper/bomberman/messaging/CollectionWrapper.java @@ -0,0 +1,129 @@ +package io.github.mviper.bomberman.messaging; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Comparator; +import java.util.List; + +public class CollectionWrapper implements Formattable { + private final Collection list; + private final Formattable delegate; + + public CollectionWrapper(Collection list) { + this.list = list; + this.delegate = new DefaultArg("length", argProperty -> { + String option = argProperty.toString().toLowerCase(); + switch (option) { + case "length": + return Message.of(list.size()); + case "map": + return new RequiredArg(argMapper -> new ContextArg(context -> { + String mapper = argMapper.toString(); + List newList = new ArrayList<>(); + int i = 0; + for (T item : list) { + newList.add(Expander.expand( + mapper, + context.plus("it", item).plus("index", i) + )); + i++; + } + return new CollectionWrapper<>(newList); + })); + case "join": + return new DefaultArg("", separator -> new ContextArg(context -> { + List formatted = new ArrayList<>(); + for (T item : list) { + formatted.add(item.format(context)); + } + if (formatted.isEmpty()) { + formatted.add(Message.empty); + } + Message result = formatted.get(0); + for (int i = 1; i < formatted.size(); i++) { + result = result.append(separator).append(formatted.get(i)); + } + return result; + })); + case "foreach": + return new DefaultArg("({index}: {it})", argMapper -> new DefaultArg(" ", argSeparator -> new ContextArg(context -> { + String mapper = argMapper.toString(); + List mapped = new ArrayList<>(); + int i = 0; + for (T item : list) { + mapped.add(Expander.expand( + mapper, + context.plus("it", item).plus("index", i) + )); + i++; + } + if (mapped.isEmpty()) { + mapped.add(Message.empty); + } + Message result = mapped.get(0); + for (int j = 1; j < mapped.size(); j++) { + result = result.append(argSeparator).append(mapped.get(j)); + } + return result; + }))); + case "sort": + return new DefaultArg("{it}", argMapper -> new ContextArg(context -> { + String mapper = argMapper.toString(); + List> indexed = new ArrayList<>(); + int i = 0; + for (T item : list) { + indexed.add(new Indexed<>(i, item)); + i++; + } + indexed.sort(Comparator.comparing(entry -> Expander.expand( + mapper, + context.plus("it", entry.value).plus("index", Message.of(entry.index)) + ).toString())); + List sorted = new ArrayList<>(); + for (Indexed entry : indexed) { + sorted.add(entry.value); + } + return new CollectionWrapper<>(sorted); + })); + case "filter": + return new RequiredArg(argFilter -> new ContextArg(context -> { + List filtered = new ArrayList<>(); + int i = 0; + for (T item : list) { + String filterOutput = Expander.expand( + argFilter.toString(), + context.plus("it", item).plus("index", Message.of(i)) + ).toString(); + if (!filterOutput.isBlank() && !"0".equals(filterOutput) && !"0.0".equals(filterOutput)) { + filtered.add(item); + } + i++; + } + return new CollectionWrapper<>(filtered); + })); + default: + throw new IllegalArgumentException("Unknown list option: " + argProperty); + } + }); + } + + @Override + public Formattable applyModifier(Message arg) { + return delegate.applyModifier(arg); + } + + @Override + public Message format(Context context) { + return delegate.format(context); + } + + private static final class Indexed { + private final int index; + private final T value; + + private Indexed(int index, T value) { + this.index = index; + this.value = value; + } + } +} diff --git a/src/main/java/io/github/mviper/bomberman/messaging/ColorWrapper.java b/src/main/java/io/github/mviper/bomberman/messaging/ColorWrapper.java new file mode 100644 index 0000000..60972db --- /dev/null +++ b/src/main/java/io/github/mviper/bomberman/messaging/ColorWrapper.java @@ -0,0 +1,21 @@ +package io.github.mviper.bomberman.messaging; + +import org.bukkit.ChatColor; + +public class ColorWrapper implements Formattable { + private final Formattable delegate; + + public ColorWrapper(ChatColor color) { + this.delegate = new RequiredArg(arg -> arg.color(color)); + } + + @Override + public Formattable applyModifier(Message arg) { + return delegate.applyModifier(arg); + } + + @Override + public Message format(Context context) { + return delegate.format(context); + } +} diff --git a/src/main/java/io/github/mviper/bomberman/messaging/Context.java b/src/main/java/io/github/mviper/bomberman/messaging/Context.java new file mode 100644 index 0000000..4d316ec --- /dev/null +++ b/src/main/java/io/github/mviper/bomberman/messaging/Context.java @@ -0,0 +1,95 @@ +package io.github.mviper.bomberman.messaging; + +import org.bukkit.command.CommandSender; +import org.bukkit.inventory.ItemStack; + +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; + +/** + * An immutable set of things referenced in the current scope. + */ +public class Context { + private final Map objects; + private final Set> functions; + public final boolean elevated; + + public Context() { + this(false); + } + + public Context(boolean elevated) { + this.objects = Collections.emptyMap(); + this.functions = Collections.emptySet(); + this.elevated = elevated; + } + + private Context(Map objects, Set> functions, boolean elevated) { + this.objects = objects; + this.functions = functions; + this.elevated = elevated; + } + + public Formattable get(String key) { + return objects.get(key); + } + + public String getFunction(String key) { + for (Function function : functions) { + String result = function.apply(key); + if (result != null) { + return result; + } + } + return null; + } + + public Context addFunctions(Function function) { + Set> next = new HashSet<>(functions); + next.add(function); + return new Context(objects, next, elevated); + } + + public Context newScope() { + return new Context(Collections.emptyMap(), functions, elevated); + } + + public Context plus(String key, Formattable thing) { + Map next = new HashMap<>(objects); + next.put(key, thing); + return new Context(next, functions, elevated); + } + + public Context plus(Context context) { + Map next = new HashMap<>(objects); + next.putAll(context.objects); + Set> nextFunctions = new HashSet<>(functions); + nextFunctions.addAll(context.functions); + return new Context(next, nextFunctions, elevated || context.elevated); + } + + public Context plus(String key, String value) { + return plus(key, Message.of(value)); + } + + public Context plus(String key, int value) { + return plus(key, Message.of(value)); + } + + public Context plus(String key, Collection value) { + return plus(key, new CollectionWrapper<>(value)); + } + + public Context plus(String key, ItemStack stack) { + return plus(key, new ItemWrapper(stack)); + } + + public Context plus(String key, CommandSender sender) { + return plus(key, new SenderWrapper(sender)); + } +} diff --git a/src/main/java/io/github/mviper/bomberman/messaging/ContextArg.java b/src/main/java/io/github/mviper/bomberman/messaging/ContextArg.java new file mode 100644 index 0000000..c85a5c9 --- /dev/null +++ b/src/main/java/io/github/mviper/bomberman/messaging/ContextArg.java @@ -0,0 +1,28 @@ +package io.github.mviper.bomberman.messaging; + +import java.util.function.Function; + +public class ContextArg implements Formattable { + private final Function function; + + public ContextArg(Function function) { + this.function = function; + } + + @Override + public Formattable applyModifier(Message arg) { + return new ExtraArgsHolder((context, args) -> { + Formattable initial = function.apply(context); + Formattable current = initial; + for (Message message : args) { + current = current.applyModifier(message); + } + return current; + }, arg); + } + + @Override + public Message format(Context context) { + return function.apply(context).format(context); + } +} diff --git a/src/main/java/io/github/mviper/bomberman/messaging/CustomPath.java b/src/main/java/io/github/mviper/bomberman/messaging/CustomPath.java new file mode 100644 index 0000000..55a0fde --- /dev/null +++ b/src/main/java/io/github/mviper/bomberman/messaging/CustomPath.java @@ -0,0 +1,30 @@ +package io.github.mviper.bomberman.messaging; + +import java.util.List; + +public class CustomPath implements Formattable { + private final Formattable delegate = new RequiredArg(functionArg -> + new AdditionalArgs(args -> new ContextArg(context -> { + String function = functionArg.toString(); + String text = context.getFunction(function); + if (text == null) { + return Message.error("{#|" + function + "}"); + } + Context callContext = context.newScope(); + for (int i = 0; i < args.size(); i++) { + callContext = callContext.plus("arg" + i, args.get(i)); + } + return Expander.expand(text, callContext); + })) + ); + + @Override + public Formattable applyModifier(Message arg) { + return delegate.applyModifier(arg); + } + + @Override + public Message format(Context context) { + return delegate.format(context); + } +} diff --git a/src/main/java/io/github/mviper/bomberman/messaging/DefaultArg.java b/src/main/java/io/github/mviper/bomberman/messaging/DefaultArg.java new file mode 100644 index 0000000..d24f0f6 --- /dev/null +++ b/src/main/java/io/github/mviper/bomberman/messaging/DefaultArg.java @@ -0,0 +1,27 @@ +package io.github.mviper.bomberman.messaging; + +import java.util.function.Function; + +public class DefaultArg implements Formattable { + private final Message text; + private final Function function; + + public DefaultArg(Message text, Function function) { + this.text = text; + this.function = function; + } + + public DefaultArg(String text, Function function) { + this(Message.of(text), function); + } + + @Override + public Formattable applyModifier(Message arg) { + return function.apply(arg); + } + + @Override + public Message format(Context context) { + return function.apply(text).format(context); + } +} diff --git a/src/main/java/io/github/mviper/bomberman/messaging/Equation.java b/src/main/java/io/github/mviper/bomberman/messaging/Equation.java new file mode 100644 index 0000000..76cd289 --- /dev/null +++ b/src/main/java/io/github/mviper/bomberman/messaging/Equation.java @@ -0,0 +1,118 @@ +package io.github.mviper.bomberman.messaging; + +import net.objecthunter.exp4j.ExpressionBuilder; +import net.objecthunter.exp4j.function.Function; +import net.objecthunter.exp4j.operator.Operator; + +import java.math.BigDecimal; + +public class Equation implements Formattable { + private static final double EPSILON = 0.00000001d; + + private static final int PRECEDENCE_NOT = Operator.PRECEDENCE_UNARY_MINUS; + private static final int PRECEDENCE_AND = Operator.PRECEDENCE_ADDITION - 10; + private static final int PRECEDENCE_OR = Operator.PRECEDENCE_ADDITION - 20; + private static final int PRECEDENCE_COMPARE = Operator.PRECEDENCE_ADDITION - 100; + private static final int PRECEDENCE_EQUAL = Operator.PRECEDENCE_ADDITION - 1000; + + private static final Operator NOT = new Operator("!", 1, true, PRECEDENCE_NOT) { + @Override + public double apply(double... args) { + return (args[0] > -EPSILON && args[0] < EPSILON) ? 1.0 : 0.0; + } + }; + + private static final Operator OR = new Operator("$", 2, true, PRECEDENCE_OR) { + @Override + public double apply(double... args) { + return (((args[0] < -EPSILON) || (args[0] > EPSILON)) || ((args[1] < -EPSILON) || (args[1] > EPSILON))) ? 1.0 : 0.0; + } + }; + + private static final Operator AND = new Operator("&", 2, true, PRECEDENCE_AND) { + @Override + public double apply(double... args) { + return (((args[0] < -EPSILON) || (args[0] > EPSILON)) && ((args[1] < -EPSILON) || (args[1] > EPSILON))) ? 1.0 : 0.0; + } + }; + + private static final Operator GREATER = new Operator(">", 2, true, PRECEDENCE_COMPARE) { + @Override + public double apply(double... args) { + return (args[0] > args[1] + EPSILON) ? 1.0 : 0.0; + } + }; + + private static final Operator LESSER = new Operator("<", 2, true, PRECEDENCE_COMPARE) { + @Override + public double apply(double... args) { + return (args[0] + EPSILON < args[1]) ? 1.0 : 0.0; + } + }; + + private static final Operator GREATER_EQUAL = new Operator(">=", 2, true, PRECEDENCE_COMPARE) { + @Override + public double apply(double... args) { + return (args[0] + EPSILON >= args[1]) ? 1.0 : 0.0; + } + }; + + private static final Operator LESSER_EQUAL = new Operator("<=", 2, true, PRECEDENCE_COMPARE) { + @Override + public double apply(double... args) { + return (args[0] <= args[1] + EPSILON) ? 1.0 : 0.0; + } + }; + + private static final Operator EQUAL = new Operator("==", 2, true, PRECEDENCE_EQUAL) { + @Override + public double apply(double... args) { + return (args[0] > args[1] - EPSILON && args[0] < args[1] + EPSILON) ? 1.0 : 0.0; + } + }; + + private static final Operator NOT_EQUAL = new Operator("!=", 2, true, PRECEDENCE_EQUAL) { + @Override + public double apply(double... args) { + return (args[0] < args[1] - EPSILON || args[0] > args[1] + EPSILON) ? 1.0 : 0.0; + } + }; + + private static final Function ROUND = new Function("round", 1) { + @Override + public double apply(double... args) { + return Math.rint(args[0]); + } + }; + + private final Formattable delegate = new RequiredArg(argEquation -> { + try { + double answer = new ExpressionBuilder(argEquation.toString()) + .operator(GREATER) + .operator(LESSER) + .operator(GREATER_EQUAL) + .operator(LESSER_EQUAL) + .operator(EQUAL) + .operator(NOT_EQUAL) + .operator(AND) + .operator(OR) + .operator(NOT) + .function(ROUND) + .build() + .evaluate(); + return Message.of(BigDecimal.valueOf(answer).stripTrailingZeros().toPlainString()); + } catch (Exception e) { + return Message.error("{" + argEquation + "}"); + } + }); + + @Override + public Formattable applyModifier(Message arg) { + return delegate.applyModifier(arg); + } + + @Override + public Message format(Context context) { + return delegate.format(context); + } +} diff --git a/src/main/java/io/github/mviper/bomberman/messaging/Execute.java b/src/main/java/io/github/mviper/bomberman/messaging/Execute.java new file mode 100644 index 0000000..6d5a68c --- /dev/null +++ b/src/main/java/io/github/mviper/bomberman/messaging/Execute.java @@ -0,0 +1,24 @@ +package io.github.mviper.bomberman.messaging; + +import org.bukkit.Bukkit; + +public class Execute implements Formattable { + private final Formattable delegate = new RequiredArg(command -> + new ContextArg(context -> { + if (context.elevated) { + Bukkit.getServer().dispatchCommand(Bukkit.getConsoleSender(), command.toString()); + } + return Message.empty; + }) + ); + + @Override + public Formattable applyModifier(Message arg) { + return delegate.applyModifier(arg); + } + + @Override + public Message format(Context context) { + return delegate.format(context); + } +} diff --git a/src/main/java/io/github/mviper/bomberman/messaging/Expander.java b/src/main/java/io/github/mviper/bomberman/messaging/Expander.java new file mode 100644 index 0000000..6e2a5ed --- /dev/null +++ b/src/main/java/io/github/mviper/bomberman/messaging/Expander.java @@ -0,0 +1,173 @@ +package io.github.mviper.bomberman.messaging; + +import org.bukkit.ChatColor; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +public final class Expander { + private static final Map FUNCTIONS; + + static { + Map functions = new HashMap<>(); + functions.put("=", new Equation()); + functions.put("#switch", new Switch()); + functions.put("#title", new TitleExpander()); + functions.put("#raw", Message.rawFlag); + functions.put("#", new CustomPath()); + functions.put("#regex", new RegexExpander()); + functions.put("#len", new LengthExpander()); + functions.put("#sub", new SubstringExpander()); + functions.put("#padl", new PadLeftExpander()); + functions.put("#padr", new PadRightExpander()); + functions.put("#exec", new Execute()); + functions.put("#rand", new RandomExpander()); + for (ChatColor color : ChatColor.values()) { + functions.put("#" + color.name().toLowerCase(Locale.ROOT), new ColorWrapper(color)); + } + FUNCTIONS = Collections.unmodifiableMap(functions); + } + + private Expander() { + } + + /** + * Expands all braces in text. The number of open braces must be balanced with close braces. + * + * @param text the text to expand + * @param context Reference-able things + * @return the expanded text + */ + public static Message expand(String text, Context context) { + Message expanded = Message.empty; + StringBuilder building = new StringBuilder(); + boolean ignoreNextSpecial = false; + int i = 0; + while (i < text.length()) { + char c = text.charAt(i); + if (c == '{' && !ignoreNextSpecial) { + if (building.length() > 0) { + expanded = expanded.append(Message.of(building.toString())); + building.setLength(0); + } + + String subtext = toNext(text, '}', i); + if (subtext == null) { + throw new IllegalArgumentException("Braces unmatched: '" + text + "'"); + } + Message expandedBrace; + if (subtext.startsWith("{!")) { + expandedBrace = Message.of(subtext.replaceFirst("!", "")); + } else { + expandedBrace = expandBrace(subtext, context); + } + expanded = expanded.append(expandedBrace); + i += subtext.length() - 1; + } else if (c == '\\' && !ignoreNextSpecial) { + ignoreNextSpecial = true; + } else { + building.append(c); + ignoreNextSpecial = false; + } + i++; + } + if (building.length() > 0) { + expanded = expanded.append(Message.of(building.toString())); + } + return expanded; + } + + /** + * Expands a brace in a message. + * Each sub brace will be expanded with the same arguments as this message. + * + * @param text the text to expands formatted as "{ key | arg1 | ... | argN }" + * @return the expanded string + */ + private static Message expandBrace(String text, Context context) { + if (text.charAt(0) != '{' || text.charAt(text.length() - 1) != '}') { + throw new RuntimeException("expandBrace() must start and end with a brace"); + } + + String keyString = toNext(text, '|', 1); + if (keyString == null) { + throw new RuntimeException("Text bad: '" + text + "'"); + } + + AtomicInteger index = new AtomicInteger(keyString.length()); + java.util.List args = new java.util.ArrayList<>(); + while (true) { + String subArg = toNext(text, '|', index.get()); + if (subArg == null) { + break; + } + args.add(Message.lazyExpand(subArg.substring(1, subArg.length() - 1), context)); + index.addAndGet(subArg.length() - 1); + } + + keyString = keyString.substring(0, keyString.length() - 1).trim().toLowerCase(Locale.ROOT); + + if ("#exec".equals(keyString) && !context.elevated) { + return Message.empty; + } + + boolean reference = keyString.startsWith("@"); + if (reference) { + keyString = keyString.substring(1); + } + + Formattable thing = context.get(keyString); + if (thing == null) { + thing = FUNCTIONS.get(keyString); + } + if (thing == null) { + return Message.error(text); + } + + Formattable modified = thing; + for (Message arg : args) { + modified = modified.applyModifier(arg); + } + + if (reference) { + return Message.reference(modified, context); + } + return modified.format(context); + } + + /** + * Gets the substring of sequence from index to the next endingChar but takes into account brace skipping. + * The returned string will include both the start and end characters. If a closing brace + * is found before the wanted character, then the remaining to that brace is returned. If the end of sequence is + * reached, then null is returned. + */ + private static String toNext(String sequence, char endingChar, int startIndex) { + int size = sequence.length(); + int openBracesFound = 0; + boolean ignoreNextSpecial = false; + for (int i = startIndex + 1; i < size; i++) { + if (ignoreNextSpecial) { + ignoreNextSpecial = false; + } else { + char c = sequence.charAt(i); + if (c == endingChar && openBracesFound == 0) { + return sequence.substring(startIndex, i + 1); + } + if (c == '{') { + openBracesFound++; + } else if (c == '}') { + openBracesFound--; + if (openBracesFound < 0) { + return sequence.substring(startIndex, i + 1); + } + } else if (c == '\\') { + ignoreNextSpecial = true; + } + } + } + return null; + } +} diff --git a/src/main/java/io/github/mviper/bomberman/messaging/ExtraArgsHolder.java b/src/main/java/io/github/mviper/bomberman/messaging/ExtraArgsHolder.java new file mode 100644 index 0000000..97c1e19 --- /dev/null +++ b/src/main/java/io/github/mviper/bomberman/messaging/ExtraArgsHolder.java @@ -0,0 +1,28 @@ +package io.github.mviper.bomberman.messaging; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.function.BiFunction; + +class ExtraArgsHolder implements Formattable { + private final BiFunction, Formattable> callback; + private final List extraArgs; + + ExtraArgsHolder(BiFunction, Formattable> callback, Message... args) { + this.callback = callback; + this.extraArgs = new ArrayList<>(Arrays.asList(args)); + } + + @Override + public Formattable applyModifier(Message arg) { + List next = new ArrayList<>(extraArgs); + next.add(arg); + return new ExtraArgsHolder(callback, next.toArray(new Message[0])); + } + + @Override + public Message format(Context context) { + return callback.apply(context, extraArgs).format(context); + } +} diff --git a/src/main/java/io/github/mviper/bomberman/messaging/Formattable.java b/src/main/java/io/github/mviper/bomberman/messaging/Formattable.java new file mode 100644 index 0000000..d1a16e4 --- /dev/null +++ b/src/main/java/io/github/mviper/bomberman/messaging/Formattable.java @@ -0,0 +1,21 @@ +package io.github.mviper.bomberman.messaging; + +public interface Formattable { + /** + * Applies a modifier to this object. + * + * @param arg the value of the modifier + */ + Formattable applyModifier(Message arg); + + default Formattable applyModifier(String arg) { + return applyModifier(Message.of(arg)); + } + + /** + * Formats this custom object into "simple" text. + * + * @param context additional variables in scope + */ + Message format(Context context); +} diff --git a/src/main/java/io/github/mviper/bomberman/messaging/ItemWrapper.java b/src/main/java/io/github/mviper/bomberman/messaging/ItemWrapper.java new file mode 100644 index 0000000..c985ecd --- /dev/null +++ b/src/main/java/io/github/mviper/bomberman/messaging/ItemWrapper.java @@ -0,0 +1,29 @@ +package io.github.mviper.bomberman.messaging; + +import org.bukkit.inventory.ItemStack; + +public class ItemWrapper implements Formattable { + private final ItemStack item; + + public ItemWrapper(ItemStack item) { + this.item = item; + } + + @Override + public Message format(Context context) { + return Text.ITEM_FORMAT.format(context.newScope().plus("item", this)); + } + + @Override + public Formattable applyModifier(Message arg) { + String option = arg.toString().toLowerCase(); + switch (option) { + case "amount": + return Message.of(item.getAmount()); + case "type": + return Message.of(item.getType().getKey().toString()); + default: + return Message.empty; + } + } +} diff --git a/src/main/java/io/github/mviper/bomberman/messaging/LengthExpander.java b/src/main/java/io/github/mviper/bomberman/messaging/LengthExpander.java new file mode 100644 index 0000000..1c38152 --- /dev/null +++ b/src/main/java/io/github/mviper/bomberman/messaging/LengthExpander.java @@ -0,0 +1,15 @@ +package io.github.mviper.bomberman.messaging; + +public class LengthExpander implements Formattable { + private final Formattable delegate = new RequiredArg(arg -> Message.of(arg.toString().length())); + + @Override + public Formattable applyModifier(Message arg) { + return delegate.applyModifier(arg); + } + + @Override + public Message format(Context context) { + return delegate.format(context); + } +} diff --git a/src/main/java/io/github/mviper/bomberman/messaging/Message.java b/src/main/java/io/github/mviper/bomberman/messaging/Message.java new file mode 100644 index 0000000..e20001d --- /dev/null +++ b/src/main/java/io/github/mviper/bomberman/messaging/Message.java @@ -0,0 +1,475 @@ +package io.github.mviper.bomberman.messaging; + +import org.bukkit.ChatColor; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; + +import javax.annotation.CheckReturnValue; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; + +/** + * A message is an immutable coloured/formatted expanded string. + */ +@CheckReturnValue +public class Message implements Formattable { + private final TreeNode contents; + + private Message(TreeNode contents) { + this.contents = contents; + } + + public static Message of(String text) { + return new Message(new StringNode(text)); + } + + public static Message of(Number num) { + return of(num.toString()); + } + + public static final Message empty = of(""); + + public static Message title(Message text, Message subtitle, int fadeIn, int duration, int fadeOut) { + return new Message(new TitleNode(text, subtitle, fadeIn, duration, fadeOut)); + } + + public static final Message rawFlag = new Message(new RawNode()); + + public static Message error(String text) { + return of(text).color(ChatColor.RED); + } + + public static Message lazyExpand(String text, Context context) { + return new Message(new LazyNode(text, context)); + } + + public static Message reference(Formattable item, Context context) { + return new Message(new ReferenceNode(item, context)); + } + + private static final class Style { + private final ChatColor color; + private final Set formats; + + private Style(ChatColor color, Set formats) { + this.color = color; + this.formats = formats; + } + } + + private static final class Cursor { + private final StringBuilder content = new StringBuilder(); + private final Deque