commit c87d923cad0944964c5829ef410c0563a1f579c6 Author: Dylan Garvis Date: Sun Aug 9 15:01:44 2026 -0400 feat(base): implement progression system diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..325df2e --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -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 diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..a0dc591 --- /dev/null +++ b/.gitea/workflows/release.yml @@ -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" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3f37d6c --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.gradle/ +build/ +out/ +.idea/ +*.iml +*.log +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..347db1f --- /dev/null +++ b/README.md @@ -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 +/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 +/baseadmin setlevel +/baseadmin clearcooldown [personal|visitor|all] +/baseadmin reset +/baseadmin reset 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. diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..97df943 --- /dev/null +++ b/build.gradle.kts @@ -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().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) + } +} diff --git a/design/index.md b/design/index.md new file mode 100644 index 0000000..759fbec --- /dev/null +++ b/design/index.md @@ -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) diff --git a/design/log.md b/design/log.md new file mode 100644 index 0000000..33e10e4 --- /dev/null +++ b/design/log.md @@ -0,0 +1,29 @@ +--- +type: Log +title: Spigot Base Design Log +description: Chronological record of material decisions affecting the Spigot Base design. +--- + +# Spigot Base Design Log + +## 2026-08-09 — Initial progression design + +- Base progression is sequential: Base I establishes a base, Base II provides navigation, Base III provides personal teleportation, and Base IV permits visitor teleportation. +- Every secondary progression path requires Base I. +- A base is cylindrical and begins with a 10-block radius and a vertical extent 25 blocks above and below its set Y coordinate. +- Only Survival-mode activity contributes progress; repeatedly placing and breaking blocks is permitted. +- Flight is limited to the base, includes a five-block horizontal warning buffer, and can be toggled by the player. +- Player-facing administration will initially use commands rather than an inventory UI. +- The build and release pipeline will follow the neighboring Trigger Spawn project pattern. + +## 2026-08-09 — Implementation started + +- Approved implementation begins with the tested Gradle/Spigot foundation, validated configuration, and durable player state. +- Feature work will proceed incrementally using tests before implementation where practical. + +## 2026-08-09 — Core implementation checkpoint + +- Implemented UUID-keyed YAML state, validated configuration, Base I–IV progression, base sizing, particle navigation, controlled base flight, personal and visitor teleportation, progress feedback, and initial administrative commands. +- Added Gradle, Spigot metadata, Gitea CI/release workflows, and project documentation based on Trigger Spawn. +- Verified the implementation with `./gradlew clean check jar`: 34 tests passed and the plugin JAR was produced successfully. +- User stories remain in progress pending live-server integration verification and completion of runtime administrative configuration editing. diff --git a/design/user-stories/index.md b/design/user-stories/index.md new file mode 100644 index 0000000..779a099 --- /dev/null +++ b/design/user-stories/index.md @@ -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) diff --git a/design/user-stories/us-001-unlock-and-establish-base.md b/design/user-stories/us-001-unlock-and-establish-base.md new file mode 100644 index 0000000..7b3b760 --- /dev/null +++ b/design/user-stories/us-001-unlock-and-establish-base.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) diff --git a/design/user-stories/us-002-unlock-base-navigation.md b/design/user-stories/us-002-unlock-base-navigation.md new file mode 100644 index 0000000..4b7f963 --- /dev/null +++ b/design/user-stories/us-002-unlock-base-navigation.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) diff --git a/design/user-stories/us-003-expand-the-base.md b/design/user-stories/us-003-expand-the-base.md new file mode 100644 index 0000000..b958883 --- /dev/null +++ b/design/user-stories/us-003-expand-the-base.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) diff --git a/design/user-stories/us-004-unlock-and-control-base-flight.md b/design/user-stories/us-004-unlock-and-control-base-flight.md new file mode 100644 index 0000000..b1ce5a1 --- /dev/null +++ b/design/user-stories/us-004-unlock-and-control-base-flight.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) diff --git a/design/user-stories/us-005-unlock-base-teleportation.md b/design/user-stories/us-005-unlock-base-teleportation.md new file mode 100644 index 0000000..f244b7d --- /dev/null +++ b/design/user-stories/us-005-unlock-base-teleportation.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) diff --git a/design/user-stories/us-006-reduce-teleport-warmup.md b/design/user-stories/us-006-reduce-teleport-warmup.md new file mode 100644 index 0000000..f1f8c0b --- /dev/null +++ b/design/user-stories/us-006-reduce-teleport-warmup.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) diff --git a/design/user-stories/us-007-reduce-teleport-cooldown.md b/design/user-stories/us-007-reduce-teleport-cooldown.md new file mode 100644 index 0000000..f56cb0a --- /dev/null +++ b/design/user-stories/us-007-reduce-teleport-cooldown.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) diff --git a/design/user-stories/us-008-unlock-visitor-access.md b/design/user-stories/us-008-unlock-visitor-access.md new file mode 100644 index 0000000..9901de5 --- /dev/null +++ b/design/user-stories/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 ` 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) diff --git a/design/user-stories/us-009-view-progression-and-notifications.md b/design/user-stories/us-009-view-progression-and-notifications.md new file mode 100644 index 0000000..d299a81 --- /dev/null +++ b/design/user-stories/us-009-view-progression-and-notifications.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) diff --git a/design/user-stories/us-010-administer-player-progression.md b/design/user-stories/us-010-administer-player-progression.md new file mode 100644 index 0000000..b9220c1 --- /dev/null +++ b/design/user-stories/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 ` displays the player's base, counters, earned path levels, active cooldowns, toggles, and visitor settings. +- [ ] `/baseadmin setlevel ` sets an earned path level while enforcing or explicitly granting required preceding levels. +- [ ] `/baseadmin setprogress ` updates the selected counter and consistently evaluates reached tiers. +- [ ] `/baseadmin reset ` resets a selected path without silently leaving benefits that require it. +- [ ] `/baseadmin reset 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) diff --git a/design/user-stories/us-011-configure-and-persist-progression.md b/design/user-stories/us-011-configure-and-persist-progression.md new file mode 100644 index 0000000..9ba32cb --- /dev/null +++ b/design/user-stories/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) diff --git a/design/user-stories/us-012-build-and-release-plugin.md b/design/user-stories/us-012-build-and-release-plugin.md new file mode 100644 index 0000000..d073e36 --- /dev/null +++ b/design/user-stories/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) diff --git a/gradle/gradlew b/gradle/gradlew new file mode 100755 index 0000000..203529c --- /dev/null +++ b/gradle/gradlew @@ -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" "$@" diff --git a/gradle/gradlew.bat b/gradle/gradlew.bat new file mode 100644 index 0000000..7e60b72 --- /dev/null +++ b/gradle/gradlew.bat @@ -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% diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..b1b8ef5 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..a351597 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..203529c --- /dev/null +++ b/gradlew @@ -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" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..7e60b72 --- /dev/null +++ b/gradlew.bat @@ -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% diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..bf3d402 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "spigot-base" diff --git a/src/main/java/games/dmg/spigotbase/AdminProgressionService.java b/src/main/java/games/dmg/spigotbase/AdminProgressionService.java new file mode 100644 index 0000000..2ebeca1 --- /dev/null +++ b/src/main/java/games/dmg/spigotbase/AdminProgressionService.java @@ -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 + ); + } + } +} diff --git a/src/main/java/games/dmg/spigotbase/BaseAdminCommand.java b/src/main/java/games/dmg/spigotbase/BaseAdminCommand.java new file mode 100644 index 0000000..2d7af0f --- /dev/null +++ b/src/main/java/games/dmg/spigotbase/BaseAdminCommand.java @@ -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 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 "); + 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 [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 [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 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 " + + " ..."); + } +} diff --git a/src/main/java/games/dmg/spigotbase/BaseArea.java b/src/main/java/games/dmg/spigotbase/BaseArea.java new file mode 100644 index 0000000..f2009c2 --- /dev/null +++ b/src/main/java/games/dmg/spigotbase/BaseArea.java @@ -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; + } +} diff --git a/src/main/java/games/dmg/spigotbase/BaseBoundsService.java b/src/main/java/games/dmg/spigotbase/BaseBoundsService.java new file mode 100644 index 0000000..8b58041 --- /dev/null +++ b/src/main/java/games/dmg/spigotbase/BaseBoundsService.java @@ -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)); + } +} diff --git a/src/main/java/games/dmg/spigotbase/BaseCommand.java b/src/main/java/games/dmg/spigotbase/BaseCommand.java new file mode 100644 index 0000000..f40b923 --- /dev/null +++ b/src/main/java/games/dmg/spigotbase/BaseCommand.java @@ -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; + } +} diff --git a/src/main/java/games/dmg/spigotbase/BaseFlightCommand.java b/src/main/java/games/dmg/spigotbase/BaseFlightCommand.java new file mode 100644 index 0000000..b9e9345 --- /dev/null +++ b/src/main/java/games/dmg/spigotbase/BaseFlightCommand.java @@ -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; + } +} diff --git a/src/main/java/games/dmg/spigotbase/BaseFlightController.java b/src/main/java/games/dmg/spigotbase/BaseFlightController.java new file mode 100644 index 0000000..2d67085 --- /dev/null +++ b/src/main/java/games/dmg/spigotbase/BaseFlightController.java @@ -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 grantedFlight = new HashSet<>(); + private final Set 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); + }; + } +} diff --git a/src/main/java/games/dmg/spigotbase/BaseLocation.java b/src/main/java/games/dmg/spigotbase/BaseLocation.java new file mode 100644 index 0000000..007533d --- /dev/null +++ b/src/main/java/games/dmg/spigotbase/BaseLocation.java @@ -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"); + } + } +} diff --git a/src/main/java/games/dmg/spigotbase/BaseNavigationCommand.java b/src/main/java/games/dmg/spigotbase/BaseNavigationCommand.java new file mode 100644 index 0000000..b10dc06 --- /dev/null +++ b/src/main/java/games/dmg/spigotbase/BaseNavigationCommand.java @@ -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; + } +} diff --git a/src/main/java/games/dmg/spigotbase/BaseNavigationController.java b/src/main/java/games/dmg/spigotbase/BaseNavigationController.java new file mode 100644 index 0000000..a44a195 --- /dev/null +++ b/src/main/java/games/dmg/spigotbase/BaseNavigationController.java @@ -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); + } + } + } +} diff --git a/src/main/java/games/dmg/spigotbase/BaseProgressCommand.java b/src/main/java/games/dmg/spigotbase/BaseProgressCommand.java new file mode 100644 index 0000000..5336428 --- /dev/null +++ b/src/main/java/games/dmg/spigotbase/BaseProgressCommand.java @@ -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; + } +} diff --git a/src/main/java/games/dmg/spigotbase/BaseProgressListener.java b/src/main/java/games/dmg/spigotbase/BaseProgressListener.java new file mode 100644 index 0000000..0251ff2 --- /dev/null +++ b/src/main/java/games/dmg/spigotbase/BaseProgressListener.java @@ -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 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) { + } +} diff --git a/src/main/java/games/dmg/spigotbase/BaseProgressionService.java b/src/main/java/games/dmg/spigotbase/BaseProgressionService.java new file mode 100644 index 0000000..94d30cd --- /dev/null +++ b/src/main/java/games/dmg/spigotbase/BaseProgressionService.java @@ -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); + } +} diff --git a/src/main/java/games/dmg/spigotbase/BaseService.java b/src/main/java/games/dmg/spigotbase/BaseService.java new file mode 100644 index 0000000..0cab9d5 --- /dev/null +++ b/src/main/java/games/dmg/spigotbase/BaseService.java @@ -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 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); + } +} diff --git a/src/main/java/games/dmg/spigotbase/BaseStateManager.java b/src/main/java/games/dmg/spigotbase/BaseStateManager.java new file mode 100644 index 0000000..6584505 --- /dev/null +++ b/src/main/java/games/dmg/spigotbase/BaseStateManager.java @@ -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 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 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 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 proposed = new HashMap<>(players); + proposed.put(playerId, updated); + repository.save(new PersistentState(proposed)); + players.clear(); + players.putAll(proposed); + dirty = false; + return updated; + } + + public Map knownPlayers() { + return Map.copyOf(players); + } + + public Optional 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); + } + } +} diff --git a/src/main/java/games/dmg/spigotbase/BaseTeleportManager.java b/src/main/java/games/dmg/spigotbase/BaseTeleportManager.java new file mode 100644 index 0000000..330115d --- /dev/null +++ b/src/main/java/games/dmg/spigotbase/BaseTeleportManager.java @@ -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 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 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 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 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(); + } + } + } +} diff --git a/src/main/java/games/dmg/spigotbase/BaseVisitorsCommand.java b/src/main/java/games/dmg/spigotbase/BaseVisitorsCommand.java new file mode 100644 index 0000000..09765f1 --- /dev/null +++ b/src/main/java/games/dmg/spigotbase/BaseVisitorsCommand.java @@ -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; + } +} diff --git a/src/main/java/games/dmg/spigotbase/DurationFormatter.java b/src/main/java/games/dmg/spigotbase/DurationFormatter.java new file mode 100644 index 0000000..42545b6 --- /dev/null +++ b/src/main/java/games/dmg/spigotbase/DurationFormatter.java @@ -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"; + } +} diff --git a/src/main/java/games/dmg/spigotbase/GoToBaseCommand.java b/src/main/java/games/dmg/spigotbase/GoToBaseCommand.java new file mode 100644 index 0000000..5a2dd70 --- /dev/null +++ b/src/main/java/games/dmg/spigotbase/GoToBaseCommand.java @@ -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 "); + 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 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(); + } +} diff --git a/src/main/java/games/dmg/spigotbase/PersistentState.java b/src/main/java/games/dmg/spigotbase/PersistentState.java new file mode 100644 index 0000000..ff458bc --- /dev/null +++ b/src/main/java/games/dmg/spigotbase/PersistentState.java @@ -0,0 +1,14 @@ +package games.dmg.spigotbase; + +import java.util.Map; +import java.util.UUID; + +public record PersistentState(Map players) { + public PersistentState { + players = players == null ? Map.of() : Map.copyOf(players); + } + + public static PersistentState empty() { + return new PersistentState(Map.of()); + } +} diff --git a/src/main/java/games/dmg/spigotbase/PlayerState.java b/src/main/java/games/dmg/spigotbase/PlayerState.java new file mode 100644 index 0000000..5a15988 --- /dev/null +++ b/src/main/java/games/dmg/spigotbase/PlayerState.java @@ -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 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 lastBaseSet, + Optional lastBaseTeleport, + Map 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 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"); + } + } +} diff --git a/src/main/java/games/dmg/spigotbase/PluginSettings.java b/src/main/java/games/dmg/spigotbase/PluginSettings.java new file mode 100644 index 0000000..6eba152 --- /dev/null +++ b/src/main/java/games/dmg/spigotbase/PluginSettings.java @@ -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 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 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 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"); + } + } +} diff --git a/src/main/java/games/dmg/spigotbase/ProgressionPath.java b/src/main/java/games/dmg/spigotbase/ProgressionPath.java new file mode 100644 index 0000000..2112f39 --- /dev/null +++ b/src/main/java/games/dmg/spigotbase/ProgressionPath.java @@ -0,0 +1,9 @@ +package games.dmg.spigotbase; + +public enum ProgressionPath { + BASE, + SIZE, + FLIGHT, + WARMUP, + COOLDOWN +} diff --git a/src/main/java/games/dmg/spigotbase/ProgressionUpdate.java b/src/main/java/games/dmg/spigotbase/ProgressionUpdate.java new file mode 100644 index 0000000..3f79227 --- /dev/null +++ b/src/main/java/games/dmg/spigotbase/ProgressionUpdate.java @@ -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); + } +} diff --git a/src/main/java/games/dmg/spigotbase/SafeBaseDestination.java b/src/main/java/games/dmg/spigotbase/SafeBaseDestination.java new file mode 100644 index 0000000..28e0296 --- /dev/null +++ b/src/main/java/games/dmg/spigotbase/SafeBaseDestination.java @@ -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 HAZARDS = Set.of( + Material.LAVA, + Material.FIRE, + Material.SOUL_FIRE, + Material.MAGMA_BLOCK, + Material.CACTUS, + Material.CAMPFIRE, + Material.SOUL_CAMPFIRE, + Material.POWDER_SNOW + ); + + Optional 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 above = candidate( + world, base, deltaX, vertical, deltaZ + ); + if (above.isPresent()) { + return above; + } + if (vertical > 0) { + Optional below = candidate( + world, base, deltaX, -vertical, deltaZ + ); + if (below.isPresent()) { + return below; + } + } + } + } + } + } + return Optional.empty(); + } + + private Optional 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() + )); + } +} diff --git a/src/main/java/games/dmg/spigotbase/SecondaryProgressionService.java b/src/main/java/games/dmg/spigotbase/SecondaryProgressionService.java new file mode 100644 index 0000000..b573926 --- /dev/null +++ b/src/main/java/games/dmg/spigotbase/SecondaryProgressionService.java @@ -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; + } +} diff --git a/src/main/java/games/dmg/spigotbase/SetBaseCommand.java b/src/main/java/games/dmg/spigotbase/SetBaseCommand.java new file mode 100644 index 0000000..c9bbf8e --- /dev/null +++ b/src/main/java/games/dmg/spigotbase/SetBaseCommand.java @@ -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 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; + } +} diff --git a/src/main/java/games/dmg/spigotbase/SpigotBasePlugin.java b/src/main/java/games/dmg/spigotbase/SpigotBasePlugin.java new file mode 100644 index 0000000..eede6a1 --- /dev/null +++ b/src/main/java/games/dmg/spigotbase/SpigotBasePlugin.java @@ -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 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); + } +} diff --git a/src/main/java/games/dmg/spigotbase/TeleportPolicy.java b/src/main/java/games/dmg/spigotbase/TeleportPolicy.java new file mode 100644 index 0000000..9c54d19 --- /dev/null +++ b/src/main/java/games/dmg/spigotbase/TeleportPolicy.java @@ -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 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)); + } +} diff --git a/src/main/java/games/dmg/spigotbase/TeleportProgressionService.java b/src/main/java/games/dmg/spigotbase/TeleportProgressionService.java new file mode 100644 index 0000000..b8e546a --- /dev/null +++ b/src/main/java/games/dmg/spigotbase/TeleportProgressionService.java @@ -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; + } +} diff --git a/src/main/java/games/dmg/spigotbase/VisitorPolicy.java b/src/main/java/games/dmg/spigotbase/VisitorPolicy.java new file mode 100644 index 0000000..9891ffd --- /dev/null +++ b/src/main/java/games/dmg/spigotbase/VisitorPolicy.java @@ -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 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)); + } +} diff --git a/src/main/java/games/dmg/spigotbase/YamlBaseStateRepository.java b/src/main/java/games/dmg/spigotbase/YamlBaseStateRepository.java new file mode 100644 index 0000000..1d8a155 --- /dev/null +++ b/src/main/java/games/dmg/spigotbase/YamlBaseStateRepository.java @@ -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 loadPlayers(YamlConfiguration yaml) { + Map 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 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 loadVisitorCooldowns(YamlConfiguration yaml, String path) { + Map 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(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 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 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()); + } +} diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml new file mode 100644 index 0000000..0ebf0ed --- /dev/null +++ b/src/main/resources/config.yml @@ -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 diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml new file mode 100644 index 0000000..1d8ffbb --- /dev/null +++ b/src/main/resources/plugin.yml @@ -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 + baseprogress: + description: View progression or toggle progress boss bars. + usage: /baseprogress [bossbar] + baseadmin: + description: Administer Spigot Base. + usage: /baseadmin + permission: spigotbase.admin +permissions: + spigotbase.admin: + description: Allows administration of Spigot Base. + default: op diff --git a/src/test/java/games/dmg/spigotbase/AdminProgressionServiceTest.java b/src/test/java/games/dmg/spigotbase/AdminProgressionServiceTest.java new file mode 100644 index 0000000..c2188c9 --- /dev/null +++ b/src/test/java/games/dmg/spigotbase/AdminProgressionServiceTest.java @@ -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) + ); + } +} diff --git a/src/test/java/games/dmg/spigotbase/BaseAreaTest.java b/src/test/java/games/dmg/spigotbase/BaseAreaTest.java new file mode 100644 index 0000000..c2bc9a9 --- /dev/null +++ b/src/test/java/games/dmg/spigotbase/BaseAreaTest.java @@ -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)); + } +} diff --git a/src/test/java/games/dmg/spigotbase/BaseBoundsServiceTest.java b/src/test/java/games/dmg/spigotbase/BaseBoundsServiceTest.java new file mode 100644 index 0000000..fe5a094 --- /dev/null +++ b/src/test/java/games/dmg/spigotbase/BaseBoundsServiceTest.java @@ -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))); + } +} diff --git a/src/test/java/games/dmg/spigotbase/BaseNavigationProgressionTest.java b/src/test/java/games/dmg/spigotbase/BaseNavigationProgressionTest.java new file mode 100644 index 0000000..50a65c6 --- /dev/null +++ b/src/test/java/games/dmg/spigotbase/BaseNavigationProgressionTest.java @@ -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()); + } +} diff --git a/src/test/java/games/dmg/spigotbase/BaseProgressionServiceTest.java b/src/test/java/games/dmg/spigotbase/BaseProgressionServiceTest.java new file mode 100644 index 0000000..5d33088 --- /dev/null +++ b/src/test/java/games/dmg/spigotbase/BaseProgressionServiceTest.java @@ -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()); + } +} diff --git a/src/test/java/games/dmg/spigotbase/BaseServiceTest.java b/src/test/java/games/dmg/spigotbase/BaseServiceTest.java new file mode 100644 index 0000000..8d08e29 --- /dev/null +++ b/src/test/java/games/dmg/spigotbase/BaseServiceTest.java @@ -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)); + } +} diff --git a/src/test/java/games/dmg/spigotbase/BaseStateManagerTest.java b/src/test/java/games/dmg/spigotbase/BaseStateManagerTest.java new file mode 100644 index 0000000..01f14b3 --- /dev/null +++ b/src/test/java/games/dmg/spigotbase/BaseStateManagerTest.java @@ -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()); + } +} diff --git a/src/test/java/games/dmg/spigotbase/PlayerStateTest.java b/src/test/java/games/dmg/spigotbase/PlayerStateTest.java new file mode 100644 index 0000000..7486f01 --- /dev/null +++ b/src/test/java/games/dmg/spigotbase/PlayerStateTest.java @@ -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() + )); + } +} diff --git a/src/test/java/games/dmg/spigotbase/PluginMetadataTest.java b/src/test/java/games/dmg/spigotbase/PluginMetadataTest.java new file mode 100644 index 0000000..269c4eb --- /dev/null +++ b/src/test/java/games/dmg/spigotbase/PluginMetadataTest.java @@ -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")); + } +} diff --git a/src/test/java/games/dmg/spigotbase/PluginSettingsTest.java b/src/test/java/games/dmg/spigotbase/PluginSettingsTest.java new file mode 100644 index 0000000..36583db --- /dev/null +++ b/src/test/java/games/dmg/spigotbase/PluginSettingsTest.java @@ -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 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))); + } +} diff --git a/src/test/java/games/dmg/spigotbase/SecondaryProgressionServiceTest.java b/src/test/java/games/dmg/spigotbase/SecondaryProgressionServiceTest.java new file mode 100644 index 0000000..a796ceb --- /dev/null +++ b/src/test/java/games/dmg/spigotbase/SecondaryProgressionServiceTest.java @@ -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()); + } +} diff --git a/src/test/java/games/dmg/spigotbase/TeleportPolicyTest.java b/src/test/java/games/dmg/spigotbase/TeleportPolicyTest.java new file mode 100644 index 0000000..f9e9294 --- /dev/null +++ b/src/test/java/games/dmg/spigotbase/TeleportPolicyTest.java @@ -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); + } +} diff --git a/src/test/java/games/dmg/spigotbase/TeleportProgressionServiceTest.java b/src/test/java/games/dmg/spigotbase/TeleportProgressionServiceTest.java new file mode 100644 index 0000000..d10efac --- /dev/null +++ b/src/test/java/games/dmg/spigotbase/TeleportProgressionServiceTest.java @@ -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 + ); + } +} diff --git a/src/test/java/games/dmg/spigotbase/VisitorPolicyTest.java b/src/test/java/games/dmg/spigotbase/VisitorPolicyTest.java new file mode 100644 index 0000000..6092aa9 --- /dev/null +++ b/src/test/java/games/dmg/spigotbase/VisitorPolicyTest.java @@ -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()); + } +} diff --git a/src/test/java/games/dmg/spigotbase/YamlBaseStateRepositoryTest.java b/src/test/java/games/dmg/spigotbase/YamlBaseStateRepositoryTest.java new file mode 100644 index 0000000..c8603b1 --- /dev/null +++ b/src/test/java/games/dmg/spigotbase/YamlBaseStateRepositoryTest.java @@ -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()); + } +}