feat(base): implement progression system
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Java
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 17
|
||||
cache: gradle
|
||||
|
||||
- name: Validate conventional commits
|
||||
if: github.event_name == 'pull_request'
|
||||
run: |
|
||||
npx -y \
|
||||
-p @commitlint/cli \
|
||||
-p @commitlint/config-conventional \
|
||||
commitlint --from "${{ github.event.pull_request.base.sha }}" --to "${{ github.sha }}" \
|
||||
--extends @commitlint/config-conventional
|
||||
|
||||
- name: Build and test
|
||||
run: ./gradlew clean check jar
|
||||
|
||||
- name: Upload development artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: spigot-base-${{ github.sha }}
|
||||
path: build/libs/*.jar
|
||||
if-no-files-found: error
|
||||
@@ -0,0 +1,141 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.RELEASE_TOKEN }}
|
||||
|
||||
- name: Set up Java
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 17
|
||||
cache: gradle
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Build and test before release
|
||||
run: ./gradlew clean check
|
||||
|
||||
- name: Capture previous tag
|
||||
id: previous_tag
|
||||
run: |
|
||||
if git describe --tags --abbrev=0 >/dev/null 2>&1; then
|
||||
echo "value=$(git describe --tags --abbrev=0)" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "value=" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Run semantic-release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
run: |
|
||||
npx -y \
|
||||
-p semantic-release@24.2.9 \
|
||||
-p @semantic-release/commit-analyzer@13.0.1 \
|
||||
-p @semantic-release/release-notes-generator@14.1.0 \
|
||||
semantic-release \
|
||||
--branches main \
|
||||
--plugins @semantic-release/commit-analyzer,@semantic-release/release-notes-generator
|
||||
|
||||
- name: Capture current tag
|
||||
id: current_tag
|
||||
run: |
|
||||
if git describe --tags --exact-match HEAD >/dev/null 2>&1; then
|
||||
echo "value=$(git describe --tags --exact-match HEAD)" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "value=" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Resolve release
|
||||
id: release
|
||||
env:
|
||||
PREVIOUS_TAG: ${{ steps.previous_tag.outputs.value }}
|
||||
CURRENT_TAG: ${{ steps.current_tag.outputs.value }}
|
||||
run: |
|
||||
if [ -n "$CURRENT_TAG" ] && [ "$CURRENT_TAG" != "$PREVIOUS_TAG" ]; then
|
||||
echo "created=true" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=$CURRENT_TAG" >> "$GITHUB_OUTPUT"
|
||||
echo "version=${CURRENT_TAG#v}" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "created=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Build versioned plugin
|
||||
if: steps.release.outputs.created == 'true'
|
||||
env:
|
||||
VERSION: ${{ steps.release.outputs.version }}
|
||||
run: ./gradlew clean jar -PreleaseVersion="$VERSION"
|
||||
|
||||
- name: Upload release workflow artifact
|
||||
if: steps.release.outputs.created == 'true'
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: spigot-base-${{ steps.release.outputs.version }}
|
||||
path: build/libs/spigot-base-${{ steps.release.outputs.version }}.jar
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Create Gitea release and upload plugin
|
||||
if: steps.release.outputs.created == 'true'
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
GITEA_SERVER_URL: ${{ github.server_url }}
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
TAG: ${{ steps.release.outputs.tag }}
|
||||
VERSION: ${{ steps.release.outputs.version }}
|
||||
PREVIOUS_TAG: ${{ steps.previous_tag.outputs.value }}
|
||||
run: |
|
||||
api_url="${GITEA_SERVER_URL}/api/v1"
|
||||
jar="build/libs/spigot-base-${VERSION}.jar"
|
||||
export RELEASE_BODY
|
||||
if [ -n "$PREVIOUS_TAG" ]; then
|
||||
RELEASE_BODY=$(git log --pretty='format:- %s (%h)' "${PREVIOUS_TAG}..HEAD")
|
||||
else
|
||||
RELEASE_BODY=$(git log --pretty='format:- %s (%h)' HEAD)
|
||||
fi
|
||||
payload=$(node -e '
|
||||
const payload = {
|
||||
tag_name: process.env.TAG,
|
||||
name: process.env.TAG,
|
||||
body: process.env.RELEASE_BODY,
|
||||
draft: false,
|
||||
prerelease: false
|
||||
};
|
||||
process.stdout.write(JSON.stringify(payload));
|
||||
')
|
||||
response=$(curl --fail-with-body --silent --show-error \
|
||||
-X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${api_url}/repos/${REPOSITORY}/releases" \
|
||||
--data "$payload")
|
||||
release_id=$(printf '%s' "$response" | node -e '
|
||||
let input = "";
|
||||
process.stdin.on("data", chunk => input += chunk);
|
||||
process.stdin.on("end", () => {
|
||||
const response = JSON.parse(input);
|
||||
if (!response.id) process.exit(1);
|
||||
process.stdout.write(String(response.id));
|
||||
});
|
||||
')
|
||||
curl --fail-with-body --silent --show-error \
|
||||
-X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary "@${jar}" \
|
||||
"${api_url}/repos/${REPOSITORY}/releases/${release_id}/assets?name=spigot-base-${VERSION}.jar"
|
||||
@@ -0,0 +1,7 @@
|
||||
.gradle/
|
||||
build/
|
||||
out/
|
||||
.idea/
|
||||
*.iml
|
||||
*.log
|
||||
.DS_Store
|
||||
@@ -0,0 +1,54 @@
|
||||
# Spigot Base
|
||||
|
||||
A Spigot 26.2 plugin providing progression-gated player bases and quality-of-life unlocks.
|
||||
|
||||
Players begin by breaking Survival-mode grass blocks or dirt. The progression paths provide a persistent base, navigation particles, expanding base bounds, controlled flight, personal teleportation, and visitor teleportation.
|
||||
|
||||
The approved behavior is specified in the [OKF design bundle](design/index.md).
|
||||
|
||||
## Requirements
|
||||
|
||||
- Spigot 26.2
|
||||
- Java 17 or newer
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
./gradlew clean check jar
|
||||
```
|
||||
|
||||
The plugin JAR is written to `build/libs/`.
|
||||
|
||||
## Player commands
|
||||
|
||||
```text
|
||||
/setbase
|
||||
/base
|
||||
/base upgrade
|
||||
/basenavigation
|
||||
/baseflight
|
||||
/basevisitors
|
||||
/gotobase <player>
|
||||
/baseprogress
|
||||
/baseprogress bossbar
|
||||
```
|
||||
|
||||
`/base` has a stationary warm-up. Looking around is allowed, while movement between blocks, damage, teleportation, world changes, death, logout, and conflicting teleport commands cancel it without consuming the cooldown.
|
||||
|
||||
## Administration
|
||||
|
||||
The `spigotbase.admin` permission is granted to server operators by default.
|
||||
|
||||
```text
|
||||
/baseadmin progress <player>
|
||||
/baseadmin setlevel <player> <base|size|flight|warmup|cooldown> <level>
|
||||
/baseadmin clearcooldown <player> [personal|visitor|all]
|
||||
/baseadmin reset <player> <base|size|flight|warmup|cooldown>
|
||||
/baseadmin reset <player> all confirm
|
||||
```
|
||||
|
||||
Default thresholds and durations are documented in `src/main/resources/config.yml`. Durable UUID-keyed state is stored in `plugins/SpigotBase/state.yml` using atomic replacement where supported.
|
||||
|
||||
## Releases
|
||||
|
||||
Gitea Actions checks pushes and pull requests and stores a development JAR. Pull requests validate conventional commits. Main-branch conventional commits drive semantic releases when the repository defines a `RELEASE_TOKEN` with contents-write permission.
|
||||
@@ -0,0 +1,46 @@
|
||||
plugins {
|
||||
java
|
||||
}
|
||||
|
||||
group = "games.dmg"
|
||||
version = providers.gradleProperty("releaseVersion")
|
||||
.orElse("0.1.0-SNAPSHOT")
|
||||
.get()
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
maven("https://hub.spigotmc.org/nexus/content/repositories/snapshots/")
|
||||
}
|
||||
|
||||
java {
|
||||
toolchain {
|
||||
languageVersion = JavaLanguageVersion.of(17)
|
||||
}
|
||||
}
|
||||
|
||||
tasks.withType<JavaCompile>().configureEach {
|
||||
options.compilerArgs.addAll(listOf("-Xlint:all", "-Werror"))
|
||||
}
|
||||
|
||||
dependencies {
|
||||
compileOnly("org.spigotmc:spigot-api:26.2-R0.1-SNAPSHOT")
|
||||
|
||||
testImplementation("org.spigotmc:spigot-api:26.2-R0.1-SNAPSHOT")
|
||||
testImplementation(platform("org.junit:junit-bom:5.13.4"))
|
||||
testImplementation("org.junit.jupiter:junit-jupiter")
|
||||
testImplementation("org.mockito:mockito-core:5.18.0")
|
||||
testImplementation("org.yaml:snakeyaml:2.4")
|
||||
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
|
||||
}
|
||||
|
||||
tasks.test {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
|
||||
val pluginVersion = version
|
||||
|
||||
tasks.processResources {
|
||||
filesMatching("plugin.yml") {
|
||||
expand("version" to pluginVersion)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
---
|
||||
type: Index
|
||||
title: Spigot Base Design
|
||||
description: Entry point for the Spigot Base OKF knowledge bundle.
|
||||
okf_version: "0.1"
|
||||
---
|
||||
|
||||
# Spigot Base Design
|
||||
|
||||
This bundle documents the progression-gated quality-of-life features, player bases, flight, teleportation, administration, persistence, and delivery requirements for the Spigot Base plugin.
|
||||
|
||||
## Explore
|
||||
|
||||
- [User stories](user-stories/index.md)
|
||||
- [Design log](log.md)
|
||||
@@ -0,0 +1,29 @@
|
||||
---
|
||||
type: Log
|
||||
title: Spigot Base Design Log
|
||||
description: Chronological record of material decisions affecting the Spigot Base design.
|
||||
---
|
||||
|
||||
# Spigot Base Design Log
|
||||
|
||||
## 2026-08-09 — Initial progression design
|
||||
|
||||
- Base progression is sequential: Base I establishes a base, Base II provides navigation, Base III provides personal teleportation, and Base IV permits visitor teleportation.
|
||||
- Every secondary progression path requires Base I.
|
||||
- A base is cylindrical and begins with a 10-block radius and a vertical extent 25 blocks above and below its set Y coordinate.
|
||||
- Only Survival-mode activity contributes progress; repeatedly placing and breaking blocks is permitted.
|
||||
- Flight is limited to the base, includes a five-block horizontal warning buffer, and can be toggled by the player.
|
||||
- Player-facing administration will initially use commands rather than an inventory UI.
|
||||
- The build and release pipeline will follow the neighboring Trigger Spawn project pattern.
|
||||
|
||||
## 2026-08-09 — Implementation started
|
||||
|
||||
- Approved implementation begins with the tested Gradle/Spigot foundation, validated configuration, and durable player state.
|
||||
- Feature work will proceed incrementally using tests before implementation where practical.
|
||||
|
||||
## 2026-08-09 — Core implementation checkpoint
|
||||
|
||||
- Implemented UUID-keyed YAML state, validated configuration, Base I–IV progression, base sizing, particle navigation, controlled base flight, personal and visitor teleportation, progress feedback, and initial administrative commands.
|
||||
- Added Gradle, Spigot metadata, Gitea CI/release workflows, and project documentation based on Trigger Spawn.
|
||||
- Verified the implementation with `./gradlew clean check jar`: 34 tests passed and the plugin JAR was produced successfully.
|
||||
- User stories remain in progress pending live-server integration verification and completion of runtime administrative configuration editing.
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
type: Index
|
||||
title: Spigot Base User Stories
|
||||
description: Catalog of user stories for the Spigot Base plugin.
|
||||
---
|
||||
|
||||
# Spigot Base User Stories
|
||||
|
||||
1. [US-001: Unlock and establish Base I](us-001-unlock-and-establish-base.md)
|
||||
2. [US-002: Unlock Base II navigation](us-002-unlock-base-navigation.md)
|
||||
3. [US-003: Expand the base](us-003-expand-the-base.md)
|
||||
4. [US-004: Unlock and control base flight](us-004-unlock-and-control-base-flight.md)
|
||||
5. [US-005: Unlock Base III teleportation](us-005-unlock-base-teleportation.md)
|
||||
6. [US-006: Reduce the base teleport warm-up](us-006-reduce-teleport-warmup.md)
|
||||
7. [US-007: Reduce the base teleport cooldown](us-007-reduce-teleport-cooldown.md)
|
||||
8. [US-008: Unlock Base IV visitor access](us-008-unlock-visitor-access.md)
|
||||
9. [US-009: View progression and unlock notifications](us-009-view-progression-and-notifications.md)
|
||||
10. [US-010: Administer player progression](us-010-administer-player-progression.md)
|
||||
11. [US-011: Configure and persist progression](us-011-configure-and-persist-progression.md)
|
||||
12. [US-012: Build and release the plugin](us-012-build-and-release-plugin.md)
|
||||
@@ -0,0 +1,32 @@
|
||||
---
|
||||
type: User Story
|
||||
title: "US-001: Unlock and establish Base I"
|
||||
description: Let players earn and establish a persistent personal base by breaking dirt and grass blocks.
|
||||
status: in-progress
|
||||
---
|
||||
|
||||
# US-001: Unlock and establish Base I
|
||||
|
||||
As a **player**, I want to earn and set a personal base so that later quality-of-life progression has a location associated with it.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] Only blocks broken while the player is in Survival mode contribute progression.
|
||||
- [ ] Breaking `GRASS_BLOCK` or `DIRT` contributes one unit of Base I progress.
|
||||
- [ ] Other dirt-like materials, including coarse dirt, rooted dirt, podzol, mycelium, and dirt paths, do not contribute by default.
|
||||
- [ ] Player-placed blocks may contribute when broken; natural-generation detection is not required.
|
||||
- [ ] Base I unlocks when the player reaches the configured threshold, which defaults to 250 qualifying blocks.
|
||||
- [ ] `/setbase` is unavailable before Base I and explains the unmet requirement.
|
||||
- [ ] After Base I unlocks, `/setbase` records the player's current world and block location as the center of a cylindrical base.
|
||||
- [ ] The initial cylinder has a configurable 10-block horizontal radius and extends a configurable 25 blocks above and 25 blocks below the set Y coordinate.
|
||||
- [ ] The first successful `/setbase` is immediately available.
|
||||
- [ ] A successful relocation starts a configurable elapsed-time cooldown that defaults to 24 hours.
|
||||
- [ ] Failed attempts do not start or extend the relocation cooldown.
|
||||
- [ ] Progress and base state are associated with the player's UUID and survive restarts.
|
||||
|
||||
## Related
|
||||
|
||||
- [US-002: Unlock Base II navigation](us-002-unlock-base-navigation.md)
|
||||
- [US-003: Expand the base](us-003-expand-the-base.md)
|
||||
- [US-004: Unlock and control base flight](us-004-unlock-and-control-base-flight.md)
|
||||
- [US-005: Unlock Base III teleportation](us-005-unlock-base-teleportation.md)
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
type: User Story
|
||||
title: "US-002: Unlock Base II navigation"
|
||||
description: Let players earn toggleable particle guidance toward their established base.
|
||||
status: in-progress
|
||||
---
|
||||
|
||||
# US-002: Unlock Base II navigation
|
||||
|
||||
As a **player with Base I**, I want visual guidance toward my base so that I can find it without relying on coordinates.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] Base II requires Base I and an established base.
|
||||
- [ ] Base II unlocks at a configurable cumulative grass-or-dirt threshold that defaults to 500 blocks, 250 more than Base I.
|
||||
- [ ] Base II provides particle-based navigation and does not grant or require a physical compass item.
|
||||
- [ ] While enabled and in the base's world, particles are drawn along the ground to indicate the direction toward the base.
|
||||
- [ ] Particle generation is bounded to avoid excessive server or client load.
|
||||
- [ ] A player in another world receives a clear message instead of a misleading particle direction.
|
||||
- [ ] `/basenavigation` toggles guidance on and off after Base II is unlocked.
|
||||
- [ ] The navigation preference persists across reconnects and restarts.
|
||||
|
||||
## Related
|
||||
|
||||
- [US-001: Unlock and establish Base I](us-001-unlock-and-establish-base.md)
|
||||
- [US-009: View progression and unlock notifications](us-009-view-progression-and-notifications.md)
|
||||
@@ -0,0 +1,29 @@
|
||||
---
|
||||
type: User Story
|
||||
title: "US-003: Expand the base"
|
||||
description: Let players expand their base radius through sequential stone, deepslate, and obsidian mining milestones.
|
||||
status: in-progress
|
||||
---
|
||||
|
||||
# US-003: Expand the base
|
||||
|
||||
As a **player with Base I**, I want mining milestones to expand my base so that more of my build receives base benefits.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] Base-size progression is unavailable until Base I is unlocked.
|
||||
- [ ] Only qualifying blocks broken in Survival mode count.
|
||||
- [ ] The first expansion requires a configurable 500 `STONE` and changes the radius from 10 to 25 blocks by default.
|
||||
- [ ] After the stone tier, the second expansion requires a separate configurable 1,000 `DEEPSLATE` and changes the radius from 25 to 75 blocks by default.
|
||||
- [ ] After the deepslate tier, the third expansion requires a separate configurable 1,000 `OBSIDIAN` and changes the radius from 75 to 150 blocks by default.
|
||||
- [ ] Stone, deepslate, and obsidian variants do not count by default.
|
||||
- [ ] Materials mined before their sequential tier becomes active do not count toward that later tier.
|
||||
- [ ] Player-placed qualifying blocks may be mined repeatedly for progress.
|
||||
- [ ] Expansions affect the cylinder's horizontal radius without independently changing its vertical bounds.
|
||||
- [ ] Relocating the base preserves earned size tiers and counters.
|
||||
|
||||
## Related
|
||||
|
||||
- [US-001: Unlock and establish Base I](us-001-unlock-and-establish-base.md)
|
||||
- [US-004: Unlock and control base flight](us-004-unlock-and-control-base-flight.md)
|
||||
- [US-011: Configure and persist progression](us-011-configure-and-persist-progression.md)
|
||||
@@ -0,0 +1,35 @@
|
||||
---
|
||||
type: User Story
|
||||
title: "US-004: Unlock and control base flight"
|
||||
description: Let players permanently unlock and toggle increasingly broad flight within their personal base.
|
||||
status: in-progress
|
||||
---
|
||||
|
||||
# US-004: Unlock and control base flight
|
||||
|
||||
As a **player with Base I**, I want to unlock controlled flight around my base so that I can build large structures more conveniently.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] Flight progression is unavailable until Base I is unlocked and a base is established.
|
||||
- [ ] The plugin observes elytra held directly in the player's inventory and armor equipment while the player is in Survival mode.
|
||||
- [ ] Elytra inside shulker boxes, bundles, or other nested containers do not count.
|
||||
- [ ] Observing one elytra permanently unlocks Flight I, two simultaneous elytra unlock Flight II, and three simultaneous elytra unlock Flight III.
|
||||
- [ ] A player observed with enough elytra for a later tier receives all unmet preceding flight tiers.
|
||||
- [ ] Elytra are not consumed, and losing them later does not revoke an earned tier.
|
||||
- [ ] Flight I permits plugin-granted flight within the current horizontal base radius and from 25 blocks below through 25 blocks above base Y by default.
|
||||
- [ ] Flight II expands the vertical range to 100 blocks below and above base Y by default.
|
||||
- [ ] Flight III expands the vertical range to the world's minimum and maximum build heights.
|
||||
- [ ] A configurable five-block horizontal warning buffer extends beyond the current base radius.
|
||||
- [ ] Plugin-granted flight remains active in the warning buffer and displays prominent on-screen notice that the player is leaving the base.
|
||||
- [ ] Passing beyond the warning buffer removes only flight granted by this plugin.
|
||||
- [ ] Flight is not granted outside the unlocked vertical range.
|
||||
- [ ] `/baseflight` toggles the player's unlocked base flight on and off.
|
||||
- [ ] The flight toggle persists across reconnects and restarts.
|
||||
- [ ] The plugin handles teleportation, world changes, game-mode changes, death, logout, and plugin shutdown without leaving unintended flight enabled.
|
||||
|
||||
## Related
|
||||
|
||||
- [US-001: Unlock and establish Base I](us-001-unlock-and-establish-base.md)
|
||||
- [US-003: Expand the base](us-003-expand-the-base.md)
|
||||
- [US-009: View progression and unlock notifications](us-009-view-progression-and-notifications.md)
|
||||
@@ -0,0 +1,32 @@
|
||||
---
|
||||
type: User Story
|
||||
title: "US-005: Unlock Base III teleportation"
|
||||
description: Let players earn a safe, stationary-warm-up teleport to their personal base.
|
||||
status: in-progress
|
||||
---
|
||||
|
||||
# US-005: Unlock Base III teleportation
|
||||
|
||||
As a **player with Base II**, I want to earn `/base` so that I can return safely to my established base.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] Base III requires Base II, an established base, and a configurable 200 qualifying block placements inside the base.
|
||||
- [ ] Only placements made in Survival mode and within the base's current horizontal and vertical bounds count.
|
||||
- [ ] Player-placed blocks and replacement of previously broken blocks may contribute repeatedly.
|
||||
- [ ] Base III unlocks `/base` with a configurable 30-second warm-up and three-hour cooldown by default.
|
||||
- [ ] Looking around without changing block coordinates does not cancel the warm-up.
|
||||
- [ ] Changing block X, Y, or Z, taking damage, teleporting, changing worlds, dying, disconnecting, or starting a conflicting teleport cancels the warm-up.
|
||||
- [ ] Cancellation clearly informs the player and does not consume the cooldown.
|
||||
- [ ] The destination is a safe standing location at or near the recorded base center.
|
||||
- [ ] An unavailable or unsafe destination is reported clearly and does not consume the cooldown.
|
||||
- [ ] Only a completed teleport starts the cooldown.
|
||||
- [ ] Cooldowns use real elapsed time and continue while the player is offline.
|
||||
|
||||
## Related
|
||||
|
||||
- [US-001: Unlock and establish Base I](us-001-unlock-and-establish-base.md)
|
||||
- [US-002: Unlock Base II navigation](us-002-unlock-base-navigation.md)
|
||||
- [US-006: Reduce the base teleport warm-up](us-006-reduce-teleport-warmup.md)
|
||||
- [US-007: Reduce the base teleport cooldown](us-007-reduce-teleport-cooldown.md)
|
||||
- [US-008: Unlock Base IV visitor access](us-008-unlock-visitor-access.md)
|
||||
@@ -0,0 +1,28 @@
|
||||
---
|
||||
type: User Story
|
||||
title: "US-006: Reduce the base teleport warm-up"
|
||||
description: Let players reduce and eventually eliminate their base teleport warm-up by building inside their base.
|
||||
status: in-progress
|
||||
---
|
||||
|
||||
# US-006: Reduce the base teleport warm-up
|
||||
|
||||
As a **player with Base III**, I want continued building to shorten my `/base` warm-up so that returning home becomes increasingly convenient.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] Warm-up progression requires Base III.
|
||||
- [ ] The qualifying placement counter from the Base III unlock is retained and used cumulatively.
|
||||
- [ ] Only Survival-mode placements inside the base's current horizontal and vertical bounds count.
|
||||
- [ ] A configurable 200 total placements grants the initial 30-second warm-up by default.
|
||||
- [ ] A configurable 1,000 total placements reduces the warm-up from 30 to 15 seconds by default.
|
||||
- [ ] A configurable 2,000 total placements reduces the warm-up from 15 to 5 seconds by default.
|
||||
- [ ] A configurable 12,000 total placements removes the warm-up by default.
|
||||
- [ ] Each tier must be earned sequentially, and progress is preserved when the base is relocated.
|
||||
- [ ] An instant warm-up does not bypass destination safety validation or cooldown enforcement.
|
||||
|
||||
## Related
|
||||
|
||||
- [US-005: Unlock Base III teleportation](us-005-unlock-base-teleportation.md)
|
||||
- [US-008: Unlock Base IV visitor access](us-008-unlock-visitor-access.md)
|
||||
- [US-011: Configure and persist progression](us-011-configure-and-persist-progression.md)
|
||||
@@ -0,0 +1,29 @@
|
||||
---
|
||||
type: User Story
|
||||
title: "US-007: Reduce the base teleport cooldown"
|
||||
description: Let players reduce and eventually eliminate their base teleport cooldown by breaking blocks inside their base.
|
||||
status: in-progress
|
||||
---
|
||||
|
||||
# US-007: Reduce the base teleport cooldown
|
||||
|
||||
As a **player with Base III**, I want work performed inside my base to shorten the `/base` cooldown so that I can return more frequently.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] Cooldown-reduction progress requires Base III.
|
||||
- [ ] Any block broken in Survival mode inside the base's current horizontal and vertical bounds contributes unless excluded by configuration.
|
||||
- [ ] Player-placed blocks may be broken repeatedly for progress.
|
||||
- [ ] The default cooldown is three hours before any cooldown-reduction milestone.
|
||||
- [ ] A configurable 1,000 cumulative blocks reduces the cooldown from three to two hours by default.
|
||||
- [ ] A configurable 2,000 cumulative blocks reduces the cooldown from two hours to one hour by default.
|
||||
- [ ] A configurable 3,000 cumulative blocks reduces the cooldown from one hour to 30 minutes by default.
|
||||
- [ ] A configurable 5,000 cumulative blocks removes the cooldown by default.
|
||||
- [ ] Each tier is sequential and relocating the base preserves earned tiers and progress.
|
||||
- [ ] An instant cooldown still enforces the applicable teleport warm-up and destination safety checks.
|
||||
|
||||
## Related
|
||||
|
||||
- [US-005: Unlock Base III teleportation](us-005-unlock-base-teleportation.md)
|
||||
- [US-006: Reduce the base teleport warm-up](us-006-reduce-teleport-warmup.md)
|
||||
- [US-008: Unlock Base IV visitor access](us-008-unlock-visitor-access.md)
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
type: User Story
|
||||
title: "US-008: Unlock Base IV visitor access"
|
||||
description: Let players spend diamonds to permit controlled, cooldown-limited visitor teleports to their base.
|
||||
status: in-progress
|
||||
---
|
||||
|
||||
# US-008: Unlock Base IV visitor access
|
||||
|
||||
As a **player with Base III**, I want to open my base to visitors so that other players can conveniently join me there under my control.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] Base IV requires Base III and an established base.
|
||||
- [ ] `/base upgrade` offers the Base IV purchase for a configurable price that defaults to 128 diamonds.
|
||||
- [ ] A successful purchase removes the complete price atomically from the player's direct inventory.
|
||||
- [ ] Insufficient funds, an invalid state, or a failed persistence operation does not consume any diamonds or grant Base IV.
|
||||
- [ ] `/basevisitors` lets a Base IV owner toggle visitor access on and off.
|
||||
- [ ] The visitor-access preference persists across reconnects and restarts.
|
||||
- [ ] `/gotobase <owner>` autocompletes bases that the requesting player is currently eligible to visit.
|
||||
- [ ] Enabled bases remain visitable while their owners are offline.
|
||||
- [ ] A visitor teleport uses the destination owner's current warm-up tier.
|
||||
- [ ] Looking around is permitted, while movement between block coordinates, damage, teleportation, world change, death, logout, or a conflicting teleport cancels the visitor warm-up.
|
||||
- [ ] Cancellation or destination failure does not consume a visitor cooldown.
|
||||
- [ ] A safe destination is resolved at or near the owner's recorded base center.
|
||||
- [ ] Each visitor has an independent cooldown for each destination owner.
|
||||
- [ ] On successful teleport, the visitor cooldown duration is captured from the destination owner's current cooldown tier.
|
||||
- [ ] A later owner cooldown upgrade applies to future visits without rewriting a cooldown already in progress.
|
||||
- [ ] Visiting another base does not consume or modify the owner's personal `/base` cooldown or the visitor's personal `/base` cooldown.
|
||||
- [ ] Disabling visits prevents new requests but does not interrupt a teleport that has already completed.
|
||||
|
||||
## Related
|
||||
|
||||
- [US-005: Unlock Base III teleportation](us-005-unlock-base-teleportation.md)
|
||||
- [US-006: Reduce the base teleport warm-up](us-006-reduce-teleport-warmup.md)
|
||||
- [US-007: Reduce the base teleport cooldown](us-007-reduce-teleport-cooldown.md)
|
||||
@@ -0,0 +1,30 @@
|
||||
---
|
||||
type: User Story
|
||||
title: "US-009: View progression and unlock notifications"
|
||||
description: Give players clear command, boss-bar, and full-screen feedback about their progression.
|
||||
status: in-progress
|
||||
---
|
||||
|
||||
# US-009: View progression and unlock notifications
|
||||
|
||||
As a **player**, I want to inspect my progression and receive timely milestone feedback so that I understand what I can unlock next.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] `/baseprogress` shows the player's Base, Base Size, Base Flight, Teleport Warm-up, and Teleport Cooldown paths.
|
||||
- [ ] Each path identifies earned levels, unmet prerequisites, current progress, the next threshold, and the next reward.
|
||||
- [ ] Locked secondary paths clearly identify Base I or another sequential level as their prerequisite.
|
||||
- [ ] Relevant qualifying activity briefly displays a configurable progress boss bar for the active milestone.
|
||||
- [ ] Boss-bar text and fill accurately represent the current count and threshold and never exceed 100 percent.
|
||||
- [ ] The automatic boss bar disappears after a configurable number of seconds.
|
||||
- [ ] `/baseprogress bossbar` toggles automatic progress boss bars on and off.
|
||||
- [ ] Disabling automatic boss bars does not prevent `/baseprogress` from displaying progress.
|
||||
- [ ] The boss-bar preference persists across reconnects and restarts.
|
||||
- [ ] Each newly unlocked level displays prominent full-screen title and subtitle text describing the reward.
|
||||
- [ ] Unlock notifications occur once per earned level and do not repeat after reconnecting or restarting.
|
||||
|
||||
## Related
|
||||
|
||||
- [US-001: Unlock and establish Base I](us-001-unlock-and-establish-base.md)
|
||||
- [US-004: Unlock and control base flight](us-004-unlock-and-control-base-flight.md)
|
||||
- [US-010: Administer player progression](us-010-administer-player-progression.md)
|
||||
@@ -0,0 +1,31 @@
|
||||
---
|
||||
type: User Story
|
||||
title: "US-010: Administer player progression"
|
||||
description: Let administrators inspect and safely modify player progression and live progression settings through commands.
|
||||
status: in-progress
|
||||
---
|
||||
|
||||
# US-010: Administer player progression
|
||||
|
||||
As a **server administrator**, I want command-based progression controls so that I can inspect players, correct state, and tune requirements without an inventory UI.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] Administrative commands require `spigotbase.admin`, which server operators receive by default.
|
||||
- [ ] Administrative player arguments safely resolve online players and previously known offline players.
|
||||
- [ ] Player state remains keyed by UUID while retaining the latest known name for lookup and display.
|
||||
- [ ] `/baseadmin progress <player>` displays the player's base, counters, earned path levels, active cooldowns, toggles, and visitor settings.
|
||||
- [ ] `/baseadmin setlevel <player> <path> <level>` sets an earned path level while enforcing or explicitly granting required preceding levels.
|
||||
- [ ] `/baseadmin setprogress <player> <path> <amount>` updates the selected counter and consistently evaluates reached tiers.
|
||||
- [ ] `/baseadmin reset <player> <path>` resets a selected path without silently leaving benefits that require it.
|
||||
- [ ] `/baseadmin reset <player> all` removes the player's base, progression, active cooldowns, and plugin preferences after confirmation.
|
||||
- [ ] Administrators can clear personal and visitor cooldowns independently.
|
||||
- [ ] Administrative commands can update configured block requirements, warm-ups, and cooldowns for each level using validated values.
|
||||
- [ ] Runtime configuration changes are persisted for subsequent restarts.
|
||||
- [ ] Lowered progression requirements are evaluated for a player on their next relevant action rather than immediately updating every stored player.
|
||||
- [ ] Every successful mutation reports exactly what changed, and invalid requests make no partial changes.
|
||||
|
||||
## Related
|
||||
|
||||
- [US-009: View progression and unlock notifications](us-009-view-progression-and-notifications.md)
|
||||
- [US-011: Configure and persist progression](us-011-configure-and-persist-progression.md)
|
||||
@@ -0,0 +1,30 @@
|
||||
---
|
||||
type: User Story
|
||||
title: "US-011: Configure and persist progression"
|
||||
description: Give operators validated configuration and durable, defensive storage for all base progression behavior.
|
||||
status: in-progress
|
||||
---
|
||||
|
||||
# US-011: Configure and persist progression
|
||||
|
||||
As a **server operator**, I want progression behavior to be configurable and durable so that the plugin remains predictable across restarts and server changes.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] Configuration supports all qualifying materials, progression thresholds, radii, vertical flight limits, warning-buffer distance, warm-ups, cooldowns, prices, particle settings, and notification durations.
|
||||
- [ ] Default values match the progression requirements documented by the related user stories.
|
||||
- [ ] Thresholds and levels are validated as nonnegative, representable, and sequentially coherent where required.
|
||||
- [ ] Radii, vertical ranges, durations, and prices reject unsafe or nonsensical values.
|
||||
- [ ] Invalid required configuration prevents partial plugin initialization and produces a clear server log message.
|
||||
- [ ] UUID-keyed state persists latest known names, base locations, counters, levels, relocation times, teleport times, visitor cooldowns, navigation preferences, flight preferences, boss-bar preferences, and visitor settings.
|
||||
- [ ] Cooldowns and relocation limits use real elapsed timestamps and continue while players are offline.
|
||||
- [ ] State is saved safely so a failed write does not replace valid persisted state with a partial document.
|
||||
- [ ] Corrupt, unknown, or invalid records are handled defensively and cannot silently grant progression or privileges.
|
||||
- [ ] Unknown forward-compatible configuration and state fields are preserved where practical.
|
||||
|
||||
## Related
|
||||
|
||||
- [US-001: Unlock and establish Base I](us-001-unlock-and-establish-base.md)
|
||||
- [US-003: Expand the base](us-003-expand-the-base.md)
|
||||
- [US-010: Administer player progression](us-010-administer-player-progression.md)
|
||||
- [US-012: Build and release the plugin](us-012-build-and-release-plugin.md)
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
type: User Story
|
||||
title: "US-012: Build and release the plugin"
|
||||
description: Give maintainers repeatable Spigot builds, automated verification, and versioned Gitea releases.
|
||||
status: in-progress
|
||||
---
|
||||
|
||||
# US-012: Build and release the plugin
|
||||
|
||||
As a **plugin maintainer**, I want automated builds and releases modeled on Trigger Spawn so that tested, correctly versioned artifacts can be distributed consistently.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] The Gradle project compiles against Spigot API `26.2-R0.1-SNAPSHOT` using a Java 17 toolchain.
|
||||
- [ ] Compiler lint warnings fail the build.
|
||||
- [ ] Automated JUnit 5 tests run as part of the Gradle check lifecycle.
|
||||
- [ ] Pushes and pull requests build and test the plugin in Gitea Actions.
|
||||
- [ ] Pull requests validate conventional commit messages.
|
||||
- [ ] CI stores a development JAR as a workflow artifact.
|
||||
- [ ] Main-branch conventional commits drive semantic versioning.
|
||||
- [ ] A successful release builds a versioned JAR and attaches it to the corresponding Gitea release.
|
||||
- [ ] Build files, Gradle wrapper, workflows, and release behavior follow `../spigot-trigger-spawn/` where applicable while using Spigot Base names and identifiers.
|
||||
|
||||
## Related
|
||||
|
||||
- [US-011: Configure and persist progression](us-011-configure-and-persist-progression.md)
|
||||
- [User-story catalog](index.md)
|
||||
+248
@@ -0,0 +1,248 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015 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
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# gradlew 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 gradlew
|
||||
#
|
||||
# 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/3d91ce3b8caaf77ad09f381f43615b715b53f72c/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\n' "$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
|
||||
|
||||
|
||||
|
||||
# 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" )
|
||||
|
||||
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='-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_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" \
|
||||
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||
"$@"
|
||||
|
||||
# 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
+82
@@ -0,0 +1,82 @@
|
||||
@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 gradlew startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables, and ensure extensions are enabled
|
||||
setlocal EnableExtensions
|
||||
|
||||
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=-Dfile.encoding=UTF-8 "-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
|
||||
|
||||
"%COMSPEC%" /c exit 1
|
||||
|
||||
: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
|
||||
|
||||
"%COMSPEC%" /c exit 1
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
|
||||
|
||||
@rem Execute gradlew
|
||||
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
|
||||
@rem which allows us to clear the local environment before executing the java command
|
||||
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
|
||||
|
||||
:exitWithErrorLevel
|
||||
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
|
||||
"%COMSPEC%" /c exit %ERRORLEVEL%
|
||||
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-9.6.1-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
@@ -0,0 +1,248 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015 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
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# gradlew 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 gradlew
|
||||
#
|
||||
# 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/3d91ce3b8caaf77ad09f381f43615b715b53f72c/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\n' "$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
|
||||
|
||||
|
||||
|
||||
# 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" )
|
||||
|
||||
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='-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_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" \
|
||||
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||
"$@"
|
||||
|
||||
# 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
+82
@@ -0,0 +1,82 @@
|
||||
@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 gradlew startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables, and ensure extensions are enabled
|
||||
setlocal EnableExtensions
|
||||
|
||||
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=-Dfile.encoding=UTF-8 "-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
|
||||
|
||||
"%COMSPEC%" /c exit 1
|
||||
|
||||
: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
|
||||
|
||||
"%COMSPEC%" /c exit 1
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
|
||||
|
||||
@rem Execute gradlew
|
||||
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
|
||||
@rem which allows us to clear the local environment before executing the java command
|
||||
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
|
||||
|
||||
:exitWithErrorLevel
|
||||
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
|
||||
"%COMSPEC%" /c exit %ERRORLEVEL%
|
||||
@@ -0,0 +1 @@
|
||||
rootProject.name = "spigot-base"
|
||||
@@ -0,0 +1,83 @@
|
||||
package games.dmg.spigotbase;
|
||||
|
||||
public final class AdminProgressionService {
|
||||
public PlayerState setLevel(PlayerState player, ProgressionPath path, int level) {
|
||||
int base = player.baseLevel();
|
||||
int size = player.sizeLevel();
|
||||
int flight = player.flightLevel();
|
||||
int warmup = player.warmupLevel();
|
||||
int cooldown = player.cooldownLevel();
|
||||
|
||||
switch (path) {
|
||||
case BASE -> {
|
||||
requireRange(level, 0, 4, "base");
|
||||
base = level;
|
||||
if (base < 3) {
|
||||
warmup = 0;
|
||||
cooldown = 0;
|
||||
}
|
||||
if (base < 1) {
|
||||
size = 0;
|
||||
flight = 0;
|
||||
}
|
||||
}
|
||||
case SIZE -> {
|
||||
requireRange(level, 0, 3, "size");
|
||||
size = level;
|
||||
if (level > 0) {
|
||||
base = Math.max(base, 1);
|
||||
}
|
||||
}
|
||||
case FLIGHT -> {
|
||||
requireRange(level, 0, 3, "flight");
|
||||
flight = level;
|
||||
if (level > 0) {
|
||||
base = Math.max(base, 1);
|
||||
}
|
||||
}
|
||||
case WARMUP -> {
|
||||
requireRange(level, 0, 3, "warm-up");
|
||||
warmup = level;
|
||||
if (level > 0) {
|
||||
base = Math.max(base, 3);
|
||||
}
|
||||
}
|
||||
case COOLDOWN -> {
|
||||
requireRange(level, 0, 4, "cooldown");
|
||||
cooldown = level;
|
||||
if (level > 0) {
|
||||
base = Math.max(base, 3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
boolean navigationEnabled = base >= 2 && player.navigationEnabled();
|
||||
boolean flightEnabled = flight > 0 && player.flightEnabled();
|
||||
boolean visitorsEnabled = base >= 4 && player.visitorsEnabled();
|
||||
if (path == ProgressionPath.BASE) {
|
||||
navigationEnabled = base >= 2;
|
||||
visitorsEnabled = base >= 4;
|
||||
}
|
||||
if (path == ProgressionPath.FLIGHT) {
|
||||
flightEnabled = flight > 0;
|
||||
}
|
||||
return player.withAdministrativeLevels(
|
||||
base,
|
||||
size,
|
||||
flight,
|
||||
warmup,
|
||||
cooldown,
|
||||
navigationEnabled,
|
||||
flightEnabled,
|
||||
visitorsEnabled
|
||||
);
|
||||
}
|
||||
|
||||
private static void requireRange(int value, int minimum, int maximum, String path) {
|
||||
if (value < minimum || value > maximum) {
|
||||
throw new IllegalArgumentException(
|
||||
path + " level must be between " + minimum + " and " + maximum
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package games.dmg.spigotbase;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.ChatColor;
|
||||
import org.bukkit.command.Command;
|
||||
import org.bukkit.command.CommandExecutor;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
final class BaseAdminCommand implements CommandExecutor {
|
||||
private final BaseStateManager stateManager;
|
||||
private final AdminProgressionService progressionService;
|
||||
|
||||
BaseAdminCommand(BaseStateManager stateManager, AdminProgressionService progressionService) {
|
||||
this.stateManager = stateManager;
|
||||
this.progressionService = progressionService;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onCommand(CommandSender sender, Command command, String label, String[] arguments) {
|
||||
if (!sender.hasPermission("spigotbase.admin")) {
|
||||
sender.sendMessage(ChatColor.RED + "You do not have permission to administer Spigot Base.");
|
||||
return true;
|
||||
}
|
||||
if (arguments.length < 2) {
|
||||
sendUsage(sender);
|
||||
return true;
|
||||
}
|
||||
Optional<PlayerState> target = resolve(arguments[1]);
|
||||
if (target.isEmpty()) {
|
||||
sender.sendMessage(ChatColor.RED + "That player is not online or previously known.");
|
||||
return true;
|
||||
}
|
||||
return switch (arguments[0].toLowerCase(Locale.ROOT)) {
|
||||
case "progress" -> showProgress(sender, target.orElseThrow());
|
||||
case "setlevel" -> setLevel(sender, target.orElseThrow(), arguments);
|
||||
case "clearcooldown" -> clearCooldown(sender, target.orElseThrow(), arguments);
|
||||
case "reset" -> reset(sender, target.orElseThrow(), arguments);
|
||||
default -> {
|
||||
sendUsage(sender);
|
||||
yield true;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private boolean showProgress(CommandSender sender, PlayerState player) {
|
||||
sender.sendMessage(ChatColor.GOLD + "=== " + player.latestName() + " Base Progress ===");
|
||||
sender.sendMessage(ChatColor.YELLOW + "Levels: base=" + player.baseLevel()
|
||||
+ " size=" + player.sizeLevel() + " flight=" + player.flightLevel()
|
||||
+ " warmup=" + player.warmupLevel() + " cooldown=" + player.cooldownLevel());
|
||||
sender.sendMessage(ChatColor.GRAY + "Grass/dirt=" + player.grassAndDirtBroken()
|
||||
+ " stone=" + player.stoneBroken() + " deepslate=" + player.deepslateBroken()
|
||||
+ " obsidian=" + player.obsidianBroken());
|
||||
sender.sendMessage(ChatColor.GRAY + "In-base placements=" + player.blocksPlacedInBase()
|
||||
+ " breaks=" + player.blocksBrokenInBase());
|
||||
sender.sendMessage(ChatColor.GRAY + "Toggles: navigation=" + player.navigationEnabled()
|
||||
+ " flight=" + player.flightEnabled() + " bossbar=" + player.bossBarEnabled()
|
||||
+ " visitors=" + player.visitorsEnabled());
|
||||
sender.sendMessage(ChatColor.GRAY + "Base: " + player.base()
|
||||
.map(base -> base.worldName() + " " + base.x() + "," + base.y() + "," + base.z())
|
||||
.orElse("not set"));
|
||||
sender.sendMessage(ChatColor.GRAY + "Visitor cooldowns=" + player.visitorCooldownUntil().size());
|
||||
return true;
|
||||
}
|
||||
|
||||
private boolean setLevel(
|
||||
CommandSender sender,
|
||||
PlayerState target,
|
||||
String[] arguments
|
||||
) {
|
||||
if (arguments.length != 4) {
|
||||
sender.sendMessage(ChatColor.RED + "Usage: /baseadmin setlevel <player> <path> <level>");
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
ProgressionPath path = ProgressionPath.valueOf(arguments[2].toUpperCase(Locale.ROOT));
|
||||
int level = Integer.parseInt(arguments[3]);
|
||||
PlayerState updated = stateManager.update(
|
||||
target.playerId(),
|
||||
target.latestName(),
|
||||
current -> progressionService.setLevel(current, path, level)
|
||||
);
|
||||
stateManager.saveIfDirty();
|
||||
sender.sendMessage(ChatColor.GREEN + "Set " + updated.latestName() + "'s "
|
||||
+ path.name().toLowerCase(Locale.ROOT) + " level to " + level + ".");
|
||||
} catch (IllegalArgumentException exception) {
|
||||
sender.sendMessage(ChatColor.RED + exception.getMessage());
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private boolean clearCooldown(
|
||||
CommandSender sender,
|
||||
PlayerState target,
|
||||
String[] arguments
|
||||
) {
|
||||
String selection = arguments.length >= 3
|
||||
? arguments[2].toLowerCase(Locale.ROOT)
|
||||
: "all";
|
||||
if (!Arrays.asList("personal", "visitor", "all").contains(selection)) {
|
||||
sender.sendMessage(ChatColor.RED
|
||||
+ "Usage: /baseadmin clearcooldown <player> [personal|visitor|all]");
|
||||
return true;
|
||||
}
|
||||
stateManager.update(target.playerId(), target.latestName(), current -> {
|
||||
PlayerState updated = current;
|
||||
if (selection.equals("personal") || selection.equals("all")) {
|
||||
updated = updated.withoutPersonalCooldown();
|
||||
}
|
||||
if (selection.equals("visitor") || selection.equals("all")) {
|
||||
updated = updated.withoutVisitorCooldowns();
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
stateManager.saveIfDirty();
|
||||
sender.sendMessage(ChatColor.GREEN + "Cleared " + selection + " cooldowns for "
|
||||
+ target.latestName() + ".");
|
||||
return true;
|
||||
}
|
||||
|
||||
private boolean reset(CommandSender sender, PlayerState target, String[] arguments) {
|
||||
if (arguments.length < 3) {
|
||||
sender.sendMessage(ChatColor.RED + "Usage: /baseadmin reset <player> <path|all> [confirm]");
|
||||
return true;
|
||||
}
|
||||
if (arguments[2].equalsIgnoreCase("all")) {
|
||||
if (arguments.length != 4 || !arguments[3].equalsIgnoreCase("confirm")) {
|
||||
sender.sendMessage(ChatColor.RED + "Repeat with: /baseadmin reset "
|
||||
+ target.latestName() + " all confirm");
|
||||
return true;
|
||||
}
|
||||
stateManager.update(
|
||||
target.playerId(),
|
||||
target.latestName(),
|
||||
ignored -> PlayerState.newPlayer(target.playerId(), target.latestName())
|
||||
);
|
||||
stateManager.saveIfDirty();
|
||||
sender.sendMessage(ChatColor.GREEN + "Reset all progression for " + target.latestName() + ".");
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
ProgressionPath path = ProgressionPath.valueOf(arguments[2].toUpperCase(Locale.ROOT));
|
||||
stateManager.update(
|
||||
target.playerId(),
|
||||
target.latestName(),
|
||||
current -> progressionService.setLevel(current, path, 0)
|
||||
);
|
||||
stateManager.saveIfDirty();
|
||||
sender.sendMessage(ChatColor.GREEN + "Reset " + path.name().toLowerCase(Locale.ROOT)
|
||||
+ " progression for " + target.latestName() + ".");
|
||||
} catch (IllegalArgumentException exception) {
|
||||
sender.sendMessage(ChatColor.RED + exception.getMessage());
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private Optional<PlayerState> resolve(String name) {
|
||||
Player online = Bukkit.getPlayerExact(name);
|
||||
if (online != null) {
|
||||
return Optional.of(stateManager.player(online.getUniqueId(), online.getName()));
|
||||
}
|
||||
return stateManager.findByName(name);
|
||||
}
|
||||
|
||||
private static void sendUsage(CommandSender sender) {
|
||||
sender.sendMessage(ChatColor.YELLOW + "Usage: /baseadmin "
|
||||
+ "<progress|setlevel|clearcooldown|reset> <player> ...");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package games.dmg.spigotbase;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public record BaseArea(BaseLocation center, int radius, int verticalRange) {
|
||||
public BaseArea {
|
||||
if (center == null) {
|
||||
throw new IllegalArgumentException("center is required");
|
||||
}
|
||||
if (radius <= 0 || verticalRange <= 0) {
|
||||
throw new IllegalArgumentException("base dimensions must be positive");
|
||||
}
|
||||
}
|
||||
|
||||
public boolean contains(UUID worldId, int x, int y, int z) {
|
||||
if (!center.worldId().equals(worldId)) {
|
||||
return false;
|
||||
}
|
||||
long deltaX = (long) x - center.x();
|
||||
long deltaZ = (long) z - center.z();
|
||||
long horizontalDistanceSquared = deltaX * deltaX + deltaZ * deltaZ;
|
||||
long radiusSquared = (long) radius * radius;
|
||||
long minimumY = (long) center.y() - verticalRange;
|
||||
long maximumY = (long) center.y() + verticalRange;
|
||||
return horizontalDistanceSquared <= radiusSquared && y >= minimumY && y <= maximumY;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package games.dmg.spigotbase;
|
||||
|
||||
public final class BaseBoundsService {
|
||||
private final PluginSettings settings;
|
||||
|
||||
public BaseBoundsService(PluginSettings settings) {
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
public int radius(PlayerState player) {
|
||||
return switch (player.sizeLevel()) {
|
||||
case 0 -> settings.initialRadius();
|
||||
case 1 -> settings.firstExpandedRadius();
|
||||
case 2 -> settings.secondExpandedRadius();
|
||||
case 3 -> settings.thirdExpandedRadius();
|
||||
default -> throw new IllegalArgumentException("unknown size level");
|
||||
};
|
||||
}
|
||||
|
||||
public int verticalRange(PlayerState player) {
|
||||
return switch (player.flightLevel()) {
|
||||
case 0, 1 -> settings.initialVerticalRange();
|
||||
case 2 -> settings.secondFlightVerticalRange();
|
||||
case 3 -> Integer.MAX_VALUE;
|
||||
default -> throw new IllegalArgumentException("unknown flight level");
|
||||
};
|
||||
}
|
||||
|
||||
public BaseArea area(PlayerState player) {
|
||||
BaseLocation base = player.base().orElseThrow(() ->
|
||||
new IllegalStateException("player has not established a base")
|
||||
);
|
||||
return new BaseArea(base, radius(player), verticalRange(player));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package games.dmg.spigotbase;
|
||||
|
||||
import java.io.IOException;
|
||||
import org.bukkit.ChatColor;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.command.Command;
|
||||
import org.bukkit.command.CommandExecutor;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.PlayerInventory;
|
||||
|
||||
final class BaseCommand implements CommandExecutor {
|
||||
private final BaseTeleportManager teleportManager;
|
||||
private final BaseStateManager stateManager;
|
||||
private final VisitorPolicy visitorPolicy;
|
||||
private final PluginSettings settings;
|
||||
|
||||
BaseCommand(
|
||||
BaseTeleportManager teleportManager,
|
||||
BaseStateManager stateManager,
|
||||
VisitorPolicy visitorPolicy,
|
||||
PluginSettings settings
|
||||
) {
|
||||
this.teleportManager = teleportManager;
|
||||
this.stateManager = stateManager;
|
||||
this.visitorPolicy = visitorPolicy;
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onCommand(CommandSender sender, Command command, String label, String[] arguments) {
|
||||
if (!(sender instanceof Player player)) {
|
||||
sender.sendMessage("Only players can use a base.");
|
||||
return true;
|
||||
}
|
||||
if (arguments.length == 0) {
|
||||
teleportManager.start(player);
|
||||
return true;
|
||||
}
|
||||
if (arguments.length == 1 && arguments[0].equalsIgnoreCase("upgrade")) {
|
||||
purchaseVisitorAccess(player);
|
||||
return true;
|
||||
}
|
||||
player.sendMessage(ChatColor.RED + "Usage: /base [upgrade]");
|
||||
return true;
|
||||
}
|
||||
|
||||
private void purchaseVisitorAccess(Player player) {
|
||||
PlayerState state = stateManager.player(player.getUniqueId(), player.getName());
|
||||
if (!visitorPolicy.canPurchase(state)) {
|
||||
player.sendMessage(ChatColor.RED + (state.baseLevel() >= 4
|
||||
? "Base IV is already unlocked."
|
||||
: "You must unlock Base III before purchasing Base IV."));
|
||||
return;
|
||||
}
|
||||
int price = settings.visitorUnlockDiamondCost();
|
||||
PlayerInventory inventory = player.getInventory();
|
||||
if (countDiamonds(inventory) < price) {
|
||||
player.sendMessage(ChatColor.RED + "Base IV costs " + price + " diamonds.");
|
||||
return;
|
||||
}
|
||||
ItemStack[] snapshot = cloneContents(inventory.getStorageContents());
|
||||
removeDiamonds(inventory, price);
|
||||
try {
|
||||
stateManager.updateAndSave(
|
||||
player.getUniqueId(),
|
||||
player.getName(),
|
||||
current -> current.withBaseLevel(4).withVisitorsEnabled(true)
|
||||
);
|
||||
} catch (IOException | RuntimeException exception) {
|
||||
inventory.setStorageContents(snapshot);
|
||||
player.sendMessage(ChatColor.RED + "The upgrade could not be saved; your diamonds were restored.");
|
||||
return;
|
||||
}
|
||||
player.sendTitle(
|
||||
ChatColor.GOLD + "Base IV Unlocked",
|
||||
ChatColor.YELLOW + "Visitors may now teleport to your base",
|
||||
10, 70, 20
|
||||
);
|
||||
player.sendMessage(ChatColor.GREEN + "Base IV unlocked for " + price + " diamonds.");
|
||||
}
|
||||
|
||||
private static int countDiamonds(PlayerInventory inventory) {
|
||||
int count = 0;
|
||||
for (ItemStack item : inventory.getStorageContents()) {
|
||||
if (item != null && item.getType() == Material.DIAMOND) {
|
||||
count += item.getAmount();
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private static void removeDiamonds(PlayerInventory inventory, int amount) {
|
||||
ItemStack[] contents = inventory.getStorageContents();
|
||||
int remaining = amount;
|
||||
for (int index = 0; index < contents.length && remaining > 0; index++) {
|
||||
ItemStack item = contents[index];
|
||||
if (item == null || item.getType() != Material.DIAMOND) {
|
||||
continue;
|
||||
}
|
||||
int removed = Math.min(remaining, item.getAmount());
|
||||
remaining -= removed;
|
||||
int newAmount = item.getAmount() - removed;
|
||||
if (newAmount == 0) {
|
||||
contents[index] = null;
|
||||
} else {
|
||||
ItemStack reduced = item.clone();
|
||||
reduced.setAmount(newAmount);
|
||||
contents[index] = reduced;
|
||||
}
|
||||
}
|
||||
inventory.setStorageContents(contents);
|
||||
}
|
||||
|
||||
private static ItemStack[] cloneContents(ItemStack[] contents) {
|
||||
ItemStack[] copy = new ItemStack[contents.length];
|
||||
for (int index = 0; index < contents.length; index++) {
|
||||
copy[index] = contents[index] == null ? null : contents[index].clone();
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package games.dmg.spigotbase;
|
||||
|
||||
import org.bukkit.ChatColor;
|
||||
import org.bukkit.command.Command;
|
||||
import org.bukkit.command.CommandExecutor;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
final class BaseFlightCommand implements CommandExecutor {
|
||||
private final BaseStateManager stateManager;
|
||||
private final BaseFlightController controller;
|
||||
|
||||
BaseFlightCommand(BaseStateManager stateManager, BaseFlightController controller) {
|
||||
this.stateManager = stateManager;
|
||||
this.controller = controller;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onCommand(CommandSender sender, Command command, String label, String[] arguments) {
|
||||
if (!(sender instanceof Player player)) {
|
||||
sender.sendMessage("Only players can use base flight.");
|
||||
return true;
|
||||
}
|
||||
PlayerState state = stateManager.player(player.getUniqueId(), player.getName());
|
||||
if (state.flightLevel() < 1) {
|
||||
player.sendMessage(ChatColor.RED + "Base flight is still locked.");
|
||||
return true;
|
||||
}
|
||||
state = stateManager.update(
|
||||
player.getUniqueId(),
|
||||
player.getName(),
|
||||
current -> current.withFlightEnabled(!current.flightEnabled())
|
||||
);
|
||||
if (!state.flightEnabled()) {
|
||||
controller.removeGrantedFlight(player);
|
||||
}
|
||||
stateManager.saveIfDirty();
|
||||
player.sendMessage(ChatColor.YELLOW + "Base flight is now "
|
||||
+ (state.flightEnabled() ? ChatColor.GREEN + "on" : ChatColor.RED + "off")
|
||||
+ ChatColor.YELLOW + ".");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package games.dmg.spigotbase;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.ChatColor;
|
||||
import org.bukkit.GameMode;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
|
||||
final class BaseFlightController implements Runnable {
|
||||
private final Server server;
|
||||
private final BaseStateManager stateManager;
|
||||
private final SecondaryProgressionService progressionService;
|
||||
private final BaseBoundsService boundsService;
|
||||
private final PluginSettings settings;
|
||||
private final Set<UUID> grantedFlight = new HashSet<>();
|
||||
private final Set<UUID> warned = new HashSet<>();
|
||||
|
||||
BaseFlightController(
|
||||
Server server,
|
||||
BaseStateManager stateManager,
|
||||
SecondaryProgressionService progressionService,
|
||||
BaseBoundsService boundsService,
|
||||
PluginSettings settings
|
||||
) {
|
||||
this.server = server;
|
||||
this.stateManager = stateManager;
|
||||
this.progressionService = progressionService;
|
||||
this.boundsService = boundsService;
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
for (Player player : server.getOnlinePlayers()) {
|
||||
PlayerState state = stateManager.player(player.getUniqueId(), player.getName());
|
||||
if (player.getGameMode() == GameMode.SURVIVAL) {
|
||||
state = observeElytra(player, state);
|
||||
}
|
||||
applyFlight(player, state);
|
||||
}
|
||||
grantedFlight.removeIf(id -> server.getPlayer(id) == null);
|
||||
warned.removeIf(id -> server.getPlayer(id) == null);
|
||||
}
|
||||
|
||||
void removeGrantedFlight(Player player) {
|
||||
UUID playerId = player.getUniqueId();
|
||||
warned.remove(playerId);
|
||||
if (grantedFlight.remove(playerId)
|
||||
&& player.getGameMode() != GameMode.CREATIVE
|
||||
&& player.getGameMode() != GameMode.SPECTATOR) {
|
||||
player.setFlying(false);
|
||||
player.setAllowFlight(false);
|
||||
}
|
||||
}
|
||||
|
||||
void removeAllGrantedFlight() {
|
||||
for (UUID playerId : Set.copyOf(grantedFlight)) {
|
||||
Player player = server.getPlayer(playerId);
|
||||
if (player != null) {
|
||||
removeGrantedFlight(player);
|
||||
}
|
||||
}
|
||||
grantedFlight.clear();
|
||||
warned.clear();
|
||||
}
|
||||
|
||||
private PlayerState observeElytra(Player player, PlayerState state) {
|
||||
int count = countElytra(player);
|
||||
ProgressionUpdate update = progressionService.observeElytraCount(state, count);
|
||||
if (!update.unlockedFlightLevel()) {
|
||||
return state;
|
||||
}
|
||||
PlayerState updated = stateManager.update(
|
||||
player.getUniqueId(), player.getName(), ignored -> update.player()
|
||||
);
|
||||
stateManager.saveIfDirty();
|
||||
player.sendTitle(
|
||||
ChatColor.GOLD + "Base Flight " + roman(updated.flightLevel()) + " Unlocked",
|
||||
ChatColor.YELLOW + "Use /baseflight to toggle flight",
|
||||
10, 70, 20
|
||||
);
|
||||
return updated;
|
||||
}
|
||||
|
||||
private void applyFlight(Player player, PlayerState state) {
|
||||
if (player.getGameMode() != GameMode.SURVIVAL
|
||||
|| !state.flightEnabled() || state.flightLevel() < 1 || state.base().isEmpty()) {
|
||||
removeGrantedFlight(player);
|
||||
return;
|
||||
}
|
||||
BaseLocation base = state.base().orElseThrow();
|
||||
if (!player.getWorld().getUID().equals(base.worldId()) || !withinVerticalRange(player, state, base)) {
|
||||
removeGrantedFlight(player);
|
||||
return;
|
||||
}
|
||||
double deltaX = player.getLocation().getX() - (base.x() + 0.5);
|
||||
double deltaZ = player.getLocation().getZ() - (base.z() + 0.5);
|
||||
double distanceSquared = deltaX * deltaX + deltaZ * deltaZ;
|
||||
int radius = boundsService.radius(state);
|
||||
int bufferedRadius = radius + settings.flightWarningBuffer();
|
||||
if (distanceSquared > (double) bufferedRadius * bufferedRadius) {
|
||||
removeGrantedFlight(player);
|
||||
return;
|
||||
}
|
||||
if (!player.getAllowFlight()) {
|
||||
player.setAllowFlight(true);
|
||||
grantedFlight.add(player.getUniqueId());
|
||||
}
|
||||
if (distanceSquared > (double) radius * radius) {
|
||||
if (warned.add(player.getUniqueId())) {
|
||||
player.sendTitle(
|
||||
ChatColor.RED + "Leaving Your Base",
|
||||
ChatColor.YELLOW + "Turn back before base flight ends",
|
||||
0, 30, 10
|
||||
);
|
||||
}
|
||||
} else {
|
||||
warned.remove(player.getUniqueId());
|
||||
}
|
||||
}
|
||||
|
||||
private boolean withinVerticalRange(Player player, PlayerState state, BaseLocation base) {
|
||||
int y = player.getLocation().getBlockY();
|
||||
if (state.flightLevel() == 3) {
|
||||
return y >= player.getWorld().getMinHeight() && y < player.getWorld().getMaxHeight();
|
||||
}
|
||||
return Math.abs((long) y - base.y()) <= boundsService.verticalRange(state);
|
||||
}
|
||||
|
||||
private static int countElytra(Player player) {
|
||||
int count = 0;
|
||||
for (ItemStack item : player.getInventory().getStorageContents()) {
|
||||
if (item != null && item.getType() == Material.ELYTRA) {
|
||||
count += item.getAmount();
|
||||
}
|
||||
}
|
||||
ItemStack chest = player.getInventory().getChestplate();
|
||||
if (chest != null && chest.getType() == Material.ELYTRA) {
|
||||
count += chest.getAmount();
|
||||
}
|
||||
return Math.min(3, count);
|
||||
}
|
||||
|
||||
private static String roman(int level) {
|
||||
return switch (level) {
|
||||
case 1 -> "I";
|
||||
case 2 -> "II";
|
||||
case 3 -> "III";
|
||||
default -> Integer.toString(level);
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package games.dmg.spigotbase;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public record BaseLocation(
|
||||
UUID worldId,
|
||||
String worldName,
|
||||
int x,
|
||||
int y,
|
||||
int z,
|
||||
float yaw,
|
||||
float pitch
|
||||
) {
|
||||
public BaseLocation {
|
||||
if (worldId == null) {
|
||||
throw new IllegalArgumentException("world ID is required");
|
||||
}
|
||||
if (worldName == null || worldName.isBlank()) {
|
||||
throw new IllegalArgumentException("world name is required");
|
||||
}
|
||||
if (!Float.isFinite(yaw) || !Float.isFinite(pitch)) {
|
||||
throw new IllegalArgumentException("rotation must be finite");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package games.dmg.spigotbase;
|
||||
|
||||
import org.bukkit.ChatColor;
|
||||
import org.bukkit.command.Command;
|
||||
import org.bukkit.command.CommandExecutor;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
final class BaseNavigationCommand implements CommandExecutor {
|
||||
private final BaseStateManager stateManager;
|
||||
|
||||
BaseNavigationCommand(BaseStateManager stateManager) {
|
||||
this.stateManager = stateManager;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onCommand(CommandSender sender, Command command, String label, String[] arguments) {
|
||||
if (!(sender instanceof Player player)) {
|
||||
sender.sendMessage("Only players can use base navigation.");
|
||||
return true;
|
||||
}
|
||||
PlayerState state = stateManager.player(player.getUniqueId(), player.getName());
|
||||
if (state.baseLevel() < 2) {
|
||||
player.sendMessage(ChatColor.RED + "Base II navigation is still locked.");
|
||||
return true;
|
||||
}
|
||||
if (state.base().isEmpty()) {
|
||||
player.sendMessage(ChatColor.RED + "Set your base before enabling navigation.");
|
||||
return true;
|
||||
}
|
||||
state = stateManager.update(
|
||||
player.getUniqueId(),
|
||||
player.getName(),
|
||||
current -> current.withNavigationEnabled(!current.navigationEnabled())
|
||||
);
|
||||
stateManager.saveIfDirty();
|
||||
player.sendMessage(ChatColor.YELLOW + "Base navigation is now "
|
||||
+ (state.navigationEnabled() ? ChatColor.GREEN + "on" : ChatColor.RED + "off")
|
||||
+ ChatColor.YELLOW + ".");
|
||||
if (state.navigationEnabled()
|
||||
&& !player.getWorld().getUID().equals(state.base().orElseThrow().worldId())) {
|
||||
player.sendMessage(ChatColor.RED + "Your base is in another world: "
|
||||
+ state.base().orElseThrow().worldName() + ".");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package games.dmg.spigotbase;
|
||||
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.Particle;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.util.Vector;
|
||||
|
||||
final class BaseNavigationController implements Runnable {
|
||||
private final Server server;
|
||||
private final BaseStateManager stateManager;
|
||||
|
||||
BaseNavigationController(Server server, BaseStateManager stateManager) {
|
||||
this.server = server;
|
||||
this.stateManager = stateManager;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
for (Player player : server.getOnlinePlayers()) {
|
||||
PlayerState state = stateManager.player(player.getUniqueId(), player.getName());
|
||||
if (!state.navigationEnabled() || state.base().isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
BaseLocation base = state.base().orElseThrow();
|
||||
if (!player.getWorld().getUID().equals(base.worldId())) {
|
||||
continue;
|
||||
}
|
||||
Location origin = player.getLocation().clone().add(0.0, 0.15, 0.0);
|
||||
Vector direction = new Vector(
|
||||
base.x() + 0.5 - origin.getX(),
|
||||
0.0,
|
||||
base.z() + 0.5 - origin.getZ()
|
||||
);
|
||||
if (direction.lengthSquared() < 1.0) {
|
||||
continue;
|
||||
}
|
||||
direction.normalize();
|
||||
for (int step = 1; step <= 5; step++) {
|
||||
Location particle = origin.clone().add(direction.clone().multiply(step));
|
||||
player.spawnParticle(Particle.END_ROD, particle, 1, 0.0, 0.0, 0.0, 0.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package games.dmg.spigotbase;
|
||||
|
||||
import org.bukkit.ChatColor;
|
||||
import org.bukkit.command.Command;
|
||||
import org.bukkit.command.CommandExecutor;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
final class BaseProgressCommand implements CommandExecutor {
|
||||
private final BaseStateManager stateManager;
|
||||
private final PluginSettings settings;
|
||||
private final TeleportPolicy teleportPolicy;
|
||||
|
||||
BaseProgressCommand(BaseStateManager stateManager, PluginSettings settings) {
|
||||
this.stateManager = stateManager;
|
||||
this.settings = settings;
|
||||
this.teleportPolicy = new TeleportPolicy(settings);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onCommand(CommandSender sender, Command command, String label, String[] arguments) {
|
||||
if (!(sender instanceof Player player)) {
|
||||
sender.sendMessage("Only players have base progression.");
|
||||
return true;
|
||||
}
|
||||
PlayerState state = stateManager.player(player.getUniqueId(), player.getName());
|
||||
if (arguments.length == 1 && arguments[0].equalsIgnoreCase("bossbar")) {
|
||||
state = stateManager.update(
|
||||
player.getUniqueId(),
|
||||
player.getName(),
|
||||
current -> current.withBossBarEnabled(!current.bossBarEnabled())
|
||||
);
|
||||
stateManager.saveIfDirty();
|
||||
player.sendMessage(ChatColor.YELLOW + "Automatic progress boss bars are now "
|
||||
+ (state.bossBarEnabled() ? ChatColor.GREEN + "on" : ChatColor.RED + "off")
|
||||
+ ChatColor.YELLOW + ".");
|
||||
return true;
|
||||
}
|
||||
player.sendMessage(ChatColor.GOLD + "=== Base Progress ===");
|
||||
showBasePath(player, state);
|
||||
showSizePath(player, state);
|
||||
showFlightPath(player, state);
|
||||
showWarmupPath(player, state);
|
||||
showCooldownPath(player, state);
|
||||
player.sendMessage(ChatColor.GRAY + "Boss bars: " + (state.bossBarEnabled() ? "on" : "off"));
|
||||
state.base().ifPresentOrElse(
|
||||
base -> player.sendMessage(ChatColor.GRAY + "Base: " + base.worldName() + " "
|
||||
+ base.x() + ", " + base.y() + ", " + base.z()),
|
||||
() -> player.sendMessage(ChatColor.GRAY + "Base: not set")
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
private void showBasePath(Player player, PlayerState state) {
|
||||
String detail = switch (state.baseLevel()) {
|
||||
case 0 -> state.grassAndDirtBroken() + "/" + settings.baseUnlockBlocks()
|
||||
+ " grass or dirt → /setbase";
|
||||
case 1 -> state.grassAndDirtBroken() + "/" + settings.navigationUnlockBlocks()
|
||||
+ " grass or dirt → navigation";
|
||||
case 2 -> state.blocksPlacedInBase() + "/" + settings.teleportUnlockPlacements()
|
||||
+ " placements → /base";
|
||||
case 3 -> settings.visitorUnlockDiamondCost() + " diamonds → visitor access";
|
||||
case 4 -> "complete; visitor access unlocked";
|
||||
default -> "invalid";
|
||||
};
|
||||
player.sendMessage(ChatColor.YELLOW + "Base " + state.baseLevel() + "/4: "
|
||||
+ ChatColor.GRAY + detail);
|
||||
}
|
||||
|
||||
private void showSizePath(Player player, PlayerState state) {
|
||||
String detail = switch (state.sizeLevel()) {
|
||||
case 0 -> state.stoneBroken() + "/" + settings.stoneExpansionBlocks() + " stone";
|
||||
case 1 -> state.deepslateBroken() + "/" + settings.deepslateExpansionBlocks() + " deepslate";
|
||||
case 2 -> state.obsidianBroken() + "/" + settings.obsidianExpansionBlocks() + " obsidian";
|
||||
case 3 -> "complete; 150-block radius by default";
|
||||
default -> "invalid";
|
||||
};
|
||||
player.sendMessage(colorForPrerequisite(state.baseLevel() >= 1) + "Base Size "
|
||||
+ state.sizeLevel() + "/3: " + ChatColor.GRAY + detail);
|
||||
}
|
||||
|
||||
private void showFlightPath(Player player, PlayerState state) {
|
||||
String detail = state.flightLevel() >= 3
|
||||
? "complete; world build height"
|
||||
: (state.flightLevel() + 1) + " simultaneous elytra required";
|
||||
player.sendMessage(colorForPrerequisite(state.baseLevel() >= 1) + "Base Flight "
|
||||
+ state.flightLevel() + "/3: " + ChatColor.GRAY + detail
|
||||
+ "; toggle=" + (state.flightEnabled() ? "on" : "off"));
|
||||
}
|
||||
|
||||
private void showWarmupPath(Player player, PlayerState state) {
|
||||
String detail = switch (state.warmupLevel()) {
|
||||
case 0 -> state.blocksPlacedInBase() + "/" + settings.secondWarmupPlacements();
|
||||
case 1 -> state.blocksPlacedInBase() + "/" + settings.thirdWarmupPlacements();
|
||||
case 2 -> state.blocksPlacedInBase() + "/" + settings.instantWarmupPlacements();
|
||||
case 3 -> "complete";
|
||||
default -> "invalid";
|
||||
};
|
||||
player.sendMessage(colorForPrerequisite(state.baseLevel() >= 3) + "Teleport Warm-up "
|
||||
+ state.warmupLevel() + "/3: " + ChatColor.GRAY
|
||||
+ DurationFormatter.friendly(teleportPolicy.warmup(state)) + "; " + detail);
|
||||
}
|
||||
|
||||
private void showCooldownPath(Player player, PlayerState state) {
|
||||
String detail = switch (state.cooldownLevel()) {
|
||||
case 0 -> state.blocksBrokenInBase() + "/" + settings.firstCooldownBreaks();
|
||||
case 1 -> state.blocksBrokenInBase() + "/" + settings.secondCooldownBreaks();
|
||||
case 2 -> state.blocksBrokenInBase() + "/" + settings.thirdCooldownBreaks();
|
||||
case 3 -> state.blocksBrokenInBase() + "/" + settings.instantCooldownBreaks();
|
||||
case 4 -> "complete";
|
||||
default -> "invalid";
|
||||
};
|
||||
player.sendMessage(colorForPrerequisite(state.baseLevel() >= 3) + "Teleport Cooldown "
|
||||
+ state.cooldownLevel() + "/4: " + ChatColor.GRAY
|
||||
+ DurationFormatter.friendly(teleportPolicy.cooldown(state)) + "; " + detail);
|
||||
}
|
||||
|
||||
private static ChatColor colorForPrerequisite(boolean met) {
|
||||
return met ? ChatColor.YELLOW : ChatColor.RED;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
package games.dmg.spigotbase;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.ChatColor;
|
||||
import org.bukkit.GameMode;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.boss.BarColor;
|
||||
import org.bukkit.boss.BarStyle;
|
||||
import org.bukkit.boss.BossBar;
|
||||
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.plugin.Plugin;
|
||||
|
||||
final class BaseProgressListener implements Listener {
|
||||
private static final long BOSS_BAR_TICKS = 60L;
|
||||
|
||||
private final Plugin plugin;
|
||||
private final BaseStateManager stateManager;
|
||||
private final BaseProgressionService baseProgressionService;
|
||||
private final SecondaryProgressionService secondaryProgressionService;
|
||||
private final TeleportProgressionService teleportProgressionService;
|
||||
private final BaseBoundsService boundsService;
|
||||
private final PluginSettings settings;
|
||||
private final Map<UUID, BossBar> activeBossBars = new HashMap<>();
|
||||
|
||||
BaseProgressListener(
|
||||
Plugin plugin,
|
||||
BaseStateManager stateManager,
|
||||
BaseProgressionService baseProgressionService,
|
||||
SecondaryProgressionService secondaryProgressionService,
|
||||
TeleportProgressionService teleportProgressionService,
|
||||
BaseBoundsService boundsService,
|
||||
PluginSettings settings
|
||||
) {
|
||||
this.plugin = plugin;
|
||||
this.stateManager = stateManager;
|
||||
this.baseProgressionService = baseProgressionService;
|
||||
this.secondaryProgressionService = secondaryProgressionService;
|
||||
this.teleportProgressionService = teleportProgressionService;
|
||||
this.boundsService = boundsService;
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void onBlockBreak(BlockBreakEvent event) {
|
||||
Player player = event.getPlayer();
|
||||
Material material = event.getBlock().getType();
|
||||
if (player.getGameMode() != GameMode.SURVIVAL) {
|
||||
return;
|
||||
}
|
||||
PlayerState before = stateManager.player(player.getUniqueId(), player.getName());
|
||||
boolean insideBase = isInsideBase(
|
||||
before,
|
||||
event.getBlock().getWorld().getUID(),
|
||||
event.getBlock().getX(),
|
||||
event.getBlock().getY(),
|
||||
event.getBlock().getZ()
|
||||
);
|
||||
if (!isProgressMaterial(material) && !(insideBase && before.baseLevel() >= 3)) {
|
||||
return;
|
||||
}
|
||||
ProgressionUpdate[] updateHolder = new ProgressionUpdate[1];
|
||||
PlayerState state = stateManager.update(player.getUniqueId(), player.getName(), current -> {
|
||||
ProgressionUpdate materialUpdate = updateForMaterial(current, material);
|
||||
ProgressionUpdate breakUpdate = insideBase
|
||||
? teleportProgressionService.recordBreak(materialUpdate.player())
|
||||
: ProgressionUpdate.unchanged(materialUpdate.player());
|
||||
ProgressionUpdate combined = combine(materialUpdate, breakUpdate);
|
||||
updateHolder[0] = combined;
|
||||
return combined.player();
|
||||
});
|
||||
ProgressionUpdate update = updateHolder[0];
|
||||
announceUnlock(player, update);
|
||||
if (hasUnlock(update)) {
|
||||
stateManager.saveIfDirty();
|
||||
}
|
||||
if (state.bossBarEnabled()) {
|
||||
if (insideBase && before.baseLevel() >= 3) {
|
||||
showProgress(player, cooldownDisplay(state));
|
||||
} else {
|
||||
showProgress(player, progressDisplay(state, material));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void onBlockPlace(BlockPlaceEvent event) {
|
||||
Player player = event.getPlayer();
|
||||
if (player.getGameMode() != GameMode.SURVIVAL) {
|
||||
return;
|
||||
}
|
||||
PlayerState before = stateManager.player(player.getUniqueId(), player.getName());
|
||||
if (!isInsideBase(
|
||||
before,
|
||||
event.getBlockPlaced().getWorld().getUID(),
|
||||
event.getBlockPlaced().getX(),
|
||||
event.getBlockPlaced().getY(),
|
||||
event.getBlockPlaced().getZ())) {
|
||||
return;
|
||||
}
|
||||
ProgressionUpdate[] updateHolder = new ProgressionUpdate[1];
|
||||
PlayerState state = stateManager.update(player.getUniqueId(), player.getName(), current -> {
|
||||
ProgressionUpdate update = teleportProgressionService.recordPlacement(current);
|
||||
updateHolder[0] = update;
|
||||
return update.player();
|
||||
});
|
||||
ProgressionUpdate update = updateHolder[0];
|
||||
announceUnlock(player, update);
|
||||
if (hasUnlock(update)) {
|
||||
stateManager.saveIfDirty();
|
||||
}
|
||||
if (state.bossBarEnabled() && state.baseLevel() >= 2) {
|
||||
showProgress(player, warmupDisplay(state));
|
||||
}
|
||||
}
|
||||
|
||||
void removeAllBossBars() {
|
||||
activeBossBars.values().forEach(BossBar::removeAll);
|
||||
activeBossBars.clear();
|
||||
}
|
||||
|
||||
private ProgressionUpdate updateForMaterial(PlayerState player, Material material) {
|
||||
return switch (material) {
|
||||
case GRASS_BLOCK, DIRT -> baseProgressionService.recordGrassOrDirtBreak(player);
|
||||
case STONE -> secondaryProgressionService.recordStoneBreak(player);
|
||||
case DEEPSLATE -> secondaryProgressionService.recordDeepslateBreak(player);
|
||||
case OBSIDIAN -> secondaryProgressionService.recordObsidianBreak(player);
|
||||
default -> ProgressionUpdate.unchanged(player);
|
||||
};
|
||||
}
|
||||
|
||||
private void announceUnlock(Player player, ProgressionUpdate update) {
|
||||
if (update.unlockedBaseLevel()) {
|
||||
int level = update.player().baseLevel();
|
||||
String subtitle = switch (level) {
|
||||
case 1 -> "/setbase is now available";
|
||||
case 2 -> "/basenavigation is now available";
|
||||
case 3 -> "/base is now available";
|
||||
case 4 -> "Visitors can now travel to your base";
|
||||
default -> "A new base benefit is available";
|
||||
};
|
||||
player.sendTitle(
|
||||
ChatColor.GOLD + "Base " + roman(level) + " Unlocked",
|
||||
ChatColor.YELLOW + subtitle,
|
||||
10, 70, 20
|
||||
);
|
||||
player.sendMessage(ChatColor.GREEN + "You unlocked Base " + roman(level) + "! " + subtitle);
|
||||
}
|
||||
if (update.unlockedSizeLevel()) {
|
||||
int radius = boundsService.radius(update.player());
|
||||
player.sendTitle(
|
||||
ChatColor.GOLD + "Base Size Upgraded",
|
||||
ChatColor.YELLOW + "Your base radius is now " + radius + " blocks",
|
||||
10, 70, 20
|
||||
);
|
||||
}
|
||||
if (update.unlockedWarmupLevel()) {
|
||||
player.sendTitle(
|
||||
ChatColor.GOLD + "Teleport Warm-up Improved",
|
||||
ChatColor.YELLOW + "Your /base warm-up is now shorter",
|
||||
10, 70, 20
|
||||
);
|
||||
}
|
||||
if (update.unlockedCooldownLevel()) {
|
||||
player.sendTitle(
|
||||
ChatColor.GOLD + "Teleport Cooldown Improved",
|
||||
ChatColor.YELLOW + "You can use /base more often",
|
||||
10, 70, 20
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private void showProgress(Player player, ProgressDisplay display) {
|
||||
if (display == null) {
|
||||
removeBossBar(player.getUniqueId());
|
||||
return;
|
||||
}
|
||||
removeBossBar(player.getUniqueId());
|
||||
BossBar bossBar = Bukkit.createBossBar(
|
||||
ChatColor.YELLOW + display.label() + ": " + display.count() + "/" + display.threshold(),
|
||||
BarColor.GREEN,
|
||||
BarStyle.SOLID
|
||||
);
|
||||
bossBar.setProgress(Math.min(1.0, (double) display.count() / display.threshold()));
|
||||
bossBar.addPlayer(player);
|
||||
activeBossBars.put(player.getUniqueId(), bossBar);
|
||||
Bukkit.getScheduler().runTaskLater(plugin, () -> {
|
||||
if (activeBossBars.remove(player.getUniqueId(), bossBar)) {
|
||||
bossBar.removeAll();
|
||||
}
|
||||
}, BOSS_BAR_TICKS);
|
||||
}
|
||||
|
||||
private ProgressDisplay progressDisplay(PlayerState state, Material material) {
|
||||
return switch (material) {
|
||||
case GRASS_BLOCK, DIRT -> state.baseLevel() < 2
|
||||
? new ProgressDisplay(
|
||||
state.baseLevel() == 0 ? "Base I" : "Base II",
|
||||
state.grassAndDirtBroken(),
|
||||
state.baseLevel() == 0 ? settings.baseUnlockBlocks() : settings.navigationUnlockBlocks()
|
||||
)
|
||||
: null;
|
||||
case STONE -> state.sizeLevel() == 0
|
||||
? new ProgressDisplay("Base Size II", state.stoneBroken(), settings.stoneExpansionBlocks())
|
||||
: null;
|
||||
case DEEPSLATE -> state.sizeLevel() == 1
|
||||
? new ProgressDisplay("Base Size III", state.deepslateBroken(), settings.deepslateExpansionBlocks())
|
||||
: null;
|
||||
case OBSIDIAN -> state.sizeLevel() == 2
|
||||
? new ProgressDisplay("Base Size IV", state.obsidianBroken(), settings.obsidianExpansionBlocks())
|
||||
: null;
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
|
||||
private ProgressDisplay warmupDisplay(PlayerState state) {
|
||||
if (state.baseLevel() == 2) {
|
||||
return new ProgressDisplay(
|
||||
"Base III", state.blocksPlacedInBase(), settings.teleportUnlockPlacements()
|
||||
);
|
||||
}
|
||||
return switch (state.warmupLevel()) {
|
||||
case 0 -> new ProgressDisplay(
|
||||
"15s Warm-up", state.blocksPlacedInBase(), settings.secondWarmupPlacements()
|
||||
);
|
||||
case 1 -> new ProgressDisplay(
|
||||
"5s Warm-up", state.blocksPlacedInBase(), settings.thirdWarmupPlacements()
|
||||
);
|
||||
case 2 -> new ProgressDisplay(
|
||||
"Instant Warm-up", state.blocksPlacedInBase(), settings.instantWarmupPlacements()
|
||||
);
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
|
||||
private ProgressDisplay cooldownDisplay(PlayerState state) {
|
||||
return switch (state.cooldownLevel()) {
|
||||
case 0 -> new ProgressDisplay(
|
||||
"2h Cooldown", state.blocksBrokenInBase(), settings.firstCooldownBreaks()
|
||||
);
|
||||
case 1 -> new ProgressDisplay(
|
||||
"1h Cooldown", state.blocksBrokenInBase(), settings.secondCooldownBreaks()
|
||||
);
|
||||
case 2 -> new ProgressDisplay(
|
||||
"30m Cooldown", state.blocksBrokenInBase(), settings.thirdCooldownBreaks()
|
||||
);
|
||||
case 3 -> new ProgressDisplay(
|
||||
"Instant Cooldown", state.blocksBrokenInBase(), settings.instantCooldownBreaks()
|
||||
);
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
|
||||
private boolean isInsideBase(PlayerState state, UUID worldId, int x, int y, int z) {
|
||||
return state.base().isPresent() && boundsService.area(state).contains(worldId, x, y, z);
|
||||
}
|
||||
|
||||
private static ProgressionUpdate combine(
|
||||
ProgressionUpdate first,
|
||||
ProgressionUpdate second
|
||||
) {
|
||||
return new ProgressionUpdate(
|
||||
second.player(),
|
||||
first.unlockedBaseLevel() || second.unlockedBaseLevel(),
|
||||
first.unlockedSizeLevel() || second.unlockedSizeLevel(),
|
||||
first.unlockedFlightLevel() || second.unlockedFlightLevel(),
|
||||
first.unlockedWarmupLevel() || second.unlockedWarmupLevel(),
|
||||
first.unlockedCooldownLevel() || second.unlockedCooldownLevel()
|
||||
);
|
||||
}
|
||||
|
||||
private static boolean hasUnlock(ProgressionUpdate update) {
|
||||
return update.unlockedBaseLevel() || update.unlockedSizeLevel()
|
||||
|| update.unlockedFlightLevel() || update.unlockedWarmupLevel()
|
||||
|| update.unlockedCooldownLevel();
|
||||
}
|
||||
|
||||
private void removeBossBar(UUID playerId) {
|
||||
BossBar previous = activeBossBars.remove(playerId);
|
||||
if (previous != null) {
|
||||
previous.removeAll();
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isProgressMaterial(Material material) {
|
||||
return material == Material.GRASS_BLOCK || material == Material.DIRT
|
||||
|| material == Material.STONE || material == Material.DEEPSLATE
|
||||
|| material == Material.OBSIDIAN;
|
||||
}
|
||||
|
||||
private static String roman(int level) {
|
||||
return switch (level) {
|
||||
case 1 -> "I";
|
||||
case 2 -> "II";
|
||||
case 3 -> "III";
|
||||
case 4 -> "IV";
|
||||
default -> Integer.toString(level);
|
||||
};
|
||||
}
|
||||
|
||||
private record ProgressDisplay(String label, long count, long threshold) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package games.dmg.spigotbase;
|
||||
|
||||
public final class BaseProgressionService {
|
||||
private final PluginSettings settings;
|
||||
|
||||
public BaseProgressionService(PluginSettings settings) {
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
public ProgressionUpdate recordGrassOrDirtBreak(PlayerState player) {
|
||||
long count = player.grassAndDirtBroken() == Long.MAX_VALUE
|
||||
? Long.MAX_VALUE
|
||||
: player.grassAndDirtBroken() + 1;
|
||||
int previousLevel = player.baseLevel();
|
||||
int baseLevel = previousLevel;
|
||||
if (previousLevel == 0 && count >= settings.baseUnlockBlocks()) {
|
||||
baseLevel = 1;
|
||||
}
|
||||
if (previousLevel == 1 && count >= settings.navigationUnlockBlocks()) {
|
||||
baseLevel = 2;
|
||||
}
|
||||
boolean unlocked = baseLevel != previousLevel;
|
||||
PlayerState updated = player.withGrassAndDirtProgress(count, baseLevel);
|
||||
if (baseLevel >= 2 && !updated.navigationEnabled()) {
|
||||
updated = updated.withNavigationEnabled(true);
|
||||
}
|
||||
return new ProgressionUpdate(updated, unlocked, false, false, false, false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package games.dmg.spigotbase;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Optional;
|
||||
|
||||
public final class BaseService {
|
||||
private final PluginSettings settings;
|
||||
|
||||
public BaseService(PluginSettings settings) {
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
public boolean canSetBase(PlayerState player, Instant now) {
|
||||
if (player.baseLevel() < 1) {
|
||||
return false;
|
||||
}
|
||||
return relocationRemaining(player, now).isEmpty();
|
||||
}
|
||||
|
||||
public Optional<Duration> relocationRemaining(PlayerState player, Instant now) {
|
||||
if (player.baseLevel() < 1 || player.lastBaseSet().isEmpty()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
Instant availableAt = player.lastBaseSet().orElseThrow()
|
||||
.plusSeconds(settings.relocationCooldownSeconds());
|
||||
if (!now.isBefore(availableAt)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return Optional.of(Duration.between(now, availableAt));
|
||||
}
|
||||
|
||||
public PlayerState setBase(PlayerState player, BaseLocation location, Instant now) {
|
||||
if (!canSetBase(player, now)) {
|
||||
throw new IllegalStateException("base cannot be set yet");
|
||||
}
|
||||
return player.withBase(location, now);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package games.dmg.spigotbase;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.function.UnaryOperator;
|
||||
import java.util.logging.Level;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
public final class BaseStateManager {
|
||||
private final YamlBaseStateRepository repository;
|
||||
private final Logger logger;
|
||||
private final Map<UUID, PlayerState> players;
|
||||
private boolean dirty;
|
||||
|
||||
public BaseStateManager(YamlBaseStateRepository repository, Logger logger) throws IOException {
|
||||
this.repository = repository;
|
||||
this.logger = logger;
|
||||
this.players = new HashMap<>(repository.load().players());
|
||||
}
|
||||
|
||||
public PlayerState player(UUID playerId, String latestName) {
|
||||
PlayerState existing = players.get(playerId);
|
||||
if (existing == null) {
|
||||
PlayerState created = PlayerState.newPlayer(playerId, latestName);
|
||||
players.put(playerId, created);
|
||||
dirty = true;
|
||||
return created;
|
||||
}
|
||||
if (!existing.latestName().equals(latestName)) {
|
||||
existing = existing.withLatestName(latestName);
|
||||
players.put(playerId, existing);
|
||||
dirty = true;
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
|
||||
public PlayerState update(UUID playerId, String latestName, UnaryOperator<PlayerState> operation) {
|
||||
PlayerState updated = operation.apply(player(playerId, latestName));
|
||||
if (!updated.playerId().equals(playerId)) {
|
||||
throw new IllegalArgumentException("updated player ID cannot change");
|
||||
}
|
||||
players.put(playerId, updated);
|
||||
dirty = true;
|
||||
return updated;
|
||||
}
|
||||
|
||||
public PlayerState updateAndSave(
|
||||
UUID playerId,
|
||||
String latestName,
|
||||
UnaryOperator<PlayerState> operation
|
||||
) throws IOException {
|
||||
PlayerState current = players.getOrDefault(
|
||||
playerId,
|
||||
PlayerState.newPlayer(playerId, latestName)
|
||||
);
|
||||
if (!current.latestName().equals(latestName)) {
|
||||
current = current.withLatestName(latestName);
|
||||
}
|
||||
PlayerState updated = operation.apply(current);
|
||||
if (!updated.playerId().equals(playerId)) {
|
||||
throw new IllegalArgumentException("updated player ID cannot change");
|
||||
}
|
||||
Map<UUID, PlayerState> proposed = new HashMap<>(players);
|
||||
proposed.put(playerId, updated);
|
||||
repository.save(new PersistentState(proposed));
|
||||
players.clear();
|
||||
players.putAll(proposed);
|
||||
dirty = false;
|
||||
return updated;
|
||||
}
|
||||
|
||||
public Map<UUID, PlayerState> knownPlayers() {
|
||||
return Map.copyOf(players);
|
||||
}
|
||||
|
||||
public Optional<PlayerState> findByName(String name) {
|
||||
return players.values().stream()
|
||||
.filter(player -> player.latestName().equalsIgnoreCase(name))
|
||||
.findFirst();
|
||||
}
|
||||
|
||||
public void saveIfDirty() {
|
||||
if (!dirty) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
repository.save(new PersistentState(players));
|
||||
dirty = false;
|
||||
} catch (IOException exception) {
|
||||
logger.log(Level.SEVERE, "Could not save Spigot Base state", exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
package games.dmg.spigotbase;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.HashMap;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.ChatColor;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.entity.Entity;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.entity.EntityDamageEvent;
|
||||
import org.bukkit.event.entity.PlayerDeathEvent;
|
||||
import org.bukkit.event.player.PlayerChangedWorldEvent;
|
||||
import org.bukkit.event.player.PlayerCommandPreprocessEvent;
|
||||
import org.bukkit.event.player.PlayerMoveEvent;
|
||||
import org.bukkit.event.player.PlayerQuitEvent;
|
||||
import org.bukkit.event.player.PlayerTeleportEvent;
|
||||
import org.bukkit.plugin.Plugin;
|
||||
import org.bukkit.scheduler.BukkitTask;
|
||||
|
||||
final class BaseTeleportManager implements Listener {
|
||||
private final Plugin plugin;
|
||||
private final BaseStateManager stateManager;
|
||||
private final TeleportPolicy policy;
|
||||
private final VisitorPolicy visitorPolicy;
|
||||
private final SafeBaseDestination destinationFinder;
|
||||
private final Clock clock;
|
||||
private final Map<UUID, Request> requests = new HashMap<>();
|
||||
|
||||
BaseTeleportManager(
|
||||
Plugin plugin,
|
||||
BaseStateManager stateManager,
|
||||
TeleportPolicy policy,
|
||||
VisitorPolicy visitorPolicy,
|
||||
SafeBaseDestination destinationFinder,
|
||||
Clock clock
|
||||
) {
|
||||
this.plugin = plugin;
|
||||
this.stateManager = stateManager;
|
||||
this.policy = policy;
|
||||
this.visitorPolicy = visitorPolicy;
|
||||
this.destinationFinder = destinationFinder;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
void start(Player player) {
|
||||
PlayerState state = stateManager.player(player.getUniqueId(), player.getName());
|
||||
if (state.baseLevel() < 3 || state.base().isEmpty()) {
|
||||
player.sendMessage(ChatColor.RED + "Base III teleportation is still locked.");
|
||||
return;
|
||||
}
|
||||
Optional<Duration> remaining = policy.remainingCooldown(state, clock.instant());
|
||||
if (remaining.isPresent()) {
|
||||
player.sendMessage(ChatColor.RED + "/base is available again in "
|
||||
+ DurationFormatter.friendly(remaining.orElseThrow()) + ".");
|
||||
return;
|
||||
}
|
||||
begin(
|
||||
player,
|
||||
state.base().orElseThrow(),
|
||||
policy.warmup(state),
|
||||
null,
|
||||
Duration.ZERO,
|
||||
"return home"
|
||||
);
|
||||
}
|
||||
|
||||
void startVisit(Player visitor, PlayerState owner) {
|
||||
if (visitor.getUniqueId().equals(owner.playerId())) {
|
||||
visitor.sendMessage(ChatColor.RED + "Use /base to visit your own base.");
|
||||
return;
|
||||
}
|
||||
if (owner.baseLevel() < 4 || !owner.visitorsEnabled() || owner.base().isEmpty()) {
|
||||
visitor.sendMessage(ChatColor.RED + "That base is not accepting visitors.");
|
||||
return;
|
||||
}
|
||||
PlayerState visitorState = stateManager.player(visitor.getUniqueId(), visitor.getName());
|
||||
Optional<Duration> remaining = visitorPolicy.remaining(
|
||||
visitorState, owner.playerId(), clock.instant()
|
||||
);
|
||||
if (remaining.isPresent()) {
|
||||
visitor.sendMessage(ChatColor.RED + "You can visit " + owner.latestName() + " again in "
|
||||
+ DurationFormatter.friendly(remaining.orElseThrow()) + ".");
|
||||
return;
|
||||
}
|
||||
begin(
|
||||
visitor,
|
||||
owner.base().orElseThrow(),
|
||||
policy.warmup(owner),
|
||||
owner.playerId(),
|
||||
policy.cooldown(owner),
|
||||
"visit " + owner.latestName() + "'s base"
|
||||
);
|
||||
}
|
||||
|
||||
private void begin(
|
||||
Player player,
|
||||
BaseLocation destination,
|
||||
Duration warmup,
|
||||
UUID visitorOwnerId,
|
||||
Duration visitorCooldown,
|
||||
String purpose
|
||||
) {
|
||||
if (requests.containsKey(player.getUniqueId())) {
|
||||
player.sendMessage(ChatColor.RED + "A base teleport is already warming up.");
|
||||
return;
|
||||
}
|
||||
Location origin = player.getLocation();
|
||||
int seconds = Math.toIntExact(warmup.getSeconds());
|
||||
Request request = new Request(
|
||||
origin.getWorld().getUID(),
|
||||
origin.getBlockX(),
|
||||
origin.getBlockY(),
|
||||
origin.getBlockZ(),
|
||||
seconds,
|
||||
destination,
|
||||
visitorOwnerId,
|
||||
visitorCooldown
|
||||
);
|
||||
if (seconds == 0) {
|
||||
complete(player, request);
|
||||
return;
|
||||
}
|
||||
requests.put(player.getUniqueId(), request);
|
||||
request.task = Bukkit.getScheduler().runTaskTimer(plugin, () -> tick(player, request), 0L, 20L);
|
||||
player.sendMessage(ChatColor.YELLOW + "Stand still for " + seconds + " seconds to " + purpose + ".");
|
||||
}
|
||||
|
||||
void cancelAll() {
|
||||
for (Request request : requests.values()) {
|
||||
request.cancelTask();
|
||||
}
|
||||
requests.clear();
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void onMove(PlayerMoveEvent event) {
|
||||
Request request = requests.get(event.getPlayer().getUniqueId());
|
||||
Location destination = event.getTo();
|
||||
if (request == null || destination == null) {
|
||||
return;
|
||||
}
|
||||
if (!request.worldId.equals(destination.getWorld().getUID())
|
||||
|| request.x != destination.getBlockX()
|
||||
|| request.y != destination.getBlockY()
|
||||
|| request.z != destination.getBlockZ()) {
|
||||
cancel(event.getPlayer(), "Base teleport cancelled because you moved.");
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void onDamage(EntityDamageEvent event) {
|
||||
Entity entity = event.getEntity();
|
||||
if (entity instanceof Player player) {
|
||||
cancel(player, "Base teleport cancelled because you took damage.");
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void onTeleport(PlayerTeleportEvent event) {
|
||||
cancel(event.getPlayer(), "Base teleport cancelled by another teleport.");
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
public void onWorldChange(PlayerChangedWorldEvent event) {
|
||||
cancel(event.getPlayer(), "Base teleport cancelled because you changed worlds.");
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
public void onDeath(PlayerDeathEvent event) {
|
||||
cancel(event.getEntity(), "Base teleport cancelled because you died.");
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
public void onQuit(PlayerQuitEvent event) {
|
||||
cancel(event.getPlayer(), null);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void onCommand(PlayerCommandPreprocessEvent event) {
|
||||
String command = event.getMessage().toLowerCase(Locale.ROOT).split("\\s+", 2)[0];
|
||||
if (command.equals("/base") || command.equals("/gotobase")
|
||||
|| command.equals("/spawn") || command.equals("/home")
|
||||
|| command.equals("/tp") || command.equals("/teleport")) {
|
||||
cancel(event.getPlayer(), "Base teleport cancelled by another teleport command.");
|
||||
}
|
||||
}
|
||||
|
||||
private void tick(Player player, Request request) {
|
||||
if (!player.isOnline() || requests.get(player.getUniqueId()) != request) {
|
||||
request.cancelTask();
|
||||
return;
|
||||
}
|
||||
if (request.remainingSeconds <= 0) {
|
||||
requests.remove(player.getUniqueId());
|
||||
request.cancelTask();
|
||||
complete(player, request);
|
||||
return;
|
||||
}
|
||||
player.sendTitle(
|
||||
ChatColor.GOLD + Integer.toString(request.remainingSeconds),
|
||||
ChatColor.YELLOW + "Stand still to return to base",
|
||||
0, 25, 5
|
||||
);
|
||||
request.remainingSeconds--;
|
||||
}
|
||||
|
||||
private void complete(Player player, Request request) {
|
||||
BaseLocation base = request.destination;
|
||||
World world = Bukkit.getWorld(base.worldId());
|
||||
if (world == null) {
|
||||
player.sendMessage(ChatColor.RED + "The destination world is not currently available.");
|
||||
return;
|
||||
}
|
||||
Optional<Location> destination = destinationFinder.find(world, base);
|
||||
if (destination.isEmpty()) {
|
||||
player.sendMessage(ChatColor.RED + "No safe location could be found at the base.");
|
||||
return;
|
||||
}
|
||||
if (!player.teleport(destination.orElseThrow(), PlayerTeleportEvent.TeleportCause.PLUGIN)) {
|
||||
player.sendMessage(ChatColor.RED + "The base teleport was prevented.");
|
||||
return;
|
||||
}
|
||||
Instant completedAt = clock.instant();
|
||||
stateManager.update(
|
||||
player.getUniqueId(),
|
||||
player.getName(),
|
||||
current -> request.visitorOwnerId == null
|
||||
? current.withLastBaseTeleport(completedAt)
|
||||
: current.withVisitorCooldown(
|
||||
request.visitorOwnerId,
|
||||
completedAt.plus(request.visitorCooldown)
|
||||
)
|
||||
);
|
||||
stateManager.saveIfDirty();
|
||||
player.sendMessage(ChatColor.GREEN + (request.visitorOwnerId == null
|
||||
? "Welcome home."
|
||||
: "Welcome to the base."));
|
||||
}
|
||||
|
||||
private void cancel(Player player, String message) {
|
||||
Request request = requests.remove(player.getUniqueId());
|
||||
if (request == null) {
|
||||
return;
|
||||
}
|
||||
request.cancelTask();
|
||||
if (message != null) {
|
||||
player.sendMessage(ChatColor.RED + message);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class Request {
|
||||
private final UUID worldId;
|
||||
private final int x;
|
||||
private final int y;
|
||||
private final int z;
|
||||
private final BaseLocation destination;
|
||||
private final UUID visitorOwnerId;
|
||||
private final Duration visitorCooldown;
|
||||
private int remainingSeconds;
|
||||
private BukkitTask task;
|
||||
|
||||
private Request(
|
||||
UUID worldId,
|
||||
int x,
|
||||
int y,
|
||||
int z,
|
||||
int remainingSeconds,
|
||||
BaseLocation destination,
|
||||
UUID visitorOwnerId,
|
||||
Duration visitorCooldown
|
||||
) {
|
||||
this.worldId = worldId;
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.z = z;
|
||||
this.remainingSeconds = remainingSeconds;
|
||||
this.destination = destination;
|
||||
this.visitorOwnerId = visitorOwnerId;
|
||||
this.visitorCooldown = visitorCooldown;
|
||||
}
|
||||
|
||||
private void cancelTask() {
|
||||
if (task != null) {
|
||||
task.cancel();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package games.dmg.spigotbase;
|
||||
|
||||
import org.bukkit.ChatColor;
|
||||
import org.bukkit.command.Command;
|
||||
import org.bukkit.command.CommandExecutor;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
final class BaseVisitorsCommand implements CommandExecutor {
|
||||
private final BaseStateManager stateManager;
|
||||
|
||||
BaseVisitorsCommand(BaseStateManager stateManager) {
|
||||
this.stateManager = stateManager;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onCommand(CommandSender sender, Command command, String label, String[] arguments) {
|
||||
if (!(sender instanceof Player player)) {
|
||||
sender.sendMessage("Only players can manage base visitors.");
|
||||
return true;
|
||||
}
|
||||
PlayerState state = stateManager.player(player.getUniqueId(), player.getName());
|
||||
if (state.baseLevel() < 4) {
|
||||
player.sendMessage(ChatColor.RED + "Base IV visitor access is still locked.");
|
||||
return true;
|
||||
}
|
||||
state = stateManager.update(
|
||||
player.getUniqueId(),
|
||||
player.getName(),
|
||||
current -> current.withVisitorsEnabled(!current.visitorsEnabled())
|
||||
);
|
||||
stateManager.saveIfDirty();
|
||||
player.sendMessage(ChatColor.YELLOW + "Visitor teleports are now "
|
||||
+ (state.visitorsEnabled() ? ChatColor.GREEN + "on" : ChatColor.RED + "off")
|
||||
+ ChatColor.YELLOW + ".");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package games.dmg.spigotbase;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
final class DurationFormatter {
|
||||
private DurationFormatter() {
|
||||
}
|
||||
|
||||
static String friendly(Duration duration) {
|
||||
long seconds = Math.max(0, duration.getSeconds());
|
||||
long hours = seconds / 3_600;
|
||||
long minutes = seconds % 3_600 / 60;
|
||||
long remainder = seconds % 60;
|
||||
if (hours > 0) {
|
||||
return minutes > 0 ? hours + "h " + minutes + "m" : hours + "h";
|
||||
}
|
||||
if (minutes > 0) {
|
||||
return remainder > 0 ? minutes + "m " + remainder + "s" : minutes + "m";
|
||||
}
|
||||
return remainder + "s";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package games.dmg.spigotbase;
|
||||
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import org.bukkit.ChatColor;
|
||||
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;
|
||||
|
||||
final class GoToBaseCommand implements CommandExecutor, TabCompleter {
|
||||
private final BaseStateManager stateManager;
|
||||
private final BaseTeleportManager teleportManager;
|
||||
|
||||
GoToBaseCommand(BaseStateManager stateManager, BaseTeleportManager teleportManager) {
|
||||
this.stateManager = stateManager;
|
||||
this.teleportManager = teleportManager;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onCommand(CommandSender sender, Command command, String label, String[] arguments) {
|
||||
if (!(sender instanceof Player player)) {
|
||||
sender.sendMessage("Only players can visit a base.");
|
||||
return true;
|
||||
}
|
||||
if (arguments.length != 1) {
|
||||
player.sendMessage(ChatColor.RED + "Usage: /gotobase <player>");
|
||||
return true;
|
||||
}
|
||||
PlayerState owner = stateManager.findByName(arguments[0]).orElse(null);
|
||||
if (owner == null || owner.baseLevel() < 4 || !owner.visitorsEnabled()
|
||||
|| owner.base().isEmpty()) {
|
||||
player.sendMessage(ChatColor.RED + "That player does not have an available base.");
|
||||
return true;
|
||||
}
|
||||
teleportManager.startVisit(player, owner);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> onTabComplete(
|
||||
CommandSender sender,
|
||||
Command command,
|
||||
String alias,
|
||||
String[] arguments
|
||||
) {
|
||||
if (!(sender instanceof Player player) || arguments.length != 1) {
|
||||
return List.of();
|
||||
}
|
||||
String prefix = arguments[0].toLowerCase(Locale.ROOT);
|
||||
return stateManager.knownPlayers().values().stream()
|
||||
.filter(owner -> !owner.playerId().equals(player.getUniqueId()))
|
||||
.filter(owner -> owner.baseLevel() >= 4 && owner.visitorsEnabled() && owner.base().isPresent())
|
||||
.map(PlayerState::latestName)
|
||||
.filter(name -> name.toLowerCase(Locale.ROOT).startsWith(prefix))
|
||||
.sorted(Comparator.comparing(String::toLowerCase))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package games.dmg.spigotbase;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
public record PersistentState(Map<UUID, PlayerState> players) {
|
||||
public PersistentState {
|
||||
players = players == null ? Map.of() : Map.copyOf(players);
|
||||
}
|
||||
|
||||
public static PersistentState empty() {
|
||||
return new PersistentState(Map.of());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
package games.dmg.spigotbase;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
public record PlayerState(
|
||||
UUID playerId,
|
||||
String latestName,
|
||||
Optional<BaseLocation> base,
|
||||
int baseLevel,
|
||||
int sizeLevel,
|
||||
int flightLevel,
|
||||
int warmupLevel,
|
||||
int cooldownLevel,
|
||||
long grassAndDirtBroken,
|
||||
long stoneBroken,
|
||||
long deepslateBroken,
|
||||
long obsidianBroken,
|
||||
long blocksPlacedInBase,
|
||||
long blocksBrokenInBase,
|
||||
boolean navigationEnabled,
|
||||
boolean flightEnabled,
|
||||
boolean bossBarEnabled,
|
||||
boolean visitorsEnabled,
|
||||
Optional<Instant> lastBaseSet,
|
||||
Optional<Instant> lastBaseTeleport,
|
||||
Map<UUID, Instant> visitorCooldownUntil
|
||||
) {
|
||||
public PlayerState {
|
||||
if (playerId == null) {
|
||||
throw new IllegalArgumentException("player ID is required");
|
||||
}
|
||||
if (latestName == null || latestName.isBlank()) {
|
||||
throw new IllegalArgumentException("latest player name is required");
|
||||
}
|
||||
base = base == null ? Optional.empty() : base;
|
||||
lastBaseSet = lastBaseSet == null ? Optional.empty() : lastBaseSet;
|
||||
lastBaseTeleport = lastBaseTeleport == null ? Optional.empty() : lastBaseTeleport;
|
||||
visitorCooldownUntil = visitorCooldownUntil == null
|
||||
? Map.of()
|
||||
: Map.copyOf(visitorCooldownUntil);
|
||||
|
||||
requireLevel(baseLevel, 0, 4, "base level");
|
||||
requireLevel(sizeLevel, 0, 3, "size level");
|
||||
requireLevel(flightLevel, 0, 3, "flight level");
|
||||
requireLevel(warmupLevel, 0, 3, "warm-up level");
|
||||
requireLevel(cooldownLevel, 0, 4, "cooldown level");
|
||||
requireNonNegative(grassAndDirtBroken, "grass and dirt broken");
|
||||
requireNonNegative(stoneBroken, "stone broken");
|
||||
requireNonNegative(deepslateBroken, "deepslate broken");
|
||||
requireNonNegative(obsidianBroken, "obsidian broken");
|
||||
requireNonNegative(blocksPlacedInBase, "blocks placed in base");
|
||||
requireNonNegative(blocksBrokenInBase, "blocks broken in base");
|
||||
|
||||
if (baseLevel == 0 && (sizeLevel > 0 || flightLevel > 0)) {
|
||||
throw new IllegalArgumentException("secondary progression requires Base I");
|
||||
}
|
||||
if (baseLevel < 2 && navigationEnabled) {
|
||||
throw new IllegalArgumentException("navigation requires Base II");
|
||||
}
|
||||
if (flightLevel == 0 && flightEnabled) {
|
||||
throw new IllegalArgumentException("enabled flight requires an unlocked flight tier");
|
||||
}
|
||||
if (baseLevel < 3 && (warmupLevel > 0 || cooldownLevel > 0)) {
|
||||
throw new IllegalArgumentException("teleport upgrades require Base III");
|
||||
}
|
||||
if (baseLevel < 4 && visitorsEnabled) {
|
||||
throw new IllegalArgumentException("visitor access requires Base IV");
|
||||
}
|
||||
if (visitorCooldownUntil.entrySet().stream().anyMatch(entry ->
|
||||
entry.getKey() == null || entry.getValue() == null)) {
|
||||
throw new IllegalArgumentException("visitor cooldowns must be complete");
|
||||
}
|
||||
}
|
||||
|
||||
public static PlayerState newPlayer(UUID playerId, String latestName) {
|
||||
return new PlayerState(
|
||||
playerId,
|
||||
latestName,
|
||||
Optional.empty(),
|
||||
0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0,
|
||||
false, false, true, false,
|
||||
Optional.empty(), Optional.empty(), Map.of()
|
||||
);
|
||||
}
|
||||
|
||||
public PlayerState withLatestName(String name) {
|
||||
return new PlayerState(
|
||||
playerId, name, base, baseLevel, sizeLevel, flightLevel,
|
||||
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
|
||||
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
|
||||
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
|
||||
lastBaseSet, lastBaseTeleport, visitorCooldownUntil
|
||||
);
|
||||
}
|
||||
|
||||
public PlayerState withGrassAndDirtProgress(long count, int newBaseLevel) {
|
||||
return new PlayerState(
|
||||
playerId, latestName, base, newBaseLevel, sizeLevel, flightLevel,
|
||||
warmupLevel, cooldownLevel, count, stoneBroken, deepslateBroken,
|
||||
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
|
||||
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
|
||||
lastBaseSet, lastBaseTeleport, visitorCooldownUntil
|
||||
);
|
||||
}
|
||||
|
||||
public PlayerState withBossBarEnabled(boolean enabled) {
|
||||
return new PlayerState(
|
||||
playerId, latestName, base, baseLevel, sizeLevel, flightLevel,
|
||||
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
|
||||
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
|
||||
navigationEnabled, flightEnabled, enabled, visitorsEnabled,
|
||||
lastBaseSet, lastBaseTeleport, visitorCooldownUntil
|
||||
);
|
||||
}
|
||||
|
||||
public PlayerState withNavigationEnabled(boolean enabled) {
|
||||
return new PlayerState(
|
||||
playerId, latestName, base, baseLevel, sizeLevel, flightLevel,
|
||||
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
|
||||
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
|
||||
enabled, flightEnabled, bossBarEnabled, visitorsEnabled,
|
||||
lastBaseSet, lastBaseTeleport, visitorCooldownUntil
|
||||
);
|
||||
}
|
||||
|
||||
public PlayerState withSizeProgress(long stone, long deepslate, long obsidian, int level) {
|
||||
return new PlayerState(
|
||||
playerId, latestName, base, baseLevel, level, flightLevel,
|
||||
warmupLevel, cooldownLevel, grassAndDirtBroken, stone, deepslate,
|
||||
obsidian, blocksPlacedInBase, blocksBrokenInBase,
|
||||
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
|
||||
lastBaseSet, lastBaseTeleport, visitorCooldownUntil
|
||||
);
|
||||
}
|
||||
|
||||
public PlayerState withFlightLevel(int level, boolean enabled) {
|
||||
return new PlayerState(
|
||||
playerId, latestName, base, baseLevel, sizeLevel, level,
|
||||
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
|
||||
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
|
||||
navigationEnabled, enabled, bossBarEnabled, visitorsEnabled,
|
||||
lastBaseSet, lastBaseTeleport, visitorCooldownUntil
|
||||
);
|
||||
}
|
||||
|
||||
public PlayerState withFlightEnabled(boolean enabled) {
|
||||
return withFlightLevel(flightLevel, enabled);
|
||||
}
|
||||
|
||||
public PlayerState withTeleportProgress(
|
||||
long placements,
|
||||
long breaks,
|
||||
int newWarmupLevel,
|
||||
int newCooldownLevel,
|
||||
int newBaseLevel
|
||||
) {
|
||||
return new PlayerState(
|
||||
playerId, latestName, base, newBaseLevel, sizeLevel, flightLevel,
|
||||
newWarmupLevel, newCooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
|
||||
obsidianBroken, placements, breaks,
|
||||
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
|
||||
lastBaseSet, lastBaseTeleport, visitorCooldownUntil
|
||||
);
|
||||
}
|
||||
|
||||
public PlayerState withTeleportLevels(int newWarmupLevel, int newCooldownLevel) {
|
||||
return withTeleportProgress(
|
||||
blocksPlacedInBase, blocksBrokenInBase, newWarmupLevel, newCooldownLevel, baseLevel
|
||||
);
|
||||
}
|
||||
|
||||
public PlayerState withLastBaseTeleport(Instant usedAt) {
|
||||
return new PlayerState(
|
||||
playerId, latestName, base, baseLevel, sizeLevel, flightLevel,
|
||||
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
|
||||
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
|
||||
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
|
||||
lastBaseSet, Optional.of(usedAt), visitorCooldownUntil
|
||||
);
|
||||
}
|
||||
|
||||
public PlayerState withBaseLevel(int level) {
|
||||
return new PlayerState(
|
||||
playerId, latestName, base, level, sizeLevel, flightLevel,
|
||||
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
|
||||
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
|
||||
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
|
||||
lastBaseSet, lastBaseTeleport, visitorCooldownUntil
|
||||
);
|
||||
}
|
||||
|
||||
public PlayerState withAdministrativeLevels(
|
||||
int newBaseLevel,
|
||||
int newSizeLevel,
|
||||
int newFlightLevel,
|
||||
int newWarmupLevel,
|
||||
int newCooldownLevel,
|
||||
boolean newNavigationEnabled,
|
||||
boolean newFlightEnabled,
|
||||
boolean newVisitorsEnabled
|
||||
) {
|
||||
return new PlayerState(
|
||||
playerId, latestName, base, newBaseLevel, newSizeLevel, newFlightLevel,
|
||||
newWarmupLevel, newCooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
|
||||
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
|
||||
newNavigationEnabled, newFlightEnabled, bossBarEnabled, newVisitorsEnabled,
|
||||
lastBaseSet, lastBaseTeleport, visitorCooldownUntil
|
||||
);
|
||||
}
|
||||
|
||||
public PlayerState withVisitorsEnabled(boolean enabled) {
|
||||
return new PlayerState(
|
||||
playerId, latestName, base, baseLevel, sizeLevel, flightLevel,
|
||||
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
|
||||
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
|
||||
navigationEnabled, flightEnabled, bossBarEnabled, enabled,
|
||||
lastBaseSet, lastBaseTeleport, visitorCooldownUntil
|
||||
);
|
||||
}
|
||||
|
||||
public PlayerState withVisitorCooldown(UUID ownerId, Instant availableAt) {
|
||||
java.util.HashMap<UUID, Instant> cooldowns = new java.util.HashMap<>(visitorCooldownUntil);
|
||||
cooldowns.put(ownerId, availableAt);
|
||||
return new PlayerState(
|
||||
playerId, latestName, base, baseLevel, sizeLevel, flightLevel,
|
||||
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
|
||||
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
|
||||
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
|
||||
lastBaseSet, lastBaseTeleport, cooldowns
|
||||
);
|
||||
}
|
||||
|
||||
public PlayerState withoutPersonalCooldown() {
|
||||
return new PlayerState(
|
||||
playerId, latestName, base, baseLevel, sizeLevel, flightLevel,
|
||||
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
|
||||
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
|
||||
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
|
||||
lastBaseSet, Optional.empty(), visitorCooldownUntil
|
||||
);
|
||||
}
|
||||
|
||||
public PlayerState withoutVisitorCooldowns() {
|
||||
return new PlayerState(
|
||||
playerId, latestName, base, baseLevel, sizeLevel, flightLevel,
|
||||
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
|
||||
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
|
||||
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
|
||||
lastBaseSet, lastBaseTeleport, Map.of()
|
||||
);
|
||||
}
|
||||
|
||||
public PlayerState withBase(BaseLocation location, Instant setAt) {
|
||||
return new PlayerState(
|
||||
playerId, latestName, Optional.of(location), baseLevel, sizeLevel, flightLevel,
|
||||
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
|
||||
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
|
||||
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
|
||||
Optional.of(setAt), lastBaseTeleport, visitorCooldownUntil
|
||||
);
|
||||
}
|
||||
|
||||
private static void requireLevel(int value, int minimum, int maximum, String name) {
|
||||
if (value < minimum || value > maximum) {
|
||||
throw new IllegalArgumentException(name + " must be between " + minimum + " and " + maximum);
|
||||
}
|
||||
}
|
||||
|
||||
private static void requireNonNegative(long value, String name) {
|
||||
if (value < 0) {
|
||||
throw new IllegalArgumentException(name + " must not be negative");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
package games.dmg.spigotbase;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
public record PluginSettings(
|
||||
int baseUnlockBlocks,
|
||||
int navigationUnlockBlocks,
|
||||
int initialRadius,
|
||||
int initialVerticalRange,
|
||||
int stoneExpansionBlocks,
|
||||
int deepslateExpansionBlocks,
|
||||
int obsidianExpansionBlocks,
|
||||
int firstExpandedRadius,
|
||||
int secondExpandedRadius,
|
||||
int thirdExpandedRadius,
|
||||
long relocationCooldownSeconds,
|
||||
int flightWarningBuffer,
|
||||
int secondFlightVerticalRange,
|
||||
int teleportUnlockPlacements,
|
||||
int secondWarmupPlacements,
|
||||
int thirdWarmupPlacements,
|
||||
int instantWarmupPlacements,
|
||||
int initialWarmupSeconds,
|
||||
int secondWarmupSeconds,
|
||||
int thirdWarmupSeconds,
|
||||
int firstCooldownBreaks,
|
||||
int secondCooldownBreaks,
|
||||
int thirdCooldownBreaks,
|
||||
int instantCooldownBreaks,
|
||||
long initialTeleportCooldownSeconds,
|
||||
long secondTeleportCooldownSeconds,
|
||||
long thirdTeleportCooldownSeconds,
|
||||
long fourthTeleportCooldownSeconds,
|
||||
int visitorUnlockDiamondCost
|
||||
) {
|
||||
private static final int DEFAULT_BASE_UNLOCK_BLOCKS = 250;
|
||||
private static final int DEFAULT_NAVIGATION_UNLOCK_BLOCKS = 500;
|
||||
private static final int DEFAULT_INITIAL_RADIUS = 10;
|
||||
private static final int DEFAULT_INITIAL_VERTICAL_RANGE = 25;
|
||||
private static final int DEFAULT_STONE_EXPANSION_BLOCKS = 500;
|
||||
private static final int DEFAULT_DEEPSLATE_EXPANSION_BLOCKS = 1_000;
|
||||
private static final int DEFAULT_OBSIDIAN_EXPANSION_BLOCKS = 1_000;
|
||||
private static final int DEFAULT_FIRST_EXPANDED_RADIUS = 25;
|
||||
private static final int DEFAULT_SECOND_EXPANDED_RADIUS = 75;
|
||||
private static final int DEFAULT_THIRD_EXPANDED_RADIUS = 150;
|
||||
private static final long DEFAULT_RELOCATION_COOLDOWN_SECONDS = 86_400L;
|
||||
private static final int DEFAULT_FLIGHT_WARNING_BUFFER = 5;
|
||||
private static final int DEFAULT_SECOND_FLIGHT_VERTICAL_RANGE = 100;
|
||||
private static final int DEFAULT_TELEPORT_UNLOCK_PLACEMENTS = 200;
|
||||
private static final int DEFAULT_SECOND_WARMUP_PLACEMENTS = 1_000;
|
||||
private static final int DEFAULT_THIRD_WARMUP_PLACEMENTS = 2_000;
|
||||
private static final int DEFAULT_INSTANT_WARMUP_PLACEMENTS = 12_000;
|
||||
private static final int DEFAULT_INITIAL_WARMUP_SECONDS = 30;
|
||||
private static final int DEFAULT_SECOND_WARMUP_SECONDS = 15;
|
||||
private static final int DEFAULT_THIRD_WARMUP_SECONDS = 5;
|
||||
private static final int DEFAULT_FIRST_COOLDOWN_BREAKS = 1_000;
|
||||
private static final int DEFAULT_SECOND_COOLDOWN_BREAKS = 2_000;
|
||||
private static final int DEFAULT_THIRD_COOLDOWN_BREAKS = 3_000;
|
||||
private static final int DEFAULT_INSTANT_COOLDOWN_BREAKS = 5_000;
|
||||
private static final long DEFAULT_INITIAL_TELEPORT_COOLDOWN_SECONDS = 10_800L;
|
||||
private static final long DEFAULT_SECOND_TELEPORT_COOLDOWN_SECONDS = 7_200L;
|
||||
private static final long DEFAULT_THIRD_TELEPORT_COOLDOWN_SECONDS = 3_600L;
|
||||
private static final long DEFAULT_FOURTH_TELEPORT_COOLDOWN_SECONDS = 1_800L;
|
||||
private static final int DEFAULT_VISITOR_UNLOCK_DIAMOND_COST = 128;
|
||||
|
||||
public PluginSettings {
|
||||
requirePositive(baseUnlockBlocks, "base-unlock-blocks");
|
||||
requirePositive(navigationUnlockBlocks, "navigation-unlock-blocks");
|
||||
if (navigationUnlockBlocks < baseUnlockBlocks) {
|
||||
throw new IllegalArgumentException(
|
||||
"navigation-unlock-blocks must be at least base-unlock-blocks"
|
||||
);
|
||||
}
|
||||
requirePositive(initialRadius, "initial-radius");
|
||||
requirePositive(initialVerticalRange, "initial-vertical-range");
|
||||
requirePositive(stoneExpansionBlocks, "stone-expansion-blocks");
|
||||
requirePositive(deepslateExpansionBlocks, "deepslate-expansion-blocks");
|
||||
requirePositive(obsidianExpansionBlocks, "obsidian-expansion-blocks");
|
||||
requirePositive(firstExpandedRadius, "first-expanded-radius");
|
||||
requirePositive(secondExpandedRadius, "second-expanded-radius");
|
||||
requirePositive(thirdExpandedRadius, "third-expanded-radius");
|
||||
if (firstExpandedRadius <= initialRadius
|
||||
|| secondExpandedRadius <= firstExpandedRadius
|
||||
|| thirdExpandedRadius <= secondExpandedRadius) {
|
||||
throw new IllegalArgumentException("expanded radii must increase at each tier");
|
||||
}
|
||||
requireNonNegative(relocationCooldownSeconds, "relocation-cooldown-seconds");
|
||||
requireNonNegative(flightWarningBuffer, "flight-warning-buffer");
|
||||
if (secondFlightVerticalRange <= initialVerticalRange) {
|
||||
throw new IllegalArgumentException("second-flight-vertical-range must exceed the initial range");
|
||||
}
|
||||
requirePositive(teleportUnlockPlacements, "teleport-unlock-placements");
|
||||
if (secondWarmupPlacements <= teleportUnlockPlacements
|
||||
|| thirdWarmupPlacements <= secondWarmupPlacements
|
||||
|| instantWarmupPlacements <= thirdWarmupPlacements) {
|
||||
throw new IllegalArgumentException("warm-up placement thresholds must increase");
|
||||
}
|
||||
requirePositive(initialWarmupSeconds, "initial-warmup-seconds");
|
||||
requirePositive(secondWarmupSeconds, "second-warmup-seconds");
|
||||
requirePositive(thirdWarmupSeconds, "third-warmup-seconds");
|
||||
if (secondWarmupSeconds >= initialWarmupSeconds
|
||||
|| thirdWarmupSeconds >= secondWarmupSeconds) {
|
||||
throw new IllegalArgumentException("warm-up durations must decrease");
|
||||
}
|
||||
if (firstCooldownBreaks <= 0 || secondCooldownBreaks <= firstCooldownBreaks
|
||||
|| thirdCooldownBreaks <= secondCooldownBreaks
|
||||
|| instantCooldownBreaks <= thirdCooldownBreaks) {
|
||||
throw new IllegalArgumentException("cooldown break thresholds must increase");
|
||||
}
|
||||
requireNonNegative(initialTeleportCooldownSeconds, "initial-teleport-cooldown-seconds");
|
||||
requireNonNegative(secondTeleportCooldownSeconds, "second-teleport-cooldown-seconds");
|
||||
requireNonNegative(thirdTeleportCooldownSeconds, "third-teleport-cooldown-seconds");
|
||||
requireNonNegative(fourthTeleportCooldownSeconds, "fourth-teleport-cooldown-seconds");
|
||||
if (secondTeleportCooldownSeconds >= initialTeleportCooldownSeconds
|
||||
|| thirdTeleportCooldownSeconds >= secondTeleportCooldownSeconds
|
||||
|| fourthTeleportCooldownSeconds >= thirdTeleportCooldownSeconds) {
|
||||
throw new IllegalArgumentException("teleport cooldown durations must decrease");
|
||||
}
|
||||
requirePositive(visitorUnlockDiamondCost, "visitor-unlock-diamond-cost");
|
||||
}
|
||||
|
||||
public static PluginSettings from(Map<String, ?> values) {
|
||||
Objects.requireNonNull(values, "values");
|
||||
return new PluginSettings(
|
||||
integer(values, "base-unlock-blocks", DEFAULT_BASE_UNLOCK_BLOCKS),
|
||||
integer(values, "navigation-unlock-blocks", DEFAULT_NAVIGATION_UNLOCK_BLOCKS),
|
||||
integer(values, "initial-radius", DEFAULT_INITIAL_RADIUS),
|
||||
integer(values, "initial-vertical-range", DEFAULT_INITIAL_VERTICAL_RANGE),
|
||||
integer(values, "stone-expansion-blocks", DEFAULT_STONE_EXPANSION_BLOCKS),
|
||||
integer(values, "deepslate-expansion-blocks", DEFAULT_DEEPSLATE_EXPANSION_BLOCKS),
|
||||
integer(values, "obsidian-expansion-blocks", DEFAULT_OBSIDIAN_EXPANSION_BLOCKS),
|
||||
integer(values, "first-expanded-radius", DEFAULT_FIRST_EXPANDED_RADIUS),
|
||||
integer(values, "second-expanded-radius", DEFAULT_SECOND_EXPANDED_RADIUS),
|
||||
integer(values, "third-expanded-radius", DEFAULT_THIRD_EXPANDED_RADIUS),
|
||||
longInteger(values, "relocation-cooldown-seconds", DEFAULT_RELOCATION_COOLDOWN_SECONDS),
|
||||
integer(values, "flight-warning-buffer", DEFAULT_FLIGHT_WARNING_BUFFER),
|
||||
integer(values, "second-flight-vertical-range", DEFAULT_SECOND_FLIGHT_VERTICAL_RANGE),
|
||||
integer(values, "teleport-unlock-placements", DEFAULT_TELEPORT_UNLOCK_PLACEMENTS),
|
||||
integer(values, "second-warmup-placements", DEFAULT_SECOND_WARMUP_PLACEMENTS),
|
||||
integer(values, "third-warmup-placements", DEFAULT_THIRD_WARMUP_PLACEMENTS),
|
||||
integer(values, "instant-warmup-placements", DEFAULT_INSTANT_WARMUP_PLACEMENTS),
|
||||
integer(values, "initial-warmup-seconds", DEFAULT_INITIAL_WARMUP_SECONDS),
|
||||
integer(values, "second-warmup-seconds", DEFAULT_SECOND_WARMUP_SECONDS),
|
||||
integer(values, "third-warmup-seconds", DEFAULT_THIRD_WARMUP_SECONDS),
|
||||
integer(values, "first-cooldown-breaks", DEFAULT_FIRST_COOLDOWN_BREAKS),
|
||||
integer(values, "second-cooldown-breaks", DEFAULT_SECOND_COOLDOWN_BREAKS),
|
||||
integer(values, "third-cooldown-breaks", DEFAULT_THIRD_COOLDOWN_BREAKS),
|
||||
integer(values, "instant-cooldown-breaks", DEFAULT_INSTANT_COOLDOWN_BREAKS),
|
||||
longInteger(
|
||||
values,
|
||||
"initial-teleport-cooldown-seconds",
|
||||
DEFAULT_INITIAL_TELEPORT_COOLDOWN_SECONDS
|
||||
),
|
||||
longInteger(
|
||||
values,
|
||||
"second-teleport-cooldown-seconds",
|
||||
DEFAULT_SECOND_TELEPORT_COOLDOWN_SECONDS
|
||||
),
|
||||
longInteger(
|
||||
values,
|
||||
"third-teleport-cooldown-seconds",
|
||||
DEFAULT_THIRD_TELEPORT_COOLDOWN_SECONDS
|
||||
),
|
||||
longInteger(
|
||||
values,
|
||||
"fourth-teleport-cooldown-seconds",
|
||||
DEFAULT_FOURTH_TELEPORT_COOLDOWN_SECONDS
|
||||
),
|
||||
integer(values, "visitor-unlock-diamond-cost", DEFAULT_VISITOR_UNLOCK_DIAMOND_COST)
|
||||
);
|
||||
}
|
||||
|
||||
private static int integer(Map<String, ?> values, String key, int defaultValue) {
|
||||
long value = longInteger(values, key, defaultValue);
|
||||
if (value > Integer.MAX_VALUE || value < Integer.MIN_VALUE) {
|
||||
throw new IllegalArgumentException(key + " must be a 32-bit integer");
|
||||
}
|
||||
return (int) value;
|
||||
}
|
||||
|
||||
private static long longInteger(Map<String, ?> values, String key, long defaultValue) {
|
||||
Object value = values.get(key);
|
||||
if (value == null) {
|
||||
return defaultValue;
|
||||
}
|
||||
if (!(value instanceof Number number)) {
|
||||
throw new IllegalArgumentException(key + " must be an integer");
|
||||
}
|
||||
if (number instanceof Float || number instanceof Double) {
|
||||
double decimal = number.doubleValue();
|
||||
if (!Double.isFinite(decimal) || decimal != Math.rint(decimal)) {
|
||||
throw new IllegalArgumentException(key + " must be an integer");
|
||||
}
|
||||
}
|
||||
return number.longValue();
|
||||
}
|
||||
|
||||
private static void requirePositive(long value, String name) {
|
||||
if (value <= 0) {
|
||||
throw new IllegalArgumentException(name + " must be positive");
|
||||
}
|
||||
}
|
||||
|
||||
private static void requireNonNegative(long value, String name) {
|
||||
if (value < 0) {
|
||||
throw new IllegalArgumentException(name + " must not be negative");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package games.dmg.spigotbase;
|
||||
|
||||
public enum ProgressionPath {
|
||||
BASE,
|
||||
SIZE,
|
||||
FLIGHT,
|
||||
WARMUP,
|
||||
COOLDOWN
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package games.dmg.spigotbase;
|
||||
|
||||
public record ProgressionUpdate(
|
||||
PlayerState player,
|
||||
boolean unlockedBaseLevel,
|
||||
boolean unlockedSizeLevel,
|
||||
boolean unlockedFlightLevel,
|
||||
boolean unlockedWarmupLevel,
|
||||
boolean unlockedCooldownLevel
|
||||
) {
|
||||
public ProgressionUpdate {
|
||||
if (player == null) {
|
||||
throw new IllegalArgumentException("player is required");
|
||||
}
|
||||
}
|
||||
|
||||
public static ProgressionUpdate unchanged(PlayerState player) {
|
||||
return new ProgressionUpdate(player, false, false, false, false, false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package games.dmg.spigotbase;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.block.Block;
|
||||
|
||||
final class SafeBaseDestination {
|
||||
private static final int HORIZONTAL_SEARCH_RADIUS = 5;
|
||||
private static final int VERTICAL_SEARCH_RADIUS = 4;
|
||||
private static final Set<Material> HAZARDS = Set.of(
|
||||
Material.LAVA,
|
||||
Material.FIRE,
|
||||
Material.SOUL_FIRE,
|
||||
Material.MAGMA_BLOCK,
|
||||
Material.CACTUS,
|
||||
Material.CAMPFIRE,
|
||||
Material.SOUL_CAMPFIRE,
|
||||
Material.POWDER_SNOW
|
||||
);
|
||||
|
||||
Optional<Location> find(World world, BaseLocation base) {
|
||||
for (int radius = 0; radius <= HORIZONTAL_SEARCH_RADIUS; radius++) {
|
||||
for (int deltaX = -radius; deltaX <= radius; deltaX++) {
|
||||
for (int deltaZ = -radius; deltaZ <= radius; deltaZ++) {
|
||||
if (radius > 0 && Math.abs(deltaX) != radius && Math.abs(deltaZ) != radius) {
|
||||
continue;
|
||||
}
|
||||
for (int vertical = 0; vertical <= VERTICAL_SEARCH_RADIUS; vertical++) {
|
||||
Optional<Location> above = candidate(
|
||||
world, base, deltaX, vertical, deltaZ
|
||||
);
|
||||
if (above.isPresent()) {
|
||||
return above;
|
||||
}
|
||||
if (vertical > 0) {
|
||||
Optional<Location> below = candidate(
|
||||
world, base, deltaX, -vertical, deltaZ
|
||||
);
|
||||
if (below.isPresent()) {
|
||||
return below;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
private Optional<Location> candidate(
|
||||
World world,
|
||||
BaseLocation base,
|
||||
int deltaX,
|
||||
int deltaY,
|
||||
int deltaZ
|
||||
) {
|
||||
int x = base.x() + deltaX;
|
||||
int y = base.y() + deltaY;
|
||||
int z = base.z() + deltaZ;
|
||||
if (y <= world.getMinHeight() || y + 1 >= world.getMaxHeight()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
Block feet = world.getBlockAt(x, y, z);
|
||||
Block head = world.getBlockAt(x, y + 1, z);
|
||||
Block ground = world.getBlockAt(x, y - 1, z);
|
||||
if (!feet.isPassable() || !head.isPassable() || !ground.getType().isSolid()
|
||||
|| feet.isLiquid() || head.isLiquid() || HAZARDS.contains(ground.getType())
|
||||
|| HAZARDS.contains(feet.getType()) || HAZARDS.contains(head.getType())) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return Optional.of(new Location(
|
||||
world, x + 0.5, y, z + 0.5, base.yaw(), base.pitch()
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package games.dmg.spigotbase;
|
||||
|
||||
public final class SecondaryProgressionService {
|
||||
private final PluginSettings settings;
|
||||
|
||||
public SecondaryProgressionService(PluginSettings settings) {
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
public ProgressionUpdate recordStoneBreak(PlayerState player) {
|
||||
if (player.baseLevel() < 1 || player.sizeLevel() != 0) {
|
||||
return ProgressionUpdate.unchanged(player);
|
||||
}
|
||||
long count = increment(player.stoneBroken());
|
||||
boolean unlocked = count >= settings.stoneExpansionBlocks();
|
||||
return new ProgressionUpdate(
|
||||
player.withSizeProgress(
|
||||
count, player.deepslateBroken(), player.obsidianBroken(), unlocked ? 1 : 0
|
||||
),
|
||||
false, unlocked, false, false, false
|
||||
);
|
||||
}
|
||||
|
||||
public ProgressionUpdate recordDeepslateBreak(PlayerState player) {
|
||||
if (player.baseLevel() < 1 || player.sizeLevel() != 1) {
|
||||
return ProgressionUpdate.unchanged(player);
|
||||
}
|
||||
long count = increment(player.deepslateBroken());
|
||||
boolean unlocked = count >= settings.deepslateExpansionBlocks();
|
||||
return new ProgressionUpdate(
|
||||
player.withSizeProgress(
|
||||
player.stoneBroken(), count, player.obsidianBroken(), unlocked ? 2 : 1
|
||||
),
|
||||
false, unlocked, false, false, false
|
||||
);
|
||||
}
|
||||
|
||||
public ProgressionUpdate recordObsidianBreak(PlayerState player) {
|
||||
if (player.baseLevel() < 1 || player.sizeLevel() != 2) {
|
||||
return ProgressionUpdate.unchanged(player);
|
||||
}
|
||||
long count = increment(player.obsidianBroken());
|
||||
boolean unlocked = count >= settings.obsidianExpansionBlocks();
|
||||
return new ProgressionUpdate(
|
||||
player.withSizeProgress(
|
||||
player.stoneBroken(), player.deepslateBroken(), count, unlocked ? 3 : 2
|
||||
),
|
||||
false, unlocked, false, false, false
|
||||
);
|
||||
}
|
||||
|
||||
public ProgressionUpdate observeElytraCount(PlayerState player, int elytraCount) {
|
||||
if (player.baseLevel() < 1 || player.base().isEmpty()
|
||||
|| elytraCount <= player.flightLevel()) {
|
||||
return ProgressionUpdate.unchanged(player);
|
||||
}
|
||||
int level = Math.min(3, Math.max(0, elytraCount));
|
||||
return new ProgressionUpdate(
|
||||
player.withFlightLevel(level, true),
|
||||
false, false, true, false, false
|
||||
);
|
||||
}
|
||||
|
||||
private static long increment(long value) {
|
||||
return value == Long.MAX_VALUE ? Long.MAX_VALUE : value + 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package games.dmg.spigotbase;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Optional;
|
||||
import org.bukkit.ChatColor;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.command.Command;
|
||||
import org.bukkit.command.CommandExecutor;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
final class SetBaseCommand implements CommandExecutor {
|
||||
private final BaseStateManager stateManager;
|
||||
private final BaseService baseService;
|
||||
private final Clock clock;
|
||||
|
||||
SetBaseCommand(BaseStateManager stateManager, BaseService baseService, Clock clock) {
|
||||
this.stateManager = stateManager;
|
||||
this.baseService = baseService;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onCommand(CommandSender sender, Command command, String label, String[] arguments) {
|
||||
if (!(sender instanceof Player player)) {
|
||||
sender.sendMessage("Only players can set a base.");
|
||||
return true;
|
||||
}
|
||||
PlayerState state = stateManager.player(player.getUniqueId(), player.getName());
|
||||
if (state.baseLevel() < 1) {
|
||||
player.sendMessage(ChatColor.RED + "Base I is locked. Break grass blocks or dirt to unlock it.");
|
||||
return true;
|
||||
}
|
||||
Instant now = clock.instant();
|
||||
Optional<Duration> remaining = baseService.relocationRemaining(state, now);
|
||||
if (remaining.isPresent()) {
|
||||
player.sendMessage(ChatColor.RED + "You can move your base again in "
|
||||
+ DurationFormatter.friendly(remaining.orElseThrow()) + ".");
|
||||
return true;
|
||||
}
|
||||
Location location = player.getLocation();
|
||||
World world = location.getWorld();
|
||||
if (world == null) {
|
||||
player.sendMessage(ChatColor.RED + "Your current world is unavailable.");
|
||||
return true;
|
||||
}
|
||||
BaseLocation base = new BaseLocation(
|
||||
world.getUID(), world.getName(), location.getBlockX(), location.getBlockY(),
|
||||
location.getBlockZ(), location.getYaw(), location.getPitch()
|
||||
);
|
||||
stateManager.update(
|
||||
player.getUniqueId(), player.getName(), current -> baseService.setBase(current, base, now)
|
||||
);
|
||||
stateManager.saveIfDirty();
|
||||
player.sendMessage(ChatColor.GREEN + "Base set at " + base.x() + ", " + base.y() + ", " + base.z() + ".");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package games.dmg.spigotbase;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Clock;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.logging.Level;
|
||||
import org.bukkit.command.PluginCommand;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
|
||||
public final class SpigotBasePlugin extends JavaPlugin {
|
||||
private PluginSettings settings;
|
||||
private BaseStateManager stateManager;
|
||||
private BaseProgressListener progressListener;
|
||||
private BaseFlightController flightController;
|
||||
private BaseTeleportManager teleportManager;
|
||||
|
||||
@Override
|
||||
public void onEnable() {
|
||||
saveDefaultConfig();
|
||||
try {
|
||||
Map<String, Object> values = getConfig().getValues(false);
|
||||
settings = PluginSettings.from(values);
|
||||
stateManager = new BaseStateManager(
|
||||
new YamlBaseStateRepository(getDataFolder().toPath().resolve("state.yml")),
|
||||
getLogger()
|
||||
);
|
||||
} catch (IllegalArgumentException | IOException exception) {
|
||||
getLogger().log(Level.SEVERE, "Could not initialize Spigot Base", exception);
|
||||
getServer().getPluginManager().disablePlugin(this);
|
||||
return;
|
||||
}
|
||||
|
||||
BaseService baseService = new BaseService(settings);
|
||||
BaseProgressionService progressionService = new BaseProgressionService(settings);
|
||||
SecondaryProgressionService secondaryProgressionService =
|
||||
new SecondaryProgressionService(settings);
|
||||
TeleportProgressionService teleportProgressionService =
|
||||
new TeleportProgressionService(settings);
|
||||
BaseBoundsService boundsService = new BaseBoundsService(settings);
|
||||
progressListener = new BaseProgressListener(
|
||||
this,
|
||||
stateManager,
|
||||
progressionService,
|
||||
secondaryProgressionService,
|
||||
teleportProgressionService,
|
||||
boundsService,
|
||||
settings
|
||||
);
|
||||
flightController = new BaseFlightController(
|
||||
getServer(), stateManager, secondaryProgressionService, boundsService, settings
|
||||
);
|
||||
VisitorPolicy visitorPolicy = new VisitorPolicy();
|
||||
teleportManager = new BaseTeleportManager(
|
||||
this,
|
||||
stateManager,
|
||||
new TeleportPolicy(settings),
|
||||
visitorPolicy,
|
||||
new SafeBaseDestination(),
|
||||
Clock.systemUTC()
|
||||
);
|
||||
getServer().getPluginManager().registerEvents(progressListener, this);
|
||||
getServer().getPluginManager().registerEvents(teleportManager, this);
|
||||
|
||||
command("setbase").setExecutor(new SetBaseCommand(stateManager, baseService, Clock.systemUTC()));
|
||||
command("base").setExecutor(
|
||||
new BaseCommand(teleportManager, stateManager, visitorPolicy, settings)
|
||||
);
|
||||
command("baseprogress").setExecutor(new BaseProgressCommand(stateManager, settings));
|
||||
command("basenavigation").setExecutor(new BaseNavigationCommand(stateManager));
|
||||
command("baseflight").setExecutor(new BaseFlightCommand(stateManager, flightController));
|
||||
command("basevisitors").setExecutor(new BaseVisitorsCommand(stateManager));
|
||||
GoToBaseCommand goToBaseCommand = new GoToBaseCommand(stateManager, teleportManager);
|
||||
command("gotobase").setExecutor(goToBaseCommand);
|
||||
command("gotobase").setTabCompleter(goToBaseCommand);
|
||||
command("baseadmin").setExecutor(
|
||||
new BaseAdminCommand(stateManager, new AdminProgressionService())
|
||||
);
|
||||
|
||||
getServer().getScheduler().runTaskTimer(
|
||||
this, new BaseNavigationController(getServer(), stateManager), 10L, 10L
|
||||
);
|
||||
getServer().getScheduler().runTaskTimer(this, flightController, 5L, 5L);
|
||||
getServer().getScheduler().runTaskTimer(this, stateManager::saveIfDirty, 600L, 600L);
|
||||
getLogger().info("Spigot Base enabled.");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDisable() {
|
||||
if (progressListener != null) {
|
||||
progressListener.removeAllBossBars();
|
||||
}
|
||||
if (flightController != null) {
|
||||
flightController.removeAllGrantedFlight();
|
||||
}
|
||||
if (teleportManager != null) {
|
||||
teleportManager.cancelAll();
|
||||
}
|
||||
if (stateManager != null) {
|
||||
stateManager.saveIfDirty();
|
||||
}
|
||||
}
|
||||
|
||||
PluginSettings settings() {
|
||||
if (settings == null) {
|
||||
throw new IllegalStateException("Plugin settings are unavailable");
|
||||
}
|
||||
return settings;
|
||||
}
|
||||
|
||||
private PluginCommand command(String name) {
|
||||
return Objects.requireNonNull(getCommand(name), "Missing command metadata for " + name);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package games.dmg.spigotbase;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Optional;
|
||||
|
||||
public final class TeleportPolicy {
|
||||
private final PluginSettings settings;
|
||||
|
||||
public TeleportPolicy(PluginSettings settings) {
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
public Duration warmup(PlayerState player) {
|
||||
return switch (player.warmupLevel()) {
|
||||
case 0 -> Duration.ofSeconds(settings.initialWarmupSeconds());
|
||||
case 1 -> Duration.ofSeconds(settings.secondWarmupSeconds());
|
||||
case 2 -> Duration.ofSeconds(settings.thirdWarmupSeconds());
|
||||
case 3 -> Duration.ZERO;
|
||||
default -> throw new IllegalArgumentException("unknown warm-up level");
|
||||
};
|
||||
}
|
||||
|
||||
public Duration cooldown(PlayerState player) {
|
||||
return switch (player.cooldownLevel()) {
|
||||
case 0 -> Duration.ofSeconds(settings.initialTeleportCooldownSeconds());
|
||||
case 1 -> Duration.ofSeconds(settings.secondTeleportCooldownSeconds());
|
||||
case 2 -> Duration.ofSeconds(settings.thirdTeleportCooldownSeconds());
|
||||
case 3 -> Duration.ofSeconds(settings.fourthTeleportCooldownSeconds());
|
||||
case 4 -> Duration.ZERO;
|
||||
default -> throw new IllegalArgumentException("unknown cooldown level");
|
||||
};
|
||||
}
|
||||
|
||||
public Optional<Duration> remainingCooldown(PlayerState player, Instant now) {
|
||||
if (player.lastBaseTeleport().isEmpty()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
Instant availableAt = player.lastBaseTeleport().orElseThrow().plus(cooldown(player));
|
||||
if (!now.isBefore(availableAt)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return Optional.of(Duration.between(now, availableAt));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package games.dmg.spigotbase;
|
||||
|
||||
public final class TeleportProgressionService {
|
||||
private final PluginSettings settings;
|
||||
|
||||
public TeleportProgressionService(PluginSettings settings) {
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
public ProgressionUpdate recordPlacement(PlayerState player) {
|
||||
if (player.baseLevel() < 2 || player.base().isEmpty()) {
|
||||
return ProgressionUpdate.unchanged(player);
|
||||
}
|
||||
long placements = increment(player.blocksPlacedInBase());
|
||||
int baseLevel = player.baseLevel();
|
||||
if (baseLevel == 2 && placements >= settings.teleportUnlockPlacements()) {
|
||||
baseLevel = 3;
|
||||
}
|
||||
int warmupLevel = baseLevel >= 3 ? warmupLevel(placements) : 0;
|
||||
return new ProgressionUpdate(
|
||||
player.withTeleportProgress(
|
||||
placements,
|
||||
player.blocksBrokenInBase(),
|
||||
warmupLevel,
|
||||
player.cooldownLevel(),
|
||||
baseLevel
|
||||
),
|
||||
baseLevel > player.baseLevel(),
|
||||
false,
|
||||
false,
|
||||
warmupLevel > player.warmupLevel(),
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
public ProgressionUpdate recordBreak(PlayerState player) {
|
||||
if (player.baseLevel() < 3 || player.base().isEmpty()) {
|
||||
return ProgressionUpdate.unchanged(player);
|
||||
}
|
||||
long breaks = increment(player.blocksBrokenInBase());
|
||||
int cooldownLevel = cooldownLevel(breaks);
|
||||
return new ProgressionUpdate(
|
||||
player.withTeleportProgress(
|
||||
player.blocksPlacedInBase(),
|
||||
breaks,
|
||||
player.warmupLevel(),
|
||||
cooldownLevel,
|
||||
player.baseLevel()
|
||||
),
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
cooldownLevel > player.cooldownLevel()
|
||||
);
|
||||
}
|
||||
|
||||
private int warmupLevel(long placements) {
|
||||
if (placements >= settings.instantWarmupPlacements()) {
|
||||
return 3;
|
||||
}
|
||||
if (placements >= settings.thirdWarmupPlacements()) {
|
||||
return 2;
|
||||
}
|
||||
if (placements >= settings.secondWarmupPlacements()) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private int cooldownLevel(long breaks) {
|
||||
if (breaks >= settings.instantCooldownBreaks()) {
|
||||
return 4;
|
||||
}
|
||||
if (breaks >= settings.thirdCooldownBreaks()) {
|
||||
return 3;
|
||||
}
|
||||
if (breaks >= settings.secondCooldownBreaks()) {
|
||||
return 2;
|
||||
}
|
||||
if (breaks >= settings.firstCooldownBreaks()) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static long increment(long value) {
|
||||
return value == Long.MAX_VALUE ? Long.MAX_VALUE : value + 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package games.dmg.spigotbase;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
public final class VisitorPolicy {
|
||||
public boolean canPurchase(PlayerState owner) {
|
||||
return owner.baseLevel() == 3 && owner.base().isPresent();
|
||||
}
|
||||
|
||||
public Optional<Duration> remaining(PlayerState visitor, UUID ownerId, Instant now) {
|
||||
Instant availableAt = visitor.visitorCooldownUntil().get(ownerId);
|
||||
if (availableAt == null || !now.isBefore(availableAt)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return Optional.of(Duration.between(now, availableAt));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
package games.dmg.spigotbase;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.time.Instant;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.configuration.ConfigurationSection;
|
||||
import org.bukkit.configuration.InvalidConfigurationException;
|
||||
import org.bukkit.configuration.file.YamlConfiguration;
|
||||
|
||||
public final class YamlBaseStateRepository {
|
||||
private final Path stateFile;
|
||||
|
||||
public YamlBaseStateRepository(Path stateFile) {
|
||||
this.stateFile = stateFile;
|
||||
}
|
||||
|
||||
public PersistentState load() throws IOException {
|
||||
if (!Files.exists(stateFile)) {
|
||||
return PersistentState.empty();
|
||||
}
|
||||
YamlConfiguration yaml = new YamlConfiguration();
|
||||
try {
|
||||
yaml.load(stateFile.toFile());
|
||||
} catch (InvalidConfigurationException exception) {
|
||||
throw new IOException("state file is not valid YAML", exception);
|
||||
}
|
||||
return new PersistentState(loadPlayers(yaml));
|
||||
}
|
||||
|
||||
public void save(PersistentState state) throws IOException {
|
||||
Path parent = stateFile.toAbsolutePath().getParent();
|
||||
if (parent != null) {
|
||||
Files.createDirectories(parent);
|
||||
}
|
||||
YamlConfiguration yaml = new YamlConfiguration();
|
||||
savePlayers(yaml, state.players());
|
||||
|
||||
Path temporary = Files.createTempFile(parent, "spigot-base-state-", ".yml");
|
||||
try {
|
||||
yaml.save(temporary.toFile());
|
||||
try {
|
||||
Files.move(
|
||||
temporary,
|
||||
stateFile,
|
||||
StandardCopyOption.REPLACE_EXISTING,
|
||||
StandardCopyOption.ATOMIC_MOVE
|
||||
);
|
||||
} catch (IOException atomicMoveFailure) {
|
||||
Files.move(temporary, stateFile, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
} finally {
|
||||
Files.deleteIfExists(temporary);
|
||||
}
|
||||
}
|
||||
|
||||
private static Map<UUID, PlayerState> loadPlayers(YamlConfiguration yaml) {
|
||||
Map<UUID, PlayerState> players = new HashMap<>();
|
||||
ConfigurationSection section = yaml.getConfigurationSection("players");
|
||||
if (section == null) {
|
||||
return players;
|
||||
}
|
||||
for (String key : section.getKeys(false)) {
|
||||
try {
|
||||
UUID playerId = UUID.fromString(key);
|
||||
String path = "players." + key;
|
||||
String name = yaml.getString(path + ".name");
|
||||
if (name == null || name.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
PlayerState player = new PlayerState(
|
||||
playerId,
|
||||
name,
|
||||
loadBase(yaml, path + ".base"),
|
||||
level(yaml, path + ".base-level"),
|
||||
level(yaml, path + ".size-level"),
|
||||
level(yaml, path + ".flight-level"),
|
||||
level(yaml, path + ".warmup-level"),
|
||||
level(yaml, path + ".cooldown-level"),
|
||||
count(yaml, path + ".grass-and-dirt-broken"),
|
||||
count(yaml, path + ".stone-broken"),
|
||||
count(yaml, path + ".deepslate-broken"),
|
||||
count(yaml, path + ".obsidian-broken"),
|
||||
count(yaml, path + ".blocks-placed-in-base"),
|
||||
count(yaml, path + ".blocks-broken-in-base"),
|
||||
yaml.getBoolean(path + ".navigation-enabled", false),
|
||||
yaml.getBoolean(path + ".flight-enabled", false),
|
||||
yaml.getBoolean(path + ".boss-bar-enabled", true),
|
||||
yaml.getBoolean(path + ".visitors-enabled", false),
|
||||
instant(yaml, path + ".last-base-set-epoch-millis"),
|
||||
instant(yaml, path + ".last-base-teleport-epoch-millis"),
|
||||
loadVisitorCooldowns(yaml, path + ".visitor-cooldowns")
|
||||
);
|
||||
players.put(playerId, player);
|
||||
} catch (IllegalArgumentException ignored) {
|
||||
// Invalid records are ignored rather than granting progression.
|
||||
}
|
||||
}
|
||||
return players;
|
||||
}
|
||||
|
||||
private static Optional<BaseLocation> loadBase(YamlConfiguration yaml, String path) {
|
||||
if (!yaml.isConfigurationSection(path)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
String worldId = yaml.getString(path + ".world-id");
|
||||
String worldName = yaml.getString(path + ".world-name");
|
||||
if (worldId == null || worldName == null
|
||||
|| !yaml.isInt(path + ".x") || !yaml.isInt(path + ".y") || !yaml.isInt(path + ".z")
|
||||
|| !yaml.isDouble(path + ".yaw") || !yaml.isDouble(path + ".pitch")) {
|
||||
throw new IllegalArgumentException("invalid base location");
|
||||
}
|
||||
return Optional.of(new BaseLocation(
|
||||
UUID.fromString(worldId),
|
||||
worldName,
|
||||
yaml.getInt(path + ".x"),
|
||||
yaml.getInt(path + ".y"),
|
||||
yaml.getInt(path + ".z"),
|
||||
(float) yaml.getDouble(path + ".yaw"),
|
||||
(float) yaml.getDouble(path + ".pitch")
|
||||
));
|
||||
}
|
||||
|
||||
private static Map<UUID, Instant> loadVisitorCooldowns(YamlConfiguration yaml, String path) {
|
||||
Map<UUID, Instant> cooldowns = new HashMap<>();
|
||||
ConfigurationSection section = yaml.getConfigurationSection(path);
|
||||
if (section == null) {
|
||||
return cooldowns;
|
||||
}
|
||||
for (String key : section.getKeys(false)) {
|
||||
try {
|
||||
if (yaml.isLong(path + "." + key)) {
|
||||
long millis = yaml.getLong(path + "." + key);
|
||||
if (millis >= 0) {
|
||||
cooldowns.put(UUID.fromString(key), Instant.ofEpochMilli(millis));
|
||||
}
|
||||
}
|
||||
} catch (IllegalArgumentException ignored) {
|
||||
// Invalid destination cooldowns grant no state.
|
||||
}
|
||||
}
|
||||
return cooldowns;
|
||||
}
|
||||
|
||||
private static int level(YamlConfiguration yaml, String path) {
|
||||
if (!yaml.isInt(path)) {
|
||||
return 0;
|
||||
}
|
||||
return yaml.getInt(path);
|
||||
}
|
||||
|
||||
private static long count(YamlConfiguration yaml, String path) {
|
||||
if (!yaml.isLong(path) && !yaml.isInt(path)) {
|
||||
return 0;
|
||||
}
|
||||
return yaml.getLong(path);
|
||||
}
|
||||
|
||||
private static Optional<Instant> instant(YamlConfiguration yaml, String path) {
|
||||
if (!yaml.isLong(path) && !yaml.isInt(path)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
long millis = yaml.getLong(path);
|
||||
return millis < 0 ? Optional.empty() : Optional.of(Instant.ofEpochMilli(millis));
|
||||
}
|
||||
|
||||
private static void savePlayers(YamlConfiguration yaml, Map<UUID, PlayerState> players) {
|
||||
for (PlayerState player : players.values()) {
|
||||
String path = "players." + player.playerId();
|
||||
yaml.set(path + ".name", player.latestName());
|
||||
player.base().ifPresent(base -> saveBase(yaml, path + ".base", base));
|
||||
yaml.set(path + ".base-level", player.baseLevel());
|
||||
yaml.set(path + ".size-level", player.sizeLevel());
|
||||
yaml.set(path + ".flight-level", player.flightLevel());
|
||||
yaml.set(path + ".warmup-level", player.warmupLevel());
|
||||
yaml.set(path + ".cooldown-level", player.cooldownLevel());
|
||||
yaml.set(path + ".grass-and-dirt-broken", player.grassAndDirtBroken());
|
||||
yaml.set(path + ".stone-broken", player.stoneBroken());
|
||||
yaml.set(path + ".deepslate-broken", player.deepslateBroken());
|
||||
yaml.set(path + ".obsidian-broken", player.obsidianBroken());
|
||||
yaml.set(path + ".blocks-placed-in-base", player.blocksPlacedInBase());
|
||||
yaml.set(path + ".blocks-broken-in-base", player.blocksBrokenInBase());
|
||||
yaml.set(path + ".navigation-enabled", player.navigationEnabled());
|
||||
yaml.set(path + ".flight-enabled", player.flightEnabled());
|
||||
yaml.set(path + ".boss-bar-enabled", player.bossBarEnabled());
|
||||
yaml.set(path + ".visitors-enabled", player.visitorsEnabled());
|
||||
yaml.set(
|
||||
path + ".last-base-set-epoch-millis",
|
||||
player.lastBaseSet().map(Instant::toEpochMilli).orElse(null)
|
||||
);
|
||||
yaml.set(
|
||||
path + ".last-base-teleport-epoch-millis",
|
||||
player.lastBaseTeleport().map(Instant::toEpochMilli).orElse(null)
|
||||
);
|
||||
for (Map.Entry<UUID, Instant> cooldown : player.visitorCooldownUntil().entrySet()) {
|
||||
yaml.set(
|
||||
path + ".visitor-cooldowns." + cooldown.getKey(),
|
||||
cooldown.getValue().toEpochMilli()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void saveBase(YamlConfiguration yaml, String path, BaseLocation base) {
|
||||
yaml.set(path + ".world-id", base.worldId().toString());
|
||||
yaml.set(path + ".world-name", base.worldName());
|
||||
yaml.set(path + ".x", base.x());
|
||||
yaml.set(path + ".y", base.y());
|
||||
yaml.set(path + ".z", base.z());
|
||||
yaml.set(path + ".yaw", base.yaw());
|
||||
yaml.set(path + ".pitch", base.pitch());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
# Core Base progression
|
||||
base-unlock-blocks: 250
|
||||
navigation-unlock-blocks: 500
|
||||
initial-radius: 10
|
||||
initial-vertical-range: 25
|
||||
relocation-cooldown-seconds: 86400
|
||||
|
||||
# Base size
|
||||
stone-expansion-blocks: 500
|
||||
deepslate-expansion-blocks: 1000
|
||||
obsidian-expansion-blocks: 1000
|
||||
first-expanded-radius: 25
|
||||
second-expanded-radius: 75
|
||||
third-expanded-radius: 150
|
||||
|
||||
# Flight
|
||||
flight-warning-buffer: 5
|
||||
second-flight-vertical-range: 100
|
||||
|
||||
# Base III teleportation and warm-up progression
|
||||
teleport-unlock-placements: 200
|
||||
second-warmup-placements: 1000
|
||||
third-warmup-placements: 2000
|
||||
instant-warmup-placements: 12000
|
||||
initial-warmup-seconds: 30
|
||||
second-warmup-seconds: 15
|
||||
third-warmup-seconds: 5
|
||||
|
||||
# Teleport cooldown progression
|
||||
first-cooldown-breaks: 1000
|
||||
second-cooldown-breaks: 2000
|
||||
third-cooldown-breaks: 3000
|
||||
instant-cooldown-breaks: 5000
|
||||
initial-teleport-cooldown-seconds: 10800
|
||||
second-teleport-cooldown-seconds: 7200
|
||||
third-teleport-cooldown-seconds: 3600
|
||||
fourth-teleport-cooldown-seconds: 1800
|
||||
|
||||
# Base IV
|
||||
visitor-unlock-diamond-cost: 128
|
||||
@@ -0,0 +1,36 @@
|
||||
name: SpigotBase
|
||||
version: ${version}
|
||||
main: games.dmg.spigotbase.SpigotBasePlugin
|
||||
api-version: "1.20"
|
||||
description: Progression-gated player bases and quality-of-life unlocks.
|
||||
author: dmg.games
|
||||
commands:
|
||||
setbase:
|
||||
description: Set your unlocked personal base.
|
||||
usage: /setbase
|
||||
base:
|
||||
description: Teleport to or upgrade your base.
|
||||
usage: /base [upgrade]
|
||||
basenavigation:
|
||||
description: Toggle particle navigation toward your base.
|
||||
usage: /basenavigation
|
||||
baseflight:
|
||||
description: Toggle flight within your base.
|
||||
usage: /baseflight
|
||||
basevisitors:
|
||||
description: Toggle visitor access to your base.
|
||||
usage: /basevisitors
|
||||
gotobase:
|
||||
description: Visit an available player base.
|
||||
usage: /gotobase <player>
|
||||
baseprogress:
|
||||
description: View progression or toggle progress boss bars.
|
||||
usage: /baseprogress [bossbar]
|
||||
baseadmin:
|
||||
description: Administer Spigot Base.
|
||||
usage: /baseadmin <subcommand>
|
||||
permission: spigotbase.admin
|
||||
permissions:
|
||||
spigotbase.admin:
|
||||
description: Allows administration of Spigot Base.
|
||||
default: op
|
||||
@@ -0,0 +1,48 @@
|
||||
package games.dmg.spigotbase;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
final class AdminProgressionServiceTest {
|
||||
private final AdminProgressionService service = new AdminProgressionService();
|
||||
|
||||
@Test
|
||||
void settingTeleportUpgradeGrantsBasePrerequisites() {
|
||||
PlayerState player = PlayerState.newPlayer(UUID.randomUUID(), "Alex");
|
||||
|
||||
PlayerState updated = service.setLevel(player, ProgressionPath.WARMUP, 2);
|
||||
|
||||
assertEquals(3, updated.baseLevel());
|
||||
assertEquals(2, updated.warmupLevel());
|
||||
}
|
||||
|
||||
@Test
|
||||
void loweringBaseRemovesDependentBenefits() {
|
||||
PlayerState player = PlayerState.newPlayer(UUID.randomUUID(), "Alex")
|
||||
.withAdministrativeLevels(4, 3, 3, 3, 4, true, true, true);
|
||||
|
||||
PlayerState updated = service.setLevel(player, ProgressionPath.BASE, 0);
|
||||
|
||||
assertEquals(0, updated.sizeLevel());
|
||||
assertEquals(0, updated.flightLevel());
|
||||
assertEquals(0, updated.warmupLevel());
|
||||
assertEquals(0, updated.cooldownLevel());
|
||||
assertFalse(updated.navigationEnabled());
|
||||
assertFalse(updated.flightEnabled());
|
||||
assertFalse(updated.visitorsEnabled());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsUnknownLevel() {
|
||||
PlayerState player = PlayerState.newPlayer(UUID.randomUUID(), "Alex");
|
||||
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> service.setLevel(player, ProgressionPath.FLIGHT, 4)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package games.dmg.spigotbase;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
final class BaseAreaTest {
|
||||
@Test
|
||||
void usesCircularHorizontalAndSymmetricVerticalBounds() {
|
||||
UUID worldId = UUID.randomUUID();
|
||||
BaseArea area = new BaseArea(
|
||||
new BaseLocation(worldId, "world", 0, 64, 0, 0, 0),
|
||||
10,
|
||||
25
|
||||
);
|
||||
|
||||
assertTrue(area.contains(worldId, 6, 89, 8));
|
||||
assertTrue(area.contains(worldId, 6, 39, 8));
|
||||
assertFalse(area.contains(worldId, 10, 64, 10));
|
||||
assertFalse(area.contains(worldId, 0, 90, 0));
|
||||
assertFalse(area.contains(UUID.randomUUID(), 0, 64, 0));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package games.dmg.spigotbase;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
final class BaseBoundsServiceTest {
|
||||
private final BaseBoundsService service = new BaseBoundsService(PluginSettings.from(Map.of()));
|
||||
|
||||
@Test
|
||||
void sizeTiersSelectConfiguredRadius() {
|
||||
PlayerState player = PlayerState.newPlayer(UUID.randomUUID(), "Alex")
|
||||
.withGrassAndDirtProgress(250, 1);
|
||||
|
||||
assertEquals(10, service.radius(player));
|
||||
assertEquals(25, service.radius(player.withSizeProgress(500, 0, 0, 1)));
|
||||
assertEquals(75, service.radius(player.withSizeProgress(500, 1_000, 0, 2)));
|
||||
assertEquals(150, service.radius(player.withSizeProgress(500, 1_000, 1_000, 3)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void flightTiersSelectSymmetricVerticalRange() {
|
||||
PlayerState player = PlayerState.newPlayer(UUID.randomUUID(), "Alex")
|
||||
.withGrassAndDirtProgress(250, 1);
|
||||
|
||||
assertEquals(25, service.verticalRange(player));
|
||||
assertEquals(25, service.verticalRange(player.withFlightLevel(1, true)));
|
||||
assertEquals(100, service.verticalRange(player.withFlightLevel(2, true)));
|
||||
assertEquals(Integer.MAX_VALUE, service.verticalRange(player.withFlightLevel(3, true)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package games.dmg.spigotbase;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
final class BaseNavigationProgressionTest {
|
||||
@Test
|
||||
void fiveHundredthGrassOrDirtBlockUnlocksBaseII() {
|
||||
BaseProgressionService service =
|
||||
new BaseProgressionService(PluginSettings.from(Map.of()));
|
||||
PlayerState player = PlayerState.newPlayer(UUID.randomUUID(), "Alex")
|
||||
.withGrassAndDirtProgress(499, 1);
|
||||
|
||||
ProgressionUpdate update = service.recordGrassOrDirtBreak(player);
|
||||
|
||||
assertEquals(2, update.player().baseLevel());
|
||||
assertTrue(update.player().navigationEnabled());
|
||||
assertTrue(update.unlockedBaseLevel());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package games.dmg.spigotbase;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
final class BaseProgressionServiceTest {
|
||||
private final PluginSettings settings = PluginSettings.from(java.util.Map.of());
|
||||
private final BaseProgressionService service = new BaseProgressionService(settings);
|
||||
|
||||
@Test
|
||||
void qualifyingBreakIncrementsProgress() {
|
||||
PlayerState initial = PlayerState.newPlayer(UUID.randomUUID(), "Alex");
|
||||
|
||||
ProgressionUpdate update = service.recordGrassOrDirtBreak(initial);
|
||||
|
||||
assertEquals(1, update.player().grassAndDirtBroken());
|
||||
assertEquals(0, update.player().baseLevel());
|
||||
assertFalse(update.unlockedBaseLevel());
|
||||
}
|
||||
|
||||
@Test
|
||||
void thresholdUnlocksBaseIExactlyOnce() {
|
||||
PlayerState initial = PlayerState.newPlayer(UUID.randomUUID(), "Alex")
|
||||
.withGrassAndDirtProgress(249, 0);
|
||||
|
||||
ProgressionUpdate unlocked = service.recordGrassOrDirtBreak(initial);
|
||||
ProgressionUpdate later = service.recordGrassOrDirtBreak(unlocked.player());
|
||||
|
||||
assertEquals(1, unlocked.player().baseLevel());
|
||||
assertTrue(unlocked.unlockedBaseLevel());
|
||||
assertEquals(1, later.player().baseLevel());
|
||||
assertFalse(later.unlockedBaseLevel());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package games.dmg.spigotbase;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
final class BaseServiceTest {
|
||||
private final PluginSettings settings = PluginSettings.from(Map.of());
|
||||
private final BaseService service = new BaseService(settings);
|
||||
|
||||
@Test
|
||||
void firstBaseCanBeSetImmediately() {
|
||||
PlayerState player = PlayerState.newPlayer(UUID.randomUUID(), "Alex")
|
||||
.withGrassAndDirtProgress(250, 1);
|
||||
|
||||
assertTrue(service.canSetBase(player, Instant.EPOCH));
|
||||
}
|
||||
|
||||
@Test
|
||||
void relocationUsesElapsedTwentyFourHours() {
|
||||
Instant firstSet = Instant.parse("2026-08-09T12:00:00Z");
|
||||
PlayerState player = PlayerState.newPlayer(UUID.randomUUID(), "Alex")
|
||||
.withGrassAndDirtProgress(250, 1)
|
||||
.withBase(
|
||||
new BaseLocation(UUID.randomUUID(), "world", 0, 64, 0, 0, 0),
|
||||
firstSet
|
||||
);
|
||||
|
||||
assertFalse(service.canSetBase(player, firstSet.plus(Duration.ofHours(23))));
|
||||
assertEquals(
|
||||
Duration.ofHours(1),
|
||||
service.relocationRemaining(player, firstSet.plus(Duration.ofHours(23))).orElseThrow()
|
||||
);
|
||||
assertTrue(service.canSetBase(player, firstSet.plus(Duration.ofHours(24))));
|
||||
}
|
||||
|
||||
@Test
|
||||
void lockedPlayerCannotSetBase() {
|
||||
PlayerState player = PlayerState.newPlayer(UUID.randomUUID(), "Alex");
|
||||
|
||||
assertFalse(service.canSetBase(player, Instant.EPOCH));
|
||||
assertEquals(Optional.empty(), service.relocationRemaining(player, Instant.EPOCH));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package games.dmg.spigotbase;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.UUID;
|
||||
import java.util.logging.Logger;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
final class BaseStateManagerTest {
|
||||
@TempDir
|
||||
Path temporaryDirectory;
|
||||
|
||||
@Test
|
||||
void durableUpdateIsImmediatelyReadableFromRepository() throws Exception {
|
||||
Path stateFile = temporaryDirectory.resolve("state.yml");
|
||||
BaseStateManager manager = new BaseStateManager(
|
||||
new YamlBaseStateRepository(stateFile), Logger.getAnonymousLogger()
|
||||
);
|
||||
UUID playerId = UUID.randomUUID();
|
||||
|
||||
manager.updateAndSave(
|
||||
playerId,
|
||||
"Alex",
|
||||
player -> player.withGrassAndDirtProgress(250, 1)
|
||||
);
|
||||
|
||||
PlayerState loaded = new YamlBaseStateRepository(stateFile).load().players().get(playerId);
|
||||
assertEquals(1, loaded.baseLevel());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package games.dmg.spigotbase;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
final class PlayerStateTest {
|
||||
@Test
|
||||
void rejectsLevelsOutsideKnownRanges() {
|
||||
UUID playerId = UUID.randomUUID();
|
||||
|
||||
assertThrows(IllegalArgumentException.class, () -> new PlayerState(
|
||||
playerId, "Alex", Optional.empty(), 5, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0,
|
||||
false, false, true, false, Optional.empty(), Optional.empty(), Map.of()
|
||||
));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsBenefitsWhosePrerequisitesAreMissing() {
|
||||
UUID playerId = UUID.randomUUID();
|
||||
|
||||
assertThrows(IllegalArgumentException.class, () -> new PlayerState(
|
||||
playerId, "Alex", Optional.empty(), 0, 1, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0,
|
||||
false, false, true, false, Optional.empty(), Optional.empty(), Map.of()
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package games.dmg.spigotbase;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.util.Map;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
|
||||
final class PluginMetadataTest {
|
||||
@Test
|
||||
void declaresPluginEntrypointCommandsAndPermissions() {
|
||||
InputStream stream = getClass().getClassLoader().getResourceAsStream("plugin.yml");
|
||||
assertNotNull(stream);
|
||||
|
||||
Map<?, ?> plugin = new Yaml().load(stream);
|
||||
assertEquals("SpigotBase", plugin.get("name"));
|
||||
assertEquals("games.dmg.spigotbase.SpigotBasePlugin", plugin.get("main"));
|
||||
assertEquals("1.20", plugin.get("api-version"));
|
||||
|
||||
Map<?, ?> commands = (Map<?, ?>) plugin.get("commands");
|
||||
for (String command : new String[] {
|
||||
"setbase", "base", "basenavigation", "baseflight",
|
||||
"basevisitors", "gotobase", "baseprogress", "baseadmin"
|
||||
}) {
|
||||
assertNotNull(commands.get(command), command);
|
||||
}
|
||||
|
||||
Map<?, ?> permissions = (Map<?, ?>) plugin.get("permissions");
|
||||
assertNotNull(permissions.get("spigotbase.admin"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package games.dmg.spigotbase;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
import java.util.Map;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
final class PluginSettingsTest {
|
||||
@Test
|
||||
void defaultsMatchApprovedProgression() {
|
||||
PluginSettings settings = PluginSettings.from(Map.of());
|
||||
|
||||
assertEquals(250, settings.baseUnlockBlocks());
|
||||
assertEquals(500, settings.navigationUnlockBlocks());
|
||||
assertEquals(10, settings.initialRadius());
|
||||
assertEquals(25, settings.initialVerticalRange());
|
||||
assertEquals(86_400L, settings.relocationCooldownSeconds());
|
||||
assertEquals(5, settings.flightWarningBuffer());
|
||||
assertEquals(200, settings.teleportUnlockPlacements());
|
||||
assertEquals(10_800L, settings.initialTeleportCooldownSeconds());
|
||||
assertEquals(128, settings.visitorUnlockDiamondCost());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsNavigationThresholdBeforeBaseThreshold() {
|
||||
Map<String, Object> values = Map.of(
|
||||
"base-unlock-blocks", 500,
|
||||
"navigation-unlock-blocks", 250
|
||||
);
|
||||
|
||||
assertThrows(IllegalArgumentException.class, () -> PluginSettings.from(values));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsNegativeDurationsAndDistances() {
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> PluginSettings.from(Map.of("relocation-cooldown-seconds", -1)));
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> PluginSettings.from(Map.of("initial-radius", 0)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package games.dmg.spigotbase;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
final class SecondaryProgressionServiceTest {
|
||||
private final PluginSettings settings = PluginSettings.from(Map.of());
|
||||
private final SecondaryProgressionService service = new SecondaryProgressionService(settings);
|
||||
|
||||
@Test
|
||||
void stoneUnlocksFirstSizeTierAfterBaseI() {
|
||||
PlayerState player = PlayerState.newPlayer(UUID.randomUUID(), "Alex")
|
||||
.withGrassAndDirtProgress(250, 1)
|
||||
.withSizeProgress(499, 0, 0, 0);
|
||||
|
||||
ProgressionUpdate update = service.recordStoneBreak(player);
|
||||
|
||||
assertEquals(1, update.player().sizeLevel());
|
||||
assertTrue(update.unlockedSizeLevel());
|
||||
}
|
||||
|
||||
@Test
|
||||
void laterMaterialsDoNotCountBeforeTheirTierIsActive() {
|
||||
PlayerState player = PlayerState.newPlayer(UUID.randomUUID(), "Alex")
|
||||
.withGrassAndDirtProgress(250, 1);
|
||||
|
||||
ProgressionUpdate update = service.recordDeepslateBreak(player);
|
||||
|
||||
assertEquals(0, update.player().deepslateBroken());
|
||||
assertFalse(update.unlockedSizeLevel());
|
||||
}
|
||||
|
||||
@Test
|
||||
void threeElytraUnlockAllFlightTiersWithoutConsumption() {
|
||||
PlayerState player = PlayerState.newPlayer(UUID.randomUUID(), "Alex")
|
||||
.withGrassAndDirtProgress(250, 1)
|
||||
.withBase(
|
||||
new BaseLocation(UUID.randomUUID(), "world", 0, 64, 0, 0, 0),
|
||||
java.time.Instant.EPOCH
|
||||
);
|
||||
|
||||
ProgressionUpdate update = service.observeElytraCount(player, 3);
|
||||
|
||||
assertEquals(3, update.player().flightLevel());
|
||||
assertTrue(update.player().flightEnabled());
|
||||
assertTrue(update.unlockedFlightLevel());
|
||||
}
|
||||
|
||||
@Test
|
||||
void secondaryProgressDoesNothingBeforeBaseI() {
|
||||
PlayerState player = PlayerState.newPlayer(UUID.randomUUID(), "Alex");
|
||||
|
||||
assertEquals(player, service.recordStoneBreak(player).player());
|
||||
assertEquals(player, service.observeElytraCount(player, 3).player());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package games.dmg.spigotbase;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
final class TeleportPolicyTest {
|
||||
private final TeleportPolicy policy = new TeleportPolicy(PluginSettings.from(Map.of()));
|
||||
|
||||
@Test
|
||||
void warmupLevelsUseApprovedDurations() {
|
||||
PlayerState player = baseThreePlayer();
|
||||
|
||||
assertEquals(Duration.ofSeconds(30), policy.warmup(player));
|
||||
assertEquals(Duration.ofSeconds(15), policy.warmup(player.withTeleportLevels(1, 0)));
|
||||
assertEquals(Duration.ofSeconds(5), policy.warmup(player.withTeleportLevels(2, 0)));
|
||||
assertEquals(Duration.ZERO, policy.warmup(player.withTeleportLevels(3, 0)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void cooldownLevelsUseApprovedDurations() {
|
||||
PlayerState player = baseThreePlayer();
|
||||
|
||||
assertEquals(Duration.ofHours(3), policy.cooldown(player));
|
||||
assertEquals(Duration.ofHours(2), policy.cooldown(player.withTeleportLevels(0, 1)));
|
||||
assertEquals(Duration.ofHours(1), policy.cooldown(player.withTeleportLevels(0, 2)));
|
||||
assertEquals(Duration.ofMinutes(30), policy.cooldown(player.withTeleportLevels(0, 3)));
|
||||
assertEquals(Duration.ZERO, policy.cooldown(player.withTeleportLevels(0, 4)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void remainingCooldownUsesElapsedTime() {
|
||||
Instant usedAt = Instant.parse("2026-08-09T10:00:00Z");
|
||||
PlayerState player = baseThreePlayer().withLastBaseTeleport(usedAt);
|
||||
|
||||
assertEquals(
|
||||
Duration.ofHours(1),
|
||||
policy.remainingCooldown(player, usedAt.plus(Duration.ofHours(2))).orElseThrow()
|
||||
);
|
||||
assertTrue(policy.remainingCooldown(player, usedAt.plus(Duration.ofHours(3))).isEmpty());
|
||||
}
|
||||
|
||||
private static PlayerState baseThreePlayer() {
|
||||
return PlayerState.newPlayer(UUID.randomUUID(), "Alex")
|
||||
.withGrassAndDirtProgress(500, 3);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package games.dmg.spigotbase;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
final class TeleportProgressionServiceTest {
|
||||
private final PluginSettings settings = PluginSettings.from(Map.of());
|
||||
private final TeleportProgressionService service = new TeleportProgressionService(settings);
|
||||
|
||||
@Test
|
||||
void twoHundredthPlacementUnlocksBaseIII() {
|
||||
PlayerState player = baseTwoPlayer().withTeleportProgress(199, 0, 0, 0, 2);
|
||||
|
||||
ProgressionUpdate update = service.recordPlacement(player);
|
||||
|
||||
assertEquals(3, update.player().baseLevel());
|
||||
assertEquals(200, update.player().blocksPlacedInBase());
|
||||
assertTrue(update.unlockedBaseLevel());
|
||||
}
|
||||
|
||||
@Test
|
||||
void cumulativePlacementsReduceWarmup() {
|
||||
PlayerState player = baseTwoPlayer().withTeleportProgress(999, 0, 0, 0, 3);
|
||||
|
||||
ProgressionUpdate update = service.recordPlacement(player);
|
||||
|
||||
assertEquals(1, update.player().warmupLevel());
|
||||
assertTrue(update.unlockedWarmupLevel());
|
||||
}
|
||||
|
||||
@Test
|
||||
void cumulativeBreaksReduceCooldownSequentially() {
|
||||
PlayerState player = baseTwoPlayer().withTeleportProgress(200, 999, 0, 0, 3);
|
||||
|
||||
ProgressionUpdate update = service.recordBreak(player);
|
||||
|
||||
assertEquals(1, update.player().cooldownLevel());
|
||||
assertEquals(1_000, update.player().blocksBrokenInBase());
|
||||
assertTrue(update.unlockedCooldownLevel());
|
||||
}
|
||||
|
||||
private static PlayerState baseTwoPlayer() {
|
||||
return PlayerState.newPlayer(UUID.randomUUID(), "Alex")
|
||||
.withGrassAndDirtProgress(500, 2)
|
||||
.withNavigationEnabled(true)
|
||||
.withBase(
|
||||
new BaseLocation(UUID.randomUUID(), "world", 0, 64, 0, 0, 0),
|
||||
Instant.EPOCH
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package games.dmg.spigotbase;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
final class VisitorPolicyTest {
|
||||
private final VisitorPolicy policy = new VisitorPolicy();
|
||||
|
||||
@Test
|
||||
void onlyBaseIIIPlayerCanPurchaseBaseIV() {
|
||||
PlayerState baseThree = PlayerState.newPlayer(UUID.randomUUID(), "Owner")
|
||||
.withGrassAndDirtProgress(500, 3)
|
||||
.withBase(
|
||||
new BaseLocation(UUID.randomUUID(), "world", 0, 64, 0, 0, 0),
|
||||
Instant.EPOCH
|
||||
);
|
||||
|
||||
assertTrue(policy.canPurchase(baseThree));
|
||||
assertFalse(policy.canPurchase(baseThree.withBaseLevel(4)));
|
||||
assertFalse(policy.canPurchase(
|
||||
PlayerState.newPlayer(UUID.randomUUID(), "Locked").withGrassAndDirtProgress(500, 2)
|
||||
));
|
||||
assertFalse(policy.canPurchase(
|
||||
PlayerState.newPlayer(UUID.randomUUID(), "Unset").withGrassAndDirtProgress(500, 3)
|
||||
));
|
||||
}
|
||||
|
||||
@Test
|
||||
void visitorCooldownIsIndependentForEachOwner() {
|
||||
UUID firstOwner = UUID.randomUUID();
|
||||
UUID secondOwner = UUID.randomUUID();
|
||||
Instant now = Instant.parse("2026-08-09T12:00:00Z");
|
||||
PlayerState visitor = PlayerState.newPlayer(UUID.randomUUID(), "Visitor")
|
||||
.withVisitorCooldown(firstOwner, now.plus(Duration.ofHours(2)));
|
||||
|
||||
assertTrue(policy.remaining(visitor, firstOwner, now).isPresent());
|
||||
assertTrue(policy.remaining(visitor, secondOwner, now).isEmpty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package games.dmg.spigotbase;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
final class YamlBaseStateRepositoryTest {
|
||||
@TempDir
|
||||
Path temporaryDirectory;
|
||||
|
||||
@Test
|
||||
void roundTripsCompletePlayerState() throws Exception {
|
||||
UUID playerId = UUID.randomUUID();
|
||||
UUID worldId = UUID.randomUUID();
|
||||
UUID ownerId = UUID.randomUUID();
|
||||
BaseLocation base = new BaseLocation(worldId, "world", 12, 70, -8, 90.0F, 5.0F);
|
||||
PlayerState player = new PlayerState(
|
||||
playerId, "Alex", Optional.of(base), 4, 3, 2, 3, 4,
|
||||
500, 500, 1_000, 1_000, 2_000, 3_000,
|
||||
true, true, false, true,
|
||||
Optional.of(Instant.ofEpochMilli(1_750_000_000_000L)),
|
||||
Optional.of(Instant.ofEpochMilli(1_750_000_100_000L)),
|
||||
Map.of(ownerId, Instant.ofEpochMilli(1_750_001_000_000L))
|
||||
);
|
||||
PersistentState expected = new PersistentState(Map.of(playerId, player));
|
||||
YamlBaseStateRepository repository =
|
||||
new YamlBaseStateRepository(temporaryDirectory.resolve("state.yml"));
|
||||
|
||||
repository.save(expected);
|
||||
|
||||
assertEquals(expected, repository.load());
|
||||
}
|
||||
|
||||
@Test
|
||||
void invalidRecordsCannotGrantProgression() throws Exception {
|
||||
UUID playerId = UUID.randomUUID();
|
||||
Path stateFile = temporaryDirectory.resolve("state.yml");
|
||||
Files.writeString(stateFile, """
|
||||
players:
|
||||
%s:
|
||||
name: Alex
|
||||
base-level: 99
|
||||
visitors-enabled: true
|
||||
""".formatted(playerId));
|
||||
|
||||
PersistentState loaded = new YamlBaseStateRepository(stateFile).load();
|
||||
|
||||
assertFalse(loaded.players().containsKey(playerId));
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingFileLoadsEmptyState() throws Exception {
|
||||
YamlBaseStateRepository repository =
|
||||
new YamlBaseStateRepository(temporaryDirectory.resolve("missing.yml"));
|
||||
|
||||
assertEquals(PersistentState.empty(), repository.load());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user