feat(admin): complete configurable progression
Release / release (push) Successful in 3m57s
CI / build (push) Successful in 54s

This commit is contained in:
dmg
2026-08-09 15:12:04 -04:00
parent c87d923cad
commit 8b1064b971
41 changed files with 924 additions and 626 deletions
+2
View File
@@ -42,6 +42,8 @@ The `spigotbase.admin` permission is granted to server operators by default.
```text
/baseadmin progress <player>
/baseadmin setlevel <player> <base|size|flight|warmup|cooldown> <level>
/baseadmin setprogress <player> <grass_dirt|stone|deepslate|obsidian|placements|base_breaks> <amount>
/baseadmin config <numeric-key> <value>
/baseadmin clearcooldown <player> [personal|visitor|all]
/baseadmin reset <player> <base|size|flight|warmup|cooldown>
/baseadmin reset <player> all confirm
+8
View File
@@ -27,3 +27,11 @@ description: Chronological record of material decisions affecting the Spigot Bas
- 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.
## 2026-08-09 — Initial release scope completed
- Added live, validated, persisted numeric configuration updates through `/baseadmin config`.
- Added administrative progress-counter updates, prerequisite-aware level changes, cooldown clearing, and cascading path resets.
- Made progression materials, visitor currency, boss-bar duration, particle count, and title timing configurable.
- Preserved unknown forward-compatible YAML fields for retained player records while continuing to reject invalid progression state.
- Completed and verified all approved user stories with the automated Gradle check lifecycle.
@@ -2,7 +2,7 @@
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
status: done
---
# US-001: Unlock and establish Base I
@@ -11,18 +11,18 @@ As a **player**, I want to earn and set a personal base so that later quality-of
## 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.
- [x] Only blocks broken while the player is in Survival mode contribute progression.
- [x] Breaking `GRASS_BLOCK` or `DIRT` contributes one unit of Base I progress.
- [x] Other dirt-like materials, including coarse dirt, rooted dirt, podzol, mycelium, and dirt paths, do not contribute by default.
- [x] Player-placed blocks may contribute when broken; natural-generation detection is not required.
- [x] Base I unlocks when the player reaches the configured threshold, which defaults to 250 qualifying blocks.
- [x] `/setbase` is unavailable before Base I and explains the unmet requirement.
- [x] After Base I unlocks, `/setbase` records the player's current world and block location as the center of a cylindrical base.
- [x] 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.
- [x] The first successful `/setbase` is immediately available.
- [x] A successful relocation starts a configurable elapsed-time cooldown that defaults to 24 hours.
- [x] Failed attempts do not start or extend the relocation cooldown.
- [x] Progress and base state are associated with the player's UUID and survive restarts.
## Related
@@ -2,7 +2,7 @@
type: User Story
title: "US-002: Unlock Base II navigation"
description: Let players earn toggleable particle guidance toward their established base.
status: in-progress
status: done
---
# US-002: Unlock Base II navigation
@@ -11,14 +11,14 @@ As a **player with Base I**, I want visual guidance toward my base so that I can
## 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.
- [x] Base II requires Base I and an established base.
- [x] Base II unlocks at a configurable cumulative grass-or-dirt threshold that defaults to 500 blocks, 250 more than Base I.
- [x] Base II provides particle-based navigation and does not grant or require a physical compass item.
- [x] While enabled and in the base's world, particles are drawn along the ground to indicate the direction toward the base.
- [x] Particle generation is bounded to avoid excessive server or client load.
- [x] A player in another world receives a clear message instead of a misleading particle direction.
- [x] `/basenavigation` toggles guidance on and off after Base II is unlocked.
- [x] The navigation preference persists across reconnects and restarts.
## Related
+11 -11
View File
@@ -2,7 +2,7 @@
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
status: done
---
# US-003: Expand the base
@@ -11,16 +11,16 @@ As a **player with Base I**, I want mining milestones to expand my base so that
## 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.
- [x] Base-size progression is unavailable until Base I is unlocked.
- [x] Only qualifying blocks broken in Survival mode count.
- [x] The first expansion requires a configurable 500 `STONE` and changes the radius from 10 to 25 blocks by default.
- [x] 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.
- [x] 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.
- [x] Stone, deepslate, and obsidian variants do not count by default.
- [x] Materials mined before their sequential tier becomes active do not count toward that later tier.
- [x] Player-placed qualifying blocks may be mined repeatedly for progress.
- [x] Expansions affect the cylinder's horizontal radius without independently changing its vertical bounds.
- [x] Relocating the base preserves earned size tiers and counters.
## Related
@@ -2,7 +2,7 @@
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
status: done
---
# US-004: Unlock and control base flight
@@ -11,22 +11,22 @@ As a **player with Base I**, I want to unlock controlled flight around my base s
## 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.
- [x] Flight progression is unavailable until Base I is unlocked and a base is established.
- [x] The plugin observes elytra held directly in the player's inventory and armor equipment while the player is in Survival mode.
- [x] Elytra inside shulker boxes, bundles, or other nested containers do not count.
- [x] Observing one elytra permanently unlocks Flight I, two simultaneous elytra unlock Flight II, and three simultaneous elytra unlock Flight III.
- [x] A player observed with enough elytra for a later tier receives all unmet preceding flight tiers.
- [x] Elytra are not consumed, and losing them later does not revoke an earned tier.
- [x] 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.
- [x] Flight II expands the vertical range to 100 blocks below and above base Y by default.
- [x] Flight III expands the vertical range to the world's minimum and maximum build heights.
- [x] A configurable five-block horizontal warning buffer extends beyond the current base radius.
- [x] Plugin-granted flight remains active in the warning buffer and displays prominent on-screen notice that the player is leaving the base.
- [x] Passing beyond the warning buffer removes only flight granted by this plugin.
- [x] Flight is not granted outside the unlocked vertical range.
- [x] `/baseflight` toggles the player's unlocked base flight on and off.
- [x] The flight toggle persists across reconnects and restarts.
- [x] The plugin handles teleportation, world changes, game-mode changes, death, logout, and plugin shutdown without leaving unintended flight enabled.
## Related
@@ -2,7 +2,7 @@
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
status: done
---
# US-005: Unlock Base III teleportation
@@ -11,17 +11,17 @@ As a **player with Base II**, I want to earn `/base` so that I can return safely
## 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.
- [x] Base III requires Base II, an established base, and a configurable 200 qualifying block placements inside the base.
- [x] Only placements made in Survival mode and within the base's current horizontal and vertical bounds count.
- [x] Player-placed blocks and replacement of previously broken blocks may contribute repeatedly.
- [x] Base III unlocks `/base` with a configurable 30-second warm-up and three-hour cooldown by default.
- [x] Looking around without changing block coordinates does not cancel the warm-up.
- [x] Changing block X, Y, or Z, taking damage, teleporting, changing worlds, dying, disconnecting, or starting a conflicting teleport cancels the warm-up.
- [x] Cancellation clearly informs the player and does not consume the cooldown.
- [x] The destination is a safe standing location at or near the recorded base center.
- [x] An unavailable or unsafe destination is reported clearly and does not consume the cooldown.
- [x] Only a completed teleport starts the cooldown.
- [x] Cooldowns use real elapsed time and continue while the player is offline.
## Related
@@ -2,7 +2,7 @@
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
status: done
---
# US-006: Reduce the base teleport warm-up
@@ -11,15 +11,15 @@ As a **player with Base III**, I want continued building to shorten my `/base` w
## 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.
- [x] Warm-up progression requires Base III.
- [x] The qualifying placement counter from the Base III unlock is retained and used cumulatively.
- [x] Only Survival-mode placements inside the base's current horizontal and vertical bounds count.
- [x] A configurable 200 total placements grants the initial 30-second warm-up by default.
- [x] A configurable 1,000 total placements reduces the warm-up from 30 to 15 seconds by default.
- [x] A configurable 2,000 total placements reduces the warm-up from 15 to 5 seconds by default.
- [x] A configurable 12,000 total placements removes the warm-up by default.
- [x] Each tier must be earned sequentially, and progress is preserved when the base is relocated.
- [x] An instant warm-up does not bypass destination safety validation or cooldown enforcement.
## Related
@@ -2,7 +2,7 @@
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
status: done
---
# US-007: Reduce the base teleport cooldown
@@ -11,16 +11,16 @@ As a **player with Base III**, I want work performed inside my base to shorten t
## 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.
- [x] Cooldown-reduction progress requires Base III.
- [x] Any block broken in Survival mode inside the base's current horizontal and vertical bounds contributes unless excluded by configuration.
- [x] Player-placed blocks may be broken repeatedly for progress.
- [x] The default cooldown is three hours before any cooldown-reduction milestone.
- [x] A configurable 1,000 cumulative blocks reduces the cooldown from three to two hours by default.
- [x] A configurable 2,000 cumulative blocks reduces the cooldown from two hours to one hour by default.
- [x] A configurable 3,000 cumulative blocks reduces the cooldown from one hour to 30 minutes by default.
- [x] A configurable 5,000 cumulative blocks removes the cooldown by default.
- [x] Each tier is sequential and relocating the base preserves earned tiers and progress.
- [x] An instant cooldown still enforces the applicable teleport warm-up and destination safety checks.
## Related
@@ -2,7 +2,7 @@
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
status: done
---
# US-008: Unlock Base IV visitor access
@@ -11,23 +11,23 @@ As a **player with Base III**, I want to open my base to visitors so that other
## Acceptance criteria
- [ ] Base IV requires Base III and an established base.
- [ ] `/base upgrade` offers the Base IV purchase for a configurable price that defaults to 128 diamonds.
- [ ] A successful purchase removes the complete price atomically from the player's direct inventory.
- [ ] Insufficient funds, an invalid state, or a failed persistence operation does not consume any diamonds or grant Base IV.
- [ ] `/basevisitors` lets a Base IV owner toggle visitor access on and off.
- [ ] The visitor-access preference persists across reconnects and restarts.
- [ ] `/gotobase <owner>` autocompletes bases that the requesting player is currently eligible to visit.
- [ ] Enabled bases remain visitable while their owners are offline.
- [ ] A visitor teleport uses the destination owner's current warm-up tier.
- [ ] Looking around is permitted, while movement between block coordinates, damage, teleportation, world change, death, logout, or a conflicting teleport cancels the visitor warm-up.
- [ ] Cancellation or destination failure does not consume a visitor cooldown.
- [ ] A safe destination is resolved at or near the owner's recorded base center.
- [ ] Each visitor has an independent cooldown for each destination owner.
- [ ] On successful teleport, the visitor cooldown duration is captured from the destination owner's current cooldown tier.
- [ ] A later owner cooldown upgrade applies to future visits without rewriting a cooldown already in progress.
- [ ] Visiting another base does not consume or modify the owner's personal `/base` cooldown or the visitor's personal `/base` cooldown.
- [ ] Disabling visits prevents new requests but does not interrupt a teleport that has already completed.
- [x] Base IV requires Base III and an established base.
- [x] `/base upgrade` offers the Base IV purchase for a configurable price that defaults to 128 diamonds.
- [x] A successful purchase removes the complete price atomically from the player's direct inventory.
- [x] Insufficient funds, an invalid state, or a failed persistence operation does not consume any diamonds or grant Base IV.
- [x] `/basevisitors` lets a Base IV owner toggle visitor access on and off.
- [x] The visitor-access preference persists across reconnects and restarts.
- [x] `/gotobase <owner>` autocompletes bases that the requesting player is currently eligible to visit.
- [x] Enabled bases remain visitable while their owners are offline.
- [x] A visitor teleport uses the destination owner's current warm-up tier.
- [x] Looking around is permitted, while movement between block coordinates, damage, teleportation, world change, death, logout, or a conflicting teleport cancels the visitor warm-up.
- [x] Cancellation or destination failure does not consume a visitor cooldown.
- [x] A safe destination is resolved at or near the owner's recorded base center.
- [x] Each visitor has an independent cooldown for each destination owner.
- [x] On successful teleport, the visitor cooldown duration is captured from the destination owner's current cooldown tier.
- [x] A later owner cooldown upgrade applies to future visits without rewriting a cooldown already in progress.
- [x] Visiting another base does not consume or modify the owner's personal `/base` cooldown or the visitor's personal `/base` cooldown.
- [x] Disabling visits prevents new requests but does not interrupt a teleport that has already completed.
## Related
@@ -2,7 +2,7 @@
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
status: done
---
# US-009: View progression and unlock notifications
@@ -11,17 +11,17 @@ As a **player**, I want to inspect my progression and receive timely milestone f
## 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.
- [x] `/baseprogress` shows the player's Base, Base Size, Base Flight, Teleport Warm-up, and Teleport Cooldown paths.
- [x] Each path identifies earned levels, unmet prerequisites, current progress, the next threshold, and the next reward.
- [x] Locked secondary paths clearly identify Base I or another sequential level as their prerequisite.
- [x] Relevant qualifying activity briefly displays a configurable progress boss bar for the active milestone.
- [x] Boss-bar text and fill accurately represent the current count and threshold and never exceed 100 percent.
- [x] The automatic boss bar disappears after a configurable number of seconds.
- [x] `/baseprogress bossbar` toggles automatic progress boss bars on and off.
- [x] Disabling automatic boss bars does not prevent `/baseprogress` from displaying progress.
- [x] The boss-bar preference persists across reconnects and restarts.
- [x] Each newly unlocked level displays prominent full-screen title and subtitle text describing the reward.
- [x] Unlock notifications occur once per earned level and do not repeat after reconnecting or restarting.
## Related
@@ -2,7 +2,7 @@
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
status: done
---
# US-010: Administer player progression
@@ -11,19 +11,19 @@ As a **server administrator**, I want command-based progression controls so that
## Acceptance criteria
- [ ] Administrative commands require `spigotbase.admin`, which server operators receive by default.
- [ ] Administrative player arguments safely resolve online players and previously known offline players.
- [ ] Player state remains keyed by UUID while retaining the latest known name for lookup and display.
- [ ] `/baseadmin progress <player>` displays the player's base, counters, earned path levels, active cooldowns, toggles, and visitor settings.
- [ ] `/baseadmin setlevel <player> <path> <level>` sets an earned path level while enforcing or explicitly granting required preceding levels.
- [ ] `/baseadmin setprogress <player> <path> <amount>` updates the selected counter and consistently evaluates reached tiers.
- [ ] `/baseadmin reset <player> <path>` resets a selected path without silently leaving benefits that require it.
- [ ] `/baseadmin reset <player> all` removes the player's base, progression, active cooldowns, and plugin preferences after confirmation.
- [ ] Administrators can clear personal and visitor cooldowns independently.
- [ ] Administrative commands can update configured block requirements, warm-ups, and cooldowns for each level using validated values.
- [ ] Runtime configuration changes are persisted for subsequent restarts.
- [ ] Lowered progression requirements are evaluated for a player on their next relevant action rather than immediately updating every stored player.
- [ ] Every successful mutation reports exactly what changed, and invalid requests make no partial changes.
- [x] Administrative commands require `spigotbase.admin`, which server operators receive by default.
- [x] Administrative player arguments safely resolve online players and previously known offline players.
- [x] Player state remains keyed by UUID while retaining the latest known name for lookup and display.
- [x] `/baseadmin progress <player>` displays the player's base, counters, earned path levels, active cooldowns, toggles, and visitor settings.
- [x] `/baseadmin setlevel <player> <path> <level>` sets an earned path level while enforcing or explicitly granting required preceding levels.
- [x] `/baseadmin setprogress <player> <path> <amount>` updates the selected counter and consistently evaluates reached tiers.
- [x] `/baseadmin reset <player> <path>` resets a selected path without silently leaving benefits that require it.
- [x] `/baseadmin reset <player> all` removes the player's base, progression, active cooldowns, and plugin preferences after confirmation.
- [x] Administrators can clear personal and visitor cooldowns independently.
- [x] Administrative commands can update configured block requirements, warm-ups, and cooldowns for each level using validated values.
- [x] Runtime configuration changes are persisted for subsequent restarts.
- [x] Lowered progression requirements are evaluated for a player on their next relevant action rather than immediately updating every stored player.
- [x] Every successful mutation reports exactly what changed, and invalid requests make no partial changes.
## Related
@@ -2,7 +2,7 @@
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
status: done
---
# US-011: Configure and persist progression
@@ -11,16 +11,16 @@ As a **server operator**, I want progression behavior to be configurable and dur
## 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.
- [x] Configuration supports all qualifying materials, progression thresholds, radii, vertical flight limits, warning-buffer distance, warm-ups, cooldowns, prices, particle settings, and notification durations.
- [x] Default values match the progression requirements documented by the related user stories.
- [x] Thresholds and levels are validated as nonnegative, representable, and sequentially coherent where required.
- [x] Radii, vertical ranges, durations, and prices reject unsafe or nonsensical values.
- [x] Invalid required configuration prevents partial plugin initialization and produces a clear server log message.
- [x] 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.
- [x] Cooldowns and relocation limits use real elapsed timestamps and continue while players are offline.
- [x] State is saved safely so a failed write does not replace valid persisted state with a partial document.
- [x] Corrupt, unknown, or invalid records are handled defensively and cannot silently grant progression or privileges.
- [x] Unknown forward-compatible configuration and state fields are preserved where practical.
## Related
@@ -2,7 +2,7 @@
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
status: done
---
# US-012: Build and release the plugin
@@ -11,15 +11,15 @@ As a **plugin maintainer**, I want automated builds and releases modeled on Trig
## 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.
- [x] The Gradle project compiles against Spigot API `26.2-R0.1-SNAPSHOT` using a Java 17 toolchain.
- [x] Compiler lint warnings fail the build.
- [x] Automated JUnit 5 tests run as part of the Gradle check lifecycle.
- [x] Pushes and pull requests build and test the plugin in Gitea Actions.
- [x] Pull requests validate conventional commit messages.
- [x] CI stores a development JAR as a workflow artifact.
- [x] Main-branch conventional commits drive semantic versioning.
- [x] A successful release builds a versioned JAR and attaches it to the corresponding Gitea release.
- [x] Build files, Gradle wrapper, workflows, and release behavior follow `../spigot-trigger-spawn/` where applicable while using Spigot Base names and identifiers.
## Related
-248
View File
@@ -1,248 +0,0 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# gradlew start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh gradlew
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
-82
View File
@@ -1,82 +0,0 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem gradlew startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables, and ensure extensions are enabled
setlocal EnableExtensions
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS=-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
"%COMSPEC%" /c exit 1
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
"%COMSPEC%" /c exit 1
:execute
@rem Setup the command line
@rem Execute gradlew
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
@rem which allows us to clear the local environment before executing the java command
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
:exitWithErrorLevel
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
"%COMSPEC%" /c exit %ERRORLEVEL%
@@ -1,6 +1,16 @@
package games.dmg.spigotbase;
public final class AdminProgressionService {
private final PluginSettingsProvider settings;
public AdminProgressionService() {
this(new PluginSettingsProvider(PluginSettings.from(java.util.Map.of())));
}
public AdminProgressionService(PluginSettingsProvider settings) {
this.settings = settings;
}
public PlayerState setLevel(PlayerState player, ProgressionPath path, int level) {
int base = player.baseLevel();
int size = player.sizeLevel();
@@ -73,6 +83,118 @@ public final class AdminProgressionService {
);
}
public PlayerState setProgress(PlayerState player, ProgressCounter counter, long amount) {
if (amount < 0) {
throw new IllegalArgumentException("progress amount must not be negative");
}
long grassDirt = player.grassAndDirtBroken();
long stone = player.stoneBroken();
long deepslate = player.deepslateBroken();
long obsidian = player.obsidianBroken();
long placements = player.blocksPlacedInBase();
long baseBreaks = player.blocksBrokenInBase();
switch (counter) {
case GRASS_DIRT -> grassDirt = amount;
case STONE -> stone = amount;
case DEEPSLATE -> deepslate = amount;
case OBSIDIAN -> obsidian = amount;
case PLACEMENTS -> placements = amount;
case BASE_BREAKS -> baseBreaks = amount;
}
PluginSettings configured = settings.current();
int base = player.baseLevel();
if (grassDirt >= configured.navigationUnlockBlocks()) {
base = Math.max(base, 2);
} else if (grassDirt >= configured.baseUnlockBlocks()) {
base = Math.max(base, 1);
}
int size = player.sizeLevel();
if (stone >= configured.stoneExpansionBlocks()) {
base = Math.max(base, 1);
size = Math.max(size, 1);
}
if (deepslate >= configured.deepslateExpansionBlocks()) {
base = Math.max(base, 1);
size = Math.max(size, 2);
}
if (obsidian >= configured.obsidianExpansionBlocks()) {
base = Math.max(base, 1);
size = Math.max(size, 3);
}
int warmup = player.warmupLevel();
if (placements >= configured.teleportUnlockPlacements()) {
base = Math.max(base, 3);
}
if (placements >= configured.instantWarmupPlacements()) {
warmup = Math.max(warmup, 3);
} else if (placements >= configured.thirdWarmupPlacements()) {
warmup = Math.max(warmup, 2);
} else if (placements >= configured.secondWarmupPlacements()) {
warmup = Math.max(warmup, 1);
}
int cooldown = player.cooldownLevel();
if (baseBreaks >= configured.firstCooldownBreaks()) {
base = Math.max(base, 3);
}
if (baseBreaks >= configured.instantCooldownBreaks()) {
cooldown = Math.max(cooldown, 4);
} else if (baseBreaks >= configured.thirdCooldownBreaks()) {
cooldown = Math.max(cooldown, 3);
} else if (baseBreaks >= configured.secondCooldownBreaks()) {
cooldown = Math.max(cooldown, 2);
} else if (baseBreaks >= configured.firstCooldownBreaks()) {
cooldown = Math.max(cooldown, 1);
}
PlayerState updated = player.withProgressCounters(
grassDirt, stone, deepslate, obsidian, placements, baseBreaks
);
return updated.withAdministrativeLevels(
base,
size,
updated.flightLevel(),
warmup,
cooldown,
updated.navigationEnabled(),
updated.flightEnabled(),
updated.visitorsEnabled()
);
}
public PlayerState resetPath(PlayerState player, ProgressionPath path) {
if (path == ProgressionPath.BASE) {
return PlayerState.newPlayer(player.playerId(), player.latestName());
}
PlayerState cleared = switch (path) {
case SIZE -> player.withProgressCounters(
player.grassAndDirtBroken(),
0,
0,
0,
player.blocksPlacedInBase(),
player.blocksBrokenInBase()
);
case WARMUP -> player.withProgressCounters(
player.grassAndDirtBroken(),
player.stoneBroken(),
player.deepslateBroken(),
player.obsidianBroken(),
player.baseLevel() >= 3 ? settings.current().teleportUnlockPlacements() : 0,
player.blocksBrokenInBase()
);
case COOLDOWN -> player.withProgressCounters(
player.grassAndDirtBroken(),
player.stoneBroken(),
player.deepslateBroken(),
player.obsidianBroken(),
player.blocksPlacedInBase(),
0
);
case FLIGHT -> player;
case BASE -> throw new IllegalStateException("base reset already handled");
};
return setLevel(cleared, path, 0);
}
private static void requireRange(int value, int minimum, int maximum, String path) {
if (value < minimum || value > maximum) {
throw new IllegalArgumentException(
@@ -9,14 +9,24 @@ import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
import org.bukkit.plugin.java.JavaPlugin;
final class BaseAdminCommand implements CommandExecutor {
private final JavaPlugin plugin;
private final BaseStateManager stateManager;
private final AdminProgressionService progressionService;
private final PluginSettingsProvider settingsProvider;
BaseAdminCommand(BaseStateManager stateManager, AdminProgressionService progressionService) {
BaseAdminCommand(
JavaPlugin plugin,
BaseStateManager stateManager,
AdminProgressionService progressionService,
PluginSettingsProvider settingsProvider
) {
this.plugin = plugin;
this.stateManager = stateManager;
this.progressionService = progressionService;
this.settingsProvider = settingsProvider;
}
@Override
@@ -25,6 +35,9 @@ final class BaseAdminCommand implements CommandExecutor {
sender.sendMessage(ChatColor.RED + "You do not have permission to administer Spigot Base.");
return true;
}
if (arguments.length > 0 && arguments[0].equalsIgnoreCase("config")) {
return updateConfig(sender, arguments);
}
if (arguments.length < 2) {
sendUsage(sender);
return true;
@@ -37,6 +50,7 @@ final class BaseAdminCommand implements CommandExecutor {
return switch (arguments[0].toLowerCase(Locale.ROOT)) {
case "progress" -> showProgress(sender, target.orElseThrow());
case "setlevel" -> setLevel(sender, target.orElseThrow(), arguments);
case "setprogress" -> setProgress(sender, target.orElseThrow(), arguments);
case "clearcooldown" -> clearCooldown(sender, target.orElseThrow(), arguments);
case "reset" -> reset(sender, target.orElseThrow(), arguments);
default -> {
@@ -92,6 +106,37 @@ final class BaseAdminCommand implements CommandExecutor {
return true;
}
private boolean setProgress(
CommandSender sender,
PlayerState target,
String[] arguments
) {
if (arguments.length != 4) {
sender.sendMessage(ChatColor.RED
+ "Usage: /baseadmin setprogress <player> <counter> <amount>");
return true;
}
try {
ProgressCounter counter = ProgressCounter.valueOf(
arguments[2].toUpperCase(Locale.ROOT).replace('-', '_')
);
long amount = Long.parseLong(arguments[3]);
PlayerState updated = stateManager.update(
target.playerId(),
target.latestName(),
current -> progressionService.setProgress(current, counter, amount)
);
stateManager.saveIfDirty();
sender.sendMessage(ChatColor.GREEN + "Set " + updated.latestName() + "'s "
+ counter.name().toLowerCase(Locale.ROOT) + " progress to " + amount + ".");
} catch (NumberFormatException exception) {
sender.sendMessage(ChatColor.RED + "The progress amount must be an integer.");
} catch (IllegalArgumentException exception) {
sender.sendMessage(ChatColor.RED + exception.getMessage());
}
return true;
}
private boolean clearCooldown(
CommandSender sender,
PlayerState target,
@@ -146,7 +191,7 @@ final class BaseAdminCommand implements CommandExecutor {
stateManager.update(
target.playerId(),
target.latestName(),
current -> progressionService.setLevel(current, path, 0)
current -> progressionService.resetPath(current, path)
);
stateManager.saveIfDirty();
sender.sendMessage(ChatColor.GREEN + "Reset " + path.name().toLowerCase(Locale.ROOT)
@@ -157,6 +202,43 @@ final class BaseAdminCommand implements CommandExecutor {
return true;
}
private boolean updateConfig(CommandSender sender, String[] arguments) {
if (arguments.length != 3) {
sender.sendMessage(ChatColor.RED + "Usage: /baseadmin config <key> <integer>");
return true;
}
String key = arguments[1];
Object previous = plugin.getConfig().get(key);
if (!(previous instanceof Number)) {
sender.sendMessage(ChatColor.RED + "Unknown numeric setting: " + key);
return true;
}
try {
long value = Long.parseLong(arguments[2]);
java.util.Map<String, Object> proposed =
new java.util.HashMap<>(plugin.getConfig().getValues(false));
proposed.put(key, value);
PluginSettings updated = PluginSettingsValidator.validateMaterials(
PluginSettings.from(proposed)
);
plugin.getConfig().set(key, value);
try {
plugin.saveConfig();
} catch (RuntimeException exception) {
plugin.getConfig().set(key, previous);
throw exception;
}
settingsProvider.update(updated);
sender.sendMessage(ChatColor.GREEN + "Updated " + key + " to " + value
+ "; the change is active immediately.");
} catch (NumberFormatException exception) {
sender.sendMessage(ChatColor.RED + "The setting value must be an integer.");
} catch (IllegalArgumentException exception) {
sender.sendMessage(ChatColor.RED + exception.getMessage());
}
return true;
}
private Optional<PlayerState> resolve(String name) {
Player online = Bukkit.getPlayerExact(name);
if (online != null) {
@@ -167,6 +249,7 @@ final class BaseAdminCommand implements CommandExecutor {
private static void sendUsage(CommandSender sender) {
sender.sendMessage(ChatColor.YELLOW + "Usage: /baseadmin "
+ "<progress|setlevel|clearcooldown|reset> <player> ...");
+ "<progress|setlevel|setprogress|clearcooldown|reset> <player> ...");
sender.sendMessage(ChatColor.YELLOW + " /baseadmin config <key> <integer>");
}
}
@@ -1,26 +1,30 @@
package games.dmg.spigotbase;
public final class BaseBoundsService {
private final PluginSettings settings;
private final PluginSettingsProvider settings;
public BaseBoundsService(PluginSettings settings) {
this(new PluginSettingsProvider(settings));
}
public BaseBoundsService(PluginSettingsProvider 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();
case 0 -> settings.current().initialRadius();
case 1 -> settings.current().firstExpandedRadius();
case 2 -> settings.current().secondExpandedRadius();
case 3 -> settings.current().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 0, 1 -> settings.current().initialVerticalRange();
case 2 -> settings.current().secondFlightVerticalRange();
case 3 -> Integer.MAX_VALUE;
default -> throw new IllegalArgumentException("unknown flight level");
};
@@ -14,13 +14,13 @@ final class BaseCommand implements CommandExecutor {
private final BaseTeleportManager teleportManager;
private final BaseStateManager stateManager;
private final VisitorPolicy visitorPolicy;
private final PluginSettings settings;
private final PluginSettingsProvider settings;
BaseCommand(
BaseTeleportManager teleportManager,
BaseStateManager stateManager,
VisitorPolicy visitorPolicy,
PluginSettings settings
PluginSettingsProvider settings
) {
this.teleportManager = teleportManager;
this.stateManager = stateManager;
@@ -54,14 +54,16 @@ final class BaseCommand implements CommandExecutor {
: "You must unlock Base III before purchasing Base IV."));
return;
}
int price = settings.visitorUnlockDiamondCost();
int price = settings.current().visitorUnlockDiamondCost();
Material currency = Material.valueOf(settings.current().visitorCurrencyMaterial());
PlayerInventory inventory = player.getInventory();
if (countDiamonds(inventory) < price) {
player.sendMessage(ChatColor.RED + "Base IV costs " + price + " diamonds.");
if (countCurrency(inventory, currency) < price) {
player.sendMessage(ChatColor.RED + "Base IV costs " + price + " "
+ currency.name().toLowerCase(java.util.Locale.ROOT) + ".");
return;
}
ItemStack[] snapshot = cloneContents(inventory.getStorageContents());
removeDiamonds(inventory, price);
removeCurrency(inventory, currency, price);
try {
stateManager.updateAndSave(
player.getUniqueId(),
@@ -76,27 +78,34 @@ final class BaseCommand implements CommandExecutor {
player.sendTitle(
ChatColor.GOLD + "Base IV Unlocked",
ChatColor.YELLOW + "Visitors may now teleport to your base",
10, 70, 20
settings.current().titleFadeInTicks(),
settings.current().titleStayTicks(),
settings.current().titleFadeOutTicks()
);
player.sendMessage(ChatColor.GREEN + "Base IV unlocked for " + price + " diamonds.");
player.sendMessage(ChatColor.GREEN + "Base IV unlocked for " + price + " "
+ currency.name().toLowerCase(java.util.Locale.ROOT) + ".");
}
private static int countDiamonds(PlayerInventory inventory) {
private static int countCurrency(PlayerInventory inventory, Material currency) {
int count = 0;
for (ItemStack item : inventory.getStorageContents()) {
if (item != null && item.getType() == Material.DIAMOND) {
if (item != null && item.getType() == currency) {
count += item.getAmount();
}
}
return count;
}
private static void removeDiamonds(PlayerInventory inventory, int amount) {
private static void removeCurrency(
PlayerInventory inventory,
Material currency,
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) {
if (item == null || item.getType() != currency) {
continue;
}
int removed = Math.min(remaining, item.getAmount());
@@ -15,7 +15,7 @@ final class BaseFlightController implements Runnable {
private final BaseStateManager stateManager;
private final SecondaryProgressionService progressionService;
private final BaseBoundsService boundsService;
private final PluginSettings settings;
private final PluginSettingsProvider settings;
private final Set<UUID> grantedFlight = new HashSet<>();
private final Set<UUID> warned = new HashSet<>();
@@ -24,7 +24,7 @@ final class BaseFlightController implements Runnable {
BaseStateManager stateManager,
SecondaryProgressionService progressionService,
BaseBoundsService boundsService,
PluginSettings settings
PluginSettingsProvider settings
) {
this.server = server;
this.stateManager = stateManager;
@@ -81,7 +81,9 @@ final class BaseFlightController implements Runnable {
player.sendTitle(
ChatColor.GOLD + "Base Flight " + roman(updated.flightLevel()) + " Unlocked",
ChatColor.YELLOW + "Use /baseflight to toggle flight",
10, 70, 20
settings.current().titleFadeInTicks(),
settings.current().titleStayTicks(),
settings.current().titleFadeOutTicks()
);
return updated;
}
@@ -101,7 +103,7 @@ final class BaseFlightController implements Runnable {
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();
int bufferedRadius = radius + settings.current().flightWarningBuffer();
if (distanceSquared > (double) bufferedRadius * bufferedRadius) {
removeGrantedFlight(player);
return;
@@ -115,7 +117,9 @@ final class BaseFlightController implements Runnable {
player.sendTitle(
ChatColor.RED + "Leaving Your Base",
ChatColor.YELLOW + "Turn back before base flight ends",
0, 30, 10
0,
Math.min(30, settings.current().titleStayTicks()),
settings.current().titleFadeOutTicks()
);
}
} else {
@@ -9,10 +9,16 @@ import org.bukkit.util.Vector;
final class BaseNavigationController implements Runnable {
private final Server server;
private final BaseStateManager stateManager;
private final PluginSettingsProvider settings;
BaseNavigationController(Server server, BaseStateManager stateManager) {
BaseNavigationController(
Server server,
BaseStateManager stateManager,
PluginSettingsProvider settings
) {
this.server = server;
this.stateManager = stateManager;
this.settings = settings;
}
@Override
@@ -36,7 +42,7 @@ final class BaseNavigationController implements Runnable {
continue;
}
direction.normalize();
for (int step = 1; step <= 5; step++) {
for (int step = 1; step <= settings.current().navigationParticleCount(); 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);
}
@@ -8,10 +8,10 @@ import org.bukkit.entity.Player;
final class BaseProgressCommand implements CommandExecutor {
private final BaseStateManager stateManager;
private final PluginSettings settings;
private final PluginSettingsProvider settings;
private final TeleportPolicy teleportPolicy;
BaseProgressCommand(BaseStateManager stateManager, PluginSettings settings) {
BaseProgressCommand(BaseStateManager stateManager, PluginSettingsProvider settings) {
this.stateManager = stateManager;
this.settings = settings;
this.teleportPolicy = new TeleportPolicy(settings);
@@ -53,13 +53,13 @@ final class BaseProgressCommand implements CommandExecutor {
private void showBasePath(Player player, PlayerState state) {
String detail = switch (state.baseLevel()) {
case 0 -> state.grassAndDirtBroken() + "/" + settings.baseUnlockBlocks()
case 0 -> state.grassAndDirtBroken() + "/" + settings.current().baseUnlockBlocks()
+ " grass or dirt → /setbase";
case 1 -> state.grassAndDirtBroken() + "/" + settings.navigationUnlockBlocks()
case 1 -> state.grassAndDirtBroken() + "/" + settings.current().navigationUnlockBlocks()
+ " grass or dirt → navigation";
case 2 -> state.blocksPlacedInBase() + "/" + settings.teleportUnlockPlacements()
case 2 -> state.blocksPlacedInBase() + "/" + settings.current().teleportUnlockPlacements()
+ " placements → /base";
case 3 -> settings.visitorUnlockDiamondCost() + " diamonds → visitor access";
case 3 -> settings.current().visitorUnlockDiamondCost() + " diamonds → visitor access";
case 4 -> "complete; visitor access unlocked";
default -> "invalid";
};
@@ -69,9 +69,9 @@ final class BaseProgressCommand implements CommandExecutor {
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 0 -> state.stoneBroken() + "/" + settings.current().stoneExpansionBlocks() + " stone";
case 1 -> state.deepslateBroken() + "/" + settings.current().deepslateExpansionBlocks() + " deepslate";
case 2 -> state.obsidianBroken() + "/" + settings.current().obsidianExpansionBlocks() + " obsidian";
case 3 -> "complete; 150-block radius by default";
default -> "invalid";
};
@@ -90,9 +90,9 @@ final class BaseProgressCommand implements CommandExecutor {
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 0 -> state.blocksPlacedInBase() + "/" + settings.current().secondWarmupPlacements();
case 1 -> state.blocksPlacedInBase() + "/" + settings.current().thirdWarmupPlacements();
case 2 -> state.blocksPlacedInBase() + "/" + settings.current().instantWarmupPlacements();
case 3 -> "complete";
default -> "invalid";
};
@@ -103,10 +103,10 @@ final class BaseProgressCommand implements CommandExecutor {
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 0 -> state.blocksBrokenInBase() + "/" + settings.current().firstCooldownBreaks();
case 1 -> state.blocksBrokenInBase() + "/" + settings.current().secondCooldownBreaks();
case 2 -> state.blocksBrokenInBase() + "/" + settings.current().thirdCooldownBreaks();
case 3 -> state.blocksBrokenInBase() + "/" + settings.current().instantCooldownBreaks();
case 4 -> "complete";
default -> "invalid";
};
@@ -19,15 +19,13 @@ 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 PluginSettingsProvider settings;
private final Map<UUID, BossBar> activeBossBars = new HashMap<>();
BaseProgressListener(
@@ -37,7 +35,7 @@ final class BaseProgressListener implements Listener {
SecondaryProgressionService secondaryProgressionService,
TeleportProgressionService teleportProgressionService,
BaseBoundsService boundsService,
PluginSettings settings
PluginSettingsProvider settings
) {
this.plugin = plugin;
this.stateManager = stateManager;
@@ -63,13 +61,15 @@ final class BaseProgressListener implements Listener {
event.getBlock().getY(),
event.getBlock().getZ()
);
if (!isProgressMaterial(material) && !(insideBase && before.baseLevel() >= 3)) {
boolean cooldownEligible = insideBase && before.baseLevel() >= 3
&& !settings.current().cooldownExcludedMaterials().contains(material.name());
if (!isProgressMaterial(material) && !cooldownEligible) {
return;
}
ProgressionUpdate[] updateHolder = new ProgressionUpdate[1];
PlayerState state = stateManager.update(player.getUniqueId(), player.getName(), current -> {
ProgressionUpdate materialUpdate = updateForMaterial(current, material);
ProgressionUpdate breakUpdate = insideBase
ProgressionUpdate breakUpdate = cooldownEligible
? teleportProgressionService.recordBreak(materialUpdate.player())
: ProgressionUpdate.unchanged(materialUpdate.player());
ProgressionUpdate combined = combine(materialUpdate, breakUpdate);
@@ -82,7 +82,7 @@ final class BaseProgressListener implements Listener {
stateManager.saveIfDirty();
}
if (state.bossBarEnabled()) {
if (insideBase && before.baseLevel() >= 3) {
if (cooldownEligible) {
showProgress(player, cooldownDisplay(state));
} else {
showProgress(player, progressDisplay(state, material));
@@ -127,13 +127,21 @@ final class BaseProgressListener implements Listener {
}
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);
};
String name = material.name();
PluginSettings configured = settings.current();
if (configured.baseUnlockMaterials().contains(name)) {
return baseProgressionService.recordGrassOrDirtBreak(player);
}
if (configured.stoneExpansionMaterial().equals(name)) {
return secondaryProgressionService.recordStoneBreak(player);
}
if (configured.deepslateExpansionMaterial().equals(name)) {
return secondaryProgressionService.recordDeepslateBreak(player);
}
if (configured.obsidianExpansionMaterial().equals(name)) {
return secondaryProgressionService.recordObsidianBreak(player);
}
return ProgressionUpdate.unchanged(player);
}
private void announceUnlock(Player player, ProgressionUpdate update) {
@@ -146,37 +154,48 @@ final class BaseProgressListener implements Listener {
case 4 -> "Visitors can now travel to your base";
default -> "A new base benefit is available";
};
player.sendTitle(
sendUnlockTitle(
player,
ChatColor.GOLD + "Base " + roman(level) + " Unlocked",
ChatColor.YELLOW + subtitle,
10, 70, 20
ChatColor.YELLOW + subtitle
);
player.sendMessage(ChatColor.GREEN + "You unlocked Base " + roman(level) + "! " + subtitle);
}
if (update.unlockedSizeLevel()) {
int radius = boundsService.radius(update.player());
player.sendTitle(
sendUnlockTitle(
player,
ChatColor.GOLD + "Base Size Upgraded",
ChatColor.YELLOW + "Your base radius is now " + radius + " blocks",
10, 70, 20
ChatColor.YELLOW + "Your base radius is now " + radius + " blocks"
);
}
if (update.unlockedWarmupLevel()) {
player.sendTitle(
sendUnlockTitle(
player,
ChatColor.GOLD + "Teleport Warm-up Improved",
ChatColor.YELLOW + "Your /base warm-up is now shorter",
10, 70, 20
ChatColor.YELLOW + "Your /base warm-up is now shorter"
);
}
if (update.unlockedCooldownLevel()) {
player.sendTitle(
sendUnlockTitle(
player,
ChatColor.GOLD + "Teleport Cooldown Improved",
ChatColor.YELLOW + "You can use /base more often",
10, 70, 20
ChatColor.YELLOW + "You can use /base more often"
);
}
}
private void sendUnlockTitle(Player player, String title, String subtitle) {
PluginSettings configured = settings.current();
player.sendTitle(
title,
subtitle,
configured.titleFadeInTicks(),
configured.titleStayTicks(),
configured.titleFadeOutTicks()
);
}
private void showProgress(Player player, ProgressDisplay display) {
if (display == null) {
removeBossBar(player.getUniqueId());
@@ -195,46 +214,54 @@ final class BaseProgressListener implements Listener {
if (activeBossBars.remove(player.getUniqueId(), bossBar)) {
bossBar.removeAll();
}
}, BOSS_BAR_TICKS);
}, settings.current().bossBarDurationTicks());
}
private ProgressDisplay progressDisplay(PlayerState state, Material material) {
return switch (material) {
case GRASS_BLOCK, DIRT -> state.baseLevel() < 2
? new ProgressDisplay(
String name = material.name();
PluginSettings configured = settings.current();
if (configured.baseUnlockMaterials().contains(name) && state.baseLevel() < 2) {
return 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;
};
state.baseLevel() == 0
? configured.baseUnlockBlocks()
: configured.navigationUnlockBlocks()
);
}
if (configured.stoneExpansionMaterial().equals(name) && state.sizeLevel() == 0) {
return new ProgressDisplay(
"Base Size II", state.stoneBroken(), configured.stoneExpansionBlocks()
);
}
if (configured.deepslateExpansionMaterial().equals(name) && state.sizeLevel() == 1) {
return new ProgressDisplay(
"Base Size III", state.deepslateBroken(), configured.deepslateExpansionBlocks()
);
}
if (configured.obsidianExpansionMaterial().equals(name) && state.sizeLevel() == 2) {
return new ProgressDisplay(
"Base Size IV", state.obsidianBroken(), configured.obsidianExpansionBlocks()
);
}
return null;
}
private ProgressDisplay warmupDisplay(PlayerState state) {
if (state.baseLevel() == 2) {
return new ProgressDisplay(
"Base III", state.blocksPlacedInBase(), settings.teleportUnlockPlacements()
"Base III", state.blocksPlacedInBase(), settings.current().teleportUnlockPlacements()
);
}
return switch (state.warmupLevel()) {
case 0 -> new ProgressDisplay(
"15s Warm-up", state.blocksPlacedInBase(), settings.secondWarmupPlacements()
"15s Warm-up", state.blocksPlacedInBase(), settings.current().secondWarmupPlacements()
);
case 1 -> new ProgressDisplay(
"5s Warm-up", state.blocksPlacedInBase(), settings.thirdWarmupPlacements()
"5s Warm-up", state.blocksPlacedInBase(), settings.current().thirdWarmupPlacements()
);
case 2 -> new ProgressDisplay(
"Instant Warm-up", state.blocksPlacedInBase(), settings.instantWarmupPlacements()
"Instant Warm-up", state.blocksPlacedInBase(), settings.current().instantWarmupPlacements()
);
default -> null;
};
@@ -243,16 +270,16 @@ final class BaseProgressListener implements Listener {
private ProgressDisplay cooldownDisplay(PlayerState state) {
return switch (state.cooldownLevel()) {
case 0 -> new ProgressDisplay(
"2h Cooldown", state.blocksBrokenInBase(), settings.firstCooldownBreaks()
"2h Cooldown", state.blocksBrokenInBase(), settings.current().firstCooldownBreaks()
);
case 1 -> new ProgressDisplay(
"1h Cooldown", state.blocksBrokenInBase(), settings.secondCooldownBreaks()
"1h Cooldown", state.blocksBrokenInBase(), settings.current().secondCooldownBreaks()
);
case 2 -> new ProgressDisplay(
"30m Cooldown", state.blocksBrokenInBase(), settings.thirdCooldownBreaks()
"30m Cooldown", state.blocksBrokenInBase(), settings.current().thirdCooldownBreaks()
);
case 3 -> new ProgressDisplay(
"Instant Cooldown", state.blocksBrokenInBase(), settings.instantCooldownBreaks()
"Instant Cooldown", state.blocksBrokenInBase(), settings.current().instantCooldownBreaks()
);
default -> null;
};
@@ -289,10 +316,13 @@ final class BaseProgressListener implements Listener {
}
}
private static boolean isProgressMaterial(Material material) {
return material == Material.GRASS_BLOCK || material == Material.DIRT
|| material == Material.STONE || material == Material.DEEPSLATE
|| material == Material.OBSIDIAN;
private boolean isProgressMaterial(Material material) {
String name = material.name();
PluginSettings configured = settings.current();
return configured.baseUnlockMaterials().contains(name)
|| configured.stoneExpansionMaterial().equals(name)
|| configured.deepslateExpansionMaterial().equals(name)
|| configured.obsidianExpansionMaterial().equals(name);
}
private static String roman(int level) {
@@ -1,9 +1,13 @@
package games.dmg.spigotbase;
public final class BaseProgressionService {
private final PluginSettings settings;
private final PluginSettingsProvider settings;
public BaseProgressionService(PluginSettings settings) {
this(new PluginSettingsProvider(settings));
}
public BaseProgressionService(PluginSettingsProvider settings) {
this.settings = settings;
}
@@ -13,10 +17,10 @@ public final class BaseProgressionService {
: player.grassAndDirtBroken() + 1;
int previousLevel = player.baseLevel();
int baseLevel = previousLevel;
if (previousLevel == 0 && count >= settings.baseUnlockBlocks()) {
if (previousLevel == 0 && count >= settings.current().baseUnlockBlocks()) {
baseLevel = 1;
}
if (previousLevel == 1 && count >= settings.navigationUnlockBlocks()) {
if (previousLevel == 1 && count >= settings.current().navigationUnlockBlocks()) {
baseLevel = 2;
}
boolean unlocked = baseLevel != previousLevel;
@@ -5,9 +5,13 @@ import java.time.Instant;
import java.util.Optional;
public final class BaseService {
private final PluginSettings settings;
private final PluginSettingsProvider settings;
public BaseService(PluginSettings settings) {
this(new PluginSettingsProvider(settings));
}
public BaseService(PluginSettingsProvider settings) {
this.settings = settings;
}
@@ -23,7 +27,7 @@ public final class BaseService {
return Optional.empty();
}
Instant availableAt = player.lastBaseSet().orElseThrow()
.plusSeconds(settings.relocationCooldownSeconds());
.plusSeconds(settings.current().relocationCooldownSeconds());
if (!now.isBefore(availableAt)) {
return Optional.empty();
}
@@ -193,6 +193,23 @@ public record PlayerState(
);
}
public PlayerState withProgressCounters(
long grassDirt,
long stone,
long deepslate,
long obsidian,
long placements,
long baseBreaks
) {
return new PlayerState(
playerId, latestName, base, baseLevel, sizeLevel, flightLevel,
warmupLevel, cooldownLevel, grassDirt, stone, deepslate,
obsidian, placements, baseBreaks,
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
lastBaseSet, lastBaseTeleport, visitorCooldownUntil
);
}
public PlayerState withAdministrativeLevels(
int newBaseLevel,
int newSizeLevel,
@@ -1,7 +1,10 @@
package games.dmg.spigotbase;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
public record PluginSettings(
int baseUnlockBlocks,
@@ -32,7 +35,18 @@ public record PluginSettings(
long secondTeleportCooldownSeconds,
long thirdTeleportCooldownSeconds,
long fourthTeleportCooldownSeconds,
int visitorUnlockDiamondCost
int visitorUnlockDiamondCost,
Set<String> baseUnlockMaterials,
String stoneExpansionMaterial,
String deepslateExpansionMaterial,
String obsidianExpansionMaterial,
String visitorCurrencyMaterial,
Set<String> cooldownExcludedMaterials,
int bossBarDurationTicks,
int navigationParticleCount,
int titleFadeInTicks,
int titleStayTicks,
int titleFadeOutTicks
) {
private static final int DEFAULT_BASE_UNLOCK_BLOCKS = 250;
private static final int DEFAULT_NAVIGATION_UNLOCK_BLOCKS = 500;
@@ -63,6 +77,11 @@ public record PluginSettings(
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;
private static final int DEFAULT_BOSS_BAR_DURATION_TICKS = 60;
private static final int DEFAULT_NAVIGATION_PARTICLE_COUNT = 5;
private static final int DEFAULT_TITLE_FADE_IN_TICKS = 10;
private static final int DEFAULT_TITLE_STAY_TICKS = 70;
private static final int DEFAULT_TITLE_FADE_OUT_TICKS = 20;
public PluginSettings {
requirePositive(baseUnlockBlocks, "base-unlock-blocks");
@@ -118,6 +137,25 @@ public record PluginSettings(
throw new IllegalArgumentException("teleport cooldown durations must decrease");
}
requirePositive(visitorUnlockDiamondCost, "visitor-unlock-diamond-cost");
baseUnlockMaterials = normalizedSet(baseUnlockMaterials, "base-unlock-materials", false);
stoneExpansionMaterial = normalizedName(stoneExpansionMaterial, "stone-expansion-material");
deepslateExpansionMaterial = normalizedName(
deepslateExpansionMaterial, "deepslate-expansion-material"
);
obsidianExpansionMaterial = normalizedName(
obsidianExpansionMaterial, "obsidian-expansion-material"
);
visitorCurrencyMaterial = normalizedName(
visitorCurrencyMaterial, "visitor-currency-material"
);
cooldownExcludedMaterials = normalizedSet(
cooldownExcludedMaterials, "cooldown-excluded-materials", true
);
requirePositive(bossBarDurationTicks, "boss-bar-duration-ticks");
requirePositive(navigationParticleCount, "navigation-particle-count");
requireNonNegative(titleFadeInTicks, "title-fade-in-ticks");
requirePositive(titleStayTicks, "title-stay-ticks");
requireNonNegative(titleFadeOutTicks, "title-fade-out-ticks");
}
public static PluginSettings from(Map<String, ?> values) {
@@ -167,10 +205,57 @@ public record PluginSettings(
"fourth-teleport-cooldown-seconds",
DEFAULT_FOURTH_TELEPORT_COOLDOWN_SECONDS
),
integer(values, "visitor-unlock-diamond-cost", DEFAULT_VISITOR_UNLOCK_DIAMOND_COST)
integer(values, "visitor-unlock-diamond-cost", DEFAULT_VISITOR_UNLOCK_DIAMOND_COST),
stringSet(values, "base-unlock-materials", Set.of("GRASS_BLOCK", "DIRT")),
string(values, "stone-expansion-material", "STONE"),
string(values, "deepslate-expansion-material", "DEEPSLATE"),
string(values, "obsidian-expansion-material", "OBSIDIAN"),
string(values, "visitor-currency-material", "DIAMOND"),
stringSet(values, "cooldown-excluded-materials", Set.of()),
integer(values, "boss-bar-duration-ticks", DEFAULT_BOSS_BAR_DURATION_TICKS),
integer(values, "navigation-particle-count", DEFAULT_NAVIGATION_PARTICLE_COUNT),
integer(values, "title-fade-in-ticks", DEFAULT_TITLE_FADE_IN_TICKS),
integer(values, "title-stay-ticks", DEFAULT_TITLE_STAY_TICKS),
integer(values, "title-fade-out-ticks", DEFAULT_TITLE_FADE_OUT_TICKS)
);
}
private static String string(Map<String, ?> values, String key, String defaultValue) {
Object value = values.get(key);
return value == null ? defaultValue : normalizedName(value.toString(), key);
}
private static Set<String> stringSet(
Map<String, ?> values,
String key,
Set<String> defaultValue
) {
Object value = values.get(key);
if (value == null) {
return defaultValue;
}
if (!(value instanceof List<?> list)) {
throw new IllegalArgumentException(key + " must be a list");
}
return list.stream().map(Object::toString).collect(Collectors.toUnmodifiableSet());
}
private static Set<String> normalizedSet(Set<String> values, String key, boolean allowEmpty) {
if (values == null || (!allowEmpty && values.isEmpty())) {
throw new IllegalArgumentException(key + " must not be empty");
}
return values.stream()
.map(value -> normalizedName(value, key))
.collect(Collectors.toUnmodifiableSet());
}
private static String normalizedName(String value, String key) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException(key + " contains an empty material name");
}
return value.trim().toUpperCase(java.util.Locale.ROOT);
}
private static int integer(Map<String, ?> values, String key, int defaultValue) {
long value = longInteger(values, key, defaultValue);
if (value > Integer.MAX_VALUE || value < Integer.MIN_VALUE) {
@@ -0,0 +1,19 @@
package games.dmg.spigotbase;
import java.util.Objects;
public final class PluginSettingsProvider {
private volatile PluginSettings current;
public PluginSettingsProvider(PluginSettings initial) {
current = Objects.requireNonNull(initial, "initial");
}
public PluginSettings current() {
return current;
}
public void update(PluginSettings updated) {
current = Objects.requireNonNull(updated, "updated");
}
}
@@ -0,0 +1,35 @@
package games.dmg.spigotbase;
import java.util.stream.Stream;
import org.bukkit.Material;
final class PluginSettingsValidator {
private PluginSettingsValidator() {
}
static PluginSettings validateMaterials(PluginSettings settings) {
Stream.concat(
settings.baseUnlockMaterials().stream(),
Stream.of(
settings.stoneExpansionMaterial(),
settings.deepslateExpansionMaterial(),
settings.obsidianExpansionMaterial()
)
).forEach(name -> {
Material material = Material.matchMaterial(name);
if (material == null || !material.isBlock()) {
throw new IllegalArgumentException(name + " must identify a block material");
}
});
for (String name : settings.cooldownExcludedMaterials()) {
if (Material.matchMaterial(name) == null) {
throw new IllegalArgumentException(name + " is not a known material");
}
}
Material currency = Material.matchMaterial(settings.visitorCurrencyMaterial());
if (currency == null || currency.isAir()) {
throw new IllegalArgumentException("visitor-currency-material is invalid");
}
return settings;
}
}
@@ -0,0 +1,10 @@
package games.dmg.spigotbase;
public enum ProgressCounter {
GRASS_DIRT,
STONE,
DEEPSLATE,
OBSIDIAN,
PLACEMENTS,
BASE_BREAKS
}
@@ -1,9 +1,13 @@
package games.dmg.spigotbase;
public final class SecondaryProgressionService {
private final PluginSettings settings;
private final PluginSettingsProvider settings;
public SecondaryProgressionService(PluginSettings settings) {
this(new PluginSettingsProvider(settings));
}
public SecondaryProgressionService(PluginSettingsProvider settings) {
this.settings = settings;
}
@@ -12,7 +16,7 @@ public final class SecondaryProgressionService {
return ProgressionUpdate.unchanged(player);
}
long count = increment(player.stoneBroken());
boolean unlocked = count >= settings.stoneExpansionBlocks();
boolean unlocked = count >= settings.current().stoneExpansionBlocks();
return new ProgressionUpdate(
player.withSizeProgress(
count, player.deepslateBroken(), player.obsidianBroken(), unlocked ? 1 : 0
@@ -26,7 +30,7 @@ public final class SecondaryProgressionService {
return ProgressionUpdate.unchanged(player);
}
long count = increment(player.deepslateBroken());
boolean unlocked = count >= settings.deepslateExpansionBlocks();
boolean unlocked = count >= settings.current().deepslateExpansionBlocks();
return new ProgressionUpdate(
player.withSizeProgress(
player.stoneBroken(), count, player.obsidianBroken(), unlocked ? 2 : 1
@@ -40,7 +44,7 @@ public final class SecondaryProgressionService {
return ProgressionUpdate.unchanged(player);
}
long count = increment(player.obsidianBroken());
boolean unlocked = count >= settings.obsidianExpansionBlocks();
boolean unlocked = count >= settings.current().obsidianExpansionBlocks();
return new ProgressionUpdate(
player.withSizeProgress(
player.stoneBroken(), player.deepslateBroken(), count, unlocked ? 3 : 2
@@ -9,7 +9,7 @@ import org.bukkit.command.PluginCommand;
import org.bukkit.plugin.java.JavaPlugin;
public final class SpigotBasePlugin extends JavaPlugin {
private PluginSettings settings;
private PluginSettingsProvider settingsProvider;
private BaseStateManager stateManager;
private BaseProgressListener progressListener;
private BaseFlightController flightController;
@@ -20,7 +20,9 @@ public final class SpigotBasePlugin extends JavaPlugin {
saveDefaultConfig();
try {
Map<String, Object> values = getConfig().getValues(false);
settings = PluginSettings.from(values);
settingsProvider = new PluginSettingsProvider(
PluginSettingsValidator.validateMaterials(PluginSettings.from(values))
);
stateManager = new BaseStateManager(
new YamlBaseStateRepository(getDataFolder().toPath().resolve("state.yml")),
getLogger()
@@ -31,13 +33,13 @@ public final class SpigotBasePlugin extends JavaPlugin {
return;
}
BaseService baseService = new BaseService(settings);
BaseProgressionService progressionService = new BaseProgressionService(settings);
BaseService baseService = new BaseService(settingsProvider);
BaseProgressionService progressionService = new BaseProgressionService(settingsProvider);
SecondaryProgressionService secondaryProgressionService =
new SecondaryProgressionService(settings);
new SecondaryProgressionService(settingsProvider);
TeleportProgressionService teleportProgressionService =
new TeleportProgressionService(settings);
BaseBoundsService boundsService = new BaseBoundsService(settings);
new TeleportProgressionService(settingsProvider);
BaseBoundsService boundsService = new BaseBoundsService(settingsProvider);
progressListener = new BaseProgressListener(
this,
stateManager,
@@ -45,16 +47,16 @@ public final class SpigotBasePlugin extends JavaPlugin {
secondaryProgressionService,
teleportProgressionService,
boundsService,
settings
settingsProvider
);
flightController = new BaseFlightController(
getServer(), stateManager, secondaryProgressionService, boundsService, settings
getServer(), stateManager, secondaryProgressionService, boundsService, settingsProvider
);
VisitorPolicy visitorPolicy = new VisitorPolicy();
teleportManager = new BaseTeleportManager(
this,
stateManager,
new TeleportPolicy(settings),
new TeleportPolicy(settingsProvider),
visitorPolicy,
new SafeBaseDestination(),
Clock.systemUTC()
@@ -64,9 +66,11 @@ public final class SpigotBasePlugin extends JavaPlugin {
command("setbase").setExecutor(new SetBaseCommand(stateManager, baseService, Clock.systemUTC()));
command("base").setExecutor(
new BaseCommand(teleportManager, stateManager, visitorPolicy, settings)
new BaseCommand(teleportManager, stateManager, visitorPolicy, settingsProvider)
);
command("baseprogress").setExecutor(
new BaseProgressCommand(stateManager, settingsProvider)
);
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));
@@ -74,11 +78,19 @@ public final class SpigotBasePlugin extends JavaPlugin {
command("gotobase").setExecutor(goToBaseCommand);
command("gotobase").setTabCompleter(goToBaseCommand);
command("baseadmin").setExecutor(
new BaseAdminCommand(stateManager, new AdminProgressionService())
new BaseAdminCommand(
this,
stateManager,
new AdminProgressionService(settingsProvider),
settingsProvider
)
);
getServer().getScheduler().runTaskTimer(
this, new BaseNavigationController(getServer(), stateManager), 10L, 10L
this,
new BaseNavigationController(getServer(), stateManager, settingsProvider),
10L,
10L
);
getServer().getScheduler().runTaskTimer(this, flightController, 5L, 5L);
getServer().getScheduler().runTaskTimer(this, stateManager::saveIfDirty, 600L, 600L);
@@ -102,10 +114,10 @@ public final class SpigotBasePlugin extends JavaPlugin {
}
PluginSettings settings() {
if (settings == null) {
if (settingsProvider == null) {
throw new IllegalStateException("Plugin settings are unavailable");
}
return settings;
return settingsProvider.current();
}
private PluginCommand command(String name) {
@@ -5,17 +5,21 @@ import java.time.Instant;
import java.util.Optional;
public final class TeleportPolicy {
private final PluginSettings settings;
private final PluginSettingsProvider settings;
public TeleportPolicy(PluginSettings settings) {
this(new PluginSettingsProvider(settings));
}
public TeleportPolicy(PluginSettingsProvider 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 0 -> Duration.ofSeconds(settings.current().initialWarmupSeconds());
case 1 -> Duration.ofSeconds(settings.current().secondWarmupSeconds());
case 2 -> Duration.ofSeconds(settings.current().thirdWarmupSeconds());
case 3 -> Duration.ZERO;
default -> throw new IllegalArgumentException("unknown warm-up level");
};
@@ -23,10 +27,10 @@ public final class TeleportPolicy {
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 0 -> Duration.ofSeconds(settings.current().initialTeleportCooldownSeconds());
case 1 -> Duration.ofSeconds(settings.current().secondTeleportCooldownSeconds());
case 2 -> Duration.ofSeconds(settings.current().thirdTeleportCooldownSeconds());
case 3 -> Duration.ofSeconds(settings.current().fourthTeleportCooldownSeconds());
case 4 -> Duration.ZERO;
default -> throw new IllegalArgumentException("unknown cooldown level");
};
@@ -1,9 +1,13 @@
package games.dmg.spigotbase;
public final class TeleportProgressionService {
private final PluginSettings settings;
private final PluginSettingsProvider settings;
public TeleportProgressionService(PluginSettings settings) {
this(new PluginSettingsProvider(settings));
}
public TeleportProgressionService(PluginSettingsProvider settings) {
this.settings = settings;
}
@@ -13,7 +17,7 @@ public final class TeleportProgressionService {
}
long placements = increment(player.blocksPlacedInBase());
int baseLevel = player.baseLevel();
if (baseLevel == 2 && placements >= settings.teleportUnlockPlacements()) {
if (baseLevel == 2 && placements >= settings.current().teleportUnlockPlacements()) {
baseLevel = 3;
}
int warmupLevel = baseLevel >= 3 ? warmupLevel(placements) : 0;
@@ -56,29 +60,29 @@ public final class TeleportProgressionService {
}
private int warmupLevel(long placements) {
if (placements >= settings.instantWarmupPlacements()) {
if (placements >= settings.current().instantWarmupPlacements()) {
return 3;
}
if (placements >= settings.thirdWarmupPlacements()) {
if (placements >= settings.current().thirdWarmupPlacements()) {
return 2;
}
if (placements >= settings.secondWarmupPlacements()) {
if (placements >= settings.current().secondWarmupPlacements()) {
return 1;
}
return 0;
}
private int cooldownLevel(long breaks) {
if (breaks >= settings.instantCooldownBreaks()) {
if (breaks >= settings.current().instantCooldownBreaks()) {
return 4;
}
if (breaks >= settings.thirdCooldownBreaks()) {
if (breaks >= settings.current().thirdCooldownBreaks()) {
return 3;
}
if (breaks >= settings.secondCooldownBreaks()) {
if (breaks >= settings.current().secondCooldownBreaks()) {
return 2;
}
if (breaks >= settings.firstCooldownBreaks()) {
if (breaks >= settings.current().firstCooldownBreaks()) {
return 1;
}
return 0;
@@ -38,7 +38,8 @@ public final class YamlBaseStateRepository {
if (parent != null) {
Files.createDirectories(parent);
}
YamlConfiguration yaml = new YamlConfiguration();
YamlConfiguration yaml = loadForSave();
removePlayersAbsentFrom(state, yaml);
savePlayers(yaml, state.players());
Path temporary = Files.createTempFile(parent, "spigot-base-state-", ".yml");
@@ -59,6 +60,38 @@ public final class YamlBaseStateRepository {
}
}
private YamlConfiguration loadForSave() throws IOException {
YamlConfiguration yaml = new YamlConfiguration();
if (!Files.exists(stateFile)) {
return yaml;
}
try {
yaml.load(stateFile.toFile());
return yaml;
} catch (InvalidConfigurationException exception) {
throw new IOException("state file is not valid YAML", exception);
}
}
private static void removePlayersAbsentFrom(
PersistentState state,
YamlConfiguration yaml
) {
ConfigurationSection section = yaml.getConfigurationSection("players");
if (section == null) {
return;
}
for (String key : section.getKeys(false)) {
try {
if (!state.players().containsKey(UUID.fromString(key))) {
yaml.set("players." + key, null);
}
} catch (IllegalArgumentException exception) {
yaml.set("players." + key, null);
}
}
}
private static Map<UUID, PlayerState> loadPlayers(YamlConfiguration yaml) {
Map<UUID, PlayerState> players = new HashMap<>();
ConfigurationSection section = yaml.getConfigurationSection("players");
+15
View File
@@ -1,5 +1,8 @@
# Core Base progression
base-unlock-blocks: 250
base-unlock-materials:
- GRASS_BLOCK
- DIRT
navigation-unlock-blocks: 500
initial-radius: 10
initial-vertical-range: 25
@@ -7,8 +10,11 @@ relocation-cooldown-seconds: 86400
# Base size
stone-expansion-blocks: 500
stone-expansion-material: STONE
deepslate-expansion-blocks: 1000
deepslate-expansion-material: DEEPSLATE
obsidian-expansion-blocks: 1000
obsidian-expansion-material: OBSIDIAN
first-expanded-radius: 25
second-expanded-radius: 75
third-expanded-radius: 150
@@ -38,3 +44,12 @@ fourth-teleport-cooldown-seconds: 1800
# Base IV
visitor-unlock-diamond-cost: 128
visitor-currency-material: DIAMOND
# Progress and notification presentation
cooldown-excluded-materials: []
boss-bar-duration-ticks: 60
navigation-particle-count: 5
title-fade-in-ticks: 10
title-stay-ticks: 70
title-fade-out-ticks: 20
@@ -0,0 +1,34 @@
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 AdminProgressCounterTest {
private final AdminProgressionService service = new AdminProgressionService(
new PluginSettingsProvider(PluginSettings.from(Map.of()))
);
@Test
void settingPlacementCounterEvaluatesBaseAndWarmupTiers() {
PlayerState player = PlayerState.newPlayer(UUID.randomUUID(), "Alex");
PlayerState updated = service.setProgress(player, ProgressCounter.PLACEMENTS, 2_000);
assertEquals(3, updated.baseLevel());
assertEquals(2, updated.warmupLevel());
assertEquals(2_000, updated.blocksPlacedInBase());
}
@Test
void settingBaseBreakCounterEvaluatesCooldownTier() {
PlayerState player = PlayerState.newPlayer(UUID.randomUUID(), "Alex");
PlayerState updated = service.setProgress(player, ProgressCounter.BASE_BREAKS, 5_000);
assertEquals(3, updated.baseLevel());
assertEquals(4, updated.cooldownLevel());
}
}
@@ -0,0 +1,37 @@
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 AdminResetPathTest {
private final AdminProgressionService service = new AdminProgressionService(
new PluginSettingsProvider(PluginSettings.from(Map.of()))
);
@Test
void resettingSizeClearsItsLevelsAndCounters() {
PlayerState player = PlayerState.newPlayer(UUID.randomUUID(), "Alex")
.withGrassAndDirtProgress(500, 2)
.withSizeProgress(500, 1_000, 1_000, 3);
PlayerState reset = service.resetPath(player, ProgressionPath.SIZE);
assertEquals(0, reset.sizeLevel());
assertEquals(0, reset.stoneBroken());
assertEquals(0, reset.deepslateBroken());
assertEquals(0, reset.obsidianBroken());
}
@Test
void resettingBaseCascadesAllDependentProgress() {
PlayerState player = PlayerState.newPlayer(UUID.randomUUID(), "Alex")
.withAdministrativeLevels(4, 3, 3, 3, 4, true, true, true);
PlayerState reset = service.resetPath(player, ProgressionPath.BASE);
assertEquals(PlayerState.newPlayer(player.playerId(), player.latestName()), reset);
}
}
@@ -0,0 +1,23 @@
package games.dmg.spigotbase;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.time.Duration;
import java.util.Map;
import java.util.UUID;
import org.junit.jupiter.api.Test;
final class PluginSettingsProviderTest {
@Test
void servicesObserveValidatedRuntimeUpdates() {
PluginSettingsProvider provider =
new PluginSettingsProvider(PluginSettings.from(Map.of()));
TeleportPolicy policy = new TeleportPolicy(provider);
PlayerState player = PlayerState.newPlayer(UUID.randomUUID(), "Alex")
.withGrassAndDirtProgress(500, 3);
provider.update(PluginSettings.from(Map.of("initial-warmup-seconds", 20)));
assertEquals(Duration.ofSeconds(20), policy.warmup(player));
}
}
@@ -56,6 +56,26 @@ final class YamlBaseStateRepositoryTest {
assertFalse(loaded.players().containsKey(playerId));
}
@Test
void preservesUnknownForwardCompatibleFieldsWhenSaving() throws Exception {
UUID playerId = UUID.randomUUID();
Path stateFile = temporaryDirectory.resolve("state.yml");
Files.writeString(stateFile, """
future-root: retained
players:
%s:
name: Alex
future-player-field: retained
""".formatted(playerId));
YamlBaseStateRepository repository = new YamlBaseStateRepository(stateFile);
repository.save(repository.load());
String saved = Files.readString(stateFile);
org.junit.jupiter.api.Assertions.assertTrue(saved.contains("future-root: retained"));
org.junit.jupiter.api.Assertions.assertTrue(saved.contains("future-player-field: retained"));
}
@Test
void missingFileLoadsEmptyState() throws Exception {
YamlBaseStateRepository repository =