From bdb18f9952807cca3964e6585d8299649e7a9ac1 Mon Sep 17 00:00:00 2001 From: Michael Burgess Date: Fri, 7 Aug 2026 11:46:43 -0400 Subject: [PATCH] Add optional web jukebox: vote for arena music tracks via a small HTTP API --- README.md | 54 +++++ .../us/tss3/blockparty/BlockPartyPlugin.java | 23 ++ .../java/us/tss3/blockparty/arena/Arena.java | 27 ++- .../blockparty/command/BlockPartyCommand.java | 17 +- .../tss3/blockparty/config/ConfigManager.java | 24 ++ .../blockparty/jukebox/JukeboxServer.java | 216 ++++++++++++++++++ .../blockparty/jukebox/VoteCodeManager.java | 48 ++++ .../tss3/blockparty/jukebox/VoteManager.java | 43 ++++ src/main/resources/config.yml | 29 +++ src/main/resources/messages.yml | 2 + 10 files changed, 475 insertions(+), 8 deletions(-) create mode 100644 src/main/java/us/tss3/blockparty/jukebox/JukeboxServer.java create mode 100644 src/main/java/us/tss3/blockparty/jukebox/VoteCodeManager.java create mode 100644 src/main/java/us/tss3/blockparty/jukebox/VoteManager.java diff --git a/README.md b/README.md index 6535aee..a1e8cbd 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,7 @@ work fine from console. | `/blockparty leave` | Leave your current arena | | `/blockparty arenas` | List arenas (alias of `list`) | | `/blockparty stats [player]` | View your or another player's stats | +| `/blockparty musicvote` | (Only if the jukebox is enabled) get a one-time code to vote for the next track on the jukebox web page | ## Permissions @@ -204,6 +205,59 @@ stop/replay-from-the-start rather than a true mid-track pause/resume — vanilla API has no seek/resume-from-position, so each "resume" restarts the track from the beginning. It's stopped entirely at match end, arena disable, or plugin shutdown. +### Jukebox (web-based track voting) + +Optional: let players vote for an arena's music track from a web page, live, instead of (or as +a live override to) the fixed `music.track`. **Off by default** — enabling it opens a real HTTP +port on your server. Config: + +```yaml +jukebox: + enabled: true + bind-address: 127.0.0.1 + port: 8642 + allowed-origin: "*" + vote-code-minutes: 10 + tracks: + - "MUSIC_DISC_PIGSTEP|Pigstep" + - "MUSIC_DISC_CAT|Cat" + # ... id|label pairs; "id" must be a valid Bukkit Sound enum name +``` + +**How it works:** + +1. A player runs `/blockparty musicvote` in-game and gets a short numeric code (valid for + `vote-code-minutes`). This is the only way to get a code — it proves you're a real player on + the server, without needing any separate account/password system on the web page. +2. On the voting page, they enter the code plus their arena and pick a track. Each vote is a + simple HTTP `POST /api/vote?arena=&code=&track=` — the plugin resolves the + code back to their player UUID, so re-voting just changes their pick rather than adding a + duplicate. +3. The plugin re-checks the current vote leader every time music resumes after a floor-wipe + pause, so the crowd can change the track between rounds within a match, not just before it + starts. Votes for an arena are cleared when it returns to `WAITING`. +4. `GET /api/tracks` lists the configured, valid tracks; `GET /api/arenas` lists each arena's + state, current track, and live vote tally, for the page to render. + +**Security model and requirements — read before enabling:** + +- This is a **plain, unauthenticated-by-default HTTP API** on your server. `bind-address: + 127.0.0.1` (the default) means it's only reachable from the same machine — you must put a + reverse proxy (Caddy, nginx, Cloudflare Tunnel, etc.) with a real TLS certificate in front of + it to expose it to the internet safely. Do **not** set `bind-address` to `0.0.0.0` and forward + the raw port directly without TLS in front of it. +- A web voting page is normally served over HTTPS (e.g. a published page); browsers block an + HTTPS page from calling a plain `http://` address ("mixed content"), so the API being behind + HTTPS isn't optional if you want a hosted page to reach it — it's required. +- The only "auth" is the short-lived numeric code from `/blockparty musicvote`; anyone who + obtains a valid code (e.g. a player shares it) can vote as that player until it expires. + There's no rate-limiting on the HTTP endpoints — treat this as a fun, low-stakes feature, not + a hardened public API. +- `allowed-origin` sets the `Access-Control-Allow-Origin` response header; tighten it from `*` + to your actual voting page's origin if you want to restrict which sites can call it. +- Changes to any `jukebox.*` setting require a full server restart to take effect — the HTTP + server isn't restarted by `/blockparty reload`. + ### Reload behavior `/blockparty reload` re-reads `config.yml`, `messages.yml`, and every arena file. Arenas diff --git a/src/main/java/us/tss3/blockparty/BlockPartyPlugin.java b/src/main/java/us/tss3/blockparty/BlockPartyPlugin.java index 788f14b..d88fa1d 100644 --- a/src/main/java/us/tss3/blockparty/BlockPartyPlugin.java +++ b/src/main/java/us/tss3/blockparty/BlockPartyPlugin.java @@ -10,6 +10,9 @@ import us.tss3.blockparty.arena.ArenaManager; import us.tss3.blockparty.command.BlockPartyCommand; import us.tss3.blockparty.config.ConfigManager; import us.tss3.blockparty.config.MessagesManager; +import us.tss3.blockparty.jukebox.JukeboxServer; +import us.tss3.blockparty.jukebox.VoteCodeManager; +import us.tss3.blockparty.jukebox.VoteManager; import us.tss3.blockparty.listener.GameplayRestrictionListener; import us.tss3.blockparty.listener.PlayerConnectionListener; import us.tss3.blockparty.persistence.StatsManager; @@ -29,6 +32,9 @@ public class BlockPartyPlugin extends JavaPlugin implements Listener { private RewardManager rewardManager; private StatsManager statsManager; private SoundUtil soundUtil; + private VoteManager voteManager; + private VoteCodeManager voteCodeManager; + private JukeboxServer jukeboxServer; @Override public void onEnable() { @@ -45,9 +51,15 @@ public class BlockPartyPlugin extends JavaPlugin implements Listener { this.statsManager = new StatsManager(this, configManager.getStorageSettings()); statsManager.init(); + this.voteManager = new VoteManager(); + this.voteCodeManager = new VoteCodeManager(); + this.arenaManager = new ArenaManager(this); arenaManager.loadAll(); + this.jukeboxServer = new JukeboxServer(this, voteManager, voteCodeManager); + jukeboxServer.start(); + this.scoreboardManager = new ScoreboardManager(this); scoreboardManager.start(); @@ -69,6 +81,9 @@ public class BlockPartyPlugin extends JavaPlugin implements Listener { @Override public void onDisable() { + if (jukeboxServer != null) { + jukeboxServer.stop(); + } if (arenaManager != null) { arenaManager.shutdownAll(); } @@ -144,4 +159,12 @@ public class BlockPartyPlugin extends JavaPlugin implements Listener { public SoundUtil getSoundUtil() { return soundUtil; } + + public VoteManager getVoteManager() { + return voteManager; + } + + public VoteCodeManager getVoteCodeManager() { + return voteCodeManager; + } } diff --git a/src/main/java/us/tss3/blockparty/arena/Arena.java b/src/main/java/us/tss3/blockparty/arena/Arena.java index 5acbcd8..672aa0e 100644 --- a/src/main/java/us/tss3/blockparty/arena/Arena.java +++ b/src/main/java/us/tss3/blockparty/arena/Arena.java @@ -53,6 +53,7 @@ public class Arena { * the match doesn't end just because "one player remains" — it keeps running rounds until * that player is actually eliminated or leaves. */ private boolean soloMode; + private String currentMusicTrack; private Material currentTarget; private Material previousTarget; private int currentRoundTime; @@ -118,6 +119,7 @@ public class Arena { cancelAllTasks(); billboardManager.despawn(); pauseMusic(); + plugin.getVoteManager().clear(config.getName()); // Force reset players out regardless of state List all = new ArrayList<>(); all.addAll(players); @@ -385,14 +387,23 @@ public class Arena { } } - /** Starts the configured background music (if enabled) for every current player/spectator, - * from the beginning of the track — Bukkit has no true "resume from position" API, so - * pause/resume below is implemented as stop/replay-from-start rather than a real seek. */ + /** The track currently playing (or last played) for this arena's match, or null if music + * is disabled/hasn't started yet. Exposed for the optional jukebox web API. */ + public String getCurrentMusicTrack() { + return currentMusicTrack; + } + + /** Starts the configured (or currently vote-winning, if the jukebox has votes) background + * music for every current player/spectator, from the beginning of the track — Bukkit has + * no true "resume from position" API, so pause/resume below is implemented as + * stop/replay-from-start rather than a real seek. Re-checking the vote on every resume + * means the crowd can democratically change the track between rounds mid-match. */ private void startMusic() { if (!plugin.getConfigManager().isMusicEnabled()) { return; } - String track = plugin.getConfigManager().getMusicTrack(); + String voted = plugin.getVoteManager().winningTrack(config.getName()); + String track = voted != null ? voted : plugin.getConfigManager().getMusicTrack(); float volume = plugin.getConfigManager().getMusicVolume(); for (UUID uuid : allParticipants()) { Player p = plugin.getServer().getPlayer(uuid); @@ -400,17 +411,17 @@ public class Arena { plugin.getSoundUtil().playMusic(p, track, volume); } } + currentMusicTrack = track; } private void pauseMusic() { - if (!plugin.getConfigManager().isMusicEnabled()) { + if (!plugin.getConfigManager().isMusicEnabled() || currentMusicTrack == null) { return; } - String track = plugin.getConfigManager().getMusicTrack(); for (UUID uuid : allParticipants()) { Player p = plugin.getServer().getPlayer(uuid); if (p != null) { - plugin.getSoundUtil().stopMusic(p, track); + plugin.getSoundUtil().stopMusic(p, currentMusicTrack); } } } @@ -572,6 +583,8 @@ public class Arena { floorManager.cancelActiveTask(); cancelAllTasks(); round = 0; + currentMusicTrack = null; + plugin.getVoteManager().clear(config.getName()); billboardManager.clear(); stateMachine.transition(ArenaPhase.WAITING); } diff --git a/src/main/java/us/tss3/blockparty/command/BlockPartyCommand.java b/src/main/java/us/tss3/blockparty/command/BlockPartyCommand.java index ec07cbf..962be05 100644 --- a/src/main/java/us/tss3/blockparty/command/BlockPartyCommand.java +++ b/src/main/java/us/tss3/blockparty/command/BlockPartyCommand.java @@ -60,6 +60,7 @@ public class BlockPartyCommand implements CommandExecutor, TabCompleter { case "leave" -> leave(sender); case "arenas" -> list(sender); case "stats" -> stats(sender, args); + case "musicvote" -> musicVote(sender); default -> sendHelp(sender); } return true; @@ -72,6 +73,7 @@ public class BlockPartyCommand implements CommandExecutor, TabCompleter { "/blockparty leave", "/blockparty arenas", "/blockparty stats [player]", + "/blockparty musicvote", "/blockparty create ", "/blockparty delete ", "/blockparty enable ", @@ -442,6 +444,19 @@ public class BlockPartyCommand implements CommandExecutor, TabCompleter { sender.sendMessage(plugin.getMessages().get("player.left")); } + private void musicVote(CommandSender sender) { + if (!requirePlayer(sender)) return; + if (!plugin.getConfigManager().isJukeboxEnabled()) { + sender.sendMessage(plugin.getMessages().get("errors.jukebox-disabled")); + return; + } + Player player = (Player) sender; + String code = plugin.getVoteCodeManager().issueCode(player.getUniqueId(), + plugin.getConfigManager().getJukeboxVoteCodeMinutes()); + sender.sendMessage(plugin.getMessages().get("player.musicvote-code", + Map.of("code", code, "minutes", String.valueOf(plugin.getConfigManager().getJukeboxVoteCodeMinutes())))); + } + private void stats(CommandSender sender, String[] args) { UUID target; String targetName; @@ -485,7 +500,7 @@ public class BlockPartyCommand implements CommandExecutor, TabCompleter { @Override public List onTabComplete(CommandSender sender, Command command, String alias, String[] args) { if (args.length == 1) { - return List.of("help", "join", "leave", "arenas", "stats", "create", "delete", "enable", "disable", + return List.of("help", "join", "leave", "arenas", "stats", "musicvote", "create", "delete", "enable", "disable", "setlobby", "setspawn", "setspectator", "setbillboard", "delbillboard", "billboards", "pos1", "pos2", "setfloor", "generate", "info", "list", "reload") .stream().filter(s -> s.startsWith(args[0].toLowerCase())).collect(Collectors.toList()); } diff --git a/src/main/java/us/tss3/blockparty/config/ConfigManager.java b/src/main/java/us/tss3/blockparty/config/ConfigManager.java index 2d562ef..3d4ebb3 100644 --- a/src/main/java/us/tss3/blockparty/config/ConfigManager.java +++ b/src/main/java/us/tss3/blockparty/config/ConfigManager.java @@ -109,6 +109,30 @@ public class ConfigManager { return (float) config.getDouble("music.volume", 1.0); } + public boolean isJukeboxEnabled() { + return config.getBoolean("jukebox.enabled", false); + } + + public String getJukeboxBindAddress() { + return config.getString("jukebox.bind-address", "127.0.0.1"); + } + + public int getJukeboxPort() { + return config.getInt("jukebox.port", 8642); + } + + public String getJukeboxAllowedOrigin() { + return config.getString("jukebox.allowed-origin", "*"); + } + + public int getJukeboxVoteCodeMinutes() { + return config.getInt("jukebox.vote-code-minutes", 10); + } + + public List getJukeboxTracks() { + return config.getStringList("jukebox.tracks"); + } + public boolean isPlaceholderApiEnabled() { return config.getBoolean("integrations.placeholderapi", true); } diff --git a/src/main/java/us/tss3/blockparty/jukebox/JukeboxServer.java b/src/main/java/us/tss3/blockparty/jukebox/JukeboxServer.java new file mode 100644 index 0000000..f35f907 --- /dev/null +++ b/src/main/java/us/tss3/blockparty/jukebox/JukeboxServer.java @@ -0,0 +1,216 @@ +package us.tss3.blockparty.jukebox; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import org.bukkit.Sound; +import us.tss3.blockparty.BlockPartyPlugin; +import us.tss3.blockparty.arena.Arena; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.Executors; +import java.util.logging.Level; + +/** + * A minimal, dependency-free HTTP API (built on the JDK's own {@code com.sun.net.httpserver}) + * that lets an external web page read an arena's available/current music track and cast + * votes for the next one. Off by default (see {@code jukebox.enabled} in config.yml) since + * it opens a real network port; see the README's "Jukebox" section for the security model + * and why this needs to sit behind an HTTPS reverse proxy to be reachable from a page served + * over HTTPS. + */ +public class JukeboxServer { + + private final BlockPartyPlugin plugin; + private final VoteManager voteManager; + private final VoteCodeManager voteCodeManager; + private HttpServer server; + + public JukeboxServer(BlockPartyPlugin plugin, VoteManager voteManager, VoteCodeManager voteCodeManager) { + this.plugin = plugin; + this.voteManager = voteManager; + this.voteCodeManager = voteCodeManager; + } + + public void start() { + if (!plugin.getConfigManager().isJukeboxEnabled()) { + return; + } + String bind = plugin.getConfigManager().getJukeboxBindAddress(); + int port = plugin.getConfigManager().getJukeboxPort(); + try { + server = HttpServer.create(new InetSocketAddress(bind, port), 0); + server.createContext("/api/health", this::handleHealth); + server.createContext("/api/tracks", this::handleTracks); + server.createContext("/api/arenas", this::handleArenas); + server.createContext("/api/vote", this::handleVote); + server.setExecutor(Executors.newFixedThreadPool(2)); + server.start(); + plugin.getLogger().info("Jukebox HTTP API listening on " + bind + ":" + port + + " - remember this needs an HTTPS reverse proxy in front of it to be reachable from a browser page."); + } catch (IOException ex) { + plugin.getLogger().log(Level.SEVERE, "Failed to start jukebox HTTP server on " + + bind + ":" + port, ex); + } + } + + public void stop() { + if (server != null) { + server.stop(0); + server = null; + } + } + + // ---------- handlers ---------- + + private void handleHealth(HttpExchange exchange) throws IOException { + if (preflight(exchange)) return; + writeJson(exchange, 200, "{\"ok\":true}"); + } + + private void handleTracks(HttpExchange exchange) throws IOException { + if (preflight(exchange)) return; + List configured = plugin.getConfigManager().getJukeboxTracks(); + StringBuilder json = new StringBuilder("["); + boolean first = true; + for (String entry : configured) { + String[] parts = entry.split("\\|", 2); + String id = parts[0].trim(); + String label = parts.length > 1 ? parts[1].trim() : id; + if (!isValidSound(id)) { + continue; + } + if (!first) json.append(','); + first = false; + json.append("{\"id\":\"").append(jsonEscape(id)).append("\",\"label\":\"").append(jsonEscape(label)).append("\"}"); + } + json.append(']'); + writeJson(exchange, 200, json.toString()); + } + + private void handleArenas(HttpExchange exchange) throws IOException { + if (preflight(exchange)) return; + StringBuilder json = new StringBuilder("["); + boolean first = true; + for (Arena arena : plugin.getArenaManager().all()) { + String name = arena.getConfig().getName(); + if (!first) json.append(','); + first = false; + Map tally = voteManager.tally(name); + StringBuilder votesJson = new StringBuilder("{"); + boolean firstVote = true; + for (Map.Entry e : tally.entrySet()) { + if (!firstVote) votesJson.append(','); + firstVote = false; + votesJson.append('"').append(jsonEscape(e.getKey())).append("\":").append(e.getValue()); + } + votesJson.append('}'); + String currentTrack = arena.getCurrentMusicTrack(); + json.append("{\"name\":\"").append(jsonEscape(name)) + .append("\",\"state\":\"").append(arena.getPhase()) + .append("\",\"currentTrack\":").append(currentTrack == null ? "null" : "\"" + jsonEscape(currentTrack) + "\"") + .append(",\"votes\":").append(votesJson) + .append('}'); + } + json.append(']'); + writeJson(exchange, 200, json.toString()); + } + + private void handleVote(HttpExchange exchange) throws IOException { + if (preflight(exchange)) return; + if (!"POST".equalsIgnoreCase(exchange.getRequestMethod())) { + writeJson(exchange, 405, "{\"error\":\"POST required\"}"); + return; + } + Map params = queryParams(exchange.getRequestURI()); + String arena = params.get("arena"); + String code = params.get("code"); + String track = params.get("track"); + if (arena == null || code == null || track == null) { + writeJson(exchange, 400, "{\"error\":\"arena, code and track are all required\"}"); + return; + } + if (plugin.getArenaManager().get(arena).isEmpty()) { + writeJson(exchange, 404, "{\"error\":\"unknown arena\"}"); + return; + } + if (!isValidSound(track)) { + writeJson(exchange, 400, "{\"error\":\"unknown track\"}"); + return; + } + UUID playerId = voteCodeManager.resolve(code); + if (playerId == null) { + writeJson(exchange, 401, "{\"error\":\"invalid or expired code - run /blockparty musicvote in-game for a new one\"}"); + return; + } + voteManager.castVote(arena, playerId, track); + StringBuilder json = new StringBuilder("{\"ok\":true,\"votes\":{"); + boolean first = true; + for (Map.Entry e : voteManager.tally(arena).entrySet()) { + if (!first) json.append(','); + first = false; + json.append('"').append(jsonEscape(e.getKey())).append("\":").append(e.getValue()); + } + json.append("}}"); + writeJson(exchange, 200, json.toString()); + } + + // ---------- helpers ---------- + + private boolean isValidSound(String name) { + try { + Sound.valueOf(name); + return true; + } catch (IllegalArgumentException ex) { + return false; + } + } + + private boolean preflight(HttpExchange exchange) throws IOException { + exchange.getResponseHeaders().add("Access-Control-Allow-Origin", plugin.getConfigManager().getJukeboxAllowedOrigin()); + exchange.getResponseHeaders().add("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); + exchange.getResponseHeaders().add("Access-Control-Allow-Headers", "Content-Type"); + if ("OPTIONS".equalsIgnoreCase(exchange.getRequestMethod())) { + exchange.sendResponseHeaders(204, -1); + return true; + } + return false; + } + + private void writeJson(HttpExchange exchange, int status, String json) throws IOException { + byte[] body = json.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "application/json; charset=utf-8"); + exchange.sendResponseHeaders(status, body.length); + try (OutputStream os = exchange.getResponseBody()) { + os.write(body); + } + } + + private Map queryParams(URI uri) { + Map params = new java.util.HashMap<>(); + String query = uri.getRawQuery(); + if (query == null || query.isBlank()) { + return params; + } + for (String pair : query.split("&")) { + int eq = pair.indexOf('='); + if (eq < 0) { + continue; + } + String key = java.net.URLDecoder.decode(pair.substring(0, eq), StandardCharsets.UTF_8); + String value = java.net.URLDecoder.decode(pair.substring(eq + 1), StandardCharsets.UTF_8); + params.put(key, value); + } + return params; + } + + private String jsonEscape(String s) { + return s.replace("\\", "\\\\").replace("\"", "\\\""); + } +} diff --git a/src/main/java/us/tss3/blockparty/jukebox/VoteCodeManager.java b/src/main/java/us/tss3/blockparty/jukebox/VoteCodeManager.java new file mode 100644 index 0000000..2e41b6c --- /dev/null +++ b/src/main/java/us/tss3/blockparty/jukebox/VoteCodeManager.java @@ -0,0 +1,48 @@ +package us.tss3.blockparty.jukebox; + +import java.security.SecureRandom; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Issues short-lived, easy-to-type numeric codes that link a web voting session back to a + * specific logged-in player, without requiring any real account/password auth on the web + * page. A code is only ever handed out via the in-game {@code /blockparty musicvote} + * command, so possessing one proves you're the player who ran it (or someone they showed it + * to) for as long as it's valid. + */ +public class VoteCodeManager { + + private record CodeEntry(UUID playerId, long expiresAtMs) { + } + + private final Map codes = new ConcurrentHashMap<>(); + private final SecureRandom random = new SecureRandom(); + + public String issueCode(UUID playerId, int ttlMinutes) { + codes.values().removeIf(e -> e.playerId().equals(playerId)); + String code; + do { + code = String.valueOf(100000 + random.nextInt(900000)); + } while (codes.containsKey(code)); + codes.put(code, new CodeEntry(playerId, System.currentTimeMillis() + ttlMinutes * 60_000L)); + return code; + } + + /** Resolves a code to the player who issued it, or null if unknown/expired. */ + public UUID resolve(String code) { + if (code == null) { + return null; + } + CodeEntry entry = codes.get(code); + if (entry == null) { + return null; + } + if (System.currentTimeMillis() > entry.expiresAtMs()) { + codes.remove(code); + return null; + } + return entry.playerId(); + } +} diff --git a/src/main/java/us/tss3/blockparty/jukebox/VoteManager.java b/src/main/java/us/tss3/blockparty/jukebox/VoteManager.java new file mode 100644 index 0000000..12243ea --- /dev/null +++ b/src/main/java/us/tss3/blockparty/jukebox/VoteManager.java @@ -0,0 +1,43 @@ +package us.tss3.blockparty.jukebox; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Tracks per-arena, per-player music track votes in memory (one vote per player per arena, + * re-voting replaces their previous pick). Never persisted - votes are a live, ephemeral + * "what should play next" signal, reset whenever an arena returns to WAITING. + */ +public class VoteManager { + + /** arena name -> (player uuid -> track id) */ + private final Map> votes = new ConcurrentHashMap<>(); + + public void castVote(String arena, UUID playerId, String track) { + votes.computeIfAbsent(arena, k -> new ConcurrentHashMap<>()).put(playerId, track); + } + + /** track id -> vote count, insertion order not meaningful. */ + public Map tally(String arena) { + Map arenaVotes = votes.getOrDefault(arena, Map.of()); + Map counts = new LinkedHashMap<>(); + for (String track : arenaVotes.values()) { + counts.merge(track, 1, Integer::sum); + } + return counts; + } + + /** The current leading track for an arena, or null if nobody has voted. */ + public String winningTrack(String arena) { + return tally(arena).entrySet().stream() + .max(Map.Entry.comparingByValue()) + .map(Map.Entry::getKey) + .orElse(null); + } + + public void clear(String arena) { + votes.remove(arena); + } +} diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 64cbc51..c8d4547 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -30,6 +30,35 @@ music: track: MUSIC_DISC_PIGSTEP volume: 1.0 +jukebox: + # Lets players vote for an arena's music track from a web page, live. OFF by default: + # enabling this opens a plain HTTP port on this machine. You are responsible for + # firewalling it and putting it behind an HTTPS reverse proxy (Caddy/nginx/Cloudflare + # Tunnel/etc.) - a browser page served over HTTPS (e.g. a published Claude Artifact) + # cannot call a plain http:// address due to mixed-content blocking, and exposing raw + # HTTP directly to the internet is not recommended. See README "Jukebox" for setup. + enabled: false + bind-address: 127.0.0.1 + port: 8642 + # CORS origin allowed to call the API. "*" allows any page; tighten this to your + # actual voting page's origin if you want to restrict it. + allowed-origin: "*" + # How long a /blockparty musicvote code stays valid, in minutes. + vote-code-minutes: 10 + # id|label pairs offered to voters. "id" must be a valid Bukkit Sound enum name. + tracks: + - "MUSIC_DISC_PIGSTEP|Pigstep" + - "MUSIC_DISC_CAT|Cat" + - "MUSIC_DISC_BLOCKS|Blocks" + - "MUSIC_DISC_CHIRP|Chirp" + - "MUSIC_DISC_FAR|Far" + - "MUSIC_DISC_MELLOHI|Mellohi" + - "MUSIC_DISC_STAL|Stal" + - "MUSIC_DISC_STRAD|Strad" + - "MUSIC_DISC_WARD|Ward" + - "MUSIC_DISC_11|13" + - "MUSIC_DISC_WAIT|Wait" + performance: # blocks processed per server tick when generating/removing/restoring the floor floor-blocks-per-tick: 200 diff --git a/src/main/resources/messages.yml b/src/main/resources/messages.yml index 8f42813..f46b089 100644 --- a/src/main/resources/messages.yml +++ b/src/main/resources/messages.yml @@ -9,6 +9,7 @@ errors: arena-full: "Arena '%arena%' is full." no-region: "Arena '%arena%' has no floor region set (use pos1/pos2)." no-materials: "Arena '%arena%' has no floor materials configured." + jukebox-disabled: "The music jukebox is not enabled on this server." admin: arena-exists: "Arena '%arena%' already exists." @@ -34,6 +35,7 @@ admin: player: joined: "You joined arena '%arena%'." left: "You left the arena." + musicvote-code: "Your music vote code: %code% (valid %minutes% minutes) - enter it on the jukebox voting page to pick the next track." countdown: actionbar: "Starting in %time%s..."