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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<UUID> 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);
|
||||
}
|
||||
|
||||
@@ -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 <arena>",
|
||||
"/blockparty delete <arena>",
|
||||
"/blockparty enable <arena>",
|
||||
@@ -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<String> 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());
|
||||
}
|
||||
|
||||
@@ -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<String> getJukeboxTracks() {
|
||||
return config.getStringList("jukebox.tracks");
|
||||
}
|
||||
|
||||
public boolean isPlaceholderApiEnabled() {
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user