diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 76bad4f..fe94278 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -68,3 +68,11 @@ jobs:
name: unit-test-reports-${{ github.run_id }}-${{ github.run_attempt }}
path: target/surefire-reports/
if-no-files-found: error
+
+ - name: Upload coverage report
+ if: ${{ !cancelled() && hashFiles('target/site/jacoco/jacoco.xml') != '' }}
+ uses: actions/upload-artifact@v7
+ with:
+ name: coverage-report-${{ github.run_id }}-${{ github.run_attempt }}
+ path: target/site/jacoco/
+ if-no-files-found: error
diff --git a/README.md b/README.md
index 1f28b57..496e46e 100644
--- a/README.md
+++ b/README.md
@@ -32,6 +32,34 @@ them before they ever reach the surface.
Technical documentation is maintained in [TF-Minecraft/Docs](https://github.com/TF-Minecraft/Docs).
+## Tests
+
+With Java 21 and Maven installed, run `mvn clean verify`. Tests exercise domain
+rules, YAML persistence, and plugin workflows using JUnit, Mockito, and MockBukkit.
+JaCoCo measures all production classes and writes its HTML report to
+`target/site/jacoco/index.html` and machine-readable results to
+`target/site/jacoco/jacoco.xml`. The build workflow uploads the coverage report.
+
+Tests should assert gameplay behavior, data preservation, or failure handling.
+Do not add tests solely to execute a line or manufacture unreachable states to
+increase coverage. MockBukkit tests do not replace testing on a real Paper server
+with the optional ItemsAdder and MMOItems integrations installed.
+
+Adapter contract tests can also run against locally supplied plugin jars:
+
+```sh
+mvn -Ppack-api-tests clean verify \
+ -Ditemsadder.jar=/path/to/ItemsAdder.jar \
+ -Dfastnbt.jar=/path/to/FastNbt-jar.jar \
+ -Dmmoitems.jar=/path/to/MMOItems.jar \
+ -Dmythiclib.jar=/path/to/MythicLib.jar
+```
+
+These tests bind the actual API classes and mock their external operations; they
+do not start those plugins. The jars remain outside this repository and are used
+only on the test runtime classpath. Use the FastNbt version declared by your
+ItemsAdder jar. The ordinary test suite needs none of these files.
+
## License
Copyright (c) 2026 TF-Minecraft contributors.
diff --git a/pom.xml b/pom.xml
index 65332a8..a410ec1 100644
--- a/pom.xml
+++ b/pom.xml
@@ -24,6 +24,18 @@
+
+ org.mockbukkit.mockbukkit
+ mockbukkit-v1.21
+ 4.95.0
+ test
+
+
+ org.mockito
+ mockito-core
+ 5.20.0
+ test
+
junit
junit
@@ -38,7 +50,62 @@
+
+
+ pack-api-tests
+
+
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+
+
+ **/*Test.java
+ **/*IT.java
+
+
+ ${itemsadder.jar}
+ ${fastnbt.jar}
+ ${mmoitems.jar}
+ ${mythiclib.jar}
+
+
+
+
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+ 3.5.4
+
+
+ org.apache.maven.surefire
+ surefire-junit4
+ 3.5.4
+
+
+
+
+ org.jacoco
+ jacoco-maven-plugin
+ 0.8.15
+
+
+ prepare-agent
+
+
+ coverage-report
+ verify
+ report
+
+
+
+
archaeo-${project.version}
${project.basedir}/src/main/java
diff --git a/src/main/java/net/tfminecraft/archaeo/ArcheologyPlugin.java b/src/main/java/net/tfminecraft/archaeo/ArcheologyPlugin.java
index 3962db0..1c87239 100644
--- a/src/main/java/net/tfminecraft/archaeo/ArcheologyPlugin.java
+++ b/src/main/java/net/tfminecraft/archaeo/ArcheologyPlugin.java
@@ -172,11 +172,10 @@ public void onEnable() {
autoRuins,
campClosure,
sitePurge);
+ // plugin.yml in this jar declares the command, so Bukkit always returns it.
PluginCommand pluginCommand = getCommand("archaeo");
- if (pluginCommand != null) {
- pluginCommand.setExecutor(command);
- pluginCommand.setTabCompleter(command);
- }
+ pluginCommand.setExecutor(command);
+ pluginCommand.setTabCompleter(command);
getLogger().info("Archaeo enabled. Loaded sites: " + sites.all().size());
}
@@ -186,34 +185,17 @@ public void onEnable() {
* @param matcher detected APIs, or vanilla-only
*/
public void bindItemMatcher(ItemMatcher matcher) {
+ // Callers (enable, reload, the pack-plugin hook) run only after onEnable built every item.
ItemMatcher bound = matcher == null ? ItemMatcher.vanillaOnly() : matcher;
- if (trackerItem != null) {
- trackerItem.setMatcher(bound);
- }
- if (prospectItem != null) {
- prospectItem.setMatcher(bound);
- }
- if (establishItem != null) {
- establishItem.setMatcher(bound);
- }
- if (brushItem != null) {
- brushItem.setMatcher(bound);
- }
- if (sketchSupplies != null) {
- sketchSupplies.setMatcher(bound);
- }
- if (digTools != null) {
- digTools.setMatcher(bound);
- }
- if (recoveredFindItem != null) {
- recoveredFindItem.setMatcher(bound);
- }
- if (sketch != null) {
- sketch.setMatcher(bound);
- }
- if (museum != null) {
- museum.setMatcher(bound);
- }
+ trackerItem.setMatcher(bound);
+ prospectItem.setMatcher(bound);
+ establishItem.setMatcher(bound);
+ brushItem.setMatcher(bound);
+ sketchSupplies.setMatcher(bound);
+ digTools.setMatcher(bound);
+ recoveredFindItem.setMatcher(bound);
+ sketch.setMatcher(bound);
+ museum.setMatcher(bound);
}
/**
diff --git a/src/main/java/net/tfminecraft/archaeo/command/ArchaeoCommand.java b/src/main/java/net/tfminecraft/archaeo/command/ArchaeoCommand.java
index 81549ff..b6380da 100644
--- a/src/main/java/net/tfminecraft/archaeo/command/ArchaeoCommand.java
+++ b/src/main/java/net/tfminecraft/archaeo/command/ArchaeoCommand.java
@@ -242,14 +242,10 @@ private boolean handleReload(CommandSender sender) {
catalogs.items().sketchPencil(),
catalogs.sketch().pencilUses());
plugin.sketch().setSettings(catalogs.sketch());
- if (autoRuins != null) {
- autoRuins.setSettings(catalogs.autoRuins());
- }
+ autoRuins.setSettings(catalogs.autoRuins());
sites.loadAll();
sender.sendMessage("Reloaded Archaeo config, catalogs, and sites from disk.");
- if (autoRuins != null) {
- sender.sendMessage(autoRuins.statusLine());
- }
+ sender.sendMessage(autoRuins.statusLine());
} catch (RuntimeException exception) {
sender.sendMessage("Reload failed: " + exception.getMessage());
}
@@ -373,7 +369,7 @@ private static boolean giveStack(
String givenLabel,
String receivedMessage
) {
- if (stack == null || stack.getType().isAir()) {
+ if (stack.getType().isAir()) {
sender.sendMessage("Could not create that item. Check pack plugins and config.yml.");
return true;
}
@@ -897,10 +893,6 @@ private boolean handleRuinTp(CommandSender sender, String[] args) {
* @return {@code true} always (handled)
*/
private boolean handleRuinAuto(CommandSender sender, String[] args) {
- if (autoRuins == null) {
- sender.sendMessage("Auto-ruins are not available on this server.");
- return true;
- }
if (args.length < 3) {
sender.sendMessage("Usage: /archaeo ruin auto status|reset");
return true;
@@ -944,7 +936,7 @@ private boolean handleCamps(CommandSender sender, String[] args) {
sender.sendMessage("Console must name a player before a serial: /archaeo ruin camps #serial");
return true;
}
- return teleportToCamp(sender, (Player) sender, onlySerial.get(), null);
+ return teleportToCamp((Player) sender, onlySerial.get(), null);
}
director = CampNames.known(args[2]);
if (director == null) {
@@ -969,7 +961,7 @@ private boolean handleCamps(CommandSender sender, String[] args) {
sender.sendMessage("Only a player can teleport to a camp.");
return true;
}
- return teleportToCamp(sender, player, serial.get(), directorId);
+ return teleportToCamp(player, serial.get(), directorId);
}
List camps = sites.findDirectedCamps(directorId);
String who = CampNames.of(sender instanceof Player player ? player : null, directorId);
@@ -979,12 +971,12 @@ private boolean handleCamps(CommandSender sender, String[] args) {
}
if (camps.size() == 1 && sender instanceof Player player) {
Site only = camps.getFirst();
- sendCampLine(sender, only, false);
- return teleportPlayerToCamp(sender, player, only);
+ sendCampLine(sender, only, null);
+ return teleportPlayerToCamp(player, only);
}
sender.sendMessage(who + " directs " + camps.size() + " camp" + (camps.size() == 1 ? "" : "s") + ":");
for (Site camp : camps) {
- sendCampLine(sender, camp, sender instanceof Player);
+ sendCampLine(sender, camp, sender instanceof Player player ? player : null);
}
if (camps.size() > 1 && sender instanceof Player) {
sender.sendMessage("Click [tp] or run /archaeo ruin camps "
@@ -997,51 +989,45 @@ private boolean handleCamps(CommandSender sender, String[] args) {
/**
* Teleports the issuer to a directed camp after checking the optional director filter.
*
- * @param sender who receives errors
- * @param traveler player to move
+ * @param traveler issuing player, who also receives errors
* @param serial site serial
* @param expectedDirector required director, or {@code null} to accept any locked camp
* @return {@code true} always
*/
- private boolean teleportToCamp(
- CommandSender sender,
- Player traveler,
- int serial,
- UUID expectedDirector
- ) {
+ private boolean teleportToCamp(Player traveler, int serial, UUID expectedDirector) {
Optional resolved = sites.findBySerial(serial);
if (resolved.isEmpty()) {
- sender.sendMessage("No site with serial #" + serial + ".");
+ traveler.sendMessage("No site with serial #" + serial + ".");
return true;
}
Site site = resolved.get();
if (!site.isCampLocked() || site.getCampX() == null) {
- sender.sendMessage(site.displayLabel() + " has no standing camp to teleport to.");
+ traveler.sendMessage(site.displayLabel() + " has no standing camp to teleport to.");
return true;
}
if (expectedDirector != null && !site.isDirector(expectedDirector)) {
- sender.sendMessage(site.displayLabel() + " is not directed by that player.");
+ traveler.sendMessage(site.displayLabel() + " is not directed by that player.");
return true;
}
- return teleportPlayerToCamp(sender, traveler, site);
+ return teleportPlayerToCamp(traveler, site);
}
/**
- * Moves {@code traveler} to the camp table block of {@code site}.
+ * Moves the issuing player to the camp table block of {@code site}.
*
- * @param sender who receives confirmations
- * @param traveler player to move
+ * @param traveler issuing player, who also receives confirmations
* @param site locked camp
* @return {@code true} always
*/
- private boolean teleportPlayerToCamp(CommandSender sender, Player traveler, Site site) {
+ private boolean teleportPlayerToCamp(Player traveler, Site site) {
World world = Bukkit.getWorld(site.getWorldName());
if (world == null) {
- sender.sendMessage("World not loaded: " + site.getWorldName());
+ traveler.sendMessage("World not loaded: " + site.getWorldName());
return true;
}
- if (site.getCampX() == null || site.getCampY() == null || site.getCampZ() == null) {
- sender.sendMessage(site.displayLabel() + " has no camp coordinates.");
+ // The table's three coordinates are written together (Site#establish, dossier load).
+ if (site.getCampX() == null) {
+ traveler.sendMessage(site.displayLabel() + " has no camp coordinates.");
return true;
}
Location destination = new Location(
@@ -1050,12 +1036,9 @@ private boolean teleportPlayerToCamp(CommandSender sender, Player traveler, Site
site.getCampY() + 1.0,
site.getCampZ() + 0.5);
traveler.teleport(destination);
- sender.sendMessage("Teleported to " + site.displayLabel()
+ traveler.sendMessage("Teleported to " + site.displayLabel()
+ " · camp " + site.getCampX() + "," + site.getCampY() + "," + site.getCampZ()
+ " (" + site.getWorldName() + ").");
- if (traveler != sender) {
- traveler.sendMessage("Staff moved you to camp " + site.publicName() + ".");
- }
return true;
}
@@ -1064,9 +1047,9 @@ private boolean teleportPlayerToCamp(CommandSender sender, Player traveler, Site
*
* @param sender who receives the line
* @param site locked camp
- * @param clickable whether to attach a {@code [tp]} run-command
+ * @param clickable player to receive a {@code [tp]} run-command, or {@code null} for plain text
*/
- private void sendCampLine(CommandSender sender, Site site, boolean clickable) {
+ private void sendCampLine(CommandSender sender, Site site, Player clickable) {
String coords = site.getCampX() == null
? "unknown"
: site.getCampX() + "," + site.getCampY() + "," + site.getCampZ();
@@ -1074,7 +1057,7 @@ private void sendCampLine(CommandSender sender, Site site, boolean clickable) {
+ " · " + site.getStatus().name().toLowerCase(Locale.ROOT)
+ " · " + site.getWorldName()
+ " · camp " + coords;
- if (!clickable || !(sender instanceof Player player)) {
+ if (clickable == null) {
sender.sendMessage(" " + body);
return;
}
@@ -1087,7 +1070,7 @@ private void sendCampLine(CommandSender sender, Site site, boolean clickable) {
tp.setHoverEvent(new HoverEvent(
HoverEvent.Action.SHOW_TEXT,
new Text("Teleport to this camp")));
- player.spigot().sendMessage(prefix, tp);
+ clickable.spigot().sendMessage(prefix, tp);
}
/**
@@ -1127,9 +1110,6 @@ private Optional resolveQuery(CommandSender sender, String query) {
* @return serial if the whole query is a number
*/
private Optional parseSerial(String raw) {
- if (raw == null) {
- return Optional.empty();
- }
String trimmed = raw.trim();
if (trimmed.startsWith("#")) {
trimmed = trimmed.substring(1).trim();
@@ -1316,7 +1296,8 @@ public List onTabComplete(CommandSender sender, Command command, String
if (args.length == 3 && "stats".equalsIgnoreCase(args[1])) {
return worldNamesStartingWith(args[2]);
}
- if (args.length >= 3 && "set-interest".equalsIgnoreCase(args[1])) {
+ // From here every ruin action has at least one token after it (args.length >= 3).
+ if ("set-interest".equalsIgnoreCase(args[1])) {
if (args.length == 3) {
return INTERESTS.stream()
.filter(value -> value.startsWith(args[2].toLowerCase(Locale.ROOT)))
@@ -1327,7 +1308,7 @@ public List onTabComplete(CommandSender sender, Command command, String
args.length == 4,
site -> site.getStatus() == SiteStatus.HIDDEN);
}
- if (args.length >= 3 && "close".equalsIgnoreCase(args[1])) {
+ if ("close".equalsIgnoreCase(args[1])) {
String last = args[args.length - 1].toLowerCase(Locale.ROOT);
if ("confirm".equals(last) && args.length > 3) {
return List.of();
@@ -1341,7 +1322,7 @@ public List onTabComplete(CommandSender sender, Command command, String
}
return suggestions;
}
- if (args.length >= 3 && "delete".equalsIgnoreCase(args[1])) {
+ if ("delete".equalsIgnoreCase(args[1])) {
String last = args[args.length - 1].toLowerCase(Locale.ROOT);
if ("confirm".equals(last) && args.length > 3) {
return List.of();
@@ -1355,7 +1336,7 @@ public List onTabComplete(CommandSender sender, Command command, String
}
return suggestions;
}
- if (args.length >= 3 && "create".equalsIgnoreCase(args[1])) {
+ if ("create".equalsIgnoreCase(args[1])) {
if (args.length == 3) {
return INTERESTS.stream()
.filter(value -> value.startsWith(args[2].toLowerCase(Locale.ROOT)))
@@ -1363,7 +1344,7 @@ public List onTabComplete(CommandSender sender, Command command, String
}
return List.of();
}
- if (args.length >= 3 && "info".equalsIgnoreCase(args[1])) {
+ if ("info".equalsIgnoreCase(args[1]) || "tp".equalsIgnoreCase(args[1])) {
String typed = Arrays.stream(args).skip(2).collect(Collectors.joining(" "));
List names = new ArrayList<>(sites.namesStartingWith(typed));
if (args.length == 3) {
@@ -1377,21 +1358,7 @@ public List onTabComplete(CommandSender sender, Command command, String
}
return names;
}
- if (args.length >= 3 && "tp".equalsIgnoreCase(args[1])) {
- String typed = Arrays.stream(args).skip(2).collect(Collectors.joining(" "));
- List names = new ArrayList<>(sites.namesStartingWith(typed));
- if (args.length == 3) {
- String token = args[2].toLowerCase(Locale.ROOT);
- for (Site site : sites.all()) {
- String serial = "#" + site.getSerial();
- if (serial.startsWith(token) || String.valueOf(site.getSerial()).startsWith(token)) {
- names.add(serial);
- }
- }
- }
- return names;
- }
- if (args.length >= 3 && "camps".equalsIgnoreCase(args[1])) {
+ if ("camps".equalsIgnoreCase(args[1])) {
if (args.length == 3) {
List suggestions = new ArrayList<>(onlineNamesStartingWith(args[2]));
String token = args[2].toLowerCase(Locale.ROOT);
@@ -1429,13 +1396,12 @@ public List onTabComplete(CommandSender sender, Command command, String
/**
* Name and serial suggestions for a staff site query, optionally filtered.
*
- * @param typed text after the subcommand
+ * @param needle text after the subcommand
* @param includeSerials whether the current token can still be a serial
* @param filter sites to offer
* @return matching names and serials
*/
- private List suggestSiteQueries(String typed, boolean includeSerials, Predicate filter) {
- String needle = typed == null ? "" : typed;
+ private List suggestSiteQueries(String needle, boolean includeSerials, Predicate filter) {
List names = new ArrayList<>();
for (String name : sites.namesStartingWith(needle)) {
boolean allowed = false;
diff --git a/src/main/java/net/tfminecraft/archaeo/config/ArtifactTemplate.java b/src/main/java/net/tfminecraft/archaeo/config/ArtifactTemplate.java
index fbe7a22..aece6c5 100644
--- a/src/main/java/net/tfminecraft/archaeo/config/ArtifactTemplate.java
+++ b/src/main/java/net/tfminecraft/archaeo/config/ArtifactTemplate.java
@@ -84,7 +84,8 @@ public ItemRef resolveRef(String chosen) {
if (chosen != null && !chosen.isBlank()) {
String token = chosen.trim();
for (ItemRef ref : items) {
- if (matchesStored(ref, token)) {
+ // Vanilla tokens are the bare material name; "minecraft:" tokens resolve via parse below.
+ if (ref.commandToken().equalsIgnoreCase(token)) {
return ref;
}
}
@@ -110,16 +111,4 @@ public Material resolveItem(String chosen) {
}
return Material.BRICK;
}
-
- /**
- * @param ref catalog pool entry
- * @param token stored dossier token
- * @return whether this is the same item, including old vanilla names without a {@code minecraft:} prefix
- */
- private static boolean matchesStored(ItemRef ref, String token) {
- if (ref.commandToken().equalsIgnoreCase(token)) {
- return true;
- }
- return ref.kind() == ItemRef.Kind.VANILLA && ref.primary().equalsIgnoreCase(token);
- }
}
diff --git a/src/main/java/net/tfminecraft/archaeo/config/CatalogRegistry.java b/src/main/java/net/tfminecraft/archaeo/config/CatalogRegistry.java
index 4b4257b..178e779 100644
--- a/src/main/java/net/tfminecraft/archaeo/config/CatalogRegistry.java
+++ b/src/main/java/net/tfminecraft/archaeo/config/CatalogRegistry.java
@@ -69,6 +69,10 @@ public CatalogRegistry(JavaPlugin plugin) {
/**
* Ensures default catalog files exist on disk (first run only), then reads them into memory.
+ * The required catalogues are parsed before anything is replaced, so a malformed file leaves
+ * the previously loaded catalogue whole (a failed {@code /archaeo reload} changes nothing).
+ *
+ * @throws IllegalStateException if a required section is missing
*/
public void load() {
copyDefaultIfAbsent("config.yml");
@@ -81,9 +85,15 @@ public void load() {
plugin.reloadConfig();
FileConfiguration config = plugin.getConfig();
+ YamlConfiguration interestFile = yaml("interest.yml");
+ Map loadedInterests = readInterests(interestFile, config);
+ Map loadedStrata = readStrata(yaml("strata.yml"));
+ Map loadedArtifacts = readArtifacts(yaml("artifacts.yml"));
+ Map loadedHints = readHints(yaml("hints.yml"));
+
loadItems(yaml("items.yml"), config);
- loadInterests(yaml("interest.yml"), config);
- loadGeneration(yaml("interest.yml"), config);
+ replace(interests, loadedInterests);
+ loadGeneration(interestFile, config);
loadTracker(config);
loadProspect(config);
loadEstablish(config);
@@ -96,13 +106,24 @@ public void load() {
loadStaffPermission(config);
loadRarities(config);
loadRarityFromWeight(config);
- loadStrata(yaml("strata.yml"));
- loadArtifacts(yaml("artifacts.yml"));
- loadHints(yaml("hints.yml"));
+ replace(strata, loadedStrata);
+ replace(artifacts, loadedArtifacts);
+ replace(hints, loadedHints);
loadInterpretations(yaml("interpretations.yml"));
loadMaterials(yaml("materials.yml"));
}
+ /**
+ * @param live catalogue map
+ * @param loaded freshly parsed rows
+ * @param key type
+ * @param row type
+ */
+ private static void replace(Map live, Map loaded) {
+ live.clear();
+ live.putAll(loaded);
+ }
+
/**
* @param level interest key from config
* @return generation budget and detection settings for that level
@@ -217,13 +238,10 @@ public String rarityLoreLine(String raw) {
}
/**
- * @param id resolved rarity key
+ * @param id rarity key from {@link #resolveRarityId(String, int)}, never blank
* @return lore line
*/
private String rarityLoreLineForId(String id) {
- if (id == null || id.isBlank()) {
- return rarities.getOrDefault("common", ArtifactRarity.COMMON.style()).loreLine();
- }
String key = id.trim().toLowerCase(Locale.ROOT);
RarityStyle style = rarities.get(key);
if (style != null) {
@@ -321,7 +339,7 @@ public List interpretationTypes() {
public List interpretationTypes(FindProfile profile) {
FindProfile key = profile == null ? FindProfile.OBJECT : profile;
List ids = profileTypeIds.get(key);
- if (ids == null || ids.isEmpty()) {
+ if (ids == null) {
return interpretationTypes();
}
List list = new ArrayList<>();
@@ -390,8 +408,9 @@ public List stationOffers(
String artifactId,
Set tags
) {
+ // Loaded types always carry at least one option.
InterpretationType type = interpretationType(typeId);
- if (type == null || type.options() == null || type.options().isEmpty()) {
+ if (type == null) {
return List.of();
}
FindProfile profile = profileOf(artifactId);
@@ -424,14 +443,13 @@ public List stationOffers(
pool.remove(floor);
offers.add(floor);
}
- while (offers.size() < want && !pool.isEmpty()) {
+ // A draw only happens when the pool holds more than three phrases, so three are always taken.
+ while (offers.size() < want) {
offers.add(takeWeighted(pool, rng, weightTags));
}
- if (offers.size() > 1) {
- int slot = rng.nextInt(offers.size());
- InterpretationTemplate first = offers.remove(0);
- offers.add(slot, first);
- }
+ int slot = rng.nextInt(offers.size());
+ InterpretationTemplate first = offers.remove(0);
+ offers.add(slot, first);
return List.copyOf(offers);
}
@@ -452,14 +470,12 @@ private static InterpretationTemplate takeWeighted(
weights[i] = pool.get(i).suggestedBy(tags) ? 3 : 1;
total += weights[i];
}
- int roll = rng.nextInt(Math.max(1, total));
+ // roll < total, so the walk always stops inside the pool.
+ int roll = rng.nextInt(total);
int index = 0;
- for (int i = 0; i < pool.size(); i++) {
- roll -= weights[i];
- if (roll < 0) {
- index = i;
- break;
- }
+ while (roll >= weights[index]) {
+ roll -= weights[index];
+ index++;
}
return pool.remove(index);
}
@@ -680,15 +696,13 @@ private ItemRef parseItemId(
* @return first non-blank string that is not a section
*/
private static String firstPath(Configuration root, String... paths) {
- if (root == null) {
- return null;
- }
for (String path : paths) {
if (!root.contains(path) || root.isConfigurationSection(path)) {
continue;
}
+ // A present scalar always reads back as text.
String value = root.getString(path);
- if (value != null && !value.isBlank()) {
+ if (!value.isBlank()) {
return value;
}
}
@@ -710,9 +724,7 @@ private Map> loadExcavationItemLists(
) {
Map> profiles = new LinkedHashMap<>();
ConfigurationSection tools = config.getConfigurationSection("excavation.tools");
- ConfigurationSection itemsExcavation = itemsFile == null
- ? null
- : itemsFile.getConfigurationSection("excavation");
+ ConfigurationSection itemsExcavation = itemsFile.getConfigurationSection("excavation");
LinkedHashSet ids = new LinkedHashSet<>(fallback.excavationProfiles().keySet());
if (tools != null) {
ids.addAll(tools.getKeys(false));
@@ -847,7 +859,7 @@ private void loadPick(FileConfiguration config) {
loadLimits(excavation, fallback.limits()),
firstBool(excavation, pickSection, fallback.neighborTraces(), "neighbor-traces"),
loadCues(excavation, fallback.cues()),
- loadProfiles(excavation, pickSection, fallback)
+ loadProfiles(excavation)
);
}
@@ -898,12 +910,12 @@ private CueSettings loadCues(ConfigurationSection excavation, CueSettings fallba
* @return materials, possibly empty
*/
private Set materialsFromList(List raw, String path) {
- if (raw == null || raw.isEmpty()) {
+ if (raw.isEmpty()) {
return Set.of();
}
EnumSet out = EnumSet.noneOf(Material.class);
for (String token : raw) {
- if (token == null || token.isBlank()) {
+ if (token.isBlank()) {
continue;
}
String trimmed = token.trim();
@@ -927,7 +939,7 @@ private Set materialsFromList(List raw, String path) {
* @param path warning path
*/
private void addTagMaterials(Set out, String tagToken, String path) {
- if (tagToken == null || tagToken.isBlank()) {
+ if (tagToken.isBlank()) {
return;
}
String key = tagToken.contains(":")
@@ -1088,45 +1100,21 @@ private static int clampPercent(int value) {
/**
* Builds named tools from {@code excavation.tools} (whitelist, lifts, window; tempo is vanilla).
+ * One profile per whitelist from {@link #loadExcavationItemLists}; the packaged hand, light and
+ * heavy lists always survive there, so the result is never empty.
*
* @param excavation {@code excavation:} or {@code null}
- * @param pickSection {@code pick:} or {@code null}
- * @param fallback packaged profiles
- * @return named tools; legacy {@code pick.tools} becomes one {@code hand} profile
- */
- private List loadProfiles(
- ConfigurationSection excavation,
- ConfigurationSection pickSection,
- PickSettings fallback
- ) {
+ * @return named tools in whitelist order
+ */
+ private List loadProfiles(ConfigurationSection excavation) {
ConfigurationSection tools = excavation == null ? null : excavation.getConfigurationSection("tools");
- LinkedHashSet ids = new LinkedHashSet<>(items.excavationProfiles().keySet());
- if (tools != null) {
- ids.addAll(tools.getKeys(false));
- }
- if (!ids.isEmpty()) {
- List profiles = new ArrayList<>();
- for (String id : ids) {
- ConfigurationSection tool = tools == null ? null : tools.getConfigurationSection(id);
- List materials = items.profileMaterials(id);
- if (materials.isEmpty() && tool != null) {
- materials = toolItems(tool);
- }
- if (materials.isEmpty()) {
- continue;
- }
- profiles.add(readTool(id, tool, materials, ExcavationTool.packaged(id)));
- }
- if (!profiles.isEmpty()) {
- return List.copyOf(profiles);
- }
- }
- if (pickSection != null && !pickSection.getStringList("tools").isEmpty()) {
- List materials = ItemRef.parseAll(plugin, pickSection.getStringList("tools"));
- ExcavationTool inherit = ExcavationTool.hand();
- return List.of(readTool("hand", pickSection, materials, inherit));
+ List profiles = new ArrayList<>();
+ for (Map.Entry> entry : items.excavationProfiles().entrySet()) {
+ String id = entry.getKey();
+ ConfigurationSection tool = tools == null ? null : tools.getConfigurationSection(id);
+ profiles.add(readTool(id, tool, entry.getValue(), ExcavationTool.packaged(id)));
}
- return fallback.profiles();
+ return List.copyOf(profiles);
}
/**
@@ -1187,31 +1175,26 @@ private static ExcavationTool readTool(
* Reads {@code tempo:} — {@code vanilla} / omit → {@code 0}; a number → Archaeo metronome ticks.
* Legacy {@code cue-ticks} is still accepted. Omitting tempo never inherits a packaged metronome.
*
- * @param section tool profile
+ * @param section tool profile, never {@code null} ({@link #readTool} handles a missing block)
* @return cue ticks; {@code 0} means vanilla mining tempo
*/
private static int readTempo(ConfigurationSection section) {
- if (section == null) {
- return 0;
- }
if (section.contains("tempo")) {
Object raw = section.get("tempo");
if (raw instanceof Number number) {
return Math.max(0, number.intValue());
}
- if (raw != null) {
- String token = raw.toString().trim();
- if (token.isEmpty()
- || token.equalsIgnoreCase("vanilla")
- || token.equalsIgnoreCase("default")
- || token.equalsIgnoreCase("auto")) {
- return 0;
- }
- try {
- return Math.max(0, Integer.parseInt(token));
- } catch (NumberFormatException ignored) {
- return 0;
- }
+ String token = raw.toString().trim();
+ if (token.isEmpty()
+ || token.equalsIgnoreCase("vanilla")
+ || token.equalsIgnoreCase("default")
+ || token.equalsIgnoreCase("auto")) {
+ return 0;
+ }
+ try {
+ return Math.max(0, Integer.parseInt(token));
+ } catch (NumberFormatException ignored) {
+ return 0;
}
}
Integer legacy = sectionIntOrNull(
@@ -1263,14 +1246,11 @@ private static boolean firstBool(ConfigurationSection first, ConfigurationSectio
/**
* First defined string list among {@code keys} on {@code section}.
*
- * @param section YAML map, or {@code null}
+ * @param section YAML map
* @param keys preference order
* @return list, or empty when none is present
*/
private static List firstStringList(ConfigurationSection section, String... keys) {
- if (section == null || keys == null) {
- return List.of();
- }
for (String key : keys) {
if (section.contains(key)) {
return section.getStringList(key);
@@ -1282,15 +1262,12 @@ private static List firstStringList(ConfigurationSection section, String
/**
* First defined double among {@code keys} on {@code section}.
*
- * @param section YAML map, or {@code null}
+ * @param section YAML map
* @param fallback when none is present
* @param keys preference order
* @return value
*/
private static double firstDouble(ConfigurationSection section, double fallback, String... keys) {
- if (section == null || keys == null) {
- return fallback;
- }
for (String key : keys) {
if (section.contains(key)) {
return section.getDouble(key);
@@ -1317,15 +1294,12 @@ private static int sectionInt(ConfigurationSection section, int fallback, String
* @return first present float, or {@code fallback}
*/
private static Float sectionFloat(ConfigurationSection section, Float fallback, String... keys) {
- if (section == null) {
- return fallback;
- }
for (String key : keys) {
if (!section.contains(key) || section.isConfigurationSection(key)) {
continue;
}
String raw = section.getString(key);
- if (raw == null || raw.isBlank()) {
+ if (raw.isBlank()) {
return null;
}
return (float) section.getDouble(key);
@@ -1357,9 +1331,6 @@ private static Integer sectionIntOrNull(ConfigurationSection section, String...
* @return double value
*/
private static double sectionDouble(ConfigurationSection section, double fallback, String... keys) {
- if (section == null) {
- return fallback;
- }
for (String key : keys) {
if (section.contains(key)) {
return section.getDouble(key);
@@ -1374,9 +1345,6 @@ private static double sectionDouble(ConfigurationSection section, double fallbac
* @return first present string, or {@code null}
*/
private static String sectionString(ConfigurationSection section, String... keys) {
- if (section == null) {
- return null;
- }
for (String key : keys) {
if (section.contains(key) && !section.isConfigurationSection(key)) {
return section.getString(key);
@@ -1496,11 +1464,11 @@ private static double readUnitInterval(ConfigurationSection section, String key,
if (!section.contains(key)) {
return fallback;
}
- if (section.isDouble(key) || section.isInt(key) || section.isLong(key)) {
+ if (section.get(key) instanceof Number) {
return section.getDouble(key);
}
String raw = section.getString(key);
- if (raw == null || raw.isBlank()) {
+ if (raw.isBlank()) {
return fallback;
}
try {
@@ -1579,7 +1547,7 @@ private LabSettings loadLab(ConfigurationSection section, LabSettings fallback)
* @return packaged wipe sound
*/
private static org.bukkit.Sound defaultToolSound(String id) {
- return switch (id == null ? "" : id.toLowerCase(java.util.Locale.ROOT)) {
+ return switch (id.toLowerCase(java.util.Locale.ROOT)) {
case "water" -> org.bukkit.Sound.ITEM_BUCKET_EMPTY;
case "air" -> org.bukkit.Sound.ITEM_BRUSH_BRUSHING_SAND;
case "brush" -> org.bukkit.Sound.ITEM_BRUSH_BRUSHING_GENERIC;
@@ -1592,7 +1560,7 @@ private static org.bukkit.Sound defaultToolSound(String id) {
* @return packaged clean pane
*/
private static Material defaultCleanGlass(String id) {
- return switch (id == null ? "" : id.toLowerCase(java.util.Locale.ROOT)) {
+ return switch (id.toLowerCase(java.util.Locale.ROOT)) {
case "metal" -> Material.GRAY_STAINED_GLASS_PANE;
case "organic" -> Material.LIME_STAINED_GLASS_PANE;
case "stone" -> Material.LIGHT_GRAY_STAINED_GLASS_PANE;
@@ -1605,7 +1573,7 @@ private static Material defaultCleanGlass(String id) {
* @return packaged stain ids for that material
*/
private static List defaultStains(String id) {
- return switch (id == null ? "" : id.toLowerCase(java.util.Locale.ROOT)) {
+ return switch (id.toLowerCase(java.util.Locale.ROOT)) {
case "ceramic" -> List.of("limescale", "soil");
case "stone" -> List.of("limescale");
case "metal" -> List.of("rust");
@@ -1621,7 +1589,7 @@ private static List defaultStains(String id) {
*/
private void loadStaffPermission(org.bukkit.configuration.file.FileConfiguration config) {
String node = config.getString("permissions.staff", "archaeo.admin");
- staffPermission = node == null || node.isBlank() ? "archaeo.admin" : node.trim();
+ staffPermission = node.isBlank() ? "archaeo.admin" : node.trim();
}
/**
@@ -1635,7 +1603,7 @@ private void loadRarities(FileConfiguration config) {
ConfigurationSection root = config.getConfigurationSection("rarity");
if (root != null) {
for (String id : root.getKeys(false)) {
- if (id == null || id.isBlank()) {
+ if (id.isBlank()) {
continue;
}
String key = id.trim().toLowerCase(Locale.ROOT);
@@ -1648,7 +1616,7 @@ private void loadRarities(FileConfiguration config) {
continue;
}
String label = section.getString("label", fallback.label());
- if (label == null || label.isBlank()) {
+ if (label.isBlank()) {
label = fallback.label();
}
ChatColor color = parseChatColor(section.getString("color"), fallback.color());
@@ -1672,7 +1640,7 @@ private void loadRarityFromWeight(FileConfiguration config) {
}
List bands = new ArrayList<>();
for (String id : root.getKeys(false)) {
- if (id == null || id.isBlank()) {
+ if (id.isBlank()) {
continue;
}
String key = id.trim().toLowerCase(Locale.ROOT);
@@ -1735,10 +1703,11 @@ private static ChatColor parseChatColor(String raw, ChatColor fallback) {
*
* @param interest parsed interest file
* @param config root plugin config
+ * @return every interest level
* @throws IllegalStateException if an interest-levels block is missing
*/
- private void loadInterests(YamlConfiguration interest, FileConfiguration config) {
- interests.clear();
+ private static Map readInterests(YamlConfiguration interest, FileConfiguration config) {
+ Map interests = new EnumMap<>(InterestLevel.class);
ConfigurationSection root = sectionOr(interest, config, "interest-levels");
if (root == null) {
throw new IllegalStateException("Missing interest-levels in interest.yml");
@@ -1762,15 +1731,18 @@ private void loadInterests(YamlConfiguration interest, FileConfiguration config)
section.getDouble("disturbed-chance")
));
}
+ return interests;
}
/**
* Reads stratum depth bands from {@code strata.yml}.
*
* @param yaml parsed strata file
+ * @return stratum rows in file order
+ * @throws IllegalStateException if {@code strata} is missing
*/
- private void loadStrata(YamlConfiguration yaml) {
- strata.clear();
+ private static Map readStrata(YamlConfiguration yaml) {
+ Map strata = new LinkedHashMap<>();
ConfigurationSection root = yaml.getConfigurationSection("strata");
if (root == null) {
throw new IllegalStateException("Missing strata in strata.yml");
@@ -1789,15 +1761,18 @@ private void loadStrata(YamlConfiguration yaml) {
section.getBoolean("always-present", true)
));
}
+ return strata;
}
/**
* Reads find templates (size, tags, relic flag) from {@code artifacts.yml}.
*
* @param yaml parsed artifacts file
+ * @return artifact rows in file order
+ * @throws IllegalStateException if {@code artifacts} is missing
*/
- private void loadArtifacts(YamlConfiguration yaml) {
- artifacts.clear();
+ private Map readArtifacts(YamlConfiguration yaml) {
+ Map artifacts = new LinkedHashMap<>();
ConfigurationSection root = yaml.getConfigurationSection("artifacts");
if (root == null) {
throw new IllegalStateException("Missing artifacts in artifacts.yml");
@@ -1823,6 +1798,7 @@ private void loadArtifacts(YamlConfiguration yaml) {
section.getString("study-notes", "")
));
}
+ return artifacts;
}
/**
@@ -2004,9 +1980,11 @@ private void loadMaterials(YamlConfiguration yaml) {
* Reads site hint texts and filter rules from {@code hints.yml}.
*
* @param yaml parsed hints file
+ * @return hint rows in file order
+ * @throws IllegalStateException if {@code hints} is missing
*/
- private void loadHints(YamlConfiguration yaml) {
- hints.clear();
+ private static Map readHints(YamlConfiguration yaml) {
+ Map hints = new LinkedHashMap<>();
ConfigurationSection root = yaml.getConfigurationSection("hints");
if (root == null) {
throw new IllegalStateException("Missing hints in hints.yml");
@@ -2031,6 +2009,7 @@ private void loadHints(YamlConfiguration yaml) {
section.contains("require-disturbed") ? section.getBoolean("require-disturbed") : null
));
}
+ return hints;
}
/**
@@ -2044,13 +2023,11 @@ private static ConfigurationSection sectionOr(
FileConfiguration fallback,
String path
) {
- if (preferred != null) {
- ConfigurationSection section = preferred.getConfigurationSection(path);
- if (section != null) {
- return section;
- }
+ ConfigurationSection section = preferred.getConfigurationSection(path);
+ if (section != null) {
+ return section;
}
- return fallback == null ? null : fallback.getConfigurationSection(path);
+ return fallback.getConfigurationSection(path);
}
/**
diff --git a/src/main/java/net/tfminecraft/archaeo/config/ConfigEnums.java b/src/main/java/net/tfminecraft/archaeo/config/ConfigEnums.java
index 9754bb3..771e183 100644
--- a/src/main/java/net/tfminecraft/archaeo/config/ConfigEnums.java
+++ b/src/main/java/net/tfminecraft/archaeo/config/ConfigEnums.java
@@ -92,7 +92,6 @@ private static String fallbackKey(Sound sound) {
if (sound == null) {
return "none";
}
- NamespacedKey key = sound.getKey();
- return key == null ? sound.toString() : key.toString();
+ return sound.getKey().toString();
}
}
diff --git a/src/main/java/net/tfminecraft/archaeo/config/InterpretationTemplate.java b/src/main/java/net/tfminecraft/archaeo/config/InterpretationTemplate.java
index 3d3a995..f6e6a4b 100644
--- a/src/main/java/net/tfminecraft/archaeo/config/InterpretationTemplate.java
+++ b/src/main/java/net/tfminecraft/archaeo/config/InterpretationTemplate.java
@@ -63,7 +63,7 @@ public boolean suggestedFor(String artifactId) {
* @return whether this phrase may be offered
*/
public boolean appliesTo(FindProfile profile) {
- if (profiles == null || profiles.isEmpty()) {
+ if (profiles.isEmpty()) {
return true;
}
return profile != null && profiles.contains(profile);
diff --git a/src/main/java/net/tfminecraft/archaeo/config/LabStain.java b/src/main/java/net/tfminecraft/archaeo/config/LabStain.java
index 0193d93..21039d5 100644
--- a/src/main/java/net/tfminecraft/archaeo/config/LabStain.java
+++ b/src/main/java/net/tfminecraft/archaeo/config/LabStain.java
@@ -14,38 +14,38 @@
* @param tool rack tool id that wipes it
*/
public record LabStain(String id, String displayName, Material glass, String tool) {
+ // Rows come from LabSettings#defaults or CatalogRegistry#loadLab, which never leave a field
+ // null and never pick air for the pane (ConfigEnums#material falls back instead).
+
/**
- * @param toolId cursor tool
- * @return whether that tool wipes this stain
+ * @param toolId cursor tool, or {@code null} when the cursor is not a rack tool
+ * @return whether that tool wipes this stain; a blank {@code tool} accepts any cursor
*/
public boolean allowsTool(String toolId) {
- if (tool == null || tool.isBlank()) {
+ if (tool.isBlank()) {
return true;
}
- return tool.equalsIgnoreCase(toolId == null ? "" : toolId);
+ return tool.equalsIgnoreCase(toolId);
}
/**
* @return pane used on the field
*/
public Material pane() {
- return glass == null || glass.isAir() ? Material.BROWN_STAINED_GLASS_PANE : glass;
+ return glass;
}
/**
- * @return name shown on the pane
+ * @return name shown on the pane; a blank {@code display-name} shows the stain key
*/
public String label() {
- if (displayName == null || displayName.isBlank()) {
- return id == null || id.isBlank() ? "Dirt" : id;
- }
- return displayName;
+ return displayName.isBlank() ? id : displayName;
}
/**
* @return rack tool key, lower case
*/
public String toolId() {
- return tool == null ? "" : tool.trim().toLowerCase(Locale.ROOT);
+ return tool.trim().toLowerCase(Locale.ROOT);
}
}
diff --git a/src/main/java/net/tfminecraft/archaeo/config/MuseumSettings.java b/src/main/java/net/tfminecraft/archaeo/config/MuseumSettings.java
index b28ef52..04cc27c 100644
--- a/src/main/java/net/tfminecraft/archaeo/config/MuseumSettings.java
+++ b/src/main/java/net/tfminecraft/archaeo/config/MuseumSettings.java
@@ -4,7 +4,6 @@
import net.tfminecraft.archaeo.item.ItemRef;
import org.bukkit.Material;
import org.bukkit.block.Block;
-import org.bukkit.block.Shelf;
import org.bukkit.entity.ArmorStand;
import org.bukkit.entity.Entity;
import org.bukkit.entity.ItemDisplay;
@@ -36,9 +35,6 @@ public static MuseumSettings defaults() {
* @return whether that material is on the whitelist
*/
public boolean allowsVanilla(Material material) {
- if (material == null) {
- return false;
- }
if (allowsNamed(material.name())) {
return true;
}
@@ -50,9 +46,6 @@ public boolean allowsVanilla(Material material) {
* @return whether this frame kind is listed
*/
public boolean allowsFrame(ItemFrame frame) {
- if (frame == null) {
- return false;
- }
String name = frame.getType().name();
if ("GLOW_ITEM_FRAME".equals(name)) {
return allowsNamed("GLOW_ITEM_FRAME");
@@ -61,11 +54,12 @@ public boolean allowsFrame(ItemFrame frame) {
}
/**
- * @param entity armor stand, item display, or ItemsAdder furniture
- * @param matcher pack lookups
+ * Vanilla entity supports. ItemsAdder furniture entities are matched by {@link #allowsSupport}.
+ *
+ * @param entity frame, armor stand, or item display
* @return whether this support is listed
*/
- public boolean allowsEntity(Entity entity, ItemMatcher matcher) {
+ public boolean allowsEntity(Entity entity) {
if (entity == null) {
return false;
}
@@ -78,14 +72,6 @@ public boolean allowsEntity(Entity entity, ItemMatcher matcher) {
if (entity instanceof ItemDisplay) {
return allowsNamed("ITEM_DISPLAY");
}
- if (matcher == null) {
- return false;
- }
- for (ItemRef ref : displays) {
- if (matcher.matchesEntity(entity, ref)) {
- return true;
- }
- }
return false;
}
@@ -102,19 +88,12 @@ public boolean allowsSupport(
Block block,
ItemMatcher matcher
) {
- if (allowsEntity(entity, matcher)) {
+ if (allowsEntity(entity)) {
return true;
}
- if (block != null) {
- if (allowsVanilla(block.getType())) {
- return true;
- }
- if (block.getState() instanceof Shelf && allowsNamed("SHELF")) {
- return true;
- }
- }
- if (matcher == null) {
- return false;
+ // Every shelf block is a *_SHELF material, so allowsVanilla already covers SHELF.
+ if (block != null && allowsVanilla(block.getType())) {
+ return true;
}
for (ItemRef ref : displays) {
if (ref.kind() != ItemRef.Kind.ITEMSADDER) {
@@ -134,9 +113,6 @@ public boolean allowsSupport(
* @return whether that name is listed
*/
private boolean allowsNamed(String name) {
- if (name == null) {
- return false;
- }
String wanted = name.toUpperCase(Locale.ROOT);
for (ItemRef ref : displays) {
if (ref.kind() == ItemRef.Kind.VANILLA && wanted.equalsIgnoreCase(ref.primary())) {
diff --git a/src/main/java/net/tfminecraft/archaeo/establish/CampArchiveBook.java b/src/main/java/net/tfminecraft/archaeo/establish/CampArchiveBook.java
index f60ed66..b94d655 100644
--- a/src/main/java/net/tfminecraft/archaeo/establish/CampArchiveBook.java
+++ b/src/main/java/net/tfminecraft/archaeo/establish/CampArchiveBook.java
@@ -37,8 +37,7 @@ public CampArchiveBook(JavaPlugin plugin) {
* @return written book that opens the archive GUI
*/
public ItemStack create(Player closer, Site site) {
- String author = closer.getName() == null ? "Director" : closer.getName();
- return create(author, site);
+ return create(closer.getName(), site);
}
/**
@@ -51,9 +50,6 @@ public ItemStack create(Player closer, Site site) {
public ItemStack create(String authorName, Site site) {
ItemStack stack = new ItemStack(Material.WRITTEN_BOOK);
BookMeta meta = (BookMeta) stack.getItemMeta();
- if (meta == null) {
- return stack;
- }
String title = RecoveredFindItem.siteName(site);
if (title.length() > 32) {
title = title.substring(0, 32);
@@ -97,15 +93,10 @@ public ItemStack stampCopy(ItemStack original, ItemStack crafted) {
ItemStack stamped = crafted.clone();
BookMeta from = (BookMeta) original.getItemMeta();
BookMeta to = (BookMeta) stamped.getItemMeta();
- if (from == null || to == null) {
- return crafted;
- }
if (from.hasDisplayName()) {
to.setDisplayName(from.getDisplayName());
}
- if (from.hasLore()) {
- to.setLore(from.getLore());
- }
+ to.setLore(from.getLore());
String siteId = from.getPersistentDataContainer().get(siteIdKey, PersistentDataType.STRING);
to.getPersistentDataContainer().set(markerKey, PersistentDataType.BYTE, (byte) 1);
if (siteId != null) {
@@ -126,9 +117,6 @@ public void refresh(ItemStack stack, Site site) {
return;
}
BookMeta meta = (BookMeta) stack.getItemMeta();
- if (meta == null) {
- return;
- }
meta.setDisplayName(displayName(site));
meta.setLore(loreOf(site));
stack.setItemMeta(meta);
diff --git a/src/main/java/net/tfminecraft/archaeo/establish/CampBoard.java b/src/main/java/net/tfminecraft/archaeo/establish/CampBoard.java
index ef6ac6f..e681c5f 100644
--- a/src/main/java/net/tfminecraft/archaeo/establish/CampBoard.java
+++ b/src/main/java/net/tfminecraft/archaeo/establish/CampBoard.java
@@ -81,20 +81,6 @@ public UUID siteId() {
return siteId;
}
- /**
- * @return whether this copy includes director actions
- */
- public boolean director() {
- return director;
- }
-
- /**
- * @return whether Close is on this copy
- */
- public boolean canClose() {
- return canClose;
- }
-
/**
* {@inheritDoc}
*/
@@ -330,19 +316,18 @@ private static String biomeLabel(Site site) {
site.centerBlockX(),
site.getSurfaceY(),
site.centerBlockZ());
- NamespacedKey key = biomeKey(biome);
- if (key == null) {
- return "unknown";
- }
- return titleCase(key.getKey().replace('_', ' '));
+ return titleCase(biomeKey(biome).getKey().replace('_', ' '));
} catch (RuntimeException ignored) {
return "unknown";
}
}
/**
+ * Prefers Spigot's {@code RegistryAware#getKeyOrNull}, which paper-api 1.21.10 does not
+ * have, so it is looked up reflectively; otherwise falls back to {@link Biome#getKey()}.
+ *
* @param biome chunk biome
- * @return namespaced key, or {@code null}
+ * @return namespaced key
*/
@SuppressWarnings("deprecation")
private static NamespacedKey biomeKey(Biome biome) {
@@ -352,19 +337,16 @@ private static NamespacedKey biomeKey(Biome biome) {
return key;
}
} catch (ReflectiveOperationException ignored) {
- // Paper 1.21.10: RegistryAware helpers are absent
+ // paper-api 1.21.10 has no RegistryAware#getKeyOrNull; use getKey below.
}
return biome.getKey();
}
/**
- * @param raw biome id with spaces
+ * @param raw biome key path with spaces; namespaced keys are never empty
* @return each word capitalised
*/
private static String titleCase(String raw) {
- if (raw == null || raw.isBlank()) {
- return "unknown";
- }
String[] words = raw.split(" ");
StringBuilder text = new StringBuilder();
for (int i = 0; i < words.length; i++) {
@@ -375,10 +357,7 @@ private static String titleCase(String raw) {
if (text.length() > 0) {
text.append(' ');
}
- text.append(Character.toUpperCase(word.charAt(0)));
- if (word.length() > 1) {
- text.append(word.substring(1));
- }
+ text.append(Character.toUpperCase(word.charAt(0))).append(word.substring(1));
}
return text.toString();
}
@@ -451,7 +430,7 @@ private static String findCounts(Site site, String stratumId) {
int lost = 0;
int total = 0;
for (BuriedFind find : site.getFinds()) {
- if (find.getStratumId() == null || !find.getStratumId().equals(stratumId)) {
+ if (!stratumId.equals(find.getStratumId())) {
continue;
}
total++;
@@ -509,13 +488,11 @@ static String statusLabel(SiteStatus status) {
private static ItemStack named(Material material, String name, String... lore) {
ItemStack stack = new ItemStack(material);
ItemMeta meta = stack.getItemMeta();
- if (meta != null) {
- meta.setDisplayName(name);
- if (lore.length > 0) {
- meta.setLore(List.of(lore));
- }
- stack.setItemMeta(meta);
+ meta.setDisplayName(name);
+ if (lore.length > 0) {
+ meta.setLore(List.of(lore));
}
+ stack.setItemMeta(meta);
return stack;
}
}
diff --git a/src/main/java/net/tfminecraft/archaeo/establish/CampClosure.java b/src/main/java/net/tfminecraft/archaeo/establish/CampClosure.java
index aa0c4c8..979ab3f 100644
--- a/src/main/java/net/tfminecraft/archaeo/establish/CampClosure.java
+++ b/src/main/java/net/tfminecraft/archaeo/establish/CampClosure.java
@@ -46,11 +46,7 @@ public CampArchiveBook archiveBook() {
* @return whether the camp is now closed
*/
public boolean close(CommandSender closer, Site site) {
- if (closer == null || site == null || !site.isCampLocked()) {
- return false;
- }
- if (!site.closeCamp()) {
- closer.sendMessage("This excavation cannot be closed.");
+ if (closer == null || site == null || !site.closeCamp()) {
return false;
}
sites.save(site);
@@ -71,16 +67,14 @@ public boolean close(CommandSender closer, Site site) {
* @param site just closed
*/
private void deliverFieldBook(CommandSender closer, Site site) {
- String author = closer instanceof Player player && player.getName() != null
- ? player.getName()
- : "Staff";
- ItemStack book = archiveBook.create(author, site);
if (closer instanceof Player player) {
- giveStack(player, book);
+ giveStack(player, archiveBook.create(player, site));
return;
}
+ ItemStack book = archiveBook.create("Staff", site);
World world = plugin.getServer().getWorld(site.getWorldName());
- if (world == null || site.getCampX() == null || site.getCampY() == null || site.getCampZ() == null) {
+ // The dossier stores the camp origin as one unit, so a present X means Y and Z are there too.
+ if (world == null || site.getCampX() == null) {
closer.sendMessage("No field book could be given from console.");
return;
}
diff --git a/src/main/java/net/tfminecraft/archaeo/establish/CampFindBoard.java b/src/main/java/net/tfminecraft/archaeo/establish/CampFindBoard.java
index 0239313..79c9c07 100644
--- a/src/main/java/net/tfminecraft/archaeo/establish/CampFindBoard.java
+++ b/src/main/java/net/tfminecraft/archaeo/establish/CampFindBoard.java
@@ -181,8 +181,9 @@ private ItemStack conditionItem(BuriedFind find) {
List lore = new ArrayList<>();
if (RecoveredFindItem.conditionKnown(find)) {
lore.add(ChatColor.GRAY + "Conservation: " + ChatColor.WHITE + find.getConservation() + "%"
- + (grade == null || grade.isBlank() ? "" : ChatColor.GRAY + " · " + grade));
- } else if (find.getState() == FindState.RECOVERED) {
+ + (grade.isBlank() ? "" : ChatColor.GRAY + " · " + grade));
+ } else {
+ // Register rows have left the cut, so a piece that is neither cleaned nor lost is recovered.
lore.add(ChatColor.DARK_GRAY + "Clean the piece to read its condition.");
}
if (find.isDisturbedBeforeDig()) {
@@ -223,13 +224,11 @@ private ItemStack readingsItem(BuriedFind find) {
private static ItemStack named(Material material, String name, String... lore) {
ItemStack stack = new ItemStack(material);
ItemMeta meta = stack.getItemMeta();
- if (meta != null) {
- meta.setDisplayName(name);
- if (lore.length > 0) {
- meta.setLore(List.of(lore));
- }
- stack.setItemMeta(meta);
+ meta.setDisplayName(name);
+ if (lore.length > 0) {
+ meta.setLore(List.of(lore));
}
+ stack.setItemMeta(meta);
return stack;
}
}
diff --git a/src/main/java/net/tfminecraft/archaeo/establish/CampFindsBoard.java b/src/main/java/net/tfminecraft/archaeo/establish/CampFindsBoard.java
index f2020bf..090a873 100644
--- a/src/main/java/net/tfminecraft/archaeo/establish/CampFindsBoard.java
+++ b/src/main/java/net/tfminecraft/archaeo/establish/CampFindsBoard.java
@@ -68,17 +68,15 @@ public Inventory getInventory() {
* @param site excavation
*/
public void open(Player player, Site site) {
+ List register = site.cataloguedFinds();
finds.clear();
- for (BuriedFind find : site.cataloguedFinds()) {
+ for (BuriedFind find : register) {
finds.add(find.getId());
}
inventory = Bukkit.createInventory(this, 27, "Finds");
- int shown = Math.min(finds.size(), LIST_SLOTS);
+ int shown = Math.min(register.size(), LIST_SLOTS);
for (int i = 0; i < shown; i++) {
- BuriedFind find = site.findById(finds.get(i)).orElse(null);
- if (find != null) {
- inventory.setItem(i, rowItem(site, find));
- }
+ inventory.setItem(i, rowItem(site, register.get(i)));
}
if (director && site.getStatus() == SiteStatus.EXHAUSTED) {
inventory.setItem(SLOT_REPORT, named(
@@ -161,13 +159,9 @@ private static ItemStack named(ItemStack base, String name, String... lore) {
ItemStack stack = base == null ? new ItemStack(Material.BRICK) : base.clone();
stack.setAmount(1);
ItemMeta meta = stack.getItemMeta();
- if (meta != null) {
- meta.setDisplayName(name);
- if (lore.length > 0) {
- meta.setLore(List.of(lore));
- }
- stack.setItemMeta(meta);
- }
+ meta.setDisplayName(name);
+ meta.setLore(List.of(lore));
+ stack.setItemMeta(meta);
return stack;
}
}
diff --git a/src/main/java/net/tfminecraft/archaeo/establish/CampIdentifyBoard.java b/src/main/java/net/tfminecraft/archaeo/establish/CampIdentifyBoard.java
index 375ec28..22f83bb 100644
--- a/src/main/java/net/tfminecraft/archaeo/establish/CampIdentifyBoard.java
+++ b/src/main/java/net/tfminecraft/archaeo/establish/CampIdentifyBoard.java
@@ -190,17 +190,17 @@ String offerAt(int slot) {
/**
* @param site excavation
- * @param template artifact, or {@code null}
+ * @param template artifact, or {@code null}; the catalogue always loads tag sets, if empty
* @return tags that only weight the draw
*/
private Set weightTags(Site site, ArtifactTemplate template) {
Set tags = new HashSet<>();
- if (template != null && template.tags() != null) {
+ if (template != null) {
tags.addAll(template.tags());
}
for (String hintId : site.getHintIds()) {
var hint = catalogs.hint(hintId);
- if (hint != null && hint.tags() != null) {
+ if (hint != null) {
tags.addAll(hint.tags());
}
}
@@ -228,13 +228,9 @@ private static ItemStack offerItem(InterpretationTemplate option) {
private static ItemStack named(Material material, String name, String... lore) {
ItemStack stack = new ItemStack(material);
ItemMeta meta = stack.getItemMeta();
- if (meta != null) {
- meta.setDisplayName(name);
- if (lore.length > 0) {
- meta.setLore(List.of(lore));
- }
- stack.setItemMeta(meta);
- }
+ meta.setDisplayName(name);
+ meta.setLore(List.of(lore));
+ stack.setItemMeta(meta);
return stack;
}
}
diff --git a/src/main/java/net/tfminecraft/archaeo/establish/CampListener.java b/src/main/java/net/tfminecraft/archaeo/establish/CampListener.java
index 67e5ba4..2a28520 100644
--- a/src/main/java/net/tfminecraft/archaeo/establish/CampListener.java
+++ b/src/main/java/net/tfminecraft/archaeo/establish/CampListener.java
@@ -215,7 +215,7 @@ public void onInteract(PlayerInteractEvent event) {
Action action = event.getAction();
Block block = event.getClickedBlock();
if (event.getHand() != EquipmentSlot.HAND) {
- if (action == Action.RIGHT_CLICK_BLOCK && block != null && locked(block)) {
+ if (block != null && action == Action.RIGHT_CLICK_BLOCK && locked(block)) {
denyUse(event);
}
return;
@@ -229,20 +229,13 @@ public void onInteract(PlayerInteractEvent event) {
if (action != Action.RIGHT_CLICK_BLOCK && action != Action.RIGHT_CLICK_AIR) {
return;
}
- if (block != null && locked(block)) {
+ Site camp = block == null ? null : lockedCamp(block);
+ if (camp != null) {
denyUse(event);
- Site site = sites.findLockedCampBlock(
- block.getWorld().getName(),
- block.getX(),
- block.getY(),
- block.getZ()).orElse(null);
- if (site == null) {
- return;
- }
if (establish.isRelocating(event.getPlayer())) {
establish.tryCancelMove(event.getPlayer());
}
- openBoard(event.getPlayer(), site);
+ openBoard(event.getPlayer(), camp);
return;
}
if (tryOpenArchive(event.getPlayer(), event.getItem(), event)) {
@@ -352,16 +345,12 @@ public void onBoardClick(InventoryClickEvent event) {
if (!(event.getInventory().getHolder() instanceof CampBoard board)) {
return;
}
- event.setCancelled(true);
- if (!(event.getWhoClicked() instanceof Player player)) {
+ Player player = topClicker(event);
+ if (player == null) {
return;
}
- if (event.getClickedInventory() == null || event.getClickedInventory() != event.getView().getTopInventory()) {
- return;
- }
- Site site = sites.findById(board.siteId()).orElse(null);
- if (site == null || !site.mayConsult()) {
- player.closeInventory();
+ Site site = consultable(player, board.siteId());
+ if (site == null) {
return;
}
int slot = event.getRawSlot();
@@ -370,7 +359,7 @@ public void onBoardClick(InventoryClickEvent event) {
return;
}
if (slot == CampBoard.SLOT_PERSONAL) {
- openStaff(player, site, board.director());
+ openStaff(player, site, staffing(player, site));
return;
}
if (slot == CampBoard.SLOT_LIMITS) {
@@ -381,11 +370,11 @@ public void onBoardClick(InventoryClickEvent event) {
outline.show(player, site);
return;
}
- if (slot == CampBoard.SLOT_CLOSE && board.canClose()) {
+ if (slot == CampBoard.SLOT_CLOSE && canCloseCamp(player, site)) {
beginClosePrompt(player, site);
return;
}
- if (!board.director()) {
+ if (!staffing(player, site)) {
return;
}
if (slot == CampBoard.SLOT_RENAME) {
@@ -419,16 +408,12 @@ public void onStaffClick(InventoryClickEvent event) {
if (!(event.getInventory().getHolder() instanceof CampStaffBoard staff)) {
return;
}
- event.setCancelled(true);
- if (!(event.getWhoClicked() instanceof Player player)) {
- return;
- }
- if (event.getClickedInventory() == null || event.getClickedInventory() != event.getView().getTopInventory()) {
+ Player player = topClicker(event);
+ if (player == null) {
return;
}
- Site site = sites.findById(staff.siteId()).orElse(null);
- if (site == null || !site.mayConsult()) {
- player.closeInventory();
+ Site site = consultable(player, staff.siteId());
+ if (site == null) {
return;
}
boolean director = staffing(player, site);
@@ -467,16 +452,12 @@ public void onWorkerClick(InventoryClickEvent event) {
if (!(event.getInventory().getHolder() instanceof CampWorkerBoard file)) {
return;
}
- event.setCancelled(true);
- if (!(event.getWhoClicked() instanceof Player player)) {
+ Player player = topClicker(event);
+ if (player == null) {
return;
}
- if (event.getClickedInventory() == null || event.getClickedInventory() != event.getView().getTopInventory()) {
- return;
- }
- Site site = sites.findById(file.siteId()).orElse(null);
- if (site == null || !site.mayConsult()) {
- player.closeInventory();
+ Site site = consultable(player, file.siteId());
+ if (site == null) {
return;
}
int slot = event.getRawSlot();
@@ -509,16 +490,12 @@ public void onFindsClick(InventoryClickEvent event) {
if (!(event.getInventory().getHolder() instanceof CampFindsBoard board)) {
return;
}
- event.setCancelled(true);
- if (!(event.getWhoClicked() instanceof Player player)) {
- return;
- }
- if (event.getClickedInventory() == null || event.getClickedInventory() != event.getView().getTopInventory()) {
+ Player player = topClicker(event);
+ if (player == null) {
return;
}
- Site site = sites.findById(board.siteId()).orElse(null);
- if (site == null || !site.mayConsult()) {
- player.closeInventory();
+ Site site = consultable(player, board.siteId());
+ if (site == null) {
return;
}
int slot = event.getRawSlot();
@@ -546,16 +523,12 @@ public void onFindFileClick(InventoryClickEvent event) {
if (!(event.getInventory().getHolder() instanceof CampFindBoard file)) {
return;
}
- event.setCancelled(true);
- if (!(event.getWhoClicked() instanceof Player player)) {
+ Player player = topClicker(event);
+ if (player == null) {
return;
}
- if (event.getClickedInventory() == null || event.getClickedInventory() != event.getView().getTopInventory()) {
- return;
- }
- Site site = sites.findById(file.siteId()).orElse(null);
- if (site == null || !site.mayConsult()) {
- player.closeInventory();
+ Site site = consultable(player, file.siteId());
+ if (site == null) {
return;
}
int slot = event.getRawSlot();
@@ -578,16 +551,12 @@ public void onIdentifyClick(InventoryClickEvent event) {
if (!(event.getInventory().getHolder() instanceof CampIdentifyBoard board)) {
return;
}
- event.setCancelled(true);
- if (!(event.getWhoClicked() instanceof Player player)) {
- return;
- }
- if (event.getClickedInventory() == null || event.getClickedInventory() != event.getView().getTopInventory()) {
+ Player player = topClicker(event);
+ if (player == null) {
return;
}
- Site site = sites.findById(board.siteId()).orElse(null);
- if (site == null || !site.mayConsult()) {
- player.closeInventory();
+ Site site = consultable(player, board.siteId());
+ if (site == null) {
return;
}
int slot = event.getRawSlot();
@@ -627,9 +596,6 @@ private void fileStationReading(Player player, Site site, CampIdentifyBoard boar
player.sendMessage("You are not authorised to write this record.");
return;
}
- if (board.type() == null) {
- return;
- }
BuriedFind find = site.findById(board.findId()).orElse(null);
if (find == null || find.getState() != FindState.RECOVERED) {
player.sendMessage("That find cannot be identified.");
@@ -746,19 +712,12 @@ public void onWoolPickerClick(InventoryClickEvent event) {
if (!(event.getInventory().getHolder() instanceof CampWoolPicker picker)) {
return;
}
- event.setCancelled(true);
- if (!(event.getWhoClicked() instanceof Player player)) {
- return;
- }
- if (event.getClickedInventory() == null || event.getClickedInventory() != event.getView().getTopInventory()) {
+ Player player = topClicker(event);
+ if (player == null) {
return;
}
Site site = sites.findById(picker.siteId()).orElse(null);
- if (site == null || !site.isCampLocked()) {
- player.closeInventory();
- return;
- }
- if (!site.isDirector(player.getUniqueId())) {
+ if (site == null || !staffing(player, site)) {
player.closeInventory();
return;
}
@@ -798,7 +757,7 @@ public void onBoardDrag(InventoryDragEvent event) {
@EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = true)
public void onChat(AsyncPlayerChatEvent event) {
Player player = event.getPlayer();
- String raw = event.getMessage() == null ? "" : event.getMessage().trim();
+ String raw = event.getMessage().trim();
if (establish.isRelocating(player) && raw.equalsIgnoreCase("cancel")) {
event.setCancelled(true);
plugin.getServer().getScheduler().runTask(plugin, () -> establish.tryCancelMove(player));
@@ -842,7 +801,7 @@ private void handleInviteChat(Player player, String raw) {
return;
}
OfflinePlayer target = CampNames.known(raw);
- if (target == null || target.getUniqueId() == null) {
+ if (target == null) {
player.sendMessage("No player with that name has joined this server.");
inviteForSite.put(player.getUniqueId(), siteId);
return;
@@ -859,10 +818,7 @@ private void handleInviteChat(Player player, String raw) {
openStaff(player, site, true);
return;
}
- if (!site.grantExcavator(target.getUniqueId())) {
- player.sendMessage("Could not add that player.");
- return;
- }
+ site.grantExcavator(target.getUniqueId());
sites.save(site);
String added = CampNames.of(player, target.getUniqueId());
player.sendMessage("Added " + added + " to the excavation staff.");
@@ -887,9 +843,7 @@ private void openStaff(Player player, Site site, boolean director) {
* @param site excavation
*/
private void openBoard(Player player, Site site) {
- if (player.getWorld() != null) {
- handPick.ensureJornada(site, player.getWorld());
- }
+ handPick.ensureJornada(site, player.getWorld());
new CampBoard(site.getId(), staffing(player, site), canCloseCamp(player, site), catalogs)
.open(player, site);
}
@@ -940,20 +894,6 @@ private void handleCloseChat(Player player, String raw) {
player.sendMessage("That excavation can no longer be closed.");
return;
}
- closeCamp(player, site);
- }
-
- /**
- * Unlocks the camp and gives the closer a field book that still opens this record.
- *
- * @param player director or server staff
- * @param site standing camp
- */
- private void closeCamp(Player player, Site site) {
- if (!canCloseCamp(player, site)) {
- player.sendMessage("Only the director or server staff may close this excavation.");
- return;
- }
campClosure.close(player, site);
}
@@ -981,6 +921,38 @@ private boolean tryOpenArchive(Player player, ItemStack stack, PlayerInteractEve
return true;
}
+ /**
+ * Cancels every click while a camp window is open, so no item can be moved in or out.
+ *
+ * @param event click inside a camp window
+ * @return the player who clicked the window itself, or {@code null} for their own inventory
+ * or the gap outside the window
+ */
+ private static Player topClicker(InventoryClickEvent event) {
+ event.setCancelled(true);
+ if (event.getClickedInventory() == null || event.getClickedInventory() != event.getView().getTopInventory()) {
+ return null;
+ }
+ // Only players can hold an inventory view open on a Paper server.
+ return (Player) event.getWhoClicked();
+ }
+
+ /**
+ * Re-reads the record a window was drawn from and closes the window when it is gone.
+ *
+ * @param player viewer
+ * @param siteId excavation the window belongs to
+ * @return the record if it may still be read, otherwise {@code null}
+ */
+ private Site consultable(Player player, UUID siteId) {
+ Site site = sites.findById(siteId).orElse(null);
+ if (site == null || !site.mayConsult()) {
+ player.closeInventory();
+ return null;
+ }
+ return site;
+ }
+
/**
* @param player viewer
* @param site excavation
@@ -1028,11 +1000,19 @@ private void giveStack(Player player, ItemStack stack) {
* @return whether it belongs to a locked camp
*/
private boolean locked(Block block) {
+ return lockedCamp(block) != null;
+ }
+
+ /**
+ * @param block world block
+ * @return locked camp owning it, or {@code null}
+ */
+ private Site lockedCamp(Block block) {
return sites.findLockedCampBlock(
block.getWorld().getName(),
block.getX(),
block.getY(),
- block.getZ()).isPresent();
+ block.getZ()).orElse(null);
}
/**
diff --git a/src/main/java/net/tfminecraft/archaeo/establish/CampNames.java b/src/main/java/net/tfminecraft/archaeo/establish/CampNames.java
index 01195ae..b6dd381 100644
--- a/src/main/java/net/tfminecraft/archaeo/establish/CampNames.java
+++ b/src/main/java/net/tfminecraft/archaeo/establish/CampNames.java
@@ -48,9 +48,9 @@ public static String of(Player viewer, UUID id) {
if (online != null) {
return online.getName();
}
- OfflinePlayer offline = Bukkit.getOfflinePlayer(id);
- String name = offline.getName();
- if (name != null && !name.isBlank()) {
+ // Paper and Spigot answer a real name or null here, never a blank one.
+ String name = Bukkit.getOfflinePlayer(id).getName();
+ if (name != null) {
return name;
}
return id.toString().substring(0, 8);
@@ -59,11 +59,11 @@ public static String of(Player viewer, UUID id) {
/**
* Matches an online player or someone who has already joined this server.
*
- * @param raw chat name
+ * @param raw chat or command name, never {@code null}
* @return known player, or {@code null}
*/
public static OfflinePlayer known(String raw) {
- if (raw == null || raw.isBlank()) {
+ if (raw.isBlank()) {
return null;
}
String name = raw.trim();
diff --git a/src/main/java/net/tfminecraft/archaeo/establish/CampStaffBoard.java b/src/main/java/net/tfminecraft/archaeo/establish/CampStaffBoard.java
index 614400a..1ac35d1 100644
--- a/src/main/java/net/tfminecraft/archaeo/establish/CampStaffBoard.java
+++ b/src/main/java/net/tfminecraft/archaeo/establish/CampStaffBoard.java
@@ -54,13 +54,6 @@ public UUID siteId() {
return siteId;
}
- /**
- * @return whether this copy includes add/remove
- */
- public boolean director() {
- return director;
- }
-
/**
* {@inheritDoc}
*/
@@ -137,9 +130,6 @@ private ItemStack addItem() {
private ItemStack head(Player viewer, Site site, UUID member) {
ItemStack stack = new ItemStack(Material.PLAYER_HEAD);
SkullMeta meta = (SkullMeta) stack.getItemMeta();
- if (meta == null) {
- return stack;
- }
OfflinePlayer owner = Bukkit.getOfflinePlayer(member);
meta.setOwningPlayer(owner);
SiteRole role = site.roleOf(member);
@@ -162,13 +152,9 @@ private ItemStack head(Player viewer, Site site, UUID member) {
private static ItemStack named(Material material, String name, String... lore) {
ItemStack stack = new ItemStack(material);
ItemMeta meta = stack.getItemMeta();
- if (meta != null) {
- meta.setDisplayName(name);
- if (lore.length > 0) {
- meta.setLore(List.of(lore));
- }
- stack.setItemMeta(meta);
- }
+ meta.setDisplayName(name);
+ meta.setLore(List.of(lore));
+ stack.setItemMeta(meta);
return stack;
}
}
diff --git a/src/main/java/net/tfminecraft/archaeo/establish/CampTemplate.java b/src/main/java/net/tfminecraft/archaeo/establish/CampTemplate.java
index 8aac26c..c2f8130 100644
--- a/src/main/java/net/tfminecraft/archaeo/establish/CampTemplate.java
+++ b/src/main/java/net/tfminecraft/archaeo/establish/CampTemplate.java
@@ -213,24 +213,23 @@ public static int clampOrigin(int desired, int minOff, int maxOff, int chunkMin,
}
/**
+ * The sign is the template's only rotatable piece and the campfire its only lightable one.
+ *
* @param material piece type
- * @param facing camp front (used when the block is directional)
+ * @param facing camp front, one of the four cardinals from {@link #facingFromYaw(float)}
* @return block data for {@link org.bukkit.entity.Player#sendBlockChange}
*/
public static BlockData dataFor(Material material, BlockFace facing) {
BlockData data = material.createBlockData();
- if (material == Material.OAK_SIGN && data instanceof Rotatable rotatable) {
- BlockFace towardPlayer = facing.getOppositeFace();
- if (towardPlayer.isCartesian() && towardPlayer.getModY() == 0) {
- rotatable.setRotation(towardPlayer);
- }
- } else if (data instanceof Directional directional && facing.isCartesian() && facing.getModY() == 0) {
+ if (data instanceof Rotatable rotatable) {
+ rotatable.setRotation(facing.getOppositeFace());
+ } else if (data instanceof Directional directional) {
directional.setFacing(facing);
}
if (data instanceof Slab slab) {
slab.setType(Slab.Type.BOTTOM);
}
- if (data instanceof Lightable lightable && material == Material.CAMPFIRE) {
+ if (data instanceof Lightable lightable) {
lightable.setLit(true);
}
return data;
diff --git a/src/main/java/net/tfminecraft/archaeo/establish/CampWoolPicker.java b/src/main/java/net/tfminecraft/archaeo/establish/CampWoolPicker.java
index 6d801ec..932afb3 100644
--- a/src/main/java/net/tfminecraft/archaeo/establish/CampWoolPicker.java
+++ b/src/main/java/net/tfminecraft/archaeo/establish/CampWoolPicker.java
@@ -107,13 +107,9 @@ static DyeColor colorAt(int slot) {
private static ItemStack named(Material material, String name, String... lore) {
ItemStack stack = new ItemStack(material);
ItemMeta meta = stack.getItemMeta();
- if (meta != null) {
- meta.setDisplayName(name);
- if (lore.length > 0) {
- meta.setLore(List.of(lore));
- }
- stack.setItemMeta(meta);
- }
+ meta.setDisplayName(name);
+ meta.setLore(List.of(lore));
+ stack.setItemMeta(meta);
return stack;
}
}
diff --git a/src/main/java/net/tfminecraft/archaeo/establish/CampWools.java b/src/main/java/net/tfminecraft/archaeo/establish/CampWools.java
index c529691..281e930 100644
--- a/src/main/java/net/tfminecraft/archaeo/establish/CampWools.java
+++ b/src/main/java/net/tfminecraft/archaeo/establish/CampWools.java
@@ -35,13 +35,8 @@ public static Material woolOf(String name) {
* @return wool block for that dye
*/
public static Material woolOf(String name, DyeColor fallback) {
- DyeColor color = parse(name, fallback);
- Material material = Material.matchMaterial(color.name() + "_WOOL");
- if (material != null) {
- return material;
- }
- Material fallbackWool = Material.matchMaterial(fallback.name() + "_WOOL");
- return fallbackWool != null ? fallbackWool : Material.RED_WOOL;
+ // Every dye colour has a matching wool block.
+ return Material.valueOf(parse(name, fallback).name() + "_WOOL");
}
/**
diff --git a/src/main/java/net/tfminecraft/archaeo/establish/CampWorkerBoard.java b/src/main/java/net/tfminecraft/archaeo/establish/CampWorkerBoard.java
index 6028f28..ae2b39f 100644
--- a/src/main/java/net/tfminecraft/archaeo/establish/CampWorkerBoard.java
+++ b/src/main/java/net/tfminecraft/archaeo/establish/CampWorkerBoard.java
@@ -70,13 +70,6 @@ public UUID member() {
return member;
}
- /**
- * @return whether this copy includes role buttons and the dismissal
- */
- public boolean director() {
- return director;
- }
-
/**
* {@inheritDoc}
*/
@@ -91,11 +84,8 @@ public Inventory getInventory() {
*/
public void open(Player player, Site site) {
String name = CampNames.of(player, member);
- String title = name;
- if (title.length() > 32) {
- title = title.substring(0, 32);
- }
- inventory = Bukkit.createInventory(this, 27, title);
+ // Player names and the short-id fallback both fit the 32-character title limit.
+ inventory = Bukkit.createInventory(this, 27, name);
ItemStack filler = named(Material.GRAY_STAINED_GLASS_PANE, " ");
for (int slot = 0; slot < 27; slot++) {
inventory.setItem(slot, filler);
@@ -161,9 +151,6 @@ static SiteRole roleAt(int slot) {
private ItemStack headItem(String name, SiteRole role, WorkerRecord record) {
ItemStack stack = new ItemStack(Material.PLAYER_HEAD);
SkullMeta meta = (SkullMeta) stack.getItemMeta();
- if (meta == null) {
- return stack;
- }
OfflinePlayer owner = Bukkit.getOfflinePlayer(member);
meta.setOwningPlayer(owner);
meta.setDisplayName(ChatColor.WHITE + name);
@@ -346,16 +333,14 @@ private static ItemStack named(Material material, String name, String... lore) {
private static ItemStack named(Material material, String name, boolean glint, String... lore) {
ItemStack stack = new ItemStack(material);
ItemMeta meta = stack.getItemMeta();
- if (meta != null) {
- meta.setDisplayName(name);
- if (lore.length > 0) {
- meta.setLore(List.of(lore));
- }
- if (glint) {
- meta.setEnchantmentGlintOverride(true);
- }
- stack.setItemMeta(meta);
+ meta.setDisplayName(name);
+ if (lore.length > 0) {
+ meta.setLore(List.of(lore));
}
+ if (glint) {
+ meta.setEnchantmentGlintOverride(true);
+ }
+ stack.setItemMeta(meta);
return stack;
}
}
diff --git a/src/main/java/net/tfminecraft/archaeo/establish/EstablishListener.java b/src/main/java/net/tfminecraft/archaeo/establish/EstablishListener.java
index b5ab579..afd2e6e 100644
--- a/src/main/java/net/tfminecraft/archaeo/establish/EstablishListener.java
+++ b/src/main/java/net/tfminecraft/archaeo/establish/EstablishListener.java
@@ -1,6 +1,8 @@
package net.tfminecraft.archaeo.establish;
import net.tfminecraft.archaeo.item.EstablishItem;
+import org.bukkit.Bukkit;
+import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
@@ -11,12 +13,18 @@
import org.bukkit.event.player.PlayerQuitEvent;
import org.bukkit.inventory.EquipmentSlot;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.UUID;
+
/**
* Routes establishment-kit clicks into {@link EstablishService}.
*/
public class EstablishListener implements Listener {
private final EstablishItem item;
private final EstablishService service;
+ /** Server tick of each player's last main-hand kit click, until the matching off-hand event. */
+ private final Map mainHandKitTick = new HashMap<>();
/**
* @param item kit recognition
@@ -29,17 +37,30 @@ public EstablishListener(EstablishItem item, EstablishService service) {
/**
* Right-click plants; sneak+left-click cycles wool on first plant; left-click aborts a camp move.
+ * The server fires one event per hand, so a kit held only in the off hand arrives on the
+ * off-hand event. When the main hand held a kit for the same click, the main-hand event has
+ * already acted, even if it spent the last kit there.
*
* @param event interact event
*/
@EventHandler(priority = EventPriority.HIGH, ignoreCancelled = false)
public void onInteract(PlayerInteractEvent event) {
- if (event.getHand() != EquipmentSlot.HAND) {
- return;
+ Player player = event.getPlayer();
+ if (event.getHand() == EquipmentSlot.OFF_HAND) {
+ // Same tick-key as wool cycling. Removing the mark here keeps it from outliving the click.
+ Integer mainTick = mainHandKitTick.remove(player.getUniqueId());
+ if (mainTick != null && mainTick == Bukkit.getCurrentTick()) {
+ return;
+ }
}
if (!item.isEstablish(event.getItem())) {
return;
}
+ if (event.getHand() == EquipmentSlot.HAND) {
+ mainHandKitTick.put(player.getUniqueId(), Bukkit.getCurrentTick());
+ } else if (item.isEstablish(player.getInventory().getItemInMainHand())) {
+ return;
+ }
Action action = event.getAction();
if (action == Action.LEFT_CLICK_AIR || action == Action.LEFT_CLICK_BLOCK) {
if (service.tryCancelMove(event.getPlayer())) {
@@ -92,6 +113,7 @@ public void onHeld(PlayerItemHeldEvent event) {
*/
@EventHandler
public void onQuit(PlayerQuitEvent event) {
+ mainHandKitTick.remove(event.getPlayer().getUniqueId());
service.clearSession(event.getPlayer());
service.clearPreview(event.getPlayer());
service.hideHud(event.getPlayer());
diff --git a/src/main/java/net/tfminecraft/archaeo/establish/EstablishService.java b/src/main/java/net/tfminecraft/archaeo/establish/EstablishService.java
index 3b84ab7..299a3e0 100644
--- a/src/main/java/net/tfminecraft/archaeo/establish/EstablishService.java
+++ b/src/main/java/net/tfminecraft/archaeo/establish/EstablishService.java
@@ -9,6 +9,7 @@
import net.tfminecraft.archaeo.model.Site;
import net.tfminecraft.archaeo.model.SiteStatus;
import net.tfminecraft.archaeo.site.SiteRepository;
+import org.bukkit.Bukkit;
import org.bukkit.ChatColor;
import org.bukkit.Chunk;
import org.bukkit.DyeColor;
@@ -56,10 +57,9 @@ public class EstablishService {
private final Map renameForSite = new ConcurrentHashMap<>();
private final Map woolChoicePrimary = new ConcurrentHashMap<>();
private final Map woolChoiceSecondary = new ConcurrentHashMap<>();
- private final Map woolCycleTick = new ConcurrentHashMap<>();
+ private final Map woolCycleTick = new ConcurrentHashMap<>();
private final Map moveAimProxies = new ConcurrentHashMap<>();
- private final Map lastPulse = new ConcurrentHashMap<>();
- private final Map lastPulseSite = new ConcurrentHashMap<>();
+ private final Map lastPulse = new ConcurrentHashMap<>();
private FindDustService findDust;
private SiteLabelRefresh labels;
@@ -126,26 +126,13 @@ public void stop() {
}
previews.clear();
lastPulse.clear();
- lastPulseSite.clear();
for (UUID playerId : new HashSet<>(moveAimProxies.keySet())) {
- Player player = plugin.getServer().getPlayer(playerId);
- if (player != null) {
- removeMoveAimProxy(player);
- } else {
- removeMoveAimProxy(playerId);
- }
+ removeMoveAimProxy(playerId);
}
- for (UUID playerId : new HashSet<>(bars.keySet())) {
- Player player = plugin.getServer().getPlayer(playerId);
- if (player != null) {
- hideHud(player);
- } else {
- BossBar bar = bars.remove(playerId);
- if (bar != null) {
- bar.removeAll();
- }
- }
+ for (BossBar bar : bars.values()) {
+ bar.removeAll();
}
+ bars.clear();
}
/**
@@ -197,8 +184,7 @@ public void pulse() {
*/
private void pulsePlacement(Player player, Site site, boolean moving) {
CampPlacement placement = assess(player, site, aimBlock(player, site));
- lastPulse.put(player.getUniqueId(), placement);
- lastPulseSite.put(player.getUniqueId(), site.getId());
+ lastPulse.put(player.getUniqueId(), new Pulse(site.getId(), placement));
applyGhosts(player, site, placement);
if (placement.allowed()) {
String action = moving
@@ -264,7 +250,7 @@ public boolean tryFinishMove(Player player) {
*/
public boolean tryFinishMoveOnAimProxy(Player player, Entity clicked) {
UUID standId = moveAimProxies.get(player.getUniqueId());
- if (standId == null || clicked == null || !standId.equals(clicked.getUniqueId())) {
+ if (standId == null || !standId.equals(clicked.getUniqueId())) {
return false;
}
tryFinishMove(player);
@@ -280,7 +266,7 @@ public boolean tryFinishMoveOnAimProxy(Player player, Entity clicked) {
*/
public boolean handleMoveAimProxyAttack(Player player, Entity clicked) {
UUID standId = moveAimProxies.get(player.getUniqueId());
- if (standId == null || clicked == null || !standId.equals(clicked.getUniqueId())) {
+ if (standId == null || !standId.equals(clicked.getUniqueId())) {
return false;
}
tryCancelMove(player);
@@ -349,7 +335,6 @@ public boolean handleRenameChat(Player player, String raw) {
}
if (raw.equalsIgnoreCase("cancel")) {
player.sendMessage("Rename cancelled.");
- renameForSite.remove(player.getUniqueId());
return true;
}
Site site = sites.findById(siteId).orElse(null);
@@ -357,7 +342,7 @@ public boolean handleRenameChat(Player player, String raw) {
player.sendMessage("That excavation is no longer active.");
return true;
}
- if (site.getDirector() == null || !site.getDirector().equals(player.getUniqueId())) {
+ if (!site.isDirector(player.getUniqueId())) {
return true;
}
String name = raw.replace('§', ' ').trim();
@@ -423,12 +408,13 @@ public void cycleWool(Player player) {
if (isRelocating(player)) {
return;
}
- long tick = player.getWorld().getFullTime();
- Long previous = woolCycleTick.put(player.getUniqueId(), tick);
+ // The server tick, not World#getFullTime: the day clock stands still when doDaylightCycle is off.
+ int tick = Bukkit.getCurrentTick();
+ Integer previous = woolCycleTick.put(player.getUniqueId(), tick);
if (previous != null && previous == tick) {
return;
}
- Site site = activeSite(player);
+ Site site = nearestConfirmedHidden(player);
String current = secondaryWoolName(player, site);
String next = CampWools.next(current).name();
woolChoiceSecondary.put(player.getUniqueId(), next);
@@ -446,7 +432,7 @@ public void cycleWool(Player player) {
* @param role primary ({@code W}) or secondary ({@code R})
*/
public void applyCampWool(Player player, Site site, DyeColor color, CampWoolRole role) {
- if (site.getDirector() == null || !site.getDirector().equals(player.getUniqueId())) {
+ if (!site.isDirector(player.getUniqueId())) {
return;
}
DyeColor fallback = role == CampWoolRole.PRIMARY ? DyeColor.WHITE : DyeColor.RED;
@@ -474,11 +460,8 @@ public void applyCampWool(Player player, Site site, DyeColor color, CampWoolRole
* @param to new wool
*/
private void recolorWoolCells(World world, Site site, CampWoolRole role, Material to) {
- Integer originX = site.getCampX();
- Integer originY = site.getCampY();
- Integer originZ = site.getCampZ();
BlockFace facing = campFacing(site);
- if (originX == null || originY == null || originZ == null || facing == null) {
+ if (facing == null) {
DyeColor fallback = role == CampWoolRole.PRIMARY ? DyeColor.WHITE : DyeColor.RED;
String stored = role == CampWoolRole.PRIMARY ? site.getCampWoolPrimary() : site.getCampWoolSecondary();
Material from = CampWools.woolOf(stored, fallback);
@@ -499,20 +482,24 @@ private void recolorWoolCells(World world, Site site, CampWoolRole role, Materia
}
Vector offset = CampTemplate.rotate(piece.dx(), piece.dz(), facing);
world.getBlockAt(
- originX + offset.getBlockX(),
- originY + piece.dy(),
- originZ + offset.getBlockZ()
+ site.getCampX() + offset.getBlockX(),
+ site.getCampY() + piece.dy(),
+ site.getCampZ() + offset.getBlockZ()
).setType(to, false);
}
}
/**
* Stored facing, or inferred from the sign cell of the tent grid ({@code N} at local −2, −2).
+ * The dossier stores the three origin coordinates together, so a present X means a full origin.
*
* @param site excavation
- * @return cardinal, or {@code null}
+ * @return cardinal, or {@code null} when the camp origin or its orientation is unknown
*/
private BlockFace campFacing(Site site) {
+ if (site.getCampX() == null) {
+ return null;
+ }
if (site.getCampFacing() != null) {
try {
BlockFace stored = BlockFace.valueOf(site.getCampFacing());
@@ -523,7 +510,7 @@ private BlockFace campFacing(Site site) {
// fall through to sign inference
}
}
- if (site.getCampX() == null || site.getCampSignX() == null) {
+ if (site.getCampSignX() == null) {
return null;
}
int dx = site.getCampSignX() - site.getCampX();
@@ -547,9 +534,6 @@ private BlockFace campFacing(Site site) {
* @param siteId excavation being erased
*/
public void abortSessionsFor(UUID siteId) {
- if (siteId == null) {
- return;
- }
for (Player player : plugin.getServer().getOnlinePlayers()) {
if (siteId.equals(relocating.get(player.getUniqueId()))) {
cancelRelocate(player);
@@ -618,10 +602,6 @@ private void tryPlace(Player player, Site site, boolean moving) {
player.sendMessage(placement.reason());
return;
}
- if (!moving && atDirectorCap(player)) {
- player.sendMessage(directorCapHint());
- return;
- }
clearPreview(player);
World world = player.getWorld();
if (moving) {
@@ -667,17 +647,16 @@ private void tryPlace(Player player, Site site, boolean moving) {
consumeOne(player);
}
if (!moving) {
- noteMissingTerrain(player, site);
+ noteMissingTerrain(player, world, site);
}
sites.save(site);
if (findDust != null) {
findDust.syncTimer();
}
- if (site.getCampSignX() != null) {
- plugin.getServer().getScheduler().runTask(plugin, () -> CampSigns.write(
- world.getBlockAt(site.getCampSignX(), site.getCampSignY(), site.getCampSignZ()),
- site.publicName()));
- }
+ // Every template has one sign piece, and an allowed placement plants all of them.
+ plugin.getServer().getScheduler().runTask(plugin, () -> CampSigns.write(
+ world.getBlockAt(site.getCampSignX(), site.getCampSignY(), site.getCampSignZ()),
+ site.publicName()));
player.playSound(player.getLocation(), Sound.BLOCK_WOODEN_BUTTON_CLICK_ON, SoundCategory.PLAYERS, 0.8f, 1.1f);
if (moving) {
player.sendMessage("Camp moved.");
@@ -714,13 +693,10 @@ private String directorCapHint() {
* First plant: finds whose cells are already air, water, or builds are damaged. Does not block the claim.
*
* @param player director
+ * @param ruinWorld world the camp was just planted in, which is always the ruin's world
* @param site excavation just established
*/
- private void noteMissingTerrain(Player player, Site site) {
- World ruinWorld = plugin.getServer().getWorld(site.getWorldName());
- if (ruinWorld == null) {
- return;
- }
+ private void noteMissingTerrain(Player player, World ruinWorld, Site site) {
ruinWorld.getChunkAt(site.getChunkX(), site.getChunkZ()).load();
PrismWound.Prior prior = PrismWound.markMissingTerrain(ruinWorld, site);
site.catalogSettledFinds(null);
@@ -745,17 +721,6 @@ private boolean holdingKit(Player player) {
|| item.isEstablish(player.getInventory().getItemInOffHand());
}
- /**
- * Site being moved, or the nearest hidden ruin ready to claim.
- *
- * @param player kit holder
- * @return site, or {@code null}
- */
- private Site activeSite(Player player) {
- Site moving = relocatingSite(player);
- return moving != null ? moving : nearestConfirmedHidden(player);
- }
-
/**
* @param player kit holder
* @return excavation being relocated, or {@code null}
@@ -765,45 +730,40 @@ private Site relocatingSite(Player player) {
if (siteId == null) {
return null;
}
- return sites.findById(siteId).orElse(null);
+ Site site = sites.findById(siteId).orElse(null);
+ if (site == null || !site.isCampLocked() || !site.isDirector(player.getUniqueId())) {
+ cancelRelocate(player);
+ return null;
+ }
+ return site;
}
/**
+ * Outside a move the site is always a hidden ruin, so only the player's own choice applies.
+ *
* @param player kit holder
- * @param site current site, or {@code null}
+ * @param site site being placed; the camp being moved while relocating
* @return DyeColor name for {@code W} cells
*/
private String primaryWoolName(Player player, Site site) {
- if (isRelocating(player) && site != null) {
+ if (isRelocating(player)) {
return site.getCampWoolPrimary();
}
String chosen = woolChoicePrimary.get(player.getUniqueId());
- if (chosen != null) {
- return chosen;
- }
- if (site != null && site.isCampLocked()) {
- return site.getCampWoolPrimary();
- }
- return "WHITE";
+ return chosen != null ? chosen : "WHITE";
}
/**
* @param player kit holder
- * @param site current site, or {@code null}
+ * @param site site being placed, the camp being moved, or {@code null} when cycling away from any ruin
* @return DyeColor name for {@code R} cells
*/
private String secondaryWoolName(Player player, Site site) {
- if (isRelocating(player) && site != null) {
+ if (isRelocating(player)) {
return site.getCampWoolSecondary();
}
String chosen = woolChoiceSecondary.get(player.getUniqueId());
- if (chosen != null) {
- return chosen;
- }
- if (site != null && site.isCampLocked()) {
- return site.getCampWoolSecondary();
- }
- return "RED";
+ return chosen != null ? chosen : "RED";
}
/**
@@ -900,11 +860,8 @@ private CampPlacement assess(Player player, Site site, Block target) {
* @return first real block on the crosshair, or {@code null}
*/
private Block aimBlock(Player player, Site site) {
- if (site == null) {
- return player.getTargetBlockExact(64);
- }
- UUID movingId = relocating.get(player.getUniqueId());
- if (movingId == null || !movingId.equals(site.getId())) {
+ // While relocating, the only site ever aimed for is the camp being moved.
+ if (!relocating.containsKey(player.getUniqueId())) {
return player.getTargetBlockExact(64);
}
BlockIterator iterator = new BlockIterator(player, 64);
@@ -932,15 +889,20 @@ private Block aimBlock(Player player, Site site) {
* @return placement to confirm, or {@code null} to re-aim
*/
private CampPlacement lastAllowedPulse(Player player, Site site) {
- UUID siteId = lastPulseSite.get(player.getUniqueId());
- if (siteId == null || !siteId.equals(site.getId())) {
- return null;
- }
- CampPlacement placement = lastPulse.get(player.getUniqueId());
- if (placement == null || !placement.allowed()) {
+ Pulse pulse = lastPulse.get(player.getUniqueId());
+ if (pulse == null || !pulse.siteId().equals(site.getId()) || !pulse.placement().allowed()) {
return null;
}
- return placement;
+ return pulse.placement();
+ }
+
+ /**
+ * Ghost drawn on the last preview tick, with the site it was drawn for.
+ *
+ * @param siteId ruin or excavation the ghost belongs to
+ * @param placement assessed ghost
+ */
+ private record Pulse(UUID siteId, CampPlacement placement) {
}
/**
@@ -952,12 +914,10 @@ private void updateMoveAimProxy(Player player) {
Location loc = player.getEyeLocation().add(player.getEyeLocation().getDirection().multiply(2.4));
UUID existingId = moveAimProxies.get(player.getUniqueId());
Entity existing = existingId == null ? null : plugin.getServer().getEntity(existingId);
+ // A world change ends the move and removes the stand, so a live stand shares the viewer's world.
if (existing instanceof ArmorStand stand && stand.isValid()) {
- if (stand.getWorld().equals(loc.getWorld())) {
- stand.teleport(loc);
- return;
- }
- removeMoveAimProxy(player);
+ stand.teleport(loc);
+ return;
}
ArmorStand stand = loc.getWorld().spawn(loc, ArmorStand.class, this::configureMoveAimProxy);
moveAimProxies.put(player.getUniqueId(), stand.getUniqueId());
@@ -1030,13 +990,16 @@ private CampPlacement.Issue chunkIssue(Site site, Block target) {
String world = chunk.getWorld().getName();
int cx = chunk.getX();
int cz = chunk.getZ();
- if (world.equals(site.getWorldName()) && cx == site.getChunkX() && cz == site.getChunkZ()) {
+ if (!world.equals(site.getWorldName())) {
+ return CampPlacement.Issue.NOT_NEIGHBOR;
+ }
+ if (cx == site.getChunkX() && cz == site.getChunkZ()) {
return CampPlacement.Issue.ON_DIG;
}
if (occupiedByOther(site, world, cx, cz)) {
return CampPlacement.Issue.OCCUPIED;
}
- if (!isValidCampChunk(site, world, cx, cz)) {
+ if (!isNeighborChunk(site.getChunkX(), site.getChunkZ(), cx, cz)) {
return CampPlacement.Issue.NOT_NEIGHBOR;
}
return null;
@@ -1148,33 +1111,16 @@ private void addSurface(World world, int x, int z, BlockData data, Map !other.getId().equals(self.getId()))
- .isPresent()
+ return sites.findByChunk(world, chunkX, chunkZ).isPresent()
|| sites.findByEstablishmentChunk(world, chunkX, chunkZ)
.filter(other -> !other.getId().equals(self.getId()))
.isPresent();
@@ -1190,9 +1136,7 @@ private boolean occupiedByOther(Site self, String world, int chunkX, int chunkZ)
* @return whether they share a full side
*/
static boolean isNeighborChunk(int siteX, int siteZ, int chunkX, int chunkZ) {
- int dx = Math.abs(chunkX - siteX);
- int dz = Math.abs(chunkZ - siteZ);
- return (dx == 1 && dz == 0) || (dx == 0 && dz == 1);
+ return Math.abs(chunkX - siteX) + Math.abs(chunkZ - siteZ) == 1;
}
/**
@@ -1228,9 +1172,8 @@ private void applyPreview(Player player, Map desired) {
*/
public void clearPreview(Player player) {
lastPulse.remove(player.getUniqueId());
- lastPulseSite.remove(player.getUniqueId());
Map previous = previews.remove(player.getUniqueId());
- if (previous == null || previous.isEmpty()) {
+ if (previous == null) {
return;
}
World world = player.getWorld();
@@ -1247,7 +1190,7 @@ public void clearPreview(Player player) {
*/
public void clearPreviewFromWorld(Player player, World from) {
Map previous = previews.remove(player.getUniqueId());
- if (previous == null || previous.isEmpty()) {
+ if (previous == null) {
return;
}
for (BlockPos pos : previous.keySet()) {
@@ -1256,8 +1199,13 @@ public void clearPreviewFromWorld(Player player, World from) {
}
/**
+ * Skips cells of another world. A world change cancels a camp move through
+ * {@link #clearSession(Player)} before {@link #clearPreviewFromWorld(Player, World)} runs, so
+ * {@link #clearPreview(Player)} then sees the destination world. The client has already
+ * dropped the old world's ghost blocks, and reading the destination would load its chunks.
+ *
* @param player viewer
- * @param world player's world
+ * @param world world to read real blocks from
* @param pos fake block
*/
private void restore(Player player, World world, BlockPos pos) {
@@ -1280,7 +1228,7 @@ private void restore(Player player, World world, BlockPos pos) {
* @param pos restored cell
*/
private void resendSign(Player player, World world, BlockPos pos) {
- if (!player.isOnline() || !pos.world().equals(world.getName())) {
+ if (!player.isOnline()) {
return;
}
CampSigns.sendTo(player, world.getBlockAt(pos.x(), pos.y(), pos.z()));
@@ -1332,9 +1280,7 @@ private void showHud(Player player, boolean allowed, String reason) {
BossBar bar = bars.computeIfAbsent(
player.getUniqueId(),
id -> plugin.getServer().createBossBar("", BarColor.WHITE, BarStyle.SOLID));
- if (!bar.getPlayers().contains(player)) {
- bar.addPlayer(player);
- }
+ bar.addPlayer(player); // Idempotent on Spigot and Paper: the viewers are a set.
bar.setVisible(true);
bar.setProgress(1.0);
bar.setColor(allowed ? BarColor.GREEN : BarColor.RED);
diff --git a/src/main/java/net/tfminecraft/archaeo/establish/FindReportBook.java b/src/main/java/net/tfminecraft/archaeo/establish/FindReportBook.java
index f4e43f5..1156b6f 100644
--- a/src/main/java/net/tfminecraft/archaeo/establish/FindReportBook.java
+++ b/src/main/java/net/tfminecraft/archaeo/establish/FindReportBook.java
@@ -50,19 +50,13 @@ public FindReportBook(JavaPlugin plugin) {
public ItemStack create(Player director, Site site, CatalogRegistry catalogs) {
ItemStack stack = new ItemStack(Material.WRITTEN_BOOK);
BookMeta meta = (BookMeta) stack.getItemMeta();
- if (meta == null) {
- return stack;
- }
String title = RecoveredFindItem.siteName(site);
if (title.length() > 32) {
title = title.substring(0, 32);
}
meta.setTitle(title);
- String author = director.getName() == null ? "Director" : director.getName();
- if (author.length() > 32) {
- author = author.substring(0, 32);
- }
- meta.setAuthor(author);
+ // Online player names are at most 16 characters, well inside the book author limit.
+ meta.setAuthor(director.getName());
meta.setGeneration(BookMeta.Generation.ORIGINAL);
addPages(meta, buildPages(director, site, catalogs));
var pdc = meta.getPersistentDataContainer();
@@ -194,7 +188,8 @@ private static String findPage(Player director, Site site, BuriedFind find, Cata
}
/**
- * Splits overlong pages so the client can still open the book.
+ * Splits overlong pages so the client can still open the book. Every page built above
+ * has a heading, so none is blank.
*
* @param meta book being filled
* @param pages raw pages
@@ -203,9 +198,6 @@ private static void addPages(BookMeta meta, List pages) {
int count = 0;
for (String raw : pages) {
String text = ChatColor.stripColor(raw);
- if (text.isBlank()) {
- continue;
- }
int start = 0;
while (start < text.length() && count < MAX_PAGES) {
int end = Math.min(text.length(), start + PAGE_CHARS);
@@ -217,8 +209,5 @@ private static void addPages(BookMeta meta, List pages) {
return;
}
}
- if (count == 0) {
- meta.addPage("Empty report.");
- }
}
}
diff --git a/src/main/java/net/tfminecraft/archaeo/excavation/DigTools.java b/src/main/java/net/tfminecraft/archaeo/excavation/DigTools.java
index 4c6f0f4..febbb2d 100644
--- a/src/main/java/net/tfminecraft/archaeo/excavation/DigTools.java
+++ b/src/main/java/net/tfminecraft/archaeo/excavation/DigTools.java
@@ -148,9 +148,6 @@ public void restoreVanillaSpeedIfSealed(ItemStack stack) {
return;
}
ItemMeta meta = stack.getItemMeta();
- if (meta == null) {
- return;
- }
boolean changed = false;
Collection modifiers = meta.getAttributeModifiers(Attribute.BLOCK_BREAK_SPEED);
if (modifiers != null) {
@@ -166,11 +163,9 @@ public void restoreVanillaSpeedIfSealed(ItemStack stack) {
// Do not rewrite a healthy ToolComponent — pack tools often share WOODEN_PICKAXE
// as base material and keep their own defaultMiningSpeed.
if (meta.getTool().getDefaultMiningSpeed() <= 0f) {
- ItemMeta vanilla = new ItemStack(stack.getType()).getItemMeta();
- if (vanilla != null) {
- meta.setTool(vanilla.getTool());
- changed = true;
- }
+ // A non-air item type always has default metadata.
+ meta.setTool(new ItemStack(stack.getType()).getItemMeta().getTool());
+ changed = true;
}
if (changed) {
stack.setItemMeta(meta);
@@ -183,8 +178,7 @@ public void restoreVanillaSpeedIfSealed(ItemStack stack) {
*/
private static boolean isLegacyArchaeoMineLock(AttributeModifier modifier) {
NamespacedKey key = modifier.getKey();
- return key != null
- && "archaeo".equals(key.getNamespace())
+ return "archaeo".equals(key.getNamespace())
&& "no_vanilla_mine".equals(key.getKey());
}
}
diff --git a/src/main/java/net/tfminecraft/archaeo/excavation/FindBreakCue.java b/src/main/java/net/tfminecraft/archaeo/excavation/FindBreakCue.java
index 95e0533..ee7fd0e 100644
--- a/src/main/java/net/tfminecraft/archaeo/excavation/FindBreakCue.java
+++ b/src/main/java/net/tfminecraft/archaeo/excavation/FindBreakCue.java
@@ -23,9 +23,6 @@ private FindBreakCue() {
* @param block cell that held the find
*/
public static void play(Player player, Block block) {
- if (block == null) {
- return;
- }
Location at = block.getLocation().add(0.5, 0.5, 0.5);
block.getWorld().playSound(at, Sound.BLOCK_DECORATED_POT_SHATTER, SoundCategory.BLOCKS, 1.15f, 0.8f);
block.getWorld().playSound(at, Sound.ENTITY_ITEM_BREAK, SoundCategory.PLAYERS, 0.55f, 0.55f);
diff --git a/src/main/java/net/tfminecraft/archaeo/excavation/FindDustService.java b/src/main/java/net/tfminecraft/archaeo/excavation/FindDustService.java
index a78a56e..528f453 100644
--- a/src/main/java/net/tfminecraft/archaeo/excavation/FindDustService.java
+++ b/src/main/java/net/tfminecraft/archaeo/excavation/FindDustService.java
@@ -107,10 +107,7 @@ public void onChunkUnload(ChunkUnloadEvent event) {
* Walks loaded find cells and sheds from those that face an opening.
*/
private void pulse() {
- if (!settings.findDust()) {
- stopTimer();
- return;
- }
+ // Every settings change goes through syncTimer, so the loop only runs while dust is on.
tick++;
int interval = settings.findDustIntervalTicks();
if (interval < 1 || tick % interval != 0) {
diff --git a/src/main/java/net/tfminecraft/archaeo/excavation/HandPickService.java b/src/main/java/net/tfminecraft/archaeo/excavation/HandPickService.java
index adf23ff..26f0eb9 100644
--- a/src/main/java/net/tfminecraft/archaeo/excavation/HandPickService.java
+++ b/src/main/java/net/tfminecraft/archaeo/excavation/HandPickService.java
@@ -10,7 +10,6 @@
import net.tfminecraft.archaeo.model.BuriedFind;
import net.tfminecraft.archaeo.model.FindState;
import net.tfminecraft.archaeo.model.Site;
-import net.tfminecraft.archaeo.model.StratumBand;
import net.tfminecraft.archaeo.model.WorkerRecord;
import net.tfminecraft.archaeo.site.SiteClosure;
import net.tfminecraft.archaeo.site.SiteRepository;
@@ -41,6 +40,7 @@
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
+import java.util.function.LongSupplier;
/**
* Hand Pick: client mining is frozen. Cue beats follow {@code cue-ticks} on the tool profile,
@@ -66,6 +66,8 @@ public class HandPickService {
private int gameTick;
private final Map cycles = new ConcurrentHashMap<>();
private final Map lastWarn = new ConcurrentHashMap<>();
+ /** Wall clock in milliseconds for the warning cooldown; tests replace it to step past the cooldown. */
+ LongSupplier clock = System::currentTimeMillis;
/**
* @param plugin scheduler
@@ -114,17 +116,11 @@ public void stop() {
task.cancel();
task = null;
}
- for (UUID id : new ArrayList<>(cycles.keySet())) {
- Player player = Bukkit.getPlayer(id);
- if (player != null) {
- finish(player);
- } else {
- cycles.remove(id);
- }
- }
for (Player player : Bukkit.getOnlinePlayers()) {
+ finish(player);
setVanillaMineLocked(player, false);
}
+ cycles.clear();
}
/**
@@ -137,14 +133,7 @@ public void noteMining(Player player, Block block) {
if (!settings.enabled()) {
return;
}
- if (!isExcavationFill(block)) {
- return;
- }
- Site site = sites.findEstablishedPrism(
- block.getWorld().getName(),
- block.getX(),
- block.getY(),
- block.getZ()).orElse(null);
+ Site site = excavationAt(block);
if (site == null) {
return;
}
@@ -246,9 +235,8 @@ public Optional toolForGive(String token) {
public void suppressVanillaBreak(Player player, Block block) {
pinBlock(player, block);
syncHeldTool(player);
- if (isExcavationFill(block)) {
- noteMining(player, block);
- }
+ // noteMining ignores cells that are not prism fill, such as a break predicted mid-hold elsewhere.
+ noteMining(player, block);
}
/**
@@ -258,7 +246,7 @@ public void suppressVanillaBreak(Player player, Block block) {
*/
public void finish(Player player) {
Cycle cycle = cycles.remove(player.getUniqueId());
- if (cycle == null || cycle.resolved) {
+ if (cycle == null) {
return;
}
if (cycle.clangTick < 0) {
@@ -268,15 +256,6 @@ public void finish(Player player) {
resolveCut(player, cycle, late);
}
- /**
- * Action-bar hint when the pick is used off the excavation prism.
- *
- * @param player holder
- */
- public void warnOffCut(Player player) {
- warn(player, "This tool is only for the open cut of an excavation.");
- }
-
/**
* @param player holder
* @return whether a cycle is open
@@ -356,7 +335,7 @@ private void tick() {
for (Map.Entry entry : new ArrayList<>(cycles.entrySet())) {
Player player = Bukkit.getPlayer(entry.getKey());
Cycle cycle = entry.getValue();
- if (player == null || !player.isOnline()) {
+ if (player == null) {
cycles.remove(entry.getKey());
continue;
}
@@ -438,14 +417,22 @@ private boolean isTargetingPrism(Player player) {
* @return whether the Hand Pick clock runs here (excavation prism fill)
*/
private boolean isExcavationFill(Block block) {
+ return excavationAt(block) != null;
+ }
+
+ /**
+ * @param block world cell
+ * @return established excavation whose prism fill this cell is, or {@code null}
+ */
+ private Site excavationAt(Block block) {
if (!PrismFill.isTerrainFill(block.getType())) {
- return false;
+ return null;
}
return sites.findEstablishedPrism(
block.getWorld().getName(),
block.getX(),
block.getY(),
- block.getZ()).isPresent();
+ block.getZ()).orElse(null);
}
/**
@@ -539,20 +526,9 @@ private boolean onVanillaBreak(Player player, Block block, Cycle cycle) {
* @param late whether the ready window was missed
*/
private void resolveCut(Player player, Cycle cycle, boolean late) {
- if (cycle.resolved) {
- return;
- }
- cycle.resolved = true;
cycles.remove(player.getUniqueId());
Block block = player.getWorld().getBlockAt(cycle.x, cycle.y, cycle.z);
- if (!isExcavationFill(block)) {
- return;
- }
- Site site = sites.findEstablishedPrism(
- block.getWorld().getName(),
- block.getX(),
- block.getY(),
- block.getZ()).orElse(null);
+ Site site = excavationAt(block);
if (site == null) {
return;
}
@@ -588,9 +564,8 @@ private void resolveCut(Player player, Cycle cycle, boolean late) {
smashedFind |= wounded;
liftFill(cell, tool);
}
- if (log != null) {
- log.addBlocksRemoved(lifted.size());
- }
+ // mayWork above means a director or excavator, so the staff log exists.
+ log.addBlocksRemoved(lifted.size());
ToolWear.spend(
player,
tool,
@@ -651,17 +626,13 @@ private void liftFill(Block block, ItemStack tool) {
* @param lifted cell that just became air (or the origin of the column)
*/
private void playLateSmash(Block lifted) {
- plugin.getServer().getScheduler().runTaskLater(plugin, () -> {
- if (!plugin.isEnabled()) {
- return;
- }
- lifted.getWorld().playSound(
- lifted.getLocation(),
- Sound.ITEM_MACE_SMASH_GROUND,
- SoundCategory.BLOCKS,
- 0.9f,
- 0.7f);
- }, 2L);
+ // Bukkit cancels a plugin's pending tasks when it is disabled.
+ plugin.getServer().getScheduler().runTaskLater(plugin, () -> lifted.getWorld().playSound(
+ lifted.getLocation(),
+ Sound.ITEM_MACE_SMASH_GROUND,
+ SoundCategory.BLOCKS,
+ 0.9f,
+ 0.7f), 2L);
}
/**
@@ -758,14 +729,11 @@ private void setVanillaMineLocked(Player player, boolean locked) {
* @param player viewer
*/
private void showHud(Player player) {
+ // Only called while cycling or while the crosshair is on the prism, so a target exists.
Cycle cycle = cycles.get(player.getUniqueId());
- Block target = player.getTargetBlockExact(6);
- if (target == null && cycle == null) {
- return;
- }
- if (cycle != null) {
- target = player.getWorld().getBlockAt(cycle.x, cycle.y, cycle.z);
- }
+ Block target = cycle != null
+ ? player.getWorld().getBlockAt(cycle.x, cycle.y, cycle.z)
+ : player.getTargetBlockExact(6);
Site site = sites.findEstablishedPrism(
target.getWorld().getName(),
target.getX(),
@@ -781,8 +749,7 @@ private void showHud(Player player) {
return;
}
ensureJornada(site, player.getWorld());
- StratumBand band = site.stratumAt(target.getY());
- String layer = band == null ? "—" : band.getId();
+ String layer = site.stratumAt(target.getY()).getId();
String pick = settings.unlimitedWorkday() ? "∞" : String.valueOf(site.getJornadaPickLeft());
String text = "STRATUM " + layer + " ⛏ " + pick;
BuriedFind aimed = site.findAt(new BlockCell(target.getX(), target.getY(), target.getZ())).orElse(null);
@@ -809,7 +776,7 @@ private void showHud(Player player) {
* @return opened prism cell, or {@code null} when still facing unrevealed fill from outside
*/
private static Block openedPrismCell(Player player, Site site, Cycle cycle, Block target) {
- if (!PrismFill.isTerrainFill(target.getType()) && site.isInPrism(target.getX(), target.getY(), target.getZ())) {
+ if (!PrismFill.isTerrainFill(target.getType())) {
return target;
}
if (cycle != null) {
@@ -834,7 +801,7 @@ private static Block openedPrismCell(Player player, Site site, Cycle cycle, Bloc
* @param message English line
*/
private void warn(Player player, String message) {
- long now = System.currentTimeMillis();
+ long now = clock.getAsLong();
Long previous = lastWarn.get(player.getUniqueId());
if (previous != null && now - previous < WARN_MS) {
return;
@@ -857,7 +824,6 @@ private static final class Cycle {
private int lastStrikeTick;
private float progress;
private int clangTick = -1;
- private boolean resolved;
/**
* @param x block X
@@ -884,7 +850,7 @@ private Cycle(
this.lastStrikeTick = tick;
this.cues = cues;
this.tool = tool;
- this.digClass = digClass == null ? DigClass.NONE : digClass;
+ this.digClass = digClass;
}
/**
diff --git a/src/main/java/net/tfminecraft/archaeo/excavation/LiftPlan.java b/src/main/java/net/tfminecraft/archaeo/excavation/LiftPlan.java
index e9cd138..8c87ec3 100644
--- a/src/main/java/net/tfminecraft/archaeo/excavation/LiftPlan.java
+++ b/src/main/java/net/tfminecraft/archaeo/excavation/LiftPlan.java
@@ -118,10 +118,9 @@ private static void addFootprint(
Block next = shuffle
? frontier.get(ThreadLocalRandom.current().nextInt(frontier.size()))
: frontier.get(0);
- if (!tryAdd(out, taken, site, next)) {
- pool.remove(next);
- continue;
- }
+ // Pool cubes were accepted when the pool was built and leave it once chosen.
+ taken.add(pack(next));
+ out.add(next);
pool.remove(next);
need--;
}
@@ -130,16 +129,13 @@ private static void addFootprint(
/**
* Pool cubes that share a face with a cube already chosen, in pool order.
*
- * @param pool remaining 3×3×2 candidates
+ * @param pool remaining 3×3×2 candidates; never holds a cube already taken
* @param taken packed keys already lifting
* @return attached candidates
*/
private static List attached(List pool, Set taken) {
List frontier = new ArrayList<>();
for (Block cell : pool) {
- if (taken.contains(pack(cell))) {
- continue;
- }
if (touchesTaken(cell, taken)) {
frontier.add(cell);
}
@@ -165,7 +161,7 @@ private static boolean touchesTaken(Block cell, Set taken) {
* @param out cubes already chosen
* @param taken packed keys
* @param site excavation
- * @param cell candidate
+ * @param cell candidate that is not yet in {@code out}
* @return whether {@code cell} was appended
*/
private static boolean tryAdd(
@@ -177,9 +173,7 @@ private static boolean tryAdd(
if (!accept(site, cell)) {
return false;
}
- if (!taken.add(pack(cell))) {
- return false;
- }
+ taken.add(pack(cell));
out.add(cell);
return true;
}
diff --git a/src/main/java/net/tfminecraft/archaeo/excavation/PrismFill.java b/src/main/java/net/tfminecraft/archaeo/excavation/PrismFill.java
index 57d64cf..df4a168 100644
--- a/src/main/java/net/tfminecraft/archaeo/excavation/PrismFill.java
+++ b/src/main/java/net/tfminecraft/archaeo/excavation/PrismFill.java
@@ -26,13 +26,11 @@ private PrismFill() {
/**
* World block the Hand Pick should work. Air, fluids, and placeable decorations are not fill.
*
- * @param material block type
+ * @param material block type of a world cell
* @return whether this is excavation substrate
*/
public static boolean isTerrainFill(Material material) {
- return material != null
- && material.isBlock()
- && !material.isAir()
+ return !material.isAir()
&& !isFluid(material)
&& !exempt(material);
}
@@ -102,9 +100,6 @@ static boolean isFluid(Material material) {
* @return whether this is not excavation fill
*/
static boolean exempt(Material material) {
- if (material.isAir()) {
- return true;
- }
if (Tag.REPLACEABLE.isTagged(material)
|| Tag.ALL_SIGNS.isTagged(material)
|| Tag.ALL_HANGING_SIGNS.isTagged(material)
diff --git a/src/main/java/net/tfminecraft/archaeo/excavation/PrismListener.java b/src/main/java/net/tfminecraft/archaeo/excavation/PrismListener.java
index 8e47809..b2dc2a8 100644
--- a/src/main/java/net/tfminecraft/archaeo/excavation/PrismListener.java
+++ b/src/main/java/net/tfminecraft/archaeo/excavation/PrismListener.java
@@ -26,6 +26,7 @@
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
+import java.util.function.LongSupplier;
/**
* Optionally locks all prism fill of an established excavation; vanilla holes
@@ -37,6 +38,8 @@ public class PrismListener implements Listener {
private final SiteRepository sites;
private final DigTools tools;
private final Map lastWarn = new ConcurrentHashMap<>();
+ /** Wall clock in milliseconds for the warning cooldown; tests replace it to step past the cooldown. */
+ LongSupplier clock = System::currentTimeMillis;
private boolean protectDigSite;
/**
@@ -288,13 +291,10 @@ private void woundCell(Block block, Player player) {
}
/**
- * @param player breaker, or {@code null}
+ * @param player breaker
*/
private void warn(Player player) {
- if (player == null) {
- return;
- }
- long now = System.currentTimeMillis();
+ long now = clock.getAsLong();
Long previous = lastWarn.get(player.getUniqueId());
if (previous != null && now - previous < MESSAGE_COOLDOWN_MS) {
return;
diff --git a/src/main/java/net/tfminecraft/archaeo/excavation/PrismOutlineService.java b/src/main/java/net/tfminecraft/archaeo/excavation/PrismOutlineService.java
index a57a251..109f361 100644
--- a/src/main/java/net/tfminecraft/archaeo/excavation/PrismOutlineService.java
+++ b/src/main/java/net/tfminecraft/archaeo/excavation/PrismOutlineService.java
@@ -95,13 +95,14 @@ public void show(Player player, Site site) {
LimitsSettings limits = catalogs.pick().limits();
List bars = new ArrayList<>();
paint(player, world, site, rings, limits, bars);
- if (bars.isEmpty()) {
- return;
- }
shows.put(player.getUniqueId(), bars);
UUID viewer = player.getUniqueId();
plugin.getServer().getScheduler().runTaskLater(
- plugin, () -> cancelById(viewer), Math.max(1L, limits.seconds() * 20L));
+ plugin, () -> {
+ if (shows.remove(viewer, bars)) {
+ bars.forEach(Entity::remove);
+ }
+ }, Math.max(1L, limits.seconds() * 20L));
player.playSound(player.getLocation(), Sound.BLOCK_AMETHYST_BLOCK_CHIME, SoundCategory.PLAYERS, 0.6f, 1.4f);
player.sendMessage("Excavation limits shown for " + limits.seconds() + " seconds.");
}
diff --git a/src/main/java/net/tfminecraft/archaeo/excavation/PrismWound.java b/src/main/java/net/tfminecraft/archaeo/excavation/PrismWound.java
index 68e407e..62be173 100644
--- a/src/main/java/net/tfminecraft/archaeo/excavation/PrismWound.java
+++ b/src/main/java/net/tfminecraft/archaeo/excavation/PrismWound.java
@@ -52,14 +52,11 @@ public static Prior markMissingTerrain(World world, Site site) {
* Whether any live find cell is no longer excavation fill. Used to refuse rewriting a hidden
* ruin whose ground was already opened, even if the dossier has not recorded the wound yet.
*
- * @param world ruin world
+ * @param world loaded ruin world
* @param site hidden ruin
* @return {@code true} when at least one find cube is air, fluid, or a build
*/
public static boolean hasMissingFindTerrain(World world, Site site) {
- if (world == null || site == null) {
- return false;
- }
for (BuriedFind find : site.getFinds()) {
for (BlockCell cell : find.getCells()) {
Block block = world.getBlockAt(cell.x(), cell.y(), cell.z());
@@ -86,8 +83,9 @@ public static Removal onCellRemoved(Site site, int x, int y, int z) {
return Removal.none();
}
boolean changed = false;
+ // Only present bands are returned, and a cell inside the prism always sits in one.
StratumBand band = site.stratumAt(y);
- if (band != null && band.isPresent() && !band.isDisturbed()) {
+ if (!band.isDisturbed()) {
band.setDisturbed(true);
changed = true;
}
diff --git a/src/main/java/net/tfminecraft/archaeo/excavation/RecoverService.java b/src/main/java/net/tfminecraft/archaeo/excavation/RecoverService.java
index 01c26c6..b8ea16c 100644
--- a/src/main/java/net/tfminecraft/archaeo/excavation/RecoverService.java
+++ b/src/main/java/net/tfminecraft/archaeo/excavation/RecoverService.java
@@ -11,7 +11,6 @@
import net.tfminecraft.archaeo.model.BuriedFind;
import net.tfminecraft.archaeo.model.FindState;
import net.tfminecraft.archaeo.model.Site;
-import net.tfminecraft.archaeo.model.WorkerRecord;
import net.tfminecraft.archaeo.site.SiteClosure;
import net.tfminecraft.archaeo.site.SiteRepository;
import org.bukkit.Bukkit;
@@ -37,6 +36,7 @@
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
+import java.util.function.LongSupplier;
/**
* Brushes matrix off a fully exposed find. The last required cube drops one item and lifts the shape.
@@ -52,6 +52,8 @@ public class RecoverService {
private RecoverySettings settings;
private final Map channels = new ConcurrentHashMap<>();
private final Map lastWarn = new ConcurrentHashMap<>();
+ /** Wall clock in milliseconds for the warning cooldown; tests replace it to step past the cooldown. */
+ LongSupplier clock = System::currentTimeMillis;
/**
* @param plugin scheduler
@@ -88,15 +90,12 @@ public void setSettings(RecoverySettings settings) {
* Cancels open brush channels.
*/
public void stop() {
- for (UUID id : List.copyOf(channels.keySet())) {
- Channel channel = channels.get(id);
+ for (Channel channel : List.copyOf(channels.values())) {
stash(channel);
- channels.remove(id);
hideBar(channel);
- if (channel != null && channel.task != null) {
- channel.task.cancel();
- }
+ channel.task.cancel();
}
+ channels.clear();
}
/**
@@ -126,8 +125,8 @@ public void watch(Player player) {
return;
}
Channel channel = new Channel(createBar(player), Math.max(1, settings.channelTicks()));
- channels.put(player.getUniqueId(), channel);
channel.task = plugin.getServer().getScheduler().runTaskTimer(plugin, () -> pulse(player, channel), 1L, 1L);
+ channels.put(player.getUniqueId(), channel);
}
/**
@@ -172,12 +171,11 @@ public void begin(Player player, Block block) {
warn(player, "That cube is already clean.");
return;
}
+ // Recovery is on and a brush is held, so watch always leaves a channel.
watch(player);
Channel channel = channels.get(player.getUniqueId());
- if (channel == null) {
- return;
- }
- if (channel.focused && channel.sameCell(block) && channel.findId.equals(find.getId())) {
+ if (channel.focused && channel.sameCell(block) && channel.findId.equals(find.getId())
+ && find.brushRemaining(cell) != null) {
channel.startGrace = 3;
return;
}
@@ -223,6 +221,7 @@ private void pulse(Player player, Channel channel) {
find = site.findAt(new BlockCell(target.getX(), target.getY(), target.getZ())).orElse(null);
}
}
+ // A find is only looked up for a real target, so a missing target leaves it null.
if (!isDustable(target, find)) {
stash(channel);
hideHud(channel);
@@ -234,10 +233,12 @@ private void pulse(Player player, Channel channel) {
stash(channel);
attach(channel, site, find, target);
}
- if (find.brushRemaining(cell) == null) {
+ Integer saved = find.brushRemaining(cell);
+ if (saved == null) {
hideHud(channel);
return;
}
+ channel.remaining = Math.min(channel.duration, saved);
showHud(channel, player);
if (!isUsingBrush(player)) {
if (channel.startGrace > 0) {
@@ -248,6 +249,14 @@ private void pulse(Player player, Channel channel) {
} else {
channel.startGrace = 0;
}
+ int tick = Bukkit.getCurrentTick();
+ for (Channel peer : channels.values()) {
+ // One cube of one site holds a single find, so the site id is enough to match it.
+ if (peer.lastBrushTick == tick && peer.sameCell(target) && site.getId().equals(peer.siteId)) {
+ return;
+ }
+ }
+ channel.lastBrushTick = tick;
channel.remaining--;
find.setBrushRemaining(cell, channel.remaining);
updateBar(channel);
@@ -265,27 +274,17 @@ private void pulse(Player player, Channel channel) {
/**
* Marks this cube clean; lifts the piece when the tedium cap is met.
*
- * @param player holder
- * @param block cleaned cell
+ * @param player holder allowed to recover here
+ * @param block cleaned cell, still fill
* @param site excavation
* @param find shape
* @param cell cleaned coordinates
*/
private void finishCell(Player player, Block block, Site site, BuriedFind find, BlockCell cell) {
- if (find.getState() != FindState.DISCOVERED) {
- return;
- }
- if (!find.getCells().contains(cell) || find.isCleaned(cell)) {
- return;
- }
- if (!PrismFill.isTerrainFill(block.getType())) {
- return;
- }
+ // The pulse has just checked that this is uncleaned fill of a discovered find and that
+ // the player may recover here, so the staff log exists.
find.markCleaned(cell);
- WorkerRecord log = site.staffLog(player.getUniqueId());
- if (log != null) {
- log.noteCellBrushed();
- }
+ site.staffLog(player.getUniqueId()).noteCellBrushed();
ToolWear.spend(
player,
player.getInventory().getItemInMainHand(),
@@ -317,9 +316,7 @@ private boolean readyToLift(World world, BuriedFind find) {
cleanedFill++;
}
}
- if (fill == 0) {
- return false;
- }
+ // The cube just cleaned is still fill, so there is at least one.
int needed = Math.min(fill, Math.max(1, settings.maxCellsToClean()));
return cleanedFill >= needed;
}
@@ -340,23 +337,14 @@ private void liftFind(Player player, Site site, BuriedFind find, Block origin) {
clearFill(block);
}
}
+ // A find whose condition reaches zero is already LOST, so a discovered one has some left.
find.setState(FindState.RECOVERED);
int conservation = find.getConservation();
String grade = catalogs.pick().conservation().gradeLabel(conservation);
boolean fieldDamaged = find.isFieldDamaged();
- if (conservation <= 0) {
- find.setState(FindState.LOST);
- site.catalogSettledFinds(player.getUniqueId());
- player.sendMessage("Those remains were destroyed. Nothing could be recovered.");
- world.playSound(origin.getLocation(), Sound.ENTITY_ITEM_BREAK, SoundCategory.BLOCKS, 0.8f, 0.7f);
- return;
- }
site.catalogSettledFinds(player.getUniqueId());
site.setRecoveredCount(site.getRecoveredCount() + 1);
- WorkerRecord log = site.staffLog(player.getUniqueId());
- if (log != null) {
- log.noteFindRecovered();
- }
+ site.staffLog(player.getUniqueId()).noteFindRecovered();
ArtifactTemplate template = catalogs.artifact(find.getArtifactId());
if (template == null) {
player.sendMessage("Recovered a find, but its template is missing from the catalog.");
@@ -463,10 +451,10 @@ private static void updateBar(Channel channel) {
}
/**
- * @param channel finished or aborted brush, or {@code null}
+ * @param channel finished or aborted brush
*/
private static void hideBar(Channel channel) {
- if (channel == null || channel.bar == null) {
+ if (channel.bar == null) {
return;
}
channel.bar.removeAll();
@@ -478,7 +466,7 @@ private static void hideBar(Channel channel) {
* @param message English line
*/
private void warn(Player player, String message) {
- long now = System.currentTimeMillis();
+ long now = clock.getAsLong();
Long previous = lastWarn.get(player.getUniqueId());
if (previous != null && now - previous < WARN_MS) {
return;
@@ -507,13 +495,13 @@ private boolean deniedRecovery(Player player, Site site) {
}
/**
- * Writes remaining ticks onto the find cube so a later look with the brush can restore the bar.
- * Marked dirty rather than committed: a few lost ticks are acceptable, a restart is not.
+ * Marks shared cube progress dirty so a later look with the brush can restore the bar.
+ * Each active pulse already updates the cube; a paused channel must not overwrite newer work.
*
* @param channel watcher, or {@code null}
*/
private void stash(Channel channel) {
- if (channel == null || !channel.focused || channel.siteId == null || channel.findId == null) {
+ if (channel == null || !channel.focused) {
return;
}
Site site = sites.findById(channel.siteId).orElse(null);
@@ -524,12 +512,6 @@ private void stash(Channel channel) {
if (find == null) {
return;
}
- BlockCell cell = new BlockCell(channel.x, channel.y, channel.z);
- if (channel.remaining <= 0 || find.isCleaned(cell)) {
- find.setBrushRemaining(cell, 0);
- } else {
- find.setBrushRemaining(cell, channel.remaining);
- }
sites.touch(site);
}
@@ -552,15 +534,16 @@ private void attach(Channel channel, Site site, BuriedFind find, Block block) {
Integer saved = find.brushRemaining(new BlockCell(block.getX(), block.getY(), block.getZ()));
channel.remaining = saved == null ? channel.duration : Math.min(channel.duration, saved);
channel.startGrace = 0;
+ channel.lastBrushTick = -1;
}
/**
* @param block aimed cube, or {@code null}
- * @param find shape at that cube, or {@code null}
+ * @param find shape at that cube; {@code null} whenever {@code block} is
* @return whether the brush can still work this fill cell
*/
private static boolean isDustable(Block block, BuriedFind find) {
- if (block == null || find == null) {
+ if (find == null) {
return false;
}
if (find.getState() != FindState.DISCOVERED) {
@@ -592,21 +575,19 @@ private static BuriedFind findOn(Site site, UUID findId) {
* @param player viewer
*/
private static void showHud(Channel channel, Player player) {
+ // The bar was created for this player and is only emptied when the channel ends.
if (channel.bar == null) {
return;
}
- if (!channel.bar.getPlayers().contains(player)) {
- channel.bar.addPlayer(player);
- }
channel.bar.setVisible(true);
updateBar(channel);
}
/**
- * @param channel watcher, or {@code null}
+ * @param channel watcher
*/
private static void hideHud(Channel channel) {
- if (channel == null || channel.bar == null) {
+ if (channel.bar == null) {
return;
}
channel.bar.setVisible(false);
@@ -615,27 +596,22 @@ private static void hideHud(Channel channel) {
/**
* Stops a brush channel aimed at this excavation so a staff wipe does not leave a HUD bar.
*
- * @param siteId excavation being erased
+ * @param siteId id of the excavation being erased
*/
public void abortForSite(UUID siteId) {
- if (siteId == null) {
- return;
- }
- for (UUID playerId : List.copyOf(channels.keySet())) {
- Channel channel = channels.get(playerId);
- if (channel == null || !siteId.equals(channel.siteId)) {
+ for (Map.Entry entry : List.copyOf(channels.entrySet())) {
+ Channel channel = entry.getValue();
+ if (!siteId.equals(channel.siteId)) {
continue;
}
- Player player = plugin.getServer().getPlayer(playerId);
+ Player player = plugin.getServer().getPlayer(entry.getKey());
if (player != null) {
teardown(player);
continue;
}
hideBar(channel);
- if (channel.task != null) {
- channel.task.cancel();
- }
- channels.remove(playerId);
+ channel.task.cancel();
+ channels.remove(entry.getKey());
}
}
@@ -644,23 +620,21 @@ public void abortForSite(UUID siteId) {
*/
private void teardown(Player player) {
Channel channel = channels.remove(player.getUniqueId());
- hideBar(channel);
- if (channel != null && channel.task != null) {
- channel.task.cancel();
+ if (channel == null) {
+ return;
}
+ hideBar(channel);
+ channel.task.cancel();
}
/**
* Spigot does not fire {@code PlayerInteractEvent} again while a brush is held
* ({@code SPIGOT-7501}). {@link Player#getItemInUse()} is true for those few ticks.
*
- * @param player holder
+ * @param player holder whose main hand is already known to be a brush
* @return whether the main-hand brush is currently being used
*/
private boolean isUsingBrush(Player player) {
- if (!brush.isBrush(player.getInventory().getItemInMainHand())) {
- return false;
- }
ItemStack using = player.getItemInUse();
if (using != null) {
return brush.isBrush(using);
@@ -681,6 +655,8 @@ private static final class Channel {
private int duration;
private final BossBar bar;
private int remaining;
+ /** Coordinates same-cell watchers so several brushes still consume only one tick of work. */
+ private int lastBrushTick = -1;
/** First pulses after a click may run before the server marks the brush as in-use. */
private int startGrace;
private BukkitTask task;
@@ -709,7 +685,7 @@ private void clearFocus() {
* @return whether this watcher is still on that cube
*/
private boolean sameCell(Block block) {
- return focused && block.getX() == x && block.getY() == y && block.getZ() == z;
+ return block.getX() == x && block.getY() == y && block.getZ() == z;
}
}
}
diff --git a/src/main/java/net/tfminecraft/archaeo/item/ItemMatcher.java b/src/main/java/net/tfminecraft/archaeo/item/ItemMatcher.java
index 329c2d2..9696088 100644
--- a/src/main/java/net/tfminecraft/archaeo/item/ItemMatcher.java
+++ b/src/main/java/net/tfminecraft/archaeo/item/ItemMatcher.java
@@ -139,7 +139,8 @@ public static ItemMatcher detect(JavaPlugin plugin) {
miPlugin = mmoClass.getField("plugin").get(null);
miTypeName = mmoClass.getMethod("getTypeName", ItemStack.class);
miId = mmoClass.getMethod("getID", ItemStack.class);
- miGetItem = findGetItem(mmoClass);
+ Class> mmoType = Class.forName("net.Indyuce.mmoitems.api.Type");
+ miGetItem = mmoClass.getMethod("getItem", mmoType, String.class);
} catch (ClassNotFoundException | NoSuchMethodException | NoSuchFieldException | IllegalAccessException exception) {
plugin.getLogger().log(Level.WARNING, "MMOItems is enabled but its API could not be bound.", exception);
}
@@ -281,7 +282,8 @@ private boolean matchesMmoItems(ItemStack stack, ItemRef ref) {
* @return pack stack or air
*/
private ItemStack createItemsAdder(ItemRef ref) {
- if (iaGetInstance == null || iaGetItemStack == null) {
+ // detect binds getItemStack after getInstance, so one check covers both.
+ if (iaGetItemStack == null) {
warn("ItemsAdder is not loaded; cannot create " + ref.primary());
return new ItemStack(Material.AIR);
}
@@ -306,7 +308,9 @@ private ItemStack createItemsAdder(ItemRef ref) {
* @return pack stack or air
*/
private ItemStack createMmoItems(ItemRef ref) {
- if (miPlugin == null || miGetItem == null) {
+ // detect binds getItem after reading the singleton, and MMOItems sets that in its constructor,
+ // long before it reports enabled, so a bound getItem always has its plugin.
+ if (miGetItem == null) {
warn("MMOItems is not loaded; cannot create " + ref.primary() + ":" + ref.secondary());
return new ItemStack(Material.AIR);
}
@@ -333,7 +337,8 @@ private ItemStack createMmoItems(ItemRef ref) {
* @return {@code namespace:id}, or {@code null}
*/
private String itemsAdderId(ItemStack stack) {
- if (iaByStack == null || iaNamespacedId == null || stack == null) {
+ // detect binds getNamespacedID after byItemStack, so one check covers both.
+ if (iaNamespacedId == null || stack == null) {
return null;
}
try {
@@ -375,7 +380,8 @@ private String itemsAdderEntityId(Entity entity) {
* @return id, or {@code null}
*/
private String invokeNamespaced(Method lookup, Method idMethod, Object argument) {
- if (lookup == null || idMethod == null || argument == null) {
+ // detect binds each id method after its lookup; matchesPlaced/matchesEntity reject null arguments.
+ if (idMethod == null) {
return null;
}
try {
@@ -392,11 +398,11 @@ private String invokeNamespaced(Method lookup, Method idMethod, Object argument)
/**
* @param left live id
- * @param right configured id
+ * @param right configured ItemsAdder id, never {@code null}
* @return case-insensitive match
*/
private static boolean idEquals(String left, String right) {
- return left != null && right != null && left.equalsIgnoreCase(right);
+ return left != null && left.equalsIgnoreCase(right);
}
/**
@@ -431,25 +437,6 @@ private String mmoId(ItemStack stack) {
}
}
- /**
- * @param mmoClass {@code net.Indyuce.mmoitems.MMOItems}
- * @return {@code getItem(Type, String)} if present
- */
- private static Method findGetItem(Class> mmoClass) {
- for (Method method : mmoClass.getMethods()) {
- if (!"getItem".equals(method.getName()) || method.getParameterCount() != 2) {
- continue;
- }
- if (method.getParameterTypes()[1] != String.class) {
- continue;
- }
- if (ItemStack.class.isAssignableFrom(method.getReturnType())) {
- return method;
- }
- }
- return null;
- }
-
/**
* @param message warning line
*/
diff --git a/src/main/java/net/tfminecraft/archaeo/item/ItemRef.java b/src/main/java/net/tfminecraft/archaeo/item/ItemRef.java
index b7fb559..4eec776 100644
--- a/src/main/java/net/tfminecraft/archaeo/item/ItemRef.java
+++ b/src/main/java/net/tfminecraft/archaeo/item/ItemRef.java
@@ -157,8 +157,9 @@ public static String yamlToken(Object value) {
}
Map.Entry, ?> entry = map.entrySet().iterator().next();
String key = String.valueOf(entry.getKey()).trim();
+ // yamlToken never hands back blank text: blank scalars become null.
String rest = yamlToken(entry.getValue());
- if (rest == null || rest.isBlank()) {
+ if (rest == null) {
return key.isBlank() ? null : key;
}
return key + ":" + rest;
@@ -199,10 +200,8 @@ public static Optional parse(JavaPlugin plugin, String raw) {
if ("minecraft".equals(prefix)) {
return vanillaName(plugin, rest);
}
- Material namespaced = Material.matchMaterial(token);
- if (namespaced != null) {
- return Optional.of(vanilla(namespaced));
- }
+ // Any other namespace is a pack id. Material.matchMaterial strips the colon, so asking it
+ // first would turn an ItemsAdder "glow:stone" into vanilla GLOWSTONE.
return itemsAdder(plugin, token, token);
}
String upper = token.toUpperCase(Locale.ROOT);
@@ -253,7 +252,8 @@ public Material vanillaMaterial() {
* @return ItemsAdder ref
*/
private static Optional itemsAdder(JavaPlugin plugin, String token, String rest) {
- if (rest.isBlank() || !rest.contains(":")) {
+ int split = rest.indexOf(':');
+ if (split <= 0 || split == rest.length() - 1) {
warn(plugin, "Invalid ItemsAdder id (want namespace:id): " + token);
return Optional.empty();
}
diff --git a/src/main/java/net/tfminecraft/archaeo/item/PackPluginHook.java b/src/main/java/net/tfminecraft/archaeo/item/PackPluginHook.java
index d4b8da5..7f0c8c0 100644
--- a/src/main/java/net/tfminecraft/archaeo/item/PackPluginHook.java
+++ b/src/main/java/net/tfminecraft/archaeo/item/PackPluginHook.java
@@ -4,9 +4,11 @@
import org.bukkit.block.Block;
import org.bukkit.entity.Entity;
import org.bukkit.entity.Player;
+import org.bukkit.event.Cancellable;
import org.bukkit.event.Event;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
+import org.bukkit.event.player.PlayerEvent;
import org.bukkit.plugin.EventExecutor;
import java.util.logging.Level;
@@ -51,7 +53,6 @@ public static void register(ArcheologyPlugin plugin) {
* @param executor handler
* @param label log label when registration fails
*/
- @SuppressWarnings("unchecked")
private static void registerNamed(
ArcheologyPlugin plugin,
Listener listener,
@@ -61,12 +62,10 @@ private static void registerNamed(
String label
) {
try {
- Class> raw = Class.forName(className);
- if (!Event.class.isAssignableFrom(raw)) {
- return;
- }
+ // asSubclass fails (and is logged below) if an incompatible ItemsAdder moved the event off Event.
+ Class extends Event> type = Class.forName(className).asSubclass(Event.class);
plugin.getServer().getPluginManager().registerEvent(
- (Class extends Event>) raw,
+ type,
listener,
priority,
executor,
@@ -84,33 +83,16 @@ private static void registerNamed(
* @param event ItemsAdder furniture interact
*/
private static void handleFurniture(ArcheologyPlugin plugin, Event event) {
- if (plugin.sketch() == null && plugin.museum() == null) {
- return;
- }
+ // FurnitureInteractEvent is a cancellable PlayerEvent, and onEnable builds the museum and
+ // sketch services before it registers this hook.
+ Player player = ((PlayerEvent) event).getPlayer();
try {
- Object playerObj = event.getClass().getMethod("getPlayer").invoke(event);
- if (!(playerObj instanceof Player player)) {
- return;
- }
String id = readNamespacedId(event);
Entity entity = readEntity(event);
Block block = entity == null ? null : entity.getLocation().getBlock();
- boolean handled = false;
- if (plugin.museum() != null
- && plugin.museum().tryOpenFromSupport(player, id, entity, block, player.isSneaking())) {
- handled = true;
- }
- if (!handled
- && plugin.sketch() != null
- && plugin.sketch().tryOpenCabinet(player, id, entity, block, player.isSneaking())) {
- handled = true;
- }
- if (handled) {
- try {
- event.getClass().getMethod("setCancelled", boolean.class).invoke(event, true);
- } catch (ReflectiveOperationException ignored) {
- // Not every IA event is cancellable.
- }
+ if (plugin.museum().tryOpenFromSupport(player, id, entity, block, player.isSneaking())
+ || plugin.sketch().tryOpenCabinet(player, id, entity, block, player.isSneaking())) {
+ ((Cancellable) event).setCancelled(true);
}
} catch (ReflectiveOperationException exception) {
plugin.getLogger().log(Level.FINE, "Furniture interact could not be read.", exception);
@@ -120,41 +102,28 @@ private static void handleFurniture(ArcheologyPlugin plugin, Event event) {
/**
* @param event furniture interact
* @return {@code namespace:id}, or {@code null}
+ * @throws ReflectiveOperationException when ItemsAdder fails to answer
*/
private static String readNamespacedId(Event event) throws ReflectiveOperationException {
- try {
- Object namespaced = event.getClass().getMethod("getNamespacedID").invoke(event);
- if (namespaced != null) {
- String text = String.valueOf(namespaced);
- if (!text.isBlank()) {
- return text;
- }
- }
- } catch (NoSuchMethodException ignored) {
- // Fall through to the furniture wrapper.
+ Object namespaced = event.getClass().getMethod("getNamespacedID").invoke(event);
+ if (namespaced != null && !String.valueOf(namespaced).isBlank()) {
+ return String.valueOf(namespaced);
}
- try {
- Object furniture = event.getClass().getMethod("getFurniture").invoke(event);
- if (furniture == null) {
- return null;
- }
- Object namespaced = furniture.getClass().getMethod("getNamespacedID").invoke(furniture);
- return namespaced == null ? null : String.valueOf(namespaced);
- } catch (NoSuchMethodException ignored) {
+ // Fall back to the furniture wrapper when the event carries no id of its own.
+ Object furniture = event.getClass().getMethod("getFurniture").invoke(event);
+ if (furniture == null) {
return null;
}
+ Object furnitureId = furniture.getClass().getMethod("getNamespacedID").invoke(furniture);
+ return furnitureId == null ? null : String.valueOf(furnitureId);
}
/**
* @param event furniture interact
* @return furniture entity, or {@code null}
+ * @throws ReflectiveOperationException when ItemsAdder fails to answer
*/
- private static Entity readEntity(Event event) {
- try {
- Object bukkit = event.getClass().getMethod("getBukkitEntity").invoke(event);
- return bukkit instanceof Entity found ? found : null;
- } catch (ReflectiveOperationException ignored) {
- return null;
- }
+ private static Entity readEntity(Event event) throws ReflectiveOperationException {
+ return (Entity) event.getClass().getMethod("getBukkitEntity").invoke(event);
}
}
diff --git a/src/main/java/net/tfminecraft/archaeo/item/RecoveredFindItem.java b/src/main/java/net/tfminecraft/archaeo/item/RecoveredFindItem.java
index 8f68696..cc67c13 100644
--- a/src/main/java/net/tfminecraft/archaeo/item/RecoveredFindItem.java
+++ b/src/main/java/net/tfminecraft/archaeo/item/RecoveredFindItem.java
@@ -136,8 +136,9 @@ private static ItemRef resolveRef(ArtifactTemplate template, BuriedFind find) {
if (template == null) {
return ItemRef.vanilla(Material.BRICK);
}
+ // BuriedFind stores a blank item as null, and every stored or generated find has an id.
String chosen = find == null ? null : find.getItem();
- if ((chosen == null || chosen.isBlank()) && find != null && find.getId() != null) {
+ if (chosen == null && find != null) {
long seed = find.getId().getMostSignificantBits() ^ find.getId().getLeastSignificantBits();
chosen = template.pickItem(new Random(seed));
find.setItem(chosen);
@@ -164,9 +165,6 @@ public void refreshCarried(
String grade,
CatalogRegistry catalogs
) {
- if (player == null || find == null || find.getId() == null) {
- return;
- }
UUID recoverer = find.getRecoveredBy() == null ? player.getUniqueId() : find.getRecoveredBy();
boolean fieldDamaged = find.isFieldDamaged();
PlayerInventory inventory = player.getInventory();
@@ -205,7 +203,7 @@ public void refresh(
String grade,
CatalogRegistry catalogs
) {
- if (stack == null || find == null || find.getId() == null || !isThisFind(stack, find.getId())) {
+ if (find == null || !isThisFind(stack, find.getId())) {
return;
}
UUID recoverer = find.getRecoveredBy();
@@ -222,7 +220,7 @@ public void refresh(
*/
public boolean retitle(ItemStack stack, Site site, CatalogRegistry catalogs) {
UUID findId = findIdOf(stack);
- if (findId == null || site == null || site.getId() == null || !site.getId().equals(siteIdOf(stack))) {
+ if (findId == null || site == null || !site.getId().equals(siteIdOf(stack))) {
return false;
}
BuriedFind find = site.findById(findId).orElse(null);
@@ -267,11 +265,7 @@ public String siteNameOf(ItemStack stack) {
if (stack == null || stack.getType().isAir()) {
return null;
}
- ItemMeta meta = stack.getItemMeta();
- if (meta == null) {
- return null;
- }
- return meta.getPersistentDataContainer().get(siteNameKey, PersistentDataType.STRING);
+ return stack.getItemMeta().getPersistentDataContainer().get(siteNameKey, PersistentDataType.STRING);
}
/**
@@ -309,11 +303,7 @@ public boolean isThisFind(ItemStack stack, UUID findId) {
if (stack == null || stack.getType().isAir() || findId == null) {
return false;
}
- ItemMeta meta = stack.getItemMeta();
- if (meta == null) {
- return false;
- }
- var pdc = meta.getPersistentDataContainer();
+ var pdc = stack.getItemMeta().getPersistentDataContainer();
if (!pdc.has(markerKey, PersistentDataType.BYTE)) {
return false;
}
@@ -329,11 +319,7 @@ public UUID findIdOf(ItemStack stack) {
if (stack == null || stack.getType().isAir()) {
return null;
}
- ItemMeta meta = stack.getItemMeta();
- if (meta == null) {
- return null;
- }
- var pdc = meta.getPersistentDataContainer();
+ var pdc = stack.getItemMeta().getPersistentDataContainer();
if (!pdc.has(markerKey, PersistentDataType.BYTE)) {
return null;
}
@@ -364,11 +350,7 @@ public UUID siteIdOf(ItemStack stack) {
if (stack == null || stack.getType().isAir()) {
return null;
}
- ItemMeta meta = stack.getItemMeta();
- if (meta == null) {
- return null;
- }
- var pdc = meta.getPersistentDataContainer();
+ var pdc = stack.getItemMeta().getPersistentDataContainer();
if (!pdc.has(markerKey, PersistentDataType.BYTE)) {
return null;
}
@@ -392,11 +374,11 @@ public String labelOf(ItemStack stack) {
return "recovered find";
}
ItemMeta meta = stack.getItemMeta();
- if (meta == null || !meta.hasDisplayName()) {
+ if (!meta.hasDisplayName()) {
return "recovered find";
}
String name = ChatColor.stripColor(meta.getDisplayName());
- return name == null || name.isBlank() ? "recovered find" : name;
+ return name.isBlank() ? "recovered find" : name;
}
/**
@@ -414,13 +396,11 @@ public ItemStack standIn(ArtifactTemplate template, Site site, BuriedFind find,
String grade = catalogs == null ? null : catalogs.pick().conservation().gradeLabel(find.getConservation());
ItemStack stack = baseStack(template, find);
ItemMeta meta = stack.getItemMeta();
- if (meta != null) {
- meta.setDisplayName(ChatColor.WHITE + name);
- List lines = lore(template, site, find, grade, find.isFieldDamaged(), catalogs);
- lines.add(ChatColor.DARK_GRAY + "The real artifact stays in your hand.");
- meta.setLore(lines);
- stack.setItemMeta(meta);
- }
+ meta.setDisplayName(ChatColor.WHITE + name);
+ List lines = lore(template, site, find, grade, find.isFieldDamaged(), catalogs);
+ lines.add(ChatColor.DARK_GRAY + "The real artifact stays in your hand.");
+ meta.setLore(lines);
+ stack.setItemMeta(meta);
return stack;
}
@@ -444,10 +424,8 @@ private void write(
boolean fieldDamaged,
CatalogRegistry catalogs
) {
+ // Only ever a non-air stack: baseStack never returns air, and refreshed stacks are tagged finds.
ItemMeta meta = stack.getItemMeta();
- if (meta == null) {
- return;
- }
String name = find.shownName(template == null ? null : template.displayName());
meta.setDisplayName(ChatColor.WHITE + name);
meta.setLore(lore(template, site, find, grade, fieldDamaged, catalogs));
@@ -460,7 +438,7 @@ private void write(
pdc.set(findIdKey, PersistentDataType.STRING, find.getId().toString());
pdc.set(findNumberKey, PersistentDataType.INTEGER, find.getFindNumber());
pdc.set(artifactIdKey, PersistentDataType.STRING, find.getArtifactId());
- pdc.set(stratumKey, PersistentDataType.STRING, find.getStratumId() == null ? "" : find.getStratumId());
+ pdc.set(stratumKey, PersistentDataType.STRING, find.getStratumId());
pdc.set(conservationKey, PersistentDataType.INTEGER, find.getConservation());
pdc.set(buriedConservationKey, PersistentDataType.INTEGER, find.getBuriedConservation());
pdc.set(gradeKey, PersistentDataType.STRING, grade == null ? "" : grade);
@@ -511,7 +489,7 @@ public List lore(
lore.add(ChatColor.RED + "Hurt while digging");
}
if (conditionKnown(find)) {
- if (template != null && catalogs != null) {
+ if (template != null) {
lore.add(ChatColor.GRAY + "Material: " + ChatColor.WHITE
+ catalogs.materialDisplayName(template.material()));
}
@@ -529,9 +507,10 @@ public List lore(
if (hint != null) {
lore.add(hint);
}
- if (find.isStudied() && template != null) {
+ if (find.isStudied()) {
+ // The dossier's own note outlives its catalog row; the catalog note is only a fallback.
String notes = find.getStudyNotes();
- if (notes == null || notes.isBlank()) {
+ if ((notes == null || notes.isBlank()) && template != null) {
notes = template.studyNotes();
}
if (notes != null && !notes.isBlank()) {
@@ -619,7 +598,7 @@ public static String readingPhrase(FindInterpretation reading, CatalogRegistry c
}
}
if (typeLabel == null || typeLabel.isBlank()) {
- return phrase == null ? "" : phrase;
+ return phrase;
}
return typeLabel + ": " + phrase;
}
@@ -653,9 +632,8 @@ private static void wrap(List lore, String text) {
}
line.append(word);
}
- if (line.length() > 0) {
- lore.add(ChatColor.WHITE + prefix + line);
- }
+ // Callers only wrap non-blank text, so the last word always leaves a line to flush.
+ lore.add(ChatColor.WHITE + prefix + line);
}
}
diff --git a/src/main/java/net/tfminecraft/archaeo/item/RecoveredFindListener.java b/src/main/java/net/tfminecraft/archaeo/item/RecoveredFindListener.java
index b7f1031..0911b01 100644
--- a/src/main/java/net/tfminecraft/archaeo/item/RecoveredFindListener.java
+++ b/src/main/java/net/tfminecraft/archaeo/item/RecoveredFindListener.java
@@ -77,7 +77,7 @@ public void onPrepareAnvil(PrepareAnvilEvent event) {
stamped.setAmount(result.getAmount());
ItemMeta meta = stamped.getItemMeta();
String typed = BuriedFind.sanitizeGivenName(strip(event.getView().getRenameText()));
- if (meta != null && typed != null) {
+ if (typed != null) {
meta.setDisplayName(ChatColor.WHITE + typed);
stamped.setItemMeta(meta);
}
@@ -131,9 +131,7 @@ public void onJoin(PlayerJoinEvent event) {
@EventHandler
public void onOpen(InventoryOpenEvent event) {
syncInventory(event.getInventory());
- if (event.getPlayer() instanceof Player player) {
- syncInventory(player.getInventory());
- }
+ syncInventory(event.getPlayer().getInventory());
}
/**
@@ -154,9 +152,6 @@ public void onPickup(EntityPickupItemEvent event) {
* @param inventory bag or chest
*/
private void syncInventory(Inventory inventory) {
- if (inventory == null) {
- return;
- }
ItemStack[] contents = inventory.getContents();
for (int i = 0; i < contents.length; i++) {
ItemStack stack = contents[i];
@@ -172,9 +167,10 @@ private void syncInventory(Inventory inventory) {
* @param typed anvil line or current display name
*/
private void applyGivenName(Player player, ItemStack stack, String typed) {
+ // onAnvilTake only gets here for a recovered piece, which always carries a find id.
UUID findId = recovered.findIdOf(stack);
UUID siteId = recovered.siteIdOf(stack);
- if (findId == null || siteId == null) {
+ if (siteId == null) {
return;
}
Site site = sites.findById(siteId).orElse(null);
diff --git a/src/main/java/net/tfminecraft/archaeo/item/SiteLabelRefresh.java b/src/main/java/net/tfminecraft/archaeo/item/SiteLabelRefresh.java
index 9da78d2..4cd3ab1 100644
--- a/src/main/java/net/tfminecraft/archaeo/item/SiteLabelRefresh.java
+++ b/src/main/java/net/tfminecraft/archaeo/item/SiteLabelRefresh.java
@@ -52,12 +52,9 @@ public SiteLabelRefresh(
* Updates every matching stack currently loaded: players, dropped items, frames, and tile inventories.
* Chests in unloaded chunks catch up when a player opens them.
*
- * @param site excavation whose public name just changed
+ * @param site stored excavation whose public name just changed
*/
public void retitle(Site site) {
- if (site == null || site.getId() == null) {
- return;
- }
for (Player player : plugin.getServer().getOnlinePlayers()) {
PlayerInventory inventory = player.getInventory();
retitleInventory(inventory, site);
@@ -105,9 +102,6 @@ public void retitle(Site site) {
*/
private void retitleArmorStand(ArmorStand stand, Site site) {
EntityEquipment equipment = stand.getEquipment();
- if (equipment == null) {
- return;
- }
for (EquipmentSlot slot : EquipmentSlot.values()) {
ItemStack stack = equipment.getItem(slot);
if (retitleStack(stack, site)) {
@@ -121,13 +115,11 @@ private void retitleArmorStand(ArmorStand stand, Site site) {
* @param site excavation
*/
private void retitleInventory(Inventory inventory, Site site) {
- if (inventory == null) {
- return;
- }
ItemStack[] contents = inventory.getContents();
for (int i = 0; i < contents.length; i++) {
ItemStack stack = contents[i];
- if (stack == null || stack.getType().isAir()) {
+ // getContents reports an empty slot as null, never as an air stack.
+ if (stack == null) {
continue;
}
boolean changed = retitleNested(stack, site);
@@ -159,7 +151,7 @@ private boolean retitleNested(ItemStack stack, Site site) {
boolean changed = false;
for (int i = 0; i < contents.length; i++) {
ItemStack nested = contents[i];
- if (nested == null || nested.getType().isAir()) {
+ if (nested == null) {
continue;
}
if (retitleStack(nested, site)) {
diff --git a/src/main/java/net/tfminecraft/archaeo/item/SketchSupplies.java b/src/main/java/net/tfminecraft/archaeo/item/SketchSupplies.java
index 6913e37..5476f9a 100644
--- a/src/main/java/net/tfminecraft/archaeo/item/SketchSupplies.java
+++ b/src/main/java/net/tfminecraft/archaeo/item/SketchSupplies.java
@@ -74,7 +74,7 @@ public void update(ItemRef paper, ItemRef pencil, int pencilUses) {
this.paper = paper;
this.pencil = pencil;
this.pencilUses = Math.max(0, pencilUses);
- if (paper != null && paper.equals(pencil)) {
+ if (paper.equals(pencil)) {
logger().warning("sketch.paper and sketch.pencil are the same item; combining them will be ambiguous.");
}
}
@@ -121,10 +121,12 @@ public boolean isSpent(ItemStack pencil) {
if (pencilUses <= 0 || pencil == null || pencil.getType().isAir()) {
return false;
}
+ // Every Paper item meta is Damageable, even on materials without a vanilla bar.
ItemMeta meta = pencil.getItemMeta();
- if (!(meta instanceof Damageable damageable) || meta.isUnbreakable()) {
+ if (meta.isUnbreakable()) {
return false;
}
+ Damageable damageable = (Damageable) meta;
int max = maxDamage(damageable, pencil);
return max > 0 && damageable.getDamage() >= max;
}
@@ -142,19 +144,14 @@ public boolean wear(Player player, ItemStack pencil) {
return false;
}
ItemMeta meta = pencil.getItemMeta();
- if (meta == null) {
- return false;
- }
applyPencilDurability(meta);
- if (!(meta instanceof Damageable damageable) || meta.isUnbreakable()) {
- pencil.setItemMeta(meta);
- return false;
- }
- int max = maxDamage(damageable, pencil);
- if (max <= 0) {
+ if (meta.isUnbreakable()) {
pencil.setItemMeta(meta);
return false;
}
+ // Finite uses always leave a configured max on the bar here.
+ Damageable damageable = (Damageable) meta;
+ int max = damageable.getMaxDamage();
int applied = afterUnbreaking(pencil, 1);
if (applied <= 0) {
pencil.setItemMeta(meta);
@@ -216,9 +213,6 @@ private ItemStack stamped(ItemStack stack, boolean sheet) {
return stack;
}
ItemMeta meta = stack.getItemMeta();
- if (meta == null) {
- return stack;
- }
markKit(meta, sheet);
ItemRef ref = sheet ? paper : pencil;
if (ownsTooltip(ref, stack)) {
@@ -240,7 +234,7 @@ private ItemStack stamped(ItemStack stack, boolean sheet) {
* @return whether Archaeo should write name and lore (vanilla copies only)
*/
private boolean ownsTooltip(ItemRef ref, ItemStack stack) {
- return ref != null && ref.kind() == ItemRef.Kind.VANILLA && !matcher.isCustom(stack);
+ return ref.kind() == ItemRef.Kind.VANILLA && !matcher.isCustom(stack);
}
/**
@@ -260,9 +254,6 @@ private void markKit(ItemMeta meta, boolean sheet) {
* @return whether {@link #createPaper()} or {@link #createPencil()} tagged this stack
*/
private boolean isMarkedKit(ItemStack stack, boolean sheet) {
- if (stack == null || !stack.hasItemMeta()) {
- return false;
- }
String kind = stack.getItemMeta().getPersistentDataContainer().get(kitKey, PersistentDataType.STRING);
return (sheet ? KIT_PAPER : KIT_PENCIL).equals(kind);
}
@@ -273,9 +264,7 @@ private boolean isMarkedKit(ItemStack stack, boolean sheet) {
* @param meta pencil meta
*/
private void applyPencilDurability(ItemMeta meta) {
- if (!(meta instanceof Damageable damageable)) {
- return;
- }
+ Damageable damageable = (Damageable) meta;
if (pencilUses <= 0) {
if (damageable.hasMaxDamage()) {
damageable.setMaxDamage(null);
diff --git a/src/main/java/net/tfminecraft/archaeo/item/ToolWear.java b/src/main/java/net/tfminecraft/archaeo/item/ToolWear.java
index 23c42ab..43a273d 100644
--- a/src/main/java/net/tfminecraft/archaeo/item/ToolWear.java
+++ b/src/main/java/net/tfminecraft/archaeo/item/ToolWear.java
@@ -38,8 +38,10 @@ public static boolean spend(Player player, ItemStack stack, int points, boolean
if (points <= 0 || stack == null || stack.getType().getMaxDurability() <= 0) {
return false;
}
+ // Every CraftBukkit item meta implements Damageable; durability alone decides whether it wears.
ItemMeta meta = stack.getItemMeta();
- if (!(meta instanceof Damageable damageable) || meta.isUnbreakable()) {
+ Damageable damageable = (Damageable) meta;
+ if (meta.isUnbreakable()) {
return false;
}
int applied = unbreaking ? afterUnbreaking(stack, points) : points;
diff --git a/src/main/java/net/tfminecraft/archaeo/model/BuriedFind.java b/src/main/java/net/tfminecraft/archaeo/model/BuriedFind.java
index fb8ece0..6676119 100644
--- a/src/main/java/net/tfminecraft/archaeo/model/BuriedFind.java
+++ b/src/main/java/net/tfminecraft/archaeo/model/BuriedFind.java
@@ -114,10 +114,11 @@ public static String sanitizeGivenName(String raw) {
if (name.isEmpty()) {
return null;
}
+ // The trimmed text starts with a visible character, so cutting it to 40 keeps it non-empty.
if (name.length() > 40) {
name = name.substring(0, 40).trim();
}
- return name.isEmpty() ? null : name;
+ return name;
}
/**
@@ -127,7 +128,8 @@ public static String sanitizeGivenName(String raw) {
* @return player-facing title
*/
public String shownName(String catalogName) {
- if (givenName != null && !givenName.isBlank()) {
+ // setGivenName stores blank names as null.
+ if (givenName != null) {
return givenName;
}
if (catalogName != null && !catalogName.isBlank()) {
@@ -219,10 +221,15 @@ public int getConservation() {
}
/**
+ * A find with nothing left is lost, as in {@link #refreshConservation()}, unless already settled.
+ *
* @param conservation remaining quality, 0–100
*/
public void setConservation(int conservation) {
this.conservation = Math.max(0, Math.min(100, conservation));
+ if (this.conservation <= 0 && state != FindState.RECOVERED) {
+ state = FindState.LOST;
+ }
}
/**
diff --git a/src/main/java/net/tfminecraft/archaeo/museum/MuseumListener.java b/src/main/java/net/tfminecraft/archaeo/museum/MuseumListener.java
index 2bfa713..df45a99 100644
--- a/src/main/java/net/tfminecraft/archaeo/museum/MuseumListener.java
+++ b/src/main/java/net/tfminecraft/archaeo/museum/MuseumListener.java
@@ -59,10 +59,11 @@ public MuseumListener(SiteRepository sites, CatalogRegistry catalogs, RecoveredF
}
/**
- * @param matcher ItemsAdder furniture lookup
+ * @param matcher ItemsAdder furniture lookup; {@link net.tfminecraft.archaeo.ArcheologyPlugin#bindItemMatcher}
+ * never passes {@code null}
*/
public void setMatcher(ItemMatcher matcher) {
- this.matcher = matcher == null ? ItemMatcher.vanillaOnly() : matcher;
+ this.matcher = matcher;
}
/**
@@ -83,8 +84,9 @@ public void onBlock(PlayerInteractEvent event) {
if (!sneaking(player)) {
return;
}
+ // RIGHT_CLICK_BLOCK always carries the clicked block.
Block block = event.getClickedBlock();
- if (block == null || !catalogs.museum().allowsSupport(null, null, block, matcher)) {
+ if (!catalogs.museum().allowsSupport(null, null, block, matcher)) {
return;
}
ItemStack displayed = firstRecovered(displayedOnBlock(block.getState(), event.getClickedPosition()));
@@ -133,8 +135,7 @@ public void onStand(PlayerArmorStandManipulateEvent event) {
if (!sneaking(event.getPlayer())) {
return;
}
- if (!catalogs.museum().allowsVanilla(Material.ARMOR_STAND)
- && !catalogs.museum().allowsEntity(event.getRightClicked(), matcher)) {
+ if (!catalogs.museum().allowsEntity(event.getRightClicked())) {
return;
}
ItemStack worn = event.getArmorStandItem();
@@ -148,7 +149,7 @@ public void onStand(PlayerArmorStandManipulateEvent event) {
/**
* ItemsAdder furniture click (from {@code FurnitureInteractEvent}).
*
- * @param player clicker
+ * @param player clicker from the furniture event
* @param namespacedId furniture id, or {@code null}
* @param entity furniture entity, or {@code null}
* @param block block under the furniture, or {@code null}
@@ -162,7 +163,7 @@ public boolean tryOpenFromSupport(
Block block,
boolean sneaking
) {
- if (player == null || !sneaking) {
+ if (!sneaking) {
return false;
}
if (!catalogs.museum().allowsSupport(namespacedId, entity, block, matcher)) {
@@ -247,9 +248,6 @@ private static List displayedOnEntity(Entity entity) {
}
if (entity instanceof ArmorStand stand) {
EntityEquipment equipment = stand.getEquipment();
- if (equipment == null) {
- return stacks;
- }
stacks.add(equipment.getItemInMainHand());
stacks.add(equipment.getItemInOffHand());
stacks.add(equipment.getHelmet());
@@ -265,9 +263,6 @@ private static List displayedOnEntity(Entity entity) {
* @return first recovered find, or {@code null}
*/
private ItemStack firstRecovered(List stacks) {
- if (stacks == null) {
- return null;
- }
for (ItemStack stack : stacks) {
if (recovered.isRecovered(stack)) {
return stack;
@@ -287,7 +282,7 @@ private void openPlaque(Player player, ItemStack stack) {
UUID siteId = recovered.siteIdOf(stack);
UUID findId = recovered.findIdOf(stack);
Site site = siteId == null ? null : sites.findById(siteId).orElse(null);
- if (site == null || !site.mayConsult() || findId == null) {
+ if (site == null || !site.mayConsult()) {
player.sendMessage("That excavation record is missing.");
return;
}
diff --git a/src/main/java/net/tfminecraft/archaeo/prospect/ProspectService.java b/src/main/java/net/tfminecraft/archaeo/prospect/ProspectService.java
index 66eb81d..46286fd 100644
--- a/src/main/java/net/tfminecraft/archaeo/prospect/ProspectService.java
+++ b/src/main/java/net/tfminecraft/archaeo/prospect/ProspectService.java
@@ -29,6 +29,7 @@
import java.util.Objects;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
+import java.util.function.LongSupplier;
/**
* Samples ground in a hidden ruin chunk. Confirmation is per player and does not plant a camp.
@@ -41,6 +42,8 @@ public class ProspectService {
private ProspectSettings settings;
private final Map channeling = new ConcurrentHashMap<>();
private final Map cooldownUntilMs = new ConcurrentHashMap<>();
+ /** Wall clock in milliseconds for the sampling cooldown; tests replace it to step past the cooldown. */
+ LongSupplier clock = System::currentTimeMillis;
/**
* @param plugin scheduler owner
@@ -242,10 +245,8 @@ private void startChannel(Player player, Block block, Site site, BlockCell cell)
* @param cell sample coordinates
*/
private void finishSample(Player player, Site site, BlockCell cell) {
- if (!site.addProspectSample(player.getUniqueId(), cell)) {
- player.sendMessage("You already sampled this spot. Try another point.");
- return;
- }
+ // begin() refused a repeated cell and one player runs one channel, so the point is new.
+ site.addProspectSample(player.getUniqueId(), cell);
damageKit(player);
int count = site.prospectSamples(player.getUniqueId()).size();
int need = Math.max(1, settings.pointsRequired());
@@ -314,10 +315,8 @@ private String readingFlavor(Site site, Player player, int sampleIndex) {
if (site.getInterest() == null) {
return "The reading is inconclusive.";
}
+ // The catalogue always holds every interest level (a failed reload keeps the old one).
InterestSettings interest = catalogs.interest(site.getInterest());
- if (interest == null) {
- return "The reading is inconclusive.";
- }
int extra = 0;
if (interest.variation() > 0) {
int hash = Objects.hash(site.getId(), player.getUniqueId(), sampleIndex);
@@ -343,19 +342,15 @@ private void sendConfirmed(Player player) {
}
/**
- * Applies one durability point to the kit in the main hand.
+ * Applies one durability point to the kit in the main hand. The channel checked the kit is
+ * still held on this tick, and every server item meta is {@link Damageable}.
*
* @param player scanner
*/
private void damageKit(Player player) {
ItemStack stack = player.getInventory().getItemInMainHand();
- if (!item.isProspect(stack)) {
- return;
- }
ItemMeta meta = stack.getItemMeta();
- if (!(meta instanceof Damageable damageable)) {
- return;
- }
+ Damageable damageable = (Damageable) meta;
short max = stack.getType().getMaxDurability();
if (max <= 0) {
return;
@@ -403,7 +398,7 @@ private void clearActionBar(Player player) {
*/
private boolean onCooldown(Player player) {
Long until = cooldownUntilMs.get(player.getUniqueId());
- return until != null && until > System.currentTimeMillis();
+ return until != null && until > clock.getAsLong();
}
/**
@@ -411,6 +406,6 @@ private boolean onCooldown(Player player) {
* @param millis cooldown length
*/
private void armCooldown(Player player, long millis) {
- cooldownUntilMs.put(player.getUniqueId(), System.currentTimeMillis() + millis);
+ cooldownUntilMs.put(player.getUniqueId(), clock.getAsLong() + millis);
}
}
diff --git a/src/main/java/net/tfminecraft/archaeo/site/AutoRuinEvaluationLedger.java b/src/main/java/net/tfminecraft/archaeo/site/AutoRuinEvaluationLedger.java
index b5a5d7a..8835082 100644
--- a/src/main/java/net/tfminecraft/archaeo/site/AutoRuinEvaluationLedger.java
+++ b/src/main/java/net/tfminecraft/archaeo/site/AutoRuinEvaluationLedger.java
@@ -75,17 +75,12 @@ public void flush() {
sawDiskRoot = false;
return;
}
+ // Every dirty entry was marked beside its bitset, and load/clearAll/reset drop both maps.
for (Map.Entry> worldEntry : dirty.entrySet()) {
String worldName = worldEntry.getKey();
Map regions = worlds.get(worldName);
- if (regions == null) {
- continue;
- }
for (Long regionKey : worldEntry.getValue().keySet()) {
- BitSet bits = regions.get(regionKey);
- if (bits != null) {
- writeRegion(worldName, regionKey, bits);
- }
+ writeRegion(worldName, regionKey, regions.get(regionKey));
}
worldEntry.getValue().clear();
}
@@ -218,8 +213,8 @@ private void loadWorldFolder(Path folder) {
try (var files = Files.list(folder)) {
files.filter(path -> path.getFileName().toString().endsWith(".bin")).forEach(path -> {
String name = path.getFileName().toString();
- // r...bin
- if (!name.startsWith("r.") || !name.endsWith(".bin")) {
+ // r...bin; the filter above already required the suffix
+ if (!name.startsWith("r.")) {
return;
}
String body = name.substring(2, name.length() - 4);
@@ -337,7 +332,7 @@ private static byte[] toBytes(BitSet bits) {
* @return bitset (empty when the file was blank)
*/
private static BitSet fromBytes(byte[] raw) {
- if (raw == null || raw.length == 0) {
+ if (raw.length == 0) {
return new BitSet(BITS);
}
return BitSet.valueOf(raw);
diff --git a/src/main/java/net/tfminecraft/archaeo/site/ChunkRuinFitness.java b/src/main/java/net/tfminecraft/archaeo/site/ChunkRuinFitness.java
index 2bc5797..6acd561 100644
--- a/src/main/java/net/tfminecraft/archaeo/site/ChunkRuinFitness.java
+++ b/src/main/java/net/tfminecraft/archaeo/site/ChunkRuinFitness.java
@@ -131,7 +131,7 @@ private static boolean isExcludedWaterBiome(World world, int x, int y, int z, Se
}
/**
- * Same Paper 1.21.10-safe key read as {@link SiteGenerator}.
+ * Same Paper- and Spigot-safe key read as {@link SiteGenerator}.
*
* @param biome chunk biome
* @return namespaced key, or {@code null} if unregistered
@@ -139,12 +139,10 @@ private static boolean isExcludedWaterBiome(World world, int x, int y, int z, Se
@SuppressWarnings("deprecation")
private static NamespacedKey biomeKey(Biome biome) {
try {
- Object value = biome.getClass().getMethod("getKeyOrNull").invoke(biome);
- if (value instanceof NamespacedKey key) {
- return key;
- }
+ // Spigot's RegistryAware: getKey() throws for an unregistered biome, so trust getKeyOrNull's null.
+ return (NamespacedKey) biome.getClass().getMethod("getKeyOrNull").invoke(biome);
} catch (ReflectiveOperationException ignored) {
- // Paper 1.21.10: RegistryAware helpers are absent
+ // Paper has no RegistryAware, so fall back to Keyed.getKey
}
return biome.getKey();
}
diff --git a/src/main/java/net/tfminecraft/archaeo/site/RuinAutoSpawner.java b/src/main/java/net/tfminecraft/archaeo/site/RuinAutoSpawner.java
index 12d4b4b..b5b1a08 100644
--- a/src/main/java/net/tfminecraft/archaeo/site/RuinAutoSpawner.java
+++ b/src/main/java/net/tfminecraft/archaeo/site/RuinAutoSpawner.java
@@ -81,7 +81,7 @@ public RuinAutoSpawner(
this.generator = generator;
this.ledger = ledger;
this.policyFile = plugin.getDataFolder().toPath().resolve("auto-ruins").resolve("evaluation-policy.txt");
- this.settings = settings == null ? AutoRuinSettings.defaults() : settings;
+ this.settings = settings;
}
/**
@@ -92,7 +92,7 @@ public RuinAutoSpawner(
* @param settings latest values after reload
*/
public void setSettings(AutoRuinSettings settings) {
- AutoRuinSettings next = settings == null ? AutoRuinSettings.defaults() : settings;
+ AutoRuinSettings next = settings;
boolean chanceChanged = Double.compare(this.settings.chancePerChunk(), next.chancePerChunk()) != 0;
this.settings = next;
if (chanceChanged) {
@@ -278,7 +278,7 @@ private void drain() {
dropUnloadedFromQueue(unloadBudget);
int evalBudget = Math.max(1, settings.maxEvaluationsPerTick());
for (int i = 0; i < evalBudget; i++) {
- Pending pending = pollLoadedPending(unloadBudget);
+ Pending pending = pollLoadedPending();
if (pending == null) {
return;
}
@@ -296,9 +296,6 @@ private void dropUnloadedFromQueue(int[] unloadBudget) {
int n = Math.min(unloadBudget[0], queue.size());
for (int i = 0; i < n; i++) {
Pending pending = queue.poll();
- if (pending == null) {
- return;
- }
unloadBudget[0]--;
if (isChunkLoaded(pending)) {
queue.offer(pending);
@@ -309,25 +306,22 @@ private void dropUnloadedFromQueue(int[] unloadBudget) {
}
/**
- * @param unloadBudget remaining checks for dropping unloaded entries this tick
- * @return next queued chunk that is still loaded, or {@code null} when none found within budget
+ * An unloaded head can only be an entry {@link #dropUnloadedFromQueue} had no budget left to
+ * check: when the budget covers the whole queue, every entry still queued was loaded this tick.
+ * It stays at the front for the next tick's purge.
+ *
+ * @return next queued chunk if it is still loaded, or {@code null} when the queue is empty or
+ * its head is unloaded
*/
- private Pending pollLoadedPending(int[] unloadBudget) {
- while (!queue.isEmpty()) {
- Pending pending = queue.poll();
- if (pending == null) {
- return null;
- }
- if (isChunkLoaded(pending)) {
- return pending;
- }
- if (unloadBudget[0] <= 0) {
- queue.addFirst(pending);
- return null;
- }
- unloadBudget[0]--;
- inflight.remove(key(pending.worldName(), pending.chunkX(), pending.chunkZ()));
+ private Pending pollLoadedPending() {
+ Pending pending = queue.poll();
+ if (pending == null) {
+ return null;
+ }
+ if (isChunkLoaded(pending)) {
+ return pending;
}
+ queue.addFirst(pending);
return null;
}
@@ -348,11 +342,8 @@ private boolean isChunkLoaded(Pending pending) {
*/
private void evaluate(Pending pending) {
String key = key(pending.worldName(), pending.chunkX(), pending.chunkZ());
+ // pollLoadedPending just found this chunk loaded in a loaded world, on this tick.
World world = plugin.getServer().getWorld(pending.worldName());
- if (world == null || !world.isChunkLoaded(pending.chunkX(), pending.chunkZ())) {
- inflight.remove(key);
- return;
- }
if (settings.maxSitesPerWorld() > 0
&& countSitesInWorld(pending.worldName()) >= settings.maxSitesPerWorld()) {
// Under the cap later this chunk may still be eligible; do not burn the evaluation.
@@ -534,14 +525,16 @@ private InterestLevel rollInterest(World world, int chunkX, int chunkZ) {
} else {
roll = ThreadLocalRandom.current().nextInt(total);
}
+ // roll < total, so it lands in some level's slot; reaching the end means the last level.
+ InterestLevel[] levels = InterestLevel.values();
int cursor = 0;
- for (InterestLevel level : InterestLevel.values()) {
- cursor += Math.max(0, weights.getOrDefault(level, 0));
+ for (int i = 0; i < levels.length - 1; i++) {
+ cursor += Math.max(0, weights.getOrDefault(levels[i], 0));
if (roll < cursor) {
- return level;
+ return levels[i];
}
}
- return InterestLevel.MEDIUM;
+ return levels[levels.length - 1];
}
/**
diff --git a/src/main/java/net/tfminecraft/archaeo/site/SiteCensus.java b/src/main/java/net/tfminecraft/archaeo/site/SiteCensus.java
index 25200ed..a7b7081 100644
--- a/src/main/java/net/tfminecraft/archaeo/site/SiteCensus.java
+++ b/src/main/java/net/tfminecraft/archaeo/site/SiteCensus.java
@@ -1,7 +1,5 @@
package net.tfminecraft.archaeo.site;
-import net.tfminecraft.archaeo.model.SiteStatus;
-
/**
* Snapshot of how many dossiers sit in each lifecycle bucket. Used by staff census, not by players.
*
@@ -22,16 +20,4 @@ public int excavations() {
return established + exhausted + closed;
}
- /**
- * @param status lifecycle bucket
- * @return count for that status
- */
- public int of(SiteStatus status) {
- return switch (status) {
- case HIDDEN -> hidden;
- case ESTABLISHED -> established;
- case EXHAUSTED -> exhausted;
- case CLOSED -> closed;
- };
- }
}
diff --git a/src/main/java/net/tfminecraft/archaeo/site/SiteClosure.java b/src/main/java/net/tfminecraft/archaeo/site/SiteClosure.java
index 47570f7..3a2ae24 100644
--- a/src/main/java/net/tfminecraft/archaeo/site/SiteClosure.java
+++ b/src/main/java/net/tfminecraft/archaeo/site/SiteClosure.java
@@ -49,9 +49,6 @@ private static void announce(Site site) {
int cx = site.getChunkX();
int cz = site.getChunkZ();
for (Player player : world.getPlayers()) {
- if (player.getLocation().getWorld() != world) {
- continue;
- }
int px = player.getLocation().getBlockX() >> 4;
int pz = player.getLocation().getBlockZ() >> 4;
if (Math.abs(px - cx) <= 1 && Math.abs(pz - cz) <= 1) {
diff --git a/src/main/java/net/tfminecraft/archaeo/site/SiteGenerator.java b/src/main/java/net/tfminecraft/archaeo/site/SiteGenerator.java
index db1ee27..3711fe6 100644
--- a/src/main/java/net/tfminecraft/archaeo/site/SiteGenerator.java
+++ b/src/main/java/net/tfminecraft/archaeo/site/SiteGenerator.java
@@ -215,9 +215,6 @@ public BuriedFind spawnStaffFind(Block origin, UUID director, ArtifactTemplate t
Set allowed = new HashSet<>(buriedPocket(origin.getWorld(), site, band, 0));
allowed.add(start);
LinkedCells grown = grow(start, allowed, occupied, size, new Random());
- if (grown.cells().isEmpty()) {
- throw new IllegalStateException("Could not grow a find from this block");
- }
BuriedFind find = new BuriedFind();
find.setId(UUID.randomUUID());
@@ -407,10 +404,8 @@ private boolean placeOne(
if (template == null) {
return false;
}
+ // pickArtifact only offers templates with at least one present stratum.
List compatible = template.strata().stream().filter(present::contains).toList();
- if (compatible.isEmpty()) {
- return false;
- }
String stratumId = compatible.get(random.nextInt(compatible.size()));
StratumBand band = site.getStrata().get(stratumId);
int size = template.sizeMin() + random.nextInt(template.sizeMax() - template.sizeMin() + 1);
@@ -439,7 +434,7 @@ private boolean placeOne(
*
* @param template artifact row (its material decides how well it survives)
* @param stratumId band that holds the find
- * @param band that band's disturbed flag
+ * @param band that band (never {@code null}), read for its disturbed flag
* @param random site RNG
* @return buried condition, 1–100
*/
@@ -463,7 +458,7 @@ private int rollBuriedConservation(
StratumDefinition definition = catalog.stratum(stratumId);
int depthSteps = definition == null ? 0 : Math.max(0, definition.order() - 1);
value -= (double) depthSteps * settings.depthPenalty();
- if (band != null && band.isDisturbed()) {
+ if (band.isDisturbed()) {
value -= settings.disturbedPenalty();
}
value *= catalog.materialSurvival(template.material());
@@ -481,15 +476,12 @@ private int rollBuriedConservation(
* sight, recoverable without digging at all.
*
* @param world ruin world; the chunk is already loaded by the caller
- * @param band stratum band, or {@code null}
+ * @param band present stratum band
* @param site chunk bounds
* @param cover fill blocks that must sit on top of a cell before it can hold a find
* @return usable cells, empty when the terrain leaves no room in this band
*/
private List buriedPocket(World world, Site site, StratumBand band, int cover) {
- if (band == null || !band.isPresent()) {
- return List.of();
- }
int minX = site.getChunkX() << 4;
int minZ = site.getChunkZ() << 4;
List pocket = new ArrayList<>();
@@ -528,7 +520,7 @@ private static boolean isBuried(World world, int x, int y, int z, int cover) {
/**
* Grows a compact face-connected blob (same Y, no diagonal corners) inside a stratum's buried pocket.
*
- * @param pocket cells this stratum may use, or {@code null}
+ * @param pocket non-empty buried cells this stratum may use
* @param occupied cells claimed by other finds
* @param targetSize desired cell count
* @param random site RNG
@@ -540,9 +532,6 @@ private List generateConnectedShape(
int targetSize,
Random random
) {
- if (pocket == null || pocket.isEmpty()) {
- return List.of();
- }
Set allowed = new HashSet<>(pocket);
for (int attempt = 0; attempt < catalog.maxShapeAttempts(); attempt++) {
BlockCell start = randomFreeCell(pocket, occupied, random);
@@ -650,9 +639,6 @@ List pickHints(Site site, InterestSettings settings, Set findTag
int want = Math.min(settings.hintCount(), pool.size());
for (int i = 0; i < want; i++) {
HintTemplate pick = weightedHint(pool, random);
- if (pick == null) {
- break;
- }
selected.add(pick.id());
pool.remove(pick);
}
@@ -720,45 +706,41 @@ private ArtifactTemplate pickArtifact(List present, boolean relic, Rando
int total = pool.stream().mapToInt(ArtifactTemplate::weight).sum();
int roll = random.nextInt(Math.max(1, total));
int cursor = 0;
- for (ArtifactTemplate template : pool) {
- cursor += template.weight();
+ for (int i = 0; i < pool.size() - 1; i++) {
+ cursor += pool.get(i).weight();
if (roll < cursor) {
- return template;
+ return pool.get(i);
}
}
return pool.getLast();
}
/**
- * @param pool remaining hint templates
+ * @param pool remaining hint templates; {@link #pickHints} never passes an empty pool
* @param random site RNG
- * @return one template by weight, or {@code null} if {@code pool} is empty
+ * @return one template by weight
*/
private HintTemplate weightedHint(List pool, Random random) {
- if (pool.isEmpty()) {
- return null;
- }
int total = pool.stream().mapToInt(HintTemplate::weight).sum();
int roll = random.nextInt(Math.max(1, total));
int cursor = 0;
- for (HintTemplate hint : pool) {
- cursor += hint.weight();
+ for (int i = 0; i < pool.size() - 1; i++) {
+ cursor += pool.get(i).weight();
if (roll < cursor) {
- return hint;
+ return pool.get(i);
}
}
return pool.getLast();
}
/**
- * @param finds placed finds
+ * @param finds finds just placed from {@link CatalogRegistry#artifacts()}, so each id resolves
* @return union of artifact tags for the finds already placed
*/
private Set findTags(List finds) {
return finds.stream()
.map(BuriedFind::getArtifactId)
.map(catalog::artifact)
- .filter(template -> template != null)
.flatMap(template -> template.tags().stream())
.collect(Collectors.toCollection(HashSet::new));
}
@@ -802,10 +784,9 @@ private String resolveName(String requested, Chunk chunk) {
}
/**
- * Reads a biome id on Paper 1.21.10 ({@code Keyed.getKey}) and on later APIs
- * ({@code getKeyOrNull}). The Maven {@code 1.21.10-R0.1-SNAPSHOT} javadoc already
- * deprecates {@code getKey} and documents {@code getKeyOrNull}; that method is not
- * on Paper 1.21.10 builds, so a direct call crashes at runtime.
+ * Reads a biome id on Paper ({@code Keyed.getKey}) and on Spigot, whose {@code RegistryAware}
+ * adds {@code getKeyOrNull} and makes {@code getKey} throw for an unregistered biome.
+ * paper-api 1.21.10 has no {@code getKeyOrNull}, so it is looked up reflectively.
*
* @param biome chunk biome
* @return namespaced key, or {@code null} if the biome is unregistered
@@ -813,12 +794,10 @@ private String resolveName(String requested, Chunk chunk) {
@SuppressWarnings("deprecation")
private static NamespacedKey biomeKey(Biome biome) {
try {
- Object value = biome.getClass().getMethod("getKeyOrNull").invoke(biome);
- if (value instanceof NamespacedKey key) {
- return key;
- }
+ // Spigot's RegistryAware: getKey() throws for an unregistered biome, so trust getKeyOrNull's null.
+ return (NamespacedKey) biome.getClass().getMethod("getKeyOrNull").invoke(biome);
} catch (ReflectiveOperationException ignored) {
- // Paper 1.21.10: RegistryAware helpers are absent
+ // Paper has no RegistryAware, so fall back to Keyed.getKey
}
return biome.getKey();
}
diff --git a/src/main/java/net/tfminecraft/archaeo/site/SitePurge.java b/src/main/java/net/tfminecraft/archaeo/site/SitePurge.java
index 5ef9133..c925d15 100644
--- a/src/main/java/net/tfminecraft/archaeo/site/SitePurge.java
+++ b/src/main/java/net/tfminecraft/archaeo/site/SitePurge.java
@@ -93,12 +93,9 @@ public SitePurge(
/**
* Erases {@code site} from memory, disk, camp, and tagged items as if it had never been filed.
*
- * @param site ruin or excavation in any status
+ * @param site stored ruin or excavation in any status
*/
public void erase(Site site) {
- if (site == null || site.getId() == null) {
- return;
- }
UUID id = site.getId();
establish.abortSessionsFor(id);
recover.abortForSite(id);
@@ -209,9 +206,6 @@ private void stripTaggedItems(UUID siteId) {
*/
private void stripArmorStand(ArmorStand stand, UUID siteId) {
EntityEquipment equipment = stand.getEquipment();
- if (equipment == null) {
- return;
- }
for (EquipmentSlot slot : EquipmentSlot.values()) {
if (belongs(equipment.getItem(slot), siteId)) {
equipment.setItem(slot, null);
@@ -222,26 +216,26 @@ private void stripArmorStand(ArmorStand stand, UUID siteId) {
/**
* @param inventory bag, chest, or similar
* @param siteId excavation
+ * @return whether any slot was cleared or rewritten
*/
- private void stripInventory(Inventory inventory, UUID siteId) {
- if (inventory == null) {
- return;
- }
+ private boolean stripInventory(Inventory inventory, UUID siteId) {
+ boolean changed = false;
ItemStack[] contents = inventory.getContents();
for (int i = 0; i < contents.length; i++) {
ItemStack stack = contents[i];
- if (stack == null || stack.getType().isAir()) {
+ // getContents reports an empty slot as null, never as an air stack.
+ if (stack == null) {
continue;
}
if (belongs(stack, siteId)) {
inventory.setItem(i, null);
- continue;
- }
- ItemStack nested = stripNested(stack, siteId);
- if (nested != stack) {
- inventory.setItem(i, nested);
+ changed = true;
+ } else if (stripNested(stack, siteId)) {
+ inventory.setItem(i, stack);
+ changed = true;
}
}
+ return changed;
}
/**
@@ -249,21 +243,20 @@ private void stripInventory(Inventory inventory, UUID siteId) {
*
* @param stack container item
* @param siteId excavation
- * @return the same stack, mutated when an inner inventory was rewritten
+ * @return whether an inner inventory lost an item, in which case {@code stack} was rewritten
*/
- private ItemStack stripNested(ItemStack stack, UUID siteId) {
+ private boolean stripNested(ItemStack stack, UUID siteId) {
ItemMeta meta = stack.getItemMeta();
if (!(meta instanceof BlockStateMeta blockMeta)) {
- return stack;
+ return false;
}
BlockState innerState = blockMeta.getBlockState();
- if (!(innerState instanceof InventoryHolder holder)) {
- return stack;
+ if (!(innerState instanceof InventoryHolder holder) || !stripInventory(holder.getInventory(), siteId)) {
+ return false;
}
- stripInventory(holder.getInventory(), siteId);
blockMeta.setBlockState(innerState);
stack.setItemMeta(blockMeta);
- return stack;
+ return true;
}
/**
@@ -272,7 +265,7 @@ private ItemStack stripNested(ItemStack stack, UUID siteId) {
* @return whether this stack is tagged to the ruin
*/
private boolean belongs(ItemStack stack, UUID siteId) {
- if (stack == null || stack.getType().isAir() || siteId == null) {
+ if (stack == null || stack.getType().isAir()) {
return false;
}
return siteId.equals(recovered.siteIdOf(stack))
diff --git a/src/main/java/net/tfminecraft/archaeo/site/SiteRepository.java b/src/main/java/net/tfminecraft/archaeo/site/SiteRepository.java
index cf2b462..70377b2 100644
--- a/src/main/java/net/tfminecraft/archaeo/site/SiteRepository.java
+++ b/src/main/java/net/tfminecraft/archaeo/site/SiteRepository.java
@@ -449,12 +449,9 @@ public void touch(Site site) {
* Commits every dirty dossier. Safe to call from the scheduler, disable, or reload.
*/
public void flushDirty() {
+ // Every dirty id is in byId: touch and commit add both; delete, erase and loadAll drop both.
for (UUID id : List.copyOf(dirty)) {
Site site = byId.get(id);
- if (site == null) {
- dirty.remove(id);
- continue;
- }
try {
commit(site);
} catch (RuntimeException exception) {
@@ -503,13 +500,9 @@ private int readIndexSerial() {
if (!indexFile.exists()) {
return 1;
}
- try {
- YamlConfiguration index = YamlConfiguration.loadConfiguration(indexFile);
- return Math.max(1, index.getInt("next-serial", 1));
- } catch (RuntimeException exception) {
- plugin.getLogger().log(Level.WARNING, "Could not read sites-index.yml", exception);
- return 1;
- }
+ // loadConfiguration logs a corrupt file itself and hands back an empty configuration.
+ YamlConfiguration index = YamlConfiguration.loadConfiguration(indexFile);
+ return Math.max(1, index.getInt("next-serial", 1));
}
/**
@@ -520,10 +513,8 @@ private int readIndexSerial() {
* @param target destination file
*/
private void replaceAtomically(YamlConfiguration yaml, File target) {
- File directory = target.getParentFile() == null ? sitesFolder : target.getParentFile();
- if (!directory.exists() && !directory.mkdirs()) {
- throw new IllegalStateException("Could not create " + directory.getPath());
- }
+ // writeAtomic has just ensured sitesFolder, which holds dossiers and sits inside the index's folder.
+ File directory = target.getParentFile();
File tmp = new File(directory, target.getName() + ".tmp");
try {
yaml.save(tmp);
@@ -616,10 +607,11 @@ private YamlConfiguration write(Site site) {
Map node = new java.util.LinkedHashMap<>();
node.put("id", find.getId().toString());
node.put("artifact-id", find.getArtifactId());
- if (find.getItem() != null && !find.getItem().isBlank()) {
+ // BuriedFind stores a blank item or name as null.
+ if (find.getItem() != null) {
node.put("item", find.getItem());
}
- if (find.getGivenName() != null && !find.getGivenName().isBlank()) {
+ if (find.getGivenName() != null) {
node.put("given-name", find.getGivenName());
}
node.put("stratum", find.getStratumId());
@@ -1065,13 +1057,10 @@ private static void addInterpretations(Object raw, BuriedFind find) {
}
/**
- * @param value YAML number or missing
- * @return integer ≥ 0, or {@code 0} when missing or unparsable
+ * @param value YAML number the caller found present
+ * @return integer ≥ 0, or {@code 0} when unparsable
*/
private static int parsePositive(Object value) {
- if (value == null) {
- return 0;
- }
try {
return Math.max(0, Integer.parseInt(String.valueOf(value)));
} catch (NumberFormatException ignored) {
diff --git a/src/main/java/net/tfminecraft/archaeo/sketch/CabinetCues.java b/src/main/java/net/tfminecraft/archaeo/sketch/CabinetCues.java
index 2c959ac..db38863 100644
--- a/src/main/java/net/tfminecraft/archaeo/sketch/CabinetCues.java
+++ b/src/main/java/net/tfminecraft/archaeo/sketch/CabinetCues.java
@@ -20,9 +20,6 @@ private CabinetCues() {
* @param player cataloguer
*/
public static void complete(JavaPlugin plugin, Player player) {
- if (player == null) {
- return;
- }
player.playSound(player.getLocation(), Sound.ENTITY_VILLAGER_YES, SoundCategory.PLAYERS, 0.9f, 1.1f);
player.getWorld().spawnParticle(
Particle.HAPPY_VILLAGER,
@@ -32,10 +29,6 @@ public static void complete(JavaPlugin plugin, Player player) {
0.25,
0.35,
0.02);
- if (plugin == null) {
- player.closeInventory();
- return;
- }
plugin.getServer().getScheduler().runTask(plugin, () -> {
if (player.isOnline()) {
player.closeInventory();
@@ -49,9 +42,6 @@ public static void complete(JavaPlugin plugin, Player player) {
* @param player cataloguer
*/
public static void signedReading(Player player) {
- if (player == null) {
- return;
- }
player.playSound(player.getLocation(), Sound.ITEM_BOOK_PAGE_TURN, SoundCategory.PLAYERS, 1f, 1.1f);
}
}
diff --git a/src/main/java/net/tfminecraft/archaeo/sketch/CabinetLab.java b/src/main/java/net/tfminecraft/archaeo/sketch/CabinetLab.java
index e564a11..0b9a895 100644
--- a/src/main/java/net/tfminecraft/archaeo/sketch/CabinetLab.java
+++ b/src/main/java/net/tfminecraft/archaeo/sketch/CabinetLab.java
@@ -63,7 +63,7 @@ final class CabinetLab {
this.sites = sites;
this.catalogs = catalogs;
this.recovered = recovered;
- this.settings = settings == null ? SketchSettings.defaults() : settings;
+ this.settings = settings;
this.kindKey = new NamespacedKey(plugin, "lab_kind");
this.toolKey = new NamespacedKey(plugin, "lab_tool");
this.stainKey = new NamespacedKey(plugin, "lab_stain");
@@ -73,26 +73,20 @@ final class CabinetLab {
* @param settings rack tools after reload
*/
void setSettings(SketchSettings settings) {
- this.settings = settings == null ? SketchSettings.defaults() : settings;
+ this.settings = settings;
}
/**
- * Opens the wipe window when the player has an uncleaned recovered piece in hand.
+ * Opens the wipe window for the archived piece the player holds, unless it is already clean.
*
* @param player clicker
- * @param block cabinet
- * @param hand main-hand stack
+ * @param block cabinet, or {@code null}
+ * @param site excavation that owns the piece
+ * @param find archive row of the piece in hand
* @return whether the lab window opened (or is already open)
*/
- boolean tryStart(Player player, Block block, ItemStack hand) {
- if (player == null || !recovered.isRecovered(hand)) {
- return false;
- }
- UUID findId = recovered.findIdOf(hand);
- UUID siteId = recovered.siteIdOf(hand);
- Site site = siteId == null ? null : sites.findById(siteId).orElse(null);
- BuriedFind find = site == null || findId == null ? null : site.findById(findId).orElse(null);
- if (find == null || find.getState() != FindState.RECOVERED || find.isLabCleaned() || find.isCatalogued()) {
+ boolean tryStart(Player player, Block block, Site site, BuriedFind find) {
+ if (find.getState() != FindState.RECOVERED || find.isLabCleaned() || find.isCatalogued()) {
return false;
}
if (open.containsKey(player.getUniqueId())) {
@@ -110,7 +104,11 @@ boolean tryStart(Player player, Block block, ItemStack hand) {
toolKey,
stainKey);
open.put(player.getUniqueId(), board);
- board.open(player);
+ if (!board.open(player)) {
+ // Another plugin cancelled the open; no close will follow to unregister the board.
+ open.remove(player.getUniqueId(), board);
+ return true;
+ }
player.sendMessage(ChatColor.GOLD + "Pick a tool and wipe the dirt. "
+ ChatColor.WHITE + material.firstStepGerund() + ".");
return true;
@@ -122,19 +120,10 @@ boolean tryStart(Player player, Block block, ItemStack hand) {
* @param player worker
*/
void cancel(Player player) {
- if (player == null) {
- return;
- }
- CabinetLabBoard board = open.get(player.getUniqueId());
- if (board == null) {
- return;
- }
- if (player.getOpenInventory().getTopInventory().getHolder() instanceof CabinetLabBoard) {
+ // A board stays registered only while its window is open: every close runs handleClose.
+ if (open.containsKey(player.getUniqueId())) {
player.closeInventory();
- return;
}
- open.remove(player.getUniqueId());
- stripLabItems(player);
}
/**
@@ -161,12 +150,12 @@ void stop() {
* @return cursor after the click
*/
ItemStack handleClick(Player player, CabinetLabBoard board, int slot, ItemStack cursor) {
- if (player == null || board == null || board.finished()) {
+ if (board.finished()) {
return cursor;
}
if (board.isToolSlot(slot)) {
ItemStack picked = board.copyTool(slot);
- if (isEmpty(cursor) || !board.isTool(cursor)) {
+ if (!board.isTool(cursor)) {
playTool(player, board.tool(board.toolId(picked)));
return picked;
}
@@ -176,7 +165,7 @@ ItemStack handleClick(Player player, CabinetLabBoard board, int slot, ItemStack
playTool(player, board.tool(board.toolId(picked)));
return picked;
}
- if (slot < 0 || slot >= LabSettings.FIELD_SLOTS) {
+ if (slot >= LabSettings.FIELD_SLOTS) {
return cursor;
}
if (!board.isTool(cursor)) {
@@ -197,9 +186,6 @@ ItemStack handleClick(Player player, CabinetLabBoard board, int slot, ItemStack
* @param board window that closed
*/
void handleClose(Player player, CabinetLabBoard board) {
- if (player == null) {
- return;
- }
open.remove(player.getUniqueId(), board);
stripLabItems(player);
plugin.getServer().getScheduler().runTask(plugin, () -> stripLabItems(player));
@@ -217,20 +203,17 @@ private void tryWipe(Player player, CabinetLabBoard board, int slot, ItemStack c
}
String toolId = board.toolId(cursor);
LabStain stain = board.stainOf(slot);
- if (stain == null || !stain.allowsTool(toolId)) {
+ if (!stain.allowsTool(toolId)) {
if (!board.warnedWrongTool()) {
board.markWarnedWrongTool();
- String label = stain == null ? "this dirt" : stain.label().toLowerCase(java.util.Locale.ROOT);
+ String label = stain.label().toLowerCase(java.util.Locale.ROOT);
player.sendMessage(ChatColor.GOLD + "That tool is not for " + label + ".");
}
player.playSound(player.getLocation(), Sound.BLOCK_NOTE_BLOCK_BASS, SoundCategory.PLAYERS, 0.4f, 0.7f);
return;
}
- if (!board.wipe(slot)) {
- return;
- }
- LabTool tool = board.tool(toolId);
- cue(player, board, tool);
+ board.wipe(slot);
+ cue(player, board, board.tool(toolId));
if (board.dirtyLeft() > 0) {
return;
}
@@ -264,13 +247,12 @@ private void finish(Player player, CabinetLabBoard board) {
/**
* @param player worker
* @param board open window
- * @param tool successful tool, or {@code null}
+ * @param tool rack tool that wiped the pane
*/
private void cue(Player player, CabinetLabBoard board, LabTool tool) {
playTool(player, tool);
- String id = tool == null ? "" : tool.id();
- Location at = board.cabinet() == null ? player.getLocation() : board.cabinet();
- if ("water".equals(id)) {
+ Location at = board.cabinet();
+ if ("water".equals(tool.id())) {
player.getWorld().spawnParticle(Particle.SPLASH, at, 8, 0.2, 0.1, 0.2, 0.01);
return;
}
@@ -279,12 +261,9 @@ private void cue(Player player, CabinetLabBoard board, LabTool tool) {
/**
* @param player listener
- * @param tool rack tool, or {@code null}
+ * @param tool rack tool; config always resolves a sound
*/
private static void playTool(Player player, LabTool tool) {
- if (player == null || tool == null || tool.sound() == null) {
- return;
- }
player.playSound(player.getLocation(), tool.sound(), SoundCategory.PLAYERS, 0.4f, 1.2f);
}
@@ -294,12 +273,13 @@ private static void playTool(Player player, LabTool tool) {
* @param player worker
*/
private void stripLabItems(Player player) {
- if (player == null || !player.isOnline()) {
+ if (!player.isOnline()) {
return;
}
if (isLabStack(player.getItemOnCursor())) {
player.setItemOnCursor(null);
}
+ // Player contents include armour and the off-hand, so this reaches every slot.
PlayerInventory inventory = player.getInventory();
ItemStack[] contents = inventory.getContents();
for (int i = 0; i < contents.length; i++) {
@@ -307,9 +287,6 @@ private void stripLabItems(Player player) {
inventory.setItem(i, null);
}
}
- if (isLabStack(inventory.getItemInOffHand())) {
- inventory.setItemInOffHand(null);
- }
}
/**
@@ -325,7 +302,7 @@ boolean isLabItem(ItemStack stack) {
* @return whether this stack was spawned by a lab window
*/
private boolean isLabStack(ItemStack stack) {
- if (stack == null || stack.getType().isAir() || !stack.hasItemMeta()) {
+ if (stack == null || !stack.hasItemMeta()) {
return false;
}
return stack.getItemMeta().getPersistentDataContainer().has(kindKey, org.bukkit.persistence.PersistentDataType.STRING);
@@ -344,8 +321,7 @@ private FindMaterial materialOf(BuriedFind find) {
* @return current lab settings
*/
private LabSettings lab() {
- LabSettings lab = settings.lab();
- return lab == null ? LabSettings.defaults() : lab;
+ return settings.lab();
}
/**
@@ -355,17 +331,7 @@ private LabSettings lab() {
* @return whether both are the same rack tool
*/
private static boolean sameTool(CabinetLabBoard board, ItemStack a, ItemStack b) {
- String left = board.toolId(a);
- String right = board.toolId(b);
- return left != null && left.equals(right);
- }
-
- /**
- * @param stack candidate
- * @return whether the stack is missing
- */
- private static boolean isEmpty(ItemStack stack) {
- return stack == null || stack.getType().isAir() || stack.getAmount() <= 0;
+ return board.toolId(a).equals(board.toolId(b));
}
/**
diff --git a/src/main/java/net/tfminecraft/archaeo/sketch/CabinetLabBoard.java b/src/main/java/net/tfminecraft/archaeo/sketch/CabinetLabBoard.java
index 14fd7c8..27f6e00 100644
--- a/src/main/java/net/tfminecraft/archaeo/sketch/CabinetLabBoard.java
+++ b/src/main/java/net/tfminecraft/archaeo/sketch/CabinetLabBoard.java
@@ -144,12 +144,13 @@ int dirtyLeft() {
/**
* @param player worker
+ * @return whether the window opened; another plugin may cancel the open
*/
- void open(org.bukkit.entity.Player player) {
+ boolean open(org.bukkit.entity.Player player) {
inventory = Bukkit.createInventory(this, 27, material.firstStepGerund());
fillField();
fillTools();
- player.openInventory(inventory);
+ return player.openInventory(inventory) != null;
}
/**
@@ -181,7 +182,7 @@ boolean isTool(ItemStack stack) {
* @return rack id, or {@code null}
*/
String toolId(ItemStack stack) {
- if (stack == null || stack.getType().isAir() || !stack.hasItemMeta()) {
+ if (stack == null || !stack.hasItemMeta()) {
return null;
}
return stack.getItemMeta().getPersistentDataContainer().get(toolKey, PersistentDataType.STRING);
@@ -196,15 +197,14 @@ LabTool tool(String id) {
}
/**
- * @param slot dirty field index
- * @return stain on that pane, or {@code null}
+ * @param slot field index
+ * @return stain on that pane, or {@code null} once it is clean
*/
LabStain stainOf(int slot) {
- ItemStack stack = slotItem(slot);
- if (stack == null || stack.getType().isAir() || !stack.hasItemMeta()) {
+ if (!isDirty(slot)) {
return null;
}
- String id = stack.getItemMeta().getPersistentDataContainer().get(stainKey, PersistentDataType.STRING);
+ String id = slotItem(slot).getItemMeta().getPersistentDataContainer().get(stainKey, PersistentDataType.STRING);
return lab.stain(id);
}
@@ -215,7 +215,7 @@ LabStain stainOf(int slot) {
* @return whether a dirty cell was cleaned
*/
boolean wipe(int slot) {
- if (!isDirty(slot) || inventory == null) {
+ if (!isDirty(slot)) {
return false;
}
inventory.setItem(slot, cleanPane());
@@ -245,7 +245,7 @@ boolean isLabStack(ItemStack stack) {
*/
private void fillField() {
List pool = stainPool();
- int want = pool.isEmpty() ? 0 : Math.max(1, Math.min(LabSettings.FIELD_SLOTS, lab.dirtyCount()));
+ int want = Math.max(1, Math.min(LabSettings.FIELD_SLOTS, lab.dirtyCount()));
List slots = new ArrayList<>();
for (int slot = 0; slot < LabSettings.FIELD_SLOTS; slot++) {
slots.add(slot);
@@ -267,20 +267,20 @@ private void fillField() {
}
/**
+ * Falls back to the first configured stain when none of the material's stains resolves;
+ * config always keeps at least one stain, so the pool is never empty.
+ *
* @return stains this material may roll, skipping unknown ids
*/
private List stainPool() {
List pool = new ArrayList<>();
- List ids = material.stains();
- if (ids != null) {
- for (String id : ids) {
- LabStain stain = lab.stain(id);
- if (stain != null) {
- pool.add(stain);
- }
+ for (String id : material.stains()) {
+ LabStain stain = lab.stain(id);
+ if (stain != null) {
+ pool.add(stain);
}
}
- if (pool.isEmpty() && lab.stains() != null && !lab.stains().isEmpty()) {
+ if (pool.isEmpty()) {
pool.add(lab.stains().get(0));
}
return pool;
@@ -290,7 +290,7 @@ private List stainPool() {
* Places rack tools on the last row, centred.
*/
private void fillTools() {
- List tools = lab.tools() == null ? List.of() : lab.tools();
+ List tools = lab.tools();
int count = Math.min(9, tools.size());
int start = LabSettings.FIELD_SLOTS + Math.max(0, (9 - count) / 2);
for (int i = 0; i < count; i++) {
@@ -304,11 +304,9 @@ private void fillTools() {
private ItemStack cleanPane() {
ItemStack stack = new ItemStack(material.cleanPane());
ItemMeta meta = stack.getItemMeta();
- if (meta != null) {
- meta.setDisplayName(ChatColor.WHITE + "Clean");
- meta.getPersistentDataContainer().set(kindKey, PersistentDataType.STRING, KIND_CLEAN);
- stack.setItemMeta(meta);
- }
+ meta.setDisplayName(ChatColor.WHITE + "Clean");
+ meta.getPersistentDataContainer().set(kindKey, PersistentDataType.STRING, KIND_CLEAN);
+ stack.setItemMeta(meta);
return stack;
}
@@ -319,18 +317,16 @@ private ItemStack cleanPane() {
private ItemStack dirtyPane(LabStain stain) {
ItemStack stack = new ItemStack(stain.pane());
ItemMeta meta = stack.getItemMeta();
- if (meta != null) {
- meta.setDisplayName(ChatColor.GOLD + stain.label());
- LabTool needed = lab.tool(stain.toolId());
- String toolName = needed == null || needed.displayName() == null || needed.displayName().isBlank()
- ? stain.toolId()
- : needed.displayName();
- meta.setLore(List.of(ChatColor.GRAY + "Wipe with " + toolName + "."));
- var pdc = meta.getPersistentDataContainer();
- pdc.set(kindKey, PersistentDataType.STRING, KIND_DIRTY);
- pdc.set(stainKey, PersistentDataType.STRING, stain.id());
- stack.setItemMeta(meta);
- }
+ meta.setDisplayName(ChatColor.GOLD + stain.label());
+ LabTool needed = lab.tool(stain.toolId());
+ String toolName = needed == null || needed.displayName().isBlank()
+ ? stain.toolId()
+ : needed.displayName();
+ meta.setLore(List.of(ChatColor.GRAY + "Wipe with " + toolName + "."));
+ var pdc = meta.getPersistentDataContainer();
+ pdc.set(kindKey, PersistentDataType.STRING, KIND_DIRTY);
+ pdc.set(stainKey, PersistentDataType.STRING, stain.id());
+ stack.setItemMeta(meta);
return stack;
}
@@ -341,21 +337,19 @@ private ItemStack dirtyPane(LabStain stain) {
private ItemStack toolStack(LabTool tool) {
ItemStack stack = new ItemStack(tool.item());
ItemMeta meta = stack.getItemMeta();
- if (meta != null) {
- String name = tool.displayName() == null || tool.displayName().isBlank()
- ? tool.id()
- : tool.displayName();
- meta.setDisplayName(ChatColor.WHITE + name);
- List lore = new ArrayList<>();
- lore.add(ChatColor.GRAY + "Click to pick up.");
- lore.add(ChatColor.GRAY + "Click the dirt to clean it.");
- addDescription(lore, tool.description());
- meta.setLore(lore);
- var pdc = meta.getPersistentDataContainer();
- pdc.set(kindKey, PersistentDataType.STRING, KIND_TOOL);
- pdc.set(toolKey, PersistentDataType.STRING, tool.id());
- stack.setItemMeta(meta);
- }
+ String name = tool.displayName().isBlank()
+ ? tool.id()
+ : tool.displayName();
+ meta.setDisplayName(ChatColor.WHITE + name);
+ List lore = new ArrayList<>();
+ lore.add(ChatColor.GRAY + "Click to pick up.");
+ lore.add(ChatColor.GRAY + "Click the dirt to clean it.");
+ addDescription(lore, tool.description());
+ meta.setLore(lore);
+ var pdc = meta.getPersistentDataContainer();
+ pdc.set(kindKey, PersistentDataType.STRING, KIND_TOOL);
+ pdc.set(toolKey, PersistentDataType.STRING, tool.id());
+ stack.setItemMeta(meta);
return stack;
}
@@ -364,7 +358,7 @@ private ItemStack toolStack(LabTool tool) {
* @return stack, or {@code null}
*/
private ItemStack slotItem(int slot) {
- if (inventory == null || slot < 0 || slot >= inventory.getSize()) {
+ if (slot < 0 || slot >= inventory.getSize()) {
return null;
}
return inventory.getItem(slot);
@@ -375,7 +369,7 @@ private ItemStack slotItem(int slot) {
* @return kind token, or {@code null}
*/
private String kindOf(ItemStack stack) {
- if (stack == null || stack.getType().isAir() || !stack.hasItemMeta()) {
+ if (stack == null || !stack.hasItemMeta()) {
return null;
}
return stack.getItemMeta().getPersistentDataContainer().get(kindKey, PersistentDataType.STRING);
@@ -388,7 +382,7 @@ private String kindOf(ItemStack stack) {
* @param text tool description, or blank
*/
private static void addDescription(List lore, String text) {
- if (text == null || text.isBlank()) {
+ if (text.isBlank()) {
return;
}
for (String paragraph : text.split("\\R")) {
@@ -416,8 +410,7 @@ private static void wrap(List lore, String text) {
}
line.append(word);
}
- if (line.length() > 0) {
- lore.add(ChatColor.DARK_GRAY + line.toString());
- }
+ // A trimmed, non-empty paragraph always leaves its last words here.
+ lore.add(ChatColor.DARK_GRAY + line.toString());
}
}
diff --git a/src/main/java/net/tfminecraft/archaeo/sketch/SketchCabinet.java b/src/main/java/net/tfminecraft/archaeo/sketch/SketchCabinet.java
index 00735ba..4e9c63a 100644
--- a/src/main/java/net/tfminecraft/archaeo/sketch/SketchCabinet.java
+++ b/src/main/java/net/tfminecraft/archaeo/sketch/SketchCabinet.java
@@ -86,9 +86,6 @@ public void open(
* @param player cataloguer
*/
public void returnContents(Player player) {
- if (inventory == null || player == null) {
- return;
- }
give(player, inventory.getItem(SLOT_SKETCH));
inventory.setItem(SLOT_SKETCH, null);
inventory.setItem(SLOT_FIND, null);
@@ -109,22 +106,20 @@ public static boolean locked(int slot) {
private static ItemStack registerControl() {
ItemStack stack = new ItemStack(Material.WRITABLE_BOOK);
ItemMeta meta = stack.getItemMeta();
- if (meta != null) {
- meta.setDisplayName(ChatColor.WHITE + "Register");
- meta.setLore(List.of(
- ChatColor.GRAY + "Place the drawing in the top slot.",
- ChatColor.GRAY + "Then click here."));
- stack.setItemMeta(meta);
- }
+ meta.setDisplayName(ChatColor.WHITE + "Register");
+ meta.setLore(List.of(
+ ChatColor.GRAY + "Place the drawing in the top slot.",
+ ChatColor.GRAY + "Then click here."));
+ stack.setItemMeta(meta);
return stack;
}
/**
* @param player receiver
- * @param stack drawing, or empty
+ * @param stack drawing, or {@code null} for an empty slot
*/
private static void give(Player player, ItemStack stack) {
- if (stack == null || stack.getType().isAir() || stack.getAmount() <= 0) {
+ if (stack == null) {
return;
}
player.getInventory().addItem(stack).values()
diff --git a/src/main/java/net/tfminecraft/archaeo/sketch/SketchInk.java b/src/main/java/net/tfminecraft/archaeo/sketch/SketchInk.java
index 1bb8b42..50f1e18 100644
--- a/src/main/java/net/tfminecraft/archaeo/sketch/SketchInk.java
+++ b/src/main/java/net/tfminecraft/archaeo/sketch/SketchInk.java
@@ -48,10 +48,7 @@ public String label() {
*/
public SketchInk next() {
SketchInk[] values = values();
- int firstStroke = CHARCOAL.ordinal();
- if (ordinal() < firstStroke) {
- return CHARCOAL;
- }
+ // PAPER sits just before CHARCOAL, so it steps straight into the stroke cycle.
int next = ordinal() + 1;
if (next >= values.length) {
return CHARCOAL;
diff --git a/src/main/java/net/tfminecraft/archaeo/sketch/SketchListener.java b/src/main/java/net/tfminecraft/archaeo/sketch/SketchListener.java
index b2942e3..808e65b 100644
--- a/src/main/java/net/tfminecraft/archaeo/sketch/SketchListener.java
+++ b/src/main/java/net/tfminecraft/archaeo/sketch/SketchListener.java
@@ -70,11 +70,11 @@ public SketchListener(SketchService sketches) {
*/
@EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true)
public void onMove(PlayerMoveEvent event) {
- if (!sketches.editing(event.getPlayer()) || event.getTo() == null) {
+ if (!sketches.editing(event.getPlayer())) {
return;
}
- if (!event.getFrom().getWorld().equals(event.getTo().getWorld())
- || event.getFrom().getX() != event.getTo().getX()
+ // Cross-world moves are PlayerTeleportEvents, which have their own handler list.
+ if (event.getFrom().getX() != event.getTo().getX()
|| event.getFrom().getY() != event.getTo().getY()
|| event.getFrom().getZ() != event.getTo().getZ()) {
Location stay = event.getFrom().clone();
@@ -138,9 +138,7 @@ public void onInteract(PlayerInteractEvent event) {
*/
@EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true)
public void onCombine(InventoryClickEvent event) {
- if (!(event.getWhoClicked() instanceof Player player)) {
- return;
- }
+ Player player = (Player) event.getWhoClicked();
if (event.getClickedInventory() == null) {
return;
}
@@ -148,9 +146,7 @@ public void onCombine(InventoryClickEvent event) {
if (type != InventoryType.PLAYER && type != InventoryType.CRAFTING && type != InventoryType.CREATIVE) {
return;
}
- ItemStack cursor = event.getCursor() == null
- ? new ItemStack(org.bukkit.Material.AIR)
- : event.getCursor();
+ ItemStack cursor = event.getCursor();
ItemStack slot = event.getCurrentItem() == null
? new ItemStack(org.bukkit.Material.AIR)
: event.getCurrentItem();
@@ -172,10 +168,8 @@ public void onCombine(InventoryClickEvent event) {
* @return {@code null} when Bukkit should treat the slot as empty
*/
private static ItemStack emptyToNull(ItemStack stack) {
- if (stack == null || stack.getType().isAir() || stack.getAmount() <= 0) {
- return null;
- }
- return stack;
+ // Spent sheets and pencils are always set to AIR as well as amount 0.
+ return stack.getType().isAir() ? null : stack;
}
/**
@@ -188,16 +182,11 @@ public void onCabinetClick(InventoryClickEvent event) {
if (!(event.getView().getTopInventory().getHolder() instanceof SketchCabinet)) {
return;
}
- if (!(event.getWhoClicked() instanceof Player player)) {
- return;
- }
+ Player player = (Player) event.getWhoClicked();
Inventory top = event.getView().getTopInventory();
if (event.getClickedInventory() == top) {
event.setCancelled(true);
- ItemStack cursor = event.getCursor() == null
- ? new ItemStack(org.bukkit.Material.AIR)
- : event.getCursor().clone();
- ItemStack next = sketches.handleCabinetClick(player, top, event.getSlot(), cursor);
+ ItemStack next = sketches.handleCabinetClick(player, top, event.getSlot(), event.getCursor().clone());
event.getView().setCursor(next);
return;
}
@@ -220,17 +209,12 @@ public void onLabClick(InventoryClickEvent event) {
if (!(event.getView().getTopInventory().getHolder() instanceof CabinetLabBoard board)) {
return;
}
- if (!(event.getWhoClicked() instanceof Player player)) {
- return;
- }
+ Player player = (Player) event.getWhoClicked();
event.setCancelled(true);
if (event.getClickedInventory() != event.getView().getTopInventory()) {
return;
}
- ItemStack cursor = event.getCursor() == null
- ? new ItemStack(org.bukkit.Material.AIR)
- : event.getCursor().clone();
- ItemStack next = sketches.handleLabClick(player, board, event.getSlot(), cursor);
+ ItemStack next = sketches.handleLabClick(player, board, event.getSlot(), event.getCursor().clone());
event.getView().setCursor(next);
}
@@ -273,9 +257,7 @@ public void onCabinetClose(InventoryCloseEvent event) {
if (!(event.getInventory().getHolder() instanceof SketchCabinet cabinet)) {
return;
}
- if (event.getPlayer() instanceof Player player) {
- cabinet.returnContents(player);
- }
+ cabinet.returnContents((Player) event.getPlayer());
}
/**
@@ -288,9 +270,7 @@ public void onLabClose(InventoryCloseEvent event) {
if (!(event.getInventory().getHolder() instanceof CabinetLabBoard board)) {
return;
}
- if (event.getPlayer() instanceof Player player) {
- sketches.handleLabClose(player, board);
- }
+ sketches.handleLabClose((Player) event.getPlayer(), board);
}
/**
@@ -438,7 +418,7 @@ public void onFish(PlayerFishEvent event) {
*/
@EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = false)
public void onInventory(InventoryOpenEvent event) {
- if (!(event.getPlayer() instanceof Player player) || !sketches.editing(player)) {
+ if (!sketches.editing((Player) event.getPlayer())) {
return;
}
InventoryType type = event.getInventory().getType();
@@ -455,9 +435,7 @@ public void onInventory(InventoryOpenEvent event) {
*/
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onInventoryOpened(InventoryOpenEvent event) {
- if (event.getPlayer() instanceof Player player) {
- sketches.stampKitsLater(player);
- }
+ sketches.stampKitsLater((Player) event.getPlayer());
}
/**
@@ -620,8 +598,7 @@ public void onChat(AsyncPlayerChatEvent event) {
return;
}
event.setCancelled(true);
- String raw = event.getMessage() == null ? "" : event.getMessage().trim();
- sketches.handleSignChatLater(player, raw);
+ sketches.handleSignChatLater(player, event.getMessage().trim());
}
/**
@@ -631,9 +608,7 @@ public void onChat(AsyncPlayerChatEvent event) {
*/
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onInventoryClick(InventoryClickEvent event) {
- if (!(event.getWhoClicked() instanceof Player player)) {
- return;
- }
+ Player player = (Player) event.getWhoClicked();
ItemStack hotbar = event.getHotbarButton() >= 0
? player.getInventory().getItem(event.getHotbarButton())
: null;
@@ -646,9 +621,7 @@ public void onInventoryClick(InventoryClickEvent event) {
*/
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onInventoryDrag(InventoryDragEvent event) {
- if (!(event.getWhoClicked() instanceof Player player)) {
- return;
- }
+ Player player = (Player) event.getWhoClicked();
java.util.ArrayList extras = new java.util.ArrayList<>();
extras.add(event.getCursor());
extras.add(event.getOldCursor());
@@ -663,10 +636,9 @@ public void onInventoryDrag(InventoryDragEvent event) {
*/
@EventHandler(priority = EventPriority.MONITOR)
public void onInventoryClose(InventoryCloseEvent event) {
- if (event.getPlayer() instanceof Player player) {
- sketches.syncHandLater(player, event.getView().getCursor());
- sketches.stampKitsLater(player);
- }
+ Player player = (Player) event.getPlayer();
+ sketches.syncHandLater(player, event.getView().getCursor());
+ sketches.stampKitsLater(player);
}
/**
diff --git a/src/main/java/net/tfminecraft/archaeo/sketch/SketchRenderer.java b/src/main/java/net/tfminecraft/archaeo/sketch/SketchRenderer.java
index 8ffe835..bf4ce2c 100644
--- a/src/main/java/net/tfminecraft/archaeo/sketch/SketchRenderer.java
+++ b/src/main/java/net/tfminecraft/archaeo/sketch/SketchRenderer.java
@@ -36,6 +36,11 @@ private static final class CanvasState {
this.sketches = sketches;
}
+ /** Keeps renderer reuse local to the service that owns its live sheets. */
+ boolean belongsTo(SketchService service) {
+ return sketches == service;
+ }
+
/**
* Copies dirty sheets onto the canvas. Other viewers see the drawing without a cursor.
*
diff --git a/src/main/java/net/tfminecraft/archaeo/sketch/SketchService.java b/src/main/java/net/tfminecraft/archaeo/sketch/SketchService.java
index 653ca8a..919b6b4 100644
--- a/src/main/java/net/tfminecraft/archaeo/sketch/SketchService.java
+++ b/src/main/java/net/tfminecraft/archaeo/sketch/SketchService.java
@@ -118,25 +118,18 @@ public SketchService(
}
/**
- * @return configured sheet and pencil
- */
- public SketchSupplies supplies() {
- return supplies;
- }
-
- /**
- * @param settings pencil wear, cabinet block, and lab wipe after reload
+ * @param settings pencil wear, cabinet block, and lab wipe after reload; the catalogue never returns {@code null}
*/
public void setSettings(SketchSettings settings) {
- this.settings = settings == null ? SketchSettings.defaults() : settings;
- this.lab.setSettings(this.settings);
+ this.settings = settings;
+ this.lab.setSettings(settings);
}
/**
- * @param matcher ItemsAdder furniture lookup
+ * @param matcher ItemsAdder furniture lookup; the plugin already substitutes vanilla-only for {@code null}
*/
public void setMatcher(ItemMatcher matcher) {
- this.matcher = matcher == null ? ItemMatcher.vanillaOnly() : matcher;
+ this.matcher = matcher;
}
/**
@@ -288,7 +281,7 @@ private void becomeSketch(Player player, ItemStack paper) {
* @return whether this is the configured cabinet
*/
public boolean isCabinet(Block block) {
- return settings != null && matcher.matchesPlaced(block, settings.cabinet());
+ return matcher.matchesPlaced(block, settings.cabinet());
}
/**
@@ -306,7 +299,7 @@ public boolean isCabinet(Block block) {
* @return whether the cabinet handled the click
*/
public boolean tryOpenCabinet(Player player, Block block, boolean sneaking) {
- if (settings != null && settings.cabinet().kind() == ItemRef.Kind.ITEMSADDER) {
+ if (settings.cabinet().kind() == ItemRef.Kind.ITEMSADDER) {
return false;
}
return tryOpenCabinet(player, null, null, block, sneaking);
@@ -319,7 +312,7 @@ public boolean tryOpenCabinet(Player player, Block block, boolean sneaking) {
* @param player clicker
* @param namespacedId ItemsAdder furniture id, or {@code null}
* @param entity furniture entity, or {@code null}
- * @param block block under the furniture, or {@code null}
+ * @param block block under the furniture ({@code PackPluginHook} derives it from the entity), or {@code null}
* @param sneaking whether the player is sneaking
* @return whether the cabinet handled the click
*/
@@ -330,7 +323,7 @@ public boolean tryOpenCabinet(
Block block,
boolean sneaking
) {
- if (player == null || editing(player) || sneaking || settings == null) {
+ if (editing(player) || sneaking) {
return false;
}
boolean cabinet = matcher.matchesNamespacedId(namespacedId, settings.cabinet())
@@ -339,9 +332,6 @@ public boolean tryOpenCabinet(
if (!cabinet) {
return false;
}
- if (block == null && entity != null) {
- block = entity.getLocation().getBlock();
- }
ItemStack hand = player.getInventory().getItemInMainHand();
if (!recovered.isRecovered(hand)) {
player.sendMessage(ChatColor.GOLD + "Bring an artifact.");
@@ -350,7 +340,7 @@ public boolean tryOpenCabinet(
UUID findId = recovered.findIdOf(hand);
UUID siteId = recovered.siteIdOf(hand);
Site site = siteId == null ? null : sites.findById(siteId).orElse(null);
- BuriedFind find = site == null || findId == null ? null : site.findById(findId).orElse(null);
+ BuriedFind find = site == null ? null : site.findById(findId).orElse(null);
if (find == null) {
player.sendMessage(ChatColor.GOLD + "This piece is not in the excavation archive.");
return true;
@@ -359,7 +349,7 @@ public boolean tryOpenCabinet(
player.sendMessage(ChatColor.GOLD + "The record on this piece is complete.");
return true;
}
- if (lab.tryStart(player, block, hand)) {
+ if (lab.tryStart(player, block, site, find)) {
return true;
}
if (!find.hasFieldSketch()) {
@@ -433,14 +423,11 @@ void handleLabClose(Player player, CabinetLabBoard board) {
* @return cursor after the click
*/
public ItemStack handleCabinetClick(Player player, Inventory top, int slot, ItemStack cursor) {
- if (player == null || top == null) {
- return cursor;
- }
if (slot == SketchCabinet.SLOT_REGISTER) {
tryRegisterCabinet(player, top);
return cursor;
}
- if (SketchCabinet.locked(slot) || slot != SketchCabinet.SLOT_SKETCH) {
+ if (slot != SketchCabinet.SLOT_SKETCH) {
return cursor;
}
ItemStack there = top.getItem(slot);
@@ -467,10 +454,7 @@ public ItemStack handleCabinetClick(Player player, Inventory top, int slot, Item
* @return whether the stack moved
*/
public boolean tryDepositCabinet(Player player, Inventory top, ItemStack stack) {
- if (top == null || isEmpty(stack)) {
- return false;
- }
- if (!isEmpty(top.getItem(SketchCabinet.SLOT_SKETCH))) {
+ if (isEmpty(stack) || !isEmpty(top.getItem(SketchCabinet.SLOT_SKETCH))) {
return false;
}
if (!acceptFinishedDrawing(player, stack)) {
@@ -488,13 +472,9 @@ public boolean tryDepositCabinet(Player player, Inventory top, ItemStack stack)
* @param top furnace cabinet
*/
public void tryRegisterCabinet(Player player, Inventory top) {
- if (player == null || top == null || !(top.getHolder() instanceof SketchCabinet cabinet)) {
- return;
- }
- Site site = cabinet.siteId() == null ? null : sites.findById(cabinet.siteId()).orElse(null);
- BuriedFind find = site == null || cabinet.findId() == null
- ? null
- : site.findById(cabinet.findId()).orElse(null);
+ SketchCabinet cabinet = (SketchCabinet) top.getHolder();
+ Site site = sites.findById(cabinet.siteId()).orElse(null);
+ BuriedFind find = site == null ? null : site.findById(cabinet.findId()).orElse(null);
if (find == null) {
refuseRegister(player, "This piece is not in the excavation archive.");
return;
@@ -512,15 +492,12 @@ public void tryRegisterCabinet(Player player, Inventory top) {
beginCabinetReading(player, top, site, find);
return;
}
+ // The top slot only ever accepts inked, signed drawings (see acceptFinishedDrawing).
ItemStack sketch = top.getItem(SketchCabinet.SLOT_SKETCH);
- if (!isSketchMap(sketch) || !isSigned(sketch)) {
+ if (!isSigned(sketch)) {
refuseRegister(player, "Place the drawing in the top slot.");
return;
}
- if (!hasInk(sheetOf(sketch))) {
- refuseRegister(player, "That drawing is still blank.");
- return;
- }
UUID bound = boundFindId(sketch);
if (bound != null && !bound.equals(find.getId())) {
refuseRegister(player, "This drawing already records a different find.");
@@ -557,17 +534,13 @@ private void beginCabinetReading(Player player, Inventory top, Site site, Buried
player.sendMessage(ChatColor.GOLD + "You are not authorised to write this record.");
return;
}
- if (!recovered.isInMainHand(player, find.getId())) {
- player.sendMessage(ChatColor.GOLD + "Keep the artifact in your hand.");
- return;
- }
if (top != null) {
handBackCabinet(player, top);
}
UUID siteId = site.getId();
UUID findId = find.getId();
plugin.getServer().getScheduler().runTask(plugin, () -> {
- if (player == null || !player.isOnline()) {
+ if (!player.isOnline()) {
return;
}
Site live = sites.findById(siteId).orElse(null);
@@ -597,11 +570,11 @@ private void refuseRegister(Player player, String message) {
* @return whether the stack may go in
*/
private boolean acceptFinishedDrawing(Player player, ItemStack stack) {
- if (!isSketchMap(stack) || !isSigned(stack)) {
+ if (!isSigned(stack)) {
refuseRegister(player, "That cannot be registered.");
return false;
}
- if (!hasInk(sheetOf(stack))) {
+ if (!sheetOf(stack).hasInk()) {
refuseRegister(player, "That drawing is still blank.");
return false;
}
@@ -629,7 +602,7 @@ private void handBackCabinet(Player player, Inventory top) {
*/
public void stampKitsLater(Player player) {
plugin.getServer().getScheduler().runTask(plugin, () -> {
- if (player == null || !player.isOnline()) {
+ if (!player.isOnline()) {
return;
}
unstackCarried(player);
@@ -656,13 +629,11 @@ public void afterBagCraft(
ItemStack slot
) {
plugin.getServer().getScheduler().runTask(plugin, () -> {
- if (player == null || !player.isOnline()) {
+ if (!player.isOnline()) {
return;
}
player.getOpenInventory().setCursor(isEmpty(cursor) ? null : cursor);
- if (inventory != null && slotIndex >= 0) {
- inventory.setItem(slotIndex, isEmpty(slot) ? null : slot);
- }
+ inventory.setItem(slotIndex, isEmpty(slot) ? null : slot);
player.updateInventory();
unstackCarried(player);
supplies.stampAll(player.getInventory().getContents());
@@ -679,7 +650,7 @@ public void afterBagCraft(
* @param stack unsigned sketch
*/
public void enter(Player player, ItemStack stack) {
- if (stack == null || !isSketchMap(stack) || isSigned(stack) || editing(player)) {
+ if (!isSketchMap(stack) || isSigned(stack) || editing(player)) {
return;
}
hydrate(stack);
@@ -688,11 +659,7 @@ public void enter(Player player, ItemStack stack) {
if (view == null) {
return;
}
- SketchSheet sheet = sheets.get(view.getId());
- if (sheet == null) {
- return;
- }
- sessions.put(player.getUniqueId(), new SketchSession(player.getUniqueId(), view, sheet));
+ sessions.put(player.getUniqueId(), new SketchSession(view, sheets.get(view.getId())));
freeze(player);
player.sendMessage(ChatColor.GOLD + "Editing field sketch.");
player.sendMessage(ChatColor.WHITE + "Sneak paints. Right-click erases. Space changes ink.");
@@ -700,7 +667,8 @@ public void enter(Player player, ItemStack stack) {
}
/**
- * Writes the sheet onto {@code stack} when possible, then thaws.
+ * Writes the sheet onto {@code stack} when possible, then thaws. Callers only leave an open
+ * session: the listener checks {@link #editing(Player)} and the service holds the session.
*
* @param player editor
* @param announce whether this leave was a player action (hotbar, inventory, drop) and should tell them
@@ -710,19 +678,16 @@ public void leave(Player player, boolean announce, ItemStack stack) {
SketchSession session = sessions.remove(player.getUniqueId());
thaw(player);
hideHud(player);
- if (session == null) {
- return;
- }
- ItemStack target = stack != null && matchesView(stack, session.view().getId())
+ ItemStack target = matchesView(stack, session.view().getId())
? stack
: findSaveTarget(player, session);
if (target == null) {
checkpoint(session);
return;
}
- if (writeItem(target, session.sheet(), isSigned(target), authorOf(target))) {
- deleteAutosave(session.view().getId());
- }
+ // Save targets are always sketch maps, so the drawing is now on the item.
+ writeItem(target, session.sheet(), isSigned(target), authorOf(target));
+ deleteAutosave(session.view().getId());
if (announce) {
player.sendMessage(ChatColor.GRAY + "Sketch saved.");
}
@@ -744,15 +709,12 @@ public void leave(Player player, boolean announce) {
* @param extras stacks that just left the inventory
*/
public void leave(Player player, boolean announce, Iterable extras) {
- SketchSession session = sessions.get(player.getUniqueId());
+ int viewId = sessions.get(player.getUniqueId()).view().getId();
ItemStack target = null;
- if (session != null && extras != null) {
- int viewId = session.view().getId();
- for (ItemStack stack : extras) {
- if (matchesView(stack, viewId)) {
- target = stack;
- break;
- }
+ for (ItemStack stack : extras) {
+ if (matchesView(stack, viewId)) {
+ target = stack;
+ break;
}
}
leave(player, announce, target);
@@ -799,7 +761,7 @@ public void syncHandLater(Player player, ItemStack... extras) {
plugin.getServer().getScheduler().runTask(plugin, () -> {
SketchSession session = sessions.get(player.getUniqueId());
if (session != null && !holdingThisSketch(player, session)) {
- leave(player, true, extras == null ? java.util.List.of() : java.util.Arrays.asList(extras));
+ leave(player, true, java.util.Arrays.asList(extras));
}
syncHand(player);
});
@@ -823,10 +785,7 @@ public void handleSignChatLater(Player player, String raw) {
*/
public void askToSign(Player player) {
SketchSession session = sessions.get(player.getUniqueId());
- if (session == null) {
- return;
- }
- if (!hasInk(session.sheet())) {
+ if (!session.sheet().hasInk()) {
refuseBlankSign(player);
return;
}
@@ -854,14 +813,20 @@ public boolean handleSignChat(Player player, String raw) {
player.sendMessage(ChatColor.WHITE + "Type sign to finish, or cancel to keep editing.");
return true;
}
- if (!hasInk(session.sheet())) {
+ if (!session.sheet().hasInk()) {
session.setAwaitingSign(false);
refuseBlankSign(player);
return true;
}
- ItemStack hand = player.getInventory().getItemInMainHand();
- writeItem(hand, session.sheet(), true, player.getName());
- leave(player, false, hand);
+ // The chat task can run before the hand sync, so lock the session's own stack, not the hand.
+ ItemStack sketch = findSaveTarget(player, session);
+ if (sketch == null) {
+ leave(player, false, (ItemStack) null);
+ player.sendMessage(ChatColor.GOLD + "The sketch is no longer with you. Nothing was signed.");
+ return true;
+ }
+ writeItem(sketch, session.sheet(), true, player.getName());
+ leave(player, false, sketch);
player.sendMessage(ChatColor.GOLD + "Sketch signed. It can no longer be edited.");
return true;
}
@@ -876,14 +841,6 @@ private void refuseBlankSign(Player player) {
player.sendMessage(ChatColor.GRAY + "Sneak paints. Right-click erases.");
}
- /**
- * @param sheet cells to judge, or {@code null}
- * @return whether at least one cell is painted
- */
- private boolean hasInk(SketchSheet sheet) {
- return sheet != null && sheet.hasInk();
- }
-
/**
* @param player possible editor
* @return whether they are frozen in the prototype
@@ -897,12 +854,12 @@ public boolean editing(Player player) {
* @return excavation id stamped when the drawing was registered, or {@code null}
*/
public UUID siteIdOf(ItemStack stack) {
- if (stack == null || !isSketchMap(stack) || !stack.hasItemMeta()) {
+ if (!isSketchMap(stack)) {
return null;
}
String raw = stack.getItemMeta().getPersistentDataContainer()
.get(siteIdKey, PersistentDataType.STRING);
- if (raw == null || raw.isBlank()) {
+ if (raw == null) {
return null;
}
try {
@@ -936,26 +893,30 @@ SketchSheet sheetOf(MapView view) {
*/
private SketchSheet sheetOf(ItemStack stack) {
hydrate(stack);
+ return storedSheet(stack);
+ }
+
+ /**
+ * Same lookup without hydrating: a sketch in a chest or ender chest has no live buffer
+ * after a restart, so its own stored pixels must be kept rather than a blank page.
+ *
+ * @param stack sketch map
+ * @return live cells for its view, else the cells stored on the item; never {@code null}
+ */
+ private SketchSheet storedSheet(ItemStack stack) {
MapView view = mapView(stack);
- if (view != null) {
- SketchSheet live = sheets.get(view.getId());
- if (live != null) {
- return live;
- }
- }
- return SketchSheet.fromBytes(cellsOf(stack));
+ SketchSheet live = view == null ? null : sheets.get(view.getId());
+ return live != null ? live : SketchSheet.fromBytes(cellsOf(stack));
}
/**
- * Clears the cursor cell. Used by right-click / interact.
+ * Clears the cursor cell. Used by right-click / interact; the listener only calls this after
+ * {@link #editing(Player)}, so the session exists.
*
* @param player editor
*/
public void erase(Player player) {
- SketchSession session = sessions.get(player.getUniqueId());
- if (session != null) {
- session.beginEraseStroke();
- }
+ sessions.get(player.getUniqueId()).beginEraseStroke();
}
/**
@@ -963,10 +924,10 @@ public void erase(Player player) {
* @return whether this is a prototype sketch map
*/
public boolean isSketchMap(ItemStack stack) {
- if (stack == null || stack.getType() != Material.FILLED_MAP || !(stack.getItemMeta() instanceof MapMeta meta)) {
+ if (stack == null || stack.getType() != Material.FILLED_MAP) {
return false;
}
- return meta.getPersistentDataContainer().has(markerKey, PersistentDataType.BYTE);
+ return stack.getItemMeta().getPersistentDataContainer().has(markerKey, PersistentDataType.BYTE);
}
/**
@@ -986,16 +947,13 @@ private void tick() {
}
}
}
- for (UUID playerId : Map.copyOf(sessions).keySet()) {
- Player player = Bukkit.getPlayer(playerId);
+ for (Map.Entry entry : Map.copyOf(sessions).entrySet()) {
+ Player player = Bukkit.getPlayer(entry.getKey());
if (player == null || !player.isOnline()) {
- sessions.remove(playerId);
- continue;
- }
- SketchSession session = sessions.get(playerId);
- if (session == null) {
+ sessions.remove(entry.getKey());
continue;
}
+ SketchSession session = entry.getValue();
if (!holdingThisSketch(player, session)) {
leave(player, true);
continue;
@@ -1012,14 +970,10 @@ private void tick() {
/**
* Saves changed sheets to disk without replacing the map item in the player's hand.
+ * Only {@link #tick()} calls this, after it has already dropped sessions of offline players.
*/
private void autosaveOpenSessions() {
- for (Map.Entry entry : Map.copyOf(sessions).entrySet()) {
- Player player = Bukkit.getPlayer(entry.getKey());
- if (player == null || !player.isOnline()) {
- continue;
- }
- SketchSession session = entry.getValue();
+ for (SketchSession session : sessions.values()) {
checkpoint(session);
}
}
@@ -1145,35 +1099,28 @@ public void hydrate(ItemStack stack) {
* Rewrites {@code #name-n} on a registered sketch after the excavation is renamed.
*
* @param stack signed sketch, or {@code null}
- * @param site excavation
+ * @param site excavation, or {@code null} once staff purged the dossier the sketch names
* @return whether the label was rewritten
*/
public boolean retitle(ItemStack stack, Site site) {
- if (stack == null || site == null || site.getId() == null || !site.getId().equals(siteIdOf(stack))) {
+ if (site == null || !site.getId().equals(siteIdOf(stack))) {
return false;
}
UUID findId = boundFindId(stack);
- if (findId == null) {
- return false;
- }
- BuriedFind find = site.findById(findId).orElse(null);
- if (find == null || !(stack.getItemMeta() instanceof MapMeta meta)) {
+ BuriedFind find = findId == null ? null : site.findById(findId).orElse(null);
+ if (find == null) {
return false;
}
+ org.bukkit.inventory.meta.ItemMeta meta = stack.getItemMeta();
+ // Recovered finds are always numbered (Site#assignMissingFindNumbers), so the label exists.
String label = find.publicNumber(site);
String current = meta.getPersistentDataContainer().get(labelKey, PersistentDataType.STRING);
- if (java.util.Objects.equals(current, label)) {
+ if (label.equals(current)) {
return false;
}
- if (label == null || label.isBlank()) {
- meta.getPersistentDataContainer().remove(labelKey);
- } else {
- meta.getPersistentDataContainer().set(labelKey, PersistentDataType.STRING, label);
- }
+ meta.getPersistentDataContainer().set(labelKey, PersistentDataType.STRING, label);
stack.setItemMeta(meta);
- MapView view = mapView(stack);
- SketchSheet sheet = view == null ? new SketchSheet() : sheets.getOrDefault(view.getId(), new SketchSheet());
- writeItem(stack, sheet, isSigned(stack), authorOf(stack));
+ writeItem(stack, storedSheet(stack), isSigned(stack), authorOf(stack));
return true;
}
@@ -1195,10 +1142,10 @@ private void retitleFromLive(ItemStack stack) {
* @return whether this sheet is locked
*/
public boolean isSigned(ItemStack stack) {
- if (!isSketchMap(stack) || !(stack.getItemMeta() instanceof MapMeta meta)) {
+ if (!isSketchMap(stack)) {
return false;
}
- Byte flag = meta.getPersistentDataContainer().get(signedKey, PersistentDataType.BYTE);
+ Byte flag = stack.getItemMeta().getPersistentDataContainer().get(signedKey, PersistentDataType.BYTE);
return flag != null && flag == 1;
}
@@ -1210,7 +1157,7 @@ private ItemStack createBlank(Player player) {
ItemStack map = createUnsigned(player.getWorld());
ItemStack previous = player.getInventory().getItemInMainHand();
player.getInventory().setItemInMainHand(map);
- if (previous != null && previous.getType() != Material.AIR) {
+ if (!previous.getType().isAir()) {
HashMap leftover = player.getInventory().addItem(previous);
leftover.values().forEach(item -> player.getWorld().dropItemNaturally(player.getLocation(), item));
}
@@ -1227,10 +1174,9 @@ public ItemStack createUnsigned(World world) {
SketchSheet sheet = new SketchSheet();
sheets.put(view.getId(), sheet);
ItemStack map = new ItemStack(Material.FILLED_MAP);
- if (map.getItemMeta() instanceof MapMeta meta) {
- meta.setMapView(view);
- map.setItemMeta(meta);
- }
+ MapMeta meta = (MapMeta) map.getItemMeta();
+ meta.setMapView(view);
+ map.setItemMeta(meta);
writeItem(map, sheet, false, null);
return map;
}
@@ -1241,9 +1187,6 @@ public ItemStack createUnsigned(World world) {
* @param player holder
*/
public void unstackCarried(Player player) {
- if (player == null) {
- return;
- }
ItemStack[] contents = player.getInventory().getContents();
for (int i = 0; i < contents.length; i++) {
ItemStack stack = contents[i];
@@ -1269,7 +1212,7 @@ private void unstackSketch(Player player, ItemStack stack) {
return;
}
markUnstackable(stack);
- if (player == null || stack.getAmount() <= 1) {
+ if (stack.getAmount() <= 1) {
return;
}
int extra = stack.getAmount() - 1;
@@ -1288,10 +1231,7 @@ private void unstackSketch(Player player, ItemStack stack) {
private ItemStack duplicateSketch(World world, ItemStack original) {
ItemStack map = createUnsigned(world);
SketchSheet sheet = SketchSheet.fromBytes(cellsOf(original));
- MapView view = mapView(map);
- if (view != null) {
- sheets.put(view.getId(), sheet);
- }
+ sheets.put(mapView(map).getId(), sheet);
writeItem(map, sheet, isSigned(original), authorOf(original));
return map;
}
@@ -1318,7 +1258,7 @@ private void bindRenderer(MapView view) {
view.setUnlimitedTracking(false);
boolean ours = false;
for (org.bukkit.map.MapRenderer renderer : view.getRenderers()) {
- if (renderer instanceof SketchRenderer) {
+ if (renderer instanceof SketchRenderer sketchRenderer && sketchRenderer.belongsTo(this)) {
ours = true;
break;
}
@@ -1326,21 +1266,20 @@ private void bindRenderer(MapView view) {
if (ours) {
return;
}
- view.getRenderers().clear();
+ for (org.bukkit.map.MapRenderer renderer : view.getRenderers()) {
+ view.removeRenderer(renderer);
+ }
view.addRenderer(new SketchRenderer(this));
}
/**
- * @param stack map item
+ * @param stack sketch map (always a {@code FILLED_MAP})
* @param sheet pixels
* @param signed locked
- * @param author signer, or {@code null}
- * @return whether the PDC write reached {@code setItemMeta}
+ * @param author signer, or {@code null} on unsigned or legacy drawings
*/
- private boolean writeItem(ItemStack stack, SketchSheet sheet, boolean signed, String author) {
- if (stack == null || !(stack.getItemMeta() instanceof MapMeta meta)) {
- return false;
- }
+ private void writeItem(ItemStack stack, SketchSheet sheet, boolean signed, String author) {
+ MapMeta meta = (MapMeta) stack.getItemMeta();
MapView view = meta.getMapView();
if (view == null) {
view = mapView(stack);
@@ -1357,7 +1296,7 @@ private boolean writeItem(ItemStack stack, SketchSheet sheet, boolean signed, St
pdc.set(mapIdKey, PersistentDataType.INTEGER, view.getId());
meta.setMapView(view);
}
- if (signed && author != null && !author.isEmpty()) {
+ if (signed && author != null) {
pdc.set(authorKey, PersistentDataType.STRING, author);
}
if (!signed) {
@@ -1366,7 +1305,6 @@ private boolean writeItem(ItemStack stack, SketchSheet sheet, boolean signed, St
applySketchAppearance(meta, signed, boundFind, boundTitle, boundLabel);
meta.setMaxStackSize(1);
stack.setItemMeta(meta);
- return true;
}
/**
@@ -1384,14 +1322,11 @@ private void deleteAutosave(int mapId) {
}
/**
- * @param stack sketch map
- * @return persisted sheet revision, or zero for older maps
+ * @param stack sketch map (always a {@code FILLED_MAP}; {@link #hydrate} has already read its map meta)
+ * @return persisted sheet revision, or zero for maps saved before revisions were stored
*/
private long metaRevision(ItemStack stack) {
- if (!(stack.getItemMeta() instanceof MapMeta meta)) {
- return 0;
- }
- Long revision = meta.getPersistentDataContainer().get(revisionKey, PersistentDataType.LONG);
+ Long revision = stack.getItemMeta().getPersistentDataContainer().get(revisionKey, PersistentDataType.LONG);
return revision == null ? 0 : revision;
}
@@ -1409,16 +1344,10 @@ private void applySketchAppearance(
String boundTitle,
String boundLabel
) {
- boolean bound = boundFind != null && !boundFind.isBlank();
- String title = boundTitle == null || boundTitle.isBlank() ? "find" : boundTitle;
- if (signed && bound) {
- meta.setDisplayName(ChatColor.WHITE + "Sketch of " + title);
- java.util.ArrayList lore = new java.util.ArrayList<>();
- if (boundLabel != null && !boundLabel.isBlank()) {
- lore.add(ChatColor.GOLD + boundLabel);
- }
- lore.add(ChatColor.GRAY + "Hold to look at the drawing.");
- meta.setLore(lore);
+ // bindSketch writes the find id, title, and label together, and only on signed drawings.
+ if (boundFind != null) {
+ meta.setDisplayName(ChatColor.WHITE + "Sketch of " + boundTitle);
+ meta.setLore(java.util.List.of(ChatColor.GOLD + boundLabel, ChatColor.GRAY + "Hold to look at the drawing."));
return;
}
if (signed) {
@@ -1477,10 +1406,7 @@ private boolean replacePaperWithMap(Player player, boolean mainHand) {
giveOrDrop(player, map);
}
player.sendMessage(ChatColor.GOLD + "Field sketch ready. Hold it to draw.");
- ItemStack hand = player.getInventory().getItemInMainHand();
- if (isSketchMap(hand) && !isSigned(hand)) {
- enter(player, hand);
- }
+ enter(player, player.getInventory().getItemInMainHand());
return true;
}
@@ -1492,40 +1418,25 @@ private boolean replacePaperWithMap(Player player, boolean mainHand) {
* @param site excavation
*/
private void bindSketch(ItemStack sketch, BuriedFind find, Site site) {
- if (!(sketch.getItemMeta() instanceof MapMeta meta) || find == null || find.getId() == null) {
- return;
- }
+ MapMeta meta = (MapMeta) sketch.getItemMeta();
org.bukkit.persistence.PersistentDataContainer pdc = meta.getPersistentDataContainer();
pdc.set(findIdKey, PersistentDataType.STRING, find.getId().toString());
- if (site != null && site.getId() != null) {
- pdc.set(siteIdKey, PersistentDataType.STRING, site.getId().toString());
- }
+ pdc.set(siteIdKey, PersistentDataType.STRING, site.getId().toString());
ArtifactTemplate template = catalogs.artifact(find.getArtifactId());
String title = find.shownName(template == null ? null : template.displayName());
pdc.set(titleKey, PersistentDataType.STRING, title);
- if (site != null) {
- String label = find.publicNumber(site);
- if (label != null && !label.isBlank()) {
- pdc.set(labelKey, PersistentDataType.STRING, label);
- }
- }
+ pdc.set(labelKey, PersistentDataType.STRING, find.publicNumber(site));
sketch.setItemMeta(meta);
- hydrate(sketch);
- MapView view = mapView(sketch);
- SketchSheet sheet = view == null ? new SketchSheet() : sheets.getOrDefault(view.getId(), new SketchSheet());
- writeItem(sketch, sheet, true, authorOf(sketch));
+ writeItem(sketch, sheetOf(sketch), true, authorOf(sketch));
}
/**
- * @param stack sketch
+ * @param stack sketch map
* @return bound find id, or {@code null}
*/
private UUID boundFindId(ItemStack stack) {
- if (!(stack.getItemMeta() instanceof MapMeta meta)) {
- return null;
- }
- String raw = meta.getPersistentDataContainer().get(findIdKey, PersistentDataType.STRING);
- if (raw == null || raw.isBlank()) {
+ String raw = stack.getItemMeta().getPersistentDataContainer().get(findIdKey, PersistentDataType.STRING);
+ if (raw == null) {
return null;
}
try {
@@ -1539,9 +1450,6 @@ private UUID boundFindId(ItemStack stack) {
* @param stack kit stack
*/
private static void consumeOne(ItemStack stack) {
- if (stack == null || stack.getType().isAir()) {
- return;
- }
int left = stack.getAmount() - 1;
if (left <= 0) {
stack.setAmount(0);
@@ -1552,12 +1460,9 @@ private static void consumeOne(ItemStack stack) {
}
/**
- * @param stack kit stack to empty
+ * @param stack deposited drawing to empty
*/
private static void consumeAll(ItemStack stack) {
- if (stack == null || stack.getType().isAir()) {
- return;
- }
stack.setAmount(0);
stack.setType(Material.AIR);
}
@@ -1567,7 +1472,7 @@ private static void consumeAll(ItemStack stack) {
* @return whether it cannot be placed back
*/
private static boolean isEmpty(ItemStack stack) {
- return stack == null || stack.getType().isAir() || stack.getAmount() <= 0;
+ return stack == null || stack.isEmpty();
}
/**
@@ -1580,51 +1485,35 @@ private static void giveOrDrop(Player player, ItemStack stack) {
}
/**
- * @param stack map
+ * @param stack sketch map
* @return stored cells, or {@code null}
*/
private byte[] cellsOf(ItemStack stack) {
- if (!(stack.getItemMeta() instanceof MapMeta meta)) {
- return null;
- }
- return meta.getPersistentDataContainer().get(cellsKey, PersistentDataType.BYTE_ARRAY);
+ return stack.getItemMeta().getPersistentDataContainer().get(cellsKey, PersistentDataType.BYTE_ARRAY);
}
/**
- * @param stack map
+ * @param stack sketch map
* @return signer, or {@code null}
*/
private String authorOf(ItemStack stack) {
- if (!(stack.getItemMeta() instanceof MapMeta meta)) {
- return null;
- }
- return meta.getPersistentDataContainer().get(authorKey, PersistentDataType.STRING);
+ return stack.getItemMeta().getPersistentDataContainer().get(authorKey, PersistentDataType.STRING);
}
/**
- * @param stack map
+ * @param stack sketch map
* @return view, or {@code null}
*/
@SuppressWarnings("deprecation")
private MapView mapView(ItemStack stack) {
- if (!(stack.getItemMeta() instanceof MapMeta meta)) {
- return null;
- }
+ MapMeta meta = (MapMeta) stack.getItemMeta();
MapView view = meta.getMapView();
if (view != null) {
return view;
}
+ // Paper's getMapView already resolves the vanilla map id; the stored copy covers a stripped link.
Integer stored = meta.getPersistentDataContainer().get(mapIdKey, PersistentDataType.INTEGER);
- if (stored != null) {
- view = Bukkit.getMap(stored);
- if (view != null) {
- return view;
- }
- }
- if (meta.hasMapId()) {
- return Bukkit.getMap(meta.getMapId());
- }
- return null;
+ return stored == null ? null : Bukkit.getMap(stored);
}
/**
@@ -1634,10 +1523,8 @@ private MapView mapView(ItemStack stack) {
* @param view resolved view
*/
private void attachView(ItemStack stack, MapView view) {
- if (!(stack.getItemMeta() instanceof MapMeta meta) || view == null) {
- return;
- }
- if (meta.getMapView() != null && meta.getMapView().getId() == view.getId()) {
+ MapMeta meta = (MapMeta) stack.getItemMeta();
+ if (meta.getMapView() != null) {
return;
}
meta.setMapView(view);
@@ -1664,9 +1551,6 @@ private boolean matchesView(ItemStack stack, int viewId) {
* @return the item that should receive the pixels, or {@code null} if it is gone
*/
private ItemStack findSaveTarget(Player player, SketchSession session) {
- if (player == null || session == null) {
- return null;
- }
int viewId = session.view().getId();
ItemStack hand = player.getInventory().getItemInMainHand();
if (matchesView(hand, viewId)) {
@@ -1676,24 +1560,13 @@ private ItemStack findSaveTarget(Player player, SketchSession session) {
if (matchesView(off, viewId)) {
return off;
}
+ // The bottom of every open view is the player's own inventory, and its cursor is theirs.
InventoryView open = player.getOpenInventory();
- if (open != null) {
- if (matchesView(open.getCursor(), viewId)) {
- return open.getCursor();
- }
- ItemStack inTop = firstMatch(open.getTopInventory(), viewId);
- if (inTop != null) {
- return inTop;
- }
- ItemStack inBottom = firstMatch(open.getBottomInventory(), viewId);
- if (inBottom != null) {
- return inBottom;
- }
- }
- if (matchesView(player.getItemOnCursor(), viewId)) {
- return player.getItemOnCursor();
+ if (matchesView(open.getCursor(), viewId)) {
+ return open.getCursor();
}
- return firstMatch(player.getInventory(), viewId);
+ ItemStack inTop = firstMatch(open.getTopInventory(), viewId);
+ return inTop != null ? inTop : firstMatch(open.getBottomInventory(), viewId);
}
/**
@@ -1736,19 +1609,14 @@ private void thaw(Player player) {
* @param frozen whether movement, jump, and mining should be multiplied to zero
*/
private void setFrozen(Player player, boolean frozen) {
- AttributeInstance speed = player.getAttribute(Attribute.MOVEMENT_SPEED);
- if (speed == null) {
- player.setWalkSpeed(frozen ? 0f : 0.2f);
- } else {
- zeroAttribute(speed, frozen);
- }
+ zeroAttribute(player, Attribute.MOVEMENT_SPEED, frozen);
zeroAttribute(player, Attribute.JUMP_STRENGTH, frozen);
zeroAttribute(player, Attribute.BLOCK_BREAK_SPEED, frozen);
}
/**
* @param player editor
- * @param attribute jump or mining speed
+ * @param attribute walk, jump, or mining speed
* @param frozen whether to attach the zeroing modifier
*/
private void zeroAttribute(Player player, Attribute attribute, boolean frozen) {
diff --git a/src/main/java/net/tfminecraft/archaeo/sketch/SketchSession.java b/src/main/java/net/tfminecraft/archaeo/sketch/SketchSession.java
index 96dac0f..0ce507d 100644
--- a/src/main/java/net/tfminecraft/archaeo/sketch/SketchSession.java
+++ b/src/main/java/net/tfminecraft/archaeo/sketch/SketchSession.java
@@ -2,8 +2,6 @@
import org.bukkit.map.MapView;
-import java.util.UUID;
-
/**
* One player frozen in front of one prototype map: cursor, current ink, and the sheet they stamp.
*/
@@ -12,7 +10,6 @@ final class SketchSession {
private static final int REPEAT_INTERVAL_TICKS = 2;
private static final int ERASE_STROKE_GRACE_TICKS = 5;
- private final UUID playerId;
private final MapView view;
private final SketchSheet sheet;
private int cursorX = SketchSheet.SIZE / 2;
@@ -30,24 +27,15 @@ final class SketchSession {
private boolean awaitingSign;
/**
- * @param playerId editor
* @param view locked map the renderer owns
* @param sheet pixels for that view
*/
- SketchSession(UUID playerId, MapView view, SketchSheet sheet) {
- this.playerId = playerId;
+ SketchSession(MapView view, SketchSheet sheet) {
this.view = view;
this.sheet = sheet;
this.autosavedRevision = sheet.revision();
}
- /**
- * @return editor
- */
- UUID playerId() {
- return playerId;
- }
-
/**
* @return map shown in hand
*/
diff --git a/src/main/java/net/tfminecraft/archaeo/tracker/TrackerService.java b/src/main/java/net/tfminecraft/archaeo/tracker/TrackerService.java
index d007469..a99f1eb 100644
--- a/src/main/java/net/tfminecraft/archaeo/tracker/TrackerService.java
+++ b/src/main/java/net/tfminecraft/archaeo/tracker/TrackerService.java
@@ -132,8 +132,8 @@ ScanHit lockedTarget(Player player) {
if (current == null) {
return best;
}
- if (best != null
- && !best.site().getId().equals(current.site().getId())
+ // The locked site is hidden, in range and ahead, so the full scan found at least that one.
+ if (!best.site().getId().equals(current.site().getId())
&& betterLook(best, current)) {
return best;
}
@@ -215,13 +215,12 @@ private ScanHit hitOrNull(Player player, Site site) {
/**
* @param player scanner
* @param site ruin
- * @return whether the player is already in the registered chunk (look no longer matters)
+ * @return whether the player is already in the registered chunk (look no longer matters);
+ * {@link #hitOrNull} has already matched the world
*/
private boolean standingOnSite(Player player, Site site) {
Location here = player.getLocation();
- return here.getWorld() != null
- && here.getWorld().getName().equals(site.getWorldName())
- && here.getChunk().getX() == site.getChunkX()
+ return here.getChunk().getX() == site.getChunkX()
&& here.getChunk().getZ() == site.getChunkZ();
}
@@ -239,16 +238,14 @@ private double[] lookHeading(Player player) {
/**
* @param player scanner
* @param site ruin
- * @return {@code 1} looking straight at the chunk, {@code 0} side-on or behind. Not used on-chunk.
+ * @return {@code 1} looking straight at the chunk, {@code 0} side-on or behind. Not used on-chunk,
+ * so the scanner is always several blocks from the chunk centre
*/
private double lookAlignment(Player player, Site site) {
Location here = player.getLocation();
double dx = site.centerBlockX() + 0.5 - here.getX();
double dz = site.centerBlockZ() + 0.5 - here.getZ();
double length = Math.hypot(dx, dz);
- if (length < 0.01) {
- return 1.0;
- }
double[] look = lookHeading(player);
return Math.max(0.0, (look[0] * dx + look[1] * dz) / length);
}
@@ -324,11 +321,9 @@ private void pip(Player player, ScanHit hit) {
double radius = ringRadius(hit, bands, index, radii);
float note = pitch + index * 0.06f;
long delay = (long) index * step;
+ // The origin is a copy of the scanner's location, which always carries its world.
plugin.getServer().getScheduler().runTaskLater(plugin, () -> {
World world = origin.getWorld();
- if (world == null) {
- return;
- }
world.playSound(origin, Sound.BLOCK_NOTE_BLOCK_CHIME, SoundCategory.PLAYERS, volume, note);
if (settings.pulseParticles()) {
spawnRing(origin, radius, dense, heading, onChunk);
@@ -343,13 +338,13 @@ private void pip(Player player, ScanHit hit) {
* @param hit locked site
* @param bands 1–4
* @param index pip index inside the burst
- * @param radii configured sizes
+ * @param radii configured sizes; the catalogue always loads three
* @return ring radius in blocks
*/
private double ringRadius(ScanHit hit, int bands, int index, List radii) {
if (bands == 1) {
double inner = radii.get(0);
- double outer = radii.size() > 1 ? radii.get(1) : inner * 2;
+ double outer = radii.get(1);
double medium = mediumBand();
double farSpan = Math.max(1.0, hit.range() - medium);
double t = Math.min(1.0, Math.max(0.0, (hit.sensedDistance() - medium) / farSpan));
@@ -444,12 +439,10 @@ private int ringCount(int bands, List radii) {
*/
private void spawnRing(Location origin, double radius, boolean near, double[] heading, boolean fullCircle) {
World world = origin.getWorld();
- if (world == null) {
- return;
- }
Particle particle = settings.waveParticle();
int points = Math.max(12, (int) Math.round(radius * (fullCircle ? 16 : near ? 14 : 10)));
- boolean clipArc = !fullCircle && (heading[0] != 0 || heading[1] != 0);
+ // The heading is a unit vector, so an off-chunk pulse always has a direction to clip to.
+ boolean clipArc = !fullCircle;
double headingAngle = Math.atan2(heading[1], heading[0]);
for (int i = 0; i < points; i++) {
double angle = (2 * Math.PI * i) / points;
diff --git a/src/test/java/net/tfminecraft/archaeo/ArcheologyPluginTest.java b/src/test/java/net/tfminecraft/archaeo/ArcheologyPluginTest.java
new file mode 100644
index 0000000..879b678
--- /dev/null
+++ b/src/test/java/net/tfminecraft/archaeo/ArcheologyPluginTest.java
@@ -0,0 +1,58 @@
+package net.tfminecraft.archaeo;
+
+import net.tfminecraft.archaeo.item.ItemMatcher;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.mockbukkit.mockbukkit.MockBukkit;
+import org.mockbukkit.mockbukkit.ServerMock;
+
+import static org.junit.Assert.*;
+
+public class ArcheologyPluginTest {
+ private ServerMock server;
+
+ @Before public void setUp() { server = MockBukkit.mock(); }
+ @After public void tearDown() { MockBukkit.unmock(); }
+
+ @Test
+ public void loadsPackagedCatalogsBindsCommandsAndDisablesCleanly() {
+ ArcheologyPlugin plugin = MockBukkit.load(ArcheologyPlugin.class);
+ assertTrue(plugin.isEnabled());
+ assertFalse(plugin.catalogs().artifacts().isEmpty());
+ assertTrue(plugin.sites().all().isEmpty());
+ assertNotNull(plugin.generator());
+ assertNotNull(plugin.autoRuins());
+ assertNotNull(plugin.trackerItem());
+ assertNotNull(plugin.prospectItem());
+ assertNotNull(plugin.establishItem());
+ assertNotNull(plugin.sketch());
+ assertNotNull(plugin.museum());
+ assertNotNull(plugin.sketchSupplies());
+ assertNotNull(plugin.getCommand("archaeo").getExecutor());
+ assertNotNull(plugin.getCommand("archaeo").getTabCompleter());
+ plugin.bindItemMatcher(null);
+ plugin.bindItemMatcher(ItemMatcher.vanillaOnly());
+ server.getScheduler().performTicks(40);
+ server.getPluginManager().disablePlugin(plugin);
+ assertFalse(plugin.isEnabled());
+ server.getScheduler().performTicks(40);
+ plugin.onDisable();
+ }
+
+ @Test
+ public void brokenCatalogueFailsStartupAndTheFollowingDisableShutsDownCleanly() throws Exception {
+ ArcheologyPlugin plugin = (ArcheologyPlugin) server.getPluginManager().loadPlugin(ArcheologyPlugin.class, new Object[0]);
+ java.nio.file.Files.createDirectories(plugin.getDataFolder().toPath());
+ java.nio.file.Files.writeString(new java.io.File(plugin.getDataFolder(), "interest.yml").toPath(), "interest-levels: {}\n");
+ Throwable failure = assertThrows(Throwable.class, () -> server.getPluginManager().enablePlugin(plugin));
+ while (failure.getCause() != null) failure = failure.getCause();
+ assertEquals("Missing interest-levels.low", failure.getMessage());
+ // Paper disables a plugin whose onEnable threw; nothing but the catalogue was built yet.
+ server.getPluginManager().disablePlugin(plugin);
+ assertFalse(plugin.isEnabled());
+ assertNull(plugin.sites());
+ assertFalse(plugin.getCommand("archaeo").getExecutor() instanceof net.tfminecraft.archaeo.command.ArchaeoCommand);
+ server.getScheduler().performTicks(40);
+ }
+}
diff --git a/src/test/java/net/tfminecraft/archaeo/command/ArchaeoCommandTest.java b/src/test/java/net/tfminecraft/archaeo/command/ArchaeoCommandTest.java
new file mode 100644
index 0000000..6ffcdb9
--- /dev/null
+++ b/src/test/java/net/tfminecraft/archaeo/command/ArchaeoCommandTest.java
@@ -0,0 +1,601 @@
+package net.tfminecraft.archaeo.command;
+
+import net.tfminecraft.archaeo.ArcheologyPlugin;
+import net.tfminecraft.archaeo.model.Site;
+import net.tfminecraft.archaeo.model.StratumBand;
+import net.tfminecraft.archaeo.model.BuriedFind;
+import net.tfminecraft.archaeo.model.FindState;
+import net.tfminecraft.archaeo.model.BlockCell;
+import org.bukkit.entity.Player;
+import net.md_5.bungee.api.chat.BaseComponent;
+import net.md_5.bungee.api.chat.ClickEvent;
+import net.tfminecraft.archaeo.model.SiteStatus;
+import org.bukkit.Material;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.mockbukkit.mockbukkit.MockBukkit;
+import org.mockbukkit.mockbukkit.ServerMock;
+import org.mockbukkit.mockbukkit.entity.PlayerMock;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.UUID;
+
+import static org.junit.Assert.*;
+import static org.mockito.Mockito.*;
+
+public class ArchaeoCommandTest {
+ private ServerMock server;
+ private ArcheologyPlugin plugin;
+ private PlayerMock staff;
+ private ArchaeoCommand commands;
+
+ @Before public void setUp() {
+ server = MockBukkit.mock();
+ var world = new org.mockbukkit.mockbukkit.world.WorldMock() {
+ @Override public org.bukkit.Chunk[] getLoadedChunks() {
+ // MockBukkit has no tile-entity lookup; this command fixture contains no block inventories.
+ return java.util.Arrays.stream(super.getLoadedChunks()).map(chunk -> {
+ org.bukkit.Chunk snapshot = spy(chunk);
+ doReturn(new org.bukkit.block.BlockState[0]).when(snapshot).getTileEntities();
+ return snapshot;
+ }).toArray(org.bukkit.Chunk[]::new);
+ }
+ };
+ world.setName("world");
+ server.addWorld(world);
+ plugin = MockBukkit.load(ArcheologyPlugin.class);
+ staff = player("Archaeologist");
+ staff.setOp(true);
+ staff.addAttachment(plugin, plugin.catalogs().staffPermission(), true);
+ commands = (ArchaeoCommand) plugin.getCommand("archaeo").getExecutor();
+ }
+ @After public void tearDown() { MockBukkit.unmock(); }
+
+ @Test
+ public void deniesNonStaffBeforeDispatchAndCompletion() {
+ staff.setOp(false);
+ staff.addAttachment(plugin, plugin.catalogs().staffPermission(), false);
+ assertContains(run("give tracker"), "You do not have permission");
+ assertTrue(staff.getInventory().isEmpty());
+ assertTrue(complete("give", "").isEmpty());
+ }
+
+ @Test
+ public void givesConfiguredRoleItemsAndNamedToolsToSelfOrOnlinePlayers() {
+ for (String role : List.of("tracker", "prospect", "establish", "brush", "paper", "pencil", "tool", "pick")) {
+ staff.getInventory().clear();
+ assertContains(run("give " + role), "Gave ");
+ assertFalse(staff.getInventory().isEmpty());
+ }
+ staff.getInventory().clear();
+ assertContains(run("give tool IRON_PICKAXE"), "iron pickaxe");
+ assertTrue(staff.getInventory().contains(Material.IRON_PICKAXE));
+ PlayerMock recipient = player("Recipient");
+ assertContains(run("give tracker Recipient"), "to Recipient");
+ assertFalse(recipient.getInventory().isEmpty());
+ assertTrue(recipient.nextMessage().contains("received an archaeological tracker"));
+ assertContains(run("give brush Missing"), "Player not online");
+ assertContains(run("give tool nonsense"), "Unknown excavation tool");
+ assertContains(run("give nonsense"), "Unknown item");
+ assertContains(run("give"), "Usage:");
+ }
+
+ @Test
+ public void handlesIncompleteCommandsAndInvalidFindArgumentsWithoutMutatingSites() {
+ for (String input : List.of("", "nonsense", "ruin", "ruin nonsense", "workday", "pick nonsense", "find", "find nonsense",
+ "ruin create", "ruin set-interest", "ruin auto", "ruin auto nonsense", "ruin tp")) {
+ assertFalse(input, run(input).isEmpty());
+ }
+ assertContains(run("find spawn nonexistent"), "Unknown artifact");
+ String artifact = plugin.catalogs().artifacts().keySet().iterator().next();
+ assertContains(run("find spawn " + artifact + " many"), "Size must be a whole number");
+ assertContains(run("ruin create nonsense"), "Unknown interest");
+ assertContains(run("ruin set-interest nonsense"), "Unknown interest");
+ assertTrue(plugin.sites().all().isEmpty());
+ }
+
+ @Test
+ public void queriesMissingSitesAndRequiresConfirmationBeforeDeletion() {
+ assertContains(run("ruin info"), "No site in this chunk");
+ assertContains(run("ruin info #123"), "No site with serial");
+ assertContains(run("ruin info Lost city"), "No site named");
+ assertContains(run("ruin info #999999999999999999999"), "No site named");
+ assertContains(run("ruin close"), "No site in this chunk");
+ assertContains(run("ruin delete"), "No site in this chunk");
+ Site site = dossier(1, "Old camp");
+ assertContains(run("ruin info #1"), "Old camp");
+ assertContains(run("ruin info Old camp"), "Old camp");
+ assertContains(run("ruin close #1"), "no standing camp");
+ assertContains(run("ruin delete #1"), "Add confirm");
+ assertTrue(plugin.sites().findBySerial(1).isPresent());
+ assertContains(run("ruin delete #1 confirm"), "Deleted");
+ assertFalse(plugin.sites().findById(site.getId()).isPresent());
+ dossier(2, "Duplicate"); dossier(3, "Duplicate");
+ assertContains(run("ruin info Duplicate"), "Several sites share that name");
+ }
+
+ @Test
+ public void statisticsIncludeSavedWorldsAndTeleportUsesStoredDatum() {
+ Site site = dossier(1, "Old camp");
+ site.setSurfaceY(40);
+ assertContains(run("ruin stats"), "Ruins: 1 (all worlds)");
+ assertContains(run("ruin stats world"), "Ruins: 1 (world)");
+ assertContains(run("ruin stats missing"), "Unknown world");
+ assertContains(run("ruin tp #1"), "Teleported to");
+ assertEquals(8.5, staff.getLocation().getX(), 0);
+ assertEquals(41, staff.getLocation().getY(), 0);
+ assertEquals(8.5, staff.getLocation().getZ(), 0);
+ site.setWorldName("unloaded");
+ assertContains(run("ruin stats unloaded"), "Ruins: 1 (unloaded)");
+ assertContains(run("ruin tp #1"), "is not loaded");
+ assertContains(run("ruin tp #999"), "No site with serial");
+ }
+
+ @Test
+ public void workdayAndAutoCommandsReportTheirStateAndReloadKeepsDossiers() {
+ assertContains(run("workday reset"), "not in an established excavation");
+ assertContains(run("workday reset Missing"), "Player not online");
+ assertContains(run("workday reset all"), "No established excavations");
+ Site site = dossier(1, "Old camp");
+ site.establish(staff.getUniqueId(), 1, 0, 20, 4, 4);
+ site.setJornadaPickLeft(0);
+ assertContains(run("workday reset"), "Reset pick jornada");
+ assertEquals(plugin.catalogs().pick().jornadaActions(), site.getJornadaPickLeft());
+ assertContains(run("pick reset all"), "Reset pick jornada");
+ assertFalse(run("ruin auto status").isEmpty());
+ assertContains(run("ruin auto reset"), "Cleared auto-ruin evaluation ledger");
+ plugin.sites().commit(site);
+ assertContains(run("reload"), "Reloaded Archaeo");
+ assertEquals("Old camp", plugin.sites().findBySerial(1).orElseThrow().getName());
+ }
+
+ @Test
+ public void completionUsesPermissionsPlayersWorldsAndEligibleSiteStates() {
+ Site hidden = dossier(1, "Old ruin");
+ Site camp = dossier(2, "Open camp");
+ camp.establish(staff.getUniqueId(), 1, 0, 20, 4, 4);
+ assertTrue(complete("g").contains("give"));
+ assertTrue(complete("give", "tr").contains("tracker"));
+ assertTrue(complete("give", "tracker", "arch").contains("Archaeologist"));
+ assertTrue(complete("give", "tool", "IRON_").contains("IRON_PICKAXE"));
+ assertTrue(complete("give", "tool", "IRON_PICKAXE", "arch").contains("Archaeologist"));
+ assertEquals(List.of("reset"), complete("workday", "r"));
+ assertTrue(complete("pick", "reset", "a").contains("all"));
+ assertEquals(List.of("spawn"), complete("find", "s"));
+ assertEquals(plugin.catalogs().artifacts().size(), complete("find", "spawn", "").size());
+ assertTrue(complete("ruin", "").contains("create"));
+ assertEquals(List.of("low"), complete("ruin", "create", "l"));
+ assertEquals(List.of("high"), complete("ruin", "set-interest", "h"));
+ assertEquals(List.of("world"), complete("ruin", "stats", "w"));
+ assertTrue(complete("ruin", "auto", "").contains("reset"));
+ assertTrue(complete("ruin", "set-interest", "high", "O").contains(hidden.getName()));
+ assertFalse(complete("ruin", "set-interest", "high", "O").contains(camp.getName()));
+ assertTrue(complete("ruin", "close", "").contains(camp.getName()));
+ assertFalse(complete("ruin", "close", "").contains(hidden.getName()));
+ assertTrue(complete("ruin", "delete", "con").contains("confirm"));
+ assertTrue(complete("ruin", "info", "#").containsAll(List.of("#1", "#2")));
+ assertTrue(complete("ruin", "tp", "Old", "r").contains("Old ruin"));
+ assertTrue(complete("ruin", "camps", "#").contains("#2"));
+ assertTrue(complete("ruin", "camps", "Archaeologist", "2").contains("#2"));
+ assertTrue(complete("ruin", "delete", "#1", "confirm").isEmpty());
+ assertTrue(complete("ruin", "close", "#2", "confirm").isEmpty());
+ assertTrue(complete("unknown", "").isEmpty());
+ }
+
+ @Test
+ public void createsAndRebuildsHiddenRuinsButPreservesEstablishedExcavations() {
+ // Give all configured strata room below the surface in MockBukkit's shallow world.
+ for (int x = 0; x < 16; x++) {
+ for (int z = 0; z < 16; z++) {
+ for (int y = 4; y <= 40; y++) staff.getWorld().getBlockAt(x, y, z).setType(Material.STONE);
+ }
+ }
+ assertContains(run("ruin create low River ruin"), "Site created:");
+ Site site = plugin.sites().findByChunk("world", 0, 0).orElseThrow();
+ assertEquals("River ruin", site.getName());
+ UUID id = site.getId(); int serial = site.getSerial();
+ assertContains(run("ruin create low Duplicate"), "already");
+ assertEquals(1, plugin.sites().all().size());
+ assertContains(run("ruin set-interest high #" + serial), "Rebuilt");
+ assertEquals(net.tfminecraft.archaeo.model.InterestLevel.HIGH, site.getInterest());
+ assertEquals(id, site.getId()); assertEquals("River ruin", site.getName());
+ site.establish(staff.getUniqueId(), 0, 0, 2, 4, 2);
+ assertFalse(run("ruin set-interest low #" + serial).isEmpty());
+ assertEquals(net.tfminecraft.archaeo.model.InterestLevel.HIGH, site.getInterest());
+ }
+
+ @Test
+ public void staffFindCommandCreatesAnExcavationAtSolidFeetAndRejectsAir() {
+ staff.teleport(new org.bukkit.Location(staff.getWorld(), 5, 8, 5));
+ assertContains(run("find spawn"), "Stand on a solid");
+ assertTrue(plugin.sites().all().isEmpty());
+ staff.getWorld().getBlockAt(5, 8, 5).setType(Material.STONE);
+ assertContains(run("find spawn"), "Spawned");
+ Site site = plugin.sites().findByChunk("world", 0, 0).orElseThrow();
+ assertEquals(SiteStatus.ESTABLISHED, site.getStatus()); assertEquals(staff.getUniqueId(), site.getDirector());
+ assertEquals(1, site.getFinds().size()); assertFalse(site.getFinds().getFirst().getCells().isEmpty());
+ assertTrue(site.getJornadaPickLeft() > 0);
+ }
+
+ @Test
+ public void campLookupTeleportsSingleResultsAndChecksDirectorFilter() {
+ assertContains(run("ruin camps"), "does not direct any open camp");
+ assertContains(run("ruin camps Nobody"), "Unknown player");
+ Site camp = dossier(1, "River camp"); camp.establish(staff.getUniqueId(), 1, 0, 20, 4, 4);
+ assertContains(run("ruin camps"), "Teleported to");
+ assertEquals(20.5, staff.getLocation().getX(), 0); assertEquals(5, staff.getLocation().getY(), 0);
+ PlayerMock guest = player("Guest");
+ assertContains(run("ruin camps Guest #1"), "not directed by that player");
+ assertContains(run("ruin camps #999"), "No site with serial");
+ assertContains(run("ruin camps Archaeologist invalid"), "Usage:");
+ camp.setWorldName("unloaded"); assertContains(run("ruin camps #1"), "World not loaded");
+ camp.setWorldName("world"); camp.setStatus(SiteStatus.CLOSED);
+ assertContains(run("ruin camps #1"), "no standing camp");
+ assertEquals(guest.getWorld(), staff.getWorld());
+ }
+
+ @Test
+ public void closingUnfinishedCampRequiresConfirmationAndIssuesArchive() {
+ Site camp = dossier(1, "River camp"); camp.establish(staff.getUniqueId(), 0, 0, 2, 4, 2);
+ var find = new net.tfminecraft.archaeo.model.BuriedFind(); find.setId(UUID.randomUUID());
+ find.setArtifactId("vessel"); find.setStratumId("I"); camp.getFinds().add(find);
+ assertContains(run("ruin close #1"), "Add confirm"); assertEquals(SiteStatus.ESTABLISHED, camp.getStatus());
+ assertFalse(run("ruin close #1 confirm").isEmpty());
+ assertEquals(SiteStatus.CLOSED, camp.getStatus()); assertTrue(staff.getInventory().contains(Material.WRITTEN_BOOK));
+ assertEquals(SiteStatus.CLOSED, plugin.sites().findById(camp.getId()).orElseThrow().getStatus());
+ }
+
+ @Test
+ public void consoleCommandsExplainWorldRequirementsAndCanGiveToNamedPlayers() {
+ var console = server.getConsoleSender();
+ for (String input : List.of("ruin create low", "find spawn", "ruin info", "ruin camps", "ruin camps #1", "sketch")) {
+ while (console.nextMessage() != null) { }
+ assertTrue(commands.onCommand(console, plugin.getCommand("archaeo"), "archaeo", input.split(" ")));
+ assertNotNull(input, console.nextMessage());
+ }
+ staff.getInventory().clear();
+ commands.onCommand(console, plugin.getCommand("archaeo"), "archaeo", new String[]{"give", "tracker", staff.getName()});
+ assertTrue(plugin.trackerItem().isTracker(staff.getInventory().getItem(0)));
+ }
+
+ @Test public void multipleDirectedCampsOfferClickableChoicesWithoutTeleportingUntilSelected() {
+ Site first = dossier(1, "River camp"); first.establish(staff.getUniqueId(), 1, 0, 20, 4, 4);
+ Site second = dossier(2, "Hill camp"); second.establish(staff.getUniqueId(), 2, 0, 36, 9, 4);
+ Site closed = dossier(3, "Old camp"); closed.establish(staff.getUniqueId(), 3, 0, 52, 4, 4); closed.setStatus(SiteStatus.CLOSED);
+ Player.Spigot spigot = mock(Player.Spigot.class); doReturn(spigot).when(staff).spigot();
+ List choices = new ArrayList<>();
+ doAnswer(call -> { choices.add((BaseComponent[]) call.getRawArguments()[0]); return null; })
+ .when(spigot).sendMessage(any(BaseComponent[].class));
+ var before = staff.getLocation().clone();
+ List output = run("ruin camps Archaeologist");
+ assertContains(output, "directs 2 camps"); assertContains(output, "Click [tp]"); assertEquals(before, staff.getLocation());
+ assertEquals(2, choices.size());
+ for (BaseComponent[] choice : choices) {
+ assertEquals(2, choice.length); assertTrue(choice[0].toPlainText().contains("camp"));
+ assertEquals("[tp]", choice[1].toPlainText()); assertEquals(ClickEvent.Action.RUN_COMMAND, choice[1].getClickEvent().getAction());
+ assertNotNull(choice[1].getHoverEvent());
+ }
+ assertEquals(List.of("/archaeo ruin camps #1", "/archaeo ruin camps #2"), choices.stream().map(parts -> parts[1].getClickEvent().getValue()).sorted().toList());
+ assertContains(run("ruin camps Archaeologist #2"), "Teleported to");
+ assertEquals(36.5, staff.getLocation().getX(), 0); assertEquals(10, staff.getLocation().getY(), 0);
+ assertEquals(2, choices.size());
+ }
+
+ @Test public void detailedDossierReportsStrataSamplingAndDistinctSourcesOfFindDamage() {
+ Site site = dossier(1, "Survey camp"); site.establish(staff.getUniqueId(), 1, 0, 20, 4, 4);
+ site.setCreatedBy(staff.getUniqueId()); site.setSurfaceY(40); site.getHintIds().addAll(List.of("pottery", "masonry"));
+ StratumBand present = new StratumBand(); present.setId("I"); present.setPresent(true); present.setMinY(36); present.setMaxY(39); present.setDisturbed(true);
+ StratumBand absent = new StratumBand(); absent.setId("II"); site.getStrata().put("I", present); site.getStrata().put("II", absent);
+ site.addProspectSample(staff.getUniqueId(), new BlockCell(1, 40, 1)); site.confirmProspect(staff.getUniqueId());
+ BuriedFind find = new BuriedFind(); find.setId(UUID.randomUUID()); find.setArtifactId("pot"); find.setStratumId("I"); find.setState(FindState.PARTIAL);
+ find.setBuriedConservation(90); find.setConservation(90);
+ find.getCells().addAll(List.of(new BlockCell(1, 38, 1), new BlockCell(2, 38, 1), new BlockCell(3, 38, 1), new BlockCell(4, 38, 1)));
+ find.woundBeforeDig(find.getCells().get(0)); find.woundFromAbove(find.getCells().get(1)); find.markCleaned(find.getCells().get(2)); site.getFinds().add(find);
+ List info = run("ruin info #1");
+ assertContains(info, "Created by: " + staff.getUniqueId()); assertContains(info, "Camp chunk 1,0 · table 20,4,4");
+ assertContains(info, "I · Y 36–39 · disturbed"); assertContains(info, "II · absent");
+ assertContains(info, "Hints: pottery, masonry"); assertContains(info, "Prospect confirmed: 1 · samplers: 1");
+ assertContains(info, "pot · stratum I · PARTIAL · 4 cells"); assertContains(info, "of 90% buried · cleaned 1");
+ assertContains(info, "disturbed before the dig (1 cells) · hurt while digging");
+ assertEquals(1, site.getFinds().size()); assertEquals(1, find.getCleanedCells().size());
+ }
+
+ @Test public void censusSeparatesEveryLifecycleStateAndConsoleCanInspectDirectedCamps() {
+ dossier(1, "Hidden");
+ Site active = dossier(2, "Active"); active.establish(staff.getUniqueId(), 1, 0, 20, 4, 4);
+ Site exhausted = dossier(3, "Exhausted"); exhausted.establish(staff.getUniqueId(), 2, 0, 36, 4, 4); exhausted.setStatus(SiteStatus.EXHAUSTED);
+ Site closed = dossier(4, "Closed"); closed.establish(staff.getUniqueId(), 3, 0, 52, 4, 4); closed.setStatus(SiteStatus.CLOSED);
+ List stats = run("ruin stats");
+ for (String expected : List.of("Ruins: 4", "Hidden: 1", "Excavations: 3", "Active: 1", "Exhausted: 1", "Closed: 1")) assertContains(stats, expected);
+ List camps = console("ruin camps Archaeologist");
+ assertContains(camps, "directs 2 camps"); assertContains(camps, "camp 20,4,4"); assertContains(camps, "camp 36,4,4");
+ assertFalse(camps.stream().anyMatch(line -> line.contains("Closed") || line.contains("[tp]")));
+ assertContains(console("ruin camps Archaeologist #2"), "Only a player can teleport");
+ assertContains(console("ruin info #2"), "Active");
+ exhausted.setStatus(SiteStatus.CLOSED);
+ assertContains(console("ruin camps Archaeologist"), "directs 1 camp:");
+ }
+
+ @Test public void consoleMustNameTargetsForLocationDependentCommandsAndGivesToolsToNamedPlayers() {
+ for (String input : List.of("ruin close", "ruin delete confirm", "ruin set-interest high"))
+ assertContains(console(input), "Console must pass a site name or serial");
+ assertContains(console("give tracker"), "Console must name a player"); assertContains(console("workday reset"), "Console must name a player or use:");
+ assertContains(console("ruin tp #1"), "must be used in-game");
+ assertContains(console("give tool IRON_PICKAXE Archaeologist"), "Gave"); assertTrue(staff.getInventory().contains(Material.IRON_PICKAXE));
+ assertContains(run("give tool Archaeologist"), "Gave");
+ assertContains(run("ruin set-interest high #999"), "No site with serial");
+ }
+
+ @Test public void workdayResetNotifiesTheNamedWorkerAndPreservesUnavailableExcavations() {
+ Site active = dossier(1, "Active"); active.establish(staff.getUniqueId(), 1, 0, 20, 4, 4);
+ Site hidden = dossier(2, "Hidden"); hidden.setChunkX(2);
+ Site closed = dossier(3, "Closed"); closed.setChunkX(3);
+ closed.establish(staff.getUniqueId(), 3, 0, 52, 4, 4); closed.setStatus(SiteStatus.CLOSED);
+ Site unloaded = dossier(4, "Unloaded"); unloaded.setWorldName("unloaded");
+ unloaded.establish(staff.getUniqueId(), 1, 0, 20, 4, 4);
+ for (Site site : List.of(active, hidden, closed, unloaded)) {
+ site.setJornadaPickLeft(0); plugin.sites().save(site);
+ }
+ PlayerMock worker = player("Worker"); worker.teleport(new org.bukkit.Location(staff.getWorld(), 20, 5, 4));
+ assertContains(console("workday reset Worker"), "Reset pick jornada on #1");
+ assertTrue(worker.nextMessage().contains("Staff reset today's Hand Pick actions"));
+ assertEquals(plugin.catalogs().pick().jornadaActions(), active.getJornadaPickLeft());
+ active.setJornadaPickLeft(0);
+ List reset = run("workday reset all");
+ assertEquals(1, reset.size()); assertContains(reset, "on #1");
+ assertEquals(plugin.catalogs().pick().jornadaActions(), active.getJornadaPickLeft());
+ for (Site preserved : List.of(hidden, closed, unloaded)) assertEquals(0, preserved.getJornadaPickLeft());
+ }
+
+ @Test public void unlimitedWorkdayExplainsWhyNoResetIsNeeded() {
+ plugin.getConfig().set("excavation.workday-actions", 0); plugin.saveConfig();
+ assertContains(run("reload"), "Reloaded Archaeo");
+ assertContains(run("workday reset all"), "Work-day actions are unlimited");
+ assertTrue(plugin.sites().all().isEmpty());
+ }
+
+ @Test public void staffFindUsesGroundUnderfootAndReportsOverlapWithoutReplacingTheFind() {
+ staff.teleport(new org.bukkit.Location(staff.getWorld(), 5, 9, 5));
+ staff.getWorld().getBlockAt(5, 8, 5).setType(Material.STONE);
+ assertContains(run("find spawn vessel 1"), "Spawned");
+ Site site = plugin.sites().findByChunk("world", 0, 0).orElseThrow();
+ BuriedFind original = site.getFinds().getFirst();
+ assertEquals(List.of(new BlockCell(5, 8, 5)), original.getCells());
+ assertContains(run("find spawn vessel 1"), "A find already occupies this block");
+ assertEquals(List.of(original), site.getFinds());
+ assertEquals(Material.STONE, staff.getWorld().getBlockAt(5, 8, 5).getType());
+ }
+
+ @Test public void sketchCommandStartsOneEditableSheetAndExplainsHowToSaveIt() {
+ // MockBukkit lacks these two rendering flags; retain real map IDs, metadata and sessions.
+ try (var maps = mockStatic(org.bukkit.Bukkit.class, CALLS_REAL_METHODS)) {
+ maps.when(() -> org.bukkit.Bukkit.createMap(staff.getWorld())).thenAnswer(call -> {
+ var view = spy(server.createMap(staff.getWorld()));
+ doNothing().when(view).setTrackingPosition(anyBoolean());
+ doNothing().when(view).setUnlimitedTracking(anyBoolean());
+ return view;
+ });
+ run("sketch");
+ assertTrue(plugin.sketch().editing(staff));
+ var sheet = staff.getInventory().getItemInMainHand();
+ assertTrue(plugin.sketch().isSketchMap(sheet));
+ assertContains(run("sketch"), "Switch the map away to save");
+ assertEquals(sheet, staff.getInventory().getItemInMainHand());
+ assertEquals(1, java.util.Arrays.stream(staff.getInventory().getContents())
+ .filter(plugin.sketch()::isSketchMap).count());
+ }
+ }
+
+ @Test public void failedReloadReportsTheBrokenCatalogueAndKeepsCreatingRuinsAtEveryInterest() throws Exception {
+ // Regression: the interest map used to be cleared before a missing level threw, so a failed
+ // reload left later ruin creation without its budget.
+ java.io.File file = new java.io.File(plugin.getDataFolder(), "interest.yml");
+ var interest = org.bukkit.configuration.file.YamlConfiguration.loadConfiguration(file);
+ interest.set("interest-levels.high", null); interest.save(file);
+ assertContains(run("reload"), "Reload failed: Missing interest-levels.high");
+ assertNotNull(plugin.catalogs().interest(net.tfminecraft.archaeo.model.InterestLevel.HIGH));
+ stoneColumn();
+ assertContains(run("ruin create high Kept budget"), "Site created:");
+ assertEquals(net.tfminecraft.archaeo.model.InterestLevel.HIGH, plugin.sites().findByChunk("world", 0, 0).orElseThrow().getInterest());
+ }
+
+ @Test public void roleItemFromAMissingPackPluginIsReportedInsteadOfGivingAir() {
+ plugin.getConfig().set("tracker.item", "itemsadder:field:tracker"); plugin.saveConfig();
+ assertContains(run("reload"), "Reloaded Archaeo");
+ staff.getInventory().clear();
+ assertContains(run("give tracker"), "Could not create that item");
+ assertTrue(staff.getInventory().isEmpty());
+ assertContains(run("give tool IRON_PICKAXE Missing"), "Player not online: Missing");
+ assertTrue(staff.getInventory().isEmpty());
+ assertContains(run("nonsense extra"), "Usage: /archaeo ruin create");
+ }
+
+ @Test public void workdayResetNamesOnlyNamedSitesAndIgnoresUnopenedGroundUnderfoot() {
+ Site hidden = dossier(1, "Hidden"); hidden.setChunkX(0);
+ assertContains(run("workday reset"), "not in an established excavation");
+ Site closed = dossier(2, "Closed camp"); closed.setChunkX(5); closed.establish(staff.getUniqueId(), 0, 1, 4, 4, 20); closed.setStatus(SiteStatus.CLOSED);
+ staff.teleport(new org.bukkit.Location(staff.getWorld(), 4, 5, 20));
+ assertContains(run("workday reset"), "not in an established excavation");
+ Site unnamed = dossier(3, null); unnamed.setChunkX(7); unnamed.establish(staff.getUniqueId(), 7, 0, 116, 4, 4); unnamed.setJornadaPickLeft(0);
+ staff.teleport(new org.bukkit.Location(staff.getWorld(), 116, 5, 4));
+ assertEquals(List.of("Reset pick jornada on #3 to " + plugin.catalogs().pick().jornadaActions() + "."), run("workday reset"));
+ unnamed.setName(" ");
+ assertEquals(List.of("Reset pick jornada on #3 to " + plugin.catalogs().pick().jornadaActions() + "."), run("workday reset"));
+ assertEquals(SiteStatus.CLOSED, closed.getStatus());
+ }
+
+ @Test public void staffFindFallsBackToTheFirstTemplateAndExplainsAnEmptyArtifactCatalogue() throws Exception {
+ writeData("artifacts.yml", "artifacts:\n urn: {display-name: Urn, size-min: 1, size-max: 2}\n");
+ assertContains(run("reload"), "Reloaded Archaeo");
+ staff.teleport(new org.bukkit.Location(staff.getWorld(), 5, 8, 5)); staff.getWorld().getBlockAt(5, 8, 5).setType(Material.STONE);
+ assertContains(run("find spawn"), "Spawned Urn");
+ assertEquals("urn", plugin.sites().findByChunk("world", 0, 0).orElseThrow().getFinds().getFirst().getArtifactId());
+ writeData("artifacts.yml", "artifacts: {}\n");
+ assertContains(run("reload"), "Reloaded Archaeo");
+ staff.teleport(new org.bukkit.Location(staff.getWorld(), 21, 8, 5)); staff.getWorld().getBlockAt(21, 8, 5).setType(Material.STONE);
+ assertContains(run("find spawn"), "No artifact templates are loaded.");
+ assertTrue(plugin.sites().findByChunk("world", 1, 0).isEmpty());
+ }
+
+ @Test public void unnamedRuinsAndRebuildsInBareChunksExplainThatNoFindFitted() {
+ // A stone lid over open air: the strata lie within the world but hold no buried ground.
+ for (int x = 0; x < 16; x++) for (int z = 0; z < 16; z++) staff.getWorld().getBlockAt(x, 40, z).setType(Material.STONE);
+ List created = run("ruin create exceptional");
+ assertContains(created, "Site created: #1"); assertContains(created, "No find fitted");
+ Site site = plugin.sites().findByChunk("world", 0, 0).orElseThrow();
+ assertTrue(site.getFinds().isEmpty()); assertFalse(site.getName().isBlank()); assertEquals(net.tfminecraft.archaeo.model.InterestLevel.EXCEPTIONAL, site.getInterest());
+ List rebuilt = run("ruin set-interest low");
+ assertContains(rebuilt, "Rebuilt #1"); assertContains(rebuilt, "No find fitted");
+ assertEquals(net.tfminecraft.archaeo.model.InterestLevel.LOW, site.getInterest());
+ List info = run("ruin info");
+ assertContains(info, "Site #1"); assertContains(info, "interest: low");
+ }
+
+ @Test public void closeAndDeleteActOnTheSiteUnderfootAndRejectUnknownSerials() {
+ assertContains(run("ruin close #9"), "No site with serial #9");
+ assertContains(run("ruin delete #9 confirm"), "No site with serial #9");
+ Site camp = dossier(1, "Finished camp"); camp.establish(staff.getUniqueId(), 0, 0, 2, 4, 2);
+ assertContains(run("ruin close"), "Closed Finished camp");
+ assertEquals(SiteStatus.CLOSED, camp.getStatus()); assertTrue(staff.getInventory().contains(Material.WRITTEN_BOOK));
+ Site open = dossier(2, "Open camp"); open.setChunkX(3); open.establish(staff.getUniqueId(), 3, 0, 52, 4, 4);
+ var find = new BuriedFind(); find.setId(UUID.randomUUID()); find.setArtifactId("vessel"); find.setStratumId("I"); open.getFinds().add(find);
+ staff.teleport(new org.bukkit.Location(staff.getWorld(), 52, 5, 4));
+ assertContains(run("ruin close confirm"), "Closed Open camp");
+ assertEquals(SiteStatus.CLOSED, open.getStatus());
+ // Paper keeps the crafting view open after a board closes; MockBukkit leaves no top inventory.
+ staff.openInventory(server.createInventory(null, 9));
+ assertContains(run("ruin delete confirm"), "Deleted #2");
+ assertTrue(plugin.sites().findById(open.getId()).isEmpty()); assertTrue(plugin.sites().findById(camp.getId()).isPresent());
+ }
+
+ @Test public void campListingHandlesUnknownDirectorsAndDossiersThatLostTheirCampTable() {
+ assertContains(run("ruin camps Nobody #1"), "Unknown player: Nobody");
+ // A dossier whose establishment block lost camp-x still loads as a locked camp.
+ Site lost = dossier(1, "Lost table"); lost.setStatus(SiteStatus.ESTABLISHED); lost.setDirector(staff.getUniqueId());
+ var before = staff.getLocation().clone();
+ List single = run("ruin camps");
+ assertContains(single, "Lost table · established · world · camp unknown"); assertContains(single, "has no camp coordinates");
+ assertContains(run("ruin camps #1"), "has no standing camp to teleport to");
+ assertEquals(before, staff.getLocation());
+ Site second = dossier(2, "Second camp"); second.establish(staff.getUniqueId(), 2, 0, 36, 4, 4);
+ Player.Spigot spigot = mock(Player.Spigot.class); doReturn(spigot).when(staff).spigot();
+ List several = run("ruin camps");
+ assertContains(several, "Archaeologist directs 2 camps:"); assertContains(several, "Click [tp] or run /archaeo ruin camps #serial");
+ verify(spigot, times(2)).sendMessage(any(BaseComponent[].class));
+ assertEquals(before, staff.getLocation());
+ }
+
+ @Test public void dossierReportsMissingInterestCreationTimeAndUndamagedEvidence() {
+ Site site = dossier(1, "Old dossier"); site.setInterest(null); site.setCreatedAt(null);
+ StratumBand band = new StratumBand(); band.setId("I"); band.setPresent(true); band.setMinY(30); band.setMaxY(34); site.getStrata().put("I", band);
+ BuriedFind find = new BuriedFind(); find.setId(UUID.randomUUID()); find.setArtifactId("pot"); find.setStratumId("I"); find.setState(FindState.HIDDEN);
+ find.setBuriedConservation(80); find.setConservation(80); find.getCells().add(new BlockCell(1, 31, 1)); site.getFinds().add(find);
+ List info = run("ruin info #1");
+ assertContains(info, "interest: none"); assertContains(info, "Created by: unknown · at unknown");
+ assertTrue(info.contains(" I · Y 30–34"));
+ assertTrue(info.contains(" pot · stratum I · HIDDEN · 1 cells · 80% of 80% buried"));
+ assertContains(run("ruin info #"), "No site named \"#\"");
+ }
+
+ @Test public void completionCoversAliasesMultiWordNamesSerialDigitsAndExtraTokens() {
+ Site hidden = dossier(1, "Old ruin"); Site camp = dossier(2, "Open camp"); camp.establish(staff.getUniqueId(), 1, 0, 20, 4, 4);
+ assertTrue(complete("give", "pick", "IRON_").contains("IRON_PICKAXE"));
+ assertEquals(List.of("Archaeologist"), complete("give", "pick", "IRON_PICKAXE", "arch"));
+ for (String[] extra : List.of(new String[]{"give", "tracker", "Arch", "x"}, new String[]{"give", "tool", "IRON_PICKAXE", "Arch", "x"},
+ new String[]{"workday", "nonsense", "a"}, new String[]{"workday", "reset", "all", "x"}, new String[]{"find", "nonsense", "v"},
+ new String[]{"find", "spawn", "vessel", "3"}, new String[]{"ruin", "create", "low", "R"}, new String[]{"ruin", "nonsense", "x"},
+ new String[]{"ruin", "camps", "Nobody", "#"}, new String[]{"ruin", "camps", "Archaeologist", "#2", "x"}))
+ assertTrue(String.join(" ", extra), complete(extra).isEmpty());
+ assertEquals(List.of("Archaeologist"), complete("workday", "reset", "arch"));
+ assertEquals(List.of("Old ruin"), complete("ruin", "set-interest", "high", "Old", "r"));
+ assertEquals(List.of("confirm"), complete("ruin", "close", "confirm"));
+ assertEquals(List.of("Open camp"), complete("ruin", "close", "Open"));
+ assertEquals(List.of("Open camp", "confirm"), complete("ruin", "close", "Open", "c"));
+ assertEquals(List.of("#2"), complete("ruin", "close", "2"));
+ assertEquals(List.of("confirm"), complete("ruin", "delete", "confirm"));
+ assertEquals(List.of("#1"), complete("ruin", "delete", "1"));
+ assertTrue(complete("ruin", "delete", "z").isEmpty());
+ assertEquals(List.of("Old ruin"), complete("ruin", "delete", "Old", "r"));
+ assertEquals(List.of("#1"), complete("ruin", "info", "1"));
+ assertTrue(complete("ruin", "info", "z").isEmpty());
+ assertEquals(List.of("Old ruin"), complete("ruin", "info", "Old", "r"));
+ assertEquals(java.util.Set.of("#1", "#2"), new java.util.HashSet<>(complete("ruin", "tp", "#")));
+ assertEquals(List.of("#2"), complete("ruin", "camps", "2"));
+ assertTrue(complete("ruin", "camps", "z").isEmpty());
+ assertEquals(List.of("#2"), complete("ruin", "camps", "Archaeologist", "#"));
+ assertTrue(complete("ruin", "camps", "Archaeologist", "9").isEmpty());
+ assertEquals(SiteStatus.HIDDEN, hidden.getStatus());
+ }
+
+ @Test public void worldCompletionOffersUnloadedDossierWorldsAndSkipsDossiersWithoutAWorld() {
+ dossier(1, "Archived").setWorldName("archive");
+ dossier(2, "Worldless").setWorldName(null); // a dossier file without its world: key
+ assertEquals(List.of("archive"), complete("ruin", "stats", "a"));
+ assertEquals(List.of("archive", "world"), complete("ruin", "stats", ""));
+ assertTrue(complete("ruin", "stats", "x").isEmpty());
+ }
+
+ @Test public void handOnlyExcavationGivesTheFallbackPickAndStillNamesAnOfflineRecipient() {
+ for (String profile : List.of("hand", "light", "heavy", "super-heavy")) plugin.getConfig().set("excavation.tools." + profile + ".items", List.of("AIR"));
+ plugin.saveConfig();
+ assertContains(run("reload"), "Reloaded Archaeo");
+ assertTrue(complete("give", "tool", "").isEmpty());
+ assertContains(run("give tool Missing"), "Player not online: Missing");
+ staff.getInventory().clear();
+ assertContains(run("give tool"), "an excavation tool (stone pickaxe)");
+ assertTrue(staff.getInventory().contains(Material.STONE_PICKAXE));
+ }
+
+ private void stoneColumn() {
+ // Give all configured strata room below the surface in MockBukkit's shallow world.
+ for (int x = 0; x < 16; x++) for (int z = 0; z < 16; z++) for (int y = 4; y <= 40; y++) staff.getWorld().getBlockAt(x, y, z).setType(Material.STONE);
+ }
+
+ private void writeData(String name, String contents) throws Exception {
+ java.nio.file.Files.writeString(new java.io.File(plugin.getDataFolder(), name).toPath(), contents);
+ }
+
+ private List console(String input) {
+ var sender = server.getConsoleSender(); while (sender.nextMessage() != null) { }
+ assertTrue(commands.onCommand(sender, plugin.getCommand("archaeo"), "archaeo", input.split(" ")));
+ List result = new ArrayList<>(); String line; while ((line = sender.nextMessage()) != null) result.add(line); return result;
+ }
+
+ private PlayerMock player(String name) {
+ PlayerMock player = spy(new PlayerMock(server, name));
+ doReturn(null).when(player).getTargetBlockExact(anyInt());
+ server.addPlayer(player);
+ player.openInventory(server.createInventory(null, 9));
+ return player;
+ }
+
+ private Site dossier(int serial, String name) {
+ Site site = new Site();
+ site.setId(UUID.randomUUID()); site.setSerial(serial); site.setName(name);
+ site.setCreatedAt(java.time.Instant.parse("2026-01-01T00:00:00Z")); site.setWorldName("world"); site.setStatus(SiteStatus.HIDDEN);
+ site.setInterest(net.tfminecraft.archaeo.model.InterestLevel.LOW);
+ plugin.sites().save(site);
+ return site;
+ }
+
+ private List run(String input) {
+ while (staff.nextMessage() != null) { }
+ assertTrue(commands.onCommand(staff, plugin.getCommand("archaeo"), "archaeo",
+ input.isEmpty() ? new String[0] : input.split(" ")));
+ List result = new ArrayList<>();
+ String message;
+ while ((message = staff.nextMessage()) != null) result.add(message);
+ return result;
+ }
+
+ private List complete(String... args) {
+ return commands.onTabComplete(staff, plugin.getCommand("archaeo"), "archaeo", args);
+ }
+
+ private static void assertContains(List messages, String expected) {
+ assertTrue("Expected '" + expected + "' in " + messages,
+ messages.stream().anyMatch(message -> message.contains(expected)));
+ }
+}
diff --git a/src/test/java/net/tfminecraft/archaeo/config/AutoRuinSettingsTest.java b/src/test/java/net/tfminecraft/archaeo/config/AutoRuinSettingsTest.java
new file mode 100644
index 0000000..72bd243
--- /dev/null
+++ b/src/test/java/net/tfminecraft/archaeo/config/AutoRuinSettingsTest.java
@@ -0,0 +1,62 @@
+package net.tfminecraft.archaeo.config;
+
+import net.tfminecraft.archaeo.model.InterestLevel;
+import org.junit.Test;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import static org.junit.Assert.*;
+
+public class AutoRuinSettingsTest {
+ @Test
+ public void packagedDefaultsLimitGenerationAndExcludeWaterBiomes() {
+ AutoRuinSettings settings = AutoRuinSettings.defaults();
+ assertTrue(settings.enabled());
+ assertTrue(settings.worlds().isEmpty());
+ assertEquals(0.004, settings.chancePerChunk(), 0);
+ assertEquals(16, settings.minChunkDistance());
+ assertEquals(60, settings.maxSitesPerWorld());
+ assertEquals(32, settings.excludeSpawnChunks());
+ assertEquals(6, settings.maxReliefBlocks());
+ assertEquals(0.35, settings.minSoilFraction(), 0);
+ assertEquals(4, settings.maxPending());
+ assertEquals(32, settings.maxUnloadPurgePerTick());
+ assertEquals(1, settings.maxEvaluationsPerTick());
+ assertTrue(settings.notifyStaff());
+ assertEquals(Map.of(InterestLevel.LOW, 50, InterestLevel.MEDIUM, 35,
+ InterestLevel.HIGH, 12, InterestLevel.EXCEPTIONAL, 3), settings.interestWeights());
+ assertEquals(Set.of("ocean", "deep_ocean", "warm_ocean", "lukewarm_ocean",
+ "deep_lukewarm_ocean", "cold_ocean", "deep_cold_ocean", "frozen_ocean",
+ "deep_frozen_ocean", "deep_warm_ocean", "river", "frozen_river"),
+ settings.excludedBiomes());
+ assertThrows(UnsupportedOperationException.class, () -> settings.worlds().add("world"));
+ assertThrows(UnsupportedOperationException.class, () -> settings.excludedBiomes().add("plains"));
+ assertThrows(UnsupportedOperationException.class,
+ () -> settings.interestWeights().put(InterestLevel.LOW, 1));
+ }
+
+ @Test
+ public void biomeNamesNormalizeNamespacesCaseAndOuterWhitespace() {
+ assertNull(AutoRuinSettings.normalizeBiomeId(null));
+ assertNull(AutoRuinSettings.normalizeBiomeId(" \t\n"));
+ assertNull(AutoRuinSettings.normalizeBiomeId("minecraft:"));
+ assertNull(AutoRuinSettings.normalizeBiomeId(":"));
+ assertEquals("ocean", AutoRuinSettings.normalizeBiomeId(" OCEAN "));
+ assertEquals("deep_ocean", AutoRuinSettings.normalizeBiomeId(" MINECRAFT:DEEP_OCEAN "));
+ assertEquals("ocean", AutoRuinSettings.normalizeBiomeId("custom:ocean"));
+ }
+
+ @Test
+ public void missingListsUseFallbackWhileExplicitListsReplaceIt() {
+ Set fallback = Set.of("river");
+ assertSame(fallback, AutoRuinSettings.normalizeBiomeList(null, fallback));
+ assertTrue(AutoRuinSettings.normalizeBiomeList(List.of(), fallback).isEmpty());
+ Set normalized = AutoRuinSettings.normalizeBiomeList(
+ Arrays.asList(null, " ", "minecraft:", " OCEAN ", "minecraft:ocean", "DEEP_OCEAN"), fallback);
+ assertEquals(Set.of("ocean", "deep_ocean"), normalized);
+ assertThrows(UnsupportedOperationException.class, () -> normalized.add("river"));
+ }
+}
diff --git a/src/test/java/net/tfminecraft/archaeo/config/CatalogRegistryTest.java b/src/test/java/net/tfminecraft/archaeo/config/CatalogRegistryTest.java
new file mode 100644
index 0000000..de92625
--- /dev/null
+++ b/src/test/java/net/tfminecraft/archaeo/config/CatalogRegistryTest.java
@@ -0,0 +1,525 @@
+package net.tfminecraft.archaeo.config;
+
+import static org.junit.Assert.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+import java.io.File;
+import java.nio.file.Files;
+import java.time.Instant;
+import java.util.*;
+import java.util.logging.Logger;
+import net.tfminecraft.archaeo.model.BuriedFind;
+import net.tfminecraft.archaeo.model.FindInterpretation;
+import net.tfminecraft.archaeo.model.InterestLevel;
+import org.bukkit.ChatColor;
+import org.bukkit.Material;
+import org.bukkit.configuration.file.YamlConfiguration;
+import org.bukkit.plugin.java.JavaPlugin;
+import org.junit.*;
+import org.junit.rules.TemporaryFolder;
+import org.mockbukkit.mockbukkit.MockBukkit;
+
+public class CatalogRegistryTest {
+ @Rule public TemporaryFolder temporary = new TemporaryFolder();
+ private JavaPlugin plugin;
+ private File folder;
+ private CatalogRegistry catalog;
+
+ @Before public void setUp() throws Exception {
+ MockBukkit.mock();
+ plugin = mock(JavaPlugin.class);
+ folder = new File(temporary.getRoot(), "catalog");
+ when(plugin.getDataFolder()).thenReturn(folder);
+ when(plugin.getLogger()).thenReturn(Logger.getLogger("CatalogRegistryTest"));
+ when(plugin.getConfig()).thenAnswer(call -> YamlConfiguration.loadConfiguration(new File(folder, "config.yml")));
+ doAnswer(call -> {
+ String resource = call.getArgument(0);
+ try (var stream = getClass().getClassLoader().getResourceAsStream(resource)) {
+ assertNotNull(resource, stream);
+ Files.copy(stream, new File(folder, resource).toPath());
+ }
+ return null;
+ }).when(plugin).saveResource(anyString(), eq(false));
+ catalog = new CatalogRegistry(plugin);
+ }
+ @After public void tearDown() { MockBukkit.unmock(); }
+
+ @Test public void packagedCatalogLoadsEveryFeatureAndPreservesOperatorEditsOnReload() throws Exception {
+ catalog.load();
+ for (InterestLevel level : InterestLevel.values()) assertEquals(level, catalog.interest(level).level());
+ assertFalse(catalog.strataInOrder().isEmpty());
+ int order = Integer.MIN_VALUE;
+ for (StratumDefinition stratum : catalog.strataInOrder()) {
+ assertTrue(stratum.order() >= order);
+ order = stratum.order();
+ assertSame(stratum, catalog.stratum(stratum.id()));
+ }
+ assertNull(catalog.stratum("missing"));
+ assertFalse(catalog.artifacts().isEmpty());
+ for (ArtifactTemplate artifact : catalog.artifacts().values()) {
+ assertSame(artifact, catalog.artifact(artifact.id()));
+ assertEquals(artifact.profile(), catalog.profileOf(artifact.id()));
+ assertNotNull(catalog.resolveRarityId(artifact));
+ assertNotNull(catalog.rarityLoreLine(artifact));
+ }
+ assertThrows(UnsupportedOperationException.class, () -> catalog.artifacts().clear());
+ assertEquals(FindProfile.OBJECT, catalog.profileOf("missing"));
+ assertFalse(catalog.hints().isEmpty());
+ for (HintTemplate hint : catalog.hints()) assertSame(hint, catalog.hint(hint.id()));
+ for (InterpretationTemplate interpretation : catalog.interpretations())
+ assertSame(interpretation, catalog.interpretation(interpretation.id()));
+ for (String missing : new String[] {null, "", " ", "missing"}) {
+ assertNull(catalog.hint(missing));
+ assertNull(catalog.interpretation(missing));
+ assertNull(catalog.interpretationType(missing));
+ }
+ assertNotNull(catalog.tracker()); assertNotNull(catalog.prospect());
+ assertNotNull(catalog.establish()); assertNotNull(catalog.pick());
+ assertNotNull(catalog.recovery()); assertNotNull(catalog.sketch());
+ assertNotNull(catalog.museum()); assertNotNull(catalog.autoRuins());
+ assertNotNull(catalog.toolWear()); assertNotNull(catalog.items());
+ assertTrue(catalog.maxShapeAttempts() > 0); assertTrue(catalog.findMinCover() > 0);
+ assertTrue(catalog.useWorldSeed());
+ write("config.yml", "permissions:\n staff: ' custom.staff '\n");
+ catalog.load();
+ assertEquals("custom.staff", catalog.staffPermission());
+ verify(plugin, times(1)).saveResource("config.yml", false);
+ }
+
+ @Test public void missingRequiredCatalogSectionsFailWithActionablePaths() throws Exception {
+ catalog.load();
+ String[][] failures = {{"interest.yml", "interest-levels"}, {"strata.yml", "strata"}, {"artifacts.yml", "artifacts"}, {"hints.yml", "hints"}};
+ for (String[] failure : failures) {
+ File file = new File(folder, failure[0]);
+ String original = Files.readString(file.toPath());
+ write(failure[0], "{}");
+ IllegalStateException error = assertThrows(IllegalStateException.class, catalog::load);
+ assertTrue(error.getMessage(), error.getMessage().contains(failure[1]));
+ write(failure[0], original);
+ }
+ write("interest.yml", "interest-levels:\n low: {}\n");
+ assertTrue(assertThrows(IllegalStateException.class, catalog::load).getMessage().contains("medium"));
+ }
+
+ @Test public void emptyFeatureConfigUsesSafeDefaultsAndLegacyInterestFallback() throws Exception {
+ catalog.load();
+ String interest = Files.readString(new File(folder, "interest.yml").toPath());
+ write("config.yml", interest + "\npermissions:\n staff: ' '\n");
+ write("interest.yml", "{}");
+ catalog.load();
+ assertEquals("archaeo.admin", catalog.staffPermission());
+ assertEquals(TrackerSettings.defaults(), catalog.tracker());
+ assertEquals(ProspectSettings.defaults(), catalog.prospect());
+ assertEquals(EstablishSettings.defaults(), catalog.establish());
+ assertEquals(RecoverySettings.defaults(), catalog.recovery());
+ assertEquals(ToolWearSettings.defaults(), catalog.toolWear());
+ assertEquals(SketchSettings.defaults(), catalog.sketch());
+ assertEquals(MuseumSettings.defaults(), catalog.museum());
+ assertEquals(AutoRuinSettings.defaults(), catalog.autoRuins());
+ write("config.yml", interest.replaceAll("(?s)generation:.*", ""));
+ catalog.load();
+ assertNotNull(catalog.interest(InterestLevel.EXCEPTIONAL));
+ }
+
+ @Test public void rarityBandsAreSortedAliasesNormalizeAndInvalidOverridesFallback() throws Exception {
+ catalog.load();
+ assertEquals("common", catalog.resolveRarityId((ArtifactTemplate) null));
+ assertEquals("legendary", catalog.resolveRarityId(null, -1));
+ assertEquals("epic", catalog.resolveRarityId(" ", 5));
+ assertEquals("rare", catalog.resolveRarityId(null, 8));
+ assertEquals("common", catalog.resolveRarityId(null, 10000));
+ assertEquals("rare", catalog.resolveRarityId(" UNCOMMON ", 1));
+ assertEquals("custom", catalog.resolveRarityId(" CUSTOM ", 1));
+ assertEquals(catalog.rarityLoreLine("common"), catalog.rarityLoreLine((String) null));
+ assertEquals(catalog.rarityLoreLine("common"), catalog.rarityLoreLine(" "));
+ assertEquals(ArtifactRarity.COMMON.loreLine(), catalog.rarityLoreLine("not-a-tier"));
+ write("config.yml", """
+ rarity:
+ custom: {label: 'Special', color: GOLD}
+ rare: BLUE
+ epic: {label: ' ', color: invalid}
+ common: {color: ' '}
+ ' ': RED
+ rarity-from-weight:
+ common: 100
+ custom: 2
+ invalid: 0
+ ' ': 3
+ """);
+ catalog.load();
+ assertEquals("custom", catalog.resolveRarityId(null, 2));
+ assertEquals("common", catalog.resolveRarityId(null, 3));
+ assertTrue(catalog.rarityLoreLine("custom").contains(ChatColor.GOLD + "Special"));
+ assertTrue(catalog.rarityLoreLine("rare").contains(ChatColor.BLUE.toString()));
+ write("config.yml", "rarity-from-weight: {bad: -1}\n");
+ catalog.load();
+ assertEquals("legendary", catalog.resolveRarityId(null, 1));
+ write("config.yml", "rarity-from-weight: {}\n");
+ catalog.load();
+ assertEquals("legendary", catalog.resolveRarityId(null, 1));
+ }
+
+ @Test public void customCatalogRowsHandleScalarGarbageItemPoolsAndMaterialFallbacks() throws Exception {
+ catalog.load();
+ write("strata.yml", "strata:\n ignored: scalar\n deep: {order: 2, depth-min: 5, depth-max: 9}\n top: {order: 1}\n");
+ write("artifacts.yml", """
+ artifacts:
+ ignored: scalar
+ fallback: {weight: -5, item: [invalid]}
+ single: {item: STONE, profile: animal}
+ pool: {item: [BRICK, STONE]}
+ """);
+ write("hints.yml", """
+ hints:
+ ignored: scalar
+ basic: {weight: -3}
+ bounded: {min-wealth: 2, max-wealth: 8, min-strata: 1, require-disturbed: false}
+ """);
+ write("materials.yml", """
+ materials:
+ ignored: scalar
+ metal: {display-name: Iron, survival: 4}
+ organic: {survival: -1}
+ ceramic: {}
+ stone: {stains: [custom], clean-glass: BLUE_STAINED_GLASS_PANE}
+ extra: {}
+ """);
+ catalog.load();
+ assertEquals(List.of("top", "deep"), catalog.strataInOrder().stream().map(StratumDefinition::id).toList());
+ assertNull(catalog.artifact("ignored"));
+ assertEquals(1, catalog.artifact("fallback").weight());
+ assertEquals("BRICK", catalog.artifact("fallback").items().get(0).primary());
+ assertEquals(2, catalog.artifact("pool").items().size());
+ assertEquals(1, catalog.hint("basic").weight());
+ assertEquals("Iron", catalog.materialDisplayName("metal"));
+ assertEquals("Unknown-id", catalog.materialDisplayName("unknown-id"));
+ for (String id : new String[] {null, "", " "}) {
+ assertEquals("unknown", catalog.materialDisplayName(id));
+ assertEquals(1, catalog.materialSurvival(id), 0);
+ assertEquals("unknown", catalog.materialOf(id).id());
+ }
+ assertEquals(1, catalog.materialSurvival("missing"), 0);
+ assertEquals(1, catalog.materialSurvival("metal"), 0);
+ assertEquals(.05, catalog.materialSurvival("organic"), 0);
+ assertEquals(List.of("custom"), catalog.materialOf("stone").stains());
+ assertEquals(Material.BLUE_STAINED_GLASS_PANE, catalog.materialOf("stone").cleanGlass());
+ write("materials.yml", "{}");
+ catalog.load();
+ for (String id : new String[] {"metal", "organic", "ceramic", "stone", "other"}) {
+ assertNotNull(catalog.materialOf(id).cleanGlass());
+ assertFalse(catalog.materialOf(id).stains().isEmpty());
+ }
+ }
+
+ @Test public void stationDrawsAreStableUniqueAndIncludeAnArtifactSpecificSuggestion() throws Exception {
+ catalog.load();
+ write("interpretations.yml", """
+ profiles:
+ object: {types: [missing, function]}
+ animal: {types: [missing]}
+ individual: {types: []}
+ types:
+ ignored: scalar
+ empty: {}
+ invalid: {options: {ignored: scalar}}
+ function:
+ options:
+ suggested: {suggested-for: [pot], suggested-by: [domestic]}
+ a: {profiles: [object, invalid]}
+ b: {}
+ c: {}
+ d: {}
+ animal-only: {profiles: [animal]}
+ size:
+ options:
+ small: {}
+ large: {}
+ """);
+ write("artifacts.yml", "artifacts:\n pot: {}\n bone: {profile: animal}\n");
+ catalog.load();
+ assertEquals(2, catalog.interpretationTypes().size());
+ assertEquals(1, catalog.interpretationTypes(null).size());
+ assertEquals(catalog.interpretationTypes(), catalog.interpretationTypes(FindProfile.ANIMAL));
+ assertTrue(catalog.stationOffers("missing", null, null, null).isEmpty());
+ assertEquals(2, catalog.stationOffers("size", null, null, null).size());
+ for (int i = 0; i < 32; i++) {
+ UUID id = new UUID(i, i * 719L);
+ var offers = catalog.stationOffers("function", id, "pot", Set.of("domestic"));
+ assertEquals(3, offers.size());
+ assertEquals(3, new HashSet<>(offers).size());
+ assertTrue(offers.stream().anyMatch(option -> option.id().equals("suggested")));
+ assertFalse(offers.stream().anyMatch(option -> option.id().equals("animal-only")));
+ assertEquals(offers, catalog.stationOffers("function", id, "pot", Set.of("domestic")));
+ }
+ assertEquals(3, catalog.stationOffers("function", null, "unknown", null).size());
+ assertNull(catalog.nextOpenType(null));
+ BuriedFind find = new BuriedFind();
+ find.setArtifactId("pot");
+ assertEquals("function", catalog.nextOpenType(find).id());
+ find.addInterpretation(new FindInterpretation("function", "a", UUID.randomUUID(), Instant.EPOCH));
+ assertNull(catalog.nextOpenType(find));
+ }
+
+ @Test public void legacyInterpretationsAreOneQuestionAndReloadClearsOldRows() throws Exception {
+ catalog.load();
+ write("interpretations.yml", "interpretations:\n invalid: scalar\n legacy: {display-name: Old}\n");
+ catalog.load();
+ assertEquals(1, catalog.interpretationTypes().size());
+ assertEquals("function", catalog.interpretationTypes().get(0).id());
+ assertEquals("Old", catalog.interpretation("legacy").displayName());
+ assertEquals(catalog.interpretationTypes(), catalog.interpretationTypes(null));
+ write("interpretations.yml", "interpretations: {invalid: scalar}\n");
+ catalog.load();
+ assertTrue(catalog.interpretations().isEmpty());
+ assertTrue(catalog.interpretationTypes().isEmpty());
+ write("interpretations.yml", "{}");
+ catalog.load();
+ assertTrue(catalog.interpretations().isEmpty());
+ }
+
+ @Test public void featureBoundsClampInvalidOperatorValues() throws Exception {
+ catalog.load();
+ write("config.yml", """
+ permissions: {staff: ' '}
+ tracker:
+ max-range: 0
+ medium-range: 400
+ close-range: 500
+ wave-radii: [1.0, 2.0, 3.0, 4.0]
+ beep-max-ticks: 0
+ beep-min-ticks: 0
+ wave-bias-blocks: -2
+ target-switch-margin: -1
+ prospect: {points-required: 0, use-ticks: 0, min-sample-distance: 0}
+ establish: {max-staff: -2, max-excavations: -3}
+ excavation:
+ workday-actions: -1
+ tool-wear: {pick: -1, brush: -1, unbreaking: false}
+ limits: {seconds: 0, thickness: 4, view-distance: 2}
+ conservation:
+ buried: {min: 150, max: -4, bias: -2, depth-penalty: -3, disturbed-penalty: -4}
+ grades:
+ - ignored
+ - {label: missing}
+ - {id: numeric, min-percent: 40, label: Named}
+ - {id: string, min-percent: '90'}
+ - {id: invalid, min-percent: bad}
+ - {id: absent}
+ brush: {hold-ticks: 0, max-cells-to-clean: 0, enabled: false}
+ sketch: {pencil-uses: -2, lab: {dirty-count: 99}}
+ museum: {displays: [LECTERN]}
+ auto-ruins:
+ chance-per-chunk: '0,25'
+ min-soil-fraction: 4
+ min-chunk-distance: -2
+ max-sites-per-world: -2
+ exclude-spawn-chunks: -2
+ max-relief-blocks: -2
+ max-pending: 0
+ max-unload-purge-per-tick: 0
+ max-evaluations-per-tick: 0
+ excluded-biomes: [minecraft:ocean]
+ interest-weights: {low: -2}
+ """);
+ write("interest.yml", Files.readString(new File(folder, "interest.yml").toPath()) + "\ngeneration: {max-shape-attempts: 7, use-world-seed: false, find-min-cover: 0}\n");
+ catalog.load();
+ assertEquals(7, catalog.maxShapeAttempts()); assertEquals(1, catalog.findMinCover()); assertFalse(catalog.useWorldSeed());
+ assertEquals(1, catalog.tracker().defaultMaxRange()); assertEquals(1, catalog.tracker().nearRange());
+ assertEquals(1, catalog.tracker().detectMessageRange());
+ assertEquals(List.of(1.0, 2.0, 3.0), catalog.tracker().waveRadii());
+ assertEquals(0, catalog.tracker().waveBiasBlocks(), 0);
+ assertEquals(1, catalog.prospect().pointsRequired());
+ assertEquals(0, catalog.pick().jornadaActions());
+ assertEquals(0, catalog.toolWear().pick()); assertEquals(0, catalog.toolWear().brush());
+ assertEquals(1, catalog.pick().limits().seconds()); assertEquals(1, catalog.pick().limits().thickness(), 0);
+ assertEquals(0, catalog.pick().conservation().buriedMin()); assertEquals(100, catalog.pick().conservation().buriedMax());
+ assertEquals("string", catalog.pick().conservation().grades().get(0).id());
+ assertEquals(1, catalog.recovery().channelTicks()); assertFalse(catalog.recovery().enabled());
+ assertEquals(0, catalog.sketch().pencilUses()); assertEquals(18, catalog.sketch().lab().dirtyCount());
+ assertTrue(catalog.museum().allowsVanilla(Material.LECTERN)); assertFalse(catalog.museum().allowsVanilla(Material.ARMOR_STAND));
+ assertEquals(.25, catalog.autoRuins().chancePerChunk(), 0); assertEquals(1, catalog.autoRuins().minSoilFraction(), 0);
+ assertEquals(Integer.valueOf(0), catalog.autoRuins().interestWeights().get(InterestLevel.LOW));
+ assertEquals(1, catalog.autoRuins().maxPending());
+ }
+
+ @Test public void legacyItemsToolAliasesTempoAndLabProfilesLoadWithoutDiscardingDefaults() throws Exception {
+ catalog.load();
+ write("config.yml", """
+ tracker: {item: ' ', wave-radii: [1.0]}
+ prospect: {item: {ignored: section}}
+ recovery: {item: BRUSH, channel-ticks: 9}
+ pick: {enabled: false, jornada-actions: 13, visual-cues: false, find-dust: false}
+ excavation:
+ cues:
+ pick-faster: [STONE, STICK, invalid, ' ', '#', '#bad key', '#unknown_tag', '#minecraft:mineable/pickaxe']
+ shovel-faster: [DIRT]
+ matched-factor: -2
+ mismatched-factor: 0
+ conservation: {grades: [ignored, {label: missing}]}
+ tools:
+ invalid: {items: [unknown]}
+ custom:
+ materials: [WOODEN_PICKAXE]
+ tempo: ' 7 '
+ dig-class: pick
+ blocks-on-time: 3
+ blocks-on-late: 1
+ late-shape: down
+ jornada-cost: 0
+ ready-window-ticks: 0
+ mining-speed: ' '
+ mining-speed-multiplier: 2.5
+ legacy-chime-ticks: -1
+ legacy: {items: [GOLDEN_PICKAXE], cue-ticks: 6, mining-speed: {ignore: section}}
+ sketch:
+ lab:
+ tools:
+ ignored: scalar
+ water: {item: WATER_BUCKET}
+ air: {item: FEATHER}
+ brush: {item: BRUSH}
+ other: {item: STICK}
+ stains:
+ ignored: scalar
+ custom: {glass: BLUE_STAINED_GLASS_PANE, tool: other}
+ museum: {enabled: true}
+ """);
+ write("items.yml", """
+ discovery: {tracker: CLOCK, prospect: COMPASS}
+ excavation:
+ pick: STICK
+ give: STICK
+ brush: BRUSH
+ old-list: [STONE_PICKAXE]
+ old-nested: {materials: [IRON_SHOVEL]}
+ bad: scalar
+ """);
+ catalog.load();
+ assertEquals("CLOCK", catalog.items().tracker().primary());
+ assertEquals("COMPASS", catalog.items().prospect().primary());
+ assertEquals(9, catalog.recovery().channelTicks());
+ assertFalse(catalog.pick().enabled());
+ assertEquals(13, catalog.pick().jornadaActions());
+ assertFalse(catalog.pick().visualCues());
+ assertEquals(TrackerSettings.defaults().waveRadii(), catalog.tracker().waveRadii());
+ assertEquals(CueSettings.defaults().matchedFactor(), catalog.pick().cues().matchedFactor(), 0);
+ assertEquals(CueSettings.defaults().mismatchedFactor(), catalog.pick().cues().mismatchedFactor(), 0);
+ assertTrue(catalog.pick().cues().pickFaster().contains(Material.STONE));
+ assertEquals(Set.of(Material.DIRT), catalog.pick().cues().shovelFaster());
+ ExcavationTool custom = tool("custom");
+ assertEquals(7, custom.cueTicks()); assertEquals(DigClass.PICK, custom.digClass());
+ assertEquals(3, custom.cellsOnTime()); assertEquals(3, custom.cellsOnLate());
+ assertEquals(1, custom.jornadaCost()); assertEquals(1, custom.readyWindowTicks());
+ assertNull(custom.miningSpeed()); assertEquals(Float.valueOf(2.5f), custom.miningSpeedMultiplier());
+ assertEquals(6, tool("legacy").cueTicks());
+ assertNotNull(tool("old-list")); assertNotNull(tool("old-nested"));
+ assertEquals(4, catalog.sketch().lab().tools().size());
+ assertEquals(1, catalog.sketch().lab().stains().size());
+ assertEquals(Material.BLUE_STAINED_GLASS_PANE, catalog.sketch().lab().stains().get(0).glass());
+ assertEquals(MuseumSettings.defaults(), catalog.museum());
+ }
+
+ @Test public void tempoTokensAndUnitIntervalParsingAcceptLegacyInputsAndRejectGarbage() throws Exception {
+ catalog.load();
+ String[] tokens = {"12", "-4", "'vanilla'", "'default'", "'auto'", "' '", "'bogus'", "'9'"};
+ int[] expected = {12, 0, 0, 0, 0, 0, 0, 9};
+ for (int i = 0; i < tokens.length; i++) {
+ write("config.yml", "excavation:\n tools:\n light: {tempo: " + tokens[i] + "}\n");
+ catalog.load();
+ assertEquals(tokens[i], expected[i], tool("light").cueTicks());
+ }
+ for (String token : new String[] {"'invalid'", "' '", "[]", "{}"}) {
+ write("config.yml", "auto-ruins: {chance-per-chunk: " + token + ", min-soil-fraction: " + token + "}\n");
+ catalog.load();
+ assertEquals(AutoRuinSettings.defaults().chancePerChunk(), catalog.autoRuins().chancePerChunk(), 0);
+ assertEquals(AutoRuinSettings.defaults().minSoilFraction(), catalog.autoRuins().minSoilFraction(), 0);
+ }
+ write("config.yml", "auto-ruins: {chance-per-chunk: -1, min-soil-fraction: '0,5'}\nsketch: {lab: {tools: {bad: scalar}, stains: {bad: scalar}}}\n");
+ catalog.load();
+ assertEquals(0, catalog.autoRuins().chancePerChunk(), 0);
+ assertEquals(.5, catalog.autoRuins().minSoilFraction(), 0);
+ assertEquals(LabSettings.defaults(), catalog.sketch().lab());
+ write("config.yml", "excavation: {cues: {}, conservation: {grades: []}}\nsketch: {pencil-uses: 3}\n");
+ catalog.load();
+ assertTrue(catalog.pick().cues().pickFaster().isEmpty());
+ assertEquals(LabSettings.defaults(), catalog.sketch().lab());
+ }
+
+ @Test public void typedQuestionsWithoutProfilesRemainAvailableToEveryClassification() throws Exception {
+ catalog.load();
+ write("interpretations.yml", "types: {function: {options: {reading: {profiles: [animal]}}}}\n");
+ catalog.load();
+ assertEquals(catalog.interpretationTypes(), catalog.interpretationTypes(FindProfile.INDIVIDUAL));
+ assertTrue(catalog.stationOffers("function", UUID.randomUUID(), null, Set.of()).isEmpty());
+ write("interpretations.yml", "profiles: {object: {types: []}}\ntypes: {function: {options: {reading: {}}}}\n");
+ catalog.load();
+ assertEquals(catalog.interpretationTypes(), catalog.interpretationTypes(FindProfile.OBJECT));
+ assertEquals(catalog.interpretationTypes(), catalog.interpretationTypes(FindProfile.INDIVIDUAL));
+ assertEquals(1, catalog.stationOffers("function", null, null, null).size());
+ }
+
+ @Test public void partialOperatorSectionsKeepPackagedValuesForTheKeysTheyOmit() throws Exception {
+ catalog.load();
+ write("config.yml", """
+ excavation:
+ conservation: {buried: {min: 50}}
+ tools:
+ light: {break-shape: {down: true}}
+ auto-ruins: {enabled: false}
+ rarity:
+ legendary: {label: Mythic}
+ sketch:
+ lab:
+ stains:
+ grime: {display-name: '', tool: ''}
+ """);
+ catalog.load();
+ assertEquals(50, catalog.pick().conservation().buriedMin());
+ assertEquals(ConservationSettings.defaults().grades(), catalog.pick().conservation().grades());
+ assertEquals(ExcavationTool.light().breakShape(), tool("light").breakShape());
+ assertFalse(catalog.autoRuins().enabled());
+ assertEquals(AutoRuinSettings.defaults().chancePerChunk(), catalog.autoRuins().chancePerChunk(), 0);
+ assertEquals(ChatColor.GRAY + "Rarity: " + ArtifactRarity.LEGENDARY.color() + "Mythic", catalog.rarityLoreLine("legendary"));
+ LabStain grime = catalog.sketch().lab().stains().get(0);
+ assertEquals("grime", grime.label());
+ assertTrue(grime.allowsTool("water")); assertTrue(grime.allowsTool(null));
+ }
+
+ @Test public void unwritableDataFolderIsLoggedAndTheLoadFailsOnTheMissingCatalogue() throws Exception {
+ File blocker = temporary.newFile("plugins");
+ folder = new File(blocker, "Archaeo");
+ Logger logger = mock(Logger.class);
+ when(plugin.getLogger()).thenReturn(logger); when(plugin.getDataFolder()).thenReturn(folder);
+ doNothing().when(plugin).saveResource(anyString(), eq(false));
+ IllegalStateException error = assertThrows(IllegalStateException.class, catalog::load);
+ assertTrue(error.getMessage(), error.getMessage().contains("interest-levels"));
+ verify(logger, atLeastOnce()).warning("Could not create " + folder.getPath());
+ }
+
+ @Test public void failedReloadKeepsEveryRequiredCatalogueThatWasAlreadyLoaded() throws Exception {
+ catalog.load();
+ Map artifacts = Map.copyOf(catalog.artifacts());
+ int hints = catalog.hints().size(), strata = catalog.strataInOrder().size();
+ // The operator edits artifacts.yml and breaks hints.yml in the same /archaeo reload.
+ YamlConfiguration edited = YamlConfiguration.loadConfiguration(new File(folder, "artifacts.yml"));
+ edited.set("artifacts.coin.display-name", "Recut coin");
+ edited.set("artifacts.amphora.display-name", "Amphora");
+ edited.save(new File(folder, "artifacts.yml"));
+ write("hints.yml", "{}");
+ assertThrows(IllegalStateException.class, catalog::load);
+ assertNull(catalog.artifact("amphora")); assertEquals("Coin", catalog.artifact("coin").displayName());
+ assertEquals(artifacts, catalog.artifacts());
+ assertEquals(hints, catalog.hints().size()); assertEquals(strata, catalog.strataInOrder().size());
+ for (InterestLevel level : InterestLevel.values()) assertNotNull(catalog.interest(level));
+ }
+
+ private ExcavationTool tool(String id) {
+ return catalog.pick().profiles().stream().filter(tool -> tool.id().equals(id)).findFirst().orElseThrow();
+ }
+
+ private void write(String name, String contents) throws Exception {
+ Files.createDirectories(folder.toPath());
+ Files.writeString(new File(folder, name).toPath(), contents);
+ }
+}
diff --git a/src/test/java/net/tfminecraft/archaeo/config/CatalogValuesTest.java b/src/test/java/net/tfminecraft/archaeo/config/CatalogValuesTest.java
new file mode 100644
index 0000000..c6b16d8
--- /dev/null
+++ b/src/test/java/net/tfminecraft/archaeo/config/CatalogValuesTest.java
@@ -0,0 +1,130 @@
+package net.tfminecraft.archaeo.config;
+
+import org.bukkit.ChatColor;
+import org.bukkit.Material;
+import org.junit.Test;
+import org.junit.Before;
+import org.junit.After;
+import org.mockbukkit.mockbukkit.MockBukkit;
+
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+import static org.junit.Assert.*;
+
+public class CatalogValuesTest {
+ @Before public void setUp() { MockBukkit.mock(); }
+ @After public void tearDown() { MockBukkit.unmock(); }
+ @Test
+ public void parsesProfilesWithoutTurningUnknownListedProfilesIntoObjects() {
+ for (FindProfile profile : FindProfile.values()) {
+ assertEquals(profile, FindProfile.fromConfig(" " + profile.name() + " "));
+ assertEquals(profile, FindProfile.parseListed(profile.id()).orElseThrow());
+ }
+ for (String raw : new String[]{null, " ", "unknown"}) {
+ assertEquals(FindProfile.OBJECT, FindProfile.fromConfig(raw));
+ assertTrue(FindProfile.parseListed(raw).isEmpty());
+ }
+ }
+
+ @Test
+ public void rarityStylesRetainLegacyAliasAndSafeLabelDefaults() {
+ for (ArtifactRarity rarity : ArtifactRarity.values()) {
+ assertEquals(rarity, ArtifactRarity.fromConfig(" " + rarity.name() + " "));
+ RarityStyle style = rarity.style();
+ assertEquals(rarity.id(), style.id());
+ assertEquals(rarity.name(), style.label());
+ assertEquals(rarity.color(), style.color());
+ assertEquals(ChatColor.GRAY + "Rarity: " + rarity.color() + rarity.name(), rarity.loreLine());
+ }
+ assertEquals(ArtifactRarity.RARE, ArtifactRarity.fromConfig("uncommon"));
+ for (String raw : new String[]{null, " ", "unknown"}) {
+ assertEquals(ArtifactRarity.COMMON, ArtifactRarity.fromConfig(raw));
+ }
+ for (String label : new String[]{null, " "}) {
+ assertEquals(ChatColor.GRAY + "Rarity: " + ChatColor.WHITE + "custom",
+ new RarityStyle("custom", label, null).loreLine());
+ }
+ }
+
+ @Test
+ public void breakShapesAndDigClassesAcceptTheirDocumentedAliases() {
+ for (String raw : new String[]{null, " ", "unknown", "down"}) {
+ assertEquals(BreakShape.DOWN, BreakShape.parse(raw));
+ }
+ for (String raw : List.of("around", "area", "ring", "plus", "cross")) {
+ assertEquals(BreakShape.AROUND, BreakShape.parse(" " + raw.toUpperCase() + " "));
+ }
+ for (String raw : List.of("random", "shuffle")) assertEquals(BreakShape.RANDOM, BreakShape.parse(raw));
+ for (String raw : new String[]{null, " ", "unknown"}) assertNull(DigClass.parse(raw));
+ for (String raw : List.of("pick", "pickaxe", "pico")) assertEquals(DigClass.PICK, DigClass.parse(raw));
+ for (String raw : List.of("shovel", "spade", "pala")) assertEquals(DigClass.SHOVEL, DigClass.parse(raw));
+ for (String raw : List.of("none", "any", "hand")) assertEquals(DigClass.NONE, DigClass.parse(raw));
+ assertEquals(DigClass.NONE, DigClass.of((Material) null));
+ assertEquals(DigClass.NONE, DigClass.of(Material.AIR));
+ assertEquals(DigClass.NONE, DigClass.of((org.bukkit.inventory.ItemStack) null));
+ }
+
+ @Test
+ public void conservationGradesIncludeTheirBoundaryAndRejectLowerValues() {
+ ConservationSettings settings = ConservationSettings.defaults();
+ assertEquals(40, settings.buriedMin());
+ assertEquals(100, settings.buriedMax());
+ assertEquals(1.0, settings.bias(), 0);
+ assertEquals(4, settings.depthPenalty());
+ assertEquals(10, settings.disturbedPenalty());
+ List grades = settings.grades();
+ for (int i = 0; i < grades.size(); i++) {
+ ConservationGrade grade = grades.get(i);
+ assertSame(grade, settings.grade(grade.minPercent()));
+ assertEquals(grade.label(), settings.gradeLabel(grade.minPercent()));
+ assertSame(i + 1 == grades.size() ? null : grades.get(i + 1), settings.grade(grade.minPercent() - 1));
+ }
+ assertNull(settings.grade(0));
+ assertEquals("", settings.gradeLabel(0));
+ assertEquals("Intact", settings.gradeLabel(100));
+ }
+
+ @Test
+ public void materialDescriptionsAndPanesUseSafeFallbacks() {
+ for (String name : new String[]{null, " "}) {
+ FindMaterial material = new FindMaterial("bone", name, 1, null, List.of());
+ assertEquals("The piece is clean.", material.firstStepDone());
+ assertEquals(Material.WHITE_STAINED_GLASS_PANE, material.cleanPane());
+ assertEquals("clean", material.firstStepVerb());
+ assertEquals("Cleaning", material.firstStepGerund());
+ }
+ assertEquals(Material.WHITE_STAINED_GLASS_PANE,
+ new FindMaterial("bone", "Bone", 1, Material.AIR, List.of()).cleanPane());
+ FindMaterial material = new FindMaterial("bone", "BONE", 1, Material.BLUE_STAINED_GLASS_PANE, List.of());
+ assertEquals("The bone is clean.", material.firstStepDone());
+ assertEquals(Material.BLUE_STAINED_GLASS_PANE, material.cleanPane());
+ }
+
+ @Test
+ public void interpretationHintsWeightMatchesAndRestrictOnlyExplicitProfiles() {
+ InterpretationTemplate unrestricted = new InterpretationTemplate("tool", "function", "Tool", null, null, null);
+ assertTrue(unrestricted.appliesTo(null));
+ assertTrue(unrestricted.appliesTo(FindProfile.ANIMAL));
+ assertFalse(unrestricted.suggestedBy(Set.of("metal")));
+ assertFalse(unrestricted.suggestedFor("pot"));
+ Set profiles = new HashSet<>(Set.of(FindProfile.OBJECT));
+ InterpretationTemplate option = new InterpretationTemplate("tool", "function", "Tool", Set.of("metal"), Set.of("knife"), profiles);
+ profiles.clear();
+ assertTrue(option.appliesTo(FindProfile.OBJECT));
+ assertFalse(option.appliesTo(FindProfile.ANIMAL));
+ assertFalse(option.appliesTo(null));
+ assertFalse(option.suggestedBy(null));
+ assertFalse(option.suggestedBy(Set.of()));
+ assertFalse(option.suggestedBy(Set.of("wood")));
+ assertTrue(option.suggestedBy(Set.of("metal")));
+ assertFalse(option.suggestedFor(null));
+ assertFalse(option.suggestedFor(" "));
+ assertFalse(option.suggestedFor("pot"));
+ assertTrue(option.suggestedFor("knife"));
+ assertFalse(new InterpretationTemplate("id", "type", "name", Set.of(), Set.of(), Set.of())
+ .suggestedBy(Set.of("metal")));
+ assertThrows(UnsupportedOperationException.class, () -> option.profiles().clear());
+ }
+}
diff --git a/src/test/java/net/tfminecraft/archaeo/config/ConfigEnumsTest.java b/src/test/java/net/tfminecraft/archaeo/config/ConfigEnumsTest.java
new file mode 100644
index 0000000..a8e9cca
--- /dev/null
+++ b/src/test/java/net/tfminecraft/archaeo/config/ConfigEnumsTest.java
@@ -0,0 +1,46 @@
+package net.tfminecraft.archaeo.config;
+
+import static org.junit.Assert.*;
+import static org.mockito.Mockito.*;
+
+import java.util.logging.Logger;
+import org.bukkit.Material;
+import org.bukkit.Particle;
+import org.bukkit.Sound;
+import org.bukkit.plugin.java.JavaPlugin;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.mockbukkit.mockbukkit.MockBukkit;
+
+public class ConfigEnumsTest {
+ private JavaPlugin plugin;
+ @Before public void setUp() {
+ MockBukkit.mock();
+ plugin = mock(JavaPlugin.class);
+ when(plugin.getLogger()).thenReturn(Logger.getLogger("ConfigEnumsTest"));
+ }
+ @After public void tearDown() { MockBukkit.unmock(); }
+
+ @Test public void materialsAcceptNamespacedTrimmedTokensAndRejectAirOrUnknown() {
+ assertEquals(Material.STONE, ConfigEnums.material(plugin, " minecraft:stone ", Material.BRICK, "material"));
+ for (String token : new String[] {null, " ", "missing_material", "AIR", "CAVE_AIR", "VOID_AIR"}) {
+ assertEquals(Material.BRICK, ConfigEnums.material(plugin, token, Material.BRICK, "material"));
+ }
+ }
+ @Test public void particlesAreCaseInsensitiveAndFallbackForUnknownNames() {
+ assertEquals(Particle.FLAME, ConfigEnums.particle(plugin, " flame ", Particle.CLOUD, "particle"));
+ for (String token : new String[] {null, " ", "missing_particle"}) {
+ assertEquals(Particle.CLOUD, ConfigEnums.particle(plugin, token, Particle.CLOUD, "particle"));
+ }
+ }
+ @Test public void soundsAcceptEnumDottedAndNamespacedForms() {
+ for (String token : new String[] {" ITEM_BUCKET_EMPTY ", "item.bucket.empty", "MINECRAFT:ITEM.BUCKET.EMPTY"}) {
+ assertEquals(Sound.ITEM_BUCKET_EMPTY, ConfigEnums.sound(plugin, token, Sound.BLOCK_WOOL_HIT, "sound"));
+ }
+ for (String token : new String[] {null, " ", "not.a.sound", "bad key!"}) {
+ assertEquals(Sound.BLOCK_WOOL_HIT, ConfigEnums.sound(plugin, token, Sound.BLOCK_WOOL_HIT, "sound"));
+ }
+ assertNull(ConfigEnums.sound(plugin, "missing", null, "sound"));
+ }
+}
diff --git a/src/test/java/net/tfminecraft/archaeo/config/ExcavationSettingsTest.java b/src/test/java/net/tfminecraft/archaeo/config/ExcavationSettingsTest.java
new file mode 100644
index 0000000..a6b7f52
--- /dev/null
+++ b/src/test/java/net/tfminecraft/archaeo/config/ExcavationSettingsTest.java
@@ -0,0 +1,130 @@
+package net.tfminecraft.archaeo.config;
+
+import net.tfminecraft.archaeo.item.ItemRef;
+import org.bukkit.Material;
+import org.bukkit.inventory.ItemStack;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.mockbukkit.mockbukkit.MockBukkit;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Random;
+import java.util.Set;
+
+import static org.junit.Assert.*;
+import static org.mockito.Mockito.*;
+
+public class ExcavationSettingsTest {
+ @Before public void setUp() { MockBukkit.mock(); }
+ @After public void tearDown() { MockBukkit.unmock(); }
+
+ @Test
+ public void cueAffinityScalesOnlyMatchingConfiguredClasses() {
+ CueSettings cues = new CueSettings(Set.of(Material.STONE), Set.of(Material.DIRT), 0.7, 1.35);
+ assertEquals(14, cues.intervalTicks(10, DigClass.PICK, Material.STONE));
+ assertEquals(14, cues.intervalTicks(10, DigClass.SHOVEL, Material.DIRT));
+ assertEquals(28, cues.intervalTicks(10, DigClass.PICK, Material.DIRT));
+ assertEquals(28, cues.intervalTicks(10, DigClass.SHOVEL, Material.STONE));
+ for (DigClass dig : DigClass.values()) {
+ assertEquals(20, cues.intervalTicks(10, dig, Material.AIR));
+ assertEquals(20, cues.intervalTicks(10, dig, null));
+ }
+ assertEquals(20, cues.intervalTicks(10, null, Material.STONE));
+ assertEquals(2, cues.intervalTicks(-1, DigClass.NONE, Material.STONE));
+ assertEquals(2, new CueSettings(Set.of(Material.STONE), Set.of(), 0, 1).intervalTicks(10, DigClass.PICK, Material.STONE));
+ assertEquals(2f, cues.progressPerCue(), 0f);
+ assertFalse(cues.isPickFaster(null));
+ assertFalse(cues.isShovelFaster(null));
+ assertTrue(CueSettings.defaults().isPickFaster(Material.STONE));
+ assertTrue(CueSettings.defaults().isShovelFaster(Material.DIRT));
+ assertFalse(CueSettings.defaults().isPickFaster(Material.AIR));
+ assertFalse(CueSettings.defaults().isShovelFaster(Material.AIR));
+ assertTrue(CueSettings.copyOf(null).isEmpty());
+ assertTrue(CueSettings.copyOf(Set.of()).isEmpty());
+ Set source = new HashSet<>(Set.of(Material.STONE));
+ Set copy = CueSettings.copyOf(source);
+ source.clear();
+ assertEquals(Set.of(Material.STONE), copy);
+ assertThrows(UnsupportedOperationException.class, copy::clear);
+ }
+
+ @Test
+ public void packagedProfilesHaveExpectedLiftsAndInferToolClass() {
+ assertEquals(ExcavationTool.hand(), ExcavationTool.packaged(null));
+ assertEquals(ExcavationTool.hand(), ExcavationTool.packaged("unknown"));
+ assertEquals(ExcavationTool.light(), ExcavationTool.packaged("light"));
+ assertEquals(ExcavationTool.heavy(), ExcavationTool.packaged("heavy"));
+ assertEquals(ExcavationTool.superHeavy(), ExcavationTool.packaged("super-heavy"));
+ assertEquals(1, ExcavationTool.hand().cellsOnLate());
+ assertEquals(2, ExcavationTool.light().cellsOnLate());
+ assertEquals(4, ExcavationTool.heavy().cellsOnLate());
+ assertEquals(8, ExcavationTool.superHeavy().cellsOnLate());
+ assertFalse(ExcavationTool.hand().usesCueTicks());
+ assertFalse(ExcavationTool.hand().hasChimeFallback());
+ ExcavationTool timed = new ExcavationTool("timed", List.of(), 2, null, 1, null, null, 1, 2, BreakShape.DOWN, 1, 20);
+ assertTrue(timed.usesCueTicks());
+ assertTrue(timed.hasChimeFallback());
+ assertEquals(DigClass.NONE, ExcavationTool.hand().resolveDigClass(new ItemStack(Material.IRON_PICKAXE)));
+ assertEquals(DigClass.PICK, timed.resolveDigClass(new ItemStack(Material.IRON_PICKAXE)));
+ assertEquals(DigClass.SHOVEL, timed.resolveDigClass(new ItemStack(Material.IRON_SHOVEL)));
+ assertEquals(DigClass.NONE, timed.resolveDigClass(new ItemStack(Material.BRICK)));
+ assertEquals(DigClass.NONE, timed.resolveDigClass(new ItemStack(Material.AIR)));
+ PickSettings settings = PickSettings.defaults();
+ assertFalse(settings.unlimitedWorkday());
+ for (int actions : new int[]{0, -1}) {
+ assertTrue(new PickSettings(true, actions, true, true, 1, 1,
+ settings.conservation(), settings.limits(), true, settings.cues(), settings.profiles()).unlimitedWorkday());
+ }
+ }
+
+ @Test
+ public void artifactPoolsDefaultCopyClampAndResolveStoredTokens() {
+ ArtifactTemplate missing = artifact(null, null);
+ assertEquals(FindProfile.OBJECT, missing.profile());
+ assertEquals("BRICK", missing.pickItem(null));
+ assertEquals("BRICK", artifact(List.of(), null).pickItem(new Random(1)));
+ List source = new ArrayList<>(List.of(ItemRef.vanilla(Material.GOLD_NUGGET),
+ new ItemRef(ItemRef.Kind.ITEMSADDER, "pack:pot", "")));
+ ArtifactTemplate template = artifact(source, FindProfile.ANIMAL);
+ source.clear();
+ assertEquals(FindProfile.ANIMAL, template.profile());
+ assertEquals(2, template.items().size());
+ assertEquals(2, template.clampSize(1));
+ assertEquals(3, template.clampSize(3));
+ assertEquals(4, template.clampSize(5));
+ assertEquals("GOLD_NUGGET", template.pickItem(null));
+ Random random = mock(Random.class);
+ when(random.nextInt(2)).thenReturn(1);
+ assertEquals("itemsadder:pack:pot", template.pickItem(random));
+ for (String token : new String[]{null, " ", " gold_nugget ", "bad_material"}) {
+ assertEquals(Material.GOLD_NUGGET, template.resolveItem(token));
+ }
+ assertEquals(Material.DIAMOND, template.resolveItem("minecraft:diamond"));
+ assertEquals(Material.BRICK, template.resolveItem("itemsadder:pack:pot"));
+ assertEquals(Material.BRICK, template.resolveItem("AIR"));
+ assertEquals(Material.BRICK, template.resolveItem("WATER"));
+ assertThrows(UnsupportedOperationException.class, () -> template.items().clear());
+ }
+
+ @Test
+ public void labLookupIgnoresCaseAndHandlesMissingCatalogs() {
+ LabSettings defaults = LabSettings.defaults();
+ assertEquals(6, defaults.dirtyCount());
+ assertEquals("water", defaults.tool("WATER").id());
+ assertEquals("rust", defaults.stain("RUST").id());
+ for (String id : new String[]{null, " ", "unknown"}) {
+ assertNull(defaults.tool(id));
+ assertNull(defaults.stain(id));
+ }
+ assertNull(new LabSettings(1, null, null).tool("water"));
+ assertNull(new LabSettings(1, null, null).stain("rust"));
+ }
+
+ private static ArtifactTemplate artifact(List items, FindProfile profile) {
+ return new ArtifactTemplate("pot", "Pot", 2, 4, "ceramic", "common", false, 10,
+ Set.of("I"), Set.of("vessel"), profile, items, "notes");
+ }
+}
diff --git a/src/test/java/net/tfminecraft/archaeo/establish/CampArchiveBookTest.java b/src/test/java/net/tfminecraft/archaeo/establish/CampArchiveBookTest.java
new file mode 100644
index 0000000..69d678f
--- /dev/null
+++ b/src/test/java/net/tfminecraft/archaeo/establish/CampArchiveBookTest.java
@@ -0,0 +1,116 @@
+package net.tfminecraft.archaeo.establish;
+
+import net.tfminecraft.archaeo.model.*;
+import org.bukkit.Material;
+import org.bukkit.NamespacedKey;
+import org.bukkit.inventory.ItemStack;
+import org.bukkit.inventory.meta.BookMeta;
+import org.bukkit.persistence.PersistentDataType;
+import org.bukkit.plugin.java.JavaPlugin;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.mockbukkit.mockbukkit.MockBukkit;
+
+import java.util.UUID;
+
+import static org.junit.Assert.*;
+
+public class CampArchiveBookTest {
+ private JavaPlugin plugin;
+ private CampArchiveBook books;
+ private Site site;
+
+ @Before public void setUp() {
+ MockBukkit.mock();
+ plugin = MockBukkit.createMockPlugin();
+ books = new CampArchiveBook(plugin);
+ site = new Site(); site.setId(UUID.randomUUID()); site.setName("River camp");
+ }
+ @After public void tearDown() { MockBukkit.unmock(); }
+
+ @Test
+ public void bookRecordsCompletionAndTruncatesVanillaTitleAndAuthorLimits() {
+ site.setName("A".repeat(50));
+ BuriedFind find = new BuriedFind(); site.getFinds().add(find);
+ ItemStack book = books.create("B".repeat(50), site);
+ BookMeta meta = (BookMeta) book.getItemMeta();
+ assertEquals("A".repeat(32), meta.getTitle());
+ assertEquals("B".repeat(32), meta.getAuthor());
+ assertEquals(BookMeta.Generation.ORIGINAL, meta.getGeneration());
+ assertTrue(meta.getPage(1).contains("not finished (0%)"));
+ assertEquals(site.getId(), books.siteIdOf(book));
+ find.setState(FindState.RECOVERED);
+ site.setName("Renamed camp");
+ books.refresh(book, site);
+ assertTrue(book.getItemMeta().getDisplayName().contains("Renamed camp"));
+ assertTrue(book.getItemMeta().getLore().getFirst().contains("100%"));
+ assertEquals(site.getId(), books.siteIdOf(book));
+ for (String author : new String[]{null, " "}) {
+ BookMeta fallback = (BookMeta) books.create(author, site).getItemMeta();
+ assertEquals("Staff", fallback.getAuthor());
+ assertTrue(fallback.getPage(1).contains("cut was finished"));
+ }
+ assertEquals("Writer", ((BookMeta) books.create(MockBukkit.getMock().addPlayer("Writer"), site).getItemMeta()).getAuthor());
+ }
+
+ @Test
+ public void copyingBookRetainsArchiveIdentityWithoutMutatingRecipeResult() {
+ ItemStack original = books.create("Author", site);
+ ItemStack crafted = new ItemStack(Material.WRITTEN_BOOK);
+ BookMeta meta = (BookMeta) crafted.getItemMeta();
+ meta.setTitle("Vanilla copy"); meta.setAuthor("Author"); meta.addPage("Copied page");
+ crafted.setItemMeta(meta);
+ ItemStack copy = books.stampCopy(original, crafted);
+ assertNotSame(crafted, copy);
+ assertFalse(books.isArchive(crafted));
+ assertTrue(books.isArchive(copy));
+ assertEquals(site.getId(), books.siteIdOf(copy));
+ assertEquals("Copied page", ((BookMeta) copy.getItemMeta()).getPage(1));
+ assertEquals(original.getItemMeta().getDisplayName(), copy.getItemMeta().getDisplayName());
+ assertEquals(original.getItemMeta().getLore(), copy.getItemMeta().getLore());
+ assertSame(crafted, books.stampCopy(new ItemStack(Material.BOOK), crafted));
+ ItemStack paper = new ItemStack(Material.PAPER);
+ assertSame(paper, books.stampCopy(original, paper));
+ assertNull(books.stampCopy(original, null));
+ }
+
+ @Test
+ public void copiesOfARenamedOrDamagedFieldBookStayFieldBooks() {
+ ItemStack original = books.create("Author", site);
+ BookMeta cleared = (BookMeta) original.getItemMeta(); cleared.setDisplayName(null); original.setItemMeta(cleared); // Anvil name cleared.
+ ItemStack copy = books.stampCopy(original, new ItemStack(Material.WRITTEN_BOOK));
+ assertTrue(books.isArchive(copy)); assertEquals(site.getId(), books.siteIdOf(copy)); assertFalse(copy.getItemMeta().hasDisplayName());
+ BookMeta damaged = (BookMeta) original.getItemMeta();
+ damaged.getPersistentDataContainer().remove(new NamespacedKey(plugin, "archive_site_id")); original.setItemMeta(damaged);
+ ItemStack orphan = books.stampCopy(original, new ItemStack(Material.WRITTEN_BOOK));
+ assertTrue(books.isArchive(orphan)); assertNull(books.siteIdOf(orphan));
+ }
+
+ @Test
+ public void ordinaryAndCorruptBooksDoNotResolveToAnArchiveDossier() {
+ assertFalse(books.isArchive(null));
+ assertFalse(books.isArchive(new ItemStack(Material.PAPER)));
+ assertFalse(books.isArchive(new ItemStack(Material.WRITTEN_BOOK)));
+ assertNull(books.siteIdOf(null));
+ books.refresh(null, site);
+ ItemStack ordinary = new ItemStack(Material.WRITTEN_BOOK);
+ books.refresh(ordinary, site);
+ assertFalse(books.isArchive(ordinary));
+ ItemStack archive = books.create("Author", site);
+ books.refresh(archive, null);
+ for (String raw : new String[]{null, " ", "not-a-uuid"}) {
+ BookMeta meta = (BookMeta) archive.getItemMeta();
+ NamespacedKey key = new NamespacedKey(plugin, "archive_site_id");
+ if (raw == null) meta.getPersistentDataContainer().remove(key);
+ else meta.getPersistentDataContainer().set(key, PersistentDataType.STRING, raw);
+ archive.setItemMeta(meta);
+ assertTrue(books.isArchive(archive));
+ assertNull(books.siteIdOf(archive));
+ }
+ BookMeta meta = (BookMeta) archive.getItemMeta();
+ meta.getPersistentDataContainer().set(new NamespacedKey(plugin, "excavation_archive"), PersistentDataType.BYTE, (byte) 0);
+ archive.setItemMeta(meta);
+ assertFalse(books.isArchive(archive));
+ }
+}
diff --git a/src/test/java/net/tfminecraft/archaeo/establish/CampBoardsTest.java b/src/test/java/net/tfminecraft/archaeo/establish/CampBoardsTest.java
new file mode 100644
index 0000000..ca8f0c4
--- /dev/null
+++ b/src/test/java/net/tfminecraft/archaeo/establish/CampBoardsTest.java
@@ -0,0 +1,296 @@
+package net.tfminecraft.archaeo.establish;
+
+import net.tfminecraft.archaeo.config.*;
+import net.tfminecraft.archaeo.item.RecoveredFindItem;
+import net.tfminecraft.archaeo.model.*;
+import org.bukkit.ChatColor;
+import org.bukkit.DyeColor;
+import org.bukkit.Material;
+import org.bukkit.NamespacedKey;
+import org.bukkit.block.Biome;
+import org.bukkit.inventory.Inventory;
+import org.bukkit.inventory.ItemStack;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.mockbukkit.mockbukkit.MockBukkit;
+import org.mockbukkit.mockbukkit.entity.PlayerMock;
+
+import java.time.Instant;
+import java.util.List;
+import java.util.Set;
+import java.util.UUID;
+
+import static org.junit.Assert.*;
+import static org.mockito.Mockito.*;
+
+public class CampBoardsTest {
+ private PlayerMock director;
+ private PlayerMock worker;
+ private Site site;
+ private CatalogRegistry catalogs;
+ private RecoveredFindItem recovered;
+
+ @Before public void setUp() {
+ MockBukkit.mock(); director = MockBukkit.getMock().addPlayer("Director"); worker = MockBukkit.getMock().addPlayer("Worker");
+ catalogs = mock(CatalogRegistry.class);
+ when(catalogs.pick()).thenReturn(PickSettings.defaults());
+ recovered = new RecoveredFindItem(MockBukkit.createMockPlugin());
+ site = new Site(); site.setId(UUID.randomUUID()); site.setName("River camp");
+ site.setWorldName(director.getWorld().getName()); site.setInterest(InterestLevel.LOW);
+ site.establish(director.getUniqueId(), 1, 0, 20, 4, 4);
+ site.getExcavators().add(worker.getUniqueId());
+ }
+ @After public void tearDown() { MockBukkit.unmock(); }
+
+ @Test
+ public void mainBoardShowsAccurateProgressAndOnlyAuthorizedCampActions() {
+ find(FindState.RECOVERED, "I"); find(FindState.LOST, "I"); find(FindState.HIDDEN, "II");
+ StratumBand band = new StratumBand(); band.setId("I"); band.setPresent(true); site.getStrata().put("I", band);
+ StratumBand absent = new StratumBand(); absent.setId("IV"); site.getStrata().put("IV", absent);
+ when(catalogs.stratum("I")).thenReturn(new StratumDefinition("I", 1, "Topsoil", 1, 3, true));
+ site.getHintIds().add("missing-note"); site.getHintIds().add("long-note");
+ when(catalogs.hint("long-note")).thenReturn(new HintTemplate("long-note", "A very long field note that should wrap at word boundaries so the whole observation remains readable.", 1,
+ Set.of(), Set.of(), Set.of(), Set.of(), null, null, null, null, null));
+ CampBoard board = new CampBoard(site.getId(), true, true, catalogs); board.open(director, site);
+ assertSame(board, director.getOpenInventory().getTopInventory().getHolder());
+ assertEquals(site.getId(), board.siteId());
+ assertEquals("Rename", name(board.getInventory(), CampBoard.SLOT_RENAME));
+ assertEquals("Move camp", name(board.getInventory(), CampBoard.SLOT_MOVE));
+ assertEquals("Close excavation", name(board.getInventory(), CampBoard.SLOT_CLOSE));
+ String info = lore(board.getInventory(), CampBoard.SLOT_INFO);
+ assertTrue(info, info.contains("1 recovered · 2 total · 1 lost"));
+ assertTrue(info.contains("1/3")); assertTrue(info.contains("Topsoil")); assertFalse(info.contains("IV:"));
+ assertTrue(lore(board.getInventory(), CampBoard.SLOT_INFORMATION).contains("missing-note"));
+ assertTrue(lore(board.getInventory(), CampBoard.SLOT_INFORMATION).contains("readable."));
+ CampBoard viewer = new CampBoard(site.getId(), false, false, catalogs); viewer.open(worker, site);
+ for (int slot : new int[]{CampBoard.SLOT_RENAME, CampBoard.SLOT_MOVE, CampBoard.SLOT_CLOSE, CampBoard.SLOT_WOOL_PRIMARY}) {
+ assertEquals(Material.GRAY_STAINED_GLASS_PANE, viewer.getInventory().getItem(slot).getType());
+ }
+ assertTrue(lore(viewer.getInventory(), CampBoard.SLOT_INFO).contains("You are"));
+ }
+
+ @Test
+ public void closedArchiveReplacesPrismActionWithLocationAndReportsUnfinishedCut() {
+ find(FindState.HIDDEN, "I"); site.setName("Long name ".repeat(5)); site.setStatus(SiteStatus.CLOSED);
+ CampBoard board = new CampBoard(site.getId(), false, false, catalogs); board.open(worker, site);
+ assertEquals(32, worker.getOpenInventory().getTitle().length());
+ assertEquals("Location", name(board.getInventory(), CampBoard.SLOT_LIMITS));
+ assertTrue(lore(board.getInventory(), CampBoard.SLOT_INFO).contains("not finished"));
+ assertFalse(lore(board.getInventory(), CampBoard.SLOT_INFO).contains("Work day:"));
+ assertTrue(lore(board.getInventory(), CampBoard.SLOT_LIMITS).contains("Coordinates:"));
+ site.getFinds().clear(); site.setWorldName("missing"); board.open(worker, site);
+ assertTrue(lore(board.getInventory(), CampBoard.SLOT_INFO).contains("No finds were generated"));
+ assertTrue(lore(board.getInventory(), CampBoard.SLOT_LIMITS).contains("unknown"));
+ site.setStatus(SiteStatus.EXHAUSTED); board.open(worker, site);
+ assertTrue(lore(board.getInventory(), CampBoard.SLOT_INFO).contains("field work is over"));
+ }
+
+ @Test
+ public void staffRosterDeduplicatesDirectorAndReflectsCapacityAndViewerRights() {
+ site.getExcavators().add(director.getUniqueId());
+ CampStaffBoard board = new CampStaffBoard(site.getId(), true, 2); board.open(director, site);
+ assertEquals(director.getUniqueId(), board.playerAt(0)); assertEquals(worker.getUniqueId(), board.playerAt(1));
+ assertNull(board.playerAt(-1)); assertNull(board.playerAt(2)); assertNull(board.playerAt(18));
+ assertTrue(board.staffFull()); assertEquals("Staff is full", name(board.getInventory(), CampStaffBoard.SLOT_ADD));
+ site.getExcavators().remove(worker.getUniqueId()); board.open(director, site);
+ assertFalse(board.staffFull()); assertEquals("Add worker", name(board.getInventory(), CampStaffBoard.SLOT_ADD));
+ CampStaffBoard viewer = new CampStaffBoard(site.getId(), false, 2); viewer.open(worker, site);
+ assertNull(viewer.getInventory().getItem(CampStaffBoard.SLOT_ADD));
+ assertEquals("Director", name(viewer.getInventory(), 0));
+ assertEquals("Back", name(viewer.getInventory(), CampStaffBoard.SLOT_BACK));
+ }
+
+ @Test
+ public void workerPageExposesTalliesAndPreventsDirectorDismissal() {
+ var record = site.worker(worker.getUniqueId());
+ record.addBlocksRemoved(4); record.noteCellBrushed(); record.noteFindRecovered(); record.noteFindDamaged();
+ record.setJoinedAt(Instant.now().minusSeconds(3 * 86400)); record.setLastActiveAt(Instant.now().minusSeconds(3 * 3600));
+ CampWorkerBoard board = new CampWorkerBoard(site.getId(), worker.getUniqueId(), true); board.open(director, site);
+ assertTrue(lore(board.getInventory(), CampWorkerBoard.SLOT_WORK).contains("4 blocks"));
+ assertTrue(lore(board.getInventory(), CampWorkerBoard.SLOT_FINDS).contains("odd chip"));
+ assertTrue(lore(board.getInventory(), CampWorkerBoard.SLOT_HEAD).contains("3 days ago"));
+ assertEquals(Material.IRON_DOOR, board.getInventory().getItem(CampWorkerBoard.SLOT_REMOVE).getType());
+ int choices = 0;
+ for (int slot = 0; slot < 27; slot++) {
+ SiteRole role = CampWorkerBoard.roleAt(slot);
+ if (role != null) {
+ choices++; assertEquals(role.displayName(), name(board.getInventory(), slot));
+ assertTrue(board.getInventory().getItem(slot).getItemMeta().getEnchantmentGlintOverride());
+ }
+ }
+ assertEquals(SiteRole.assignable().size(), choices);
+ CampWorkerBoard lead = new CampWorkerBoard(site.getId(), director.getUniqueId(), true); lead.open(director, site);
+ assertEquals(Material.GRAY_STAINED_GLASS_PANE, lead.getInventory().getItem(CampWorkerBoard.SLOT_REMOVE).getType());
+ assertTrue(lore(lead.getInventory(), CampWorkerBoard.SLOT_ROLE).contains("cannot be reassigned"));
+ CampWorkerBoard viewer = new CampWorkerBoard(site.getId(), worker.getUniqueId(), false); viewer.open(worker, site);
+ assertTrue(lore(viewer.getInventory(), CampWorkerBoard.SLOT_ROLE).contains("Only the director"));
+ }
+
+ @Test
+ public void findsRegisterIncludesSettledFindsButNotBuriedOnesAndRestrictsReportIssuing() {
+ BuriedFind buried = find(FindState.HIDDEN, "I");
+ BuriedFind recoveredFind = find(FindState.RECOVERED, "I"); recoveredFind.setFindNumber(1);
+ BuriedFind lost = find(FindState.LOST, "II"); lost.setGivenName("Broken vessel"); lost.setFindNumber(2);
+ CampFindsBoard board = new CampFindsBoard(site.getId(), true, catalogs, recovered); board.open(director, site);
+ assertEquals(recoveredFind.getId(), board.findAt(0)); assertEquals(lost.getId(), board.findAt(1));
+ assertNull(board.findAt(-1)); assertNull(board.findAt(2)); assertNull(board.findAt(18));
+ assertEquals("Broken vessel", name(board.getInventory(), 1));
+ assertTrue(lore(board.getInventory(), 0).contains("#River camp-1"));
+ assertNull(board.getInventory().getItem(CampFindsBoard.SLOT_REPORT));
+ site.setStatus(SiteStatus.EXHAUSTED); board.open(director, site);
+ assertEquals("Issue report", name(board.getInventory(), CampFindsBoard.SLOT_REPORT));
+ CampFindsBoard viewer = new CampFindsBoard(site.getId(), false, catalogs, recovered); viewer.open(worker, site);
+ assertNull(viewer.getInventory().getItem(CampFindsBoard.SLOT_REPORT));
+ assertEquals(FindState.HIDDEN, buried.getState());
+ }
+
+ @Test
+ public void woolPaletteMapsClicksToColorsAndHighlightsCurrentRoleSelection() {
+ site.setCampWoolPrimary("BLUE"); site.setCampWoolSecondary("YELLOW");
+ for (CampWoolRole role : CampWoolRole.values()) {
+ CampWoolPicker board = new CampWoolPicker(site.getId(), role); board.open(director, site);
+ DyeColor current = role == CampWoolRole.PRIMARY ? DyeColor.BLUE : DyeColor.YELLOW;
+ for (int slot = 0; slot < DyeColor.values().length; slot++) {
+ DyeColor color = CampWoolPicker.colorAt(slot);
+ assertEquals(Material.valueOf(color.name() + "_WOOL"), board.getInventory().getItem(slot).getType());
+ assertEquals(color == current, lore(board.getInventory(), slot).contains("Current colour"));
+ }
+ assertEquals("Back", name(board.getInventory(), CampWoolPicker.SLOT_BACK));
+ }
+ assertNull(CampWoolPicker.colorAt(-1)); assertNull(CampWoolPicker.colorAt(16));
+ }
+
+ @Test
+ public void finishedCutsDropTheCompletionWarningsAndUnlimitedWorkDaysSaySo() {
+ find(FindState.RECOVERED, "I"); find(FindState.LOST, "I");
+ var d = PickSettings.defaults();
+ when(catalogs.pick()).thenReturn(new PickSettings(d.enabled(), 0, d.visualCues(), d.findDust(), d.findDustIntervalTicks(),
+ d.findDustCount(), d.conservation(), d.limits(), d.neighborTraces(), d.cues(), d.profiles()));
+ CampBoard board = new CampBoard(site.getId(), true, true, catalogs); board.open(director, site);
+ assertTrue(lore(board.getInventory(), CampBoard.SLOT_INFO).contains("Work day: unlimited"));
+ assertFalse(lore(board.getInventory(), CampBoard.SLOT_CLOSE).contains("% complete"));
+ site.getFinds().clear(); board.open(director, site); // A ruin generated without finds has nothing to leave behind.
+ assertFalse(lore(board.getInventory(), CampBoard.SLOT_CLOSE).contains("% complete"));
+ find(FindState.RECOVERED, "I"); site.setStatus(SiteStatus.CLOSED);
+ CampBoard archive = new CampBoard(site.getId(), false, false, catalogs); archive.open(worker, site);
+ assertTrue(lore(archive.getInventory(), CampBoard.SLOT_INFO).contains("1/1 finds settled."));
+ assertFalse(lore(archive.getInventory(), CampBoard.SLOT_INFO).contains("not finished"));
+ }
+
+ @Test
+ public void dossierShowsTheConfiguredInterestNameAndOmitsInterestForUnratedRuins() {
+ when(catalogs.interest(InterestLevel.LOW)).thenReturn(new InterestSettings(InterestLevel.LOW, "Modest scatter", 1, 0, 1, 2, 0, 0, 1, 0, 0));
+ CampBoard board = new CampBoard(site.getId(), false, false, catalogs); board.open(worker, site);
+ assertTrue(lore(board.getInventory(), CampBoard.SLOT_INFORMATION).contains("Approximate interest: Modest scatter"));
+ site.setInterest(null); board.open(worker, site);
+ String dossier = lore(board.getInventory(), CampBoard.SLOT_INFORMATION);
+ assertFalse(dossier.contains("interest")); assertTrue(dossier.contains("No field notes"));
+ }
+
+ @Test
+ public void strataWithoutCatalogNamesAndUnassignedFindsAreCountedHonestly() {
+ StratumBand band = new StratumBand(); band.setId("III"); band.setPresent(true); site.getStrata().put("III", band);
+ StratumBand retired = new StratumBand(); retired.setId("V"); retired.setPresent(true); site.getStrata().put("V", retired);
+ when(catalogs.stratum("III")).thenReturn(new StratumDefinition("III", 3, " ", 1, 3, true));
+ find(FindState.HIDDEN, "III"); find(FindState.RECOVERED, "III");
+ // A hand-edited dossier without `stratum:` loads as the text "null"; another names a band this site never had.
+ find(FindState.RECOVERED, "null"); find(FindState.HIDDEN, "IX");
+ site.getHintIds().add("blank"); when(catalogs.hint("blank")).thenReturn(new HintTemplate("blank", " ", 1,
+ Set.of(), Set.of(), Set.of(), Set.of(), null, null, null, null, null));
+ CampBoard board = new CampBoard(site.getId(), false, false, catalogs); board.open(worker, site);
+ String info = lore(board.getInventory(), CampBoard.SLOT_INFO);
+ // One row per surveyed band, named by id when the catalogue has no name (V was removed from it).
+ assertEquals(info, List.of("III: 1 recovered · 2 total · 0 lost", "V: 0 recovered · 0 total · 0 lost"),
+ info.lines().filter(line -> line.contains(" recovered · ")).toList());
+ assertTrue(info, info.contains("Progress: █████░░░░░ 2/4")); // Stray finds still count towards the cut.
+ assertFalse(info, info.contains("null")); assertFalse(info, info.contains("IX"));
+ assertEquals(List.of("Field notes:"), lore(board.getInventory(), CampBoard.SLOT_INFORMATION).lines()
+ .filter(line -> line.startsWith("Field") || line.startsWith("-")).toList());
+ }
+
+ @Test
+ public void closedCampLocationNamesDatapackBiomesAndFallsBackWhenTheBiomeCannotBeRead() {
+ site.setStatus(SiteStatus.CLOSED);
+ org.mockbukkit.mockbukkit.world.WorldMock terrace = spy(new org.mockbukkit.mockbukkit.world.WorldMock());
+ terrace.setName("terrace"); MockBukkit.getMock().addWorld(terrace); site.setWorldName("terrace");
+ Biome custom = mock(Biome.class); when(custom.getKey()).thenReturn(new NamespacedKey("terralith", "_moonlight__grove"));
+ doReturn(custom).when(terrace).getBiome(anyInt(), anyInt(), anyInt());
+ CampBoard board = new CampBoard(site.getId(), false, false, catalogs); board.open(worker, site);
+ assertTrue(lore(board.getInventory(), CampBoard.SLOT_LIMITS).contains("Biome: Moonlight Grove"));
+ // Spigot's RegistryAware adds getKeyOrNull (paper-api 1.21.10 has no such method); an unregistered biome returns null there and getKey throws.
+ SpigotBiome unregistered = mock(SpigotBiome.class); when(unregistered.getKey()).thenThrow(new IllegalStateException("unregistered"));
+ doReturn(unregistered).when(terrace).getBiome(anyInt(), anyInt(), anyInt()); board.open(worker, site);
+ assertTrue(lore(board.getInventory(), CampBoard.SLOT_LIMITS).contains("Biome: unknown"));
+ SpigotBiome registered = mock(SpigotBiome.class); when(registered.getKeyOrNull()).thenReturn(NamespacedKey.minecraft("cherry_grove"));
+ doReturn(registered).when(terrace).getBiome(anyInt(), anyInt(), anyInt()); board.open(worker, site);
+ assertTrue(lore(board.getInventory(), CampBoard.SLOT_LIMITS).contains("Biome: Cherry Grove"));
+ site.setWorldName(null); board.open(worker, site); // A dossier saved without its world key.
+ assertTrue(lore(board.getInventory(), CampBoard.SLOT_LIMITS).contains("Biome: unknown"));
+ }
+
+ /** Shape of {@link Biome} on Spigot, where it is {@code RegistryAware}; paper-api 1.21.10 lacks {@code getKeyOrNull}. */
+ public interface SpigotBiome extends Biome { NamespacedKey getKeyOrNull(); }
+
+ @Test
+ public void oversizedRostersAndRegistersFillOnlyTheListRowsAndKeepTheActionRowClickable() {
+ for (int n = 0; n < 20; n++) site.getExcavators().add(UUID.randomUUID()); // A roster filed before the cap was lowered.
+ CampStaffBoard staff = new CampStaffBoard(site.getId(), true, 18); staff.open(director, site);
+ assertNotNull(staff.playerAt(17)); assertNull(staff.playerAt(CampStaffBoard.SLOT_ADD));
+ assertEquals("Staff is full", name(staff.getInventory(), CampStaffBoard.SLOT_ADD));
+ for (int n = 1; n <= 20; n++) find(FindState.RECOVERED, "I").setFindNumber(n);
+ site.setStatus(SiteStatus.EXHAUSTED);
+ CampFindsBoard register = new CampFindsBoard(site.getId(), true, catalogs, recovered); register.open(director, site);
+ assertNotNull(register.findAt(17)); assertNull(register.findAt(CampFindsBoard.SLOT_REPORT));
+ assertEquals("Issue report", name(register.getInventory(), CampFindsBoard.SLOT_REPORT));
+ }
+
+ @Test
+ public void registerRowsNameTheCatalogPieceAndColourTheirProgress() {
+ ArtifactTemplate pot = new ArtifactTemplate("pot", "Clay vessel", 1, 1, "ceramic", "", false, 1,
+ Set.of(), Set.of(), FindProfile.OBJECT, List.of(), "");
+ when(catalogs.artifact("pot")).thenReturn(pot);
+ BuriedFind fresh = find(FindState.RECOVERED, "I"); fresh.setFindNumber(1);
+ BuriedFind cleaned = find(FindState.RECOVERED, "I"); cleaned.setFindNumber(2); cleaned.setLabCleaned(true);
+ BuriedFind drawn = find(FindState.RECOVERED, "I"); drawn.setFindNumber(3); drawn.setFieldSketch(true);
+ BuriedFind studied = find(FindState.RECOVERED, "I"); studied.setFindNumber(4); studied.setStudied(true);
+ BuriedFind filed = find(FindState.RECOVERED, "I"); filed.setFindNumber(5); filed.setLabCleaned(true); filed.setFieldSketch(true);
+ filed.addInterpretation(new FindInterpretation("purpose", "vessel", director.getUniqueId(), Instant.now()));
+ CampFindsBoard board = new CampFindsBoard(site.getId(), false, catalogs, recovered); board.open(worker, site);
+ assertEquals("Clay vessel", name(board.getInventory(), 0));
+ List expected = List.of(ChatColor.GRAY, ChatColor.AQUA, ChatColor.AQUA, ChatColor.AQUA, ChatColor.GOLD);
+ for (int slot = 0; slot < expected.size(); slot++)
+ assertTrue(slot + "", board.getInventory().getItem(slot).getItemMeta().getLore().get(2).startsWith(expected.get(slot).toString()));
+ }
+
+ @Test
+ public void workerFilesReadHonestlyForLegacyStaffWithoutATallyAndForEachKindOfHands() {
+ UUID legacy = UUID.randomUUID(); site.getExcavators().add(legacy); // Loaded from a dossier older than staff files.
+ CampWorkerBoard file = new CampWorkerBoard(site.getId(), legacy, false); file.open(director, site);
+ String head = lore(file.getInventory(), CampWorkerBoard.SLOT_HEAD);
+ assertTrue(head.contains("On the staff since: —")); assertTrue(head.contains("Last worked: never"));
+ assertTrue(lore(file.getInventory(), CampWorkerBoard.SLOT_WORK).contains("Has not worked the cut yet."));
+ assertTrue(lore(file.getInventory(), CampWorkerBoard.SLOT_FINDS).contains("No piece has passed through their hands."));
+ assertNull(site.workerRecord(legacy)); // Reading the file must not create a page in the dossier.
+ List verdicts = List.of("Has not hurt a single piece.", "Breaks more than they bring out.", "Has taken pieces past saving.");
+ for (int i = 0; i < verdicts.size(); i++) {
+ UUID hand = UUID.randomUUID(); site.getExcavators().add(hand); var tally = site.worker(hand);
+ if (i == 0) tally.noteFindRecovered(); else if (i == 1) tally.noteFindDamaged(); else tally.noteFindLost();
+ file = new CampWorkerBoard(site.getId(), hand, true); file.open(director, site);
+ assertTrue(lore(file.getInventory(), CampWorkerBoard.SLOT_FINDS).contains(verdicts.get(i)));
+ }
+ var record = site.worker(worker.getUniqueId()); record.setJoinedAt(null); record.noteCellBrushed();
+ file = new CampWorkerBoard(site.getId(), worker.getUniqueId(), true);
+ record.setLastActiveAt(Instant.now().minusSeconds(300)); file.open(director, site);
+ assertFalse(lore(file.getInventory(), CampWorkerBoard.SLOT_WORK).contains("Has not worked")); // Brushing counts as work.
+ head = lore(file.getInventory(), CampWorkerBoard.SLOT_HEAD);
+ assertTrue(head.contains("On the staff since: —")); assertTrue(head.contains("Last worked: 5 minutes ago"));
+ }
+
+ private BuriedFind find(FindState state, String stratum) {
+ BuriedFind find = new BuriedFind(); find.setId(UUID.randomUUID()); find.setArtifactId("pot"); find.setStratumId(stratum);
+ find.setState(state); site.getFinds().add(find); return find;
+ }
+ private static String name(Inventory inventory, int slot) { return ChatColor.stripColor(inventory.getItem(slot).getItemMeta().getDisplayName()); }
+ private static String lore(Inventory inventory, int slot) { return ChatColor.stripColor(String.join("\n", inventory.getItem(slot).getItemMeta().getLore())); }
+}
diff --git a/src/test/java/net/tfminecraft/archaeo/establish/CampClosureTest.java b/src/test/java/net/tfminecraft/archaeo/establish/CampClosureTest.java
new file mode 100644
index 0000000..2214fe7
--- /dev/null
+++ b/src/test/java/net/tfminecraft/archaeo/establish/CampClosureTest.java
@@ -0,0 +1,142 @@
+package net.tfminecraft.archaeo.establish;
+
+import net.tfminecraft.archaeo.model.*;
+import net.tfminecraft.archaeo.site.SiteClosure;
+import net.tfminecraft.archaeo.site.SiteRepository;
+import org.bukkit.Location;
+import org.bukkit.Material;
+import org.bukkit.entity.Item;
+import org.bukkit.inventory.ItemStack;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.mockbukkit.mockbukkit.MockBukkit;
+import org.mockbukkit.mockbukkit.ServerMock;
+import org.mockbukkit.mockbukkit.entity.PlayerMock;
+import org.mockbukkit.mockbukkit.world.WorldMock;
+
+import java.util.UUID;
+
+import static org.junit.Assert.*;
+import static org.mockito.Mockito.*;
+
+public class CampClosureTest {
+ private ServerMock server;
+ private WorldMock world;
+ private PlayerMock director;
+ private SiteRepository sites;
+ private CampClosure closure;
+ private Site site;
+
+ @Before public void setUp() {
+ server = MockBukkit.mock();
+ world = server.addSimpleWorld("world");
+ director = server.addPlayer("Director");
+ var plugin = MockBukkit.createMockPlugin();
+ sites = new SiteRepository(plugin);
+ sites.loadAll();
+ closure = new CampClosure(plugin, sites);
+ site = new Site();
+ site.setId(UUID.randomUUID()); site.setSerial(1); site.setName("River camp");
+ site.setCreatedAt(java.time.Instant.parse("2026-01-01T00:00:00Z")); site.setWorldName("world"); site.setInterest(InterestLevel.LOW);
+ site.establish(director.getUniqueId(), 1, 0, 20, 4, 4);
+ sites.save(site);
+ }
+ @After public void tearDown() { MockBukkit.unmock(); }
+
+ @Test
+ public void closingCampPersistsStatusAndGivesOneLiveArchive() {
+ assertFalse(closure.close(null, site));
+ assertFalse(closure.close(director, null));
+ assertTrue(closure.close(director, site));
+ assertEquals(SiteStatus.CLOSED, site.getStatus());
+ assertFalse(site.isCampLocked());
+ ItemStack book = director.getInventory().getItem(0);
+ assertTrue(closure.archiveBook().isArchive(book));
+ assertEquals(site.getId(), closure.archiveBook().siteIdOf(book));
+ assertTrue(director.nextMessage().contains("Closed River camp"));
+ assertFalse(closure.close(director, site));
+ assertEquals(1, book.getAmount());
+ sites.loadAll();
+ assertEquals(SiteStatus.CLOSED, sites.findById(site.getId()).orElseThrow().getStatus());
+ }
+
+ @Test
+ public void unfinishedClosureNotifiesDirectorAndDropsOverflowInsteadOfLosingBook() {
+ BuriedFind find = new BuriedFind(); find.setId(UUID.randomUUID());
+ find.getCells().add(new BlockCell(0, 1, 0)); site.getFinds().add(find);
+ PlayerMock staff = server.addPlayer("Staff");
+ for (int slot = 0; slot < 36; slot++) staff.getInventory().setItem(slot, new ItemStack(Material.STONE, 64));
+ // MockBukkit also scans equipment when adding items; use a valid fully equipped inventory.
+ staff.getInventory().setHelmet(new ItemStack(Material.IRON_HELMET));
+ staff.getInventory().setChestplate(new ItemStack(Material.IRON_CHESTPLATE));
+ staff.getInventory().setLeggings(new ItemStack(Material.IRON_LEGGINGS));
+ staff.getInventory().setBoots(new ItemStack(Material.IRON_BOOTS));
+ staff.getInventory().setItemInOffHand(new ItemStack(Material.TORCH, 64));
+ PlayerMock receiver = spy(staff);
+ var fullStorage = spy(staff.getInventory());
+ doReturn(fullStorage).when(receiver).getInventory();
+ // Bukkit addItem uses storage only; MockBukkit also searches two phantom player slots.
+ doAnswer(call -> {
+ var overflow = new java.util.HashMap();
+ overflow.put(0, call.getArgument(0)); return overflow;
+ }).when(fullStorage).addItem(any(ItemStack.class));
+ assertTrue(closure.close(receiver, site));
+ assertTrue(staff.nextMessage().contains("0% complete"));
+ assertTrue(director.nextMessage().contains("was closed"));
+ assertTrue(staff.getWorld().getEntities().stream().filter(Item.class::isInstance).map(Item.class::cast)
+ .anyMatch(item -> closure.archiveBook().isArchive(item.getItemStack())));
+ }
+
+ @Test
+ public void consoleDropsArchiveAtCampAndMissingWorldStillClosesDossier() {
+ assertTrue(closure.close(server.getConsoleSender(), site));
+ Item drop = world.getEntities().stream().filter(Item.class::isInstance).map(Item.class::cast).findFirst().orElseThrow();
+ assertEquals(site.getId(), closure.archiveBook().siteIdOf(drop.getItemStack()));
+ assertTrue(drop.getLocation().distance(new Location(world, 20.5, 5, 4.5)) < 2);
+ Site missing = new Site();
+ missing.setCreatedAt(java.time.Instant.parse("2026-01-01T00:00:00Z")); missing.setId(UUID.randomUUID()); missing.setWorldName("missing"); missing.setInterest(InterestLevel.LOW);
+ missing.establish(UUID.randomUUID(), 1, 0, 20, 4, 4);
+ assertTrue(closure.close(server.getConsoleSender(), missing));
+ assertEquals(SiteStatus.CLOSED, missing.getStatus());
+ }
+
+ @Test
+ public void consoleClosingALegacyCampWithoutAnOriginStillClosesItAndSaysNoBookWasDropped() {
+ site.setCampX(null); site.setDirector(null); // A dossier saved before camp origins and directors were recorded.
+ var console = spy(server.getConsoleSender());
+ assertTrue(closure.close(console, site)); assertEquals(SiteStatus.CLOSED, site.getStatus());
+ verify(console).sendMessage("No field book could be given from console.");
+ assertTrue(world.getEntities().stream().noneMatch(Item.class::isInstance)); assertNull(director.nextMessage());
+ sites.loadAll(); assertEquals(SiteStatus.CLOSED, sites.findById(site.getId()).orElseThrow().getStatus());
+ }
+
+ @Test
+ public void lastFindExhaustsSiteOnceAndNotifiesNearbyPlayersAndRemoteRosterOnce() {
+ BuriedFind find = new BuriedFind(); find.setId(UUID.randomUUID()); site.getFinds().add(find);
+ PlayerMock nearby = server.addPlayer("Nearby");
+ PlayerMock distant = server.addPlayer("Distant");
+ PlayerMock worker = server.addPlayer("Worker");
+ director.teleport(new Location(world, 4, 5, 4));
+ nearby.teleport(new Location(world, 20, 5, 4));
+ distant.teleport(new Location(world, 100, 5, 100));
+ worker.teleport(new Location(world, 100, 5, 100));
+ site.getExcavators().add(director.getUniqueId());
+ site.getExcavators().add(worker.getUniqueId());
+ site.getExcavators().add(UUID.randomUUID());
+ assertFalse(SiteClosure.settle(sites, null));
+ assertFalse(SiteClosure.settle(sites, site));
+ find.setState(FindState.RECOVERED);
+ assertTrue(SiteClosure.settle(sites, site));
+ assertEquals(SiteStatus.EXHAUSTED, site.getStatus());
+ for (PlayerMock recipient : new PlayerMock[]{director, nearby, worker}) {
+ assertTrue(recipient.nextMessage().contains("is exhausted"));
+ assertNull(recipient.nextMessage());
+ }
+ assertNull(distant.nextMessage());
+ assertFalse(SiteClosure.settle(sites, site));
+ assertNull(director.nextMessage());
+ sites.loadAll();
+ assertEquals(SiteStatus.EXHAUSTED, sites.findById(site.getId()).orElseThrow().getStatus());
+ }
+}
diff --git a/src/test/java/net/tfminecraft/archaeo/establish/CampFindBoardTest.java b/src/test/java/net/tfminecraft/archaeo/establish/CampFindBoardTest.java
new file mode 100644
index 0000000..4572b75
--- /dev/null
+++ b/src/test/java/net/tfminecraft/archaeo/establish/CampFindBoardTest.java
@@ -0,0 +1,110 @@
+package net.tfminecraft.archaeo.establish;
+
+import net.tfminecraft.archaeo.config.*;
+import net.tfminecraft.archaeo.model.*;
+import org.bukkit.ChatColor;
+import org.bukkit.Material;
+import org.bukkit.inventory.Inventory;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.mockbukkit.mockbukkit.MockBukkit;
+import org.mockbukkit.mockbukkit.entity.PlayerMock;
+
+import java.time.Instant;
+import java.util.List;
+import java.util.Set;
+import java.util.UUID;
+
+import static org.junit.Assert.*;
+import static org.mockito.Mockito.*;
+
+public class CampFindBoardTest {
+ private PlayerMock viewer;
+ private Site site;
+ private BuriedFind find;
+ private CatalogRegistry catalogs;
+
+ @Before public void setUp() {
+ MockBukkit.mock(); viewer = MockBukkit.getMock().addPlayer("Curator");
+ catalogs = mock(CatalogRegistry.class);
+ when(catalogs.pick()).thenReturn(PickSettings.defaults());
+ when(catalogs.materialOf(any())).thenReturn(new FindMaterial("ceramic", "Ceramic", 1, null, List.of()));
+ when(catalogs.materialDisplayName("ceramic")).thenReturn("Ceramic");
+ ArtifactTemplate pot = new ArtifactTemplate("pot", "Clay vessel", 1, 2, "ceramic", "rare", false, 1,
+ Set.of(), Set.of(), FindProfile.OBJECT, List.of(), "");
+ when(catalogs.artifact("pot")).thenReturn(pot);
+ when(catalogs.rarityLoreLine(pot)).thenReturn("Rare");
+ site = new Site(); site.setId(UUID.randomUUID()); site.setName("River camp");
+ find = new BuriedFind(); find.setId(UUID.randomUUID()); find.setArtifactId("pot");
+ find.setStratumId("II"); find.setState(FindState.RECOVERED); find.setFindNumber(7);
+ find.setRecoveredBy(viewer.getUniqueId()); site.getFinds().add(find);
+ }
+ @After public void tearDown() { MockBukkit.unmock(); }
+
+ @Test public void archiveRevealsConditionThenRarityAsCabinetWorkProgresses() {
+ CampFindBoard board = new CampFindBoard(site.getId(), find.getId(), catalogs);
+ board.open(viewer, site);
+ assertSame(board, viewer.getOpenInventory().getTopInventory().getHolder());
+ assertEquals("#River camp-7", viewer.getOpenInventory().getTitle());
+ String identity = lore(board.getInventory(), CampFindBoard.SLOT_IDENTITY);
+ assertTrue(identity.contains("(1/3)")); assertFalse(identity.contains("Material:")); assertFalse(identity.contains("Rare"));
+ assertTrue(lore(board.getInventory(), CampFindBoard.SLOT_CONDITION).contains("Clean the piece"));
+ String origin = lore(board.getInventory(), CampFindBoard.SLOT_PROVENIENCE);
+ assertTrue(origin.contains("River camp")); assertTrue(origin.contains("II")); assertTrue(origin.contains("Curator"));
+ assertEquals(Material.WRITABLE_BOOK, board.getInventory().getItem(CampFindBoard.SLOT_READINGS).getType());
+ find.setLabCleaned(true); board.open(viewer, site);
+ identity = lore(board.getInventory(), CampFindBoard.SLOT_IDENTITY);
+ assertTrue(identity.contains("Material: Ceramic")); assertTrue(identity.contains("(2/3)")); assertFalse(identity.contains("Rare"));
+ assertTrue(lore(board.getInventory(), CampFindBoard.SLOT_CONDITION).contains("Conservation:"));
+ find.setFieldSketch(true); board.open(viewer, site);
+ assertTrue(lore(board.getInventory(), CampFindBoard.SLOT_IDENTITY).contains("Rare"));
+ }
+
+ @Test public void museumPlaqueShowsSignedReadingsAndClosesWithoutCabinetInstructions() {
+ find.setGivenName("River offering"); find.setLabCleaned(true); find.setFieldSketch(true);
+ find.addInterpretation(new FindInterpretation("function", "vessel", viewer.getUniqueId(), Instant.now()));
+ when(catalogs.interpretation("vessel")).thenReturn(new InterpretationTemplate("vessel", "function", "Storage vessel", Set.of(), Set.of(), Set.of()));
+ CampFindBoard board = new CampFindBoard(site.getId(), find.getId(), catalogs, true); board.open(viewer, site);
+ assertTrue(board.isMuseum());
+ assertEquals("River offering", name(board.getInventory(), CampFindBoard.SLOT_IDENTITY));
+ assertFalse(lore(board.getInventory(), CampFindBoard.SLOT_IDENTITY).contains("cabinet"));
+ assertEquals(Material.WRITTEN_BOOK, board.getInventory().getItem(CampFindBoard.SLOT_READINGS).getType());
+ assertTrue(lore(board.getInventory(), CampFindBoard.SLOT_READINGS).contains("Storage vessel"));
+ assertTrue(lore(board.getInventory(), CampFindBoard.SLOT_READINGS).contains("Curator"));
+ assertEquals("Close", name(board.getInventory(), CampFindBoard.SLOT_BACK));
+ }
+
+ @Test public void lostPiecesRetainSeparatePriorDisturbanceAndExcavationDamageEvidence() {
+ BlockCell cell = new BlockCell(1, 2, 3); BlockCell struck = new BlockCell(2, 2, 3);
+ find.getCells().addAll(List.of(cell, struck));
+ find.woundBeforeDig(cell); find.woundDirect(struck); find.setState(FindState.LOST);
+ CampFindBoard board = new CampFindBoard(site.getId(), find.getId(), catalogs); board.open(viewer, site);
+ String condition = lore(board.getInventory(), CampFindBoard.SLOT_CONDITION);
+ assertTrue(condition.contains("Conservation:")); assertTrue(condition.contains("Disturbed before the dig"));
+ assertTrue(condition.contains("Hurt while digging")); assertTrue(condition.contains("Nothing could be recovered"));
+ assertFalse(lore(board.getInventory(), CampFindBoard.SLOT_IDENTITY).contains("cabinet"));
+ }
+
+ @Test public void removedCatalogAndLongSiteNameStillProduceReadableArchiveAndDeletedFindClosesIt() {
+ when(catalogs.artifact("pot")).thenReturn(null); site.setName("Long excavation name ".repeat(4));
+ CampFindBoard board = new CampFindBoard(site.getId(), find.getId(), catalogs); board.open(viewer, site);
+ assertEquals(32, viewer.getOpenInventory().getTitle().length());
+ assertFalse(name(board.getInventory(), CampFindBoard.SLOT_IDENTITY).isBlank());
+ assertTrue(lore(board.getInventory(), CampFindBoard.SLOT_PROVENIENCE).contains(site.getName()));
+ site.getFinds().clear(); board.open(viewer, site);
+ assertNotSame(board.getInventory(), viewer.getOpenInventory().getTopInventory());
+ }
+
+ @Test public void sketchedPiecesOfARetiredCatalogEntryAndDestroyedPiecesShowOnlyWhatIsKnown() {
+ when(catalogs.artifact("pot")).thenReturn(null); find.setFieldSketch(true);
+ CampFindBoard board = new CampFindBoard(site.getId(), find.getId(), catalogs); board.open(viewer, site);
+ assertFalse(lore(board.getInventory(), CampFindBoard.SLOT_IDENTITY).contains("Rare")); verify(catalogs, never()).rarityLoreLine(any(ArtifactTemplate.class));
+ find.setConservation(0); find.setState(FindState.LOST); board.open(viewer, site);
+ String condition = lore(board.getInventory(), CampFindBoard.SLOT_CONDITION);
+ assertTrue(condition, condition.startsWith("Conservation: 0%\n")); assertFalse(condition.contains("·"));
+ }
+
+ private static String name(Inventory inv, int slot) { return ChatColor.stripColor(inv.getItem(slot).getItemMeta().getDisplayName()); }
+ private static String lore(Inventory inv, int slot) { return ChatColor.stripColor(String.join("\n", inv.getItem(slot).getItemMeta().getLore())); }
+}
diff --git a/src/test/java/net/tfminecraft/archaeo/establish/CampIdentifyBoardTest.java b/src/test/java/net/tfminecraft/archaeo/establish/CampIdentifyBoardTest.java
new file mode 100644
index 0000000..e4ac8c3
--- /dev/null
+++ b/src/test/java/net/tfminecraft/archaeo/establish/CampIdentifyBoardTest.java
@@ -0,0 +1,105 @@
+package net.tfminecraft.archaeo.establish;
+
+import net.tfminecraft.archaeo.config.*;
+import net.tfminecraft.archaeo.item.RecoveredFindItem;
+import net.tfminecraft.archaeo.model.*;
+import org.bukkit.ChatColor;
+import org.bukkit.Material;
+import org.bukkit.event.inventory.InventoryType;
+import org.bukkit.inventory.ItemStack;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.mockbukkit.mockbukkit.MockBukkit;
+import org.mockbukkit.mockbukkit.entity.PlayerMock;
+
+import java.util.List;
+import java.util.Set;
+import java.util.UUID;
+
+import static org.junit.Assert.*;
+import static org.mockito.Mockito.*;
+
+public class CampIdentifyBoardTest {
+ private PlayerMock player;
+ private Site site;
+ private BuriedFind find;
+ private CatalogRegistry catalogs;
+ private RecoveredFindItem recovered;
+ private InterpretationType question;
+
+ @Before public void setUp() {
+ MockBukkit.mock(); player = MockBukkit.getMock().addPlayer();
+ recovered = new RecoveredFindItem(MockBukkit.createMockPlugin());
+ catalogs = mock(CatalogRegistry.class); when(catalogs.pick()).thenReturn(PickSettings.defaults());
+ ArtifactTemplate pot = new ArtifactTemplate("pot", "Clay vessel", 1, 1, "ceramic", "", false, 1,
+ Set.of(), Set.of("clay"), FindProfile.OBJECT, List.of(), "");
+ when(catalogs.artifact("pot")).thenReturn(pot);
+ when(catalogs.materialDisplayName("ceramic")).thenReturn("Ceramic");
+ when(catalogs.rarityLoreLine(pot)).thenReturn("Common");
+ site = new Site(); site.setId(UUID.randomUUID()); site.setName("River camp");
+ find = new BuriedFind(); find.setId(UUID.randomUUID()); find.setArtifactId("pot"); find.setStratumId("I");
+ find.setState(FindState.RECOVERED); find.setLabCleaned(true); find.setFieldSketch(true); site.getFinds().add(find);
+ question = new InterpretationType("function", "Function", "What purpose did this ancient object serve?", List.of());
+ when(catalogs.nextOpenType(find)).thenReturn(question);
+ }
+ @After public void tearDown() { MockBukkit.unmock(); }
+
+ @Test public void stationCombinesEvidenceTagsAndMapsVisibleOffersWithoutTouchingRealItem() {
+ site.getHintIds().add("river"); site.getHintIds().add("removed");
+ when(catalogs.hint("river")).thenReturn(new HintTemplate("river", "River bank", 1, Set.of("water"),
+ Set.of(), Set.of(), Set.of(), null, null, null, null, null));
+ List offers = List.of(option("vessel", "Storage vessel"), option("ritual", "Ritual offering"), option("trade", "Trade ware"));
+ when(catalogs.stationOffers("function", find.getId(), "pot", Set.of("clay", "water"))).thenReturn(offers);
+ ItemStack held = new ItemStack(Material.BRICK, 2); player.getInventory().setItemInMainHand(held);
+ CampIdentifyBoard board = new CampIdentifyBoard(site.getId(), find.getId(), catalogs, recovered, true);
+ assertTrue(board.open(player, site));
+ assertSame(board.getInventory(), player.getOpenInventory().getTopInventory());
+ assertEquals(InventoryType.BREWING, board.getInventory().getType());
+ assertEquals(question.question().substring(0, 32), player.getOpenInventory().getTitle());
+ assertEquals(question, board.type());
+ for (int i = 0; i < offers.size(); i++) {
+ assertEquals(offers.get(i).id(), board.offerAt(i));
+ assertEquals(offers.get(i).displayName(), ChatColor.stripColor(board.getInventory().getItem(i).getItemMeta().getDisplayName()));
+ }
+ assertNull(board.offerAt(CampIdentifyBoard.SLOT_PIECE)); assertNull(board.offerAt(CampIdentifyBoard.SLOT_BACK));
+ assertNull(recovered.findIdOf(board.getInventory().getItem(CampIdentifyBoard.SLOT_PIECE)));
+ assertTrue(String.join(" ", board.getInventory().getItem(CampIdentifyBoard.SLOT_PIECE).getItemMeta().getLore()).contains("real artifact stays"));
+ assertEquals(held, player.getInventory().getItemInMainHand()); assertTrue(find.getInterpretations().isEmpty());
+ }
+
+ @Test public void reopeningAfterCatalogReloadClearsOldOfferClickTargets() {
+ when(catalogs.stationOffers(anyString(), any(), anyString(), anySet())).thenReturn(List.of(option("a", "A"), option("b", "B"), option("c", "C")));
+ CampIdentifyBoard board = new CampIdentifyBoard(site.getId(), find.getId(), catalogs, recovered);
+ assertTrue(board.open(player, site)); assertEquals("c", board.offerAt(2));
+ when(catalogs.artifact("pot")).thenReturn(null);
+ when(catalogs.stationOffers(anyString(), any(), anyString(), anySet())).thenReturn(List.of(option("new", "New reading")));
+ assertTrue(board.open(player, site));
+ assertEquals("new", board.offerAt(0)); assertNull(board.offerAt(1)); assertNull(board.offerAt(2));
+ assertNull(board.getInventory().getItem(1)); assertNull(board.getInventory().getItem(2));
+ verify(catalogs).stationOffers("function", find.getId(), "pot", Set.of());
+ }
+
+ @Test public void completedRecordReturnsToArchiveOrClosesCabinet() {
+ when(catalogs.nextOpenType(find)).thenReturn(null);
+ CampIdentifyBoard archive = new CampIdentifyBoard(site.getId(), find.getId(), catalogs, recovered);
+ assertFalse(archive.open(player, site));
+ assertTrue(player.getOpenInventory().getTopInventory().getHolder() instanceof CampFindBoard);
+ assertTrue(ChatColor.stripColor(player.nextMessage()).contains("record on this piece is complete"));
+ var archiveInventory = player.getOpenInventory().getTopInventory();
+ CampIdentifyBoard cabinet = new CampIdentifyBoard(site.getId(), find.getId(), catalogs, recovered, true);
+ assertFalse(cabinet.open(player, site));
+ assertNotSame(archiveInventory, player.getOpenInventory().getTopInventory());
+ }
+
+ @Test public void deletedFindClosesExistingStation() {
+ when(catalogs.stationOffers(anyString(), any(), anyString(), anySet())).thenReturn(List.of(option("a", "A")));
+ CampIdentifyBoard board = new CampIdentifyBoard(site.getId(), find.getId(), catalogs, recovered);
+ assertTrue(board.open(player, site)); site.getFinds().clear(); assertFalse(board.open(player, site));
+ assertNotSame(board.getInventory(), player.getOpenInventory().getTopInventory());
+ }
+
+ private static InterpretationTemplate option(String id, String label) {
+ return new InterpretationTemplate(id, "function", label, Set.of(), Set.of(), Set.of());
+ }
+}
diff --git a/src/test/java/net/tfminecraft/archaeo/establish/CampListenerTest.java b/src/test/java/net/tfminecraft/archaeo/establish/CampListenerTest.java
new file mode 100644
index 0000000..9ef756b
--- /dev/null
+++ b/src/test/java/net/tfminecraft/archaeo/establish/CampListenerTest.java
@@ -0,0 +1,737 @@
+package net.tfminecraft.archaeo.establish;
+
+import net.tfminecraft.archaeo.config.*;
+import net.tfminecraft.archaeo.excavation.HandPickService;
+import net.tfminecraft.archaeo.excavation.PrismOutlineService;
+import net.tfminecraft.archaeo.item.*;
+import net.tfminecraft.archaeo.model.*;
+import net.tfminecraft.archaeo.site.SiteRepository;
+import org.bukkit.*;
+import org.bukkit.block.Block;
+import org.bukkit.block.BlockFace;
+import org.bukkit.entity.Item;
+import org.bukkit.entity.Entity;
+import org.bukkit.event.entity.EntityChangeBlockEvent;
+import org.bukkit.event.entity.EntityDamageByEntityEvent;
+import org.bukkit.util.Vector;
+import org.bukkit.event.Event;
+import org.bukkit.event.block.*;
+import org.bukkit.event.entity.EntityExplodeEvent;
+import org.bukkit.event.inventory.*;
+import org.bukkit.event.player.*;
+import org.bukkit.inventory.*;
+import org.bukkit.plugin.java.JavaPlugin;
+import org.junit.*;
+import org.mockbukkit.mockbukkit.MockBukkit;
+import org.mockbukkit.mockbukkit.ServerMock;
+import org.mockbukkit.mockbukkit.entity.PlayerMock;
+
+import java.time.Instant;
+import java.util.*;
+
+import static org.junit.Assert.*;
+import static org.mockito.Mockito.*;
+
+public class CampListenerTest {
+ private ServerMock server;
+ private JavaPlugin plugin;
+ private PlayerMock director, worker;
+ private Site site;
+ private Block camp, outside;
+ private SiteRepository sites;
+ private CatalogRegistry catalogs;
+ private EstablishService establish;
+ private HandPickService handPick;
+ private PrismOutlineService outline;
+ private RecoveredFindItem recovered;
+ private CampClosure closure;
+ private CampArchiveBook archive;
+ private CampListener listener;
+
+ @Before public void setup() {
+ server = MockBukkit.mock(); plugin = MockBukkit.createMockPlugin();
+ director = server.addPlayer("Director"); worker = server.addPlayer("Worker");
+ site = new Site(); site.setId(UUID.randomUUID()); site.setName("River camp");
+ site.setWorldName(director.getWorld().getName()); site.setCreatedAt(Instant.EPOCH); site.setInterest(InterestLevel.LOW);
+ site.establish(director.getUniqueId(), 1, 0, 20, 5, 4);
+ camp = director.getWorld().getBlockAt(20, 5, 4); camp.setType(Material.CAMPFIRE);
+ outside = director.getWorld().getBlockAt(21, 5, 4); outside.setType(Material.STONE);
+ sites = mock(SiteRepository.class); catalogs = mock(CatalogRegistry.class);
+ establish = mock(EstablishService.class); handPick = mock(HandPickService.class); outline = mock(PrismOutlineService.class);
+ recovered = mock(RecoveredFindItem.class); closure = mock(CampClosure.class); archive = new CampArchiveBook(plugin);
+ when(closure.archiveBook()).thenReturn(archive);
+ when(sites.findById(site.getId())).thenReturn(Optional.of(site));
+ when(sites.findLockedCampBlock(site.getWorldName(), 20, 5, 4)).thenAnswer(call -> site.isCampLocked() ? Optional.of(site) : Optional.empty());
+ when(catalogs.pick()).thenReturn(PickSettings.defaults()); when(catalogs.establish()).thenReturn(EstablishSettings.defaults());
+ when(catalogs.staffPermission()).thenReturn("archaeo.admin");
+ when(recovered.standIn(any(), any(), any(), any())).thenReturn(new ItemStack(Material.BRICK));
+ listener = new CampListener(plugin, sites, catalogs, new EstablishItem(ItemRef.vanilla(Material.STICK)),
+ establish, handPick, outline, new BrushItem(ItemRef.vanilla(Material.BRUSH)), recovered, closure);
+ }
+ @After public void teardown() { MockBukkit.unmock(); }
+
+ @Test public void campInteractionDeniesVanillaUseAndOpensOnlyOneMainHandBoard() {
+ PlayerInteractEvent off = interact(director, camp, EquipmentSlot.OFF_HAND, null);
+ listener.onInteract(off); assertEquals(Event.Result.DENY, off.useInteractedBlock());
+ assertNull(director.getOpenInventory().getTopInventory());
+ when(establish.isRelocating(director)).thenReturn(true);
+ PlayerInteractEvent event = interact(director, camp, EquipmentSlot.HAND, null);
+ listener.onInteract(event);
+ assertEquals(Event.Result.DENY, event.useItemInHand()); assertTrue(event.isCancelled());
+ assertTrue(director.getOpenInventory().getTopInventory().getHolder() instanceof CampBoard);
+ verify(establish).tryCancelMove(director); verify(handPick).ensureJornada(site, director.getWorld());
+ }
+
+ @Test public void campProtectionPreservesOnlyRegisteredBlocksDuringWorldEvents() {
+ BlockBreakEvent broken = new BlockBreakEvent(camp, worker); listener.onBreak(broken); assertTrue(broken.isCancelled());
+ BlockBreakEvent normal = new BlockBreakEvent(outside, worker); listener.onBreak(normal); assertFalse(normal.isCancelled());
+ BlockBurnEvent burn = new BlockBurnEvent(camp, outside); listener.onBurn(burn); assertTrue(burn.isCancelled());
+ BlockFadeEvent fade = new BlockFadeEvent(camp, camp.getState()); listener.onFade(fade); assertTrue(fade.isCancelled());
+ SignChangeEvent sign = new SignChangeEvent(camp, worker, new String[]{"changed", "", "", ""});
+ listener.onSignChange(sign); assertTrue(sign.isCancelled());
+ List blast = new ArrayList<>(List.of(camp, outside));
+ EntityExplodeEvent explosion = mock(EntityExplodeEvent.class); when(explosion.blockList()).thenReturn(blast);
+ listener.onEntityExplode(explosion); assertEquals(List.of(outside), blast);
+ BlockPistonExtendEvent extend = new BlockPistonExtendEvent(outside, List.of(camp), BlockFace.NORTH);
+ listener.onPistonExtend(extend); assertTrue(extend.isCancelled());
+ BlockPistonRetractEvent retract = new BlockPistonRetractEvent(outside, List.of(outside), BlockFace.NORTH);
+ listener.onPistonRetract(retract); assertFalse(retract.isCancelled());
+ site.setStatus(SiteStatus.CLOSED);
+ BlockBreakEvent closed = new BlockBreakEvent(camp, worker); listener.onBreak(closed); assertFalse(closed.isCancelled());
+ }
+
+ @Test public void boardClicksCannotMoveItemsAndAuthorizedActionsRouteToTheirServices() {
+ board(director, true, true); InventoryClickEvent bottom = click(director, 27);
+ listener.onBoardClick(bottom); assertTrue(bottom.isCancelled()); verifyNoInteractions(establish);
+ InventoryClickEvent rename = click(director, CampBoard.SLOT_RENAME); listener.onBoardClick(rename);
+ assertTrue(rename.isCancelled()); verify(establish).beginRename(director, site);
+ board(director, true, true); listener.onBoardClick(click(director, CampBoard.SLOT_MOVE));
+ verify(establish).beginRelocate(director, site);
+ board(director, true, true); listener.onBoardClick(click(director, CampBoard.SLOT_LIMITS)); verify(outline).show(director, site);
+ board(director, true, true); listener.onBoardClick(click(director, CampBoard.SLOT_WOOL_PRIMARY));
+ assertTrue(director.getOpenInventory().getTopInventory().getHolder() instanceof CampWoolPicker);
+ listener.onWoolPickerClick(click(director, DyeColor.BLUE.ordinal()));
+ verify(establish).applyCampWool(director, site, DyeColor.BLUE, CampWoolRole.PRIMARY);
+ assertTrue(director.getOpenInventory().getTopInventory().getHolder() instanceof CampBoard);
+ }
+
+ @Test public void formerDirectorCannotUseAnAlreadyOpenBoardToEditTheCamp() {
+ for (int slot : new int[]{CampBoard.SLOT_RENAME, CampBoard.SLOT_MOVE, CampBoard.SLOT_WOOL_PRIMARY, CampBoard.SLOT_WOOL_SECONDARY}) {
+ site.setDirector(director.getUniqueId()); board(director, true, true);
+ site.setDirector(worker.getUniqueId()); clearInvocations(establish);
+ listener.onBoardClick(click(director, slot));
+ verify(establish, never()).beginRename(any(), any()); verify(establish, never()).beginRelocate(any(), any());
+ assertTrue(director.getOpenInventory().getTopInventory() == null
+ || !(director.getOpenInventory().getTopInventory().getHolder() instanceof CampWoolPicker));
+ }
+ }
+
+ @Test public void closedCampCannotBeEditedThroughAnAlreadyOpenDirectorBoard() {
+ for (int slot : new int[]{CampBoard.SLOT_RENAME, CampBoard.SLOT_MOVE, CampBoard.SLOT_WOOL_PRIMARY, CampBoard.SLOT_WOOL_SECONDARY}) {
+ site.setStatus(SiteStatus.ESTABLISHED); board(director, true, true);
+ // Another authorised player can close the camp while this board stays open.
+ site.setStatus(SiteStatus.CLOSED); clearInvocations(establish);
+ listener.onBoardClick(click(director, slot));
+ verify(establish, never()).beginRename(any(), any()); verify(establish, never()).beginRelocate(any(), any());
+ assertTrue(director.getOpenInventory().getTopInventory() == null
+ || !(director.getOpenInventory().getTopInventory().getHolder() instanceof CampWoolPicker));
+ }
+ }
+
+ @Test public void viewersCanConsultButCannotRenameMoveOrChangeStaff() {
+ board(worker, false, false);
+ listener.onBoardClick(click(worker, CampBoard.SLOT_RENAME)); listener.onBoardClick(click(worker, CampBoard.SLOT_MOVE));
+ verifyNoInteractions(establish);
+ listener.onBoardClick(click(worker, CampBoard.SLOT_PERSONAL));
+ assertTrue(worker.getOpenInventory().getTopInventory().getHolder() instanceof CampStaffBoard);
+ listener.onStaffClick(click(worker, CampStaffBoard.SLOT_ADD));
+ AsyncPlayerChatEvent chat = chat(worker, "Director"); listener.onChat(chat); assertFalse(chat.isCancelled());
+ assertEquals(List.of(director.getUniqueId()), site.getExcavators());
+ board(worker, false, false); listener.onBoardClick(click(worker, CampBoard.SLOT_DOCUMENTATION));
+ assertTrue(worker.getOpenInventory().getTopInventory().getHolder() instanceof CampFindsBoard);
+ }
+
+ @Test public void invitesValidateKnownPlayersAndPersistOnlyAfterMainThreadConfirmation() {
+ invite(); AsyncPlayerChatEvent unknown = chat(director, "Nobody"); listener.onChat(unknown);
+ assertTrue(unknown.isCancelled()); server.getScheduler().performOneTick();
+ assertEquals(List.of(director.getUniqueId()), site.getExcavators()); assertTrue(messages(director).contains("No player with that name has joined this server."));
+ AsyncPlayerChatEvent accepted = chat(director, " wOrKeR "); listener.onChat(accepted);
+ assertTrue(accepted.isCancelled()); assertEquals(List.of(director.getUniqueId()), site.getExcavators());
+ server.getScheduler().performOneTick(); assertTrue(site.getExcavators().contains(worker.getUniqueId()));
+ verify(sites).save(site); assertEquals("You may now excavate River camp.", worker.nextMessage());
+ invite(); listener.onChat(chat(director, "cancel")); server.getScheduler().performOneTick();
+ assertTrue(messages(director).contains("Add worker cancelled."));
+ AsyncPlayerChatEvent ordinary = chat(director, "hello"); listener.onChat(ordinary); assertFalse(ordinary.isCancelled());
+ }
+
+ @Test public void pendingInvitesRecheckCapacityAndOwnershipAtSubmission() {
+ invite();
+ when(catalogs.establish()).thenReturn(new EstablishSettings(true, true, Material.RED_STAINED_GLASS,
+ Material.LIGHT_BLUE_STAINED_GLASS, 1, 1));
+ listener.onChat(chat(director, "Worker")); server.getScheduler().performOneTick();
+ assertEquals(List.of(director.getUniqueId()), site.getExcavators()); verify(sites, never()).save(any());
+ when(catalogs.establish()).thenReturn(EstablishSettings.defaults()); invite(); site.setDirector(worker.getUniqueId());
+ listener.onChat(chat(director, "Worker")); server.getScheduler().performOneTick();
+ assertTrue(messages(director).contains("That excavation is no longer yours to staff.")); verify(sites, never()).save(any());
+ }
+
+ @Test public void staffRolesAndDismissalUpdatePermissionsAndNotifyTheWorker() {
+ site.grantExcavator(worker.getUniqueId());
+ new CampWorkerBoard(site.getId(), worker.getUniqueId(), true).open(director, site);
+ int roleSlot = java.util.stream.IntStream.range(0, 27).filter(i -> CampWorkerBoard.roleAt(i) == SiteRole.EXCAVATOR).findFirst().orElseThrow();
+ listener.onWorkerClick(click(director, roleSlot)); assertEquals(SiteRole.EXCAVATOR, site.roleOf(worker.getUniqueId()));
+ assertTrue(messages(worker).stream().anyMatch(s -> s.contains("You are now")));
+ listener.onWorkerClick(click(director, CampWorkerBoard.SLOT_REMOVE));
+ assertFalse(site.getExcavators().contains(worker.getUniqueId())); assertFalse(site.mayWork(worker.getUniqueId()));
+ assertEquals("You may no longer work on River camp.", worker.nextMessage()); verify(sites, times(2)).save(site);
+ new CampWorkerBoard(site.getId(), director.getUniqueId(), true).open(director, site);
+ listener.onWorkerClick(click(director, CampWorkerBoard.SLOT_REMOVE));
+ assertEquals(director.getUniqueId(), site.getDirector()); assertTrue(messages(director).contains("The director cannot be removed."));
+ }
+
+ @Test public void closePromptRequiresExplicitConfirmationAndRechecksAuthorization() {
+ BuriedFind buried = find(FindState.HIDDEN); site.getFinds().add(buried);
+ board(director, true, true); listener.onBoardClick(click(director, CampBoard.SLOT_CLOSE));
+ assertTrue(messages(director).stream().anyMatch(s -> s.contains("0% complete")));
+ listener.onChat(chat(director, "maybe")); server.getScheduler().performOneTick(); verify(closure, never()).close(any(), any());
+ listener.onChat(chat(director, "cancel")); server.getScheduler().performOneTick(); verify(closure, never()).close(any(), any());
+ board(director, true, true); listener.onBoardClick(click(director, CampBoard.SLOT_CLOSE));
+ listener.onChat(chat(director, "confirm")); verify(closure, never()).close(any(), any());
+ server.getScheduler().performOneTick(); verify(closure).close(director, site);
+ clearInvocations(closure); board(director, true, true); listener.onBoardClick(click(director, CampBoard.SLOT_CLOSE));
+ site.setDirector(worker.getUniqueId()); listener.onChat(chat(director, "confirm")); server.getScheduler().performOneTick();
+ verify(closure, never()).close(any(), any()); assertTrue(messages(director).contains("That excavation can no longer be closed."));
+ }
+
+ @Test public void renameAndMoveChatAreScheduledAndUnrelatedChatRemainsPublic() {
+ when(establish.isRenaming(director)).thenReturn(true);
+ AsyncPlayerChatEvent rename = chat(director, " River name "); listener.onChat(rename); assertTrue(rename.isCancelled());
+ verify(establish, never()).handleRenameChat(any(), any()); server.getScheduler().performOneTick();
+ verify(establish).handleRenameChat(director, "River name");
+ when(establish.isRelocating(director)).thenReturn(true);
+ listener.onChat(chat(director, " cancel ")); server.getScheduler().performOneTick(); verify(establish).tryCancelMove(director);
+ when(establish.isRenaming(director)).thenReturn(false); when(establish.isRelocating(director)).thenReturn(false);
+ AsyncPlayerChatEvent ordinary = chat(director, "chat"); listener.onChat(ordinary); assertFalse(ordinary.isCancelled());
+ }
+
+ @Test public void archiveCopiesRetainTheirLiveRecordAndMissingRecordsDoNotOpenBoards() {
+ site.setStatus(SiteStatus.CLOSED); ItemStack book = archive.create(director, site);
+ PlayerInteractEvent event = interact(worker, null, EquipmentSlot.HAND, book); listener.onInteract(event);
+ assertTrue(event.isCancelled()); assertTrue(worker.getOpenInventory().getTopInventory().getHolder() instanceof CampBoard);
+ CampBoard board = (CampBoard) worker.getOpenInventory().getTopInventory().getHolder();
+ for (int slot : new int[]{CampBoard.SLOT_RENAME, CampBoard.SLOT_MOVE, CampBoard.SLOT_CLOSE})
+ assertEquals(Material.GRAY_STAINED_GLASS_PANE, board.getInventory().getItem(slot).getType());
+ CraftingInventory inventory = mock(CraftingInventory.class); ItemStack vanilla = new ItemStack(Material.WRITTEN_BOOK);
+ when(inventory.getResult()).thenReturn(vanilla); when(inventory.getMatrix()).thenReturn(new ItemStack[]{new ItemStack(Material.PAPER), book});
+ PrepareItemCraftEvent craft = mock(PrepareItemCraftEvent.class); when(craft.getInventory()).thenReturn(inventory);
+ listener.onCopyArchive(craft);
+ var copy = org.mockito.ArgumentCaptor.forClass(ItemStack.class); verify(inventory).setResult(copy.capture());
+ assertTrue(archive.isArchive(copy.getValue())); assertEquals(site.getId(), archive.siteIdOf(copy.getValue()));
+ when(sites.findById(site.getId())).thenReturn(Optional.empty()); worker.closeInventory();
+ listener.onInteract(interact(worker, null, EquipmentSlot.HAND, book));
+ assertEquals("That excavation record is missing.", worker.nextMessage());
+ }
+
+ @Test public void stationSigningRequiresRoleAndHeldArtifactThenPersistsTheAuthorOnce() {
+ BuriedFind find = find(FindState.RECOVERED); site.getFinds().add(find);
+ InterpretationType type = new InterpretationType("purpose", "Purpose", "What was it for?", List.of(new InterpretationTemplate("vessel", "purpose", "Vessel", Set.of(), Set.of(), Set.of())));
+ when(catalogs.nextOpenType(find)).thenAnswer(call -> find.isCatalogued() ? null : type);
+ when(catalogs.stationOffers(anyString(), any(), any(), anySet())).thenReturn(List.of(
+ new InterpretationTemplate("vessel", "purpose", "Vessel", Set.of(), Set.of(), Set.of())));
+ // MockBukkit's brewing inventory wrongly casts custom holders to BrewingStand.
+ // A chest inventory retains the same holder contract for listener routing.
+ CampIdentifyBoard board = mock(CampIdentifyBoard.class);
+ when(board.siteId()).thenReturn(site.getId()); when(board.findId()).thenReturn(find.getId());
+ when(board.type()).thenReturn(type); when(board.atCabinet()).thenReturn(true);
+ when(board.offerAt(CampIdentifyBoard.SLOT_OFFER_0)).thenReturn("vessel");
+ worker.openInventory(Bukkit.createInventory(board, 9)); listener.onIdentifyClick(click(worker, CampIdentifyBoard.SLOT_OFFER_0));
+ assertFalse(find.isCatalogued()); assertTrue(messages(worker).contains("You are not authorised to write this record."));
+ site.grantExcavator(worker.getUniqueId()); site.assignRole(worker.getUniqueId(), SiteRole.ARCHAEOLOGIST);
+ listener.onIdentifyClick(click(worker, CampIdentifyBoard.SLOT_OFFER_0));
+ assertFalse(find.isCatalogued()); assertTrue(messages(worker).contains("Keep the artifact in your hand."));
+ when(recovered.isInMainHand(worker, find.getId())).thenReturn(true);
+ listener.onIdentifyClick(click(worker, CampIdentifyBoard.SLOT_OFFER_0));
+ assertEquals(1, find.getInterpretations().size()); assertEquals("vessel", find.getInterpretations().getFirst().interpretationId());
+ assertEquals(worker.getUniqueId(), find.getInterpretations().getFirst().author()); verify(sites).save(site);
+ listener.onIdentifyClick(click(worker, CampIdentifyBoard.SLOT_OFFER_0));
+ assertEquals(1, find.getInterpretations().size()); verify(sites).save(site);
+ server.getScheduler().performOneTick();
+ assertNull(worker.getOpenInventory().getTopInventory());
+ }
+
+ @Test public void reportIssuingRequiresDirectorAndExhaustedSiteAndPreservesOverflowBooks() {
+ new CampFindsBoard(site.getId(), false, catalogs, recovered).open(worker, site);
+ listener.onFindsClick(click(worker, CampFindsBoard.SLOT_REPORT));
+ assertTrue(messages(worker).contains("Only the director may issue the report."));
+ new CampFindsBoard(site.getId(), true, catalogs, recovered).open(director, site);
+ listener.onFindsClick(click(director, CampFindsBoard.SLOT_REPORT));
+ assertTrue(messages(director).contains("The report is issued when the cut is closed."));
+ site.setStatus(SiteStatus.EXHAUSTED);
+ for (int slot = 0; slot < 36; slot++) director.getInventory().setItem(slot, new ItemStack(Material.STONE, 64));
+ director.getInventory().setArmorContents(new ItemStack[]{new ItemStack(Material.IRON_BOOTS),
+ new ItemStack(Material.IRON_LEGGINGS), new ItemStack(Material.IRON_CHESTPLATE), new ItemStack(Material.IRON_HELMET)});
+ director.getInventory().setItemInOffHand(new ItemStack(Material.TORCH, 64));
+ PlayerMock receiver = spy(director); var fullStorage = spy(director.getInventory());
+ doReturn(fullStorage).when(receiver).getInventory();
+ // Bukkit addItem uses storage only; MockBukkit also searches two phantom player slots.
+ doAnswer(call -> {
+ var overflow = new HashMap();
+ overflow.put(0, call.getArgument(0)); return overflow;
+ }).when(fullStorage).addItem(any(ItemStack.class));
+ InventoryClickEvent reportClick = spy(click(director, CampFindsBoard.SLOT_REPORT));
+ doReturn(receiver).when(reportClick).getWhoClicked();
+ listener.onFindsClick(reportClick);
+ assertTrue(director.getWorld().getEntities().stream().filter(Item.class::isInstance).map(Item.class::cast)
+ .anyMatch(item -> new FindReportBook(plugin).isReport(item.getItemStack())));
+ }
+
+ @Test public void dragsCannotInsertItemsIntoCampBoardsAndStaleRecordsCloseTheView() {
+ board(director, true, true);
+ InventoryDragEvent drag = new InventoryDragEvent(director.getOpenInventory(), new ItemStack(Material.STONE),
+ new ItemStack(Material.STONE, 2), false, Map.of(0, new ItemStack(Material.STONE)));
+ listener.onBoardDrag(drag); assertTrue(drag.isCancelled());
+ when(sites.findById(site.getId())).thenReturn(Optional.empty()); listener.onBoardClick(click(director, CampBoard.SLOT_RENAME));
+ assertNull(director.getOpenInventory().getTopInventory()); verifyNoInteractions(establish);
+ }
+
+ @Test public void successiveStationQuestionsPersistDistinctAnswersAndRefreshTheHeldPiece() {
+ BuriedFind find = find(FindState.RECOVERED); site.getFinds().add(find);
+ site.grantExcavator(worker.getUniqueId()); site.assignRole(worker.getUniqueId(), SiteRole.ARCHAEOLOGIST);
+ InterpretationTemplate purpose = new InterpretationTemplate("vessel", "purpose", "Vessel", Set.of(), Set.of(), Set.of());
+ InterpretationTemplate epoch = new InterpretationTemplate("roman", "epoch", "Roman", Set.of(), Set.of(), Set.of());
+ InterpretationType first = new InterpretationType("purpose", "Purpose", "What was it for?", List.of(purpose));
+ InterpretationType second = new InterpretationType("epoch", "Epoch", "When was it made?", List.of(epoch));
+ when(catalogs.nextOpenType(find)).thenAnswer(call -> switch (find.getInterpretations().size()) {
+ case 0 -> first; case 1 -> second; default -> null;
+ });
+ when(catalogs.stationOffers(anyString(), any(), any(), anySet())).thenAnswer(call ->
+ "purpose".equals(call.getArgument(0)) ? List.of(purpose) : List.of(epoch));
+ ArtifactTemplate template = mock(ArtifactTemplate.class); when(template.tags()).thenReturn(Set.of());
+ when(catalogs.artifact("pot")).thenReturn(template); when(recovered.isInMainHand(worker, find.getId())).thenReturn(true);
+ // Adapt only the unsupported brewing inventory holder; keep boards and question transitions real.
+ try (var bukkit = mockStatic(Bukkit.class, CALLS_REAL_METHODS)) {
+ bukkit.when(() -> Bukkit.createInventory(any(InventoryHolder.class), eq(InventoryType.BREWING), anyString()))
+ .thenAnswer(call -> server.createInventory(call.getArgument(0), 9, (String) call.getArgument(2)));
+ assertTrue(new CampIdentifyBoard(site.getId(), find.getId(), catalogs, recovered, true).open(worker, site));
+ InventoryClickEvent firstClick = click(worker, CampIdentifyBoard.SLOT_OFFER_0);
+ listener.onIdentifyClick(firstClick);
+ assertEquals("purpose", find.getInterpretations().getFirst().typeId());
+ CampIdentifyBoard next = (CampIdentifyBoard) worker.getOpenInventory().getTopInventory().getHolder();
+ assertEquals("epoch", next.type().id()); assertTrue(next.atCabinet());
+ listener.onIdentifyClick(firstClick); // A delayed repeat of the first question must not file twice.
+ assertEquals(1, find.getInterpretations().size());
+ listener.onIdentifyClick(click(worker, CampIdentifyBoard.SLOT_OFFER_0));
+ assertEquals(List.of("vessel", "roman"), find.getInterpretations().stream().map(FindInterpretation::interpretationId).toList());
+ assertTrue(find.getInterpretations().stream().allMatch(answer -> worker.getUniqueId().equals(answer.author())));
+ verify(sites, times(2)).save(site);
+ verify(recovered, times(2)).refreshCarried(eq(worker), eq(site), eq(find), eq(template), anyString(), eq(catalogs));
+ server.getScheduler().performOneTick(); assertNull(worker.getOpenInventory().getTopInventory());
+ }
+ }
+
+ @Test public void stationBackReturnsToTheFindRecordOrLeavesTheCabinetWithoutChangingAnswers() {
+ when(catalogs.materialOf(any())).thenReturn(new FindMaterial("ceramic", "Ceramic", 1.0, Material.WHITE_STAINED_GLASS_PANE, List.of()));
+ BuriedFind find = find(FindState.RECOVERED); site.getFinds().add(find);
+ for (boolean cabinet : new boolean[]{false, true}) {
+ CampIdentifyBoard board = mock(CampIdentifyBoard.class);
+ when(board.siteId()).thenReturn(site.getId()); when(board.findId()).thenReturn(find.getId());
+ when(board.atCabinet()).thenReturn(cabinet);
+ worker.openInventory(Bukkit.createInventory(board, 9));
+ listener.onIdentifyClick(click(worker, CampIdentifyBoard.SLOT_BACK));
+ if (cabinet) assertNull(worker.getOpenInventory().getTopInventory());
+ else assertTrue(worker.getOpenInventory().getTopInventory().getHolder() instanceof CampFindBoard);
+ }
+ assertTrue(find.getInterpretations().isEmpty()); verify(sites, never()).save(any());
+ }
+
+ @Test public void findRegisterAndMuseumFilesNavigateWithoutLettingVisitorsTakeIcons() {
+ BuriedFind find = find(FindState.RECOVERED); site.getFinds().add(find);
+ when(catalogs.materialOf(any())).thenReturn(new FindMaterial("ceramic", "Ceramic", 1, Material.WHITE_STAINED_GLASS_PANE, List.of()));
+ new CampFindsBoard(site.getId(), false, catalogs, recovered).open(worker, site);
+ CampFindsBoard register = (CampFindsBoard) worker.getOpenInventory().getTopInventory().getHolder();
+ int row = java.util.stream.IntStream.range(0, 27).filter(slot -> find.getId().equals(register.findAt(slot))).findFirst().orElseThrow();
+ listener.onFindsClick(click(worker, row));
+ Inventory file = worker.getOpenInventory().getTopInventory(); assertTrue(file.getHolder() instanceof CampFindBoard);
+ ItemStack icon = file.getItem(CampFindBoard.SLOT_IDENTITY).clone();
+ InventoryClickEvent identity = click(worker, CampFindBoard.SLOT_IDENTITY); listener.onFindFileClick(identity);
+ assertTrue(identity.isCancelled()); assertEquals(icon, file.getItem(CampFindBoard.SLOT_IDENTITY));
+ InventoryClickEvent bottom = click(worker, 27); listener.onFindFileClick(bottom); assertTrue(bottom.isCancelled());
+ listener.onFindFileClick(click(worker, CampFindBoard.SLOT_BACK));
+ assertTrue(worker.getOpenInventory().getTopInventory().getHolder() instanceof CampFindsBoard);
+ listener.onFindsClick(click(worker, CampFindsBoard.SLOT_BACK));
+ assertTrue(worker.getOpenInventory().getTopInventory().getHolder() instanceof CampBoard);
+ new CampFindBoard(site.getId(), find.getId(), catalogs, true).open(worker, site);
+ listener.onFindFileClick(click(worker, CampFindBoard.SLOT_BACK)); assertNull(worker.getOpenInventory().getTopInventory());
+ new CampFindBoard(site.getId(), find.getId(), catalogs).open(worker, site);
+ when(sites.findById(site.getId())).thenReturn(Optional.empty());
+ listener.onFindFileClick(click(worker, CampFindBoard.SLOT_BACK)); assertNull(worker.getOpenInventory().getTopInventory());
+ assertEquals(1, find.getFindNumber()); verify(sites).save(site); // Legacy recovered rows gain a stable register number once.
+ }
+
+ @Test public void relocationProxyUsesMainHandAndAttackOrSlotChangeCancelsOnlyActiveMoves() {
+ Entity proxy = mock(Entity.class); Entity ordinary = mock(Entity.class);
+ when(establish.tryFinishMoveOnAimProxy(director, proxy)).thenReturn(true);
+ PlayerInteractAtEntityEvent at = new PlayerInteractAtEntityEvent(director, proxy, new Vector(), EquipmentSlot.HAND);
+ listener.onAimProxyAt(at); assertTrue(at.isCancelled());
+ PlayerInteractEntityEvent generic = new PlayerInteractEntityEvent(director, proxy, EquipmentSlot.HAND);
+ listener.onAimProxy(generic); assertTrue(generic.isCancelled());
+ PlayerInteractEntityEvent offhand = new PlayerInteractEntityEvent(director, proxy, EquipmentSlot.OFF_HAND);
+ listener.onAimProxy(offhand); assertFalse(offhand.isCancelled());
+ PlayerInteractAtEntityEvent offhandAt = new PlayerInteractAtEntityEvent(director, proxy, new Vector(), EquipmentSlot.OFF_HAND);
+ listener.onAimProxyAt(offhandAt); assertFalse(offhandAt.isCancelled());
+ verify(establish, times(2)).tryFinishMoveOnAimProxy(director, proxy);
+ PlayerInteractEntityEvent other = new PlayerInteractEntityEvent(director, ordinary, EquipmentSlot.HAND);
+ listener.onAimProxy(other); assertFalse(other.isCancelled());
+ when(establish.handleMoveAimProxyAttack(director, proxy)).thenReturn(true);
+ EntityDamageByEntityEvent attack = mock(EntityDamageByEntityEvent.class);
+ when(attack.getDamager()).thenReturn(director); when(attack.getEntity()).thenReturn(proxy);
+ listener.onAimProxyAttack(attack); verify(attack).setCancelled(true);
+ listener.onHeld(new PlayerItemHeldEvent(director, 0, 1)); verify(establish, never()).tryCancelMove(any());
+ when(establish.isRelocating(director)).thenReturn(true);
+ listener.onHeld(new PlayerItemHeldEvent(director, 1, 2)); verify(establish).tryCancelMove(director);
+ PlayerInteractEvent finish = interact(director, outside, EquipmentSlot.HAND, null);
+ listener.onInteract(finish); assertTrue(finish.isCancelled()); verify(establish).tryFinishMove(director);
+ when(establish.tryCancelMove(director)).thenReturn(true);
+ PlayerInteractEvent abort = new PlayerInteractEvent(director, Action.LEFT_CLICK_AIR, null, null, BlockFace.SELF, EquipmentSlot.HAND);
+ listener.onInteract(abort); assertTrue(abort.isCancelled()); verify(establish, times(2)).tryCancelMove(director);
+ }
+
+ @Test public void entitiesAndBlockExplosionsCannotAlterCampButUnrelatedBlocksRemainDestructible() {
+ EntityChangeBlockEvent change = mock(EntityChangeBlockEvent.class); when(change.getBlock()).thenReturn(camp);
+ listener.onEntityChange(change); verify(change).setCancelled(true);
+ EntityChangeBlockEvent normal = mock(EntityChangeBlockEvent.class); when(normal.getBlock()).thenReturn(outside);
+ listener.onEntityChange(normal); verify(normal, never()).setCancelled(anyBoolean());
+ List blocks = new ArrayList<>(List.of(outside, camp));
+ BlockExplodeEvent blast = mock(BlockExplodeEvent.class); when(blast.blockList()).thenReturn(blocks);
+ listener.onExplode(blast); assertEquals(List.of(outside), blocks);
+ BlockPistonExtendEvent head = new BlockPistonExtendEvent(outside, List.of(), BlockFace.WEST);
+ listener.onPistonExtend(head); assertTrue(head.isCancelled());
+ BlockPistonRetractEvent pull = new BlockPistonRetractEvent(outside, List.of(camp), BlockFace.WEST);
+ listener.onPistonRetract(pull); assertTrue(pull.isCancelled());
+ }
+
+ @Test public void fullRosterDoesNotCaptureChatAndDuplicateInvitesNeverSave() {
+ when(catalogs.establish()).thenReturn(new EstablishSettings(true, true, Material.RED_STAINED_GLASS, Material.LIGHT_BLUE_STAINED_GLASS, 1, 1));
+ invite(); AsyncPlayerChatEvent normal = chat(director, "Worker"); listener.onChat(normal);
+ assertFalse(normal.isCancelled()); assertTrue(director.getOpenInventory().getTopInventory().getHolder() instanceof CampStaffBoard);
+ when(catalogs.establish()).thenReturn(EstablishSettings.defaults());
+ for (String member : List.of("Director", "Worker")) {
+ if (member.equals("Worker")) site.grantExcavator(worker.getUniqueId());
+ invite(); listener.onChat(chat(director, member)); server.getScheduler().performOneTick();
+ assertTrue(messages(director).stream().anyMatch(line -> line.contains("already on the staff")));
+ }
+ verify(sites, never()).save(any()); assertEquals(2, site.getExcavators().size());
+ }
+
+ @Test public void workerFilesRecheckDirectorAndCampBeforeChangingRolesOrDismissing() {
+ site.grantExcavator(worker.getUniqueId());
+ new CampStaffBoard(site.getId(), true, 18).open(director, site);
+ CampStaffBoard roster = (CampStaffBoard) director.getOpenInventory().getTopInventory().getHolder();
+ int workerSlot = java.util.stream.IntStream.range(0, 18).filter(slot -> worker.getUniqueId().equals(roster.playerAt(slot))).findFirst().orElseThrow();
+ listener.onStaffClick(click(director, workerSlot)); assertTrue(director.getOpenInventory().getTopInventory().getHolder() instanceof CampWorkerBoard);
+ listener.onWorkerClick(click(director, CampWorkerBoard.SLOT_BACK));
+ listener.onStaffClick(click(director, CampStaffBoard.SLOT_BACK)); assertTrue(director.getOpenInventory().getTopInventory().getHolder() instanceof CampBoard);
+ new CampWorkerBoard(site.getId(), worker.getUniqueId(), true).open(director, site);
+ site.setDirector(UUID.randomUUID()); listener.onWorkerClick(click(director, CampWorkerBoard.SLOT_REMOVE));
+ assertTrue(site.getExcavators().contains(worker.getUniqueId()));
+ site.setDirector(director.getUniqueId()); site.setStatus(SiteStatus.CLOSED);
+ listener.onWorkerClick(click(director, CampWorkerBoard.SLOT_REMOVE)); assertTrue(site.getExcavators().contains(worker.getUniqueId()));
+ when(sites.findById(site.getId())).thenReturn(Optional.empty());
+ listener.onWorkerClick(click(director, CampWorkerBoard.SLOT_REMOVE)); assertNull(director.getOpenInventory().getTopInventory());
+ verify(sites, never()).save(any());
+ }
+
+ @Test public void offlineWorkersCanBeReassignedAndDismissedWithoutAnOnlineNotificationTarget() {
+ site.grantExcavator(worker.getUniqueId()); worker.disconnect();
+ new CampWorkerBoard(site.getId(), worker.getUniqueId(), true).open(director, site);
+ int roleSlot = java.util.stream.IntStream.range(0, 27).filter(i -> CampWorkerBoard.roleAt(i) == SiteRole.EXCAVATOR).findFirst().orElseThrow();
+ listener.onWorkerClick(click(director, roleSlot)); assertEquals(SiteRole.EXCAVATOR, site.roleOf(worker.getUniqueId()));
+ listener.onWorkerClick(click(director, roleSlot)); verify(sites).save(site); // Selecting the current role must not dirty the dossier.
+ listener.onWorkerClick(click(director, CampWorkerBoard.SLOT_REMOVE));
+ assertFalse(site.mayWork(worker.getUniqueId())); verify(sites, times(2)).save(site);
+ }
+
+ @Test public void secondaryWoolPickerSupportsBackAndRejectsChoicesAfterTheCampCloses() {
+ board(director, true, true); listener.onBoardClick(click(director, CampBoard.SLOT_WOOL_SECONDARY));
+ assertTrue(director.getOpenInventory().getTopInventory().getHolder() instanceof CampWoolPicker);
+ listener.onWoolPickerClick(click(director, DyeColor.YELLOW.ordinal()));
+ verify(establish).applyCampWool(director, site, DyeColor.YELLOW, CampWoolRole.SECONDARY);
+ listener.onBoardClick(click(director, CampBoard.SLOT_WOOL_SECONDARY));
+ listener.onWoolPickerClick(click(director, CampWoolPicker.SLOT_BACK));
+ assertTrue(director.getOpenInventory().getTopInventory().getHolder() instanceof CampBoard);
+ listener.onBoardClick(click(director, CampBoard.SLOT_WOOL_PRIMARY)); assertTrue(site.closeCamp());
+ clearInvocations(establish); listener.onWoolPickerClick(click(director, DyeColor.BLUE.ordinal()));
+ verify(establish, never()).applyCampWool(any(), any(), any(), any());
+ assertNull(director.getOpenInventory().getTopInventory());
+ }
+
+ @Test public void woolPickerRechecksTheDirectorWhenAnOpenCampChangesOwner() {
+ board(director, true, true); listener.onBoardClick(click(director, CampBoard.SLOT_WOOL_PRIMARY));
+ site.setDirector(worker.getUniqueId()); listener.onWoolPickerClick(click(director, DyeColor.BLUE.ordinal()));
+ verify(establish, never()).applyCampWool(any(), any(), any(), any());
+ assertNull(director.getOpenInventory().getTopInventory());
+ }
+
+ @Test public void staffCanCloseAnExhaustedCampAfterExplicitConfirmation() {
+ site.setStatus(SiteStatus.EXHAUSTED); worker.addAttachment(plugin, "archaeo.admin", true);
+ listener.onInteract(interact(worker, camp, EquipmentSlot.HAND, null));
+ listener.onBoardClick(click(worker, CampBoard.SLOT_CLOSE));
+ assertTrue(messages(worker).contains("Type confirm to close the camp. The record will move to a field book. Type cancel to keep it."));
+ listener.onChat(chat(worker, "confirm")); verify(closure, never()).close(any(), any());
+ server.getScheduler().performOneTick(); verify(closure).close(worker, site);
+ }
+
+ @Test public void breakingDuringSneakingRelocationCannotDamageTheAimedTerrain() {
+ when(establish.isRelocating(director)).thenReturn(true); when(establish.sneakHeld(director)).thenReturn(true);
+ BlockBreakEvent protectedBreak = new BlockBreakEvent(outside, director); listener.onBreak(protectedBreak);
+ assertTrue(protectedBreak.isCancelled()); assertEquals(Material.STONE, outside.getType());
+ when(establish.isRelocating(director)).thenReturn(false);
+ BlockBreakEvent ordinaryBreak = new BlockBreakEvent(outside, director); listener.onBreak(ordinaryBreak);
+ assertFalse(ordinaryBreak.isCancelled());
+ }
+
+ @Test public void ordinaryBlocksNextToTheCampRemainOpenToFireDecayPistonsAndSigns() {
+ BlockBurnEvent burn = new BlockBurnEvent(outside, camp); listener.onBurn(burn); assertFalse(burn.isCancelled());
+ BlockFadeEvent fade = new BlockFadeEvent(outside, outside.getState()); listener.onFade(fade); assertFalse(fade.isCancelled());
+ SignChangeEvent sign = new SignChangeEvent(outside, worker, new String[]{"shop", "", "", ""});
+ listener.onSignChange(sign); assertFalse(sign.isCancelled());
+ Block beyond = outside.getRelative(BlockFace.EAST); beyond.setType(Material.STONE);
+ BlockPistonExtendEvent push = new BlockPistonExtendEvent(beyond, List.of(beyond.getRelative(BlockFace.EAST)), BlockFace.EAST);
+ listener.onPistonExtend(push); assertFalse(push.isCancelled());
+ assertEquals(Material.CAMPFIRE, camp.getType());
+ }
+
+ @Test public void offHandAndTramplingEventsOnlyBlockVanillaUseOfCampPieces() {
+ PlayerInteractEvent ordinary = interact(director, outside, EquipmentSlot.OFF_HAND, new ItemStack(Material.TORCH));
+ listener.onInteract(ordinary); assertFalse(ordinary.isCancelled()); assertEquals(Event.Result.DEFAULT, ordinary.useItemInHand());
+ PlayerInteractEvent air = interact(director, null, EquipmentSlot.OFF_HAND, new ItemStack(Material.BREAD));
+ listener.onInteract(air); assertEquals(Event.Result.DEFAULT, air.useItemInHand());
+ // Paper reports trampling (pressure plates, farmland) with no hand at all.
+ PlayerInteractEvent trample = new PlayerInteractEvent(director, Action.PHYSICAL, null, camp, BlockFace.SELF, null);
+ listener.onInteract(trample); assertFalse(trample.isCancelled());
+ assertNull(director.getOpenInventory().getTopInventory()); verifyNoInteractions(establish);
+ }
+
+ @Test public void relocationIsOnlyConfirmedByRightClicksAndAnyLeftClickAbortsIt() {
+ when(establish.isRelocating(director)).thenReturn(true);
+ // The five-argument Bukkit constructor, used by plugins that fire synthetic trampling, defaults to the main hand.
+ PlayerInteractEvent trample = new PlayerInteractEvent(director, Action.PHYSICAL, null, outside, BlockFace.SELF);
+ listener.onInteract(trample); assertFalse(trample.isCancelled());
+ PlayerInteractEvent kit = interact(director, null, EquipmentSlot.HAND, new ItemStack(Material.STICK));
+ listener.onInteract(kit); assertEquals(Event.Result.DEFAULT, kit.useItemInHand()); // EstablishListener confirms kit clicks itself.
+ verify(establish, never()).tryFinishMove(any());
+ when(establish.tryCancelMove(director)).thenReturn(true);
+ PlayerInteractEvent abort = new PlayerInteractEvent(director, Action.LEFT_CLICK_BLOCK, null, outside, BlockFace.UP, EquipmentSlot.HAND);
+ listener.onInteract(abort); assertTrue(abort.isCancelled()); verify(establish).tryCancelMove(director);
+ when(establish.isRelocating(director)).thenReturn(false); when(establish.tryCancelMove(director)).thenReturn(false);
+ PlayerInteractEvent mining = new PlayerInteractEvent(director, Action.LEFT_CLICK_BLOCK, null, outside, BlockFace.UP, EquipmentSlot.HAND);
+ listener.onInteract(mining); assertFalse(mining.isCancelled());
+ PlayerInteractEvent eating = interact(director, null, EquipmentSlot.HAND, new ItemStack(Material.BREAD));
+ listener.onInteract(eating); assertEquals(Event.Result.DEFAULT, eating.useItemInHand()); verify(establish, never()).tryFinishMove(any());
+ }
+
+ @Test public void chattingDuringAMoveStaysPublicUnlessItIsTheCancelWord() {
+ when(establish.isRelocating(director)).thenReturn(true);
+ AsyncPlayerChatEvent talk = chat(director, "where should it go?"); listener.onChat(talk);
+ assertFalse(talk.isCancelled()); server.getScheduler().performOneTick(); verify(establish, never()).tryCancelMove(any());
+ AsyncPlayerChatEvent cancel = chat(director, "Cancel"); listener.onChat(cancel);
+ assertTrue(cancel.isCancelled()); // The cancel word never reaches public chat.
+ verify(establish, never()).tryCancelMove(any()); // Chat is async; the move is abandoned on the main thread.
+ server.getScheduler().performOneTick(); verify(establish).tryCancelMove(director);
+ verify(establish, never()).handleRenameChat(any(), any());
+ }
+
+ @Test public void onlyFieldBookCopiesAreRestampedAndOtherCraftingIsUntouched() {
+ CraftingInventory grid = mock(CraftingInventory.class); PrepareItemCraftEvent craft = mock(PrepareItemCraftEvent.class);
+ when(craft.getInventory()).thenReturn(grid);
+ listener.onCopyArchive(craft); // No recipe matches the grid yet.
+ when(grid.getResult()).thenReturn(new ItemStack(Material.OAK_PLANKS, 4)); listener.onCopyArchive(craft);
+ ItemStack diary = new ItemStack(Material.WRITTEN_BOOK);
+ when(grid.getResult()).thenReturn(diary.clone()); when(grid.getMatrix()).thenReturn(new ItemStack[]{diary, new ItemStack(Material.WRITABLE_BOOK), null});
+ listener.onCopyArchive(craft);
+ verify(grid, never()).setResult(any());
+ }
+
+ @Test public void aimProxyHandlersIgnoreOrdinaryEntitiesAndMobAttacks() {
+ Entity villager = mock(Entity.class);
+ PlayerInteractAtEntityEvent trade = new PlayerInteractAtEntityEvent(director, villager, new Vector(), EquipmentSlot.HAND);
+ listener.onAimProxyAt(trade); assertFalse(trade.isCancelled());
+ EntityDamageByEntityEvent swing = mock(EntityDamageByEntityEvent.class);
+ when(swing.getDamager()).thenReturn(director); when(swing.getEntity()).thenReturn(villager);
+ listener.onAimProxyAttack(swing); verify(swing, never()).setCancelled(anyBoolean());
+ EntityDamageByEntityEvent zombie = mock(EntityDamageByEntityEvent.class);
+ when(zombie.getDamager()).thenReturn(mock(org.bukkit.entity.Zombie.class)); when(zombie.getEntity()).thenReturn(director);
+ listener.onAimProxyAttack(zombie); verify(zombie, never()).setCancelled(anyBoolean());
+ verify(establish, never()).handleMoveAimProxyAttack(any(), eq(director));
+ }
+
+ @Test public void everyCampWindowSwallowsStrayClicksAndClosesOnceItsRecordIsGoneOrUnreadable() {
+ BuriedFind find = find(FindState.RECOVERED); find.setFindNumber(1); site.getFinds().add(find); site.grantExcavator(worker.getUniqueId());
+ when(catalogs.materialOf(any())).thenReturn(new FindMaterial("ceramic", "Ceramic", 1, Material.WHITE_STAINED_GLASS_PANE, List.of()));
+ CampIdentifyBoard station = mock(CampIdentifyBoard.class); when(station.siteId()).thenReturn(site.getId());
+ List windows = List.of(() -> board(director, true, true),
+ () -> new CampStaffBoard(site.getId(), true, 18).open(director, site),
+ () -> new CampWorkerBoard(site.getId(), worker.getUniqueId(), true).open(director, site),
+ () -> new CampFindsBoard(site.getId(), true, catalogs, recovered).open(director, site),
+ () -> new CampFindBoard(site.getId(), find.getId(), catalogs).open(director, site),
+ // MockBukkit cannot hold a custom holder in a brewing window; a chest keeps the routing contract.
+ () -> director.openInventory(Bukkit.createInventory(station, 9)),
+ () -> new CampWoolPicker(site.getId(), CampWoolRole.PRIMARY).open(director, site));
+ for (Runnable window : windows) {
+ for (int slot : new int[]{27, -999}) {
+ site.setStatus(SiteStatus.ESTABLISHED); window.run(); Inventory open = director.getOpenInventory().getTopInventory();
+ InventoryClickEvent stray = new InventoryClickEvent(director.getOpenInventory(), slot < 0 ? InventoryType.SlotType.OUTSIDE
+ : InventoryType.SlotType.CONTAINER, slot, ClickType.LEFT, InventoryAction.PICKUP_ALL);
+ clickAll(stray); assertTrue(stray.isCancelled()); assertSame(open, director.getOpenInventory().getTopInventory());
+ }
+ window.run(); when(sites.findById(site.getId())).thenReturn(Optional.empty());
+ clickAll(click(director, 0)); assertNull(director.getOpenInventory().getTopInventory());
+ when(sites.findById(site.getId())).thenReturn(Optional.of(site)); window.run();
+ site.setStatus(SiteStatus.HIDDEN); // A dossier reloaded without its status reads as an unclaimed ruin.
+ clickAll(click(director, 0)); assertNull(director.getOpenInventory().getTopInventory());
+ }
+ director.openInventory(Bukkit.createInventory(null, 27)); InventoryClickEvent chest = click(director, 0);
+ clickAll(chest); assertFalse(chest.isCancelled());
+ verify(sites, never()).save(any()); verifyNoInteractions(establish, outline);
+ }
+
+ @Test public void dragsIntoAnyCampOrCabinetWindowAreCancelledButOrdinaryChestsAreNot() {
+ BuriedFind find = find(FindState.RECOVERED); site.getFinds().add(find);
+ when(catalogs.materialOf(any())).thenReturn(new FindMaterial("ceramic", "Ceramic", 1, Material.WHITE_STAINED_GLASS_PANE, List.of()));
+ List windows = List.of(() -> board(director, true, true),
+ () -> new CampWoolPicker(site.getId(), CampWoolRole.SECONDARY).open(director, site),
+ () -> new CampStaffBoard(site.getId(), true, 18).open(director, site),
+ () -> new CampWorkerBoard(site.getId(), director.getUniqueId(), true).open(director, site),
+ () -> new CampFindsBoard(site.getId(), true, catalogs, recovered).open(director, site),
+ () -> new CampFindBoard(site.getId(), find.getId(), catalogs).open(director, site),
+ () -> director.openInventory(Bukkit.createInventory(mock(CampIdentifyBoard.class), 9)),
+ () -> director.openInventory(Bukkit.createInventory(new net.tfminecraft.archaeo.sketch.SketchCabinet(), 9)));
+ for (Runnable window : windows) { window.run(); assertTrue(drag().isCancelled()); }
+ director.openInventory(Bukkit.createInventory(null, 27)); assertFalse(drag().isCancelled());
+ }
+
+ @Test public void campButtonsDoNothingWhereThereIsNoActionToTake() {
+ site.setStatus(SiteStatus.CLOSED); board(director, false, false);
+ listener.onBoardClick(click(director, CampBoard.SLOT_LIMITS)); // A filed camp shows its location instead of the prism.
+ verify(outline, never()).show(any(), any()); assertTrue(director.getOpenInventory().getTopInventory().getHolder() instanceof CampBoard);
+ site.setStatus(SiteStatus.ESTABLISHED); board(worker, false, false); listener.onBoardClick(click(worker, CampBoard.SLOT_CLOSE));
+ assertTrue(messages(worker).isEmpty()); listener.onChat(chat(worker, "confirm")); server.getScheduler().performOneTick();
+ verify(closure, never()).close(any(), any());
+ board(director, true, true); listener.onBoardClick(click(director, 0));
+ assertTrue(director.getOpenInventory().getTopInventory().getHolder() instanceof CampBoard);
+ new CampStaffBoard(site.getId(), true, 18).open(director, site); listener.onStaffClick(click(director, 20));
+ assertTrue(director.getOpenInventory().getTopInventory().getHolder() instanceof CampStaffBoard);
+ site.grantExcavator(worker.getUniqueId()); new CampWorkerBoard(site.getId(), worker.getUniqueId(), true).open(director, site);
+ listener.onWorkerClick(click(director, 0)); assertEquals(SiteRole.defaultRole(), site.roleOf(worker.getUniqueId()));
+ new CampFindsBoard(site.getId(), true, catalogs, recovered).open(director, site); listener.onFindsClick(click(director, 0));
+ assertTrue(director.getOpenInventory().getTopInventory().getHolder() instanceof CampFindsBoard);
+ new CampWoolPicker(site.getId(), CampWoolRole.PRIMARY).open(director, site); listener.onWoolPickerClick(click(director, 20));
+ assertTrue(director.getOpenInventory().getTopInventory().getHolder() instanceof CampWoolPicker);
+ CampIdentifyBoard station = mock(CampIdentifyBoard.class); when(station.siteId()).thenReturn(site.getId());
+ director.openInventory(Bukkit.createInventory(station, 9)); listener.onIdentifyClick(click(director, CampIdentifyBoard.SLOT_PIECE));
+ verify(sites, never()).save(any()); verify(establish, never()).beginRename(any(), any());
+ verify(establish, never()).beginRelocate(any(), any()); verify(establish, never()).applyCampWool(any(), any(), any(), any());
+ }
+
+ @Test public void stationRefusesToSignForAPieceTheReloadedDossierNoLongerHoldsAsRecovered() {
+ BuriedFind find = find(FindState.RECOVERED); site.getFinds().add(find);
+ InterpretationType type = new InterpretationType("purpose", "Purpose", "What was it for?", List.of());
+ CampIdentifyBoard station = mock(CampIdentifyBoard.class);
+ when(station.siteId()).thenReturn(site.getId()); when(station.findId()).thenReturn(find.getId());
+ when(station.type()).thenReturn(type); when(station.offerAt(CampIdentifyBoard.SLOT_OFFER_0)).thenReturn("vessel");
+ when(recovered.isInMainHand(director, find.getId())).thenReturn(true);
+ director.openInventory(Bukkit.createInventory(station, 9));
+ // Staff edit the dossier and run /archaeo reload while the station stays open.
+ find.setState(FindState.LOST); listener.onIdentifyClick(click(director, CampIdentifyBoard.SLOT_OFFER_0));
+ site.getFinds().clear(); listener.onIdentifyClick(click(director, CampIdentifyBoard.SLOT_OFFER_0));
+ assertEquals(List.of("That find cannot be identified.", "That find cannot be identified."), messages(director));
+ assertTrue(find.getInterpretations().isEmpty()); verify(sites, never()).save(any());
+ }
+
+ @Test public void staffInvitesRejectBlankNamesAndClosedCampsAndFileADoubleSubmissionOnce() {
+ invite(); listener.onChat(chat(director, " ")); server.getScheduler().performOneTick();
+ assertTrue(messages(director).contains("No player with that name has joined this server."));
+ listener.onChat(chat(director, "Worker")); listener.onChat(chat(director, "Worker")); // Two lines before the next tick.
+ server.getScheduler().performOneTick();
+ assertEquals(1, messages(director).stream().filter(line -> line.startsWith("Added")).count());
+ assertEquals(2, site.getExcavators().size()); verify(sites).save(site);
+ invite(); site.closeCamp(); listener.onChat(chat(director, "Worker")); server.getScheduler().performOneTick();
+ assertTrue(messages(director).contains("That excavation is no longer yours to staff."));
+ site.setStatus(SiteStatus.ESTABLISHED); invite(); when(sites.findById(site.getId())).thenReturn(Optional.empty());
+ listener.onChat(chat(director, "Worker")); server.getScheduler().performOneTick();
+ assertTrue(messages(director).contains("That excavation is no longer yours to staff.")); verify(sites).save(site);
+ }
+
+ @Test public void playersWhoHaveLeftTheServerCanStillBeHiredWithoutANotice() {
+ PlayerMock returning = server.addPlayer("Surveyor"); returning.disconnect();
+ invite(); listener.onChat(chat(director, "surveyor")); server.getScheduler().performOneTick();
+ assertTrue(site.getExcavators().contains(returning.getUniqueId()));
+ assertTrue(messages(director).contains("Added Surveyor to the excavation staff.")); verify(sites).save(site);
+ }
+
+ @Test public void fullRostersReportTheirPluralCapacityWithoutHiring() {
+ when(catalogs.establish()).thenReturn(new EstablishSettings(true, true, Material.RED_STAINED_GLASS, Material.LIGHT_BLUE_STAINED_GLASS, 2, 1));
+ invite(); site.grantExcavator(UUID.randomUUID()); // Another director session filled the last place meanwhile.
+ listener.onChat(chat(director, "Worker")); server.getScheduler().performOneTick();
+ assertTrue(messages(director).contains("This excavation already has 2 people on the staff."));
+ assertFalse(site.getExcavators().contains(worker.getUniqueId())); verify(sites, never()).save(any());
+ }
+
+ @Test public void repeatedOrLateCloseConfirmationsNeverCloseTwiceOrCloseADeletedRecord() {
+ board(director, true, true); listener.onBoardClick(click(director, CampBoard.SLOT_CLOSE));
+ listener.onChat(chat(director, "confirm")); listener.onChat(chat(director, "confirm")); server.getScheduler().performOneTick();
+ verify(closure, times(1)).close(director, site);
+ clearInvocations(closure); board(director, true, true); listener.onBoardClick(click(director, CampBoard.SLOT_CLOSE));
+ when(sites.findById(site.getId())).thenReturn(Optional.empty()); // Staff erased the ruin before the reply.
+ listener.onChat(chat(director, "confirm")); server.getScheduler().performOneTick();
+ verify(closure, never()).close(any(), any()); assertTrue(messages(director).contains("That excavation can no longer be closed."));
+ }
+
+ @Test public void damagedOrOrphanedFieldBooksReportAMissingRecord() {
+ site.setStatus(SiteStatus.CLOSED); ItemStack book = archive.create(director, site);
+ var meta = book.getItemMeta(); meta.getPersistentDataContainer().set(new NamespacedKey(plugin, "archive_site_id"),
+ org.bukkit.persistence.PersistentDataType.STRING, "not-a-uuid"); ItemStack damaged = book.clone(); damaged.setItemMeta(meta);
+ PlayerInteractEvent open = interact(worker, null, EquipmentSlot.HAND, damaged); listener.onInteract(open);
+ assertTrue(open.isCancelled()); assertEquals("That excavation record is missing.", worker.nextMessage());
+ site.setStatus(SiteStatus.HIDDEN); listener.onInteract(interact(worker, null, EquipmentSlot.HAND, book));
+ assertEquals("That excavation record is missing.", worker.nextMessage()); assertNull(worker.getOpenInventory().getTopInventory());
+ }
+
+ @Test public void onlyTheDirectorSeesTheReportButtonOnAnExhaustedRegister() {
+ site.setStatus(SiteStatus.EXHAUSTED); site.grantExcavator(worker.getUniqueId());
+ board(worker, false, false); listener.onBoardClick(click(worker, CampBoard.SLOT_DOCUMENTATION));
+ assertNull(worker.getOpenInventory().getTopInventory().getItem(CampFindsBoard.SLOT_REPORT));
+ board(director, true, true); listener.onBoardClick(click(director, CampBoard.SLOT_DOCUMENTATION));
+ assertEquals(Material.WRITTEN_BOOK, director.getOpenInventory().getTopInventory().getItem(CampFindsBoard.SLOT_REPORT).getType());
+ }
+
+ private void clickAll(InventoryClickEvent event) {
+ listener.onBoardClick(event); listener.onStaffClick(event); listener.onWorkerClick(event); listener.onFindsClick(event);
+ listener.onFindFileClick(event); listener.onIdentifyClick(event); listener.onWoolPickerClick(event);
+ }
+ private InventoryDragEvent drag() {
+ InventoryDragEvent drag = new InventoryDragEvent(director.getOpenInventory(), new ItemStack(Material.STONE),
+ new ItemStack(Material.STONE, 2), false, Map.of(0, new ItemStack(Material.STONE)));
+ listener.onBoardDrag(drag); return drag;
+ }
+ private void board(PlayerMock player, boolean director, boolean close) { new CampBoard(site.getId(), director, close, catalogs).open(player, site); }
+ private void invite() {
+ new CampStaffBoard(site.getId(), true, catalogs.establish().maxStaff()).open(director, site);
+ listener.onStaffClick(click(director, CampStaffBoard.SLOT_ADD));
+ }
+ private static InventoryClickEvent click(PlayerMock player, int slot) {
+ return new InventoryClickEvent(player.getOpenInventory(), InventoryType.SlotType.CONTAINER, slot,
+ ClickType.LEFT, InventoryAction.PICKUP_ALL);
+ }
+ private static AsyncPlayerChatEvent chat(PlayerMock player, String message) { return new AsyncPlayerChatEvent(true, player, message, new HashSet<>()); }
+ private static PlayerInteractEvent interact(PlayerMock player, Block block, EquipmentSlot hand, ItemStack item) {
+ return new PlayerInteractEvent(player, block == null ? Action.RIGHT_CLICK_AIR : Action.RIGHT_CLICK_BLOCK, item, block, BlockFace.UP, hand);
+ }
+ private static BuriedFind find(FindState state) {
+ BuriedFind find = new BuriedFind(); find.setId(UUID.randomUUID()); find.setArtifactId("pot"); find.setState(state); return find;
+ }
+ private static List messages(PlayerMock player) {
+ List messages = new ArrayList<>(); String message;
+ while ((message = player.nextMessage()) != null) messages.add(message); return messages;
+ }
+}
diff --git a/src/test/java/net/tfminecraft/archaeo/establish/CampNamesTest.java b/src/test/java/net/tfminecraft/archaeo/establish/CampNamesTest.java
new file mode 100644
index 0000000..32ffd95
--- /dev/null
+++ b/src/test/java/net/tfminecraft/archaeo/establish/CampNamesTest.java
@@ -0,0 +1,44 @@
+package net.tfminecraft.archaeo.establish;
+
+import net.tfminecraft.archaeo.model.Site;
+import org.bukkit.OfflinePlayer;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.mockbukkit.mockbukkit.MockBukkit;
+import org.mockbukkit.mockbukkit.ServerMock;
+
+import java.util.List;
+import java.util.UUID;
+
+import static org.junit.Assert.*;
+import static org.mockito.Mockito.*;
+
+public class CampNamesTest {
+ private ServerMock server;
+
+ @Before public void setUp() { server = MockBukkit.mock(); }
+ @After public void tearDown() { MockBukkit.unmock(); }
+
+ @Test public void dossiersWithoutADirectorListOnlyTheirExcavatorsOnce() {
+ Site site = new Site(); UUID first = UUID.randomUUID(), second = UUID.randomUUID();
+ site.getExcavators().addAll(List.of(first, second, first)); // Hand-edited YAML may repeat an entry.
+ assertEquals(List.of(first, second), CampNames.roster(site));
+ }
+
+ @Test public void unknownAccountsShowAShortIdAndNamelessProfilesAreNeverMatchedByName() {
+ UUID stranger = UUID.randomUUID(); OfflinePlayer nameless = nameless(stranger);
+ assertEquals(stranger.toString().substring(0, 8), CampNames.of(null, stranger));
+ when(nameless.getName()).thenReturn("Wanderer"); assertEquals("Wanderer", CampNames.of(null, stranger)); // Cached from an earlier visit.
+ when(nameless.getName()).thenReturn(null);
+ server.addPlayer("Keeper").disconnect();
+ assertEquals("Keeper", CampNames.known(" keeper ").getName());
+ assertNull(CampNames.known("Nobody"));
+ }
+
+ /** Paper returns a null name for accounts it never cached; MockBukkit invents one, so register the profile. */
+ private OfflinePlayer nameless(UUID id) {
+ OfflinePlayer profile = mock(OfflinePlayer.class); when(profile.getUniqueId()).thenReturn(id);
+ server.getPlayerList().addOfflinePlayer(profile); return profile;
+ }
+}
diff --git a/src/test/java/net/tfminecraft/archaeo/establish/CampSignsTest.java b/src/test/java/net/tfminecraft/archaeo/establish/CampSignsTest.java
new file mode 100644
index 0000000..573926a
--- /dev/null
+++ b/src/test/java/net/tfminecraft/archaeo/establish/CampSignsTest.java
@@ -0,0 +1,63 @@
+package net.tfminecraft.archaeo.establish;
+
+import org.bukkit.Material;
+import org.bukkit.block.Block;
+import org.bukkit.block.Sign;
+import org.bukkit.block.sign.Side;
+import org.bukkit.entity.Player;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.mockbukkit.mockbukkit.MockBukkit;
+
+import java.util.List;
+
+import static org.junit.Assert.*;
+import static org.mockito.Mockito.*;
+import static org.mockito.AdditionalMatchers.aryEq;
+
+public class CampSignsTest {
+ private Block block;
+ @Before public void setup() {
+ block = MockBukkit.mock().addSimpleWorld("world").getBlockAt(0, 5, 0);
+ block.setType(Material.OAK_SIGN);
+ }
+ @After public void teardown() { MockBukkit.unmock(); }
+
+ @Test public void wrapsAtWordsSplitsLongTokensAndRespectsTheFourLineSignLimit() {
+ assertEquals(List.of(), CampSigns.wrap(" \t ", 15, 4));
+ assertEquals(List.of("Old river camp"), CampSigns.wrap(" Old river camp ", 15, 4));
+ assertEquals(List.of("The ancient", "river", "settlement"),
+ CampSigns.wrap("The ancient river settlement", 15, 4));
+ assertEquals(List.of("123456789012345", "67890"), CampSigns.wrap("12345678901234567890", 15, 4));
+ assertEquals(List.of("one", "two", "three", "four"), CampSigns.wrap("one two three four five", 5, 4));
+ }
+
+ @Test public void renamingUpdatesAndWaxesFrontClearsOldLinesAndPreservesBack() {
+ // MockBukkit does not implement waxing; preserve its real front/back text storage.
+ Sign sign = spy((Sign) block.getState());
+ doNothing().when(sign).setWaxed(true);
+ block = spy(block); doReturn(sign).when(block).getState();
+ sign.getSide(Side.BACK).setLine(0, "Do not erase"); sign.update();
+ CampSigns.write(block, "Old river camp with a very long archaeological title");
+ Sign written = (Sign) block.getState(); verify(sign).setWaxed(true); verify(sign, times(2)).update();
+ assertEquals("Old river camp", written.getSide(Side.FRONT).getLine(0));
+ assertEquals("Do not erase", written.getSide(Side.BACK).getLine(0));
+ CampSigns.write(block, "Quarry");
+ assertArrayEquals(new String[]{"Quarry", "", "", ""}, ((Sign) block.getState()).getSide(Side.FRONT).getLines());
+ CampSigns.write(block, null);
+ assertArrayEquals(new String[]{"", "", "", ""}, ((Sign) block.getState()).getSide(Side.FRONT).getLines());
+ }
+
+ @Test public void restoringPreviewSendsRealBlockAndTextWithoutChangingTheServerSign() {
+ Sign before = (Sign) block.getState(); before.getSide(Side.FRONT).setLine(0, "River camp"); before.update();
+ Player viewer = mock(Player.class); CampSigns.sendTo(viewer, block);
+ var order = inOrder(viewer);
+ order.verify(viewer).sendBlockChange(block.getLocation(), before.getBlockData());
+ order.verify(viewer).sendSignChange(eq(block.getLocation()), aryEq(before.getSide(Side.FRONT).getLines()));
+ assertArrayEquals(before.getSide(Side.FRONT).getLines(), ((Sign) block.getState()).getSide(Side.FRONT).getLines());
+ block.setType(Material.STONE); clearInvocations(viewer);
+ CampSigns.write(block, "ignored"); CampSigns.sendTo(viewer, block);
+ assertEquals(Material.STONE, block.getType()); verifyNoInteractions(viewer);
+ }
+}
diff --git a/src/test/java/net/tfminecraft/archaeo/establish/CampTemplateTest.java b/src/test/java/net/tfminecraft/archaeo/establish/CampTemplateTest.java
new file mode 100644
index 0000000..ccf5fb6
--- /dev/null
+++ b/src/test/java/net/tfminecraft/archaeo/establish/CampTemplateTest.java
@@ -0,0 +1,103 @@
+package net.tfminecraft.archaeo.establish;
+
+import org.bukkit.DyeColor;
+import org.bukkit.Material;
+import org.bukkit.block.BlockFace;
+import org.bukkit.block.data.Directional;
+import org.bukkit.block.data.Lightable;
+import org.bukkit.block.data.Rotatable;
+import org.bukkit.block.data.type.Slab;
+import org.bukkit.util.Vector;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.mockbukkit.mockbukkit.MockBukkit;
+
+import java.util.HashSet;
+import java.util.Set;
+
+import static org.junit.Assert.*;
+
+public class CampTemplateTest {
+ @Before public void setUp() { MockBukkit.mock(); }
+ @After public void tearDown() { MockBukkit.unmock(); }
+
+ @Test
+ public void templateIsAnImmutableUniqueCampAndRecolorsOnlyWoolRoles() {
+ var defaults = CampTemplate.basic();
+ assertEquals(defaults, CampTemplate.basic(null, null));
+ var custom = CampTemplate.basic(Material.BLUE_WOOL, Material.YELLOW_WOOL);
+ assertEquals(defaults.size(), custom.size());
+ Set cells = new HashSet<>();
+ for (int i = 0; i < defaults.size(); i++) {
+ var before = defaults.get(i); var after = custom.get(i);
+ assertTrue(cells.add(new Vector(before.dx(), before.dy(), before.dz())));
+ assertEquals(before.dx(), after.dx()); assertEquals(before.dy(), after.dy()); assertEquals(before.dz(), after.dz());
+ Material expected = before.wool() == CampWoolRole.PRIMARY ? Material.BLUE_WOOL
+ : before.wool() == CampWoolRole.SECONDARY ? Material.YELLOW_WOOL : before.material();
+ assertEquals(expected, after.material());
+ }
+ assertEquals(1, defaults.stream().filter(p -> p.material() == Material.OAK_SIGN).count());
+ assertEquals(1, defaults.stream().filter(p -> p.material() == Material.CAMPFIRE).count());
+ assertThrows(UnsupportedOperationException.class, defaults::clear);
+ }
+
+ @Test
+ public void facingWrapsYawAndSnapsAtCardinalBoundaries() {
+ float[] angles = {-540, -180, 180, 540, 135, -135.01f, 179};
+ for (float angle : angles) assertEquals(BlockFace.NORTH, CampTemplate.facingFromYaw(angle));
+ for (float angle : new float[]{-45, 0, 44.99f, 360, 720}) assertEquals(BlockFace.SOUTH, CampTemplate.facingFromYaw(angle));
+ for (float angle : new float[]{45, 90, 134.99f, 450}) assertEquals(BlockFace.WEST, CampTemplate.facingFromYaw(angle));
+ for (float angle : new float[]{-135, -90, -45.01f, 270}) assertEquals(BlockFace.EAST, CampTemplate.facingFromYaw(angle));
+ }
+
+ @Test
+ public void rotationAndClampingKeepEveryPieceInsideItsChunk() {
+ assertEquals(new Vector(2, 0, 1), CampTemplate.rotate(2, 1, BlockFace.SOUTH));
+ assertEquals(new Vector(-1, 0, 2), CampTemplate.rotate(2, 1, BlockFace.WEST));
+ assertEquals(new Vector(-2, 0, -1), CampTemplate.rotate(2, 1, BlockFace.NORTH));
+ assertEquals(new Vector(1, 0, -2), CampTemplate.rotate(2, 1, BlockFace.EAST));
+ for (BlockFace facing : new BlockFace[]{BlockFace.SOUTH, BlockFace.WEST, BlockFace.NORTH, BlockFace.EAST}) {
+ int[] bounds = CampTemplate.offsetBounds(CampTemplate.basic(), facing);
+ for (int desired : new int[]{-100, -16, -8, -1, 100}) {
+ int x = CampTemplate.clampOrigin(desired, bounds[0], bounds[1], -16, -1);
+ int z = CampTemplate.clampOrigin(desired, bounds[2], bounds[3], -16, -1);
+ for (var piece : CampTemplate.basic()) {
+ Vector offset = CampTemplate.rotate(piece.dx(), piece.dz(), facing);
+ assertTrue(x + offset.getBlockX() >= -16 && x + offset.getBlockX() <= -1);
+ assertTrue(z + offset.getBlockZ() >= -16 && z + offset.getBlockZ() <= -1);
+ }
+ }
+ }
+ assertEquals(7, CampTemplate.clampOrigin(100, -20, 20, 0, 15));
+ }
+
+ @Test
+ public void blockStatesFaceTheCampAndProvideLitFireAndBottomSlab() {
+ for (BlockFace facing : new BlockFace[]{BlockFace.SOUTH, BlockFace.WEST, BlockFace.NORTH, BlockFace.EAST}) {
+ assertEquals(facing.getOppositeFace(), ((Rotatable) CampTemplate.dataFor(Material.OAK_SIGN, facing)).getRotation());
+ var fire = CampTemplate.dataFor(Material.CAMPFIRE, facing);
+ assertEquals(facing, ((Directional) fire).getFacing());
+ assertTrue(((Lightable) fire).isLit());
+ assertEquals(Slab.Type.BOTTOM, ((Slab) CampTemplate.dataFor(Material.OAK_SLAB, facing)).getType());
+ assertEquals(Material.WHITE_WOOL, CampTemplate.dataFor(Material.WHITE_WOOL, facing).getMaterial());
+ }
+ }
+
+ @Test
+ public void woolSelectionWrapsAndInvalidSavedColorsUseTheFallback() {
+ DyeColor[] palette = CampWools.palette();
+ DyeColor first = palette[0]; palette[0] = DyeColor.BLACK;
+ assertEquals(first, CampWools.palette()[0]);
+ for (DyeColor color : DyeColor.values()) {
+ assertEquals(color, CampWools.parse(color.name().toLowerCase().replace('_', ' ')));
+ assertEquals(Material.valueOf(color.name() + "_WOOL"), CampWools.woolOf(color.name()));
+ assertEquals(DyeColor.values()[(color.ordinal() + 1) % DyeColor.values().length], CampWools.next(color.name()));
+ }
+ for (String invalid : new String[]{null, " ", "unknown"}) {
+ assertEquals(DyeColor.RED, CampWools.parse(invalid));
+ assertEquals(Material.BLUE_WOOL, CampWools.woolOf(invalid, DyeColor.BLUE));
+ }
+ assertEquals("light blue wool", CampWools.label(DyeColor.LIGHT_BLUE));
+ }
+}
diff --git a/src/test/java/net/tfminecraft/archaeo/establish/EstablishListenerTest.java b/src/test/java/net/tfminecraft/archaeo/establish/EstablishListenerTest.java
new file mode 100644
index 0000000..bbb0468
--- /dev/null
+++ b/src/test/java/net/tfminecraft/archaeo/establish/EstablishListenerTest.java
@@ -0,0 +1,143 @@
+package net.tfminecraft.archaeo.establish;
+
+import net.tfminecraft.archaeo.item.EstablishItem;
+import org.bukkit.Bukkit;
+import org.bukkit.World;
+import org.bukkit.block.Block;
+import org.bukkit.entity.Player;
+import org.bukkit.event.block.Action;
+import org.bukkit.event.player.*;
+import org.bukkit.inventory.EquipmentSlot;
+import org.bukkit.inventory.ItemStack;
+import org.bukkit.inventory.PlayerInventory;
+import org.mockito.MockedStatic;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+import static org.junit.Assert.*;
+import static org.mockito.Mockito.*;
+
+public class EstablishListenerTest {
+ private EstablishItem kit;
+ private EstablishService service;
+ private EstablishListener listener;
+ private Player player;
+ private ItemStack stack;
+ private Block block;
+ private World world;
+ private MockedStatic bukkit;
+
+ @Before public void setup() {
+ kit = mock(EstablishItem.class); service = mock(EstablishService.class);
+ listener = new EstablishListener(kit, service); player = mock(Player.class);
+ stack = mock(ItemStack.class); block = mock(Block.class);
+ when(kit.isEstablish(stack)).thenReturn(true);
+ // doDaylightCycle is off, so the day clock never moves; only the server tick does.
+ world = mock(World.class); when(player.getWorld()).thenReturn(world); when(world.getFullTime()).thenReturn(100L);
+ bukkit = mockStatic(Bukkit.class); bukkit.when(Bukkit::getCurrentTick).thenReturn(100);
+ when(player.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
+ }
+
+ @After public void teardown() { bukkit.close(); }
+
+ @Test public void onlyKitInteractionsPlaceCampsAndExistingCampClicksPassThrough() {
+ PlayerInventory inventory = mock(PlayerInventory.class); when(player.getInventory()).thenReturn(inventory);
+ ItemStack mainKit = mock(ItemStack.class); when(inventory.getItemInMainHand()).thenReturn(mainKit); when(kit.isEstablish(mainKit)).thenReturn(true);
+ listener.onInteract(click(Action.RIGHT_CLICK_BLOCK, EquipmentSlot.OFF_HAND)); // The main-hand kit already acted.
+ when(kit.isEstablish(stack)).thenReturn(false);
+ listener.onInteract(click(Action.RIGHT_CLICK_BLOCK, EquipmentSlot.HAND));
+ verifyNoInteractions(service);
+ when(kit.isEstablish(stack)).thenReturn(true);
+ when(service.isLockedCampBlock(block)).thenReturn(true);
+ PlayerInteractEvent camp = click(Action.RIGHT_CLICK_BLOCK, EquipmentSlot.HAND);
+ listener.onInteract(camp); assertFalse(camp.isCancelled());
+ verify(service, never()).tryUseKit(any(), any());
+ when(service.isLockedCampBlock(block)).thenReturn(false);
+ for (Action action : new Action[]{Action.RIGHT_CLICK_BLOCK, Action.RIGHT_CLICK_AIR}) {
+ PlayerInteractEvent event = click(action, EquipmentSlot.HAND);
+ listener.onInteract(event); assertTrue(event.isCancelled());
+ }
+ verify(service).tryUseKit(player, block);
+ verify(service).tryUseKit(player, null);
+ }
+
+ @Test public void leftClickCancelsMovesBeforeConsideringWoolAndRequiresSneakToCycle() {
+ when(service.tryCancelMove(player)).thenReturn(true);
+ PlayerInteractEvent moving = click(Action.LEFT_CLICK_AIR, EquipmentSlot.HAND);
+ listener.onInteract(moving); assertTrue(moving.isCancelled());
+ verify(service, never()).sneakHeld(any());
+ when(service.tryCancelMove(player)).thenReturn(false);
+ PlayerInteractEvent normal = click(Action.LEFT_CLICK_BLOCK, EquipmentSlot.HAND);
+ listener.onInteract(normal); assertFalse(normal.isCancelled());
+ when(service.sneakHeld(player)).thenReturn(true);
+ PlayerInteractEvent cycling = click(Action.LEFT_CLICK_BLOCK, EquipmentSlot.HAND);
+ listener.onInteract(cycling); assertTrue(cycling.isCancelled());
+ verify(service).tryCycleWool(player);
+ clearInvocations(service);
+ listener.onInteract(click(Action.PHYSICAL, EquipmentSlot.HAND)); verifyNoInteractions(service);
+ }
+
+ @Test public void hotbarMovementAbortsRelocationAndSneakScrollingCyclesOnlyTheHeldKit() {
+ PlayerInventory inventory = mock(PlayerInventory.class); when(player.getInventory()).thenReturn(inventory);
+ when(inventory.getItem(2)).thenReturn(stack);
+ when(service.isRelocating(player)).thenReturn(true);
+ PlayerItemHeldEvent moving = new PlayerItemHeldEvent(player, 2, 3); listener.onHeld(moving);
+ verify(service).tryCancelMove(player); assertFalse(moving.isCancelled());
+ when(service.isRelocating(player)).thenReturn(false);
+ PlayerItemHeldEvent ordinary = new PlayerItemHeldEvent(player, 2, 3); listener.onHeld(ordinary);
+ assertFalse(ordinary.isCancelled()); verify(service, never()).cycleWool(player);
+ when(service.sneakHeld(player)).thenReturn(true); when(kit.isEstablish(stack)).thenReturn(false);
+ listener.onHeld(ordinary); assertFalse(ordinary.isCancelled());
+ when(kit.isEstablish(stack)).thenReturn(true);
+ listener.onHeld(ordinary); assertTrue(ordinary.isCancelled()); verify(service).cycleWool(player);
+ }
+
+ @Test public void disconnectAndWorldChangeClearSessionAndRestoreTheCorrectWorld() {
+ listener.onQuit(new PlayerQuitEvent(player, "bye"));
+ verify(service).clearSession(player); verify(service).clearPreview(player); verify(service).hideHud(player);
+ clearInvocations(service); World old = mock(World.class);
+ listener.onWorldChange(new PlayerChangedWorldEvent(player, old));
+ verify(service).clearSession(player); verify(service).clearPreviewFromWorld(player, old);
+ verify(service, never()).clearPreview(player);
+ }
+
+ @Test public void aKitHeldOnlyInTheOffHandPlantsTheCampItsGhostPromised() {
+ PlayerInventory inventory = mock(PlayerInventory.class); when(player.getInventory()).thenReturn(inventory);
+ when(inventory.getItemInMainHand()).thenReturn(mock(ItemStack.class)); // An empty or ordinary main hand.
+ PlayerInteractEvent offHand = click(Action.RIGHT_CLICK_AIR, EquipmentSlot.OFF_HAND);
+ listener.onInteract(offHand); assertTrue(offHand.isCancelled()); verify(service).tryUseKit(player, null);
+ when(kit.isEstablish(stack)).thenReturn(false); clearInvocations(service);
+ listener.onInteract(click(Action.RIGHT_CLICK_BLOCK, EquipmentSlot.OFF_HAND)); verifyNoInteractions(service);
+ }
+
+ @Test public void spendingTheLastMainHandKitDoesNotReplayTheClickFromAnOffHandKit() {
+ PlayerInventory inventory = mock(PlayerInventory.class); when(player.getInventory()).thenReturn(inventory);
+ ItemStack empty = mock(ItemStack.class); when(inventory.getItemInMainHand()).thenReturn(stack);
+ // The kit is a stick, so the client sends the off-hand packet too; the main-hand plant spends the last kit.
+ doAnswer(call -> { when(inventory.getItemInMainHand()).thenReturn(empty); return null; })
+ .when(service).tryUseKit(player, block);
+ listener.onInteract(click(Action.RIGHT_CLICK_BLOCK, EquipmentSlot.HAND));
+ PlayerInteractEvent offHand = click(Action.RIGHT_CLICK_BLOCK, EquipmentSlot.OFF_HAND);
+ listener.onInteract(offHand);
+ verify(service, times(1)).tryUseKit(any(), any()); assertFalse(offHand.isCancelled());
+ bukkit.when(Bukkit::getCurrentTick).thenReturn(101); // Next click: only the off-hand kit remains.
+ PlayerInteractEvent later = click(Action.RIGHT_CLICK_BLOCK, EquipmentSlot.OFF_HAND);
+ listener.onInteract(later); assertTrue(later.isCancelled()); verify(service, times(2)).tryUseKit(player, block);
+ }
+
+ @Test public void aMainHandKitClickWithoutAnOffHandPacketDoesNotBlockALaterOffHandPlant() {
+ PlayerInventory inventory = mock(PlayerInventory.class); when(player.getInventory()).thenReturn(inventory);
+ when(inventory.getItemInMainHand()).thenReturn(stack);
+ // The client skips the off-hand packet when the main-hand click was handled on its side.
+ listener.onInteract(click(Action.RIGHT_CLICK_AIR, EquipmentSlot.HAND));
+ when(inventory.getItemInMainHand()).thenReturn(mock(ItemStack.class)); bukkit.when(Bukkit::getCurrentTick).thenReturn(160);
+ PlayerInteractEvent offHand = click(Action.RIGHT_CLICK_AIR, EquipmentSlot.OFF_HAND);
+ listener.onInteract(offHand); assertTrue(offHand.isCancelled()); verify(service, times(2)).tryUseKit(player, null);
+ }
+
+ private PlayerInteractEvent click(Action action, EquipmentSlot hand) {
+ Block clicked = action == Action.RIGHT_CLICK_AIR || action == Action.LEFT_CLICK_AIR ? null : block;
+ return new PlayerInteractEvent(player, action, stack, clicked, org.bukkit.block.BlockFace.UP, hand);
+ }
+}
diff --git a/src/test/java/net/tfminecraft/archaeo/establish/EstablishServiceTest.java b/src/test/java/net/tfminecraft/archaeo/establish/EstablishServiceTest.java
new file mode 100644
index 0000000..0c11d00
--- /dev/null
+++ b/src/test/java/net/tfminecraft/archaeo/establish/EstablishServiceTest.java
@@ -0,0 +1,746 @@
+package net.tfminecraft.archaeo.establish;
+
+import net.tfminecraft.archaeo.config.EstablishSettings;
+import net.tfminecraft.archaeo.excavation.FindDustService;
+import net.tfminecraft.archaeo.item.EstablishItem;
+import net.tfminecraft.archaeo.item.ItemRef;
+import net.tfminecraft.archaeo.item.SiteLabelRefresh;
+import net.tfminecraft.archaeo.model.*;
+import net.tfminecraft.archaeo.site.SiteRepository;
+import org.bukkit.*;
+import org.bukkit.block.Block;
+import org.bukkit.block.Sign;
+import org.bukkit.block.sign.Side;
+import org.bukkit.block.data.BlockData;
+import org.bukkit.boss.BossBar;
+import org.bukkit.entity.ArmorStand;
+import org.bukkit.entity.Entity;
+import org.bukkit.entity.Player;
+import org.bukkit.entity.Pose;
+import org.bukkit.inventory.ItemStack;
+import org.bukkit.inventory.PlayerInventory;
+import org.bukkit.plugin.java.JavaPlugin;
+import org.bukkit.scheduler.BukkitScheduler;
+import org.bukkit.scheduler.BukkitTask;
+import org.bukkit.util.BlockIterator;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.mockbukkit.mockbukkit.MockBukkit;
+import org.mockbukkit.mockbukkit.world.WorldMock;
+import org.mockito.ArgumentCaptor;
+
+import java.util.*;
+import java.util.function.Consumer;
+
+import static org.junit.Assert.*;
+import static org.mockito.Mockito.*;
+
+public class EstablishServiceTest {
+ private JavaPlugin plugin;
+ private Server server;
+ private WorldMock world;
+ private Player player;
+ private PlayerInventory inventory;
+ private SiteRepository sites;
+ private EstablishService service;
+ private Site site;
+ private BossBar bar;
+ private BukkitTask repeating;
+ private ItemStack kits;
+ private final List records = new ArrayList<>();
+ private final List scheduled = new ArrayList<>();
+ private ArmorStand proxy;
+
+ @Before public void setup() {
+ var mockServer = MockBukkit.mock();
+ world = spy(new WorldMock(Material.GRASS_BLOCK, -64, 320, 3));
+ world.setName("world"); mockServer.addWorld(world);
+ plugin = mock(JavaPlugin.class); server = mock(Server.class); player = mock(Player.class);
+ inventory = mock(PlayerInventory.class); sites = mock(SiteRepository.class); bar = mock(BossBar.class);
+ UUID playerId = UUID.randomUUID();
+ when(plugin.getServer()).thenReturn(server); when(server.getWorld("world")).thenReturn(world);
+ when(server.getPlayer(playerId)).thenReturn(player); doReturn(List.of(player)).when(server).getOnlinePlayers();
+ when(player.getUniqueId()).thenReturn(playerId); when(player.getWorld()).thenReturn(world);
+ when(player.getLocation()).thenAnswer(call -> new Location(world, 20, 4, 4));
+ when(player.getEyeLocation()).thenAnswer(call -> new Location(world, 20, 5.6, 4));
+ when(player.getInventory()).thenReturn(inventory); when(player.isOnline()).thenReturn(true);
+ when(player.getPose()).thenReturn(Pose.STANDING);
+ kits = new ItemStack(Material.STICK, 2);
+ when(inventory.getItemInMainHand()).thenReturn(kits);
+ when(inventory.getItemInOffHand()).thenReturn(new ItemStack(Material.AIR));
+ doReturn(world.getBlockAt(20, 3, 4)).when(player).getTargetBlockExact(64);
+ when(server.createBossBar(anyString(), any(), any())).thenReturn(bar);
+ BukkitScheduler scheduler = mock(BukkitScheduler.class); when(server.getScheduler()).thenReturn(scheduler);
+ repeating = mock(BukkitTask.class);
+ when(scheduler.runTaskTimer(eq(plugin), any(Runnable.class), eq(20L), eq(5L))).thenReturn(repeating);
+ when(scheduler.runTask(eq(plugin), any(Runnable.class))).thenAnswer(call -> {
+ scheduled.add(call.getArgument(1)); return mock(BukkitTask.class);
+ });
+ site = hidden("world", 0, 0); site.confirmProspect(playerId); records.add(site);
+ when(sites.all()).thenAnswer(call -> List.copyOf(records));
+ when(sites.findById(any())).thenAnswer(call -> records.stream().filter(s -> s.getId().equals(call.getArgument(0))).findFirst());
+ when(sites.findByChunk(anyString(), anyInt(), anyInt())).thenAnswer(call -> records.stream()
+ .filter(s -> s.getWorldName().equals(call.getArgument(0)) && s.getChunkX() == (int) call.getArgument(1)
+ && s.getChunkZ() == (int) call.getArgument(2)).findFirst());
+ when(sites.findByEstablishmentChunk(anyString(), anyInt(), anyInt())).thenAnswer(call -> records.stream()
+ .filter(s -> s.hasEstablishment() && s.getWorldName().equals(call.getArgument(0))
+ && s.getEstablishmentChunkX() == (int) call.getArgument(1)
+ && s.getEstablishmentChunkZ() == (int) call.getArgument(2)).findFirst());
+ service = new EstablishService(plugin, sites, new EstablishItem(ItemRef.vanilla(Material.STICK)), EstablishSettings.defaults());
+ proxy = mock(ArmorStand.class); UUID proxyId = UUID.randomUUID();
+ when(proxy.getUniqueId()).thenReturn(proxyId); when(proxy.getWorld()).thenReturn(world); when(proxy.isValid()).thenReturn(true);
+ when(server.getEntity(proxyId)).thenReturn(proxy);
+ doAnswer(call -> { Consumer configure = call.getArgument(2); configure.accept(proxy); return proxy; })
+ .when(world).spawn(any(Location.class), eq(ArmorStand.class), any(Consumer.class));
+ }
+
+ @After public void teardown() { MockBukkit.unmock(); }
+
+ @Test public void previewIsClientOnlyAndConfirmPlantsTheShownCampConsumesOneKitAndPersists() {
+ FindDustService dust = mock(FindDustService.class); service.setFindDust(dust);
+ Block ground = world.getBlockAt(20, 3, 4);
+ service.pulse();
+ verify(player, atLeastOnce()).sendBlockChange(any(Location.class), any(BlockData.class));
+ verify(bar).setColor(org.bukkit.boss.BarColor.GREEN);
+ assertEquals(SiteStatus.HIDDEN, site.getStatus()); assertTrue(site.getCampBlocks().isEmpty());
+ assertEquals(Material.GRASS_BLOCK, ground.getType());
+ assertEquals(Material.AIR, world.getBlockAt(18, 4, 6).getType());
+ when(player.getTargetBlockExact(64)).thenReturn(null);
+ service.tryUseKit(player, null);
+ assertEquals(SiteStatus.ESTABLISHED, site.getStatus()); assertEquals(player.getUniqueId(), site.getDirector());
+ assertEquals(Integer.valueOf(1), site.getEstablishmentChunkX()); assertEquals(Integer.valueOf(0), site.getEstablishmentChunkZ());
+ assertEquals(Integer.valueOf(20), site.getCampX()); assertFalse(site.getCampBlocks().isEmpty());
+ assertNotNull(site.getCampSignX()); assertEquals(1, kits.getAmount());
+ for (BlockCell cell : site.getCampBlocks()) assertFalse(world.getBlockAt(cell.x(), cell.y(), cell.z()).getType().isAir());
+ verify(sites).save(site); verify(dust).syncTimer();
+ assertFalse(scheduled.isEmpty()); scheduled.forEach(Runnable::run);
+ verify(player).sendMessage("You established an archaeological excavation.");
+ }
+
+ @Test public void invalidTerrainAndNonNeighborTargetsLeaveWorldAndInventoryUntouched() {
+ assertRejected(null, CampPlacement.Issue.LOOK_MISS);
+ assertRejected(world.getBlockAt(4, 3, 4), CampPlacement.Issue.ON_DIG);
+ assertRejected(world.getBlockAt(20, 3, 20), CampPlacement.Issue.NOT_NEIGHBOR);
+ records.add(hidden("world", 1, 0));
+ assertRejected(world.getBlockAt(20, 3, 4), CampPlacement.Issue.OCCUPIED); records.removeLast();
+ var obstruction = spy(world.getBlockAt(18, 4, 6));
+ doReturn(false).when(obstruction).isPassable();
+ doReturn(obstruction).when(world).getBlockAt(18, 4, 6);
+ obstruction.setType(Material.STONE);
+ assertRejected(world.getBlockAt(20, 3, 4), CampPlacement.Issue.BLOCKED);
+ world.getBlockAt(18, 4, 6).setType(Material.AIR); world.getBlockAt(18, 3, 6).setType(Material.AIR);
+ assertRejected(world.getBlockAt(20, 3, 4), CampPlacement.Issue.UNSUPPORTED);
+ world.getBlockAt(18, 3, 6).setType(Material.WATER);
+ assertRejected(world.getBlockAt(20, 3, 4), CampPlacement.Issue.FLUID);
+ assertEquals(2, kits.getAmount()); assertEquals(SiteStatus.HIDDEN, site.getStatus()); verify(sites, never()).save(any());
+ }
+
+ @Test public void eligibilityRequiresConfirmationNearbyWorldAndDirectorCapacity() {
+ site.getProspectConfirmed().clear(); service.tryUseKit(player, null);
+ verify(player).sendMessage("Look at a chunk next to a ruin you have confirmed.");
+ site.confirmProspect(player.getUniqueId()); site.setWorldName("other"); service.pulse();
+ verify(bar).setTitle(contains("No confirmed ruin nearby"));
+ site.setWorldName("world"); site.setChunkX(10); service.tryUseKit(player, null);
+ site.setChunkX(0); when(sites.countDirectedCamps(player.getUniqueId())).thenReturn(1);
+ service.tryUseKit(player, null); verify(player).sendMessage(contains("already direct an excavation"));
+ service.setSettings(settings(true, 2)); when(sites.countDirectedCamps(player.getUniqueId())).thenReturn(2);
+ service.pulse(); service.tryUseKit(player, null); verify(player).sendMessage(contains("already direct 2 excavations"));
+ assertEquals(SiteStatus.HIDDEN, site.getStatus());
+ service.setSettings(settings(true, 0)); service.tryUseKit(player, null);
+ assertEquals(SiteStatus.ESTABLISHED, site.getStatus());
+ }
+
+ @Test public void nearestConfirmedRuinWinsAndAnOffhandKitIsConsumed() {
+ Site farther = hidden("world", 0, 1); farther.confirmProspect(player.getUniqueId()); records.addFirst(farther);
+ when(inventory.getItemInMainHand()).thenReturn(new ItemStack(Material.AIR));
+ when(inventory.getItemInOffHand()).thenReturn(kits);
+ service.pulse(); service.tryUseKit(player, null);
+ assertEquals(SiteStatus.ESTABLISHED, site.getStatus()); assertEquals(SiteStatus.HIDDEN, farther.getStatus());
+ assertEquals(1, kits.getAmount());
+ }
+
+ @Test public void previewDiffsAvoidDuplicatePacketsAndClearRestoresRealBlocks() {
+ service.pulse(); clearInvocations(player); service.pulse();
+ verify(player, never()).sendBlockChange(any(Location.class), any(BlockData.class));
+ service.clearPreview(player);
+ ArgumentCaptor locations = ArgumentCaptor.forClass(Location.class);
+ ArgumentCaptor data = ArgumentCaptor.forClass(BlockData.class);
+ verify(player, atLeastOnce()).sendBlockChange(locations.capture(), data.capture());
+ for (int i = 0; i < locations.getAllValues().size(); i++) {
+ Location at = locations.getAllValues().get(i);
+ assertEquals(world.getBlockAt(at).getBlockData(), data.getAllValues().get(i));
+ }
+ clearInvocations(player); service.clearPreview(player);
+ verify(player, never()).sendBlockChange(any(Location.class), any(BlockData.class));
+ }
+
+ @Test public void renameValidatesOwnershipAndRetitlesStoredFindsAfterSaving() {
+ establishDirectly(); SiteLabelRefresh labels = mock(SiteLabelRefresh.class); service.setLabelRefresh(labels);
+ service.beginRename(player, site); assertTrue(service.isRenaming(player));
+ assertTrue(service.handleRenameChat(player, "§ ")); assertTrue(service.isRenaming(player));
+ assertTrue(service.handleRenameChat(player, "x".repeat(41))); assertTrue(service.isRenaming(player));
+ assertTrue(service.handleRenameChat(player, " River § camp "));
+ assertEquals("River camp", site.getName()); assertFalse(service.isRenaming(player));
+ verify(sites).save(site); verify(labels).retitle(site);
+ assertFalse(service.handleRenameChat(player, "ordinary chat"));
+ service.beginRename(player, site); assertTrue(service.handleRenameChat(player, "CaNcEl"));
+ assertEquals("River camp", site.getName());
+ service.beginRename(player, site); service.abortRename(player); assertFalse(service.isRenaming(player));
+ service.beginRename(player, site); site.setDirector(UUID.randomUUID());
+ assertTrue(service.handleRenameChat(player, "Unauthorized")); assertEquals("River camp", site.getName());
+ service.beginRename(player, site); site.setStatus(SiteStatus.CLOSED);
+ assertTrue(service.handleRenameChat(player, "Closed")); verify(player).sendMessage("That excavation is no longer active.");
+ }
+
+ @Test public void woolCyclingIsOncePerTickAndRequiresSneakingWithTheKit() {
+ assertFalse(service.tryCycleWool(player)); when(player.getPose()).thenReturn(Pose.SNEAKING);
+ assertTrue(service.tryCycleWool(player)); assertTrue(service.tryCycleWool(player));
+ service.tryUseKit(player, null);
+ assertEquals(CampWools.next("RED").name(), site.getCampWoolSecondary());
+ service.beginRelocate(player, site); assertFalse(service.tryCycleWool(player));
+ service.cancelRelocate(player);
+ when(inventory.getItemInMainHand()).thenReturn(new ItemStack(Material.AIR));
+ assertFalse(service.tryCycleWool(player));
+ }
+
+ @Test public void recoloringChangesOnlyTheSelectedRoleAndRequiresTheDirector() {
+ service.tryUseKit(player, null);
+ List originals = List.copyOf(site.getCampBlocks());
+ service.applyCampWool(player, site, DyeColor.BLUE, CampWoolRole.PRIMARY);
+ assertEquals("BLUE", site.getCampWoolPrimary());
+ assertTrue(originals.stream().anyMatch(c -> world.getBlockAt(c.x(), c.y(), c.z()).getType() == Material.BLUE_WOOL));
+ assertTrue(originals.stream().anyMatch(c -> world.getBlockAt(c.x(), c.y(), c.z()).getType() == Material.RED_WOOL));
+ service.applyCampWool(player, site, DyeColor.YELLOW, CampWoolRole.SECONDARY);
+ assertEquals("YELLOW", site.getCampWoolSecondary());
+ site.setDirector(UUID.randomUUID()); clearInvocations(sites);
+ service.applyCampWool(player, site, DyeColor.GREEN, CampWoolRole.PRIMARY);
+ assertEquals("BLUE", site.getCampWoolPrimary()); verify(sites, never()).save(any());
+ }
+
+ @Test public void legacyCampRecoloringUsesRecordedCellsWithoutTouchingOtherMaterials() {
+ establishDirectly(); site.setCampFacing("legacy-facing");
+ Block wool = world.getBlockAt(20, 4, 4), stone = world.getBlockAt(21, 4, 4);
+ wool.setType(Material.WHITE_WOOL); stone.setType(Material.STONE);
+ site.getCampBlocks().addAll(List.of(new BlockCell(20, 4, 4), new BlockCell(21, 4, 4)));
+ service.applyCampWool(player, site, DyeColor.BLACK, CampWoolRole.PRIMARY);
+ assertEquals(Material.BLACK_WOOL, wool.getType()); assertEquals(Material.STONE, stone.getType());
+ assertEquals("BLACK", site.getCampWoolPrimary()); verify(sites).save(site);
+ }
+
+ @Test public void relocationPreviewHidesOldCampAndConfirmationMovesItWithoutSpendingAKit() {
+ service.tryUseKit(player, null); List old = List.copyOf(site.getCampBlocks());
+ service.beginRelocate(player, site);
+ Block destination = spy(world.getBlockAt(-8, 3, 4));
+ doReturn(false).when(destination).isPassable();
+ try (var rays = mockConstruction(BlockIterator.class, (iterator, context) -> {
+ when(iterator.hasNext()).thenReturn(true, false); when(iterator.next()).thenReturn(destination);
+ })) {
+ service.pulse(); service.pulse();
+ verify(proxy).teleport(any(Location.class));
+ verify(proxy).setInvisible(true); verify(proxy).setPersistent(false);
+ assertTrue(service.isRelocating(player));
+ assertFalse(service.tryFinishMoveOnAimProxy(player, mock(Entity.class)));
+ assertTrue(service.tryFinishMoveOnAimProxy(player, proxy));
+ }
+ assertFalse(service.isRelocating(player)); assertEquals(Integer.valueOf(-1), site.getEstablishmentChunkX());
+ assertEquals(1, kits.getAmount());
+ for (BlockCell cell : old) assertEquals(Material.AIR, world.getBlockAt(cell.x(), cell.y(), cell.z()).getType());
+ verify(proxy).remove(); verify(player).sendMessage("Camp moved.");
+ }
+
+ @Test public void cancellingOrDisablingRelocationPreservesExistingCampAndDropsSessions() {
+ service.tryUseKit(player, null); List old = List.copyOf(site.getCampBlocks());
+ service.beginRelocate(player, site); service.beginRename(player, site);
+ service.abortSessionsFor(site.getId()); assertFalse(service.isRelocating(player)); assertFalse(service.isRenaming(player));
+ service.beginRelocate(player, site); assertTrue(service.tryCancelMove(player)); assertFalse(service.tryCancelMove(player));
+ service.beginRelocate(player, site); service.setSettings(settings(false, 1)); service.pulse();
+ assertFalse(service.isRelocating(player)); assertEquals(old, site.getCampBlocks());
+ for (BlockCell cell : old) assertFalse(world.getBlockAt(cell.x(), cell.y(), cell.z()).getType().isAir());
+ service.beginRename(player, site); service.clearSession(player); assertFalse(service.isRenaming(player));
+ assertFalse(service.tryFinishMove(player)); assertFalse(service.handleMoveAimProxyAttack(player, null));
+ }
+
+ @Test public void shutdownCancelsPreviewTaskAndRemovesHudAndGhosts() {
+ service.start(); service.pulse(); service.stop(); service.stop();
+ verify(repeating).cancel(); verify(bar).removeAll();
+ assertEquals(SiteStatus.HIDDEN, site.getStatus()); assertEquals(2, kits.getAmount());
+ }
+
+
+ @Test public void claimingPreviouslyDestroyedTerrainRecordsLossWithoutBlamingTheDirector() {
+ BuriedFind find = new BuriedFind(); find.setId(UUID.randomUUID()); find.setArtifactId("coin");
+ BlockCell missing = new BlockCell(1, 1, 1); find.getCells().add(missing); site.getFinds().add(find);
+ world.getBlockAt(1, 1, 1).setType(Material.AIR);
+ service.tryUseKit(player, null);
+ assertEquals(SiteStatus.ESTABLISHED, site.getStatus()); assertEquals(FindState.LOST, find.getState());
+ assertTrue(find.isDisturbedBeforeDig()); assertFalse(find.isFieldDamaged());
+ assertNull(find.getRecoveredBy()); assertTrue(find.getFindNumber() > 0);
+ verify(player).sendMessage("One find was already disturbed before this dig opened, 1 beyond recovery.");
+ verify(sites).save(site);
+ }
+
+ @Test public void attackingMoveProxyCancelsTheMoveAndRestoresTheExistingCamp() {
+ service.tryUseKit(player, null); List original = List.copyOf(site.getCampBlocks());
+ service.beginRelocate(player, site);
+ Block destination = spy(world.getBlockAt(-8, 3, 4)); doReturn(false).when(destination).isPassable();
+ try (var rays = mockConstruction(BlockIterator.class, (iterator, context) -> {
+ when(iterator.hasNext()).thenReturn(true, false); when(iterator.next()).thenReturn(destination);
+ })) {
+ service.pulse(); assertFalse(service.handleMoveAimProxyAttack(player, mock(Entity.class)));
+ assertTrue(service.handleMoveAimProxyAttack(player, proxy));
+ }
+ assertFalse(service.isRelocating(player)); assertEquals(original, site.getCampBlocks());
+ for (BlockCell cell : original) assertFalse(world.getBlockAt(cell.x(), cell.y(), cell.z()).getType().isAir());
+ verify(proxy).remove(); verify(player).sendMessage("Camp move cancelled. It stays where it is.");
+ }
+
+ @Test public void campClosedDuringMoveCannotBeRelocatedFromTheLastPreview() {
+ service.tryUseKit(player, null); List original = List.copyOf(site.getCampBlocks());
+ service.beginRelocate(player, site);
+ Block destination = spy(world.getBlockAt(-8, 3, 4)); doReturn(false).when(destination).isPassable();
+ try (var rays = mockConstruction(BlockIterator.class, (iterator, context) -> {
+ when(iterator.hasNext()).thenReturn(true, false); when(iterator.next()).thenReturn(destination);
+ })) {
+ service.pulse(); assertTrue(site.closeCamp()); clearInvocations(sites);
+ assertFalse("A closed camp must reject confirmation of an earlier move preview", service.tryFinishMove(player));
+ }
+ assertFalse(service.isRelocating(player)); assertEquals(SiteStatus.CLOSED, site.getStatus());
+ assertEquals(Integer.valueOf(20), site.getCampX()); assertEquals(original, site.getCampBlocks());
+ assertEquals(1, kits.getAmount()); verify(sites, never()).save(any()); verify(proxy).remove();
+ for (BlockCell cell : original) assertFalse(world.getBlockAt(cell.x(), cell.y(), cell.z()).getType().isAir());
+ }
+
+ @Test public void previewTickCancelsMoveWhenAnotherPlayerClosesTheCamp() {
+ service.tryUseKit(player, null); service.beginRelocate(player, site);
+ Block destination = spy(world.getBlockAt(-8, 3, 4)); doReturn(false).when(destination).isPassable();
+ try (var rays = mockConstruction(BlockIterator.class, (iterator, context) -> {
+ when(iterator.hasNext()).thenReturn(true, false); when(iterator.next()).thenReturn(destination);
+ })) {
+ service.pulse(); assertTrue(site.closeCamp()); clearInvocations(sites);
+ service.pulse();
+ }
+ assertFalse("The next preview tick must abandon the closed camp", service.isRelocating(player));
+ verify(proxy).remove(); verify(sites, never()).save(any()); assertEquals(Integer.valueOf(20), site.getCampX());
+ }
+
+ @Test public void renameUpdatesTheLoadedSignAndRefreshesLabelsAfterTheRecordIsSaved() {
+ establishDirectly();
+ Block signBlock = spy(world.getBlockAt(18, 4, 2)); signBlock.setType(Material.OAK_SIGN);
+ Sign sign = spy((Sign) signBlock.getState());
+ // MockBukkit stores real sign lines but leaves wax support unimplemented.
+ doNothing().when(sign).setWaxed(true); doReturn(sign).when(signBlock).getState();
+ doReturn(signBlock).when(world).getBlockAt(18, 4, 2);
+ site.setCampSignX(18); site.setCampSignY(4); site.setCampSignZ(2);
+ SiteLabelRefresh labels = mock(SiteLabelRefresh.class); service.setLabelRefresh(labels);
+ service.beginRename(player, site); service.handleRenameChat(player, " Ancient river settlement ");
+ assertEquals("Ancient river settlement", site.getName());
+ assertArrayEquals(new String[]{"Ancient river", "settlement", "", ""}, sign.getSide(Side.FRONT).getLines());
+ var order = inOrder(sites, labels, sign);
+ order.verify(sites).save(site); order.verify(labels).retitle(site); order.verify(sign).setWaxed(true); order.verify(sign).update();
+ assertFalse(service.isRenaming(player));
+ }
+
+ @Test public void legacyRotatedCampsInferWoolCellsFromTheirSignPosition() {
+ establishDirectly();
+ for (var facing : List.of(org.bukkit.block.BlockFace.SOUTH, org.bukkit.block.BlockFace.WEST,
+ org.bukkit.block.BlockFace.NORTH, org.bukkit.block.BlockFace.EAST)) {
+ site.setCampFacing(null);
+ var sign = CampTemplate.rotate(-2, -2, facing);
+ site.setCampSignX(20 + sign.getBlockX()); site.setCampSignY(4); site.setCampSignZ(4 + sign.getBlockZ());
+ var pieces = CampTemplate.basic(Material.WHITE_WOOL, Material.RED_WOOL);
+ for (var piece : pieces) {
+ if (piece.wool() == null) continue;
+ var offset = CampTemplate.rotate(piece.dx(), piece.dz(), facing);
+ world.getBlockAt(20 + offset.getBlockX(), 4 + piece.dy(), 4 + offset.getBlockZ()).setType(piece.material());
+ }
+ service.applyCampWool(player, site, DyeColor.BLUE, CampWoolRole.PRIMARY);
+ for (var piece : pieces) {
+ if (piece.wool() == null) continue;
+ var offset = CampTemplate.rotate(piece.dx(), piece.dz(), facing);
+ assertEquals(piece.wool() == CampWoolRole.PRIMARY ? Material.BLUE_WOOL : Material.RED_WOOL,
+ world.getBlockAt(20 + offset.getBlockX(), 4 + piece.dy(), 4 + offset.getBlockZ()).getType());
+ }
+ }
+ verify(sites, times(4)).save(site);
+ }
+
+ @Test public void worldChangeRestoresPreviewUsingTheWorldItWasDrawnIn() {
+ service.pulse(); clearInvocations(player);
+ WorldMock destination = new WorldMock(); destination.setName("destination");
+ when(player.getWorld()).thenReturn(destination);
+ service.clearPreviewFromWorld(player, world);
+ ArgumentCaptor locations = ArgumentCaptor.forClass(Location.class);
+ ArgumentCaptor data = ArgumentCaptor.forClass(BlockData.class);
+ verify(player, atLeastOnce()).sendBlockChange(locations.capture(), data.capture());
+ for (int i = 0; i < locations.getAllValues().size(); i++) {
+ Location at = locations.getAllValues().get(i);
+ assertSame(world, at.getWorld()); assertEquals(world.getBlockAt(at).getBlockData(), data.getAllValues().get(i));
+ }
+ clearInvocations(player); service.clearPreviewFromWorld(player, world);
+ verify(player, never()).sendBlockChange(any(Location.class), any(BlockData.class));
+ }
+
+ @Test public void portallingAwayMidMoveNeverReadsTheDestinationWorldAtTheGhostsCoordinates() {
+ service.tryUseKit(player, null); service.beginRelocate(player, site);
+ Block aimed = spy(world.getBlockAt(-8, 3, 4)); doReturn(false).when(aimed).isPassable();
+ try (var rays = mockConstruction(BlockIterator.class, (iterator, context) -> {
+ when(iterator.hasNext()).thenReturn(true, false); when(iterator.next()).thenReturn(aimed);
+ })) { service.pulse(); }
+ clearInvocations(player);
+ // PlayerChangedWorldEvent fires after the move, so the listener's clearSession sees the new world.
+ WorldMock destination = spy(new WorldMock()); destination.setName("destination");
+ when(player.getWorld()).thenReturn(destination);
+ service.clearSession(player); service.clearPreviewFromWorld(player, world);
+ assertFalse(service.isRelocating(player));
+ verify(destination, never()).getBlockAt(anyInt(), anyInt(), anyInt());
+ ArgumentCaptor sent = ArgumentCaptor.forClass(Location.class);
+ verify(player, atLeast(0)).sendBlockChange(sent.capture(), any(BlockData.class));
+ for (Location at : sent.getAllValues()) assertNotSame(destination, at.getWorld());
+ }
+
+ @Test public void shutdownRemovesOfflineViewersAimProxyAndBossBar() {
+ service.tryUseKit(player, null); service.beginRelocate(player, site);
+ Block destination = spy(world.getBlockAt(-8, 3, 4)); doReturn(false).when(destination).isPassable();
+ try (var rays = mockConstruction(BlockIterator.class, (iterator, context) -> {
+ when(iterator.hasNext()).thenReturn(true, false); when(iterator.next()).thenReturn(destination);
+ })) { service.pulse(); }
+ when(server.getPlayer(player.getUniqueId())).thenReturn(null);
+ when(player.isOnline()).thenReturn(false);
+ clearInvocations(proxy, bar, player); service.stop();
+ verify(proxy).remove(); verify(bar).removeAll();
+ verify(player, never()).sendBlockChange(any(Location.class), any(BlockData.class));
+ assertEquals(SiteStatus.ESTABLISHED, site.getStatus());
+ }
+
+ @Test public void changingAimRestoresThePreviousGhostAndPuttingAwayTheKitRemovesTheHud() {
+ service.pulse(); clearInvocations(player, bar);
+ Block abandoned = world.getBlockAt(18, 4, 6);
+ doReturn(world.getBlockAt(-8, 3, 4)).when(player).getTargetBlockExact(64); service.pulse();
+ verify(player).sendBlockChange(abandoned.getLocation(), abandoned.getBlockData());
+ assertEquals(Material.AIR, abandoned.getType()); assertEquals(SiteStatus.HIDDEN, site.getStatus());
+ when(inventory.getItemInMainHand()).thenReturn(new ItemStack(Material.AIR));
+ clearInvocations(player); service.pulse();
+ verify(bar).removePlayer(player);
+ ArgumentCaptor restored = ArgumentCaptor.forClass(Location.class);
+ ArgumentCaptor data = ArgumentCaptor.forClass(BlockData.class);
+ verify(player, atLeastOnce()).sendBlockChange(restored.capture(), data.capture());
+ for (int n = 0; n < restored.getAllValues().size(); n++)
+ assertEquals(world.getBlockAt(restored.getAllValues().get(n)).getBlockData(), data.getAllValues().get(n));
+ verify(sites, never()).save(any()); assertEquals(2, kits.getAmount());
+ }
+
+ @Test public void relocationCanRecoverFromLookingIntoAirAndIgnoresHiddenCampAndPassableBlocks() {
+ service.tryUseKit(player, null); service.beginRelocate(player, site);
+ List original = List.copyOf(site.getCampBlocks());
+ BlockCell oldCell = original.getFirst(); Block oldCamp = world.getBlockAt(oldCell.x(), oldCell.y(), oldCell.z());
+ Block air = world.getBlockAt(0, 5, 0), water = world.getBlockAt(0, 5, 1); water.setType(Material.WATER);
+ Block grass = spy(world.getBlockAt(0, 5, 2)); grass.setType(Material.SHORT_GRASS); doReturn(true).when(grass).isPassable();
+ Block target = spy(world.getBlockAt(-8, 3, 4)); doReturn(false).when(target).isPassable();
+ java.util.concurrent.atomic.AtomicBoolean aimedAtGround = new java.util.concurrent.atomic.AtomicBoolean(false);
+ try (var rays = mockConstruction(BlockIterator.class, (ray, context) -> {
+ Iterator blocks = (aimedAtGround.get() ? List.of(oldCamp, air, water, grass, target) : List.of(oldCamp, air)).iterator();
+ when(ray.hasNext()).thenAnswer(call -> blocks.hasNext()); when(ray.next()).thenAnswer(call -> blocks.next());
+ })) {
+ service.pulse(); verify(bar).setColor(org.bukkit.boss.BarColor.RED);
+ assertTrue(service.tryFinishMove(player));
+ assertEquals(original, site.getCampBlocks()); verify(player).sendMessage(CampPlacement.Issue.LOOK_MISS.message());
+ aimedAtGround.set(true); service.tryUseKit(player, null);
+ }
+ assertEquals(Integer.valueOf(-8), site.getCampX()); assertFalse(service.isRelocating(player));
+ assertEquals(1, kits.getAmount()); verify(player).sendMessage("Camp moved.");
+ }
+
+ @Test public void cancellingMoveRestoresSignTextAfterTheBlockPacketWithoutChangingTheSign() {
+ service.tryUseKit(player, null); scheduled.clear();
+ Block signBlock = world.getBlockAt(site.getCampSignX(), site.getCampSignY(), site.getCampSignZ());
+ // MockBukkit's setBlockData placement does not materialise sign state; actual Paper does.
+ signBlock.setType(Material.OAK_SIGN);
+ Sign sign = (Sign) signBlock.getState(); sign.getSide(Side.FRONT).setLine(0, "River camp"); sign.update();
+ service.beginRelocate(player, site);
+ Block target = spy(world.getBlockAt(-8, 3, 4)); doReturn(false).when(target).isPassable();
+ try (var rays = mockConstruction(BlockIterator.class, (ray, context) -> {
+ when(ray.hasNext()).thenReturn(true, false); when(ray.next()).thenReturn(target);
+ })) { service.pulse(); }
+ when(inventory.getItemInMainHand()).thenReturn(new ItemStack(Material.AIR)); clearInvocations(player, bar);
+ service.tryCancelMove(player);
+ verify(player).sendBlockChange(signBlock.getLocation(), signBlock.getBlockData());
+ verify(player, never()).sendSignChange(any(Location.class), any(String[].class)); verify(bar).removePlayer(player);
+ assertFalse(scheduled.isEmpty()); List.copyOf(scheduled).forEach(Runnable::run);
+ verify(player).sendSignChange(eq(signBlock.getLocation()), org.mockito.AdditionalMatchers.aryEq(new String[]{"River camp", "", "", ""}));
+ assertEquals("River camp", ((Sign) signBlock.getState()).getSide(Side.FRONT).getLine(0));
+ assertEquals(SiteStatus.ESTABLISHED, site.getStatus()); assertEquals(Integer.valueOf(20), site.getCampX());
+ }
+
+ @Test public void kitClicksOnAnExistingCampAndDisabledPlacementDoNotSpendItemsOrReplaceIt() {
+ service.tryUseKit(player, null); clearInvocations(sites);
+ List original = List.copyOf(site.getCampBlocks());
+ BlockCell cell = original.getFirst(); Block camp = world.getBlockAt(cell.x(), cell.y(), cell.z());
+ when(sites.findLockedCampBlock("world", cell.x(), cell.y(), cell.z())).thenReturn(Optional.of(site));
+ service.tryUseKit(player, camp);
+ service.setSettings(settings(false, 1)); service.tryUseKit(player, null);
+ assertEquals(original, site.getCampBlocks()); assertEquals(1, kits.getAmount()); verify(sites, never()).save(any());
+ }
+
+ @Test public void newCampRetainsTheDirectorsLastChosenWoolPalette() {
+ service.tryUseKit(player, null);
+ service.applyCampWool(player, site, DyeColor.BLUE, CampWoolRole.PRIMARY);
+ service.applyCampWool(player, site, DyeColor.YELLOW, CampWoolRole.SECONDARY);
+ assertTrue(site.closeCamp());
+ Site next = hidden("world", 0, 1); next.confirmProspect(player.getUniqueId()); records.add(next);
+ doReturn(world.getBlockAt(20, 3, 20)).when(player).getTargetBlockExact(64);
+ service.tryUseKit(player, null);
+ assertEquals(SiteStatus.ESTABLISHED, next.getStatus());
+ assertEquals("BLUE", next.getCampWoolPrimary()); assertEquals("YELLOW", next.getCampWoolSecondary());
+ assertTrue(next.getCampBlocks().stream().anyMatch(cell -> world.getBlockAt(cell.x(), cell.y(), cell.z()).getType() == Material.BLUE_WOOL));
+ assertTrue(next.getCampBlocks().stream().anyMatch(cell -> world.getBlockAt(cell.x(), cell.y(), cell.z()).getType() == Material.YELLOW_WOOL));
+ verify(sites).save(next);
+ }
+
+ @Test public void renameOfAnErasedRecordStopsAndAnUnloadedCampWorldStillKeepsTheNewName() {
+ establishDirectly(); service.beginRename(player, site); records.remove(site);
+ assertTrue(service.handleRenameChat(player, "Gone")); verify(player).sendMessage("That excavation is no longer active.");
+ records.add(site); site.setCampSignX(18); site.setCampSignY(4); site.setCampSignZ(2);
+ when(server.getWorld("world")).thenReturn(null); // The camp world is unloaded; the dossier still takes the name.
+ service.beginRename(player, site); assertTrue(service.handleRenameChat(player, "Upper terrace"));
+ assertEquals("Upper terrace", site.getName()); verify(sites).save(site); assertFalse(service.isRenaming(player));
+ assertNotEquals(Material.OAK_SIGN, world.getBlockAt(18, 4, 2).getType());
+ verify(player).sendMessage("Excavation renamed to Upper terrace.");
+ }
+
+ @Test public void eachSneakClickOnANewTickAdvancesTheTentColourEvenAwayFromAnyRuin() {
+ when(player.isSneaking()).thenReturn(true);
+ // doDaylightCycle is off, so the day clock stands still while server ticks pass.
+ doReturn(world.getFullTime()).when(world).getFullTime();
+ assertTrue(service.tryCycleWool(player)); MockBukkit.getMock().getScheduler().performOneTick(); assertTrue(service.tryCycleWool(player));
+ site.setWorldName("other"); MockBukkit.getMock().getScheduler().performOneTick(); clearInvocations(player);
+ assertTrue(service.tryCycleWool(player)); // No ruin in reach: nothing to redraw, but the choice is kept.
+ verify(player, never()).sendBlockChange(any(Location.class), any(BlockData.class));
+ site.setWorldName("world"); service.pulse(); service.tryUseKit(player, null);
+ String third = CampWools.next(CampWools.next(CampWools.next("RED").name()).name()).name();
+ assertEquals(third, site.getCampWoolSecondary());
+ assertTrue(site.getCampBlocks().stream().anyMatch(cell -> world.getBlockAt(cell.x(), cell.y(), cell.z()).getType() == Material.valueOf(third + "_WOOL")));
+ }
+
+ @Test public void cyclingWoolDuringAMoveNeitherRecolorsTheCampNorDrawsAFirstPlantGhost() {
+ service.tryUseKit(player, null); String secondary = site.getCampWoolSecondary(); service.beginRelocate(player, site);
+ clearInvocations(player); service.cycleWool(player);
+ verify(player, never()).sendBlockChange(any(Location.class), any(BlockData.class));
+ assertEquals(secondary, site.getCampWoolSecondary()); assertTrue(service.isRelocating(player));
+ }
+
+ @Test public void recoloringWhileTheCampWorldIsUnloadedStillFilesTheChoice() {
+ service.tryUseKit(player, null);
+ List before = site.getCampBlocks().stream().map(cell -> world.getBlockAt(cell.x(), cell.y(), cell.z()).getType()).toList();
+ when(server.getWorld("world")).thenReturn(null);
+ service.applyCampWool(player, site, DyeColor.GREEN, CampWoolRole.SECONDARY);
+ assertEquals("GREEN", site.getCampWoolSecondary()); verify(sites, times(2)).save(site);
+ assertEquals(before, site.getCampBlocks().stream().map(cell -> world.getBlockAt(cell.x(), cell.y(), cell.z()).getType()).toList());
+ }
+
+ @Test public void dossiersWithoutAUsableOriginOrOrientationRecolorOnlyTheirRecordedCells() {
+ establishDirectly();
+ Block wool = world.getBlockAt(20, 5, 4), stone = world.getBlockAt(21, 5, 4);
+ site.getCampBlocks().addAll(List.of(new BlockCell(20, 5, 4), new BlockCell(21, 5, 4)));
+ // States a hand-edited dossier or an older plugin version can leave behind.
+ List damage = List.of(
+ () -> { site.setCampFacing("SOUTH"); site.setCampX(null); },
+ () -> { site.setCampX(20); site.setCampFacing(null); site.setCampSignX(18); site.setCampSignY(5); site.setCampSignZ(2); },
+ () -> { site.setCampSignX(20); site.setCampSignY(4); site.setCampSignZ(4); });
+ List colours = List.of(DyeColor.BLUE, DyeColor.GREEN, DyeColor.BLACK);
+ for (int i = 0; i < damage.size(); i++) {
+ wool.setType(CampWools.woolOf(site.getCampWoolSecondary(), DyeColor.RED)); stone.setType(Material.STONE); damage.get(i).run();
+ service.applyCampWool(player, site, colours.get(i), CampWoolRole.SECONDARY);
+ assertEquals(CampWools.woolOf(colours.get(i).name()), wool.getType()); assertEquals(Material.STONE, stone.getType());
+ }
+ assertEquals("BLACK", site.getCampWoolSecondary());
+ }
+
+ @Test public void unreadableStoredFacingFallsBackToTheSignPosition() {
+ establishDirectly(); var sign = CampTemplate.rotate(-2, -2, org.bukkit.block.BlockFace.WEST);
+ site.setCampSignX(20 + sign.getBlockX()); site.setCampSignY(4); site.setCampSignZ(4 + sign.getBlockZ());
+ for (DyeColor colour : List.of(DyeColor.CYAN, DyeColor.LIME)) {
+ site.setCampFacing(colour == DyeColor.CYAN ? "UP" : "NORTH_EAST");
+ service.applyCampWool(player, site, colour, CampWoolRole.PRIMARY);
+ for (var piece : CampTemplate.basic()) {
+ if (piece.wool() != CampWoolRole.PRIMARY) continue;
+ var offset = CampTemplate.rotate(piece.dx(), piece.dz(), org.bukkit.block.BlockFace.WEST);
+ assertEquals(CampWools.woolOf(colour.name()), world.getBlockAt(20 + offset.getBlockX(), 4 + piece.dy(), 4 + offset.getBlockZ()).getType());
+ }
+ }
+ }
+
+ @Test public void plantingOverPartlyOpenedGroundCountsEachDisturbedFindWithoutCallingItLost() {
+ for (int n = 0; n < 2; n++) {
+ BuriedFind find = new BuriedFind(); find.setId(UUID.randomUUID()); find.setArtifactId("coin");
+ find.getCells().addAll(List.of(new BlockCell(n * 2, 1, 1), new BlockCell(n * 2, 1, 2))); site.getFinds().add(find);
+ world.getBlockAt(n * 2, 1, 1).setType(Material.AIR);
+ }
+ service.tryUseKit(player, null);
+ verify(player).sendMessage("2 finds were already disturbed before this dig opened.");
+ assertTrue(site.getFinds().stream().allMatch(find -> find.isDisturbedBeforeDig() && find.getState() != FindState.LOST));
+ }
+
+ @Test public void aMoveEndsOnTheNextTickWhenTheRecordIsErasedOrTheCampChangesHands() {
+ service.tryUseKit(player, null); List original = List.copyOf(site.getCampBlocks());
+ service.beginRelocate(player, site); records.remove(site); service.pulse(); assertFalse(service.isRelocating(player));
+ records.add(site); service.beginRelocate(player, site); site.setDirector(UUID.randomUUID()); service.pulse();
+ assertFalse(service.isRelocating(player)); assertEquals(original, site.getCampBlocks()); verify(sites, times(1)).save(site);
+ }
+
+ @Test public void aFartherRuinListedLaterDoesNotDisplaceTheNearestOne() {
+ Site farther = hidden("world", 0, 1); farther.confirmProspect(player.getUniqueId()); records.add(farther);
+ service.tryUseKit(player, null);
+ assertEquals(SiteStatus.ESTABLISHED, site.getStatus()); assertEquals(SiteStatus.HIDDEN, farther.getStatus());
+ }
+
+ @Test public void everyUnsupportedFloorPieceIsDrawnInvalidAndOneReasonIsShown() {
+ for (int x = 16; x <= 31; x++) for (int z = 0; z <= 15; z++) world.getBlockAt(x, 3, z).setType(Material.AIR);
+ doReturn(world.getBlockAt(20, 2, 4)).when(player).getTargetBlockExact(64); world.getBlockAt(20, 2, 4).setType(Material.STONE);
+ world.getBlockAt(20, 2, 4).setType(Material.STONE); service.pulse(); clearInvocations(player);
+ world.getBlockAt(20, 2, 4).setType(Material.AIR); doReturn(world.getBlockAt(20, 3, 4)).when(player).getTargetBlockExact(64);
+ service.pulse();
+ ArgumentCaptor shown = ArgumentCaptor.forClass(BlockData.class);
+ verify(player, atLeastOnce()).sendBlockChange(any(Location.class), shown.capture());
+ long floor = CampTemplate.basic().stream().filter(piece -> piece.dy() == 0).count();
+ assertTrue(shown.getAllValues().stream().filter(data -> data.getMaterial() == Material.RED_STAINED_GLASS).count() >= floor);
+ verify(bar).setTitle(org.bukkit.ChatColor.RED + CampPlacement.Issue.UNSUPPORTED.message());
+ service.tryUseKit(player, null); assertEquals(SiteStatus.HIDDEN, site.getStatus()); assertEquals(2, kits.getAmount());
+ }
+
+ @Test public void aGhostDrawnForOneRuinIsNeverPlantedForAnotherThatBecameNearer() {
+ service.pulse(); verify(bar).setColor(org.bukkit.boss.BarColor.GREEN);
+ Site other = hidden("world", 0, 3); other.confirmProspect(player.getUniqueId()); records.add(other);
+ when(player.getLocation()).thenAnswer(call -> new Location(world, 8, 4, 50)); // Walked away before the next preview tick.
+ service.tryUseKit(player, null);
+ assertEquals(SiteStatus.HIDDEN, site.getStatus()); assertEquals(SiteStatus.HIDDEN, other.getStatus());
+ verify(player).sendMessage(CampPlacement.Issue.NOT_NEIGHBOR.message()); assertEquals(2, kits.getAmount());
+ }
+
+ @Test public void aDroppedMoveProxyIsRespawnedAndCancellingCopesWithAVanishedStand() {
+ service.tryUseKit(player, null); assertFalse(service.tryFinishMoveOnAimProxy(player, proxy));
+ service.beginRelocate(player, site);
+ Block destination = spy(world.getBlockAt(-8, 3, 4)); doReturn(false).when(destination).isPassable();
+ try (var rays = mockConstruction(BlockIterator.class, (iterator, context) -> {
+ when(iterator.hasNext()).thenReturn(true, false); when(iterator.next()).thenReturn(destination);
+ })) {
+ service.pulse(); when(proxy.isValid()).thenReturn(false); // A chunk unload removed the non-persistent stand.
+ service.pulse(); verify(world, times(2)).spawn(any(Location.class), eq(ArmorStand.class), any(Consumer.class));
+ verify(proxy, never()).teleport(any(Location.class));
+ }
+ when(server.getEntity(proxy.getUniqueId())).thenReturn(null);
+ assertTrue(service.tryCancelMove(player)); verify(proxy, never()).remove();
+ assertFalse(service.handleMoveAimProxyAttack(player, proxy)); assertEquals(Integer.valueOf(20), site.getCampX());
+ }
+
+ @Test public void campsMayStandInAnySideChunkButNotTwoChunksAway() {
+ assertRejected(world.getBlockAt(40, 3, 4), CampPlacement.Issue.NOT_NEIGHBOR);
+ Block south = world.getBlockAt(4, 3, 20); when(player.getTargetBlockExact(64)).thenReturn(south); service.tryUseKit(player, null);
+ assertEquals(SiteStatus.ESTABLISHED, site.getStatus());
+ assertEquals(Integer.valueOf(0), site.getEstablishmentChunkX()); assertEquals(Integer.valueOf(1), site.getEstablishmentChunkZ());
+ }
+
+ @Test public void aSideChunkHoldingAnotherExcavationsCampIsOccupied() {
+ Site neighbour = hidden("world", 2, 0); neighbour.establish(UUID.randomUUID(), 1, 0, 24, 4, 4); records.add(neighbour);
+ assertRejected(world.getBlockAt(20, 3, 4), CampPlacement.Issue.OCCUPIED);
+ assertEquals(SiteStatus.HIDDEN, site.getStatus()); assertEquals(2, kits.getAmount());
+ }
+
+ @Test public void aCampCanBeShiftedAcrossItsOwnFootprint() {
+ service.tryUseKit(player, null); service.beginRelocate(player, site);
+ Block shifted = spy(world.getBlockAt(21, 3, 4)); doReturn(false).when(shifted).isPassable();
+ try (var rays = mockConstruction(BlockIterator.class, (iterator, context) -> {
+ when(iterator.hasNext()).thenReturn(true, false); when(iterator.next()).thenReturn(shifted);
+ })) { service.pulse(); assertTrue(service.tryFinishMove(player)); }
+ assertEquals(Integer.valueOf(21), site.getCampX()); assertEquals(Integer.valueOf(1), site.getEstablishmentChunkX());
+ for (BlockCell cell : site.getCampBlocks()) assertFalse(world.getBlockAt(cell.x(), cell.y(), cell.z()).getType().isAir());
+ service.beginRelocate(player, site); // A four-block shift lands the new side wall on the old one.
+ Block further = spy(world.getBlockAt(25, 3, 4)); doReturn(false).when(further).isPassable();
+ try (var rays = mockConstruction(BlockIterator.class, (iterator, context) -> {
+ when(iterator.hasNext()).thenReturn(true, false); when(iterator.next()).thenReturn(further);
+ })) { service.pulse(); assertTrue(service.tryFinishMove(player)); }
+ assertEquals(Integer.valueOf(25), site.getCampX());
+ for (BlockCell cell : site.getCampBlocks()) assertFalse(world.getBlockAt(cell.x(), cell.y(), cell.z()).getType().isAir());
+ verify(player, times(2)).sendMessage("Camp moved."); assertEquals(1, kits.getAmount());
+ }
+
+ @Test public void aPieceCannotBePlantedIntoStandingWater() {
+ Block pool = spy(world.getBlockAt(18, 4, 6)); pool.setType(Material.WATER);
+ // Paper reports water as passable (it has no collision box); MockBukkit does not model passability.
+ doReturn(true).when(pool).isPassable(); doReturn(pool).when(world).getBlockAt(18, 4, 6);
+ assertRejected(world.getBlockAt(20, 3, 4), CampPlacement.Issue.BLOCKED);
+ assertEquals(2, kits.getAmount()); assertEquals(SiteStatus.HIDDEN, site.getStatus());
+ }
+
+ @Test public void moveAimSkipsGrassButRestsOnAnOpenGateLikeAnySolidBlock() {
+ service.tryUseKit(player, null); service.beginRelocate(player, site);
+ Block grass = spy(world.getBlockAt(-8, 8, 4)); grass.setType(Material.SHORT_GRASS); doReturn(true).when(grass).isPassable();
+ Block gate = spy(world.getBlockAt(-8, 3, 4)); gate.setType(Material.OAK_FENCE_GATE);
+ doReturn(true).when(gate).isPassable(); // An open gate has no collision box.
+ try (var rays = mockConstruction(BlockIterator.class, (ray, context) -> {
+ Iterator blocks = List.of(grass, gate, world.getBlockAt(-8, 2, 4)).iterator();
+ when(ray.hasNext()).thenAnswer(call -> blocks.hasNext()); when(ray.next()).thenAnswer(call -> blocks.next());
+ })) { service.pulse(); assertTrue(service.tryFinishMove(player)); }
+ assertEquals(Integer.valueOf(4), site.getCampY()); assertEquals(Integer.valueOf(-1), site.getEstablishmentChunkX());
+ verify(player).sendMessage("Camp moved.");
+ }
+
+ @Test public void signTextIsNotResentToAViewerWhoLeftBeforeTheFollowingTick() {
+ service.tryUseKit(player, null); scheduled.clear();
+ Block signBlock = world.getBlockAt(site.getCampSignX(), site.getCampSignY(), site.getCampSignZ());
+ signBlock.setType(Material.OAK_SIGN); // MockBukkit's setBlockData placement does not materialise sign state.
+ service.beginRelocate(player, site);
+ Block target = spy(world.getBlockAt(-8, 3, 4)); doReturn(false).when(target).isPassable();
+ try (var rays = mockConstruction(BlockIterator.class, (ray, context) -> {
+ when(ray.hasNext()).thenReturn(true, false); when(ray.next()).thenReturn(target);
+ })) { service.pulse(); }
+ service.tryCancelMove(player); assertFalse(scheduled.isEmpty());
+ when(player.isOnline()).thenReturn(false); List.copyOf(scheduled).forEach(Runnable::run);
+ verify(player, never()).sendSignChange(any(Location.class), any(String[].class));
+ }
+
+ @Test public void erasingAnotherRuinLeavesThisDirectorsMoveAndRenameRunning() {
+ service.tryUseKit(player, null); service.beginRelocate(player, site);
+ service.abortSessionsFor(UUID.randomUUID()); assertTrue(service.isRelocating(player));
+ service.cancelRelocate(player); service.beginRename(player, site);
+ service.abortSessionsFor(UUID.randomUUID()); assertTrue(service.isRenaming(player));
+ }
+
+ @Test public void aMoveNeverCarriesTheCampIntoAWorldTheDossierDoesNotNameAfterAReload() {
+ service.tryUseKit(player, null); service.beginRelocate(player, site); clearInvocations(sites);
+ site.setWorldName("world_nether"); // Staff edit the dossier's world and run /archaeo reload mid-move.
+ Block destination = spy(world.getBlockAt(-8, 3, 4)); doReturn(false).when(destination).isPassable();
+ try (var rays = mockConstruction(BlockIterator.class, (iterator, context) -> {
+ when(iterator.hasNext()).thenReturn(true, false); when(iterator.next()).thenReturn(destination);
+ })) { service.pulse(); assertTrue(service.tryFinishMove(player)); }
+ verify(bar).setTitle(org.bukkit.ChatColor.RED + CampPlacement.Issue.NOT_NEIGHBOR.message());
+ ArgumentCaptor shown = ArgumentCaptor.forClass(BlockData.class);
+ verify(player, atLeastOnce()).sendBlockChange(any(Location.class), shown.capture()); // No ruin frame in the wrong world.
+ assertTrue(shown.getAllValues().stream().noneMatch(data -> data.getMaterial() == Material.LIGHT_BLUE_STAINED_GLASS));
+ verify(player).sendMessage(CampPlacement.Issue.NOT_NEIGHBOR.message());
+ assertEquals(Integer.valueOf(20), site.getCampX()); assertTrue(service.isRelocating(player)); verify(sites, never()).save(any());
+ }
+
+ @Test public void campsArePlantedOverTallGrassAndReplaceIt() {
+ Block tuft = spy(world.getBlockAt(18, 4, 6)); tuft.setType(Material.SHORT_GRASS);
+ // Paper reports grass as passable; MockBukkit does not model passability.
+ doReturn(true).when(tuft).isPassable(); doReturn(tuft).when(world).getBlockAt(18, 4, 6);
+ service.tryUseKit(player, null);
+ assertEquals(SiteStatus.ESTABLISHED, site.getStatus()); assertTrue(site.isCampBlock(18, 4, 6));
+ assertNotEquals(Material.SHORT_GRASS, tuft.getType());
+ }
+
+ private void assertRejected(Block target, CampPlacement.Issue issue) {
+ when(player.getTargetBlockExact(64)).thenReturn(target); service.tryUseKit(player, null);
+ verify(player).sendMessage(issue.message());
+ }
+ private void establishDirectly() { site.establish(player.getUniqueId(), 1, 0, 20, 4, 4); }
+ private static EstablishSettings settings(boolean enabled, int max) {
+ return new EstablishSettings(enabled, true, Material.RED_STAINED_GLASS, Material.LIGHT_BLUE_STAINED_GLASS, 18, max);
+ }
+ private static Site hidden(String world, int x, int z) {
+ Site site = new Site(); site.setId(UUID.randomUUID()); site.setWorldName(world); site.setChunkX(x); site.setChunkZ(z);
+ site.setInterest(InterestLevel.LOW); site.setName("Old camp");
+ return site;
+ }
+}
diff --git a/src/test/java/net/tfminecraft/archaeo/establish/FindReportBookTest.java b/src/test/java/net/tfminecraft/archaeo/establish/FindReportBookTest.java
new file mode 100644
index 0000000..663b0bf
--- /dev/null
+++ b/src/test/java/net/tfminecraft/archaeo/establish/FindReportBookTest.java
@@ -0,0 +1,129 @@
+package net.tfminecraft.archaeo.establish;
+
+import net.tfminecraft.archaeo.config.*;
+import net.tfminecraft.archaeo.model.*;
+import org.bukkit.Material;
+import org.bukkit.NamespacedKey;
+import org.bukkit.inventory.ItemStack;
+import org.bukkit.inventory.meta.BookMeta;
+import org.bukkit.persistence.PersistentDataType;
+import org.bukkit.plugin.java.JavaPlugin;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.mockbukkit.mockbukkit.MockBukkit;
+import org.mockbukkit.mockbukkit.entity.PlayerMock;
+
+import java.time.Instant;
+import java.util.List;
+import java.util.Set;
+import java.util.UUID;
+
+import static org.junit.Assert.*;
+import static org.mockito.Mockito.*;
+
+public class FindReportBookTest {
+ private JavaPlugin plugin;
+ private PlayerMock director;
+ private Site site;
+ private CatalogRegistry catalogs;
+ private FindReportBook reports;
+
+ @Before public void setUp() {
+ MockBukkit.mock(); plugin = MockBukkit.createMockPlugin(); director = MockBukkit.getMock().addPlayer("Director");
+ reports = new FindReportBook(plugin); catalogs = mock(CatalogRegistry.class);
+ site = new Site(); site.setId(UUID.randomUUID()); site.setName("River excavation"); site.setSerial(23);
+ site.setInterest(InterestLevel.LOW); site.establish(director.getUniqueId(), 0, 0, 10, 4, 0);
+ site.setStatus(SiteStatus.EXHAUSTED);
+ }
+ @After public void tearDown() { MockBukkit.unmock(); }
+
+ @Test public void reportIsSignedSnapshotOfFiledFindsWithProvenienceAndReadings() {
+ BuriedFind pot = find(FindState.RECOVERED, 1); pot.setGivenName("Storage jar"); pot.setRecoveredBy(director.getUniqueId());
+ pot.setStudied(true); pot.setStudyNotes("Incised maker mark");
+ pot.addInterpretation(new FindInterpretation("function", "storage", director.getUniqueId(), Instant.now()));
+ BuriedFind lost = find(FindState.LOST, 2); lost.setGivenName("Lost rim");
+ BuriedFind hidden = find(FindState.HIDDEN, 0); hidden.setGivenName("Buried secret");
+ ArtifactTemplate template = new ArtifactTemplate("pot", "Clay vessel", 1, 1, "ceramic", "", false, 1,
+ Set.of(), Set.of(), FindProfile.OBJECT, List.of(), "Catalog note");
+ when(catalogs.artifact("pot")).thenReturn(template);
+ when(catalogs.rarityLoreLine(template)).thenReturn("Rare");
+ site.getHintIds().add("missing-old-note"); site.getHintIds().add("river");
+ when(catalogs.hint("river")).thenReturn(new HintTemplate("river", "River deposits", 1, Set.of(), Set.of(), Set.of(), Set.of(), null, null, null, null, null));
+ ItemStack report = reports.create(director, site, catalogs); BookMeta meta = (BookMeta) report.getItemMeta();
+ assertTrue(reports.isReport(report)); assertEquals(site.getId(), reports.siteIdOf(report));
+ assertEquals(site.getName(), meta.getTitle()); assertEquals("Director", meta.getAuthor());
+ assertEquals(BookMeta.Generation.ORIGINAL, meta.getGeneration());
+ String pages = String.join("\n", meta.getPages());
+ for (String text : List.of("Excavation #23", "Signed by Director", "River deposits", "missing-old-note", "Storage jar", "Stratum II", "Rare", "Incised maker mark", "According to Director", "storage", "Lost rim")) {
+ assertTrue(text + " missing from " + pages, pages.contains(text));
+ }
+ assertFalse(pages.contains("Buried secret")); assertFalse(pages.contains("Catalog note"));
+ pot.setGivenName("Renamed after signing"); site.setName("New camp name");
+ assertEquals(pages, String.join("\n", ((BookMeta) report.getItemMeta()).getPages()));
+ }
+
+ @Test public void emptyArchiveExplainsMissingNotesAndFinds() {
+ BookMeta meta = (BookMeta) reports.create(director, site, catalogs).getItemMeta();
+ assertEquals(3, meta.getPageCount());
+ assertEquals("Field notes\nNo field notes were filed.", meta.getPage(2));
+ assertEquals("No finds were filed.", meta.getPage(3));
+ }
+
+ @Test public void lengthyReportsRespectClientPageLimitsAndRetainTextAcrossSplits() {
+ site.setName("Long excavation name ".repeat(5));
+ BuriedFind find = find(FindState.RECOVERED, 1); find.setStudied(true);
+ String notes = "Field observation ".repeat(100); find.setStudyNotes(notes);
+ BookMeta meta = (BookMeta) reports.create(director, site, catalogs).getItemMeta();
+ assertEquals(32, meta.getTitle().length()); assertTrue(meta.getPageCount() > 3);
+ assertTrue(String.join("", meta.getPages()).contains(notes.trim()));
+ assertTrue(meta.getPages().stream().allMatch(page -> page.length() <= 800));
+ find.setStudyNotes("Long note ".repeat(10000));
+ BookMeta capped = (BookMeta) reports.create(director, site, catalogs).getItemMeta();
+ assertEquals(100, capped.getPageCount()); assertTrue(capped.getPages().stream().allMatch(page -> page.length() <= 800));
+ }
+
+ @Test public void reportNamesTheConfiguredInterestAndFallsBackToCatalogNotesForLegacyRows() {
+ when(catalogs.interest(InterestLevel.LOW)).thenReturn(new InterestSettings(InterestLevel.LOW, "Modest scatter", 1, 0, 1, 2, 0, 0, 1, 0, 0));
+ ArtifactTemplate pot = new ArtifactTemplate("pot", "Clay vessel", 1, 1, "ceramic", "", false, 1,
+ Set.of(), Set.of(), FindProfile.OBJECT, List.of(), "Catalog note");
+ ArtifactTemplate bead = new ArtifactTemplate("bead", "Glass bead", 1, 1, "glass", "", false, 1,
+ Set.of(), Set.of(), FindProfile.OBJECT, List.of(), " ");
+ when(catalogs.artifact("pot")).thenReturn(pot); when(catalogs.artifact("bead")).thenReturn(bead);
+ when(catalogs.rarityLoreLine(any(ArtifactTemplate.class))).thenReturn("Common");
+ BuriedFind legacy = find(FindState.RECOVERED, 0); legacy.setStudied(true); legacy.setStudyNotes(" "); // Recovered before numbering.
+ BuriedFind plain = find(FindState.RECOVERED, 2); plain.setArtifactId("bead"); plain.setStudied(true);
+ BuriedFind removed = find(FindState.RECOVERED, 3); removed.setArtifactId("retired"); removed.setStudied(true);
+ BuriedFind waiting = find(FindState.RECOVERED, 4); waiting.setArtifactId("retired");
+ List pages = ((BookMeta) reports.create(director, site, catalogs).getItemMeta()).getPages();
+ assertTrue(pages.getFirst().contains("Interest: Modest scatter"));
+ assertTrue(pages.get(2), pages.get(2).startsWith("Find\nClay vessel")); assertTrue(pages.get(2).contains("Catalog note"));
+ String kept = "Conservation " + plain.getConservation() + "%";
+ assertEquals(List.of("Stratum II", kept, "Studied", "Common", "No readings filed."), pages.get(3).lines().skip(2).toList());
+ assertEquals(List.of("Stratum II", kept, "Studied", "No readings filed."), pages.get(4).lines().skip(2).toList());
+ assertEquals(List.of("Stratum II", kept, "Field catalog", "Not studied.", "No readings filed."), pages.get(5).lines().skip(2).toList());
+ site.setInterest(null); // An unrated ruin.
+ assertFalse(((BookMeta) reports.create(director, site, catalogs).getItemMeta()).getPage(1).contains("Interest"));
+ }
+
+ @Test public void damagedBookTagsCannotResolveAnUnrelatedExcavation() {
+ assertFalse(reports.isReport(null)); assertFalse(reports.isReport(new ItemStack(Material.BOOK)));
+ assertFalse(reports.isReport(new ItemStack(Material.WRITTEN_BOOK)));
+ ItemStack report = reports.create(director, site, catalogs);
+ for (String id : List.of("", "not-a-uuid")) {
+ BookMeta meta = (BookMeta) report.getItemMeta();
+ meta.getPersistentDataContainer().set(new NamespacedKey(plugin, "site_id"), PersistentDataType.STRING, id);
+ report.setItemMeta(meta); assertTrue(reports.isReport(report)); assertNull(reports.siteIdOf(report));
+ }
+ BookMeta meta = (BookMeta) report.getItemMeta();
+ meta.getPersistentDataContainer().remove(new NamespacedKey(plugin, "site_id")); report.setItemMeta(meta);
+ assertNull(reports.siteIdOf(report));
+ meta.getPersistentDataContainer().set(new NamespacedKey(plugin, "excavation_report"), PersistentDataType.BYTE, (byte) 0);
+ report.setItemMeta(meta); assertFalse(reports.isReport(report)); assertNull(reports.siteIdOf(report));
+ }
+
+ private BuriedFind find(FindState state, int number) {
+ BuriedFind find = new BuriedFind(); find.setId(UUID.randomUUID()); find.setArtifactId("pot"); find.setStratumId("II");
+ find.setFindNumber(number); find.setState(state); site.getFinds().add(find); return find;
+ }
+}
diff --git a/src/test/java/net/tfminecraft/archaeo/excavation/FindDustServiceTest.java b/src/test/java/net/tfminecraft/archaeo/excavation/FindDustServiceTest.java
new file mode 100644
index 0000000..f548fb3
--- /dev/null
+++ b/src/test/java/net/tfminecraft/archaeo/excavation/FindDustServiceTest.java
@@ -0,0 +1,308 @@
+package net.tfminecraft.archaeo.excavation;
+
+import net.tfminecraft.archaeo.config.*;
+import net.tfminecraft.archaeo.model.*;
+import net.tfminecraft.archaeo.site.SiteRepository;
+import org.bukkit.Chunk;
+import org.bukkit.Location;
+import org.bukkit.Material;
+import org.bukkit.Particle;
+import org.bukkit.block.Block;
+import org.bukkit.event.world.ChunkLoadEvent;
+import org.bukkit.event.world.ChunkUnloadEvent;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.mockbukkit.mockbukkit.MockBukkit;
+import org.mockbukkit.mockbukkit.ServerMock;
+import org.mockbukkit.mockbukkit.block.BlockMock;
+import org.mockbukkit.mockbukkit.util.SpawnedParticle;
+import org.mockbukkit.mockbukkit.world.WorldMock;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.UUID;
+
+import static org.junit.Assert.*;
+import static org.mockito.Mockito.*;
+
+public class FindDustServiceTest {
+ private ServerMock server;
+ private SnapshotWorld world;
+ private SiteRepository sites;
+ private Site site;
+ private BuriedFind find;
+ private FindDustService service;
+
+ @Before
+ public void setup() {
+ server = MockBukkit.mock();
+ world = new SnapshotWorld();
+ world.setName("dust");
+ server.addWorld(world);
+ world.loadChunk(0, 0);
+ sites = mock(SiteRepository.class);
+ site = new Site();
+ site.setId(UUID.randomUUID());
+ site.setStatus(SiteStatus.ESTABLISHED);
+ site.setWorldName(world.getName());
+ find = new BuriedFind();
+ find.setId(UUID.randomUUID());
+ find.getCells().add(new BlockCell(8, 40, 8));
+ site.getFinds().add(find);
+ block(8, 40, 8).setType(Material.STONE);
+ when(sites.all()).thenReturn(List.of(site));
+ when(sites.findByChunk("dust", 0, 0)).thenReturn(Optional.of(site));
+ service = new FindDustService(MockBukkit.createMockPlugin(), sites, settings(true, 2, 4));
+ }
+
+ @After
+ public void teardown() {
+ if (service != null) service.stop();
+ MockBukkit.unmock();
+ }
+
+ @Test
+ public void exposedFindEmitsWaxAtConfiguredCadenceWithoutChangingTerrain() {
+ service.start();
+ ticks(1);
+ assertTrue(world.getSpawnedParticles().isEmpty());
+ assertEquals(FindState.HIDDEN, find.getState());
+ ticks(1);
+ assertEquals(FindState.DISCOVERED, find.getState());
+ List bursts = world.getSpawnedParticles();
+ assertEquals(7, bursts.size());
+ SpawnedParticle center = bursts.getFirst();
+ assertEquals(8.5, center.x(), 0);
+ assertEquals(40.5, center.y(), 0);
+ assertEquals(8.5, center.z(), 0);
+ assertEquals(4, center.count());
+ assertEquals(0.3, center.offsetX(), 0);
+ for (SpawnedParticle burst : bursts) {
+ assertEquals(Particle.WAX_ON, burst.particle());
+ assertEquals(0.1, burst.extra(), 0);
+ }
+ for (SpawnedParticle face : bursts.subList(1, 7)) {
+ assertEquals(2, face.count());
+ assertEquals(0.08, face.offsetX(), 0);
+ double faceDistance = Math.abs(face.x() - 8.5) + Math.abs(face.y() - 40.5) + Math.abs(face.z() - 8.5);
+ assertEquals(0.55, faceDistance, 0.000001);
+ }
+ assertEquals(Material.STONE, block(8, 40, 8).getType());
+ verify(sites).touch(site);
+ ticks(2);
+ assertEquals(14, world.getSpawnedParticles().size());
+ verify(sites, times(1)).touch(site);
+ verify(sites, never()).save(any());
+ }
+
+ @Test
+ public void exposureTransitionsFromHiddenThroughPartialToDiscovered() {
+ find.getCells().add(new BlockCell(9, 40, 8));
+ for (int x = 7; x <= 10; x++) {
+ for (int y = 39; y <= 41; y++) {
+ for (int z = 7; z <= 9; z++) block(x, y, z).setType(Material.STONE);
+ }
+ }
+ service.start();
+ ticks(2);
+ assertEquals(FindState.HIDDEN, find.getState());
+ assertTrue(world.getSpawnedParticles().isEmpty());
+ block(8, 41, 8).setType(Material.AIR);
+ ticks(2);
+ assertEquals(FindState.PARTIAL, find.getState());
+ assertEquals(2, world.getSpawnedParticles().size());
+ block(9, 41, 8).setType(Material.AIR);
+ ticks(2);
+ assertEquals(FindState.DISCOVERED, find.getState());
+ assertEquals(6, world.getSpawnedParticles().size());
+ verify(sites, times(2)).touch(site);
+ }
+
+ @Test
+ public void cleanedCubesRemainDiscoveredButStopSheddingDust() {
+ find.markCleaned(new BlockCell(8, 40, 8));
+ service.start();
+ ticks(4);
+ assertEquals(FindState.DISCOVERED, find.getState());
+ assertTrue(world.getSpawnedParticles().isEmpty());
+ assertEquals(Material.STONE, block(8, 40, 8).getType());
+ verify(sites).touch(site);
+ }
+
+ @Test
+ public void missingFillMarksLostWhileAlreadySettledFindsRemainUntouched() {
+ block(8, 40, 8).setType(Material.WATER);
+ for (FindState state : List.of(FindState.RECOVERED, FindState.LOST)) {
+ BuriedFind settled = new BuriedFind();
+ settled.setId(UUID.randomUUID());
+ settled.setState(state);
+ settled.getCells().add(new BlockCell(10 + site.getFinds().size(), 40, 8));
+ BlockCell cell = settled.getCells().getFirst();
+ block(cell.x(), cell.y(), cell.z()).setType(Material.STONE);
+ site.getFinds().add(settled);
+ }
+ service.start();
+ ticks(4);
+ assertEquals(FindState.LOST, find.getState());
+ assertEquals(FindState.RECOVERED, site.getFinds().get(1).getState());
+ assertEquals(FindState.LOST, site.getFinds().get(2).getState());
+ assertTrue(world.getSpawnedParticles().isEmpty());
+ verify(sites).touch(site);
+ }
+
+ @Test
+ public void loadedEstablishedSiteIsRequiredAndChunkLoadStartsTheTimer() {
+ world.unloadChunk(0, 0);
+ service.start();
+ ticks(4);
+ assertTrue(world.getSpawnedParticles().isEmpty());
+ world.loadChunk(0, 0);
+ service.onChunkLoad(new ChunkLoadEvent(world.getChunkAt(0, 0), false));
+ ticks(2);
+ assertEquals(7, world.getSpawnedParticles().size());
+ service.start();
+ service.start();
+ ticks(2);
+ assertEquals(14, world.getSpawnedParticles().size());
+ }
+
+ @Test
+ public void chunkUnloadDefersStopUntilAfterEventAndChunkReloadRestartsCleanly() {
+ service.start();
+ ticks(2);
+ world.clearSpawnedParticles();
+ Chunk departing = world.getChunkAt(0, 0);
+ service.onChunkUnload(new ChunkUnloadEvent(departing));
+ world.unloadChunk(0, 0);
+ ticks(4);
+ assertTrue(world.getSpawnedParticles().isEmpty());
+ world.loadChunk(0, 0);
+ service.onChunkLoad(new ChunkLoadEvent(world.getChunkAt(0, 0), false));
+ ticks(2);
+ assertEquals(7, world.getSpawnedParticles().size());
+ }
+
+ @Test
+ public void disablingStoppingOrSettlingSiteStopsDustAndReenableResumesIt() {
+ service.start();
+ ticks(2);
+ world.clearSpawnedParticles();
+ service.setSettings(settings(false, 2, 4));
+ ticks(4);
+ assertTrue(world.getSpawnedParticles().isEmpty());
+ service.setSettings(settings(true, 2, 4));
+ ticks(2);
+ assertEquals(7, world.getSpawnedParticles().size());
+ world.clearSpawnedParticles();
+ service.stop();
+ ticks(4);
+ assertTrue(world.getSpawnedParticles().isEmpty());
+ service.start();
+ site.setStatus(SiteStatus.EXHAUSTED);
+ ticks(4);
+ assertTrue(world.getSpawnedParticles().isEmpty());
+ }
+
+ @Test
+ public void unknownWorldAndHiddenSitesNeverRevealFinds() {
+ site.setStatus(SiteStatus.HIDDEN);
+ service.start();
+ ticks(4);
+ site.setStatus(SiteStatus.ESTABLISHED);
+ site.setWorldName("missing-world");
+ service.syncTimer();
+ ticks(4);
+ assertEquals(FindState.HIDDEN, find.getState());
+ assertTrue(world.getSpawnedParticles().isEmpty());
+ verify(sites, never()).touch(any());
+ }
+
+ @Test
+ public void unavailableRuinsDoNotStopDustAtAnotherLoadedExcavationOrLoadTheirChunks() {
+ Site missingWorld = new Site();
+ missingWorld.setId(UUID.randomUUID());
+ missingWorld.setWorldName("missing-world");
+ missingWorld.setStatus(SiteStatus.ESTABLISHED);
+ BuriedFind remoteFind = new BuriedFind(); remoteFind.setId(UUID.randomUUID());
+ remoteFind.getCells().add(new BlockCell(8, 40, 8)); missingWorld.getFinds().add(remoteFind);
+ Site unloadedChunk = new Site();
+ unloadedChunk.setId(UUID.randomUUID());
+ unloadedChunk.setWorldName(world.getName()); unloadedChunk.setChunkX(12);
+ unloadedChunk.setStatus(SiteStatus.ESTABLISHED);
+ BuriedFind unloadedFind = new BuriedFind(); unloadedFind.setId(UUID.randomUUID());
+ unloadedFind.getCells().add(new BlockCell(200, 40, 8)); unloadedChunk.getFinds().add(unloadedFind);
+ when(sites.all()).thenReturn(List.of(missingWorld, unloadedChunk, site));
+
+ assertFalse(world.isChunkLoaded(12, 0));
+ service.start(); ticks(4);
+
+ assertEquals(FindState.DISCOVERED, find.getState());
+ assertEquals(14, world.getSpawnedParticles().size());
+ assertEquals(FindState.HIDDEN, remoteFind.getState());
+ assertEquals(FindState.HIDDEN, unloadedFind.getState());
+ assertFalse(world.isChunkLoaded(12, 0));
+ verify(sites).touch(site);
+ verify(sites, never()).touch(missingWorld);
+ verify(sites, never()).touch(unloadedChunk);
+ }
+
+ @Test
+ public void zeroIntervalPausesRevealAndMinimumParticleCountRemainsVisible() {
+ service.setSettings(settings(true, 0, 0));
+ ticks(4);
+ assertEquals(FindState.HIDDEN, find.getState());
+ assertTrue(world.getSpawnedParticles().isEmpty());
+ service.setSettings(settings(true, 1, 0));
+ ticks(1);
+ assertEquals(FindState.DISCOVERED, find.getState());
+ assertEquals(7, world.getSpawnedParticles().size());
+ for (SpawnedParticle burst : world.getSpawnedParticles()) assertEquals(1, burst.count());
+ }
+
+ @Test
+ public void unrelatedOrUnclaimedChunksLoadingDoNotStartTheDustLoop() {
+ world.loadChunk(5, 5);
+ service.onChunkLoad(new ChunkLoadEvent(world.getChunkAt(5, 5), false));
+ site.setStatus(SiteStatus.HIDDEN);
+ service.onChunkLoad(new ChunkLoadEvent(world.getChunkAt(0, 0), false));
+ site.setStatus(SiteStatus.ESTABLISHED);
+ ticks(4);
+ assertTrue(world.getSpawnedParticles().isEmpty());
+ assertEquals(FindState.HIDDEN, find.getState());
+ verify(sites, never()).touch(any());
+ }
+
+ @Test
+ public void storedFindCellInAnUnloadedNeighbourChunkIsLeftAloneAndNotLoaded() {
+ // A hand-edited or migrated dossier can list a cube just across the chunk border.
+ find.getCells().add(new BlockCell(16, 40, 8));
+ assertFalse(world.isChunkLoaded(1, 0));
+ service.start();
+ ticks(4);
+ assertEquals(FindState.HIDDEN, find.getState());
+ assertTrue(world.getSpawnedParticles().isEmpty());
+ assertFalse(world.isChunkLoaded(1, 0));
+ verify(sites, never()).touch(any());
+ }
+
+ private PickSettings settings(boolean dust, int interval, int count) {
+ PickSettings defaults = PickSettings.defaults();
+ return new PickSettings(true, 8, false, dust, interval, count,
+ defaults.conservation(), defaults.limits(), false, defaults.cues(), defaults.profiles());
+ }
+
+ private Block block(int x, int y, int z) { return world.getBlockAt(x, y, z); }
+ private void ticks(int count) { server.getScheduler().performTicks(count); }
+
+ private static class SnapshotWorld extends WorldMock {
+ private final Map blocks = new HashMap<>();
+ @Override public BlockMock getBlockAt(int x, int y, int z) {
+ return blocks.computeIfAbsent(new BlockCell(x, y, z), key -> new BlockMock(new Location(this, x, y, z)) {
+ @Override public Location getLocation() { return super.getLocation().clone(); }
+ });
+ }
+ }
+}
diff --git a/src/test/java/net/tfminecraft/archaeo/excavation/FindWoundLedgerTest.java b/src/test/java/net/tfminecraft/archaeo/excavation/FindWoundLedgerTest.java
new file mode 100644
index 0000000..d31f1f4
--- /dev/null
+++ b/src/test/java/net/tfminecraft/archaeo/excavation/FindWoundLedgerTest.java
@@ -0,0 +1,90 @@
+package net.tfminecraft.archaeo.excavation;
+
+import net.tfminecraft.archaeo.model.BuriedFind;
+import net.tfminecraft.archaeo.model.FindState;
+import net.tfminecraft.archaeo.model.WorkerRecord;
+import org.junit.Test;
+
+import java.util.UUID;
+
+import static org.junit.Assert.*;
+
+public class FindWoundLedgerTest {
+ @Test
+ public void missingWorkerOrFindDoesNotConsumeTheCharge() {
+ FindWoundLedger ledger = new FindWoundLedger();
+ WorkerRecord worker = new WorkerRecord();
+ BuriedFind find = find();
+ ledger.charge(null, find);
+ ledger.charge(worker, null);
+ ledger.charge(null, null);
+ assertFalse(worker.hasWorked());
+ assertNull(worker.getLastActiveAt());
+ ledger.charge(worker, find);
+ assertEquals(1, worker.getFindsDamaged());
+ assertEquals(0, worker.getFindsLost());
+ assertNotNull(worker.getLastActiveAt());
+ }
+
+ @Test
+ public void repeatedCellsAndReloadedInstancesCountOncePerFindId() {
+ FindWoundLedger ledger = new FindWoundLedger();
+ WorkerRecord worker = new WorkerRecord();
+ worker.setFindsDamaged(3);
+ BuriedFind find = find();
+ ledger.charge(worker, find);
+ ledger.charge(worker, find);
+ BuriedFind reloaded = find();
+ reloaded.setId(find.getId());
+ ledger.charge(worker, reloaded);
+ assertEquals(4, worker.getFindsDamaged());
+ assertEquals(0, worker.getFindsLost());
+ ledger.charge(worker, find());
+ assertEquals(5, worker.getFindsDamaged());
+ }
+
+ @Test
+ public void laterFatalCellAddsLossWithoutChargingDamageAgain() {
+ FindWoundLedger ledger = new FindWoundLedger();
+ WorkerRecord worker = new WorkerRecord();
+ worker.setFindsLost(2);
+ BuriedFind find = find();
+ ledger.charge(worker, find);
+ find.setState(FindState.LOST);
+ ledger.charge(worker, find);
+ ledger.charge(worker, find);
+ assertEquals(1, worker.getFindsDamaged());
+ assertEquals(3, worker.getFindsLost());
+ }
+
+ @Test
+ public void fatalFirstHitCountsBothDamageAndLossAndDistinctPiecesAccumulate() {
+ FindWoundLedger ledger = new FindWoundLedger();
+ WorkerRecord worker = new WorkerRecord();
+ for (int i = 0; i < 2; i++) {
+ BuriedFind find = find();
+ find.setState(FindState.LOST);
+ ledger.charge(worker, find);
+ ledger.charge(worker, find);
+ }
+ assertEquals(2, worker.getFindsDamaged());
+ assertEquals(2, worker.getFindsLost());
+ assertEquals(0, worker.getFindsRecovered());
+ }
+
+ @Test
+ public void separateEventsEachChargeTheirOwnDamage() {
+ WorkerRecord worker = new WorkerRecord();
+ BuriedFind find = find();
+ new FindWoundLedger().charge(worker, find);
+ new FindWoundLedger().charge(worker, find);
+ assertEquals(2, worker.getFindsDamaged());
+ assertEquals(0, worker.getFindsLost());
+ }
+
+ private static BuriedFind find() {
+ BuriedFind find = new BuriedFind();
+ find.setId(UUID.randomUUID());
+ return find;
+ }
+}
diff --git a/src/test/java/net/tfminecraft/archaeo/excavation/HandPickListenerTest.java b/src/test/java/net/tfminecraft/archaeo/excavation/HandPickListenerTest.java
new file mode 100644
index 0000000..fcef624
--- /dev/null
+++ b/src/test/java/net/tfminecraft/archaeo/excavation/HandPickListenerTest.java
@@ -0,0 +1,235 @@
+package net.tfminecraft.archaeo.excavation;
+
+import net.tfminecraft.archaeo.model.Site;
+import net.tfminecraft.archaeo.site.SiteRepository;
+import org.bukkit.Material;
+import org.bukkit.block.Block;
+import org.bukkit.block.BlockFace;
+import org.bukkit.entity.Item;
+import org.bukkit.entity.Player;
+import org.bukkit.event.Event;
+import org.bukkit.event.block.Action;
+import org.bukkit.event.block.BlockBreakEvent;
+import org.bukkit.event.block.BlockDamageEvent;
+import org.bukkit.event.player.*;
+import org.bukkit.inventory.EquipmentSlot;
+import org.bukkit.inventory.ItemStack;
+import org.bukkit.inventory.PlayerInventory;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.mockbukkit.mockbukkit.MockBukkit;
+import org.mockbukkit.mockbukkit.ServerMock;
+
+import java.util.Optional;
+
+import static org.junit.Assert.*;
+import static org.mockito.Mockito.*;
+
+public class HandPickListenerTest {
+ private ServerMock server;
+ private HandPickService pick;
+ private SiteRepository sites;
+ private HandPickListener listener;
+ private Player player;
+ private PlayerInventory inventory;
+ private Block block;
+ private ItemStack tool;
+
+ @Before
+ public void setup() {
+ server = MockBukkit.mock();
+ pick = mock(HandPickService.class);
+ sites = mock(SiteRepository.class);
+ listener = new HandPickListener(pick, sites);
+ player = mock(Player.class);
+ inventory = mock(PlayerInventory.class);
+ when(player.getInventory()).thenReturn(inventory);
+ tool = new ItemStack(Material.IRON_PICKAXE);
+ when(inventory.getItemInMainHand()).thenReturn(tool);
+ when(pick.isExcavationTool(any())).thenAnswer(invocation -> {
+ ItemStack stack = invocation.getArgument(0);
+ return stack != null && stack.getType() == Material.IRON_PICKAXE;
+ });
+ block = server.addSimpleWorld("listener").getBlockAt(8, 40, 8);
+ block.setType(Material.STONE);
+ when(sites.findEstablishedPrism("listener", 8, 40, 8)).thenReturn(Optional.of(new Site()));
+ when(player.getTargetBlockExact(6)).thenReturn(block);
+ server.getPluginManager().registerEvents(listener, MockBukkit.createMockPlugin());
+ }
+
+ @After
+ public void teardown() {
+ MockBukkit.unmock();
+ }
+
+ @Test
+ public void prismDamageDisablesInstantBreakAndStartsClockEvenIfAlreadyCancelled() {
+ BlockDamageEvent event = new BlockDamageEvent(player, block, tool, true);
+ event.setCancelled(true);
+ server.getPluginManager().callEvent(event);
+ assertTrue(event.isCancelled());
+ assertFalse(event.getInstaBreak());
+ verify(pick).syncHeldTool(player);
+ verify(pick).noteMining(player, block);
+ }
+
+ @Test
+ public void unlistedToolOrBlocksOutsidePrismKeepVanillaDamageBehavior() {
+ when(inventory.getItemInMainHand()).thenReturn(new ItemStack(Material.STICK));
+ BlockDamageEvent unlisted = new BlockDamageEvent(player, block, tool, true);
+ listener.onDamage(unlisted);
+ assertTrue(unlisted.getInstaBreak());
+ when(inventory.getItemInMainHand()).thenReturn(tool);
+ when(sites.findEstablishedPrism("listener", 8, 40, 8)).thenReturn(Optional.empty());
+ BlockDamageEvent outside = new BlockDamageEvent(player, block, tool, true);
+ listener.onDamage(outside);
+ assertTrue(outside.getInstaBreak());
+ verify(pick, never()).noteMining(any(), any());
+ verify(pick, never()).syncHeldTool(any());
+ }
+
+ @Test
+ public void predictedBreakIsSuppressedOnCutOrDuringAnExistingCycle() {
+ BlockBreakEvent onCut = new BlockBreakEvent(block, player);
+ server.getPluginManager().callEvent(onCut);
+ assertTrue(onCut.isCancelled());
+ verify(pick).suppressVanillaBreak(player, block);
+ when(sites.findEstablishedPrism("listener", 8, 40, 8)).thenReturn(Optional.empty());
+ when(pick.isCycling(player)).thenReturn(true);
+ BlockBreakEvent cycling = new BlockBreakEvent(block, player);
+ server.getPluginManager().callEvent(cycling);
+ assertTrue(cycling.isCancelled());
+ verify(pick, times(2)).suppressVanillaBreak(player, block);
+ }
+
+ @Test
+ public void ordinaryBreakOutsideCutOrWithoutConfiguredToolIsNotCancelled() {
+ when(sites.findEstablishedPrism("listener", 8, 40, 8)).thenReturn(Optional.empty());
+ BlockBreakEvent outside = new BlockBreakEvent(block, player);
+ listener.onBreak(outside);
+ assertFalse(outside.isCancelled());
+ when(inventory.getItemInMainHand()).thenReturn(new ItemStack(Material.STICK));
+ when(pick.isCycling(player)).thenReturn(true);
+ BlockBreakEvent unlisted = new BlockBreakEvent(block, player);
+ listener.onBreak(unlisted);
+ assertFalse(unlisted.isCancelled());
+ verify(pick, never()).suppressVanillaBreak(any(), any());
+ }
+
+ @Test
+ public void onlyMainArmSwingAimedAtPrismFillKeepsMiningAlive() {
+ listener.onSwing(new PlayerAnimationEvent(player, PlayerAnimationType.ARM_SWING));
+ listener.onSwing(new PlayerAnimationEvent(player, PlayerAnimationType.OFF_ARM_SWING));
+ when(player.getTargetBlockExact(6)).thenReturn(null);
+ listener.onSwing(new PlayerAnimationEvent(player, PlayerAnimationType.ARM_SWING));
+ when(player.getTargetBlockExact(6)).thenReturn(block);
+ block.setType(Material.AIR);
+ listener.onSwing(new PlayerAnimationEvent(player, PlayerAnimationType.ARM_SWING));
+ block.setType(Material.STONE);
+ when(inventory.getItemInMainHand()).thenReturn(new ItemStack(Material.STICK));
+ listener.onSwing(new PlayerAnimationEvent(player, PlayerAnimationType.ARM_SWING));
+ verify(pick, times(1)).noteMining(player, block);
+ }
+
+ @Test
+ public void leftClickStartsClockWhileRightClickPreventsTillingAndPathing() {
+ PlayerInteractEvent left = click(Action.LEFT_CLICK_BLOCK, EquipmentSlot.HAND);
+ server.getPluginManager().callEvent(left);
+ verify(pick).noteMining(player, block);
+ assertFalse(left.isCancelled());
+ PlayerInteractEvent right = click(Action.RIGHT_CLICK_BLOCK, EquipmentSlot.HAND);
+ server.getPluginManager().callEvent(right);
+ assertTrue(right.isCancelled());
+ assertEquals(Event.Result.DENY, right.useInteractedBlock());
+ assertEquals(Event.Result.DENY, right.useItemInHand());
+ }
+
+ @Test
+ public void offhandAirAndOutsideCutInteractionsRemainVanilla() {
+ PlayerInteractEvent offhand = click(Action.RIGHT_CLICK_BLOCK, EquipmentSlot.OFF_HAND);
+ listener.onInteract(offhand);
+ assertFalse(offhand.isCancelled());
+ listener.onInteract(click(Action.LEFT_CLICK_AIR, EquipmentSlot.HAND));
+ when(sites.findEstablishedPrism("listener", 8, 40, 8)).thenReturn(Optional.empty());
+ PlayerInteractEvent outside = click(Action.RIGHT_CLICK_BLOCK, EquipmentSlot.HAND);
+ listener.onInteract(outside);
+ assertFalse(outside.isCancelled());
+ verify(pick, never()).noteMining(any(), any());
+ }
+
+ @Test
+ public void hotbarAndSwapFinishOldCycleAndSyncTheIncomingStack() {
+ ItemStack next = new ItemStack(Material.IRON_PICKAXE);
+ when(inventory.getItem(2)).thenReturn(next);
+ server.getPluginManager().callEvent(new PlayerItemHeldEvent(player, 0, 2));
+ verify(pick).finish(player);
+ verify(pick).syncHeldTool(player, next);
+ ItemStack offHand = new ItemStack(Material.STICK);
+ server.getPluginManager().callEvent(new PlayerSwapHandItemsEvent(player, next, offHand));
+ verify(pick, times(2)).finish(player);
+ verify(pick).syncHeldTool(player, offHand);
+ }
+
+ @Test
+ public void droppingToolFinishesButDroppingUnrelatedStackDoesNot() {
+ Item item = mock(Item.class);
+ when(item.getItemStack()).thenReturn(new ItemStack(Material.STICK));
+ server.getPluginManager().callEvent(new PlayerDropItemEvent(player, item));
+ verify(pick, never()).finish(player);
+ when(item.getItemStack()).thenReturn(tool);
+ server.getPluginManager().callEvent(new PlayerDropItemEvent(player, item));
+ verify(pick).finish(player);
+ verify(pick).syncHeldTool(player);
+ }
+
+ @Test
+ public void cancelledInventoryChangesLeaveActiveCycleAlone() {
+ PlayerItemHeldEvent held = new PlayerItemHeldEvent(player, 0, 1);
+ held.setCancelled(true);
+ PlayerSwapHandItemsEvent swap = new PlayerSwapHandItemsEvent(player, tool, tool);
+ swap.setCancelled(true);
+ PlayerDropItemEvent drop = new PlayerDropItemEvent(player, mock(Item.class));
+ drop.setCancelled(true);
+ server.getPluginManager().callEvent(held);
+ server.getPluginManager().callEvent(swap);
+ server.getPluginManager().callEvent(drop);
+ verifyNoInteractions(pick);
+ }
+
+ @Test
+ public void joinSyncsToolAndQuitFinishesThenUnlocksMining() {
+ server.getPluginManager().callEvent(new PlayerJoinEvent(player, "joined"));
+ verify(pick).syncHeldTool(player);
+ server.getPluginManager().callEvent(new PlayerQuitEvent(player, "left"));
+ var order = inOrder(pick);
+ order.verify(pick).syncHeldTool(player);
+ order.verify(pick).finish(player);
+ order.verify(pick).syncHeldTool(player, null);
+ }
+
+ @Test
+ public void unlistedItemsLeftClicksOutsideTheCutAndBlocklessClicksStayVanilla() {
+ when(inventory.getItemInMainHand()).thenReturn(new ItemStack(Material.STICK));
+ PlayerInteractEvent stick = click(Action.RIGHT_CLICK_BLOCK, EquipmentSlot.HAND);
+ server.getPluginManager().callEvent(stick);
+ assertFalse(stick.isCancelled());
+ when(inventory.getItemInMainHand()).thenReturn(tool);
+ when(sites.findEstablishedPrism("listener", 8, 40, 8)).thenReturn(Optional.empty());
+ server.getPluginManager().callEvent(click(Action.LEFT_CLICK_BLOCK, EquipmentSlot.HAND));
+ when(sites.findEstablishedPrism("listener", 8, 40, 8)).thenReturn(Optional.of(new Site()));
+ // Vanilla always sends a block with a block click, but other plugins call this event too and
+ // the Bukkit constructor accepts a null block, so such a click must be ignored without throwing.
+ for (Action action : new Action[] {Action.LEFT_CLICK_BLOCK, Action.RIGHT_CLICK_BLOCK}) {
+ PlayerInteractEvent blockless = new PlayerInteractEvent(player, action, tool, null, BlockFace.UP, EquipmentSlot.HAND);
+ server.getPluginManager().callEvent(blockless);
+ // Bukkit already denies the missing block; the listener must not deny the item too.
+ assertEquals(Event.Result.DEFAULT, blockless.useItemInHand());
+ }
+ verify(pick, never()).noteMining(any(), any());
+ }
+
+ private PlayerInteractEvent click(Action action, EquipmentSlot hand) {
+ return new PlayerInteractEvent(player, action, tool, block, BlockFace.UP, hand);
+ }
+}
diff --git a/src/test/java/net/tfminecraft/archaeo/excavation/HandPickServiceTest.java b/src/test/java/net/tfminecraft/archaeo/excavation/HandPickServiceTest.java
new file mode 100644
index 0000000..2d06998
--- /dev/null
+++ b/src/test/java/net/tfminecraft/archaeo/excavation/HandPickServiceTest.java
@@ -0,0 +1,916 @@
+package net.tfminecraft.archaeo.excavation;
+
+import net.tfminecraft.archaeo.config.*;
+import net.md_5.bungee.api.chat.BaseComponent;
+import net.tfminecraft.archaeo.item.ItemRef;
+import net.tfminecraft.archaeo.model.*;
+import net.tfminecraft.archaeo.site.SiteRepository;
+import org.bukkit.Location;
+import org.bukkit.Material;
+import org.bukkit.NamespacedKey;
+import org.bukkit.Particle;
+import org.bukkit.Sound;
+import org.bukkit.SoundCategory;
+import org.bukkit.attribute.Attribute;
+import org.bukkit.attribute.AttributeModifier;
+import org.bukkit.block.Block;
+import org.bukkit.entity.Player;
+import org.bukkit.event.block.BlockDamageEvent;
+import org.bukkit.inventory.ItemStack;
+import org.bukkit.plugin.java.JavaPlugin;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.mockbukkit.mockbukkit.MockBukkit;
+import org.mockbukkit.mockbukkit.ServerMock;
+import org.mockbukkit.mockbukkit.block.BlockMock;
+import org.mockbukkit.mockbukkit.attribute.AttributeInstanceMock;
+import org.mockbukkit.mockbukkit.entity.PlayerMock;
+import org.mockbukkit.mockbukkit.world.WorldMock;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.UUID;
+
+import static org.junit.Assert.*;
+import static org.mockito.Mockito.*;
+
+public class HandPickServiceTest {
+ private ServerMock server;
+ private SnapshotWorld world;
+ private PlayerMock player;
+ private SiteRepository sites;
+ private CatalogRegistry catalogs;
+ private HandPickService service;
+ private Site site;
+ private Block target;
+ private ExcavationTool profile;
+ /** Second whitelisted profile, served for a diamond pick when a test configures it. */
+ private ExcavationTool survey;
+ private final List subtitles = new ArrayList<>();
+ private final List actionBars = new ArrayList<>();
+
+ @Before
+ public void setup() {
+ server = MockBukkit.mock();
+ world = new SnapshotWorld();
+ world.setName("pick");
+ server.addWorld(world);
+ JavaPlugin plugin = MockBukkit.createMockPlugin();
+ player = spy(new PlayerMock(server, "Digger"));
+ server.addPlayer(player);
+ // This newer player attribute is absent from MockBukkit's default player attributes.
+ doReturn(new AttributeInstanceMock(Attribute.BLOCK_BREAK_SPEED, 1.0))
+ .when(player).getAttribute(Attribute.BLOCK_BREAK_SPEED);
+ player.teleport(new Location(world, 8, 42, 8));
+ player.getInventory().setItemInMainHand(new ItemStack(Material.IRON_PICKAXE));
+ doAnswer(invocation -> target).when(player).getTargetBlockExact(6);
+ doReturn(actionBarSink(actionBars)).when(player).spigot();
+ doReturn(List.of()).when(player).getLastTwoTargetBlocks(null, 6);
+ doAnswer(invocation -> {
+ subtitles.add(invocation.getArgument(1));
+ return null;
+ }).when(player).sendTitle(anyString(), anyString(), anyInt(), anyInt(), anyInt());
+ doNothing().when(player).spawnParticle(any(Particle.class), any(Location.class), anyInt(),
+ anyDouble(), anyDouble(), anyDouble(), anyDouble(), any());
+ doNothing().when(player).spawnParticle(any(Particle.class), any(Location.class), anyInt(),
+ anyDouble(), anyDouble(), anyDouble(), anyDouble());
+ site = new Site();
+ site.setId(UUID.randomUUID());
+ site.setStatus(SiteStatus.ESTABLISHED);
+ site.setWorldName(world.getName());
+ site.setDirector(player.getUniqueId());
+ StratumBand band = new StratumBand();
+ band.setId("I");
+ band.setPresent(true);
+ band.setMinY(38);
+ band.setMaxY(42);
+ site.getStrata().put("I", band);
+ target = stone(8, 40, 8);
+ stone(8, 39, 8);
+ sites = mock(SiteRepository.class);
+ when(sites.findEstablishedPrism(anyString(), anyInt(), anyInt(), anyInt())).thenAnswer(invocation -> {
+ boolean inside = site.getStatus() == SiteStatus.ESTABLISHED
+ && world.getName().equals(invocation.getArgument(0))
+ && site.isInPrism(invocation.getArgument(1), invocation.getArgument(2), invocation.getArgument(3));
+ return inside ? Optional.of(site) : Optional.empty();
+ });
+ catalogs = mock(CatalogRegistry.class);
+ when(catalogs.toolWear()).thenReturn(new ToolWearSettings(0, 0, false));
+ profile = profile(1, 0, 20);
+ DigTools tools = mock(DigTools.class);
+ when(tools.match(any())).thenAnswer(invocation -> Optional.ofNullable(held(invocation.getArgument(0))));
+ when(tools.isAllowed(any())).thenAnswer(invocation -> held(invocation.getArgument(0)) != null);
+ service = new HandPickService(plugin, sites, catalogs, tools, settings(true, 10));
+ service.start();
+ }
+
+ @After
+ public void teardown() {
+ if (service != null) service.stop();
+ MockBukkit.unmock();
+ }
+
+ @Test
+ public void invalidToolsDisabledSettingsAndNonFillNeverStartCycles() {
+ service.setSettings(settings(false, 10));
+ service.noteMining(player, target);
+ assertFalse(service.isCycling(player));
+ service.setSettings(settings(true, 10));
+ player.getInventory().setItemInMainHand(new ItemStack(Material.STICK));
+ service.noteMining(player, target);
+ assertFalse(service.isCycling(player));
+ player.getInventory().setItemInMainHand(new ItemStack(Material.IRON_PICKAXE));
+ for (Material type : List.of(Material.AIR, Material.WATER, Material.TORCH)) {
+ target.setType(type);
+ service.noteMining(player, target);
+ assertFalse(service.isCycling(player));
+ }
+ service.noteMining(player, stone(16, 40, 8));
+ assertFalse(service.isCycling(player));
+ verify(sites, never()).save(any());
+ }
+
+ @Test
+ public void permissionsAndExhaustedWorkdayRejectStartWithoutRemovingTerrain() {
+ site.setDirector(UUID.randomUUID());
+ service.noteMining(player, target);
+ service.noteMining(player, target);
+ assertFalse(service.isCycling(player));
+ verify(player, times(1)).sendMessage("You are not authorised to work on this excavation.");
+ site.setDirector(player.getUniqueId());
+ service.ensureJornada(site, world);
+ site.setJornadaPickLeft(1);
+ service.noteMining(player, target);
+ assertFalse(service.isCycling(player));
+ assertEquals(Material.STONE, target.getType());
+ assertEquals(1, site.getJornadaPickLeft());
+ }
+
+ @Test
+ public void earlyReleasePreservesTerrainWorkdayAndWorkerCounters() {
+ service.noteMining(player, target);
+ assertTrue(service.isCycling(player));
+ service.finish(player);
+ service.finish(player);
+ assertFalse(service.isCycling(player));
+ assertEquals(Material.STONE, target.getType());
+ assertEquals(10, site.getJornadaPickLeft());
+ assertNull(site.workerRecord(player.getUniqueId()));
+ verify(sites, never()).save(any());
+ }
+
+ @Test
+ public void readyReleaseLiftsOnlyAimedCellAndSpendsOneCycleCost() {
+ awaitReady();
+ assertEquals(Material.STONE, target.getType());
+ service.finish(player);
+ assertEquals(Material.AIR, target.getType());
+ assertEquals(Material.STONE, world.getBlockAt(8, 39, 8).getType());
+ assertFalse(service.isCycling(player));
+ assertEquals(8, site.getJornadaPickLeft());
+ assertEquals(1, site.staffLog(player.getUniqueId()).getBlocksRemoved());
+ verify(sites).save(site);
+ service.finish(player);
+ assertEquals(8, site.getJornadaPickLeft());
+ }
+
+ @Test
+ public void holdingThroughNextBeatLiftsExtraCellWithoutDoubleSpending() {
+ awaitReady();
+ keepHoldingUntilResolved(8);
+ assertEquals(Material.AIR, target.getType());
+ assertEquals(Material.AIR, world.getBlockAt(8, 39, 8).getType());
+ assertEquals(8, site.getJornadaPickLeft());
+ assertEquals(2, site.staffLog(player.getUniqueId()).getBlocksRemoved());
+ ticks(3);
+ service.finish(player);
+ verify(sites).save(site);
+ }
+
+ @Test
+ public void missingReadyWindowTriggersLateCutBeforeNextSlowCue() {
+ profile = profile(5, 0, 1);
+ service.setSettings(settings(true, 10));
+ awaitReady();
+ keepHoldingUntilResolved(4);
+ assertEquals(Material.AIR, world.getBlockAt(8, 39, 8).getType());
+ assertEquals(2, site.staffLog(player.getUniqueId()).getBlocksRemoved());
+ ticks(3);
+ verify(player).playSound(any(Location.class), eq(Sound.ITEM_MACE_SMASH_GROUND), any(SoundCategory.class), anyFloat(), anyFloat());
+ }
+
+ @Test
+ public void lateCutWithNothingElseToBreakLiftsOneCubeWithoutTheHeavySmash() {
+ profile = profile(5, 0, 1);
+ service.setSettings(settings(true, 10));
+ world.getBlockAt(8, 39, 8).setType(Material.AIR); // Already dug out below the aimed cube.
+ awaitReady();
+ keepHoldingUntilResolved(4);
+ ticks(3);
+ assertEquals(Material.AIR, target.getType());
+ assertEquals(1, site.staffLog(player.getUniqueId()).getBlocksRemoved());
+ verify(player, never()).playSound(any(Location.class), eq(Sound.ITEM_MACE_SMASH_GROUND), any(SoundCategory.class), anyFloat(), anyFloat());
+ }
+
+ @Test
+ public void stalePacketsReleaseReadySlowCycleWithoutInventingLateInput() {
+ profile = profile(10, 0, 3);
+ service.setSettings(settings(true, 10));
+ awaitReady();
+ ticks(17);
+ assertFalse(service.isCycling(player));
+ assertEquals(Material.AIR, target.getType());
+ assertEquals(Material.STONE, world.getBlockAt(8, 39, 8).getType());
+ assertEquals(1, site.staffLog(player.getUniqueId()).getBlocksRemoved());
+ }
+
+ @Test
+ public void earlyStaleCycleStopsWithoutDigging() {
+ profile = profile(10, 0, 3);
+ service.setSettings(settings(true, 10));
+ service.noteMining(player, target);
+ ticks(17);
+ assertFalse(service.isCycling(player));
+ assertEquals(Material.STONE, target.getType());
+ assertEquals(10, site.getJornadaPickLeft());
+ }
+
+ @Test
+ public void changingTargetFinishesReadyCellThenStartsNewCycle() {
+ awaitReady();
+ Block previous = target;
+ target = stone(9, 40, 8);
+ service.noteMining(player, target);
+ assertEquals(Material.AIR, previous.getType());
+ assertEquals(Material.STONE, target.getType());
+ assertTrue(service.isCycling(player));
+ service.finish(player);
+ assertEquals(Material.STONE, target.getType());
+ assertEquals(8, site.getJornadaPickLeft());
+ }
+
+ @Test
+ public void toolChangeAndPermissionRevocationEndCyclesSafely() {
+ service.noteMining(player, target);
+ player.getInventory().setItemInMainHand(new ItemStack(Material.STICK));
+ ticks(1);
+ assertFalse(service.isCycling(player));
+ assertEquals(Material.STONE, target.getType());
+ player.getInventory().setItemInMainHand(new ItemStack(Material.IRON_PICKAXE));
+ awaitReady();
+ site.setDirector(UUID.randomUUID());
+ ticks(1);
+ assertFalse(service.isCycling(player));
+ assertEquals(Material.STONE, target.getType());
+ assertEquals(10, site.getJornadaPickLeft());
+ }
+
+ @Test
+ public void worldChangeOrSpentBudgetBeforeReleaseCannotSpendOrDigAgain() {
+ awaitReady();
+ site.setJornadaPickLeft(0);
+ service.finish(player);
+ assertEquals(Material.STONE, target.getType());
+ assertEquals(0, site.getJornadaPickLeft());
+ service.refillJornada(site, world);
+ subtitles.clear();
+ awaitReady();
+ target.setType(Material.AIR);
+ service.finish(player);
+ assertEquals(Material.STONE, world.getBlockAt(8, 39, 8).getType());
+ assertEquals(10, site.getJornadaPickLeft());
+ }
+
+ @Test
+ public void lateFindDamageCountsPiecesOnceAndArchivesLoss() {
+ BuriedFind find = new BuriedFind();
+ find.setId(UUID.randomUUID());
+ find.getCells().addAll(List.of(new BlockCell(8, 40, 8), new BlockCell(8, 39, 8), new BlockCell(9, 39, 8)));
+ site.getFinds().add(find);
+ stone(9, 39, 8);
+ awaitReady();
+ keepHoldingUntilResolved(8);
+ assertEquals(FindState.LOST, find.getState());
+ assertEquals(0, find.getConservation());
+ assertEquals(1, site.staffLog(player.getUniqueId()).getFindsDamaged());
+ assertEquals(1, site.staffLog(player.getUniqueId()).getFindsLost());
+ assertEquals(2, site.staffLog(player.getUniqueId()).getBlocksRemoved());
+ assertEquals(SiteStatus.EXHAUSTED, site.getStatus());
+ assertEquals(player.getUniqueId(), find.getRecoveredBy());
+ assertTrue(find.getFindNumber() > 0);
+ verify(player).sendMessage("Buried archaeological remains were destroyed.");
+ }
+
+ @Test
+ public void workdayRefillsOnlyOnNewDayOrExplicitStaffRefill() {
+ world.setFullTime(24000);
+ service.ensureJornada(site, world);
+ assertEquals(1, site.getJornadaWorldDay());
+ assertEquals(10, site.getJornadaPickLeft());
+ site.setJornadaPickLeft(3);
+ service.ensureJornada(site, world);
+ assertEquals(3, site.getJornadaPickLeft());
+ world.setFullTime(48000);
+ service.ensureJornada(site, world);
+ assertEquals(10, site.getJornadaPickLeft());
+ assertEquals(2, site.getJornadaWorldDay());
+ site.setJornadaPickLeft(1);
+ assertEquals(10, service.refillJornada(site, world));
+ verify(sites, times(2)).touch(site);
+ verify(sites).save(site);
+ }
+
+ @Test
+ public void unlimitedWorkdayDoesNotRewriteOrConsumeStoredDailyBudget() {
+ service.setSettings(settings(true, 0));
+ site.setJornadaPickLeft(7);
+ site.setJornadaWorldDay(12);
+ assertEquals(0, service.refillJornada(site, world));
+ service.ensureJornada(site, world);
+ awaitReady();
+ service.finish(player);
+ assertEquals(Material.AIR, target.getType());
+ assertEquals(7, site.getJornadaPickLeft());
+ assertEquals(12, site.getJornadaWorldDay());
+ verify(sites, never()).touch(any());
+ }
+
+ @Test
+ public void miningLockIsIdempotentScopedToCutAndPreservesOtherModifiers() {
+ var speed = player.getAttribute(Attribute.BLOCK_BREAK_SPEED);
+ AttributeModifier unrelated = new AttributeModifier(new NamespacedKey("other", "boost"), 0.5,
+ AttributeModifier.Operation.ADD_NUMBER);
+ speed.addModifier(unrelated);
+ service.syncHeldTool(player);
+ service.syncHeldTool(player);
+ assertEquals(2, speed.getModifiers().size());
+ assertTrue(speed.getModifiers().stream().anyMatch(modifier -> modifier.getKey().getKey().equals("no_vanilla_mine")));
+ target = null;
+ service.syncHeldTool(player);
+ assertEquals(List.of(unrelated), List.copyOf(speed.getModifiers()));
+ target = world.getBlockAt(8, 40, 8);
+ service.syncHeldTool(player);
+ service.stop();
+ assertEquals(List.of(unrelated), List.copyOf(speed.getModifiers()));
+ }
+
+ @Test
+ public void predictedVanillaBreakPinsRealBlockAndStartsClockWithoutBreakingIt() {
+ service.suppressVanillaBreak(player, target);
+ verify(player).sendBlockChange(target.getLocation(), target.getBlockData());
+ assertTrue(service.isCycling(player));
+ assertEquals(Material.STONE, target.getType());
+ assertEquals(10, site.getJornadaPickLeft());
+ }
+
+ @Test
+ public void legacyVanillaProgressAndFallbackBothReachReadyWithoutChangingTerrainEarly() {
+ profile = profile(0, 0, 20);
+ world.breakProgress = 1f;
+ service.setSettings(settings(true, 10));
+ awaitReady();
+ assertEquals(Material.STONE, target.getType());
+ service.finish(player);
+ assertEquals(Material.AIR, target.getType());
+ target.setType(Material.STONE);
+ subtitles.clear();
+ world.breakProgress = 0f;
+ profile = profile(0, 1, 20);
+ service.setSettings(settings(true, 10));
+ awaitReady();
+ service.finish(player);
+ assertEquals(Material.AIR, target.getType());
+ assertEquals(6, site.getJornadaPickLeft());
+ }
+
+ @Test
+ public void zeroLegacyProgressWithoutFallbackCannotGenerateAReadyCue() {
+ profile = profile(0, 0, 20);
+ world.breakProgress = 0f;
+ service.setSettings(settings(true, 10));
+ service.noteMining(player, target);
+ for (int tick = 0; tick < 12; tick++) {
+ service.noteMining(player, target);
+ ticks(1);
+ }
+ assertTrue(subtitles.isEmpty());
+ service.finish(player);
+ assertEquals(Material.STONE, target.getType());
+ assertEquals(10, site.getJornadaPickLeft());
+ }
+
+ @Test
+ public void openedCellHudCountsHiddenConnectedShapeAndThenShowsClear() {
+ service.setSettings(settings(true, 10, true));
+ BuriedFind nearby = nearbyPot(8, 40, 7);
+ Block opened = target;
+ opened.setType(Material.AIR);
+ target = stone(8, 40, 9);
+ doReturn(List.of(opened, target)).when(player).getLastTwoTargetBlocks(null, 6);
+ ticks(1);
+ assertEquals("STRATUM I ⛏ 10 Ceramic: 1", actionBars.getLast());
+ assertEquals(FindState.HIDDEN, nearby.getState());
+ nearby.setState(FindState.LOST);
+ ticks(1);
+ assertEquals("STRATUM I ⛏ 10 clear", actionBars.getLast());
+ service.setSettings(settings(true, 10, false));
+ ticks(1);
+ assertEquals("STRATUM I ⛏ 10", actionBars.getLast());
+ }
+
+ @Test
+ public void wallHudUsesAirImmediatelyInFrontOnlyInsideTheExcavation() {
+ service.setSettings(settings(true, 10, true));
+ Block opened = world.getBlockAt(8, 40, 7);
+ nearbyPot(8, 40, 6);
+ doReturn(List.of(opened, target)).when(player).getLastTwoTargetBlocks(null, 6);
+ ticks(1);
+ assertEquals("STRATUM I ⛏ 10 Ceramic: 1", actionBars.getLast());
+ opened.setType(Material.WATER);
+ ticks(1);
+ assertEquals("STRATUM I ⛏ 10", actionBars.getLast());
+ target = stone(0, 40, 8);
+ doReturn(List.of(world.getBlockAt(-1, 40, 8), target)).when(player).getLastTwoTargetBlocks(null, 6);
+ ticks(1);
+ assertEquals("STRATUM I ⛏ 10", actionBars.getLast());
+ }
+
+ @Test
+ public void activeHoldDoesNotRevealTracesUntilItsCellIsOpened() {
+ service.setSettings(settings(true, 10, true));
+ nearbyPot(8, 40, 7);
+ doReturn(List.of(world.getBlockAt(8, 40, 9), target)).when(player).getLastTwoTargetBlocks(null, 6);
+ awaitReady();
+ assertEquals("STRATUM I ⛏ 10", actionBars.getLast());
+ service.finish(player);
+ verify(player).sendMessage("Traces of Ceramic: 1");
+ Block opened = target;
+ target = stone(8, 40, 9);
+ doReturn(List.of(opened, target)).when(player).getLastTwoTargetBlocks(null, 6);
+ ticks(1);
+ assertEquals("STRATUM I ⛏ 8 Ceramic: 1", actionBars.getLast());
+ assertEquals(Material.AIR, opened.getType());
+ }
+
+ @Test
+ public void discoveredFindHudShowsConditionAndUnlimitedBudgetThenPermissionRefusal() {
+ BuriedFind aimed = nearbyPot(8, 40, 8);
+ aimed.setState(FindState.DISCOVERED);
+ aimed.setConservation(75);
+ service.setSettings(settings(true, 0, true));
+ ticks(1);
+ assertEquals("STRATUM I ⛏ ∞ 75%", actionBars.getLast());
+ site.setDirector(UUID.randomUUID());
+ ticks(1);
+ assertEquals("Not authorised to work here", actionBars.getLast());
+ assertEquals(Material.STONE, target.getType());
+ }
+
+ @Test
+ public void competingWorkersCannotOverspendTheLastSharedWorkdayAction() {
+ service.setSettings(settings(true, 2));
+ world.getBlockAt(8, 39, 8).setType(Material.AIR);
+ Block secondCell = stone(10, 40, 8);
+ List secondCues = new ArrayList<>();
+ PlayerMock second = worker("Second digger", secondCell, secondCues);
+ boolean firstReady = false;
+ boolean secondReady = false;
+ for (int tick = 0; tick < 30 && !(firstReady && secondReady); tick++) {
+ service.noteMining(player, target);
+ service.noteMining(second, secondCell);
+ ticks(1);
+ firstReady = subtitles.contains("Release");
+ secondReady = secondCues.contains("Release");
+ }
+ assertTrue(firstReady && secondReady);
+ service.finish(player);
+ service.finish(second);
+ boolean firstSucceeded = target.getType() == Material.AIR;
+ boolean secondSucceeded = secondCell.getType() == Material.AIR;
+ assertTrue("Exactly one worker may spend the last shared action", firstSucceeded ^ secondSucceeded);
+ PlayerMock winner = firstSucceeded ? player : second;
+ PlayerMock waiting = firstSucceeded ? second : player;
+ assertEquals(Material.STONE, firstSucceeded ? secondCell.getType() : target.getType());
+ assertEquals(0, site.getJornadaPickLeft());
+ assertEquals(1, site.staffLog(winner.getUniqueId()).getBlocksRemoved());
+ assertNull(site.workerRecord(waiting.getUniqueId()));
+ verify(waiting).sendMessage("The excavation work day is over.");
+ verify(sites).save(site);
+ assertFalse(service.isCycling(player));
+ assertFalse(service.isCycling(second));
+ }
+
+ @Test
+ public void stoppingDuringEarlyHoldsUnlocksEveryWorkerAndCancelsFutureCues() {
+ profile = profile(20, 0, 20);
+ Block secondCell = stone(10, 40, 8);
+ List secondCues = new ArrayList<>();
+ PlayerMock second = worker("Second digger", secondCell, secondCues);
+ service.noteMining(player, target);
+ service.noteMining(second, secondCell);
+ ticks(1);
+ assertFalse(player.getAttribute(Attribute.BLOCK_BREAK_SPEED).getModifiers().isEmpty());
+ assertFalse(second.getAttribute(Attribute.BLOCK_BREAK_SPEED).getModifiers().isEmpty());
+ service.stop();
+ assertFalse(service.isCycling(player));
+ assertFalse(service.isCycling(second));
+ assertTrue(player.getAttribute(Attribute.BLOCK_BREAK_SPEED).getModifiers().isEmpty());
+ assertTrue(second.getAttribute(Attribute.BLOCK_BREAK_SPEED).getModifiers().isEmpty());
+ int shown = subtitles.size() + secondCues.size();
+ ticks(100);
+ assertEquals(shown, subtitles.size() + secondCues.size());
+ assertEquals(Material.STONE, target.getType());
+ assertEquals(Material.STONE, secondCell.getType());
+ assertEquals(10, site.getJornadaPickLeft());
+ verify(sites, never()).save(any());
+ }
+
+ @Test
+ public void anotherWorkersCutEndsTheActiveHoldWithoutChargingAgain() {
+ awaitReady();
+ // A second worker's allowed cut removes the cell before this player's next tick.
+ target.breakNaturally(new ItemStack(Material.IRON_PICKAXE));
+ ticks(1);
+ assertFalse(service.isCycling(player));
+ assertEquals(10, site.getJornadaPickLeft());
+ assertNull(site.workerRecord(player.getUniqueId()));
+ assertEquals(Material.STONE, world.getBlockAt(8, 39, 8).getType());
+ assertTrue(player.getAttribute(Attribute.BLOCK_BREAK_SPEED).getModifiers().isEmpty());
+ service.finish(player);
+ verify(sites, never()).save(any());
+ }
+
+ @Test
+ public void audioOnlyCuesKeepReleaseTimingWithoutTitlesOrParticles() {
+ PickSettings normal = settings(true, 10);
+ service.setSettings(new PickSettings(normal.enabled(), normal.jornadaActions(), false,
+ normal.findDust(), normal.findDustIntervalTicks(), normal.findDustCount(), normal.conservation(),
+ normal.limits(), normal.neighborTraces(), normal.cues(), normal.profiles()));
+ boolean heardReady = false;
+ for (int tick = 0; tick < 30 && !heardReady; tick++) {
+ service.noteMining(player, target);
+ ticks(1);
+ heardReady = player.getHeardSounds().stream().anyMatch(sound ->
+ sound.getSound().equals(Sound.BLOCK_NOTE_BLOCK_CHIME.getKey().getKey())
+ && sound.getPitch() == 1.45f);
+ }
+ assertTrue("The audio cue still tells the player when to release", heardReady);
+ assertTrue(player.getHeardSounds().stream().anyMatch(sound ->
+ sound.getSound().equals(Sound.BLOCK_NOTE_BLOCK_CHIME.getKey().getKey())
+ && sound.getCategory() == SoundCategory.BLOCKS && sound.getPitch() == 0.85f));
+ assertTrue(subtitles.isEmpty());
+ verify(player, never()).spawnParticle(any(Particle.class), any(Location.class), anyInt(),
+ anyDouble(), anyDouble(), anyDouble(), anyDouble(), any());
+ verify(player, never()).spawnParticle(any(Particle.class), any(Location.class), anyInt(),
+ anyDouble(), anyDouble(), anyDouble(), anyDouble());
+ assertEquals(Material.STONE, target.getType());
+ service.finish(player);
+ assertEquals(Material.AIR, target.getType());
+ assertEquals(Material.STONE, world.getBlockAt(8, 39, 8).getType());
+ assertEquals(8, site.getJornadaPickLeft());
+ }
+
+ @Test
+ public void highVanillaProgressResolvesMultipleCuesInOneTickButSpendsOnlyOneCut() {
+ profile = profile(0, 0, 20);
+ world.breakProgress = 20f;
+ service.setSettings(settings(true, 10));
+ service.noteMining(player, target);
+ ticks(1);
+ assertTrue(subtitles.contains("Soon"));
+ assertEquals(1, subtitles.stream().filter("Release"::equals).count());
+ assertFalse(service.isCycling(player));
+ assertEquals(Material.AIR, target.getType());
+ assertEquals(Material.AIR, world.getBlockAt(8, 39, 8).getType());
+ assertEquals(8, site.getJornadaPickLeft());
+ assertEquals(2, site.staffLog(player.getUniqueId()).getBlocksRemoved());
+ service.finish(player);
+ ticks(5);
+ assertEquals(8, site.getJornadaPickLeft());
+ verify(sites).save(site);
+ }
+
+ @Test
+ public void actualDamageListenerStartsOnlyWhitelistedToolCycles() {
+ server.getPluginManager().registerEvents(new HandPickListener(service, sites), MockBukkit.createMockPlugin());
+ BlockDamageEvent allowed = new BlockDamageEvent(player, target,
+ player.getInventory().getItemInMainHand(), true);
+ server.getPluginManager().callEvent(allowed);
+ assertFalse(allowed.getInstaBreak());
+ assertTrue(service.isCycling(player));
+ assertFalse(player.getAttribute(Attribute.BLOCK_BREAK_SPEED).getModifiers().isEmpty());
+ service.finish(player);
+ player.getInventory().setItemInMainHand(new ItemStack(Material.STICK));
+ BlockDamageEvent unrelated = new BlockDamageEvent(player, target,
+ player.getInventory().getItemInMainHand(), true);
+ server.getPluginManager().callEvent(unrelated);
+ assertTrue(unrelated.getInstaBreak());
+ assertFalse(service.isCycling(player));
+ ticks(1);
+ assertTrue(player.getAttribute(Attribute.BLOCK_BREAK_SPEED).getModifiers().isEmpty());
+ assertEquals(Material.STONE, target.getType());
+ assertEquals(10, site.getJornadaPickLeft());
+ verify(sites, never()).save(any());
+ }
+
+ @Test
+ public void releaseAfterTheReadyWindowLiftsTheLateCutEvenBeforeTheNextTickNoticesIt() {
+ profile = profile(5, 0, 1);
+ service.setSettings(settings(true, 10));
+ awaitReady();
+ for (int tick = 0; tick < 2; tick++) {
+ service.noteMining(player, target);
+ ticks(1);
+ }
+ assertTrue("Still inside the ready window after two ticks", service.isCycling(player));
+ // The next packet lands past the window; the hotbar release arrives before any tick runs.
+ service.noteMining(player, target);
+ service.finish(player);
+ assertEquals(Material.AIR, target.getType());
+ assertEquals(Material.AIR, world.getBlockAt(8, 39, 8).getType());
+ assertEquals(2, site.staffLog(player.getUniqueId()).getBlocksRemoved());
+ assertEquals(8, site.getJornadaPickLeft());
+ }
+
+ @Test
+ public void swappingToAnotherToolProfileMidHoldRestartsTheHoldWithThatProfile() {
+ survey = shaped("survey", Material.DIAMOND_PICKAXE, BreakShape.DOWN, 2, 2, 1);
+ service.noteMining(player, target);
+ // An inventory click swaps the stack without a hotbar event; the next tick ends the hold.
+ player.getInventory().setItemInMainHand(new ItemStack(Material.DIAMOND_PICKAXE));
+ ticks(1);
+ assertFalse(service.isCycling(player));
+ assertEquals(Material.STONE, target.getType());
+ player.getInventory().setItemInMainHand(new ItemStack(Material.IRON_PICKAXE));
+ service.noteMining(player, target);
+ // Swap and dig packet in the same tick: the same cube restarts under the new profile.
+ player.getInventory().setItemInMainHand(new ItemStack(Material.DIAMOND_PICKAXE));
+ service.noteMining(player, target);
+ assertTrue(service.isCycling(player));
+ assertEquals(Material.STONE, target.getType());
+ assertEquals(10, site.getJornadaPickLeft());
+ awaitReady();
+ service.finish(player);
+ assertEquals("The survey profile lifts two cubes on time", Material.AIR, world.getBlockAt(8, 39, 8).getType());
+ assertEquals(Material.AIR, target.getType());
+ assertEquals("and costs one action", 9, site.getJornadaPickLeft());
+ }
+
+ @Test
+ public void closingTheSiteMidHoldEndsItWithoutLiftingAndStopsItsHud() {
+ profile = profile(20, 0, 20);
+ service.setSettings(settings(true, 10));
+ service.noteMining(player, target);
+ ticks(10);
+ assertTrue("An idle hold survives until the stale window", service.isCycling(player));
+ int shown = actionBars.size();
+ assertTrue(shown > 0);
+ site.setStatus(SiteStatus.EXHAUSTED);
+ ticks(1);
+ assertEquals("A closed site no longer answers the held cube's HUD", shown, actionBars.size());
+ ticks(10);
+ assertFalse(service.isCycling(player));
+ site.setStatus(SiteStatus.ESTABLISHED);
+ service.noteMining(player, target);
+ site.setStatus(SiteStatus.EXHAUSTED);
+ ticks(1);
+ assertFalse("An active hold on a closed site ends at once", service.isCycling(player));
+ assertEquals(Material.STONE, target.getType());
+ assertEquals(10, site.getJornadaPickLeft());
+ verify(sites, never()).save(any());
+ }
+
+ @Test
+ public void lookingAtTheSkyWithThePickShowsNoHudAndLeavesMiningUnlocked() {
+ target = null;
+ ticks(3);
+ assertTrue(actionBars.isEmpty());
+ assertTrue(player.getAttribute(Attribute.BLOCK_BREAK_SPEED).getModifiers().isEmpty());
+ }
+
+ @Test
+ public void revokedPermitBeforeReleaseLiftsNothingAndSaysWhy() {
+ awaitReady();
+ site.setDirector(UUID.randomUUID());
+ service.finish(player);
+ assertEquals(Material.STONE, target.getType());
+ assertEquals(10, site.getJornadaPickLeft());
+ assertNull(site.workerRecord(player.getUniqueId()));
+ verify(player).sendMessage("You are not authorised to work on this excavation.");
+ verify(sites, never()).save(any());
+ }
+
+ @Test
+ public void lateAroundLiftHitsOnlyTheAimedFindCubeAndGrazesTheRest() {
+ profile = shaped("test", Material.IRON_PICKAXE, BreakShape.AROUND, 1, 3, 2);
+ service.setSettings(settings(true, 10));
+ BuriedFind find = new BuriedFind();
+ find.setId(UUID.randomUUID());
+ BlockCell aimed = new BlockCell(8, 40, 8), south = new BlockCell(8, 40, 9), east = new BlockCell(9, 40, 8);
+ find.getCells().addAll(List.of(aimed, south, east, new BlockCell(9, 40, 9)));
+ site.getFinds().add(find);
+ stone(8, 40, 9); stone(9, 40, 8); stone(9, 40, 9);
+ awaitReady();
+ keepHoldingUntilResolved(8);
+ assertEquals(Material.AIR, world.getBlockAt(8, 40, 9).getType());
+ assertEquals(Material.AIR, world.getBlockAt(9, 40, 8).getType());
+ assertEquals(Material.STONE, world.getBlockAt(9, 40, 9).getType());
+ assertEquals(Set.of(aimed), find.getDirectHitCells());
+ assertEquals(Set.of(south, east), find.getGrazedCells());
+ assertEquals(3, site.staffLog(player.getUniqueId()).getBlocksRemoved());
+ assertEquals(1, site.staffLog(player.getUniqueId()).getFindsDamaged());
+ }
+
+ @Test
+ public void cutWithNoFindAroundItSendsNoTraceLine() {
+ service.setSettings(settings(true, 10, true));
+ awaitReady();
+ service.finish(player);
+ assertEquals(Material.AIR, target.getType());
+ verify(player, never()).sendMessage(startsWith("Traces of"));
+ verify(player, never()).sendMessage("Buried archaeological remains were destroyed.");
+ }
+
+ @Test
+ public void cubeRefilledWhereAPieceWasLiftedShowsTracesInsteadOfTheOldCondition() {
+ service.setSettings(settings(true, 10, true));
+ BuriedFind lifted = nearbyPot(8, 40, 8);
+ lifted.setState(FindState.RECOVERED);
+ lifted.setConservation(80);
+ nearbyPot(8, 40, 6);
+ doReturn(List.of(world.getBlockAt(8, 40, 7), target)).when(player).getLastTwoTargetBlocks(null, 6);
+ ticks(1);
+ assertEquals("STRATUM I ⛏ 10 Ceramic: 1", actionBars.getLast());
+ }
+
+ @Test
+ public void headUnderWaterShowsNoTraceFragmentForTheWallAhead() {
+ service.setSettings(settings(true, 10, true));
+ nearbyPot(8, 40, 6);
+ Block eye = world.getBlockAt(8, 40, 7);
+ eye.setType(Material.WATER);
+ // Only air is see-through for the line of sight, so it stops in the water at eye level.
+ doReturn(List.of(eye)).when(player).getLastTwoTargetBlocks(null, 6);
+ ticks(1);
+ assertEquals("STRATUM I ⛏ 10", actionBars.getLast());
+ }
+
+ @Test
+ public void torchStandingInTheCutCountsAsAnOpenedCubeForTraces() {
+ service.setSettings(settings(true, 10, true));
+ nearbyPot(8, 40, 7);
+ target = world.getBlockAt(8, 41, 7);
+ target.setType(Material.TORCH);
+ ticks(1);
+ assertEquals("STRATUM I ⛏ 10 Ceramic: 1", actionBars.getLast());
+ assertFalse(service.isCycling(player));
+ }
+
+ @Test
+ public void movingTheHoldToTheCubeAboveOrBesideReleasesTheReadyCube() {
+ Block above = stone(8, 41, 8), beside = stone(8, 41, 9);
+ for (Block[] step : new Block[][] {{target, above}, {above, beside}}) {
+ target = step[0];
+ subtitles.clear();
+ awaitReady();
+ target = step[1];
+ service.noteMining(player, target);
+ assertEquals(Material.AIR, step[0].getType());
+ assertEquals(Material.STONE, step[1].getType());
+ assertTrue(service.isCycling(player));
+ }
+ service.finish(player);
+ assertEquals(Material.STONE, beside.getType());
+ assertEquals(6, site.getJornadaPickLeft());
+ }
+
+ @Test
+ public void refusalRepeatsOnlyAfterTheWarningCooldown() {
+ java.util.concurrent.atomic.AtomicLong now = new java.util.concurrent.atomic.AtomicLong(1_000_000); service.clock = now::get;
+ site.setDirector(UUID.randomUUID());
+ service.noteMining(player, target);
+ now.addAndGet(2999); service.noteMining(player, target);
+ verify(player, times(1)).sendMessage("You are not authorised to work on this excavation.");
+ now.addAndGet(1);
+ service.noteMining(player, target);
+ verify(player, times(2)).sendMessage("You are not authorised to work on this excavation.");
+ assertFalse(service.isCycling(player));
+ }
+
+ private ExcavationTool held(ItemStack stack) {
+ if (stack == null) return null;
+ if (stack.getType() == Material.IRON_PICKAXE) return profile;
+ return stack.getType() == Material.DIAMOND_PICKAXE ? survey : null;
+ }
+
+ private BuriedFind nearbyPot(int x, int y, int z) {
+ BuriedFind nearby = new BuriedFind();
+ nearby.setId(UUID.randomUUID());
+ nearby.setArtifactId("pot");
+ nearby.getCells().add(new BlockCell(x, y, z));
+ stone(x, y, z);
+ site.getFinds().add(nearby);
+ when(catalogs.artifact("pot")).thenReturn(new ArtifactTemplate("pot", "Pot", 1, 1, "ceramic", null,
+ false, 1, Set.of(), Set.of(), null, List.of(), null));
+ when(catalogs.materialDisplayName("ceramic")).thenReturn("Ceramic");
+ return nearby;
+ }
+
+ private PlayerMock worker(String name, Block aimed, List cues) {
+ PlayerMock worker = spy(new PlayerMock(server, name));
+ server.addPlayer(worker);
+ worker.teleport(new Location(world, aimed.getX(), 42, aimed.getZ()));
+ worker.getInventory().setItemInMainHand(new ItemStack(Material.IRON_PICKAXE));
+ doReturn(new AttributeInstanceMock(Attribute.BLOCK_BREAK_SPEED, 1.0))
+ .when(worker).getAttribute(Attribute.BLOCK_BREAK_SPEED);
+ doReturn(aimed).when(worker).getTargetBlockExact(6);
+ doReturn(mock(Player.Spigot.class)).when(worker).spigot();
+ doAnswer(invocation -> { cues.add(invocation.getArgument(1)); return null; })
+ .when(worker).sendTitle(anyString(), anyString(), anyInt(), anyInt(), anyInt());
+ doNothing().when(worker).spawnParticle(any(Particle.class), any(Location.class), anyInt(),
+ anyDouble(), anyDouble(), anyDouble(), anyDouble(), any());
+ doNothing().when(worker).spawnParticle(any(Particle.class), any(Location.class), anyInt(),
+ anyDouble(), anyDouble(), anyDouble(), anyDouble());
+ site.getExcavators().add(worker.getUniqueId());
+ return worker;
+ }
+
+ private static Player.Spigot actionBarSink(List messages) {
+ return mock(Player.Spigot.class, invocation -> {
+ if (invocation.getMethod().getName().equals("sendMessage")) {
+ for (Object argument : invocation.getArguments()) {
+ if (argument instanceof BaseComponent component) messages.add(component.toPlainText());
+ if (argument instanceof BaseComponent[] components) messages.add(BaseComponent.toPlainText(components));
+ }
+ }
+ return null;
+ });
+ }
+
+ private void awaitReady() {
+ service.noteMining(player, target);
+ for (int tick = 0; tick < 100 && !subtitles.contains("Release"); tick++) {
+ service.noteMining(player, target);
+ ticks(1);
+ }
+ assertTrue("A complete hold must emit the ready subtitle", subtitles.contains("Release"));
+ assertTrue(service.isCycling(player));
+ }
+
+ private void keepHoldingUntilResolved(int bound) {
+ for (int tick = 0; tick < bound && service.isCycling(player); tick++) {
+ service.noteMining(player, target);
+ ticks(1);
+ }
+ assertFalse("A continued hold must resolve after ready", service.isCycling(player));
+ }
+
+ private void ticks(int count) {
+ server.getScheduler().performTicks(count);
+ }
+
+ private Block stone(int x, int y, int z) {
+ Block block = world.getBlockAt(x, y, z);
+ block.setType(Material.STONE);
+ return block;
+ }
+
+ private ExcavationTool profile(int cueTicks, int fallback, int readyWindow) {
+ return new ExcavationTool("test", List.of(ItemRef.vanilla(Material.IRON_PICKAXE)), cueTicks,
+ DigClass.NONE, fallback, null, null, 1, 2, BreakShape.DOWN, 2, readyWindow);
+ }
+
+ private ExcavationTool shaped(String id, Material item, BreakShape shape, int onTime, int late, int cost) {
+ return new ExcavationTool(id, List.of(ItemRef.vanilla(item)), 1, DigClass.NONE, 0, null, null,
+ onTime, late, shape, cost, 20);
+ }
+
+ private PickSettings settings(boolean enabled, int budget) {
+ return settings(enabled, budget, false);
+ }
+
+ private PickSettings settings(boolean enabled, int budget, boolean traces) {
+ return new PickSettings(enabled, budget, true, false, 6, 2,
+ ConservationSettings.defaults(), LimitsSettings.defaults(), traces,
+ CueSettings.defaults(), List.of(profile));
+ }
+
+ /** MockBukkit's block locations need snapshots; vanilla speed is supplied as an external input. */
+ private static class SnapshotWorld extends WorldMock {
+ private final Map blocks = new HashMap<>();
+ private float breakProgress;
+
+ @Override
+ public BlockMock getBlockAt(int x, int y, int z) {
+ return blocks.computeIfAbsent(new BlockCell(x, y, z), key -> new BlockMock(new Location(this, x, y, z)) {
+ @Override public Location getLocation() { return super.getLocation().clone(); }
+ @Override public float getBreakSpeed(Player player) { return breakProgress; }
+ });
+ }
+ }
+}
diff --git a/src/test/java/net/tfminecraft/archaeo/excavation/HoldCuePlanTest.java b/src/test/java/net/tfminecraft/archaeo/excavation/HoldCuePlanTest.java
new file mode 100644
index 0000000..5a27f9a
--- /dev/null
+++ b/src/test/java/net/tfminecraft/archaeo/excavation/HoldCuePlanTest.java
@@ -0,0 +1,35 @@
+package net.tfminecraft.archaeo.excavation;
+
+import org.junit.Test;
+
+import static net.tfminecraft.archaeo.excavation.HoldCuePlan.BreakCue.*;
+import static org.junit.Assert.*;
+
+public class HoldCuePlanTest {
+ @Test
+ public void rolledPlansPlayOneToThreeClingsThenExactlyOneClang() {
+ for (int sample = 0; sample < 100; sample++) {
+ HoldCuePlan plan = HoldCuePlan.roll();
+ int clings = 0;
+ HoldCuePlan.BreakCue cue;
+ while ((cue = plan.nextCue()) == CLING) {
+ clings++;
+ assertTrue("A ready cue must follow at most three clings", clings <= 3);
+ }
+ assertTrue(clings >= 1);
+ assertEquals(CLANG, cue);
+ for (int beat = 0; beat < 10; beat++) assertEquals(AFTER, plan.nextCue());
+ }
+ }
+
+ @Test
+ public void eachHoldHasAnIndependentCueClock() {
+ HoldCuePlan finished = HoldCuePlan.roll();
+ HoldCuePlan untouched = HoldCuePlan.roll();
+ assertNotSame(finished, untouched);
+ for (int beat = 0; beat < 4; beat++) finished.nextCue();
+ assertEquals(AFTER, finished.nextCue());
+ assertEquals(CLING, untouched.nextCue());
+ assertEquals(CLING, HoldCuePlan.roll().nextCue());
+ }
+}
diff --git a/src/test/java/net/tfminecraft/archaeo/excavation/LiftPlanTest.java b/src/test/java/net/tfminecraft/archaeo/excavation/LiftPlanTest.java
new file mode 100644
index 0000000..a692760
--- /dev/null
+++ b/src/test/java/net/tfminecraft/archaeo/excavation/LiftPlanTest.java
@@ -0,0 +1,200 @@
+package net.tfminecraft.archaeo.excavation;
+
+import net.tfminecraft.archaeo.config.BreakShape;
+import net.tfminecraft.archaeo.config.ExcavationTool;
+import net.tfminecraft.archaeo.model.Site;
+import net.tfminecraft.archaeo.model.StratumBand;
+import org.bukkit.Material;
+import org.bukkit.World;
+import org.bukkit.block.Block;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.mockbukkit.mockbukkit.MockBukkit;
+
+import java.util.HashSet;
+import java.util.List;
+
+import static org.junit.Assert.*;
+
+public class LiftPlanTest {
+ private World world;
+ private Site site;
+ private Block origin;
+
+ @Before
+ public void setup() {
+ world = MockBukkit.mock().addSimpleWorld("lift");
+ site = new Site();
+ StratumBand band = new StratumBand();
+ band.setPresent(true);
+ band.setMinY(38);
+ band.setMaxY(42);
+ site.getStrata().put("I", band);
+ origin = stone(8, 40, 8);
+ }
+
+ @After
+ public void teardown() {
+ MockBukkit.unmock();
+ }
+
+ @Test
+ public void invalidOriginCannotStartLiftForAnyShape() {
+ for (BreakShape shape : BreakShape.values()) {
+ for (Material invalid : List.of(Material.AIR, Material.WATER, Material.TORCH)) {
+ origin.setType(invalid);
+ assertTrue(LiftPlan.cells(site, origin, tool(shape, 3, 5), false).isEmpty());
+ }
+ assertTrue(LiftPlan.cells(site, stone(16, 40, 8), tool(shape, 3, 5), false).isEmpty());
+ assertTrue(LiftPlan.cells(site, stone(8, 43, 8), tool(shape, 3, 5), false).isEmpty());
+ }
+ }
+
+ @Test
+ public void minimumCountAlwaysIncludesOnlyTheAimedCell() {
+ fillFootprint(origin);
+ for (BreakShape shape : BreakShape.values()) {
+ for (int count : new int[] {-2, 0, 1}) {
+ assertEquals(List.of(origin), LiftPlan.cells(site, origin, tool(shape, count, -4), false));
+ assertEquals(List.of(origin), LiftPlan.cells(site, origin, tool(shape, count, -4), true));
+ }
+ }
+ }
+
+ @Test
+ public void lateCountCannotShrinkTheReadyLift() {
+ fillFootprint(origin);
+ assertEquals(4, LiftPlan.cells(site, origin, tool(BreakShape.AROUND, 4, 2), true).size());
+ assertEquals(2, LiftPlan.cells(site, origin, tool(BreakShape.AROUND, 2, 4), false).size());
+ assertEquals(4, LiftPlan.cells(site, origin, tool(BreakShape.AROUND, 2, 4), true).size());
+ }
+
+ @Test
+ public void downLiftsInDescendingOrderAndStopsAtThePrismFloor() {
+ for (int y = 36; y < 40; y++) stone(8, y, 8);
+ assertEquals(List.of(origin, world.getBlockAt(8, 39, 8), world.getBlockAt(8, 38, 8)),
+ LiftPlan.cells(site, origin, tool(BreakShape.DOWN, 8, 8), false));
+ assertEquals(List.of(origin, world.getBlockAt(8, 39, 8)),
+ LiftPlan.cells(site, origin, tool(BreakShape.DOWN, 2, 2), false));
+ }
+
+ @Test
+ public void downNeverJumpsAcrossAirFluidOrDecoration() {
+ stone(8, 38, 8);
+ for (Material barrier : List.of(Material.AIR, Material.LAVA, Material.TORCH)) {
+ world.getBlockAt(8, 39, 8).setType(barrier);
+ assertEquals(List.of(origin), LiftPlan.cells(site, origin, tool(BreakShape.DOWN, 3, 3), false));
+ }
+ }
+
+ @Test
+ public void aroundPrefersCardinalsThenDiagonalsThenLowerLayer() {
+ fillFootprint(origin);
+ List lift = LiftPlan.cells(site, origin, tool(BreakShape.AROUND, 30, 30), false);
+ int[][] offsets = {{0, 0}, {0, 1}, {1, 0}, {0, -1}, {-1, 0},
+ {1, 1}, {1, -1}, {-1, -1}, {-1, 1}};
+ assertEquals(18, lift.size());
+ for (int down = 0; down <= 1; down++) {
+ for (int i = 0; i < offsets.length; i++) {
+ assertEquals(origin.getRelative(offsets[i][0], -down, offsets[i][1]), lift.get(down * 9 + i));
+ }
+ }
+ assertConnected(lift);
+ }
+
+ @Test
+ public void disconnectedDiagonalCannotJoinThroughAnEdgeOrCorner() {
+ Block diagonal = stone(9, 40, 9);
+ for (BreakShape shape : List.of(BreakShape.AROUND, BreakShape.RANDOM)) {
+ assertEquals(List.of(origin), LiftPlan.cells(site, origin, tool(shape, 18, 18), false));
+ }
+ Block bridge = stone(8, 40, 9);
+ assertEquals(List.of(origin, bridge, diagonal),
+ LiftPlan.cells(site, origin, tool(BreakShape.AROUND, 18, 18), false));
+ }
+
+ @Test
+ public void lowerLayerCanConnectOtherwiseIsolatedUpperCell() {
+ Block bottom = stone(8, 39, 8);
+ Block bottomEast = stone(9, 39, 8);
+ Block bottomCorner = stone(9, 39, 9);
+ Block topCorner = stone(9, 40, 9);
+ assertEquals(List.of(origin, bottom, bottomEast, bottomCorner, topCorner),
+ LiftPlan.cells(site, origin, tool(BreakShape.AROUND, 18, 18), false));
+ }
+
+ @Test
+ public void footprintClipsAtChunkAndStratumBoundariesIncludingNegativeChunks() {
+ for (int chunk : new int[] {0, -1}) {
+ site.setChunkX(chunk);
+ site.setChunkZ(chunk);
+ Block edge = stone(chunk * 16, 38, chunk * 16);
+ fillFootprint(edge);
+ for (BreakShape shape : List.of(BreakShape.AROUND, BreakShape.RANDOM)) {
+ List lift = LiftPlan.cells(site, edge, tool(shape, 30, 30), false);
+ assertEquals(4, lift.size());
+ assertConnected(lift);
+ for (Block block : lift) {
+ assertEquals(38, block.getY());
+ assertTrue(site.isInPrism(block.getX(), block.getY(), block.getZ()));
+ }
+ }
+ }
+ }
+
+ @Test
+ public void randomLiftsPreserveCountBoundsUniquenessAndFaceConnectivity() {
+ fillFootprint(origin);
+ origin.getRelative(1, 0, 0).setType(Material.AIR);
+ origin.getRelative(0, -1, -1).setType(Material.WATER);
+ for (int count = 2; count <= 20; count++) {
+ for (int sample = 0; sample < 5; sample++) {
+ List lift = LiftPlan.cells(site, origin, tool(BreakShape.RANDOM, count, count), false);
+ assertEquals(Math.min(count, 16), lift.size());
+ assertEquals(origin, lift.getFirst());
+ assertConnected(lift);
+ for (Block block : lift) {
+ assertTrue(Math.abs(block.getX() - origin.getX()) <= 1);
+ assertTrue(Math.abs(block.getZ() - origin.getZ()) <= 1);
+ assertTrue(block.getY() == origin.getY() || block.getY() == origin.getY() - 1);
+ assertEquals(Material.STONE, block.getType());
+ }
+ }
+ }
+ }
+
+ private static void assertConnected(List lift) {
+ assertEquals(lift.size(), new HashSet<>(lift).size());
+ for (int i = 1; i < lift.size(); i++) {
+ Block cell = lift.get(i);
+ boolean attached = false;
+ for (int j = 0; j < i; j++) {
+ Block prior = lift.get(j);
+ int distance = Math.abs(cell.getX() - prior.getX())
+ + Math.abs(cell.getY() - prior.getY()) + Math.abs(cell.getZ() - prior.getZ());
+ attached |= distance == 1;
+ }
+ assertTrue("Each new cell shares a face with an earlier cell", attached);
+ }
+ }
+
+ private void fillFootprint(Block center) {
+ for (int dx = -1; dx <= 1; dx++) {
+ for (int dz = -1; dz <= 1; dz++) {
+ for (int dy = -1; dy <= 0; dy++) center.getRelative(dx, dy, dz).setType(Material.STONE);
+ }
+ }
+ }
+
+ private Block stone(int x, int y, int z) {
+ Block block = world.getBlockAt(x, y, z);
+ block.setType(Material.STONE);
+ return block;
+ }
+
+ private static ExcavationTool tool(BreakShape shape, int onTime, int late) {
+ return new ExcavationTool("test", List.of(), 0, null, 0, null, null,
+ onTime, late, shape, 1, 20);
+ }
+}
diff --git a/src/test/java/net/tfminecraft/archaeo/excavation/NeighborTracesTest.java b/src/test/java/net/tfminecraft/archaeo/excavation/NeighborTracesTest.java
new file mode 100644
index 0000000..3f37e3c
--- /dev/null
+++ b/src/test/java/net/tfminecraft/archaeo/excavation/NeighborTracesTest.java
@@ -0,0 +1,133 @@
+package net.tfminecraft.archaeo.excavation;
+
+import net.tfminecraft.archaeo.config.ArtifactTemplate;
+import net.tfminecraft.archaeo.config.CatalogRegistry;
+import net.tfminecraft.archaeo.model.BlockCell;
+import net.tfminecraft.archaeo.model.BuriedFind;
+import net.tfminecraft.archaeo.model.FindState;
+import net.tfminecraft.archaeo.model.Site;
+import org.bukkit.Material;
+import org.bukkit.World;
+import org.bukkit.block.Block;
+import org.bukkit.block.BlockFace;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.mockbukkit.mockbukkit.MockBukkit;
+
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import static org.junit.Assert.*;
+import static org.mockito.Mockito.*;
+
+public class NeighborTracesTest {
+ private CatalogRegistry catalogs;
+ private Site site;
+ private Block origin;
+
+ @Before
+ public void setup() {
+ World world = MockBukkit.mock().addSimpleWorld("traces");
+ catalogs = mock(CatalogRegistry.class);
+ site = new Site();
+ origin = world.getBlockAt(8, 40, 8);
+ }
+
+ @After
+ public void teardown() {
+ MockBukkit.unmock();
+ }
+
+ @Test
+ public void emptyNeighbourhoodProducesNoChatAndClearHud() {
+ Map counts = NeighborTraces.count(site, catalogs, origin);
+ assertTrue(counts.isEmpty());
+ assertNull(NeighborTraces.chatLine(catalogs, counts));
+ assertEquals("clear", NeighborTraces.hudFragment(catalogs, counts));
+ verifyNoInteractions(catalogs);
+ }
+
+ @Test
+ public void countsEachFaceAdjacentCubeIncludingSeveralFromTheSameArtifact() {
+ artifact("pot", "ceramic");
+ BuriedFind pot = add("pot", origin.getRelative(BlockFace.WEST), origin.getRelative(-1, 0, -1),
+ origin.getRelative(BlockFace.NORTH), origin.getRelative(1, 0, -1),
+ origin.getRelative(BlockFace.EAST));
+ artifact("bone", "bone");
+ add("bone", origin.getRelative(BlockFace.UP));
+ add("bone", origin.getRelative(BlockFace.DOWN));
+ add("bone", origin.getRelative(BlockFace.SOUTH));
+ Map counts = NeighborTraces.count(site, catalogs, origin);
+ assertEquals(Map.of("ceramic", 3, "bone", 3), counts);
+ assertEquals(List.of("bone", "ceramic"), List.copyOf(counts.keySet()));
+ assertEquals(FindState.HIDDEN, pot.getState());
+ assertEquals(100, pot.getConservation());
+ }
+
+ @Test
+ public void ignoresOriginDiagonalsAndMoreDistantFindCells() {
+ add("pot", origin);
+ add("pot", origin.getRelative(1, 1, 0));
+ add("pot", origin.getRelative(1, 0, 1));
+ add("pot", origin.getRelative(0, 0, 2));
+ assertTrue(NeighborTraces.count(site, catalogs, origin).isEmpty());
+ verifyNoInteractions(catalogs);
+ }
+
+ @Test
+ public void lostRecoveredAndMissingTerrainDoNotLeaveTraces() {
+ add("lost", origin.getRelative(BlockFace.UP)).setState(FindState.LOST);
+ add("recovered", origin.getRelative(BlockFace.DOWN)).setState(FindState.RECOVERED);
+ BlockFace[] faces = {BlockFace.NORTH, BlockFace.SOUTH, BlockFace.EAST};
+ Material[] missing = {Material.AIR, Material.WATER, Material.TORCH};
+ for (int i = 0; i < faces.length; i++) {
+ Block block = origin.getRelative(faces[i]);
+ add("gone", block);
+ block.setType(missing[i]);
+ }
+ assertTrue(NeighborTraces.count(site, catalogs, origin).isEmpty());
+ verifyNoInteractions(catalogs);
+ }
+
+ @Test
+ public void missingTemplatesAndNullOrBlankMaterialGroupAsUnknown() {
+ add("missing", origin.getRelative(BlockFace.UP));
+ artifact("null", null);
+ add("null", origin.getRelative(BlockFace.DOWN));
+ artifact("blank", " ");
+ add("blank", origin.getRelative(BlockFace.NORTH));
+ assertEquals(Map.of("unknown", 3), NeighborTraces.count(site, catalogs, origin));
+ }
+
+ @Test
+ public void chatAndHudUseCatalogLabelsAndPreserveCountOrder() {
+ when(catalogs.materialDisplayName("ceramic")).thenReturn("Ceramic");
+ when(catalogs.materialDisplayName("bone")).thenReturn("Bone");
+ Map counts = new LinkedHashMap<>();
+ counts.put("ceramic", 2);
+ assertEquals("Traces of Ceramic: 2", NeighborTraces.chatLine(catalogs, counts));
+ assertEquals("Ceramic: 2", NeighborTraces.hudFragment(catalogs, counts));
+ counts.put("bone", 1);
+ assertEquals("Traces of Ceramic: 2 · Bone: 1", NeighborTraces.chatLine(catalogs, counts));
+ assertEquals("Ceramic: 2 · Bone: 1", NeighborTraces.hudFragment(catalogs, counts));
+ }
+
+ private void artifact(String id, String material) {
+ when(catalogs.artifact(id)).thenReturn(new ArtifactTemplate(id, id, 1, 6, material,
+ null, false, 1, Set.of(), Set.of(), null, List.of(), null));
+ }
+
+ private BuriedFind add(String id, Block... cells) {
+ BuriedFind find = new BuriedFind();
+ find.setArtifactId(id);
+ for (Block cell : cells) {
+ cell.setType(Material.STONE);
+ find.getCells().add(new BlockCell(cell.getX(), cell.getY(), cell.getZ()));
+ }
+ site.getFinds().add(find);
+ return find;
+ }
+}
diff --git a/src/test/java/net/tfminecraft/archaeo/excavation/PrismFillTest.java b/src/test/java/net/tfminecraft/archaeo/excavation/PrismFillTest.java
new file mode 100644
index 0000000..451915d
--- /dev/null
+++ b/src/test/java/net/tfminecraft/archaeo/excavation/PrismFillTest.java
@@ -0,0 +1,53 @@
+package net.tfminecraft.archaeo.excavation;
+
+import org.bukkit.Material;
+import org.bukkit.block.Block;
+import org.bukkit.block.BlockFace;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.mockbukkit.mockbukkit.MockBukkit;
+import org.mockbukkit.mockbukkit.world.WorldMock;
+
+import java.util.List;
+
+import static org.junit.Assert.*;
+
+public class PrismFillTest {
+ private WorldMock world;
+
+ @Before
+ public void setup() {
+ world = MockBukkit.mock().addSimpleWorld("fill");
+ }
+
+ @After
+ public void teardown() { MockBukkit.unmock(); }
+
+ @Test
+ public void buildsAndGroundAreFillWhileAirFluidsAndDecorationsAreNot() {
+ for (Material fill : List.of(Material.DIRT, Material.STONE, Material.COBBLESTONE, Material.BRICKS,
+ Material.OAK_PLANKS, Material.GLASS, Material.FURNACE)) {
+ assertTrue(fill + " is dug as fill", PrismFill.isTerrainFill(fill));
+ }
+ for (Material open : List.of(Material.AIR, Material.CAVE_AIR, Material.WATER, Material.LAVA,
+ Material.BUBBLE_COLUMN, Material.SHORT_GRASS, Material.TORCH, Material.OAK_SIGN,
+ Material.OAK_HANGING_SIGN, Material.LADDER, Material.RAIL, Material.SCAFFOLDING, Material.LANTERN)) {
+ assertFalse(open + " is not fill", PrismFill.isTerrainFill(open));
+ }
+ }
+
+ @Test
+ public void snowAndPowderSnowLeaveAFaceOpenButASealedCubeHasNone() {
+ Block cube = world.getBlockAt(8, 40, 8);
+ cube.setType(Material.STONE);
+ for (BlockFace face : PrismFill.FACES) cube.getRelative(face).setType(Material.DIRT);
+ assertFalse(PrismFill.hasOpenFace(cube));
+ assertTrue(PrismFill.openingFaces(cube).isEmpty());
+ for (Material cover : List.of(Material.SNOW, Material.POWDER_SNOW)) {
+ cube.getRelative(BlockFace.UP).setType(cover);
+ assertTrue(cover + " does not seal the face", PrismFill.hasOpenFace(cube));
+ assertEquals(List.of(BlockFace.UP), PrismFill.openingFaces(cube));
+ }
+ }
+}
diff --git a/src/test/java/net/tfminecraft/archaeo/excavation/PrismListenerTest.java b/src/test/java/net/tfminecraft/archaeo/excavation/PrismListenerTest.java
new file mode 100644
index 0000000..d99d86e
--- /dev/null
+++ b/src/test/java/net/tfminecraft/archaeo/excavation/PrismListenerTest.java
@@ -0,0 +1,395 @@
+package net.tfminecraft.archaeo.excavation;
+
+import net.tfminecraft.archaeo.model.*;
+import net.tfminecraft.archaeo.config.PickSettings;
+import net.tfminecraft.archaeo.site.SiteRepository;
+import org.bukkit.ExplosionResult;
+import org.bukkit.Location;
+import org.bukkit.Material;
+import org.bukkit.block.Block;
+import org.bukkit.block.BlockFace;
+import org.bukkit.entity.Entity;
+import org.bukkit.entity.Player;
+import org.bukkit.event.block.*;
+import org.bukkit.event.entity.EntityChangeBlockEvent;
+import org.bukkit.event.entity.EntityExplodeEvent;
+import org.bukkit.inventory.ItemStack;
+import org.bukkit.inventory.PlayerInventory;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.mockbukkit.mockbukkit.MockBukkit;
+import org.mockbukkit.mockbukkit.ServerMock;
+import org.mockbukkit.mockbukkit.block.BlockMock;
+import org.mockbukkit.mockbukkit.world.WorldMock;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.UUID;
+
+import static org.junit.Assert.*;
+import static org.mockito.Mockito.*;
+
+public class PrismListenerTest {
+ private ServerMock server;
+ private SnapshotWorld world;
+ private SiteRepository sites;
+ private PrismListener listener;
+ private Player player;
+ private PlayerInventory inventory;
+ private Site site;
+ private BuriedFind find;
+ private Block block;
+
+ @Before
+ public void setup() {
+ server = MockBukkit.mock();
+ world = new SnapshotWorld();
+ world.setName("prism");
+ server.addWorld(world);
+ sites = mock(SiteRepository.class);
+ DigTools tools = mock(DigTools.class);
+ when(tools.isAllowed(any())).thenAnswer(invocation -> {
+ ItemStack item = invocation.getArgument(0);
+ return item != null && item.getType() == Material.IRON_PICKAXE;
+ });
+ player = mock(Player.class);
+ when(player.getUniqueId()).thenReturn(UUID.randomUUID());
+ inventory = mock(PlayerInventory.class);
+ when(player.getInventory()).thenReturn(inventory);
+ when(inventory.getItemInMainHand()).thenReturn(new ItemStack(Material.STICK));
+ site = new Site();
+ site.setId(UUID.randomUUID());
+ site.setStatus(SiteStatus.ESTABLISHED);
+ site.setWorldName(world.getName());
+ site.setDirector(player.getUniqueId());
+ StratumBand band = new StratumBand();
+ band.setId("I");
+ band.setPresent(true);
+ band.setMinY(39);
+ band.setMaxY(41);
+ site.getStrata().put("I", band);
+ find = new BuriedFind();
+ find.setId(UUID.randomUUID());
+ for (int x = 8; x <= 10; x++) {
+ find.getCells().add(new BlockCell(x, 40, 8));
+ stone(x, 40, 8);
+ }
+ site.getFinds().add(find);
+ block = world.getBlockAt(8, 40, 8);
+ when(sites.findPrism(anyString(), anyInt(), anyInt(), anyInt())).thenAnswer(invocation ->
+ world.getName().equals(invocation.getArgument(0))
+ && site.isInPrism(invocation.getArgument(1), invocation.getArgument(2), invocation.getArgument(3))
+ ? Optional.of(site) : Optional.empty());
+ when(sites.findEstablishedPrism(anyString(), anyInt(), anyInt(), anyInt())).thenAnswer(invocation ->
+ site.getStatus() == SiteStatus.ESTABLISHED
+ && world.getName().equals(invocation.getArgument(0))
+ && site.isInPrism(invocation.getArgument(1), invocation.getArgument(2), invocation.getArgument(3))
+ ? Optional.of(site) : Optional.empty());
+ listener = new PrismListener(sites, tools, true);
+ server.getPluginManager().registerEvents(listener, MockBukkit.createMockPlugin());
+ }
+
+ @After
+ public void teardown() { MockBukkit.unmock(); }
+
+ @Test
+ public void protectedBreakAndDamageAreCancelledWithoutWoundingAndWarningIsThrottled() {
+ for (int i = 0; i < 2; i++) {
+ BlockBreakEvent event = new BlockBreakEvent(block, player);
+ server.getPluginManager().callEvent(event);
+ assertTrue(event.isCancelled());
+ }
+ BlockDamageEvent damage = new BlockDamageEvent(player, block, new ItemStack(Material.STICK), false);
+ server.getPluginManager().callEvent(damage);
+ assertTrue(damage.isCancelled());
+ assertEquals(100, find.getConservation());
+ assertFalse(site.stratumAt(40).isDisturbed());
+ verify(player, times(1)).sendMessage("Use an excavation tool.");
+ verify(sites, never()).save(any());
+ }
+
+ @Test
+ public void whitelistedToolsAreDelegatedToTheHandPickListener() {
+ when(inventory.getItemInMainHand()).thenReturn(new ItemStack(Material.IRON_PICKAXE));
+ BlockBreakEvent event = new BlockBreakEvent(block, player);
+ listener.onBreak(event);
+ assertFalse(event.isCancelled());
+ BlockDamageEvent damage = new BlockDamageEvent(player, block, new ItemStack(Material.IRON_PICKAXE), false);
+ listener.onDamage(damage);
+ assertFalse(damage.isCancelled());
+ verify(player, never()).sendMessage(anyString());
+ }
+
+ @Test
+ public void offPrismAndDecorativeBlocksAreNotLocked() {
+ for (Block candidate : List.of(stone(16, 40, 8), stone(8, 42, 8))) {
+ BlockBreakEvent event = new BlockBreakEvent(candidate, player);
+ server.getPluginManager().callEvent(event);
+ assertFalse(event.isCancelled());
+ }
+ block.setType(Material.TORCH);
+ BlockDamageEvent decoration = new BlockDamageEvent(player, block, new ItemStack(Material.STICK), false);
+ server.getPluginManager().callEvent(decoration);
+ assertFalse(decoration.isCancelled());
+ assertEquals(100, find.getConservation());
+ verify(sites, never()).save(any());
+ }
+
+ @Test
+ public void unprotectedBreakGrazesFindDisturbsLayerAndOnlySavesFreshChanges() {
+ listener.setProtectDigSite(false);
+ BlockBreakEvent first = new BlockBreakEvent(block, player);
+ server.getPluginManager().callEvent(first);
+ assertFalse(first.isCancelled());
+ assertEquals(67, find.getConservation());
+ assertTrue(site.stratumAt(40).isDisturbed());
+ assertEquals(1, site.staffLog(player.getUniqueId()).getFindsDamaged());
+ verify(sites).save(site);
+ server.getPluginManager().callEvent(new BlockBreakEvent(block, player));
+ assertEquals(67, find.getConservation());
+ verify(sites, times(1)).save(site);
+ verify(player, times(1)).sendMessage("Buried archaeological remains were destroyed.");
+ }
+
+ @Test
+ public void hiddenRuinIsNotProtectedButVanillaDamageStillRecordsDamage() {
+ site.setStatus(SiteStatus.HIDDEN);
+ BlockBreakEvent event = new BlockBreakEvent(block, player);
+ server.getPluginManager().callEvent(event);
+ assertFalse(event.isCancelled());
+ assertEquals(67, find.getConservation());
+ assertTrue(site.stratumAt(40).isDisturbed());
+ verify(sites).save(site);
+ }
+
+ @Test
+ public void cancelledExternalBreakCannotChargeWorkerOrDamageFind() {
+ listener.setProtectDigSite(false);
+ BlockBreakEvent event = new BlockBreakEvent(block, player);
+ event.setCancelled(true);
+ server.getPluginManager().callEvent(event);
+ assertEquals(100, find.getConservation());
+ assertTrue(site.getWorkers().isEmpty());
+ verify(sites, never()).save(any());
+ }
+
+ @Test
+ public void fireAndEntityChangesRespectProtectionThenRecordEnvironmentalWounds() {
+ BlockBurnEvent fire = new BlockBurnEvent(block);
+ EntityChangeBlockEvent change = new EntityChangeBlockEvent(mock(Entity.class), block,
+ Material.AIR.createBlockData());
+ server.getPluginManager().callEvent(fire);
+ server.getPluginManager().callEvent(change);
+ assertTrue(fire.isCancelled());
+ assertTrue(change.isCancelled());
+ assertEquals(100, find.getConservation());
+ listener.setProtectDigSite(false);
+ server.getPluginManager().callEvent(new BlockBurnEvent(block));
+ server.getPluginManager().callEvent(new EntityChangeBlockEvent(mock(Entity.class),
+ world.getBlockAt(9, 40, 8), Material.AIR.createBlockData()));
+ assertEquals(33, find.getConservation());
+ assertTrue(site.getWorkers().isEmpty());
+ verify(sites, times(2)).save(site);
+ }
+
+ @Test
+ public void playerEntityChangeAttributesDamageToTheWorker() {
+ listener.setProtectDigSite(false);
+ server.getPluginManager().callEvent(new EntityChangeBlockEvent(player, block,
+ Material.AIR.createBlockData()));
+ assertEquals(67, find.getConservation());
+ assertEquals(1, site.staffLog(player.getUniqueId()).getFindsDamaged());
+ }
+
+ @Test
+ public void explosionsRemoveOnlyProtectedFillFromTheirMutableBlockLists() {
+ Block outside = stone(16, 40, 8);
+ Block decoration = world.getBlockAt(12, 40, 8);
+ decoration.setType(Material.TORCH);
+ List blockList = new ArrayList<>(List.of(block, outside, decoration));
+ BlockExplodeEvent explosion = blockExplosion(blockList);
+ server.getPluginManager().callEvent(explosion);
+ assertFalse(explosion.isCancelled());
+ assertEquals(List.of(outside, decoration), explosion.blockList());
+ assertEquals(100, find.getConservation());
+ List entityList = new ArrayList<>(List.of(block, outside));
+ EntityExplodeEvent entityExplosion = entityExplosion(mock(Entity.class), entityList);
+ server.getPluginManager().callEvent(entityExplosion);
+ assertEquals(List.of(outside), entityExplosion.blockList());
+ assertEquals(100, find.getConservation());
+ }
+
+ @Test
+ public void playerExplosionChargesEachPieceOnceAndArchivesFatalLoss() {
+ listener.setProtectDigSite(false);
+ List cells = new ArrayList<>(List.of(block, world.getBlockAt(9, 40, 8),
+ world.getBlockAt(10, 40, 8), stone(16, 40, 8)));
+ server.getPluginManager().callEvent(entityExplosion(player, cells));
+ assertEquals(FindState.LOST, find.getState());
+ assertEquals(0, find.getConservation());
+ assertEquals(1, site.staffLog(player.getUniqueId()).getFindsDamaged());
+ assertEquals(1, site.staffLog(player.getUniqueId()).getFindsLost());
+ assertEquals(SiteStatus.EXHAUSTED, site.getStatus());
+ assertEquals(player.getUniqueId(), find.getRecoveredBy());
+ assertTrue(find.getFindNumber() > 0);
+ verify(player, times(1)).sendMessage("Buried archaeological remains were destroyed.");
+ verify(sites, times(2)).save(site);
+ }
+
+ @Test
+ public void environmentalExplosionSavesOneDossierForSeveralWoundedCells() {
+ listener.setProtectDigSite(false);
+ server.getPluginManager().callEvent(blockExplosion(new ArrayList<>(List.of(block,
+ world.getBlockAt(9, 40, 8)))));
+ assertEquals(33, find.getConservation());
+ assertEquals(2, find.getGrazedCells().size());
+ assertTrue(site.getWorkers().isEmpty());
+ verify(sites).save(site);
+ }
+
+ @Test
+ public void pistonsCannotMoveProtectedFillOrPushAHeadIntoIt() {
+ Block piston = piston(7, 40, 8);
+ BlockPistonExtendEvent headOnly = new BlockPistonExtendEvent(piston, List.of(), BlockFace.EAST);
+ server.getPluginManager().callEvent(headOnly);
+ assertTrue(headOnly.isCancelled());
+ BlockPistonExtendEvent push = new BlockPistonExtendEvent(piston, List.of(block), BlockFace.EAST);
+ server.getPluginManager().callEvent(push);
+ assertTrue(push.isCancelled());
+ BlockPistonRetractEvent pull = new BlockPistonRetractEvent(piston, List.of(block), BlockFace.WEST);
+ server.getPluginManager().callEvent(pull);
+ assertTrue(pull.isCancelled());
+ assertEquals(100, find.getConservation());
+ verify(sites, never()).save(any());
+ }
+
+ @Test
+ public void permittedPistonMovesDamageFindsButEmptyOffPrismMovesDoNothing() {
+ listener.setProtectDigSite(false);
+ Block piston = piston(7, 40, 8);
+ BlockPistonExtendEvent push = new BlockPistonExtendEvent(piston, List.of(block), BlockFace.EAST);
+ server.getPluginManager().callEvent(push);
+ assertFalse(push.isCancelled());
+ BlockPistonRetractEvent pull = new BlockPistonRetractEvent(piston,
+ List.of(world.getBlockAt(9, 40, 8)), BlockFace.WEST);
+ server.getPluginManager().callEvent(pull);
+ assertFalse(pull.isCancelled());
+ assertEquals(33, find.getConservation());
+ BlockPistonRetractEvent empty = new BlockPistonRetractEvent(piston(16, 40, 8), List.of(), BlockFace.EAST);
+ server.getPluginManager().callEvent(empty);
+ assertFalse(empty.isCancelled());
+ assertTrue(site.getWorkers().isEmpty());
+ verify(sites, times(2)).save(site);
+ }
+
+ @Test
+ public void placedDecorationFamiliesRemainMineableWhileConstructionBlocksAreProtected() {
+ Block decoration = world.getBlockAt(12, 40, 8);
+ stone(12, 39, 8);
+ stone(12, 41, 8);
+ stone(12, 40, 9);
+ for (Material material : List.of(Material.SHORT_GRASS, Material.OAK_SIGN, Material.OAK_HANGING_SIGN,
+ Material.CANDLE, Material.CANDLE_CAKE, Material.WHITE_BANNER, Material.STONE_BUTTON,
+ Material.STONE_PRESSURE_PLATE, Material.LADDER, Material.WHITE_CARPET, Material.FLOWER_POT,
+ Material.RAIL, Material.TORCH)) {
+ decoration.setType(material);
+ BlockDamageEvent mine = new BlockDamageEvent(player, decoration, new ItemStack(Material.STICK), false);
+ server.getPluginManager().callEvent(mine);
+ assertFalse(material + " should remain mineable", mine.isCancelled());
+ }
+ for (Material material : List.of(Material.OAK_PLANKS, Material.GLASS, Material.FURNACE,
+ Material.PISTON, Material.CHEST)) {
+ decoration.setType(material);
+ BlockDamageEvent mine = new BlockDamageEvent(player, decoration, new ItemStack(Material.STICK), false);
+ server.getPluginManager().callEvent(mine);
+ assertTrue(material + " is construction fill and stays protected", mine.isCancelled());
+ }
+ assertEquals(100, find.getConservation());
+ assertFalse(site.stratumAt(40).isDisturbed());
+ verify(sites, never()).save(any());
+ }
+
+ @Test
+ public void snowVegetationAndFluidsExposeFindDustButSolidCoverSealsIt() {
+ find.getCells().clear();
+ find.getCells().add(new BlockCell(8, 40, 8));
+ for (BlockFace face : PrismFill.FACES) block.getRelative(face).setType(Material.STONE);
+ world.loadChunk(0, 0);
+ when(sites.all()).thenReturn(List.of(site));
+ PickSettings defaults = PickSettings.defaults();
+ PickSettings dustSettings = new PickSettings(true, 8, false, true, 1, 1,
+ defaults.conservation(), defaults.limits(), false, defaults.cues(), defaults.profiles());
+ FindDustService dust = new FindDustService(MockBukkit.createMockPlugin(), sites, dustSettings);
+ try {
+ dust.start();
+ for (Material cover : List.of(Material.SHORT_GRASS, Material.SNOW, Material.POWDER_SNOW,
+ Material.WATER, Material.LAVA, Material.BUBBLE_COLUMN)) {
+ block.getRelative(BlockFace.UP).setType(cover);
+ world.clearSpawnedParticles();
+ server.getScheduler().performTicks(1);
+ assertEquals(cover + " leaves an exposed face", FindState.DISCOVERED, find.getState());
+ assertEquals(2, world.getSpawnedParticles().size());
+ assertEquals(Material.STONE, block.getType());
+ assertEquals(cover, block.getRelative(BlockFace.UP).getType());
+ }
+ block.getRelative(BlockFace.UP).setType(Material.STONE);
+ world.clearSpawnedParticles();
+ server.getScheduler().performTicks(1);
+ assertEquals(FindState.HIDDEN, find.getState());
+ assertTrue(world.getSpawnedParticles().isEmpty());
+ } finally {
+ dust.stop();
+ }
+ }
+
+ @Test
+ public void lockWarningRepeatsOnlyAfterTheCooldown() {
+ java.util.concurrent.atomic.AtomicLong now = new java.util.concurrent.atomic.AtomicLong(1_000_000); listener.clock = now::get;
+ server.getPluginManager().callEvent(new BlockBreakEvent(block, player));
+ now.addAndGet(2999); server.getPluginManager().callEvent(new BlockBreakEvent(block, player));
+ verify(player, times(1)).sendMessage("Use an excavation tool.");
+ now.addAndGet(1);
+ BlockBreakEvent later = new BlockBreakEvent(block, player);
+ server.getPluginManager().callEvent(later);
+ assertTrue(later.isCancelled());
+ verify(player, times(2)).sendMessage("Use an excavation tool.");
+ assertEquals(Material.STONE, block.getType());
+ }
+
+ private BlockExplodeEvent blockExplosion(List cells) {
+ Block source = world.getBlockAt(6, 40, 8);
+ source.setType(Material.RESPAWN_ANCHOR);
+ var beforeExplosion = source.getState();
+ source.setType(Material.AIR);
+ return new BlockExplodeEvent(source, beforeExplosion, cells, 1f, ExplosionResult.DESTROY);
+ }
+
+ private EntityExplodeEvent entityExplosion(Entity source, List cells) {
+ return new EntityExplodeEvent(source, block.getLocation(), cells, 1f, ExplosionResult.DESTROY);
+ }
+
+ private Block piston(int x, int y, int z) {
+ Block result = world.getBlockAt(x, y, z);
+ result.setType(Material.STICKY_PISTON);
+ return result;
+ }
+
+ private Block stone(int x, int y, int z) {
+ Block result = world.getBlockAt(x, y, z);
+ result.setType(Material.STONE);
+ return result;
+ }
+
+ private static class SnapshotWorld extends WorldMock {
+ private final Map blocks = new HashMap<>();
+ @Override public BlockMock getBlockAt(int x, int y, int z) {
+ return blocks.computeIfAbsent(new BlockCell(x, y, z), key -> new BlockMock(new Location(this, x, y, z)) {
+ @Override public Location getLocation() { return super.getLocation().clone(); }
+ });
+ }
+ }
+}
diff --git a/src/test/java/net/tfminecraft/archaeo/excavation/PrismOutlineServiceTest.java b/src/test/java/net/tfminecraft/archaeo/excavation/PrismOutlineServiceTest.java
new file mode 100644
index 0000000..abddb95
--- /dev/null
+++ b/src/test/java/net/tfminecraft/archaeo/excavation/PrismOutlineServiceTest.java
@@ -0,0 +1,266 @@
+package net.tfminecraft.archaeo.excavation;
+
+import net.tfminecraft.archaeo.config.CatalogRegistry;
+import net.tfminecraft.archaeo.config.LimitsSettings;
+import net.tfminecraft.archaeo.config.PickSettings;
+import net.tfminecraft.archaeo.model.Site;
+import net.tfminecraft.archaeo.model.StratumBand;
+import org.bukkit.Color;
+import org.bukkit.Location;
+import org.bukkit.Material;
+import org.bukkit.entity.BlockDisplay;
+import org.bukkit.entity.Display;
+import org.bukkit.entity.Entity;
+import org.bukkit.plugin.java.JavaPlugin;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.mockbukkit.mockbukkit.MockBukkit;
+import org.mockbukkit.mockbukkit.ServerMock;
+import org.mockbukkit.mockbukkit.entity.PlayerMock;
+import org.mockbukkit.mockbukkit.entity.BlockDisplayMock;
+import org.mockbukkit.mockbukkit.world.WorldMock;
+import org.joml.Vector3f;
+
+import java.util.List;
+import java.util.UUID;
+import java.util.function.Consumer;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import static org.junit.Assert.*;
+import static org.mockito.Mockito.*;
+
+public class PrismOutlineServiceTest {
+ private ServerMock server;
+ private WorldMock world;
+ private JavaPlugin plugin;
+ private CatalogRegistry catalogs;
+ private PrismOutlineService service;
+ private PlayerMock viewer;
+ private PlayerMock other;
+ private Site site;
+
+ @Before
+ public void setup() {
+ server = MockBukkit.mock();
+ world = new DisplayWorld(server);
+ world.setName("outline");
+ server.addWorld(world);
+ plugin = MockBukkit.createMockPlugin();
+ viewer = spy(new PlayerMock(server, "Viewer"));
+ other = spy(new PlayerMock(server, "Other"));
+ server.addPlayer(viewer);
+ server.addPlayer(other);
+ viewer.teleport(new Location(world, 0, 30, 0));
+ other.teleport(new Location(world, 0, 30, 0));
+ catalogs = mock(CatalogRegistry.class);
+ configure(new LimitsSettings(1, 0.08, 128));
+ service = new PrismOutlineService(plugin, catalogs);
+ site = new Site();
+ site.setWorldName(world.getName());
+ site.setChunkX(-2);
+ site.setChunkZ(3);
+ band("deep", 10, 13, true);
+ band("absent", 100, 120, false);
+ band("shallow", 14, 20, true);
+ }
+
+ @After
+ public void teardown() {
+ if (service != null) service.stop();
+ MockBukkit.unmock();
+ }
+
+ @Test
+ public void outlinesChunkEdgesAndSortedPresentStratumSeamsForOneViewer() {
+ service.show(viewer, site);
+ List bars = bars();
+ assertEquals(16, bars.size());
+ int xEdges = 0, yEdges = 0, zEdges = 0, seams = 0;
+ Set ringHeights = new java.util.HashSet<>();
+ for (BlockDisplay display : bars) {
+ Location at = display.getLocation();
+ assertTrue(at.getX() == -32 || at.getX() == -16);
+ assertTrue(at.getZ() == 48 || at.getZ() == 64);
+ Vector3f scale = display.getTransformation().getScale();
+ Vector3f translation = display.getTransformation().getTranslation();
+ if (scale.x > 1) {
+ xEdges++;
+ assertEquals(16, scale.x, 0.00001);
+ assertEquals(0.08, scale.y, 0.00001);
+ assertEquals(0.08, scale.z, 0.00001);
+ assertEquals(0, translation.x, 0);
+ assertEquals(-0.04, translation.y, 0.00001);
+ ringHeights.add(at.getY());
+ } else if (scale.y > 1) {
+ yEdges++;
+ assertEquals(11, scale.y, 0.00001);
+ assertEquals(10, at.getY(), 0);
+ assertEquals(0.08, scale.x, 0.00001);
+ assertEquals(0.08, scale.z, 0.00001);
+ assertEquals(13, display.getDisplayHeight(), 0);
+ } else {
+ zEdges++;
+ assertEquals(16, scale.z, 0.00001);
+ assertEquals(0.08, scale.x, 0.00001);
+ assertEquals(0.08, scale.y, 0.00001);
+ ringHeights.add(at.getY());
+ }
+ boolean seam = display.getBlock().getMaterial() == Material.LIGHT_BLUE_CONCRETE;
+ if (seam) {
+ seams++;
+ assertEquals(14, at.getY(), 0);
+ assertEquals(Color.fromRGB(170, 215, 255), display.getGlowColorOverride());
+ } else {
+ assertEquals(Material.ORANGE_CONCRETE, display.getBlock().getMaterial());
+ assertEquals(Color.fromRGB(255, 205, 120), display.getGlowColorOverride());
+ }
+ assertFalse(display.isVisibleByDefault());
+ assertFalse(display.isPersistent());
+ assertTrue(display.isGlowing());
+ assertEquals(Display.Billboard.FIXED, display.getBillboard());
+ assertEquals(15, display.getBrightness().getBlockLight());
+ assertEquals(15, display.getBrightness().getSkyLight());
+ assertEquals(2, display.getViewRange(), 0);
+ verify(viewer).showEntity(plugin, display);
+ verify(other, never()).showEntity(plugin, display);
+ }
+ assertEquals(6, xEdges);
+ assertEquals(4, yEdges);
+ assertEquals(6, zEdges);
+ assertEquals(4, seams);
+ assertEquals(Set.of(21.0, 14.0, 10.0), ringHeights);
+ }
+
+ @Test
+ public void staticBarsRemainUntilDurationExpiresThenAllAreRemoved() {
+ service.show(viewer, site);
+ List created = bars();
+ ticks(19);
+ assertEquals(created.stream().map(Entity::getUniqueId).collect(Collectors.toSet()),
+ bars().stream().map(Entity::getUniqueId).collect(Collectors.toSet()));
+ for (BlockDisplay bar : created) assertFalse(bar.isDead());
+ ticks(1);
+ for (BlockDisplay bar : created) assertTrue(bar.isDead());
+ assertTrue(bars().isEmpty());
+ }
+
+ @Test
+ public void replacingOutlineGivesNewBarsTheirFullDuration() {
+ service.show(viewer, site);
+ List old = bars();
+ ticks(10);
+ service.show(viewer, site);
+ List replacement = bars();
+ assertEquals(16, replacement.size());
+ for (BlockDisplay bar : old) assertTrue(bar.isDead());
+ ticks(10);
+ for (BlockDisplay bar : replacement) assertFalse("Old expiry must not remove replacement", bar.isDead());
+ ticks(9);
+ assertEquals(16, bars().size());
+ ticks(1);
+ for (BlockDisplay bar : replacement) assertTrue(bar.isDead());
+ }
+
+ @Test
+ public void viewerCancellationAndShutdownKeepOtherViewersIndependent() {
+ service.show(viewer, site);
+ List first = bars();
+ service.show(other, site);
+ List second = bars().stream().filter(bar -> !first.contains(bar)).toList();
+ assertEquals(16, second.size());
+ service.cancel(viewer);
+ for (BlockDisplay bar : first) assertTrue(bar.isDead());
+ for (BlockDisplay bar : second) assertFalse(bar.isDead());
+ service.stop();
+ for (BlockDisplay bar : second) assertTrue(bar.isDead());
+ ticks(25);
+ assertTrue(bars().isEmpty());
+ }
+
+ @Test
+ public void invalidShowRequestsPreserveExistingOutlineAndExplainWhy() {
+ service.show(viewer, site);
+ List current = bars();
+ site.setWorldName("other-world");
+ service.show(viewer, site);
+ verify(viewer).sendMessage("That excavation is in another world.");
+ site.setWorldName(world.getName());
+ site.getStrata().clear();
+ service.show(viewer, site);
+ verify(viewer).sendMessage("This site has no excavated strata to outline.");
+ assertEquals(current.size(), bars().size());
+ for (BlockDisplay bar : current) assertFalse(bar.isDead());
+ }
+
+ @Test
+ public void liveSettingsControlNextOutlineThicknessRangeAndDuration() {
+ configure(new LimitsSettings(2, 0.2, 64));
+ service.show(viewer, site);
+ for (BlockDisplay bar : bars()) {
+ Vector3f scale = bar.getTransformation().getScale();
+ assertEquals(0.2, Math.min(scale.x, Math.min(scale.y, scale.z)), 0.00001);
+ assertEquals(1, bar.getViewRange(), 0);
+ }
+ ticks(39);
+ assertEquals(16, bars().size());
+ ticks(1);
+ assertTrue(bars().isEmpty());
+ }
+
+ @Test
+ public void invertedStratumFromAMalformedDossierDrawsItsRingsWithoutUprights() {
+ site.getStrata().clear();
+ band("inverted", 50, 40, true);
+ service.show(viewer, site);
+ List bars = bars();
+ assertEquals(8, bars.size());
+ for (BlockDisplay bar : bars) {
+ assertTrue("No bar runs upwards", bar.getTransformation().getScale().y < 1);
+ }
+ assertEquals(Set.of(41.0, 50.0), bars.stream().map(bar -> bar.getLocation().getY()).collect(Collectors.toSet()));
+ verify(viewer).sendMessage("Excavation limits shown for 1 seconds.");
+ }
+
+ private void configure(LimitsSettings limits) {
+ PickSettings defaults = PickSettings.defaults();
+ when(catalogs.pick()).thenReturn(new PickSettings(true, 8, true, true, 6, 2,
+ defaults.conservation(), limits, true, defaults.cues(), defaults.profiles()));
+ }
+
+ private void band(String id, int min, int max, boolean present) {
+ StratumBand band = new StratumBand();
+ band.setId(id);
+ band.setMinY(min);
+ band.setMaxY(max);
+ band.setPresent(present);
+ site.getStrata().put(id, band);
+ }
+
+ private List bars() { return world.getEntitiesByClass(BlockDisplay.class).stream().toList(); }
+ private void ticks(int count) { server.getScheduler().performTicks(count); }
+ /** MockBukkit has display geometry but does not yet store default visibility or billboard mode. */
+ private static class DisplayWorld extends WorldMock {
+ private final ServerMock server;
+ DisplayWorld(ServerMock server) { this.server = server; }
+ @Override
+ public T spawn(Location location, Class type, Consumer super T> configure) {
+ if (type != BlockDisplay.class) return super.spawn(location, type, configure);
+ BlockDisplayMock display = new BlockDisplayMock(server, UUID.randomUUID()) {
+ private boolean visible = true;
+ private Display.Billboard billboard = Display.Billboard.FIXED;
+ @Override public void setVisibleByDefault(boolean value) { visible = value; }
+ @Override public boolean isVisibleByDefault() { return visible; }
+ @Override public void setBillboard(Display.Billboard value) { billboard = value; }
+ @Override public Display.Billboard getBillboard() { return billboard; }
+ };
+ display.setLocation(location);
+ T entity = type.cast(display);
+ configure.accept(entity);
+ server.registerEntity(display);
+ return entity;
+ }
+ }
+
+}
diff --git a/src/test/java/net/tfminecraft/archaeo/excavation/PrismWoundTest.java b/src/test/java/net/tfminecraft/archaeo/excavation/PrismWoundTest.java
new file mode 100644
index 0000000..a82b3b7
--- /dev/null
+++ b/src/test/java/net/tfminecraft/archaeo/excavation/PrismWoundTest.java
@@ -0,0 +1,196 @@
+package net.tfminecraft.archaeo.excavation;
+
+import net.tfminecraft.archaeo.model.BlockCell;
+import net.tfminecraft.archaeo.model.BuriedFind;
+import net.tfminecraft.archaeo.model.FindState;
+import net.tfminecraft.archaeo.model.Site;
+import net.tfminecraft.archaeo.model.StratumBand;
+import org.bukkit.Material;
+import org.bukkit.World;
+import org.bukkit.block.Block;
+import org.junit.Test;
+import org.junit.Before;
+import org.junit.After;
+import org.mockbukkit.mockbukkit.MockBukkit;
+
+import java.util.List;
+import java.util.UUID;
+
+import static org.junit.Assert.*;
+import static org.mockito.Mockito.*;
+
+public class PrismWoundTest {
+ @Before
+ public void startServer() {
+ MockBukkit.mock();
+ }
+
+ @After
+ public void stopServer() {
+ MockBukkit.unmock();
+ }
+
+ @Test
+ public void claimCountsPiecesAndLossesAndDoesNotChargeMissingCellsTwice() {
+ Site site = site();
+ BuriedFind partial = find(site, 1, 2, 3, 4);
+ BuriedFind lost = find(site, 5);
+ BuriedFind intact = find(site, 6);
+ World world = mock(World.class);
+ block(world, 1, Material.AIR, false);
+ block(world, 2, Material.WATER, true);
+ block(world, 3, Material.TORCH, false);
+ block(world, 4, Material.STONE, false);
+ block(world, 5, Material.CAVE_AIR, false);
+ block(world, 6, Material.STONE, false);
+ PrismWound.Prior first = PrismWound.markMissingTerrain(world, site);
+ assertEquals(2, first.disturbed());
+ assertEquals(1, first.lost());
+ assertEquals(List.of(cell(1), cell(2), cell(3)), List.copyOf(partial.getPriorCells()));
+ assertEquals(25, partial.getConservation());
+ assertFalse(partial.isFieldDamaged());
+ assertEquals(FindState.LOST, lost.getState());
+ assertEquals(100, intact.getConservation());
+ assertEquals(new PrismWound.Prior(0, 0), PrismWound.markMissingTerrain(world, site));
+ }
+
+ @Test
+ public void emptyClaimHasNoDisturbanceOrWorldReads() {
+ World world = mock(World.class);
+ assertEquals(new PrismWound.Prior(0, 0), PrismWound.markMissingTerrain(world, site()));
+ verifyNoInteractions(world);
+ }
+
+ @Test
+ public void missingTerrainQueryScansPastIntactCellsWithoutChangingFinds() {
+ Site site = site();
+ BuriedFind find = find(site, 1, 2);
+ World world = mock(World.class);
+ block(world, 1, Material.STONE, false);
+ Block second = block(world, 2, Material.STONE, false);
+ assertFalse(PrismWound.hasMissingFindTerrain(world, site));
+ for (Material missing : List.of(Material.AIR, Material.WATER, Material.TORCH)) {
+ when(second.getType()).thenReturn(missing);
+ when(second.isLiquid()).thenReturn(missing == Material.WATER);
+ assertTrue(missing.toString(), PrismWound.hasMissingFindTerrain(world, site));
+ }
+ assertEquals(100, find.getConservation());
+ assertTrue(find.getPriorCells().isEmpty());
+ assertEquals(FindState.HIDDEN, find.getState());
+ }
+
+ @Test
+ public void missingTerrainQueryStopsAtFirstMissingCell() {
+ Site site = site();
+ find(site, 1, 2);
+ World world = mock(World.class);
+ block(world, 1, Material.AIR, false);
+ assertTrue(PrismWound.hasMissingFindTerrain(world, site));
+ verify(world, never()).getBlockAt(2, 10, 1);
+ }
+
+ @Test
+ public void removalOutsideChunkOrStratumDoesNotTouchDossier() {
+ Site site = site();
+ BuriedFind find = find(site, 1, 2);
+ for (int[] pos : new int[][] {{-1, 10, 1}, {16, 10, 1}, {1, 10, -1}, {1, 10, 16}, {1, 9, 1}, {1, 13, 1}}) {
+ assertEquals(PrismWound.Removal.none(), PrismWound.onCellRemoved(site, pos[0], pos[1], pos[2]));
+ }
+ assertFalse(site.stratumAt(10).isDisturbed());
+ assertEquals(100, find.getConservation());
+ }
+
+ @Test
+ public void emptyPrismCellDisturbsLayerOnlyOnceIncludingInclusiveEdges() {
+ Site site = site();
+ PrismWound.Removal first = PrismWound.onCellRemoved(site, 0, 10, 0);
+ assertTrue(first.dossierChanged());
+ assertFalse(first.findSmashed());
+ assertTrue(site.stratumAt(10).isDisturbed());
+ assertEquals(PrismWound.Removal.none(), PrismWound.onCellRemoved(site, 15, 12, 15));
+ Site upper = site();
+ assertEquals(new PrismWound.Removal(true, false), PrismWound.onCellRemoved(upper, 15, 12, 15));
+ }
+
+ @Test
+ public void removalGrazesFindEvenWhenLayerWasAlreadyDisturbed() {
+ Site site = site();
+ BuriedFind find = find(site, 1, 2, 3, 4);
+ assertEquals(new PrismWound.Removal(true, true), PrismWound.onCellRemoved(site, 1, 10, 1));
+ assertEquals(75, find.getConservation());
+ assertEquals(new PrismWound.Removal(true, true), PrismWound.onCellRemoved(site, 2, 10, 1));
+ assertEquals(50, find.getConservation());
+ assertEquals(PrismWound.Removal.none(), PrismWound.onCellRemoved(site, 2, 10, 1));
+ assertEquals(List.of(cell(1), cell(2)), List.copyOf(find.getGrazedCells()));
+ }
+
+ @Test
+ public void absentStratumDoesNotAcceptRemoval() {
+ Site site = site();
+ site.getStrata().get("I").setPresent(false);
+ assertEquals(PrismWound.Removal.none(), PrismWound.onCellRemoved(site, 1, 10, 1));
+ assertFalse(site.getStrata().get("I").isDisturbed());
+ }
+
+ @Test
+ public void aimedAndFallingHitsApplyDifferentDamageAndAreIdempotent() {
+ Site directSite = site();
+ BuriedFind direct = find(directSite, 1, 2, 3, 4);
+ direct.setBuriedConservation(90);
+ assertTrue(PrismWound.smashFindAt(directSite, 1, 10, 1, true));
+ assertEquals(40, direct.getConservation());
+ assertEquals(List.of(cell(1)), List.copyOf(direct.getDirectHitCells()));
+ assertFalse(PrismWound.smashFindAt(directSite, 1, 10, 1, true));
+ Site fallingSite = site();
+ BuriedFind falling = find(fallingSite, 1, 2, 3, 4);
+ falling.setBuriedConservation(90);
+ assertTrue(PrismWound.smashFindAt(fallingSite, 1, 10, 1));
+ assertEquals(65, falling.getConservation());
+ assertFalse(PrismWound.smashFindAt(fallingSite, 1, 10, 1));
+ }
+
+ @Test
+ public void hitsIgnoreEmptyRecoveredAndLostCells() {
+ Site site = site();
+ assertFalse(PrismWound.smashFindAt(site, 1, 10, 1, true));
+ BuriedFind find = find(site, 1, 2);
+ for (FindState terminal : List.of(FindState.RECOVERED, FindState.LOST)) {
+ find.setState(terminal);
+ assertFalse(PrismWound.smashFindAt(site, 1, 10, 1, true));
+ assertFalse(PrismWound.smashFindAt(site, 1, 10, 1, false));
+ }
+ assertTrue(find.getGrazedCells().isEmpty());
+ assertTrue(find.getDirectHitCells().isEmpty());
+ }
+
+ private static Site site() {
+ Site site = new Site();
+ StratumBand band = new StratumBand();
+ band.setId("I");
+ band.setPresent(true);
+ band.setMinY(10);
+ band.setMaxY(12);
+ site.getStrata().put("I", band);
+ return site;
+ }
+
+ private static BuriedFind find(Site site, int... xs) {
+ BuriedFind find = new BuriedFind();
+ find.setId(UUID.randomUUID());
+ for (int x : xs) find.getCells().add(cell(x));
+ site.getFinds().add(find);
+ return find;
+ }
+
+ private static BlockCell cell(int x) {
+ return new BlockCell(x, 10, 1);
+ }
+
+ private static Block block(World world, int x, Material type, boolean liquid) {
+ Block block = mock(Block.class);
+ when(block.getType()).thenReturn(type);
+ when(block.isLiquid()).thenReturn(liquid);
+ when(world.getBlockAt(x, 10, 1)).thenReturn(block);
+ return block;
+ }
+}
diff --git a/src/test/java/net/tfminecraft/archaeo/excavation/RecoverListenerTest.java b/src/test/java/net/tfminecraft/archaeo/excavation/RecoverListenerTest.java
new file mode 100644
index 0000000..40e1c80
--- /dev/null
+++ b/src/test/java/net/tfminecraft/archaeo/excavation/RecoverListenerTest.java
@@ -0,0 +1,125 @@
+package net.tfminecraft.archaeo.excavation;
+
+import net.tfminecraft.archaeo.item.BrushItem;
+import org.bukkit.Material;
+import org.bukkit.block.Block;
+import org.bukkit.block.BlockFace;
+import org.bukkit.entity.Item;
+import org.bukkit.entity.Player;
+import org.bukkit.event.block.Action;
+import org.bukkit.event.player.*;
+import org.bukkit.inventory.EquipmentSlot;
+import org.bukkit.inventory.ItemStack;
+import org.bukkit.inventory.PlayerInventory;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.mockbukkit.mockbukkit.MockBukkit;
+import org.mockbukkit.mockbukkit.ServerMock;
+
+import static org.junit.Assert.*;
+import static org.mockito.Mockito.*;
+
+public class RecoverListenerTest {
+ private ServerMock server;
+ private RecoverService recover;
+ private RecoverListener listener;
+ private Player player;
+ private PlayerInventory inventory;
+ private Block block;
+ private ItemStack brushStack;
+
+ @Before
+ public void setup() {
+ server = MockBukkit.mock();
+ recover = mock(RecoverService.class);
+ BrushItem brush = mock(BrushItem.class);
+ listener = new RecoverListener(brush, recover);
+ player = mock(Player.class);
+ inventory = mock(PlayerInventory.class);
+ when(player.getInventory()).thenReturn(inventory);
+ block = server.addSimpleWorld("listener").getBlockAt(8, 40, 8);
+ brushStack = new ItemStack(Material.BRUSH);
+ when(brush.isBrush(any())).thenAnswer(invocation -> {
+ ItemStack stack = invocation.getArgument(0);
+ return stack != null && stack.getType() == Material.BRUSH;
+ });
+ server.getPluginManager().registerEvents(listener, MockBukkit.createMockPlugin());
+ }
+
+ @After
+ public void teardown() {
+ MockBukkit.unmock();
+ }
+
+ @Test
+ public void mainHandBrushClickBeginsRecoveryWithoutCancellingVanillaUse() {
+ PlayerInteractEvent event = click(Action.RIGHT_CLICK_BLOCK, EquipmentSlot.HAND, brushStack, block);
+ server.getPluginManager().callEvent(event);
+ verify(recover).begin(player, block);
+ assertFalse(event.isCancelled());
+ }
+
+ @Test
+ public void alreadyCancelledInteractionStillStartsRecoveryWithoutChangingCancellation() {
+ PlayerInteractEvent event = click(Action.RIGHT_CLICK_BLOCK, EquipmentSlot.HAND, brushStack, block);
+ event.setCancelled(true);
+ server.getPluginManager().callEvent(event);
+ verify(recover).begin(player, block);
+ assertTrue(event.isCancelled());
+ }
+
+ @Test
+ public void otherActionsOffhandAndNonBrushesDoNotStartRecovery() {
+ listener.onInteract(click(Action.LEFT_CLICK_BLOCK, EquipmentSlot.HAND, brushStack, block));
+ listener.onInteract(click(Action.RIGHT_CLICK_AIR, EquipmentSlot.HAND, brushStack, null));
+ listener.onInteract(click(Action.RIGHT_CLICK_BLOCK, EquipmentSlot.OFF_HAND, brushStack, block));
+ listener.onInteract(click(Action.RIGHT_CLICK_BLOCK, EquipmentSlot.HAND, new ItemStack(Material.STICK), block));
+ listener.onInteract(click(Action.RIGHT_CLICK_BLOCK, EquipmentSlot.HAND, null, block));
+ // Vanilla always sends a block with a block click, but other plugins call this event too and
+ // the Bukkit constructor accepts a null block, so such a click must be ignored without throwing.
+ listener.onInteract(click(Action.RIGHT_CLICK_BLOCK, EquipmentSlot.HAND, brushStack, null));
+ verifyNoInteractions(recover);
+ }
+
+ @Test
+ public void hotbarBrushStartsWatchingAndSwitchingAwayCancels() {
+ when(inventory.getItem(1)).thenReturn(brushStack);
+ server.getPluginManager().callEvent(new PlayerItemHeldEvent(player, 0, 1));
+ verify(recover).watch(player);
+ when(inventory.getItem(2)).thenReturn(new ItemStack(Material.STICK));
+ server.getPluginManager().callEvent(new PlayerItemHeldEvent(player, 1, 2));
+ verify(recover).cancel(player);
+ }
+
+ @Test
+ public void cancelledHotbarChangeDoesNotInterruptTheChannel() {
+ PlayerItemHeldEvent event = new PlayerItemHeldEvent(player, 0, 1);
+ event.setCancelled(true);
+ server.getPluginManager().callEvent(event);
+ verifyNoInteractions(recover);
+ }
+
+ @Test
+ public void swapsDropsAndQuitsCancelExistingChannel() {
+ server.getPluginManager().callEvent(new PlayerSwapHandItemsEvent(player, brushStack, null));
+ server.getPluginManager().callEvent(new PlayerDropItemEvent(player, mock(Item.class)));
+ server.getPluginManager().callEvent(new PlayerQuitEvent(player, "left"));
+ verify(recover, times(3)).cancel(player);
+ }
+
+ @Test
+ public void cancelledSwapsAndDropsPreserveExistingChannel() {
+ PlayerSwapHandItemsEvent swap = new PlayerSwapHandItemsEvent(player, brushStack, null);
+ swap.setCancelled(true);
+ PlayerDropItemEvent drop = new PlayerDropItemEvent(player, mock(Item.class));
+ drop.setCancelled(true);
+ server.getPluginManager().callEvent(swap);
+ server.getPluginManager().callEvent(drop);
+ verifyNoInteractions(recover);
+ }
+
+ private PlayerInteractEvent click(Action action, EquipmentSlot hand, ItemStack item, Block clicked) {
+ return new PlayerInteractEvent(player, action, item, clicked, BlockFace.UP, hand);
+ }
+}
diff --git a/src/test/java/net/tfminecraft/archaeo/excavation/RecoverServiceTest.java b/src/test/java/net/tfminecraft/archaeo/excavation/RecoverServiceTest.java
new file mode 100644
index 0000000..a435784
--- /dev/null
+++ b/src/test/java/net/tfminecraft/archaeo/excavation/RecoverServiceTest.java
@@ -0,0 +1,802 @@
+package net.tfminecraft.archaeo.excavation;
+
+import net.tfminecraft.archaeo.config.*;
+import net.tfminecraft.archaeo.events.FindRecoveredEvent;
+import net.tfminecraft.archaeo.item.BrushItem;
+import net.tfminecraft.archaeo.item.RecoveredFindItem;
+import net.tfminecraft.archaeo.model.*;
+import net.tfminecraft.archaeo.site.SiteRepository;
+import org.bukkit.Material;
+import org.bukkit.Location;
+import org.bukkit.World;
+import org.bukkit.block.Block;
+import org.bukkit.boss.BarColor;
+import org.bukkit.boss.BarFlag;
+import org.bukkit.boss.BarStyle;
+import org.bukkit.boss.BossBar;
+import org.bukkit.entity.Item;
+import org.bukkit.entity.Player;
+import org.bukkit.event.EventHandler;
+import org.bukkit.event.Listener;
+import org.bukkit.inventory.ItemStack;
+import org.bukkit.inventory.PlayerInventory;
+import org.bukkit.inventory.meta.Damageable;
+import org.bukkit.plugin.java.JavaPlugin;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.mockbukkit.mockbukkit.MockBukkit;
+import org.mockbukkit.mockbukkit.ServerMock;
+import org.mockbukkit.mockbukkit.block.BlockMock;
+import org.mockbukkit.mockbukkit.entity.PlayerMock;
+import org.mockbukkit.mockbukkit.world.WorldMock;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.UUID;
+
+import static org.junit.Assert.*;
+import static org.mockito.Mockito.*;
+
+public class RecoverServiceTest {
+ private RecordingServer server;
+ private World world;
+ private SiteRepository sites;
+ private CatalogRegistry catalogs;
+ private RecoveredFindItem recoveredItems;
+ private RecoverService service;
+ private Player player;
+ private UUID playerId;
+ private PlayerInventory inventory;
+ private ItemStack held;
+ private Site site;
+ private BuriedFind find;
+ private Block target;
+ private ArtifactTemplate template;
+ private final List