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