Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4518478ea1 | ||
|
|
23e8fcec6e | ||
|
|
11e3ad1720 | ||
|
|
8a9640d992 | ||
|
|
3be3e43072 | ||
|
|
9692df9101 | ||
|
|
f36c40d5aa | ||
|
|
259ea9410c | ||
|
|
26aeb8c416 | ||
|
|
4278e08bf3 | ||
|
|
5c9dc5b361 | ||
|
|
00aa7566ad | ||
|
|
84e013ac11 |
@@ -22,30 +22,43 @@ The plugin JAR is written to `build/libs/`.
|
|||||||
## Player commands
|
## Player commands
|
||||||
|
|
||||||
```text
|
```text
|
||||||
/setbase
|
/setbase (alias: /sethome)
|
||||||
/base
|
/base (alias: /home)
|
||||||
/base upgrade
|
/gotobase <player> (alias: /visit <player>)
|
||||||
/basenavigation
|
/basesettings (alias: /homesettings)
|
||||||
/baseflight
|
/basesettings status
|
||||||
/basevisitors
|
/basesettings upgrade
|
||||||
/gotobase <player>
|
/basesettings pocket upgrade
|
||||||
/baseprogress
|
/basesettings pocket mobs <enable|disable>
|
||||||
/baseprogress bossbar
|
/basesettings visitors <allowed|blocked>
|
||||||
|
/basesettings navigation <enable|disable>
|
||||||
|
/basesettings flight <enable|disable>
|
||||||
|
/basesettings border <enable|disable>
|
||||||
|
/basesettings spawnable <enable|disable>
|
||||||
|
/basesettings bossbar <enable|disable>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Navigation particles appear only in the base's world and when the player is more than 25 blocks beyond the current base border.
|
||||||
|
|
||||||
|
After 250 Survival-mode block placements anywhere by default, the spawnable overlay can mark nearby dark hostile-mob spawning surfaces inside the player's base with owner-only red particles. The threshold is configurable.
|
||||||
|
|
||||||
`/base` has a stationary warm-up. Looking around is allowed, while movement between blocks, damage, teleportation, world changes, death, logout, and conflicting teleport commands cancel it without consuming the cooldown.
|
`/base` has a stationary warm-up. Looking around is allowed, while movement between blocks, damage, teleportation, world changes, death, logout, and conflicting teleport commands cancel it without consuming the cooldown.
|
||||||
|
|
||||||
|
Base IV owners can purchase and expand a persistent Pocket Base with `/basesettings pocket upgrade`. Activating a complete diamond-block portal frame inside the normal base with flint and steel opens one public entrance to the owner's grass platform in a private void world. The owner can move the return portal by building and igniting another complete frame inside the unlocked Pocket Base boundary; only the newly activated return portal remains functional. Natural hostile and passive mob spawning is disabled by default and can be controlled by the owner with `/basesettings pocket mobs <enable|disable>`.
|
||||||
|
|
||||||
## Administration
|
## Administration
|
||||||
|
|
||||||
The `spigotbase.admin` permission is granted to server operators by default.
|
The `spigotbase.admin` permission is granted to server operators by default. `/homeadmin` aliases `/baseadmin`.
|
||||||
|
|
||||||
```text
|
```text
|
||||||
/baseadmin progress <player>
|
/baseadmin status <player>
|
||||||
/baseadmin setlevel <player> <base|size|flight|warmup|cooldown> <level>
|
/baseadmin setlevel <player> <base|size|flight|warmup|cooldown> <level>
|
||||||
/baseadmin setprogress <player> <grass_dirt|stone|deepslate|obsidian|placements|base_breaks> <amount>
|
/baseadmin setprogress <player> <grass_dirt|stone|deepslate|obsidian|placements|total_placements|base_breaks> <amount>
|
||||||
|
/baseadmin setsetting <player> spawnable <enable|disable>
|
||||||
/baseadmin config <numeric-key> <value>
|
/baseadmin config <numeric-key> <value>
|
||||||
|
# e.g. /baseadmin config spawnable-overlay-unlock-placements 250
|
||||||
/baseadmin clearcooldown <player> [personal|visitor|all]
|
/baseadmin clearcooldown <player> [personal|visitor|all]
|
||||||
/baseadmin reset <player> <base|size|flight|warmup|cooldown>
|
/baseadmin reset <player> <size|flight|warmup|cooldown>
|
||||||
/baseadmin reset <player> all confirm
|
/baseadmin reset <player> all confirm
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,11 @@ description: Chronological record of material decisions affecting the Spigot Bas
|
|||||||
- Verified the implementation with `./gradlew clean check jar`: 34 tests passed and the plugin JAR was produced successfully.
|
- 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.
|
- User stories remain in progress pending live-server integration verification and completion of runtime administrative configuration editing.
|
||||||
|
|
||||||
|
## 2026-08-09 — Navigation preference fix
|
||||||
|
|
||||||
|
- Added explicit, idempotent `/basenavigation on` and `/basenavigation off` modes with autocomplete while retaining no-argument toggling.
|
||||||
|
- Preserved a disabled navigation preference during later grass-or-dirt progression instead of forcing navigation back on.
|
||||||
|
|
||||||
## 2026-08-09 — Initial release scope completed
|
## 2026-08-09 — Initial release scope completed
|
||||||
|
|
||||||
- Added live, validated, persisted numeric configuration updates through `/baseadmin config`.
|
- Added live, validated, persisted numeric configuration updates through `/baseadmin config`.
|
||||||
@@ -35,3 +40,68 @@ description: Chronological record of material decisions affecting the Spigot Bas
|
|||||||
- Made progression materials, visitor currency, boss-bar duration, particle count, and title timing configurable.
|
- 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.
|
- 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.
|
- Completed and verified all approved user stories with the automated Gradle check lifecycle.
|
||||||
|
|
||||||
|
## 2026-08-10 — Player command aliases
|
||||||
|
|
||||||
|
- Added `/sethome` for `/setbase`, `/home` for `/base`, and `/visit` for `/gotobase`.
|
||||||
|
- Visitor autocomplete remains available for eligible offline bases through the `/visit` alias.
|
||||||
|
- Verified the aliases and plugin build with `./gradlew clean check jar`.
|
||||||
|
|
||||||
|
## 2026-08-10 — Toggle command autocomplete
|
||||||
|
|
||||||
|
- Added explicit, idempotent `on` and `off` modes and autocomplete to base flight and visitor access while retaining no-argument toggling.
|
||||||
|
- Added `/homenavigation`, `/homeflight`, and `/homevisitors` aliases with the same autocomplete as their canonical commands.
|
||||||
|
- Generalized toggle preference resolution for navigation, flight, and visitor access.
|
||||||
|
- Verified the implementation with `./gradlew clean check jar`.
|
||||||
|
|
||||||
|
## 2026-08-10 — Unified base settings command
|
||||||
|
|
||||||
|
- Replaced the separate progress, navigation, flight, and visitor commands with `/basesettings` and its `/homesettings` alias.
|
||||||
|
- Added nested autocomplete for status, Base IV upgrades, visitor access, navigation, flight, and progress boss-bar settings.
|
||||||
|
- Moved the Base IV purchase from `/base upgrade` to `/basesettings upgrade`, leaving `/base` and `/home` dedicated to personal teleportation.
|
||||||
|
- Preserved the full progression report as the default `/basesettings` response and as `/basesettings status`.
|
||||||
|
- Verified the breaking command restructure with `./gradlew clean check jar`.
|
||||||
|
|
||||||
|
## 2026-08-10 — Administrative command autocomplete and reset safety
|
||||||
|
|
||||||
|
- Added `/homeadmin` as an alias for `/baseadmin` and replaced the administrative `progress` subcommand with `status`.
|
||||||
|
- Added permission-aware, context-sensitive autocomplete for subcommands, online and known offline players, paths, levels, counters, cooldown types, reset confirmation, and numeric configuration keys.
|
||||||
|
- Removed the unsafe unconfirmed base reset path; complete resets now require `reset <player> all confirm`.
|
||||||
|
- Tightened argument validation and replaced implementation-specific enum errors with player-facing usage guidance.
|
||||||
|
- Verified the administrative revision with `./gradlew clean check jar`.
|
||||||
|
|
||||||
|
## 2026-08-10 — Base border visualization
|
||||||
|
|
||||||
|
- Added persisted, owner-only base border particles controlled by `/basesettings border enable|disable` with contextual autocomplete.
|
||||||
|
- Rendered a bounded nearby arc at the player's current height using the current base radius while respecting world and vertical bounds.
|
||||||
|
- Added border preference reporting to player and administrative status output and safe defaults for existing state files.
|
||||||
|
- Verified the feature with `./gradlew clean check jar`.
|
||||||
|
|
||||||
|
## 2026-08-10 — Spawnable block overlay
|
||||||
|
|
||||||
|
- Added persistent progression for Survival-mode block placements anywhere, with existing in-base placement counts used as a safe migration minimum.
|
||||||
|
- Added `/basesettings spawnable enable|disable` after a configurable 250-placement unlock and reported its progress and preference in player and admin status output.
|
||||||
|
- Added admin controls for total placement progress, the unlock threshold, and each unlocked player's overlay preference.
|
||||||
|
- Added owner-only red particles for nearby dark spawning surfaces inside base bounds, using bounded incremental scanning and rendering budgets.
|
||||||
|
- Verified the feature with `./gradlew clean check jar`.
|
||||||
|
|
||||||
|
## 2026-08-10 — Navigation visibility buffer
|
||||||
|
|
||||||
|
- Limited enabled base navigation particles to the base's world and positions more than 25 horizontal blocks beyond the current base border.
|
||||||
|
- Used the live size-tier radius so expansion and relocation immediately update the visibility threshold.
|
||||||
|
- Added exact-boundary and controller integration coverage.
|
||||||
|
- Verified the change with `./gradlew clean check jar`.
|
||||||
|
|
||||||
|
## 2026-08-24 — Pocket Base mob-spawning control
|
||||||
|
|
||||||
|
- Disabled natural hostile and passive mob spawning by default in new and existing Pocket Bases without a saved preference.
|
||||||
|
- Added the persisted owner setting `/basesettings pocket mobs enable|disable`, contextual autocomplete, and status reporting.
|
||||||
|
- Reapplied saved spawn flags when Pocket Base worlds load and restored the prior world setting when persistence fails.
|
||||||
|
- Verified the feature with `./gradlew clean check jar`.
|
||||||
|
|
||||||
|
## 2026-08-24 — Relocatable Pocket Base return portals
|
||||||
|
|
||||||
|
- Allowed Pocket Base owners to activate a complete return portal frame with flint and steel anywhere inside their unlocked pocket boundary.
|
||||||
|
- Persisted one active return portal location while retaining the generated portal as the default for existing Pocket Bases.
|
||||||
|
- Made only the selected intact frame functional without removing old frame blocks, preserving the void-return safety fallback.
|
||||||
|
- Verified the feature with `./gradlew clean check jar`.
|
||||||
|
|||||||
@@ -18,3 +18,9 @@ description: Catalog of user stories for the Spigot Base plugin.
|
|||||||
10. [US-010: Administer player progression](us-010-administer-player-progression.md)
|
10. [US-010: Administer player progression](us-010-administer-player-progression.md)
|
||||||
11. [US-011: Configure and persist progression](us-011-configure-and-persist-progression.md)
|
11. [US-011: Configure and persist progression](us-011-configure-and-persist-progression.md)
|
||||||
12. [US-012: Build and release the plugin](us-012-build-and-release-plugin.md)
|
12. [US-012: Build and release the plugin](us-012-build-and-release-plugin.md)
|
||||||
|
13. [US-013: Visualize the base border](us-013-visualize-base-border.md)
|
||||||
|
14. [US-014: Highlight spawnable blocks](us-014-highlight-spawnable-blocks.md)
|
||||||
|
15. [US-015: Unlock a Pocket Base](us-015-unlock-a-pocket-base.md)
|
||||||
|
16. [US-016: Build and use Pocket Base portals](us-016-build-and-use-pocket-base-portals.md)
|
||||||
|
17. [US-017: Expand a Pocket Base](us-017-expand-a-pocket-base.md)
|
||||||
|
18. [US-018: Control Pocket Base mob spawning](us-018-control-pocket-base-mob-spawning.md)
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ As a **player**, I want to earn and set a personal base so that later quality-of
|
|||||||
- [x] Player-placed blocks may contribute when broken; natural-generation detection is not required.
|
- [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] 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] `/setbase` is unavailable before Base I and explains the unmet requirement.
|
||||||
|
- [x] `/sethome` is an alias for `/setbase` with identical behavior.
|
||||||
- [x] After Base I unlocks, `/setbase` records the player's current world and block location as the center of a cylindrical base.
|
- [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 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] The first successful `/setbase` is immediately available.
|
||||||
|
|||||||
@@ -14,10 +14,14 @@ As a **player with Base I**, I want visual guidance toward my base so that I can
|
|||||||
- [x] Base II requires Base I and an established base.
|
- [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 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] 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] While enabled and in the base's world, particles are drawn along the ground only when the player is horizontally more than 25 blocks beyond the current base border.
|
||||||
|
- [x] No navigation particles appear inside the base, exactly 25 blocks beyond its border, or anywhere within that buffer.
|
||||||
|
- [x] Expansion and relocation update the navigation visibility threshold immediately.
|
||||||
- [x] Particle generation is bounded to avoid excessive server or client load.
|
- [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] 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] `/basesettings navigation enable` enables guidance idempotently after Base II is unlocked, and `/basesettings navigation disable` disables it idempotently.
|
||||||
|
- [x] Invalid navigation arguments show command usage, and `enable` and `disable` are offered through autocomplete.
|
||||||
|
- [x] Grass-or-dirt progress after Base II preserves the player's selected navigation preference.
|
||||||
- [x] The navigation preference persists across reconnects and restarts.
|
- [x] The navigation preference persists across reconnects and restarts.
|
||||||
|
|
||||||
## Related
|
## Related
|
||||||
|
|||||||
@@ -21,10 +21,11 @@ As a **player with Base I**, I want to unlock controlled flight around my base s
|
|||||||
- [x] Flight II expands the vertical range to 100 blocks below and 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] 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] 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] Plugin-granted flight remains active in the warning buffer, and a prominent on-screen notice that the player is leaving the base appears only while the player is actively flying.
|
||||||
- [x] Passing beyond the warning buffer removes only flight granted by this plugin.
|
- [x] Passing beyond the warning buffer removes only flight granted by this plugin.
|
||||||
- [x] Flight is not granted outside the unlocked vertical range.
|
- [x] Flight is not granted outside the unlocked vertical range.
|
||||||
- [x] `/baseflight` toggles the player's unlocked base flight on and off.
|
- [x] `/basesettings flight enable` enables unlocked flight idempotently, and `/basesettings flight disable` disables it idempotently.
|
||||||
|
- [x] Invalid flight arguments show command usage, and `enable` and `disable` are offered through autocomplete.
|
||||||
- [x] The flight toggle persists across reconnects and restarts.
|
- [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.
|
- [x] The plugin handles teleportation, world changes, game-mode changes, death, logout, and plugin shutdown without leaving unintended flight enabled.
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ As a **player with Base II**, I want to earn `/base` so that I can return safely
|
|||||||
- [x] Only placements made in Survival mode and within the base's current horizontal and vertical bounds count.
|
- [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] 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] Base III unlocks `/base` with a configurable 30-second warm-up and three-hour cooldown by default.
|
||||||
|
- [x] `/home` aliases the teleport-only `/base` command.
|
||||||
- [x] Looking around without changing block coordinates does not cancel the warm-up.
|
- [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] 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] Cancellation clearly informs the player and does not consume the cooldown.
|
||||||
|
|||||||
@@ -12,14 +12,17 @@ As a **player with Base III**, I want to open my base to visitors so that other
|
|||||||
## Acceptance criteria
|
## Acceptance criteria
|
||||||
|
|
||||||
- [x] Base IV requires Base III and an established base.
|
- [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] `/basesettings upgrade` offers the Base IV purchase for a configurable price that defaults to 128 diamonds, replacing `/base upgrade` and `/home upgrade`.
|
||||||
- [x] A successful purchase removes the complete price atomically from the player's direct inventory.
|
- [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] 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] `/basesettings visitors allowed` permits visitor access idempotently for a Base IV owner, and `/basesettings visitors blocked` blocks it idempotently.
|
||||||
|
- [x] Invalid visitor arguments show command usage, and `allowed` and `blocked` are offered through autocomplete.
|
||||||
- [x] The visitor-access preference persists across reconnects and restarts.
|
- [x] The visitor-access preference persists across reconnects and restarts.
|
||||||
- [x] `/gotobase <owner>` autocompletes bases that the requesting player is currently eligible to visit.
|
- [x] `/gotobase <owner>` autocompletes bases that the requesting player is currently eligible to visit.
|
||||||
|
- [x] `/visit <owner>` aliases `/gotobase <owner>` with identical autocomplete, including eligible bases whose owners are offline.
|
||||||
- [x] Enabled bases remain visitable while their owners are offline.
|
- [x] Enabled bases remain visitable while their owners are offline.
|
||||||
- [x] A visitor teleport uses the destination owner's current warm-up tier.
|
- [x] `/visit` and `/gotobase` require the visitor to be in the destination base's recorded world; cross-dimension requests are rejected before warm-up with a clear message and without consuming cooldown.
|
||||||
|
- [x] A visitor teleport uses the destination owner's current warm-up tier, but always requires at least a one-second stationary warm-up; `/visit` and `/gotobase` never teleport instantly.
|
||||||
- [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] 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] 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] A safe destination is resolved at or near the owner's recorded base center.
|
||||||
@@ -34,3 +37,5 @@ As a **player with Base III**, I want to open my base to visitors so that other
|
|||||||
- [US-005: Unlock Base III teleportation](us-005-unlock-base-teleportation.md)
|
- [US-005: Unlock Base III teleportation](us-005-unlock-base-teleportation.md)
|
||||||
- [US-006: Reduce the base teleport warm-up](us-006-reduce-teleport-warmup.md)
|
- [US-006: Reduce the base teleport warm-up](us-006-reduce-teleport-warmup.md)
|
||||||
- [US-007: Reduce the base teleport cooldown](us-007-reduce-teleport-cooldown.md)
|
- [US-007: Reduce the base teleport cooldown](us-007-reduce-teleport-cooldown.md)
|
||||||
|
- [US-015: Unlock a Pocket Base](us-015-unlock-a-pocket-base.md)
|
||||||
|
- [US-016: Build and use Pocket Base portals](us-016-build-and-use-pocket-base-portals.md)
|
||||||
|
|||||||
@@ -11,17 +11,20 @@ As a **player**, I want to inspect my progression and receive timely milestone f
|
|||||||
|
|
||||||
## Acceptance criteria
|
## Acceptance criteria
|
||||||
|
|
||||||
- [x] `/baseprogress` shows the player's Base, Base Size, Base Flight, Teleport Warm-up, and Teleport Cooldown paths.
|
- [x] `/basesettings` and `/basesettings status` show the player's Base, Base Size, Base Flight, Teleport Warm-up, and Teleport Cooldown paths plus current visitor, navigation, flight, and boss-bar settings.
|
||||||
- [x] Each path identifies earned levels, unmet prerequisites, current progress, the next threshold, and the next reward.
|
- [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] 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] 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] 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] The automatic boss bar disappears after a configurable number of seconds.
|
||||||
- [x] `/baseprogress bossbar` toggles automatic progress boss bars on and off.
|
- [x] `/basesettings bossbar enable` enables automatic progress boss bars idempotently, and `/basesettings bossbar disable` disables them idempotently.
|
||||||
- [x] Disabling automatic boss bars does not prevent `/baseprogress` from displaying progress.
|
- [x] Invalid boss-bar arguments show command usage, and `enable` and `disable` are offered through autocomplete.
|
||||||
|
- [x] Disabling automatic boss bars does not prevent `/basesettings` from displaying progress.
|
||||||
- [x] The boss-bar preference persists across reconnects and restarts.
|
- [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] 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.
|
- [x] Unlock notifications occur once per earned level and do not repeat after reconnecting or restarting.
|
||||||
|
- [x] `/homesettings` aliases `/basesettings` with identical behavior and autocomplete.
|
||||||
|
- [x] Superseded `/baseprogress`, `/basenavigation`, `/baseflight`, and `/basevisitors` commands and their aliases are no longer registered.
|
||||||
|
|
||||||
## Related
|
## Related
|
||||||
|
|
||||||
|
|||||||
@@ -14,16 +14,20 @@ As a **server administrator**, I want command-based progression controls so that
|
|||||||
- [x] Administrative commands require `spigotbase.admin`, which server operators receive by default.
|
- [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] 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] Player state remains keyed by UUID while retaining the latest known name for lookup and display.
|
||||||
- [x] `/baseadmin progress <player>` displays the player's base, counters, earned path levels, active cooldowns, toggles, and visitor settings.
|
- [x] `/baseadmin status <player>` displays the player's base, counters, earned path levels, active cooldowns, toggles, and visitor settings.
|
||||||
- [x] `/baseadmin setlevel <player> <path> <level>` sets an earned path level while enforcing or explicitly granting required preceding levels.
|
- [x] `/baseadmin setlevel <player> <path> <level>` sets an earned path level while enforcing or explicitly granting required preceding levels.
|
||||||
- [x] `/baseadmin setprogress <player> <path> <amount>` updates the selected counter and consistently evaluates reached tiers.
|
- [x] `/baseadmin setprogress <player> <path> <amount>` updates the selected counter and consistently evaluates reached tiers.
|
||||||
- [x] `/baseadmin reset <player> <path>` resets a selected path without silently leaving benefits that require it.
|
- [x] `/baseadmin reset <player> <size|flight|warmup|cooldown>` resets a selected path without silently leaving benefits that require it.
|
||||||
|
- [x] `/baseadmin reset <player> base` is rejected and directs the administrator to the confirmed complete-reset command.
|
||||||
- [x] `/baseadmin reset <player> all` removes the player's base, progression, active cooldowns, and plugin preferences after confirmation.
|
- [x] `/baseadmin reset <player> all` removes the player's base, progression, active cooldowns, and plugin preferences after confirmation.
|
||||||
- [x] Administrators can clear personal and visitor cooldowns independently.
|
- [x] 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] 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] 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] 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.
|
- [x] Every successful mutation reports exactly what changed, and invalid requests make no partial changes.
|
||||||
|
- [x] `/homeadmin` aliases `/baseadmin` with identical permission requirements and autocomplete.
|
||||||
|
- [x] Permission-aware autocomplete offers subcommands, online and known offline players, progression paths, valid levels, progress counters, cooldown types, reset paths and confirmation, and numeric configuration keys.
|
||||||
|
- [x] Invalid and extra arguments show friendly usage without exposing implementation-specific enum errors or modifying state.
|
||||||
|
|
||||||
## Related
|
## Related
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
---
|
||||||
|
type: User Story
|
||||||
|
title: "US-013: Visualize the base border"
|
||||||
|
description: Let players display a bounded, owner-only particle arc along the current edge of their established base.
|
||||||
|
status: done
|
||||||
|
---
|
||||||
|
|
||||||
|
# US-013: Visualize the base border
|
||||||
|
|
||||||
|
As a **player with Base I**, I want to display a particle effect at my base border so that I can understand where base benefits begin and end while building.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [x] Border visualization requires Base I and an established base.
|
||||||
|
- [x] `/basesettings border enable` enables visualization idempotently, and `/basesettings border disable` disables it idempotently.
|
||||||
|
- [x] `border`, `enable`, and `disable` are offered through contextual autocomplete.
|
||||||
|
- [x] Border visualization is disabled by default and the preference persists across reconnects and restarts.
|
||||||
|
- [x] Older state files without the preference load it as disabled.
|
||||||
|
- [x] `/basesettings status` and `/baseadmin status` display the border preference.
|
||||||
|
- [x] Particles are visible only to the base owner.
|
||||||
|
- [x] Particles trace the current circular horizontal boundary at the player's current Y-level and follow base expansion or relocation immediately.
|
||||||
|
- [x] Particles render only while the player is in the base world, within the base's vertical range, and reasonably close to the boundary.
|
||||||
|
- [x] Particle generation is bounded to avoid excessive server and client load.
|
||||||
|
- [x] A complete administrative reset disables border visualization.
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- [US-001: Unlock and establish Base I](us-001-unlock-and-establish-base.md)
|
||||||
|
- [US-003: Expand the base](us-003-expand-the-base.md)
|
||||||
|
- [US-009: View progression and unlock notifications](us-009-view-progression-and-notifications.md)
|
||||||
|
- [US-010: Administer player progression](us-010-administer-player-progression.md)
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
---
|
||||||
|
type: User Story
|
||||||
|
title: "US-014: Highlight spawnable blocks"
|
||||||
|
description: Unlock an owner-only red particle overlay for nearby hostile-mob spawnable blocks after 250 placements anywhere.
|
||||||
|
status: done
|
||||||
|
---
|
||||||
|
|
||||||
|
# US-014: Highlight spawnable blocks
|
||||||
|
|
||||||
|
As a **player with an established base**, I want to highlight nearby blocks where hostile mobs can spawn so that I can find lighting gaps in my base.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [x] Every block placed in Survival mode anywhere contributes to a persistent total-placement counter.
|
||||||
|
- [x] The overlay unlocks permanently at a configurable 250 total placements by default.
|
||||||
|
- [x] Existing players begin with at least their persisted in-base placement count because those placements are known to qualify.
|
||||||
|
- [x] `/basesettings spawnable enable` enables the unlocked overlay idempotently, and `/basesettings spawnable disable` disables it idempotently.
|
||||||
|
- [x] `spawnable`, `enable`, and `disable` are offered through contextual autocomplete.
|
||||||
|
- [x] Enabling requires both the placement unlock and an established base; the persisted preference defaults to disabled.
|
||||||
|
- [x] `/basesettings status` and `/baseadmin status` display placement progress, unlock state, and preference.
|
||||||
|
- [x] Administrators can change a player's total-placement counter through `/baseadmin setprogress`.
|
||||||
|
- [x] Administrators can persistently change the required placement threshold through `/baseadmin config spawnable-overlay-unlock-placements`.
|
||||||
|
- [x] Administrators can enable or disable an unlocked player's overlay through `/baseadmin setsetting <player> spawnable <enable|disable>`.
|
||||||
|
- [x] Owner-only red particles mark nearby candidate hostile-mob spawning surfaces inside the current base bounds.
|
||||||
|
- [x] Candidate surfaces have block light zero, a solid spawning surface, and two passable blocks above them.
|
||||||
|
- [x] Scanning is bounded to 16 horizontal blocks, eight vertical blocks, and a fixed per-update work and particle budget.
|
||||||
|
- [x] A complete administrative reset clears total-placement progress and disables the overlay.
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- [US-001: Unlock and establish Base I](us-001-unlock-and-establish-base.md)
|
||||||
|
- [US-009: View progression and notifications](us-009-view-progression-and-notifications.md)
|
||||||
|
- [US-010: Administer player progression](us-010-administer-player-progression.md)
|
||||||
|
- [US-011: Configure and persist progression](us-011-configure-and-persist-progression.md)
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
---
|
||||||
|
type: User Story
|
||||||
|
title: "US-015: Unlock a Pocket Base"
|
||||||
|
description: Let Base IV owners purchase a persistent private void world that serves as the foundation for portal access and expansion.
|
||||||
|
status: done
|
||||||
|
---
|
||||||
|
|
||||||
|
# US-015: Unlock a Pocket Base
|
||||||
|
|
||||||
|
As a **player with an established Base IV**, I want to unlock a persistent Pocket Base so that I can build in a personal space beyond the normal world.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [x] Pocket Base progression requires an established Base IV.
|
||||||
|
- [x] `/basesettings pocket upgrade` unlocks Pocket Base I and is offered through contextual autocomplete.
|
||||||
|
- [x] Unlocking atomically charges a configurable currency and amount that default to 64 diamond blocks.
|
||||||
|
- [x] Insufficient funds or failed persistence or world creation does not consume currency or grant Pocket Base I.
|
||||||
|
- [x] Pocket Base I creates a persistent owner-specific void world keyed by the owner's UUID.
|
||||||
|
- [x] The initial platform is 64 by 64 blocks with one grass layer over three dirt layers.
|
||||||
|
- [x] The pocket world provides a safe arrival location and a generated return portal.
|
||||||
|
- [x] Pocket Base worlds, contents, ownership, and progression persist through reconnects, restarts, and normal-base relocation.
|
||||||
|
- [x] `/basesettings status` displays the current Pocket Base level and the next upgrade requirement.
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- [US-008: Unlock Base IV visitor access](us-008-unlock-visitor-access.md)
|
||||||
|
- [US-009: View progression and notifications](us-009-view-progression-and-notifications.md)
|
||||||
|
- [US-011: Configure and persist progression](us-011-configure-and-persist-progression.md)
|
||||||
|
- [US-016: Build and use Pocket Base portals](us-016-build-and-use-pocket-base-portals.md)
|
||||||
|
- [US-017: Expand a Pocket Base](us-017-expand-a-pocket-base.md)
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
---
|
||||||
|
type: User Story
|
||||||
|
title: "US-016: Build and use Pocket Base portals"
|
||||||
|
description: Let an owner create one public portal between their normal base and persistent Pocket Base.
|
||||||
|
status: done
|
||||||
|
---
|
||||||
|
|
||||||
|
# US-016: Build and use Pocket Base portals
|
||||||
|
|
||||||
|
As a **Pocket Base owner**, I want to connect my normal base to my Pocket Base with a public portal so that I and other players can move between them naturally.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [x] The entrance uses a standard 4-by-5 outer frame with a 2-by-3 interior and a configurable frame material that defaults to diamond blocks.
|
||||||
|
- [x] Only the Pocket Base owner can activate a complete entrance frame with flint and steel.
|
||||||
|
- [x] Every required frame block must be within the owner's current normal-base bounds when the entrance is activated.
|
||||||
|
- [x] Custom particles and collision provide portal-like visuals and travel without relying on vanilla Nether portal blocks.
|
||||||
|
- [x] Each owner has at most one active entrance portal.
|
||||||
|
- [x] Activating another valid entrance deactivates the previous entrance without removing its frame blocks.
|
||||||
|
- [x] Breaking any required frame block immediately deactivates the entrance.
|
||||||
|
- [x] Any player can use an active entrance without a separate visitor setting, warm-up, or cooldown.
|
||||||
|
- [x] Visitors can build, break blocks, open containers, and otherwise interact normally inside the Pocket Base.
|
||||||
|
- [x] The generated return portal sends players safely to the owner's active entrance.
|
||||||
|
- [x] Only the owner can activate a complete portal frame inside their own Pocket Base with flint and steel.
|
||||||
|
- [x] Every required return-frame block must be within the owner's unlocked Pocket Base boundary.
|
||||||
|
- [x] Each Pocket Base has at most one active return portal, and activating another valid frame moves the active return location without removing the old frame.
|
||||||
|
- [x] Relocated return portals persist through restarts, while existing Pocket Bases use the generated portal as their default.
|
||||||
|
- [x] Breaking the active return frame disables return travel through it.
|
||||||
|
- [x] Return travel falls back to the server spawn when the owner has no valid normal-world entrance.
|
||||||
|
- [x] Falling below Y -64 performs the same safe return without void damage.
|
||||||
|
- [x] Players who disconnect inside a Pocket Base remain there when they reconnect.
|
||||||
|
- [x] Moving the normal base deactivates its entrance while preserving the Pocket Base world and its contents.
|
||||||
|
- [x] Portal ownership and the active entrance location persist through restarts.
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- [US-003: Expand the base](us-003-expand-the-base.md)
|
||||||
|
- [US-008: Unlock Base IV visitor access](us-008-unlock-visitor-access.md)
|
||||||
|
- [US-015: Unlock a Pocket Base](us-015-unlock-a-pocket-base.md)
|
||||||
|
- [US-017: Expand a Pocket Base](us-017-expand-a-pocket-base.md)
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
---
|
||||||
|
type: User Story
|
||||||
|
title: "US-017: Expand a Pocket Base"
|
||||||
|
description: Let Pocket Base owners purchase unlimited levels that add permanent buildable terrain around their pocket world.
|
||||||
|
status: done
|
||||||
|
---
|
||||||
|
|
||||||
|
# US-017: Expand a Pocket Base
|
||||||
|
|
||||||
|
As a **Pocket Base owner**, I want to purchase additional buildable space so that my pocket world can grow with my projects.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [x] Using `/basesettings pocket upgrade` after Pocket Base I purchases the next Pocket Base level.
|
||||||
|
- [x] Each additional level atomically charges a configurable currency and amount that default to 16 diamond blocks.
|
||||||
|
- [x] Insufficient funds or failed persistence or terrain generation does not consume currency or grant a level.
|
||||||
|
- [x] Each level adds 16 blocks on every side, producing Level I at 64 by 64, Level II at 96 by 96, Level III at 128 by 128, and size `64 + 32 × (level - 1)` thereafter.
|
||||||
|
- [x] Newly unlocked terrain has one grass layer over three dirt layers.
|
||||||
|
- [x] Expansion preserves all existing blocks, inventories, and entities in previously unlocked terrain.
|
||||||
|
- [x] Owners and visitors cannot place blocks outside the currently unlocked horizontal boundary.
|
||||||
|
- [x] Pocket Base progression has no plugin-configured level cap, subject only to Minecraft coordinate and integer limits.
|
||||||
|
- [x] Pocket Base levels, dimensions, contents, and build boundaries persist through restarts.
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- [US-011: Configure and persist progression](us-011-configure-and-persist-progression.md)
|
||||||
|
- [US-015: Unlock a Pocket Base](us-015-unlock-a-pocket-base.md)
|
||||||
|
- [US-016: Build and use Pocket Base portals](us-016-build-and-use-pocket-base-portals.md)
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
---
|
||||||
|
type: User Story
|
||||||
|
title: "US-018: Control Pocket Base mob spawning"
|
||||||
|
description: Let Pocket Base owners control natural mob spawning in their private world, with spawning disabled by default.
|
||||||
|
status: done
|
||||||
|
---
|
||||||
|
|
||||||
|
# US-018: Control Pocket Base mob spawning
|
||||||
|
|
||||||
|
As a **Pocket Base owner**, I want to enable or disable natural mob spawning in my Pocket Base so that I can choose whether the private world supports normal mob activity.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [x] Natural mob spawning is disabled by default in every new Pocket Base and in existing Pocket Bases that have no saved preference.
|
||||||
|
- [x] A Pocket Base owner can use `/basesettings pocket mobs enable|disable` to control natural mob spawning in their own Pocket Base.
|
||||||
|
- [x] The setting controls both hostile and passive natural spawning only in the owner's Pocket Base.
|
||||||
|
- [x] The command is unavailable until Pocket Base I is unlocked.
|
||||||
|
- [x] The command and its modes are offered through contextual autocomplete.
|
||||||
|
- [x] `/basesettings status` displays the current Pocket Base mob-spawning setting.
|
||||||
|
- [x] The preference persists across restarts and is reapplied when the Pocket Base world loads.
|
||||||
|
- [x] Disabling natural spawning does not remove existing mobs or prevent explicitly spawned or summoned mobs.
|
||||||
|
- [x] Persistence or world-application failures produce a clear failure message without reporting a successful change.
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- [US-015: Unlock a Pocket Base](us-015-unlock-a-pocket-base.md)
|
||||||
|
- [US-016: Build and use Pocket Base portals](us-016-build-and-use-pocket-base-portals.md)
|
||||||
|
- [US-017: Expand a Pocket Base](us-017-expand-a-pocket-base.md)
|
||||||
@@ -92,6 +92,7 @@ public final class AdminProgressionService {
|
|||||||
long deepslate = player.deepslateBroken();
|
long deepslate = player.deepslateBroken();
|
||||||
long obsidian = player.obsidianBroken();
|
long obsidian = player.obsidianBroken();
|
||||||
long placements = player.blocksPlacedInBase();
|
long placements = player.blocksPlacedInBase();
|
||||||
|
long totalPlacements = player.totalBlocksPlaced();
|
||||||
long baseBreaks = player.blocksBrokenInBase();
|
long baseBreaks = player.blocksBrokenInBase();
|
||||||
switch (counter) {
|
switch (counter) {
|
||||||
case GRASS_DIRT -> grassDirt = amount;
|
case GRASS_DIRT -> grassDirt = amount;
|
||||||
@@ -99,6 +100,7 @@ public final class AdminProgressionService {
|
|||||||
case DEEPSLATE -> deepslate = amount;
|
case DEEPSLATE -> deepslate = amount;
|
||||||
case OBSIDIAN -> obsidian = amount;
|
case OBSIDIAN -> obsidian = amount;
|
||||||
case PLACEMENTS -> placements = amount;
|
case PLACEMENTS -> placements = amount;
|
||||||
|
case TOTAL_PLACEMENTS -> totalPlacements = amount;
|
||||||
case BASE_BREAKS -> baseBreaks = amount;
|
case BASE_BREAKS -> baseBreaks = amount;
|
||||||
}
|
}
|
||||||
PluginSettings configured = settings.current();
|
PluginSettings configured = settings.current();
|
||||||
@@ -147,7 +149,11 @@ public final class AdminProgressionService {
|
|||||||
}
|
}
|
||||||
PlayerState updated = player.withProgressCounters(
|
PlayerState updated = player.withProgressCounters(
|
||||||
grassDirt, stone, deepslate, obsidian, placements, baseBreaks
|
grassDirt, stone, deepslate, obsidian, placements, baseBreaks
|
||||||
);
|
).withTotalBlocksPlaced(totalPlacements);
|
||||||
|
if (totalPlacements < configured.spawnableOverlayUnlockPlacements()
|
||||||
|
&& updated.spawnableOverlayEnabled()) {
|
||||||
|
updated = updated.withSpawnableOverlayEnabled(false);
|
||||||
|
}
|
||||||
return updated.withAdministrativeLevels(
|
return updated.withAdministrativeLevels(
|
||||||
base,
|
base,
|
||||||
size,
|
size,
|
||||||
|
|||||||
@@ -1,17 +1,38 @@
|
|||||||
package games.dmg.spigotbase;
|
package games.dmg.spigotbase;
|
||||||
|
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
import java.util.TreeSet;
|
||||||
import org.bukkit.Bukkit;
|
import org.bukkit.Bukkit;
|
||||||
import org.bukkit.ChatColor;
|
import org.bukkit.ChatColor;
|
||||||
import org.bukkit.command.Command;
|
import org.bukkit.command.Command;
|
||||||
import org.bukkit.command.CommandExecutor;
|
import org.bukkit.command.CommandExecutor;
|
||||||
import org.bukkit.command.CommandSender;
|
import org.bukkit.command.CommandSender;
|
||||||
|
import org.bukkit.command.TabCompleter;
|
||||||
import org.bukkit.entity.Player;
|
import org.bukkit.entity.Player;
|
||||||
import org.bukkit.plugin.java.JavaPlugin;
|
import org.bukkit.plugin.java.JavaPlugin;
|
||||||
|
|
||||||
final class BaseAdminCommand implements CommandExecutor {
|
final class BaseAdminCommand implements CommandExecutor, TabCompleter {
|
||||||
|
private static final List<String> SUBCOMMANDS = List.of(
|
||||||
|
"status", "setlevel", "setprogress", "setsetting", "clearcooldown", "reset", "config"
|
||||||
|
);
|
||||||
|
private static final List<String> PATHS = List.of(
|
||||||
|
"base", "size", "flight", "warmup", "cooldown"
|
||||||
|
);
|
||||||
|
private static final List<String> COUNTERS = List.of(
|
||||||
|
"grass_dirt", "stone", "deepslate", "obsidian", "placements", "total_placements",
|
||||||
|
"base_breaks"
|
||||||
|
);
|
||||||
|
private static final List<String> SETTINGS = List.of("spawnable");
|
||||||
|
private static final List<String> ENABLE_MODES = List.of("enable", "disable");
|
||||||
|
private static final List<String> COOLDOWN_TYPES = List.of("personal", "visitor", "all");
|
||||||
|
private static final List<String> RESET_PATHS = List.of(
|
||||||
|
"size", "flight", "warmup", "cooldown", "all"
|
||||||
|
);
|
||||||
|
private static final List<String> LEVELS_THREE = List.of("0", "1", "2", "3");
|
||||||
|
private static final List<String> LEVELS_FOUR = List.of("0", "1", "2", "3", "4");
|
||||||
private final JavaPlugin plugin;
|
private final JavaPlugin plugin;
|
||||||
private final BaseStateManager stateManager;
|
private final BaseStateManager stateManager;
|
||||||
private final AdminProgressionService progressionService;
|
private final AdminProgressionService progressionService;
|
||||||
@@ -48,9 +69,10 @@ final class BaseAdminCommand implements CommandExecutor {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return switch (arguments[0].toLowerCase(Locale.ROOT)) {
|
return switch (arguments[0].toLowerCase(Locale.ROOT)) {
|
||||||
case "progress" -> showProgress(sender, target.orElseThrow());
|
case "status" -> showProgress(sender, target.orElseThrow(), arguments);
|
||||||
case "setlevel" -> setLevel(sender, target.orElseThrow(), arguments);
|
case "setlevel" -> setLevel(sender, target.orElseThrow(), arguments);
|
||||||
case "setprogress" -> setProgress(sender, target.orElseThrow(), arguments);
|
case "setprogress" -> setProgress(sender, target.orElseThrow(), arguments);
|
||||||
|
case "setsetting" -> setSetting(sender, target.orElseThrow(), arguments);
|
||||||
case "clearcooldown" -> clearCooldown(sender, target.orElseThrow(), arguments);
|
case "clearcooldown" -> clearCooldown(sender, target.orElseThrow(), arguments);
|
||||||
case "reset" -> reset(sender, target.orElseThrow(), arguments);
|
case "reset" -> reset(sender, target.orElseThrow(), arguments);
|
||||||
default -> {
|
default -> {
|
||||||
@@ -60,7 +82,15 @@ final class BaseAdminCommand implements CommandExecutor {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean showProgress(CommandSender sender, PlayerState player) {
|
private boolean showProgress(
|
||||||
|
CommandSender sender,
|
||||||
|
PlayerState player,
|
||||||
|
String[] arguments
|
||||||
|
) {
|
||||||
|
if (arguments.length != 2) {
|
||||||
|
sender.sendMessage(ChatColor.RED + "Usage: /baseadmin status <player>");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
sender.sendMessage(ChatColor.GOLD + "=== " + player.latestName() + " Base Progress ===");
|
sender.sendMessage(ChatColor.GOLD + "=== " + player.latestName() + " Base Progress ===");
|
||||||
sender.sendMessage(ChatColor.YELLOW + "Levels: base=" + player.baseLevel()
|
sender.sendMessage(ChatColor.YELLOW + "Levels: base=" + player.baseLevel()
|
||||||
+ " size=" + player.sizeLevel() + " flight=" + player.flightLevel()
|
+ " size=" + player.sizeLevel() + " flight=" + player.flightLevel()
|
||||||
@@ -68,11 +98,17 @@ final class BaseAdminCommand implements CommandExecutor {
|
|||||||
sender.sendMessage(ChatColor.GRAY + "Grass/dirt=" + player.grassAndDirtBroken()
|
sender.sendMessage(ChatColor.GRAY + "Grass/dirt=" + player.grassAndDirtBroken()
|
||||||
+ " stone=" + player.stoneBroken() + " deepslate=" + player.deepslateBroken()
|
+ " stone=" + player.stoneBroken() + " deepslate=" + player.deepslateBroken()
|
||||||
+ " obsidian=" + player.obsidianBroken());
|
+ " obsidian=" + player.obsidianBroken());
|
||||||
|
int spawnableThreshold = settingsProvider.current()
|
||||||
|
.spawnableOverlayUnlockPlacements();
|
||||||
sender.sendMessage(ChatColor.GRAY + "In-base placements=" + player.blocksPlacedInBase()
|
sender.sendMessage(ChatColor.GRAY + "In-base placements=" + player.blocksPlacedInBase()
|
||||||
|
+ " total placements=" + player.totalBlocksPlaced()
|
||||||
+ " breaks=" + player.blocksBrokenInBase());
|
+ " breaks=" + player.blocksBrokenInBase());
|
||||||
|
sender.sendMessage(ChatColor.GRAY + "Spawnable overlay="
|
||||||
|
+ (player.totalBlocksPlaced() >= spawnableThreshold ? "unlocked" : "locked")
|
||||||
|
+ " (" + player.totalBlocksPlaced() + "/" + spawnableThreshold + ")");
|
||||||
sender.sendMessage(ChatColor.GRAY + "Toggles: navigation=" + player.navigationEnabled()
|
sender.sendMessage(ChatColor.GRAY + "Toggles: navigation=" + player.navigationEnabled()
|
||||||
+ " flight=" + player.flightEnabled() + " bossbar=" + player.bossBarEnabled()
|
+ " flight=" + player.flightEnabled() + " border=" + player.borderEnabled()
|
||||||
+ " visitors=" + player.visitorsEnabled());
|
+ " spawnable=" + player.spawnableOverlayEnabled() + " bossbar=" + player.bossBarEnabled() + " visitors=" + player.visitorsEnabled());
|
||||||
sender.sendMessage(ChatColor.GRAY + "Base: " + player.base()
|
sender.sendMessage(ChatColor.GRAY + "Base: " + player.base()
|
||||||
.map(base -> base.worldName() + " " + base.x() + "," + base.y() + "," + base.z())
|
.map(base -> base.worldName() + " " + base.x() + "," + base.y() + "," + base.z())
|
||||||
.orElse("not set"));
|
.orElse("not set"));
|
||||||
@@ -89,8 +125,16 @@ final class BaseAdminCommand implements CommandExecutor {
|
|||||||
sender.sendMessage(ChatColor.RED + "Usage: /baseadmin setlevel <player> <path> <level>");
|
sender.sendMessage(ChatColor.RED + "Usage: /baseadmin setlevel <player> <path> <level>");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
final ProgressionPath path;
|
||||||
|
try {
|
||||||
|
path = ProgressionPath.valueOf(arguments[2].toUpperCase(Locale.ROOT));
|
||||||
|
} catch (IllegalArgumentException exception) {
|
||||||
|
sender.sendMessage(ChatColor.RED
|
||||||
|
+ "Usage: /baseadmin setlevel <player> "
|
||||||
|
+ "<base|size|flight|warmup|cooldown> <level>");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
ProgressionPath path = ProgressionPath.valueOf(arguments[2].toUpperCase(Locale.ROOT));
|
|
||||||
int level = Integer.parseInt(arguments[3]);
|
int level = Integer.parseInt(arguments[3]);
|
||||||
PlayerState updated = stateManager.update(
|
PlayerState updated = stateManager.update(
|
||||||
target.playerId(),
|
target.playerId(),
|
||||||
@@ -100,6 +144,8 @@ final class BaseAdminCommand implements CommandExecutor {
|
|||||||
stateManager.saveIfDirty();
|
stateManager.saveIfDirty();
|
||||||
sender.sendMessage(ChatColor.GREEN + "Set " + updated.latestName() + "'s "
|
sender.sendMessage(ChatColor.GREEN + "Set " + updated.latestName() + "'s "
|
||||||
+ path.name().toLowerCase(Locale.ROOT) + " level to " + level + ".");
|
+ path.name().toLowerCase(Locale.ROOT) + " level to " + level + ".");
|
||||||
|
} catch (NumberFormatException exception) {
|
||||||
|
sender.sendMessage(ChatColor.RED + "The level must be an integer.");
|
||||||
} catch (IllegalArgumentException exception) {
|
} catch (IllegalArgumentException exception) {
|
||||||
sender.sendMessage(ChatColor.RED + exception.getMessage());
|
sender.sendMessage(ChatColor.RED + exception.getMessage());
|
||||||
}
|
}
|
||||||
@@ -116,10 +162,19 @@ final class BaseAdminCommand implements CommandExecutor {
|
|||||||
+ "Usage: /baseadmin setprogress <player> <counter> <amount>");
|
+ "Usage: /baseadmin setprogress <player> <counter> <amount>");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
final ProgressCounter counter;
|
||||||
try {
|
try {
|
||||||
ProgressCounter counter = ProgressCounter.valueOf(
|
counter = ProgressCounter.valueOf(
|
||||||
arguments[2].toUpperCase(Locale.ROOT).replace('-', '_')
|
arguments[2].toUpperCase(Locale.ROOT).replace('-', '_')
|
||||||
);
|
);
|
||||||
|
} catch (IllegalArgumentException exception) {
|
||||||
|
sender.sendMessage(ChatColor.RED
|
||||||
|
+ "Usage: /baseadmin setprogress <player> "
|
||||||
|
+ "<grass_dirt|stone|deepslate|obsidian|placements|total_placements|base_breaks> "
|
||||||
|
+ "<amount>");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
try {
|
||||||
long amount = Long.parseLong(arguments[3]);
|
long amount = Long.parseLong(arguments[3]);
|
||||||
PlayerState updated = stateManager.update(
|
PlayerState updated = stateManager.update(
|
||||||
target.playerId(),
|
target.playerId(),
|
||||||
@@ -137,12 +192,50 @@ final class BaseAdminCommand implements CommandExecutor {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private boolean setSetting(
|
||||||
|
CommandSender sender,
|
||||||
|
PlayerState target,
|
||||||
|
String[] arguments
|
||||||
|
) {
|
||||||
|
if (arguments.length != 4
|
||||||
|
|| !arguments[2].equalsIgnoreCase("spawnable")
|
||||||
|
|| !ENABLE_MODES.contains(arguments[3].toLowerCase(Locale.ROOT))) {
|
||||||
|
sender.sendMessage(ChatColor.RED + "Usage: /baseadmin setsetting <player> "
|
||||||
|
+ "spawnable <enable|disable>");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
boolean enabled = arguments[3].equalsIgnoreCase("enable");
|
||||||
|
if (enabled && target.totalBlocksPlaced()
|
||||||
|
< settingsProvider.current().spawnableOverlayUnlockPlacements()) {
|
||||||
|
sender.sendMessage(ChatColor.RED + "That player has not unlocked the spawnable overlay.");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (enabled && (target.baseLevel() < 1 || target.base().isEmpty())) {
|
||||||
|
sender.sendMessage(ChatColor.RED + "That player does not have an established base.");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
PlayerState updated = stateManager.update(
|
||||||
|
target.playerId(),
|
||||||
|
target.latestName(),
|
||||||
|
current -> current.withSpawnableOverlayEnabled(enabled)
|
||||||
|
);
|
||||||
|
stateManager.saveIfDirty();
|
||||||
|
sender.sendMessage(ChatColor.GREEN + "Set " + updated.latestName()
|
||||||
|
+ "'s spawnable overlay to " + (enabled ? "enabled" : "disabled") + ".");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
private boolean clearCooldown(
|
private boolean clearCooldown(
|
||||||
CommandSender sender,
|
CommandSender sender,
|
||||||
PlayerState target,
|
PlayerState target,
|
||||||
String[] arguments
|
String[] arguments
|
||||||
) {
|
) {
|
||||||
String selection = arguments.length >= 3
|
if (arguments.length < 2 || arguments.length > 3) {
|
||||||
|
sender.sendMessage(ChatColor.RED
|
||||||
|
+ "Usage: /baseadmin clearcooldown <player> [personal|visitor|all]");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
String selection = arguments.length == 3
|
||||||
? arguments[2].toLowerCase(Locale.ROOT)
|
? arguments[2].toLowerCase(Locale.ROOT)
|
||||||
: "all";
|
: "all";
|
||||||
if (!Arrays.asList("personal", "visitor", "all").contains(selection)) {
|
if (!Arrays.asList("personal", "visitor", "all").contains(selection)) {
|
||||||
@@ -186,8 +279,26 @@ final class BaseAdminCommand implements CommandExecutor {
|
|||||||
sender.sendMessage(ChatColor.GREEN + "Reset all progression for " + target.latestName() + ".");
|
sender.sendMessage(ChatColor.GREEN + "Reset all progression for " + target.latestName() + ".");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if (arguments.length != 3) {
|
||||||
|
sender.sendMessage(ChatColor.RED
|
||||||
|
+ "Usage: /baseadmin reset <player> <size|flight|warmup|cooldown|all> [confirm]");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (arguments[2].equalsIgnoreCase("base")) {
|
||||||
|
sender.sendMessage(ChatColor.RED
|
||||||
|
+ "Resetting base removes all progression. Use: /baseadmin reset "
|
||||||
|
+ target.latestName() + " all confirm");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
final ProgressionPath path;
|
||||||
|
try {
|
||||||
|
path = ProgressionPath.valueOf(arguments[2].toUpperCase(Locale.ROOT));
|
||||||
|
} catch (IllegalArgumentException exception) {
|
||||||
|
sender.sendMessage(ChatColor.RED
|
||||||
|
+ "Usage: /baseadmin reset <player> <size|flight|warmup|cooldown|all> [confirm]");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
ProgressionPath path = ProgressionPath.valueOf(arguments[2].toUpperCase(Locale.ROOT));
|
|
||||||
stateManager.update(
|
stateManager.update(
|
||||||
target.playerId(),
|
target.playerId(),
|
||||||
target.latestName(),
|
target.latestName(),
|
||||||
@@ -239,6 +350,80 @@ final class BaseAdminCommand implements CommandExecutor {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<String> onTabComplete(
|
||||||
|
CommandSender sender,
|
||||||
|
Command command,
|
||||||
|
String alias,
|
||||||
|
String[] arguments
|
||||||
|
) {
|
||||||
|
if (!sender.hasPermission("spigotbase.admin")) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
if (arguments.length == 1) {
|
||||||
|
String prefix = arguments[0].toLowerCase(Locale.ROOT);
|
||||||
|
return SUBCOMMANDS.stream()
|
||||||
|
.filter(subcommand -> subcommand.startsWith(prefix))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
if (arguments.length == 2 && arguments[0].equalsIgnoreCase("config")) {
|
||||||
|
String prefix = arguments[1].toLowerCase(Locale.ROOT);
|
||||||
|
return plugin.getConfig().getValues(false).entrySet().stream()
|
||||||
|
.filter(entry -> entry.getValue() instanceof Number)
|
||||||
|
.map(java.util.Map.Entry::getKey)
|
||||||
|
.filter(key -> key.toLowerCase(Locale.ROOT).startsWith(prefix))
|
||||||
|
.sorted(String.CASE_INSENSITIVE_ORDER)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
if (arguments.length == 2) {
|
||||||
|
String prefix = arguments[1].toLowerCase(Locale.ROOT);
|
||||||
|
TreeSet<String> names = new TreeSet<>(String.CASE_INSENSITIVE_ORDER);
|
||||||
|
stateManager.knownPlayers().values().stream()
|
||||||
|
.map(PlayerState::latestName)
|
||||||
|
.forEach(names::add);
|
||||||
|
if (plugin.getServer() != null) {
|
||||||
|
plugin.getServer().getOnlinePlayers().stream()
|
||||||
|
.map(Player::getName)
|
||||||
|
.forEach(names::add);
|
||||||
|
}
|
||||||
|
return names.stream()
|
||||||
|
.filter(name -> name.toLowerCase(Locale.ROOT).startsWith(prefix))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
if (arguments.length == 3) {
|
||||||
|
List<String> options = switch (arguments[0].toLowerCase(Locale.ROOT)) {
|
||||||
|
case "setlevel" -> PATHS;
|
||||||
|
case "setprogress" -> COUNTERS;
|
||||||
|
case "setsetting" -> SETTINGS;
|
||||||
|
case "clearcooldown" -> COOLDOWN_TYPES;
|
||||||
|
case "reset" -> RESET_PATHS;
|
||||||
|
default -> List.of();
|
||||||
|
};
|
||||||
|
return complete(options, arguments[2]);
|
||||||
|
}
|
||||||
|
if (arguments.length == 4 && arguments[0].equalsIgnoreCase("setsetting")) {
|
||||||
|
return complete(ENABLE_MODES, arguments[3]);
|
||||||
|
}
|
||||||
|
if (arguments.length == 4 && arguments[0].equalsIgnoreCase("setlevel")) {
|
||||||
|
List<String> levels = switch (arguments[2].toLowerCase(Locale.ROOT)) {
|
||||||
|
case "base", "cooldown" -> LEVELS_FOUR;
|
||||||
|
case "size", "flight", "warmup" -> LEVELS_THREE;
|
||||||
|
default -> List.of();
|
||||||
|
};
|
||||||
|
return complete(levels, arguments[3]);
|
||||||
|
}
|
||||||
|
if (arguments.length == 4 && arguments[0].equalsIgnoreCase("reset")
|
||||||
|
&& arguments[2].equalsIgnoreCase("all")) {
|
||||||
|
return complete(List.of("confirm"), arguments[3]);
|
||||||
|
}
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<String> complete(List<String> options, String input) {
|
||||||
|
String prefix = input.toLowerCase(Locale.ROOT);
|
||||||
|
return options.stream().filter(option -> option.startsWith(prefix)).toList();
|
||||||
|
}
|
||||||
|
|
||||||
private Optional<PlayerState> resolve(String name) {
|
private Optional<PlayerState> resolve(String name) {
|
||||||
Player online = Bukkit.getPlayerExact(name);
|
Player online = Bukkit.getPlayerExact(name);
|
||||||
if (online != null) {
|
if (online != null) {
|
||||||
@@ -249,7 +434,7 @@ final class BaseAdminCommand implements CommandExecutor {
|
|||||||
|
|
||||||
private static void sendUsage(CommandSender sender) {
|
private static void sendUsage(CommandSender sender) {
|
||||||
sender.sendMessage(ChatColor.YELLOW + "Usage: /baseadmin "
|
sender.sendMessage(ChatColor.YELLOW + "Usage: /baseadmin "
|
||||||
+ "<progress|setlevel|setprogress|clearcooldown|reset> <player> ...");
|
+ "<status|setlevel|setprogress|setsetting|clearcooldown|reset> <player> ...");
|
||||||
sender.sendMessage(ChatColor.YELLOW + " /baseadmin config <key> <integer>");
|
sender.sendMessage(ChatColor.YELLOW + " /baseadmin config <key> <integer>");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package games.dmg.spigotbase;
|
||||||
|
|
||||||
|
import org.bukkit.Location;
|
||||||
|
import org.bukkit.Particle;
|
||||||
|
import org.bukkit.Server;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
|
||||||
|
final class BaseBorderController implements Runnable {
|
||||||
|
private final Server server;
|
||||||
|
private final BaseStateManager stateManager;
|
||||||
|
private final BaseBoundsService boundsService;
|
||||||
|
|
||||||
|
BaseBorderController(
|
||||||
|
Server server,
|
||||||
|
BaseStateManager stateManager,
|
||||||
|
BaseBoundsService boundsService
|
||||||
|
) {
|
||||||
|
this.server = server;
|
||||||
|
this.stateManager = stateManager;
|
||||||
|
this.boundsService = boundsService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void run() {
|
||||||
|
for (Player player : server.getOnlinePlayers()) {
|
||||||
|
PlayerState state = stateManager.player(player.getUniqueId(), player.getName());
|
||||||
|
if (!state.borderEnabled() || state.base().isEmpty()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
BaseLocation base = state.base().orElseThrow();
|
||||||
|
if (!player.getWorld().getUID().equals(base.worldId())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Location playerLocation = player.getLocation();
|
||||||
|
long minimumY = (long) base.y() - boundsService.verticalRange(state);
|
||||||
|
long maximumY = (long) base.y() + boundsService.verticalRange(state);
|
||||||
|
if (playerLocation.getY() < minimumY || playerLocation.getY() > maximumY) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (BaseBorderGeometry.Point point : BaseBorderGeometry.visiblePoints(
|
||||||
|
base.x() + 0.5,
|
||||||
|
base.z() + 0.5,
|
||||||
|
boundsService.radius(state),
|
||||||
|
playerLocation.getX(),
|
||||||
|
playerLocation.getZ()
|
||||||
|
)) {
|
||||||
|
Location particle = new Location(
|
||||||
|
player.getWorld(),
|
||||||
|
point.x(),
|
||||||
|
playerLocation.getY() + 0.15,
|
||||||
|
point.z()
|
||||||
|
);
|
||||||
|
player.spawnParticle(
|
||||||
|
Particle.END_ROD,
|
||||||
|
particle,
|
||||||
|
1,
|
||||||
|
0.0, 0.0, 0.0, 0.0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package games.dmg.spigotbase;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
final class BaseBorderGeometry {
|
||||||
|
static final int MAX_PARTICLES = 64;
|
||||||
|
static final double VISIBILITY_DISTANCE = 32.0;
|
||||||
|
private static final double PARTICLE_SPACING = 1.0;
|
||||||
|
|
||||||
|
private BaseBorderGeometry() {
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<Point> visiblePoints(
|
||||||
|
double centerX,
|
||||||
|
double centerZ,
|
||||||
|
int radius,
|
||||||
|
double playerX,
|
||||||
|
double playerZ
|
||||||
|
) {
|
||||||
|
if (radius <= 0) {
|
||||||
|
throw new IllegalArgumentException("border radius must be positive");
|
||||||
|
}
|
||||||
|
double playerRadius = Math.hypot(playerX - centerX, playerZ - centerZ);
|
||||||
|
if (Math.abs(playerRadius - radius) > VISIBILITY_DISTANCE) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
double circumference = 2.0 * Math.PI * radius;
|
||||||
|
int count = Math.min(MAX_PARTICLES, Math.max(1, (int) Math.ceil(
|
||||||
|
circumference / PARTICLE_SPACING
|
||||||
|
)));
|
||||||
|
double centerAngle = Math.atan2(playerZ - centerZ, playerX - centerX);
|
||||||
|
double angleStep = circumference <= MAX_PARTICLES * PARTICLE_SPACING
|
||||||
|
? 2.0 * Math.PI / count
|
||||||
|
: PARTICLE_SPACING / radius;
|
||||||
|
double startAngle = circumference <= MAX_PARTICLES * PARTICLE_SPACING
|
||||||
|
? 0.0
|
||||||
|
: centerAngle - angleStep * (count - 1) / 2.0;
|
||||||
|
|
||||||
|
List<Point> points = new ArrayList<>(count);
|
||||||
|
double visibilitySquared = VISIBILITY_DISTANCE * VISIBILITY_DISTANCE;
|
||||||
|
for (int index = 0; index < count; index++) {
|
||||||
|
double angle = startAngle + angleStep * index;
|
||||||
|
double x = centerX + radius * Math.cos(angle);
|
||||||
|
double z = centerZ + radius * Math.sin(angle);
|
||||||
|
double deltaX = x - playerX;
|
||||||
|
double deltaZ = z - playerZ;
|
||||||
|
if (deltaX * deltaX + deltaZ * deltaZ <= visibilitySquared) {
|
||||||
|
points.add(new Point(x, z));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return List.copyOf(points);
|
||||||
|
}
|
||||||
|
|
||||||
|
record Point(double x, double z) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,31 +1,16 @@
|
|||||||
package games.dmg.spigotbase;
|
package games.dmg.spigotbase;
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
import org.bukkit.ChatColor;
|
import org.bukkit.ChatColor;
|
||||||
import org.bukkit.Material;
|
|
||||||
import org.bukkit.command.Command;
|
import org.bukkit.command.Command;
|
||||||
import org.bukkit.command.CommandExecutor;
|
import org.bukkit.command.CommandExecutor;
|
||||||
import org.bukkit.command.CommandSender;
|
import org.bukkit.command.CommandSender;
|
||||||
import org.bukkit.entity.Player;
|
import org.bukkit.entity.Player;
|
||||||
import org.bukkit.inventory.ItemStack;
|
|
||||||
import org.bukkit.inventory.PlayerInventory;
|
|
||||||
|
|
||||||
final class BaseCommand implements CommandExecutor {
|
final class BaseCommand implements CommandExecutor {
|
||||||
private final BaseTeleportManager teleportManager;
|
private final BaseTeleportManager teleportManager;
|
||||||
private final BaseStateManager stateManager;
|
|
||||||
private final VisitorPolicy visitorPolicy;
|
|
||||||
private final PluginSettingsProvider settings;
|
|
||||||
|
|
||||||
BaseCommand(
|
BaseCommand(BaseTeleportManager teleportManager) {
|
||||||
BaseTeleportManager teleportManager,
|
|
||||||
BaseStateManager stateManager,
|
|
||||||
VisitorPolicy visitorPolicy,
|
|
||||||
PluginSettingsProvider settings
|
|
||||||
) {
|
|
||||||
this.teleportManager = teleportManager;
|
this.teleportManager = teleportManager;
|
||||||
this.stateManager = stateManager;
|
|
||||||
this.visitorPolicy = visitorPolicy;
|
|
||||||
this.settings = settings;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -38,95 +23,7 @@ final class BaseCommand implements CommandExecutor {
|
|||||||
teleportManager.start(player);
|
teleportManager.start(player);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (arguments.length == 1 && arguments[0].equalsIgnoreCase("upgrade")) {
|
player.sendMessage(ChatColor.RED + "Usage: /base");
|
||||||
purchaseVisitorAccess(player);
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
player.sendMessage(ChatColor.RED + "Usage: /base [upgrade]");
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void purchaseVisitorAccess(Player player) {
|
|
||||||
PlayerState state = stateManager.player(player.getUniqueId(), player.getName());
|
|
||||||
if (!visitorPolicy.canPurchase(state)) {
|
|
||||||
player.sendMessage(ChatColor.RED + (state.baseLevel() >= 4
|
|
||||||
? "Base IV is already unlocked."
|
|
||||||
: "You must unlock Base III before purchasing Base IV."));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
int price = settings.current().visitorUnlockDiamondCost();
|
|
||||||
Material currency = Material.valueOf(settings.current().visitorCurrencyMaterial());
|
|
||||||
PlayerInventory inventory = player.getInventory();
|
|
||||||
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());
|
|
||||||
removeCurrency(inventory, currency, price);
|
|
||||||
try {
|
|
||||||
stateManager.updateAndSave(
|
|
||||||
player.getUniqueId(),
|
|
||||||
player.getName(),
|
|
||||||
current -> current.withBaseLevel(4).withVisitorsEnabled(true)
|
|
||||||
);
|
|
||||||
} catch (IOException | RuntimeException exception) {
|
|
||||||
inventory.setStorageContents(snapshot);
|
|
||||||
player.sendMessage(ChatColor.RED + "The upgrade could not be saved; your diamonds were restored.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
player.sendTitle(
|
|
||||||
ChatColor.GOLD + "Base IV Unlocked",
|
|
||||||
ChatColor.YELLOW + "Visitors may now teleport to your base",
|
|
||||||
settings.current().titleFadeInTicks(),
|
|
||||||
settings.current().titleStayTicks(),
|
|
||||||
settings.current().titleFadeOutTicks()
|
|
||||||
);
|
|
||||||
player.sendMessage(ChatColor.GREEN + "Base IV unlocked for " + price + " "
|
|
||||||
+ currency.name().toLowerCase(java.util.Locale.ROOT) + ".");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static int countCurrency(PlayerInventory inventory, Material currency) {
|
|
||||||
int count = 0;
|
|
||||||
for (ItemStack item : inventory.getStorageContents()) {
|
|
||||||
if (item != null && item.getType() == currency) {
|
|
||||||
count += item.getAmount();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return count;
|
|
||||||
}
|
|
||||||
|
|
||||||
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() != currency) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
int removed = Math.min(remaining, item.getAmount());
|
|
||||||
remaining -= removed;
|
|
||||||
int newAmount = item.getAmount() - removed;
|
|
||||||
if (newAmount == 0) {
|
|
||||||
contents[index] = null;
|
|
||||||
} else {
|
|
||||||
ItemStack reduced = item.clone();
|
|
||||||
reduced.setAmount(newAmount);
|
|
||||||
contents[index] = reduced;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
inventory.setStorageContents(contents);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static ItemStack[] cloneContents(ItemStack[] contents) {
|
|
||||||
ItemStack[] copy = new ItemStack[contents.length];
|
|
||||||
for (int index = 0; index < contents.length; index++) {
|
|
||||||
copy[index] = contents[index] == null ? null : contents[index].clone();
|
|
||||||
}
|
|
||||||
return copy;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
package games.dmg.spigotbase;
|
|
||||||
|
|
||||||
import org.bukkit.ChatColor;
|
|
||||||
import org.bukkit.command.Command;
|
|
||||||
import org.bukkit.command.CommandExecutor;
|
|
||||||
import org.bukkit.command.CommandSender;
|
|
||||||
import org.bukkit.entity.Player;
|
|
||||||
|
|
||||||
final class BaseFlightCommand implements CommandExecutor {
|
|
||||||
private final BaseStateManager stateManager;
|
|
||||||
private final BaseFlightController controller;
|
|
||||||
|
|
||||||
BaseFlightCommand(BaseStateManager stateManager, BaseFlightController controller) {
|
|
||||||
this.stateManager = stateManager;
|
|
||||||
this.controller = controller;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean onCommand(CommandSender sender, Command command, String label, String[] arguments) {
|
|
||||||
if (!(sender instanceof Player player)) {
|
|
||||||
sender.sendMessage("Only players can use base flight.");
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
PlayerState state = stateManager.player(player.getUniqueId(), player.getName());
|
|
||||||
if (state.flightLevel() < 1) {
|
|
||||||
player.sendMessage(ChatColor.RED + "Base flight is still locked.");
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
state = stateManager.update(
|
|
||||||
player.getUniqueId(),
|
|
||||||
player.getName(),
|
|
||||||
current -> current.withFlightEnabled(!current.flightEnabled())
|
|
||||||
);
|
|
||||||
if (!state.flightEnabled()) {
|
|
||||||
controller.removeGrantedFlight(player);
|
|
||||||
}
|
|
||||||
stateManager.saveIfDirty();
|
|
||||||
player.sendMessage(ChatColor.YELLOW + "Base flight is now "
|
|
||||||
+ (state.flightEnabled() ? ChatColor.GREEN + "on" : ChatColor.RED + "off")
|
|
||||||
+ ChatColor.YELLOW + ".");
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -80,7 +80,7 @@ final class BaseFlightController implements Runnable {
|
|||||||
stateManager.saveIfDirty();
|
stateManager.saveIfDirty();
|
||||||
player.sendTitle(
|
player.sendTitle(
|
||||||
ChatColor.GOLD + "Base Flight " + roman(updated.flightLevel()) + " Unlocked",
|
ChatColor.GOLD + "Base Flight " + roman(updated.flightLevel()) + " Unlocked",
|
||||||
ChatColor.YELLOW + "Use /baseflight to toggle flight",
|
ChatColor.YELLOW + "Use /basesettings flight enable to enable flight",
|
||||||
settings.current().titleFadeInTicks(),
|
settings.current().titleFadeInTicks(),
|
||||||
settings.current().titleStayTicks(),
|
settings.current().titleStayTicks(),
|
||||||
settings.current().titleFadeOutTicks()
|
settings.current().titleFadeOutTicks()
|
||||||
@@ -112,7 +112,7 @@ final class BaseFlightController implements Runnable {
|
|||||||
player.setAllowFlight(true);
|
player.setAllowFlight(true);
|
||||||
grantedFlight.add(player.getUniqueId());
|
grantedFlight.add(player.getUniqueId());
|
||||||
}
|
}
|
||||||
if (distanceSquared > (double) radius * radius) {
|
if (distanceSquared > (double) radius * radius && player.isFlying()) {
|
||||||
if (warned.add(player.getUniqueId())) {
|
if (warned.add(player.getUniqueId())) {
|
||||||
player.sendTitle(
|
player.sendTitle(
|
||||||
ChatColor.RED + "Leaving Your Base",
|
ChatColor.RED + "Leaving Your Base",
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
package games.dmg.spigotbase;
|
|
||||||
|
|
||||||
import org.bukkit.ChatColor;
|
|
||||||
import org.bukkit.command.Command;
|
|
||||||
import org.bukkit.command.CommandExecutor;
|
|
||||||
import org.bukkit.command.CommandSender;
|
|
||||||
import org.bukkit.entity.Player;
|
|
||||||
|
|
||||||
final class BaseNavigationCommand implements CommandExecutor {
|
|
||||||
private final BaseStateManager stateManager;
|
|
||||||
|
|
||||||
BaseNavigationCommand(BaseStateManager stateManager) {
|
|
||||||
this.stateManager = stateManager;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean onCommand(CommandSender sender, Command command, String label, String[] arguments) {
|
|
||||||
if (!(sender instanceof Player player)) {
|
|
||||||
sender.sendMessage("Only players can use base navigation.");
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
PlayerState state = stateManager.player(player.getUniqueId(), player.getName());
|
|
||||||
if (state.baseLevel() < 2) {
|
|
||||||
player.sendMessage(ChatColor.RED + "Base II navigation is still locked.");
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (state.base().isEmpty()) {
|
|
||||||
player.sendMessage(ChatColor.RED + "Set your base before enabling navigation.");
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
state = stateManager.update(
|
|
||||||
player.getUniqueId(),
|
|
||||||
player.getName(),
|
|
||||||
current -> current.withNavigationEnabled(!current.navigationEnabled())
|
|
||||||
);
|
|
||||||
stateManager.saveIfDirty();
|
|
||||||
player.sendMessage(ChatColor.YELLOW + "Base navigation is now "
|
|
||||||
+ (state.navigationEnabled() ? ChatColor.GREEN + "on" : ChatColor.RED + "off")
|
|
||||||
+ ChatColor.YELLOW + ".");
|
|
||||||
if (state.navigationEnabled()
|
|
||||||
&& !player.getWorld().getUID().equals(state.base().orElseThrow().worldId())) {
|
|
||||||
player.sendMessage(ChatColor.RED + "Your base is in another world: "
|
|
||||||
+ state.base().orElseThrow().worldName() + ".");
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -10,6 +10,7 @@ final class BaseNavigationController implements Runnable {
|
|||||||
private final Server server;
|
private final Server server;
|
||||||
private final BaseStateManager stateManager;
|
private final BaseStateManager stateManager;
|
||||||
private final PluginSettingsProvider settings;
|
private final PluginSettingsProvider settings;
|
||||||
|
private final BaseBoundsService boundsService;
|
||||||
|
|
||||||
BaseNavigationController(
|
BaseNavigationController(
|
||||||
Server server,
|
Server server,
|
||||||
@@ -19,6 +20,7 @@ final class BaseNavigationController implements Runnable {
|
|||||||
this.server = server;
|
this.server = server;
|
||||||
this.stateManager = stateManager;
|
this.stateManager = stateManager;
|
||||||
this.settings = settings;
|
this.settings = settings;
|
||||||
|
this.boundsService = new BaseBoundsService(settings);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -32,7 +34,16 @@ final class BaseNavigationController implements Runnable {
|
|||||||
if (!player.getWorld().getUID().equals(base.worldId())) {
|
if (!player.getWorld().getUID().equals(base.worldId())) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
Location origin = player.getLocation().clone().add(0.0, 0.15, 0.0);
|
Location playerLocation = player.getLocation();
|
||||||
|
if (!BaseNavigationVisibility.isBeyondBorderBuffer(
|
||||||
|
base,
|
||||||
|
boundsService.radius(state),
|
||||||
|
playerLocation.getX(),
|
||||||
|
playerLocation.getZ()
|
||||||
|
)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Location origin = playerLocation.clone().add(0.0, 0.15, 0.0);
|
||||||
Vector direction = new Vector(
|
Vector direction = new Vector(
|
||||||
base.x() + 0.5 - origin.getX(),
|
base.x() + 0.5 - origin.getX(),
|
||||||
0.0,
|
0.0,
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package games.dmg.spigotbase;
|
||||||
|
|
||||||
|
final class BaseNavigationVisibility {
|
||||||
|
static final int BORDER_BUFFER = 25;
|
||||||
|
|
||||||
|
private BaseNavigationVisibility() {
|
||||||
|
}
|
||||||
|
|
||||||
|
static boolean isBeyondBorderBuffer(
|
||||||
|
BaseLocation base,
|
||||||
|
int radius,
|
||||||
|
double playerX,
|
||||||
|
double playerZ
|
||||||
|
) {
|
||||||
|
if (base == null) {
|
||||||
|
throw new IllegalArgumentException("base is required");
|
||||||
|
}
|
||||||
|
if (radius <= 0) {
|
||||||
|
throw new IllegalArgumentException("base radius must be positive");
|
||||||
|
}
|
||||||
|
double deltaX = playerX - (base.x() + 0.5);
|
||||||
|
double deltaZ = playerZ - (base.z() + 0.5);
|
||||||
|
double minimumDistance = radius + BORDER_BUFFER;
|
||||||
|
return deltaX * deltaX + deltaZ * deltaZ > minimumDistance * minimumDistance;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
package games.dmg.spigotbase;
|
|
||||||
|
|
||||||
import org.bukkit.ChatColor;
|
|
||||||
import org.bukkit.command.Command;
|
|
||||||
import org.bukkit.command.CommandExecutor;
|
|
||||||
import org.bukkit.command.CommandSender;
|
|
||||||
import org.bukkit.entity.Player;
|
|
||||||
|
|
||||||
final class BaseProgressCommand implements CommandExecutor {
|
|
||||||
private final BaseStateManager stateManager;
|
|
||||||
private final PluginSettingsProvider settings;
|
|
||||||
private final TeleportPolicy teleportPolicy;
|
|
||||||
|
|
||||||
BaseProgressCommand(BaseStateManager stateManager, PluginSettingsProvider settings) {
|
|
||||||
this.stateManager = stateManager;
|
|
||||||
this.settings = settings;
|
|
||||||
this.teleportPolicy = new TeleportPolicy(settings);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean onCommand(CommandSender sender, Command command, String label, String[] arguments) {
|
|
||||||
if (!(sender instanceof Player player)) {
|
|
||||||
sender.sendMessage("Only players have base progression.");
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
PlayerState state = stateManager.player(player.getUniqueId(), player.getName());
|
|
||||||
if (arguments.length == 1 && arguments[0].equalsIgnoreCase("bossbar")) {
|
|
||||||
state = stateManager.update(
|
|
||||||
player.getUniqueId(),
|
|
||||||
player.getName(),
|
|
||||||
current -> current.withBossBarEnabled(!current.bossBarEnabled())
|
|
||||||
);
|
|
||||||
stateManager.saveIfDirty();
|
|
||||||
player.sendMessage(ChatColor.YELLOW + "Automatic progress boss bars are now "
|
|
||||||
+ (state.bossBarEnabled() ? ChatColor.GREEN + "on" : ChatColor.RED + "off")
|
|
||||||
+ ChatColor.YELLOW + ".");
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
player.sendMessage(ChatColor.GOLD + "=== Base Progress ===");
|
|
||||||
showBasePath(player, state);
|
|
||||||
showSizePath(player, state);
|
|
||||||
showFlightPath(player, state);
|
|
||||||
showWarmupPath(player, state);
|
|
||||||
showCooldownPath(player, state);
|
|
||||||
player.sendMessage(ChatColor.GRAY + "Boss bars: " + (state.bossBarEnabled() ? "on" : "off"));
|
|
||||||
state.base().ifPresentOrElse(
|
|
||||||
base -> player.sendMessage(ChatColor.GRAY + "Base: " + base.worldName() + " "
|
|
||||||
+ base.x() + ", " + base.y() + ", " + base.z()),
|
|
||||||
() -> player.sendMessage(ChatColor.GRAY + "Base: not set")
|
|
||||||
);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void showBasePath(Player player, PlayerState state) {
|
|
||||||
String detail = switch (state.baseLevel()) {
|
|
||||||
case 0 -> state.grassAndDirtBroken() + "/" + settings.current().baseUnlockBlocks()
|
|
||||||
+ " grass or dirt → /setbase";
|
|
||||||
case 1 -> state.grassAndDirtBroken() + "/" + settings.current().navigationUnlockBlocks()
|
|
||||||
+ " grass or dirt → navigation";
|
|
||||||
case 2 -> state.blocksPlacedInBase() + "/" + settings.current().teleportUnlockPlacements()
|
|
||||||
+ " placements → /base";
|
|
||||||
case 3 -> settings.current().visitorUnlockDiamondCost() + " diamonds → visitor access";
|
|
||||||
case 4 -> "complete; visitor access unlocked";
|
|
||||||
default -> "invalid";
|
|
||||||
};
|
|
||||||
player.sendMessage(ChatColor.YELLOW + "Base " + state.baseLevel() + "/4: "
|
|
||||||
+ ChatColor.GRAY + detail);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void showSizePath(Player player, PlayerState state) {
|
|
||||||
String detail = switch (state.sizeLevel()) {
|
|
||||||
case 0 -> state.stoneBroken() + "/" + settings.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";
|
|
||||||
};
|
|
||||||
player.sendMessage(colorForPrerequisite(state.baseLevel() >= 1) + "Base Size "
|
|
||||||
+ state.sizeLevel() + "/3: " + ChatColor.GRAY + detail);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void showFlightPath(Player player, PlayerState state) {
|
|
||||||
String detail = state.flightLevel() >= 3
|
|
||||||
? "complete; world build height"
|
|
||||||
: (state.flightLevel() + 1) + " simultaneous elytra required";
|
|
||||||
player.sendMessage(colorForPrerequisite(state.baseLevel() >= 1) + "Base Flight "
|
|
||||||
+ state.flightLevel() + "/3: " + ChatColor.GRAY + detail
|
|
||||||
+ "; toggle=" + (state.flightEnabled() ? "on" : "off"));
|
|
||||||
}
|
|
||||||
|
|
||||||
private void showWarmupPath(Player player, PlayerState state) {
|
|
||||||
String detail = switch (state.warmupLevel()) {
|
|
||||||
case 0 -> state.blocksPlacedInBase() + "/" + settings.current().secondWarmupPlacements();
|
|
||||||
case 1 -> state.blocksPlacedInBase() + "/" + settings.current().thirdWarmupPlacements();
|
|
||||||
case 2 -> state.blocksPlacedInBase() + "/" + settings.current().instantWarmupPlacements();
|
|
||||||
case 3 -> "complete";
|
|
||||||
default -> "invalid";
|
|
||||||
};
|
|
||||||
player.sendMessage(colorForPrerequisite(state.baseLevel() >= 3) + "Teleport Warm-up "
|
|
||||||
+ state.warmupLevel() + "/3: " + ChatColor.GRAY
|
|
||||||
+ DurationFormatter.friendly(teleportPolicy.warmup(state)) + "; " + detail);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void showCooldownPath(Player player, PlayerState state) {
|
|
||||||
String detail = switch (state.cooldownLevel()) {
|
|
||||||
case 0 -> state.blocksBrokenInBase() + "/" + settings.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";
|
|
||||||
};
|
|
||||||
player.sendMessage(colorForPrerequisite(state.baseLevel() >= 3) + "Teleport Cooldown "
|
|
||||||
+ state.cooldownLevel() + "/4: " + ChatColor.GRAY
|
|
||||||
+ DurationFormatter.friendly(teleportPolicy.cooldown(state)) + "; " + detail);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static ChatColor colorForPrerequisite(boolean met) {
|
|
||||||
return met ? ChatColor.YELLOW : ChatColor.RED;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -24,6 +24,7 @@ final class BaseProgressListener implements Listener {
|
|||||||
private final BaseProgressionService baseProgressionService;
|
private final BaseProgressionService baseProgressionService;
|
||||||
private final SecondaryProgressionService secondaryProgressionService;
|
private final SecondaryProgressionService secondaryProgressionService;
|
||||||
private final TeleportProgressionService teleportProgressionService;
|
private final TeleportProgressionService teleportProgressionService;
|
||||||
|
private final SpawnableOverlayProgressionService spawnableOverlayProgressionService;
|
||||||
private final BaseBoundsService boundsService;
|
private final BaseBoundsService boundsService;
|
||||||
private final PluginSettingsProvider settings;
|
private final PluginSettingsProvider settings;
|
||||||
private final Map<UUID, BossBar> activeBossBars = new HashMap<>();
|
private final Map<UUID, BossBar> activeBossBars = new HashMap<>();
|
||||||
@@ -34,6 +35,7 @@ final class BaseProgressListener implements Listener {
|
|||||||
BaseProgressionService baseProgressionService,
|
BaseProgressionService baseProgressionService,
|
||||||
SecondaryProgressionService secondaryProgressionService,
|
SecondaryProgressionService secondaryProgressionService,
|
||||||
TeleportProgressionService teleportProgressionService,
|
TeleportProgressionService teleportProgressionService,
|
||||||
|
SpawnableOverlayProgressionService spawnableOverlayProgressionService,
|
||||||
BaseBoundsService boundsService,
|
BaseBoundsService boundsService,
|
||||||
PluginSettingsProvider settings
|
PluginSettingsProvider settings
|
||||||
) {
|
) {
|
||||||
@@ -42,6 +44,7 @@ final class BaseProgressListener implements Listener {
|
|||||||
this.baseProgressionService = baseProgressionService;
|
this.baseProgressionService = baseProgressionService;
|
||||||
this.secondaryProgressionService = secondaryProgressionService;
|
this.secondaryProgressionService = secondaryProgressionService;
|
||||||
this.teleportProgressionService = teleportProgressionService;
|
this.teleportProgressionService = teleportProgressionService;
|
||||||
|
this.spawnableOverlayProgressionService = spawnableOverlayProgressionService;
|
||||||
this.boundsService = boundsService;
|
this.boundsService = boundsService;
|
||||||
this.settings = settings;
|
this.settings = settings;
|
||||||
}
|
}
|
||||||
@@ -97,26 +100,43 @@ final class BaseProgressListener implements Listener {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
PlayerState before = stateManager.player(player.getUniqueId(), player.getName());
|
PlayerState before = stateManager.player(player.getUniqueId(), player.getName());
|
||||||
if (!isInsideBase(
|
boolean insideBase = isInsideBase(
|
||||||
before,
|
before,
|
||||||
event.getBlockPlaced().getWorld().getUID(),
|
event.getBlockPlaced().getWorld().getUID(),
|
||||||
event.getBlockPlaced().getX(),
|
event.getBlockPlaced().getX(),
|
||||||
event.getBlockPlaced().getY(),
|
event.getBlockPlaced().getY(),
|
||||||
event.getBlockPlaced().getZ())) {
|
event.getBlockPlaced().getZ()
|
||||||
return;
|
);
|
||||||
}
|
|
||||||
ProgressionUpdate[] updateHolder = new ProgressionUpdate[1];
|
ProgressionUpdate[] updateHolder = new ProgressionUpdate[1];
|
||||||
|
SpawnableOverlayProgressionUpdate[] overlayHolder =
|
||||||
|
new SpawnableOverlayProgressionUpdate[1];
|
||||||
PlayerState state = stateManager.update(player.getUniqueId(), player.getName(), current -> {
|
PlayerState state = stateManager.update(player.getUniqueId(), player.getName(), current -> {
|
||||||
ProgressionUpdate update = teleportProgressionService.recordPlacement(current);
|
SpawnableOverlayProgressionUpdate overlay =
|
||||||
|
spawnableOverlayProgressionService.recordPlacement(current);
|
||||||
|
ProgressionUpdate update = insideBase
|
||||||
|
? teleportProgressionService.recordPlacement(overlay.player())
|
||||||
|
: ProgressionUpdate.unchanged(overlay.player());
|
||||||
|
overlayHolder[0] = overlay;
|
||||||
updateHolder[0] = update;
|
updateHolder[0] = update;
|
||||||
return update.player();
|
return update.player();
|
||||||
});
|
});
|
||||||
ProgressionUpdate update = updateHolder[0];
|
ProgressionUpdate update = updateHolder[0];
|
||||||
|
SpawnableOverlayProgressionUpdate overlay = overlayHolder[0];
|
||||||
announceUnlock(player, update);
|
announceUnlock(player, update);
|
||||||
if (hasUnlock(update)) {
|
if (overlay.unlocked()) {
|
||||||
|
sendUnlockTitle(
|
||||||
|
player,
|
||||||
|
ChatColor.GOLD + "Spawnable Overlay Unlocked",
|
||||||
|
ChatColor.YELLOW + "/basesettings spawnable enable is now available"
|
||||||
|
);
|
||||||
|
player.sendMessage(ChatColor.GREEN
|
||||||
|
+ "You unlocked spawnable block highlighting! Use "
|
||||||
|
+ "/basesettings spawnable enable.");
|
||||||
|
}
|
||||||
|
if (hasUnlock(update) || overlay.unlocked()) {
|
||||||
stateManager.saveIfDirty();
|
stateManager.saveIfDirty();
|
||||||
}
|
}
|
||||||
if (state.bossBarEnabled() && state.baseLevel() >= 2) {
|
if (insideBase && state.bossBarEnabled() && state.baseLevel() >= 2) {
|
||||||
showProgress(player, warmupDisplay(state));
|
showProgress(player, warmupDisplay(state));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -149,7 +169,7 @@ final class BaseProgressListener implements Listener {
|
|||||||
int level = update.player().baseLevel();
|
int level = update.player().baseLevel();
|
||||||
String subtitle = switch (level) {
|
String subtitle = switch (level) {
|
||||||
case 1 -> "/setbase is now available";
|
case 1 -> "/setbase is now available";
|
||||||
case 2 -> "/basenavigation is now available";
|
case 2 -> "/basesettings navigation enable is now available";
|
||||||
case 3 -> "/base is now available";
|
case 3 -> "/base is now available";
|
||||||
case 4 -> "Visitors can now travel to your base";
|
case 4 -> "Visitors can now travel to your base";
|
||||||
default -> "A new base benefit is available";
|
default -> "A new base benefit is available";
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ public final class BaseProgressionService {
|
|||||||
}
|
}
|
||||||
boolean unlocked = baseLevel != previousLevel;
|
boolean unlocked = baseLevel != previousLevel;
|
||||||
PlayerState updated = player.withGrassAndDirtProgress(count, baseLevel);
|
PlayerState updated = player.withGrassAndDirtProgress(count, baseLevel);
|
||||||
if (baseLevel >= 2 && !updated.navigationEnabled()) {
|
if (unlocked && baseLevel == 2) {
|
||||||
updated = updated.withNavigationEnabled(true);
|
updated = updated.withNavigationEnabled(true);
|
||||||
}
|
}
|
||||||
return new ProgressionUpdate(updated, unlocked, false, false, false, false);
|
return new ProgressionUpdate(updated, unlocked, false, false, false, false);
|
||||||
|
|||||||
@@ -0,0 +1,564 @@
|
|||||||
|
package games.dmg.spigotbase;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import org.bukkit.ChatColor;
|
||||||
|
import org.bukkit.Material;
|
||||||
|
import org.bukkit.command.Command;
|
||||||
|
import org.bukkit.command.CommandExecutor;
|
||||||
|
import org.bukkit.command.CommandSender;
|
||||||
|
import org.bukkit.command.TabCompleter;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
import org.bukkit.inventory.ItemStack;
|
||||||
|
import org.bukkit.inventory.PlayerInventory;
|
||||||
|
|
||||||
|
final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
|
||||||
|
private static final List<String> SETTINGS = List.of(
|
||||||
|
"status", "upgrade", "pocket", "visitors", "navigation", "flight", "border",
|
||||||
|
"spawnable", "bossbar"
|
||||||
|
);
|
||||||
|
private static final List<String> VISITOR_MODES = List.of("allowed", "blocked");
|
||||||
|
private static final List<String> ENABLE_MODES = List.of("enable", "disable");
|
||||||
|
private final BaseStateManager stateManager;
|
||||||
|
private final PluginSettingsProvider settings;
|
||||||
|
private final TeleportPolicy teleportPolicy;
|
||||||
|
private final BaseFlightController flightController;
|
||||||
|
private final PocketBaseManager pocketBases;
|
||||||
|
|
||||||
|
BaseSettingsCommand(
|
||||||
|
BaseStateManager stateManager,
|
||||||
|
PluginSettingsProvider settings,
|
||||||
|
BaseFlightController flightController
|
||||||
|
) {
|
||||||
|
this(stateManager, settings, flightController, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
BaseSettingsCommand(
|
||||||
|
BaseStateManager stateManager,
|
||||||
|
PluginSettingsProvider settings,
|
||||||
|
BaseFlightController flightController,
|
||||||
|
PocketBaseManager pocketBases
|
||||||
|
) {
|
||||||
|
this.stateManager = stateManager;
|
||||||
|
this.settings = settings;
|
||||||
|
this.teleportPolicy = new TeleportPolicy(settings);
|
||||||
|
this.flightController = flightController;
|
||||||
|
this.pocketBases = pocketBases;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean onCommand(CommandSender sender, Command command, String label, String[] arguments) {
|
||||||
|
if (!(sender instanceof Player player)) {
|
||||||
|
sender.sendMessage("Only players have base progression.");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
PlayerState state = stateManager.player(player.getUniqueId(), player.getName());
|
||||||
|
if (arguments.length == 0
|
||||||
|
|| arguments.length == 1 && arguments[0].equalsIgnoreCase("status")) {
|
||||||
|
showStatus(player, state);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (arguments.length == 1 && arguments[0].equalsIgnoreCase("upgrade")) {
|
||||||
|
return purchaseVisitorAccess(player, state);
|
||||||
|
}
|
||||||
|
if (arguments.length == 2 && arguments[0].equalsIgnoreCase("pocket")
|
||||||
|
&& arguments[1].equalsIgnoreCase("upgrade")) {
|
||||||
|
return purchasePocketUpgrade(player, state);
|
||||||
|
}
|
||||||
|
if (arguments.length == 3 && arguments[0].equalsIgnoreCase("pocket")
|
||||||
|
&& arguments[1].equalsIgnoreCase("mobs")) {
|
||||||
|
return updatePocketMobSpawning(player, arguments[2]);
|
||||||
|
}
|
||||||
|
if (arguments.length == 2 && arguments[0].equalsIgnoreCase("visitors")) {
|
||||||
|
return updateVisitors(player, state, arguments[1]);
|
||||||
|
}
|
||||||
|
if (arguments.length == 2 && arguments[0].equalsIgnoreCase("navigation")) {
|
||||||
|
return updateNavigation(player, state, arguments[1]);
|
||||||
|
}
|
||||||
|
if (arguments.length == 2 && arguments[0].equalsIgnoreCase("flight")) {
|
||||||
|
return updateFlight(player, state, arguments[1]);
|
||||||
|
}
|
||||||
|
if (arguments.length == 2 && arguments[0].equalsIgnoreCase("border")) {
|
||||||
|
return updateBorder(player, state, arguments[1]);
|
||||||
|
}
|
||||||
|
if (arguments.length == 2 && arguments[0].equalsIgnoreCase("spawnable")) {
|
||||||
|
return updateSpawnableOverlay(player, state, arguments[1]);
|
||||||
|
}
|
||||||
|
if (arguments.length == 2 && arguments[0].equalsIgnoreCase("bossbar")) {
|
||||||
|
return updateBossBar(player, arguments[1]);
|
||||||
|
}
|
||||||
|
sendUsage(player);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean purchaseVisitorAccess(Player player, PlayerState state) {
|
||||||
|
if (!new VisitorPolicy().canPurchase(state)) {
|
||||||
|
player.sendMessage(ChatColor.RED + (state.baseLevel() >= 4
|
||||||
|
? "Base IV is already unlocked."
|
||||||
|
: "You must unlock Base III before purchasing Base IV."));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
int price = settings.current().visitorUnlockDiamondCost();
|
||||||
|
Material currency = Material.valueOf(settings.current().visitorCurrencyMaterial());
|
||||||
|
PlayerInventory inventory = player.getInventory();
|
||||||
|
if (countCurrency(inventory, currency) < price) {
|
||||||
|
player.sendMessage(ChatColor.RED + "Base IV costs " + price + " "
|
||||||
|
+ currency.name().toLowerCase(java.util.Locale.ROOT) + ".");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
ItemStack[] snapshot = cloneContents(inventory.getStorageContents());
|
||||||
|
removeCurrency(inventory, currency, price);
|
||||||
|
try {
|
||||||
|
stateManager.updateAndSave(
|
||||||
|
player.getUniqueId(),
|
||||||
|
player.getName(),
|
||||||
|
current -> current.withBaseLevel(4).withVisitorsEnabled(true)
|
||||||
|
);
|
||||||
|
} catch (IOException | RuntimeException exception) {
|
||||||
|
inventory.setStorageContents(snapshot);
|
||||||
|
player.sendMessage(ChatColor.RED
|
||||||
|
+ "The upgrade could not be saved; your diamonds were restored.");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
player.sendTitle(
|
||||||
|
ChatColor.GOLD + "Base IV Unlocked",
|
||||||
|
ChatColor.YELLOW + "Visitors may now teleport to your base",
|
||||||
|
settings.current().titleFadeInTicks(),
|
||||||
|
settings.current().titleStayTicks(),
|
||||||
|
settings.current().titleFadeOutTicks()
|
||||||
|
);
|
||||||
|
player.sendMessage(ChatColor.GREEN + "Base IV unlocked for " + price + " "
|
||||||
|
+ currency.name().toLowerCase(java.util.Locale.ROOT) + ".");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean purchasePocketUpgrade(Player player, PlayerState owner) {
|
||||||
|
if (pocketBases == null) {
|
||||||
|
player.sendMessage(ChatColor.RED + "Pocket Bases are currently unavailable.");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (owner.baseLevel() < 4 || owner.base().isEmpty()) {
|
||||||
|
player.sendMessage(ChatColor.RED
|
||||||
|
+ "Establish Base IV before purchasing a Pocket Base.");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
PocketBaseState current = pocketBases.state(owner.playerId());
|
||||||
|
int price = current.level() == 0
|
||||||
|
? settings.current().pocketBaseUnlockCost()
|
||||||
|
: settings.current().pocketBaseUpgradeCost();
|
||||||
|
Material currency = Material.valueOf(settings.current().pocketBaseCurrencyMaterial());
|
||||||
|
PlayerInventory inventory = player.getInventory();
|
||||||
|
if (countCurrency(inventory, currency) < price) {
|
||||||
|
player.sendMessage(ChatColor.RED + "Pocket Base "
|
||||||
|
+ (current.level() == 0 ? "I" : "level " + (current.level() + 1))
|
||||||
|
+ " costs " + price + " "
|
||||||
|
+ currency.name().toLowerCase(java.util.Locale.ROOT) + ".");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
ItemStack[] snapshot = cloneContents(inventory.getStorageContents());
|
||||||
|
removeCurrency(inventory, currency, price);
|
||||||
|
final PocketBaseState upgraded;
|
||||||
|
try {
|
||||||
|
upgraded = pocketBases.upgrade(owner.playerId());
|
||||||
|
} catch (IOException | RuntimeException exception) {
|
||||||
|
inventory.setStorageContents(snapshot);
|
||||||
|
player.sendMessage(ChatColor.RED
|
||||||
|
+ "The Pocket Base upgrade failed; your payment was restored.");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
player.sendTitle(
|
||||||
|
ChatColor.GOLD + "Pocket Base " + upgraded.level() + " Unlocked",
|
||||||
|
ChatColor.YELLOW + "Build a portal inside your base",
|
||||||
|
settings.current().titleFadeInTicks(),
|
||||||
|
settings.current().titleStayTicks(),
|
||||||
|
settings.current().titleFadeOutTicks()
|
||||||
|
);
|
||||||
|
player.sendMessage(ChatColor.GREEN + "Pocket Base level " + upgraded.level()
|
||||||
|
+ " unlocked for " + price + " "
|
||||||
|
+ currency.name().toLowerCase(java.util.Locale.ROOT) + ".");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean updatePocketMobSpawning(Player player, String mode) {
|
||||||
|
if (pocketBases == null) {
|
||||||
|
player.sendMessage(ChatColor.RED + "Pocket Bases are currently unavailable.");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
PocketBaseState current = pocketBases.state(player.getUniqueId());
|
||||||
|
if (current.level() < 1) {
|
||||||
|
player.sendMessage(ChatColor.RED + "Pocket Base I is still locked.");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
Boolean enabled = enabledMode(mode);
|
||||||
|
if (enabled == null) {
|
||||||
|
sendUsage(player);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
final PocketBaseState updated;
|
||||||
|
try {
|
||||||
|
updated = pocketBases.setMobSpawning(player.getUniqueId(), enabled);
|
||||||
|
} catch (IOException | RuntimeException exception) {
|
||||||
|
player.sendMessage(ChatColor.RED
|
||||||
|
+ "The Pocket Base mob-spawning setting could not be changed.");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
player.sendMessage(ChatColor.YELLOW + "Pocket Base mob spawning is now "
|
||||||
|
+ (updated.mobSpawningEnabled()
|
||||||
|
? ChatColor.GREEN + "enabled"
|
||||||
|
: ChatColor.RED + "disabled")
|
||||||
|
+ ChatColor.YELLOW + ".");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean updateVisitors(Player player, PlayerState state, String mode) {
|
||||||
|
if (state.baseLevel() < 4) {
|
||||||
|
player.sendMessage(ChatColor.RED + "Base IV visitor access is still locked.");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
final boolean enabled;
|
||||||
|
if (mode.equalsIgnoreCase("allowed")) {
|
||||||
|
enabled = true;
|
||||||
|
} else if (mode.equalsIgnoreCase("blocked")) {
|
||||||
|
enabled = false;
|
||||||
|
} else {
|
||||||
|
sendUsage(player);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
state = stateManager.update(
|
||||||
|
player.getUniqueId(),
|
||||||
|
player.getName(),
|
||||||
|
current -> current.withVisitorsEnabled(enabled)
|
||||||
|
);
|
||||||
|
stateManager.saveIfDirty();
|
||||||
|
player.sendMessage(ChatColor.YELLOW + "Visitor teleports are now "
|
||||||
|
+ (state.visitorsEnabled() ? ChatColor.GREEN + "allowed" : ChatColor.RED + "blocked")
|
||||||
|
+ ChatColor.YELLOW + ".");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean updateNavigation(Player player, PlayerState state, String mode) {
|
||||||
|
if (state.baseLevel() < 2) {
|
||||||
|
player.sendMessage(ChatColor.RED + "Base II navigation is still locked.");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (state.base().isEmpty()) {
|
||||||
|
player.sendMessage(ChatColor.RED + "Set your base before enabling navigation.");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
Boolean enabled = enabledMode(mode);
|
||||||
|
if (enabled == null) {
|
||||||
|
sendUsage(player);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
state = stateManager.update(
|
||||||
|
player.getUniqueId(),
|
||||||
|
player.getName(),
|
||||||
|
current -> current.withNavigationEnabled(enabled)
|
||||||
|
);
|
||||||
|
stateManager.saveIfDirty();
|
||||||
|
player.sendMessage(ChatColor.YELLOW + "Base navigation is now "
|
||||||
|
+ (state.navigationEnabled() ? ChatColor.GREEN + "enabled" : ChatColor.RED + "disabled")
|
||||||
|
+ ChatColor.YELLOW + ".");
|
||||||
|
if (state.navigationEnabled()
|
||||||
|
&& !player.getWorld().getUID().equals(state.base().orElseThrow().worldId())) {
|
||||||
|
player.sendMessage(ChatColor.RED + "Your base is in another world: "
|
||||||
|
+ state.base().orElseThrow().worldName() + ".");
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean updateFlight(Player player, PlayerState state, String mode) {
|
||||||
|
if (state.flightLevel() < 1) {
|
||||||
|
player.sendMessage(ChatColor.RED + "Base flight is still locked.");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
Boolean enabled = enabledMode(mode);
|
||||||
|
if (enabled == null) {
|
||||||
|
sendUsage(player);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
state = stateManager.update(
|
||||||
|
player.getUniqueId(),
|
||||||
|
player.getName(),
|
||||||
|
current -> current.withFlightEnabled(enabled)
|
||||||
|
);
|
||||||
|
if (!state.flightEnabled()) {
|
||||||
|
flightController.removeGrantedFlight(player);
|
||||||
|
}
|
||||||
|
stateManager.saveIfDirty();
|
||||||
|
player.sendMessage(ChatColor.YELLOW + "Base flight is now "
|
||||||
|
+ (state.flightEnabled() ? ChatColor.GREEN + "enabled" : ChatColor.RED + "disabled")
|
||||||
|
+ ChatColor.YELLOW + ".");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean updateBorder(Player player, PlayerState state, String mode) {
|
||||||
|
if (state.baseLevel() < 1 || state.base().isEmpty()) {
|
||||||
|
player.sendMessage(ChatColor.RED
|
||||||
|
+ "Establish Base I before enabling border visualization.");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
Boolean enabled = enabledMode(mode);
|
||||||
|
if (enabled == null) {
|
||||||
|
sendUsage(player);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
state = stateManager.update(
|
||||||
|
player.getUniqueId(),
|
||||||
|
player.getName(),
|
||||||
|
current -> current.withBorderEnabled(enabled)
|
||||||
|
);
|
||||||
|
stateManager.saveIfDirty();
|
||||||
|
player.sendMessage(ChatColor.YELLOW + "Base border visualization is now "
|
||||||
|
+ (state.borderEnabled() ? ChatColor.GREEN + "enabled" : ChatColor.RED + "disabled")
|
||||||
|
+ ChatColor.YELLOW + ".");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean updateSpawnableOverlay(Player player, PlayerState state, String mode) {
|
||||||
|
if (state.totalBlocksPlaced() < settings.current().spawnableOverlayUnlockPlacements()) {
|
||||||
|
player.sendMessage(ChatColor.RED + "Spawnable highlighting unlocks after "
|
||||||
|
+ settings.current().spawnableOverlayUnlockPlacements() + " total placements.");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (state.baseLevel() < 1 || state.base().isEmpty()) {
|
||||||
|
player.sendMessage(ChatColor.RED
|
||||||
|
+ "Establish Base I before enabling spawnable highlighting.");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
Boolean enabled = enabledMode(mode);
|
||||||
|
if (enabled == null) {
|
||||||
|
sendUsage(player);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
state = stateManager.update(
|
||||||
|
player.getUniqueId(),
|
||||||
|
player.getName(),
|
||||||
|
current -> current.withSpawnableOverlayEnabled(enabled)
|
||||||
|
);
|
||||||
|
stateManager.saveIfDirty();
|
||||||
|
player.sendMessage(ChatColor.YELLOW + "Spawnable block highlighting is now "
|
||||||
|
+ (state.spawnableOverlayEnabled()
|
||||||
|
? ChatColor.GREEN + "enabled"
|
||||||
|
: ChatColor.RED + "disabled")
|
||||||
|
+ ChatColor.YELLOW + ".");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean updateBossBar(Player player, String mode) {
|
||||||
|
Boolean enabled = enabledMode(mode);
|
||||||
|
if (enabled == null) {
|
||||||
|
sendUsage(player);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
PlayerState state = stateManager.update(
|
||||||
|
player.getUniqueId(),
|
||||||
|
player.getName(),
|
||||||
|
current -> current.withBossBarEnabled(enabled)
|
||||||
|
);
|
||||||
|
stateManager.saveIfDirty();
|
||||||
|
player.sendMessage(ChatColor.YELLOW + "Automatic progress boss bars are now "
|
||||||
|
+ (state.bossBarEnabled() ? ChatColor.GREEN + "enabled" : ChatColor.RED + "disabled")
|
||||||
|
+ ChatColor.YELLOW + ".");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void showStatus(Player player, PlayerState state) {
|
||||||
|
player.sendMessage(ChatColor.GOLD + "=== Base Progress ===");
|
||||||
|
showBasePath(player, state);
|
||||||
|
showSizePath(player, state);
|
||||||
|
showFlightPath(player, state);
|
||||||
|
showWarmupPath(player, state);
|
||||||
|
showCooldownPath(player, state);
|
||||||
|
showPocketPath(player, state);
|
||||||
|
int spawnableThreshold = settings.current().spawnableOverlayUnlockPlacements();
|
||||||
|
player.sendMessage(ChatColor.YELLOW + "Spawnable Overlay: " + ChatColor.GRAY
|
||||||
|
+ state.totalBlocksPlaced() + "/" + spawnableThreshold + " placements; "
|
||||||
|
+ (state.totalBlocksPlaced() >= spawnableThreshold ? "unlocked" : "locked")
|
||||||
|
+ "; setting=" + (state.spawnableOverlayEnabled() ? "enabled" : "disabled"));
|
||||||
|
player.sendMessage(ChatColor.GRAY + "Settings: visitors="
|
||||||
|
+ (state.visitorsEnabled() ? "allowed" : "blocked")
|
||||||
|
+ " navigation=" + (state.navigationEnabled() ? "enabled" : "disabled")
|
||||||
|
+ " flight=" + (state.flightEnabled() ? "enabled" : "disabled")
|
||||||
|
+ " border=" + (state.borderEnabled() ? "enabled" : "disabled")
|
||||||
|
+ " spawnable=" + (state.spawnableOverlayEnabled() ? "enabled" : "disabled")
|
||||||
|
+ " bossbar=" + (state.bossBarEnabled() ? "enabled" : "disabled"));
|
||||||
|
state.base().ifPresentOrElse(
|
||||||
|
base -> player.sendMessage(ChatColor.GRAY + "Base: " + base.worldName() + " "
|
||||||
|
+ base.x() + ", " + base.y() + ", " + base.z()),
|
||||||
|
() -> player.sendMessage(ChatColor.GRAY + "Base: not set")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void showBasePath(Player player, PlayerState state) {
|
||||||
|
String detail = switch (state.baseLevel()) {
|
||||||
|
case 0 -> state.grassAndDirtBroken() + "/" + settings.current().baseUnlockBlocks()
|
||||||
|
+ " grass or dirt → /setbase";
|
||||||
|
case 1 -> state.grassAndDirtBroken() + "/" + settings.current().navigationUnlockBlocks()
|
||||||
|
+ " grass or dirt → navigation";
|
||||||
|
case 2 -> state.blocksPlacedInBase() + "/" + settings.current().teleportUnlockPlacements()
|
||||||
|
+ " placements → /base";
|
||||||
|
case 3 -> settings.current().visitorUnlockDiamondCost()
|
||||||
|
+ " diamonds → /basesettings upgrade";
|
||||||
|
case 4 -> "complete; visitor access unlocked";
|
||||||
|
default -> "invalid";
|
||||||
|
};
|
||||||
|
player.sendMessage(ChatColor.YELLOW + "Base " + state.baseLevel() + "/4: "
|
||||||
|
+ ChatColor.GRAY + detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void showSizePath(Player player, PlayerState state) {
|
||||||
|
String detail = switch (state.sizeLevel()) {
|
||||||
|
case 0 -> state.stoneBroken() + "/" + settings.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";
|
||||||
|
};
|
||||||
|
player.sendMessage(colorForPrerequisite(state.baseLevel() >= 1) + "Base Size "
|
||||||
|
+ state.sizeLevel() + "/3: " + ChatColor.GRAY + detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void showFlightPath(Player player, PlayerState state) {
|
||||||
|
String detail = state.flightLevel() >= 3
|
||||||
|
? "complete; world build height"
|
||||||
|
: (state.flightLevel() + 1) + " simultaneous elytra required";
|
||||||
|
player.sendMessage(colorForPrerequisite(state.baseLevel() >= 1) + "Base Flight "
|
||||||
|
+ state.flightLevel() + "/3: " + ChatColor.GRAY + detail
|
||||||
|
+ "; setting=" + (state.flightEnabled() ? "enabled" : "disabled"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void showWarmupPath(Player player, PlayerState state) {
|
||||||
|
String detail = switch (state.warmupLevel()) {
|
||||||
|
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";
|
||||||
|
};
|
||||||
|
player.sendMessage(colorForPrerequisite(state.baseLevel() >= 3) + "Teleport Warm-up "
|
||||||
|
+ state.warmupLevel() + "/3: " + ChatColor.GRAY
|
||||||
|
+ DurationFormatter.friendly(teleportPolicy.warmup(state)) + "; " + detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void showPocketPath(Player player, PlayerState owner) {
|
||||||
|
PocketBaseState pocket = pocketBases == null
|
||||||
|
? PocketBaseState.locked(owner.playerId())
|
||||||
|
: pocketBases.state(owner.playerId());
|
||||||
|
int price = pocket.level() == 0
|
||||||
|
? settings.current().pocketBaseUnlockCost()
|
||||||
|
: settings.current().pocketBaseUpgradeCost();
|
||||||
|
player.sendMessage(colorForPrerequisite(owner.baseLevel() >= 4 && owner.base().isPresent())
|
||||||
|
+ "Pocket Base " + pocket.level() + ": " + ChatColor.GRAY + price + " "
|
||||||
|
+ settings.current().pocketBaseCurrencyMaterial().toLowerCase(Locale.ROOT)
|
||||||
|
+ " → /basesettings pocket upgrade; mob spawning="
|
||||||
|
+ (pocket.mobSpawningEnabled() ? "enabled" : "disabled"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void showCooldownPath(Player player, PlayerState state) {
|
||||||
|
String detail = switch (state.cooldownLevel()) {
|
||||||
|
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";
|
||||||
|
};
|
||||||
|
player.sendMessage(colorForPrerequisite(state.baseLevel() >= 3) + "Teleport Cooldown "
|
||||||
|
+ state.cooldownLevel() + "/4: " + ChatColor.GRAY
|
||||||
|
+ DurationFormatter.friendly(teleportPolicy.cooldown(state)) + "; " + detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<String> onTabComplete(
|
||||||
|
CommandSender sender,
|
||||||
|
Command command,
|
||||||
|
String alias,
|
||||||
|
String[] arguments
|
||||||
|
) {
|
||||||
|
if (arguments.length == 1) {
|
||||||
|
String prefix = arguments[0].toLowerCase(Locale.ROOT);
|
||||||
|
return SETTINGS.stream().filter(setting -> setting.startsWith(prefix)).toList();
|
||||||
|
}
|
||||||
|
if (arguments.length == 2) {
|
||||||
|
List<String> modes = arguments[0].equalsIgnoreCase("visitors")
|
||||||
|
? VISITOR_MODES
|
||||||
|
: switch (arguments[0].toLowerCase(Locale.ROOT)) {
|
||||||
|
case "pocket" -> List.of("upgrade", "mobs");
|
||||||
|
case "navigation", "flight", "border", "spawnable", "bossbar" -> ENABLE_MODES;
|
||||||
|
default -> List.of();
|
||||||
|
};
|
||||||
|
String prefix = arguments[1].toLowerCase(Locale.ROOT);
|
||||||
|
return modes.stream().filter(mode -> mode.startsWith(prefix)).toList();
|
||||||
|
}
|
||||||
|
if (arguments.length == 3 && arguments[0].equalsIgnoreCase("pocket")
|
||||||
|
&& arguments[1].equalsIgnoreCase("mobs")) {
|
||||||
|
String prefix = arguments[2].toLowerCase(Locale.ROOT);
|
||||||
|
return ENABLE_MODES.stream().filter(mode -> mode.startsWith(prefix)).toList();
|
||||||
|
}
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int countCurrency(PlayerInventory inventory, Material currency) {
|
||||||
|
int count = 0;
|
||||||
|
for (ItemStack item : inventory.getStorageContents()) {
|
||||||
|
if (item != null && item.getType() == currency) {
|
||||||
|
count += item.getAmount();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
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() != currency) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
int removed = Math.min(remaining, item.getAmount());
|
||||||
|
remaining -= removed;
|
||||||
|
int newAmount = item.getAmount() - removed;
|
||||||
|
if (newAmount == 0) {
|
||||||
|
contents[index] = null;
|
||||||
|
} else {
|
||||||
|
ItemStack reduced = item.clone();
|
||||||
|
reduced.setAmount(newAmount);
|
||||||
|
contents[index] = reduced;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
inventory.setStorageContents(contents);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ItemStack[] cloneContents(ItemStack[] contents) {
|
||||||
|
ItemStack[] copy = new ItemStack[contents.length];
|
||||||
|
for (int index = 0; index < contents.length; index++) {
|
||||||
|
copy[index] = contents[index] == null ? null : contents[index].clone();
|
||||||
|
}
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Boolean enabledMode(String mode) {
|
||||||
|
if (mode.equalsIgnoreCase("enable")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (mode.equalsIgnoreCase("disable")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ChatColor colorForPrerequisite(boolean met) {
|
||||||
|
return met ? ChatColor.YELLOW : ChatColor.RED;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void sendUsage(Player player) {
|
||||||
|
player.sendMessage(ChatColor.RED + "Usage: /basesettings "
|
||||||
|
+ "[status|upgrade|pocket upgrade|pocket mobs <enable|disable>"
|
||||||
|
+ "|visitors <allowed|blocked>|navigation <enable|disable>"
|
||||||
|
+ "|flight <enable|disable>|border <enable|disable>"
|
||||||
|
+ "|spawnable <enable|disable>|bossbar <enable|disable>]");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -83,6 +83,13 @@ final class BaseTeleportManager implements Listener {
|
|||||||
visitor.sendMessage(ChatColor.RED + "That base is not accepting visitors.");
|
visitor.sendMessage(ChatColor.RED + "That base is not accepting visitors.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
BaseLocation destination = owner.base().orElseThrow();
|
||||||
|
if (!visitor.getWorld().getUID().equals(destination.worldId())) {
|
||||||
|
visitor.sendMessage(
|
||||||
|
ChatColor.RED + "You must be in the same dimension as that base to visit it."
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
PlayerState visitorState = stateManager.player(visitor.getUniqueId(), visitor.getName());
|
PlayerState visitorState = stateManager.player(visitor.getUniqueId(), visitor.getName());
|
||||||
Optional<Duration> remaining = visitorPolicy.remaining(
|
Optional<Duration> remaining = visitorPolicy.remaining(
|
||||||
visitorState, owner.playerId(), clock.instant()
|
visitorState, owner.playerId(), clock.instant()
|
||||||
@@ -94,8 +101,8 @@ final class BaseTeleportManager implements Listener {
|
|||||||
}
|
}
|
||||||
begin(
|
begin(
|
||||||
visitor,
|
visitor,
|
||||||
owner.base().orElseThrow(),
|
destination,
|
||||||
policy.warmup(owner),
|
policy.visitorWarmup(owner),
|
||||||
owner.playerId(),
|
owner.playerId(),
|
||||||
policy.cooldown(owner),
|
policy.cooldown(owner),
|
||||||
"visit " + owner.latestName() + "'s base"
|
"visit " + owner.latestName() + "'s base"
|
||||||
@@ -189,7 +196,7 @@ final class BaseTeleportManager implements Listener {
|
|||||||
public void onCommand(PlayerCommandPreprocessEvent event) {
|
public void onCommand(PlayerCommandPreprocessEvent event) {
|
||||||
String command = event.getMessage().toLowerCase(Locale.ROOT).split("\\s+", 2)[0];
|
String command = event.getMessage().toLowerCase(Locale.ROOT).split("\\s+", 2)[0];
|
||||||
if (command.equals("/base") || command.equals("/gotobase")
|
if (command.equals("/base") || command.equals("/gotobase")
|
||||||
|| command.equals("/spawn") || command.equals("/home")
|
|| command.equals("/visit") || command.equals("/spawn") || command.equals("/home")
|
||||||
|| command.equals("/tp") || command.equals("/teleport")) {
|
|| command.equals("/tp") || command.equals("/teleport")) {
|
||||||
cancel(event.getPlayer(), "Base teleport cancelled by another teleport command.");
|
cancel(event.getPlayer(), "Base teleport cancelled by another teleport command.");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
package games.dmg.spigotbase;
|
|
||||||
|
|
||||||
import org.bukkit.ChatColor;
|
|
||||||
import org.bukkit.command.Command;
|
|
||||||
import org.bukkit.command.CommandExecutor;
|
|
||||||
import org.bukkit.command.CommandSender;
|
|
||||||
import org.bukkit.entity.Player;
|
|
||||||
|
|
||||||
final class BaseVisitorsCommand implements CommandExecutor {
|
|
||||||
private final BaseStateManager stateManager;
|
|
||||||
|
|
||||||
BaseVisitorsCommand(BaseStateManager stateManager) {
|
|
||||||
this.stateManager = stateManager;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean onCommand(CommandSender sender, Command command, String label, String[] arguments) {
|
|
||||||
if (!(sender instanceof Player player)) {
|
|
||||||
sender.sendMessage("Only players can manage base visitors.");
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
PlayerState state = stateManager.player(player.getUniqueId(), player.getName());
|
|
||||||
if (state.baseLevel() < 4) {
|
|
||||||
player.sendMessage(ChatColor.RED + "Base IV visitor access is still locked.");
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
state = stateManager.update(
|
|
||||||
player.getUniqueId(),
|
|
||||||
player.getName(),
|
|
||||||
current -> current.withVisitorsEnabled(!current.visitorsEnabled())
|
|
||||||
);
|
|
||||||
stateManager.saveIfDirty();
|
|
||||||
player.sendMessage(ChatColor.YELLOW + "Visitor teleports are now "
|
|
||||||
+ (state.visitorsEnabled() ? ChatColor.GREEN + "on" : ChatColor.RED + "off")
|
|
||||||
+ ChatColor.YELLOW + ".");
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
package games.dmg.spigotbase;
|
||||||
|
|
||||||
|
public record BlockPosition(int x, int y, int z) {
|
||||||
|
}
|
||||||
@@ -26,7 +26,10 @@ public record PlayerState(
|
|||||||
boolean visitorsEnabled,
|
boolean visitorsEnabled,
|
||||||
Optional<Instant> lastBaseSet,
|
Optional<Instant> lastBaseSet,
|
||||||
Optional<Instant> lastBaseTeleport,
|
Optional<Instant> lastBaseTeleport,
|
||||||
Map<UUID, Instant> visitorCooldownUntil
|
Map<UUID, Instant> visitorCooldownUntil,
|
||||||
|
boolean borderEnabled,
|
||||||
|
long totalBlocksPlaced,
|
||||||
|
boolean spawnableOverlayEnabled
|
||||||
) {
|
) {
|
||||||
public PlayerState {
|
public PlayerState {
|
||||||
if (playerId == null) {
|
if (playerId == null) {
|
||||||
@@ -53,6 +56,7 @@ public record PlayerState(
|
|||||||
requireNonNegative(obsidianBroken, "obsidian broken");
|
requireNonNegative(obsidianBroken, "obsidian broken");
|
||||||
requireNonNegative(blocksPlacedInBase, "blocks placed in base");
|
requireNonNegative(blocksPlacedInBase, "blocks placed in base");
|
||||||
requireNonNegative(blocksBrokenInBase, "blocks broken in base");
|
requireNonNegative(blocksBrokenInBase, "blocks broken in base");
|
||||||
|
requireNonNegative(totalBlocksPlaced, "total blocks placed");
|
||||||
|
|
||||||
if (baseLevel == 0 && (sizeLevel > 0 || flightLevel > 0)) {
|
if (baseLevel == 0 && (sizeLevel > 0 || flightLevel > 0)) {
|
||||||
throw new IllegalArgumentException("secondary progression requires Base I");
|
throw new IllegalArgumentException("secondary progression requires Base I");
|
||||||
@@ -69,12 +73,83 @@ public record PlayerState(
|
|||||||
if (baseLevel < 4 && visitorsEnabled) {
|
if (baseLevel < 4 && visitorsEnabled) {
|
||||||
throw new IllegalArgumentException("visitor access requires Base IV");
|
throw new IllegalArgumentException("visitor access requires Base IV");
|
||||||
}
|
}
|
||||||
|
if (borderEnabled && (baseLevel < 1 || base.isEmpty())) {
|
||||||
|
throw new IllegalArgumentException("border visualization requires an established Base I");
|
||||||
|
}
|
||||||
|
if (spawnableOverlayEnabled && (baseLevel < 1 || base.isEmpty())) {
|
||||||
|
throw new IllegalArgumentException("spawnable overlay requires an established Base I");
|
||||||
|
}
|
||||||
if (visitorCooldownUntil.entrySet().stream().anyMatch(entry ->
|
if (visitorCooldownUntil.entrySet().stream().anyMatch(entry ->
|
||||||
entry.getKey() == null || entry.getValue() == null)) {
|
entry.getKey() == null || entry.getValue() == null)) {
|
||||||
throw new IllegalArgumentException("visitor cooldowns must be complete");
|
throw new IllegalArgumentException("visitor cooldowns must be complete");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public PlayerState(
|
||||||
|
UUID playerId,
|
||||||
|
String latestName,
|
||||||
|
Optional<BaseLocation> base,
|
||||||
|
int baseLevel,
|
||||||
|
int sizeLevel,
|
||||||
|
int flightLevel,
|
||||||
|
int warmupLevel,
|
||||||
|
int cooldownLevel,
|
||||||
|
long grassAndDirtBroken,
|
||||||
|
long stoneBroken,
|
||||||
|
long deepslateBroken,
|
||||||
|
long obsidianBroken,
|
||||||
|
long blocksPlacedInBase,
|
||||||
|
long blocksBrokenInBase,
|
||||||
|
boolean navigationEnabled,
|
||||||
|
boolean flightEnabled,
|
||||||
|
boolean bossBarEnabled,
|
||||||
|
boolean visitorsEnabled,
|
||||||
|
Optional<Instant> lastBaseSet,
|
||||||
|
Optional<Instant> lastBaseTeleport,
|
||||||
|
Map<UUID, Instant> visitorCooldownUntil
|
||||||
|
) {
|
||||||
|
this(
|
||||||
|
playerId, latestName, base, baseLevel, sizeLevel, flightLevel,
|
||||||
|
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
|
||||||
|
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
|
||||||
|
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
|
||||||
|
lastBaseSet, lastBaseTeleport, visitorCooldownUntil, false, 0, false
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public PlayerState(
|
||||||
|
UUID playerId,
|
||||||
|
String latestName,
|
||||||
|
Optional<BaseLocation> base,
|
||||||
|
int baseLevel,
|
||||||
|
int sizeLevel,
|
||||||
|
int flightLevel,
|
||||||
|
int warmupLevel,
|
||||||
|
int cooldownLevel,
|
||||||
|
long grassAndDirtBroken,
|
||||||
|
long stoneBroken,
|
||||||
|
long deepslateBroken,
|
||||||
|
long obsidianBroken,
|
||||||
|
long blocksPlacedInBase,
|
||||||
|
long blocksBrokenInBase,
|
||||||
|
boolean navigationEnabled,
|
||||||
|
boolean flightEnabled,
|
||||||
|
boolean bossBarEnabled,
|
||||||
|
boolean visitorsEnabled,
|
||||||
|
Optional<Instant> lastBaseSet,
|
||||||
|
Optional<Instant> lastBaseTeleport,
|
||||||
|
Map<UUID, Instant> visitorCooldownUntil,
|
||||||
|
boolean borderEnabled
|
||||||
|
) {
|
||||||
|
this(
|
||||||
|
playerId, latestName, base, baseLevel, sizeLevel, flightLevel,
|
||||||
|
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
|
||||||
|
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
|
||||||
|
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
|
||||||
|
lastBaseSet, lastBaseTeleport, visitorCooldownUntil, borderEnabled, 0, false
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public static PlayerState newPlayer(UUID playerId, String latestName) {
|
public static PlayerState newPlayer(UUID playerId, String latestName) {
|
||||||
return new PlayerState(
|
return new PlayerState(
|
||||||
playerId,
|
playerId,
|
||||||
@@ -93,7 +168,8 @@ public record PlayerState(
|
|||||||
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
|
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
|
||||||
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
|
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
|
||||||
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
|
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
|
||||||
lastBaseSet, lastBaseTeleport, visitorCooldownUntil
|
lastBaseSet, lastBaseTeleport, visitorCooldownUntil, borderEnabled,
|
||||||
|
totalBlocksPlaced, spawnableOverlayEnabled
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,7 +179,8 @@ public record PlayerState(
|
|||||||
warmupLevel, cooldownLevel, count, stoneBroken, deepslateBroken,
|
warmupLevel, cooldownLevel, count, stoneBroken, deepslateBroken,
|
||||||
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
|
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
|
||||||
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
|
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
|
||||||
lastBaseSet, lastBaseTeleport, visitorCooldownUntil
|
lastBaseSet, lastBaseTeleport, visitorCooldownUntil, borderEnabled,
|
||||||
|
totalBlocksPlaced, spawnableOverlayEnabled
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,7 +190,8 @@ public record PlayerState(
|
|||||||
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
|
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
|
||||||
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
|
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
|
||||||
navigationEnabled, flightEnabled, enabled, visitorsEnabled,
|
navigationEnabled, flightEnabled, enabled, visitorsEnabled,
|
||||||
lastBaseSet, lastBaseTeleport, visitorCooldownUntil
|
lastBaseSet, lastBaseTeleport, visitorCooldownUntil, borderEnabled,
|
||||||
|
totalBlocksPlaced, spawnableOverlayEnabled
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,7 +201,8 @@ public record PlayerState(
|
|||||||
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
|
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
|
||||||
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
|
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
|
||||||
enabled, flightEnabled, bossBarEnabled, visitorsEnabled,
|
enabled, flightEnabled, bossBarEnabled, visitorsEnabled,
|
||||||
lastBaseSet, lastBaseTeleport, visitorCooldownUntil
|
lastBaseSet, lastBaseTeleport, visitorCooldownUntil, borderEnabled,
|
||||||
|
totalBlocksPlaced, spawnableOverlayEnabled
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,7 +212,8 @@ public record PlayerState(
|
|||||||
warmupLevel, cooldownLevel, grassAndDirtBroken, stone, deepslate,
|
warmupLevel, cooldownLevel, grassAndDirtBroken, stone, deepslate,
|
||||||
obsidian, blocksPlacedInBase, blocksBrokenInBase,
|
obsidian, blocksPlacedInBase, blocksBrokenInBase,
|
||||||
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
|
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
|
||||||
lastBaseSet, lastBaseTeleport, visitorCooldownUntil
|
lastBaseSet, lastBaseTeleport, visitorCooldownUntil, borderEnabled,
|
||||||
|
totalBlocksPlaced, spawnableOverlayEnabled
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,7 +223,8 @@ public record PlayerState(
|
|||||||
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
|
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
|
||||||
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
|
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
|
||||||
navigationEnabled, enabled, bossBarEnabled, visitorsEnabled,
|
navigationEnabled, enabled, bossBarEnabled, visitorsEnabled,
|
||||||
lastBaseSet, lastBaseTeleport, visitorCooldownUntil
|
lastBaseSet, lastBaseTeleport, visitorCooldownUntil, borderEnabled,
|
||||||
|
totalBlocksPlaced, spawnableOverlayEnabled
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,7 +244,8 @@ public record PlayerState(
|
|||||||
newWarmupLevel, newCooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
|
newWarmupLevel, newCooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
|
||||||
obsidianBroken, placements, breaks,
|
obsidianBroken, placements, breaks,
|
||||||
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
|
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
|
||||||
lastBaseSet, lastBaseTeleport, visitorCooldownUntil
|
lastBaseSet, lastBaseTeleport, visitorCooldownUntil, borderEnabled,
|
||||||
|
totalBlocksPlaced, spawnableOverlayEnabled
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,7 +261,8 @@ public record PlayerState(
|
|||||||
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
|
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
|
||||||
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
|
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
|
||||||
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
|
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
|
||||||
lastBaseSet, Optional.of(usedAt), visitorCooldownUntil
|
lastBaseSet, Optional.of(usedAt), visitorCooldownUntil, borderEnabled,
|
||||||
|
totalBlocksPlaced, spawnableOverlayEnabled
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,7 +272,8 @@ public record PlayerState(
|
|||||||
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
|
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
|
||||||
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
|
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
|
||||||
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
|
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
|
||||||
lastBaseSet, lastBaseTeleport, visitorCooldownUntil
|
lastBaseSet, lastBaseTeleport, visitorCooldownUntil, borderEnabled,
|
||||||
|
totalBlocksPlaced, spawnableOverlayEnabled
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,7 +290,8 @@ public record PlayerState(
|
|||||||
warmupLevel, cooldownLevel, grassDirt, stone, deepslate,
|
warmupLevel, cooldownLevel, grassDirt, stone, deepslate,
|
||||||
obsidian, placements, baseBreaks,
|
obsidian, placements, baseBreaks,
|
||||||
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
|
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
|
||||||
lastBaseSet, lastBaseTeleport, visitorCooldownUntil
|
lastBaseSet, lastBaseTeleport, visitorCooldownUntil, borderEnabled,
|
||||||
|
totalBlocksPlaced, spawnableOverlayEnabled
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,7 +310,10 @@ public record PlayerState(
|
|||||||
newWarmupLevel, newCooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
|
newWarmupLevel, newCooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
|
||||||
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
|
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
|
||||||
newNavigationEnabled, newFlightEnabled, bossBarEnabled, newVisitorsEnabled,
|
newNavigationEnabled, newFlightEnabled, bossBarEnabled, newVisitorsEnabled,
|
||||||
lastBaseSet, lastBaseTeleport, visitorCooldownUntil
|
lastBaseSet, lastBaseTeleport, visitorCooldownUntil,
|
||||||
|
borderEnabled && newBaseLevel >= 1 && base.isPresent(),
|
||||||
|
totalBlocksPlaced,
|
||||||
|
spawnableOverlayEnabled && newBaseLevel >= 1 && base.isPresent()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,7 +323,41 @@ public record PlayerState(
|
|||||||
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
|
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
|
||||||
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
|
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
|
||||||
navigationEnabled, flightEnabled, bossBarEnabled, enabled,
|
navigationEnabled, flightEnabled, bossBarEnabled, enabled,
|
||||||
lastBaseSet, lastBaseTeleport, visitorCooldownUntil
|
lastBaseSet, lastBaseTeleport, visitorCooldownUntil, borderEnabled,
|
||||||
|
totalBlocksPlaced, spawnableOverlayEnabled
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public PlayerState withBorderEnabled(boolean enabled) {
|
||||||
|
return new PlayerState(
|
||||||
|
playerId, latestName, base, baseLevel, sizeLevel, flightLevel,
|
||||||
|
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
|
||||||
|
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
|
||||||
|
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
|
||||||
|
lastBaseSet, lastBaseTeleport, visitorCooldownUntil, enabled,
|
||||||
|
totalBlocksPlaced, spawnableOverlayEnabled
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public PlayerState withTotalBlocksPlaced(long count) {
|
||||||
|
return new PlayerState(
|
||||||
|
playerId, latestName, base, baseLevel, sizeLevel, flightLevel,
|
||||||
|
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
|
||||||
|
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
|
||||||
|
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
|
||||||
|
lastBaseSet, lastBaseTeleport, visitorCooldownUntil, borderEnabled,
|
||||||
|
count, spawnableOverlayEnabled
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public PlayerState withSpawnableOverlayEnabled(boolean enabled) {
|
||||||
|
return new PlayerState(
|
||||||
|
playerId, latestName, base, baseLevel, sizeLevel, flightLevel,
|
||||||
|
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
|
||||||
|
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
|
||||||
|
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
|
||||||
|
lastBaseSet, lastBaseTeleport, visitorCooldownUntil, borderEnabled,
|
||||||
|
totalBlocksPlaced, enabled
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,7 +369,8 @@ public record PlayerState(
|
|||||||
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
|
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
|
||||||
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
|
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
|
||||||
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
|
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
|
||||||
lastBaseSet, lastBaseTeleport, cooldowns
|
lastBaseSet, lastBaseTeleport, cooldowns, borderEnabled,
|
||||||
|
totalBlocksPlaced, spawnableOverlayEnabled
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,7 +380,8 @@ public record PlayerState(
|
|||||||
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
|
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
|
||||||
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
|
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
|
||||||
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
|
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
|
||||||
lastBaseSet, Optional.empty(), visitorCooldownUntil
|
lastBaseSet, Optional.empty(), visitorCooldownUntil, borderEnabled,
|
||||||
|
totalBlocksPlaced, spawnableOverlayEnabled
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,7 +391,8 @@ public record PlayerState(
|
|||||||
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
|
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
|
||||||
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
|
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
|
||||||
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
|
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
|
||||||
lastBaseSet, lastBaseTeleport, Map.of()
|
lastBaseSet, lastBaseTeleport, Map.of(), borderEnabled,
|
||||||
|
totalBlocksPlaced, spawnableOverlayEnabled
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -277,7 +402,8 @@ public record PlayerState(
|
|||||||
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
|
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
|
||||||
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
|
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
|
||||||
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
|
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
|
||||||
Optional.of(setAt), lastBaseTeleport, visitorCooldownUntil
|
Optional.of(setAt), lastBaseTeleport, visitorCooldownUntil, borderEnabled,
|
||||||
|
totalBlocksPlaced, spawnableOverlayEnabled
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ public record PluginSettings(
|
|||||||
int flightWarningBuffer,
|
int flightWarningBuffer,
|
||||||
int secondFlightVerticalRange,
|
int secondFlightVerticalRange,
|
||||||
int teleportUnlockPlacements,
|
int teleportUnlockPlacements,
|
||||||
|
int spawnableOverlayUnlockPlacements,
|
||||||
int secondWarmupPlacements,
|
int secondWarmupPlacements,
|
||||||
int thirdWarmupPlacements,
|
int thirdWarmupPlacements,
|
||||||
int instantWarmupPlacements,
|
int instantWarmupPlacements,
|
||||||
@@ -36,11 +37,15 @@ public record PluginSettings(
|
|||||||
long thirdTeleportCooldownSeconds,
|
long thirdTeleportCooldownSeconds,
|
||||||
long fourthTeleportCooldownSeconds,
|
long fourthTeleportCooldownSeconds,
|
||||||
int visitorUnlockDiamondCost,
|
int visitorUnlockDiamondCost,
|
||||||
|
int pocketBaseUnlockCost,
|
||||||
|
int pocketBaseUpgradeCost,
|
||||||
Set<String> baseUnlockMaterials,
|
Set<String> baseUnlockMaterials,
|
||||||
String stoneExpansionMaterial,
|
String stoneExpansionMaterial,
|
||||||
String deepslateExpansionMaterial,
|
String deepslateExpansionMaterial,
|
||||||
String obsidianExpansionMaterial,
|
String obsidianExpansionMaterial,
|
||||||
String visitorCurrencyMaterial,
|
String visitorCurrencyMaterial,
|
||||||
|
String pocketBaseCurrencyMaterial,
|
||||||
|
String pocketBasePortalFrameMaterial,
|
||||||
Set<String> cooldownExcludedMaterials,
|
Set<String> cooldownExcludedMaterials,
|
||||||
int bossBarDurationTicks,
|
int bossBarDurationTicks,
|
||||||
int navigationParticleCount,
|
int navigationParticleCount,
|
||||||
@@ -62,6 +67,7 @@ public record PluginSettings(
|
|||||||
private static final int DEFAULT_FLIGHT_WARNING_BUFFER = 5;
|
private static final int DEFAULT_FLIGHT_WARNING_BUFFER = 5;
|
||||||
private static final int DEFAULT_SECOND_FLIGHT_VERTICAL_RANGE = 100;
|
private static final int DEFAULT_SECOND_FLIGHT_VERTICAL_RANGE = 100;
|
||||||
private static final int DEFAULT_TELEPORT_UNLOCK_PLACEMENTS = 200;
|
private static final int DEFAULT_TELEPORT_UNLOCK_PLACEMENTS = 200;
|
||||||
|
private static final int DEFAULT_SPAWNABLE_OVERLAY_UNLOCK_PLACEMENTS = 250;
|
||||||
private static final int DEFAULT_SECOND_WARMUP_PLACEMENTS = 1_000;
|
private static final int DEFAULT_SECOND_WARMUP_PLACEMENTS = 1_000;
|
||||||
private static final int DEFAULT_THIRD_WARMUP_PLACEMENTS = 2_000;
|
private static final int DEFAULT_THIRD_WARMUP_PLACEMENTS = 2_000;
|
||||||
private static final int DEFAULT_INSTANT_WARMUP_PLACEMENTS = 12_000;
|
private static final int DEFAULT_INSTANT_WARMUP_PLACEMENTS = 12_000;
|
||||||
@@ -77,6 +83,8 @@ public record PluginSettings(
|
|||||||
private static final long DEFAULT_THIRD_TELEPORT_COOLDOWN_SECONDS = 3_600L;
|
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 long DEFAULT_FOURTH_TELEPORT_COOLDOWN_SECONDS = 1_800L;
|
||||||
private static final int DEFAULT_VISITOR_UNLOCK_DIAMOND_COST = 128;
|
private static final int DEFAULT_VISITOR_UNLOCK_DIAMOND_COST = 128;
|
||||||
|
private static final int DEFAULT_POCKET_BASE_UNLOCK_COST = 64;
|
||||||
|
private static final int DEFAULT_POCKET_BASE_UPGRADE_COST = 16;
|
||||||
private static final int DEFAULT_BOSS_BAR_DURATION_TICKS = 60;
|
private static final int DEFAULT_BOSS_BAR_DURATION_TICKS = 60;
|
||||||
private static final int DEFAULT_NAVIGATION_PARTICLE_COUNT = 5;
|
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_FADE_IN_TICKS = 10;
|
||||||
@@ -110,6 +118,7 @@ public record PluginSettings(
|
|||||||
throw new IllegalArgumentException("second-flight-vertical-range must exceed the initial range");
|
throw new IllegalArgumentException("second-flight-vertical-range must exceed the initial range");
|
||||||
}
|
}
|
||||||
requirePositive(teleportUnlockPlacements, "teleport-unlock-placements");
|
requirePositive(teleportUnlockPlacements, "teleport-unlock-placements");
|
||||||
|
requirePositive(spawnableOverlayUnlockPlacements, "spawnable-overlay-unlock-placements");
|
||||||
if (secondWarmupPlacements <= teleportUnlockPlacements
|
if (secondWarmupPlacements <= teleportUnlockPlacements
|
||||||
|| thirdWarmupPlacements <= secondWarmupPlacements
|
|| thirdWarmupPlacements <= secondWarmupPlacements
|
||||||
|| instantWarmupPlacements <= thirdWarmupPlacements) {
|
|| instantWarmupPlacements <= thirdWarmupPlacements) {
|
||||||
@@ -137,6 +146,8 @@ public record PluginSettings(
|
|||||||
throw new IllegalArgumentException("teleport cooldown durations must decrease");
|
throw new IllegalArgumentException("teleport cooldown durations must decrease");
|
||||||
}
|
}
|
||||||
requirePositive(visitorUnlockDiamondCost, "visitor-unlock-diamond-cost");
|
requirePositive(visitorUnlockDiamondCost, "visitor-unlock-diamond-cost");
|
||||||
|
requirePositive(pocketBaseUnlockCost, "pocket-base-unlock-cost");
|
||||||
|
requirePositive(pocketBaseUpgradeCost, "pocket-base-upgrade-cost");
|
||||||
baseUnlockMaterials = normalizedSet(baseUnlockMaterials, "base-unlock-materials", false);
|
baseUnlockMaterials = normalizedSet(baseUnlockMaterials, "base-unlock-materials", false);
|
||||||
stoneExpansionMaterial = normalizedName(stoneExpansionMaterial, "stone-expansion-material");
|
stoneExpansionMaterial = normalizedName(stoneExpansionMaterial, "stone-expansion-material");
|
||||||
deepslateExpansionMaterial = normalizedName(
|
deepslateExpansionMaterial = normalizedName(
|
||||||
@@ -148,6 +159,12 @@ public record PluginSettings(
|
|||||||
visitorCurrencyMaterial = normalizedName(
|
visitorCurrencyMaterial = normalizedName(
|
||||||
visitorCurrencyMaterial, "visitor-currency-material"
|
visitorCurrencyMaterial, "visitor-currency-material"
|
||||||
);
|
);
|
||||||
|
pocketBaseCurrencyMaterial = normalizedName(
|
||||||
|
pocketBaseCurrencyMaterial, "pocket-base-currency-material"
|
||||||
|
);
|
||||||
|
pocketBasePortalFrameMaterial = normalizedName(
|
||||||
|
pocketBasePortalFrameMaterial, "pocket-base-portal-frame-material"
|
||||||
|
);
|
||||||
cooldownExcludedMaterials = normalizedSet(
|
cooldownExcludedMaterials = normalizedSet(
|
||||||
cooldownExcludedMaterials, "cooldown-excluded-materials", true
|
cooldownExcludedMaterials, "cooldown-excluded-materials", true
|
||||||
);
|
);
|
||||||
@@ -175,6 +192,11 @@ public record PluginSettings(
|
|||||||
integer(values, "flight-warning-buffer", DEFAULT_FLIGHT_WARNING_BUFFER),
|
integer(values, "flight-warning-buffer", DEFAULT_FLIGHT_WARNING_BUFFER),
|
||||||
integer(values, "second-flight-vertical-range", DEFAULT_SECOND_FLIGHT_VERTICAL_RANGE),
|
integer(values, "second-flight-vertical-range", DEFAULT_SECOND_FLIGHT_VERTICAL_RANGE),
|
||||||
integer(values, "teleport-unlock-placements", DEFAULT_TELEPORT_UNLOCK_PLACEMENTS),
|
integer(values, "teleport-unlock-placements", DEFAULT_TELEPORT_UNLOCK_PLACEMENTS),
|
||||||
|
integer(
|
||||||
|
values,
|
||||||
|
"spawnable-overlay-unlock-placements",
|
||||||
|
DEFAULT_SPAWNABLE_OVERLAY_UNLOCK_PLACEMENTS
|
||||||
|
),
|
||||||
integer(values, "second-warmup-placements", DEFAULT_SECOND_WARMUP_PLACEMENTS),
|
integer(values, "second-warmup-placements", DEFAULT_SECOND_WARMUP_PLACEMENTS),
|
||||||
integer(values, "third-warmup-placements", DEFAULT_THIRD_WARMUP_PLACEMENTS),
|
integer(values, "third-warmup-placements", DEFAULT_THIRD_WARMUP_PLACEMENTS),
|
||||||
integer(values, "instant-warmup-placements", DEFAULT_INSTANT_WARMUP_PLACEMENTS),
|
integer(values, "instant-warmup-placements", DEFAULT_INSTANT_WARMUP_PLACEMENTS),
|
||||||
@@ -206,11 +228,15 @@ public record PluginSettings(
|
|||||||
DEFAULT_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),
|
||||||
|
integer(values, "pocket-base-unlock-cost", DEFAULT_POCKET_BASE_UNLOCK_COST),
|
||||||
|
integer(values, "pocket-base-upgrade-cost", DEFAULT_POCKET_BASE_UPGRADE_COST),
|
||||||
stringSet(values, "base-unlock-materials", Set.of("GRASS_BLOCK", "DIRT")),
|
stringSet(values, "base-unlock-materials", Set.of("GRASS_BLOCK", "DIRT")),
|
||||||
string(values, "stone-expansion-material", "STONE"),
|
string(values, "stone-expansion-material", "STONE"),
|
||||||
string(values, "deepslate-expansion-material", "DEEPSLATE"),
|
string(values, "deepslate-expansion-material", "DEEPSLATE"),
|
||||||
string(values, "obsidian-expansion-material", "OBSIDIAN"),
|
string(values, "obsidian-expansion-material", "OBSIDIAN"),
|
||||||
string(values, "visitor-currency-material", "DIAMOND"),
|
string(values, "visitor-currency-material", "DIAMOND"),
|
||||||
|
string(values, "pocket-base-currency-material", "DIAMOND_BLOCK"),
|
||||||
|
string(values, "pocket-base-portal-frame-material", "DIAMOND_BLOCK"),
|
||||||
stringSet(values, "cooldown-excluded-materials", Set.of()),
|
stringSet(values, "cooldown-excluded-materials", Set.of()),
|
||||||
integer(values, "boss-bar-duration-ticks", DEFAULT_BOSS_BAR_DURATION_TICKS),
|
integer(values, "boss-bar-duration-ticks", DEFAULT_BOSS_BAR_DURATION_TICKS),
|
||||||
integer(values, "navigation-particle-count", DEFAULT_NAVIGATION_PARTICLE_COUNT),
|
integer(values, "navigation-particle-count", DEFAULT_NAVIGATION_PARTICLE_COUNT),
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ final class PluginSettingsValidator {
|
|||||||
Stream.of(
|
Stream.of(
|
||||||
settings.stoneExpansionMaterial(),
|
settings.stoneExpansionMaterial(),
|
||||||
settings.deepslateExpansionMaterial(),
|
settings.deepslateExpansionMaterial(),
|
||||||
settings.obsidianExpansionMaterial()
|
settings.obsidianExpansionMaterial(),
|
||||||
|
settings.pocketBasePortalFrameMaterial()
|
||||||
)
|
)
|
||||||
).forEach(name -> {
|
).forEach(name -> {
|
||||||
Material material = Material.matchMaterial(name);
|
Material material = Material.matchMaterial(name);
|
||||||
@@ -26,10 +27,15 @@ final class PluginSettingsValidator {
|
|||||||
throw new IllegalArgumentException(name + " is not a known material");
|
throw new IllegalArgumentException(name + " is not a known material");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Material currency = Material.matchMaterial(settings.visitorCurrencyMaterial());
|
validateCurrency(settings.visitorCurrencyMaterial(), "visitor-currency-material");
|
||||||
if (currency == null || currency.isAir()) {
|
validateCurrency(settings.pocketBaseCurrencyMaterial(), "pocket-base-currency-material");
|
||||||
throw new IllegalArgumentException("visitor-currency-material is invalid");
|
|
||||||
}
|
|
||||||
return settings;
|
return settings;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void validateCurrency(String name, String setting) {
|
||||||
|
Material currency = Material.matchMaterial(name);
|
||||||
|
if (currency == null || currency.isAir()) {
|
||||||
|
throw new IllegalArgumentException(setting + " is invalid");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,339 @@
|
|||||||
|
package games.dmg.spigotbase;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.logging.Level;
|
||||||
|
import java.util.logging.Logger;
|
||||||
|
import org.bukkit.ChatColor;
|
||||||
|
import org.bukkit.Location;
|
||||||
|
import org.bukkit.Material;
|
||||||
|
import org.bukkit.Particle;
|
||||||
|
import org.bukkit.Server;
|
||||||
|
import org.bukkit.World;
|
||||||
|
import org.bukkit.block.Block;
|
||||||
|
import org.bukkit.entity.Entity;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
import org.bukkit.event.EventHandler;
|
||||||
|
import org.bukkit.event.EventPriority;
|
||||||
|
import org.bukkit.event.Listener;
|
||||||
|
import org.bukkit.event.block.Action;
|
||||||
|
import org.bukkit.event.block.BlockBreakEvent;
|
||||||
|
import org.bukkit.event.block.BlockPlaceEvent;
|
||||||
|
import org.bukkit.event.entity.EntityDamageEvent;
|
||||||
|
import org.bukkit.event.player.PlayerInteractEvent;
|
||||||
|
import org.bukkit.event.player.PlayerMoveEvent;
|
||||||
|
import org.bukkit.event.player.PlayerTeleportEvent;
|
||||||
|
|
||||||
|
final class PocketBaseController implements Listener, Runnable {
|
||||||
|
private static final int VOID_RETURN_Y = -64;
|
||||||
|
private static final long PORTAL_COOLDOWN_NANOS = 2_000_000_000L;
|
||||||
|
private final Server server;
|
||||||
|
private final BaseStateManager baseStates;
|
||||||
|
private final BaseBoundsService baseBounds;
|
||||||
|
private final PocketBaseManager pocketBases;
|
||||||
|
private final PluginSettingsProvider settings;
|
||||||
|
private final Logger logger;
|
||||||
|
private final Map<UUID, Long> cooldownUntil = new HashMap<>();
|
||||||
|
|
||||||
|
PocketBaseController(
|
||||||
|
Server server,
|
||||||
|
BaseStateManager baseStates,
|
||||||
|
BaseBoundsService baseBounds,
|
||||||
|
PocketBaseManager pocketBases,
|
||||||
|
PluginSettingsProvider settings,
|
||||||
|
Logger logger
|
||||||
|
) {
|
||||||
|
this.server = server;
|
||||||
|
this.baseStates = baseStates;
|
||||||
|
this.baseBounds = baseBounds;
|
||||||
|
this.pocketBases = pocketBases;
|
||||||
|
this.settings = settings;
|
||||||
|
this.logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void run() {
|
||||||
|
validateEntrances();
|
||||||
|
for (PocketBaseState state : pocketBases.knownStates().values()) {
|
||||||
|
state.entrance().ifPresent(this::showPortal);
|
||||||
|
if (state.level() > 0 && pocketBases.returnPortalIsIntact(state.ownerId())) {
|
||||||
|
showPortal(pocketBases.returnPortal(state.ownerId()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
long now = System.nanoTime();
|
||||||
|
cooldownUntil.entrySet().removeIf(entry -> entry.getValue() <= now);
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true)
|
||||||
|
public void onActivate(PlayerInteractEvent event) {
|
||||||
|
if (event.getAction() != Action.RIGHT_CLICK_BLOCK || event.getClickedBlock() == null
|
||||||
|
|| event.getItem() == null
|
||||||
|
|| event.getItem().getType() != Material.FLINT_AND_STEEL) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Player player = event.getPlayer();
|
||||||
|
PocketBaseState pocket = pocketBases.state(player.getUniqueId());
|
||||||
|
if (pocket.level() < 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Block clicked = event.getClickedBlock();
|
||||||
|
World world = clicked.getWorld();
|
||||||
|
Material frameMaterial = Material.valueOf(
|
||||||
|
settings.current().pocketBasePortalFrameMaterial()
|
||||||
|
);
|
||||||
|
Optional<PocketPortalLocation> portal = PocketPortalGeometry.findFrame(
|
||||||
|
position(clicked),
|
||||||
|
world.getUID(),
|
||||||
|
world.getName(),
|
||||||
|
candidate -> block(world, candidate).getType() == frameMaterial,
|
||||||
|
candidate -> isAir(block(world, candidate).getType())
|
||||||
|
);
|
||||||
|
if (portal.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Optional<UUID> pocketOwner = pocketBases.ownerForPocketWorld(world.getUID());
|
||||||
|
if (pocketOwner.isPresent()) {
|
||||||
|
if (!pocketOwner.orElseThrow().equals(player.getUniqueId())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (PocketPortalGeometry.frameBlocks(portal.orElseThrow()).stream()
|
||||||
|
.anyMatch(candidate -> !pocketBases.policy().contains(
|
||||||
|
pocket.level(), candidate.x(), candidate.z()
|
||||||
|
))) {
|
||||||
|
player.sendMessage(ChatColor.RED
|
||||||
|
+ "The complete return portal must be inside your Pocket Base boundary.");
|
||||||
|
event.setCancelled(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
pocketBases.activateReturnPortal(player.getUniqueId(), portal.orElseThrow());
|
||||||
|
} catch (IOException | RuntimeException exception) {
|
||||||
|
logger.log(Level.SEVERE, "Could not activate Pocket Base return portal", exception);
|
||||||
|
player.sendMessage(ChatColor.RED
|
||||||
|
+ "The Pocket Base return portal could not be activated.");
|
||||||
|
event.setCancelled(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.setCancelled(true);
|
||||||
|
player.sendMessage(ChatColor.GREEN + "Pocket Base return portal activated.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
PlayerState owner = baseStates.player(player.getUniqueId(), player.getName());
|
||||||
|
if (owner.baseLevel() < 4 || owner.base().isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
BaseArea area = baseBounds.area(owner);
|
||||||
|
if (PocketPortalGeometry.frameBlocks(portal.orElseThrow()).stream().anyMatch(candidate ->
|
||||||
|
!area.contains(world.getUID(), candidate.x(), candidate.y(), candidate.z()))) {
|
||||||
|
player.sendMessage(ChatColor.RED
|
||||||
|
+ "The complete Pocket Base portal must be inside your base.");
|
||||||
|
event.setCancelled(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
pocketBases.activateEntrance(player.getUniqueId(), portal.orElseThrow());
|
||||||
|
} catch (IOException | RuntimeException exception) {
|
||||||
|
logger.log(Level.SEVERE, "Could not activate Pocket Base entrance", exception);
|
||||||
|
player.sendMessage(ChatColor.RED + "The Pocket Base portal could not be activated.");
|
||||||
|
event.setCancelled(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.setCancelled(true);
|
||||||
|
player.sendMessage(ChatColor.GREEN + "Pocket Base portal activated.");
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||||
|
public void onBreak(BlockBreakEvent event) {
|
||||||
|
BlockPosition broken = position(event.getBlock());
|
||||||
|
UUID worldId = event.getBlock().getWorld().getUID();
|
||||||
|
for (PocketBaseState state : pocketBases.knownStates().values()) {
|
||||||
|
if (state.entrance().isPresent()) {
|
||||||
|
PocketPortalLocation portal = state.entrance().orElseThrow();
|
||||||
|
if (portal.worldId().equals(worldId)
|
||||||
|
&& PocketPortalGeometry.isFrame(portal, broken)) {
|
||||||
|
deactivate(state.ownerId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true)
|
||||||
|
public void onPlace(BlockPlaceEvent event) {
|
||||||
|
Block block = event.getBlockPlaced();
|
||||||
|
Optional<UUID> ownerId = pocketBases.ownerForPocketWorld(block.getWorld().getUID());
|
||||||
|
if (ownerId.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
PocketBaseState state = pocketBases.state(ownerId.orElseThrow());
|
||||||
|
if (!pocketBases.policy().contains(state.level(), block.getX(), block.getZ())) {
|
||||||
|
event.setCancelled(true);
|
||||||
|
event.getPlayer().sendMessage(ChatColor.RED
|
||||||
|
+ "That block is outside the unlocked Pocket Base boundary.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||||
|
public void onMove(PlayerMoveEvent event) {
|
||||||
|
Location destination = event.getTo();
|
||||||
|
if (destination == null || onCooldown(event.getPlayer())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Player player = event.getPlayer();
|
||||||
|
World world = destination.getWorld();
|
||||||
|
if (world == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Optional<UUID> pocketOwner = pocketBases.ownerForPocketWorld(world.getUID());
|
||||||
|
if (pocketOwner.isPresent()) {
|
||||||
|
UUID ownerId = pocketOwner.orElseThrow();
|
||||||
|
if (destination.getY() < VOID_RETURN_Y
|
||||||
|
|| pocketBases.returnPortalIsIntact(ownerId)
|
||||||
|
&& PocketPortalGeometry.isInterior(
|
||||||
|
pocketBases.returnPortal(ownerId), position(destination)
|
||||||
|
)) {
|
||||||
|
leavePocket(player, ownerId);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
BlockPosition position = position(destination);
|
||||||
|
for (PocketBaseState state : pocketBases.knownStates().values()) {
|
||||||
|
if (state.entrance().isEmpty()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
PocketPortalLocation portal = state.entrance().orElseThrow();
|
||||||
|
if (portal.worldId().equals(world.getUID())
|
||||||
|
&& isIntact(portal)
|
||||||
|
&& PocketPortalGeometry.isInterior(portal, position)) {
|
||||||
|
teleport(player, pocketBases.pocketArrival(state.ownerId()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true)
|
||||||
|
public void onVoidDamage(EntityDamageEvent event) {
|
||||||
|
Entity entity = event.getEntity();
|
||||||
|
if (event.getCause() != EntityDamageEvent.DamageCause.VOID
|
||||||
|
|| !(entity instanceof Player player)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Optional<UUID> owner = pocketBases.ownerForPocketWorld(player.getWorld().getUID());
|
||||||
|
if (owner.isPresent()) {
|
||||||
|
event.setCancelled(true);
|
||||||
|
leavePocket(player, owner.orElseThrow());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean isPocketWorld(UUID worldId) {
|
||||||
|
return pocketBases.ownerForPocketWorld(worldId).isPresent();
|
||||||
|
}
|
||||||
|
|
||||||
|
void deactivateForRelocation(UUID ownerId) {
|
||||||
|
deactivate(ownerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void leavePocket(Player player, UUID ownerId) {
|
||||||
|
PocketBaseState state = pocketBases.state(ownerId);
|
||||||
|
if (state.entrance().isPresent() && isIntact(state.entrance().orElseThrow())) {
|
||||||
|
teleport(player, outsidePortal(state.entrance().orElseThrow()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
World fallbackWorld = server.getWorlds().get(0);
|
||||||
|
teleport(player, fallbackWorld.getSpawnLocation());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void teleport(Player player, Location destination) {
|
||||||
|
cooldownUntil.put(player.getUniqueId(), System.nanoTime() + PORTAL_COOLDOWN_NANOS);
|
||||||
|
player.teleport(destination, PlayerTeleportEvent.TeleportCause.PLUGIN);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean onCooldown(Player player) {
|
||||||
|
return cooldownUntil.getOrDefault(player.getUniqueId(), 0L) > System.nanoTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateEntrances() {
|
||||||
|
for (PocketBaseState state : pocketBases.knownStates().values()) {
|
||||||
|
if (state.entrance().isEmpty()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
PlayerState owner = baseStates.knownPlayers().get(state.ownerId());
|
||||||
|
PocketPortalLocation portal = state.entrance().orElseThrow();
|
||||||
|
if (owner == null || owner.baseLevel() < 4 || owner.base().isEmpty()
|
||||||
|
|| !isIntact(portal)
|
||||||
|
|| PocketPortalGeometry.frameBlocks(portal).stream().anyMatch(position ->
|
||||||
|
!baseBounds.area(owner).contains(
|
||||||
|
portal.worldId(), position.x(), position.y(), position.z()
|
||||||
|
))) {
|
||||||
|
deactivate(state.ownerId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isIntact(PocketPortalLocation portal) {
|
||||||
|
World world = server.getWorld(portal.worldId());
|
||||||
|
if (world == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
Material frame = Material.valueOf(settings.current().pocketBasePortalFrameMaterial());
|
||||||
|
return PocketPortalGeometry.frameBlocks(portal).stream()
|
||||||
|
.allMatch(position -> block(world, position).getType() == frame);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void showPortal(PocketPortalLocation portal) {
|
||||||
|
World world = server.getWorld(portal.worldId());
|
||||||
|
if (world == null || !isIntact(portal)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (BlockPosition position : PocketPortalGeometry.interiorBlocks(portal)) {
|
||||||
|
world.spawnParticle(
|
||||||
|
Particle.PORTAL,
|
||||||
|
position.x() + 0.5,
|
||||||
|
position.y() + 0.5,
|
||||||
|
position.z() + 0.5,
|
||||||
|
4,
|
||||||
|
0.25,
|
||||||
|
0.35,
|
||||||
|
0.25,
|
||||||
|
0.02
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void deactivate(UUID ownerId) {
|
||||||
|
try {
|
||||||
|
pocketBases.deactivateEntrance(ownerId);
|
||||||
|
} catch (IOException exception) {
|
||||||
|
logger.log(Level.SEVERE, "Could not deactivate Pocket Base entrance", exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Location outsidePortal(PocketPortalLocation portal) {
|
||||||
|
World world = server.getWorld(portal.worldId());
|
||||||
|
if (world == null) {
|
||||||
|
return server.getWorlds().get(0).getSpawnLocation();
|
||||||
|
}
|
||||||
|
return portal.axis() == PocketPortalAxis.X
|
||||||
|
? new Location(world, portal.x() + 2.0, portal.y() + 1.0, portal.z() + 1.5)
|
||||||
|
: new Location(world, portal.x() + 1.5, portal.y() + 1.0, portal.z() + 2.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isAir(Material material) {
|
||||||
|
return material == Material.AIR || material == Material.CAVE_AIR
|
||||||
|
|| material == Material.VOID_AIR;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Block block(World world, BlockPosition position) {
|
||||||
|
return world.getBlockAt(position.x(), position.y(), position.z());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static BlockPosition position(Block block) {
|
||||||
|
return new BlockPosition(block.getX(), block.getY(), block.getZ());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static BlockPosition position(Location location) {
|
||||||
|
return new BlockPosition(location.getBlockX(), location.getBlockY(), location.getBlockZ());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
package games.dmg.spigotbase;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.bukkit.Location;
|
||||||
|
import org.bukkit.World;
|
||||||
|
|
||||||
|
final class PocketBaseManager {
|
||||||
|
private final PocketBaseStateManager states;
|
||||||
|
private final PocketBaseWorldService worlds;
|
||||||
|
private final PocketBasePolicy policy;
|
||||||
|
|
||||||
|
PocketBaseManager(
|
||||||
|
PocketBaseStateManager states,
|
||||||
|
PocketBaseWorldService worlds,
|
||||||
|
PluginSettingsProvider settings
|
||||||
|
) {
|
||||||
|
this.states = states;
|
||||||
|
this.worlds = worlds;
|
||||||
|
this.policy = new PocketBasePolicy(settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
void loadWorlds() {
|
||||||
|
worlds.loadExisting(states.knownStates());
|
||||||
|
}
|
||||||
|
|
||||||
|
PocketBaseState state(UUID ownerId) {
|
||||||
|
return states.state(ownerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<UUID, PocketBaseState> knownStates() {
|
||||||
|
return states.knownStates();
|
||||||
|
}
|
||||||
|
|
||||||
|
PocketBaseState upgrade(UUID ownerId) throws IOException {
|
||||||
|
PocketBaseState current = states.state(ownerId);
|
||||||
|
int nextLevel = Math.addExact(current.level(), 1);
|
||||||
|
policy.size(nextLevel);
|
||||||
|
worlds.expand(ownerId, current.level(), nextLevel);
|
||||||
|
return states.updateAndSave(ownerId, state -> state.withLevel(nextLevel));
|
||||||
|
}
|
||||||
|
|
||||||
|
PocketBaseState activateEntrance(
|
||||||
|
UUID ownerId,
|
||||||
|
PocketPortalLocation portal
|
||||||
|
) throws IOException {
|
||||||
|
PocketBaseState current = states.state(ownerId);
|
||||||
|
if (current.level() < 1) {
|
||||||
|
throw new IllegalStateException("Pocket Base I is still locked");
|
||||||
|
}
|
||||||
|
return states.updateAndSave(ownerId, state -> state.withEntrance(portal));
|
||||||
|
}
|
||||||
|
|
||||||
|
void deactivateEntrance(UUID ownerId) throws IOException {
|
||||||
|
PocketBaseState current = states.state(ownerId);
|
||||||
|
if (current.entrance().isPresent()) {
|
||||||
|
states.updateAndSave(ownerId, PocketBaseState::withoutEntrance);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PocketBaseState activateReturnPortal(
|
||||||
|
UUID ownerId,
|
||||||
|
PocketPortalLocation portal
|
||||||
|
) throws IOException {
|
||||||
|
PocketBaseState current = states.state(ownerId);
|
||||||
|
if (current.level() < 1) {
|
||||||
|
throw new IllegalStateException("Pocket Base I is still locked");
|
||||||
|
}
|
||||||
|
return states.updateAndSave(ownerId, state -> state.withReturnPortal(portal));
|
||||||
|
}
|
||||||
|
|
||||||
|
PocketBaseState setMobSpawning(UUID ownerId, boolean enabled) throws IOException {
|
||||||
|
PocketBaseState current = states.state(ownerId);
|
||||||
|
if (current.level() < 1) {
|
||||||
|
throw new IllegalStateException("Pocket Base I is still locked");
|
||||||
|
}
|
||||||
|
worlds.setMobSpawning(ownerId, enabled);
|
||||||
|
try {
|
||||||
|
return states.updateAndSave(
|
||||||
|
ownerId,
|
||||||
|
state -> state.withMobSpawningEnabled(enabled)
|
||||||
|
);
|
||||||
|
} catch (IOException | RuntimeException exception) {
|
||||||
|
try {
|
||||||
|
worlds.setMobSpawning(ownerId, current.mobSpawningEnabled());
|
||||||
|
} catch (RuntimeException rollbackFailure) {
|
||||||
|
exception.addSuppressed(rollbackFailure);
|
||||||
|
}
|
||||||
|
throw exception;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Optional<UUID> ownerForPocketWorld(UUID worldId) {
|
||||||
|
return worlds.ownerForWorld(worldId);
|
||||||
|
}
|
||||||
|
|
||||||
|
Location pocketArrival(UUID ownerId) {
|
||||||
|
return worlds.arrival(ownerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
PocketPortalLocation returnPortal(UUID ownerId) {
|
||||||
|
return states.state(ownerId).returnPortal().orElseGet(
|
||||||
|
() -> worlds.returnPortal(ownerId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean returnPortalIsIntact(UUID ownerId) {
|
||||||
|
return worlds.returnPortalIsIntact(ownerId, returnPortal(ownerId));
|
||||||
|
}
|
||||||
|
|
||||||
|
World pocketWorld(UUID ownerId) {
|
||||||
|
return worlds.world(ownerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
PocketBasePolicy policy() {
|
||||||
|
return policy;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package games.dmg.spigotbase;
|
||||||
|
|
||||||
|
public final class PocketBasePolicy {
|
||||||
|
private final PluginSettingsProvider settings;
|
||||||
|
|
||||||
|
public PocketBasePolicy(PluginSettings settings) {
|
||||||
|
this(new PluginSettingsProvider(settings));
|
||||||
|
}
|
||||||
|
|
||||||
|
public PocketBasePolicy(PluginSettingsProvider settings) {
|
||||||
|
this.settings = settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int size(int level) {
|
||||||
|
if (level < 1) {
|
||||||
|
throw new IllegalArgumentException("Pocket Base level must be positive");
|
||||||
|
}
|
||||||
|
return Math.addExact(64, Math.multiplyExact(32, level - 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
public int price(PocketBaseState state) {
|
||||||
|
return state.level() == 0
|
||||||
|
? settings.current().pocketBaseUnlockCost()
|
||||||
|
: settings.current().pocketBaseUpgradeCost();
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean contains(int level, int x, int z) {
|
||||||
|
int halfSize = size(level) / 2;
|
||||||
|
return x >= -halfSize && x < halfSize && z >= -halfSize && z < halfSize;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package games.dmg.spigotbase;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public record PocketBaseState(
|
||||||
|
UUID ownerId,
|
||||||
|
int level,
|
||||||
|
Optional<PocketPortalLocation> entrance,
|
||||||
|
Optional<PocketPortalLocation> returnPortal,
|
||||||
|
boolean mobSpawningEnabled
|
||||||
|
) {
|
||||||
|
public PocketBaseState {
|
||||||
|
if (ownerId == null) {
|
||||||
|
throw new IllegalArgumentException("owner ID is required");
|
||||||
|
}
|
||||||
|
if (level < 0) {
|
||||||
|
throw new IllegalArgumentException("Pocket Base level must not be negative");
|
||||||
|
}
|
||||||
|
entrance = entrance == null ? Optional.empty() : entrance;
|
||||||
|
returnPortal = returnPortal == null ? Optional.empty() : returnPortal;
|
||||||
|
if (level == 0 && (entrance.isPresent() || returnPortal.isPresent())) {
|
||||||
|
throw new IllegalArgumentException("a locked Pocket Base cannot have a portal");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public PocketBaseState(
|
||||||
|
UUID ownerId,
|
||||||
|
int level,
|
||||||
|
Optional<PocketPortalLocation> entrance
|
||||||
|
) {
|
||||||
|
this(ownerId, level, entrance, Optional.empty(), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public PocketBaseState(
|
||||||
|
UUID ownerId,
|
||||||
|
int level,
|
||||||
|
Optional<PocketPortalLocation> entrance,
|
||||||
|
boolean mobSpawningEnabled
|
||||||
|
) {
|
||||||
|
this(ownerId, level, entrance, Optional.empty(), mobSpawningEnabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static PocketBaseState locked(UUID ownerId) {
|
||||||
|
return new PocketBaseState(
|
||||||
|
ownerId, 0, Optional.empty(), Optional.empty(), false
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public PocketBaseState withLevel(int newLevel) {
|
||||||
|
return new PocketBaseState(
|
||||||
|
ownerId, newLevel, entrance, returnPortal, mobSpawningEnabled
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public PocketBaseState withEntrance(PocketPortalLocation portal) {
|
||||||
|
return new PocketBaseState(
|
||||||
|
ownerId, level, Optional.of(portal), returnPortal, mobSpawningEnabled
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public PocketBaseState withoutEntrance() {
|
||||||
|
return new PocketBaseState(
|
||||||
|
ownerId, level, Optional.empty(), returnPortal, mobSpawningEnabled
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public PocketBaseState withReturnPortal(PocketPortalLocation portal) {
|
||||||
|
return new PocketBaseState(
|
||||||
|
ownerId, level, entrance, Optional.of(portal), mobSpawningEnabled
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public PocketBaseState withMobSpawningEnabled(boolean enabled) {
|
||||||
|
return new PocketBaseState(ownerId, level, entrance, returnPortal, enabled);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package games.dmg.spigotbase;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.function.UnaryOperator;
|
||||||
|
|
||||||
|
final class PocketBaseStateManager {
|
||||||
|
private final YamlPocketBaseRepository repository;
|
||||||
|
private final Map<UUID, PocketBaseState> states;
|
||||||
|
|
||||||
|
PocketBaseStateManager(YamlPocketBaseRepository repository) throws IOException {
|
||||||
|
this.repository = repository;
|
||||||
|
this.states = new HashMap<>(repository.load());
|
||||||
|
}
|
||||||
|
|
||||||
|
PocketBaseState state(UUID ownerId) {
|
||||||
|
return states.getOrDefault(ownerId, PocketBaseState.locked(ownerId));
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<UUID, PocketBaseState> knownStates() {
|
||||||
|
return Map.copyOf(states);
|
||||||
|
}
|
||||||
|
|
||||||
|
PocketBaseState updateAndSave(
|
||||||
|
UUID ownerId,
|
||||||
|
UnaryOperator<PocketBaseState> operation
|
||||||
|
) throws IOException {
|
||||||
|
PocketBaseState updated = operation.apply(state(ownerId));
|
||||||
|
if (!updated.ownerId().equals(ownerId)) {
|
||||||
|
throw new IllegalArgumentException("Pocket Base owner ID cannot change");
|
||||||
|
}
|
||||||
|
Map<UUID, PocketBaseState> proposed = new HashMap<>(states);
|
||||||
|
proposed.put(ownerId, updated);
|
||||||
|
repository.save(proposed);
|
||||||
|
states.clear();
|
||||||
|
states.putAll(proposed);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
package games.dmg.spigotbase;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.bukkit.Location;
|
||||||
|
import org.bukkit.Material;
|
||||||
|
import org.bukkit.Server;
|
||||||
|
import org.bukkit.World;
|
||||||
|
import org.bukkit.WorldCreator;
|
||||||
|
|
||||||
|
final class PocketBaseWorldService {
|
||||||
|
private static final int PLATFORM_Y = 64;
|
||||||
|
private static final String WORLD_PREFIX = "spigot_base_pocket_";
|
||||||
|
private final Server server;
|
||||||
|
private final PluginSettingsProvider settings;
|
||||||
|
private final PocketBasePolicy policy;
|
||||||
|
private final Map<UUID, UUID> ownersByWorld = new HashMap<>();
|
||||||
|
|
||||||
|
PocketBaseWorldService(Server server, PluginSettingsProvider settings) {
|
||||||
|
this.server = server;
|
||||||
|
this.settings = settings;
|
||||||
|
this.policy = new PocketBasePolicy(settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
void loadExisting(Map<UUID, PocketBaseState> states) {
|
||||||
|
for (PocketBaseState state : states.values()) {
|
||||||
|
if (state.level() > 0) {
|
||||||
|
setMobSpawning(state.ownerId(), state.mobSpawningEnabled());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
World expand(UUID ownerId, int previousLevel, int newLevel) {
|
||||||
|
World world = ensureWorld(ownerId);
|
||||||
|
int newHalfSize = policy.size(newLevel) / 2;
|
||||||
|
int previousHalfSize = previousLevel == 0 ? 0 : policy.size(previousLevel) / 2;
|
||||||
|
for (int x = -newHalfSize; x < newHalfSize; x++) {
|
||||||
|
for (int z = -newHalfSize; z < newHalfSize; z++) {
|
||||||
|
boolean previouslyUnlocked = previousLevel > 0
|
||||||
|
&& x >= -previousHalfSize && x < previousHalfSize
|
||||||
|
&& z >= -previousHalfSize && z < previousHalfSize;
|
||||||
|
if (previouslyUnlocked) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (int y = PLATFORM_Y - 3; y < PLATFORM_Y; y++) {
|
||||||
|
world.getBlockAt(x, y, z).setType(Material.DIRT, false);
|
||||||
|
}
|
||||||
|
world.getBlockAt(x, PLATFORM_Y, z).setType(Material.GRASS_BLOCK, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (previousLevel == 0) {
|
||||||
|
createReturnPortal(world);
|
||||||
|
world.setSpawnLocation(0, PLATFORM_Y + 1, 5);
|
||||||
|
}
|
||||||
|
world.save();
|
||||||
|
return world;
|
||||||
|
}
|
||||||
|
|
||||||
|
Optional<UUID> ownerForWorld(UUID worldId) {
|
||||||
|
return Optional.ofNullable(ownersByWorld.get(worldId));
|
||||||
|
}
|
||||||
|
|
||||||
|
World world(UUID ownerId) {
|
||||||
|
return ensureWorld(ownerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
void setMobSpawning(UUID ownerId, boolean enabled) {
|
||||||
|
ensureWorld(ownerId).setSpawnFlags(enabled, enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
Location arrival(UUID ownerId) {
|
||||||
|
return new Location(ensureWorld(ownerId), 0.5, PLATFORM_Y + 1, 5.5, 180.0f, 0.0f);
|
||||||
|
}
|
||||||
|
|
||||||
|
PocketPortalLocation returnPortal(UUID ownerId) {
|
||||||
|
World world = ensureWorld(ownerId);
|
||||||
|
return new PocketPortalLocation(
|
||||||
|
world.getUID(), world.getName(), -2, PLATFORM_Y + 1, 0, PocketPortalAxis.X
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean returnPortalIsIntact(UUID ownerId, PocketPortalLocation portal) {
|
||||||
|
World world = ensureWorld(ownerId);
|
||||||
|
if (!portal.worldId().equals(world.getUID())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
Material frame = Material.valueOf(settings.current().pocketBasePortalFrameMaterial());
|
||||||
|
return PocketPortalGeometry.frameBlocks(portal).stream()
|
||||||
|
.allMatch(position -> world.getBlockAt(
|
||||||
|
position.x(), position.y(), position.z()
|
||||||
|
).getType() == frame);
|
||||||
|
}
|
||||||
|
|
||||||
|
private World ensureWorld(UUID ownerId) {
|
||||||
|
String name = worldName(ownerId);
|
||||||
|
World world = server.getWorld(name);
|
||||||
|
boolean created = world == null;
|
||||||
|
if (created) {
|
||||||
|
WorldCreator creator = new WorldCreator(name)
|
||||||
|
.environment(World.Environment.NORMAL)
|
||||||
|
.generator(new VoidPocketChunkGenerator())
|
||||||
|
.generateStructures(false);
|
||||||
|
world = server.createWorld(creator);
|
||||||
|
}
|
||||||
|
if (world == null) {
|
||||||
|
throw new IllegalStateException("Could not create Pocket Base world " + name);
|
||||||
|
}
|
||||||
|
if (created) {
|
||||||
|
world.setSpawnFlags(false, false);
|
||||||
|
}
|
||||||
|
ownersByWorld.put(world.getUID(), ownerId);
|
||||||
|
return world;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void createReturnPortal(World world) {
|
||||||
|
PocketPortalLocation portal = new PocketPortalLocation(
|
||||||
|
world.getUID(), world.getName(), -2, PLATFORM_Y + 1, 0, PocketPortalAxis.X
|
||||||
|
);
|
||||||
|
Material frame = Material.valueOf(settings.current().pocketBasePortalFrameMaterial());
|
||||||
|
for (BlockPosition position : PocketPortalGeometry.frameBlocks(portal)) {
|
||||||
|
world.getBlockAt(position.x(), position.y(), position.z()).setType(frame, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String worldName(UUID ownerId) {
|
||||||
|
return WORLD_PREFIX + ownerId.toString().replace("-", "");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package games.dmg.spigotbase;
|
||||||
|
|
||||||
|
public enum PocketPortalAxis {
|
||||||
|
X,
|
||||||
|
Z
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package games.dmg.spigotbase;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.function.Predicate;
|
||||||
|
|
||||||
|
public final class PocketPortalGeometry {
|
||||||
|
private PocketPortalGeometry() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Optional<PocketPortalLocation> findFrame(
|
||||||
|
BlockPosition clicked,
|
||||||
|
UUID worldId,
|
||||||
|
String worldName,
|
||||||
|
Predicate<BlockPosition> isFrameMaterial,
|
||||||
|
Predicate<BlockPosition> isOpenInterior
|
||||||
|
) {
|
||||||
|
for (PocketPortalAxis axis : PocketPortalAxis.values()) {
|
||||||
|
int clickedHorizontal = axis == PocketPortalAxis.X ? clicked.x() : clicked.z();
|
||||||
|
for (int horizontalOffset = 0; horizontalOffset < 4; horizontalOffset++) {
|
||||||
|
for (int verticalOffset = 0; verticalOffset < 5; verticalOffset++) {
|
||||||
|
int originHorizontal = clickedHorizontal - horizontalOffset;
|
||||||
|
int originY = clicked.y() - verticalOffset;
|
||||||
|
PocketPortalLocation candidate = axis == PocketPortalAxis.X
|
||||||
|
? new PocketPortalLocation(
|
||||||
|
worldId, worldName, originHorizontal, originY, clicked.z(), axis
|
||||||
|
)
|
||||||
|
: new PocketPortalLocation(
|
||||||
|
worldId, worldName, clicked.x(), originY, originHorizontal, axis
|
||||||
|
);
|
||||||
|
if (frameBlocks(candidate).stream().allMatch(isFrameMaterial)
|
||||||
|
&& interiorBlocks(candidate).stream().allMatch(isOpenInterior)) {
|
||||||
|
return Optional.of(candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<BlockPosition> frameBlocks(PocketPortalLocation portal) {
|
||||||
|
List<BlockPosition> blocks = new ArrayList<>(14);
|
||||||
|
for (int horizontal = 0; horizontal < 4; horizontal++) {
|
||||||
|
blocks.add(position(portal, horizontal, 0));
|
||||||
|
blocks.add(position(portal, horizontal, 4));
|
||||||
|
}
|
||||||
|
for (int vertical = 1; vertical < 4; vertical++) {
|
||||||
|
blocks.add(position(portal, 0, vertical));
|
||||||
|
blocks.add(position(portal, 3, vertical));
|
||||||
|
}
|
||||||
|
return List.copyOf(blocks);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<BlockPosition> interiorBlocks(PocketPortalLocation portal) {
|
||||||
|
List<BlockPosition> blocks = new ArrayList<>(6);
|
||||||
|
for (int horizontal = 1; horizontal < 3; horizontal++) {
|
||||||
|
for (int vertical = 1; vertical < 4; vertical++) {
|
||||||
|
blocks.add(position(portal, horizontal, vertical));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return List.copyOf(blocks);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean isFrame(PocketPortalLocation portal, BlockPosition position) {
|
||||||
|
return frameBlocks(portal).contains(position);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean isInterior(PocketPortalLocation portal, BlockPosition position) {
|
||||||
|
return interiorBlocks(portal).contains(position);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static BlockPosition position(
|
||||||
|
PocketPortalLocation portal,
|
||||||
|
int horizontal,
|
||||||
|
int vertical
|
||||||
|
) {
|
||||||
|
return portal.axis() == PocketPortalAxis.X
|
||||||
|
? new BlockPosition(portal.x() + horizontal, portal.y() + vertical, portal.z())
|
||||||
|
: new BlockPosition(portal.x(), portal.y() + vertical, portal.z() + horizontal);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package games.dmg.spigotbase;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public record PocketPortalLocation(
|
||||||
|
UUID worldId,
|
||||||
|
String worldName,
|
||||||
|
int x,
|
||||||
|
int y,
|
||||||
|
int z,
|
||||||
|
PocketPortalAxis axis
|
||||||
|
) {
|
||||||
|
public PocketPortalLocation {
|
||||||
|
if (worldId == null) {
|
||||||
|
throw new IllegalArgumentException("portal world ID is required");
|
||||||
|
}
|
||||||
|
if (worldName == null || worldName.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("portal world name is required");
|
||||||
|
}
|
||||||
|
if (axis == null) {
|
||||||
|
throw new IllegalArgumentException("portal axis is required");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,5 +6,6 @@ public enum ProgressCounter {
|
|||||||
DEEPSLATE,
|
DEEPSLATE,
|
||||||
OBSIDIAN,
|
OBSIDIAN,
|
||||||
PLACEMENTS,
|
PLACEMENTS,
|
||||||
|
TOTAL_PLACEMENTS,
|
||||||
BASE_BREAKS
|
BASE_BREAKS
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,11 +16,22 @@ final class SetBaseCommand implements CommandExecutor {
|
|||||||
private final BaseStateManager stateManager;
|
private final BaseStateManager stateManager;
|
||||||
private final BaseService baseService;
|
private final BaseService baseService;
|
||||||
private final Clock clock;
|
private final Clock clock;
|
||||||
|
private final PocketBaseController pocketBaseController;
|
||||||
|
|
||||||
SetBaseCommand(BaseStateManager stateManager, BaseService baseService, Clock clock) {
|
SetBaseCommand(BaseStateManager stateManager, BaseService baseService, Clock clock) {
|
||||||
|
this(stateManager, baseService, clock, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
SetBaseCommand(
|
||||||
|
BaseStateManager stateManager,
|
||||||
|
BaseService baseService,
|
||||||
|
Clock clock,
|
||||||
|
PocketBaseController pocketBaseController
|
||||||
|
) {
|
||||||
this.stateManager = stateManager;
|
this.stateManager = stateManager;
|
||||||
this.baseService = baseService;
|
this.baseService = baseService;
|
||||||
this.clock = clock;
|
this.clock = clock;
|
||||||
|
this.pocketBaseController = pocketBaseController;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -47,6 +58,10 @@ final class SetBaseCommand implements CommandExecutor {
|
|||||||
player.sendMessage(ChatColor.RED + "Your current world is unavailable.");
|
player.sendMessage(ChatColor.RED + "Your current world is unavailable.");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if (pocketBaseController != null && pocketBaseController.isPocketWorld(world.getUID())) {
|
||||||
|
player.sendMessage(ChatColor.RED + "You cannot set your normal base inside a Pocket Base.");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
BaseLocation base = new BaseLocation(
|
BaseLocation base = new BaseLocation(
|
||||||
world.getUID(), world.getName(), location.getBlockX(), location.getBlockY(),
|
world.getUID(), world.getName(), location.getBlockX(), location.getBlockY(),
|
||||||
location.getBlockZ(), location.getYaw(), location.getPitch()
|
location.getBlockZ(), location.getYaw(), location.getPitch()
|
||||||
@@ -55,6 +70,9 @@ final class SetBaseCommand implements CommandExecutor {
|
|||||||
player.getUniqueId(), player.getName(), current -> baseService.setBase(current, base, now)
|
player.getUniqueId(), player.getName(), current -> baseService.setBase(current, base, now)
|
||||||
);
|
);
|
||||||
stateManager.saveIfDirty();
|
stateManager.saveIfDirty();
|
||||||
|
if (pocketBaseController != null) {
|
||||||
|
pocketBaseController.deactivateForRelocation(player.getUniqueId());
|
||||||
|
}
|
||||||
player.sendMessage(ChatColor.GREEN + "Base set at " + base.x() + ", " + base.y() + ", " + base.z() + ".");
|
player.sendMessage(ChatColor.GREEN + "Base set at " + base.x() + ", " + base.y() + ", " + base.z() + ".");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package games.dmg.spigotbase;
|
||||||
|
|
||||||
|
import org.bukkit.block.Block;
|
||||||
|
import org.bukkit.block.BlockFace;
|
||||||
|
import org.bukkit.block.data.Bisected;
|
||||||
|
import org.bukkit.block.data.type.Slab;
|
||||||
|
import org.bukkit.block.data.type.Stairs;
|
||||||
|
|
||||||
|
final class SpawnableBlockPolicy {
|
||||||
|
private SpawnableBlockPolicy() {
|
||||||
|
}
|
||||||
|
|
||||||
|
static boolean isCandidate(Block surface) {
|
||||||
|
if (!hasSpawnableTop(surface)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
Block above = surface.getRelative(BlockFace.UP);
|
||||||
|
Block secondAbove = above.getRelative(BlockFace.UP);
|
||||||
|
return isEmpty(above)
|
||||||
|
&& isEmpty(secondAbove)
|
||||||
|
&& above.getLightFromBlocks() == 0
|
||||||
|
&& above.getLightFromSky() <= 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean hasSpawnableTop(Block block) {
|
||||||
|
if (block.getBlockData() instanceof Slab slab) {
|
||||||
|
return slab.getType() == Slab.Type.TOP || slab.getType() == Slab.Type.DOUBLE;
|
||||||
|
}
|
||||||
|
if (block.getBlockData() instanceof Stairs stairs) {
|
||||||
|
return stairs.getHalf() == Bisected.Half.TOP;
|
||||||
|
}
|
||||||
|
return block.getType().isOccluding();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isEmpty(Block block) {
|
||||||
|
return block.isPassable() && !block.isLiquid();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
package games.dmg.spigotbase;
|
||||||
|
|
||||||
|
import java.util.ArrayDeque;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.bukkit.Color;
|
||||||
|
import org.bukkit.Location;
|
||||||
|
import org.bukkit.Particle;
|
||||||
|
import org.bukkit.Server;
|
||||||
|
import org.bukkit.World;
|
||||||
|
import org.bukkit.block.Block;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
|
||||||
|
final class SpawnableOverlayController implements Runnable {
|
||||||
|
static final int HORIZONTAL_RADIUS = 16;
|
||||||
|
static final int VERTICAL_RADIUS = 8;
|
||||||
|
static final int WORK_BUDGET = 2_048;
|
||||||
|
static final int PARTICLE_BUDGET = 128;
|
||||||
|
private static final int PENDING_BUDGET = PARTICLE_BUDGET * 4;
|
||||||
|
private static final int WIDTH = HORIZONTAL_RADIUS * 2 + 1;
|
||||||
|
private static final int HEIGHT = VERTICAL_RADIUS * 2 + 1;
|
||||||
|
private static final int TOTAL_POSITIONS = WIDTH * WIDTH * HEIGHT;
|
||||||
|
private static final Particle.DustOptions RED_DUST =
|
||||||
|
new Particle.DustOptions(Color.RED, 1.0F);
|
||||||
|
|
||||||
|
private final Server server;
|
||||||
|
private final BaseStateManager stateManager;
|
||||||
|
private final BaseBoundsService boundsService;
|
||||||
|
private final PluginSettingsProvider settings;
|
||||||
|
private final Map<UUID, ScanState> scans = new HashMap<>();
|
||||||
|
|
||||||
|
SpawnableOverlayController(
|
||||||
|
Server server,
|
||||||
|
BaseStateManager stateManager,
|
||||||
|
BaseBoundsService boundsService,
|
||||||
|
PluginSettingsProvider settings
|
||||||
|
) {
|
||||||
|
this.server = server;
|
||||||
|
this.stateManager = stateManager;
|
||||||
|
this.boundsService = boundsService;
|
||||||
|
this.settings = settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void run() {
|
||||||
|
Set<UUID> active = new HashSet<>();
|
||||||
|
for (Player player : server.getOnlinePlayers()) {
|
||||||
|
PlayerState playerState = stateManager.player(
|
||||||
|
player.getUniqueId(), player.getName()
|
||||||
|
);
|
||||||
|
if (!eligible(player, playerState)) {
|
||||||
|
scans.remove(player.getUniqueId());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
active.add(player.getUniqueId());
|
||||||
|
updateAndRender(player, playerState);
|
||||||
|
}
|
||||||
|
scans.keySet().removeIf(playerId -> !active.contains(playerId));
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean eligible(Player player, PlayerState state) {
|
||||||
|
return state.spawnableOverlayEnabled()
|
||||||
|
&& state.totalBlocksPlaced() >= settings.current()
|
||||||
|
.spawnableOverlayUnlockPlacements()
|
||||||
|
&& state.base().isPresent()
|
||||||
|
&& player.getWorld().getUID().equals(state.base().orElseThrow().worldId());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateAndRender(Player player, PlayerState state) {
|
||||||
|
Location location = player.getLocation();
|
||||||
|
BaseLocation base = state.base().orElseThrow();
|
||||||
|
int centerX = location.getBlockX();
|
||||||
|
int centerY = location.getBlockY() - 1;
|
||||||
|
int centerZ = location.getBlockZ();
|
||||||
|
ScanOrigin origin = new ScanOrigin(
|
||||||
|
player.getWorld().getUID(), centerX, centerY, centerZ,
|
||||||
|
base.x(), base.y(), base.z(), boundsService.radius(state)
|
||||||
|
);
|
||||||
|
ScanState scan = scans.get(player.getUniqueId());
|
||||||
|
if (scan == null || !scan.origin.near(origin)) {
|
||||||
|
scan = new ScanState(origin);
|
||||||
|
scans.put(player.getUniqueId(), scan);
|
||||||
|
}
|
||||||
|
scan(player.getWorld(), boundsService.area(state), scan);
|
||||||
|
render(player, scan);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void scan(World world, BaseArea base, ScanState scan) {
|
||||||
|
int work = Math.min(WORK_BUDGET, TOTAL_POSITIONS);
|
||||||
|
int minimumWorldY = world.getMinHeight();
|
||||||
|
int maximumWorldY = world.getMaxHeight();
|
||||||
|
UUID worldId = world.getUID();
|
||||||
|
for (int offset = 0; offset < work; offset++) {
|
||||||
|
int index = (scan.cursor + offset) % TOTAL_POSITIONS;
|
||||||
|
int verticalIndex = index % HEIGHT;
|
||||||
|
int horizontalIndex = index / HEIGHT;
|
||||||
|
int zIndex = horizontalIndex % WIDTH;
|
||||||
|
int xIndex = horizontalIndex / WIDTH;
|
||||||
|
int x = scan.origin.centerX + xIndex - HORIZONTAL_RADIUS;
|
||||||
|
int y = scan.origin.centerY + verticalIndex - VERTICAL_RADIUS;
|
||||||
|
int z = scan.origin.centerZ + zIndex - HORIZONTAL_RADIUS;
|
||||||
|
BlockPosition position = new BlockPosition(x, y, z);
|
||||||
|
scan.spawnable.remove(position);
|
||||||
|
int deltaX = x - scan.origin.centerX;
|
||||||
|
int deltaZ = z - scan.origin.centerZ;
|
||||||
|
if (deltaX * deltaX + deltaZ * deltaZ
|
||||||
|
> HORIZONTAL_RADIUS * HORIZONTAL_RADIUS
|
||||||
|
|| y < minimumWorldY
|
||||||
|
|| y >= maximumWorldY
|
||||||
|
|| !base.contains(worldId, x, y, z)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Block surface = world.getBlockAt(x, y, z);
|
||||||
|
if (SpawnableBlockPolicy.isCandidate(surface)) {
|
||||||
|
scan.spawnable.add(position);
|
||||||
|
if (scan.pending.size() < PENDING_BUDGET) {
|
||||||
|
scan.pending.addLast(position);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
scan.cursor = (scan.cursor + work) % TOTAL_POSITIONS;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void render(Player player, ScanState scan) {
|
||||||
|
for (int attempted = 0; attempted < PARTICLE_BUDGET; attempted++) {
|
||||||
|
BlockPosition position = scan.pending.pollFirst();
|
||||||
|
if (position == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!scan.spawnable.contains(position)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
player.spawnParticle(
|
||||||
|
Particle.DUST,
|
||||||
|
new Location(
|
||||||
|
player.getWorld(),
|
||||||
|
position.x + 0.5,
|
||||||
|
position.y + 1.05,
|
||||||
|
position.z + 0.5
|
||||||
|
),
|
||||||
|
1,
|
||||||
|
RED_DUST
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private record BlockPosition(int x, int y, int z) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private record ScanOrigin(
|
||||||
|
UUID worldId,
|
||||||
|
int centerX,
|
||||||
|
int centerY,
|
||||||
|
int centerZ,
|
||||||
|
int baseX,
|
||||||
|
int baseY,
|
||||||
|
int baseZ,
|
||||||
|
int baseRadius
|
||||||
|
) {
|
||||||
|
boolean near(ScanOrigin other) {
|
||||||
|
return worldId.equals(other.worldId)
|
||||||
|
&& Math.abs(centerX - other.centerX) <= 4
|
||||||
|
&& Math.abs(centerY - other.centerY) <= 4
|
||||||
|
&& Math.abs(centerZ - other.centerZ) <= 4
|
||||||
|
&& baseX == other.baseX
|
||||||
|
&& baseY == other.baseY
|
||||||
|
&& baseZ == other.baseZ
|
||||||
|
&& baseRadius == other.baseRadius;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class ScanState {
|
||||||
|
private final ScanOrigin origin;
|
||||||
|
private final Set<BlockPosition> spawnable = new HashSet<>();
|
||||||
|
private final ArrayDeque<BlockPosition> pending = new ArrayDeque<>();
|
||||||
|
private int cursor;
|
||||||
|
|
||||||
|
private ScanState(ScanOrigin origin) {
|
||||||
|
this.origin = origin;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package games.dmg.spigotbase;
|
||||||
|
|
||||||
|
final class SpawnableOverlayProgressionService {
|
||||||
|
private final PluginSettingsProvider settings;
|
||||||
|
|
||||||
|
SpawnableOverlayProgressionService(PluginSettingsProvider settings) {
|
||||||
|
this.settings = settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
SpawnableOverlayProgressionUpdate recordPlacement(PlayerState player) {
|
||||||
|
long previous = player.totalBlocksPlaced();
|
||||||
|
long updated = previous == Long.MAX_VALUE ? Long.MAX_VALUE : previous + 1;
|
||||||
|
int threshold = settings.current().spawnableOverlayUnlockPlacements();
|
||||||
|
return new SpawnableOverlayProgressionUpdate(
|
||||||
|
player.withTotalBlocksPlaced(updated),
|
||||||
|
previous < threshold && updated >= threshold
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package games.dmg.spigotbase;
|
||||||
|
|
||||||
|
record SpawnableOverlayProgressionUpdate(PlayerState player, boolean unlocked) {
|
||||||
|
SpawnableOverlayProgressionUpdate {
|
||||||
|
if (player == null) {
|
||||||
|
throw new IllegalArgumentException("player is required");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,8 @@ public final class SpigotBasePlugin extends JavaPlugin {
|
|||||||
private BaseProgressListener progressListener;
|
private BaseProgressListener progressListener;
|
||||||
private BaseFlightController flightController;
|
private BaseFlightController flightController;
|
||||||
private BaseTeleportManager teleportManager;
|
private BaseTeleportManager teleportManager;
|
||||||
|
private PocketBaseManager pocketBaseManager;
|
||||||
|
private PocketBaseController pocketBaseController;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onEnable() {
|
public void onEnable() {
|
||||||
@@ -27,7 +29,18 @@ public final class SpigotBasePlugin extends JavaPlugin {
|
|||||||
new YamlBaseStateRepository(getDataFolder().toPath().resolve("state.yml")),
|
new YamlBaseStateRepository(getDataFolder().toPath().resolve("state.yml")),
|
||||||
getLogger()
|
getLogger()
|
||||||
);
|
);
|
||||||
} catch (IllegalArgumentException | IOException exception) {
|
PocketBaseStateManager pocketStates = new PocketBaseStateManager(
|
||||||
|
new YamlPocketBaseRepository(
|
||||||
|
getDataFolder().toPath().resolve("pocket-bases.yml")
|
||||||
|
)
|
||||||
|
);
|
||||||
|
pocketBaseManager = new PocketBaseManager(
|
||||||
|
pocketStates,
|
||||||
|
new PocketBaseWorldService(getServer(), settingsProvider),
|
||||||
|
settingsProvider
|
||||||
|
);
|
||||||
|
pocketBaseManager.loadWorlds();
|
||||||
|
} catch (RuntimeException | IOException exception) {
|
||||||
getLogger().log(Level.SEVERE, "Could not initialize Spigot Base", exception);
|
getLogger().log(Level.SEVERE, "Could not initialize Spigot Base", exception);
|
||||||
getServer().getPluginManager().disablePlugin(this);
|
getServer().getPluginManager().disablePlugin(this);
|
||||||
return;
|
return;
|
||||||
@@ -46,6 +59,7 @@ public final class SpigotBasePlugin extends JavaPlugin {
|
|||||||
progressionService,
|
progressionService,
|
||||||
secondaryProgressionService,
|
secondaryProgressionService,
|
||||||
teleportProgressionService,
|
teleportProgressionService,
|
||||||
|
new SpawnableOverlayProgressionService(settingsProvider),
|
||||||
boundsService,
|
boundsService,
|
||||||
settingsProvider
|
settingsProvider
|
||||||
);
|
);
|
||||||
@@ -61,30 +75,41 @@ public final class SpigotBasePlugin extends JavaPlugin {
|
|||||||
new SafeBaseDestination(),
|
new SafeBaseDestination(),
|
||||||
Clock.systemUTC()
|
Clock.systemUTC()
|
||||||
);
|
);
|
||||||
|
pocketBaseController = new PocketBaseController(
|
||||||
|
getServer(),
|
||||||
|
stateManager,
|
||||||
|
boundsService,
|
||||||
|
pocketBaseManager,
|
||||||
|
settingsProvider,
|
||||||
|
getLogger()
|
||||||
|
);
|
||||||
getServer().getPluginManager().registerEvents(progressListener, this);
|
getServer().getPluginManager().registerEvents(progressListener, this);
|
||||||
getServer().getPluginManager().registerEvents(teleportManager, this);
|
getServer().getPluginManager().registerEvents(teleportManager, this);
|
||||||
|
getServer().getPluginManager().registerEvents(pocketBaseController, this);
|
||||||
|
|
||||||
command("setbase").setExecutor(new SetBaseCommand(stateManager, baseService, Clock.systemUTC()));
|
command("setbase").setExecutor(new SetBaseCommand(
|
||||||
command("base").setExecutor(
|
stateManager, baseService, Clock.systemUTC(), pocketBaseController
|
||||||
new BaseCommand(teleportManager, stateManager, visitorPolicy, settingsProvider)
|
));
|
||||||
|
command("base").setExecutor(new BaseCommand(teleportManager));
|
||||||
|
BaseSettingsCommand settingsCommand = new BaseSettingsCommand(
|
||||||
|
stateManager,
|
||||||
|
settingsProvider,
|
||||||
|
flightController,
|
||||||
|
pocketBaseManager
|
||||||
);
|
);
|
||||||
command("baseprogress").setExecutor(
|
command("basesettings").setExecutor(settingsCommand);
|
||||||
new BaseProgressCommand(stateManager, settingsProvider)
|
command("basesettings").setTabCompleter(settingsCommand);
|
||||||
);
|
|
||||||
command("basenavigation").setExecutor(new BaseNavigationCommand(stateManager));
|
|
||||||
command("baseflight").setExecutor(new BaseFlightCommand(stateManager, flightController));
|
|
||||||
command("basevisitors").setExecutor(new BaseVisitorsCommand(stateManager));
|
|
||||||
GoToBaseCommand goToBaseCommand = new GoToBaseCommand(stateManager, teleportManager);
|
GoToBaseCommand goToBaseCommand = new GoToBaseCommand(stateManager, teleportManager);
|
||||||
command("gotobase").setExecutor(goToBaseCommand);
|
command("gotobase").setExecutor(goToBaseCommand);
|
||||||
command("gotobase").setTabCompleter(goToBaseCommand);
|
command("gotobase").setTabCompleter(goToBaseCommand);
|
||||||
command("baseadmin").setExecutor(
|
BaseAdminCommand adminCommand = new BaseAdminCommand(
|
||||||
new BaseAdminCommand(
|
|
||||||
this,
|
this,
|
||||||
stateManager,
|
stateManager,
|
||||||
new AdminProgressionService(settingsProvider),
|
new AdminProgressionService(settingsProvider),
|
||||||
settingsProvider
|
settingsProvider
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
command("baseadmin").setExecutor(adminCommand);
|
||||||
|
command("baseadmin").setTabCompleter(adminCommand);
|
||||||
|
|
||||||
getServer().getScheduler().runTaskTimer(
|
getServer().getScheduler().runTaskTimer(
|
||||||
this,
|
this,
|
||||||
@@ -92,7 +117,22 @@ public final class SpigotBasePlugin extends JavaPlugin {
|
|||||||
10L,
|
10L,
|
||||||
10L
|
10L
|
||||||
);
|
);
|
||||||
|
getServer().getScheduler().runTaskTimer(
|
||||||
|
this,
|
||||||
|
new BaseBorderController(getServer(), stateManager, boundsService),
|
||||||
|
10L,
|
||||||
|
10L
|
||||||
|
);
|
||||||
|
getServer().getScheduler().runTaskTimer(
|
||||||
|
this,
|
||||||
|
new SpawnableOverlayController(
|
||||||
|
getServer(), stateManager, boundsService, settingsProvider
|
||||||
|
),
|
||||||
|
10L,
|
||||||
|
10L
|
||||||
|
);
|
||||||
getServer().getScheduler().runTaskTimer(this, flightController, 5L, 5L);
|
getServer().getScheduler().runTaskTimer(this, flightController, 5L, 5L);
|
||||||
|
getServer().getScheduler().runTaskTimer(this, pocketBaseController, 5L, 5L);
|
||||||
getServer().getScheduler().runTaskTimer(this, stateManager::saveIfDirty, 600L, 600L);
|
getServer().getScheduler().runTaskTimer(this, stateManager::saveIfDirty, 600L, 600L);
|
||||||
getLogger().info("Spigot Base enabled.");
|
getLogger().info("Spigot Base enabled.");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,13 @@ public final class TeleportPolicy {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Duration visitorWarmup(PlayerState owner) {
|
||||||
|
Duration warmup = warmup(owner);
|
||||||
|
return warmup.compareTo(Duration.ofSeconds(1)) < 0
|
||||||
|
? Duration.ofSeconds(1)
|
||||||
|
: warmup;
|
||||||
|
}
|
||||||
|
|
||||||
public Duration cooldown(PlayerState player) {
|
public Duration cooldown(PlayerState player) {
|
||||||
return switch (player.cooldownLevel()) {
|
return switch (player.cooldownLevel()) {
|
||||||
case 0 -> Duration.ofSeconds(settings.current().initialTeleportCooldownSeconds());
|
case 0 -> Duration.ofSeconds(settings.current().initialTeleportCooldownSeconds());
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package games.dmg.spigotbase;
|
||||||
|
|
||||||
|
import org.bukkit.generator.ChunkGenerator;
|
||||||
|
|
||||||
|
final class VoidPocketChunkGenerator extends ChunkGenerator {
|
||||||
|
@Override
|
||||||
|
public boolean shouldGenerateNoise() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean shouldGenerateSurface() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean shouldGenerateCaves() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean shouldGenerateDecorations() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean shouldGenerateMobs() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean shouldGenerateStructures() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -127,7 +127,10 @@ public final class YamlBaseStateRepository {
|
|||||||
yaml.getBoolean(path + ".visitors-enabled", false),
|
yaml.getBoolean(path + ".visitors-enabled", false),
|
||||||
instant(yaml, path + ".last-base-set-epoch-millis"),
|
instant(yaml, path + ".last-base-set-epoch-millis"),
|
||||||
instant(yaml, path + ".last-base-teleport-epoch-millis"),
|
instant(yaml, path + ".last-base-teleport-epoch-millis"),
|
||||||
loadVisitorCooldowns(yaml, path + ".visitor-cooldowns")
|
loadVisitorCooldowns(yaml, path + ".visitor-cooldowns"),
|
||||||
|
yaml.getBoolean(path + ".border-enabled", false),
|
||||||
|
totalPlacementCount(yaml, path),
|
||||||
|
yaml.getBoolean(path + ".spawnable-overlay-enabled", false)
|
||||||
);
|
);
|
||||||
players.put(playerId, player);
|
players.put(playerId, player);
|
||||||
} catch (IllegalArgumentException ignored) {
|
} catch (IllegalArgumentException ignored) {
|
||||||
@@ -194,6 +197,13 @@ public final class YamlBaseStateRepository {
|
|||||||
return yaml.getLong(path);
|
return yaml.getLong(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static long totalPlacementCount(YamlConfiguration yaml, String playerPath) {
|
||||||
|
return Math.max(
|
||||||
|
count(yaml, playerPath + ".total-blocks-placed"),
|
||||||
|
count(yaml, playerPath + ".blocks-placed-in-base")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private static Optional<Instant> instant(YamlConfiguration yaml, String path) {
|
private static Optional<Instant> instant(YamlConfiguration yaml, String path) {
|
||||||
if (!yaml.isLong(path) && !yaml.isInt(path)) {
|
if (!yaml.isLong(path) && !yaml.isInt(path)) {
|
||||||
return Optional.empty();
|
return Optional.empty();
|
||||||
@@ -222,6 +232,9 @@ public final class YamlBaseStateRepository {
|
|||||||
yaml.set(path + ".flight-enabled", player.flightEnabled());
|
yaml.set(path + ".flight-enabled", player.flightEnabled());
|
||||||
yaml.set(path + ".boss-bar-enabled", player.bossBarEnabled());
|
yaml.set(path + ".boss-bar-enabled", player.bossBarEnabled());
|
||||||
yaml.set(path + ".visitors-enabled", player.visitorsEnabled());
|
yaml.set(path + ".visitors-enabled", player.visitorsEnabled());
|
||||||
|
yaml.set(path + ".border-enabled", player.borderEnabled());
|
||||||
|
yaml.set(path + ".total-blocks-placed", player.totalBlocksPlaced());
|
||||||
|
yaml.set(path + ".spawnable-overlay-enabled", player.spawnableOverlayEnabled());
|
||||||
yaml.set(
|
yaml.set(
|
||||||
path + ".last-base-set-epoch-millis",
|
path + ".last-base-set-epoch-millis",
|
||||||
player.lastBaseSet().map(Instant::toEpochMilli).orElse(null)
|
player.lastBaseSet().map(Instant::toEpochMilli).orElse(null)
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
package games.dmg.spigotbase;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.StandardCopyOption;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.bukkit.configuration.ConfigurationSection;
|
||||||
|
import org.bukkit.configuration.InvalidConfigurationException;
|
||||||
|
import org.bukkit.configuration.file.YamlConfiguration;
|
||||||
|
|
||||||
|
public final class YamlPocketBaseRepository {
|
||||||
|
private final Path stateFile;
|
||||||
|
|
||||||
|
public YamlPocketBaseRepository(Path stateFile) {
|
||||||
|
this.stateFile = stateFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<UUID, PocketBaseState> load() throws IOException {
|
||||||
|
if (!Files.exists(stateFile)) {
|
||||||
|
return Map.of();
|
||||||
|
}
|
||||||
|
YamlConfiguration yaml = new YamlConfiguration();
|
||||||
|
try {
|
||||||
|
yaml.load(stateFile.toFile());
|
||||||
|
} catch (InvalidConfigurationException exception) {
|
||||||
|
throw new IOException("Pocket Base state is not valid YAML", exception);
|
||||||
|
}
|
||||||
|
Map<UUID, PocketBaseState> states = new HashMap<>();
|
||||||
|
ConfigurationSection owners = yaml.getConfigurationSection("owners");
|
||||||
|
if (owners == null) {
|
||||||
|
return states;
|
||||||
|
}
|
||||||
|
for (String key : owners.getKeys(false)) {
|
||||||
|
try {
|
||||||
|
UUID ownerId = UUID.fromString(key);
|
||||||
|
String path = "owners." + key;
|
||||||
|
int level = yaml.getInt(path + ".level", 0);
|
||||||
|
PocketBaseState state = new PocketBaseState(
|
||||||
|
ownerId,
|
||||||
|
level,
|
||||||
|
loadPortal(yaml, path + ".entrance"),
|
||||||
|
loadPortal(yaml, path + ".return-portal"),
|
||||||
|
yaml.getBoolean(path + ".mob-spawning-enabled", false)
|
||||||
|
);
|
||||||
|
states.put(ownerId, state);
|
||||||
|
} catch (IllegalArgumentException ignored) {
|
||||||
|
// Invalid records grant no Pocket Base progression.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return states;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void save(Map<UUID, PocketBaseState> states) throws IOException {
|
||||||
|
Path parent = stateFile.toAbsolutePath().getParent();
|
||||||
|
if (parent != null) {
|
||||||
|
Files.createDirectories(parent);
|
||||||
|
}
|
||||||
|
YamlConfiguration yaml = new YamlConfiguration();
|
||||||
|
for (PocketBaseState state : states.values()) {
|
||||||
|
String path = "owners." + state.ownerId();
|
||||||
|
yaml.set(path + ".level", state.level());
|
||||||
|
yaml.set(path + ".mob-spawning-enabled", state.mobSpawningEnabled());
|
||||||
|
state.entrance().ifPresent(portal -> savePortal(yaml, path + ".entrance", portal));
|
||||||
|
state.returnPortal().ifPresent(portal ->
|
||||||
|
savePortal(yaml, path + ".return-portal", portal)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Path temporary = Files.createTempFile(parent, "spigot-base-pocket-", ".yml");
|
||||||
|
try {
|
||||||
|
yaml.save(temporary.toFile());
|
||||||
|
try {
|
||||||
|
Files.move(
|
||||||
|
temporary,
|
||||||
|
stateFile,
|
||||||
|
StandardCopyOption.REPLACE_EXISTING,
|
||||||
|
StandardCopyOption.ATOMIC_MOVE
|
||||||
|
);
|
||||||
|
} catch (IOException atomicMoveFailure) {
|
||||||
|
Files.move(temporary, stateFile, StandardCopyOption.REPLACE_EXISTING);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
Files.deleteIfExists(temporary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Optional<PocketPortalLocation> loadPortal(
|
||||||
|
YamlConfiguration yaml,
|
||||||
|
String path
|
||||||
|
) {
|
||||||
|
if (!yaml.isConfigurationSection(path)) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
String worldId = yaml.getString(path + ".world-id");
|
||||||
|
String worldName = yaml.getString(path + ".world-name");
|
||||||
|
String axis = yaml.getString(path + ".axis");
|
||||||
|
if (worldId == null || worldName == null || axis == null
|
||||||
|
|| !yaml.isInt(path + ".x") || !yaml.isInt(path + ".y")
|
||||||
|
|| !yaml.isInt(path + ".z")) {
|
||||||
|
throw new IllegalArgumentException("invalid Pocket Base portal");
|
||||||
|
}
|
||||||
|
return Optional.of(new PocketPortalLocation(
|
||||||
|
UUID.fromString(worldId),
|
||||||
|
worldName,
|
||||||
|
yaml.getInt(path + ".x"),
|
||||||
|
yaml.getInt(path + ".y"),
|
||||||
|
yaml.getInt(path + ".z"),
|
||||||
|
PocketPortalAxis.valueOf(axis)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void savePortal(
|
||||||
|
YamlConfiguration yaml,
|
||||||
|
String path,
|
||||||
|
PocketPortalLocation portal
|
||||||
|
) {
|
||||||
|
yaml.set(path + ".world-id", portal.worldId().toString());
|
||||||
|
yaml.set(path + ".world-name", portal.worldName());
|
||||||
|
yaml.set(path + ".x", portal.x());
|
||||||
|
yaml.set(path + ".y", portal.y());
|
||||||
|
yaml.set(path + ".z", portal.z());
|
||||||
|
yaml.set(path + ".axis", portal.axis().name());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -46,6 +46,15 @@ fourth-teleport-cooldown-seconds: 1800
|
|||||||
visitor-unlock-diamond-cost: 128
|
visitor-unlock-diamond-cost: 128
|
||||||
visitor-currency-material: DIAMOND
|
visitor-currency-material: DIAMOND
|
||||||
|
|
||||||
|
# Pocket Base
|
||||||
|
pocket-base-unlock-cost: 64
|
||||||
|
pocket-base-upgrade-cost: 16
|
||||||
|
pocket-base-currency-material: DIAMOND_BLOCK
|
||||||
|
pocket-base-portal-frame-material: DIAMOND_BLOCK
|
||||||
|
|
||||||
|
# Spawnable block overlay
|
||||||
|
spawnable-overlay-unlock-placements: 250
|
||||||
|
|
||||||
# Progress and notification presentation
|
# Progress and notification presentation
|
||||||
cooldown-excluded-materials: []
|
cooldown-excluded-materials: []
|
||||||
boss-bar-duration-ticks: 60
|
boss-bar-duration-ticks: 60
|
||||||
|
|||||||
@@ -8,27 +8,23 @@ commands:
|
|||||||
setbase:
|
setbase:
|
||||||
description: Set your unlocked personal base.
|
description: Set your unlocked personal base.
|
||||||
usage: /setbase
|
usage: /setbase
|
||||||
|
aliases: [sethome]
|
||||||
base:
|
base:
|
||||||
description: Teleport to or upgrade your base.
|
description: Teleport to your base.
|
||||||
usage: /base [upgrade]
|
usage: /base
|
||||||
basenavigation:
|
aliases: [home]
|
||||||
description: Toggle particle navigation toward your base.
|
|
||||||
usage: /basenavigation
|
|
||||||
baseflight:
|
|
||||||
description: Toggle flight within your base.
|
|
||||||
usage: /baseflight
|
|
||||||
basevisitors:
|
|
||||||
description: Toggle visitor access to your base.
|
|
||||||
usage: /basevisitors
|
|
||||||
gotobase:
|
gotobase:
|
||||||
description: Visit an available player base.
|
description: Visit an available player base.
|
||||||
usage: /gotobase <player>
|
usage: /gotobase <player>
|
||||||
baseprogress:
|
aliases: [visit]
|
||||||
description: View progression or toggle progress boss bars.
|
basesettings:
|
||||||
usage: /baseprogress [bossbar]
|
description: View progression, upgrade, and manage base settings.
|
||||||
|
usage: /basesettings [status|upgrade|pocket|visitors|navigation|flight|border|spawnable|bossbar]
|
||||||
|
aliases: [homesettings]
|
||||||
baseadmin:
|
baseadmin:
|
||||||
description: Administer Spigot Base.
|
description: Administer Spigot Base.
|
||||||
usage: /baseadmin <subcommand>
|
usage: /baseadmin <subcommand>
|
||||||
|
aliases: [homeadmin]
|
||||||
permission: spigotbase.admin
|
permission: spigotbase.admin
|
||||||
permissions:
|
permissions:
|
||||||
spigotbase.admin:
|
spigotbase.admin:
|
||||||
|
|||||||
@@ -11,6 +11,19 @@ final class AdminProgressCounterTest {
|
|||||||
new PluginSettingsProvider(PluginSettings.from(Map.of()))
|
new PluginSettingsProvider(PluginSettings.from(Map.of()))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void settingTotalPlacementCounterUnlocksSpawnableOverlay() {
|
||||||
|
PlayerState player = PlayerState.newPlayer(UUID.randomUUID(), "Alex");
|
||||||
|
|
||||||
|
PlayerState updated = service.setProgress(
|
||||||
|
player,
|
||||||
|
ProgressCounter.TOTAL_PLACEMENTS,
|
||||||
|
250
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(250, updated.totalBlocksPlaced());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void settingPlacementCounterEvaluatesBaseAndWarmupTiers() {
|
void settingPlacementCounterEvaluatesBaseAndWarmupTiers() {
|
||||||
PlayerState player = PlayerState.newPlayer(UUID.randomUUID(), "Alex");
|
PlayerState player = PlayerState.newPlayer(UUID.randomUUID(), "Alex");
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
|
|||||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
@@ -36,6 +37,40 @@ final class AdminProgressionServiceTest {
|
|||||||
assertFalse(updated.visitorsEnabled());
|
assertFalse(updated.visitorsEnabled());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void loweringBaseDisablesBorderVisualization() {
|
||||||
|
UUID playerId = UUID.randomUUID();
|
||||||
|
PlayerState player = PlayerState.newPlayer(playerId, "Alex")
|
||||||
|
.withAdministrativeLevels(1, 0, 0, 0, 0, false, false, false)
|
||||||
|
.withBase(
|
||||||
|
new BaseLocation(UUID.randomUUID(), "world", 0, 64, 0, 0, 0),
|
||||||
|
Instant.EPOCH
|
||||||
|
)
|
||||||
|
.withBorderEnabled(true);
|
||||||
|
|
||||||
|
PlayerState updated = service.setLevel(player, ProgressionPath.BASE, 0);
|
||||||
|
|
||||||
|
assertFalse(updated.borderEnabled());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void completeResetClearsSpawnableOverlayProgressAndPreference() {
|
||||||
|
UUID playerId = UUID.randomUUID();
|
||||||
|
PlayerState player = PlayerState.newPlayer(playerId, "Alex")
|
||||||
|
.withAdministrativeLevels(1, 0, 0, 0, 0, false, false, false)
|
||||||
|
.withBase(
|
||||||
|
new BaseLocation(UUID.randomUUID(), "world", 0, 64, 0, 0, 0),
|
||||||
|
Instant.EPOCH
|
||||||
|
)
|
||||||
|
.withTotalBlocksPlaced(250)
|
||||||
|
.withSpawnableOverlayEnabled(true);
|
||||||
|
|
||||||
|
PlayerState updated = service.resetPath(player, ProgressionPath.BASE);
|
||||||
|
|
||||||
|
assertEquals(0, updated.totalBlocksPlaced());
|
||||||
|
assertFalse(updated.spawnableOverlayEnabled());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void rejectsUnknownLevel() {
|
void rejectsUnknownLevel() {
|
||||||
PlayerState player = PlayerState.newPlayer(UUID.randomUUID(), "Alex");
|
PlayerState player = PlayerState.newPlayer(UUID.randomUUID(), "Alex");
|
||||||
|
|||||||
@@ -0,0 +1,323 @@
|
|||||||
|
package games.dmg.spigotbase;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.mockStatic;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
import java.util.function.UnaryOperator;
|
||||||
|
import org.bukkit.Bukkit;
|
||||||
|
import org.bukkit.ChatColor;
|
||||||
|
import org.bukkit.command.CommandSender;
|
||||||
|
import org.bukkit.configuration.file.FileConfiguration;
|
||||||
|
import org.bukkit.plugin.java.JavaPlugin;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.mockito.MockedStatic;
|
||||||
|
|
||||||
|
final class BaseAdminCommandTest {
|
||||||
|
@Test
|
||||||
|
void adminCanEnableUnlockedSpawnableOverlayForPlayer() {
|
||||||
|
CommandSender sender = mock(CommandSender.class);
|
||||||
|
when(sender.hasPermission("spigotbase.admin")).thenReturn(true);
|
||||||
|
UUID playerId = UUID.randomUUID();
|
||||||
|
PlayerState target = PlayerState.newPlayer(playerId, "Builder")
|
||||||
|
.withAdministrativeLevels(1, 0, 0, 0, 0, false, false, false)
|
||||||
|
.withBase(
|
||||||
|
new BaseLocation(UUID.randomUUID(), "world", 0, 64, 0, 0, 0),
|
||||||
|
Instant.EPOCH
|
||||||
|
)
|
||||||
|
.withTotalBlocksPlaced(250);
|
||||||
|
AtomicReference<PlayerState> updated = new AtomicReference<>();
|
||||||
|
BaseStateManager stateManager = mock(BaseStateManager.class);
|
||||||
|
when(stateManager.findByName("Builder")).thenReturn(Optional.of(target));
|
||||||
|
when(stateManager.update(any(), anyString(), any())).thenAnswer(invocation -> {
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
UnaryOperator<PlayerState> operation = invocation.getArgument(2);
|
||||||
|
PlayerState result = operation.apply(target);
|
||||||
|
updated.set(result);
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
BaseAdminCommand command = new BaseAdminCommand(
|
||||||
|
mock(JavaPlugin.class),
|
||||||
|
stateManager,
|
||||||
|
new AdminProgressionService(
|
||||||
|
new PluginSettingsProvider(PluginSettings.from(Map.of()))
|
||||||
|
),
|
||||||
|
new PluginSettingsProvider(PluginSettings.from(Map.of()))
|
||||||
|
);
|
||||||
|
|
||||||
|
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class)) {
|
||||||
|
bukkit.when(() -> Bukkit.getPlayerExact("Builder")).thenReturn(null);
|
||||||
|
command.onCommand(sender, null, "baseadmin",
|
||||||
|
new String[] {"setsetting", "Builder", "spawnable", "enable"});
|
||||||
|
}
|
||||||
|
|
||||||
|
assertTrue(updated.get().spawnableOverlayEnabled());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void invalidPathShowsFriendlyUsage() {
|
||||||
|
CommandSender sender = mock(CommandSender.class);
|
||||||
|
when(sender.hasPermission("spigotbase.admin")).thenReturn(true);
|
||||||
|
PlayerState target = PlayerState.newPlayer(UUID.randomUUID(), "Builder");
|
||||||
|
BaseStateManager stateManager = mock(BaseStateManager.class);
|
||||||
|
when(stateManager.findByName("Builder")).thenReturn(Optional.of(target));
|
||||||
|
BaseAdminCommand command = new BaseAdminCommand(
|
||||||
|
mock(JavaPlugin.class),
|
||||||
|
stateManager,
|
||||||
|
mock(AdminProgressionService.class),
|
||||||
|
new PluginSettingsProvider(PluginSettings.from(Map.of()))
|
||||||
|
);
|
||||||
|
|
||||||
|
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class)) {
|
||||||
|
bukkit.when(() -> Bukkit.getPlayerExact("Builder")).thenReturn(null);
|
||||||
|
command.onCommand(sender, null, "baseadmin",
|
||||||
|
new String[] {"setlevel", "Builder", "unknown", "1"});
|
||||||
|
}
|
||||||
|
|
||||||
|
verify(sender).sendMessage(ChatColor.RED
|
||||||
|
+ "Usage: /baseadmin setlevel <player> <base|size|flight|warmup|cooldown> <level>");
|
||||||
|
verify(stateManager, never()).update(any(), anyString(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void autocompletesValidLevelsAndResetConfirmation() {
|
||||||
|
CommandSender sender = mock(CommandSender.class);
|
||||||
|
when(sender.hasPermission("spigotbase.admin")).thenReturn(true);
|
||||||
|
BaseAdminCommand command = new BaseAdminCommand(
|
||||||
|
mock(JavaPlugin.class),
|
||||||
|
mock(BaseStateManager.class),
|
||||||
|
mock(AdminProgressionService.class),
|
||||||
|
new PluginSettingsProvider(PluginSettings.from(Map.of()))
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
List.of("0", "1", "2", "3", "4"),
|
||||||
|
command.onTabComplete(sender, null, "baseadmin",
|
||||||
|
new String[] {"setlevel", "Builder", "base", ""})
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
List.of("0", "1", "2", "3"),
|
||||||
|
command.onTabComplete(sender, null, "baseadmin",
|
||||||
|
new String[] {"setlevel", "Builder", "flight", ""})
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
List.of("confirm"),
|
||||||
|
command.onTabComplete(sender, null, "baseadmin",
|
||||||
|
new String[] {"reset", "Builder", "all", ""})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void autocompletesCommandSpecificOptions() {
|
||||||
|
CommandSender sender = mock(CommandSender.class);
|
||||||
|
when(sender.hasPermission("spigotbase.admin")).thenReturn(true);
|
||||||
|
BaseAdminCommand command = new BaseAdminCommand(
|
||||||
|
mock(JavaPlugin.class),
|
||||||
|
mock(BaseStateManager.class),
|
||||||
|
mock(AdminProgressionService.class),
|
||||||
|
new PluginSettingsProvider(PluginSettings.from(Map.of()))
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
List.of("base", "size", "flight", "warmup", "cooldown"),
|
||||||
|
command.onTabComplete(sender, null, "baseadmin",
|
||||||
|
new String[] {"setlevel", "Builder", ""})
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
List.of(
|
||||||
|
"grass_dirt", "stone", "deepslate", "obsidian", "placements",
|
||||||
|
"total_placements", "base_breaks"
|
||||||
|
),
|
||||||
|
command.onTabComplete(sender, null, "baseadmin",
|
||||||
|
new String[] {"setprogress", "Builder", ""})
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
List.of("spawnable"),
|
||||||
|
command.onTabComplete(sender, null, "baseadmin",
|
||||||
|
new String[] {"setsetting", "Builder", ""})
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
List.of("enable"),
|
||||||
|
command.onTabComplete(sender, null, "baseadmin",
|
||||||
|
new String[] {"setsetting", "Builder", "spawnable", "e"})
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
List.of("personal", "visitor", "all"),
|
||||||
|
command.onTabComplete(sender, null, "baseadmin",
|
||||||
|
new String[] {"clearcooldown", "Builder", ""})
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
List.of("size", "flight", "warmup", "cooldown", "all"),
|
||||||
|
command.onTabComplete(sender, null, "baseadmin",
|
||||||
|
new String[] {"reset", "Builder", ""})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void adminCanChangeSpawnableOverlayUnlockThreshold() {
|
||||||
|
CommandSender sender = mock(CommandSender.class);
|
||||||
|
when(sender.hasPermission("spigotbase.admin")).thenReturn(true);
|
||||||
|
JavaPlugin plugin = mock(JavaPlugin.class);
|
||||||
|
FileConfiguration configuration = mock(FileConfiguration.class);
|
||||||
|
when(plugin.getConfig()).thenReturn(configuration);
|
||||||
|
when(configuration.get("spawnable-overlay-unlock-placements")).thenReturn(250);
|
||||||
|
when(configuration.getValues(false)).thenReturn(Map.of(
|
||||||
|
"spawnable-overlay-unlock-placements", 250
|
||||||
|
));
|
||||||
|
PluginSettingsProvider settings = new PluginSettingsProvider(
|
||||||
|
PluginSettings.from(Map.of())
|
||||||
|
);
|
||||||
|
BaseAdminCommand command = new BaseAdminCommand(
|
||||||
|
plugin,
|
||||||
|
mock(BaseStateManager.class),
|
||||||
|
mock(AdminProgressionService.class),
|
||||||
|
settings
|
||||||
|
);
|
||||||
|
|
||||||
|
try (MockedStatic<PluginSettingsValidator> validator =
|
||||||
|
mockStatic(PluginSettingsValidator.class)) {
|
||||||
|
validator.when(() -> PluginSettingsValidator.validateMaterials(
|
||||||
|
any(PluginSettings.class)
|
||||||
|
)).thenAnswer(invocation -> invocation.getArgument(0));
|
||||||
|
command.onCommand(sender, null, "baseadmin", new String[] {
|
||||||
|
"config", "spawnable-overlay-unlock-placements", "500"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
verify(configuration).set("spawnable-overlay-unlock-placements", 500L);
|
||||||
|
verify(plugin).saveConfig();
|
||||||
|
assertEquals(500, settings.current().spawnableOverlayUnlockPlacements());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void autocompletesNumericConfigurationKeys() {
|
||||||
|
CommandSender sender = mock(CommandSender.class);
|
||||||
|
when(sender.hasPermission("spigotbase.admin")).thenReturn(true);
|
||||||
|
JavaPlugin plugin = mock(JavaPlugin.class);
|
||||||
|
FileConfiguration configuration = mock(FileConfiguration.class);
|
||||||
|
when(plugin.getConfig()).thenReturn(configuration);
|
||||||
|
when(configuration.getValues(false)).thenReturn(Map.of(
|
||||||
|
"base-unlock-blocks", 250,
|
||||||
|
"visitor-currency-material", "DIAMOND",
|
||||||
|
"title-stay-ticks", 70
|
||||||
|
));
|
||||||
|
BaseAdminCommand command = new BaseAdminCommand(
|
||||||
|
plugin,
|
||||||
|
mock(BaseStateManager.class),
|
||||||
|
mock(AdminProgressionService.class),
|
||||||
|
new PluginSettingsProvider(PluginSettings.from(Map.of()))
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
List.of("base-unlock-blocks"),
|
||||||
|
command.onTabComplete(sender, null, "baseadmin", new String[] {"config", "base"})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void autocompletesKnownOfflinePlayers() {
|
||||||
|
CommandSender sender = mock(CommandSender.class);
|
||||||
|
when(sender.hasPermission("spigotbase.admin")).thenReturn(true);
|
||||||
|
BaseStateManager stateManager = mock(BaseStateManager.class);
|
||||||
|
PlayerState alex = PlayerState.newPlayer(UUID.randomUUID(), "Alex");
|
||||||
|
PlayerState blake = PlayerState.newPlayer(UUID.randomUUID(), "Blake");
|
||||||
|
when(stateManager.knownPlayers()).thenReturn(Map.of(
|
||||||
|
alex.playerId(), alex,
|
||||||
|
blake.playerId(), blake
|
||||||
|
));
|
||||||
|
BaseAdminCommand command = new BaseAdminCommand(
|
||||||
|
mock(JavaPlugin.class),
|
||||||
|
stateManager,
|
||||||
|
mock(AdminProgressionService.class),
|
||||||
|
new PluginSettingsProvider(PluginSettings.from(Map.of()))
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
List.of("Alex", "Blake"),
|
||||||
|
command.onTabComplete(sender, null, "baseadmin", new String[] {"status", ""})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void autocompletesAdminSubcommandsForPermittedSenders() {
|
||||||
|
CommandSender sender = mock(CommandSender.class);
|
||||||
|
when(sender.hasPermission("spigotbase.admin")).thenReturn(true);
|
||||||
|
BaseAdminCommand command = new BaseAdminCommand(
|
||||||
|
mock(JavaPlugin.class),
|
||||||
|
mock(BaseStateManager.class),
|
||||||
|
mock(AdminProgressionService.class),
|
||||||
|
new PluginSettingsProvider(PluginSettings.from(Map.of()))
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
List.of(
|
||||||
|
"status", "setlevel", "setprogress", "setsetting", "clearcooldown", "reset",
|
||||||
|
"config"
|
||||||
|
),
|
||||||
|
command.onTabComplete(sender, null, "baseadmin", new String[] {""})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resetBaseRequiresConfirmedCompleteReset() {
|
||||||
|
CommandSender sender = mock(CommandSender.class);
|
||||||
|
when(sender.hasPermission("spigotbase.admin")).thenReturn(true);
|
||||||
|
PlayerState target = PlayerState.newPlayer(UUID.randomUUID(), "Builder");
|
||||||
|
BaseStateManager stateManager = mock(BaseStateManager.class);
|
||||||
|
when(stateManager.findByName("Builder")).thenReturn(Optional.of(target));
|
||||||
|
BaseAdminCommand command = new BaseAdminCommand(
|
||||||
|
mock(JavaPlugin.class),
|
||||||
|
stateManager,
|
||||||
|
mock(AdminProgressionService.class),
|
||||||
|
new PluginSettingsProvider(PluginSettings.from(Map.of()))
|
||||||
|
);
|
||||||
|
|
||||||
|
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class)) {
|
||||||
|
bukkit.when(() -> Bukkit.getPlayerExact("Builder")).thenReturn(null);
|
||||||
|
command.onCommand(
|
||||||
|
sender,
|
||||||
|
null,
|
||||||
|
"baseadmin",
|
||||||
|
new String[] {"reset", "Builder", "base"}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
verify(stateManager, never()).update(any(), anyString(), any());
|
||||||
|
verify(sender).sendMessage(ChatColor.RED
|
||||||
|
+ "Resetting base removes all progression. Use: /baseadmin reset Builder all confirm");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void statusDisplaysKnownPlayerProgress() {
|
||||||
|
CommandSender sender = mock(CommandSender.class);
|
||||||
|
when(sender.hasPermission("spigotbase.admin")).thenReturn(true);
|
||||||
|
PlayerState target = PlayerState.newPlayer(UUID.randomUUID(), "Builder");
|
||||||
|
BaseStateManager stateManager = mock(BaseStateManager.class);
|
||||||
|
when(stateManager.findByName("Builder")).thenReturn(Optional.of(target));
|
||||||
|
BaseAdminCommand command = new BaseAdminCommand(
|
||||||
|
mock(JavaPlugin.class),
|
||||||
|
stateManager,
|
||||||
|
mock(AdminProgressionService.class),
|
||||||
|
new PluginSettingsProvider(PluginSettings.from(Map.of()))
|
||||||
|
);
|
||||||
|
|
||||||
|
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class)) {
|
||||||
|
bukkit.when(() -> Bukkit.getPlayerExact("Builder")).thenReturn(null);
|
||||||
|
command.onCommand(sender, null, "baseadmin", new String[] {"status", "Builder"});
|
||||||
|
}
|
||||||
|
|
||||||
|
verify(sender).sendMessage(ChatColor.GOLD + "=== Builder Base Progress ===");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package games.dmg.spigotbase;
|
||||||
|
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.atLeastOnce;
|
||||||
|
import static org.mockito.Mockito.doReturn;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.bukkit.Location;
|
||||||
|
import org.bukkit.Particle;
|
||||||
|
import org.bukkit.Server;
|
||||||
|
import org.bukkit.World;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
final class BaseBorderControllerTest {
|
||||||
|
@Test
|
||||||
|
void doesNotRenderOutsideTheBaseVerticalRange() {
|
||||||
|
UUID playerId = UUID.randomUUID();
|
||||||
|
UUID worldId = UUID.randomUUID();
|
||||||
|
PlayerState state = PlayerState.newPlayer(playerId, "Builder")
|
||||||
|
.withAdministrativeLevels(1, 0, 0, 0, 0, false, false, false)
|
||||||
|
.withBase(new BaseLocation(worldId, "world", 0, 64, 0, 0, 0), Instant.EPOCH)
|
||||||
|
.withBorderEnabled(true);
|
||||||
|
|
||||||
|
World world = mock(World.class);
|
||||||
|
when(world.getUID()).thenReturn(worldId);
|
||||||
|
Player player = mock(Player.class);
|
||||||
|
when(player.getUniqueId()).thenReturn(playerId);
|
||||||
|
when(player.getName()).thenReturn("Builder");
|
||||||
|
when(player.getWorld()).thenReturn(world);
|
||||||
|
when(player.getLocation()).thenReturn(new Location(world, 10.5, 100.0, 0.5));
|
||||||
|
Server server = mock(Server.class);
|
||||||
|
doReturn(java.util.List.of(player)).when(server).getOnlinePlayers();
|
||||||
|
BaseStateManager stateManager = mock(BaseStateManager.class);
|
||||||
|
when(stateManager.player(playerId, "Builder")).thenReturn(state);
|
||||||
|
|
||||||
|
new BaseBorderController(
|
||||||
|
server,
|
||||||
|
stateManager,
|
||||||
|
new BaseBoundsService(PluginSettings.from(Map.of()))
|
||||||
|
).run();
|
||||||
|
|
||||||
|
verify(player, never()).spawnParticle(
|
||||||
|
eq(Particle.END_ROD),
|
||||||
|
any(Location.class),
|
||||||
|
eq(1),
|
||||||
|
eq(0.0), eq(0.0), eq(0.0), eq(0.0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void ownerSeesParticlesAtCurrentHeightNearEnabledBorder() {
|
||||||
|
UUID playerId = UUID.randomUUID();
|
||||||
|
UUID worldId = UUID.randomUUID();
|
||||||
|
PlayerState state = PlayerState.newPlayer(playerId, "Builder")
|
||||||
|
.withAdministrativeLevels(1, 0, 0, 0, 0, false, false, false)
|
||||||
|
.withBase(new BaseLocation(worldId, "world", 0, 64, 0, 0, 0), Instant.EPOCH)
|
||||||
|
.withBorderEnabled(true);
|
||||||
|
|
||||||
|
World world = mock(World.class);
|
||||||
|
when(world.getUID()).thenReturn(worldId);
|
||||||
|
Player player = mock(Player.class);
|
||||||
|
when(player.getUniqueId()).thenReturn(playerId);
|
||||||
|
when(player.getName()).thenReturn("Builder");
|
||||||
|
when(player.getWorld()).thenReturn(world);
|
||||||
|
when(player.getLocation()).thenReturn(new Location(world, 10.5, 70.0, 0.5));
|
||||||
|
Server server = mock(Server.class);
|
||||||
|
doReturn(java.util.List.of(player)).when(server).getOnlinePlayers();
|
||||||
|
BaseStateManager stateManager = mock(BaseStateManager.class);
|
||||||
|
when(stateManager.player(playerId, "Builder")).thenReturn(state);
|
||||||
|
|
||||||
|
new BaseBorderController(
|
||||||
|
server,
|
||||||
|
stateManager,
|
||||||
|
new BaseBoundsService(PluginSettings.from(Map.of()))
|
||||||
|
).run();
|
||||||
|
|
||||||
|
verify(player, atLeastOnce()).spawnParticle(
|
||||||
|
eq(Particle.END_ROD),
|
||||||
|
any(Location.class),
|
||||||
|
eq(1),
|
||||||
|
eq(0.0), eq(0.0), eq(0.0), eq(0.0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package games.dmg.spigotbase;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
final class BaseBorderGeometryTest {
|
||||||
|
@Test
|
||||||
|
void producesBoundedPointsOnTheCurrentCircularBoundary() {
|
||||||
|
List<BaseBorderGeometry.Point> points = BaseBorderGeometry.visiblePoints(
|
||||||
|
0.5, 0.5, 150, 150.5, 0.5
|
||||||
|
);
|
||||||
|
|
||||||
|
assertFalse(points.isEmpty());
|
||||||
|
assertTrue(points.size() <= BaseBorderGeometry.MAX_PARTICLES);
|
||||||
|
assertTrue(points.stream().allMatch(point ->
|
||||||
|
Math.abs(Math.hypot(point.x() - 0.5, point.z() - 0.5) - 150.0) < 0.0001
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void omitsParticlesWhenPlayerIsFarFromBoundary() {
|
||||||
|
assertTrue(BaseBorderGeometry.visiblePoints(0.5, 0.5, 10, 100.5, 0.5).isEmpty());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package games.dmg.spigotbase;
|
||||||
|
|
||||||
|
import static org.mockito.ArgumentMatchers.anyInt;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.Mockito.doReturn;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.times;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.bukkit.Location;
|
||||||
|
import org.bukkit.Server;
|
||||||
|
import org.bukkit.World;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
import org.bukkit.inventory.ItemStack;
|
||||||
|
import org.bukkit.inventory.PlayerInventory;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
final class BaseFlightControllerTest {
|
||||||
|
private final UUID playerId = UUID.randomUUID();
|
||||||
|
private final UUID worldId = UUID.randomUUID();
|
||||||
|
private final Server server = mock(Server.class);
|
||||||
|
private final BaseStateManager stateManager = mock(BaseStateManager.class);
|
||||||
|
private final Player player = mock(Player.class);
|
||||||
|
private final PlayerState state = PlayerState.newPlayer(playerId, "Alex")
|
||||||
|
.withGrassAndDirtProgress(250, 1)
|
||||||
|
.withBase(new BaseLocation(worldId, "world", 0, 64, 0, 0, 0), Instant.EPOCH)
|
||||||
|
.withFlightLevel(1, true);
|
||||||
|
private final BaseFlightController controller = new BaseFlightController(
|
||||||
|
server,
|
||||||
|
stateManager,
|
||||||
|
new SecondaryProgressionService(PluginSettings.from(Map.of())),
|
||||||
|
new BaseBoundsService(PluginSettings.from(Map.of())),
|
||||||
|
new PluginSettingsProvider(PluginSettings.from(Map.of()))
|
||||||
|
);
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
World world = mock(World.class);
|
||||||
|
Location location = mock(Location.class);
|
||||||
|
PlayerInventory inventory = mock(PlayerInventory.class);
|
||||||
|
|
||||||
|
doReturn(List.of(player)).when(server).getOnlinePlayers();
|
||||||
|
when(player.getUniqueId()).thenReturn(playerId);
|
||||||
|
when(player.getName()).thenReturn("Alex");
|
||||||
|
when(player.getGameMode()).thenReturn(org.bukkit.GameMode.SURVIVAL);
|
||||||
|
when(player.getInventory()).thenReturn(inventory);
|
||||||
|
when(inventory.getStorageContents()).thenReturn(new ItemStack[0]);
|
||||||
|
when(stateManager.player(playerId, "Alex")).thenReturn(state);
|
||||||
|
when(player.getWorld()).thenReturn(world);
|
||||||
|
when(world.getUID()).thenReturn(worldId);
|
||||||
|
when(player.getLocation()).thenReturn(location);
|
||||||
|
when(location.getX()).thenReturn(12.5);
|
||||||
|
when(location.getZ()).thenReturn(0.5);
|
||||||
|
when(location.getBlockY()).thenReturn(64);
|
||||||
|
when(player.getAllowFlight()).thenReturn(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void walkingInWarningBufferDoesNotShowLeavingBaseWarning() {
|
||||||
|
when(player.isFlying()).thenReturn(false);
|
||||||
|
|
||||||
|
controller.run();
|
||||||
|
|
||||||
|
verify(player, never()).sendTitle(anyString(), anyString(), anyInt(), anyInt(), anyInt());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void flyingInWarningBufferShowsLeavingBaseWarning() {
|
||||||
|
when(player.isFlying()).thenReturn(true);
|
||||||
|
|
||||||
|
controller.run();
|
||||||
|
|
||||||
|
verify(player).sendTitle(anyString(), anyString(), anyInt(), anyInt(), anyInt());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void warningCanAppearAgainAfterPlayerStopsAndResumesFlyingInBuffer() {
|
||||||
|
when(player.isFlying()).thenReturn(true, false, true);
|
||||||
|
|
||||||
|
controller.run();
|
||||||
|
controller.run();
|
||||||
|
controller.run();
|
||||||
|
|
||||||
|
verify(player, times(2)).sendTitle(anyString(), anyString(), anyInt(), anyInt(), anyInt());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package games.dmg.spigotbase;
|
||||||
|
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.doReturn;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.times;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.bukkit.Location;
|
||||||
|
import org.bukkit.Particle;
|
||||||
|
import org.bukkit.Server;
|
||||||
|
import org.bukkit.World;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
final class BaseNavigationControllerTest {
|
||||||
|
@Test
|
||||||
|
void showsParticlesOnlyMoreThanTwentyFiveBlocksBeyondBorder() {
|
||||||
|
UUID playerId = UUID.randomUUID();
|
||||||
|
UUID worldId = UUID.randomUUID();
|
||||||
|
PlayerState state = PlayerState.newPlayer(playerId, "Builder")
|
||||||
|
.withAdministrativeLevels(2, 0, 0, 0, 0, true, false, false)
|
||||||
|
.withBase(new BaseLocation(worldId, "world", 0, 64, 0, 0, 0), Instant.EPOCH);
|
||||||
|
World world = mock(World.class);
|
||||||
|
when(world.getUID()).thenReturn(worldId);
|
||||||
|
Player player = mock(Player.class);
|
||||||
|
when(player.getUniqueId()).thenReturn(playerId);
|
||||||
|
when(player.getName()).thenReturn("Builder");
|
||||||
|
when(player.getWorld()).thenReturn(world);
|
||||||
|
when(player.getLocation()).thenReturn(new Location(world, 35.5, 64.0, 0.5));
|
||||||
|
Server server = mock(Server.class);
|
||||||
|
doReturn(java.util.List.of(player)).when(server).getOnlinePlayers();
|
||||||
|
BaseStateManager stateManager = mock(BaseStateManager.class);
|
||||||
|
when(stateManager.player(playerId, "Builder")).thenReturn(state);
|
||||||
|
PluginSettingsProvider settings = new PluginSettingsProvider(
|
||||||
|
PluginSettings.from(Map.of())
|
||||||
|
);
|
||||||
|
|
||||||
|
new BaseNavigationController(server, stateManager, settings).run();
|
||||||
|
|
||||||
|
verify(player, never()).spawnParticle(
|
||||||
|
eq(Particle.END_ROD), any(Location.class), eq(1),
|
||||||
|
eq(0.0), eq(0.0), eq(0.0), eq(0.0)
|
||||||
|
);
|
||||||
|
|
||||||
|
when(player.getLocation()).thenReturn(new Location(world, 35.51, 64.0, 0.5));
|
||||||
|
new BaseNavigationController(server, stateManager, settings).run();
|
||||||
|
|
||||||
|
verify(player, times(settings.current().navigationParticleCount())).spawnParticle(
|
||||||
|
eq(Particle.END_ROD), any(Location.class), eq(1),
|
||||||
|
eq(0.0), eq(0.0), eq(0.0), eq(0.0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package games.dmg.spigotbase;
|
package games.dmg.spigotbase;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -21,4 +22,18 @@ final class BaseNavigationProgressionTest {
|
|||||||
assertTrue(update.player().navigationEnabled());
|
assertTrue(update.player().navigationEnabled());
|
||||||
assertTrue(update.unlockedBaseLevel());
|
assertTrue(update.unlockedBaseLevel());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void laterProgressPreservesDisabledNavigationPreference() {
|
||||||
|
BaseProgressionService service =
|
||||||
|
new BaseProgressionService(PluginSettings.from(Map.of()));
|
||||||
|
PlayerState player = PlayerState.newPlayer(UUID.randomUUID(), "Alex")
|
||||||
|
.withGrassAndDirtProgress(500, 2)
|
||||||
|
.withNavigationEnabled(false);
|
||||||
|
|
||||||
|
ProgressionUpdate update = service.recordGrassOrDirtBreak(player);
|
||||||
|
|
||||||
|
assertFalse(update.player().navigationEnabled());
|
||||||
|
assertFalse(update.unlockedBaseLevel());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package games.dmg.spigotbase;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
final class BaseNavigationVisibilityTest {
|
||||||
|
private static final BaseLocation BASE = new BaseLocation(
|
||||||
|
UUID.randomUUID(), "world", 0, 64, 0, 0, 0
|
||||||
|
);
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void hidesNavigationAtAndWithinTwentyFiveBlocksBeyondBorder() {
|
||||||
|
assertFalse(BaseNavigationVisibility.isBeyondBorderBuffer(BASE, 10, 35.5, 0.5));
|
||||||
|
assertFalse(BaseNavigationVisibility.isBeyondBorderBuffer(BASE, 10, 20.5, 0.5));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void showsNavigationMoreThanTwentyFiveBlocksBeyondCurrentBorder() {
|
||||||
|
assertTrue(BaseNavigationVisibility.isBeyondBorderBuffer(BASE, 10, 35.51, 0.5));
|
||||||
|
assertFalse(BaseNavigationVisibility.isBeyondBorderBuffer(BASE, 75, 100.5, 0.5));
|
||||||
|
assertTrue(BaseNavigationVisibility.isBeyondBorderBuffer(BASE, 75, 100.51, 0.5));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package games.dmg.spigotbase;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
import java.util.function.UnaryOperator;
|
||||||
|
import org.bukkit.GameMode;
|
||||||
|
import org.bukkit.World;
|
||||||
|
import org.bukkit.block.Block;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
import org.bukkit.event.block.BlockPlaceEvent;
|
||||||
|
import org.bukkit.plugin.Plugin;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
final class BaseProgressListenerTest {
|
||||||
|
@Test
|
||||||
|
void survivalPlacementOutsideBaseCountsTowardOverlayUnlock() {
|
||||||
|
UUID playerId = UUID.randomUUID();
|
||||||
|
PlayerState current = PlayerState.newPlayer(playerId, "Builder");
|
||||||
|
AtomicReference<PlayerState> updated = new AtomicReference<>();
|
||||||
|
BaseStateManager stateManager = mock(BaseStateManager.class);
|
||||||
|
when(stateManager.player(playerId, "Builder")).thenReturn(current);
|
||||||
|
when(stateManager.update(any(), anyString(), any())).thenAnswer(invocation -> {
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
UnaryOperator<PlayerState> operation = invocation.getArgument(2);
|
||||||
|
PlayerState result = operation.apply(current);
|
||||||
|
updated.set(result);
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
Player player = mock(Player.class);
|
||||||
|
when(player.getUniqueId()).thenReturn(playerId);
|
||||||
|
when(player.getName()).thenReturn("Builder");
|
||||||
|
when(player.getGameMode()).thenReturn(GameMode.SURVIVAL);
|
||||||
|
World world = mock(World.class);
|
||||||
|
when(world.getUID()).thenReturn(UUID.randomUUID());
|
||||||
|
Block block = mock(Block.class);
|
||||||
|
when(block.getWorld()).thenReturn(world);
|
||||||
|
BlockPlaceEvent event = mock(BlockPlaceEvent.class);
|
||||||
|
when(event.getPlayer()).thenReturn(player);
|
||||||
|
when(event.getBlockPlaced()).thenReturn(block);
|
||||||
|
PluginSettingsProvider settings = new PluginSettingsProvider(
|
||||||
|
PluginSettings.from(Map.of())
|
||||||
|
);
|
||||||
|
BaseProgressListener listener = new BaseProgressListener(
|
||||||
|
mock(Plugin.class),
|
||||||
|
stateManager,
|
||||||
|
mock(BaseProgressionService.class),
|
||||||
|
mock(SecondaryProgressionService.class),
|
||||||
|
mock(TeleportProgressionService.class),
|
||||||
|
new SpawnableOverlayProgressionService(settings),
|
||||||
|
new BaseBoundsService(settings),
|
||||||
|
settings
|
||||||
|
);
|
||||||
|
|
||||||
|
listener.onBlockPlace(event);
|
||||||
|
|
||||||
|
assertEquals(1, updated.get().totalBlocksPlaced());
|
||||||
|
assertEquals(0, updated.get().blocksPlacedInBase());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,488 @@
|
|||||||
|
package games.dmg.spigotbase;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.times;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
import java.util.function.UnaryOperator;
|
||||||
|
import org.bukkit.ChatColor;
|
||||||
|
import org.bukkit.Material;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
import org.bukkit.inventory.ItemStack;
|
||||||
|
import org.bukkit.inventory.PlayerInventory;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
|
||||||
|
final class BaseSettingsCommandTest {
|
||||||
|
@Test
|
||||||
|
void spawnableEnableIsIdempotentAfterUnlock() {
|
||||||
|
UUID playerId = UUID.randomUUID();
|
||||||
|
PlayerState current = PlayerState.newPlayer(playerId, "Builder")
|
||||||
|
.withAdministrativeLevels(1, 0, 0, 0, 0, false, false, false)
|
||||||
|
.withBase(
|
||||||
|
new BaseLocation(UUID.randomUUID(), "world", 0, 64, 0, 0, 0),
|
||||||
|
Instant.EPOCH
|
||||||
|
)
|
||||||
|
.withTotalBlocksPlaced(250);
|
||||||
|
|
||||||
|
CommandResult result = execute(current, "spawnable", "enable");
|
||||||
|
|
||||||
|
assertTrue(result.updated().spawnableOverlayEnabled());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void borderEnableIsIdempotent() {
|
||||||
|
UUID playerId = UUID.randomUUID();
|
||||||
|
PlayerState current = PlayerState.newPlayer(playerId, "Builder")
|
||||||
|
.withAdministrativeLevels(1, 0, 0, 0, 0, false, false, false)
|
||||||
|
.withBase(
|
||||||
|
new BaseLocation(UUID.randomUUID(), "world", 0, 64, 0, 0, 0),
|
||||||
|
Instant.EPOCH
|
||||||
|
);
|
||||||
|
|
||||||
|
CommandResult result = execute(current, "border", "enable");
|
||||||
|
|
||||||
|
assertTrue(result.updated().borderEnabled());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void invalidArgumentsShowUsage() {
|
||||||
|
UUID playerId = UUID.randomUUID();
|
||||||
|
Player player = mock(Player.class);
|
||||||
|
when(player.getUniqueId()).thenReturn(playerId);
|
||||||
|
when(player.getName()).thenReturn("Builder");
|
||||||
|
BaseStateManager stateManager = mock(BaseStateManager.class);
|
||||||
|
when(stateManager.player(playerId, "Builder"))
|
||||||
|
.thenReturn(PlayerState.newPlayer(playerId, "Builder"));
|
||||||
|
BaseSettingsCommand command = new BaseSettingsCommand(
|
||||||
|
stateManager,
|
||||||
|
new PluginSettingsProvider(PluginSettings.from(Map.of())),
|
||||||
|
mock(BaseFlightController.class)
|
||||||
|
);
|
||||||
|
|
||||||
|
command.onCommand(player, null, "basesettings", new String[] {"unknown"});
|
||||||
|
|
||||||
|
verify(player).sendMessage(org.mockito.ArgumentMatchers.<String>argThat(
|
||||||
|
message -> message.contains("Usage: /basesettings")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void autocompletesValuesForEachSetting() {
|
||||||
|
BaseSettingsCommand command = new BaseSettingsCommand(
|
||||||
|
mock(BaseStateManager.class),
|
||||||
|
new PluginSettingsProvider(PluginSettings.from(Map.of())),
|
||||||
|
mock(BaseFlightController.class)
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
List.of("allowed"),
|
||||||
|
command.onTabComplete(null, null, "basesettings", new String[] {"visitors", "a"})
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
List.of("upgrade"),
|
||||||
|
command.onTabComplete(null, null, "basesettings", new String[] {"pocket", "u"})
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
List.of("mobs"),
|
||||||
|
command.onTabComplete(null, null, "basesettings", new String[] {"pocket", "m"})
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
List.of("enable"),
|
||||||
|
command.onTabComplete(
|
||||||
|
null, null, "basesettings", new String[] {"pocket", "mobs", "e"}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
for (String setting : List.of("navigation", "flight", "border", "spawnable", "bossbar")) {
|
||||||
|
assertEquals(
|
||||||
|
List.of("disable"),
|
||||||
|
command.onTabComplete(null, null, "basesettings", new String[] {setting, "d"})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void autocompletesSettingNames() {
|
||||||
|
BaseSettingsCommand command = new BaseSettingsCommand(
|
||||||
|
mock(BaseStateManager.class),
|
||||||
|
new PluginSettingsProvider(PluginSettings.from(Map.of())),
|
||||||
|
mock(BaseFlightController.class)
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
List.of(
|
||||||
|
"status", "upgrade", "pocket", "visitors", "navigation", "flight", "border",
|
||||||
|
"spawnable", "bossbar"
|
||||||
|
),
|
||||||
|
command.onTabComplete(null, null, "basesettings", new String[] {""})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void upgradePurchasesBaseIv() throws Exception {
|
||||||
|
UUID playerId = UUID.randomUUID();
|
||||||
|
Player player = mock(Player.class);
|
||||||
|
when(player.getUniqueId()).thenReturn(playerId);
|
||||||
|
when(player.getName()).thenReturn("Host");
|
||||||
|
PlayerInventory inventory = mock(PlayerInventory.class);
|
||||||
|
when(player.getInventory()).thenReturn(inventory);
|
||||||
|
when(inventory.getStorageContents()).thenReturn(new ItemStack[] {
|
||||||
|
new ItemStack(Material.DIAMOND, 64),
|
||||||
|
new ItemStack(Material.DIAMOND, 64)
|
||||||
|
});
|
||||||
|
|
||||||
|
PlayerState current = PlayerState.newPlayer(playerId, "Host")
|
||||||
|
.withAdministrativeLevels(3, 0, 0, 0, 0, false, false, false)
|
||||||
|
.withBase(
|
||||||
|
new BaseLocation(UUID.randomUUID(), "world", 0, 64, 0, 0, 0),
|
||||||
|
Instant.EPOCH
|
||||||
|
);
|
||||||
|
AtomicReference<PlayerState> updated = new AtomicReference<>();
|
||||||
|
BaseStateManager stateManager = mock(BaseStateManager.class);
|
||||||
|
when(stateManager.player(playerId, "Host")).thenReturn(current);
|
||||||
|
when(stateManager.updateAndSave(any(), anyString(), any())).thenAnswer(invocation -> {
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
UnaryOperator<PlayerState> operation = invocation.getArgument(2);
|
||||||
|
PlayerState result = operation.apply(current);
|
||||||
|
updated.set(result);
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
BaseSettingsCommand command = new BaseSettingsCommand(
|
||||||
|
stateManager,
|
||||||
|
new PluginSettingsProvider(PluginSettings.from(Map.of())),
|
||||||
|
mock(BaseFlightController.class)
|
||||||
|
);
|
||||||
|
|
||||||
|
command.onCommand(player, null, "basesettings", new String[] {"upgrade"});
|
||||||
|
|
||||||
|
assertEquals(4, updated.get().baseLevel());
|
||||||
|
assertTrue(updated.get().visitorsEnabled());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void pocketUpgradePurchasesFirstLevelAfterBaseIv() throws Exception {
|
||||||
|
UUID playerId = UUID.randomUUID();
|
||||||
|
Player player = mock(Player.class);
|
||||||
|
when(player.getUniqueId()).thenReturn(playerId);
|
||||||
|
when(player.getName()).thenReturn("Builder");
|
||||||
|
PlayerInventory inventory = mock(PlayerInventory.class);
|
||||||
|
when(player.getInventory()).thenReturn(inventory);
|
||||||
|
when(inventory.getStorageContents()).thenReturn(new ItemStack[] {
|
||||||
|
new ItemStack(Material.DIAMOND_BLOCK, 64)
|
||||||
|
});
|
||||||
|
PlayerState owner = PlayerState.newPlayer(playerId, "Builder")
|
||||||
|
.withAdministrativeLevels(4, 0, 0, 0, 0, false, false, true)
|
||||||
|
.withBase(
|
||||||
|
new BaseLocation(UUID.randomUUID(), "world", 0, 64, 0, 0, 0),
|
||||||
|
Instant.EPOCH
|
||||||
|
);
|
||||||
|
BaseStateManager stateManager = mock(BaseStateManager.class);
|
||||||
|
when(stateManager.player(playerId, "Builder")).thenReturn(owner);
|
||||||
|
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
|
||||||
|
when(pocketBases.state(playerId)).thenReturn(PocketBaseState.locked(playerId));
|
||||||
|
when(pocketBases.upgrade(playerId)).thenReturn(
|
||||||
|
new PocketBaseState(playerId, 1, java.util.Optional.empty())
|
||||||
|
);
|
||||||
|
BaseSettingsCommand command = new BaseSettingsCommand(
|
||||||
|
stateManager,
|
||||||
|
new PluginSettingsProvider(PluginSettings.from(Map.of())),
|
||||||
|
mock(BaseFlightController.class),
|
||||||
|
pocketBases
|
||||||
|
);
|
||||||
|
|
||||||
|
command.onCommand(player, null, "basesettings", new String[] {"pocket", "upgrade"});
|
||||||
|
|
||||||
|
verify(pocketBases).upgrade(playerId);
|
||||||
|
verify(inventory).setStorageContents(any(ItemStack[].class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void pocketOwnerCanEnableNaturalMobSpawning() throws Exception {
|
||||||
|
UUID playerId = UUID.randomUUID();
|
||||||
|
Player player = mock(Player.class);
|
||||||
|
when(player.getUniqueId()).thenReturn(playerId);
|
||||||
|
when(player.getName()).thenReturn("Builder");
|
||||||
|
BaseStateManager stateManager = mock(BaseStateManager.class);
|
||||||
|
when(stateManager.player(playerId, "Builder"))
|
||||||
|
.thenReturn(PlayerState.newPlayer(playerId, "Builder"));
|
||||||
|
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
|
||||||
|
when(pocketBases.state(playerId)).thenReturn(
|
||||||
|
new PocketBaseState(playerId, 1, java.util.Optional.empty(), false)
|
||||||
|
);
|
||||||
|
when(pocketBases.setMobSpawning(playerId, true)).thenReturn(
|
||||||
|
new PocketBaseState(playerId, 1, java.util.Optional.empty(), true)
|
||||||
|
);
|
||||||
|
BaseSettingsCommand command = new BaseSettingsCommand(
|
||||||
|
stateManager,
|
||||||
|
new PluginSettingsProvider(PluginSettings.from(Map.of())),
|
||||||
|
mock(BaseFlightController.class),
|
||||||
|
pocketBases
|
||||||
|
);
|
||||||
|
|
||||||
|
command.onCommand(
|
||||||
|
player, null, "basesettings", new String[] {"pocket", "mobs", "enable"}
|
||||||
|
);
|
||||||
|
|
||||||
|
verify(pocketBases).setMobSpawning(playerId, true);
|
||||||
|
verify(player).sendMessage(org.mockito.ArgumentMatchers.<String>argThat(
|
||||||
|
message -> message.contains("mob spawning") && message.contains("enabled")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void failedPocketMobSpawningChangeReportsOnlyFailure() throws Exception {
|
||||||
|
UUID playerId = UUID.randomUUID();
|
||||||
|
Player player = mock(Player.class);
|
||||||
|
when(player.getUniqueId()).thenReturn(playerId);
|
||||||
|
when(player.getName()).thenReturn("Builder");
|
||||||
|
BaseStateManager stateManager = mock(BaseStateManager.class);
|
||||||
|
when(stateManager.player(playerId, "Builder"))
|
||||||
|
.thenReturn(PlayerState.newPlayer(playerId, "Builder"));
|
||||||
|
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
|
||||||
|
when(pocketBases.state(playerId)).thenReturn(
|
||||||
|
new PocketBaseState(playerId, 1, java.util.Optional.empty(), false)
|
||||||
|
);
|
||||||
|
when(pocketBases.setMobSpawning(playerId, true))
|
||||||
|
.thenThrow(new IOException("save failed"));
|
||||||
|
BaseSettingsCommand command = new BaseSettingsCommand(
|
||||||
|
stateManager,
|
||||||
|
new PluginSettingsProvider(PluginSettings.from(Map.of())),
|
||||||
|
mock(BaseFlightController.class),
|
||||||
|
pocketBases
|
||||||
|
);
|
||||||
|
|
||||||
|
command.onCommand(
|
||||||
|
player, null, "basesettings", new String[] {"pocket", "mobs", "enable"}
|
||||||
|
);
|
||||||
|
|
||||||
|
verify(player).sendMessage(org.mockito.ArgumentMatchers.<String>argThat(
|
||||||
|
message -> message.contains("could not be changed")
|
||||||
|
));
|
||||||
|
verify(player, never()).sendMessage(org.mockito.ArgumentMatchers.<String>argThat(
|
||||||
|
message -> message.contains("is now")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void lockedPlayerCannotEnablePocketMobSpawning() throws Exception {
|
||||||
|
UUID playerId = UUID.randomUUID();
|
||||||
|
Player player = mock(Player.class);
|
||||||
|
when(player.getUniqueId()).thenReturn(playerId);
|
||||||
|
when(player.getName()).thenReturn("Builder");
|
||||||
|
BaseStateManager stateManager = mock(BaseStateManager.class);
|
||||||
|
when(stateManager.player(playerId, "Builder"))
|
||||||
|
.thenReturn(PlayerState.newPlayer(playerId, "Builder"));
|
||||||
|
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
|
||||||
|
when(pocketBases.state(playerId)).thenReturn(PocketBaseState.locked(playerId));
|
||||||
|
BaseSettingsCommand command = new BaseSettingsCommand(
|
||||||
|
stateManager,
|
||||||
|
new PluginSettingsProvider(PluginSettings.from(Map.of())),
|
||||||
|
mock(BaseFlightController.class),
|
||||||
|
pocketBases
|
||||||
|
);
|
||||||
|
|
||||||
|
command.onCommand(
|
||||||
|
player, null, "basesettings", new String[] {"pocket", "mobs", "enable"}
|
||||||
|
);
|
||||||
|
|
||||||
|
verify(pocketBases, never()).setMobSpawning(playerId, true);
|
||||||
|
verify(player).sendMessage(org.mockito.ArgumentMatchers.<String>argThat(
|
||||||
|
message -> message.contains("Pocket Base I") && message.contains("locked")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void failedPocketUpgradeRestoresPayment() throws Exception {
|
||||||
|
UUID playerId = UUID.randomUUID();
|
||||||
|
Player player = mock(Player.class);
|
||||||
|
when(player.getUniqueId()).thenReturn(playerId);
|
||||||
|
when(player.getName()).thenReturn("Builder");
|
||||||
|
PlayerInventory inventory = mock(PlayerInventory.class);
|
||||||
|
when(player.getInventory()).thenReturn(inventory);
|
||||||
|
when(inventory.getStorageContents()).thenReturn(new ItemStack[] {
|
||||||
|
new ItemStack(Material.DIAMOND_BLOCK, 64)
|
||||||
|
});
|
||||||
|
PlayerState owner = PlayerState.newPlayer(playerId, "Builder")
|
||||||
|
.withAdministrativeLevels(4, 0, 0, 0, 0, false, false, true)
|
||||||
|
.withBase(
|
||||||
|
new BaseLocation(UUID.randomUUID(), "world", 0, 64, 0, 0, 0),
|
||||||
|
Instant.EPOCH
|
||||||
|
);
|
||||||
|
BaseStateManager stateManager = mock(BaseStateManager.class);
|
||||||
|
when(stateManager.player(playerId, "Builder")).thenReturn(owner);
|
||||||
|
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
|
||||||
|
when(pocketBases.state(playerId)).thenReturn(PocketBaseState.locked(playerId));
|
||||||
|
when(pocketBases.upgrade(playerId)).thenThrow(new IOException("save failed"));
|
||||||
|
BaseSettingsCommand command = new BaseSettingsCommand(
|
||||||
|
stateManager,
|
||||||
|
new PluginSettingsProvider(PluginSettings.from(Map.of())),
|
||||||
|
mock(BaseFlightController.class),
|
||||||
|
pocketBases
|
||||||
|
);
|
||||||
|
|
||||||
|
command.onCommand(player, null, "basesettings", new String[] {"pocket", "upgrade"});
|
||||||
|
|
||||||
|
ArgumentCaptor<ItemStack[]> contents = ArgumentCaptor.forClass(ItemStack[].class);
|
||||||
|
verify(inventory, times(2)).setStorageContents(contents.capture());
|
||||||
|
ItemStack[] restored = contents.getAllValues().get(1);
|
||||||
|
assertEquals(Material.DIAMOND_BLOCK, restored[0].getType());
|
||||||
|
assertEquals(64, restored[0].getAmount());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void statusDisplaysPocketMobSpawningPreference() {
|
||||||
|
UUID playerId = UUID.randomUUID();
|
||||||
|
Player player = mock(Player.class);
|
||||||
|
when(player.getUniqueId()).thenReturn(playerId);
|
||||||
|
when(player.getName()).thenReturn("Builder");
|
||||||
|
BaseStateManager stateManager = mock(BaseStateManager.class);
|
||||||
|
when(stateManager.player(playerId, "Builder"))
|
||||||
|
.thenReturn(PlayerState.newPlayer(playerId, "Builder"));
|
||||||
|
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
|
||||||
|
when(pocketBases.state(playerId)).thenReturn(
|
||||||
|
new PocketBaseState(playerId, 1, java.util.Optional.empty(), false)
|
||||||
|
);
|
||||||
|
BaseSettingsCommand command = new BaseSettingsCommand(
|
||||||
|
stateManager,
|
||||||
|
new PluginSettingsProvider(PluginSettings.from(Map.of())),
|
||||||
|
mock(BaseFlightController.class),
|
||||||
|
pocketBases
|
||||||
|
);
|
||||||
|
|
||||||
|
command.onCommand(player, null, "basesettings", new String[] {"status"});
|
||||||
|
|
||||||
|
verify(player).sendMessage(org.mockito.ArgumentMatchers.<String>argThat(
|
||||||
|
message -> message.contains("Pocket Base 1")
|
||||||
|
&& message.contains("mob spawning=disabled")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void bossbarEnableIsIdempotent() {
|
||||||
|
UUID playerId = UUID.randomUUID();
|
||||||
|
PlayerState current = PlayerState.newPlayer(playerId, "Miner");
|
||||||
|
|
||||||
|
CommandResult result = execute(current, "bossbar", "enable");
|
||||||
|
|
||||||
|
assertTrue(result.updated().bossBarEnabled());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void flightDisableIsIdempotentAndRemovesGrantedFlight() {
|
||||||
|
UUID playerId = UUID.randomUUID();
|
||||||
|
PlayerState current = PlayerState.newPlayer(playerId, "Pilot")
|
||||||
|
.withAdministrativeLevels(1, 0, 1, 0, 0, false, true, false);
|
||||||
|
|
||||||
|
CommandResult result = execute(current, "flight", "disable");
|
||||||
|
|
||||||
|
assertFalse(result.updated().flightEnabled());
|
||||||
|
verify(result.flightController()).removeGrantedFlight(any(Player.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void navigationDisableIsIdempotent() {
|
||||||
|
UUID playerId = UUID.randomUUID();
|
||||||
|
PlayerState current = PlayerState.newPlayer(playerId, "Explorer")
|
||||||
|
.withAdministrativeLevels(2, 0, 0, 0, 0, true, false, false)
|
||||||
|
.withBase(
|
||||||
|
new BaseLocation(UUID.randomUUID(), "world", 0, 64, 0, 0, 0),
|
||||||
|
Instant.EPOCH
|
||||||
|
);
|
||||||
|
|
||||||
|
CommandResult result = execute(current, "navigation", "disable");
|
||||||
|
|
||||||
|
assertFalse(result.updated().navigationEnabled());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void visitorsAllowedIsIdempotent() {
|
||||||
|
UUID playerId = UUID.randomUUID();
|
||||||
|
PlayerState current = PlayerState.newPlayer(playerId, "Host")
|
||||||
|
.withAdministrativeLevels(4, 0, 0, 0, 0, false, false, true);
|
||||||
|
|
||||||
|
CommandResult result = execute(current, "visitors", "allowed");
|
||||||
|
|
||||||
|
assertTrue(result.updated().visitorsEnabled());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void defaultAndStatusDisplayTheFullProgressReport() {
|
||||||
|
UUID playerId = UUID.randomUUID();
|
||||||
|
Player player = mock(Player.class);
|
||||||
|
when(player.getUniqueId()).thenReturn(playerId);
|
||||||
|
when(player.getName()).thenReturn("Builder");
|
||||||
|
|
||||||
|
BaseStateManager stateManager = mock(BaseStateManager.class);
|
||||||
|
when(stateManager.player(playerId, "Builder"))
|
||||||
|
.thenReturn(PlayerState.newPlayer(playerId, "Builder"));
|
||||||
|
BaseSettingsCommand command = new BaseSettingsCommand(
|
||||||
|
stateManager,
|
||||||
|
new PluginSettingsProvider(PluginSettings.from(Map.of())),
|
||||||
|
mock(BaseFlightController.class)
|
||||||
|
);
|
||||||
|
|
||||||
|
command.onCommand(player, null, "basesettings", new String[0]);
|
||||||
|
command.onCommand(player, null, "basesettings", new String[] {"status"});
|
||||||
|
|
||||||
|
verify(player, times(2)).sendMessage(ChatColor.GOLD + "=== Base Progress ===");
|
||||||
|
verify(player, times(2)).sendMessage(
|
||||||
|
org.mockito.ArgumentMatchers.<String>argThat(message ->
|
||||||
|
message.contains("Spawnable Overlay:")
|
||||||
|
&& message.contains("0/250 placements")
|
||||||
|
&& message.contains("locked")
|
||||||
|
)
|
||||||
|
);
|
||||||
|
verify(player, times(2)).sendMessage(
|
||||||
|
org.mockito.ArgumentMatchers.<String>argThat(message ->
|
||||||
|
message.contains("Settings: visitors=")
|
||||||
|
&& message.contains("navigation=")
|
||||||
|
&& message.contains("flight=")
|
||||||
|
&& message.contains("border=")
|
||||||
|
&& message.contains("spawnable=")
|
||||||
|
&& message.contains("bossbar=")
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CommandResult execute(PlayerState current, String... arguments) {
|
||||||
|
Player player = mock(Player.class);
|
||||||
|
when(player.getUniqueId()).thenReturn(current.playerId());
|
||||||
|
when(player.getName()).thenReturn(current.latestName());
|
||||||
|
|
||||||
|
AtomicReference<PlayerState> updated = new AtomicReference<>();
|
||||||
|
BaseStateManager stateManager = mock(BaseStateManager.class);
|
||||||
|
when(stateManager.player(current.playerId(), current.latestName())).thenReturn(current);
|
||||||
|
when(stateManager.update(any(), anyString(), any())).thenAnswer(invocation -> {
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
UnaryOperator<PlayerState> operation = invocation.getArgument(2);
|
||||||
|
PlayerState result = operation.apply(current);
|
||||||
|
updated.set(result);
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
BaseFlightController flightController = mock(BaseFlightController.class);
|
||||||
|
BaseSettingsCommand command = new BaseSettingsCommand(
|
||||||
|
stateManager,
|
||||||
|
new PluginSettingsProvider(PluginSettings.from(Map.of())),
|
||||||
|
flightController
|
||||||
|
);
|
||||||
|
|
||||||
|
command.onCommand(player, null, "basesettings", arguments);
|
||||||
|
return new CommandResult(updated.get(), flightController);
|
||||||
|
}
|
||||||
|
|
||||||
|
private record CommandResult(PlayerState updated, BaseFlightController flightController) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package games.dmg.spigotbase;
|
||||||
|
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.verifyNoInteractions;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.bukkit.ChatColor;
|
||||||
|
import org.bukkit.World;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
import org.bukkit.plugin.Plugin;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
final class BaseTeleportManagerTest {
|
||||||
|
@Test
|
||||||
|
void visitFromAnotherWorldIsRejectedBeforeWarmupAndCooldownChecks() {
|
||||||
|
UUID visitorId = UUID.randomUUID();
|
||||||
|
UUID ownerId = UUID.randomUUID();
|
||||||
|
UUID baseWorldId = UUID.randomUUID();
|
||||||
|
BaseStateManager stateManager = mock(BaseStateManager.class);
|
||||||
|
TeleportPolicy teleportPolicy = mock(TeleportPolicy.class);
|
||||||
|
VisitorPolicy visitorPolicy = mock(VisitorPolicy.class);
|
||||||
|
Player visitor = mock(Player.class);
|
||||||
|
World visitorWorld = mock(World.class);
|
||||||
|
PlayerState owner = PlayerState.newPlayer(ownerId, "Owner")
|
||||||
|
.withGrassAndDirtProgress(500, 4)
|
||||||
|
.withBase(
|
||||||
|
new BaseLocation(baseWorldId, "world", 0, 64, 0, 0, 0),
|
||||||
|
Instant.EPOCH
|
||||||
|
)
|
||||||
|
.withVisitorsEnabled(true);
|
||||||
|
PlayerState visitorState = PlayerState.newPlayer(visitorId, "Visitor");
|
||||||
|
BaseTeleportManager manager = new BaseTeleportManager(
|
||||||
|
mock(Plugin.class),
|
||||||
|
stateManager,
|
||||||
|
teleportPolicy,
|
||||||
|
visitorPolicy,
|
||||||
|
mock(SafeBaseDestination.class),
|
||||||
|
Clock.systemUTC()
|
||||||
|
);
|
||||||
|
|
||||||
|
when(visitor.getUniqueId()).thenReturn(visitorId);
|
||||||
|
when(visitor.getName()).thenReturn("Visitor");
|
||||||
|
when(visitor.getWorld()).thenReturn(visitorWorld);
|
||||||
|
when(visitorWorld.getUID()).thenReturn(UUID.randomUUID());
|
||||||
|
when(stateManager.player(visitorId, "Visitor")).thenReturn(visitorState);
|
||||||
|
when(visitorPolicy.remaining(eq(visitorState), eq(ownerId), any()))
|
||||||
|
.thenReturn(Optional.of(Duration.ofSeconds(1)));
|
||||||
|
|
||||||
|
manager.startVisit(visitor, owner);
|
||||||
|
|
||||||
|
verify(visitor).sendMessage(
|
||||||
|
ChatColor.RED + "You must be in the same dimension as that base to visit it."
|
||||||
|
);
|
||||||
|
verifyNoInteractions(stateManager, teleportPolicy, visitorPolicy);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,48 @@
|
|||||||
package games.dmg.spigotbase;
|
package games.dmg.spigotbase;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
final class PlayerStateTest {
|
final class PlayerStateTest {
|
||||||
|
@Test
|
||||||
|
void spawnableOverlayProgressAndPreferenceCanBeUpdated() {
|
||||||
|
PlayerState player = PlayerState.newPlayer(UUID.randomUUID(), "Alex")
|
||||||
|
.withAdministrativeLevels(1, 0, 0, 0, 0, false, false, false)
|
||||||
|
.withBase(
|
||||||
|
new BaseLocation(UUID.randomUUID(), "world", 0, 64, 0, 0, 0),
|
||||||
|
Instant.EPOCH
|
||||||
|
);
|
||||||
|
|
||||||
|
PlayerState updated = player
|
||||||
|
.withTotalBlocksPlaced(250)
|
||||||
|
.withSpawnableOverlayEnabled(true);
|
||||||
|
|
||||||
|
assertTrue(updated.spawnableOverlayEnabled());
|
||||||
|
assertTrue(updated.totalBlocksPlaced() == 250);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void borderVisualizationIsDisabledByDefaultAndCanBeEnabled() {
|
||||||
|
UUID playerId = UUID.randomUUID();
|
||||||
|
PlayerState player = PlayerState.newPlayer(playerId, "Alex");
|
||||||
|
PlayerState established = player
|
||||||
|
.withAdministrativeLevels(1, 0, 0, 0, 0, false, false, false)
|
||||||
|
.withBase(
|
||||||
|
new BaseLocation(UUID.randomUUID(), "world", 0, 64, 0, 0, 0),
|
||||||
|
Instant.EPOCH
|
||||||
|
);
|
||||||
|
|
||||||
|
assertFalse(player.borderEnabled());
|
||||||
|
assertTrue(established.withBorderEnabled(true).borderEnabled());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void rejectsLevelsOutsideKnownRanges() {
|
void rejectsLevelsOutsideKnownRanges() {
|
||||||
UUID playerId = UUID.randomUUID();
|
UUID playerId = UUID.randomUUID();
|
||||||
|
|||||||
@@ -1,14 +1,68 @@
|
|||||||
package games.dmg.spigotbase;
|
package games.dmg.spigotbase;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.yaml.snakeyaml.Yaml;
|
import org.yaml.snakeyaml.Yaml;
|
||||||
|
|
||||||
final class PluginMetadataTest {
|
final class PluginMetadataTest {
|
||||||
|
@Test
|
||||||
|
void homeadminAliasesBaseadmin() {
|
||||||
|
Map<?, ?> commands = commands();
|
||||||
|
|
||||||
|
Map<?, ?> admin = (Map<?, ?>) commands.get("baseadmin");
|
||||||
|
assertEquals(List.of("homeadmin"), admin.get("aliases"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void doesNotDeclareSupersededSettingsCommands() {
|
||||||
|
Map<?, ?> commands = commands();
|
||||||
|
|
||||||
|
for (String command : List.of(
|
||||||
|
"baseprogress", "basenavigation", "baseflight", "basevisitors"
|
||||||
|
)) {
|
||||||
|
assertFalse(commands.containsKey(command), command);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void homesettingsAliasesBasesettings() {
|
||||||
|
Map<?, ?> commands = commands();
|
||||||
|
|
||||||
|
Map<?, ?> settings = (Map<?, ?>) commands.get("basesettings");
|
||||||
|
assertNotNull(settings);
|
||||||
|
assertEquals(List.of("homesettings"), settings.get("aliases"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sethomeAliasesSetbase() {
|
||||||
|
Map<?, ?> commands = commands();
|
||||||
|
|
||||||
|
Map<?, ?> setbase = (Map<?, ?>) commands.get("setbase");
|
||||||
|
assertEquals(List.of("sethome"), setbase.get("aliases"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void homeAliasesBase() {
|
||||||
|
Map<?, ?> commands = commands();
|
||||||
|
|
||||||
|
Map<?, ?> base = (Map<?, ?>) commands.get("base");
|
||||||
|
assertEquals(List.of("home"), base.get("aliases"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void visitAliasesGotobase() {
|
||||||
|
Map<?, ?> commands = commands();
|
||||||
|
|
||||||
|
Map<?, ?> gotobase = (Map<?, ?>) commands.get("gotobase");
|
||||||
|
assertEquals(List.of("visit"), gotobase.get("aliases"));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void declaresPluginEntrypointCommandsAndPermissions() {
|
void declaresPluginEntrypointCommandsAndPermissions() {
|
||||||
InputStream stream = getClass().getClassLoader().getResourceAsStream("plugin.yml");
|
InputStream stream = getClass().getClassLoader().getResourceAsStream("plugin.yml");
|
||||||
@@ -21,8 +75,7 @@ final class PluginMetadataTest {
|
|||||||
|
|
||||||
Map<?, ?> commands = (Map<?, ?>) plugin.get("commands");
|
Map<?, ?> commands = (Map<?, ?>) plugin.get("commands");
|
||||||
for (String command : new String[] {
|
for (String command : new String[] {
|
||||||
"setbase", "base", "basenavigation", "baseflight",
|
"setbase", "base", "gotobase", "basesettings", "baseadmin"
|
||||||
"basevisitors", "gotobase", "baseprogress", "baseadmin"
|
|
||||||
}) {
|
}) {
|
||||||
assertNotNull(commands.get(command), command);
|
assertNotNull(commands.get(command), command);
|
||||||
}
|
}
|
||||||
@@ -30,4 +83,11 @@ final class PluginMetadataTest {
|
|||||||
Map<?, ?> permissions = (Map<?, ?>) plugin.get("permissions");
|
Map<?, ?> permissions = (Map<?, ?>) plugin.get("permissions");
|
||||||
assertNotNull(permissions.get("spigotbase.admin"));
|
assertNotNull(permissions.get("spigotbase.admin"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Map<?, ?> commands() {
|
||||||
|
InputStream stream = getClass().getClassLoader().getResourceAsStream("plugin.yml");
|
||||||
|
assertNotNull(stream);
|
||||||
|
Map<?, ?> plugin = new Yaml().load(stream);
|
||||||
|
return (Map<?, ?>) plugin.get("commands");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,8 +18,28 @@ final class PluginSettingsTest {
|
|||||||
assertEquals(86_400L, settings.relocationCooldownSeconds());
|
assertEquals(86_400L, settings.relocationCooldownSeconds());
|
||||||
assertEquals(5, settings.flightWarningBuffer());
|
assertEquals(5, settings.flightWarningBuffer());
|
||||||
assertEquals(200, settings.teleportUnlockPlacements());
|
assertEquals(200, settings.teleportUnlockPlacements());
|
||||||
|
assertEquals(250, settings.spawnableOverlayUnlockPlacements());
|
||||||
assertEquals(10_800L, settings.initialTeleportCooldownSeconds());
|
assertEquals(10_800L, settings.initialTeleportCooldownSeconds());
|
||||||
assertEquals(128, settings.visitorUnlockDiamondCost());
|
assertEquals(128, settings.visitorUnlockDiamondCost());
|
||||||
|
assertEquals(64, settings.pocketBaseUnlockCost());
|
||||||
|
assertEquals(16, settings.pocketBaseUpgradeCost());
|
||||||
|
assertEquals("DIAMOND_BLOCK", settings.pocketBaseCurrencyMaterial());
|
||||||
|
assertEquals("DIAMOND_BLOCK", settings.pocketBasePortalFrameMaterial());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void pocketBaseCostsAndMaterialsAreConfigurable() {
|
||||||
|
PluginSettings settings = PluginSettings.from(Map.of(
|
||||||
|
"pocket-base-unlock-cost", 32,
|
||||||
|
"pocket-base-upgrade-cost", 8,
|
||||||
|
"pocket-base-currency-material", "EMERALD_BLOCK",
|
||||||
|
"pocket-base-portal-frame-material", "GOLD_BLOCK"
|
||||||
|
));
|
||||||
|
|
||||||
|
assertEquals(32, settings.pocketBaseUnlockCost());
|
||||||
|
assertEquals(8, settings.pocketBaseUpgradeCost());
|
||||||
|
assertEquals("EMERALD_BLOCK", settings.pocketBaseCurrencyMaterial());
|
||||||
|
assertEquals("GOLD_BLOCK", settings.pocketBasePortalFrameMaterial());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -0,0 +1,245 @@
|
|||||||
|
package games.dmg.spigotbase;
|
||||||
|
|
||||||
|
import static org.mockito.ArgumentMatchers.anyInt;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.logging.Logger;
|
||||||
|
import org.bukkit.Material;
|
||||||
|
import org.bukkit.Server;
|
||||||
|
import org.bukkit.World;
|
||||||
|
import org.bukkit.block.Block;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
import org.bukkit.event.block.Action;
|
||||||
|
import org.bukkit.event.block.BlockPlaceEvent;
|
||||||
|
import org.bukkit.event.player.PlayerInteractEvent;
|
||||||
|
import org.bukkit.inventory.ItemStack;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
final class PocketBaseControllerTest {
|
||||||
|
@Test
|
||||||
|
void ownerCanActivateCompleteFrameInsideNormalBase() throws Exception {
|
||||||
|
UUID ownerId = UUID.randomUUID();
|
||||||
|
UUID worldId = UUID.randomUUID();
|
||||||
|
PocketPortalLocation portal = new PocketPortalLocation(
|
||||||
|
worldId, "world", -2, 64, 0, PocketPortalAxis.X
|
||||||
|
);
|
||||||
|
Set<BlockPosition> frame = new HashSet<>(PocketPortalGeometry.frameBlocks(portal));
|
||||||
|
Player owner = mock(Player.class);
|
||||||
|
World world = mock(World.class);
|
||||||
|
Block clicked = block(world, -2, 66, 0, Material.DIAMOND_BLOCK);
|
||||||
|
PlayerInteractEvent event = mock(PlayerInteractEvent.class);
|
||||||
|
BaseStateManager baseStates = mock(BaseStateManager.class);
|
||||||
|
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
|
||||||
|
PluginSettings settings = PluginSettings.from(Map.of());
|
||||||
|
PlayerState ownerState = PlayerState.newPlayer(ownerId, "Owner")
|
||||||
|
.withAdministrativeLevels(4, 0, 0, 0, 0, false, false, true)
|
||||||
|
.withBase(new BaseLocation(worldId, "world", 0, 64, 0, 0, 0), Instant.EPOCH);
|
||||||
|
|
||||||
|
when(owner.getUniqueId()).thenReturn(ownerId);
|
||||||
|
when(owner.getName()).thenReturn("Owner");
|
||||||
|
when(event.getPlayer()).thenReturn(owner);
|
||||||
|
when(event.getAction()).thenReturn(Action.RIGHT_CLICK_BLOCK);
|
||||||
|
when(event.getClickedBlock()).thenReturn(clicked);
|
||||||
|
when(event.getItem()).thenReturn(new ItemStack(Material.FLINT_AND_STEEL));
|
||||||
|
when(world.getUID()).thenReturn(worldId);
|
||||||
|
when(world.getName()).thenReturn("world");
|
||||||
|
when(world.getBlockAt(anyInt(), anyInt(), anyInt())).thenAnswer(invocation -> {
|
||||||
|
BlockPosition position = new BlockPosition(
|
||||||
|
invocation.getArgument(0), invocation.getArgument(1), invocation.getArgument(2)
|
||||||
|
);
|
||||||
|
return block(
|
||||||
|
world,
|
||||||
|
position.x(),
|
||||||
|
position.y(),
|
||||||
|
position.z(),
|
||||||
|
frame.contains(position) ? Material.DIAMOND_BLOCK : Material.AIR
|
||||||
|
);
|
||||||
|
});
|
||||||
|
when(baseStates.player(ownerId, "Owner")).thenReturn(ownerState);
|
||||||
|
when(pocketBases.state(ownerId)).thenReturn(
|
||||||
|
new PocketBaseState(ownerId, 1, Optional.empty())
|
||||||
|
);
|
||||||
|
when(pocketBases.activateEntrance(ownerId, portal)).thenReturn(
|
||||||
|
new PocketBaseState(ownerId, 1, Optional.of(portal))
|
||||||
|
);
|
||||||
|
PocketBaseController controller = new PocketBaseController(
|
||||||
|
mock(Server.class),
|
||||||
|
baseStates,
|
||||||
|
new BaseBoundsService(settings),
|
||||||
|
pocketBases,
|
||||||
|
new PluginSettingsProvider(settings),
|
||||||
|
Logger.getAnonymousLogger()
|
||||||
|
);
|
||||||
|
|
||||||
|
controller.onActivate(event);
|
||||||
|
|
||||||
|
verify(pocketBases).activateEntrance(ownerId, portal);
|
||||||
|
verify(event).setCancelled(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void ownerCanActivateCompleteReturnFrameInsideOwnPocketBase() throws Exception {
|
||||||
|
UUID ownerId = UUID.randomUUID();
|
||||||
|
UUID worldId = UUID.randomUUID();
|
||||||
|
PocketPortalLocation portal = new PocketPortalLocation(
|
||||||
|
worldId, "pocket", 8, 65, 8, PocketPortalAxis.X
|
||||||
|
);
|
||||||
|
Set<BlockPosition> frame = new HashSet<>(PocketPortalGeometry.frameBlocks(portal));
|
||||||
|
Player owner = mock(Player.class);
|
||||||
|
World world = mock(World.class);
|
||||||
|
Block clicked = block(world, 8, 67, 8, Material.DIAMOND_BLOCK);
|
||||||
|
PlayerInteractEvent event = mock(PlayerInteractEvent.class);
|
||||||
|
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
|
||||||
|
PluginSettings settings = PluginSettings.from(Map.of());
|
||||||
|
|
||||||
|
when(owner.getUniqueId()).thenReturn(ownerId);
|
||||||
|
when(event.getPlayer()).thenReturn(owner);
|
||||||
|
when(event.getAction()).thenReturn(Action.RIGHT_CLICK_BLOCK);
|
||||||
|
when(event.getClickedBlock()).thenReturn(clicked);
|
||||||
|
when(event.getItem()).thenReturn(new ItemStack(Material.FLINT_AND_STEEL));
|
||||||
|
when(world.getUID()).thenReturn(worldId);
|
||||||
|
when(world.getName()).thenReturn("pocket");
|
||||||
|
when(world.getBlockAt(anyInt(), anyInt(), anyInt())).thenAnswer(invocation -> {
|
||||||
|
BlockPosition position = new BlockPosition(
|
||||||
|
invocation.getArgument(0), invocation.getArgument(1), invocation.getArgument(2)
|
||||||
|
);
|
||||||
|
return block(
|
||||||
|
world,
|
||||||
|
position.x(),
|
||||||
|
position.y(),
|
||||||
|
position.z(),
|
||||||
|
frame.contains(position) ? Material.DIAMOND_BLOCK : Material.AIR
|
||||||
|
);
|
||||||
|
});
|
||||||
|
when(pocketBases.ownerForPocketWorld(worldId)).thenReturn(Optional.of(ownerId));
|
||||||
|
when(pocketBases.state(ownerId)).thenReturn(
|
||||||
|
new PocketBaseState(ownerId, 1, Optional.empty())
|
||||||
|
);
|
||||||
|
when(pocketBases.policy()).thenReturn(new PocketBasePolicy(settings));
|
||||||
|
PocketBaseController controller = new PocketBaseController(
|
||||||
|
mock(Server.class),
|
||||||
|
mock(BaseStateManager.class),
|
||||||
|
new BaseBoundsService(settings),
|
||||||
|
pocketBases,
|
||||||
|
new PluginSettingsProvider(settings),
|
||||||
|
Logger.getAnonymousLogger()
|
||||||
|
);
|
||||||
|
|
||||||
|
controller.onActivate(event);
|
||||||
|
|
||||||
|
verify(pocketBases).activateReturnPortal(ownerId, portal);
|
||||||
|
verify(event).setCancelled(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void returnFrameOutsideUnlockedPocketBoundaryIsRejected() throws Exception {
|
||||||
|
UUID ownerId = UUID.randomUUID();
|
||||||
|
UUID worldId = UUID.randomUUID();
|
||||||
|
PocketPortalLocation portal = new PocketPortalLocation(
|
||||||
|
worldId, "pocket", 30, 65, 8, PocketPortalAxis.X
|
||||||
|
);
|
||||||
|
Set<BlockPosition> frame = new HashSet<>(PocketPortalGeometry.frameBlocks(portal));
|
||||||
|
Player owner = mock(Player.class);
|
||||||
|
World world = mock(World.class);
|
||||||
|
Block clicked = block(world, 30, 67, 8, Material.DIAMOND_BLOCK);
|
||||||
|
PlayerInteractEvent event = mock(PlayerInteractEvent.class);
|
||||||
|
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
|
||||||
|
PluginSettings settings = PluginSettings.from(Map.of());
|
||||||
|
|
||||||
|
when(owner.getUniqueId()).thenReturn(ownerId);
|
||||||
|
when(event.getPlayer()).thenReturn(owner);
|
||||||
|
when(event.getAction()).thenReturn(Action.RIGHT_CLICK_BLOCK);
|
||||||
|
when(event.getClickedBlock()).thenReturn(clicked);
|
||||||
|
when(event.getItem()).thenReturn(new ItemStack(Material.FLINT_AND_STEEL));
|
||||||
|
when(world.getUID()).thenReturn(worldId);
|
||||||
|
when(world.getName()).thenReturn("pocket");
|
||||||
|
when(world.getBlockAt(anyInt(), anyInt(), anyInt())).thenAnswer(invocation -> {
|
||||||
|
BlockPosition position = new BlockPosition(
|
||||||
|
invocation.getArgument(0), invocation.getArgument(1), invocation.getArgument(2)
|
||||||
|
);
|
||||||
|
return block(
|
||||||
|
world,
|
||||||
|
position.x(),
|
||||||
|
position.y(),
|
||||||
|
position.z(),
|
||||||
|
frame.contains(position) ? Material.DIAMOND_BLOCK : Material.AIR
|
||||||
|
);
|
||||||
|
});
|
||||||
|
when(pocketBases.ownerForPocketWorld(worldId)).thenReturn(Optional.of(ownerId));
|
||||||
|
when(pocketBases.state(ownerId)).thenReturn(
|
||||||
|
new PocketBaseState(ownerId, 1, Optional.empty())
|
||||||
|
);
|
||||||
|
when(pocketBases.policy()).thenReturn(new PocketBasePolicy(settings));
|
||||||
|
PocketBaseController controller = new PocketBaseController(
|
||||||
|
mock(Server.class),
|
||||||
|
mock(BaseStateManager.class),
|
||||||
|
new BaseBoundsService(settings),
|
||||||
|
pocketBases,
|
||||||
|
new PluginSettingsProvider(settings),
|
||||||
|
Logger.getAnonymousLogger()
|
||||||
|
);
|
||||||
|
|
||||||
|
controller.onActivate(event);
|
||||||
|
|
||||||
|
verify(pocketBases, never()).activateReturnPortal(ownerId, portal);
|
||||||
|
verify(event).setCancelled(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void placementOutsideUnlockedPocketBoundaryIsCancelled() {
|
||||||
|
UUID ownerId = UUID.randomUUID();
|
||||||
|
UUID worldId = UUID.randomUUID();
|
||||||
|
World world = mock(World.class);
|
||||||
|
Block placed = block(world, 32, 65, 0, Material.STONE);
|
||||||
|
Player player = mock(Player.class);
|
||||||
|
BlockPlaceEvent event = mock(BlockPlaceEvent.class);
|
||||||
|
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
|
||||||
|
PluginSettings settings = PluginSettings.from(Map.of());
|
||||||
|
|
||||||
|
when(world.getUID()).thenReturn(worldId);
|
||||||
|
when(event.getBlockPlaced()).thenReturn(placed);
|
||||||
|
when(event.getPlayer()).thenReturn(player);
|
||||||
|
when(pocketBases.ownerForPocketWorld(worldId)).thenReturn(Optional.of(ownerId));
|
||||||
|
when(pocketBases.state(ownerId)).thenReturn(
|
||||||
|
new PocketBaseState(ownerId, 1, Optional.empty())
|
||||||
|
);
|
||||||
|
when(pocketBases.policy()).thenReturn(new PocketBasePolicy(settings));
|
||||||
|
PocketBaseController controller = new PocketBaseController(
|
||||||
|
mock(Server.class),
|
||||||
|
mock(BaseStateManager.class),
|
||||||
|
new BaseBoundsService(settings),
|
||||||
|
pocketBases,
|
||||||
|
new PluginSettingsProvider(settings),
|
||||||
|
Logger.getAnonymousLogger()
|
||||||
|
);
|
||||||
|
|
||||||
|
controller.onPlace(event);
|
||||||
|
|
||||||
|
verify(event).setCancelled(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Block block(
|
||||||
|
World world,
|
||||||
|
int x,
|
||||||
|
int y,
|
||||||
|
int z,
|
||||||
|
Material material
|
||||||
|
) {
|
||||||
|
Block block = mock(Block.class);
|
||||||
|
when(block.getWorld()).thenReturn(world);
|
||||||
|
when(block.getX()).thenReturn(x);
|
||||||
|
when(block.getY()).thenReturn(y);
|
||||||
|
when(block.getZ()).thenReturn(z);
|
||||||
|
when(block.getType()).thenReturn(material);
|
||||||
|
return block;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package games.dmg.spigotbase;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.mockito.Mockito.inOrder;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.function.UnaryOperator;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.mockito.InOrder;
|
||||||
|
|
||||||
|
final class PocketBaseManagerTest {
|
||||||
|
@Test
|
||||||
|
void relocatedReturnPortalOverridesGeneratedDefault() {
|
||||||
|
UUID ownerId = UUID.randomUUID();
|
||||||
|
PocketPortalLocation relocated = new PocketPortalLocation(
|
||||||
|
UUID.randomUUID(), "pocket", 20, 65, 12, PocketPortalAxis.Z
|
||||||
|
);
|
||||||
|
PocketBaseStateManager states = mock(PocketBaseStateManager.class);
|
||||||
|
PocketBaseWorldService worlds = mock(PocketBaseWorldService.class);
|
||||||
|
when(states.state(ownerId)).thenReturn(new PocketBaseState(
|
||||||
|
ownerId, 1, Optional.empty(), Optional.of(relocated), false
|
||||||
|
));
|
||||||
|
PocketBaseManager manager = new PocketBaseManager(
|
||||||
|
states,
|
||||||
|
worlds,
|
||||||
|
new PluginSettingsProvider(PluginSettings.from(Map.of()))
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(relocated, manager.returnPortal(ownerId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void existingStateWithoutRelocationUsesGeneratedReturnPortal() {
|
||||||
|
UUID ownerId = UUID.randomUUID();
|
||||||
|
PocketPortalLocation generated = new PocketPortalLocation(
|
||||||
|
UUID.randomUUID(), "pocket", -2, 65, 0, PocketPortalAxis.X
|
||||||
|
);
|
||||||
|
PocketBaseStateManager states = mock(PocketBaseStateManager.class);
|
||||||
|
PocketBaseWorldService worlds = mock(PocketBaseWorldService.class);
|
||||||
|
when(states.state(ownerId)).thenReturn(
|
||||||
|
new PocketBaseState(ownerId, 1, Optional.empty())
|
||||||
|
);
|
||||||
|
when(worlds.returnPortal(ownerId)).thenReturn(generated);
|
||||||
|
PocketBaseManager manager = new PocketBaseManager(
|
||||||
|
states,
|
||||||
|
worlds,
|
||||||
|
new PluginSettingsProvider(PluginSettings.from(Map.of()))
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(generated, manager.returnPortal(ownerId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void persistsAndAppliesMobSpawningPreference() throws Exception {
|
||||||
|
UUID ownerId = UUID.randomUUID();
|
||||||
|
PocketBaseState current = new PocketBaseState(ownerId, 1, Optional.empty(), false);
|
||||||
|
PocketBaseStateManager states = mock(PocketBaseStateManager.class);
|
||||||
|
PocketBaseWorldService worlds = mock(PocketBaseWorldService.class);
|
||||||
|
when(states.state(ownerId)).thenReturn(current);
|
||||||
|
when(states.updateAndSave(org.mockito.ArgumentMatchers.eq(ownerId),
|
||||||
|
org.mockito.ArgumentMatchers.any())).thenAnswer(invocation -> {
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
UnaryOperator<PocketBaseState> operation = invocation.getArgument(1);
|
||||||
|
return operation.apply(current);
|
||||||
|
});
|
||||||
|
PocketBaseManager manager = new PocketBaseManager(
|
||||||
|
states,
|
||||||
|
worlds,
|
||||||
|
new PluginSettingsProvider(PluginSettings.from(Map.of()))
|
||||||
|
);
|
||||||
|
|
||||||
|
manager.setMobSpawning(ownerId, true);
|
||||||
|
|
||||||
|
InOrder order = inOrder(worlds, states);
|
||||||
|
order.verify(worlds).setMobSpawning(ownerId, true);
|
||||||
|
order.verify(states).updateAndSave(
|
||||||
|
org.mockito.ArgumentMatchers.eq(ownerId), org.mockito.ArgumentMatchers.any()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void restoresWorldSettingWhenPersistenceFails() throws Exception {
|
||||||
|
UUID ownerId = UUID.randomUUID();
|
||||||
|
PocketBaseState current = new PocketBaseState(ownerId, 1, Optional.empty(), false);
|
||||||
|
PocketBaseStateManager states = mock(PocketBaseStateManager.class);
|
||||||
|
PocketBaseWorldService worlds = mock(PocketBaseWorldService.class);
|
||||||
|
when(states.state(ownerId)).thenReturn(current);
|
||||||
|
when(states.updateAndSave(org.mockito.ArgumentMatchers.eq(ownerId),
|
||||||
|
org.mockito.ArgumentMatchers.any())).thenThrow(new IOException("save failed"));
|
||||||
|
PocketBaseManager manager = new PocketBaseManager(
|
||||||
|
states,
|
||||||
|
worlds,
|
||||||
|
new PluginSettingsProvider(PluginSettings.from(Map.of()))
|
||||||
|
);
|
||||||
|
|
||||||
|
assertThrows(IOException.class, () -> manager.setMobSpawning(ownerId, true));
|
||||||
|
|
||||||
|
verify(worlds).setMobSpawning(ownerId, true);
|
||||||
|
verify(worlds).setMobSpawning(ownerId, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package games.dmg.spigotbase;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
final class PocketBasePolicyTest {
|
||||||
|
private final PocketBasePolicy policy = new PocketBasePolicy(PluginSettings.from(Map.of()));
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void levelsExpandBySixteenBlocksOnEverySide() {
|
||||||
|
assertEquals(64, policy.size(1));
|
||||||
|
assertEquals(96, policy.size(2));
|
||||||
|
assertEquals(128, policy.size(3));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unlockAndLaterLevelsUseTheirConfiguredPrices() {
|
||||||
|
assertEquals(64, policy.price(PocketBaseState.locked(UUID.randomUUID())));
|
||||||
|
assertEquals(16, policy.price(new PocketBaseState(UUID.randomUUID(), 1, java.util.Optional.empty())));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void buildBoundaryMatchesUnlockedPlatform() {
|
||||||
|
assertTrue(policy.contains(1, -32, -32));
|
||||||
|
assertTrue(policy.contains(1, 31, 31));
|
||||||
|
assertFalse(policy.contains(1, -33, 0));
|
||||||
|
assertFalse(policy.contains(1, 32, 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
package games.dmg.spigotbase;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.bukkit.Material;
|
||||||
|
import org.bukkit.Server;
|
||||||
|
import org.bukkit.World;
|
||||||
|
import org.bukkit.WorldCreator;
|
||||||
|
import org.bukkit.block.Block;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
final class PocketBaseWorldServiceTest {
|
||||||
|
@Test
|
||||||
|
void firstLevelGeneratesGrassPlatformAndReturnPortal() {
|
||||||
|
UUID ownerId = UUID.randomUUID();
|
||||||
|
UUID worldId = UUID.randomUUID();
|
||||||
|
Server server = mock(Server.class);
|
||||||
|
World world = mock(World.class);
|
||||||
|
Map<BlockPosition, Block> blocks = blocks(world);
|
||||||
|
when(server.createWorld(any(WorldCreator.class))).thenReturn(world);
|
||||||
|
when(world.getUID()).thenReturn(worldId);
|
||||||
|
when(world.getName()).thenReturn("pocket");
|
||||||
|
PocketBaseWorldService service = new PocketBaseWorldService(
|
||||||
|
server,
|
||||||
|
new PluginSettingsProvider(PluginSettings.from(Map.of()))
|
||||||
|
);
|
||||||
|
|
||||||
|
service.expand(ownerId, 0, 1);
|
||||||
|
|
||||||
|
verify(blocks.get(new BlockPosition(-32, 64, -32)))
|
||||||
|
.setType(Material.GRASS_BLOCK, false);
|
||||||
|
verify(blocks.get(new BlockPosition(31, 61, 31))).setType(Material.DIRT, false);
|
||||||
|
verify(blocks.get(new BlockPosition(-2, 65, 0)))
|
||||||
|
.setType(Material.DIAMOND_BLOCK, false);
|
||||||
|
verify(world).setSpawnLocation(0, 65, 5);
|
||||||
|
verify(world).setSpawnFlags(false, false);
|
||||||
|
assertEquals(ownerId, service.ownerForWorld(worldId).orElseThrow());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void checksRelocatedReturnPortalFrameAtItsSavedLocation() {
|
||||||
|
UUID ownerId = UUID.randomUUID();
|
||||||
|
UUID worldId = UUID.randomUUID();
|
||||||
|
Server server = mock(Server.class);
|
||||||
|
World world = mock(World.class);
|
||||||
|
Map<BlockPosition, Block> blocks = blocks(world);
|
||||||
|
when(server.getWorld(org.mockito.ArgumentMatchers.anyString())).thenReturn(world);
|
||||||
|
when(world.getUID()).thenReturn(worldId);
|
||||||
|
PocketPortalLocation portal = new PocketPortalLocation(
|
||||||
|
worldId, "pocket", 20, 65, 12, PocketPortalAxis.Z
|
||||||
|
);
|
||||||
|
for (BlockPosition position : PocketPortalGeometry.frameBlocks(portal)) {
|
||||||
|
when(world.getBlockAt(position.x(), position.y(), position.z()).getType())
|
||||||
|
.thenReturn(Material.DIAMOND_BLOCK);
|
||||||
|
}
|
||||||
|
PocketBaseWorldService service = new PocketBaseWorldService(
|
||||||
|
server,
|
||||||
|
new PluginSettingsProvider(PluginSettings.from(Map.of()))
|
||||||
|
);
|
||||||
|
|
||||||
|
assertTrue(service.returnPortalIsIntact(ownerId, portal));
|
||||||
|
|
||||||
|
when(world.getBlockAt(20, 65, 12).getType()).thenReturn(Material.AIR);
|
||||||
|
assertFalse(service.returnPortalIsIntact(ownerId, portal));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void reappliesSavedMobSpawningPreferenceWhenWorldLoads() {
|
||||||
|
UUID ownerId = UUID.randomUUID();
|
||||||
|
Server server = mock(Server.class);
|
||||||
|
World world = mock(World.class);
|
||||||
|
when(server.getWorld(org.mockito.ArgumentMatchers.anyString())).thenReturn(world);
|
||||||
|
when(world.getUID()).thenReturn(UUID.randomUUID());
|
||||||
|
PocketBaseWorldService service = new PocketBaseWorldService(
|
||||||
|
server,
|
||||||
|
new PluginSettingsProvider(PluginSettings.from(Map.of()))
|
||||||
|
);
|
||||||
|
|
||||||
|
service.loadExisting(Map.of(
|
||||||
|
ownerId,
|
||||||
|
new PocketBaseState(ownerId, 1, Optional.empty(), true)
|
||||||
|
));
|
||||||
|
|
||||||
|
verify(world).setSpawnFlags(true, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void laterLevelAddsRingWithoutOverwritingExistingTerrain() {
|
||||||
|
UUID ownerId = UUID.randomUUID();
|
||||||
|
Server server = mock(Server.class);
|
||||||
|
World world = mock(World.class);
|
||||||
|
Map<BlockPosition, Block> blocks = blocks(world);
|
||||||
|
when(server.createWorld(any(WorldCreator.class))).thenReturn(world);
|
||||||
|
when(world.getUID()).thenReturn(UUID.randomUUID());
|
||||||
|
when(world.getName()).thenReturn("pocket");
|
||||||
|
PocketBaseWorldService service = new PocketBaseWorldService(
|
||||||
|
server,
|
||||||
|
new PluginSettingsProvider(PluginSettings.from(Map.of()))
|
||||||
|
);
|
||||||
|
Block existingCenter = world.getBlockAt(0, 64, 0);
|
||||||
|
|
||||||
|
service.expand(ownerId, 1, 2);
|
||||||
|
|
||||||
|
verify(blocks.get(new BlockPosition(47, 64, 47)))
|
||||||
|
.setType(Material.GRASS_BLOCK, false);
|
||||||
|
verify(existingCenter, never())
|
||||||
|
.setType(any(Material.class), org.mockito.ArgumentMatchers.anyBoolean());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Map<BlockPosition, Block> blocks(World world) {
|
||||||
|
Map<BlockPosition, Block> blocks = new HashMap<>();
|
||||||
|
when(world.getBlockAt(
|
||||||
|
org.mockito.ArgumentMatchers.anyInt(),
|
||||||
|
org.mockito.ArgumentMatchers.anyInt(),
|
||||||
|
org.mockito.ArgumentMatchers.anyInt()
|
||||||
|
)).thenAnswer(invocation -> {
|
||||||
|
BlockPosition position = new BlockPosition(
|
||||||
|
invocation.getArgument(0), invocation.getArgument(1), invocation.getArgument(2)
|
||||||
|
);
|
||||||
|
return blocks.computeIfAbsent(position, ignored -> mock(Block.class));
|
||||||
|
});
|
||||||
|
return blocks;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package games.dmg.spigotbase;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
final class PocketPortalGeometryTest {
|
||||||
|
@Test
|
||||||
|
void findsStandardFrameContainingIgnitedBlock() {
|
||||||
|
UUID worldId = UUID.randomUUID();
|
||||||
|
PocketPortalLocation expected = new PocketPortalLocation(
|
||||||
|
worldId, "world", 10, 64, 20, PocketPortalAxis.X
|
||||||
|
);
|
||||||
|
Set<BlockPosition> frame = new HashSet<>(PocketPortalGeometry.frameBlocks(expected));
|
||||||
|
|
||||||
|
Optional<PocketPortalLocation> found = PocketPortalGeometry.findFrame(
|
||||||
|
new BlockPosition(10, 66, 20),
|
||||||
|
worldId,
|
||||||
|
"world",
|
||||||
|
frame::contains,
|
||||||
|
ignored -> true
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(expected, found.orElseThrow());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void identifiesPortalInteriorAndRequiredFrameBlocks() {
|
||||||
|
PocketPortalLocation portal = new PocketPortalLocation(
|
||||||
|
UUID.randomUUID(), "world", 0, 64, 0, PocketPortalAxis.Z
|
||||||
|
);
|
||||||
|
|
||||||
|
assertTrue(PocketPortalGeometry.isInterior(portal, new BlockPosition(0, 65, 1)));
|
||||||
|
assertTrue(PocketPortalGeometry.isFrame(portal, new BlockPosition(0, 68, 3)));
|
||||||
|
assertEquals(14, PocketPortalGeometry.frameBlocks(portal).size());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package games.dmg.spigotbase;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
import org.bukkit.block.Block;
|
||||||
|
import org.bukkit.block.BlockFace;
|
||||||
|
import org.bukkit.block.data.type.Slab;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
final class SpawnableBlockPolicyTest {
|
||||||
|
@Test
|
||||||
|
void acceptsDarkSolidSurfaceWithTwoPassableBlocksAbove() {
|
||||||
|
Block surface = mock(Block.class);
|
||||||
|
Block above = mock(Block.class);
|
||||||
|
Block secondAbove = mock(Block.class);
|
||||||
|
Slab slab = mock(Slab.class);
|
||||||
|
when(slab.getType()).thenReturn(Slab.Type.TOP);
|
||||||
|
when(surface.getBlockData()).thenReturn(slab);
|
||||||
|
when(surface.getRelative(BlockFace.UP)).thenReturn(above);
|
||||||
|
when(above.getRelative(BlockFace.UP)).thenReturn(secondAbove);
|
||||||
|
when(above.isPassable()).thenReturn(true);
|
||||||
|
when(secondAbove.isPassable()).thenReturn(true);
|
||||||
|
when(above.getLightFromBlocks()).thenReturn((byte) 0);
|
||||||
|
when(above.getLightFromSky()).thenReturn((byte) 0);
|
||||||
|
|
||||||
|
assertTrue(SpawnableBlockPolicy.isCandidate(surface));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsBlockLitSurface() {
|
||||||
|
Block surface = mock(Block.class);
|
||||||
|
Block above = mock(Block.class);
|
||||||
|
Block secondAbove = mock(Block.class);
|
||||||
|
Slab slab = mock(Slab.class);
|
||||||
|
when(slab.getType()).thenReturn(Slab.Type.TOP);
|
||||||
|
when(surface.getBlockData()).thenReturn(slab);
|
||||||
|
when(surface.getRelative(BlockFace.UP)).thenReturn(above);
|
||||||
|
when(above.getRelative(BlockFace.UP)).thenReturn(secondAbove);
|
||||||
|
when(above.isPassable()).thenReturn(true);
|
||||||
|
when(secondAbove.isPassable()).thenReturn(true);
|
||||||
|
when(above.getLightFromBlocks()).thenReturn((byte) 1);
|
||||||
|
|
||||||
|
assertFalse(SpawnableBlockPolicy.isCandidate(surface));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package games.dmg.spigotbase;
|
||||||
|
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyInt;
|
||||||
|
import static org.mockito.Mockito.atLeastOnce;
|
||||||
|
import static org.mockito.Mockito.doReturn;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.bukkit.Location;
|
||||||
|
import org.bukkit.Particle;
|
||||||
|
import org.bukkit.Server;
|
||||||
|
import org.bukkit.World;
|
||||||
|
import org.bukkit.block.Block;
|
||||||
|
import org.bukkit.block.BlockFace;
|
||||||
|
import org.bukkit.block.data.type.Slab;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
final class SpawnableOverlayControllerTest {
|
||||||
|
@Test
|
||||||
|
void ownerReceivesBoundedRedParticlesForNearbyCandidates() {
|
||||||
|
UUID playerId = UUID.randomUUID();
|
||||||
|
UUID worldId = UUID.randomUUID();
|
||||||
|
PlayerState state = PlayerState.newPlayer(playerId, "Builder")
|
||||||
|
.withAdministrativeLevels(1, 0, 0, 0, 0, false, false, false)
|
||||||
|
.withBase(new BaseLocation(worldId, "world", 0, 64, 0, 0, 0), Instant.EPOCH)
|
||||||
|
.withTotalBlocksPlaced(250)
|
||||||
|
.withSpawnableOverlayEnabled(true);
|
||||||
|
|
||||||
|
Block surface = mock(Block.class);
|
||||||
|
Block above = mock(Block.class);
|
||||||
|
Block secondAbove = mock(Block.class);
|
||||||
|
Slab slab = mock(Slab.class);
|
||||||
|
when(slab.getType()).thenReturn(Slab.Type.TOP);
|
||||||
|
when(surface.getBlockData()).thenReturn(slab);
|
||||||
|
when(surface.getRelative(BlockFace.UP)).thenReturn(above);
|
||||||
|
when(above.getRelative(BlockFace.UP)).thenReturn(secondAbove);
|
||||||
|
when(above.isPassable()).thenReturn(true);
|
||||||
|
when(secondAbove.isPassable()).thenReturn(true);
|
||||||
|
when(above.getLightFromBlocks()).thenReturn((byte) 0);
|
||||||
|
when(above.getLightFromSky()).thenReturn((byte) 0);
|
||||||
|
|
||||||
|
World world = mock(World.class);
|
||||||
|
when(world.getUID()).thenReturn(worldId);
|
||||||
|
when(world.getMinHeight()).thenReturn(-64);
|
||||||
|
when(world.getMaxHeight()).thenReturn(320);
|
||||||
|
when(world.getBlockAt(anyInt(), anyInt(), anyInt()))
|
||||||
|
.thenReturn(surface);
|
||||||
|
Player player = mock(Player.class);
|
||||||
|
when(player.getUniqueId()).thenReturn(playerId);
|
||||||
|
when(player.getName()).thenReturn("Builder");
|
||||||
|
when(player.getWorld()).thenReturn(world);
|
||||||
|
when(player.getLocation()).thenReturn(new Location(world, 0.5, 65.0, 0.5));
|
||||||
|
Server server = mock(Server.class);
|
||||||
|
doReturn(java.util.List.of(player)).when(server).getOnlinePlayers();
|
||||||
|
BaseStateManager stateManager = mock(BaseStateManager.class);
|
||||||
|
when(stateManager.player(playerId, "Builder")).thenReturn(state);
|
||||||
|
PluginSettingsProvider settings = new PluginSettingsProvider(
|
||||||
|
PluginSettings.from(Map.of())
|
||||||
|
);
|
||||||
|
|
||||||
|
SpawnableOverlayController controller = new SpawnableOverlayController(
|
||||||
|
server,
|
||||||
|
stateManager,
|
||||||
|
new BaseBoundsService(settings),
|
||||||
|
settings
|
||||||
|
);
|
||||||
|
controller.run();
|
||||||
|
controller.run();
|
||||||
|
controller.run();
|
||||||
|
|
||||||
|
verify(player, atLeastOnce()).spawnParticle(
|
||||||
|
eq(Particle.DUST),
|
||||||
|
any(Location.class),
|
||||||
|
eq(1),
|
||||||
|
any(Particle.DustOptions.class)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package games.dmg.spigotbase;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
final class SpawnableOverlayProgressionServiceTest {
|
||||||
|
@Test
|
||||||
|
void twoHundredFiftiethPlacementUnlocksOverlay() {
|
||||||
|
PlayerState player = PlayerState.newPlayer(UUID.randomUUID(), "Builder")
|
||||||
|
.withTotalBlocksPlaced(249);
|
||||||
|
SpawnableOverlayProgressionService service = new SpawnableOverlayProgressionService(
|
||||||
|
new PluginSettingsProvider(PluginSettings.from(Map.of()))
|
||||||
|
);
|
||||||
|
|
||||||
|
SpawnableOverlayProgressionUpdate update = service.recordPlacement(player);
|
||||||
|
|
||||||
|
assertEquals(250, update.player().totalBlocksPlaced());
|
||||||
|
assertTrue(update.unlocked());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,17 @@ final class TeleportPolicyTest {
|
|||||||
assertEquals(Duration.ZERO, policy.warmup(player.withTeleportLevels(3, 0)));
|
assertEquals(Duration.ZERO, policy.warmup(player.withTeleportLevels(3, 0)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void visitorWarmupHasOneSecondMinimum() {
|
||||||
|
PlayerState player = baseThreePlayer();
|
||||||
|
|
||||||
|
assertEquals(Duration.ofSeconds(30), policy.visitorWarmup(player));
|
||||||
|
assertEquals(
|
||||||
|
Duration.ofSeconds(1),
|
||||||
|
policy.visitorWarmup(player.withTeleportLevels(3, 0))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void cooldownLevelsUseApprovedDurations() {
|
void cooldownLevelsUseApprovedDurations() {
|
||||||
PlayerState player = baseThreePlayer();
|
PlayerState player = baseThreePlayer();
|
||||||
|
|||||||
@@ -28,7 +28,10 @@ final class YamlBaseStateRepositoryTest {
|
|||||||
true, true, false, true,
|
true, true, false, true,
|
||||||
Optional.of(Instant.ofEpochMilli(1_750_000_000_000L)),
|
Optional.of(Instant.ofEpochMilli(1_750_000_000_000L)),
|
||||||
Optional.of(Instant.ofEpochMilli(1_750_000_100_000L)),
|
Optional.of(Instant.ofEpochMilli(1_750_000_100_000L)),
|
||||||
Map.of(ownerId, Instant.ofEpochMilli(1_750_001_000_000L))
|
Map.of(ownerId, Instant.ofEpochMilli(1_750_001_000_000L)),
|
||||||
|
true,
|
||||||
|
2_500,
|
||||||
|
true
|
||||||
);
|
);
|
||||||
PersistentState expected = new PersistentState(Map.of(playerId, player));
|
PersistentState expected = new PersistentState(Map.of(playerId, player));
|
||||||
YamlBaseStateRepository repository =
|
YamlBaseStateRepository repository =
|
||||||
@@ -39,6 +42,44 @@ final class YamlBaseStateRepositoryTest {
|
|||||||
assertEquals(expected, repository.load());
|
assertEquals(expected, repository.load());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void olderStateUsesKnownInBasePlacementsAsTotalPlacementMinimum() throws Exception {
|
||||||
|
UUID playerId = UUID.randomUUID();
|
||||||
|
Path stateFile = temporaryDirectory.resolve("state.yml");
|
||||||
|
Files.writeString(stateFile, """
|
||||||
|
players:
|
||||||
|
%s:
|
||||||
|
name: Alex
|
||||||
|
blocks-placed-in-base: 225
|
||||||
|
""".formatted(playerId));
|
||||||
|
|
||||||
|
PlayerState player = new YamlBaseStateRepository(stateFile)
|
||||||
|
.load()
|
||||||
|
.players()
|
||||||
|
.get(playerId);
|
||||||
|
|
||||||
|
assertEquals(225, player.totalBlocksPlaced());
|
||||||
|
assertFalse(player.spawnableOverlayEnabled());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void olderStateWithoutBorderPreferenceDefaultsToDisabled() throws Exception {
|
||||||
|
UUID playerId = UUID.randomUUID();
|
||||||
|
Path stateFile = temporaryDirectory.resolve("state.yml");
|
||||||
|
Files.writeString(stateFile, """
|
||||||
|
players:
|
||||||
|
%s:
|
||||||
|
name: Alex
|
||||||
|
""".formatted(playerId));
|
||||||
|
|
||||||
|
PlayerState player = new YamlBaseStateRepository(stateFile)
|
||||||
|
.load()
|
||||||
|
.players()
|
||||||
|
.get(playerId);
|
||||||
|
|
||||||
|
assertFalse(player.borderEnabled());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void invalidRecordsCannotGrantProgression() throws Exception {
|
void invalidRecordsCannotGrantProgression() throws Exception {
|
||||||
UUID playerId = UUID.randomUUID();
|
UUID playerId = UUID.randomUUID();
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package games.dmg.spigotbase;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
|
final class YamlPocketBaseRepositoryTest {
|
||||||
|
@TempDir
|
||||||
|
Path temporaryDirectory;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void roundTripsLevelsAndActiveEntrances() throws Exception {
|
||||||
|
UUID ownerId = UUID.randomUUID();
|
||||||
|
UUID worldId = UUID.randomUUID();
|
||||||
|
PocketBaseState expected = new PocketBaseState(
|
||||||
|
ownerId,
|
||||||
|
7,
|
||||||
|
Optional.of(new PocketPortalLocation(
|
||||||
|
worldId, "world", 10, 65, -4, PocketPortalAxis.X
|
||||||
|
)),
|
||||||
|
Optional.of(new PocketPortalLocation(
|
||||||
|
worldId, "pocket", 20, 65, 12, PocketPortalAxis.Z
|
||||||
|
)),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
YamlPocketBaseRepository repository = new YamlPocketBaseRepository(
|
||||||
|
temporaryDirectory.resolve("pocket-bases.yml")
|
||||||
|
);
|
||||||
|
|
||||||
|
repository.save(Map.of(ownerId, expected));
|
||||||
|
|
||||||
|
assertEquals(expected, repository.load().get(ownerId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void existingRecordWithoutMobPreferenceDefaultsToDisabled() throws Exception {
|
||||||
|
UUID ownerId = UUID.randomUUID();
|
||||||
|
Path stateFile = temporaryDirectory.resolve("pocket-bases.yml");
|
||||||
|
Files.writeString(stateFile, "owners:\n " + ownerId + ":\n level: 1\n");
|
||||||
|
YamlPocketBaseRepository repository = new YamlPocketBaseRepository(stateFile);
|
||||||
|
|
||||||
|
PocketBaseState loaded = repository.load().get(ownerId);
|
||||||
|
|
||||||
|
assertFalse(loaded.mobSpawningEnabled());
|
||||||
|
assertTrue(loaded.returnPortal().isEmpty());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user