Build configurable BlockParty minigame
Build / build (push) Failing after 27s

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
Michael Burgess
2026-08-07 05:47:55 -04:00
co-authored by Claude Sonnet 5
commit 7b02b7efb7
49 changed files with 4349 additions and 0 deletions
+29
View File
@@ -0,0 +1,29 @@
name: Build
on:
push:
branches: [ "master", "main" ]
pull_request:
branches: [ "master", "main" ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '21'
- name: Build with Gradle
run: ./gradlew clean build
- name: Upload plugin jar
uses: actions/upload-artifact@v3
with:
name: BlockParty
path: build/libs/BlockParty-*.jar
+8
View File
@@ -0,0 +1,8 @@
.gradle/
build/
!gradle/wrapper/gradle-wrapper.jar
.idea/
*.iml
out/
.vscode/
*.log
+213
View File
@@ -0,0 +1,213 @@
# BlockParty
BlockParty is a colored-floor elimination minigame for PaperMC servers. Players stand on a
floor of colored blocks; each round a target color is announced and players must move onto
it before time runs out. All other blocks vanish, anyone not standing on the target color
(or standing on nothing) falls and is eliminated, and the floor is restored for the next,
slightly faster round. Last player standing wins.
## Requirements
- PaperMC 1.21.x (API version `1.21`)
- Java 21
- No other plugins required. Optional soft-dependencies: PlaceholderAPI, Vault.
## Installation
1. Download `BlockParty-<version>.jar` (see [Building from source](#building-from-source))
or grab it from the CI build artifacts.
2. Drop it into your server's `plugins/` folder.
3. Start/restart the server. `config.yml`, `messages.yml` and the `arenas/` folder are
created automatically under `plugins/BlockParty/`.
## Commands
All admin/config commands live under `/blockparty` (alias `/bp`) and work from console
where it makes sense (`create`, `delete`, `enable`, `disable`, `info`, `list`, `reload`).
Commands that require a physical location (`setlobby`, `setspawn`, `setspectator`,
`pos1`, `pos2`) require a player sender.
### Admin
| Command | Description |
|---|---|
| `/blockparty help` | Show command help |
| `/blockparty create <arena>` | Create a new, disabled arena |
| `/blockparty delete <arena>` | Delete an arena and its config file |
| `/blockparty enable <arena>` | Validate and enable an arena |
| `/blockparty disable <arena>` | Disable an arena, kicking anyone inside |
| `/blockparty setlobby <arena>` | Set the waiting-room location |
| `/blockparty setspawn <arena>` | Set the in-game spawn location |
| `/blockparty setspectator <arena>` | Set the spectator/eliminated location |
| `/blockparty pos1 <arena>` | Set floor region corner 1 |
| `/blockparty pos2 <arena>` | Set floor region corner 2 |
| `/blockparty setfloor <arena> <mat1,mat2,...>` | Set the allowed floor materials |
| `/blockparty generate <arena>` | Randomly generate and persist the floor layout |
| `/blockparty info <arena>` | Show arena configuration/status and validation errors |
| `/blockparty list` | List all arenas |
| `/blockparty reload` | Reload config.yml, messages.yml and arena definitions |
### Player
| Command | Description |
|---|---|
| `/blockparty join <arena>` | Join an arena's waiting lobby |
| `/blockparty leave` | Leave your current arena |
| `/blockparty arenas` | List arenas (alias of `list`) |
| `/blockparty stats [player]` | View your or another player's stats |
## Permissions
| Permission | Default | Description |
|---|---|---|
| `blockparty.play` | true | Baseline permission to use player commands |
| `blockparty.join` | true | Allows joining arenas |
| `blockparty.spectate` | true | Allows spectating |
| `blockparty.admin` | op | Parent permission, grants all `blockparty.admin.*` |
| `blockparty.admin.create` | op | Create arenas |
| `blockparty.admin.delete` | op | Delete arenas |
| `blockparty.admin.setup` | op | Configure lobby/spawn/floor/etc, enable/disable |
| `blockparty.admin.reload` | op | Reload the plugin |
## Creating an arena — full walkthrough
1. `/bp create lobby1` — creates `plugins/BlockParty/arenas/lobby1.yml`.
2. Stand where players should wait and run `/bp setlobby lobby1`.
3. Stand where players should spawn on the floor and run `/bp setspawn lobby1`.
4. Stand where eliminated players/spectators should go and run `/bp setspectator lobby1`.
5. Stand at one corner of the floor area and run `/bp pos1 lobby1`.
6. Stand at the opposite corner and run `/bp pos2 lobby1`.
7. Either:
- Run `/bp setfloor lobby1 RED_CONCRETE,BLUE_CONCRETE,GREEN_CONCRETE,YELLOW_CONCRETE` to
choose the color palette (defaults to the full concrete rainbow if you skip this and
just generate), then `/bp generate lobby1` to lay out and persist a random floor, **or**
- Build the floor by hand out of allowed materials, then run `/bp generate lobby1` is
still required once so BlockParty captures/serializes the exact layout it should restore
each round (BlockParty does not scan the world every round — it restores from the saved
layout for performance and determinism).
8. `/bp info lobby1` to confirm there are no validation errors.
9. `/bp enable lobby1`.
10. Players run `/bp join lobby1`. Once `min-players` is reached the countdown starts
automatically; joining an arena mid-countdown is allowed by default
(`allow-join-while-waiting: true`).
### Floor selection details
- `pos1` / `pos2` define the two opposite corners of the rectangular floor region (any Y).
- `setfloor` only changes which materials are *allowed* to be used/regenerated — it does not
touch blocks in the world.
- `generate` is the step that actually writes a random layout (biased evenly across the
configured palette) across the whole region and serializes it into the arena's yml file
(`generated-layout`) so it can be restored exactly, every round, without re-scanning or
re-randomizing the world.
## Player usage
- `/bp join <arena>` to enter the waiting lobby. Your inventory, armor, offhand, health,
food, XP, game mode, flight state, location and potion effects are all captured and will
be restored exactly when you leave, get eliminated-and-the-match-ends, or the match ends.
- `/bp leave` at any time to exit and be restored.
- During a round, a title/subtitle/action bar/sound announce the target color, and the
target block is placed in your hotbar (slot 9) as a visual reminder. A boss bar (if
enabled) shows the remaining time as a shrinking progress bar.
- Get eliminated by not standing on the target color when the floor clears, or by falling
off entirely. Eliminated players become spectators at the configured spectator location.
- Last player standing wins; rewards, stats and celebration effects (fireworks, titles,
sounds) fire automatically.
## Configuration overview
- **`config.yml`** — global settings: `ui.*` toggles for scoreboard/bossbar/titles/actionbar,
`sounds.*` (Bukkit `Sound` enum names; invalid names are logged and skipped, never crash
the plugin), `performance.floor-blocks-per-tick` (batch size for floor mutations),
`defaults.*` (used only when `/bp create` seeds a new arena), `default-floor-materials`
(the full 16-color concrete palette by default), `rewards.winner.commands`, and
`integrations.placeholderapi`.
- **`messages.yml`** — every user-facing string, in [MiniMessage](https://docs.advntr.dev/minimessage/format.html)
format (Adventure, bundled with Paper). Placeholders like `%player%`, `%arena%`, `%round%`,
`%time%`, `%color%` are substituted per-message.
- **`arenas/<name>.yml`** — one file per arena; a broken file only disables that one arena
(the rest still load) and the error is logged.
### Reload behavior
`/blockparty reload` re-reads `config.yml`, `messages.yml`, and every arena file. Arenas
currently in the `RUNNING` or `ENDING` state are **skipped** during arena reload (their
config keeps running unmodified) to avoid corrupting an active match; a warning is logged
per skipped arena. Arenas that are `WAITING`, `STARTING` or `DISABLED` are reloaded fully
from disk. This is a deliberate design choice: rather than queuing a reload for when the
arena goes idle, in-progress matches simply finish on their old configuration and pick up
the new configuration the next time they return to `WAITING`.
## Rewards
```yaml
rewards:
winner:
commands:
- "eco give %player% 100"
- "say %player% won BlockParty in %arena% after %round% rounds!"
```
Commands run as the console, so no Vault/economy plugin is required by BlockParty itself —
if you reference an economy command (like `eco give`) you need that plugin installed
separately. `%player%`, `%arena%` and `%round%` are replaced before dispatch.
## PlaceholderAPI support
If PlaceholderAPI is installed, BlockParty registers a `%blockparty_...%` expansion
automatically (soft-dependency — startup never fails if PAPI is absent):
- `%blockparty_wins%`
- `%blockparty_games_played%`
- `%blockparty_best_round%`
- `%blockparty_arena%`
- `%blockparty_players_<arena>%`
- `%blockparty_state_<arena>%`
## Building from source
```bash
./gradlew clean build
```
The shaded plugin jar (with `sqlite-jdbc` bundled and relocated) is produced at
`build/libs/BlockParty-<version>.jar`.
Run unit tests only:
```bash
./gradlew test
```
Unit tests cover the pure-logic package (`us.tss3.blockparty.logic`) with no Bukkit
dependency: arena state transitions, countdown start/cancel logic, round-time
progression/clamping math, target-color selection (deterministic via an injectable RNG),
elimination/winner determination, arena config validation, and player-count/join logic.
## Troubleshooting
- **"Arena failed validation" on enable** — run `/bp info <arena>` to see exactly which
fields are missing (lobby/spawn/spectator/region/materials/etc).
- **World unloaded warning** — if an arena's world is unloaded while the plugin is running,
BlockParty detects it, safely disables that arena and restores any players inside it.
- **Invalid sound/material in config** — logged as a warning at startup/use and skipped;
it will not crash the plugin.
- **Nothing happens on `/bp join`** — check the arena is enabled (`/bp info <arena>`), not
full, and that you are not already in another arena (`/bp leave` first).
- **Floor doesn't look right** — remember `setfloor` only changes the allowed palette; you
must run `/bp generate <arena>` to actually (re)generate and persist a layout.
## Architecture
- `logic/` — pure, Bukkit-free game logic (unit tested).
- `arena/``Arena` (per-arena runtime state machine + round loop) and `ArenaManager`.
- `config/``ArenaConfig`, `ArenaConfigLoader`, `ConfigManager`, `MessagesManager`.
- `floor/``FloorManager`, tick-batched floor generation/removal/restoration.
- `session/``PlayerSession`, `SessionManager` (single-arena-membership enforcement).
- `model/``PlayerState` snapshot/restore.
- `command/``BlockPartyCommand` (single entry point, dispatches to handlers).
- `listener/` — connection/quit handling and gameplay restrictions.
- `scoreboard/` — interval-based sidebar scoreboard.
- `reward/` — console-command reward dispatch.
- `persistence/` — async SQLite-backed stats (`StatsDatabase`, `StatsManager`).
- `placeholder/` — optional PlaceholderAPI expansion, loaded only when PAPI is present.
- `util/` — sound/location helpers.
+57
View File
@@ -0,0 +1,57 @@
plugins {
id 'java'
id 'com.gradleup.shadow' version '8.3.5'
}
group = 'us.tss3'
version = '1.0.0'
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
repositories {
mavenCentral()
maven { url = 'https://repo.papermc.io/repository/maven-public/' }
maven { url = 'https://repo.extendedclip.com/content/repositories/placeholderapi/' }
}
dependencies {
compileOnly 'io.papermc.paper:paper-api:1.21.4-R0.1-SNAPSHOT'
compileOnly 'me.clip:placeholderapi:2.11.6'
implementation 'org.xerial:sqlite-jdbc:3.47.1.0'
testImplementation platform('org.junit:junit-bom:5.11.3')
testImplementation 'org.junit.jupiter:junit-jupiter'
}
def targetJavaVersion = 21
tasks.withType(JavaCompile).configureEach {
options.encoding = 'UTF-8'
options.release.set(targetJavaVersion)
}
processResources {
def props = [version: version]
inputs.properties props
filteringCharset 'UTF-8'
filesMatching('plugin.yml') {
expand props
}
}
shadowJar {
archiveClassifier.set('')
archiveBaseName.set('BlockParty')
relocate 'org.sqlite', 'us.tss3.blockparty.libs.sqlite'
}
test {
useJUnitPlatform()
}
build {
dependsOn shadowJar
}
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored
+252
View File
@@ -0,0 +1,252 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
Vendored
+94
View File
@@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+1
View File
@@ -0,0 +1 @@
rootProject.name = 'BlockParty'
@@ -0,0 +1,147 @@
package us.tss3.blockparty;
import org.bukkit.Bukkit;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.world.WorldUnloadEvent;
import org.bukkit.plugin.java.JavaPlugin;
import us.tss3.blockparty.arena.Arena;
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.listener.GameplayRestrictionListener;
import us.tss3.blockparty.listener.PlayerConnectionListener;
import us.tss3.blockparty.persistence.StatsManager;
import us.tss3.blockparty.placeholder.BlockPartyExpansion;
import us.tss3.blockparty.reward.RewardManager;
import us.tss3.blockparty.scoreboard.ScoreboardManager;
import us.tss3.blockparty.session.SessionManager;
import us.tss3.blockparty.util.SoundUtil;
public class BlockPartyPlugin extends JavaPlugin implements Listener {
private ConfigManager configManager;
private MessagesManager messagesManager;
private ArenaManager arenaManager;
private SessionManager sessionManager;
private ScoreboardManager scoreboardManager;
private RewardManager rewardManager;
private StatsManager statsManager;
private SoundUtil soundUtil;
@Override
public void onEnable() {
saveDefaultConfig();
this.configManager = new ConfigManager(this);
configManager.load();
this.messagesManager = new MessagesManager(this);
messagesManager.load();
this.sessionManager = new SessionManager();
this.soundUtil = new SoundUtil(this);
this.rewardManager = new RewardManager(this);
this.statsManager = new StatsManager(this);
statsManager.init();
this.arenaManager = new ArenaManager(this);
arenaManager.loadAll();
this.scoreboardManager = new ScoreboardManager(this);
scoreboardManager.start();
getServer().getPluginManager().registerEvents(new PlayerConnectionListener(this), this);
getServer().getPluginManager().registerEvents(new GameplayRestrictionListener(this), this);
getServer().getPluginManager().registerEvents(this, this);
BlockPartyCommand commandHandler = new BlockPartyCommand(this);
var command = getCommand("blockparty");
if (command != null) {
command.setExecutor(commandHandler);
command.setTabCompleter(commandHandler);
}
registerPlaceholderApi();
getLogger().info("BlockParty enabled.");
}
@Override
public void onDisable() {
if (arenaManager != null) {
arenaManager.shutdownAll();
}
if (scoreboardManager != null) {
scoreboardManager.stop();
}
if (statsManager != null) {
statsManager.shutdown();
}
getLogger().info("BlockParty disabled.");
}
/** Reloads config.yml, messages.yml and arena definitions. Arenas mid-match are skipped (see ArenaManager#reloadSafely). */
public void reloadEverything() {
configManager.load();
messagesManager.load();
arenaManager.reloadSafely();
}
private void registerPlaceholderApi() {
if (!configManager.isPlaceholderApiEnabled()) {
return;
}
if (Bukkit.getPluginManager().getPlugin("PlaceholderAPI") == null) {
return;
}
try {
new BlockPartyExpansion(this).register();
getLogger().info("Hooked into PlaceholderAPI.");
} catch (Throwable t) {
getLogger().warning("Failed to register PlaceholderAPI expansion: " + t.getMessage());
}
}
@EventHandler
public void onWorldUnload(WorldUnloadEvent event) {
for (Arena arena : arenaManager.all()) {
if (arena.getConfig().getWorldName() != null && arena.getConfig().getWorldName().equals(event.getWorld().getName())) {
getLogger().warning("World '" + event.getWorld().getName() + "' unloaded - disabling arena '" + arena.getConfig().getName() + "'.");
arena.disable();
}
}
}
public ConfigManager getConfigManager() {
return configManager;
}
public MessagesManager getMessages() {
return messagesManager;
}
public ArenaManager getArenaManager() {
return arenaManager;
}
public SessionManager getSessionManager() {
return sessionManager;
}
public ScoreboardManager getScoreboardManager() {
return scoreboardManager;
}
public RewardManager getRewardManager() {
return rewardManager;
}
public StatsManager getStatsManager() {
return statsManager;
}
public SoundUtil getSoundUtil() {
return soundUtil;
}
}
@@ -0,0 +1,529 @@
package us.tss3.blockparty.arena;
import net.kyori.adventure.bossbar.BossBar;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.title.Title;
import org.bukkit.GameMode;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
import org.bukkit.scheduler.BukkitTask;
import us.tss3.blockparty.BlockPartyPlugin;
import us.tss3.blockparty.config.ArenaConfig;
import us.tss3.blockparty.floor.FloorManager;
import us.tss3.blockparty.logic.ArenaPhase;
import us.tss3.blockparty.logic.ArenaStateMachine;
import us.tss3.blockparty.logic.CountdownLogic;
import us.tss3.blockparty.logic.EliminationLogic;
import us.tss3.blockparty.logic.RoundTimeCalculator;
import us.tss3.blockparty.logic.TargetColorSelector;
import us.tss3.blockparty.model.PlayerState;
import us.tss3.blockparty.session.PlayerSession;
import java.time.Duration;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Random;
import java.util.UUID;
/** A single running arena: owns its state machine, tasks and round lifecycle. */
public class Arena {
private final BlockPartyPlugin plugin;
private final ArenaConfig config;
private final FloorManager floorManager;
private final ArenaStateMachine stateMachine = new ArenaStateMachine(ArenaPhase.DISABLED);
private final Random random = new Random();
private final TargetColorSelector selector = new TargetColorSelector(random::nextInt);
private final List<UUID> players = new ArrayList<>();
private final List<UUID> spectators = new ArrayList<>();
private final List<BukkitTask> tasks = new ArrayList<>();
private BossBar bossBar;
private int countdownRemaining;
private int round = 0;
private Material currentTarget;
private Material previousTarget;
private int currentRoundTime;
private int roundTimeRemaining;
public Arena(BlockPartyPlugin plugin, ArenaConfig config) {
this.plugin = plugin;
this.config = config;
this.floorManager = new FloorManager(plugin, config);
if (config.isEnabled()) {
stateMachine.transition(ArenaPhase.WAITING);
}
}
public ArenaConfig getConfig() {
return config;
}
public FloorManager getFloorManager() {
return floorManager;
}
public ArenaPhase getPhase() {
return stateMachine.getState();
}
public List<UUID> getPlayers() {
return players;
}
public List<UUID> getSpectators() {
return spectators;
}
public int getRound() {
return round;
}
public Material getCurrentTarget() {
return currentTarget;
}
public int getRoundTimeRemaining() {
return roundTimeRemaining;
}
public int getCurrentRoundTime() {
return currentRoundTime;
}
public boolean isEnabled() {
return getPhase() != ArenaPhase.DISABLED;
}
public void enable() {
if (stateMachine.transition(ArenaPhase.WAITING)) {
config.setEnabled(true);
}
}
public void disable() {
cancelAllTasks();
// Force reset players out regardless of state
List<UUID> all = new ArrayList<>();
all.addAll(players);
all.addAll(spectators);
for (UUID uuid : all) {
removePlayerForce(uuid);
}
stateMachine.transition(ArenaPhase.DISABLED);
config.setEnabled(false);
}
// ---------- Join / Leave ----------
public boolean canJoin() {
ArenaPhase phase = getPhase();
if (phase != ArenaPhase.WAITING && !(phase == ArenaPhase.STARTING && config.isAllowJoinWhileStarting())) {
return false;
}
return players.size() < config.getMaxPlayers();
}
public void addPlayer(Player player) {
PlayerState state = PlayerState.capture(player);
PlayerSession session = new PlayerSession(player.getUniqueId(), config.getName(), state);
plugin.getSessionManager().add(session);
players.add(player.getUniqueId());
preparePlayerForLobby(player);
plugin.getScoreboardManager().attach(player, this);
maybeStartCountdown();
}
private void preparePlayerForLobby(Player player) {
player.getInventory().clear();
player.setGameMode(GameMode.ADVENTURE);
player.setHealth(20.0);
player.setFoodLevel(20);
player.setFireTicks(0);
if (config.getLobby() != null) {
player.teleport(config.getLobby());
}
}
public void removePlayer(UUID uuid, boolean restoreState) {
players.remove(uuid);
spectators.remove(uuid);
PlayerSession session = plugin.getSessionManager().get(uuid);
Player player = plugin.getServer().getPlayer(uuid);
if (session != null) {
if (restoreState && player != null) {
session.getSavedState().restore(player);
}
plugin.getSessionManager().remove(uuid);
}
if (player != null) {
plugin.getScoreboardManager().detach(player);
}
checkCountdownCancel();
checkForWinner();
}
private void removePlayerForce(UUID uuid) {
removePlayer(uuid, true);
}
private void checkCountdownCancel() {
if (getPhase() == ArenaPhase.STARTING
&& CountdownLogic.shouldCancel(players.size(), config.getMinPlayers(), true)) {
cancelCountdown();
}
}
// ---------- Countdown / Starting ----------
private void maybeStartCountdown() {
if (getPhase() == ArenaPhase.WAITING
&& CountdownLogic.shouldStart(players.size(), config.getMinPlayers(), false)) {
startCountdown();
}
}
private void startCountdown() {
if (!stateMachine.transition(ArenaPhase.STARTING)) {
return;
}
countdownRemaining = config.getCountdownDuration();
BukkitTask task = plugin.getServer().getScheduler().runTaskTimer(plugin, () -> {
if (CountdownLogic.isFinished(countdownRemaining)) {
return;
}
broadcastCountdownTick();
countdownRemaining = CountdownLogic.tick(countdownRemaining);
if (CountdownLogic.isFinished(countdownRemaining)) {
beginMatch();
}
}, 0L, 20L);
tasks.add(task);
}
private void cancelCountdown() {
cancelAllTasks();
stateMachine.transition(ArenaPhase.WAITING);
for (UUID uuid : players) {
Player p = plugin.getServer().getPlayer(uuid);
if (p != null) {
p.sendMessage(plugin.getMessages().get("countdown.cancelled"));
}
}
}
private void broadcastCountdownTick() {
Map<String, String> ph = Map.of("time", String.valueOf(countdownRemaining), "arena", config.getName());
for (UUID uuid : players) {
Player p = plugin.getServer().getPlayer(uuid);
if (p == null) {
continue;
}
if (plugin.getConfigManager().isActionbarEnabled()) {
p.sendActionBar(plugin.getMessages().get("countdown.actionbar", ph));
}
if (plugin.getConfigManager().isTitlesEnabled() && (countdownRemaining <= 5 || countdownRemaining == config.getCountdownDuration())) {
p.showTitle(Title.title(plugin.getMessages().get("countdown.title", ph), Component.empty()));
}
plugin.getSoundUtil().play(p, plugin.getConfigManager().getSound("countdown"), 1f, 1f);
}
}
// ---------- Match flow ----------
private void beginMatch() {
cancelAllTasks();
if (!stateMachine.transition(ArenaPhase.RUNNING)) {
return;
}
round = 0;
previousTarget = null;
if (!floorManager.hasLayout()) {
floorManager.generate(random);
}
floorManager.restoreFull(plugin.getConfigManager().getFloorBatchBlocksPerTick(), () -> {
for (UUID uuid : players) {
Player p = plugin.getServer().getPlayer(uuid);
if (p == null) {
continue;
}
p.setGameMode(GameMode.ADVENTURE);
if (config.getSpawn() != null) {
p.teleport(config.getSpawn());
}
}
startNextRound();
});
}
private void startNextRound() {
if (EliminationLogic.isMatchOver(players.size())) {
endMatch(players.isEmpty() ? null : (players.isEmpty() ? null : players.get(0)));
return;
}
round++;
List<Material> available = floorManager.materialsInLayout().stream().distinct().toList();
if (available.isEmpty()) {
available = config.getFloorMaterials();
}
currentTarget = selector.selectExcluding(available, previousTarget);
previousTarget = currentTarget;
currentRoundTime = RoundTimeCalculator.timeForRound(
config.getStartingRoundTime(), config.getMinimumRoundTime(), config.getRoundTimeReduction(), round);
roundTimeRemaining = currentRoundTime;
announceTarget();
setupBossBar();
BukkitTask task = plugin.getServer().getScheduler().runTaskTimer(plugin, () -> {
roundTimeRemaining--;
updateBossBar();
if (roundTimeRemaining <= 0) {
cancelAllTasks();
processRoundEnd();
}
}, 20L, 20L);
tasks.add(task);
}
private void announceTarget() {
Map<String, String> ph = Map.of("color", formatMaterial(currentTarget), "round", String.valueOf(round),
"time", String.valueOf(currentRoundTime));
ItemStack display = new ItemStack(currentTarget);
for (UUID uuid : players) {
Player p = plugin.getServer().getPlayer(uuid);
if (p == null) {
continue;
}
if (plugin.getConfigManager().isTitlesEnabled()) {
p.showTitle(Title.title(plugin.getMessages().get("round.title", ph), plugin.getMessages().get("round.subtitle", ph)));
}
if (plugin.getConfigManager().isActionbarEnabled()) {
p.sendActionBar(plugin.getMessages().get("round.actionbar", ph));
}
p.getInventory().setItem(8, display.clone());
plugin.getSoundUtil().play(p, plugin.getConfigManager().getSound("target-select"), 1f, 1f);
}
}
private void setupBossBar() {
if (!plugin.getConfigManager().isBossbarEnabled()) {
return;
}
if (bossBar == null) {
bossBar = BossBar.bossBar(Component.text("BlockParty"), 1f, BossBar.Color.YELLOW, BossBar.Overlay.PROGRESS);
}
bossBar.progress(1f);
for (UUID uuid : players) {
Player p = plugin.getServer().getPlayer(uuid);
if (p != null) {
p.showBossBar(bossBar);
}
}
}
private void updateBossBar() {
if (bossBar == null || currentRoundTime <= 0) {
return;
}
float progress = Math.max(0f, Math.min(1f, (float) roundTimeRemaining / (float) currentRoundTime));
bossBar.progress(progress);
}
private void processRoundEnd() {
plugin.getSoundUtil().play(config.getSpawn(), plugin.getConfigManager().getSound("floor-disappear"), 1f, 1f);
floorManager.removeNonTarget(currentTarget, plugin.getConfigManager().getFloorBatchBlocksPerTick(), () -> {
BukkitTask delay = plugin.getServer().getScheduler().runTaskLater(plugin, this::evaluateEliminations,
config.getFloorRemoveDelaySeconds() * 20L);
tasks.add(delay);
});
}
private void evaluateEliminations() {
List<EliminationLogic.PlayerFloorState<UUID, Material>> states = new ArrayList<>();
for (UUID uuid : players) {
Player p = plugin.getServer().getPlayer(uuid);
Material standing = null;
if (p != null && p.getWorld() != null) {
standing = floorManager.materialAtLocation(p.getWorld(), p.getLocation().getX(), p.getLocation().getY(), p.getLocation().getZ());
}
states.add(new EliminationLogic.PlayerFloorState<>(uuid, standing));
}
List<UUID> eliminated = EliminationLogic.determineEliminated(states, currentTarget);
for (UUID uuid : eliminated) {
eliminatePlayer(uuid);
}
for (UUID uuid : players) {
PlayerSession session = plugin.getSessionManager().get(uuid);
if (session != null && !eliminated.contains(uuid)) {
session.incrementRoundsSurvived();
}
}
if (EliminationLogic.isMatchOver(players.size())) {
UUID winner = players.isEmpty() ? null : players.get(0);
if (bossBar != null) {
for (UUID uuid : spectators) {
Player p = plugin.getServer().getPlayer(uuid);
if (p != null) {
p.hideBossBar(bossBar);
}
}
}
endMatch(winner);
return;
}
BukkitTask restoreDelay = plugin.getServer().getScheduler().runTaskLater(plugin, () ->
floorManager.restoreFull(plugin.getConfigManager().getFloorBatchBlocksPerTick(), this::startNextRound),
config.getFloorRestoreDelaySeconds() * 20L);
tasks.add(restoreDelay);
}
private void eliminatePlayer(UUID uuid) {
players.remove(uuid);
spectators.add(uuid);
Player p = plugin.getServer().getPlayer(uuid);
PlayerSession session = plugin.getSessionManager().get(uuid);
if (session != null) {
session.setRole(PlayerSession.Role.SPECTATOR);
session.setEliminated(true);
}
if (p != null) {
p.setGameMode(GameMode.SPECTATOR);
if (config.getSpectator() != null) {
p.teleport(config.getSpectator());
}
p.sendMessage(plugin.getMessages().get("game.eliminated", Map.of("arena", config.getName(), "round", String.valueOf(round))));
plugin.getSoundUtil().play(p, plugin.getConfigManager().getSound("eliminate"), 1f, 1f);
if (bossBar != null) {
p.hideBossBar(bossBar);
}
}
plugin.getStatsManager().recordElimination(uuid, round);
}
private void endMatch(UUID winner) {
stateMachine.transition(ArenaPhase.ENDING);
cancelAllTasks();
if (winner != null) {
Player winnerPlayer = plugin.getServer().getPlayer(winner);
String winnerName = winnerPlayer != null ? winnerPlayer.getName() : "Unknown";
for (UUID uuid : allParticipants()) {
Player p = plugin.getServer().getPlayer(uuid);
if (p == null) {
continue;
}
p.showTitle(Title.title(plugin.getMessages().get("game.win-title", Map.of("player", winnerName)),
Component.empty()));
plugin.getSoundUtil().play(p, plugin.getConfigManager().getSound("victory"), 1f, 1f);
}
if (winnerPlayer != null) {
spawnCelebration(winnerPlayer);
}
plugin.getStatsManager().recordWin(winner);
plugin.getRewardManager().dispatchWinnerRewards(winnerName, config.getName(), round);
} else {
for (UUID uuid : allParticipants()) {
Player p = plugin.getServer().getPlayer(uuid);
if (p != null) {
p.sendMessage(plugin.getMessages().get("game.no-winner"));
}
}
}
for (UUID uuid : players) {
plugin.getStatsManager().recordGamePlayed(uuid);
}
for (UUID uuid : spectators) {
plugin.getStatsManager().recordGamePlayed(uuid);
}
BukkitTask endTask = plugin.getServer().getScheduler().runTaskLater(plugin, this::resetToWaiting,
config.getWinEndingDelaySeconds() * 20L);
tasks.add(endTask);
}
private void spawnCelebration(Player winner) {
try {
for (int i = 0; i < 3; i++) {
org.bukkit.entity.Firework fw = winner.getWorld().spawn(winner.getLocation(), org.bukkit.entity.Firework.class);
org.bukkit.inventory.meta.FireworkMeta meta = fw.getFireworkMeta();
meta.addEffect(org.bukkit.FireworkEffect.builder()
.withColor(org.bukkit.Color.YELLOW, org.bukkit.Color.LIME)
.with(org.bukkit.FireworkEffect.Type.BALL_LARGE)
.trail(true)
.build());
meta.setPower(1);
fw.setFireworkMeta(meta);
}
} catch (Exception ignored) {
}
}
private List<UUID> allParticipants() {
List<UUID> all = new ArrayList<>(players);
all.addAll(spectators);
return all;
}
private void resetToWaiting() {
List<UUID> all = allParticipants();
for (UUID uuid : all) {
removePlayer(uuid, true);
}
players.clear();
spectators.clear();
if (bossBar != null) {
bossBar.removeViewer(plugin.getServer().getConsoleSender());
}
floorManager.cancelActiveTask();
cancelAllTasks();
round = 0;
stateMachine.transition(ArenaPhase.WAITING);
}
private void checkForWinner() {
if (getPhase() == ArenaPhase.RUNNING && EliminationLogic.isMatchOver(players.size())) {
cancelAllTasks();
evaluateEliminations();
}
}
private void cancelAllTasks() {
for (BukkitTask task : tasks) {
if (task != null) {
task.cancel();
}
}
tasks.clear();
floorManager.cancelActiveTask();
}
/** Called on plugin disable / world unload to safely stop everything and restore players. */
public void shutdown() {
cancelAllTasks();
List<UUID> all = allParticipants();
for (UUID uuid : all) {
Player p = plugin.getServer().getPlayer(uuid);
PlayerSession session = plugin.getSessionManager().get(uuid);
if (session != null && p != null) {
session.getSavedState().restore(p);
}
plugin.getSessionManager().remove(uuid);
}
players.clear();
spectators.clear();
}
private String formatMaterial(Material material) {
String name = material.name().replace("_CONCRETE", "").replace("_", " ").toLowerCase();
return name.substring(0, 1).toUpperCase() + name.substring(1);
}
}
@@ -0,0 +1,115 @@
package us.tss3.blockparty.arena;
import us.tss3.blockparty.BlockPartyPlugin;
import us.tss3.blockparty.config.ArenaConfig;
import us.tss3.blockparty.config.ArenaConfigLoader;
import us.tss3.blockparty.logic.ArenaPhase;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/** Owns the collection of loaded arenas, keyed by lowercase name. */
public class ArenaManager {
private final BlockPartyPlugin plugin;
private final ArenaConfigLoader loader;
private final Map<String, Arena> arenas = new LinkedHashMap<>();
public ArenaManager(BlockPartyPlugin plugin) {
this.plugin = plugin;
this.loader = new ArenaConfigLoader(plugin);
}
public ArenaConfigLoader getLoader() {
return loader;
}
public void loadAll() {
for (Arena arena : arenas.values()) {
arena.shutdown();
}
arenas.clear();
for (ArenaConfig cfg : loader.loadAll()) {
arenas.put(cfg.getName().toLowerCase(), new Arena(plugin, cfg));
}
plugin.getLogger().info("Loaded " + arenas.size() + " arena(s).");
}
/** Reload config/messages/arenas without disturbing arenas that are RUNNING or ENDING. */
public void reloadSafely() {
for (Map.Entry<String, Arena> entry : arenas.entrySet()) {
Arena arena = entry.getValue();
if (arena.getPhase() == ArenaPhase.RUNNING || arena.getPhase() == ArenaPhase.ENDING) {
plugin.getLogger().warning("Skipping reload of arena '" + arena.getConfig().getName() + "' - match in progress.");
continue;
}
ArenaConfig fresh = loader.load(new java.io.File(loader.getArenasFolder(), arena.getConfig().getName() + ".yml"));
if (fresh != null) {
arenas.put(entry.getKey(), new Arena(plugin, fresh));
}
}
// Load any brand-new arena files not currently tracked
for (ArenaConfig cfg : loader.loadAll()) {
arenas.putIfAbsent(cfg.getName().toLowerCase(), new Arena(plugin, cfg));
}
}
public Optional<Arena> get(String name) {
if (name == null) {
return Optional.empty();
}
return Optional.ofNullable(arenas.get(name.toLowerCase()));
}
public boolean exists(String name) {
return arenas.containsKey(name.toLowerCase());
}
public Arena createArena(String name, org.bukkit.World world) {
ArenaConfig cfg = new ArenaConfig(name);
if (world != null) {
cfg.setWorldName(world.getName());
}
cfg.setFloorMaterials(plugin.getConfigManager().getDefaultFloorMaterials().stream()
.map(org.bukkit.Material::matchMaterial)
.filter(java.util.Objects::nonNull)
.toList());
cfg.setStartingRoundTime(plugin.getConfigManager().getDefaultStartingRoundTime());
cfg.setMinimumRoundTime(plugin.getConfigManager().getDefaultMinimumRoundTime());
cfg.setRoundTimeReduction(plugin.getConfigManager().getDefaultRoundReduction());
cfg.setCountdownDuration(plugin.getConfigManager().getDefaultCountdown());
loader.save(cfg);
Arena arena = new Arena(plugin, cfg);
arenas.put(name.toLowerCase(), arena);
return arena;
}
public boolean deleteArena(String name) {
Arena arena = arenas.remove(name.toLowerCase());
if (arena != null) {
arena.shutdown();
}
return loader.delete(name);
}
public Collection<Arena> all() {
return arenas.values();
}
public List<String> names() {
return arenas.values().stream().map(a -> a.getConfig().getName()).toList();
}
public void save(Arena arena) {
loader.save(arena.getConfig());
}
public void shutdownAll() {
for (Arena arena : arenas.values()) {
arena.shutdown();
}
}
}
@@ -0,0 +1,407 @@
package us.tss3.blockparty.command;
import org.bukkit.Bukkit;
import org.bukkit.Location;
import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender;
import org.bukkit.command.TabCompleter;
import org.bukkit.entity.Player;
import us.tss3.blockparty.BlockPartyPlugin;
import us.tss3.blockparty.arena.Arena;
import us.tss3.blockparty.config.ArenaConfig;
import us.tss3.blockparty.logic.ArenaPhase;
import us.tss3.blockparty.persistence.PlayerStats;
import us.tss3.blockparty.session.PlayerSession;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.stream.Collectors;
/** Single entry-point handling all /blockparty (alias /bp) subcommands. */
public class BlockPartyCommand implements CommandExecutor, TabCompleter {
private final BlockPartyPlugin plugin;
public BlockPartyCommand(BlockPartyPlugin plugin) {
this.plugin = plugin;
}
@Override
public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
if (args.length == 0) {
sendHelp(sender);
return true;
}
String sub = args[0].toLowerCase();
switch (sub) {
case "help" -> sendHelp(sender);
case "create" -> create(sender, args);
case "delete" -> delete(sender, args);
case "enable" -> enable(sender, args);
case "disable" -> disable(sender, args);
case "setlobby" -> setLobby(sender, args);
case "setspawn" -> setSpawn(sender, args);
case "setspectator" -> setSpectator(sender, args);
case "pos1" -> pos1(sender, args);
case "pos2" -> pos2(sender, args);
case "setfloor" -> setFloor(sender, args);
case "generate" -> generate(sender, args);
case "info" -> info(sender, args);
case "list" -> list(sender);
case "reload" -> reload(sender);
case "join" -> join(sender, args);
case "leave" -> leave(sender);
case "arenas" -> list(sender);
case "stats" -> stats(sender, args);
default -> sendHelp(sender);
}
return true;
}
private void sendHelp(CommandSender sender) {
List<String> lines = List.of(
"/blockparty help",
"/blockparty join <arena>",
"/blockparty leave",
"/blockparty arenas",
"/blockparty stats [player]",
"/blockparty create <arena>",
"/blockparty delete <arena>",
"/blockparty enable <arena>",
"/blockparty disable <arena>",
"/blockparty setlobby <arena>",
"/blockparty setspawn <arena>",
"/blockparty setspectator <arena>",
"/blockparty pos1 <arena>",
"/blockparty pos2 <arena>",
"/blockparty setfloor <arena>",
"/blockparty generate <arena>",
"/blockparty info <arena>",
"/blockparty list",
"/blockparty reload"
);
sender.sendMessage("§6=== BlockParty Commands ===");
for (String line : lines) {
sender.sendMessage("§7" + line);
}
}
private boolean requireAdmin(CommandSender sender, String perm) {
if (!sender.hasPermission(perm) && !sender.hasPermission("blockparty.admin")) {
sender.sendMessage(plugin.getMessages().get("errors.no-permission"));
return false;
}
return true;
}
private boolean requirePlayer(CommandSender sender) {
if (!(sender instanceof Player)) {
sender.sendMessage("This command can only be used by a player.");
return false;
}
return true;
}
private String argOrCurrentArenaless(String[] args) {
return args.length > 1 ? args[1] : null;
}
// -------- admin commands --------
private void create(CommandSender sender, String[] args) {
if (!requireAdmin(sender, "blockparty.admin.create")) return;
String name = argOrCurrentArenaless(args);
if (name == null) {
sender.sendMessage("Usage: /blockparty create <arena>");
return;
}
if (plugin.getArenaManager().exists(name)) {
sender.sendMessage(plugin.getMessages().get("admin.arena-exists", Map.of("arena", name)));
return;
}
org.bukkit.World world = sender instanceof Player p ? p.getWorld() : Bukkit.getWorlds().get(0);
plugin.getArenaManager().createArena(name, world);
sender.sendMessage(plugin.getMessages().get("admin.arena-created", Map.of("arena", name)));
}
private void delete(CommandSender sender, String[] args) {
if (!requireAdmin(sender, "blockparty.admin.delete")) return;
String name = argOrCurrentArenaless(args);
if (name == null || !plugin.getArenaManager().exists(name)) {
sender.sendMessage(plugin.getMessages().get("errors.unknown-arena", Map.of("arena", String.valueOf(name))));
return;
}
plugin.getArenaManager().deleteArena(name);
sender.sendMessage(plugin.getMessages().get("admin.arena-deleted", Map.of("arena", name)));
}
private void enable(CommandSender sender, String[] args) {
if (!requireAdmin(sender, "blockparty.admin.setup")) return;
withArena(sender, args, arena -> {
List<String> errors = plugin.getArenaManager().getLoader().validate(arena.getConfig());
if (!errors.isEmpty()) {
sender.sendMessage(plugin.getMessages().get("admin.validation-failed", Map.of("arena", arena.getConfig().getName())));
for (String e : errors) {
sender.sendMessage("§c - " + e);
}
return;
}
arena.enable();
plugin.getArenaManager().save(arena);
sender.sendMessage(plugin.getMessages().get("admin.arena-enabled", Map.of("arena", arena.getConfig().getName())));
});
}
private void disable(CommandSender sender, String[] args) {
if (!requireAdmin(sender, "blockparty.admin.setup")) return;
withArena(sender, args, arena -> {
arena.disable();
plugin.getArenaManager().save(arena);
sender.sendMessage(plugin.getMessages().get("admin.arena-disabled", Map.of("arena", arena.getConfig().getName())));
});
}
private void setLobby(CommandSender sender, String[] args) {
if (!requireAdmin(sender, "blockparty.admin.setup") || !requirePlayer(sender)) return;
withArena(sender, args, arena -> {
arena.getConfig().setLobby(((Player) sender).getLocation());
plugin.getArenaManager().save(arena);
sender.sendMessage(plugin.getMessages().get("admin.lobby-set", Map.of("arena", arena.getConfig().getName())));
});
}
private void setSpawn(CommandSender sender, String[] args) {
if (!requireAdmin(sender, "blockparty.admin.setup") || !requirePlayer(sender)) return;
withArena(sender, args, arena -> {
arena.getConfig().setSpawn(((Player) sender).getLocation());
plugin.getArenaManager().save(arena);
sender.sendMessage(plugin.getMessages().get("admin.spawn-set", Map.of("arena", arena.getConfig().getName())));
});
}
private void setSpectator(CommandSender sender, String[] args) {
if (!requireAdmin(sender, "blockparty.admin.setup") || !requirePlayer(sender)) return;
withArena(sender, args, arena -> {
arena.getConfig().setSpectator(((Player) sender).getLocation());
plugin.getArenaManager().save(arena);
sender.sendMessage(plugin.getMessages().get("admin.spectator-set", Map.of("arena", arena.getConfig().getName())));
});
}
private void pos1(CommandSender sender, String[] args) {
if (!requireAdmin(sender, "blockparty.admin.setup") || !requirePlayer(sender)) return;
withArena(sender, args, arena -> {
Location loc = ((Player) sender).getLocation();
arena.getConfig().setPos1(new int[]{loc.getBlockX(), loc.getBlockY(), loc.getBlockZ()});
arena.getConfig().setWorldName(loc.getWorld().getName());
plugin.getArenaManager().save(arena);
sender.sendMessage(plugin.getMessages().get("admin.pos1-set", Map.of("arena", arena.getConfig().getName())));
});
}
private void pos2(CommandSender sender, String[] args) {
if (!requireAdmin(sender, "blockparty.admin.setup") || !requirePlayer(sender)) return;
withArena(sender, args, arena -> {
Location loc = ((Player) sender).getLocation();
arena.getConfig().setPos2(new int[]{loc.getBlockX(), loc.getBlockY(), loc.getBlockZ()});
plugin.getArenaManager().save(arena);
sender.sendMessage(plugin.getMessages().get("admin.pos2-set", Map.of("arena", arena.getConfig().getName())));
});
}
private void setFloor(CommandSender sender, String[] args) {
if (!requireAdmin(sender, "blockparty.admin.setup")) return;
// /bp setfloor <arena> <mat1,mat2,...>
String name = argOrCurrentArenaless(args);
if (name == null || args.length < 3) {
sender.sendMessage("Usage: /blockparty setfloor <arena> <mat1,mat2,...>");
return;
}
var opt = plugin.getArenaManager().get(name);
if (opt.isEmpty()) {
sender.sendMessage(plugin.getMessages().get("errors.unknown-arena", Map.of("arena", name)));
return;
}
Arena arena = opt.get();
List<org.bukkit.Material> materials = new ArrayList<>();
for (String matName : args[2].split(",")) {
org.bukkit.Material mat = org.bukkit.Material.matchMaterial(matName);
if (mat == null) {
sender.sendMessage("§cInvalid material skipped: " + matName);
continue;
}
materials.add(mat);
}
arena.getConfig().setFloorMaterials(materials);
plugin.getArenaManager().save(arena);
sender.sendMessage(plugin.getMessages().get("admin.floor-set", Map.of("arena", name, "count", String.valueOf(materials.size()))));
}
private void generate(CommandSender sender, String[] args) {
if (!requireAdmin(sender, "blockparty.admin.setup")) return;
withArena(sender, args, arena -> {
if (!arena.getConfig().hasFloorRegion()) {
sender.sendMessage(plugin.getMessages().get("errors.no-region", Map.of("arena", arena.getConfig().getName())));
return;
}
if (arena.getConfig().getFloorMaterials().isEmpty()) {
sender.sendMessage(plugin.getMessages().get("errors.no-materials", Map.of("arena", arena.getConfig().getName())));
return;
}
arena.getFloorManager().generate(new java.util.Random());
plugin.getArenaManager().save(arena);
sender.sendMessage(plugin.getMessages().get("admin.floor-generated", Map.of("arena", arena.getConfig().getName())));
});
}
private void info(CommandSender sender, String[] args) {
String name = argOrCurrentArenaless(args);
if (name == null) {
sender.sendMessage("Usage: /blockparty info <arena>");
return;
}
var opt = plugin.getArenaManager().get(name);
if (opt.isEmpty()) {
sender.sendMessage(plugin.getMessages().get("errors.unknown-arena", Map.of("arena", name)));
return;
}
Arena arena = opt.get();
ArenaConfig cfg = arena.getConfig();
sender.sendMessage("§6=== Arena: " + cfg.getName() + " ===");
sender.sendMessage("§7State: §f" + arena.getPhase());
sender.sendMessage("§7World: §f" + cfg.getWorldName());
sender.sendMessage("§7Players: §f" + arena.getPlayers().size() + "/" + cfg.getMaxPlayers() + " (min " + cfg.getMinPlayers() + ")");
sender.sendMessage("§7Lobby set: §f" + (cfg.getLobby() != null));
sender.sendMessage("§7Spawn set: §f" + (cfg.getSpawn() != null));
sender.sendMessage("§7Spectator set: §f" + (cfg.getSpectator() != null));
sender.sendMessage("§7Floor region set: §f" + cfg.hasFloorRegion());
sender.sendMessage("§7Floor generated: §f" + cfg.hasGeneratedLayout());
sender.sendMessage("§7Floor materials: §f" + cfg.getFloorMaterials().size());
List<String> errors = plugin.getArenaManager().getLoader().validate(cfg);
sender.sendMessage("§7Valid: §f" + errors.isEmpty());
for (String e : errors) {
sender.sendMessage("§c - " + e);
}
}
private void list(CommandSender sender) {
sender.sendMessage("§6=== Arenas ===");
for (Arena arena : plugin.getArenaManager().all()) {
sender.sendMessage("§7- " + arena.getConfig().getName() + " §8(" + arena.getPhase() + ", "
+ arena.getPlayers().size() + "/" + arena.getConfig().getMaxPlayers() + ")");
}
}
private void reload(CommandSender sender) {
if (!requireAdmin(sender, "blockparty.admin.reload")) return;
plugin.reloadEverything();
sender.sendMessage(plugin.getMessages().get("admin.reloaded"));
}
// -------- player commands --------
private void join(CommandSender sender, String[] args) {
if (!requirePlayer(sender)) return;
if (!sender.hasPermission("blockparty.join") && !sender.hasPermission("blockparty.play")) {
sender.sendMessage(plugin.getMessages().get("errors.no-permission"));
return;
}
Player player = (Player) sender;
String name = argOrCurrentArenaless(args);
if (name == null) {
sender.sendMessage("Usage: /blockparty join <arena>");
return;
}
var opt = plugin.getArenaManager().get(name);
if (opt.isEmpty()) {
sender.sendMessage(plugin.getMessages().get("errors.unknown-arena", Map.of("arena", name)));
return;
}
Arena arena = opt.get();
if (plugin.getSessionManager().isInSession(player.getUniqueId())) {
sender.sendMessage(plugin.getMessages().get("errors.already-in-arena"));
return;
}
if (!arena.isEnabled()) {
sender.sendMessage(plugin.getMessages().get("errors.arena-disabled", Map.of("arena", name)));
return;
}
if (!arena.canJoin()) {
sender.sendMessage(plugin.getMessages().get("errors.arena-full", Map.of("arena", name)));
return;
}
arena.addPlayer(player);
plugin.getSoundUtil().play(player, plugin.getConfigManager().getSound("join"), 1f, 1f);
sender.sendMessage(plugin.getMessages().get("player.joined", Map.of("arena", name)));
}
private void leave(CommandSender sender) {
if (!requirePlayer(sender)) return;
Player player = (Player) sender;
PlayerSession session = plugin.getSessionManager().get(player.getUniqueId());
if (session == null) {
sender.sendMessage(plugin.getMessages().get("errors.not-in-arena"));
return;
}
plugin.getArenaManager().get(session.getArenaName()).ifPresent(arena -> arena.removePlayer(player.getUniqueId(), true));
sender.sendMessage(plugin.getMessages().get("player.left"));
}
private void stats(CommandSender sender, String[] args) {
UUID target;
String targetName;
if (args.length > 1) {
org.bukkit.OfflinePlayer off = Bukkit.getOfflinePlayer(args[1]);
target = off.getUniqueId();
targetName = off.getName() != null ? off.getName() : args[1];
} else {
if (!requirePlayer(sender)) return;
target = ((Player) sender).getUniqueId();
targetName = sender.getName();
}
String finalName = targetName;
plugin.getStatsManager().getStats(target).thenAccept(stats ->
Bukkit.getScheduler().runTask(plugin, () -> sendStats(sender, finalName, stats)));
}
private void sendStats(CommandSender sender, String name, PlayerStats stats) {
sender.sendMessage("§6=== Stats: " + name + " ===");
sender.sendMessage("§7Games played: §f" + stats.getGamesPlayed());
sender.sendMessage("§7Wins: §f" + stats.getWins());
sender.sendMessage("§7Eliminations: §f" + stats.getEliminations());
sender.sendMessage("§7Best round: §f" + stats.getBestRound());
sender.sendMessage("§7Total rounds survived: §f" + stats.getTotalRoundsSurvived());
}
private void withArena(CommandSender sender, String[] args, java.util.function.Consumer<Arena> consumer) {
String name = argOrCurrentArenaless(args);
if (name == null) {
sender.sendMessage("Usage: /blockparty <cmd> <arena>");
return;
}
var opt = plugin.getArenaManager().get(name);
if (opt.isEmpty()) {
sender.sendMessage(plugin.getMessages().get("errors.unknown-arena", Map.of("arena", name)));
return;
}
consumer.accept(opt.get());
}
@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",
"setlobby", "setspawn", "setspectator", "pos1", "pos2", "setfloor", "generate", "info", "list", "reload")
.stream().filter(s -> s.startsWith(args[0].toLowerCase())).collect(Collectors.toList());
}
if (args.length == 2) {
return plugin.getArenaManager().names().stream()
.filter(n -> n.toLowerCase().startsWith(args[1].toLowerCase()))
.collect(Collectors.toList());
}
return new ArrayList<>();
}
}
@@ -0,0 +1,230 @@
package us.tss3.blockparty.config;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.World;
import java.util.ArrayList;
import java.util.List;
/** Mutable POJO describing a single arena's configuration, mirrors arenas/<name>.yml. */
public class ArenaConfig {
private String name;
private String worldName;
private Location lobby;
private Location spawn;
private Location spectator;
private int[] pos1; // x,y,z block coords
private int[] pos2;
private int minPlayers = 2;
private int maxPlayers = 16;
private int countdownDuration = 15;
private int roundPrepDuration = 5;
private int floorRemoveDelaySeconds = 1;
private int floorRestoreDelaySeconds = 3;
private int startingRoundTime = 20;
private int minimumRoundTime = 4;
private int roundTimeReduction = 1;
private int winEndingDelaySeconds = 6;
private List<Material> floorMaterials = new ArrayList<>();
private boolean allowJoinWhileStarting = true;
private boolean spectatorsAllowed = true;
private boolean enabled = false;
/** flat serialized layout: "x,y,z,MATERIAL" per generated block, empty until /bp generate */
private List<String> generatedLayout = new ArrayList<>();
public ArenaConfig(String name) {
this.name = name;
}
public String getName() {
return name;
}
public String getWorldName() {
return worldName;
}
public void setWorldName(String worldName) {
this.worldName = worldName;
}
public World getWorld() {
return worldName == null ? null : org.bukkit.Bukkit.getWorld(worldName);
}
public Location getLobby() {
return lobby;
}
public void setLobby(Location lobby) {
this.lobby = lobby;
if (lobby != null && lobby.getWorld() != null) {
this.worldName = lobby.getWorld().getName();
}
}
public Location getSpawn() {
return spawn;
}
public void setSpawn(Location spawn) {
this.spawn = spawn;
}
public Location getSpectator() {
return spectator;
}
public void setSpectator(Location spectator) {
this.spectator = spectator;
}
public int[] getPos1() {
return pos1;
}
public void setPos1(int[] pos1) {
this.pos1 = pos1;
}
public int[] getPos2() {
return pos2;
}
public void setPos2(int[] pos2) {
this.pos2 = pos2;
}
public boolean hasFloorRegion() {
return pos1 != null && pos2 != null;
}
public int getMinPlayers() {
return minPlayers;
}
public void setMinPlayers(int minPlayers) {
this.minPlayers = minPlayers;
}
public int getMaxPlayers() {
return maxPlayers;
}
public void setMaxPlayers(int maxPlayers) {
this.maxPlayers = maxPlayers;
}
public int getCountdownDuration() {
return countdownDuration;
}
public void setCountdownDuration(int countdownDuration) {
this.countdownDuration = countdownDuration;
}
public int getRoundPrepDuration() {
return roundPrepDuration;
}
public void setRoundPrepDuration(int roundPrepDuration) {
this.roundPrepDuration = roundPrepDuration;
}
public int getFloorRemoveDelaySeconds() {
return floorRemoveDelaySeconds;
}
public void setFloorRemoveDelaySeconds(int floorRemoveDelaySeconds) {
this.floorRemoveDelaySeconds = floorRemoveDelaySeconds;
}
public int getFloorRestoreDelaySeconds() {
return floorRestoreDelaySeconds;
}
public void setFloorRestoreDelaySeconds(int floorRestoreDelaySeconds) {
this.floorRestoreDelaySeconds = floorRestoreDelaySeconds;
}
public int getStartingRoundTime() {
return startingRoundTime;
}
public void setStartingRoundTime(int startingRoundTime) {
this.startingRoundTime = startingRoundTime;
}
public int getMinimumRoundTime() {
return minimumRoundTime;
}
public void setMinimumRoundTime(int minimumRoundTime) {
this.minimumRoundTime = minimumRoundTime;
}
public int getRoundTimeReduction() {
return roundTimeReduction;
}
public void setRoundTimeReduction(int roundTimeReduction) {
this.roundTimeReduction = roundTimeReduction;
}
public int getWinEndingDelaySeconds() {
return winEndingDelaySeconds;
}
public void setWinEndingDelaySeconds(int winEndingDelaySeconds) {
this.winEndingDelaySeconds = winEndingDelaySeconds;
}
public List<Material> getFloorMaterials() {
return floorMaterials;
}
public void setFloorMaterials(List<Material> floorMaterials) {
this.floorMaterials = floorMaterials;
}
public boolean isAllowJoinWhileStarting() {
return allowJoinWhileStarting;
}
public void setAllowJoinWhileStarting(boolean allowJoinWhileStarting) {
this.allowJoinWhileStarting = allowJoinWhileStarting;
}
public boolean isSpectatorsAllowed() {
return spectatorsAllowed;
}
public void setSpectatorsAllowed(boolean spectatorsAllowed) {
this.spectatorsAllowed = spectatorsAllowed;
}
public boolean isEnabled() {
return enabled;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
public List<String> getGeneratedLayout() {
return generatedLayout;
}
public void setGeneratedLayout(List<String> generatedLayout) {
this.generatedLayout = generatedLayout;
}
public boolean hasGeneratedLayout() {
return generatedLayout != null && !generatedLayout.isEmpty();
}
}
@@ -0,0 +1,177 @@
package us.tss3.blockparty.config;
import org.bukkit.Bukkit;
import org.bukkit.Material;
import org.bukkit.configuration.file.YamlConfiguration;
import org.bukkit.plugin.Plugin;
import us.tss3.blockparty.logic.ArenaConfigValidator;
import us.tss3.blockparty.util.LocationUtil;
import java.io.File;
import java.util.ArrayList;
import java.util.List;
import java.util.logging.Level;
/** Loads/saves individual arena yml files. Each arena is loaded independently; a bad file is logged and skipped. */
public class ArenaConfigLoader {
private final Plugin plugin;
private final File arenasFolder;
public ArenaConfigLoader(Plugin plugin) {
this.plugin = plugin;
this.arenasFolder = new File(plugin.getDataFolder(), "arenas");
if (!arenasFolder.exists()) {
arenasFolder.mkdirs();
}
}
public File getArenasFolder() {
return arenasFolder;
}
public List<ArenaConfig> loadAll() {
List<ArenaConfig> arenas = new ArrayList<>();
File[] files = arenasFolder.listFiles((dir, name) -> name.endsWith(".yml"));
if (files == null) {
return arenas;
}
for (File file : files) {
try {
ArenaConfig cfg = load(file);
if (cfg != null) {
arenas.add(cfg);
}
} catch (Exception ex) {
plugin.getLogger().log(Level.WARNING, "Failed to load arena file " + file.getName() + ": " + ex.getMessage(), ex);
}
}
return arenas;
}
public ArenaConfig load(File file) {
String name = file.getName().replace(".yml", "");
YamlConfiguration yml = YamlConfiguration.loadConfiguration(file);
ArenaConfig cfg = new ArenaConfig(name);
cfg.setWorldName(yml.getString("world"));
cfg.setLobby(LocationUtil.fromSection(yml.getConfigurationSection("lobby")));
cfg.setSpawn(LocationUtil.fromSection(yml.getConfigurationSection("spawn")));
cfg.setSpectator(LocationUtil.fromSection(yml.getConfigurationSection("spectator")));
if (yml.contains("pos1")) {
cfg.setPos1(new int[]{yml.getInt("pos1.x"), yml.getInt("pos1.y"), yml.getInt("pos1.z")});
}
if (yml.contains("pos2")) {
cfg.setPos2(new int[]{yml.getInt("pos2.x"), yml.getInt("pos2.y"), yml.getInt("pos2.z")});
}
cfg.setMinPlayers(yml.getInt("min-players", 2));
cfg.setMaxPlayers(yml.getInt("max-players", 16));
cfg.setCountdownDuration(yml.getInt("countdown-duration", 15));
cfg.setRoundPrepDuration(yml.getInt("round-prep-duration", 5));
cfg.setFloorRemoveDelaySeconds(yml.getInt("floor-remove-delay", 1));
cfg.setFloorRestoreDelaySeconds(yml.getInt("floor-restore-delay", 3));
cfg.setStartingRoundTime(yml.getInt("starting-round-time", 20));
cfg.setMinimumRoundTime(yml.getInt("minimum-round-time", 4));
cfg.setRoundTimeReduction(yml.getInt("round-time-reduction", 1));
cfg.setWinEndingDelaySeconds(yml.getInt("win-ending-delay", 6));
cfg.setAllowJoinWhileStarting(yml.getBoolean("allow-join-while-waiting", true));
cfg.setSpectatorsAllowed(yml.getBoolean("spectators-allowed", true));
cfg.setEnabled(yml.getBoolean("enabled", false));
List<Material> materials = new ArrayList<>();
for (String matName : yml.getStringList("floor-materials")) {
Material mat = Material.matchMaterial(matName);
if (mat == null) {
plugin.getLogger().warning("Arena '" + name + "': invalid floor material '" + matName + "' skipped.");
continue;
}
materials.add(mat);
}
cfg.setFloorMaterials(materials);
cfg.setGeneratedLayout(yml.getStringList("generated-layout"));
return cfg;
}
public void save(ArenaConfig cfg) {
File file = new File(arenasFolder, cfg.getName() + ".yml");
YamlConfiguration yml = new YamlConfiguration();
if (cfg.getWorldName() != null) {
yml.set("world", cfg.getWorldName());
}
if (cfg.getLobby() != null) {
LocationUtil.toSection(yml.createSection("lobby"), cfg.getLobby());
}
if (cfg.getSpawn() != null) {
LocationUtil.toSection(yml.createSection("spawn"), cfg.getSpawn());
}
if (cfg.getSpectator() != null) {
LocationUtil.toSection(yml.createSection("spectator"), cfg.getSpectator());
}
if (cfg.getPos1() != null) {
yml.set("pos1.x", cfg.getPos1()[0]);
yml.set("pos1.y", cfg.getPos1()[1]);
yml.set("pos1.z", cfg.getPos1()[2]);
}
if (cfg.getPos2() != null) {
yml.set("pos2.x", cfg.getPos2()[0]);
yml.set("pos2.y", cfg.getPos2()[1]);
yml.set("pos2.z", cfg.getPos2()[2]);
}
yml.set("min-players", cfg.getMinPlayers());
yml.set("max-players", cfg.getMaxPlayers());
yml.set("countdown-duration", cfg.getCountdownDuration());
yml.set("round-prep-duration", cfg.getRoundPrepDuration());
yml.set("floor-remove-delay", cfg.getFloorRemoveDelaySeconds());
yml.set("floor-restore-delay", cfg.getFloorRestoreDelaySeconds());
yml.set("starting-round-time", cfg.getStartingRoundTime());
yml.set("minimum-round-time", cfg.getMinimumRoundTime());
yml.set("round-time-reduction", cfg.getRoundTimeReduction());
yml.set("win-ending-delay", cfg.getWinEndingDelaySeconds());
yml.set("allow-join-while-waiting", cfg.isAllowJoinWhileStarting());
yml.set("spectators-allowed", cfg.isSpectatorsAllowed());
yml.set("enabled", cfg.isEnabled());
List<String> matNames = new ArrayList<>();
for (Material m : cfg.getFloorMaterials()) {
matNames.add(m.name());
}
yml.set("floor-materials", matNames);
yml.set("generated-layout", cfg.getGeneratedLayout());
try {
yml.save(file);
} catch (Exception ex) {
plugin.getLogger().log(Level.SEVERE, "Failed to save arena " + cfg.getName(), ex);
}
}
public boolean delete(String name) {
File file = new File(arenasFolder, name + ".yml");
return file.exists() && file.delete();
}
public List<String> validate(ArenaConfig cfg) {
boolean worldOk = cfg.getWorldName() != null && Bukkit.getWorld(cfg.getWorldName()) != null;
ArenaConfigValidator.Fields fields = new ArenaConfigValidator.Fields(
cfg.getName(),
worldOk ? cfg.getWorldName() : null,
cfg.getLobby() != null,
cfg.getSpawn() != null,
cfg.getSpectator() != null,
cfg.hasFloorRegion(),
cfg.getMinPlayers(),
cfg.getMaxPlayers(),
cfg.getCountdownDuration(),
cfg.getRoundPrepDuration(),
cfg.getFloorRemoveDelaySeconds(),
cfg.getFloorRestoreDelaySeconds(),
cfg.getStartingRoundTime(),
cfg.getMinimumRoundTime(),
cfg.getRoundTimeReduction(),
cfg.getWinEndingDelaySeconds(),
cfg.getFloorMaterials().stream().map(Material::name).toList()
);
List<String> errors = new ArrayList<>(ArenaConfigValidator.validate(fields));
if (!worldOk && cfg.getWorldName() != null) {
errors.add("world '" + cfg.getWorldName() + "' is not currently loaded");
}
return errors;
}
}
@@ -0,0 +1,83 @@
package us.tss3.blockparty.config;
import org.bukkit.configuration.file.FileConfiguration;
import org.bukkit.plugin.java.JavaPlugin;
import java.util.List;
/** Wraps global config.yml access: UI toggles, sounds, rewards, default floor palette. */
public class ConfigManager {
private final JavaPlugin plugin;
private FileConfiguration config;
public ConfigManager(JavaPlugin plugin) {
this.plugin = plugin;
}
public void load() {
plugin.saveDefaultConfig();
plugin.reloadConfig();
this.config = plugin.getConfig();
}
public FileConfiguration raw() {
return config;
}
public boolean isScoreboardEnabled() {
return config.getBoolean("ui.scoreboard", true);
}
public boolean isBossbarEnabled() {
return config.getBoolean("ui.bossbar", true);
}
public boolean isTitlesEnabled() {
return config.getBoolean("ui.titles", true);
}
public boolean isActionbarEnabled() {
return config.getBoolean("ui.actionbar", true);
}
public int getScoreboardUpdateIntervalTicks() {
return config.getInt("ui.scoreboard-update-interval-ticks", 10);
}
public String getSound(String key) {
return config.getString("sounds." + key);
}
public List<String> getRewardCommands() {
return config.getStringList("rewards.winner.commands");
}
public List<String> getDefaultFloorMaterials() {
return config.getStringList("default-floor-materials");
}
public int getDefaultStartingRoundTime() {
return config.getInt("defaults.starting-round-time", 20);
}
public int getDefaultMinimumRoundTime() {
return config.getInt("defaults.minimum-round-time", 4);
}
public int getDefaultRoundReduction() {
return config.getInt("defaults.round-time-reduction", 1);
}
public int getDefaultCountdown() {
return config.getInt("defaults.countdown-duration", 15);
}
public int getFloorBatchBlocksPerTick() {
return config.getInt("performance.floor-blocks-per-tick", 200);
}
public boolean isPlaceholderApiEnabled() {
return config.getBoolean("integrations.placeholderapi", true);
}
}
@@ -0,0 +1,77 @@
package us.tss3.blockparty.config;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.minimessage.MiniMessage;
import org.bukkit.configuration.file.YamlConfiguration;
import org.bukkit.plugin.java.JavaPlugin;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.Map;
/** Loads messages.yml and renders MiniMessage strings with placeholder substitution. */
public class MessagesManager {
private final JavaPlugin plugin;
private final MiniMessage miniMessage = MiniMessage.miniMessage();
private YamlConfiguration messages;
public MessagesManager(JavaPlugin plugin) {
this.plugin = plugin;
}
public void load() {
File file = new File(plugin.getDataFolder(), "messages.yml");
if (!file.exists()) {
plugin.saveResource("messages.yml", false);
}
messages = YamlConfiguration.loadConfiguration(file);
// fill defaults from jar for any missing keys
try (InputStream in = plugin.getResource("messages.yml")) {
if (in != null) {
YamlConfiguration defaults = YamlConfiguration.loadConfiguration(new InputStreamReader(in, StandardCharsets.UTF_8));
messages.setDefaults(defaults);
}
} catch (IOException ignored) {
}
}
public String raw(String key) {
String value = messages.getString(key);
if (value == null) {
return "<red>Missing message: " + key + "</red>";
}
return value;
}
public Component get(String key) {
return miniMessage.deserialize(raw(key));
}
public Component get(String key, Map<String, String> placeholders) {
String value = raw(key);
for (Map.Entry<String, String> e : placeholders.entrySet()) {
value = value.replace("%" + e.getKey() + "%", e.getValue());
}
return miniMessage.deserialize(value);
}
public String getPlain(String key, Map<String, String> placeholders) {
String value = raw(key);
for (Map.Entry<String, String> e : placeholders.entrySet()) {
value = value.replace("%" + e.getKey() + "%", e.getValue());
}
return value;
}
public MiniMessage miniMessage() {
return miniMessage;
}
public java.util.List<String> rawList(String key) {
return messages.getStringList(key);
}
}
@@ -0,0 +1,229 @@
package us.tss3.blockparty.floor;
import org.bukkit.Material;
import org.bukkit.World;
import org.bukkit.block.Block;
import org.bukkit.plugin.Plugin;
import org.bukkit.scheduler.BukkitTask;
import us.tss3.blockparty.config.ArenaConfig;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Random;
/**
* Handles floor generation/removal/restoration for a single arena, batching block mutations
* across ticks so a large floor never causes a watchdog-tripping synchronous loop.
*/
public class FloorManager {
private final Plugin plugin;
private final ArenaConfig config;
/** location key "x,y,z" -> material, the canonical generated layout */
private final Map<String, Material> layout = new HashMap<>();
private BukkitTask activeTask;
public FloorManager(Plugin plugin, ArenaConfig config) {
this.plugin = plugin;
this.config = config;
loadLayoutFromConfig();
}
private void loadLayoutFromConfig() {
layout.clear();
for (String entry : config.getGeneratedLayout()) {
String[] parts = entry.split(",");
if (parts.length != 4) {
continue;
}
try {
String key = parts[0] + "," + parts[1] + "," + parts[2];
Material mat = Material.matchMaterial(parts[3]);
if (mat != null) {
layout.put(key, mat);
}
} catch (Exception ignored) {
}
}
}
public boolean hasLayout() {
return !layout.isEmpty();
}
/** Generates a random layout across the region and persists it into the arena config (does not save to disk itself). */
public void generate(Random random) {
if (!config.hasFloorRegion() || config.getFloorMaterials().isEmpty()) {
return;
}
layout.clear();
List<Material> palette = config.getFloorMaterials();
int[] p1 = config.getPos1();
int[] p2 = config.getPos2();
int minX = Math.min(p1[0], p2[0]);
int maxX = Math.max(p1[0], p2[0]);
int minY = Math.min(p1[1], p2[1]);
int maxY = Math.max(p1[1], p2[1]);
int minZ = Math.min(p1[2], p2[2]);
int maxZ = Math.max(p1[2], p2[2]);
for (int x = minX; x <= maxX; x++) {
for (int y = minY; y <= maxY; y++) {
for (int z = minZ; z <= maxZ; z++) {
Material mat = palette.get(random.nextInt(palette.size()));
layout.put(x + "," + y + "," + z, mat);
}
}
}
List<String> serialized = new ArrayList<>();
for (Map.Entry<String, Material> e : layout.entrySet()) {
serialized.add(e.getKey() + "," + e.getValue().name());
}
config.setGeneratedLayout(serialized);
}
public List<Material> materialsInLayout() {
return new ArrayList<>(layout.values());
}
/** Places every block from the stored layout, batched across ticks. */
public void restoreFull(int blocksPerTick, Runnable onComplete) {
World world = config.getWorld();
if (world == null) {
if (onComplete != null) {
onComplete.run();
}
return;
}
cancelActiveTask();
Deque<Map.Entry<String, Material>> queue = new ArrayDeque<>(layout.entrySet());
org.bukkit.scheduler.BukkitRunnable runnable = new org.bukkit.scheduler.BukkitRunnable() {
@Override
public void run() {
int processed = 0;
while (processed < blocksPerTick && !queue.isEmpty()) {
Map.Entry<String, Material> entry = queue.poll();
setBlock(world, entry.getKey(), entry.getValue());
processed++;
}
if (queue.isEmpty()) {
cancel();
activeTask = null;
if (onComplete != null) {
onComplete.run();
}
}
}
};
activeTask = runnable.runTaskTimer(plugin, 0L, 1L);
}
/** Removes every block that is not the target material, batched across ticks. */
public void removeNonTarget(Material target, int blocksPerTick, Runnable onComplete) {
World world = config.getWorld();
if (world == null) {
if (onComplete != null) {
onComplete.run();
}
return;
}
cancelActiveTask();
Deque<String> queue = new ArrayDeque<>();
for (Map.Entry<String, Material> entry : layout.entrySet()) {
if (entry.getValue() != target) {
queue.add(entry.getKey());
}
}
org.bukkit.scheduler.BukkitRunnable runnable = new org.bukkit.scheduler.BukkitRunnable() {
@Override
public void run() {
int processed = 0;
while (processed < blocksPerTick && !queue.isEmpty()) {
String key = queue.poll();
setBlock(world, key, Material.AIR);
processed++;
}
if (queue.isEmpty()) {
cancel();
activeTask = null;
if (onComplete != null) {
onComplete.run();
}
}
}
};
activeTask = runnable.runTaskTimer(plugin, 0L, 1L);
}
/** Clears the entire floor region to air, batched. */
public void clearAll(int blocksPerTick, Runnable onComplete) {
World world = config.getWorld();
if (world == null) {
if (onComplete != null) {
onComplete.run();
}
return;
}
cancelActiveTask();
Deque<String> queue = new ArrayDeque<>(layout.keySet());
org.bukkit.scheduler.BukkitRunnable runnable = new org.bukkit.scheduler.BukkitRunnable() {
@Override
public void run() {
int processed = 0;
while (processed < blocksPerTick && !queue.isEmpty()) {
setBlock(world, queue.poll(), Material.AIR);
processed++;
}
if (queue.isEmpty()) {
cancel();
activeTask = null;
if (onComplete != null) {
onComplete.run();
}
}
}
};
activeTask = runnable.runTaskTimer(plugin, 0L, 1L);
}
public Material materialAt(World world, int x, int y, int z) {
return layout.get(x + "," + y + "," + z);
}
public Material materialAtLocation(World world, double x, double y, double z) {
// check the block directly below the player's feet
int bx = (int) Math.floor(x);
int by = (int) Math.floor(y) - 1;
int bz = (int) Math.floor(z);
Material stored = layout.get(bx + "," + by + "," + bz);
if (stored == null) {
return null;
}
Block block = world.getBlockAt(bx, by, bz);
if (block.getType() == Material.AIR) {
return null;
}
return block.getType();
}
private void setBlock(World world, String key, Material material) {
String[] parts = key.split(",");
int x = Integer.parseInt(parts[0]);
int y = Integer.parseInt(parts[1]);
int z = Integer.parseInt(parts[2]);
Block block = world.getBlockAt(x, y, z);
if (block.getType() != material) {
block.setType(material, false);
}
}
public void cancelActiveTask() {
if (activeTask != null) {
activeTask.cancel();
activeTask = null;
}
}
}
@@ -0,0 +1,95 @@
package us.tss3.blockparty.listener;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.block.BlockBreakEvent;
import org.bukkit.event.block.BlockPlaceEvent;
import org.bukkit.event.entity.EntityDamageByEntityEvent;
import org.bukkit.event.entity.EntityDamageEvent;
import org.bukkit.event.entity.FoodLevelChangeEvent;
import org.bukkit.event.inventory.InventoryClickEvent;
import org.bukkit.event.player.PlayerDropItemEvent;
import org.bukkit.event.player.PlayerPickupItemEvent;
import us.tss3.blockparty.BlockPartyPlugin;
import us.tss3.blockparty.session.PlayerSession;
/** Restricts destructive/interfering actions for participants without touching non-participants elsewhere. */
public class GameplayRestrictionListener implements Listener {
private final BlockPartyPlugin plugin;
public GameplayRestrictionListener(BlockPartyPlugin plugin) {
this.plugin = plugin;
}
private boolean isParticipant(Player player) {
return plugin.getSessionManager().isInSession(player.getUniqueId());
}
@EventHandler(priority = EventPriority.HIGH)
public void onBreak(BlockBreakEvent event) {
if (isParticipant(event.getPlayer())) {
event.setCancelled(true);
}
}
@EventHandler(priority = EventPriority.HIGH)
public void onPlace(BlockPlaceEvent event) {
if (isParticipant(event.getPlayer())) {
event.setCancelled(true);
}
}
@EventHandler(priority = EventPriority.HIGH)
public void onDrop(PlayerDropItemEvent event) {
if (isParticipant(event.getPlayer())) {
event.setCancelled(true);
}
}
@EventHandler(priority = EventPriority.HIGH)
public void onPickup(PlayerPickupItemEvent event) {
if (isParticipant(event.getPlayer())) {
event.setCancelled(true);
}
}
@EventHandler(priority = EventPriority.HIGH)
public void onInventoryClick(InventoryClickEvent event) {
if (event.getWhoClicked() instanceof Player player && isParticipant(player)) {
event.setCancelled(true);
}
}
@EventHandler(priority = EventPriority.HIGH)
public void onDamageByEntity(EntityDamageByEntityEvent event) {
if (event.getEntity() instanceof Player victim && isParticipant(victim)) {
event.setCancelled(true);
}
}
@EventHandler(priority = EventPriority.HIGH)
public void onDamage(EntityDamageEvent event) {
if (event.getEntity() instanceof Player player && isParticipant(player)) {
PlayerSession session = plugin.getSessionManager().get(player.getUniqueId());
if (session != null && session.getRole() == PlayerSession.Role.SPECTATOR) {
event.setCancelled(true);
return;
}
// allow fall damage / void to be handled by elimination logic normally, but avoid death mid-round:
if (event.getCause() == EntityDamageEvent.DamageCause.VOID) {
event.setCancelled(true);
player.setFallDistance(0);
}
}
}
@EventHandler(priority = EventPriority.HIGH)
public void onFoodChange(FoodLevelChangeEvent event) {
if (event.getEntity() instanceof Player player && isParticipant(player)) {
event.setCancelled(true);
}
}
}
@@ -0,0 +1,42 @@
package us.tss3.blockparty.listener;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerQuitEvent;
import org.bukkit.event.player.PlayerRespawnEvent;
import us.tss3.blockparty.BlockPartyPlugin;
import us.tss3.blockparty.arena.Arena;
import us.tss3.blockparty.session.PlayerSession;
/** Handles disconnects and respawns during a match, keeping session state consistent. */
public class PlayerConnectionListener implements Listener {
private final BlockPartyPlugin plugin;
public PlayerConnectionListener(BlockPartyPlugin plugin) {
this.plugin = plugin;
}
@EventHandler
public void onQuit(PlayerQuitEvent event) {
PlayerSession session = plugin.getSessionManager().get(event.getPlayer().getUniqueId());
if (session == null) {
return;
}
plugin.getArenaManager().get(session.getArenaName()).ifPresent(arena ->
arena.removePlayer(event.getPlayer().getUniqueId(), false));
}
@EventHandler
public void onRespawn(PlayerRespawnEvent event) {
PlayerSession session = plugin.getSessionManager().get(event.getPlayer().getUniqueId());
if (session == null) {
return;
}
plugin.getArenaManager().get(session.getArenaName()).ifPresent(arena -> {
if (arena.getConfig().getSpectator() != null) {
event.setRespawnLocation(arena.getConfig().getSpectator());
}
});
}
}
@@ -0,0 +1,98 @@
package us.tss3.blockparty.logic;
import java.util.ArrayList;
import java.util.List;
/**
* Validates the raw fields of an arena configuration without touching Bukkit types.
* Callers adapt their config objects into a {@link Fields} record.
*/
public final class ArenaConfigValidator {
private ArenaConfigValidator() {
}
public record Fields(
String name,
String world,
boolean hasLobby,
boolean hasSpawn,
boolean hasSpectator,
boolean hasFloorCorners,
int minPlayers,
int maxPlayers,
int countdownDuration,
int roundPrepDuration,
int floorRemoveDelay,
int floorRestoreDelay,
int startingRoundTime,
int minimumRoundTime,
int reductionPerRound,
int winEndingDelay,
List<String> floorMaterials
) {
}
public static List<String> validate(Fields f) {
List<String> errors = new ArrayList<>();
if (isBlank(f.name())) {
errors.add("name is missing");
}
if (isBlank(f.world())) {
errors.add("world is missing or invalid");
}
if (!f.hasLobby()) {
errors.add("lobby location is not set");
}
if (!f.hasSpawn()) {
errors.add("spawn location is not set");
}
if (!f.hasSpectator()) {
errors.add("spectator location is not set");
}
if (!f.hasFloorCorners()) {
errors.add("floor region (pos1/pos2) is not set");
}
if (f.minPlayers() < 1) {
errors.add("min-players must be >= 1");
}
if (f.maxPlayers() < f.minPlayers()) {
errors.add("max-players must be >= min-players");
}
if (f.countdownDuration() <= 0) {
errors.add("countdown-duration must be > 0");
}
if (f.roundPrepDuration() < 0) {
errors.add("round-prep-duration must be >= 0");
}
if (f.floorRemoveDelay() < 0) {
errors.add("floor-remove-delay must be >= 0");
}
if (f.floorRestoreDelay() < 0) {
errors.add("floor-restore-delay must be >= 0");
}
if (f.startingRoundTime() <= 0) {
errors.add("starting-round-time must be > 0");
}
if (f.minimumRoundTime() < 0) {
errors.add("minimum-round-time must be >= 0");
}
if (f.minimumRoundTime() > f.startingRoundTime()) {
errors.add("minimum-round-time must be <= starting-round-time");
}
if (f.reductionPerRound() < 0) {
errors.add("round-time-reduction must be >= 0");
}
if (f.winEndingDelay() < 0) {
errors.add("win-ending-delay must be >= 0");
}
if (f.floorMaterials() == null || f.floorMaterials().isEmpty()) {
errors.add("floor-materials palette is empty");
}
return errors;
}
private static boolean isBlank(String s) {
return s == null || s.isBlank();
}
}
@@ -0,0 +1,10 @@
package us.tss3.blockparty.logic;
/** Pure enum representing the lifecycle state of an arena. No Bukkit dependencies. */
public enum ArenaPhase {
DISABLED,
WAITING,
STARTING,
RUNNING,
ENDING
}
@@ -0,0 +1,51 @@
package us.tss3.blockparty.logic;
import java.util.EnumMap;
import java.util.EnumSet;
import java.util.Map;
import java.util.Set;
/**
* Pure state-machine describing legal transitions between {@link ArenaPhase} values.
* No Bukkit dependencies so it can be unit tested directly.
*/
public final class ArenaStateMachine {
private static final Map<ArenaPhase, Set<ArenaPhase>> TRANSITIONS = new EnumMap<>(ArenaPhase.class);
static {
TRANSITIONS.put(ArenaPhase.DISABLED, EnumSet.of(ArenaPhase.WAITING));
TRANSITIONS.put(ArenaPhase.WAITING, EnumSet.of(ArenaPhase.STARTING, ArenaPhase.DISABLED));
TRANSITIONS.put(ArenaPhase.STARTING, EnumSet.of(ArenaPhase.RUNNING, ArenaPhase.WAITING, ArenaPhase.DISABLED));
TRANSITIONS.put(ArenaPhase.RUNNING, EnumSet.of(ArenaPhase.ENDING, ArenaPhase.DISABLED));
TRANSITIONS.put(ArenaPhase.ENDING, EnumSet.of(ArenaPhase.WAITING, ArenaPhase.DISABLED));
}
private ArenaPhase current;
public ArenaStateMachine() {
this(ArenaPhase.DISABLED);
}
public ArenaStateMachine(ArenaPhase initial) {
this.current = initial;
}
public ArenaPhase getState() {
return current;
}
public boolean canTransition(ArenaPhase target) {
Set<ArenaPhase> allowed = TRANSITIONS.get(current);
return allowed != null && allowed.contains(target);
}
/** @return true if the transition happened */
public boolean transition(ArenaPhase target) {
if (!canTransition(target)) {
return false;
}
this.current = target;
return true;
}
}
@@ -0,0 +1,26 @@
package us.tss3.blockparty.logic;
/** Pure logic for deciding when a waiting-lobby countdown should start/cancel. */
public final class CountdownLogic {
private CountdownLogic() {
}
public static boolean shouldStart(int currentPlayers, int minPlayers, boolean alreadyCounting) {
return !alreadyCounting && currentPlayers >= minPlayers && minPlayers > 0;
}
/** Below-min-players cancels an in-progress countdown, resetting to WAITING. */
public static boolean shouldCancel(int currentPlayers, int minPlayers, boolean currentlyCounting) {
return currentlyCounting && currentPlayers < minPlayers;
}
/** @return remaining ticks/seconds after decrementing by one, clamped at 0 */
public static int tick(int remaining) {
return Math.max(0, remaining - 1);
}
public static boolean isFinished(int remaining) {
return remaining <= 0;
}
}
@@ -0,0 +1,38 @@
package us.tss3.blockparty.logic;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
/** Pure logic for determining eliminations and win conditions given player/material state. */
public final class EliminationLogic {
private EliminationLogic() {
}
public record PlayerFloorState<K, M>(K playerId, M standingOn) {
}
/** @return list of player ids standing on a material other than the target (or on nothing) */
public static <K, M> List<K> determineEliminated(List<PlayerFloorState<K, M>> states, M target) {
List<K> eliminated = new ArrayList<>();
for (PlayerFloorState<K, M> state : states) {
if (state.standingOn() == null || !state.standingOn().equals(target)) {
eliminated.add(state.playerId());
}
}
return eliminated;
}
/** Determines whether the match should end and who the winner is (if any). */
public static <K> Optional<K> determineWinner(List<K> remainingPlayers) {
if (remainingPlayers.size() == 1) {
return Optional.of(remainingPlayers.get(0));
}
return Optional.empty();
}
public static boolean isMatchOver(int remainingPlayers) {
return remainingPlayers <= 1;
}
}
@@ -0,0 +1,34 @@
package us.tss3.blockparty.logic;
/** Pure logic for join/leave-related player count decisions. */
public final class PlayerCountLogic {
private PlayerCountLogic() {
}
public static boolean canJoin(int currentPlayers, int maxPlayers, boolean arenaEnabled,
boolean alreadyInAnotherArena, boolean allowJoinWhileWaiting,
ArenaPhase phase) {
if (!arenaEnabled) {
return false;
}
if (alreadyInAnotherArena) {
return false;
}
if (currentPlayers >= maxPlayers) {
return false;
}
if (phase == ArenaPhase.RUNNING || phase == ArenaPhase.ENDING) {
return false;
}
if (phase == ArenaPhase.STARTING && !allowJoinWhileWaiting) {
return false;
}
return phase == ArenaPhase.WAITING || phase == ArenaPhase.STARTING;
}
/** A single player alone should never auto-start the match unless min-players is configured as 1. */
public static boolean autoStartsAlone(int minPlayers) {
return minPlayers <= 1;
}
}
@@ -0,0 +1,29 @@
package us.tss3.blockparty.logic;
/** Pure math for computing round time progression across rounds. */
public final class RoundTimeCalculator {
private RoundTimeCalculator() {
}
/**
* @param startingSeconds time for round 1
* @param minimumSeconds floor value, never go below this
* @param reductionPerRound seconds removed per round after the first
* @param roundNumber 1-indexed round number
*/
public static int timeForRound(int startingSeconds, int minimumSeconds, int reductionPerRound, int roundNumber) {
if (roundNumber < 1) {
roundNumber = 1;
}
long reduction = (long) reductionPerRound * (roundNumber - 1);
long result = startingSeconds - reduction;
if (result < minimumSeconds) {
result = minimumSeconds;
}
if (result < 0) {
result = 0;
}
return (int) result;
}
}
@@ -0,0 +1,46 @@
package us.tss3.blockparty.logic;
import java.util.List;
import java.util.function.IntUnaryOperator;
/**
* Selects a random target color/material from a palette using an injectable
* randomness source so behaviour is deterministic in tests.
*/
public final class TargetColorSelector {
private final IntUnaryOperator boundedRandom;
/** @param boundedRandom given an exclusive upper bound, returns a value in [0, bound) */
public TargetColorSelector(IntUnaryOperator boundedRandom) {
this.boundedRandom = boundedRandom;
}
public <T> T select(List<T> palette) {
if (palette == null || palette.isEmpty()) {
throw new IllegalArgumentException("palette must not be empty");
}
int index = boundedRandom.applyAsInt(palette.size());
if (index < 0 || index >= palette.size()) {
index = 0;
}
return palette.get(index);
}
/** Avoid repeating the immediately previous color when the palette allows it. */
public <T> T selectExcluding(List<T> palette, T previous) {
if (palette == null || palette.isEmpty()) {
throw new IllegalArgumentException("palette must not be empty");
}
if (palette.size() == 1 || previous == null) {
return select(palette);
}
T pick = select(palette);
int attempts = 0;
while (pick.equals(previous) && attempts < 8) {
pick = select(palette);
attempts++;
}
return pick;
}
}
@@ -0,0 +1,84 @@
package us.tss3.blockparty.model;
import org.bukkit.GameMode;
import org.bukkit.Location;
import org.bukkit.inventory.ItemStack;
import org.bukkit.potion.PotionEffect;
import org.bukkit.entity.Player;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
/** Snapshot of a player's pre-game state so it can be restored exactly afterwards. */
public class PlayerState {
private final ItemStack[] inventoryContents;
private final ItemStack[] armorContents;
private final ItemStack offhand;
private final double health;
private final int foodLevel;
private final float saturation;
private final double exp;
private final int level;
private final GameMode gameMode;
private final boolean flying;
private final boolean allowFlight;
private final Location location;
private final List<PotionEffect> potionEffects;
private boolean restored = false;
private PlayerState(Player player) {
this.inventoryContents = player.getInventory().getContents().clone();
this.armorContents = player.getInventory().getArmorContents().clone();
this.offhand = player.getInventory().getItemInOffHand().clone();
this.health = player.getHealth();
this.foodLevel = player.getFoodLevel();
this.saturation = player.getSaturation();
this.exp = player.getExp();
this.level = player.getLevel();
this.gameMode = player.getGameMode();
this.flying = player.isFlying();
this.allowFlight = player.getAllowFlight();
this.location = player.getLocation().clone();
this.potionEffects = new ArrayList<>(player.getActivePotionEffects());
}
public static PlayerState capture(Player player) {
return new PlayerState(player);
}
/** Idempotent: restoring twice has no additional effect (guards against duplication exploits). */
public synchronized void restore(Player player) {
if (restored) {
return;
}
restored = true;
player.getInventory().setContents(inventoryContents);
player.getInventory().setArmorContents(armorContents);
player.getInventory().setItemInOffHand(offhand);
player.setGameMode(gameMode);
player.setHealth(Math.min(health, player.getAttribute(org.bukkit.attribute.Attribute.MAX_HEALTH) != null
? player.getAttribute(org.bukkit.attribute.Attribute.MAX_HEALTH).getValue() : health));
player.setFoodLevel(foodLevel);
player.setSaturation(saturation);
player.setExp((float) exp);
player.setLevel(level);
player.setAllowFlight(allowFlight);
player.setFlying(flying);
Collection<PotionEffect> current = new ArrayList<>(player.getActivePotionEffects());
for (PotionEffect effect : current) {
player.removePotionEffect(effect.getType());
}
for (PotionEffect effect : potionEffects) {
player.addPotionEffect(effect);
}
if (location.getWorld() != null) {
player.teleport(location);
}
}
public Location getLocation() {
return location;
}
}
@@ -0,0 +1,50 @@
package us.tss3.blockparty.persistence;
import java.util.UUID;
public class PlayerStats {
private final UUID uuid;
private int gamesPlayed;
private int wins;
private int eliminations;
private int bestRound;
private int totalRoundsSurvived;
public PlayerStats(UUID uuid, int gamesPlayed, int wins, int eliminations, int bestRound, int totalRoundsSurvived) {
this.uuid = uuid;
this.gamesPlayed = gamesPlayed;
this.wins = wins;
this.eliminations = eliminations;
this.bestRound = bestRound;
this.totalRoundsSurvived = totalRoundsSurvived;
}
public static PlayerStats empty(UUID uuid) {
return new PlayerStats(uuid, 0, 0, 0, 0, 0);
}
public UUID getUuid() {
return uuid;
}
public int getGamesPlayed() {
return gamesPlayed;
}
public int getWins() {
return wins;
}
public int getEliminations() {
return eliminations;
}
public int getBestRound() {
return bestRound;
}
public int getTotalRoundsSurvived() {
return totalRoundsSurvived;
}
}
@@ -0,0 +1,128 @@
package us.tss3.blockparty.persistence;
import org.bukkit.plugin.Plugin;
import java.io.File;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.UUID;
import java.util.logging.Level;
/** SQLite-backed statistics storage. All public methods are safe to call from any thread;
* callers are expected to invoke them off the main thread (see StatsManager). */
public class StatsDatabase {
private final Plugin plugin;
private Connection connection;
public StatsDatabase(Plugin plugin) {
this.plugin = plugin;
}
public synchronized void connect() {
try {
File dbFile = new File(plugin.getDataFolder(), "blockparty.db");
Class.forName("org.sqlite.JDBC");
connection = DriverManager.getConnection("jdbc:sqlite:" + dbFile.getAbsolutePath());
try (Statement st = connection.createStatement()) {
st.execute("CREATE TABLE IF NOT EXISTS player_stats (" +
"uuid TEXT PRIMARY KEY," +
"games_played INTEGER NOT NULL DEFAULT 0," +
"wins INTEGER NOT NULL DEFAULT 0," +
"eliminations INTEGER NOT NULL DEFAULT 0," +
"best_round INTEGER NOT NULL DEFAULT 0," +
"total_rounds_survived INTEGER NOT NULL DEFAULT 0" +
")");
}
} catch (Exception ex) {
plugin.getLogger().log(Level.SEVERE, "Failed to initialize SQLite stats database", ex);
}
}
public synchronized void close() {
try {
if (connection != null && !connection.isClosed()) {
connection.close();
}
} catch (SQLException ignored) {
}
}
private synchronized void ensureRow(String uuid) throws SQLException {
try (PreparedStatement ps = connection.prepareStatement(
"INSERT OR IGNORE INTO player_stats(uuid) VALUES (?)")) {
ps.setString(1, uuid);
ps.executeUpdate();
}
}
public synchronized PlayerStats getStats(UUID uuid) {
if (connection == null) {
return PlayerStats.empty(uuid);
}
try {
ensureRow(uuid.toString());
try (PreparedStatement ps = connection.prepareStatement(
"SELECT games_played, wins, eliminations, best_round, total_rounds_survived FROM player_stats WHERE uuid=?")) {
ps.setString(1, uuid.toString());
try (ResultSet rs = ps.executeQuery()) {
if (rs.next()) {
return new PlayerStats(uuid, rs.getInt(1), rs.getInt(2), rs.getInt(3), rs.getInt(4), rs.getInt(5));
}
}
}
} catch (SQLException ex) {
plugin.getLogger().log(Level.WARNING, "Failed to read stats for " + uuid, ex);
}
return PlayerStats.empty(uuid);
}
public synchronized void incrementGamesPlayed(UUID uuid) {
update(uuid, "games_played = games_played + 1");
}
public synchronized void incrementWins(UUID uuid) {
update(uuid, "wins = wins + 1");
}
public synchronized void incrementEliminations(UUID uuid) {
update(uuid, "eliminations = eliminations + 1");
}
public synchronized void recordRoundReached(UUID uuid, int round) {
if (connection == null) {
return;
}
try {
ensureRow(uuid.toString());
try (PreparedStatement ps = connection.prepareStatement(
"UPDATE player_stats SET best_round = MAX(best_round, ?), total_rounds_survived = total_rounds_survived + 1 WHERE uuid=?")) {
ps.setInt(1, round);
ps.setString(2, uuid.toString());
ps.executeUpdate();
}
} catch (SQLException ex) {
plugin.getLogger().log(Level.WARNING, "Failed to update round stats for " + uuid, ex);
}
}
private void update(UUID uuid, String setClause) {
if (connection == null) {
return;
}
try {
ensureRow(uuid.toString());
try (PreparedStatement ps = connection.prepareStatement(
"UPDATE player_stats SET " + setClause + " WHERE uuid=?")) {
ps.setString(1, uuid.toString());
ps.executeUpdate();
}
} catch (SQLException ex) {
plugin.getLogger().log(Level.WARNING, "Failed to update stats for " + uuid, ex);
}
}
}
@@ -0,0 +1,51 @@
package us.tss3.blockparty.persistence;
import org.bukkit.plugin.Plugin;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
/** Async facade over StatsDatabase so callers never block the main thread. */
public class StatsManager {
private final Plugin plugin;
private final StatsDatabase database;
public StatsManager(Plugin plugin) {
this.plugin = plugin;
this.database = new StatsDatabase(plugin);
}
public void init() {
runAsync(database::connect);
}
public void shutdown() {
database.close();
}
public void recordGamePlayed(UUID uuid) {
runAsync(() -> database.incrementGamesPlayed(uuid));
}
public void recordWin(UUID uuid) {
runAsync(() -> database.incrementWins(uuid));
}
public void recordElimination(UUID uuid, int round) {
runAsync(() -> {
database.incrementEliminations(uuid);
database.recordRoundReached(uuid, round);
});
}
public CompletableFuture<PlayerStats> getStats(UUID uuid) {
CompletableFuture<PlayerStats> future = new CompletableFuture<>();
plugin.getServer().getAsyncScheduler().runNow(plugin, task -> future.complete(database.getStats(uuid)));
return future;
}
private void runAsync(Runnable runnable) {
plugin.getServer().getAsyncScheduler().runNow(plugin, task -> runnable.run());
}
}
@@ -0,0 +1,80 @@
package us.tss3.blockparty.placeholder;
import me.clip.placeholderapi.expansion.PlaceholderExpansion;
import org.bukkit.OfflinePlayer;
import org.jetbrains.annotations.NotNull;
import us.tss3.blockparty.BlockPartyPlugin;
import us.tss3.blockparty.arena.Arena;
import us.tss3.blockparty.persistence.PlayerStats;
import us.tss3.blockparty.session.PlayerSession;
/**
* PlaceholderAPI expansion. This class references PlaceholderAPI classes directly, so it must
* only ever be loaded/constructed when PlaceholderAPI's plugin jar is confirmed present -
* see BlockPartyPlugin#registerPlaceholderApi.
*/
public class BlockPartyExpansion extends PlaceholderExpansion {
private final BlockPartyPlugin plugin;
public BlockPartyExpansion(BlockPartyPlugin plugin) {
this.plugin = plugin;
}
@Override
public @NotNull String getIdentifier() {
return "blockparty";
}
@Override
public @NotNull String getAuthor() {
return "tss3";
}
@Override
public @NotNull String getVersion() {
return plugin.getDescription().getVersion();
}
@Override
public boolean persist() {
return true;
}
@Override
public String onRequest(OfflinePlayer player, @NotNull String params) {
if (params.equals("wins") || params.equals("games_played") || params.equals("best_round")) {
if (player == null) {
return "0";
}
PlayerStats stats;
try {
stats = plugin.getStatsManager().getStats(player.getUniqueId()).get();
} catch (Exception ex) {
return "0";
}
return switch (params) {
case "wins" -> String.valueOf(stats.getWins());
case "games_played" -> String.valueOf(stats.getGamesPlayed());
case "best_round" -> String.valueOf(stats.getBestRound());
default -> "";
};
}
if (params.equals("arena")) {
if (player == null || player.getUniqueId() == null) {
return "";
}
PlayerSession session = plugin.getSessionManager().get(player.getUniqueId());
return session != null ? session.getArenaName() : "";
}
if (params.startsWith("players_")) {
String arenaName = params.substring("players_".length());
return plugin.getArenaManager().get(arenaName).map(a -> String.valueOf(a.getPlayers().size())).orElse("0");
}
if (params.startsWith("state_")) {
String arenaName = params.substring("state_".length());
return plugin.getArenaManager().get(arenaName).map(Arena::getPhase).map(Enum::name).orElse("UNKNOWN");
}
return null;
}
}
@@ -0,0 +1,27 @@
package us.tss3.blockparty.reward;
import org.bukkit.Bukkit;
import us.tss3.blockparty.BlockPartyPlugin;
import java.util.List;
/** Dispatches configured console commands as rewards, substituting placeholders. No Vault required. */
public class RewardManager {
private final BlockPartyPlugin plugin;
public RewardManager(BlockPartyPlugin plugin) {
this.plugin = plugin;
}
public void dispatchWinnerRewards(String playerName, String arenaName, int round) {
List<String> commands = plugin.getConfigManager().getRewardCommands();
for (String cmd : commands) {
String parsed = cmd.replace("%player%", playerName)
.replace("%arena%", arenaName)
.replace("%round%", String.valueOf(round));
plugin.getServer().getScheduler().runTask(plugin, () ->
Bukkit.dispatchCommand(Bukkit.getConsoleSender(), parsed));
}
}
}
@@ -0,0 +1,101 @@
package us.tss3.blockparty.scoreboard;
import org.bukkit.entity.Player;
import org.bukkit.scheduler.BukkitTask;
import org.bukkit.scoreboard.DisplaySlot;
import org.bukkit.scoreboard.Objective;
import org.bukkit.scoreboard.Scoreboard;
import us.tss3.blockparty.BlockPartyPlugin;
import us.tss3.blockparty.arena.Arena;
import us.tss3.blockparty.logic.ArenaPhase;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
/** Maintains a per-player sidebar scoreboard, refreshed on an interval rather than every tick. */
public class ScoreboardManager {
private final BlockPartyPlugin plugin;
private final Map<UUID, Arena> attached = new HashMap<>();
private BukkitTask updateTask;
public ScoreboardManager(BlockPartyPlugin plugin) {
this.plugin = plugin;
}
public void start() {
int interval = plugin.getConfigManager().getScoreboardUpdateIntervalTicks();
updateTask = plugin.getServer().getScheduler().runTaskTimer(plugin, this::updateAll, interval, interval);
}
public void stop() {
if (updateTask != null) {
updateTask.cancel();
updateTask = null;
}
}
public void attach(Player player, Arena arena) {
if (!plugin.getConfigManager().isScoreboardEnabled()) {
return;
}
attached.put(player.getUniqueId(), arena);
render(player, arena);
}
public void detach(Player player) {
attached.remove(player.getUniqueId());
player.setScoreboard(plugin.getServer().getScoreboardManager().getNewScoreboard());
}
private void updateAll() {
attached.forEach((uuid, arena) -> {
Player p = plugin.getServer().getPlayer(uuid);
if (p != null) {
render(p, arena);
}
});
}
private void render(Player player, Arena arena) {
Scoreboard board = plugin.getServer().getScoreboardManager().getNewScoreboard();
Objective objective = board.registerNewObjective("blockparty", "dummy",
plugin.getMessages().get("scoreboard.title"));
objective.setDisplaySlot(DisplaySlot.SIDEBAR);
int line = 10;
String targetName = arena.getCurrentTarget() != null
? arena.getCurrentTarget().name().replace("_CONCRETE", "").replace("_", " ")
: "-";
Map<String, String> ph = Map.of(
"arena", arena.getConfig().getName(),
"round", String.valueOf(arena.getRound()),
"players", String.valueOf(arena.getPlayers().size()),
"target", targetName,
"time", String.valueOf(Math.max(0, arena.getRoundTimeRemaining())),
"state", arena.getPhase().name()
);
for (String rawLine : plugin.getMessages().rawList("scoreboard.lines")) {
String rendered = applyPlaceholders(rawLine, ph);
if (rendered.isBlank()) {
rendered = " ".repeat(line == 10 ? 1 : (10 - line + 1));
}
objective.getScore(rendered).setScore(line--);
}
player.setScoreboard(board);
}
private String applyPlaceholders(String text, Map<String, String> ph) {
String result = text;
for (Map.Entry<String, String> e : ph.entrySet()) {
result = result.replace("%" + e.getKey() + "%", e.getValue());
}
// strip minimessage-ish tags crudely for scoreboard (legacy) - scoreboards support limited components on modern paper
return org.bukkit.ChatColor.translateAlternateColorCodes('&', stripMini(result));
}
private String stripMini(String s) {
return s.replaceAll("<[^>]+>", "");
}
}
@@ -0,0 +1,73 @@
package us.tss3.blockparty.session;
import org.bukkit.Material;
import us.tss3.blockparty.model.PlayerState;
import java.util.UUID;
/** Per-player session tracking their participation in a single arena match. */
public class PlayerSession {
public enum Role {
PLAYER,
SPECTATOR
}
private final UUID uuid;
private final String arenaName;
private final PlayerState savedState;
private Role role = Role.PLAYER;
private Material standingOn;
private int roundsSurvived = 0;
private boolean eliminated = false;
public PlayerSession(UUID uuid, String arenaName, PlayerState savedState) {
this.uuid = uuid;
this.arenaName = arenaName;
this.savedState = savedState;
}
public UUID getUuid() {
return uuid;
}
public String getArenaName() {
return arenaName;
}
public PlayerState getSavedState() {
return savedState;
}
public Role getRole() {
return role;
}
public void setRole(Role role) {
this.role = role;
}
public Material getStandingOn() {
return standingOn;
}
public void setStandingOn(Material standingOn) {
this.standingOn = standingOn;
}
public int getRoundsSurvived() {
return roundsSurvived;
}
public void incrementRoundsSurvived() {
roundsSurvived++;
}
public boolean isEliminated() {
return eliminated;
}
public void setEliminated(boolean eliminated) {
this.eliminated = eliminated;
}
}
@@ -0,0 +1,31 @@
package us.tss3.blockparty.session;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
/** Tracks which arena (if any) each online player is currently in. Enforces single-arena membership. */
public class SessionManager {
private final Map<UUID, PlayerSession> sessions = new ConcurrentHashMap<>();
public boolean isInSession(UUID uuid) {
return sessions.containsKey(uuid);
}
public PlayerSession get(UUID uuid) {
return sessions.get(uuid);
}
public void add(PlayerSession session) {
sessions.put(session.getUuid(), session);
}
public void remove(UUID uuid) {
sessions.remove(uuid);
}
public Map<UUID, PlayerSession> all() {
return sessions;
}
}
@@ -0,0 +1,44 @@
package us.tss3.blockparty.util;
import org.bukkit.Bukkit;
import org.bukkit.Location;
import org.bukkit.World;
import org.bukkit.configuration.ConfigurationSection;
public final class LocationUtil {
private LocationUtil() {
}
public static Location fromSection(ConfigurationSection section) {
if (section == null) {
return null;
}
String worldName = section.getString("world");
if (worldName == null) {
return null;
}
World world = Bukkit.getWorld(worldName);
if (world == null) {
return null;
}
double x = section.getDouble("x");
double y = section.getDouble("y");
double z = section.getDouble("z");
float yaw = (float) section.getDouble("yaw");
float pitch = (float) section.getDouble("pitch");
return new Location(world, x, y, z, yaw, pitch);
}
public static void toSection(ConfigurationSection section, Location loc) {
if (loc == null || loc.getWorld() == null) {
return;
}
section.set("world", loc.getWorld().getName());
section.set("x", loc.getX());
section.set("y", loc.getY());
section.set("z", loc.getZ());
section.set("yaw", (double) loc.getYaw());
section.set("pitch", (double) loc.getPitch());
}
}
@@ -0,0 +1,59 @@
package us.tss3.blockparty.util;
import org.bukkit.Location;
import org.bukkit.Sound;
import org.bukkit.entity.Player;
import org.bukkit.plugin.Plugin;
import java.util.HashMap;
import java.util.Map;
import java.util.logging.Level;
/** Resolves and plays configured sound names safely, warning (not crashing) on invalid names. */
public final class SoundUtil {
private final Plugin plugin;
private final Map<String, Sound> cache = new HashMap<>();
private final Map<String, Boolean> warned = new HashMap<>();
public SoundUtil(Plugin plugin) {
this.plugin = plugin;
}
public void play(Player player, String soundName, float volume, float pitch) {
Sound sound = resolve(soundName);
if (sound == null) {
return;
}
player.playSound(player.getLocation(), sound, volume, pitch);
}
public void play(Location location, String soundName, float volume, float pitch) {
Sound sound = resolve(soundName);
if (sound == null || location.getWorld() == null) {
return;
}
location.getWorld().playSound(location, sound, volume, pitch);
}
private Sound resolve(String name) {
if (name == null || name.isBlank()) {
return null;
}
if (cache.containsKey(name)) {
return cache.get(name);
}
try {
Sound sound = Sound.valueOf(name.toUpperCase(java.util.Locale.ROOT));
cache.put(name, sound);
return sound;
} catch (IllegalArgumentException ex) {
cache.put(name, null);
if (!warned.getOrDefault(name, false)) {
plugin.getLogger().log(Level.WARNING, "Invalid sound name in config: '" + name + "' - skipping.");
warned.put(name, true);
}
return null;
}
}
}
+55
View File
@@ -0,0 +1,55 @@
# BlockParty global configuration
ui:
scoreboard: true
bossbar: true
titles: true
actionbar: true
scoreboard-update-interval-ticks: 10
sounds:
join: ENTITY_EXPERIENCE_ORB_PICKUP
countdown: BLOCK_NOTE_BLOCK_PLING
target-select: ENTITY_ENDER_DRAGON_GROWL
floor-disappear: ENTITY_GENERIC_EXPLODE
eliminate: ENTITY_VILLAGER_NO
round-complete: BLOCK_NOTE_BLOCK_BELL
victory: UI_TOAST_CHALLENGE_COMPLETE
performance:
# blocks processed per server tick when generating/removing/restoring the floor
floor-blocks-per-tick: 200
# example/default timings used when creating a brand-new arena via /blockparty create
defaults:
countdown-duration: 15
starting-round-time: 20
minimum-round-time: 4
round-time-reduction: 1
default-floor-materials:
- RED_CONCRETE
- ORANGE_CONCRETE
- YELLOW_CONCRETE
- LIME_CONCRETE
- GREEN_CONCRETE
- CYAN_CONCRETE
- LIGHT_BLUE_CONCRETE
- BLUE_CONCRETE
- PURPLE_CONCRETE
- MAGENTA_CONCRETE
- PINK_CONCRETE
- WHITE_CONCRETE
- BLACK_CONCRETE
- GRAY_CONCRETE
- LIGHT_GRAY_CONCRETE
- BROWN_CONCRETE
rewards:
winner:
commands:
- "eco give %player% 100"
- "say %player% won BlockParty in %arena% after %round% rounds!"
integrations:
placeholderapi: true
+56
View File
@@ -0,0 +1,56 @@
# BlockParty messages - MiniMessage format supported (https://docs.advntr.dev/minimessage/format.html)
errors:
no-permission: "<red>You do not have permission to do that.</red>"
unknown-arena: "<red>Unknown arena: %arena%</red>"
already-in-arena: "<red>You are already in an arena. Use /blockparty leave first.</red>"
not-in-arena: "<red>You are not currently in an arena.</red>"
arena-disabled: "<red>Arena '%arena%' is disabled.</red>"
arena-full: "<red>Arena '%arena%' is full.</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>"
admin:
arena-exists: "<red>Arena '%arena%' already exists.</red>"
arena-created: "<green>Arena '%arena%' created.</green>"
arena-deleted: "<green>Arena '%arena%' deleted.</green>"
arena-enabled: "<green>Arena '%arena%' enabled.</green>"
arena-disabled: "<yellow>Arena '%arena%' disabled.</yellow>"
validation-failed: "<red>Arena '%arena%' failed validation:</red>"
lobby-set: "<green>Lobby set for '%arena%'.</green>"
spawn-set: "<green>Spawn set for '%arena%'.</green>"
spectator-set: "<green>Spectator location set for '%arena%'.</green>"
pos1-set: "<green>Position 1 set for '%arena%'.</green>"
pos2-set: "<green>Position 2 set for '%arena%'.</green>"
floor-set: "<green>Floor palette for '%arena%' set to %count% material(s).</green>"
floor-generated: "<green>Floor generated for '%arena%'.</green>"
reloaded: "<green>BlockParty configuration reloaded.</green>"
player:
joined: "<green>You joined arena '%arena%'.</green>"
left: "<yellow>You left the arena.</yellow>"
countdown:
actionbar: "<yellow>Starting in %time%s...</yellow>"
title: "<gold>%time%</gold>"
cancelled: "<red>Not enough players - countdown cancelled.</red>"
round:
title: "<white>Round %round%</white>"
subtitle: "<yellow>Stand on <bold>%color%</bold>!</yellow>"
actionbar: "<yellow>Target: %color% - %time%s</yellow>"
game:
eliminated: "<red>You were eliminated in round %round% of '%arena%'.</red>"
win-title: "<gold><bold>%player% wins!</bold></gold>"
no-winner: "<gray>The match ended with no winner.</gray>"
scoreboard:
title: "<gold><bold>BlockParty</bold></gold>"
lines:
- "&7Arena: &f%arena%"
- "&7Round: &f%round%"
- ""
- "&7Players: &f%players%"
- "&7Target: &f%target%"
- "&7Time: &f%time%s"
+44
View File
@@ -0,0 +1,44 @@
name: BlockParty
version: '${version}'
main: us.tss3.blockparty.BlockPartyPlugin
api-version: '1.21'
author: tss3
description: Colored-floor elimination minigame.
softdepend: [PlaceholderAPI, Vault]
commands:
blockparty:
description: BlockParty main command
aliases: [bp]
usage: /<command> help
permissions:
blockparty.play:
description: Allows playing BlockParty (join/leave/stats)
default: true
blockparty.join:
description: Allows joining arenas
default: true
blockparty.spectate:
description: Allows spectating arenas
default: true
blockparty.admin:
description: Grants all BlockParty admin permissions
default: op
children:
blockparty.admin.create: true
blockparty.admin.delete: true
blockparty.admin.setup: true
blockparty.admin.reload: true
blockparty.admin.create:
description: Allows creating arenas
default: op
blockparty.admin.delete:
description: Allows deleting arenas
default: op
blockparty.admin.setup:
description: Allows configuring arenas (lobby/spawn/floor/etc)
default: op
blockparty.admin.reload:
description: Allows reloading the plugin
default: op
@@ -0,0 +1,74 @@
package us.tss3.blockparty.logic;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
class ArenaConfigValidatorTest {
private ArenaConfigValidator.Fields validFields() {
return new ArenaConfigValidator.Fields(
"arena1", "world", true, true, true, true,
2, 16, 10, 5, 1, 3, 20, 3, 1, 5,
List.of("RED_CONCRETE", "BLUE_CONCRETE")
);
}
@Test
void validFieldsProduceNoErrors() {
assertTrue(ArenaConfigValidator.validate(validFields()).isEmpty());
}
@Test
void missingLobbyDetected() {
ArenaConfigValidator.Fields f = new ArenaConfigValidator.Fields(
"arena1", "world", false, true, true, true,
2, 16, 10, 5, 1, 3, 20, 3, 1, 5,
List.of("RED_CONCRETE"));
List<String> errors = ArenaConfigValidator.validate(f);
assertTrue(errors.stream().anyMatch(e -> e.contains("lobby")));
}
@Test
void emptyPaletteDetected() {
ArenaConfigValidator.Fields f = new ArenaConfigValidator.Fields(
"arena1", "world", true, true, true, true,
2, 16, 10, 5, 1, 3, 20, 3, 1, 5,
List.of());
List<String> errors = ArenaConfigValidator.validate(f);
assertTrue(errors.stream().anyMatch(e -> e.contains("floor-materials")));
}
@Test
void maxLessThanMinDetected() {
ArenaConfigValidator.Fields f = new ArenaConfigValidator.Fields(
"arena1", "world", true, true, true, true,
10, 5, 10, 5, 1, 3, 20, 3, 1, 5,
List.of("RED_CONCRETE"));
List<String> errors = ArenaConfigValidator.validate(f);
assertTrue(errors.stream().anyMatch(e -> e.contains("max-players")));
}
@Test
void minimumRoundTimeAboveStartingDetected() {
ArenaConfigValidator.Fields f = new ArenaConfigValidator.Fields(
"arena1", "world", true, true, true, true,
2, 16, 10, 5, 1, 3, 5, 20, 1, 5,
List.of("RED_CONCRETE"));
List<String> errors = ArenaConfigValidator.validate(f);
assertTrue(errors.stream().anyMatch(e -> e.contains("minimum-round-time")));
}
@Test
void missingNameAndWorldDetected() {
ArenaConfigValidator.Fields f = new ArenaConfigValidator.Fields(
"", "", true, true, true, true,
2, 16, 10, 5, 1, 3, 20, 3, 1, 5,
List.of("RED_CONCRETE"));
List<String> errors = ArenaConfigValidator.validate(f);
assertTrue(errors.stream().anyMatch(e -> e.contains("name")));
assertTrue(errors.stream().anyMatch(e -> e.contains("world")));
}
}
@@ -0,0 +1,49 @@
package us.tss3.blockparty.logic;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class ArenaStateMachineTest {
@Test
void disabledTransitionsToWaiting() {
ArenaStateMachine sm = new ArenaStateMachine(ArenaPhase.DISABLED);
assertTrue(sm.transition(ArenaPhase.WAITING));
assertEquals(ArenaPhase.WAITING, sm.getState());
}
@Test
void cannotSkipToRunningFromWaiting() {
ArenaStateMachine sm = new ArenaStateMachine(ArenaPhase.WAITING);
assertFalse(sm.canTransition(ArenaPhase.RUNNING));
assertFalse(sm.transition(ArenaPhase.RUNNING));
assertEquals(ArenaPhase.WAITING, sm.getState());
}
@Test
void fullHappyPathCycle() {
ArenaStateMachine sm = new ArenaStateMachine(ArenaPhase.DISABLED);
assertTrue(sm.transition(ArenaPhase.WAITING));
assertTrue(sm.transition(ArenaPhase.STARTING));
assertTrue(sm.transition(ArenaPhase.RUNNING));
assertTrue(sm.transition(ArenaPhase.ENDING));
assertTrue(sm.transition(ArenaPhase.WAITING));
}
@Test
void anyStateCanDisable() {
for (ArenaPhase phase : ArenaPhase.values()) {
ArenaStateMachine sm = new ArenaStateMachine(phase);
if (phase != ArenaPhase.DISABLED) {
assertTrue(sm.canTransition(ArenaPhase.DISABLED), "expected " + phase + " -> DISABLED");
}
}
}
@Test
void startingCanRevertToWaiting() {
ArenaStateMachine sm = new ArenaStateMachine(ArenaPhase.STARTING);
assertTrue(sm.transition(ArenaPhase.WAITING));
}
}
@@ -0,0 +1,46 @@
package us.tss3.blockparty.logic;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class CountdownLogicTest {
@Test
void startsWhenMinReached() {
assertTrue(CountdownLogic.shouldStart(3, 3, false));
assertTrue(CountdownLogic.shouldStart(5, 3, false));
}
@Test
void doesNotStartBelowMin() {
assertFalse(CountdownLogic.shouldStart(2, 3, false));
}
@Test
void doesNotRestartIfAlreadyCounting() {
assertFalse(CountdownLogic.shouldStart(5, 3, true));
}
@Test
void cancelsWhenBelowMinDuringCountdown() {
assertTrue(CountdownLogic.shouldCancel(2, 3, true));
}
@Test
void doesNotCancelWhenNotCounting() {
assertFalse(CountdownLogic.shouldCancel(2, 3, false));
}
@Test
void tickClampsAtZero() {
assertEquals(0, CountdownLogic.tick(0));
assertEquals(4, CountdownLogic.tick(5));
}
@Test
void isFinishedAtZeroOrBelow() {
assertTrue(CountdownLogic.isFinished(0));
assertFalse(CountdownLogic.isFinished(1));
}
}
@@ -0,0 +1,49 @@
package us.tss3.blockparty.logic;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
class EliminationLogicTest {
@Test
void eliminatesWrongMaterial() {
List<EliminationLogic.PlayerFloorState<String, String>> states = List.of(
new EliminationLogic.PlayerFloorState<>("p1", "RED"),
new EliminationLogic.PlayerFloorState<>("p2", "BLUE"),
new EliminationLogic.PlayerFloorState<>("p3", "RED")
);
List<String> eliminated = EliminationLogic.determineEliminated(states, "RED");
assertEquals(List.of("p2"), eliminated);
}
@Test
void eliminatesPlayersStandingOnNothing() {
List<EliminationLogic.PlayerFloorState<String, String>> states = List.of(
new EliminationLogic.PlayerFloorState<>("p1", null)
);
assertEquals(List.of("p1"), EliminationLogic.determineEliminated(states, "RED"));
}
@Test
void winnerDeterminedWithOnePlayerLeft() {
Optional<String> winner = EliminationLogic.determineWinner(List.of("p1"));
assertTrue(winner.isPresent());
assertEquals("p1", winner.get());
}
@Test
void noWinnerWithMultiplePlayers() {
assertTrue(EliminationLogic.determineWinner(List.of("p1", "p2")).isEmpty());
}
@Test
void matchOverAtOneOrZero() {
assertTrue(EliminationLogic.isMatchOver(1));
assertTrue(EliminationLogic.isMatchOver(0));
assertFalse(EliminationLogic.isMatchOver(2));
}
}
@@ -0,0 +1,45 @@
package us.tss3.blockparty.logic;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class PlayerCountLogicTest {
@Test
void canJoinWhenWaitingAndRoom() {
assertTrue(PlayerCountLogic.canJoin(2, 10, true, false, true, ArenaPhase.WAITING));
}
@Test
void cannotJoinWhenFull() {
assertFalse(PlayerCountLogic.canJoin(10, 10, true, false, true, ArenaPhase.WAITING));
}
@Test
void cannotJoinWhenDisabled() {
assertFalse(PlayerCountLogic.canJoin(2, 10, false, false, true, ArenaPhase.WAITING));
}
@Test
void cannotJoinWhenAlreadyInAnotherArena() {
assertFalse(PlayerCountLogic.canJoin(2, 10, true, true, true, ArenaPhase.WAITING));
}
@Test
void cannotJoinWhenRunning() {
assertFalse(PlayerCountLogic.canJoin(2, 10, true, false, true, ArenaPhase.RUNNING));
}
@Test
void startingRespectsAllowJoinFlag() {
assertFalse(PlayerCountLogic.canJoin(2, 10, true, false, false, ArenaPhase.STARTING));
assertTrue(PlayerCountLogic.canJoin(2, 10, true, false, true, ArenaPhase.STARTING));
}
@Test
void autoStartAloneOnlyWhenMinIsOne() {
assertTrue(PlayerCountLogic.autoStartsAlone(1));
assertFalse(PlayerCountLogic.autoStartsAlone(2));
}
}
@@ -0,0 +1,34 @@
package us.tss3.blockparty.logic;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
class RoundTimeCalculatorTest {
@Test
void firstRoundUsesStartingTime() {
assertEquals(20, RoundTimeCalculator.timeForRound(20, 5, 2, 1));
}
@Test
void reducesEachRound() {
assertEquals(18, RoundTimeCalculator.timeForRound(20, 5, 2, 2));
assertEquals(16, RoundTimeCalculator.timeForRound(20, 5, 2, 3));
}
@Test
void clampsAtMinimum() {
assertEquals(5, RoundTimeCalculator.timeForRound(20, 5, 2, 20));
}
@Test
void neverNegativeEvenWithoutMinimum() {
assertEquals(0, RoundTimeCalculator.timeForRound(5, 0, 100, 3));
}
@Test
void roundNumberBelowOneTreatedAsOne() {
assertEquals(20, RoundTimeCalculator.timeForRound(20, 5, 2, 0));
}
}
@@ -0,0 +1,45 @@
package us.tss3.blockparty.logic;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
class TargetColorSelectorTest {
@Test
void deterministicWithFixedRng() {
TargetColorSelector selector = new TargetColorSelector(bound -> 2);
String result = selector.select(List.of("RED", "GREEN", "BLUE", "YELLOW"));
assertEquals("BLUE", result);
}
@Test
void emptyPaletteThrows() {
TargetColorSelector selector = new TargetColorSelector(bound -> 0);
assertThrows(IllegalArgumentException.class, () -> selector.select(List.of()));
}
@Test
void selectExcludingAvoidsRepeatWhenPossible() {
int[] calls = {0};
int[] sequence = {0, 0, 1}; // first two picks are index 0 (== previous), third differs
TargetColorSelector selector = new TargetColorSelector(bound -> sequence[Math.min(calls[0]++, sequence.length - 1)]);
List<String> palette = List.of("RED", "GREEN");
String result = selector.selectExcluding(palette, "RED");
assertEquals("GREEN", result);
}
@Test
void selectExcludingSinglePaletteReturnsSameValue() {
TargetColorSelector selector = new TargetColorSelector(bound -> 0);
assertEquals("RED", selector.selectExcluding(List.of("RED"), "RED"));
}
@Test
void outOfRangeIndexFallsBackToZero() {
TargetColorSelector selector = new TargetColorSelector(bound -> 99);
assertEquals("RED", selector.select(List.of("RED", "GREEN")));
}
}