diff --git a/README.md b/README.md index 347db1f..e47f7e0 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,8 @@ The `spigotbase.admin` permission is granted to server operators by default. ```text /baseadmin progress /baseadmin setlevel +/baseadmin setprogress +/baseadmin config /baseadmin clearcooldown [personal|visitor|all] /baseadmin reset /baseadmin reset all confirm diff --git a/design/log.md b/design/log.md index 33e10e4..78f27a5 100644 --- a/design/log.md +++ b/design/log.md @@ -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. diff --git a/design/user-stories/us-001-unlock-and-establish-base.md b/design/user-stories/us-001-unlock-and-establish-base.md index 7b3b760..49af35e 100644 --- a/design/user-stories/us-001-unlock-and-establish-base.md +++ b/design/user-stories/us-001-unlock-and-establish-base.md @@ -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 diff --git a/design/user-stories/us-002-unlock-base-navigation.md b/design/user-stories/us-002-unlock-base-navigation.md index 4b7f963..76d5113 100644 --- a/design/user-stories/us-002-unlock-base-navigation.md +++ b/design/user-stories/us-002-unlock-base-navigation.md @@ -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 diff --git a/design/user-stories/us-003-expand-the-base.md b/design/user-stories/us-003-expand-the-base.md index b958883..5c6a310 100644 --- a/design/user-stories/us-003-expand-the-base.md +++ b/design/user-stories/us-003-expand-the-base.md @@ -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 diff --git a/design/user-stories/us-004-unlock-and-control-base-flight.md b/design/user-stories/us-004-unlock-and-control-base-flight.md index b1ce5a1..723c6e1 100644 --- a/design/user-stories/us-004-unlock-and-control-base-flight.md +++ b/design/user-stories/us-004-unlock-and-control-base-flight.md @@ -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 diff --git a/design/user-stories/us-005-unlock-base-teleportation.md b/design/user-stories/us-005-unlock-base-teleportation.md index f244b7d..83d68ce 100644 --- a/design/user-stories/us-005-unlock-base-teleportation.md +++ b/design/user-stories/us-005-unlock-base-teleportation.md @@ -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 diff --git a/design/user-stories/us-006-reduce-teleport-warmup.md b/design/user-stories/us-006-reduce-teleport-warmup.md index f1f8c0b..6be7b7a 100644 --- a/design/user-stories/us-006-reduce-teleport-warmup.md +++ b/design/user-stories/us-006-reduce-teleport-warmup.md @@ -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 diff --git a/design/user-stories/us-007-reduce-teleport-cooldown.md b/design/user-stories/us-007-reduce-teleport-cooldown.md index f56cb0a..5ce9016 100644 --- a/design/user-stories/us-007-reduce-teleport-cooldown.md +++ b/design/user-stories/us-007-reduce-teleport-cooldown.md @@ -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 diff --git a/design/user-stories/us-008-unlock-visitor-access.md b/design/user-stories/us-008-unlock-visitor-access.md index 9901de5..b6403cf 100644 --- a/design/user-stories/us-008-unlock-visitor-access.md +++ b/design/user-stories/us-008-unlock-visitor-access.md @@ -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 ` 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 ` 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 diff --git a/design/user-stories/us-009-view-progression-and-notifications.md b/design/user-stories/us-009-view-progression-and-notifications.md index d299a81..a3a764d 100644 --- a/design/user-stories/us-009-view-progression-and-notifications.md +++ b/design/user-stories/us-009-view-progression-and-notifications.md @@ -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 diff --git a/design/user-stories/us-010-administer-player-progression.md b/design/user-stories/us-010-administer-player-progression.md index b9220c1..86e4f1e 100644 --- a/design/user-stories/us-010-administer-player-progression.md +++ b/design/user-stories/us-010-administer-player-progression.md @@ -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 ` displays the player's base, counters, earned path levels, active cooldowns, toggles, and visitor settings. -- [ ] `/baseadmin setlevel ` sets an earned path level while enforcing or explicitly granting required preceding levels. -- [ ] `/baseadmin setprogress ` updates the selected counter and consistently evaluates reached tiers. -- [ ] `/baseadmin reset ` resets a selected path without silently leaving benefits that require it. -- [ ] `/baseadmin reset all` removes the player's base, progression, active cooldowns, and plugin preferences after confirmation. -- [ ] Administrators can clear personal and visitor cooldowns independently. -- [ ] Administrative commands can update configured block requirements, warm-ups, and cooldowns for each level using validated values. -- [ ] Runtime configuration changes are persisted for subsequent restarts. -- [ ] Lowered progression requirements are evaluated for a player on their next relevant action rather than immediately updating every stored player. -- [ ] Every successful mutation reports exactly what changed, and invalid requests make no partial changes. +- [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 ` displays the player's base, counters, earned path levels, active cooldowns, toggles, and visitor settings. +- [x] `/baseadmin setlevel ` sets an earned path level while enforcing or explicitly granting required preceding levels. +- [x] `/baseadmin setprogress ` updates the selected counter and consistently evaluates reached tiers. +- [x] `/baseadmin reset ` resets a selected path without silently leaving benefits that require it. +- [x] `/baseadmin reset 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 diff --git a/design/user-stories/us-011-configure-and-persist-progression.md b/design/user-stories/us-011-configure-and-persist-progression.md index 9ba32cb..7f44b47 100644 --- a/design/user-stories/us-011-configure-and-persist-progression.md +++ b/design/user-stories/us-011-configure-and-persist-progression.md @@ -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 diff --git a/design/user-stories/us-012-build-and-release-plugin.md b/design/user-stories/us-012-build-and-release-plugin.md index d073e36..66274a0 100644 --- a/design/user-stories/us-012-build-and-release-plugin.md +++ b/design/user-stories/us-012-build-and-release-plugin.md @@ -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 diff --git a/gradle/gradlew b/gradle/gradlew deleted file mode 100755 index 203529c..0000000 --- a/gradle/gradlew +++ /dev/null @@ -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" "$@" diff --git a/gradle/gradlew.bat b/gradle/gradlew.bat deleted file mode 100644 index 7e60b72..0000000 --- a/gradle/gradlew.bat +++ /dev/null @@ -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% diff --git a/src/main/java/games/dmg/spigotbase/AdminProgressionService.java b/src/main/java/games/dmg/spigotbase/AdminProgressionService.java index 2ebeca1..60a23ee 100644 --- a/src/main/java/games/dmg/spigotbase/AdminProgressionService.java +++ b/src/main/java/games/dmg/spigotbase/AdminProgressionService.java @@ -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( diff --git a/src/main/java/games/dmg/spigotbase/BaseAdminCommand.java b/src/main/java/games/dmg/spigotbase/BaseAdminCommand.java index 2d7af0f..65b9ec3 100644 --- a/src/main/java/games/dmg/spigotbase/BaseAdminCommand.java +++ b/src/main/java/games/dmg/spigotbase/BaseAdminCommand.java @@ -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 "); + 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 "); + 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 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 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 " - + " ..."); + + " ..."); + sender.sendMessage(ChatColor.YELLOW + " /baseadmin config "); } } diff --git a/src/main/java/games/dmg/spigotbase/BaseBoundsService.java b/src/main/java/games/dmg/spigotbase/BaseBoundsService.java index 8b58041..02a1948 100644 --- a/src/main/java/games/dmg/spigotbase/BaseBoundsService.java +++ b/src/main/java/games/dmg/spigotbase/BaseBoundsService.java @@ -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"); }; diff --git a/src/main/java/games/dmg/spigotbase/BaseCommand.java b/src/main/java/games/dmg/spigotbase/BaseCommand.java index f40b923..a64fdfc 100644 --- a/src/main/java/games/dmg/spigotbase/BaseCommand.java +++ b/src/main/java/games/dmg/spigotbase/BaseCommand.java @@ -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()); diff --git a/src/main/java/games/dmg/spigotbase/BaseFlightController.java b/src/main/java/games/dmg/spigotbase/BaseFlightController.java index 2d67085..b5eafbc 100644 --- a/src/main/java/games/dmg/spigotbase/BaseFlightController.java +++ b/src/main/java/games/dmg/spigotbase/BaseFlightController.java @@ -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 grantedFlight = new HashSet<>(); private final Set 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 { diff --git a/src/main/java/games/dmg/spigotbase/BaseNavigationController.java b/src/main/java/games/dmg/spigotbase/BaseNavigationController.java index a44a195..11488c2 100644 --- a/src/main/java/games/dmg/spigotbase/BaseNavigationController.java +++ b/src/main/java/games/dmg/spigotbase/BaseNavigationController.java @@ -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); } diff --git a/src/main/java/games/dmg/spigotbase/BaseProgressCommand.java b/src/main/java/games/dmg/spigotbase/BaseProgressCommand.java index 5336428..1273fc5 100644 --- a/src/main/java/games/dmg/spigotbase/BaseProgressCommand.java +++ b/src/main/java/games/dmg/spigotbase/BaseProgressCommand.java @@ -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"; }; diff --git a/src/main/java/games/dmg/spigotbase/BaseProgressListener.java b/src/main/java/games/dmg/spigotbase/BaseProgressListener.java index 0251ff2..0b6f9eb 100644 --- a/src/main/java/games/dmg/spigotbase/BaseProgressListener.java +++ b/src/main/java/games/dmg/spigotbase/BaseProgressListener.java @@ -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 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( - 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; - }; + 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 + ? 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) { diff --git a/src/main/java/games/dmg/spigotbase/BaseProgressionService.java b/src/main/java/games/dmg/spigotbase/BaseProgressionService.java index 94d30cd..4253847 100644 --- a/src/main/java/games/dmg/spigotbase/BaseProgressionService.java +++ b/src/main/java/games/dmg/spigotbase/BaseProgressionService.java @@ -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; diff --git a/src/main/java/games/dmg/spigotbase/BaseService.java b/src/main/java/games/dmg/spigotbase/BaseService.java index 0cab9d5..0f46d30 100644 --- a/src/main/java/games/dmg/spigotbase/BaseService.java +++ b/src/main/java/games/dmg/spigotbase/BaseService.java @@ -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(); } diff --git a/src/main/java/games/dmg/spigotbase/PlayerState.java b/src/main/java/games/dmg/spigotbase/PlayerState.java index 5a15988..5caca5b 100644 --- a/src/main/java/games/dmg/spigotbase/PlayerState.java +++ b/src/main/java/games/dmg/spigotbase/PlayerState.java @@ -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, diff --git a/src/main/java/games/dmg/spigotbase/PluginSettings.java b/src/main/java/games/dmg/spigotbase/PluginSettings.java index 6eba152..3f3306f 100644 --- a/src/main/java/games/dmg/spigotbase/PluginSettings.java +++ b/src/main/java/games/dmg/spigotbase/PluginSettings.java @@ -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 baseUnlockMaterials, + String stoneExpansionMaterial, + String deepslateExpansionMaterial, + String obsidianExpansionMaterial, + String visitorCurrencyMaterial, + Set 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 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 values, String key, String defaultValue) { + Object value = values.get(key); + return value == null ? defaultValue : normalizedName(value.toString(), key); + } + + private static Set stringSet( + Map values, + String key, + Set 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 normalizedSet(Set 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 values, String key, int defaultValue) { long value = longInteger(values, key, defaultValue); if (value > Integer.MAX_VALUE || value < Integer.MIN_VALUE) { diff --git a/src/main/java/games/dmg/spigotbase/PluginSettingsProvider.java b/src/main/java/games/dmg/spigotbase/PluginSettingsProvider.java new file mode 100644 index 0000000..7d4d4cc --- /dev/null +++ b/src/main/java/games/dmg/spigotbase/PluginSettingsProvider.java @@ -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"); + } +} diff --git a/src/main/java/games/dmg/spigotbase/PluginSettingsValidator.java b/src/main/java/games/dmg/spigotbase/PluginSettingsValidator.java new file mode 100644 index 0000000..78451fa --- /dev/null +++ b/src/main/java/games/dmg/spigotbase/PluginSettingsValidator.java @@ -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; + } +} diff --git a/src/main/java/games/dmg/spigotbase/ProgressCounter.java b/src/main/java/games/dmg/spigotbase/ProgressCounter.java new file mode 100644 index 0000000..2a30556 --- /dev/null +++ b/src/main/java/games/dmg/spigotbase/ProgressCounter.java @@ -0,0 +1,10 @@ +package games.dmg.spigotbase; + +public enum ProgressCounter { + GRASS_DIRT, + STONE, + DEEPSLATE, + OBSIDIAN, + PLACEMENTS, + BASE_BREAKS +} diff --git a/src/main/java/games/dmg/spigotbase/SecondaryProgressionService.java b/src/main/java/games/dmg/spigotbase/SecondaryProgressionService.java index b573926..f2a252a 100644 --- a/src/main/java/games/dmg/spigotbase/SecondaryProgressionService.java +++ b/src/main/java/games/dmg/spigotbase/SecondaryProgressionService.java @@ -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 diff --git a/src/main/java/games/dmg/spigotbase/SpigotBasePlugin.java b/src/main/java/games/dmg/spigotbase/SpigotBasePlugin.java index eede6a1..5949d50 100644 --- a/src/main/java/games/dmg/spigotbase/SpigotBasePlugin.java +++ b/src/main/java/games/dmg/spigotbase/SpigotBasePlugin.java @@ -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 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) { diff --git a/src/main/java/games/dmg/spigotbase/TeleportPolicy.java b/src/main/java/games/dmg/spigotbase/TeleportPolicy.java index 9c54d19..1bfbe70 100644 --- a/src/main/java/games/dmg/spigotbase/TeleportPolicy.java +++ b/src/main/java/games/dmg/spigotbase/TeleportPolicy.java @@ -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"); }; diff --git a/src/main/java/games/dmg/spigotbase/TeleportProgressionService.java b/src/main/java/games/dmg/spigotbase/TeleportProgressionService.java index b8e546a..179fb4a 100644 --- a/src/main/java/games/dmg/spigotbase/TeleportProgressionService.java +++ b/src/main/java/games/dmg/spigotbase/TeleportProgressionService.java @@ -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; diff --git a/src/main/java/games/dmg/spigotbase/YamlBaseStateRepository.java b/src/main/java/games/dmg/spigotbase/YamlBaseStateRepository.java index 1d8a155..8f8bc5c 100644 --- a/src/main/java/games/dmg/spigotbase/YamlBaseStateRepository.java +++ b/src/main/java/games/dmg/spigotbase/YamlBaseStateRepository.java @@ -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 loadPlayers(YamlConfiguration yaml) { Map players = new HashMap<>(); ConfigurationSection section = yaml.getConfigurationSection("players"); diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 0ebf0ed..a88f5a7 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -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 diff --git a/src/test/java/games/dmg/spigotbase/AdminProgressCounterTest.java b/src/test/java/games/dmg/spigotbase/AdminProgressCounterTest.java new file mode 100644 index 0000000..3edbbf5 --- /dev/null +++ b/src/test/java/games/dmg/spigotbase/AdminProgressCounterTest.java @@ -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()); + } +} diff --git a/src/test/java/games/dmg/spigotbase/AdminResetPathTest.java b/src/test/java/games/dmg/spigotbase/AdminResetPathTest.java new file mode 100644 index 0000000..4c86728 --- /dev/null +++ b/src/test/java/games/dmg/spigotbase/AdminResetPathTest.java @@ -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); + } +} diff --git a/src/test/java/games/dmg/spigotbase/PluginSettingsProviderTest.java b/src/test/java/games/dmg/spigotbase/PluginSettingsProviderTest.java new file mode 100644 index 0000000..6e3c918 --- /dev/null +++ b/src/test/java/games/dmg/spigotbase/PluginSettingsProviderTest.java @@ -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)); + } +} diff --git a/src/test/java/games/dmg/spigotbase/YamlBaseStateRepositoryTest.java b/src/test/java/games/dmg/spigotbase/YamlBaseStateRepositoryTest.java index c8603b1..f51da15 100644 --- a/src/test/java/games/dmg/spigotbase/YamlBaseStateRepositoryTest.java +++ b/src/test/java/games/dmg/spigotbase/YamlBaseStateRepositoryTest.java @@ -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 =