Add optional web jukebox: vote for arena music tracks via a small HTTP API
Build / build (push) Successful in 1m19s
Build / build (push) Successful in 1m19s
This commit is contained in:
@@ -57,6 +57,7 @@ work fine from console.
|
|||||||
| `/blockparty leave` | Leave your current arena |
|
| `/blockparty leave` | Leave your current arena |
|
||||||
| `/blockparty arenas` | List arenas (alias of `list`) |
|
| `/blockparty arenas` | List arenas (alias of `list`) |
|
||||||
| `/blockparty stats [player]` | View your or another player's stats |
|
| `/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
|
## 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.
|
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.
|
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=<arena>&code=<code>&track=<id>` — 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
|
### Reload behavior
|
||||||
|
|
||||||
`/blockparty reload` re-reads `config.yml`, `messages.yml`, and every arena file. Arenas
|
`/blockparty reload` re-reads `config.yml`, `messages.yml`, and every arena file. Arenas
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ import us.tss3.blockparty.arena.ArenaManager;
|
|||||||
import us.tss3.blockparty.command.BlockPartyCommand;
|
import us.tss3.blockparty.command.BlockPartyCommand;
|
||||||
import us.tss3.blockparty.config.ConfigManager;
|
import us.tss3.blockparty.config.ConfigManager;
|
||||||
import us.tss3.blockparty.config.MessagesManager;
|
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.GameplayRestrictionListener;
|
||||||
import us.tss3.blockparty.listener.PlayerConnectionListener;
|
import us.tss3.blockparty.listener.PlayerConnectionListener;
|
||||||
import us.tss3.blockparty.persistence.StatsManager;
|
import us.tss3.blockparty.persistence.StatsManager;
|
||||||
@@ -29,6 +32,9 @@ public class BlockPartyPlugin extends JavaPlugin implements Listener {
|
|||||||
private RewardManager rewardManager;
|
private RewardManager rewardManager;
|
||||||
private StatsManager statsManager;
|
private StatsManager statsManager;
|
||||||
private SoundUtil soundUtil;
|
private SoundUtil soundUtil;
|
||||||
|
private VoteManager voteManager;
|
||||||
|
private VoteCodeManager voteCodeManager;
|
||||||
|
private JukeboxServer jukeboxServer;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onEnable() {
|
public void onEnable() {
|
||||||
@@ -45,9 +51,15 @@ public class BlockPartyPlugin extends JavaPlugin implements Listener {
|
|||||||
this.statsManager = new StatsManager(this, configManager.getStorageSettings());
|
this.statsManager = new StatsManager(this, configManager.getStorageSettings());
|
||||||
statsManager.init();
|
statsManager.init();
|
||||||
|
|
||||||
|
this.voteManager = new VoteManager();
|
||||||
|
this.voteCodeManager = new VoteCodeManager();
|
||||||
|
|
||||||
this.arenaManager = new ArenaManager(this);
|
this.arenaManager = new ArenaManager(this);
|
||||||
arenaManager.loadAll();
|
arenaManager.loadAll();
|
||||||
|
|
||||||
|
this.jukeboxServer = new JukeboxServer(this, voteManager, voteCodeManager);
|
||||||
|
jukeboxServer.start();
|
||||||
|
|
||||||
this.scoreboardManager = new ScoreboardManager(this);
|
this.scoreboardManager = new ScoreboardManager(this);
|
||||||
scoreboardManager.start();
|
scoreboardManager.start();
|
||||||
|
|
||||||
@@ -69,6 +81,9 @@ public class BlockPartyPlugin extends JavaPlugin implements Listener {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onDisable() {
|
public void onDisable() {
|
||||||
|
if (jukeboxServer != null) {
|
||||||
|
jukeboxServer.stop();
|
||||||
|
}
|
||||||
if (arenaManager != null) {
|
if (arenaManager != null) {
|
||||||
arenaManager.shutdownAll();
|
arenaManager.shutdownAll();
|
||||||
}
|
}
|
||||||
@@ -144,4 +159,12 @@ public class BlockPartyPlugin extends JavaPlugin implements Listener {
|
|||||||
public SoundUtil getSoundUtil() {
|
public SoundUtil getSoundUtil() {
|
||||||
return soundUtil;
|
return soundUtil;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public VoteManager getVoteManager() {
|
||||||
|
return voteManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
public VoteCodeManager getVoteCodeManager() {
|
||||||
|
return voteCodeManager;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ public class Arena {
|
|||||||
* the match doesn't end just because "one player remains" — it keeps running rounds until
|
* the match doesn't end just because "one player remains" — it keeps running rounds until
|
||||||
* that player is actually eliminated or leaves. */
|
* that player is actually eliminated or leaves. */
|
||||||
private boolean soloMode;
|
private boolean soloMode;
|
||||||
|
private String currentMusicTrack;
|
||||||
private Material currentTarget;
|
private Material currentTarget;
|
||||||
private Material previousTarget;
|
private Material previousTarget;
|
||||||
private int currentRoundTime;
|
private int currentRoundTime;
|
||||||
@@ -118,6 +119,7 @@ public class Arena {
|
|||||||
cancelAllTasks();
|
cancelAllTasks();
|
||||||
billboardManager.despawn();
|
billboardManager.despawn();
|
||||||
pauseMusic();
|
pauseMusic();
|
||||||
|
plugin.getVoteManager().clear(config.getName());
|
||||||
// Force reset players out regardless of state
|
// Force reset players out regardless of state
|
||||||
List<UUID> all = new ArrayList<>();
|
List<UUID> all = new ArrayList<>();
|
||||||
all.addAll(players);
|
all.addAll(players);
|
||||||
@@ -385,14 +387,23 @@ public class Arena {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Starts the configured background music (if enabled) for every current player/spectator,
|
/** The track currently playing (or last played) for this arena's match, or null if music
|
||||||
* from the beginning of the track — Bukkit has no true "resume from position" API, so
|
* is disabled/hasn't started yet. Exposed for the optional jukebox web API. */
|
||||||
* pause/resume below is implemented as stop/replay-from-start rather than a real seek. */
|
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() {
|
private void startMusic() {
|
||||||
if (!plugin.getConfigManager().isMusicEnabled()) {
|
if (!plugin.getConfigManager().isMusicEnabled()) {
|
||||||
return;
|
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();
|
float volume = plugin.getConfigManager().getMusicVolume();
|
||||||
for (UUID uuid : allParticipants()) {
|
for (UUID uuid : allParticipants()) {
|
||||||
Player p = plugin.getServer().getPlayer(uuid);
|
Player p = plugin.getServer().getPlayer(uuid);
|
||||||
@@ -400,17 +411,17 @@ public class Arena {
|
|||||||
plugin.getSoundUtil().playMusic(p, track, volume);
|
plugin.getSoundUtil().playMusic(p, track, volume);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
currentMusicTrack = track;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void pauseMusic() {
|
private void pauseMusic() {
|
||||||
if (!plugin.getConfigManager().isMusicEnabled()) {
|
if (!plugin.getConfigManager().isMusicEnabled() || currentMusicTrack == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
String track = plugin.getConfigManager().getMusicTrack();
|
|
||||||
for (UUID uuid : allParticipants()) {
|
for (UUID uuid : allParticipants()) {
|
||||||
Player p = plugin.getServer().getPlayer(uuid);
|
Player p = plugin.getServer().getPlayer(uuid);
|
||||||
if (p != null) {
|
if (p != null) {
|
||||||
plugin.getSoundUtil().stopMusic(p, track);
|
plugin.getSoundUtil().stopMusic(p, currentMusicTrack);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -572,6 +583,8 @@ public class Arena {
|
|||||||
floorManager.cancelActiveTask();
|
floorManager.cancelActiveTask();
|
||||||
cancelAllTasks();
|
cancelAllTasks();
|
||||||
round = 0;
|
round = 0;
|
||||||
|
currentMusicTrack = null;
|
||||||
|
plugin.getVoteManager().clear(config.getName());
|
||||||
billboardManager.clear();
|
billboardManager.clear();
|
||||||
stateMachine.transition(ArenaPhase.WAITING);
|
stateMachine.transition(ArenaPhase.WAITING);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ public class BlockPartyCommand implements CommandExecutor, TabCompleter {
|
|||||||
case "leave" -> leave(sender);
|
case "leave" -> leave(sender);
|
||||||
case "arenas" -> list(sender);
|
case "arenas" -> list(sender);
|
||||||
case "stats" -> stats(sender, args);
|
case "stats" -> stats(sender, args);
|
||||||
|
case "musicvote" -> musicVote(sender);
|
||||||
default -> sendHelp(sender);
|
default -> sendHelp(sender);
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@@ -72,6 +73,7 @@ public class BlockPartyCommand implements CommandExecutor, TabCompleter {
|
|||||||
"/blockparty leave",
|
"/blockparty leave",
|
||||||
"/blockparty arenas",
|
"/blockparty arenas",
|
||||||
"/blockparty stats [player]",
|
"/blockparty stats [player]",
|
||||||
|
"/blockparty musicvote",
|
||||||
"/blockparty create <arena>",
|
"/blockparty create <arena>",
|
||||||
"/blockparty delete <arena>",
|
"/blockparty delete <arena>",
|
||||||
"/blockparty enable <arena>",
|
"/blockparty enable <arena>",
|
||||||
@@ -442,6 +444,19 @@ public class BlockPartyCommand implements CommandExecutor, TabCompleter {
|
|||||||
sender.sendMessage(plugin.getMessages().get("player.left"));
|
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) {
|
private void stats(CommandSender sender, String[] args) {
|
||||||
UUID target;
|
UUID target;
|
||||||
String targetName;
|
String targetName;
|
||||||
@@ -485,7 +500,7 @@ public class BlockPartyCommand implements CommandExecutor, TabCompleter {
|
|||||||
@Override
|
@Override
|
||||||
public List<String> onTabComplete(CommandSender sender, Command command, String alias, String[] args) {
|
public List<String> onTabComplete(CommandSender sender, Command command, String alias, String[] args) {
|
||||||
if (args.length == 1) {
|
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")
|
"setlobby", "setspawn", "setspectator", "setbillboard", "delbillboard", "billboards", "pos1", "pos2", "setfloor", "generate", "info", "list", "reload")
|
||||||
.stream().filter(s -> s.startsWith(args[0].toLowerCase())).collect(Collectors.toList());
|
.stream().filter(s -> s.startsWith(args[0].toLowerCase())).collect(Collectors.toList());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -109,6 +109,30 @@ public class ConfigManager {
|
|||||||
return (float) config.getDouble("music.volume", 1.0);
|
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<String> getJukeboxTracks() {
|
||||||
|
return config.getStringList("jukebox.tracks");
|
||||||
|
}
|
||||||
|
|
||||||
public boolean isPlaceholderApiEnabled() {
|
public boolean isPlaceholderApiEnabled() {
|
||||||
return config.getBoolean("integrations.placeholderapi", true);
|
return config.getBoolean("integrations.placeholderapi", true);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<String> 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<String, Integer> tally = voteManager.tally(name);
|
||||||
|
StringBuilder votesJson = new StringBuilder("{");
|
||||||
|
boolean firstVote = true;
|
||||||
|
for (Map.Entry<String, Integer> 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<String, String> 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<String, Integer> 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<String, String> queryParams(URI uri) {
|
||||||
|
Map<String, String> 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("\"", "\\\"");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String, CodeEntry> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String, Map<UUID, String>> 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<String, Integer> tally(String arena) {
|
||||||
|
Map<UUID, String> arenaVotes = votes.getOrDefault(arena, Map.of());
|
||||||
|
Map<String, Integer> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,6 +30,35 @@ music:
|
|||||||
track: MUSIC_DISC_PIGSTEP
|
track: MUSIC_DISC_PIGSTEP
|
||||||
volume: 1.0
|
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:
|
performance:
|
||||||
# blocks processed per server tick when generating/removing/restoring the floor
|
# blocks processed per server tick when generating/removing/restoring the floor
|
||||||
floor-blocks-per-tick: 200
|
floor-blocks-per-tick: 200
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ errors:
|
|||||||
arena-full: "<red>Arena '%arena%' is full.</red>"
|
arena-full: "<red>Arena '%arena%' is full.</red>"
|
||||||
no-region: "<red>Arena '%arena%' has no floor region set (use pos1/pos2).</red>"
|
no-region: "<red>Arena '%arena%' has no floor region set (use pos1/pos2).</red>"
|
||||||
no-materials: "<red>Arena '%arena%' has no floor materials configured.</red>"
|
no-materials: "<red>Arena '%arena%' has no floor materials configured.</red>"
|
||||||
|
jukebox-disabled: "<red>The music jukebox is not enabled on this server.</red>"
|
||||||
|
|
||||||
admin:
|
admin:
|
||||||
arena-exists: "<red>Arena '%arena%' already exists.</red>"
|
arena-exists: "<red>Arena '%arena%' already exists.</red>"
|
||||||
@@ -34,6 +35,7 @@ admin:
|
|||||||
player:
|
player:
|
||||||
joined: "<green>You joined arena '%arena%'.</green>"
|
joined: "<green>You joined arena '%arena%'.</green>"
|
||||||
left: "<yellow>You left the arena.</yellow>"
|
left: "<yellow>You left the arena.</yellow>"
|
||||||
|
musicvote-code: "<green>Your music vote code: <bold>%code%</bold></green> <gray>(valid %minutes% minutes) - enter it on the jukebox voting page to pick the next track.</gray>"
|
||||||
|
|
||||||
countdown:
|
countdown:
|
||||||
actionbar: "<yellow>Starting in %time%s...</yellow>"
|
actionbar: "<yellow>Starting in %time%s...</yellow>"
|
||||||
|
|||||||
Reference in New Issue
Block a user