diff --git a/README.md b/README.md index 4bf50a8..d72c5fd 100644 --- a/README.md +++ b/README.md @@ -91,32 +91,45 @@ To release a frozen player, use `/unfreeze ` Allows players to temporarily disable certain permissions (i.e. those that give them staff-only alerts), intended for use when screen sharing or live-streaming gameplay. +#### Placeholder + +When PlaceholderAPI is available, a placeholder is made available to display when a player has streamer mode active. + +- ``%rel_streamermode_tag%``: Displays a `⬤` tag in red when streamer mode is active. +- ``%rel_streamermode_prefix%``: Displays the tag followed by a space when streamer mode is active. +- ``%rel_streamermode_suffix%``: Displays the tag preceded by a space when streamer mode is active. + ## Permissions -| Permission | Description | -|---------------------------------------|---------------------------------------------------------------------------------------------------| -| `admintoolbox.target` | Use [`/target`](#target-locations) at current location | -| `admintoolbox.target.player` | Use [`/target `](#target-locations) | -| `admintoolbox.target.location` | Use [`/target [y] [world]`](#target-locations) | -| `admintoolbox.reveal` | Use [`/reveal`](#reveal) | -| `admintoolbox.yell` | Use [`/yell`](#yell) | -| `admintoolbox.freeze` | Use [`/freeze` and `/unfreeze`](#freeze) | -| `admintoolbox.spawn` | Use [`/spawn`](#targeting-spawn) in current world | -| `admintoolbox.spawn.all` | Use [`/spawn [world]`](#targeting-spawn) | -| `admintoolbox.fullbright` | Use [`/fullbright`](#fullbright) while in admin mode | -| `admintoolbox.broadcast.receive` | Receive alerts about others' [targets](#spectate), [yells](#yell), and [freeze](#freeze) actions. | -| `admintoolbox.broadcast.exempt` | Do not send alerts to players with `admintoolbox.broadcast.receive` | -| `admintoolbox.streamermode` | Use [streamer mode](#streamer-mode) | -| `admintoolbox.streamermode.unlimited` | Bypass maximum streamer mode duration. (Set in config.yml) | +| Permission | Description | +|----------------------------------------------|---------------------------------------------------------------------------------------------------| +| `admintoolbox.target` | Use [`/target`](#target-locations) at current location | +| `admintoolbox.target.player` | Use [`/target `](#target-locations) | +| `admintoolbox.target.location` | Use [`/target [y] [world]`](#target-locations) | +| `admintoolbox.reveal` | Use [`/reveal`](#reveal) | +| `admintoolbox.yell` | Use [`/yell`](#yell) | +| `admintoolbox.freeze` | Use [`/freeze` and `/unfreeze`](#freeze) | +| `admintoolbox.spawn` | Use [`/spawn`](#targeting-spawn) in current world | +| `admintoolbox.spawn.all` | Use [`/spawn [world]`](#targeting-spawn) | +| `admintoolbox.fullbright` | Use [`/fullbright`](#fullbright) while in admin mode | +| `admintoolbox.broadcast.receive` | Receive alerts about others' [targets](#spectate), [yells](#yell), and [freeze](#freeze) actions. | +| `admintoolbox.broadcast.exempt` | Do not send alerts to players with `admintoolbox.broadcast.receive` | +| `admintoolbox.streamermode` | Use [streamer mode](#streamer-mode) | +| `admintoolbox.streamermode.unlimited` | Bypass maximum streamer mode duration. (Set in config.yml) | +| `admintoolbox.streamermode.placeholder.wear` | Wear the streamer mode status placeholder. | +| `admintoolbox.streamermode.placeholder.view` | View other players' streamer mode status placeholders. | ## Integrations - **[LuckPerms](https://luckperms.net/)** - - Required for [Streamer Mode](#streamer-mode). + - Required for [streamer mode](#streamer-mode). - **New in version 1.4.0:** Custom context for conditionally applying permissions based on admin state. - **`admintoolbox:state`** can be any of `spectating`, `revealed`, or `normal` (not in admin mode). - **[BlueMap](https://bluemap.bluecolored.de)** - The plugin will hide admins who are [revealed](#reveal) from the map. +- **[PlaceholderAPI](https://modrinth.com/plugin/placeholderapi)** + - The plugin provides a placeholder for players' [streamer mode](#streamer-mode) status. + - Players must have the appropriate [permissions](#permissions) to wear and view the placeholder. ## Analytics diff --git a/build.gradle.kts b/build.gradle.kts index 182ae77..f65cb7c 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -8,7 +8,7 @@ plugins { group = "org.modernbeta.admintoolbox" -val baseVersion = "1.4.1" +val baseVersion = "1.5.0" version = run { // CI: on release tag - use that version val refType = System.getenv("GITHUB_REF_TYPE") @@ -65,12 +65,16 @@ repositories { maven("https://repo.bluecolored.de/releases") { name = "bluemap" } + maven("https://repo.extendedclip.com/releases/") { + name = "clip" + } } dependencies { compileOnly("io.papermc.paper:paper-api:1.20.4-R0.1-SNAPSHOT") compileOnly("net.luckperms:api:5.4") compileOnly("de.bluecolored:bluemap-api:2.7.4") + compileOnly("me.clip:placeholderapi:2.11.7") implementation("org.bstats:bstats-bukkit:3.1.0") } @@ -101,6 +105,8 @@ tasks.shadowJar { val plugins = runPaper.downloadPluginsSpec { modrinth("viaversion", "5.6.0") // makes testing much easier modrinth("bluemap", "5.5-paper") + modrinth("placeholderapi", "2.11.7") + modrinth("tab-was-taken", "5.4.0") } // Paper (non-Folia!) server @@ -109,7 +115,7 @@ tasks.runServer { downloadPlugins { from(plugins) // Add Folia-incompatible plugins below - modrinth("luckperms", "v5.5.0-bukkit") // they are working on Folia support but it's not ready yet! + modrinth("luckperms", "v5.5.0-bukkit") } } @@ -117,6 +123,10 @@ tasks.runServer { runPaper.folia.registerTask { minecraftVersion("1.20.4") downloadPlugins.from(plugins) + downloadPlugins { + // LuckPerms for Folia + url("https://ci.lucko.me/job/LuckPerms-Folia/9/artifact/bukkit/loader/build/libs/LuckPerms-Bukkit-5.5.11.jar") + } } // better IntelliJ IDEA debugging diff --git a/run/README.md b/run/README.md new file mode 100644 index 0000000..5475588 --- /dev/null +++ b/run/README.md @@ -0,0 +1 @@ +The `run` directory contains configuration files for the development testing server. diff --git a/run/plugins/TAB/config.yml b/run/plugins/TAB/config.yml new file mode 100644 index 0000000..3f7ed7e --- /dev/null +++ b/run/plugins/TAB/config.yml @@ -0,0 +1,200 @@ +header-footer: + enabled: false + designs: + default: + header: null + footer: null +tablist-name-formatting: + enabled: true + disable-condition: '%world%=disabledworld' +scoreboard-teams: + enabled: false + enable-collision: true + invisible-nametags: false + sorting-types: null + case-sensitive-sorting: true + can-see-friendly-invisibles: false + disable-condition: '%world%=disabledworld' +playerlist-objective: + enabled: false + value: '%ping%' + fancy-value: '&7Ping: %ping%' + title: TAB + render-type: INTEGER + disable-condition: '%world%=disabledworld' +belowname-objective: + enabled: true + value: '%rel_streamermode_tag%' + title: '' + fancy-value: '&c%health%' + fancy-value-default: NPC + disable-condition: '%world%=disabledworld' +prevent-spectator-effect: + enabled: false +bossbar: + enabled: false + toggle-command: /bossbar + remember-toggle-choice: false + hidden-by-default: false + bars: + ServerInfo: + style: PROGRESS + color: '%animation:barcolors%' + progress: '100' + text: '&fWebsite: &bwww.domain.com' +scoreboard: + enabled: false + toggle-command: /sb + remember-toggle-choice: false + hidden-by-default: false + use-numbers: true + static-number: 0 + delay-on-join-milliseconds: 0 + scoreboards: + scoreboard-1.20.3+: + title: <#E0B11E>MyServer + display-condition: '%player-version-id%>=765;%bedrock%=false' + lines: + - '&7%date%' + - '%animation:MyAnimation1%' + - '&6Online:' + - '* &eOnline&7:||%online%' + - '* &eCurrent World&7:||%worldonline%' + - '* &eStaff&7:||%staffonline%' + - '' + - '&6Personal Info:' + - '* &bRank&7:||%group%' + - '* &bPing&7:||%ping%&8ms' + - '* &bWorld&7:||%world%' + - '%animation:MyAnimation1%' + scoreboard: + title: <#E0B11E>MyServer + lines: + - '&7%date%' + - '%animation:MyAnimation1%' + - '&6Online:' + - '* &eOnline&7: &f%online%' + - '* &eCurrent World&7: &f%worldonline%' + - '* &eStaff&7: &f%staffonline%' + - '' + - '&6Personal Info:' + - '* &bRank&7: &f%group%' + - '* &bPing&7: &f%ping%&8ms' + - '* &bWorld&7: &f%world%' + - '%animation:MyAnimation1%' +layout: + enabled: false + direction: COLUMNS + default-skin: mineskin:37e93c8e12cd426cb28fce31969e0674 + enable-remaining-players-text: true + remaining-players-text: '... and %s more' + empty-slot-ping-value: 1000 + layouts: + default: + fixed-slots: + - '1|&3Website&f:' + - 2|&bmyserver.net + - '3|&8&m ' + - '4|&3Name&f:' + - 5|&b%player% + - '7|&3Rank&f:' + - '8|Rank: %group%' + - '10|&3World&f:' + - 11|&b%world% + - '13|&3Time&f:' + - 14|&b%time% + - '21|&3Teamspeak&f:' + - 22|&bts.myserver.net + - '23|&8&m ' + - '41|&3Store&f:' + - 42|&bshop.myserver.net + - '43|&8&m ' + groups: + staff: + condition: permission:tab.staff + slots: + - 24-40 + players: + slots: + - 44-80 +ping-spoof: + enabled: false + value: 0 +global-playerlist: + enabled: false + display-others-as-spectators: false + display-vanished-players-as-spectators: true + isolate-unlisted-servers: false + update-latency: false + spy-servers: + - spyserver1 + - spyserver2 + server-groups: + lobbies: + - lobby1 + - lobby2 + group2: + - server1 + - server2 +placeholders: + date-format: dd.MM.yyyy + time-format: '[HH:mm:ss / h:mm a]' + time-offset: 0 + register-tab-expansion: false +placeholder-output-replacements: null +conditions: null +placeholder-refresh-intervals: + default-refresh-interval: 500 + '%server_uptime%': 1000 + '%server_tps_1_colored%': 1000 + '%server_unique_joins%': 5000 + '%player_health%': 200 + '%player_ping%': 1000 + '%vault_prefix%': 1000 + '%rel_factionsuuid_relation_color%': 1000 +assign-groups-by-permissions: false +primary-group-finding-list: +- Owner +- Admin +- Helper +- default +permission-refresh-interval: 1000 +debug: false +mysql: + enabled: false + host: 127.0.0.1 + port: 3306 + database: tab + username: user + password: password + useSSL: true +proxy-support: + enabled: false + type: PLUGIN + plugin: + name: RedisBungee + redis: + url: redis://:password@localhost:6379/0 + rabbitmq: + exchange: plugin + url: amqp://guest:guest@localhost:5672/%2F +components: + minimessage-support: true + disable-shadow-for-heads: true +config-version: 2 +per-world-playerlist: + enabled: false + allow-bypass-permission: false + ignore-effect-in-worlds: + - ignoredworld + - build + shared-playerlist-world-groups: + lobby: + - lobby1 + - lobby2 + minigames: + - paintball + - bedwars +compensate-for-packetevents-bug: false +use-bukkit-permissions-manager: false +use-online-uuid-in-tablist: true diff --git a/run/plugins/TAB/groups.yml b/run/plugins/TAB/groups.yml new file mode 100644 index 0000000..19e00c0 --- /dev/null +++ b/run/plugins/TAB/groups.yml @@ -0,0 +1,3 @@ +_DEFAULT_: + tabprefix: "%rel_streamermode_prefix%&r%luckperms-prefix%" + tagsuffix: "%luckperms-suffix%" diff --git a/src/main/java/org/modernbeta/admintoolbox/AdminToolboxPlugin.java b/src/main/java/org/modernbeta/admintoolbox/AdminToolboxPlugin.java index 9544b4a..a8a8801 100644 --- a/src/main/java/org/modernbeta/admintoolbox/AdminToolboxPlugin.java +++ b/src/main/java/org/modernbeta/admintoolbox/AdminToolboxPlugin.java @@ -12,7 +12,9 @@ import org.modernbeta.admintoolbox.commands.*; import org.modernbeta.admintoolbox.integration.BlueMapIntegration; import org.modernbeta.admintoolbox.integration.luckperms.LuckPermsIntegration; +import org.modernbeta.admintoolbox.integration.placeholderapi.PlaceholderAPIIntegration; import org.modernbeta.admintoolbox.managers.FreezeManager; +import org.modernbeta.admintoolbox.managers.StreamerModeManager; import org.modernbeta.admintoolbox.managers.admin.AdminManager; import javax.annotation.Nullable; @@ -26,8 +28,9 @@ public class AdminToolboxPlugin extends JavaPlugin { static AdminToolboxPlugin instance; - AdminManager adminManager; - FreezeManager freezeManager; + private AdminManager adminManager; + private FreezeManager freezeManager; + private @Nullable StreamerModeManager streamerModeManager; PermissionAudience broadcastAudience; @@ -36,6 +39,7 @@ public class AdminToolboxPlugin extends JavaPlugin { private @Nullable BlueMapIntegration blueMapIntegration = null; private @Nullable LuckPermsIntegration luckPermsIntegration = null; + private @Nullable PlaceholderAPIIntegration placeholderAPIIntegration = null; private static final String ADMIN_STATE_CONFIG_FILENAME = "admin-state.yml"; @@ -50,7 +54,6 @@ public void onEnable() { this.adminManager = new AdminManager(); this.freezeManager = new FreezeManager(); - this.broadcastAudience = new PermissionAudience(BROADCAST_AUDIENCE_PERMISSION); createAdminStateConfig(); @@ -78,7 +81,8 @@ public void onEnable() { this.luckPermsIntegration = new LuckPermsIntegration(provider.getProvider()); this.luckPermsIntegration.registerCalculator(); - getCommand("streamermode").setExecutor(new StreamerModeCommand()); + this.streamerModeManager = new StreamerModeManager(this, luckPermsIntegration); + getCommand("streamermode").setExecutor(new StreamerModeCommand(streamerModeManager)); } } catch (NoClassDefFoundError e) { getLogger().warning("LuckPerms not found! Some features will be unavailable."); @@ -94,6 +98,13 @@ public void onEnable() { getLogger().warning("BlueMap API not found! Some features will be unavailable."); } + try { + this.placeholderAPIIntegration = new PlaceholderAPIIntegration(this); + this.placeholderAPIIntegration.registerPlaceholders(); + } catch (NoClassDefFoundError e) { + getLogger().warning("PlaceholderAPI is not available! Some features will be unavailable."); + } + // bStats - plugin analytics. Toggleable in server-level bStats config. new Metrics(this, BSTATS_PLUGIN_ID); @@ -149,6 +160,10 @@ public FreezeManager getFreezeManager() { return freezeManager; } + public Optional getStreamerModeManager() { + return Optional.ofNullable(streamerModeManager); + } + public PermissionAudience getAdminAudience() { return broadcastAudience; } @@ -177,8 +192,8 @@ public Configuration getConfigDefaults() { streamerMode.set("disable-permissions", List.of("admintoolbox.broadcast.receive")); // docs - streamerMode.setInlineComments("allow", List.of("Enable or disable usage of Streamer Mode. 'true' enables usage of Streamer Mode, while 'false' disables Streamer Mode entirely.")); - streamerMode.setInlineComments("max-duration", List.of("The maximum duration a player can enable Streamer Mode for, in minutes.")); + streamerMode.setInlineComments("allow", List.of("Enable or disable usage of streamer mode. 'true' is enabled, 'false' is disabled")); + streamerMode.setInlineComments("max-duration", List.of("The maximum duration a player can enable streamer mode for, in minutes.")); streamerMode.setInlineComments("disable-permissions", List.of("The list of permissions to disable for the given time period.")); } diff --git a/src/main/java/org/modernbeta/admintoolbox/commands/StreamerModeCommand.java b/src/main/java/org/modernbeta/admintoolbox/commands/StreamerModeCommand.java index 57eb6d2..f7989dc 100644 --- a/src/main/java/org/modernbeta/admintoolbox/commands/StreamerModeCommand.java +++ b/src/main/java/org/modernbeta/admintoolbox/commands/StreamerModeCommand.java @@ -1,12 +1,6 @@ package org.modernbeta.admintoolbox.commands; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; -import net.luckperms.api.LuckPerms; -import net.luckperms.api.model.user.User; -import net.luckperms.api.node.Node; -import net.luckperms.api.node.NodeType; -import net.luckperms.api.node.types.MetaNode; -import net.luckperms.api.node.types.PermissionNode; import org.bukkit.command.Command; import org.bukkit.command.CommandExecutor; import org.bukkit.command.CommandSender; @@ -15,6 +9,7 @@ import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.modernbeta.admintoolbox.AdminToolboxPlugin; +import org.modernbeta.admintoolbox.managers.StreamerModeManager; import java.time.Duration; import java.time.temporal.ChronoUnit; @@ -25,51 +20,45 @@ import java.util.regex.Matcher; import java.util.regex.Pattern; +import static org.modernbeta.admintoolbox.managers.StreamerModeManager.STREAMER_MODE_USE_PERMISSION; + public class StreamerModeCommand implements CommandExecutor, TabCompleter { private final AdminToolboxPlugin plugin = AdminToolboxPlugin.getInstance(); + private final StreamerModeManager manager; - private static final String STREAMER_MODE_COMMAND_PERMISSION = "admintoolbox.streamermode"; - private static final String STREAMER_MODE_BYPASS_MAX_DURATION_PERMISSION = "admintoolbox.streamermode.unlimited"; - private static final String STREAMER_MODE_LP_META_KEY = "at-streamer-mode-enabled"; + public StreamerModeCommand(StreamerModeManager streamerModeManager) { + this.manager = streamerModeManager; + } @Override public boolean onCommand(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, @NotNull String[] args) { - if (!sender.hasPermission(STREAMER_MODE_COMMAND_PERMISSION)) + if (!sender.hasPermission(STREAMER_MODE_USE_PERMISSION)) return false; // Bukkit should handle this for us, just a sanity-check if (!(sender instanceof Player player)) { - sender.sendRichMessage("Only players may use Streamer Mode."); + sender.sendRichMessage("Only players may use streamer mode."); return true; } if (!plugin.getConfig().getBoolean("streamer-mode.allow", false)) { - sender.sendRichMessage("Streamer Mode is disabled on this server.", + sender.sendRichMessage("Streamer mode is disabled on this server.", Placeholder.unparsed("addendum", player.isOp() ? " (streamer-mode -> allow is 'false' in config.yml)" : "")); return true; } if (plugin.getLuckPerms().isEmpty()) { - sender.sendRichMessage("LuckPerms is required to use Streamer Mode. Is it enabled?"); + sender.sendRichMessage("LuckPerms is required to use streamer mode. Is it enabled?"); return true; } - LuckPerms luckPerms = plugin.getLuckPerms().get().api(); - - List disablePermissions = plugin.getConfig().getStringList("streamer-mode.disable-permissions"); - User user = luckPerms.getPlayerAdapter(Player.class).getUser(player); - if (args.length == 0 && isStreamerModeActive(luckPerms, player)) { - user.data().clear(NodeType.META.predicate((node) -> node.getMetaKey().equals(STREAMER_MODE_LP_META_KEY))); - user.data().clear(NodeType.PERMISSION.predicate((node) -> // only delete negated, expiring nodes that match configured permissions - node.isNegated() - && node.getExpiryDuration() != null - && disablePermissions.contains(node.getPermission()) - )); - luckPerms.getUserManager().saveUser(user); - - sender.sendRichMessage("Streamer Mode has been disabled."); + if (args.length == 0 && manager.isActive(player)) { + manager.disable(player) + .thenAccept(state -> { + sender.sendRichMessage("Streamer mode has been disabled."); + }); return true; } if (args.length < 1) { - sender.sendRichMessage("You must provide a duration for Streamer Mode!"); + sender.sendRichMessage("You must provide a duration for streamer mode!"); return false; } else if (args.length > 1) { return false; @@ -85,40 +74,16 @@ public boolean onCommand(@NotNull CommandSender sender, @NotNull Command command Duration duration = parsedDuration.get(); - final double maxDurationMinutes = plugin.getConfig().getDouble("streamer-mode.max-duration"); - if ((duration.getSeconds() > (maxDurationMinutes * 60)) - && !sender.hasPermission(STREAMER_MODE_BYPASS_MAX_DURATION_PERMISSION)) { + if (!manager.isAllowableDuration(duration, player)) { sender.sendRichMessage("That duration is above the maximum allowed!"); return true; } - MetaNode metaNode = MetaNode.builder() - .key(STREAMER_MODE_LP_META_KEY) - .value(Boolean.toString(true)) - .expiry(duration) - .build(); - - user.data().clear(NodeType.META.predicate((node) -> node.getMetaKey().equals(STREAMER_MODE_LP_META_KEY))); - user.data().add(metaNode); - - // using LuckPerms API, add negated/'false' versions of permissions from config.yml to user for duration - for (String permission : disablePermissions) { - Node permissionNode = PermissionNode.builder() - .permission(permission) - .expiry(duration) - .negated(true) - .build(); - - user.data().clear(NodeType.PERMISSION.predicate( - (node) -> node.getPermission().equals(permission) && node.isNegated() - )); - user.data().add(permissionNode); - } - - luckPerms.getUserManager().saveUser(user); - - sender.sendRichMessage("Streamer Mode will be enabled for .", - Placeholder.unparsed("duration", formatDuration(duration))); + manager.enable(player, duration) + .thenAccept(state -> { + sender.sendRichMessage("Streamer mode will be enabled for .", + Placeholder.unparsed("duration", formatDuration(state.duration()))); + }); return true; } @@ -130,7 +95,7 @@ public boolean onCommand(@NotNull CommandSender sender, @NotNull Command command String partialEntry = args[0]; - if(partialEntry.isEmpty()) { + if (partialEntry.isEmpty()) { // Suggest durations if nothing is entered yet -- this is a good UX hint for how to use the command! return List.of("15m", "30m", "5h", "8h"); } @@ -159,7 +124,7 @@ public boolean onCommand(@NotNull CommandSender sender, @NotNull Command command /// Only one duration segment is supported. That means durations such as /// '1h15m' will fail to parse. private Optional parseDuration(String input) { - Pattern durationPattern = Pattern.compile("^\\s*(?\\d{1,3})(?[mh])\\s*$", Pattern.CASE_INSENSITIVE); + Pattern durationPattern = Pattern.compile("^\\s*(?[1-9]\\d{0,2})(?[mh])\\s*$", Pattern.CASE_INSENSITIVE); Matcher matcher = durationPattern.matcher(input); if (!matcher.matches()) @@ -197,11 +162,4 @@ private String formatDuration(Duration duration) { return String.join(" ", resultList); } - - private boolean isStreamerModeActive(LuckPerms luckPerms, Player player) { - return luckPerms.getPlayerAdapter(Player.class) - .getMetaData(player) - .getMetaValue(STREAMER_MODE_LP_META_KEY, Boolean::valueOf) - .orElse(false); - } } diff --git a/src/main/java/org/modernbeta/admintoolbox/integration/placeholderapi/PlaceholderAPIIntegration.java b/src/main/java/org/modernbeta/admintoolbox/integration/placeholderapi/PlaceholderAPIIntegration.java new file mode 100644 index 0000000..d659085 --- /dev/null +++ b/src/main/java/org/modernbeta/admintoolbox/integration/placeholderapi/PlaceholderAPIIntegration.java @@ -0,0 +1,20 @@ +package org.modernbeta.admintoolbox.integration.placeholderapi; + +import me.clip.placeholderapi.PlaceholderAPI; +import org.modernbeta.admintoolbox.AdminToolboxPlugin; +import org.modernbeta.admintoolbox.integration.placeholderapi.expansion.StreamerModePlaceholder; + +public class PlaceholderAPIIntegration { + private final AdminToolboxPlugin plugin; + + private final StreamerModePlaceholder streamerModePlaceholder; + + public PlaceholderAPIIntegration(AdminToolboxPlugin plugin) { + this.plugin = plugin; + this.streamerModePlaceholder = new StreamerModePlaceholder(plugin); + } + + public boolean registerPlaceholders() { + return this.streamerModePlaceholder.register(); + } +} diff --git a/src/main/java/org/modernbeta/admintoolbox/integration/placeholderapi/expansion/StreamerModePlaceholder.java b/src/main/java/org/modernbeta/admintoolbox/integration/placeholderapi/expansion/StreamerModePlaceholder.java new file mode 100644 index 0000000..a0fb81e --- /dev/null +++ b/src/main/java/org/modernbeta/admintoolbox/integration/placeholderapi/expansion/StreamerModePlaceholder.java @@ -0,0 +1,63 @@ +package org.modernbeta.admintoolbox.integration.placeholderapi.expansion; + +import me.clip.placeholderapi.expansion.PlaceholderExpansion; +import me.clip.placeholderapi.expansion.Relational; +import org.bukkit.ChatColor; +import org.bukkit.entity.Player; +import org.jetbrains.annotations.NotNull; +import org.modernbeta.admintoolbox.AdminToolboxPlugin; + +import javax.annotation.Nullable; + +public class StreamerModePlaceholder extends PlaceholderExpansion implements Relational { + private final AdminToolboxPlugin plugin; + + private static final String SM_VIEW_PERMISSION = "admintoolbox.streamermode.placeholder.view"; + private static final String SM_WEAR_PERMISSION = "admintoolbox.streamermode.placeholder.wear"; + + public StreamerModePlaceholder(AdminToolboxPlugin plugin) { + this.plugin = plugin; + } + + @Override + public @NotNull String getIdentifier() { + return "streamermode"; + } + + @SuppressWarnings("UnstableApiUsage") + @Override + public @NotNull String getAuthor() { + return String.join(", ", plugin.getPluginMeta().getAuthors()); + } + + @SuppressWarnings("UnstableApiUsage") + @Override + public @NotNull String getVersion() { + return plugin.getPluginMeta().getVersion(); + } + + @Override + public boolean persist() { + return true; + } + + @Override + public String onPlaceholderRequest(Player viewer, Player wearer, String identifier) { + if (viewer == null || wearer == null) return ""; + if (!viewer.hasPermission(SM_VIEW_PERMISSION)) return ""; + if (!wearer.hasPermission(SM_WEAR_PERMISSION)) return ""; + + boolean isActive = plugin.getStreamerModeManager() + .map(sm -> sm.isActive(wearer)) + .orElse(false); + if (!isActive) return ""; + + String tag = ChatColor.RED + "⬤"; + return switch (identifier.toLowerCase()) { + case "prefix" -> tag + " "; + case "suffix" -> " " + tag; + case "tag" -> tag; + default -> null; + }; + } +} diff --git a/src/main/java/org/modernbeta/admintoolbox/managers/StreamerModeManager.java b/src/main/java/org/modernbeta/admintoolbox/managers/StreamerModeManager.java new file mode 100644 index 0000000..d20e076 --- /dev/null +++ b/src/main/java/org/modernbeta/admintoolbox/managers/StreamerModeManager.java @@ -0,0 +1,137 @@ +package org.modernbeta.admintoolbox.managers; + +import net.luckperms.api.model.user.User; +import net.luckperms.api.model.user.UserManager; +import net.luckperms.api.node.Node; +import net.luckperms.api.node.NodeType; +import net.luckperms.api.node.types.MetaNode; +import net.luckperms.api.node.types.PermissionNode; +import net.luckperms.api.platform.PlayerAdapter; +import org.bukkit.OfflinePlayer; +import org.bukkit.entity.Player; +import org.modernbeta.admintoolbox.AdminToolboxPlugin; +import org.modernbeta.admintoolbox.integration.luckperms.LuckPermsIntegration; + +import javax.annotation.Nullable; +import java.time.Duration; +import java.util.List; +import java.util.concurrent.CompletableFuture; + +public class StreamerModeManager { + public static final String STREAMER_MODE_USE_PERMISSION = "admintoolbox.streamermode"; + public static final String STREAMER_MODE_BYPASS_MAX_DURATION_PERMISSION = "admintoolbox.streamermode.unlimited"; + private static final String STREAMER_MODE_LP_META_KEY = "at-streamer-mode-enabled"; + + private final AdminToolboxPlugin plugin; + private final LuckPermsIntegration luckPerms; + + public StreamerModeManager(AdminToolboxPlugin plugin, LuckPermsIntegration luckPerms) { + this.plugin = plugin; + this.luckPerms = luckPerms; + } + + public record StreamerModeState( + OfflinePlayer player, + boolean isEnabled, + @Nullable Duration duration + ) { + } + + public CompletableFuture enable(Player player, Duration duration) { + UserManager userManager = luckPerms.api().getUserManager(); + User user = luckPerms.api().getPlayerAdapter(Player.class).getUser(player); + List disablePermissions = plugin.getConfig().getStringList("streamer-mode.disable-permissions"); + + MetaNode metaNode = MetaNode.builder() + .key(STREAMER_MODE_LP_META_KEY) + .value(Boolean.toString(true)) + .expiry(duration) + .build(); + + user.data().clear(NodeType.META.predicate((node) -> node.getMetaKey().equals(STREAMER_MODE_LP_META_KEY))); + user.data().add(metaNode); + + // using LuckPerms API, add negated/'false' versions of permissions from config.yml to user for duration + for (String permission : disablePermissions) { + Node permissionNode = PermissionNode.builder() + .permission(permission) + .expiry(duration) + .negated(true) + .build(); + + user.data().clear(NodeType.PERMISSION.predicate( + (node) -> node.getPermission().equals(permission) && node.isNegated() + )); + user.data().add(permissionNode); + } + + return userManager.saveUser(user) + .thenApply((_void) -> new StreamerModeState( + player, + true, + duration + )); + } + + public CompletableFuture disable(Player player) { + UserManager userManager = luckPerms.api().getUserManager(); + User user = luckPerms.api().getPlayerAdapter(Player.class).getUser(player); + List disablePermissions = plugin.getConfig().getStringList("streamer-mode.disable-permissions"); + + user.data().clear(NodeType.META.predicate((node) -> node.getMetaKey().equals(STREAMER_MODE_LP_META_KEY))); + user.data().clear(NodeType.PERMISSION.predicate((node) -> // only delete negated, expiring nodes that match configured permissions + node.isNegated() + && node.getExpiryDuration() != null + && node.getExpiryDuration().isPositive() + && disablePermissions.contains(node.getPermission()) + )); + + return userManager.saveUser(user) + .thenApply((_void) -> new StreamerModeState( + player, + false, + null + )); + } + + public boolean isActive(Player player) { + return getState(player).isEnabled(); + } + + public boolean isAllowableDuration(Duration duration, Player player) { + final double maxDurationMinutes = plugin.getConfig().getDouble("streamer-mode.max-duration", 720d); + return (duration.getSeconds() <= (maxDurationMinutes * 60)) + || player.hasPermission(STREAMER_MODE_BYPASS_MAX_DURATION_PERMISSION); + } + + public StreamerModeState getState(Player player) { + final PlayerAdapter playerAdapter = + luckPerms.api().getPlayerAdapter(Player.class); + + boolean isEnabled = playerAdapter + .getMetaData(player) + .getMetaValue(STREAMER_MODE_LP_META_KEY, Boolean::valueOf) + .orElse(false); + + Duration duration = null; + if (isEnabled) getDuration:{ + Node node = playerAdapter + .getMetaData(player) + .queryMetaValue(STREAMER_MODE_LP_META_KEY) + .node(); + + if (node == null || node.hasExpired()) { + isEnabled = false; + break getDuration; + } + + duration = node.getExpiryDuration(); + } + + return new StreamerModeState( + player, + isEnabled, + duration + ); + } +} diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index bc66e6e..2c75478 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -4,7 +4,7 @@ authors: [ Karltroid, iLynxcat ] main: org.modernbeta.admintoolbox.AdminToolboxPlugin api-version: '1.20' folia-supported: true -softdepend: [ BlueMap, LuckPerms ] +softdepend: [ BlueMap, LuckPerms, PlaceholderAPI ] default-permission: op permissions: @@ -52,10 +52,16 @@ permissions: - admintoolbox.yell - admintoolbox.broadcast.receive admintoolbox.streamermode: - description: Can enter and exit Streamer Mode. + description: Can enter and exit streamer mode. admintoolbox.streamermode.unlimited: - description: Can bypass maximum duration in Streamer Mode. + description: Can bypass maximum duration in streamer mode. default: false + admintoolbox.streamermode.placeholder.wear: + description: Can wear placeholder while in streamer mode. + default: true + admintoolbox.streamermode.placeholder.view: + description: Can see other players' streamer mode status placeholder. + default: op commands: admintoolbox: @@ -111,7 +117,7 @@ commands: aliases: [ fb, nightvision, nv ] permission: admintoolbox.fullbright streamermode: - description: Enter Streamer Mode, temporarily disabling certain privileges. + description: Enter streamer mode, temporarily disabling certain privileges. usage: / [duration] aliases: [ sm, pausealerts, pa ] permission: admintoolbox.streamermode