feat(spawning): add spawnable block overlay
This commit is contained in:
@@ -32,9 +32,12 @@ The plugin JAR is written to `build/libs/`.
|
|||||||
/basesettings navigation <enable|disable>
|
/basesettings navigation <enable|disable>
|
||||||
/basesettings flight <enable|disable>
|
/basesettings flight <enable|disable>
|
||||||
/basesettings border <enable|disable>
|
/basesettings border <enable|disable>
|
||||||
|
/basesettings spawnable <enable|disable>
|
||||||
/basesettings bossbar <enable|disable>
|
/basesettings bossbar <enable|disable>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
## Administration
|
## Administration
|
||||||
@@ -44,8 +47,10 @@ The `spigotbase.admin` permission is granted to server operators by default. `/h
|
|||||||
```text
|
```text
|
||||||
/baseadmin status <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> <size|flight|warmup|cooldown>
|
/baseadmin reset <player> <size|flight|warmup|cooldown>
|
||||||
/baseadmin reset <player> all confirm
|
/baseadmin reset <player> all confirm
|
||||||
|
|||||||
@@ -76,3 +76,11 @@ description: Chronological record of material decisions affecting the Spigot Bas
|
|||||||
- Rendered a bounded nearby arc at the player's current height using the current base radius while respecting world and vertical bounds.
|
- 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.
|
- 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`.
|
- 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`.
|
||||||
|
|||||||
@@ -19,3 +19,4 @@ description: Catalog of user stories for the Spigot Base plugin.
|
|||||||
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)
|
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)
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -16,14 +16,17 @@ import org.bukkit.plugin.java.JavaPlugin;
|
|||||||
|
|
||||||
final class BaseAdminCommand implements CommandExecutor, TabCompleter {
|
final class BaseAdminCommand implements CommandExecutor, TabCompleter {
|
||||||
private static final List<String> SUBCOMMANDS = List.of(
|
private static final List<String> SUBCOMMANDS = List.of(
|
||||||
"status", "setlevel", "setprogress", "clearcooldown", "reset", "config"
|
"status", "setlevel", "setprogress", "setsetting", "clearcooldown", "reset", "config"
|
||||||
);
|
);
|
||||||
private static final List<String> PATHS = List.of(
|
private static final List<String> PATHS = List.of(
|
||||||
"base", "size", "flight", "warmup", "cooldown"
|
"base", "size", "flight", "warmup", "cooldown"
|
||||||
);
|
);
|
||||||
private static final List<String> COUNTERS = List.of(
|
private static final List<String> COUNTERS = List.of(
|
||||||
"grass_dirt", "stone", "deepslate", "obsidian", "placements", "base_breaks"
|
"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> COOLDOWN_TYPES = List.of("personal", "visitor", "all");
|
||||||
private static final List<String> RESET_PATHS = List.of(
|
private static final List<String> RESET_PATHS = List.of(
|
||||||
"size", "flight", "warmup", "cooldown", "all"
|
"size", "flight", "warmup", "cooldown", "all"
|
||||||
@@ -69,6 +72,7 @@ final class BaseAdminCommand implements CommandExecutor, TabCompleter {
|
|||||||
case "status" -> showProgress(sender, target.orElseThrow(), arguments);
|
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 -> {
|
||||||
@@ -94,11 +98,17 @@ final class BaseAdminCommand implements CommandExecutor, TabCompleter {
|
|||||||
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() + " border=" + player.borderEnabled()
|
+ " flight=" + player.flightEnabled() + " border=" + player.borderEnabled()
|
||||||
+ " bossbar=" + player.bossBarEnabled() + " 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"));
|
||||||
@@ -160,7 +170,8 @@ final class BaseAdminCommand implements CommandExecutor, TabCompleter {
|
|||||||
} catch (IllegalArgumentException exception) {
|
} catch (IllegalArgumentException exception) {
|
||||||
sender.sendMessage(ChatColor.RED
|
sender.sendMessage(ChatColor.RED
|
||||||
+ "Usage: /baseadmin setprogress <player> "
|
+ "Usage: /baseadmin setprogress <player> "
|
||||||
+ "<grass_dirt|stone|deepslate|obsidian|placements|base_breaks> <amount>");
|
+ "<grass_dirt|stone|deepslate|obsidian|placements|total_placements|base_breaks> "
|
||||||
|
+ "<amount>");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -181,6 +192,39 @@ final class BaseAdminCommand implements CommandExecutor, TabCompleter {
|
|||||||
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,
|
||||||
@@ -350,12 +394,16 @@ final class BaseAdminCommand implements CommandExecutor, TabCompleter {
|
|||||||
List<String> options = switch (arguments[0].toLowerCase(Locale.ROOT)) {
|
List<String> options = switch (arguments[0].toLowerCase(Locale.ROOT)) {
|
||||||
case "setlevel" -> PATHS;
|
case "setlevel" -> PATHS;
|
||||||
case "setprogress" -> COUNTERS;
|
case "setprogress" -> COUNTERS;
|
||||||
|
case "setsetting" -> SETTINGS;
|
||||||
case "clearcooldown" -> COOLDOWN_TYPES;
|
case "clearcooldown" -> COOLDOWN_TYPES;
|
||||||
case "reset" -> RESET_PATHS;
|
case "reset" -> RESET_PATHS;
|
||||||
default -> List.of();
|
default -> List.of();
|
||||||
};
|
};
|
||||||
return complete(options, arguments[2]);
|
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")) {
|
if (arguments.length == 4 && arguments[0].equalsIgnoreCase("setlevel")) {
|
||||||
List<String> levels = switch (arguments[2].toLowerCase(Locale.ROOT)) {
|
List<String> levels = switch (arguments[2].toLowerCase(Locale.ROOT)) {
|
||||||
case "base", "cooldown" -> LEVELS_FOUR;
|
case "base", "cooldown" -> LEVELS_FOUR;
|
||||||
@@ -386,7 +434,7 @@ final class BaseAdminCommand implements CommandExecutor, TabCompleter {
|
|||||||
|
|
||||||
private static void sendUsage(CommandSender sender) {
|
private static void sendUsage(CommandSender sender) {
|
||||||
sender.sendMessage(ChatColor.YELLOW + "Usage: /baseadmin "
|
sender.sendMessage(ChatColor.YELLOW + "Usage: /baseadmin "
|
||||||
+ "<status|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>");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ import org.bukkit.inventory.PlayerInventory;
|
|||||||
|
|
||||||
final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
|
final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
|
||||||
private static final List<String> SETTINGS = List.of(
|
private static final List<String> SETTINGS = List.of(
|
||||||
"status", "upgrade", "visitors", "navigation", "flight", "border", "bossbar"
|
"status", "upgrade", "visitors", "navigation", "flight", "border", "spawnable",
|
||||||
|
"bossbar"
|
||||||
);
|
);
|
||||||
private static final List<String> VISITOR_MODES = List.of("allowed", "blocked");
|
private static final List<String> VISITOR_MODES = List.of("allowed", "blocked");
|
||||||
private static final List<String> ENABLE_MODES = List.of("enable", "disable");
|
private static final List<String> ENABLE_MODES = List.of("enable", "disable");
|
||||||
@@ -62,6 +63,9 @@ final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
|
|||||||
if (arguments.length == 2 && arguments[0].equalsIgnoreCase("border")) {
|
if (arguments.length == 2 && arguments[0].equalsIgnoreCase("border")) {
|
||||||
return updateBorder(player, state, arguments[1]);
|
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")) {
|
if (arguments.length == 2 && arguments[0].equalsIgnoreCase("bossbar")) {
|
||||||
return updateBossBar(player, arguments[1]);
|
return updateBossBar(player, arguments[1]);
|
||||||
}
|
}
|
||||||
@@ -215,6 +219,36 @@ final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
|
|||||||
return true;
|
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) {
|
private boolean updateBossBar(Player player, String mode) {
|
||||||
Boolean enabled = enabledMode(mode);
|
Boolean enabled = enabledMode(mode);
|
||||||
if (enabled == null) {
|
if (enabled == null) {
|
||||||
@@ -240,11 +274,17 @@ final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
|
|||||||
showFlightPath(player, state);
|
showFlightPath(player, state);
|
||||||
showWarmupPath(player, state);
|
showWarmupPath(player, state);
|
||||||
showCooldownPath(player, state);
|
showCooldownPath(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="
|
player.sendMessage(ChatColor.GRAY + "Settings: visitors="
|
||||||
+ (state.visitorsEnabled() ? "allowed" : "blocked")
|
+ (state.visitorsEnabled() ? "allowed" : "blocked")
|
||||||
+ " navigation=" + (state.navigationEnabled() ? "enabled" : "disabled")
|
+ " navigation=" + (state.navigationEnabled() ? "enabled" : "disabled")
|
||||||
+ " flight=" + (state.flightEnabled() ? "enabled" : "disabled")
|
+ " flight=" + (state.flightEnabled() ? "enabled" : "disabled")
|
||||||
+ " border=" + (state.borderEnabled() ? "enabled" : "disabled")
|
+ " border=" + (state.borderEnabled() ? "enabled" : "disabled")
|
||||||
|
+ " spawnable=" + (state.spawnableOverlayEnabled() ? "enabled" : "disabled")
|
||||||
+ " bossbar=" + (state.bossBarEnabled() ? "enabled" : "disabled"));
|
+ " bossbar=" + (state.bossBarEnabled() ? "enabled" : "disabled"));
|
||||||
state.base().ifPresentOrElse(
|
state.base().ifPresentOrElse(
|
||||||
base -> player.sendMessage(ChatColor.GRAY + "Base: " + base.worldName() + " "
|
base -> player.sendMessage(ChatColor.GRAY + "Base: " + base.worldName() + " "
|
||||||
@@ -333,7 +373,7 @@ final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
|
|||||||
List<String> modes = arguments[0].equalsIgnoreCase("visitors")
|
List<String> modes = arguments[0].equalsIgnoreCase("visitors")
|
||||||
? VISITOR_MODES
|
? VISITOR_MODES
|
||||||
: switch (arguments[0].toLowerCase(Locale.ROOT)) {
|
: switch (arguments[0].toLowerCase(Locale.ROOT)) {
|
||||||
case "navigation", "flight", "border", "bossbar" -> ENABLE_MODES;
|
case "navigation", "flight", "border", "spawnable", "bossbar" -> ENABLE_MODES;
|
||||||
default -> List.of();
|
default -> List.of();
|
||||||
};
|
};
|
||||||
String prefix = arguments[1].toLowerCase(Locale.ROOT);
|
String prefix = arguments[1].toLowerCase(Locale.ROOT);
|
||||||
@@ -399,6 +439,7 @@ final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
|
|||||||
private static void sendUsage(Player player) {
|
private static void sendUsage(Player player) {
|
||||||
player.sendMessage(ChatColor.RED + "Usage: /basesettings "
|
player.sendMessage(ChatColor.RED + "Usage: /basesettings "
|
||||||
+ "[status|upgrade|visitors <allowed|blocked>|navigation <enable|disable>"
|
+ "[status|upgrade|visitors <allowed|blocked>|navigation <enable|disable>"
|
||||||
+ "|flight <enable|disable>|border <enable|disable>|bossbar <enable|disable>]");
|
+ "|flight <enable|disable>|border <enable|disable>"
|
||||||
|
+ "|spawnable <enable|disable>|bossbar <enable|disable>]");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,9 @@ public record PlayerState(
|
|||||||
Optional<Instant> lastBaseSet,
|
Optional<Instant> lastBaseSet,
|
||||||
Optional<Instant> lastBaseTeleport,
|
Optional<Instant> lastBaseTeleport,
|
||||||
Map<UUID, Instant> visitorCooldownUntil,
|
Map<UUID, Instant> visitorCooldownUntil,
|
||||||
boolean borderEnabled
|
boolean borderEnabled,
|
||||||
|
long totalBlocksPlaced,
|
||||||
|
boolean spawnableOverlayEnabled
|
||||||
) {
|
) {
|
||||||
public PlayerState {
|
public PlayerState {
|
||||||
if (playerId == null) {
|
if (playerId == null) {
|
||||||
@@ -54,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");
|
||||||
@@ -73,6 +76,9 @@ public record PlayerState(
|
|||||||
if (borderEnabled && (baseLevel < 1 || base.isEmpty())) {
|
if (borderEnabled && (baseLevel < 1 || base.isEmpty())) {
|
||||||
throw new IllegalArgumentException("border visualization requires an established Base I");
|
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");
|
||||||
@@ -107,7 +113,40 @@ 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, false
|
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
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,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, borderEnabled
|
lastBaseSet, lastBaseTeleport, visitorCooldownUntil, borderEnabled,
|
||||||
|
totalBlocksPlaced, spawnableOverlayEnabled
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,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, borderEnabled
|
lastBaseSet, lastBaseTeleport, visitorCooldownUntil, borderEnabled,
|
||||||
|
totalBlocksPlaced, spawnableOverlayEnabled
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,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, borderEnabled
|
lastBaseSet, lastBaseTeleport, visitorCooldownUntil, borderEnabled,
|
||||||
|
totalBlocksPlaced, spawnableOverlayEnabled
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,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, borderEnabled
|
lastBaseSet, lastBaseTeleport, visitorCooldownUntil, borderEnabled,
|
||||||
|
totalBlocksPlaced, spawnableOverlayEnabled
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,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, borderEnabled
|
lastBaseSet, lastBaseTeleport, visitorCooldownUntil, borderEnabled,
|
||||||
|
totalBlocksPlaced, spawnableOverlayEnabled
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,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, borderEnabled
|
lastBaseSet, lastBaseTeleport, visitorCooldownUntil, borderEnabled,
|
||||||
|
totalBlocksPlaced, spawnableOverlayEnabled
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,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, borderEnabled
|
lastBaseSet, lastBaseTeleport, visitorCooldownUntil, borderEnabled,
|
||||||
|
totalBlocksPlaced, spawnableOverlayEnabled
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,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, borderEnabled
|
lastBaseSet, Optional.of(usedAt), visitorCooldownUntil, borderEnabled,
|
||||||
|
totalBlocksPlaced, spawnableOverlayEnabled
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,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, borderEnabled
|
lastBaseSet, lastBaseTeleport, visitorCooldownUntil, borderEnabled,
|
||||||
|
totalBlocksPlaced, spawnableOverlayEnabled
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,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, borderEnabled
|
lastBaseSet, lastBaseTeleport, visitorCooldownUntil, borderEnabled,
|
||||||
|
totalBlocksPlaced, spawnableOverlayEnabled
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,7 +311,9 @@ public record PlayerState(
|
|||||||
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()
|
borderEnabled && newBaseLevel >= 1 && base.isPresent(),
|
||||||
|
totalBlocksPlaced,
|
||||||
|
spawnableOverlayEnabled && newBaseLevel >= 1 && base.isPresent()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,7 +323,8 @@ 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, borderEnabled
|
lastBaseSet, lastBaseTeleport, visitorCooldownUntil, borderEnabled,
|
||||||
|
totalBlocksPlaced, spawnableOverlayEnabled
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -282,7 +334,30 @@ 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, enabled
|
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
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,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, borderEnabled
|
lastBaseSet, lastBaseTeleport, cooldowns, borderEnabled,
|
||||||
|
totalBlocksPlaced, spawnableOverlayEnabled
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -304,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, borderEnabled
|
lastBaseSet, Optional.empty(), visitorCooldownUntil, borderEnabled,
|
||||||
|
totalBlocksPlaced, spawnableOverlayEnabled
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -314,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(), borderEnabled
|
lastBaseSet, lastBaseTeleport, Map.of(), borderEnabled,
|
||||||
|
totalBlocksPlaced, spawnableOverlayEnabled
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -324,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, borderEnabled
|
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,
|
||||||
@@ -62,6 +63,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;
|
||||||
@@ -110,6 +112,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) {
|
||||||
@@ -175,6 +178,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),
|
||||||
|
|||||||
@@ -6,5 +6,6 @@ public enum ProgressCounter {
|
|||||||
DEEPSLATE,
|
DEEPSLATE,
|
||||||
OBSIDIAN,
|
OBSIDIAN,
|
||||||
PLACEMENTS,
|
PLACEMENTS,
|
||||||
|
TOTAL_PLACEMENTS,
|
||||||
BASE_BREAKS
|
BASE_BREAKS
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -46,6 +46,7 @@ public final class SpigotBasePlugin extends JavaPlugin {
|
|||||||
progressionService,
|
progressionService,
|
||||||
secondaryProgressionService,
|
secondaryProgressionService,
|
||||||
teleportProgressionService,
|
teleportProgressionService,
|
||||||
|
new SpawnableOverlayProgressionService(settingsProvider),
|
||||||
boundsService,
|
boundsService,
|
||||||
settingsProvider
|
settingsProvider
|
||||||
);
|
);
|
||||||
@@ -97,6 +98,14 @@ public final class SpigotBasePlugin extends JavaPlugin {
|
|||||||
10L,
|
10L,
|
||||||
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, stateManager::saveIfDirty, 600L, 600L);
|
getServer().getScheduler().runTaskTimer(this, stateManager::saveIfDirty, 600L, 600L);
|
||||||
getLogger().info("Spigot Base enabled.");
|
getLogger().info("Spigot Base enabled.");
|
||||||
|
|||||||
@@ -128,7 +128,9 @@ public final class YamlBaseStateRepository {
|
|||||||
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)
|
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) {
|
||||||
@@ -195,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();
|
||||||
@@ -224,6 +233,8 @@ public final class YamlBaseStateRepository {
|
|||||||
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 + ".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)
|
||||||
|
|||||||
@@ -46,6 +46,9 @@ fourth-teleport-cooldown-seconds: 1800
|
|||||||
visitor-unlock-diamond-cost: 128
|
visitor-unlock-diamond-cost: 128
|
||||||
visitor-currency-material: DIAMOND
|
visitor-currency-material: DIAMOND
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ commands:
|
|||||||
aliases: [visit]
|
aliases: [visit]
|
||||||
basesettings:
|
basesettings:
|
||||||
description: View progression, upgrade, and manage base settings.
|
description: View progression, upgrade, and manage base settings.
|
||||||
usage: /basesettings [status|upgrade|visitors|navigation|flight|border|bossbar]
|
usage: /basesettings [status|upgrade|visitors|navigation|flight|border|spawnable|bossbar]
|
||||||
aliases: [homesettings]
|
aliases: [homesettings]
|
||||||
baseadmin:
|
baseadmin:
|
||||||
description: Administer Spigot Base.
|
description: Administer Spigot Base.
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -53,6 +53,24 @@ final class AdminProgressionServiceTest {
|
|||||||
assertFalse(updated.borderEnabled());
|
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");
|
||||||
|
|||||||
@@ -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.assertTrue;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.ArgumentMatchers.anyString;
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
import static org.mockito.Mockito.mock;
|
import static org.mockito.Mockito.mock;
|
||||||
@@ -9,10 +10,13 @@ import static org.mockito.Mockito.never;
|
|||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
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 java.util.concurrent.atomic.AtomicReference;
|
||||||
|
import java.util.function.UnaryOperator;
|
||||||
import org.bukkit.Bukkit;
|
import org.bukkit.Bukkit;
|
||||||
import org.bukkit.ChatColor;
|
import org.bukkit.ChatColor;
|
||||||
import org.bukkit.command.CommandSender;
|
import org.bukkit.command.CommandSender;
|
||||||
@@ -22,6 +26,46 @@ import org.junit.jupiter.api.Test;
|
|||||||
import org.mockito.MockedStatic;
|
import org.mockito.MockedStatic;
|
||||||
|
|
||||||
final class BaseAdminCommandTest {
|
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
|
@Test
|
||||||
void invalidPathShowsFriendlyUsage() {
|
void invalidPathShowsFriendlyUsage() {
|
||||||
CommandSender sender = mock(CommandSender.class);
|
CommandSender sender = mock(CommandSender.class);
|
||||||
@@ -92,10 +136,23 @@ final class BaseAdminCommandTest {
|
|||||||
new String[] {"setlevel", "Builder", ""})
|
new String[] {"setlevel", "Builder", ""})
|
||||||
);
|
);
|
||||||
assertEquals(
|
assertEquals(
|
||||||
List.of("grass_dirt", "stone", "deepslate", "obsidian", "placements", "base_breaks"),
|
List.of(
|
||||||
|
"grass_dirt", "stone", "deepslate", "obsidian", "placements",
|
||||||
|
"total_placements", "base_breaks"
|
||||||
|
),
|
||||||
command.onTabComplete(sender, null, "baseadmin",
|
command.onTabComplete(sender, null, "baseadmin",
|
||||||
new String[] {"setprogress", "Builder", ""})
|
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(
|
assertEquals(
|
||||||
List.of("personal", "visitor", "all"),
|
List.of("personal", "visitor", "all"),
|
||||||
command.onTabComplete(sender, null, "baseadmin",
|
command.onTabComplete(sender, null, "baseadmin",
|
||||||
@@ -108,6 +165,42 @@ final class BaseAdminCommandTest {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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
|
@Test
|
||||||
void autocompletesNumericConfigurationKeys() {
|
void autocompletesNumericConfigurationKeys() {
|
||||||
CommandSender sender = mock(CommandSender.class);
|
CommandSender sender = mock(CommandSender.class);
|
||||||
@@ -169,7 +262,10 @@ final class BaseAdminCommandTest {
|
|||||||
);
|
);
|
||||||
|
|
||||||
assertEquals(
|
assertEquals(
|
||||||
List.of("status", "setlevel", "setprogress", "clearcooldown", "reset", "config"),
|
List.of(
|
||||||
|
"status", "setlevel", "setprogress", "setsetting", "clearcooldown", "reset",
|
||||||
|
"config"
|
||||||
|
),
|
||||||
command.onTabComplete(sender, null, "baseadmin", new String[] {""})
|
command.onTabComplete(sender, null, "baseadmin", new String[] {""})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,22 @@ import org.bukkit.inventory.PlayerInventory;
|
|||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
final class BaseSettingsCommandTest {
|
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
|
@Test
|
||||||
void borderEnableIsIdempotent() {
|
void borderEnableIsIdempotent() {
|
||||||
UUID playerId = UUID.randomUUID();
|
UUID playerId = UUID.randomUUID();
|
||||||
@@ -73,7 +89,7 @@ final class BaseSettingsCommandTest {
|
|||||||
List.of("allowed"),
|
List.of("allowed"),
|
||||||
command.onTabComplete(null, null, "basesettings", new String[] {"visitors", "a"})
|
command.onTabComplete(null, null, "basesettings", new String[] {"visitors", "a"})
|
||||||
);
|
);
|
||||||
for (String setting : List.of("navigation", "flight", "border", "bossbar")) {
|
for (String setting : List.of("navigation", "flight", "border", "spawnable", "bossbar")) {
|
||||||
assertEquals(
|
assertEquals(
|
||||||
List.of("disable"),
|
List.of("disable"),
|
||||||
command.onTabComplete(null, null, "basesettings", new String[] {setting, "d"})
|
command.onTabComplete(null, null, "basesettings", new String[] {setting, "d"})
|
||||||
@@ -90,7 +106,10 @@ final class BaseSettingsCommandTest {
|
|||||||
);
|
);
|
||||||
|
|
||||||
assertEquals(
|
assertEquals(
|
||||||
List.of("status", "upgrade", "visitors", "navigation", "flight", "border", "bossbar"),
|
List.of(
|
||||||
|
"status", "upgrade", "visitors", "navigation", "flight", "border",
|
||||||
|
"spawnable", "bossbar"
|
||||||
|
),
|
||||||
command.onTabComplete(null, null, "basesettings", new String[] {""})
|
command.onTabComplete(null, null, "basesettings", new String[] {""})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -204,12 +223,20 @@ final class BaseSettingsCommandTest {
|
|||||||
command.onCommand(player, null, "basesettings", new String[] {"status"});
|
command.onCommand(player, null, "basesettings", new String[] {"status"});
|
||||||
|
|
||||||
verify(player, times(2)).sendMessage(ChatColor.GOLD + "=== Base Progress ===");
|
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(
|
verify(player, times(2)).sendMessage(
|
||||||
org.mockito.ArgumentMatchers.<String>argThat(message ->
|
org.mockito.ArgumentMatchers.<String>argThat(message ->
|
||||||
message.contains("Settings: visitors=")
|
message.contains("Settings: visitors=")
|
||||||
&& message.contains("navigation=")
|
&& message.contains("navigation=")
|
||||||
&& message.contains("flight=")
|
&& message.contains("flight=")
|
||||||
&& message.contains("border=")
|
&& message.contains("border=")
|
||||||
|
&& message.contains("spawnable=")
|
||||||
&& message.contains("bossbar=")
|
&& message.contains("bossbar=")
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,6 +11,23 @@ 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
|
@Test
|
||||||
void borderVisualizationIsDisabledByDefaultAndCanBeEnabled() {
|
void borderVisualizationIsDisabledByDefaultAndCanBeEnabled() {
|
||||||
UUID playerId = UUID.randomUUID();
|
UUID playerId = UUID.randomUUID();
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ 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());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,8 @@ final class YamlBaseStateRepositoryTest {
|
|||||||
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
|
true
|
||||||
);
|
);
|
||||||
PersistentState expected = new PersistentState(Map.of(playerId, player));
|
PersistentState expected = new PersistentState(Map.of(playerId, player));
|
||||||
@@ -40,6 +42,26 @@ 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
|
@Test
|
||||||
void olderStateWithoutBorderPreferenceDefaultsToDisabled() throws Exception {
|
void olderStateWithoutBorderPreferenceDefaultsToDisabled() throws Exception {
|
||||||
UUID playerId = UUID.randomUUID();
|
UUID playerId = UUID.randomUUID();
|
||||||
|
|||||||
Reference in New Issue
Block a user