Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
@@ -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
|
||||
@@ -0,0 +1,8 @@
|
||||
.gradle/
|
||||
build/
|
||||
!gradle/wrapper/gradle-wrapper.jar
|
||||
.idea/
|
||||
*.iml
|
||||
out/
|
||||
.vscode/
|
||||
*.log
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
Vendored
BIN
Binary file not shown.
+7
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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")));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user