feat(base): implement progression system

This commit is contained in:
dmg
2026-08-09 15:01:44 -04:00
commit c87d923cad
76 changed files with 5092 additions and 0 deletions
+40
View File
@@ -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
+141
View File
@@ -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"
+7
View File
@@ -0,0 +1,7 @@
.gradle/
build/
out/
.idea/
*.iml
*.log
.DS_Store
+54
View File
@@ -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.
+46
View File
@@ -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)
}
}
+15
View File
@@ -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)
+29
View File
@@ -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 IIV 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.
+20
View File
@@ -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)
Vendored Executable
+248
View File
@@ -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" "$@"
+82
View File
@@ -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%
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+248
View File
@@ -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
View File
@@ -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%
+1
View File
@@ -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());
}
}
+40
View File
@@ -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
+36
View File
@@ -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());
}
}