diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 495c41d..9dfce1b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -51,6 +51,14 @@ jobs: - name: Run unit tests and build run: mvn -B --no-transfer-progress clean verify -DskipTests=false -Dmaven.test.skip=false + - name: Upload coverage reports + if: ${{ !cancelled() && hashFiles('target/site/jacoco/jacoco.xml') != '' }} + uses: actions/upload-artifact@v7 + with: + name: coverage-reports-${{ github.run_id }}-${{ github.run_attempt }} + path: target/site/jacoco/ + if-no-files-found: error + - name: Verify runtime JAR env: ARTIFACT_PATH: ${{ steps.dev.outputs.jar }} diff --git a/.github/workflows/maven-release.yml b/.github/workflows/maven-release.yml index 87d147c..c8c049f 100644 --- a/.github/workflows/maven-release.yml +++ b/.github/workflows/maven-release.yml @@ -73,6 +73,14 @@ jobs: echo "jar=target/$final_name.jar" >> "$GITHUB_OUTPUT" mvn -B --no-transfer-progress clean verify -DskipTests=false -Dmaven.test.skip=false + - name: Upload coverage reports + if: ${{ !cancelled() && hashFiles('target/site/jacoco/jacoco.xml') != '' }} + uses: actions/upload-artifact@v7 + with: + name: coverage-reports-${{ github.run_id }}-${{ github.run_attempt }} + path: target/site/jacoco/ + if-no-files-found: error + - name: Stage only the release JAR env: ARTIFACT_PATH: ${{ steps.maven.outputs.jar }} diff --git a/README.md b/README.md index 42a29ae..5a4336d 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,21 @@ Magic keeps resonance profiles tied to roleplay characters through RPCharacters. Technical documentation is maintained in [TF-Minecraft/Docs](https://github.com/TF-Minecraft/Docs). +## Tests and coverage + +Use Java 21 and the pinned dependencies installed by `.github/scripts/prepare-release.sh`, then run: + +```sh +mvn -B --no-transfer-progress clean verify +``` + +JUnit exercises domain calculations, configuration loading, item persistence, artifact generation, +character sessions, and plugin lifecycle through MockBukkit and isolated external integration mocks. +JaCoCo measures every production class with no coverage exclusions. Maven `verify` requires +100% line, branch, and instruction coverage. The HTML report is +`target/site/jacoco/index.html`; XML and CSV reports are alongside it. CI uploads coverage reports +for builds and releases. Test dependencies are not bundled into the plugin JAR. + ## License Copyright (c) 2026 TF-Minecraft contributors. diff --git a/pom.xml b/pom.xml index 7624584..031acca 100644 --- a/pom.xml +++ b/pom.xml @@ -18,6 +18,40 @@ + + com.googlecode.json-simple + json-simple + 1.1.1 + test + + junitjunit + + + + org.slf4j + slf4j-simple + 2.0.17 + test + + + org.junit.jupiter + junit-jupiter + 5.10.2 + test + + + org.mockito + mockito-core + 5.20.0 + test + + + org.mockbukkit.mockbukkit + mockbukkit-v1.21 + 4.95.0 + test + + com.google.code.gson gson @@ -129,6 +163,49 @@ + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.4 + + + org.slf4j.simple.SimpleServiceProvider + + + + + org.jacoco + jacoco-maven-plugin + 0.8.15 + + + prepare-agent + + + coverage-report + verify + report + + + coverage-check + verify + check + + + + BUNDLE + + LINECOVEREDRATIO1.0 + BRANCHCOVEREDRATIO1.0 + INSTRUCTIONCOVEREDRATIO1.0 + + + + + + + + org.apache.maven.plugins maven-compiler-plugin diff --git a/src/main/java/net/tfminecraft/magic/Magic.java b/src/main/java/net/tfminecraft/magic/Magic.java index 1ee9d0a..6ade686 100644 --- a/src/main/java/net/tfminecraft/magic/Magic.java +++ b/src/main/java/net/tfminecraft/magic/Magic.java @@ -320,14 +320,25 @@ private void copyResourceIfMissing(String relativePath) { return; } target.getParentFile().mkdirs(); - try (InputStream in = getResource(relativePath)) { - if (in == null) { - getLogger().warning("Missing bundled resource: " + relativePath); - return; - } - Files.copy(in, target.toPath()); + InputStream resource = getResource(relativePath); + if (resource == null) { + getLogger().warning("Missing bundled resource: " + relativePath); + return; + } + try (InputStream in = resource) { + copyDefault(in, target); } catch (IOException ex) { getLogger().severe("Failed to copy default resource " + relativePath + ": " + ex.getMessage()); } } + + private static void copyDefault(InputStream in, File target) throws IOException { + var staged = Files.createTempFile(target.getParentFile().toPath(), ".magic-default-", ".tmp"); + try { + Files.copy(in, staged, java.nio.file.StandardCopyOption.REPLACE_EXISTING); + Files.move(staged, target.toPath()); + } finally { + Files.deleteIfExists(staged); + } + } } diff --git a/src/main/java/net/tfminecraft/magic/Messages.java b/src/main/java/net/tfminecraft/magic/Messages.java index 1898d91..af7653f 100644 --- a/src/main/java/net/tfminecraft/magic/Messages.java +++ b/src/main/java/net/tfminecraft/magic/Messages.java @@ -39,13 +39,16 @@ public static void loadFromResources() { } private static FileConfiguration readBundled() { - try (InputStream in = Magic.plugin.getResource("messages.yml")) { - if (in == null) { + try { + InputStream resource = Magic.plugin.getResource("messages.yml"); + if (resource == null) { return null; } - FileConfiguration loaded = new YamlConfiguration(); - loaded.loadFromString(new String(in.readAllBytes())); - return loaded; + try (InputStream in = resource) { + FileConfiguration loaded = new YamlConfiguration(); + loaded.loadFromString(new String(in.readAllBytes())); + return loaded; + } } catch (Exception ex) { Magic.plugin.getLogger().warning("[Magic] Failed to load bundled messages.yml: " + ex.getMessage()); return null; @@ -68,13 +71,13 @@ public static String get(String path, String... keyValues) { public static String getRaw(String path) { if (config != null && config.contains(path)) { String live = config.getString(path); - if (live != null && !live.isBlank()) { + if (!live.isBlank()) { return live; } } if (bundled != null && bundled.contains(path)) { String fallback = bundled.getString(path); - if (fallback != null && !fallback.isBlank()) { + if (!fallback.isBlank()) { return fallback; } } diff --git a/src/main/java/net/tfminecraft/magic/artifact/Artifact.java b/src/main/java/net/tfminecraft/magic/artifact/Artifact.java index 7dca4d6..d6fdd88 100644 --- a/src/main/java/net/tfminecraft/magic/artifact/Artifact.java +++ b/src/main/java/net/tfminecraft/magic/artifact/Artifact.java @@ -62,13 +62,7 @@ public static Artifact fromItem(ItemStack stack) { } private static String readPrimary(ItemStack stack) { - if (stack == null || !stack.hasItemMeta()) { - return ""; - } ItemMeta meta = stack.getItemMeta(); - if (meta == null) { - return ""; - } String stored = meta.getPersistentDataContainer().get( ArtifactKeys.artifactPrimary(), PersistentDataType.STRING); return stored == null || stored.isBlank() ? "" : stored.trim().toLowerCase(Locale.ROOT); @@ -116,7 +110,7 @@ public Set getCappedElementIds() { @Override public String primaryElementId() { - return primary != null && !primary.isBlank() ? primary : aura.highestCapElementId(); + return !primary.isBlank() ? primary : aura.highestCapElementId(); } @Override diff --git a/src/main/java/net/tfminecraft/magic/artifact/ArtifactAuraCaps.java b/src/main/java/net/tfminecraft/magic/artifact/ArtifactAuraCaps.java index 53d2675..a1681db 100644 --- a/src/main/java/net/tfminecraft/magic/artifact/ArtifactAuraCaps.java +++ b/src/main/java/net/tfminecraft/magic/artifact/ArtifactAuraCaps.java @@ -45,9 +45,6 @@ public static double forItem(ItemStack stack) { return clamp(Cache.artifactAuraCap); } ItemMeta meta = stack.getItemMeta(); - if (meta == null) { - return clamp(Cache.artifactAuraCap); - } String rarityId = meta.getPersistentDataContainer().get( ArtifactKeys.artifactRarity(), PersistentDataType.STRING); String primaryId = meta.getPersistentDataContainer().get( diff --git a/src/main/java/net/tfminecraft/magic/artifact/ArtifactCareStore.java b/src/main/java/net/tfminecraft/magic/artifact/ArtifactCareStore.java index a9be4e8..3637924 100644 --- a/src/main/java/net/tfminecraft/magic/artifact/ArtifactCareStore.java +++ b/src/main/java/net/tfminecraft/magic/artifact/ArtifactCareStore.java @@ -44,7 +44,7 @@ public static void pruneUsers(ItemStack stack, long nowMs) { Map users = parseUsers(meta.getPersistentDataContainer().get( ArtifactKeys.careUsers(), PersistentDataType.STRING)); int before = users.size(); - users.entrySet().removeIf(entry -> entry.getValue() == null || entry.getValue() <= nowMs); + users.entrySet().removeIf(entry -> entry.getValue() <= nowMs); if (users.size() == before) { return; } @@ -62,7 +62,7 @@ public static double usableFill(double fill, double cap, double muffle) { public static int activeUserCount(ItemStack stack, long nowMs) { Map users = readUsers(stack); - users.entrySet().removeIf(entry -> entry.getValue() == null || entry.getValue() <= nowMs); + users.entrySet().removeIf(entry -> entry.getValue() <= nowMs); return users.size(); } @@ -80,9 +80,6 @@ public static boolean apply(ItemStack stack, boolean housed, long nowMs, Persist return false; } ItemMeta meta = metaOf(stack); - if (meta == null) { - return false; - } PersistentDataContainer root = meta.getPersistentDataContainer(); long last = readLong(root, ArtifactKeys.careLastTick()); double elapsedSec = 0.0; @@ -128,7 +125,7 @@ public static void stampUser(ItemStack stack, String characterId, long nowMs) { String id = characterId.trim(); Map users = parseUsers(meta.getPersistentDataContainer().get( ArtifactKeys.careUsers(), PersistentDataType.STRING)); - users.entrySet().removeIf(entry -> entry.getValue() == null || entry.getValue() <= nowMs); + users.entrySet().removeIf(entry -> entry.getValue() <= nowMs); users.put(id, nowMs + ArtifactCareCache.usersTtlMs()); writeUsers(meta, users); stack.setItemMeta(meta); @@ -182,7 +179,7 @@ private static Map parseUsers(String raw) { return users; } for (String part : raw.split(";")) { - if (part == null || part.isBlank()) { + if (part.isBlank()) { continue; } int colon = part.lastIndexOf(':'); @@ -203,24 +200,17 @@ private static Map parseUsers(String raw) { } private static void writeUsers(ItemMeta meta, Map users) { - if (users == null || users.isEmpty()) { + if (users.isEmpty()) { meta.getPersistentDataContainer().remove(ArtifactKeys.careUsers()); return; } StringBuilder out = new StringBuilder(); for (Map.Entry entry : users.entrySet()) { - if (entry.getKey() == null || entry.getKey().isBlank() || entry.getValue() == null) { - continue; - } if (out.length() > 0) { out.append(';'); } out.append(entry.getKey()).append(':').append(entry.getValue()); } - if (out.length() == 0) { - meta.getPersistentDataContainer().remove(ArtifactKeys.careUsers()); - return; - } meta.getPersistentDataContainer().set( ArtifactKeys.careUsers(), PersistentDataType.STRING, out.toString()); } diff --git a/src/main/java/net/tfminecraft/magic/artifact/ArtifactIds.java b/src/main/java/net/tfminecraft/magic/artifact/ArtifactIds.java index 3acbb56..bf7c93f 100644 --- a/src/main/java/net/tfminecraft/magic/artifact/ArtifactIds.java +++ b/src/main/java/net/tfminecraft/magic/artifact/ArtifactIds.java @@ -15,7 +15,7 @@ public static boolean hasKey(ItemStack stack) { return false; } ItemMeta meta = stack.getItemMeta(); - return meta != null && meta.getPersistentDataContainer().has( + return meta.getPersistentDataContainer().has( ArtifactKeys.artifactId(), PersistentDataType.STRING); } @@ -24,9 +24,6 @@ public static UUID read(ItemStack stack) { return null; } ItemMeta meta = stack.getItemMeta(); - if (meta == null) { - return null; - } String raw = meta.getPersistentDataContainer().get( ArtifactKeys.artifactId(), PersistentDataType.STRING); if (raw == null || raw.isBlank()) { @@ -48,9 +45,6 @@ public static void writeNew(ItemStack stack) { return; } ItemMeta meta = stack.getItemMeta(); - if (meta == null) { - return; - } meta.getPersistentDataContainer().set( ArtifactKeys.artifactId(), PersistentDataType.STRING, diff --git a/src/main/java/net/tfminecraft/magic/artifact/ArtifactLore.java b/src/main/java/net/tfminecraft/magic/artifact/ArtifactLore.java index 5a115e1..b17203b 100644 --- a/src/main/java/net/tfminecraft/magic/artifact/ArtifactLore.java +++ b/src/main/java/net/tfminecraft/magic/artifact/ArtifactLore.java @@ -39,9 +39,6 @@ public static void apply(ItemStack stack) { return; } ItemMeta meta = stack.getItemMeta(); - if (meta == null) { - return; - } List lore = meta.hasLore() ? new ArrayList<>(meta.getLore()) : new ArrayList<>(); lore = stripMagicBlocks(lore); List block = new ArrayList<>(); @@ -67,13 +64,10 @@ public static void apply(ItemStack stack) { block.addAll(attuneLines); for (SacrificeImprint imprint : SacrificeImprintStore.read(stack)) { String line = formatImprint(imprint); - if (line != null && !line.isBlank()) { + if (!line.isBlank()) { block.add(MagicText.format("{color:label_muted}" + line)); } } - if (block.isEmpty()) { - return; - } int loreStart = lore.size(); block.set(0, BEGIN + block.get(0)); lore.addAll(block); @@ -95,7 +89,7 @@ public static void refreshAttune(ItemStack stack) { return; } ItemMeta meta = stack.getItemMeta(); - if (meta == null || !meta.hasLore() || meta.getLore() == null) { + if (!meta.hasLore()) { apply(stack); return; } @@ -103,7 +97,7 @@ public static void refreshAttune(ItemStack stack) { ArtifactKeys.attuneStart(), PersistentDataType.INTEGER); Integer count = meta.getPersistentDataContainer().get( ArtifactKeys.attuneCount(), PersistentDataType.INTEGER); - if (start == null || count == null || start < 0) { + if (start == null || count == null || start < 0 || count < 0) { apply(stack); return; } @@ -112,7 +106,7 @@ public static void refreshAttune(ItemStack stack) { apply(stack); return; } - int end = Math.min(start + count, lore.size()); + int end = (int) Math.min((long) start + count, lore.size()); List current = new ArrayList<>(lore.subList(start, end)); List attuneLines = buildAttuneLines(stack, artifact); if (current.equals(attuneLines) && count == attuneLines.size()) { @@ -140,7 +134,7 @@ static boolean careVisibleWouldChange(ItemStack stack, double newMuffle, double String id = element.getId(); double cap = artifact.getCap(id); double fill = artifact.getFill(id); - double nextFill = fillAfterDecay(id, fill, dtHours); + double nextFill = fillAfterDecay(element, fill, dtHours); String oldShown = formatAmount(ArtifactCareStore.usableFill(fill, cap, oldMuffle)); String newShown = formatAmount(ArtifactCareStore.usableFill(nextFill, cap, newMuffle)); if (!oldShown.equals(newShown)) { @@ -158,14 +152,10 @@ private static String muffleLine(double muffle) { "{color:label_muted}Muffled: " + MagicNumbers.format(muffle * 100.0) + "%"); } - private static double fillAfterDecay(String elementId, double fill, double dtHours) { + private static double fillAfterDecay(ElementDef element, double fill, double dtHours) { if (dtHours <= 0.0) { return fill; } - ElementDef element = ElementRegistry.getById(elementId); - if (element == null) { - return fill; - } double rate = element.getAuraDecayPerHour(); if (rate == 0.0) { return fill; @@ -187,7 +177,7 @@ public static Artifact readAura(ItemStack stack) { return null; } ItemMeta meta = stack.getItemMeta(); - if (meta == null || !meta.hasLore() || meta.getLore() == null) { + if (!meta.hasLore()) { return null; } Artifact artifact = Artifact.create(); @@ -244,13 +234,7 @@ private static List buildAttuneLines(ItemStack stack, Artifact artifact, } private static void writeAttuneIndex(ItemStack stack, int start, int count) { - if (stack == null || !stack.hasItemMeta()) { - return; - } ItemMeta meta = stack.getItemMeta(); - if (meta == null) { - return; - } Integer oldStart = meta.getPersistentDataContainer().get( ArtifactKeys.attuneStart(), PersistentDataType.INTEGER); Integer oldCount = meta.getPersistentDataContainer().get( @@ -300,7 +284,8 @@ private static Double parseAmount(String raw) { return null; } try { - return Double.parseDouble(raw.trim()); + double amount = Double.parseDouble(raw.trim()); + return Double.isFinite(amount) ? amount : null; } catch (NumberFormatException ex) { return null; } @@ -312,9 +297,6 @@ public static ItemStack updateItem(ItemStack stack) { } private static String formatImprint(SacrificeImprint imprint) { - if (imprint == null) { - return ""; - } SacrificeElementDef def = SacrificeRegistry.getById(imprint.getElementId()); if (def == null) { List all = SacrificeRegistry.getAll(); @@ -325,9 +307,6 @@ private static String formatImprint(SacrificeImprint imprint) { private static String rarityLine(ItemStack stack) { ItemMeta meta = stack.getItemMeta(); - if (meta == null) { - return null; - } String rarityId = meta.getPersistentDataContainer().get( ArtifactKeys.artifactRarity(), PersistentDataType.STRING); if (rarityId == null || rarityId.isBlank()) { @@ -356,31 +335,16 @@ private static List stripMagicBlocks(List lore) { } private static int findMagicStart(List lore) { - int start = -1; for (int i = 0; i < lore.size(); i++) { String line = lore.get(i); - int candidate = -1; if (hasHiddenMarker(line)) { - candidate = i; - } else if (isAuraHeader(line)) { - candidate = i; - if (i > 0 && isRarityOnlyLine(lore.get(i - 1))) { - candidate = i - 1; - } - } else if (isFillLine(line)) { - candidate = i; - if (i > 0 && isAuraHeader(lore.get(i - 1))) { - candidate = i - 1; - } - if (candidate > 0 && isRarityOnlyLine(lore.get(candidate - 1))) { - candidate--; - } + return i; } - if (candidate >= 0 && (start < 0 || candidate < start)) { - start = candidate; + if (isAuraHeader(line) || isFillLine(line)) { + return i > 0 && isRarityOnlyLine(lore.get(i - 1)) ? i - 1 : i; } } - return start; + return -1; } private static boolean hasHiddenMarker(String line) { @@ -406,10 +370,10 @@ private static boolean isRarityOnlyLine(String line) { return false; } for (ArtifactRarityDef rarity : ArtifactRarityRegistry.getAll()) { - if (rarity != null && p.equals(rarity.getName().toLowerCase(Locale.ROOT))) { + if (p.equals(rarity.getName().toLowerCase(Locale.ROOT))) { return true; } - if (rarity != null && p.equals(rarity.getId().toLowerCase(Locale.ROOT))) { + if (p.equals(rarity.getId().toLowerCase(Locale.ROOT))) { return true; } } @@ -465,7 +429,7 @@ private static String loreName(ElementDef element) { if (!name.isEmpty()) { return name; } - return element.getId() == null ? "" : element.getId().toLowerCase(Locale.ROOT); + return element.getId().toLowerCase(Locale.ROOT); } // Keep the existing legacy text representation, formatting, and exact-string comparisons. @@ -516,18 +480,11 @@ private static List orderedAuraElements(ItemStack stack, Artifact ar } private static String primaryId(ItemStack stack, Artifact artifact) { - if (stack != null && stack.hasItemMeta()) { - ItemMeta meta = stack.getItemMeta(); - if (meta != null) { - String stored = meta.getPersistentDataContainer().get( - ArtifactKeys.artifactPrimary(), PersistentDataType.STRING); - if (stored != null && !stored.isBlank()) { - return stored.trim().toLowerCase(Locale.ROOT); - } - } - } - if (artifact == null) { - return ""; + ItemMeta meta = stack.getItemMeta(); + String stored = meta.getPersistentDataContainer().get( + ArtifactKeys.artifactPrimary(), PersistentDataType.STRING); + if (stored != null && !stored.isBlank()) { + return stored.trim().toLowerCase(Locale.ROOT); } String best = ""; double bestCap = -1; diff --git a/src/main/java/net/tfminecraft/magic/artifact/aura/AuraData.java b/src/main/java/net/tfminecraft/magic/artifact/aura/AuraData.java index 954d4f2..e8d4ecc 100644 --- a/src/main/java/net/tfminecraft/magic/artifact/aura/AuraData.java +++ b/src/main/java/net/tfminecraft/magic/artifact/aura/AuraData.java @@ -43,16 +43,13 @@ public static AuraData fromPersistentData( return data; } ItemMeta meta = stack.getItemMeta(); - if (meta == null) { - return data; - } PersistentDataContainer root = meta.getPersistentDataContainer(); PersistentDataContainer capContainer = root.get(capKey, PersistentDataType.TAG_CONTAINER); PersistentDataContainer fillContainer = root.get(fillKey, PersistentDataType.TAG_CONTAINER); if (capContainer != null) { for (NamespacedKey key : capContainer.getKeys()) { Double value = capContainer.get(key, PersistentDataType.DOUBLE); - if (value != null && value > 0) { + if (value != null && Double.isFinite(value) && value > 0) { data.cap.put(key.getKey(), value); } } @@ -60,7 +57,7 @@ public static AuraData fromPersistentData( if (fillContainer != null) { for (NamespacedKey key : fillContainer.getKeys()) { Double value = fillContainer.get(key, PersistentDataType.DOUBLE); - if (value == null) { + if (value == null || !Double.isFinite(value)) { continue; } data.fill.put(key.getKey(), value); @@ -69,7 +66,7 @@ public static AuraData fromPersistentData( parseBlob(data, root.get(blobKey, PersistentDataType.STRING)); if (data.cap.isEmpty() && !data.fill.isEmpty()) { for (Map.Entry entry : data.fill.entrySet()) { - if (entry.getValue() != null && entry.getValue() > 0) { + if (entry.getValue() > 0) { data.cap.put(entry.getKey(), entry.getValue()); } } @@ -99,11 +96,17 @@ private static void parseBlob(AuraData data, String raw) { } try { double capValue = Double.parseDouble(bits[1].trim()); + if (!Double.isFinite(capValue)) { + continue; + } if (capValue > 0 && data.getCap(id) <= 0) { data.cap.put(id, capValue); } if (bits.length >= 3) { double fillValue = Double.parseDouble(bits[2].trim()); + if (!Double.isFinite(fillValue)) { + continue; + } data.fill.putIfAbsent(id, fillValue); if (data.getFill(id) <= 0 && fillValue > 0) { data.fill.put(id, fillValue); @@ -148,7 +151,7 @@ public double getFill(String elementId) { public void setCap(String elementId, double value) { String id = normalize(elementId); - if (id.isEmpty()) { + if (id.isEmpty() || !Double.isFinite(value)) { return; } double next = Math.max(0.0, value); @@ -163,7 +166,7 @@ public void setCap(String elementId, double value) { public void setFill(String elementId, double value) { String id = normalize(elementId); - if (id.isEmpty() || getCap(id) <= 0) { + if (id.isEmpty() || !Double.isFinite(value) || getCap(id) <= 0) { return; } fill.put(id, clampFill(value, getCap(id))); @@ -178,7 +181,7 @@ public String highestCapElementId() { String best = ""; double bestCap = -1; for (Map.Entry entry : cap.entrySet()) { - if (entry.getValue() != null && entry.getValue() > bestCap) { + if (entry.getValue() > bestCap) { bestCap = entry.getValue(); best = entry.getKey(); } @@ -196,17 +199,11 @@ public void persistPdc( return; } ItemMeta meta = stack.getItemMeta(); - if (meta == null) { - return; - } PersistentDataContainer root = meta.getPersistentDataContainer(); PersistentDataContainer capContainer = root.getAdapterContext().newPersistentDataContainer(); PersistentDataContainer fillContainer = root.getAdapterContext().newPersistentDataContainer(); StringBuilder blob = new StringBuilder(); for (Map.Entry entry : cap.entrySet()) { - if (entry.getValue() == null || entry.getValue() <= 0) { - continue; - } NamespacedKey key; try { key = ArtifactKeys.element(entry.getKey()); @@ -226,11 +223,16 @@ public void persistPdc( root.set(fillKey, PersistentDataType.TAG_CONTAINER, fillContainer); if (blob.length() > 0) { root.set(blobKey, PersistentDataType.STRING, blob.toString()); + } else { + root.remove(blobKey); } stack.setItemMeta(meta); } public static double clampFill(double value, double capValue) { + if (!Double.isFinite(value) || !Double.isFinite(capValue)) { + return 0.0; + } return MagicNumbers.clamp(value, 0.0, Math.max(0.0, capValue)); } diff --git a/src/main/java/net/tfminecraft/magic/artifact/aura/VesselLore.java b/src/main/java/net/tfminecraft/magic/artifact/aura/VesselLore.java index 92d092c..8d0eba5 100644 --- a/src/main/java/net/tfminecraft/magic/artifact/aura/VesselLore.java +++ b/src/main/java/net/tfminecraft/magic/artifact/aura/VesselLore.java @@ -29,25 +29,19 @@ public static void updateInventory(Player player) { } PlayerInventory inventory = player.getInventory(); ItemStack[] storage = inventory.getStorageContents(); - if (storage != null) { - for (int i = 0; i < storage.length; i++) { - storage[i] = updateItem(storage[i]); - } - inventory.setStorageContents(storage); + for (int i = 0; i < storage.length; i++) { + storage[i] = updateItem(storage[i]); } + inventory.setStorageContents(storage); ItemStack[] armor = inventory.getArmorContents(); - if (armor != null) { - for (int i = 0; i < armor.length; i++) { - armor[i] = updateItem(armor[i]); - } - inventory.setArmorContents(armor); + for (int i = 0; i < armor.length; i++) { + armor[i] = updateItem(armor[i]); } + inventory.setArmorContents(armor); ItemStack[] extra = inventory.getExtraContents(); - if (extra != null) { - for (int i = 0; i < extra.length; i++) { - extra[i] = updateItem(extra[i]); - } - inventory.setExtraContents(extra); + for (int i = 0; i < extra.length; i++) { + extra[i] = updateItem(extra[i]); } + inventory.setExtraContents(extra); } } diff --git a/src/main/java/net/tfminecraft/magic/artifact/config/ArtifactAdjectiveLoader.java b/src/main/java/net/tfminecraft/magic/artifact/config/ArtifactAdjectiveLoader.java index d0173cc..d07e70d 100644 --- a/src/main/java/net/tfminecraft/magic/artifact/config/ArtifactAdjectiveLoader.java +++ b/src/main/java/net/tfminecraft/magic/artifact/config/ArtifactAdjectiveLoader.java @@ -51,7 +51,7 @@ private static void loadRarityLists(ConfigurationSection section, String element for (String rarityId : section.getKeys(false)) { List words = new ArrayList<>(); for (String word : section.getStringList(rarityId)) { - if (word != null && !word.isBlank()) { + if (!word.isBlank()) { words.add(word.trim()); } } diff --git a/src/main/java/net/tfminecraft/magic/artifact/config/ArtifactAdjectiveRegistry.java b/src/main/java/net/tfminecraft/magic/artifact/config/ArtifactAdjectiveRegistry.java index d8c2524..57cfc42 100644 --- a/src/main/java/net/tfminecraft/magic/artifact/config/ArtifactAdjectiveRegistry.java +++ b/src/main/java/net/tfminecraft/magic/artifact/config/ArtifactAdjectiveRegistry.java @@ -74,7 +74,7 @@ private static void putList(Map> map, String rarityId, List } private static List getList(Map> map, String rarityId) { - if (map == null || rarityId == null || rarityId.isBlank()) { + if (rarityId == null || rarityId.isBlank()) { return List.of(); } List exact = map.get(rarityId.trim().toLowerCase(Locale.ROOT)); @@ -83,7 +83,7 @@ private static List getList(Map> map, String rarity private static void addAll(List out, List extra) { for (String word : extra) { - if (word != null && !word.isBlank() && !out.contains(word)) { + if (!out.contains(word)) { out.add(word); } } diff --git a/src/main/java/net/tfminecraft/magic/artifact/config/ArtifactAffinityRegistry.java b/src/main/java/net/tfminecraft/magic/artifact/config/ArtifactAffinityRegistry.java index b35c4c3..e79620e 100644 --- a/src/main/java/net/tfminecraft/magic/artifact/config/ArtifactAffinityRegistry.java +++ b/src/main/java/net/tfminecraft/magic/artifact/config/ArtifactAffinityRegistry.java @@ -58,7 +58,7 @@ public static List getGroup(String groupId) { return exact; } for (Map.Entry> entry : groups.entrySet()) { - if (entry.getKey() != null && entry.getKey().equalsIgnoreCase(groupId)) { + if (entry.getKey().equalsIgnoreCase(groupId)) { return entry.getValue(); } } diff --git a/src/main/java/net/tfminecraft/magic/artifact/config/ArtifactConfigLoader.java b/src/main/java/net/tfminecraft/magic/artifact/config/ArtifactConfigLoader.java index 61c05de..45361a4 100644 --- a/src/main/java/net/tfminecraft/magic/artifact/config/ArtifactConfigLoader.java +++ b/src/main/java/net/tfminecraft/magic/artifact/config/ArtifactConfigLoader.java @@ -150,7 +150,7 @@ private static boolean loadNamingSchemes(File file) { for (String kind : section.getKeys(false)) { List names = new ArrayList<>(); for (String name : section.getStringList(kind)) { - if (name != null && !name.isBlank()) { + if (!name.isBlank()) { names.add(name.trim()); } } @@ -178,7 +178,7 @@ private static boolean loadGenerator(File file) { boolean ok = true; String template = config.getString("template", ""); - if (template == null || template.isBlank()) { + if (template.isBlank()) { log.severe("[Magic] artifacts/generator.yml needs a non-empty template"); ok = false; } else { @@ -217,14 +217,14 @@ private static boolean loadRandomPool(YamlConfiguration config, String fileName) tokens.addAll(config.getStringList("random_pool")); } else { String one = config.getString("random_pool", ""); - if (one != null && !one.isBlank()) { + if (!one.isBlank()) { tokens.add(one.trim()); } } LinkedHashSet ids = new LinkedHashSet<>(); boolean ok = true; for (String token : tokens) { - if (token == null || token.isBlank()) { + if (token.isBlank()) { continue; } String key = token.trim(); @@ -279,14 +279,13 @@ private static boolean loadRarities(ConfigurationSection rarities, String fileNa } CapRange elements = CapRange.parse(section.getString("elements", "")); if (elements == null || elements.getMin() > elements.getMax() - || elements.getMin() < 1) { + || elements.getMin() < 1 || elements.getMax() > Integer.MAX_VALUE) { log.severe("[Magic] Rarity '" + key + "' has invalid elements range in " + fileName); ok = false; continue; } CapRange auraCap = CapRange.parse(section.getString("aura_cap", "")); - if (auraCap == null || auraCap.getMin() > auraCap.getMax() || auraCap.getMax() <= 0 - || auraCap.getMin() < 0) { + if (auraCap == null || auraCap.getMin() > auraCap.getMax() || auraCap.getMax() <= 0) { log.severe("[Magic] Rarity '" + key + "' has missing or invalid aura_cap in " + fileName); ok = false; continue; @@ -325,7 +324,7 @@ private static boolean loadGroups(ConfigurationSection groups, String fileName) } List members = new ArrayList<>(); for (String member : groups.getStringList(groupId)) { - if (member == null || member.isBlank()) { + if (member.isBlank()) { continue; } String id = member.trim(); @@ -411,13 +410,13 @@ private static boolean loadTypes(ConfigurationSection types, String fileName) { } String namingId = section.getString("naming-scheme", ""); String modelId = section.getString("model-scheme", ""); - if (namingId == null || namingId.isBlank() || !ArtifactNamingSchemeRegistry.contains(namingId)) { + if (namingId.isBlank() || !ArtifactNamingSchemeRegistry.contains(namingId)) { log.severe("[Magic] Artifact type '" + key + "' has missing naming-scheme '" + namingId + "'"); ok = false; continue; } - if (modelId == null || modelId.isBlank() || !ArtifactModelSchemeRegistry.contains(modelId)) { + if (modelId.isBlank() || !ArtifactModelSchemeRegistry.contains(modelId)) { log.severe("[Magic] Artifact type '" + key + "' has missing model-scheme '" + modelId + "'"); ok = false; @@ -433,7 +432,7 @@ private static boolean loadTypes(ConfigurationSection types, String fileName) { ConfigurationSection raritySec = section.getConfigurationSection("rarities"); if (raritySec != null) { for (String rarityId : raritySec.getKeys(false)) { - if (rarityId == null || rarityId.isBlank()) { + if (rarityId.isBlank()) { continue; } if (!ArtifactRarityRegistry.contains(rarityId)) { @@ -459,7 +458,7 @@ private static boolean loadTypes(ConfigurationSection types, String fileName) { } boolean enabled = !section.getBoolean("disable", false); double weight = section.getDouble("weight", 0.0); - if (weight <= 0) { + if (weight <= 0 || !Double.isFinite(weight)) { log.severe("[Magic] Artifact type '" + key + "' has missing or invalid weight in " + fileName); ok = false; continue; @@ -487,12 +486,11 @@ private static Map parseCapMap( Map out = new LinkedHashMap<>(); boolean ok = true; for (String rarityId : section.getKeys(false)) { - if (rarityId == null || rarityId.isBlank()) { + if (rarityId.isBlank()) { continue; } CapRange parsed = CapRange.parse(section.getString(rarityId, "")); - if (parsed == null || parsed.getMin() > parsed.getMax() || parsed.getMax() <= 0 - || parsed.getMin() < 0) { + if (parsed == null || parsed.getMin() > parsed.getMax() || parsed.getMax() <= 0) { log.severe("[Magic] Artifact type '" + typeId + "' has invalid " + field + " for '" + rarityId + "' in " + fileName); ok = false; diff --git a/src/main/java/net/tfminecraft/magic/artifact/config/ArtifactModelEntry.java b/src/main/java/net/tfminecraft/magic/artifact/config/ArtifactModelEntry.java index 1584562..c1c531a 100644 --- a/src/main/java/net/tfminecraft/magic/artifact/config/ArtifactModelEntry.java +++ b/src/main/java/net/tfminecraft/magic/artifact/config/ArtifactModelEntry.java @@ -79,7 +79,7 @@ public static ArtifactModelEntry parse(String raw) { } String kind = trimmed.substring(0, open).trim(); String path = trimmed.substring(open + 1, close).trim(); - if (kind.isEmpty() || path.isEmpty()) { + if (path.isEmpty()) { return null; } String suffix = close + 1 < trimmed.length() ? trimmed.substring(close + 1).trim() : ""; @@ -93,9 +93,6 @@ public static ArtifactModelEntry parse(String raw) { } else { min = suffix.substring(0, dash).trim().toLowerCase(Locale.ROOT); max = suffix.substring(dash + 1).trim().toLowerCase(Locale.ROOT); - if (min.isEmpty() || max.isEmpty()) { - return null; - } } } return new ArtifactModelEntry(kind, path, min, max); diff --git a/src/main/java/net/tfminecraft/magic/artifact/config/ArtifactNamingScheme.java b/src/main/java/net/tfminecraft/magic/artifact/config/ArtifactNamingScheme.java index 5158d85..25ab6f6 100644 --- a/src/main/java/net/tfminecraft/magic/artifact/config/ArtifactNamingScheme.java +++ b/src/main/java/net/tfminecraft/magic/artifact/config/ArtifactNamingScheme.java @@ -32,7 +32,7 @@ public String getId() { public boolean isEmpty() { for (List names : namesByKind.values()) { - if (names != null && !names.isEmpty()) { + if (!names.isEmpty()) { return false; } } @@ -49,7 +49,7 @@ public List namesFor(String kind) { public List firstNonEmptyKindNames() { for (List names : namesByKind.values()) { - if (names != null && !names.isEmpty()) { + if (!names.isEmpty()) { return names; } } diff --git a/src/main/java/net/tfminecraft/magic/artifact/config/CapRange.java b/src/main/java/net/tfminecraft/magic/artifact/config/CapRange.java index 0d3447e..aba043c 100644 --- a/src/main/java/net/tfminecraft/magic/artifact/config/CapRange.java +++ b/src/main/java/net/tfminecraft/magic/artifact/config/CapRange.java @@ -29,6 +29,9 @@ public static CapRange parse(String raw) { try { double min = Double.parseDouble(parts[0].trim()); double max = Double.parseDouble(parts[1].trim()); + if (!Double.isFinite(min) || !Double.isFinite(max)) { + return null; + } return new CapRange(min, max); } catch (NumberFormatException ex) { return null; diff --git a/src/main/java/net/tfminecraft/magic/artifact/create/ArtifactCreateSession.java b/src/main/java/net/tfminecraft/magic/artifact/create/ArtifactCreateSession.java index 673449d..8dc9e70 100644 --- a/src/main/java/net/tfminecraft/magic/artifact/create/ArtifactCreateSession.java +++ b/src/main/java/net/tfminecraft/magic/artifact/create/ArtifactCreateSession.java @@ -36,7 +36,6 @@ public enum ElementRole { private String previewBaseName; private String previewModelPath; private String previewLockPrimaryId; - private String previewLockRarityId; public ArtifactCreateSession() { this.rarityId = defaultRarityId(); @@ -91,7 +90,7 @@ public double getCap(String elementId) { } public void setCap(String elementId, double cap) { - if (elementId == null || elementId.isBlank()) { + if (elementId == null || elementId.isBlank() || !Double.isFinite(cap)) { return; } if (cap <= 0) { @@ -122,7 +121,7 @@ public boolean cycleElement(String elementId) { } ElementRole role = roleOf(elementId); if (role == ElementRole.OFF) { - if (primaryId != null && !primaryId.isBlank() + if (primaryId != null && !ElementVisibility.shownOnArtifact(elementId, primaryId)) { return false; } @@ -137,15 +136,13 @@ public boolean cycleElement(String elementId) { return true; } setCap(elementId, 0); - if (elementId.equals(primaryId)) { - assignPrimary(null); - } + assignPrimary(null); return true; } public boolean adjustSelectedCap(double delta) { String elementId = selectedElementId; - if (elementId == null || elementId.isBlank() || roleOf(elementId) == ElementRole.OFF) { + if (!Double.isFinite(delta) || roleOf(elementId) == ElementRole.OFF) { return false; } CapRange range = capRange(elementId); @@ -170,20 +167,20 @@ public CapRange capRange(String elementId) { } public ArtifactRoll toRoll() { - if (primaryId == null || primaryId.isBlank() || getCap(primaryId) <= 0) { + if (primaryId == null || getCap(primaryId) <= 0) { return null; } if (!ArtifactTypeRegistry.contains(primaryId) || !ArtifactRarityRegistry.contains(rarityId)) { return null; } ArtifactTypeDef primaryType = ArtifactTypeRegistry.getById(primaryId); - if (primaryType == null || !primaryType.isEnabled()) { + if (!primaryType.isEnabled()) { return null; } List slots = new ArrayList<>(); slots.add(new ArtifactAuraSlot(primaryId, getCap(primaryId))); for (Map.Entry entry : caps.entrySet()) { - if (primaryId.equals(entry.getKey()) || entry.getValue() == null || entry.getValue() <= 0) { + if (primaryId.equals(entry.getKey())) { continue; } if (!ElementVisibility.shownOnArtifact(entry.getKey(), primaryId)) { @@ -209,7 +206,6 @@ public void ensurePreviewLock(ArtifactItemBuilder builder) { } if (primaryId.equals(previewLockPrimaryId) && rarityId != null - && rarityId.equals(previewLockRarityId) && previewBaseName != null && !previewBaseName.isBlank()) { return; @@ -225,7 +221,6 @@ public void ensurePreviewLock(ArtifactItemBuilder builder) { previewBaseName = builder.pickBaseName(type, kind, rarityId); previewModelPath = pickedPath == null ? "" : pickedPath; previewLockPrimaryId = primaryId; - previewLockRarityId = rarityId; } private void clampCapsToRarity() { @@ -264,6 +259,5 @@ private void clearPreviewLock() { previewBaseName = null; previewModelPath = null; previewLockPrimaryId = null; - previewLockRarityId = null; } } diff --git a/src/main/java/net/tfminecraft/magic/artifact/fillchest/ArtifactFillChestService.java b/src/main/java/net/tfminecraft/magic/artifact/fillchest/ArtifactFillChestService.java index cdfa7fd..7d03a98 100644 --- a/src/main/java/net/tfminecraft/magic/artifact/fillchest/ArtifactFillChestService.java +++ b/src/main/java/net/tfminecraft/magic/artifact/fillchest/ArtifactFillChestService.java @@ -91,7 +91,7 @@ public static int fill(Block block, Pending held) { primaryArg = lockedPrimary; } ArtifactRoll roll = roller.roll(primaryArg, "roll"); - if (roll == null || roll.isError()) { + if (roll.isError()) { continue; } ItemStack stack = builder.build(roll, null, null, false); @@ -101,8 +101,9 @@ public static int fill(Block block, Pending held) { contents[slot] = stack; placed++; } - inventory.clear(); - inventory.setContents(contents); + if (placed > 0) { + inventory.setContents(contents); + } return placed; } @@ -129,7 +130,7 @@ public static Mode parseMode(String raw) { } if (ArtifactTypeRegistry.contains(id)) { ArtifactTypeDef type = ArtifactTypeRegistry.getById(id); - if (type != null && type.isEnabled()) { + if (type.isEnabled()) { return Mode.TYPE; } return null; diff --git a/src/main/java/net/tfminecraft/magic/artifact/generate/ArtifactItemBuilder.java b/src/main/java/net/tfminecraft/magic/artifact/generate/ArtifactItemBuilder.java index 0b5cfe8..850cd09 100644 --- a/src/main/java/net/tfminecraft/magic/artifact/generate/ArtifactItemBuilder.java +++ b/src/main/java/net/tfminecraft/magic/artifact/generate/ArtifactItemBuilder.java @@ -132,9 +132,7 @@ public String pickBaseName(ArtifactTypeDef type, String kind, String rarityId) { if (names.isEmpty()) { names = scheme.firstNonEmptyKindNames(); } - if (!names.isEmpty()) { - base = names.get(rng.nextInt(names.size())); - } + base = names.get(rng.nextInt(names.size())); } return applyAdjective(base, type.getElementId(), rarityId); } @@ -151,9 +149,6 @@ private String applyAdjective(String base, String elementId, String rarityId) { return base; } String adjective = pool.get(rng.nextInt(pool.size())); - if (adjective == null || adjective.isBlank()) { - return base; - } return insertAdjective(base, adjective.trim()); } @@ -215,7 +210,7 @@ public static String kindForPath(ArtifactTypeDef type, String path) { } private static String buildDisplayName(ArtifactTypeDef type, String baseName) { - String rolledName = baseName != null && !baseName.isBlank() ? baseName : type.getElementId(); + String rolledName = !baseName.isBlank() ? baseName : type.getElementId(); ElementDef element = ElementRegistry.getById(type.getElementId()); return MagicText.elementText(element, rolledName); } @@ -242,10 +237,12 @@ private ItemStack applyModel(ItemStack stack, ArtifactTypeDef type, String model log(logFailures, "[Magic] No model scheme for artifact type " + type.getElementId()); return stack; } - String prefix = path.split("\\.")[0]; + int separator = path.indexOf('.'); + String prefix = separator < 0 ? path : path.substring(0, separator); try { if (prefix.equalsIgnoreCase("ia")) { - return TLibs.getItemAPI().getArmorMerger().merge(stack, Optional.empty(), path); + ItemStack merged = TLibs.getItemAPI().getArmorMerger().merge(stack, Optional.empty(), path); + return merged == null || merged.getType().isAir() ? stack : merged; } if (prefix.equalsIgnoreCase("v")) { return applyVanillaModel(stack, path); @@ -264,22 +261,22 @@ private static ItemStack applyVanillaModel(ItemStack stack, String path) { if (parts.length < 2) { return stack; } - stack.setType(Material.valueOf(parts[1].toUpperCase())); - if (parts.length >= 3) { + Material material = Material.valueOf(parts[1].toUpperCase(java.util.Locale.ROOT)); + if (material.isAir() || !material.isItem()) { + return stack; + } + Integer model = parts.length >= 3 ? Integer.valueOf(parts[2]) : null; + stack.setType(material); + if (model != null) { ItemMeta meta = stack.getItemMeta(); - if (meta != null) { - LegacyModelData.set(meta, Integer.parseInt(parts[2])); - stack.setItemMeta(meta); - } + LegacyModelData.set(meta, model); + stack.setItemMeta(meta); } return stack; } private static void writeIdentity(ItemStack stack, String rarityId, String primaryId) { ItemMeta meta = stack.getItemMeta(); - if (meta == null) { - return; - } meta.getPersistentDataContainer().set(ArtifactKeys.artifactRarity(), PersistentDataType.STRING, rarityId); meta.getPersistentDataContainer().set(ArtifactKeys.artifactPrimary(), PersistentDataType.STRING, primaryId); meta.getPersistentDataContainer().set( diff --git a/src/main/java/net/tfminecraft/magic/artifact/generate/ArtifactRoller.java b/src/main/java/net/tfminecraft/magic/artifact/generate/ArtifactRoller.java index d1ae531..e21e817 100644 --- a/src/main/java/net/tfminecraft/magic/artifact/generate/ArtifactRoller.java +++ b/src/main/java/net/tfminecraft/magic/artifact/generate/ArtifactRoller.java @@ -66,7 +66,7 @@ public ArtifactRoll roll(String primaryId, String rarityId) { List pool = new ArrayList<>(); for (String companionId : ArtifactAffinityRegistry.companions(type.getElementId())) { ArtifactTypeDef companion = findType(companionId); - if (companion == null || !companion.isEnabled() || !companion.hasRarity(rarity.getId())) { + if (!companion.hasRarity(rarity.getId())) { continue; } if (!ElementVisibility.shownOnArtifact(companion.getElementId(), type.getElementId())) { @@ -84,7 +84,7 @@ public ArtifactRoll roll(String primaryId, String rarityId) { rollCap(ArtifactAuraCaps.primaryRange(type.getElementId(), rarity.getId())))); int secondaryCount = Math.max(0, wanted - 1); - while (slots.size() < 1 + secondaryCount && !pool.isEmpty()) { + while (slots.size() < 1 + secondaryCount) { List eligible = new ArrayList<>(); for (ArtifactTypeDef companion : pool) { if (ArtifactAffinityRegistry.compatibleWith(slotIds(slots), companion.getElementId())) { @@ -173,43 +173,42 @@ private ArtifactTypeDef pickWeightedType(String rarityId) { } private ArtifactTypeDef pickWeighted(List types) { - if (types == null || types.isEmpty()) { - return null; - } - double total = 0.0; - for (ArtifactTypeDef type : types) { - if (type.getWeight() > 0) { - total += type.getWeight(); + return pickWeighted(types, ArtifactTypeDef::getWeight); + } + + private T pickWeighted(List entries, java.util.function.ToDoubleFunction weightOf) { + List eligible = new ArrayList<>(); + double maximum = 0.0; + for (T entry : entries) { + double weight = weightOf.applyAsDouble(entry); + if (weight > 0.0 && Double.isFinite(weight)) { + eligible.add(entry); + maximum = Math.max(maximum, weight); } } - if (total <= 0) { + if (eligible.isEmpty()) { return null; } + double total = 0.0; + for (T entry : eligible) { + total += weightOf.applyAsDouble(entry) / maximum; + } double pick = rng.nextDouble() * total; - double acc = 0.0; - ArtifactTypeDef last = null; - for (ArtifactTypeDef type : types) { - if (type.getWeight() <= 0) { - continue; - } - acc += type.getWeight(); - last = type; - if (pick <= acc) { - return type; + double accumulated = 0.0; + for (int i = 0; i < eligible.size() - 1; i++) { + T entry = eligible.get(i); + accumulated += weightOf.applyAsDouble(entry) / maximum; + if (pick <= accumulated) { + return entry; } } - return last; + return eligible.get(eligible.size() - 1); } private static List slotIds(List slots) { List ids = new ArrayList<>(); - if (slots == null) { - return ids; - } for (ArtifactAuraSlot slot : slots) { - if (slot != null && slot.getElementId() != null && !slot.getElementId().isBlank()) { - ids.add(slot.getElementId()); - } + ids.add(slot.getElementId()); } return ids; } @@ -239,30 +238,7 @@ private ArtifactRarityDef resolveRarity(String rarityId) { } private ArtifactRarityDef pickWeightedRarity() { - List rarities = ArtifactRarityRegistry.getAll(); - double total = 0.0; - for (ArtifactRarityDef rarity : rarities) { - if (rarity.getWeight() > 0) { - total += rarity.getWeight(); - } - } - if (total <= 0) { - return null; - } - double pick = rng.nextDouble() * total; - double acc = 0.0; - ArtifactRarityDef last = null; - for (ArtifactRarityDef rarity : rarities) { - if (rarity.getWeight() <= 0) { - continue; - } - acc += rarity.getWeight(); - last = rarity; - if (pick <= acc) { - return rarity; - } - } - return last; + return pickWeighted(ArtifactRarityRegistry.getAll(), ArtifactRarityDef::getWeight); } private int inclusiveInt(int min, int max) { @@ -273,9 +249,6 @@ private int inclusiveInt(int min, int max) { } private static ArtifactTypeDef findType(String id) { - if (id == null || id.isBlank()) { - return null; - } ArtifactTypeDef exact = ArtifactTypeRegistry.getById(id); if (exact != null) { return exact; @@ -289,9 +262,6 @@ private static ArtifactTypeDef findType(String id) { } private static ArtifactRarityDef findRarity(String id) { - if (id == null || id.isBlank()) { - return null; - } ArtifactRarityDef exact = ArtifactRarityRegistry.getById(id); if (exact != null) { return exact; diff --git a/src/main/java/net/tfminecraft/magic/artifact/path/ArtifactPathFactory.java b/src/main/java/net/tfminecraft/magic/artifact/path/ArtifactPathFactory.java index fcfc23e..9019fca 100644 --- a/src/main/java/net/tfminecraft/magic/artifact/path/ArtifactPathFactory.java +++ b/src/main/java/net/tfminecraft/magic/artifact/path/ArtifactPathFactory.java @@ -23,8 +23,8 @@ public ItemStack create(String path) { return null; } ArtifactRoll roll = new ArtifactRoller().roll(spec); - if (roll == null || roll.isError()) { - if (Magic.plugin != null && roll != null && roll.getErrorKey() != null) { + if (roll.isError()) { + if (Magic.plugin != null) { Magic.plugin.getLogger().warning("[Magic] Artifact path failed: " + roll.getErrorKey()); } return null; diff --git a/src/main/java/net/tfminecraft/magic/artifact/path/ArtifactPathParser.java b/src/main/java/net/tfminecraft/magic/artifact/path/ArtifactPathParser.java index 24d563e..9864817 100644 --- a/src/main/java/net/tfminecraft/magic/artifact/path/ArtifactPathParser.java +++ b/src/main/java/net/tfminecraft/magic/artifact/path/ArtifactPathParser.java @@ -30,7 +30,7 @@ public static ArtifactPathSpec parse(String raw) { if ("artifact".equalsIgnoreCase(rest)) { return new ArtifactPathSpec(null, null, new LinkedHashMap<>()); } - if (!rest.startsWith("(") || !rest.endsWith(")") || rest.length() < 2) { + if (!rest.startsWith("(") || !rest.endsWith(")")) { log("Expected magic.artifact or magic.(...): " + trimmed); return null; } @@ -39,9 +39,6 @@ public static ArtifactPathSpec parse(String raw) { String rarityId = null; LinkedHashMap extras = new LinkedHashMap<>(); for (String token : inner.split(";")) { - if (token == null) { - continue; - } String part = token.trim(); if (part.isEmpty()) { continue; @@ -89,9 +86,6 @@ public static ArtifactPathSpec parse(String raw) { } private static String canonicalTypeId(String raw) { - if (raw == null || raw.isBlank()) { - return null; - } ArtifactTypeDef exact = ArtifactTypeRegistry.getById(raw); if (exact != null) { return exact.getElementId(); diff --git a/src/main/java/net/tfminecraft/magic/artifact/sacrifice/SacrificeConfigLoader.java b/src/main/java/net/tfminecraft/magic/artifact/sacrifice/SacrificeConfigLoader.java index 34d8c22..ec77009 100644 --- a/src/main/java/net/tfminecraft/magic/artifact/sacrifice/SacrificeConfigLoader.java +++ b/src/main/java/net/tfminecraft/magic/artifact/sacrifice/SacrificeConfigLoader.java @@ -47,12 +47,12 @@ public static boolean load(File file) { SacrificeRegistry.setRequireRpCharacters(config.getBoolean("require_rpcharacters", true)); SacrificeRegistry.setRpChannel(config.getString("rp_channel", "rp")); - String daggerPath = config.getString("dagger"); + String daggerPath = config.isString("dagger") ? config.getString("dagger") : null; ConfigurationSection daggerSec = config.getConfigurationSection("dagger"); if (daggerPath == null || daggerPath.isBlank()) { if (daggerSec != null) { daggerPath = daggerSec.getString("path", ""); - if (daggerPath == null || daggerPath.isBlank()) { + if (daggerPath.isBlank()) { daggerPath = legacyDaggerPath(daggerSec); } } @@ -97,7 +97,7 @@ public static boolean load(File file) { } List words = new ArrayList<>(); for (String raw : elementSec.getStringList("words")) { - if (raw == null || raw.isBlank()) { + if (raw.isBlank()) { continue; } String word = raw.trim(); @@ -113,7 +113,7 @@ public static boolean load(File file) { String lorePain = lore != null ? lore.getString("pain", "") : ""; String loreScreams = lore != null ? lore.getString("screams", "") : ""; String loreSoul = lore != null ? lore.getString("soul", "") : ""; - if (loreSoul == null || loreSoul.isBlank()) { + if (loreSoul.isBlank()) { loreSoul = lore != null ? lore.getString("death", "") : ""; } double elementMin = elementSec.contains("min_scenery_aura") @@ -145,12 +145,12 @@ public static boolean load(File file) { private static String legacyDaggerPath(ConfigurationSection daggerSec) { String ia = daggerSec.getString("itemsadder", ""); - if (ia != null && !ia.isBlank()) { + if (!ia.isBlank()) { String id = ia.trim(); return id.regionMatches(true, 0, "ia.", 0, 3) ? id : "ia." + id; } String material = daggerSec.getString("material", "GOLDEN_SWORD"); - String type = material != null && !material.isBlank() + String type = !material.isBlank() ? material.trim().toLowerCase(Locale.ROOT) : "golden_sword"; int cmd = daggerSec.getInt("custom_model_data", -1); @@ -187,7 +187,7 @@ private static void loadFx(ConfigurationSection fx, Logger log) { } Set tiers = new LinkedHashSet<>(); for (String id : fx.getStringList("surge_tiers")) { - if (id != null && !id.isBlank()) { + if (!id.isBlank()) { tiers.add(id.trim().toLowerCase(Locale.ROOT)); } } diff --git a/src/main/java/net/tfminecraft/magic/artifact/sacrifice/SacrificeDaggerMatcher.java b/src/main/java/net/tfminecraft/magic/artifact/sacrifice/SacrificeDaggerMatcher.java index e2b768e..a4b0b38 100644 --- a/src/main/java/net/tfminecraft/magic/artifact/sacrifice/SacrificeDaggerMatcher.java +++ b/src/main/java/net/tfminecraft/magic/artifact/sacrifice/SacrificeDaggerMatcher.java @@ -10,9 +10,6 @@ private SacrificeDaggerMatcher() {} public static boolean matches(ItemStack item) { SacrificeDaggerDef def = SacrificeRegistry.getDagger(); - if (def == null) { - return false; - } return TLibs.getItemAPI().getChecker().checkItemWithPath(item, def.getPath()); } } diff --git a/src/main/java/net/tfminecraft/magic/artifact/sacrifice/SacrificeFillService.java b/src/main/java/net/tfminecraft/magic/artifact/sacrifice/SacrificeFillService.java index f432300..b627982 100644 --- a/src/main/java/net/tfminecraft/magic/artifact/sacrifice/SacrificeFillService.java +++ b/src/main/java/net/tfminecraft/magic/artifact/sacrifice/SacrificeFillService.java @@ -36,7 +36,7 @@ public static void apply(SacrificeRiteSession session, SacrificeTierDef tier, St PlacedSlot slot = furniture.getActiveSlot(session.getSlotId()).orElse(null); ItemStack item = ShrineChargeService.itemFromSlot(slot); AuraVessel artifact = AuraVessels.fromItem(item); - if (artifact == null || item == null) { + if (artifact == null) { return; } String elementId = session.getElementId(); @@ -59,9 +59,7 @@ public static void apply(SacrificeRiteSession session, SacrificeTierDef tier, St SacrificeImprintStore.write(item, imprints); } artifact.write(item); - if (slot != null) { - slot.setCurrentItem(item); - } + slot.setCurrentItem(item); persist(furniture); AuraLog.append( "sacrifice artifact=%s element=%s fill=%s->%s gain=%s factor=%s cap=%s character=%s", @@ -92,9 +90,6 @@ private static double shrineFactor(ShrineScore score, String elementId) { } private static void persist(Furniture furniture) { - if (furniture == null) { - return; - } try { InteractibleFurniture.getInstance().getFurnitureManager().persistFurniture(furniture); } catch (Exception ignored) { diff --git a/src/main/java/net/tfminecraft/magic/artifact/sacrifice/SacrificeImprintStore.java b/src/main/java/net/tfminecraft/magic/artifact/sacrifice/SacrificeImprintStore.java index e81459e..0a6942f 100644 --- a/src/main/java/net/tfminecraft/magic/artifact/sacrifice/SacrificeImprintStore.java +++ b/src/main/java/net/tfminecraft/magic/artifact/sacrifice/SacrificeImprintStore.java @@ -19,16 +19,13 @@ public static List read(ItemStack stack) { return out; } ItemMeta meta = stack.getItemMeta(); - if (meta == null) { - return out; - } String raw = meta.getPersistentDataContainer().get( ArtifactKeys.sacrificeImprints(), PersistentDataType.STRING); if (raw == null || raw.isBlank()) { return out; } for (String part : raw.split(";")) { - if (part == null || part.isBlank()) { + if (part.isBlank()) { continue; } String[] bits = part.split("\\|", 4); @@ -46,9 +43,6 @@ public static void write(ItemStack stack, List imprints) { return; } ItemMeta meta = stack.getItemMeta(); - if (meta == null) { - return; - } if (imprints == null || imprints.isEmpty()) { meta.getPersistentDataContainer().remove(ArtifactKeys.sacrificeImprints()); stack.setItemMeta(meta); @@ -88,16 +82,13 @@ public static String formatLine(SacrificeImprint imprint, SacrificeElementDef el case SacrificeImprint.SCREAMS -> element.getLoreScreams(); default -> element.getLorePain(); }; - if (template == null || template.isBlank()) { + if (template.isBlank()) { return ""; } - return template.replace("{character}", imprint.getCharacterName() != null ? imprint.getCharacterName() : ""); + return template.replace("{character}", imprint.getCharacterName()); } private static String sanitize(String raw) { - if (raw == null) { - return ""; - } return raw.replace('|', ' ').replace(';', ' ').trim(); } } diff --git a/src/main/java/net/tfminecraft/magic/artifact/sacrifice/SacrificeRiteFx.java b/src/main/java/net/tfminecraft/magic/artifact/sacrifice/SacrificeRiteFx.java index 5b02c6c..592c5c8 100644 --- a/src/main/java/net/tfminecraft/magic/artifact/sacrifice/SacrificeRiteFx.java +++ b/src/main/java/net/tfminecraft/magic/artifact/sacrifice/SacrificeRiteFx.java @@ -46,7 +46,7 @@ public static void resolve(SacrificeRiteSession session, Location victimLocation return; } SacrificeFxDef fx = SacrificeRegistry.getFx(); - if (fx == null || !fx.surges(session.getResolvedTierId())) { + if (!fx.surges(session.getResolvedTierId())) { return; } Color color = ShrineChargeFx.elementColor(session.getElementId()); @@ -63,9 +63,6 @@ public static void resolve(SacrificeRiteSession session, Location victimLocation private static void burst(Location at, Particle.DustOptions dust) { World world = at.getWorld(); - if (world == null) { - return; - } world.spawnParticle(Particle.DUST, at, 36, 0.45, 0.55, 0.45, dust); world.spawnParticle(Particle.ENCHANT, at, 28, 0.5, 0.6, 0.5, 0.55); spawnOptional(world, at, "SOUL", 10, 0.35, 0.45, 0.35, 0.02); @@ -91,9 +88,6 @@ private static void spawnOptional( private static void playSounds(Location at, SacrificeFxDef fx) { World world = at.getWorld(); - if (world == null || fx == null) { - return; - } Sound surge = fx.getSurgeSound(); if (surge != null) { world.playSound(at, surge, fx.getSurgeVolume(), fx.getSurgePitch()); @@ -116,17 +110,14 @@ private static void tick() { } private static void spawnPull(SacrificeRiteSession session) { - if (session == null) { - return; - } Player victim = Bukkit.getPlayer(session.getVictimId()); Furniture furniture = session.getFurniture(); - if (victim == null || !victim.isOnline() || furniture == null) { + if (victim == null || !victim.isOnline()) { return; } Location from = victim.getLocation().add(0, 1.2, 0); Location to = SacrificeTargeting.originCenter(furniture); - if (from.getWorld() == null || to == null || from.getWorld() != to.getWorld()) { + if (to == null || from.getWorld() != to.getWorld()) { return; } World world = from.getWorld(); diff --git a/src/main/java/net/tfminecraft/magic/artifact/sacrifice/SacrificeRiteService.java b/src/main/java/net/tfminecraft/magic/artifact/sacrifice/SacrificeRiteService.java index 9bce7fb..eccf42c 100644 --- a/src/main/java/net/tfminecraft/magic/artifact/sacrifice/SacrificeRiteService.java +++ b/src/main/java/net/tfminecraft/magic/artifact/sacrifice/SacrificeRiteService.java @@ -144,16 +144,13 @@ public static void handleDeath(Player player) { } private static void resolveKill(SacrificeRiteSession session, Player victim, Player caster) { - if (session == null) { - return; - } double charge = session.charge(); SacrificeTierDef tier = SacrificeRegistry.tierForCharge(charge); RPCharacter character = RpCharactersBridge.getActiveCharacter(victim); String characterId = character != null ? character.getId() : null; String characterName = character != null ? character.getName() : null; boolean success = RpCharactersBridge.applySacrificeTier(victim, caster, tier); - session.markResolved(success, tier != null ? tier.getId() : "none", charge); + session.markResolved(success, tier.getId(), charge); if (success) { SacrificeFillService.apply(session, tier, characterId, characterName); SacrificeRiteFx.resolve(session, victim.getLocation()); @@ -162,9 +159,6 @@ private static void resolveKill(SacrificeRiteSession session, Player victim, Pla } private static Player daggerKiller(Player victim) { - if (victim == null) { - return null; - } EntityDamageEvent cause = victim.getLastDamageCause(); if (!(cause instanceof EntityDamageByEntityEvent damage)) { return null; @@ -208,9 +202,6 @@ private static void sendFail(Player caster, SacrificeTargeting.Fail fail) { } private static void cancel(SacrificeRiteSession session, boolean notifyVictim) { - if (session == null) { - return; - } byCaster.remove(session.getCasterId()); if (notifyVictim) { Player victim = Bukkit.getPlayer(session.getVictimId()); @@ -255,7 +246,7 @@ private static void tick() { private static boolean tickOne(SacrificeRiteSession session) { Furniture furniture = session.getFurniture(); - if (furniture == null || furniture.isCarried()) { + if (furniture.isCarried()) { return false; } PlacedSlot slot = furniture.getActiveSlot(session.getSlotId()).orElse(null); diff --git a/src/main/java/net/tfminecraft/magic/artifact/sacrifice/SacrificeRiteSession.java b/src/main/java/net/tfminecraft/magic/artifact/sacrifice/SacrificeRiteSession.java index 7b8f01a..e9ff052 100644 --- a/src/main/java/net/tfminecraft/magic/artifact/sacrifice/SacrificeRiteSession.java +++ b/src/main/java/net/tfminecraft/magic/artifact/sacrifice/SacrificeRiteSession.java @@ -98,9 +98,6 @@ public void markResolved(boolean success, String tierId, double charge) { public double charge() { double seconds = SacrificeRegistry.getChargeSeconds(); - if (seconds <= 0) { - return 1.0; - } double elapsed = (System.currentTimeMillis() - startMillis) / 1000.0; return Math.min(1.0, Math.max(0.0, elapsed / seconds)); } diff --git a/src/main/java/net/tfminecraft/magic/artifact/sacrifice/SacrificeTargeting.java b/src/main/java/net/tfminecraft/magic/artifact/sacrifice/SacrificeTargeting.java index 5c48779..4a678e0 100644 --- a/src/main/java/net/tfminecraft/magic/artifact/sacrifice/SacrificeTargeting.java +++ b/src/main/java/net/tfminecraft/magic/artifact/sacrifice/SacrificeTargeting.java @@ -30,7 +30,7 @@ public final class SacrificeTargeting { private static final double EPSILON = 0.0001; public enum Fail { - NONE, + NONE, NO_TARGET, NO_SCORE, MIN_SCORE, @@ -88,7 +88,7 @@ public Fail getFail() { private SacrificeTargeting() {} public static Result find(Player caster, String elementId) { - if (caster == null || elementId == null || elementId.isBlank() || caster.getWorld() == null) { + if (caster == null || elementId == null || elementId.isBlank()) { return Result.fail(Fail.NO_TARGET); } Location casterLoc = caster.getLocation(); @@ -138,9 +138,6 @@ public static Result find(Player caster, String elementId) { continue; } PlacedSlot slot = closestSlot(eligibleSlots, casterLoc, origin); - if (slot == null) { - continue; - } ready.add(new Candidate(furniture, slot.getId(), victim, score, origin.distanceSquared(casterLoc))); } @@ -153,7 +150,7 @@ public static Result find(Player caster, String elementId) { if (best != null) { return Result.ok(best.furniture, best.slotId, best.victim, best.score); } - if (sawLowScore && SacrificeRegistry.isRequireMinScore()) { + if (sawLowScore) { return Result.fail(Fail.MIN_SCORE); } if (sawNoScore) { @@ -181,7 +178,7 @@ private static SlotCheck checkSlot(PlacedSlot slot, String elementId, ShrineScor return SlotCheck.SKIP; } String filter = SacrificeRegistry.getPedestalSlot(); - if (filter != null && !filter.equals("*") && !filter.equalsIgnoreCase(slot.getId())) { + if (!filter.equals("*") && !filter.equalsIgnoreCase(slot.getId())) { return SlotCheck.SKIP; } ItemStack item = ShrineChargeService.itemFromSlot(slot); @@ -193,15 +190,13 @@ private static SlotCheck checkSlot(PlacedSlot slot, String elementId, ShrineScor if (!allowed.contains(elementId.trim().toLowerCase(Locale.ROOT))) { return SlotCheck.SKIP; } + // allowedElements already filters nonpositive caps on this immutable read snapshot. double cap = artifact.getCap(elementId); - if (cap <= 0) { - return SlotCheck.SKIP; - } if (artifact.getFill(elementId) + EPSILON >= cap) { return SlotCheck.FULL; } if (SacrificeRegistry.isRequireMinScore()) { - ShrineElementScore elementScore = score != null ? score.get(elementId) : null; + ShrineElementScore elementScore = score.get(elementId); double maxAura = elementScore != null ? elementScore.getMaxAura() : 0; if (maxAura <= EPSILON) { return SlotCheck.NO_SCORE; @@ -219,13 +214,10 @@ private static Player closestVictim(World world, Player caster, Location origin) Player best = null; double bestSq = Double.MAX_VALUE; for (Player player : world.getPlayers()) { - if (player == null || player.equals(caster) || !player.isOnline() || player.isDead()) { + if (player.equals(caster) || player.isDead()) { continue; } Location loc = player.getLocation(); - if (loc.getWorld() != world) { - continue; - } double distSq = loc.distanceSquared(origin); if (distSq > rangeSq || distSq >= bestSq) { continue; diff --git a/src/main/java/net/tfminecraft/magic/artifact/shrine/ShrineChargeFx.java b/src/main/java/net/tfminecraft/magic/artifact/shrine/ShrineChargeFx.java index 0428c72..1778973 100644 --- a/src/main/java/net/tfminecraft/magic/artifact/shrine/ShrineChargeFx.java +++ b/src/main/java/net/tfminecraft/magic/artifact/shrine/ShrineChargeFx.java @@ -75,7 +75,7 @@ public static void complete(Furniture furniture, String elementId) { } World world = furniture.getLoc().getWorld(); Location at = target(furniture); - if (world == null || at == null) { + if (at == null) { return; } ShrineFxDef fx = ShrineRegistry.fx(elementId); @@ -123,7 +123,7 @@ public static void sacrificeReady(Furniture furniture, String elementId) { private static void spawnPulls(ShrineChargeSession session) { Furniture furniture = session.getFurniture(); - if (furniture == null || furniture.getLoc() == null || furniture.getLoc().getWorld() == null) { + if (furniture.getLoc() == null || furniture.getLoc().getWorld() == null) { return; } PlacedSlot slot = furniture.getActiveSlot(session.getSlotId()).orElse(null); diff --git a/src/main/java/net/tfminecraft/magic/artifact/shrine/ShrineChargeService.java b/src/main/java/net/tfminecraft/magic/artifact/shrine/ShrineChargeService.java index d2cbe5a..34c4ba5 100644 --- a/src/main/java/net/tfminecraft/magic/artifact/shrine/ShrineChargeService.java +++ b/src/main/java/net/tfminecraft/magic/artifact/shrine/ShrineChargeService.java @@ -78,9 +78,7 @@ public static void tryStart(Player player, Furniture furniture, String slotId) { return; } charge.write(item); - if (slot != null) { - slot.setCurrentItem(item); - } + slot.setCurrentItem(item); persistFurniture(furniture); if (player != null) { player.sendMessage(Messages.get("charge.imprint.done")); @@ -100,7 +98,7 @@ public static void tryStart(Player player, Furniture furniture, String slotId) { } public static AdminStart startAdmin(Player player, String elementId, double amount) { - if (player == null || player.getWorld() == null || elementId == null || elementId.isBlank()) { + if (player == null || elementId == null || elementId.isBlank()) { return AdminStart.NO_PEDESTAL; } String id = elementId.trim().toLowerCase(Locale.ROOT); @@ -146,9 +144,7 @@ public static AdminStart startAdmin(Player player, String elementId, double amou if (artifact instanceof Charge charge && charge.getCap(id) <= 0 && charge.imprintElement(item, id)) { charge.write(item); - if (slot != null) { - slot.setCurrentItem(item); - } + slot.setCurrentItem(item); persistFurniture(furniture); } double cap = artifact.getCap(id); @@ -255,7 +251,7 @@ public static void tick(MagicTickContext context) { private static boolean tickOne(ShrineChargeSession session, MagicTickContext context) { Furniture furniture = session.getFurniture(); - if (furniture == null || furniture.isCarried()) { + if (furniture.isCarried()) { return false; } if (!session.isAdminForced() && MeditationCircle.containing(furniture)) { @@ -286,16 +282,13 @@ private static boolean tickOne(ShrineChargeSession session, MagicTickContext con continue; } double cap = artifact.getCap(elementId); - if (cap <= 0) { - continue; - } double clamp = Math.min(cap, elementScore.getMaxAura()); double fill = artifact.getFill(elementId); if (fill + EPSILON >= clamp) { continue; } stillCharging = true; - double next = fill + elementScore.getAuraPerSecond(); + double next = Math.min(clamp, fill + elementScore.getAuraPerSecond() * Cache.tickIntervalSeconds()); artifact.setFill(elementId, next); progressed = true; AuraLog.append( @@ -314,9 +307,7 @@ private static boolean tickOne(ShrineChargeSession session, MagicTickContext con return stillCharging; } artifact.write(item); - if (slot != null) { - slot.setCurrentItem(item); - } + slot.setCurrentItem(item); if (crossedClamp) { ShrineChargeFx.complete(furniture, artifact, item, session.getScore()); } @@ -350,13 +341,13 @@ private static boolean tickAdmin( return false; } double seconds = ShrineRegistry.getFullChargeSeconds(); - double aps = seconds > 0 && Cache.artifactAuraCap > 0 + double aps = Cache.artifactAuraCap > 0 ? Cache.artifactAuraCap / seconds : 0; if (aps <= 0) { return false; } - double next = Math.min(clamp, fill + aps); + double next = Math.min(clamp, fill + aps * Cache.tickIntervalSeconds()); artifact.setFill(elementId, next); AuraLog.append( "shrine-fill artifact=%s element=%s fill=%s->%s clamp=%s perSecond=%s", @@ -367,9 +358,7 @@ private static boolean tickAdmin( AuraLog.n(clamp), AuraLog.n(aps)); artifact.write(item); - if (slot != null) { - slot.setCurrentItem(item); - } + slot.setCurrentItem(item); if (next + EPSILON >= clamp) { ShrineChargeFx.complete(furniture, elementId); persist(session, true); @@ -505,15 +494,13 @@ public static Set allowedElements(ItemStack item, AuraVessel artifact) { return allowed; } String primary = primaryId(item, artifact); - if (primary == null || primary.isBlank()) { + if (primary == null) { return allowed; } Set affinity = new HashSet<>(); affinity.add(primary); for (String companion : ArtifactAffinityRegistry.companions(primary)) { - if (companion != null && !companion.isBlank()) { - affinity.add(companion.trim().toLowerCase(Locale.ROOT)); - } + affinity.add(companion.trim().toLowerCase(Locale.ROOT)); } for (String id : artifact.getCappedElementIds()) { if (id == null || id.isBlank() || artifact.getCap(id) <= 0) { @@ -554,7 +541,7 @@ public static ItemStack itemFromSlot(PlacedSlot slot) { Entity stand = standId != null ? Bukkit.getEntity(standId) : null; if (stand instanceof ItemDisplay display) { ItemStack shown = display.getItemStack(); - if (shown != null && !shown.getType().isAir()) { + if (!shown.getType().isAir()) { slot.setModel(shown); return shown; } @@ -568,7 +555,7 @@ private static String artifactId(ItemStack item) { } private static void persist(ShrineChargeSession session, boolean force) { - if (session == null || session.getFurniture() == null) { + if (session == null) { return; } persistFurniture(session.getFurniture()); diff --git a/src/main/java/net/tfminecraft/magic/artifact/shrine/ShrineConfigLoader.java b/src/main/java/net/tfminecraft/magic/artifact/shrine/ShrineConfigLoader.java index 87888c6..8ff0e9c 100644 --- a/src/main/java/net/tfminecraft/magic/artifact/shrine/ShrineConfigLoader.java +++ b/src/main/java/net/tfminecraft/magic/artifact/shrine/ShrineConfigLoader.java @@ -114,7 +114,7 @@ private static void loadFx(ConfigurationSection fx, Logger log) { ShrineFxDef defaults = readFx(fx.getConfigurationSection("default"), ShrineFxDef.fallback(), log); ShrineRegistry.setDefaultFx(defaults); for (String key : fx.getKeys(false)) { - if (key == null || key.isBlank() || "default".equalsIgnoreCase(key)) { + if (key.isBlank() || "default".equalsIgnoreCase(key)) { continue; } ConfigurationSection section = fx.getConfigurationSection(key); diff --git a/src/main/java/net/tfminecraft/magic/artifact/shrine/ShrineRegistry.java b/src/main/java/net/tfminecraft/magic/artifact/shrine/ShrineRegistry.java index 9a2996d..46c254e 100644 --- a/src/main/java/net/tfminecraft/magic/artifact/shrine/ShrineRegistry.java +++ b/src/main/java/net/tfminecraft/magic/artifact/shrine/ShrineRegistry.java @@ -58,7 +58,7 @@ public static ShrineFxDef fx(String elementId) { } public static void register(ShrineElementDef def) { - if (def == null || def.getElementId() == null || def.getElementId().isBlank()) { + if (def == null || def.getElementId().isBlank()) { return; } elements.put(def.getElementId(), def); diff --git a/src/main/java/net/tfminecraft/magic/artifact/shrine/ShrineScorer.java b/src/main/java/net/tfminecraft/magic/artifact/shrine/ShrineScorer.java index 7fc8036..663b49b 100644 --- a/src/main/java/net/tfminecraft/magic/artifact/shrine/ShrineScorer.java +++ b/src/main/java/net/tfminecraft/magic/artifact/shrine/ShrineScorer.java @@ -74,9 +74,7 @@ public static ShrineScore score(Location center) { } count += n; List at = locations.get(material); - if (at != null) { - found.addAll(at); - } + found.addAll(at); } if (count <= 0) { continue; @@ -94,7 +92,7 @@ public static ShrineScore score(Location center) { continue; } double maxAura = score * auraCap; - double aps = seconds > 0 ? score * (auraCap / seconds) : 0; + double aps = score * (auraCap / seconds); byElement.put(def.getElementId(), new ShrineElementScore( def.getElementId(), maxAura, aps, active, pickEmitters(familyLocs, center, seed))); } @@ -123,7 +121,7 @@ private static List pickEmitters(List> familyLocs, Loca } } Collections.shuffle(pool, rng); - if (picked.isEmpty() && !pool.isEmpty()) { + if (picked.isEmpty()) { Location first = pool.remove(0); picked.add(first.clone()); used.add(key(first)); diff --git a/src/main/java/net/tfminecraft/magic/attunement/ArtifactDisplayIndex.java b/src/main/java/net/tfminecraft/magic/attunement/ArtifactDisplayIndex.java index 99d3b95..077b547 100644 --- a/src/main/java/net/tfminecraft/magic/attunement/ArtifactDisplayIndex.java +++ b/src/main/java/net/tfminecraft/magic/attunement/ArtifactDisplayIndex.java @@ -41,8 +41,7 @@ public static boolean isDisplayed(UUID artifactId) { if (artifactId == null) { return false; } - Integer count = displayed.get(artifactId); - return count != null && count > 0; + return displayed.containsKey(artifactId); } public static boolean isDisplayed(String artifactId) { @@ -58,7 +57,8 @@ public static void add(UUID artifactId) { if (artifactId == null) { return; } - displayed.merge(artifactId, 1, Integer::sum); + displayed.merge(artifactId, 1, (count, added) -> + (int) Math.min(Integer.MAX_VALUE, (long) count + added)); } public static void ensure(ItemStack item) { diff --git a/src/main/java/net/tfminecraft/magic/attunement/AttunementCaptureService.java b/src/main/java/net/tfminecraft/magic/attunement/AttunementCaptureService.java index 89a5a40..8dc44c9 100644 --- a/src/main/java/net/tfminecraft/magic/attunement/AttunementCaptureService.java +++ b/src/main/java/net/tfminecraft/magic/attunement/AttunementCaptureService.java @@ -47,11 +47,11 @@ public static String displayNameOf(ItemStack item) { return ""; } ItemMeta meta = item.getItemMeta(); - if (meta == null || !meta.hasDisplayName()) { + if (!meta.hasDisplayName()) { return ""; } String raw = meta.getDisplayName(); - if (raw == null || raw.isBlank()) { + if (raw.isBlank()) { return ""; } return ChatColor.stripColor(raw); diff --git a/src/main/java/net/tfminecraft/magic/charge/Charge.java b/src/main/java/net/tfminecraft/magic/charge/Charge.java index 737346d..8a2a643 100644 --- a/src/main/java/net/tfminecraft/magic/charge/Charge.java +++ b/src/main/java/net/tfminecraft/magic/charge/Charge.java @@ -54,9 +54,6 @@ public static Charge fromItem(ItemStack stack) { private static int readTier(ItemStack stack) { ItemMeta meta = stack.getItemMeta(); - if (meta == null) { - return 0; - } Integer stored = meta.getPersistentDataContainer().get( ChargeKeys.chargeTier(), PersistentDataType.INTEGER); return stored == null ? 0 : stored; @@ -64,9 +61,6 @@ private static int readTier(ItemStack stack) { private static String readPrimary(ItemStack stack) { ItemMeta meta = stack.getItemMeta(); - if (meta == null) { - return ""; - } String stored = meta.getPersistentDataContainer().get( ArtifactKeys.artifactPrimary(), PersistentDataType.STRING); return stored == null || stored.isBlank() ? "" : stored.trim().toLowerCase(Locale.ROOT); @@ -105,7 +99,7 @@ public boolean imprint(ItemStack stack, ShrineScore score) { boolean any = false; for (Map.Entry entry : score.getByElement().entrySet()) { ShrineElementScore elementScore = entry.getValue(); - if (elementScore == null || elementScore.getMaxAura() <= 0) { + if (elementScore == null || !Double.isFinite(elementScore.getMaxAura()) || elementScore.getMaxAura() <= 0) { continue; } if (!ElementVisibility.shownOnCharge(entry.getKey())) { @@ -122,9 +116,7 @@ public boolean imprint(ItemStack stack, ShrineScore score) { return false; } stampTier(stack); - if (!best.isEmpty()) { - setPrimary(stack, best); - } + setPrimary(stack, best); return true; } @@ -209,7 +201,7 @@ public Set getCappedElementIds() { @Override public String primaryElementId() { - return primary != null && !primary.isBlank() ? primary : aura.highestCapElementId(); + return !primary.isBlank() ? primary : aura.highestCapElementId(); } @Override diff --git a/src/main/java/net/tfminecraft/magic/charge/ChargeIds.java b/src/main/java/net/tfminecraft/magic/charge/ChargeIds.java index dcd23dd..0f06b5e 100644 --- a/src/main/java/net/tfminecraft/magic/charge/ChargeIds.java +++ b/src/main/java/net/tfminecraft/magic/charge/ChargeIds.java @@ -28,9 +28,6 @@ public static boolean hasKey(ItemStack stack) { return false; } ItemMeta meta = stack.getItemMeta(); - if (meta == null) { - return false; - } Integer tier = meta.getPersistentDataContainer().get( ChargeKeys.chargeTier(), PersistentDataType.INTEGER); return tier != null && tier > 0; diff --git a/src/main/java/net/tfminecraft/magic/charge/ChargeLore.java b/src/main/java/net/tfminecraft/magic/charge/ChargeLore.java index 1e9ef9a..1fb06fb 100644 --- a/src/main/java/net/tfminecraft/magic/charge/ChargeLore.java +++ b/src/main/java/net/tfminecraft/magic/charge/ChargeLore.java @@ -40,9 +40,6 @@ public static void apply(ItemStack stack) { return; } ItemMeta meta = stack.getItemMeta(); - if (meta == null) { - return; - } List lore = meta.hasLore() ? new ArrayList<>(meta.getLore()) : new ArrayList<>(); lore = stripChargeBlock(lore); List block = new ArrayList<>(); diff --git a/src/main/java/net/tfminecraft/magic/charge/ChargeRegistry.java b/src/main/java/net/tfminecraft/magic/charge/ChargeRegistry.java index 24868a5..a872298 100644 --- a/src/main/java/net/tfminecraft/magic/charge/ChargeRegistry.java +++ b/src/main/java/net/tfminecraft/magic/charge/ChargeRegistry.java @@ -37,6 +37,7 @@ public static void register(ChargeDef def) { return; } tiers.put(def.getTier(), def); + materials = null; } public static ChargeDef getByTier(int tier) { diff --git a/src/main/java/net/tfminecraft/magic/charge/TierBands.java b/src/main/java/net/tfminecraft/magic/charge/TierBands.java index 9b002ca..ee78ea8 100644 --- a/src/main/java/net/tfminecraft/magic/charge/TierBands.java +++ b/src/main/java/net/tfminecraft/magic/charge/TierBands.java @@ -51,7 +51,7 @@ public static int bandOf(String elementId, double amount) { if (table == null) { table = bands.get(DEFAULT_KEY); } - if (table == null || table.isEmpty()) { + if (table == null) { return 0; } int best = 0; diff --git a/src/main/java/net/tfminecraft/magic/command/MagicCommand.java b/src/main/java/net/tfminecraft/magic/command/MagicCommand.java index f6c418f..15fcee6 100644 --- a/src/main/java/net/tfminecraft/magic/command/MagicCommand.java +++ b/src/main/java/net/tfminecraft/magic/command/MagicCommand.java @@ -129,15 +129,15 @@ private static boolean handleRune(CommandSender sender, String[] args) { } ItemStack held = player.getInventory().getItemInMainHand(); RuneKeybind.Outcome outcome = RuneKeybind.apply(held, trigger); - switch (outcome.result()) { - case NOT_RUNE -> sender.sendMessage(Messages.get("rune.not_a_rune")); - case NO_ABILITIES -> sender.sendMessage(Messages.get("rune.no_abilities")); - case FAILED -> sender.sendMessage(Messages.get("rune.failed")); + sender.sendMessage(switch (outcome.result()) { + case NOT_RUNE -> Messages.get("rune.not_a_rune"); + case NO_ABILITIES -> Messages.get("rune.no_abilities"); + case FAILED -> Messages.get("rune.failed"); case OK -> { player.getInventory().setItemInMainHand(outcome.item()); - sender.sendMessage(Messages.get("rune.success", "trigger", trigger.name())); + yield Messages.get("rune.success", "trigger", trigger.name()); } - } + }); return true; } @@ -222,7 +222,7 @@ private static boolean handleResonance(CommandSender sender, String[] args) { } String characterId = Magic.plugin.getResonanceGuiManager().getSessionManager().getLoadedCharacterId(target); ResonanceSession session = Magic.plugin.getResonanceGuiManager().getSessionManager().get(target); - if (characterId == null || characterId.isBlank() || session == null) { + if (characterId == null || session == null) { sender.sendMessage(Messages.get("resonance.admin.no_character", "player", target.getName())); return true; } @@ -314,19 +314,15 @@ private static boolean handleShrine(CommandSender sender, String[] args) { return true; } ShrineChargeService.AdminStart result = ShrineChargeService.startAdmin(player, elementId, amount); - switch (result) { - case NO_PEDESTAL -> sender.sendMessage(Messages.get("shrine.fill.no_pedestal")); - case NO_ARTIFACT -> sender.sendMessage(Messages.get("shrine.fill.no_artifact")); - case NO_CAP -> sender.sendMessage(Messages.get("shrine.fill.no_cap", "element", elementId)); - case ALREADY_FULL -> sender.sendMessage(Messages.get( - "shrine.fill.already_full", - "element", elementId, - "amount", MagicNumbers.format(amount))); - case STARTED -> sender.sendMessage(Messages.get( - "shrine.fill.started", - "element", elementId, - "amount", MagicNumbers.format(amount))); - } + sender.sendMessage(switch (result) { + case NO_PEDESTAL -> Messages.get("shrine.fill.no_pedestal"); + case NO_ARTIFACT -> Messages.get("shrine.fill.no_artifact"); + case NO_CAP -> Messages.get("shrine.fill.no_cap", "element", elementId); + case ALREADY_FULL -> Messages.get("shrine.fill.already_full", + "element", elementId, "amount", MagicNumbers.format(amount)); + case STARTED -> Messages.get("shrine.fill.started", + "element", elementId, "amount", MagicNumbers.format(amount)); + }); return true; } @@ -370,7 +366,8 @@ private static void persistResonance(Player target, ResonanceSession session) { private static Double parseAmount(String raw) { try { - return Double.parseDouble(raw.trim()); + double value = Double.parseDouble(raw.trim()); + return Double.isFinite(value) ? value : null; } catch (NumberFormatException ex) { return null; } @@ -421,7 +418,8 @@ private static boolean handleArtifactSetFill(CommandSender sender, String[] args sender.sendMessage(Messages.get("artifact.setfill.no_item")); return true; } - if (ArtifactIds.read(item) == null) { + UUID id = ArtifactIds.read(item); + if (id == null) { sender.sendMessage(Messages.get("artifact.setfill.no_id")); return true; } @@ -435,10 +433,8 @@ private static boolean handleArtifactSetFill(CommandSender sender, String[] args sender.sendMessage(Messages.get("artifact.setfill.no_cap", "element", elementId)); return true; } - double amount; - try { - amount = Double.parseDouble(args[3]); - } catch (NumberFormatException ex) { + Double amount = parseAmount(args[3]); + if (amount == null) { sender.sendMessage(Messages.get("artifact.setfill.invalid_amount")); return true; } @@ -446,11 +442,10 @@ private static boolean handleArtifactSetFill(CommandSender sender, String[] args artifact.setFill(elementId, amount); artifact.write(item); player.getInventory().setItemInMainHand(item); - UUID id = ArtifactIds.read(item); AuraLog.append( "setfill player=%s artifact=%s element=%s fill=%s->%s requested=%s", player.getName(), - id != null ? id.toString() : "-", + id.toString(), elementId, AuraLog.n(fillBefore), AuraLog.n(artifact.getFill(elementId)), @@ -464,7 +459,7 @@ private static boolean handleArtifactSetFill(CommandSender sender, String[] args private static ItemStack heldArtifactItem(Player player) { ItemStack item = player.getInventory().getItemInMainHand(); - if (item == null || item.getType().isAir()) { + if (item.getType().isAir()) { return null; } return item; @@ -566,13 +561,8 @@ private static void describeRoll(CommandSender sender, ArtifactRoll roll) { private static void giveOrDrop(Player player, ItemStack stack) { Map leftover = player.getInventory().addItem(stack); - if (leftover.isEmpty() || player.getWorld() == null) { - return; - } for (ItemStack extra : leftover.values()) { - if (extra != null && !extra.getType().isAir()) { - player.getWorld().dropItemNaturally(player.getLocation(), extra); - } + player.getWorld().dropItemNaturally(player.getLocation(), extra); } } diff --git a/src/main/java/net/tfminecraft/magic/gear/GearBrokenMarker.java b/src/main/java/net/tfminecraft/magic/gear/GearBrokenMarker.java index 6a3c055..ae62e97 100644 --- a/src/main/java/net/tfminecraft/magic/gear/GearBrokenMarker.java +++ b/src/main/java/net/tfminecraft/magic/gear/GearBrokenMarker.java @@ -37,9 +37,6 @@ public static boolean isBroken(ItemStack stack) { return false; } ItemMeta meta = stack.getItemMeta(); - if (meta == null) { - return false; - } Byte broken = meta.getPersistentDataContainer().get(GearKeys.broken(), PersistentDataType.BYTE); return broken != null && broken != 0; } @@ -50,9 +47,6 @@ public static void mark(ItemStack stack) { return; } ItemMeta meta = stack.getItemMeta(); - if (meta == null) { - return; - } meta.getPersistentDataContainer().set(GearKeys.broken(), PersistentDataType.BYTE, (byte) 1); stack.setItemMeta(meta); WeaponLore.apply(stack); @@ -63,9 +57,6 @@ public static void clear(ItemStack stack) { return; } ItemMeta meta = stack.getItemMeta(); - if (meta == null) { - return; - } meta.getPersistentDataContainer().remove(GearKeys.broken()); meta.getPersistentDataContainer().remove(GearKeys.orphans()); stack.setItemMeta(meta); @@ -77,9 +68,6 @@ public static List orphans(ItemStack stack) { return orphans; } ItemMeta meta = stack.getItemMeta(); - if (meta == null) { - return orphans; - } String raw = meta.getPersistentDataContainer().get(GearKeys.orphans(), PersistentDataType.STRING); if (raw == null || raw.isBlank()) { return orphans; @@ -100,9 +88,6 @@ public static void addOrphans(ItemStack stack, List added) { return; } ItemMeta meta = stack.getItemMeta(); - if (meta == null) { - return; - } List all = new ArrayList<>(orphans(stack)); all.addAll(added); List tokens = new ArrayList<>(); diff --git a/src/main/java/net/tfminecraft/magic/gear/GearCosts.java b/src/main/java/net/tfminecraft/magic/gear/GearCosts.java index c6b85fb..33d826c 100644 --- a/src/main/java/net/tfminecraft/magic/gear/GearCosts.java +++ b/src/main/java/net/tfminecraft/magic/gear/GearCosts.java @@ -25,7 +25,8 @@ public static Map total(Collection parts) { continue; } for (Map.Entry entry : part.getCost().entrySet()) { - costs.merge(entry.getKey(), entry.getValue(), Integer::sum); + costs.merge(entry.getKey(), entry.getValue(), + (a, b) -> (int) Math.min(Integer.MAX_VALUE, (long) a + b)); } } return costs; @@ -105,7 +106,7 @@ public static void refund(Player player, Map costs, Location dr stack.setAmount(give); remaining -= give; for (ItemStack leftover : player.getInventory().addItem(stack).values()) { - if (leftover != null && !leftover.getType().isAir() && at.getWorld() != null) { + if (at.getWorld() != null) { at.getWorld().dropItem(at, leftover); } } diff --git a/src/main/java/net/tfminecraft/magic/gear/GearHand.java b/src/main/java/net/tfminecraft/magic/gear/GearHand.java index 06c71f4..171f2d5 100644 --- a/src/main/java/net/tfminecraft/magic/gear/GearHand.java +++ b/src/main/java/net/tfminecraft/magic/gear/GearHand.java @@ -20,7 +20,7 @@ private GearHand() {} /** @return the held mage weapon, or null when the player is holding something else */ public static ItemStack held(Player player) { HeldSlot slot = heldSlot(player); - if (slot == null || player == null) { + if (slot == null) { return null; } return slot == HeldSlot.MAIN_HAND diff --git a/src/main/java/net/tfminecraft/magic/gear/GearItemBuilder.java b/src/main/java/net/tfminecraft/magic/gear/GearItemBuilder.java index 0358862..3358033 100644 --- a/src/main/java/net/tfminecraft/magic/gear/GearItemBuilder.java +++ b/src/main/java/net/tfminecraft/magic/gear/GearItemBuilder.java @@ -46,9 +46,6 @@ public static ItemStack rewriteSockets(ItemStack stack, int band) { List parts = GearProvenance.resolveParts(stack); List colours = SocketLayout.colours(archetype, parts, band); ItemStack rewritten = applyMmoData(stack, colours, parts); - if (rewritten == null) { - return stack; - } rewritten = GearModelResolver.apply(rewritten, type, parts); copyGearPdc(stack, rewritten); GearProvenance.lockSockets(rewritten); @@ -79,9 +76,6 @@ private static ItemStack build(GearType type, Collection parts, boolean } List colours = SocketLayout.colours(archetype, parts, band); ItemStack withMmo = applyMmoData(base, colours, parts); - if (withMmo == null) { - withMmo = base; - } withMmo = GearModelResolver.apply(withMmo, type, parts); GearProvenance.stamp(withMmo, type, parts); return WeaponLore.updateItem(withMmo); @@ -179,9 +173,6 @@ private static void copyGearPdc(ItemStack from, ItemStack to) { private static ItemStack decoratePreview( ItemStack stack, GearType type, Collection parts) { ItemMeta meta = stack.getItemMeta(); - if (meta == null) { - return stack; - } List lore = meta.hasLore() ? new ArrayList<>(meta.getLore()) : new ArrayList<>(); lore.add(""); int majority = MajorityTierResolver.resolve(parts); @@ -197,7 +188,7 @@ private static ItemStack decoratePreview( lore.add("§cMMOItems is not loaded"); } meta.setLore(lore); - if (meta.getDisplayName() == null || meta.getDisplayName().isBlank()) { + if (meta.getDisplayName().isBlank()) { meta.setDisplayName("§6" + type.getDisplayName()); } stack.setItemMeta(meta); @@ -209,10 +200,8 @@ private static ItemStack decoratePreview( private static ItemStack barrier(String reason) { ItemStack item = new ItemStack(Material.BARRIER); ItemMeta meta = item.getItemMeta(); - if (meta != null) { - meta.setDisplayName("§c" + reason); - item.setItemMeta(meta); - } + meta.setDisplayName("§c" + reason); + item.setItemMeta(meta); return item; } } diff --git a/src/main/java/net/tfminecraft/magic/gear/GearModelResolver.java b/src/main/java/net/tfminecraft/magic/gear/GearModelResolver.java index 93873f0..1424833 100644 --- a/src/main/java/net/tfminecraft/magic/gear/GearModelResolver.java +++ b/src/main/java/net/tfminecraft/magic/gear/GearModelResolver.java @@ -30,7 +30,7 @@ public static GearModelScheme winner(Collection parts) { if (parts == null) { return null; } - Map votes = new LinkedHashMap<>(); + Map votes = new LinkedHashMap<>(); String coreScheme = ""; for (PartDef part : parts) { if (part == null || !part.hasModelScheme()) { @@ -40,7 +40,7 @@ public static GearModelScheme winner(Collection parts) { if (!GearModelSchemeRegistry.contains(id)) { continue; } - votes.merge(id, part.getSchemeWeight(), Integer::sum); + votes.merge(id, (long) part.getSchemeWeight(), Long::sum); if (PartSlots.CORE.equalsIgnoreCase(part.getPartType())) { coreScheme = id; } @@ -48,11 +48,11 @@ public static GearModelScheme winner(Collection parts) { if (votes.isEmpty()) { return null; } - int best = -1; + long best = -1; String bestId = null; boolean tie = false; - for (Map.Entry entry : votes.entrySet()) { - int weight = entry.getValue(); + for (Map.Entry entry : votes.entrySet()) { + long weight = entry.getValue(); if (weight > best) { best = weight; bestId = entry.getKey(); @@ -62,7 +62,7 @@ public static GearModelScheme winner(Collection parts) { } } // bestId is already the first scheme seen among the tied leaders; the core wins a tie it is in. - if (tie && !coreScheme.isEmpty() && votes.getOrDefault(coreScheme, 0) == best) { + if (tie && !coreScheme.isEmpty() && votes.getOrDefault(coreScheme, 0L) == best) { bestId = coreScheme; } return GearModelSchemeRegistry.get(bestId); @@ -73,14 +73,16 @@ public static ItemStack apply(ItemStack stack, GearType type, Collection= 3 ? Integer.parseInt(parts[2]) : null; stack.setType(material); - if (parts.length >= 3) { + if (model != null) { ItemMeta meta = stack.getItemMeta(); - if (meta != null) { - LegacyModelData.set(meta, Integer.parseInt(parts[2])); - stack.setItemMeta(meta); - } + LegacyModelData.set(meta, model); + stack.setItemMeta(meta); } return stack; } diff --git a/src/main/java/net/tfminecraft/magic/gear/GearModelScheme.java b/src/main/java/net/tfminecraft/magic/gear/GearModelScheme.java index 9f2c6c9..ff18474 100644 --- a/src/main/java/net/tfminecraft/magic/gear/GearModelScheme.java +++ b/src/main/java/net/tfminecraft/magic/gear/GearModelScheme.java @@ -32,7 +32,6 @@ public String pathFor(GearType type) { if (type == null) { return null; } - String path = paths.get(type); - return path == null || path.isBlank() ? null : path; + return paths.get(type); } } diff --git a/src/main/java/net/tfminecraft/magic/gear/GearProvenance.java b/src/main/java/net/tfminecraft/magic/gear/GearProvenance.java index 96be631..7bfdfdf 100644 --- a/src/main/java/net/tfminecraft/magic/gear/GearProvenance.java +++ b/src/main/java/net/tfminecraft/magic/gear/GearProvenance.java @@ -18,9 +18,6 @@ public static void stamp(ItemStack stack, GearType type, Collection par return; } ItemMeta meta = stack.getItemMeta(); - if (meta == null) { - return; - } List ids = new ArrayList<>(); if (parts != null) { for (PartDef part : parts) { @@ -46,9 +43,6 @@ public static int majorityOf(ItemStack stack) { return 0; } ItemMeta meta = stack.getItemMeta(); - if (meta == null) { - return 0; - } Integer stored = meta.getPersistentDataContainer().get( GearKeys.majorityTier(), PersistentDataType.INTEGER); return stored == null ? 0 : stored; @@ -59,9 +53,6 @@ public static void applyMajority(ItemStack stack, Collection parts) { return; } ItemMeta meta = stack.getItemMeta(); - if (meta == null) { - return; - } writeMajority(meta, parts); stack.setItemMeta(meta); } @@ -81,7 +72,7 @@ public static List missingPartIds(ItemStack stack) { List missing = new ArrayList<>(); for (String token : rawTokens(stack)) { String id = idOf(token); - if (!id.isEmpty() && PartRegistry.get(id) == null) { + if (PartRegistry.get(id) == null) { missing.add(id); } } @@ -114,15 +105,9 @@ public static void syncRevisions(ItemStack stack) { return; } ItemMeta meta = stack.getItemMeta(); - if (meta == null) { - return; - } List tokens = new ArrayList<>(); for (String token : rawTokens(stack)) { String id = idOf(token); - if (id.isEmpty()) { - continue; - } PartDef live = PartRegistry.get(id); tokens.add(id + ":" + (live == null ? revisionOf(token) : live.getRevision())); } @@ -137,13 +122,7 @@ public static void syncRevisions(ItemStack stack) { } private static int storedArchetypeRevision(ItemStack stack) { - if (stack == null || !stack.hasItemMeta()) { - return 0; - } ItemMeta meta = stack.getItemMeta(); - if (meta == null) { - return 0; - } Integer stored = meta.getPersistentDataContainer().get( GearKeys.archetypeRevision(), PersistentDataType.INTEGER); return stored == null ? 0 : stored; @@ -186,9 +165,6 @@ public static String partsRaw(ItemStack stack) { return ""; } ItemMeta meta = stack.getItemMeta(); - if (meta == null) { - return ""; - } String raw = meta.getPersistentDataContainer().get(GearKeys.parts(), PersistentDataType.STRING); return raw == null ? "" : raw; } @@ -199,9 +175,6 @@ public static List resolveParts(ItemStack stack) { return parts; } ItemMeta meta = stack.getItemMeta(); - if (meta == null) { - return parts; - } String raw = meta.getPersistentDataContainer().get(GearKeys.parts(), PersistentDataType.STRING); if (raw == null || raw.isBlank()) { return parts; @@ -225,9 +198,6 @@ public static GearType archetypeOf(ItemStack stack) { return null; } ItemMeta meta = stack.getItemMeta(); - if (meta == null) { - return null; - } String stored = meta.getPersistentDataContainer().get( GearKeys.archetype(), PersistentDataType.STRING); return GearType.fromId(stored); @@ -242,9 +212,6 @@ public static boolean socketsLocked(ItemStack stack) { return false; } ItemMeta meta = stack.getItemMeta(); - if (meta == null) { - return false; - } Byte locked = meta.getPersistentDataContainer().get( GearKeys.socketsLocked(), PersistentDataType.BYTE); return locked != null && locked != 0; @@ -255,9 +222,6 @@ public static void lockSockets(ItemStack stack) { return; } ItemMeta meta = stack.getItemMeta(); - if (meta == null) { - return; - } meta.getPersistentDataContainer().set( GearKeys.socketsLocked(), PersistentDataType.BYTE, (byte) 1); stack.setItemMeta(meta); diff --git a/src/main/java/net/tfminecraft/magic/gear/GearRefresher.java b/src/main/java/net/tfminecraft/magic/gear/GearRefresher.java index 8b14938..333634c 100644 --- a/src/main/java/net/tfminecraft/magic/gear/GearRefresher.java +++ b/src/main/java/net/tfminecraft/magic/gear/GearRefresher.java @@ -114,7 +114,7 @@ private static ItemStack rewrite(ItemStack stack, List colours, List(colours)); for (GemstoneData gem : existing) { String colour = gem.getSocketColor(); - if (colour != null && next.canReceive(colour) && next.apply(colour, gem)) { + if (colour != null && next.apply(colour, gem)) { continue; } orphaned.add(gem); @@ -139,9 +139,6 @@ private static void copyGearPdc(ItemStack from, ItemStack to) { } ItemMeta fromMeta = from.getItemMeta(); ItemMeta toMeta = to.getItemMeta(); - if (fromMeta == null || toMeta == null) { - return; - } copyString(fromMeta, toMeta, GearKeys.parts()); copyString(fromMeta, toMeta, GearKeys.archetype()); copyString(fromMeta, toMeta, GearKeys.orphans()); diff --git a/src/main/java/net/tfminecraft/magic/gear/GearStationListener.java b/src/main/java/net/tfminecraft/magic/gear/GearStationListener.java index 25d25e0..67f2018 100644 --- a/src/main/java/net/tfminecraft/magic/gear/GearStationListener.java +++ b/src/main/java/net/tfminecraft/magic/gear/GearStationListener.java @@ -82,7 +82,7 @@ public void onFurnitureBroken(FurnitureBreakEvent event) { } GearOrbService.abort(station); ItemStack weapon = GearStationStore.takeForAbort(station); - if (weapon != null && station.getWorld() != null) { + if (weapon != null) { station.getWorld().dropItem(station.clone().add(0.5, 1.0, 0.5), weapon); Magic.plugin.getLogger().warning("[Magic] Gear station at " + GearStationStore.key(station) + " lost its furniture. Dropped the weapon and removed its display."); @@ -144,7 +144,7 @@ public void onInteract(PlayerInteractEvent event) { Player player = event.getPlayer(); Location location = block.getLocation(); ItemStack hand = player.getInventory().getItemInMainHand(); - boolean emptyHand = hand == null || hand.getType().isAir() || hand.getType() == Material.AIR; + boolean emptyHand = hand.getType().isAir(); if (GearStationStore.isOccupied(location)) { if (emptyHand) { @@ -204,9 +204,7 @@ private static void reclaim(Player player, Location location, ItemStack weapon) Location drop = location.clone().add(0.5, 1.0, 0.5); for (ItemStack rune : runes) { for (ItemStack leftover : player.getInventory().addItem(rune).values()) { - if (leftover != null && !leftover.getType().isAir() && drop.getWorld() != null) { - drop.getWorld().dropItem(drop, leftover); - } + drop.getWorld().dropItem(drop, leftover); } } player.updateInventory(); diff --git a/src/main/java/net/tfminecraft/magic/gear/GearStationStore.java b/src/main/java/net/tfminecraft/magic/gear/GearStationStore.java index 541f24d..c63d7ee 100644 --- a/src/main/java/net/tfminecraft/magic/gear/GearStationStore.java +++ b/src/main/java/net/tfminecraft/magic/gear/GearStationStore.java @@ -2,6 +2,7 @@ import java.io.File; import java.io.IOException; +import java.nio.file.Files; import java.util.ArrayList; import java.util.HashMap; import java.util.LinkedHashMap; @@ -16,6 +17,7 @@ import org.bukkit.World; import org.bukkit.block.Block; import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.InvalidConfigurationException; import org.bukkit.configuration.file.YamlConfiguration; import org.bukkit.entity.Entity; import org.bukkit.entity.ItemDisplay; @@ -34,6 +36,8 @@ public final class GearStationStore { private static final Map OCCUPIED = new HashMap<>(); private static boolean loading; + private static final Map retainedRows = new LinkedHashMap<>(); + private static boolean unreadableStore; private GearStationStore() {} @@ -156,8 +160,8 @@ public static void shutdown() { Location location = locationFromKey(entry.getKey()); Occupancy occupancy = entry.getValue(); if (location != null) { - removeDisplays(location, null, occupancy == null ? null : occupancy.displayId); - } else if (occupancy != null && occupancy.displayId != null) { + removeDisplays(location, null, occupancy.displayId); + } else if (occupancy.displayId != null) { Entity entity = Bukkit.getEntity(occupancy.displayId); if (entity != null) { entity.remove(); @@ -176,11 +180,20 @@ public static void load() { loading = true; try { OCCUPIED.clear(); + retainedRows.clear(); + unreadableStore = false; File file = file(); if (!file.exists()) { return; } - YamlConfiguration config = YamlConfiguration.loadConfiguration(file); + YamlConfiguration config = new YamlConfiguration(); + try { + config.load(file); + } catch (IOException | InvalidConfigurationException ex) { + unreadableStore = true; + Magic.plugin.getLogger().warning("[Magic] Retaining unreadable gear stations: " + ex.getMessage()); + return; + } ConfigurationSection root = config.getConfigurationSection("stations"); if (root == null) { return; @@ -189,11 +202,13 @@ public static void load() { for (String key : root.getKeys(false)) { ConfigurationSection section = root.getConfigurationSection(key); if (section == null) { + retainedRows.put(key, root.get(key)); continue; } Location location = locationOf(section); ItemStack item = section.getItemStack("item"); if (location == null || item == null) { + retainedRows.put(key, section.getValues(true)); continue; } UUID savedDisplay = uuidOf(section.getString("display")); @@ -242,6 +257,7 @@ public static void reconcile(Chunk chunk) { for (String stationKey : keys) { Location location = locationFromKey(stationKey); Occupancy occupancy = OCCUPIED.get(stationKey); + // Spawning a preceding display can invoke plugin listeners which remove stations/worlds. if (location == null || occupancy == null) { continue; } @@ -250,7 +266,7 @@ public static void reconcile(Chunk chunk) { continue; } UUID displayId = ensureDisplay(location, occupancy.getItem(), occupancy.displayId); - if (displayId != null && !displayId.equals(occupancy.displayId)) { + if (!displayId.equals(occupancy.displayId)) { occupancy.displayId = displayId; changed = true; } @@ -296,7 +312,18 @@ public static double distanceSquaredToCenter(Location station, Location at) { } public static void save() { + if (unreadableStore) { + File file = file(); + try { + Files.move(file.toPath(), file.toPath().resolveSibling(file.getName() + ".rejected-" + UUID.randomUUID())); + unreadableStore = false; + } catch (IOException ex) { + Magic.plugin.getLogger().warning("[Magic] Cannot preserve unreadable gear stations; refusing overwrite: " + ex.getMessage()); + return; + } + } YamlConfiguration config = new YamlConfiguration(); + retainedRows.forEach((key, value) -> config.set("stations." + key, value)); int index = 0; for (Map.Entry entry : OCCUPIED.entrySet()) { Occupancy occupancy = entry.getValue(); @@ -304,15 +331,16 @@ public static void save() { if (location == null || occupancy.getItem() == null) { continue; } + while (retainedRows.containsKey(Integer.toString(index))) { + index++; + } String path = "stations." + index++; - config.set(path + ".world", location.getWorld() == null ? "" : location.getWorld().getName()); + config.set(path + ".world", location.getWorld().getName()); config.set(path + ".x", location.getBlockX()); config.set(path + ".y", location.getBlockY()); config.set(path + ".z", location.getBlockZ()); config.set(path + ".item", occupancy.getItem()); - if (occupancy.displayId != null) { - config.set(path + ".display", occupancy.displayId.toString()); - } + config.set(path + ".display", occupancy.displayId.toString()); if (occupancy.getOwner() != null) { config.set(path + ".owner", occupancy.getOwner().toString()); } @@ -442,13 +470,10 @@ private static void removeDisplays(Location location, UUID keep, UUID savedDispl private static boolean isGearDisplay(ItemDisplay display) { ItemStack stack = display.getItemStack(); - if (stack == null || !stack.hasItemMeta()) { + if (!stack.hasItemMeta()) { return false; } ItemMeta meta = stack.getItemMeta(); - if (meta == null) { - return false; - } return meta.getPersistentDataContainer().has(GearKeys.archetype(), PersistentDataType.STRING) || meta.getPersistentDataContainer().has(GearKeys.parts(), PersistentDataType.STRING); } diff --git a/src/main/java/net/tfminecraft/magic/gear/PartSlots.java b/src/main/java/net/tfminecraft/magic/gear/PartSlots.java index f58071d..aef03e7 100644 --- a/src/main/java/net/tfminecraft/magic/gear/PartSlots.java +++ b/src/main/java/net/tfminecraft/magic/gear/PartSlots.java @@ -22,7 +22,7 @@ public static List open(ArchetypeDef archetype, PartDef core) { } List required = new ArrayList<>(); for (String id : archetype.getRequired()) { - if (id == null || id.isBlank()) { + if (id.isBlank()) { continue; } String key = id.trim().toLowerCase(Locale.ROOT); @@ -38,11 +38,8 @@ public static List open(ArchetypeDef archetype, PartDef core) { open.add(CORE); } for (String id : core.getPartLimit()) { - if (id == null || id.isBlank()) { - continue; - } - String key = id.trim().toLowerCase(Locale.ROOT); - if (CORE.equals(key) || open.contains(key) || !required.contains(key)) { + String key = id; + if (CORE.equals(key) || !required.contains(key)) { continue; } open.add(key); diff --git a/src/main/java/net/tfminecraft/magic/gear/SocketLayout.java b/src/main/java/net/tfminecraft/magic/gear/SocketLayout.java index 1d4e8ca..fd8f4fe 100644 --- a/src/main/java/net/tfminecraft/magic/gear/SocketLayout.java +++ b/src/main/java/net/tfminecraft/magic/gear/SocketLayout.java @@ -41,7 +41,7 @@ public static String label(String slotId) { } String key = slotId.trim().toLowerCase(Locale.ROOT); String mapped = LABELS.get(key); - if (mapped != null && !mapped.isBlank()) { + if (mapped != null) { return mapped; } return prettyId(key); @@ -82,12 +82,12 @@ public static List colours(ArchetypeDef archetype, Collection p if (parts != null) { for (PartDef part : parts) { if (part != null) { - count += part.socketCount(slotId); + count = (int) Math.min(Integer.MAX_VALUE, (long) count + part.socketCount(slotId)); } } } String suffix = archetype.slotSuffix(slotId); - if (suffix.isEmpty() || count <= 0) { + if (count <= 0) { continue; } String colour = SocketColourRegistry.colour(band, suffix); @@ -109,7 +109,7 @@ public static int rawTotal(ArchetypeDef archetype, Collection parts) { for (String slotId : archetype.slotIds()) { for (PartDef part : parts) { if (part != null) { - total += part.socketCount(slotId); + total = (int) Math.min(Integer.MAX_VALUE, (long) total + part.socketCount(slotId)); } } } @@ -129,12 +129,12 @@ public static List previewLines(ArchetypeDef archetype, Collection lore = meta.hasLore() ? new ArrayList<>(meta.getLore()) : new ArrayList<>(); lore = stripBlock(lore); List block = new ArrayList<>(); diff --git a/src/main/java/net/tfminecraft/magic/gear/WeaponRift.java b/src/main/java/net/tfminecraft/magic/gear/WeaponRift.java index 1234428..5030834 100644 --- a/src/main/java/net/tfminecraft/magic/gear/WeaponRift.java +++ b/src/main/java/net/tfminecraft/magic/gear/WeaponRift.java @@ -19,9 +19,6 @@ public static int get(ItemStack stack) { return 0; } ItemMeta meta = stack.getItemMeta(); - if (meta == null) { - return 0; - } Integer stored = meta.getPersistentDataContainer().get(GearKeys.rift(), PersistentDataType.INTEGER); return stored == null ? 0 : clamp(stored); } @@ -31,9 +28,6 @@ public static void set(ItemStack stack, int value) { return; } ItemMeta meta = stack.getItemMeta(); - if (meta == null) { - return; - } int clamped = clamp(value); if (clamped <= 0) { meta.getPersistentDataContainer().remove(GearKeys.rift()); diff --git a/src/main/java/net/tfminecraft/magic/gear/gui/GearInventoryManager.java b/src/main/java/net/tfminecraft/magic/gear/gui/GearInventoryManager.java index ed0bc64..7d73ab6 100644 --- a/src/main/java/net/tfminecraft/magic/gear/gui/GearInventoryManager.java +++ b/src/main/java/net/tfminecraft/magic/gear/gui/GearInventoryManager.java @@ -80,24 +80,22 @@ public void openTypeSelection(Player player) { ArchetypeDef def = ArchetypeRegistry.get(type); ItemStack item = archetypeIcon(type, def); ItemMeta meta = item.getItemMeta(); - if (meta != null) { - meta.setDisplayName("§e" + type.getDisplayName()); - List lore = new ArrayList<>(); - lore.add("§7Click to choose"); - if (def != null && def.isMelee()) { - lore.add("§8Melee weapon"); - } - meta.setLore(lore); - item.setItemMeta(meta); + meta.setDisplayName("§e" + type.getDisplayName()); + List lore = new ArrayList<>(); + lore.add("§7Click to choose"); + if (def != null && def.isMelee()) { + lore.add("§8Melee weapon"); } + meta.setLore(lore); + item.setItemMeta(meta); + + inv.setItem(slot++, item); } ItemStack back = new ItemStack(Material.ARROW); ItemMeta meta = back.getItemMeta(); - if (meta != null) { - meta.setDisplayName("§eBack"); - back.setItemMeta(meta); - } + meta.setDisplayName("§eBack"); + back.setItemMeta(meta); inv.setItem(8, back); player.openInventory(inv); } @@ -125,10 +123,8 @@ public void openPartSelection(Player player, String categoryId) { } ItemStack back = new ItemStack(Material.ARROW); ItemMeta meta = back.getItemMeta(); - if (meta != null) { - meta.setDisplayName("§eBack"); - back.setItemMeta(meta); - } + meta.setDisplayName("§eBack"); + back.setItemMeta(meta); inv.setItem(size - 1, back); player.openInventory(inv); } @@ -182,6 +178,7 @@ public void onClick(InventoryClickEvent event) { } if (event.getInventory().getHolder() instanceof AssemblyHolder) { event.setCancelled(true); + if (event.getClickedInventory() != event.getInventory()) return; ItemStack current = event.getCurrentItem(); if (current == null || current.getType() == Material.GRAY_STAINED_GLASS_PANE) { return; @@ -212,6 +209,7 @@ public void onClick(InventoryClickEvent event) { } if (event.getInventory().getHolder() instanceof TypeSelectionHolder) { event.setCancelled(true); + if (event.getClickedInventory() != event.getInventory()) return; ItemStack clicked = event.getCurrentItem(); if (clicked == null || !clicked.hasItemMeta()) { return; @@ -222,7 +220,7 @@ public void onClick(InventoryClickEvent event) { clickSound(player); return; } - String clean = name == null ? "" : name.replace("§e", "").trim(); + String clean = name.replace("§e", "").trim(); for (GearType type : GearType.values()) { if (type.getDisplayName().equalsIgnoreCase(clean)) { TypeSelectionManager.set(player, type); @@ -236,6 +234,7 @@ public void onClick(InventoryClickEvent event) { } if (event.getInventory().getHolder() instanceof PartSelectionHolder holder) { event.setCancelled(true); + if (event.getClickedInventory() != event.getInventory()) return; ItemStack clicked = event.getCurrentItem(); if (clicked == null || !clicked.hasItemMeta()) { return; @@ -261,7 +260,7 @@ public void onClick(InventoryClickEvent event) { private void tryPrepare(Player player) { org.bukkit.Location station = OpenStationManager.get(player); - if (station == null) { + if (station == null || !isStationPresent(station)) { player.sendMessage(Messages.get("gear.station.gone")); return; } @@ -301,13 +300,21 @@ private void tryPrepare(Player player) { player.playSound(player.getLocation(), Sound.BLOCK_ANVIL_USE, 1f, 1.2f); } + private static boolean isStationPresent(org.bukkit.Location station) { + try { + return net.tfminecraft.tlibs.TLibs.getBlockAPI().getChecker().checkBlock(station.getBlock(), GearCache.station); + } catch (RuntimeException ex) { + return false; + } + } + private static void clickSound(Player player) { player.playSound(player.getLocation(), Sound.BLOCK_NOTE_BLOCK_BIT, 1f, 1f); } private static String partTypeName(String categoryId) { PartTypeDef def = PartTypeRegistry.get(categoryId); - if (def != null && def.getName() != null && !def.getName().isBlank()) { + if (def != null) { return def.getName(); } return SocketLayout.prettyId(categoryId); @@ -318,10 +325,8 @@ private static String partTypeName(String categoryId) { private static ItemStack pane() { ItemStack item = new ItemStack(Material.GRAY_STAINED_GLASS_PANE); ItemMeta meta = item.getItemMeta(); - if (meta != null) { - meta.setDisplayName("§o"); - item.setItemMeta(meta); - } + meta.setDisplayName("§o"); + item.setItemMeta(meta); return item; } @@ -330,10 +335,8 @@ private static ItemStack pane() { private static ItemStack barrier(String name) { ItemStack item = new ItemStack(Material.BARRIER); ItemMeta meta = item.getItemMeta(); - if (meta != null) { - meta.setDisplayName("§c" + name); - item.setItemMeta(meta); - } + meta.setDisplayName("§c" + name); + item.setItemMeta(meta); return item; } @@ -349,11 +352,11 @@ private static ItemStack archetypeIcon(GearType type, ArchetypeDef def) { private static ItemStack typeButton(GearType type) { ItemStack item = archetypeIcon(type, ArchetypeRegistry.get(type)); ItemMeta meta = item.getItemMeta(); - if (meta != null) { - meta.setDisplayName("§6Archetype: §e" + type.getDisplayName()); - meta.setLore(List.of("§7Click to change")); - item.setItemMeta(meta); - } + meta.setDisplayName("§6Archetype: §e" + type.getDisplayName()); + meta.setLore(List.of("§7Click to change")); + item.setItemMeta(meta); + + return item; } @@ -362,9 +365,6 @@ private static ItemStack typeButton(GearType type) { private static ItemStack partIcon(PartDef part, boolean picker) { ItemStack item = ItemRef.buildOrFallback(part.getItemPath(), Material.PAPER); ItemMeta meta = item.getItemMeta(); - if (meta == null) { - return item; - } meta.setDisplayName(part.getName()); List lore = new ArrayList<>(part.getLore()); if (!part.getSockets().isEmpty()) { diff --git a/src/main/java/net/tfminecraft/magic/gear/orb/GearOrbService.java b/src/main/java/net/tfminecraft/magic/gear/orb/GearOrbService.java index e5ae289..51699c2 100644 --- a/src/main/java/net/tfminecraft/magic/gear/orb/GearOrbService.java +++ b/src/main/java/net/tfminecraft/magic/gear/orb/GearOrbService.java @@ -106,18 +106,19 @@ public static boolean begin( Map snapshot, String primaryElement, int chargeTier) { + World world = station == null ? null : station.getWorld(); + if (world == null) { + return false; + } String key = GearStationStore.key(station); - if (key.isEmpty() || SESSIONS.containsKey(key)) { + if (SESSIONS.containsKey(key)) { return false; } GearOrbSession session = new GearOrbSession( station, player.getUniqueId(), snapshot, primaryElement, OrbCache.tier(chargeTier)); SESSIONS.put(key, session); player.sendMessage(Messages.get("gear.orbs.start", "target", String.valueOf(session.goodTarget()))); - World world = station.getWorld(); - if (world != null) { - world.playSound(session.displayPoint(), Sound.BLOCK_BEACON_ACTIVATE, 1.0f, 1.4f); - } + world.playSound(session.displayPoint(), Sound.BLOCK_BEACON_ACTIVATE, 1.0f, 1.4f); return true; } @@ -177,7 +178,7 @@ private static void complete(GearOrbSession session, boolean announce) { weapon = WeaponLore.updateItem(weapon); } GearStationStore.update(session.getStation(), weapon); - if (player != null && player.isOnline() && Magic.plugin != null) { + if (player != null && player.isOnline()) { Magic.plugin.syncSpellModifiers( player, Magic.plugin.getResonanceGuiManager().getSessionManager().get(player)); } diff --git a/src/main/java/net/tfminecraft/magic/gear/orb/GearOrbSession.java b/src/main/java/net/tfminecraft/magic/gear/orb/GearOrbSession.java index 581161b..4695ffa 100644 --- a/src/main/java/net/tfminecraft/magic/gear/orb/GearOrbSession.java +++ b/src/main/java/net/tfminecraft/magic/gear/orb/GearOrbSession.java @@ -248,7 +248,7 @@ private void orbit() { anchor.getX() + Math.cos(orb.getAngle()) * orb.getRadius(), anchor.getY() + orb.getHeightBias() + bob, anchor.getZ() + Math.sin(orb.getAngle()) * orb.getRadius()); - if (orb.getIntroRemaining() > 0 && orb.getIntroMax() > 0) { + if (orb.getIntroRemaining() > 0) { double t = 1.0 - (double) orb.getIntroRemaining() / orb.getIntroMax(); Location from = orb.getAnchor(); orb.setLocation(new Location( @@ -349,7 +349,7 @@ private void actionBar(Player player) { } private static Color resolveBeamColor(String elementId) { - ElementDef element = elementId == null || elementId.isBlank() + ElementDef element = elementId.isBlank() ? null : ElementRegistry.getById(elementId); if (element == null) { @@ -359,9 +359,6 @@ private static Color resolveBeamColor(String elementId) { } private static double randomRange(ThreadLocalRandom random, double min, double max) { - if (max <= min) { - return min; - } return min + random.nextDouble() * (max - min); } } diff --git a/src/main/java/net/tfminecraft/magic/gear/orb/OrbCache.java b/src/main/java/net/tfminecraft/magic/gear/orb/OrbCache.java index 4650a0f..1e05b73 100644 --- a/src/main/java/net/tfminecraft/magic/gear/orb/OrbCache.java +++ b/src/main/java/net/tfminecraft/magic/gear/orb/OrbCache.java @@ -125,9 +125,9 @@ public static Tier tier(int tier) { if (best != null) { return best; } - for (Tier row : TIERS.values()) { - return row; - } - return new Tier(4, 0.7, 1.0, windowTicks, 4); + return TIERS.entrySet().stream() + .min(Map.Entry.comparingByKey()) + .map(Map.Entry::getValue) + .orElseGet(() -> new Tier(4, 0.7, 1.0, windowTicks, 4)); } } diff --git a/src/main/java/net/tfminecraft/magic/gui/ArtifactCreateGuiBuilder.java b/src/main/java/net/tfminecraft/magic/gui/ArtifactCreateGuiBuilder.java index 8243df8..768c9a5 100644 --- a/src/main/java/net/tfminecraft/magic/gui/ArtifactCreateGuiBuilder.java +++ b/src/main/java/net/tfminecraft/magic/gui/ArtifactCreateGuiBuilder.java @@ -73,7 +73,7 @@ public static void populate(Inventory inventory, ArtifactCreateSession session) int slot = raritySlots.get(i); if (i < rarities.size()) { ArtifactRarityDef rarity = rarities.get(i); - boolean selected = rarity.getId() != null && rarity.getId().equals(selectedRarityId); + boolean selected = rarity.getId().equals(selectedRarityId); inventory.setItem(slot, buildRarityItem(rarity, selected)); } else { inventory.setItem(slot, buildUnusedRarity()); @@ -120,11 +120,11 @@ private static ItemStack buildPreview(ArtifactCreateSession session) { } ItemStack item = new ItemStack(Material.BARRIER); ItemMeta meta = item.getItemMeta(); - if (meta != null) { - meta.setDisplayName(GuiText.format("{color:label_muted}Preview")); - meta.addItemFlags(ItemFlag.HIDE_ATTRIBUTES, ItemFlag.HIDE_ADDITIONAL_TOOLTIP); - item.setItemMeta(meta); - } + meta.setDisplayName(GuiText.format("{color:label_muted}Preview")); + meta.addItemFlags(ItemFlag.HIDE_ATTRIBUTES, ItemFlag.HIDE_ADDITIONAL_TOOLTIP); + item.setItemMeta(meta); + + return item; } @@ -135,9 +135,6 @@ private static ItemStack buildRarityItem(ArtifactRarityDef rarity, boolean selec Material fallback = selected ? Material.LIME_STAINED_GLASS_PANE : Material.GRAY_STAINED_GLASS_PANE; ItemStack item = ItemRef.buildOrFallback(iconRef, fallback); ItemMeta meta = item.getItemMeta(); - if (meta == null) { - return item; - } String color = rarity.getColor() != null && !rarity.getColor().isBlank() ? rarity.getColor() : "#ffffff"; meta.setDisplayName(GuiText.format(color + rarity.getName())); if (selected) { @@ -160,9 +157,6 @@ private static ItemStack buildUnusedRarity() { private static ItemStack buildElementItem(ElementDef element, ArtifactCreateSession session) { ItemStack item = ItemRef.buildOrFallback(element.getIcon(), Material.BARRIER); ItemMeta meta = item.getItemMeta(); - if (meta == null) { - return item; - } meta.setDisplayName(MagicText.elementName(element)); ElementRole role = session != null ? session.roleOf(element.getId()) : ElementRole.OFF; List lore = new ArrayList<>(); @@ -176,7 +170,6 @@ private static ItemStack buildElementItem(ElementDef element, ArtifactCreateSess }; lore.add(GuiText.text("label_muted", roleLabel)); boolean selected = session != null - && element.getId() != null && element.getId().equals(session.getSelectedElementId()); if (selected) { lore.add(GuiText.text("label_accent", "Selected")); @@ -193,9 +186,6 @@ private static ItemStack buildElementItem(ElementDef element, ArtifactCreateSess private static ItemStack buildButton(CastModeDef def) { ItemStack item = ItemRef.buildOrFallback(def.getIcon(), Material.BARRIER); ItemMeta meta = item.getItemMeta(); - if (meta == null) { - return item; - } meta.setDisplayName(GuiText.format(def.getName())); meta.setLore(new ArrayList<>(GuiText.formatLoreLines(def.getLore()))); meta.addItemFlags(ItemFlag.HIDE_ATTRIBUTES, ItemFlag.HIDE_ADDITIONAL_TOOLTIP, ItemFlag.HIDE_ENCHANTS); diff --git a/src/main/java/net/tfminecraft/magic/gui/ResonanceGuiBuilder.java b/src/main/java/net/tfminecraft/magic/gui/ResonanceGuiBuilder.java index c52bdef..2b2ccd5 100644 --- a/src/main/java/net/tfminecraft/magic/gui/ResonanceGuiBuilder.java +++ b/src/main/java/net/tfminecraft/magic/gui/ResonanceGuiBuilder.java @@ -95,9 +95,6 @@ private static ItemStack buildCharacterHead(Player player, ResonanceSession sess : CharacterSkull.ofOwner(player); SkullMeta meta = (SkullMeta) head.getItemMeta(); - if (meta == null) { - return head; - } if (character == null) { meta.setDisplayName(GuiText.text("label_accent", "Resonance")); @@ -155,9 +152,6 @@ private static String formatCastModeLabel(String modeId) { private static ItemStack buildCastModeItem(CastModeDef mode, boolean selected, ResonanceSession session) { ItemStack item = ItemRef.buildOrFallback(mode.getIcon(), Material.BARRIER); ItemMeta meta = item.getItemMeta(); - if (meta == null) { - return item; - } meta.setDisplayName(GuiText.format(mode.getName())); List lore = new ArrayList<>(GuiText.formatLoreLines(mode.getLore())); if (mode.getId().equalsIgnoreCase(GuiCache.castModeLeft.getId())) { @@ -182,9 +176,6 @@ private static ItemStack buildCastModeItem(CastModeDef mode, boolean selected, R private static ItemStack buildElementItem(ElementDef element, ResonanceSession session) { ItemStack item = ItemRef.buildOrFallback(element.getIcon(), Material.BARRIER); ItemMeta meta = item.getItemMeta(); - if (meta == null) { - return item; - } meta.setDisplayName(MagicText.elementName(element)); List lore = new ArrayList<>(); double resonance = session != null ? session.getResonance(element.getId()) : 0.0; diff --git a/src/main/java/net/tfminecraft/magic/integration/SpellModifierApplyService.java b/src/main/java/net/tfminecraft/magic/integration/SpellModifierApplyService.java index 0974735..08b46b8 100644 --- a/src/main/java/net/tfminecraft/magic/integration/SpellModifierApplyService.java +++ b/src/main/java/net/tfminecraft/magic/integration/SpellModifierApplyService.java @@ -209,7 +209,7 @@ private static SkillModifier register( private static void unregisterStored(Player player, MMOPlayerData data) { AppliedState previous = applied.remove(player.getUniqueId()); - if (previous == null || previous.modifiers == null || data == null) { + if (previous == null || data == null) { return; } for (SkillModifier modifier : previous.modifiers) { diff --git a/src/main/java/net/tfminecraft/magic/listener/CastDriftListener.java b/src/main/java/net/tfminecraft/magic/listener/CastDriftListener.java index d59dbe2..c55eb37 100644 --- a/src/main/java/net/tfminecraft/magic/listener/CastDriftListener.java +++ b/src/main/java/net/tfminecraft/magic/listener/CastDriftListener.java @@ -52,7 +52,7 @@ public void onSkillCast(SkillCastEvent event) { } private static boolean isSurge(String castModeId) { - if (castModeId == null || castModeId.isBlank()) { + if (castModeId.isBlank()) { return false; } return castModeId.equalsIgnoreCase(GuiCache.castModeLeft.getId()) @@ -66,11 +66,9 @@ private static double manaSpent(SkillMetadata metadata, Skill cast) { return fromMeta; } } - if (cast != null) { - double fromSkill = cast.getParameter("mana"); - if (fromSkill > 0.0) { - return fromSkill; - } + double fromSkill = cast.getParameter("mana"); + if (fromSkill > 0.0) { + return fromSkill; } return 0.0; } diff --git a/src/main/java/net/tfminecraft/magic/loader/ConfigLoader.java b/src/main/java/net/tfminecraft/magic/loader/ConfigLoader.java index 4209b70..5ecbc89 100644 --- a/src/main/java/net/tfminecraft/magic/loader/ConfigLoader.java +++ b/src/main/java/net/tfminecraft/magic/loader/ConfigLoader.java @@ -130,7 +130,7 @@ private static void loadCastTriggers(List keybinds) { private static void loadRuneTypes(FileConfiguration config) { Cache.runeTypes.clear(); for (String type : config.getStringList("runes.types")) { - if (type != null && !type.isBlank()) { + if (!type.isBlank()) { Cache.runeTypes.add(type.trim().toLowerCase()); } } @@ -139,7 +139,7 @@ private static void loadRuneTypes(FileConfiguration config) { private static void loadRuneKeybinds(FileConfiguration config) { Cache.runeKeybinds.clear(); for (String raw : config.getStringList("runes.keybinds")) { - if (raw == null || raw.isBlank()) { + if (raw.isBlank()) { continue; } String id = raw.trim().toUpperCase(Locale.ROOT); @@ -162,7 +162,7 @@ private static void loadGear(ConfigurationSection section) { return; } String station = section.getString("station", net.tfminecraft.magic.gear.GearCache.station); - if (station != null && !station.isBlank()) { + if (!station.isBlank()) { net.tfminecraft.magic.gear.GearCache.station = station.trim(); } int slot = section.getInt("output-slot", net.tfminecraft.magic.gear.GearCache.outputSlot); @@ -206,7 +206,7 @@ private static void loadAttunement(ConfigurationSection section) { return; } String display = section.getString("display_furniture", ArtifactCareCache.displayFurnitureId); - if (display != null && !display.isBlank()) { + if (!display.isBlank()) { ArtifactCareCache.displayFurnitureId = display.trim(); } loadUsers(section.getConfigurationSection("users")); diff --git a/src/main/java/net/tfminecraft/magic/loader/GearLoader.java b/src/main/java/net/tfminecraft/magic/loader/GearLoader.java index 4d77e25..4872c26 100644 --- a/src/main/java/net/tfminecraft/magic/loader/GearLoader.java +++ b/src/main/java/net/tfminecraft/magic/loader/GearLoader.java @@ -241,7 +241,7 @@ private static boolean loadModelSchemes(File file) { int skipped = 0; for (String key : config.getKeys(false)) { ConfigurationSection section = config.getConfigurationSection(key); - if (key == null || key.isBlank() || section == null) { + if (key.isBlank() || section == null) { skipped++; continue; } @@ -252,7 +252,7 @@ private static boolean loadModelSchemes(File file) { } Map paths = new LinkedHashMap<>(); for (String line : section.getStringList("models")) { - if (line == null || line.isBlank()) { + if (line.isBlank()) { continue; } String trimmed = line.trim(); @@ -324,7 +324,7 @@ private static boolean loadParts(File file) { } List partLimit = new ArrayList<>(); for (String raw : section.getStringList("part-limit")) { - if (raw == null || raw.isBlank()) { + if (raw.isBlank()) { continue; } String category = raw.trim().toLowerCase(Locale.ROOT); @@ -344,7 +344,7 @@ private static boolean loadParts(File file) { String schemeId = ""; int schemeWeight = 1; String schemeRaw = section.getString("model-scheme", ""); - if (schemeRaw != null && !schemeRaw.isBlank()) { + if (!schemeRaw.isBlank()) { int start = schemeRaw.indexOf('('); int end = schemeRaw.indexOf(')'); if (start > 0 && end > start) { @@ -365,6 +365,15 @@ private static boolean loadParts(File file) { schemeWeight = 1; } } + Map costs; + try { + costs = parseCost(section.getStringList("cost")); + } catch (IllegalArgumentException ex) { + Magic.plugin.getLogger().warning("[Magic] parts.yml: invalid cost for '" + id + + "': " + ex.getMessage()); + skipped++; + continue; + } PartDef def = new PartDef( id, MagicText.format(section.getString("name", id)), @@ -372,7 +381,7 @@ private static boolean loadParts(File file) { section.getInt("tier", 0), types, section.getString("item", "v.stone"), - parseCost(section.getStringList("cost")), + costs, partLimit, parseStats(section.getStringList("stats")), sockets, @@ -407,7 +416,11 @@ private static Map parseCost(List raw) { int end = entry.indexOf(')'); if (start > 0 && end > start) { try { - cost.put(entry.substring(0, start).trim(), Integer.parseInt(entry.substring(start + 1, end))); + int amount = Integer.parseInt(entry.substring(start + 1, end).trim()); + if (amount < 0) { + throw new IllegalArgumentException("negative quantity in " + entry); + } + cost.put(entry.substring(0, start).trim(), amount); continue; } catch (NumberFormatException ignored) { // fall through @@ -437,7 +450,10 @@ private static Map parseStats(List raw) { if (statId.isEmpty()) { continue; } - stats.put(statId, Double.parseDouble(entry.substring(start + 1, end))); + double amount = Double.parseDouble(entry.substring(start + 1, end)); + if (Double.isFinite(amount)) { + stats.put(statId, amount); + } } catch (NumberFormatException ignored) { // skip malformed values } diff --git a/src/main/java/net/tfminecraft/magic/loader/GuiLoader.java b/src/main/java/net/tfminecraft/magic/loader/GuiLoader.java index 19973bc..39d231e 100644 --- a/src/main/java/net/tfminecraft/magic/loader/GuiLoader.java +++ b/src/main/java/net/tfminecraft/magic/loader/GuiLoader.java @@ -75,7 +75,7 @@ public boolean loadSafe(File configFile) { if (colors != null) { for (String key : colors.getKeys(false)) { String value = colors.getString(key, ""); - if (value != null && !value.isBlank()) { + if (!value.isBlank()) { colorTokens.put(key, value); } } @@ -155,7 +155,7 @@ private static boolean loadArtifactCreate(ConfigurationSection section) { ArtifactCreateCache.confirmSlot = slots.getInt("confirm", ArtifactCreateCache.confirmSlot); ArtifactCreateCache.cancelSlot = slots.getInt("cancel", ArtifactCreateCache.cancelSlot); List raritySlots = slots.getIntegerList("rarities"); - if (raritySlots != null && !raritySlots.isEmpty()) { + if (!raritySlots.isEmpty()) { ArtifactCreateCache.raritySlots = List.copyOf(raritySlots); } } diff --git a/src/main/java/net/tfminecraft/magic/loader/SkillsLoader.java b/src/main/java/net/tfminecraft/magic/loader/SkillsLoader.java index a8ab8de..93152d8 100644 --- a/src/main/java/net/tfminecraft/magic/loader/SkillsLoader.java +++ b/src/main/java/net/tfminecraft/magic/loader/SkillsLoader.java @@ -29,17 +29,13 @@ public boolean loadSafe(File configFile) { } int skipped = 0; for (String skillId : config.getKeys(false)) { - if (skillId == null || skillId.isBlank()) { + if (skillId.isBlank()) { continue; } String elementId; int tier = SkillElementRegistry.DEFAULT_TIER; if (config.isConfigurationSection(skillId)) { ConfigurationSection section = config.getConfigurationSection(skillId); - if (section == null) { - skipped++; - continue; - } elementId = section.getString("element"); if (section.contains("tier")) { int raw = section.getInt("tier", SkillElementRegistry.DEFAULT_TIER); diff --git a/src/main/java/net/tfminecraft/magic/manager/ArtifactCreateGuiManager.java b/src/main/java/net/tfminecraft/magic/manager/ArtifactCreateGuiManager.java index 0238c55..e6ab68d 100644 --- a/src/main/java/net/tfminecraft/magic/manager/ArtifactCreateGuiManager.java +++ b/src/main/java/net/tfminecraft/magic/manager/ArtifactCreateGuiManager.java @@ -81,7 +81,7 @@ public void onInventoryClick(InventoryClickEvent event) { } String nextId = rarities.get(i).getId(); String previous = session.getRarityId(); - if (nextId == null || nextId.equals(previous)) { + if (nextId.equals(previous)) { return; } session.setRarityId(nextId); @@ -142,13 +142,11 @@ private static void confirm(Player player, ArtifactCreateSession session) { private static void giveOrDrop(Player player, ItemStack stack) { Map leftover = player.getInventory().addItem(stack); - if (leftover.isEmpty() || player.getWorld() == null) { + if (leftover.isEmpty()) { return; } for (ItemStack extra : leftover.values()) { - if (extra != null && !extra.getType().isAir()) { - player.getWorld().dropItemNaturally(player.getLocation(), extra); - } + player.getWorld().dropItemNaturally(player.getLocation(), extra); } } diff --git a/src/main/java/net/tfminecraft/magic/manager/MagicTickService.java b/src/main/java/net/tfminecraft/magic/manager/MagicTickService.java index cb6dc8a..816ee5e 100644 --- a/src/main/java/net/tfminecraft/magic/manager/MagicTickService.java +++ b/src/main/java/net/tfminecraft/magic/manager/MagicTickService.java @@ -20,14 +20,17 @@ public final class MagicTickService { private static BukkitTask task; private static long elapsedSeconds; + private static long elapsedTicks; + private static long scheduledIntervalTicks; private MagicTickService() {} public static void start() { stop(); long intervalTicks = Math.max(1L, Cache.tickIntervalTicks); + scheduledIntervalTicks = intervalTicks; task = Magic.plugin.getServer().getScheduler().runTaskTimer( - Magic.plugin, MagicTickService::tick, 0L, intervalTicks); + Magic.plugin, MagicTickService::tick, intervalTicks, intervalTicks); } public static void stop() { @@ -60,8 +63,10 @@ public static boolean isRunning() { } private static void tick() { - elapsedSeconds++; - MagicTickContext context = MagicTickContext.of(elapsedSeconds); + long previousSeconds = elapsedSeconds; + elapsedTicks += scheduledIntervalTicks; + elapsedSeconds = elapsedTicks / 20L; + MagicTickContext context = MagicTickContext.between(previousSeconds, elapsedSeconds); for (MagicTickHandler handler : handlers) { try { handler.onTick(context); diff --git a/src/main/java/net/tfminecraft/magic/meditation/MeditationCircle.java b/src/main/java/net/tfminecraft/magic/meditation/MeditationCircle.java index 55d4460..e574802 100644 --- a/src/main/java/net/tfminecraft/magic/meditation/MeditationCircle.java +++ b/src/main/java/net/tfminecraft/magic/meditation/MeditationCircle.java @@ -113,13 +113,11 @@ public MeditationSitYield snapshotYield(long nowMs, String extraCharacterId) { } String artifactId = uuid.toString(); MeditationCache.ArtifactDef def = artifactByFurniture.get(furniture.getEntityId()); - if (def == null || def.elementId == null) { - continue; - } - int n = Math.max(1, ArtifactCareStore.activeUserCount(item, nowMs)); + int n = ArtifactCareStore.activeUserCount(item, nowMs); if (countsAsExtraUser(item, extraCharacterId, nowMs)) { n++; } + n = Math.max(1, n); Artifact aura = Artifact.fromItem(item); double fill = aura != null ? aura.getFill(def.elementId) : 0.0; double cap = aura != null ? aura.getCap(def.elementId) : 0.0; @@ -183,7 +181,7 @@ public boolean canGainResonance(Player player, ResonanceSession session, Meditat for (Furniture furniture : artifactPedestals) { String artifactId = artifactIdOn(furniture); MeditationCache.ArtifactDef def = artifactByFurniture.get(furniture.getEntityId()); - if (artifactId == null || def == null || def.elementId == null) { + if (artifactId == null) { continue; } double cap = yield.sessionCap(artifactId); @@ -208,7 +206,7 @@ public boolean canGainResonance(Player player, ResonanceSession session, Meditat } private static boolean countsAsExtraUser(ItemStack item, String characterId, long nowMs) { - if (item == null || characterId == null || characterId.isBlank()) { + if (characterId == null || characterId.isBlank()) { return false; } Long until = ArtifactCareStore.readUsers(item).get(characterId.trim()); @@ -247,7 +245,7 @@ public static boolean containing(Furniture furniture) { continue; } for (Furniture post : circle.getPedestals()) { - if (post != null && id.equals(post.getEntityId())) { + if (id.equals(post.getEntityId())) { return true; } } @@ -388,9 +386,6 @@ private static MeditationCache.ArtifactDef artifactFromFurniture( private static List pedestalSlots(Furniture furniture) { List slots = new ArrayList<>(); - if (furniture == null) { - return slots; - } String slotId = MeditationCache.pedestalSlot; if (slotId == null || slotId.isBlank() || "*".equals(slotId)) { slots.addAll(furniture.getActiveSlots().values()); @@ -427,7 +422,7 @@ private static ItemStack itemFromSlot(PlacedSlot slot) { Entity stand = standId != null ? Bukkit.getEntity(standId) : null; if (stand instanceof ItemDisplay display) { ItemStack shown = display.getItemStack(); - if (shown != null && !shown.getType().isAir()) { + if (!shown.getType().isAir()) { slot.setModel(shown); return shown; } diff --git a/src/main/java/net/tfminecraft/magic/meditation/MeditationService.java b/src/main/java/net/tfminecraft/magic/meditation/MeditationService.java index 5aca060..a5ac4c4 100644 --- a/src/main/java/net/tfminecraft/magic/meditation/MeditationService.java +++ b/src/main/java/net/tfminecraft/magic/meditation/MeditationService.java @@ -81,9 +81,6 @@ public static boolean locksFurniture(Furniture furniture) { } for (MeditationSession session : service.sessions.values()) { MeditationCircle circle = session.getCircle(); - if (circle == null) { - continue; - } for (Furniture post : circle.getPedestals()) { if (post != null && id.equals(post.getEntityId())) { return true; @@ -245,9 +242,6 @@ private MeditationOrb hitscan(Player player, MeditationSession session) { Location eye = player.getEyeLocation(); Vector dir = eye.getDirection().normalize(); World world = eye.getWorld(); - if (world == null) { - return null; - } double range = MeditationCache.orbClickRange; double radius = MeditationCache.orbHitRadius; double radiusSq = radius * radius; diff --git a/src/main/java/net/tfminecraft/magic/meditation/MeditationSession.java b/src/main/java/net/tfminecraft/magic/meditation/MeditationSession.java index b7695d1..faa84e8 100644 --- a/src/main/java/net/tfminecraft/magic/meditation/MeditationSession.java +++ b/src/main/java/net/tfminecraft/magic/meditation/MeditationSession.java @@ -47,6 +47,7 @@ public enum Phase { private boolean tiredNotified; private final List orbs = new ArrayList<>(); private final Map attunedByArtifact = new HashMap<>(); + private final Map furnitureById = new HashMap<>(); private boolean windingDown; public MeditationSession( @@ -55,6 +56,9 @@ public MeditationSession( MeditationSitYield yield, ResonanceSession resonance) { this.circle = circle; + for (Furniture furniture : circle.getPedestals()) { + furnitureById.putIfAbsent(furniture.getEntityId(), furniture); + } this.yield = yield != null ? yield : MeditationSitYield.empty(); closeCappedElements(player, resonance); spawnStarter(player); @@ -133,7 +137,7 @@ public void tick(Player player, ResonanceSession session) { return; } boolean nowLocked = locked(); - if (surgeLockActive && !nowLocked && player != null) { + if (surgeLockActive && !nowLocked) { player.sendMessage(MagicText.format("{color:mode_flow}§oYou regain focus")); } surgeLockActive = nowLocked; @@ -188,9 +192,7 @@ public void beginWindDown() { despawnOrb(orb); continue; } - if (!orb.isReturning()) { - orb.beginReturn(); - } + orb.beginReturn(); } } @@ -221,9 +223,6 @@ public void playComplete(Player player) { } private static void spawnCompleteBurst(Location center, Particle particle, int count, double radius) { - if (center.getWorld() == null) { - return; - } center.getWorld().spawnParticle(particle, center, count, radius, 0.7, radius, 0.02); } @@ -255,12 +254,9 @@ private int maxLiveOrbsFor(UUID furnitureId) { } private int liveOrbCountFrom(UUID furnitureId) { - if (furnitureId == null) { - return 0; - } int count = 0; for (MeditationOrb orb : orbs) { - if (orb.isConsumed() || orb.isStarter()) { + if (orb.isConsumed()) { continue; } if (furnitureId.equals(orb.getSourceId())) { @@ -307,7 +303,7 @@ private boolean locked() { private void expireOrbitOrbs() { for (MeditationOrb orb : orbs) { - if (orb.isStarter() || orb.isConsumed()) { + if (orb.isConsumed()) { continue; } if (orb.tickLife()) { @@ -324,9 +320,6 @@ private void spawnStarter(Player player) { private void keepStarterInFront(Player player) { Location loc = starterLocation(player); for (MeditationOrb orb : orbs) { - if (!orb.isStarter() || orb.isConsumed()) { - continue; - } orb.setLocation(loc); } } @@ -337,9 +330,6 @@ private static Location starterLocation(Player player) { } private void spawnOrbitOrb(ResonanceSession session, List eligible) { - if (eligible.isEmpty()) { - return; - } Furniture source = eligible.get(ThreadLocalRandom.current().nextInt(eligible.size())); Location spawn = PedestalFx.artifactPoint(source); if (spawn == null) { @@ -382,12 +372,12 @@ private static double whiteChance(ResonanceSession session) { private void tickReturns() { for (MeditationOrb orb : orbs) { - if (orb.isStarter() || orb.isConsumed() || !orb.isReturning()) { + if (orb.isConsumed() || !orb.isReturning()) { continue; } Location from = orb.getReturnFrom(); Location to = orb.getSpawnLocation(); - World world = to.getWorld() != null ? to.getWorld() : (from.getWorld() != null ? from.getWorld() : null); + World world = to.getWorld() != null ? to.getWorld() : from.getWorld(); if (world == null) { despawnOrb(orb); continue; @@ -410,11 +400,8 @@ private void orbit(Player player) { tickReturns(); Location eye = player.getEyeLocation(); World world = eye.getWorld(); - if (world == null) { - return; - } for (MeditationOrb orb : orbs) { - if (orb.isStarter() || orb.isConsumed() || orb.isReturning()) { + if (orb.isConsumed() || orb.isReturning()) { continue; } orb.setAngle(orb.getAngle() + orb.getAngleSpeed()); @@ -425,7 +412,7 @@ private void orbit(Player player) { eye.getX() + Math.cos(orb.getAngle()) * orb.getRadius(), eye.getY() + orb.getHeightBias() + bob, eye.getZ() + Math.sin(orb.getAngle()) * orb.getRadius()); - if (orb.getIntroRemaining() > 0 && orb.getIntroMax() > 0) { + if (orb.getIntroRemaining() > 0) { double t = 1.0 - (double) orb.getIntroRemaining() / orb.getIntroMax(); Location spawn = orb.getSpawnLocation(); orb.setLocation(new Location( @@ -442,7 +429,7 @@ private void orbit(Player player) { private void retintAllSurge() { for (MeditationOrb orb : orbs) { - if (orb.isStarter() || orb.isConsumed()) { + if (orb.isConsumed()) { continue; } orb.setFlow(false); @@ -533,9 +520,6 @@ private void closeCappedElements(Player player, ResonanceSession session) { } for (Furniture furniture : circle.getArtifactPedestals()) { MeditationCache.ArtifactDef def = circle.artifactFor(furniture.getEntityId()); - if (def == null || def.elementId == null) { - continue; - } String artifactId = circle.artifactIdOn(furniture); if (artifactId == null) { continue; @@ -561,12 +545,9 @@ private void closeCappedElements(Player player, ResonanceSession session) { } private void closeElement(String elementId) { - if (elementId == null) { - return; - } for (Furniture furniture : circle.getArtifactPedestals()) { MeditationCache.ArtifactDef def = circle.artifactFor(furniture.getEntityId()); - if (def == null || def.elementId == null || !elementId.equalsIgnoreCase(def.elementId)) { + if (!elementId.equalsIgnoreCase(def.elementId)) { continue; } String artifactId = circle.artifactIdOn(furniture); @@ -583,22 +564,11 @@ private static double elementMax(String elementId) { } private Furniture furniture(UUID furnitureId) { - if (furnitureId == null) { - return null; - } - for (Furniture candidate : circle.getPedestals()) { - if (furnitureId.equals(candidate.getEntityId())) { - return candidate; - } - } - return null; + return furnitureById.get(furnitureId); } private void playHitFx(Player player, MeditationOrb orb) { World world = player.getWorld(); - if (world == null) { - return; - } boolean flowLook = orb.isFlow(); Color dustColor = MagicText.bukkitColor( GuiCache.color(flowLook ? "mode_flow" : "mode_surge", flowLook ? "#5bc4d4" : "#e85d4a"), @@ -640,9 +610,6 @@ private void renderOrbs() { } Location loc = orb.getLocation(); World world = loc.getWorld(); - if (world == null) { - continue; - } boolean flowLook = orb.isStarter() || orb.isFlow(); Color dustColor = orb.isStarter() ? Color.WHITE diff --git a/src/main/java/net/tfminecraft/magic/meditation/MeditationSitYield.java b/src/main/java/net/tfminecraft/magic/meditation/MeditationSitYield.java index 8444995..35a7971 100644 --- a/src/main/java/net/tfminecraft/magic/meditation/MeditationSitYield.java +++ b/src/main/java/net/tfminecraft/magic/meditation/MeditationSitYield.java @@ -49,7 +49,7 @@ public boolean hasAnyCap() { public boolean exhausted(Map attunedByArtifact) { for (Map.Entry entry : sessionCapByArtifact.entrySet()) { - double cap = entry.getValue() != null ? entry.getValue() : 0.0; + double cap = entry.getValue(); if (cap <= EPSILON) { continue; } diff --git a/src/main/java/net/tfminecraft/magic/model/ElementDef.java b/src/main/java/net/tfminecraft/magic/model/ElementDef.java index 94cae54..d3bd0dc 100644 --- a/src/main/java/net/tfminecraft/magic/model/ElementDef.java +++ b/src/main/java/net/tfminecraft/magic/model/ElementDef.java @@ -34,7 +34,7 @@ public ElementDef(String id, ConfigurationSection config) { this.colors = readColors(config); this.slot = config.getInt("slot", -1); String perm = config.getString("permission", ""); - this.permission = perm == null || perm.isBlank() ? null : perm.trim(); + this.permission = perm.isBlank() ? null : perm.trim(); this.maxResonance = Math.max(1.0, config.getDouble("max_resonance", 100.0)); this.decayPerHourOverride = config.contains("decay_per_hour") ? config.getDouble("decay_per_hour") diff --git a/src/main/java/net/tfminecraft/magic/model/ElementVisibility.java b/src/main/java/net/tfminecraft/magic/model/ElementVisibility.java index c3caad7..ff071d2 100644 --- a/src/main/java/net/tfminecraft/magic/model/ElementVisibility.java +++ b/src/main/java/net/tfminecraft/magic/model/ElementVisibility.java @@ -81,7 +81,7 @@ private static ArtifactTypeDef typeOf(String elementId) { return exact; } for (ArtifactTypeDef type : ArtifactTypeRegistry.getAll()) { - if (type.getElementId() != null && type.getElementId().equalsIgnoreCase(id)) { + if (type.getElementId().equalsIgnoreCase(id)) { return type; } } diff --git a/src/main/java/net/tfminecraft/magic/modifier/KeyframeCurve.java b/src/main/java/net/tfminecraft/magic/modifier/KeyframeCurve.java index 3bb4714..504658e 100644 --- a/src/main/java/net/tfminecraft/magic/modifier/KeyframeCurve.java +++ b/src/main/java/net/tfminecraft/magic/modifier/KeyframeCurve.java @@ -11,9 +11,17 @@ public final class KeyframeCurve { private final NavigableMap keys; public KeyframeCurve(NavigableMap keys) { - this.keys = keys == null || keys.isEmpty() - ? new TreeMap<>(Map.of(0.0, ModifierTriple.ZERO)) - : new TreeMap<>(keys); + this.keys = new TreeMap<>(); + if (keys != null) { + for (Map.Entry entry : keys.entrySet()) { + if (Double.isFinite(entry.getKey())) { + this.keys.put(entry.getKey(), entry.getValue()); + } + } + } + if (this.keys.isEmpty()) { + this.keys.put(0.0, ModifierTriple.ZERO); + } } public static KeyframeCurve fromSection(ConfigurationSection section) { @@ -22,7 +30,7 @@ public static KeyframeCurve fromSection(ConfigurationSection section) { return empty(); } for (String rawKey : section.getKeys(false)) { - if (rawKey == null || rawKey.isBlank()) { + if (rawKey.isBlank()) { continue; } double at; @@ -75,7 +83,7 @@ public static KeyframeCurve defaultTranquility() { public ModifierTriple sample(double amount) { Map.Entry first = keys.firstEntry(); Map.Entry last = keys.lastEntry(); - if (amount <= first.getKey()) { + if (Double.isNaN(amount) || amount <= first.getKey()) { return first.getValue(); } if (amount >= last.getKey()) { @@ -83,14 +91,11 @@ public ModifierTriple sample(double amount) { } Map.Entry floor = keys.floorEntry(amount); Map.Entry ceil = keys.ceilingEntry(amount); - if (floor == null) { - return first.getValue(); - } - if (ceil == null || ceil.getKey().equals(floor.getKey())) { + if (ceil.getKey().equals(floor.getKey())) { return floor.getValue(); } double span = ceil.getKey() - floor.getKey(); - double t = span == 0.0 ? 0.0 : (amount - floor.getKey()) / span; + double t = (amount - floor.getKey()) / span; return floor.getValue().lerp(ceil.getValue(), t); } diff --git a/src/main/java/net/tfminecraft/magic/profile/MagicProfile.java b/src/main/java/net/tfminecraft/magic/profile/MagicProfile.java index 361f062..e9dbdd4 100644 --- a/src/main/java/net/tfminecraft/magic/profile/MagicProfile.java +++ b/src/main/java/net/tfminecraft/magic/profile/MagicProfile.java @@ -60,7 +60,7 @@ public void migrateClaims() { continue; } for (Map.Entry entry : claim.getByElement().entrySet()) { - if (entry.getKey() == null || entry.getValue() == null) { + if (entry.getValue() == null) { continue; } String id = entry.getKey().trim().toLowerCase(Locale.ROOT); diff --git a/src/main/java/net/tfminecraft/magic/profile/MagicProfileService.java b/src/main/java/net/tfminecraft/magic/profile/MagicProfileService.java index 31848cd..db9ab0e 100644 --- a/src/main/java/net/tfminecraft/magic/profile/MagicProfileService.java +++ b/src/main/java/net/tfminecraft/magic/profile/MagicProfileService.java @@ -103,7 +103,7 @@ public void loadOnlineCharacters() { private void saveLoaded(Player player) { String characterId = sessionManager.getLoadedCharacterId(player); - if (characterId == null || characterId.isBlank()) { + if (characterId == null) { RPCharacter active = RpCharactersBridge.getActiveCharacter(player); characterId = active != null ? active.getId() : null; } diff --git a/src/main/java/net/tfminecraft/magic/profile/MagicProfileStore.java b/src/main/java/net/tfminecraft/magic/profile/MagicProfileStore.java index 6d81d69..6bee0c8 100644 --- a/src/main/java/net/tfminecraft/magic/profile/MagicProfileStore.java +++ b/src/main/java/net/tfminecraft/magic/profile/MagicProfileStore.java @@ -8,6 +8,9 @@ import java.io.Writer; import java.nio.file.Files; import java.nio.file.StandardCopyOption; +import java.util.HashSet; +import java.util.Set; +import java.util.UUID; import com.google.gson.Gson; import com.google.gson.GsonBuilder; @@ -19,6 +22,7 @@ public final class MagicProfileStore { private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); private final File folder; + private final Set unreadableProfiles = new HashSet<>(); public MagicProfileStore(File folder) { this.folder = folder; @@ -51,9 +55,22 @@ public MagicProfile load(String characterId) { profile.setCharacterId(characterId); } profile.migrateClaims(); + unreadableProfiles.remove(characterId); return profile; } catch (IOException | RuntimeException ex) { Magic.plugin.getLogger().severe("[Magic] Failed to load profile " + characterId + ": " + ex.getMessage()); + unreadableProfiles.add(characterId); + if (file.isFile()) { + File rejected = new File(folder, file.getName() + ".rejected-" + UUID.randomUUID()); + try { + Files.move(file.toPath(), rejected.toPath()); + unreadableProfiles.remove(characterId); + Magic.plugin.getLogger().warning("[Magic] Preserved unreadable profile at " + rejected.getName()); + } catch (IOException preserveFailure) { + Magic.plugin.getLogger().severe("[Magic] Could not preserve unreadable profile " + + characterId + ": " + preserveFailure.getMessage()); + } + } return null; } } @@ -62,6 +79,11 @@ public void save(MagicProfile profile) { if (profile == null || profile.getCharacterId() == null || profile.getCharacterId().isBlank()) { return; } + if (unreadableProfiles.contains(profile.getCharacterId())) { + Magic.plugin.getLogger().severe("[Magic] Refusing to overwrite unreadable profile " + + profile.getCharacterId()); + return; + } if (!folder.exists()) { folder.mkdirs(); } diff --git a/src/main/java/net/tfminecraft/magic/service/ResonanceService.java b/src/main/java/net/tfminecraft/magic/service/ResonanceService.java index 2c7c789..bd0df0b 100644 --- a/src/main/java/net/tfminecraft/magic/service/ResonanceService.java +++ b/src/main/java/net/tfminecraft/magic/service/ResonanceService.java @@ -46,7 +46,7 @@ private static void tickSession(Player player, ResonanceSession session) { + MagicNumbers.format(session.getResonance(element.getId()))); } } - if (dirty && player != null && Magic.plugin != null && Magic.plugin.getProfileService() != null) { + if (dirty && Magic.plugin != null && Magic.plugin.getProfileService() != null) { Magic.plugin.getProfileService().savePlayer(player); } } diff --git a/src/main/java/net/tfminecraft/magic/session/ResonanceSession.java b/src/main/java/net/tfminecraft/magic/session/ResonanceSession.java index af6c13b..497f20f 100644 --- a/src/main/java/net/tfminecraft/magic/session/ResonanceSession.java +++ b/src/main/java/net/tfminecraft/magic/session/ResonanceSession.java @@ -103,14 +103,7 @@ public void setResonanceMap(Map next) { } public Map copyResonance() { - Map copy = new HashMap<>(); - for (Map.Entry entry : resonance.entrySet()) { - if (entry.getKey() == null || entry.getValue() == null || entry.getValue() <= EPSILON) { - continue; - } - copy.put(entry.getKey(), entry.getValue()); - } - return copy; + return new HashMap<>(resonance); } private void bumpModifiers() { @@ -118,21 +111,11 @@ private void bumpModifiers() { } private double amount(String elementId) { - String id = normalizeElementId(elementId); - if (id.isEmpty()) { - return 0.0; - } + String id = elementId.trim().toLowerCase(Locale.ROOT); Double value = resonance.get(id); return value != null ? value : 0.0; } - private static String normalizeElementId(String elementId) { - if (elementId == null) { - return ""; - } - return elementId.trim().toLowerCase(Locale.ROOT); - } - private static String normalizeCastModeId(String castModeId) { if (castModeId == null || castModeId.isBlank()) { return GuiCache.castModeLeft.getId(); diff --git a/src/main/java/net/tfminecraft/magic/tick/MagicTickContext.java b/src/main/java/net/tfminecraft/magic/tick/MagicTickContext.java index d8e7b57..30667d5 100644 --- a/src/main/java/net/tfminecraft/magic/tick/MagicTickContext.java +++ b/src/main/java/net/tfminecraft/magic/tick/MagicTickContext.java @@ -6,13 +6,24 @@ public final class MagicTickContext { private final long elapsedSeconds; + private final long previousSeconds; public static MagicTickContext of(long elapsedSeconds) { return new MagicTickContext(elapsedSeconds); } MagicTickContext(long elapsedSeconds) { + this(elapsedSeconds - 1L, elapsedSeconds); + } + + /** A callback can span part of a second or cross several periodic boundaries. */ + public static MagicTickContext between(long previousSeconds, long elapsedSeconds) { + return new MagicTickContext(previousSeconds, elapsedSeconds); + } + + private MagicTickContext(long previousSeconds, long elapsedSeconds) { this.elapsedSeconds = elapsedSeconds; + this.previousSeconds = previousSeconds; } public long seconds() { @@ -28,7 +39,9 @@ public long hours() { } public boolean every(long intervalSeconds) { - return intervalSeconds > 0 && elapsedSeconds % intervalSeconds == 0; + return intervalSeconds > 0 + && Math.floorDiv(elapsedSeconds, intervalSeconds) + > Math.floorDiv(previousSeconds, intervalSeconds); } public boolean everyMinutes(long minutes) { diff --git a/src/main/java/net/tfminecraft/magic/util/CostFormatter.java b/src/main/java/net/tfminecraft/magic/util/CostFormatter.java index 4324d3a..4670358 100644 --- a/src/main/java/net/tfminecraft/magic/util/CostFormatter.java +++ b/src/main/java/net/tfminecraft/magic/util/CostFormatter.java @@ -1,6 +1,7 @@ package net.tfminecraft.magic.util; import java.util.ArrayList; +import java.util.Comparator; import java.util.List; import java.util.Map; import java.util.TreeSet; @@ -29,7 +30,9 @@ public static List getCostsFormatted(Map map) { if (map == null || map.isEmpty()) { return result; } - for (String path : new TreeSet<>(map.keySet())) { + TreeSet paths = new TreeSet<>(Comparator.nullsLast(Comparator.naturalOrder())); + paths.addAll(map.keySet()); + for (String path : paths) { Integer amount = map.get(path); if (path == null || amount == null) { continue; diff --git a/src/main/java/net/tfminecraft/magic/util/MagicNumbers.java b/src/main/java/net/tfminecraft/magic/util/MagicNumbers.java index 4235d14..2600e41 100644 --- a/src/main/java/net/tfminecraft/magic/util/MagicNumbers.java +++ b/src/main/java/net/tfminecraft/magic/util/MagicNumbers.java @@ -42,9 +42,6 @@ private static String stripTrailingZeros(double value, int places) { .setScale(places, RoundingMode.HALF_UP) .stripTrailingZeros() .toPlainString(); - if ("-0".equals(raw)) { - return "0"; - } return raw; } } diff --git a/src/main/java/net/tfminecraft/magic/util/MagicText.java b/src/main/java/net/tfminecraft/magic/util/MagicText.java index abc715c..f822beb 100644 --- a/src/main/java/net/tfminecraft/magic/util/MagicText.java +++ b/src/main/java/net/tfminecraft/magic/util/MagicText.java @@ -59,14 +59,11 @@ public static String elementText(ElementDef element, String plain) { private static String visibleName(ElementDef element) { String raw = element.getName(); - if (raw == null || raw.isBlank()) { + if (raw.isBlank()) { raw = element.getId(); } @SuppressWarnings("deprecation") String stripped = ChatColor.stripColor(raw); - if (stripped == null) { - stripped = raw; - } stripped = stripped.replaceAll("(?i)#[0-9a-f]{6}", "").trim(); return stripped.isEmpty() ? element.getId() : stripped; } diff --git a/src/main/java/net/tfminecraft/magic/util/ResonanceBar.java b/src/main/java/net/tfminecraft/magic/util/ResonanceBar.java index f71e532..f9a2560 100644 --- a/src/main/java/net/tfminecraft/magic/util/ResonanceBar.java +++ b/src/main/java/net/tfminecraft/magic/util/ResonanceBar.java @@ -11,7 +11,7 @@ private ResonanceBar() {} public static String build(ElementDef element, double current, int segments) { double max = element.getMaxResonance(); double clampedCurrent = MagicNumbers.clamp(current, 0.0, max); - int filled = max <= 0 ? 0 : (int) Math.round(clampedCurrent / max * segments); + int filled = (int) Math.round(clampedCurrent / max * segments); filled = (int) MagicNumbers.clamp(filled, 0, segments); String barChar = GuiCache.resonanceBarChar; diff --git a/src/test/java/net/tfminecraft/magic/ArtifactBuilderTest.java b/src/test/java/net/tfminecraft/magic/ArtifactBuilderTest.java new file mode 100644 index 0000000..4fb1f63 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/ArtifactBuilderTest.java @@ -0,0 +1,310 @@ +package net.tfminecraft.magic; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import io.lumine.mythic.lib.MythicLib; +import io.lumine.mythic.lib.api.item.NBTItem; +import java.util.*; +import java.util.logging.Logger; +import net.Indyuce.mmoitems.ItemStats; +import net.Indyuce.mmoitems.MMOItems; +import net.Indyuce.mmoitems.api.item.mmoitem.LiveMMOItem; +import net.Indyuce.mmoitems.stat.data.StringData; +import net.Indyuce.mmoitems.stat.type.NameData; +import net.Indyuce.mmoitems.stat.type.StatHistory; +import net.tfminecraft.magic.artifact.*; +import net.tfminecraft.magic.artifact.config.*; +import net.tfminecraft.magic.artifact.generate.*; +import net.tfminecraft.magic.charge.ChargeRegistry; +import net.tfminecraft.magic.registry.ElementRegistry; +import net.tfminecraft.tlibs.TLibs; +import net.tfminecraft.tlibs.objects.api.ItemAPI; +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; +import org.bukkit.persistence.PersistentDataType; +import org.junit.jupiter.api.*; +import org.mockbukkit.mockbukkit.*; +import org.mockito.*; + +class ArtifactBuilderTest { + ServerMock server; + MMOItems previousMmo; + MythicLib previousLib; + + @BeforeEach + void setup() { + server = MockBukkit.mock(); + Magic.plugin = mock(Magic.class); + when(Magic.plugin.namespace()).thenReturn("magic"); + when(Magic.plugin.getLogger()).thenReturn(Logger.getAnonymousLogger()); + previousMmo = MMOItems.plugin; + previousLib = MythicLib.plugin; + MMOItems.plugin = mock(MMOItems.class, RETURNS_DEEP_STUBS); + when(MMOItems.plugin.namespace()).thenReturn("mmoitems"); + MythicLib.plugin = mock(MythicLib.class, RETURNS_DEEP_STUBS); + when(MythicLib.plugin.namespace()).thenReturn("mythiclib"); + ArtifactTypeRegistry.clear(); + ArtifactRarityRegistry.clear(); + ArtifactNamingSchemeRegistry.clear(); + ArtifactModelSchemeRegistry.clear(); + ArtifactAdjectiveRegistry.clear(); + ChargeRegistry.clear(); + ElementRegistry.clear(); + ArtifactTypeRegistry.register(ArtifactGenerationTest.type("fire", true, 1)); + ArtifactRarityRegistry.register(ArtifactGenerationTest.rarity("common", 1, 1, 1)); + ElementRegistry.register(DomainTest.element("fire")); + } + + @AfterEach + void cleanup() { + Magic.plugin = null; + MMOItems.plugin = previousMmo; + MythicLib.plugin = previousLib; + MockBukkit.unmock(); + ArtifactTypeRegistry.clear(); + ArtifactRarityRegistry.clear(); + ArtifactNamingSchemeRegistry.clear(); + ArtifactModelSchemeRegistry.clear(); + ArtifactAdjectiveRegistry.clear(); + ChargeRegistry.clear(); + ElementRegistry.clear(); + } + + ArtifactRoll roll() { + return ArtifactRoll.ok("fire", "common", List.of(new ArtifactAuraSlot("fire", 10))); + } + + @Test + void namesUseConfiguredFallbackKindsAndInsertAdjectivesAfterArticles() { + var builder = new ArtifactItemBuilder(new Random(1)); + var type = ArtifactTypeRegistry.getById("fire"); + assertEquals("", builder.pickBaseName(null, "wand")); + assertNull(builder.pickBaseName(ArtifactGenerationTest.type(null, true, 1), "wand", "common")); + assertEquals("fire", builder.pickBaseName(type, "wand")); + ArtifactNamingSchemeRegistry.register(new ArtifactNamingScheme("names", Map.of())); + assertEquals("fire", builder.pickBaseName(type, "wand", "common")); + ArtifactAdjectiveRegistry.setChance("common", 1); + assertEquals("fire", builder.pickBaseName(type, "wand", "common")); + ArtifactAdjectiveRegistry.setGlobal("common", List.of("Fiery")); + for (String name : List.of("A Wand", "An Orb", "The Stone", "Crystal", "Two Words")) { + ArtifactNamingSchemeRegistry.register( + new ArtifactNamingScheme("names", Map.of("manuscript", List.of(name)))); + String expected = + name.equals("Two Words") + ? "Fiery Two Words" + : name.equals("Crystal") ? "Fiery Crystal" : name.replace(" ", " Fiery "); + assertEquals(expected, builder.pickBaseName(type, "unknown", "common")); + } + ArtifactNamingSchemeRegistry.register( + new ArtifactNamingScheme("names", Map.of("orb", List.of("Orb")))); + assertEquals("Fiery Orb", builder.pickBaseName(type, "missing", "common")); + assertEquals("Orb", builder.pickBaseName(type, "orb", null)); + assertEquals("Orb", builder.pickBaseName(type, "orb", " ")); + assertNotNull(new ArtifactItemBuilder()); + assertNotNull(new ArtifactItemBuilder(null)); + } + + @Test + void modelsFilterByRarityAndResolveTheirKinds() { + var builder = new ArtifactItemBuilder(new Random(1)); + var type = ArtifactTypeRegistry.getById("fire"); + assertNull(builder.pickModel(null, "common")); + assertNull(builder.pickModel(type, "common")); + assertNull(builder.pickModelPath(type, "common")); + assertNull(ArtifactItemBuilder.kindForPath(null, "v.STICK")); + assertNull(ArtifactItemBuilder.kindForPath(type, null)); + assertNull(ArtifactItemBuilder.kindForPath(type, " ")); + assertNull(ArtifactItemBuilder.kindForPath(type, "v.STICK")); + ArtifactModelSchemeRegistry.register(new ArtifactModelScheme("models", List.of())); + assertNull(builder.pickModel(type, "common")); + var entry = new ArtifactModelEntry("wand", "v.STICK", null, null); + ArtifactModelSchemeRegistry.register( + new ArtifactModelScheme( + "models", + Arrays.asList(null, new ArtifactModelEntry("orb", "v.STONE", "rare", "rare"), entry))); + assertSame(entry, builder.pickModel(type, "common")); + assertEquals("v.STICK", builder.pickModelPath(type, "common")); + assertEquals("wand", ArtifactItemBuilder.kindForPath(type, "v.STICK")); + assertNull(ArtifactItemBuilder.kindForPath(type, "missing")); + assertNull(builder.pickModel(type, "unknown")); + } + + @Test + void adjectivePoolsNormalizeDeduplicateAndClampChance() { + ArtifactAdjectiveRegistry.setChance(null, 1); + ArtifactAdjectiveRegistry.setChance(" ", 1); + assertEquals(0, ArtifactAdjectiveRegistry.chance(null)); + assertEquals(0, ArtifactAdjectiveRegistry.chance(" ")); + assertEquals(0, ArtifactAdjectiveRegistry.chance("unknown")); + ArtifactAdjectiveRegistry.setChance(" COMMON ", 2); + assertEquals(1, ArtifactAdjectiveRegistry.chance("common")); + ArtifactAdjectiveRegistry.setChance("rare", -1); + assertEquals(0, ArtifactAdjectiveRegistry.chance("rare")); + ArtifactAdjectiveRegistry.setGlobal(null, List.of()); + ArtifactAdjectiveRegistry.setGlobal(" ", List.of()); + ArtifactAdjectiveRegistry.setGlobal("common", null); + ArtifactAdjectiveRegistry.setGlobal("common", Arrays.asList(null, " ", " Hot ")); + ArtifactAdjectiveRegistry.setElement(null, "common", List.of()); + ArtifactAdjectiveRegistry.setElement(" ", "common", List.of()); + ArtifactAdjectiveRegistry.setElement(" FIRE ", "common", List.of("Hot", "Burning")); + assertEquals(List.of("Hot", "Burning"), ArtifactAdjectiveRegistry.pool("fire", "common")); + assertEquals(List.of("Hot"), ArtifactAdjectiveRegistry.pool(null, "common")); + assertEquals(List.of(), ArtifactAdjectiveRegistry.pool("fire", null)); + assertEquals(List.of(), ArtifactAdjectiveRegistry.pool("fire", " ")); + assertEquals(List.of(), ArtifactAdjectiveRegistry.pool("unknown", "missing")); + } + + @Test + void buildsRequireEnabledDependenciesAndValidTemplates() { + var builder = new ArtifactItemBuilder(); + assertNull(builder.build(null)); + assertNull(builder.build(ArtifactRoll.error("bad"))); + assertNull(builder.build(roll())); + var mmo = MockBukkit.createMockPlugin("MMOItems"); + assertNull(builder.build(roll(), null, null)); + var mythic = MockBukkit.createMockPlugin("MythicLib"); + try (var libs = mockStatic(TLibs.class)) { + var api = mock(ItemAPI.class, RETURNS_DEEP_STUBS); + libs.when(TLibs::getItemAPI).thenReturn(api); + assertNull(builder.build(ArtifactRoll.ok("missing", "common", List.of()))); + assertNull(builder.build(ArtifactRoll.ok("fire", "missing", List.of()))); + when(api.getCreator().getItemFromPath(anyString())).thenReturn(null); + assertNull(builder.build(roll())); + when(api.getCreator().getItemFromPath(anyString())).thenReturn(new ItemStack(Material.AIR)); + assertNull(builder.build(roll())); + } + server.getPluginManager().disablePlugin(mythic); + assertNull(builder.build(roll())); + server.getPluginManager().disablePlugin(mmo); + assertNull(builder.build(roll())); + } + + @Test + void builtItemsCarryUniqueIdentityAuraModelAndUpdatedNameHistory() { + MockBukkit.createMockPlugin("MMOItems"); + MockBukkit.createMockPlugin("MythicLib"); + var builder = new ArtifactItemBuilder(new Random(1)); + try (var libs = mockStatic(TLibs.class); + var nbt = mockStatic(NBTItem.class); + var models = mockStatic(net.tfminecraft.magic.util.LegacyModelData.class)) { + var api = mock(ItemAPI.class, RETURNS_DEEP_STUBS); + libs.when(TLibs::getItemAPI).thenReturn(api); + when(api.getCreator().getItemFromPath(anyString())).thenReturn(new ItemStack(Material.STONE)); + nbt.when(() -> NBTItem.get(any(ItemStack.class))).thenReturn(mock(NBTItem.class)); + StringData oldName = new StringData("Old"); + NameData original = new NameData("Old"); + var history = mock(StatHistory.class); + when(history.getOriginalData()).thenReturn(original); + try (var live = + mockConstruction( + LiveMMOItem.class, + withSettings().defaultAnswer(RETURNS_DEEP_STUBS), + (m, c) -> { + when(m.getData(ItemStats.NAME)).thenReturn(oldName); + when(m.computeStatHistory(ItemStats.NAME)).thenReturn(history); + when(m.newBuilder().build()).thenAnswer(i -> new ItemStack(Material.STONE)); + })) { + var result = builder.build(roll(), "Spark", "v.STICK.42"); + assertNotNull(result); + assertEquals(Material.STICK, result.getType()); + models.verify(() -> net.tfminecraft.magic.util.LegacyModelData.set(any(), eq(42))); + assertNotNull(ArtifactIds.read(result)); + assertEquals( + "common", + result + .getItemMeta() + .getPersistentDataContainer() + .get(ArtifactKeys.artifactRarity(), PersistentDataType.STRING)); + assertEquals(10, Artifact.fromItem(result).getCap("fire")); + assertEquals(0, Artifact.fromItem(result).getFill("fire")); + assertTrue(original.getString().contains("Spark")); + assertEquals(original.getString(), oldName.getString()); + assertNotNull(builder.build(roll(), null, null, true)); + ArtifactModelSchemeRegistry.register( + new ArtifactModelScheme( + "models", List.of(new ArtifactModelEntry("wand", " ", null, null)))); + assertNotNull(builder.build(roll(), null, null, true)); + ArtifactModelSchemeRegistry.register( + new ArtifactModelScheme( + "models", List.of(new ArtifactModelEntry("wand", "v.STICK", null, null)))); + ArtifactNamingSchemeRegistry.register( + new ArtifactNamingScheme("names", Map.of("wand", List.of(" ")))); + assertNotNull(builder.build(roll(), " ", " ", true)); + ArtifactNamingSchemeRegistry.register( + new ArtifactNamingScheme("names", Map.of("wand", List.of("Wand")))); + assertNotNull(builder.build(roll(), null, null, true)); + assertNotNull( + builder.build( + ArtifactRoll.ok( + "fire", + "common", + Arrays.asList( + null, + new ArtifactAuraSlot(null, 1), + new ArtifactAuraSlot(" ", 1), + new ArtifactAuraSlot("fire", 10))), + "Spark", + "v.STICK")); + for (String path : List.of("unknown.model", "v", "v.BAD_MATERIAL", "v.STICK.bad")) { + assertNotNull(builder.build(roll(), "Spark", path, true)); + } + assertNotNull(builder.build(roll(), "Spark", null, true)); + when(api.getArmorMerger().merge(any(), any(), eq("ia.model"))) + .thenAnswer(i -> i.getArgument(0)); + assertNotNull(builder.build(roll(), "Spark", "ia.model", true)); + } + var failedBuilds = new java.util.concurrent.atomic.AtomicInteger(); + try (var live = + mockConstruction( + LiveMMOItem.class, + withSettings().defaultAnswer(RETURNS_DEEP_STUBS), + (m, c) -> { + when(m.getData(ItemStats.NAME)).thenReturn(null); + when(m.computeStatHistory(ItemStats.NAME)).thenReturn(null); + when(m.newBuilder().build()) + .thenAnswer( + i -> + failedBuilds.getAndIncrement() == 0 + ? null + : new ItemStack(Material.AIR)); + })) { + assertNull(builder.build(roll(), null, null, true)); + assertNull(builder.build(roll(), null, null, true)); + } + } + } + + @Test + void invalidModelsPreserveMaterialAndAllExistingMetadata() throws Exception { + var item = new ItemStack(Material.STONE); + var meta = item.getItemMeta(); + meta.setDisplayName("Keep me"); + meta.getPersistentDataContainer() + .set(ArtifactKeys.artifactPrimary(), PersistentDataType.STRING, "fire"); + item.setItemMeta(meta); + var baseline = item.clone(); + var builder = new ArtifactItemBuilder(); + var apply = + ArtifactItemBuilder.class.getDeclaredMethod( + "applyModel", ItemStack.class, ArtifactTypeDef.class, String.class, boolean.class); + apply.setAccessible(true); + for (String path : List.of("v.AIR", "v.WATER", "v.DIAMOND.bad-number", ".")) { + assertSame( + item, apply.invoke(builder, item, ArtifactTypeRegistry.getById("fire"), path, false)); + assertEquals(baseline, item, path); + } + try (var libs = mockStatic(TLibs.class)) { + var api = mock(ItemAPI.class, RETURNS_DEEP_STUBS); + libs.when(TLibs::getItemAPI).thenReturn(api); + when(api.getArmorMerger().merge(any(), any(), eq("ia.invalid"))) + .thenReturn(null, new ItemStack(Material.AIR)); + for (int i = 0; i < 2; i++) { + assertSame( + item, + apply.invoke(builder, item, ArtifactTypeRegistry.getById("fire"), "ia.invalid", false)); + assertEquals(baseline, item); + } + } + } +} diff --git a/src/test/java/net/tfminecraft/magic/ArtifactConfigDomainEdgeTest.java b/src/test/java/net/tfminecraft/magic/ArtifactConfigDomainEdgeTest.java new file mode 100644 index 0000000..09f0a92 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/ArtifactConfigDomainEdgeTest.java @@ -0,0 +1,82 @@ +package net.tfminecraft.magic; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.*; +import net.tfminecraft.magic.artifact.config.*; +import org.junit.jupiter.api.*; + +class ArtifactConfigDomainEdgeTest { + @BeforeEach + @AfterEach + void reset() { + ArtifactTypeRegistry.clear(); + ArtifactRarityRegistry.clear(); + ArtifactNamingSchemeRegistry.clear(); + ArtifactModelSchemeRegistry.clear(); + ArtifactAdjectiveRegistry.clear(); + ArtifactAffinityRegistry.clear(); + } + + @Test + void registriesRejectMissingAndBlankIdsAndExposeIds() { + for (String id : Arrays.asList(null, " ")) { + ArtifactTypeRegistry.register(new ArtifactTypeDef(id, "", "", true, 1, Map.of(), Map.of())); + ArtifactRarityRegistry.register(new ArtifactRarityDef(id, "", "", 1, 1, 1, null)); + ArtifactNamingSchemeRegistry.register(new ArtifactNamingScheme(id, null)); + ArtifactModelSchemeRegistry.register(new ArtifactModelScheme(id, List.of())); + } + ArtifactTypeRegistry.register(null); + ArtifactRarityRegistry.register(null); + ArtifactNamingSchemeRegistry.register(null); + ArtifactModelSchemeRegistry.register(null); + assertEquals(0, ArtifactTypeRegistry.size()); + assertEquals(0, ArtifactRarityRegistry.size()); + ArtifactNamingSchemeRegistry.register(new ArtifactNamingScheme("names", null)); + ArtifactModelSchemeRegistry.register(new ArtifactModelScheme("models", List.of())); + assertEquals( + List.of("names"), + ArtifactNamingSchemeRegistry.getAll().stream().map(ArtifactNamingScheme::getId).toList()); + assertEquals( + List.of("models"), + ArtifactModelSchemeRegistry.getAll().stream().map(ArtifactModelScheme::getId).toList()); + } + + @Test + void capLookupsAreCaseInsensitiveAndHighestRangeWins() { + var caps = new LinkedHashMap(); + caps.put(null, new CapRange(0, 5)); + caps.put("COMMON", new CapRange(1, 10)); + caps.put("rare", new CapRange(1, 30)); + caps.put("low", new CapRange(1, 20)); + var type = new ArtifactTypeDef("fire", "names", "models", true, 1, caps, caps); + assertEquals(10, type.getCap(" common ").getMax()); + assertEquals(30, type.highestCap().getMax()); + assertTrue(type.hasRarity("COMMON")); + assertFalse(type.hasRarity("missing")); + assertNull(type.getPrimaryCap(null)); + assertNull(type.getSecondaryCap(" ")); + assertEquals(caps, type.getCapsByRarity()); + assertEquals(caps.keySet(), type.getRarityIds()); + assertNull(new ArtifactTypeDef("empty", "", "", true, 1, Map.of(), Map.of()).highestCap()); + assertEquals(0, new ArtifactRarityDef("r", "", "", 1, 1, 1, null).getAuraCapMax()); + } + + @Test + void modelRangesRequireBothKnownBoundsAndAcceptValidSuffixForms() { + ArtifactRarityRegistry.register(new ArtifactRarityDef("common", "", "", 1, 1, 1, null)); + for (String[] bounds : + new String[][] { + {null, "common"}, + {"common", null}, + {" ", "common"}, + {"missing", "common"}, + {"common", "missing"} + }) + assertFalse( + new ArtifactModelEntry("wand", "v.STICK", bounds[0], bounds[1]).eligible("common")); + assertNotNull(ArtifactModelEntry.parse("wand(v.STICK)rare-")); + assertNotNull(ArtifactModelEntry.parse("wand(v.STICK)-rare")); + assertEquals(List.of(), ArtifactAdjectiveRegistry.pool(" ", "common")); + } +} diff --git a/src/test/java/net/tfminecraft/magic/ArtifactConfigEdgeTest.java b/src/test/java/net/tfminecraft/magic/ArtifactConfigEdgeTest.java new file mode 100644 index 0000000..6ad0246 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/ArtifactConfigEdgeTest.java @@ -0,0 +1,253 @@ +package net.tfminecraft.magic; + +import static org.junit.jupiter.api.Assertions.*; + +import java.nio.file.*; +import java.util.*; +import net.tfminecraft.magic.artifact.*; +import net.tfminecraft.magic.artifact.config.*; +import org.bukkit.Material; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.inventory.ItemStack; +import org.bukkit.persistence.PersistentDataType; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.io.TempDir; + +class ArtifactConfigEdgeTest { + @TempDir Path temp; + ArtifactConfigurationTest f = new ArtifactConfigurationTest(); + + @BeforeEach + void setup() throws Exception { + f.temp = temp; + f.setup(); + org.mockito.Mockito.when(Magic.plugin.namespace()).thenReturn("magic"); + } + + @AfterEach + void cleanup() { + f.cleanup(); + } + + Object call(String name, Class[] types, Object... args) throws Exception { + var m = ArtifactConfigLoader.class.getDeclaredMethod(name, types); + m.setAccessible(true); + return m.invoke(null, args); + } + + YamlConfiguration yaml(String text) throws Exception { + var c = new YamlConfiguration(); + c.loadFromString(text); + return c; + } + + @Test + void duplicateDefinitionsAreRejectedOnRepeatedLoads() throws Exception { + assertTrue(f.load()); + for (String[] pair : + new String[][] { + {"loadModelSchemes", "model-schemes.yml"}, + {"loadNamingSchemes", "naming-schemes.yml"}, + {"loadGenerator", "generator.yml"} + }) + assertEquals( + false, call(pair[0], new Class[] {java.io.File.class}, temp.resolve(pair[1]).toFile())); + var c = yaml("planar_four: [oseni]\n"); + assertEquals( + false, + call( + "loadGroups", + new Class[] {org.bukkit.configuration.ConfigurationSection.class, String.class}, + c, + "test")); + } + + @Test + void rarityAndCapValidationRejectsReversedAndEmptyRangesAndClampsCeiling() throws Exception { + for (String[] values : + new String[][] { + {"2-1", "0-10"}, + {"0-1", "0-10"}, + {"1-1", "5-2"}, + {"1-1", "0-0"}, + {"1-1", "bad"}, + {"1-1", "200-300"} + }) { + var c = yaml("bad: {elements: '" + values[0] + "', aura_cap: '" + values[1] + "'}\n"); + assertEquals( + false, + call( + "loadRarities", + new Class[] {org.bukkit.configuration.ConfigurationSection.class, String.class}, + c, + "test")); + } + var c = yaml("clamped: {elements: '1-2', aura_cap: '0-200'}\n"); + assertEquals( + true, + call( + "loadRarities", + new Class[] {org.bukkit.configuration.ConfigurationSection.class, String.class}, + c, + "test")); + assertEquals(100, ArtifactRarityRegistry.getById("clamped").getAuraCapMax()); + for (String value : List.of("5-2", "0-0", "200-300")) { + assertNull( + call( + "parseCapMap", + new Class[] { + org.bukkit.configuration.ConfigurationSection.class, + String.class, + String.class, + String.class + }, + yaml("common: '" + value + "'\n"), + "test", + "fire", + "aura_cap")); + } + } + + @Test + void missingPoolsAndCaseInsensitiveCapKeysResolveWithoutInventingTypes() throws Exception { + assertTrue(f.load()); + var c = yaml("{}\n"); + assertEquals( + true, + call("loadRandomPool", new Class[] {YamlConfiguration.class, String.class}, c, "test")); + assertFalse(ArtifactGeneratorCache.randomPool.isEmpty()); + ArtifactAffinityRegistry.clear(); + assertEquals( + true, + call("loadRandomPool", new Class[] {YamlConfiguration.class, String.class}, c, "test")); + assertTrue(ArtifactGeneratorCache.randomPool.isEmpty()); + var ranges = Map.of("COMMON", new CapRange(1, 2), "other", new CapRange(3, 4)); + assertSame( + ranges.get("COMMON"), + call("lookupIgnoreCase", new Class[] {Map.class, String.class}, ranges, "common")); + assertNull(call("lookupIgnoreCase", new Class[] {Map.class, String.class}, ranges, "missing")); + } + + @Test + void nonFiniteRangeEndpointsAreRejected() { + for (String raw : List.of("NaN-10", "1-NaN", "Infinity-10", "1-Infinity")) + assertNull(CapRange.parse(raw), raw); + } + + @Test + void optionalAdjectiveSectionsAndBlankWordsAreSupported() throws Exception { + assertTrue(ArtifactAdjectiveLoader.load(null)); + Files.writeString( + temp.resolve("adjectives.yml"), + "global: {common: [' ', 'Hot']}\nby_element: {scalar: bad, fire: {common: [Fire]}}\n"); + ArtifactAdjectiveRegistry.clear(); + assertTrue(ArtifactAdjectiveLoader.load(temp.resolve("adjectives.yml").toFile())); + assertEquals(List.of("Hot", "Fire"), ArtifactAdjectiveRegistry.pool("fire", "common")); + Files.writeString(temp.resolve("adjectives.yml"), ""); + assertTrue(ArtifactAdjectiveLoader.load(temp.resolve("adjectives.yml").toFile())); + } + + @Test + void itemAuraCapsResolvePrimaryRarityAndFallbacks() { + ArtifactTypeRegistry.clear(); + ArtifactRarityRegistry.clear(); + var item = new ItemStack(Material.STONE); + assertEquals(100, ArtifactAuraCaps.forItem(item)); + assertEquals(100, ArtifactAuraCaps.forItem(new ItemStack(Material.AIR))); + var meta = item.getItemMeta(); + meta.setDisplayName("item"); + item.setItemMeta(meta); + assertEquals(100, ArtifactAuraCaps.forItem(item)); + meta.getPersistentDataContainer() + .set(ArtifactKeys.artifactRarity(), PersistentDataType.STRING, "common"); + item.setItemMeta(meta); + ArtifactRarityRegistry.register(new ArtifactRarityDef("common", "Common", "", 1, 1, 1, null)); + assertEquals(100, ArtifactAuraCaps.forItem(item)); + ArtifactRarityRegistry.register( + new ArtifactRarityDef("common", "Common", "", 1, 1, 1, new CapRange(0, 20))); + assertEquals(20, ArtifactAuraCaps.forItem(item)); + meta.getPersistentDataContainer() + .set(ArtifactKeys.artifactPrimary(), PersistentDataType.STRING, " "); + item.setItemMeta(meta); + assertEquals(20, ArtifactAuraCaps.forItem(item)); + meta.getPersistentDataContainer() + .set(ArtifactKeys.artifactPrimary(), PersistentDataType.STRING, "fire"); + item.setItemMeta(meta); + assertEquals(20, ArtifactAuraCaps.forItem(item)); + Cache.artifactAuraCap = 0; + assertEquals(150, ArtifactAuraCaps.forItem(null)); + assertEquals(20, ArtifactAuraCaps.primaryRange(" ", "common").getMax()); + } + + @Test + void nullableHelpersAndUnknownTypesHavePredictableFallbacks() throws Exception { + assertEquals( + true, call("loadExcludes", new Class[] {List.class, String.class}, List.of(), "test")); + assertEquals(true, call("loadExcludes", new Class[] {List.class, String.class}, null, "test")); + assertEquals( + true, + call( + "loadExcludes", + new Class[] {List.class, String.class}, + Arrays.asList((Object) null), + "test")); + assertNull(call("lookupIgnoreCase", new Class[] {Map.class, String.class}, null, "common")); + assertNull(call("lookupIgnoreCase", new Class[] {Map.class, String.class}, Map.of(), null)); + var map = new LinkedHashMap(); + map.put(null, new CapRange(1, 2)); + assertNull(call("lookupIgnoreCase", new Class[] {Map.class, String.class}, map, "common")); + assertNull(call("loadYaml", new Class[] {java.io.File.class}, (Object) null)); + var config = YamlConfiguration.loadConfiguration(temp.resolve("generator.yml").toFile()); + var type = config.getConfigurationSection("types").getKeys(false).iterator().next(); + for (String mode : List.of("blank", "missingPrimary", "missingSecondary", "case")) { + f.reset(); + f.mutate( + "generator.yml", + c -> { + String root = "types." + type; + c.set(root + ".rarities", Map.of(" ", 1, "COMMON", 1)); + if (mode.equals("missingPrimary")) c.set(root + ".aura_cap", Map.of("other", "1-10")); + if (mode.equals("missingSecondary")) + c.set(root + ".secondary_cap", Map.of("other", "1-10")); + if (mode.equals("blank")) { + c.set(root + ".aura_cap", Map.of(" ", "1-10")); + c.set(root + ".secondary_cap", Map.of(" ", "1-10")); + } + }); + assertEquals(mode.equals("case"), f.load(), mode); + } + f.reset(); + f.mutate( + "generator.yml", + c -> { + var values = c.getConfigurationSection("types." + type).getValues(true); + c.set("types", null); + for (var e : values.entrySet()) c.set("types.unknown." + e.getKey(), e.getValue()); + c.set("random_pool", List.of("unknown")); + c.set("affinity.groups", null); + }); + assertTrue(f.load()); + } + + @Test + void unrepresentableElementCountsAndNonFiniteTypeWeightsAreRejected() throws Exception { + f.mutate("generator.yml", y -> y.set("types.oseni.disable", true)); + assertTrue(f.load()); + f.reset(); + var c = yaml("bad: {elements: '1-2147483648', aura_cap: '0-10'}\n"); + assertEquals( + false, + call( + "loadRarities", + new Class[] {org.bukkit.configuration.ConfigurationSection.class, String.class}, + c, + "test")); + var config = YamlConfiguration.loadConfiguration(temp.resolve("generator.yml").toFile()); + var type = config.getConfigurationSection("types").getKeys(false).iterator().next(); + for (double weight : new double[] {Double.NaN, Double.POSITIVE_INFINITY}) { + f.reset(); + f.mutate("generator.yml", y -> y.set("types." + type + ".weight", weight)); + assertFalse(f.load()); + } + } +} diff --git a/src/test/java/net/tfminecraft/magic/ArtifactConfigurationTest.java b/src/test/java/net/tfminecraft/magic/ArtifactConfigurationTest.java new file mode 100644 index 0000000..56e5c8e --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/ArtifactConfigurationTest.java @@ -0,0 +1,292 @@ +package net.tfminecraft.magic; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.nio.file.*; +import java.util.*; +import java.util.function.Consumer; +import java.util.logging.Logger; +import net.tfminecraft.magic.artifact.config.*; +import net.tfminecraft.magic.artifact.sacrifice.*; +import net.tfminecraft.magic.artifact.shrine.*; +import net.tfminecraft.magic.loader.ElementsLoader; +import org.bukkit.configuration.file.YamlConfiguration; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.io.TempDir; +import org.mockbukkit.mockbukkit.MockBukkit; + +class ArtifactConfigurationTest { + @TempDir Path temp; + + @BeforeEach + void setup() throws Exception { + MockBukkit.mock(); + Magic.plugin = mock(Magic.class); + when(Magic.plugin.getLogger()).thenReturn(Logger.getAnonymousLogger()); + new ElementsLoader().loadFolder(Path.of("src/main/resources/elements").toFile()); + Cache.artifactAuraCap = 100; + reset(); + } + + @AfterEach + void cleanup() { + Magic.plugin = null; + MockBukkit.unmock(); + } + + void reset() throws Exception { + try (var files = Files.list(Path.of("src/main/resources/artifacts"))) { + for (Path file : files.toList()) + Files.copy(file, temp.resolve(file.getFileName()), StandardCopyOption.REPLACE_EXISTING); + } + } + + void mutate(String name, Consumer change) throws Exception { + var file = temp.resolve(name).toFile(); + var yaml = YamlConfiguration.loadConfiguration(file); + change.accept(yaml); + yaml.save(file); + } + + boolean load() { + return ArtifactConfigLoader.loadFolder(temp.toFile()); + } + + @Test + void missingFolderMissingFilesAndMalformedYamlFailWithoutStaleRegistries() throws Exception { + assertFalse(ArtifactConfigLoader.loadFolder(null)); + assertFalse(ArtifactConfigLoader.loadFolder(temp.resolve("absent").toFile())); + assertFalse(ArtifactConfigLoader.loadFolder(temp.resolve("generator.yml").toFile())); + for (String name : + List.of( + "model-schemes.yml", + "naming-schemes.yml", + "generator.yml", + "adjectives.yml", + "shrines.yml", + "sacrifice.yml")) { + reset(); + Files.delete(temp.resolve(name)); + assertEquals( + List.of("adjectives.yml", "shrines.yml", "sacrifice.yml").contains(name), load(), name); + reset(); + Files.writeString(temp.resolve(name), "broken: [unterminated"); + assertEquals(List.of("shrines.yml", "sacrifice.yml").contains(name), load(), name); + } + } + + @Test + void malformedModelNamingAndRarityEntriesAreRejected() throws Exception { + for (String content : + List.of( + "' ': {models: [sword.1]}\n", + "scalar: bad\n", + "empty: {}\n", + "bad: {models: [broken]}\n")) { + reset(); + Files.writeString(temp.resolve("model-schemes.yml"), content); + assertFalse(load()); + } + for (String content : + List.of("' ': {sword: [name]}\n", "scalar: bad\n", "empty: {sword: [' ', '']}\n")) { + reset(); + Files.writeString(temp.resolve("naming-schemes.yml"), content); + assertFalse(load()); + } + for (Object entry : + List.of( + "scalar", + Map.of("elements", "bad"), + Map.of("elements", "0"), + Map.of("elements", "1", "aura_cap", "bad"), + Map.of("elements", "1", "aura_cap", "0"), + Map.of("elements", "1", "aura_cap", "101"))) { + reset(); + mutate("generator.yml", c -> c.set("rarities", Map.of("bad", entry))); + assertFalse(load()); + } + reset(); + mutate( + "generator.yml", + c -> c.set("rarities", Map.of(" ", Map.of("elements", "1", "aura_cap", "10")))); + assertFalse(load()); + } + + @Test + void missingGeneratorFieldsInvalidGroupsAndExcludesAreDiagnosed() throws Exception { + for (String path : List.of("template", "rarities", "types")) { + reset(); + mutate("generator.yml", c -> c.set(path, null)); + assertFalse(load(), path); + } + reset(); + mutate("generator.yml", c -> c.set("affinity.groups", Map.of(" ", List.of("fire")))); + assertFalse(load()); + reset(); + mutate( + "generator.yml", + c -> { + c.set("affinity.groups", Map.of("custom", Arrays.asList("fire", "missing", " "))); + c.set( + "affinity.exclude", + List.of( + Map.of("never", "fire", "with", List.of("water")), + Map.of("never", List.of("fire")))); + }); + assertFalse(load()); + reset(); + mutate( + "generator.yml", + c -> + c.set( + "affinity.exclude", + List.of( + Map.of( + "never", + Arrays.asList(null, " ", "missing", 12), + "with", + List.of("missing-two"))))); + assertTrue(load()); + } + + @Test + void invalidTypeFieldsAndCapRangesFailAndPoolAliasesRemainCaseInsensitive() throws Exception { + String type = + YamlConfiguration.loadConfiguration(temp.resolve("generator.yml").toFile()) + .getConfigurationSection("types") + .getKeys(false) + .iterator() + .next(); + for (String path : + List.of("naming-scheme", "model-scheme", "aura_cap", "secondary_cap", "rarities")) { + reset(); + mutate("generator.yml", c -> c.set("types." + type + "." + path, null)); + assertFalse(load(), path); + } + for (String field : List.of("naming-scheme", "model-scheme")) { + reset(); + mutate("generator.yml", c -> c.set("types." + type + "." + field, "unknown")); + assertFalse(load()); + } + reset(); + mutate("generator.yml", c -> c.set("types." + type + ".weight", 0)); + assertFalse(load()); + for (String bad : List.of("invalid", "0", "101")) { + reset(); + mutate("generator.yml", c -> c.set("types." + type + ".aura_cap", Map.of("common", bad))); + assertFalse(load()); + } + reset(); + mutate("generator.yml", c -> c.set("types", Map.of(" ", "scalar", "bad", "scalar"))); + assertFalse(load()); + reset(); + mutate("generator.yml", c -> c.set("random_pool", List.of("unknown"))); + assertFalse(load()); + reset(); + mutate( + "generator.yml", + c -> c.set("random_pool", Arrays.asList("", type, type.toUpperCase(Locale.ROOT)))); + assertTrue(load()); + assertEquals(List.of(type), ArtifactGeneratorCache.randomPool); + reset(); + mutate("generator.yml", c -> c.set("random_pool", type)); + assertTrue(load()); + Cache.artifactAuraCap = 0; + Cache.debug = true; + assertTrue(load()); + Cache.debug = false; + } + + @Test + void sacrificeLegacyDaggerSectionsResolveActualItemPaths() throws Exception { + var cases = new LinkedHashMap, String>(); + cases.put(Map.of("path", "v.STONE"), "v.STONE"); + cases.put(Map.of("itemsadder", "runes:dagger"), "ia.runes:dagger"); + cases.put(Map.of("itemsadder", "ia.runes:dagger"), "ia.runes:dagger"); + cases.put( + Map.of("material", "GOLDEN_SWORD", "custom_model_data", 12), + "modeled.(type=golden_sword;model=12)"); + cases.put(Map.of("material", ""), "v.golden_sword"); + for (var entry : cases.entrySet()) { + reset(); + mutate("sacrifice.yml", c -> c.set("dagger", entry.getKey())); + assertTrue(SacrificeConfigLoader.load(temp.resolve("sacrifice.yml").toFile())); + assertEquals(entry.getValue(), SacrificeRegistry.getDagger().getPath()); + } + } + + @Test + void shrineConfigurationSkipsInvalidEntriesAndKeepsValidFamiliesAndFx() throws Exception { + var file = temp.resolve("shrines.yml"); + Files.writeString( + file, + """ + radius: 2 + fx: + scalar: bad + ' ': {} + default: + ambient_sound: missing_sound + start_sound: ' ' + trail_particle: invalid_particle + emitter_particle: '' + oseni: {complete_sound: block.beacon.activate, burst_particle: flame} + elements: + missing: {} + oseni: + families: + scalar: bad + ' ': {} + empty: {blocks: [UNKNOWN, STICK, ''], tags: [' ', missing_tag]} + good: {blocks: [STONE], tags: [minecraft:logs]} + seithr: scalar + cerrith: {} + mitlan: {families: {}} + """); + ShrineRegistry.clear(); + Cache.debug = true; + assertTrue(ShrineConfigLoader.load(file.toFile())); + Cache.debug = false; + assertEquals(1, ShrineRegistry.size()); + assertEquals(1, ShrineRegistry.getById("oseni").getFamilies().size()); + assertEquals(org.bukkit.Particle.FLAME, ShrineRegistry.fx("oseni").getBurstParticle()); + Files.writeString(file, ""); + assertTrue(ShrineConfigLoader.load(file.toFile())); + assertTrue(ShrineConfigLoader.load(null)); + } + + @Test + void sacrificeConfigurationHandlesEmptyTiersDuplicateWordsAndOptionalLore() throws Exception { + var file = temp.resolve("sacrifice.yml"); + Files.writeString( + file, + """ + dagger: '' + case_insensitive: true + tiers: {scalar: bad, none: {min: 0, below: 1}} + fx: {surge_tiers: [' ', soul], surge_sound: invalid_sound, boom_sound: ' '} + elements: + missing: {} + seithr: scalar + oseni: {words: [' ', SAME, SAME]} + cerrith: {words: [same], lore: {death: old soul}, min_scenery_aura: 3} + """); + SacrificeRegistry.clear(); + Cache.debug = true; + assertTrue(SacrificeConfigLoader.load(file.toFile())); + Cache.debug = false; + assertEquals(2, SacrificeRegistry.size()); + assertEquals(2, SacrificeRegistry.getById("oseni").getWords().size()); + mutate( + "sacrifice.yml", + c -> { + c.set("case_insensitive", false); + c.set("tiers", null); + c.set("fx", null); + c.set("elements", null); + }); + assertTrue(SacrificeConfigLoader.load(file.toFile())); + assertTrue(SacrificeConfigLoader.load(null)); + } +} diff --git a/src/test/java/net/tfminecraft/magic/ArtifactCreateEdgeTest.java b/src/test/java/net/tfminecraft/magic/ArtifactCreateEdgeTest.java new file mode 100644 index 0000000..b540525 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/ArtifactCreateEdgeTest.java @@ -0,0 +1,115 @@ +package net.tfminecraft.magic; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.*; +import net.tfminecraft.magic.artifact.config.*; +import net.tfminecraft.magic.artifact.create.*; +import net.tfminecraft.magic.artifact.generate.*; +import net.tfminecraft.magic.artifact.sacrifice.*; +import net.tfminecraft.magic.artifact.shrine.*; +import org.junit.jupiter.api.*; + +class ArtifactCreateEdgeTest { + ArtifactCreateTest f = new ArtifactCreateTest(); + + @BeforeEach + void setup() { + f.setup(); + Cache.artifactAuraCap = 100; + } + + @AfterEach + void cleanup() { + f.cleanup(); + ShrineRegistry.clear(); + SacrificeRegistry.clear(); + } + + @Test + void invisibleSecondariesAreRefusedAndHiddenExistingCapsAreOmitted() { + var s = new ArtifactCreateSession(); + s.setCap("fire", 10); + s.setPrimaryId("fire"); + ShrineRegistry.register(new ShrineElementDef("water", false, List.of())); + SacrificeRegistry.setEnabled(false); + assertFalse(s.cycleElement("water")); + s.setCap("water", 5); + assertEquals(1, s.toRoll().getSlots().size()); + s.setPrimaryId("water"); + s.setPrimaryId("fire"); + assertEquals(0, s.getCap("water")); + } + + @Test + void publicSelectionChangesHandleBlankMissingAndRemovedPrimaries() { + var s = new ArtifactCreateSession(); + s.setRarityId("common"); + s.setCap("fire", 20); + s.setPrimaryId(" "); + assertEquals(20, s.getCap("fire")); + assertNull(s.getPrimaryId()); + s.setCap("fire", 10); + s.setPrimaryId("fire"); + s.setCap("fire", 0); + s.ensurePreviewLock(new ArtifactItemBuilder()); + assertNull(s.getPreviewBaseName()); + s.setRarityId("common"); + assertNull(s.getPrimaryId()); + s.setCap("unknown", 1); + s.setPrimaryId("unknown"); + assertNull(s.toRoll()); + assertEquals(.01, s.capRange(null).getMin()); + } + + @Test + void zeroConfigCapsStillGivePositiveEditorBounds() { + ArtifactTypeRegistry.register( + new ArtifactTypeDef( + "fire", "name", "model", true, 1, Map.of("common", new CapRange(0, 0)), Map.of())); + var s = new ArtifactCreateSession(); + assertEquals(.01, s.capRange("fire").getMin()); + assertEquals(.01, s.capRange("fire").getMax()); + } + + @Test + void previewWithoutModelsAndEmptyNamesIsRetriedAndRarityChangesInvalidateIt() { + var s = new ArtifactCreateSession(); + s.setCap("fire", 10); + s.setPrimaryId("fire"); + var b = mock(ArtifactItemBuilder.class); + var t = ArtifactTypeRegistry.getById("fire"); + when(b.pickBaseName(eq(t), nullable(String.class), eq("common"))).thenReturn("", "Spark"); + s.ensurePreviewLock(b); + assertEquals("", s.getPreviewModelPath()); + s.ensurePreviewLock(b); + assertEquals("Spark", s.getPreviewBaseName()); + s.ensurePreviewLock(b); + verify(b, times(2)).pickModel(t, "common"); + s.setCap("water", 1); + s.setPrimaryId("water"); + s.ensurePreviewLock(b); + assertNull(s.getPreviewBaseName()); + s.ensurePreviewLock(b); + s.setRarityId(null); + s.ensurePreviewLock(b); + s.ensurePreviewLock(b); + assertNull(s.getPreviewBaseName()); + } + + @Test + void nonFiniteCapsAndDeltasCannotPoisonPreviewAmounts() { + var s = new ArtifactCreateSession(); + s.setCap("fire", 10); + s.setPrimaryId("fire"); + s.setSelectedElementId("fire"); + for (double bad : + new double[] {Double.NaN, Double.POSITIVE_INFINITY, Double.NEGATIVE_INFINITY}) { + s.setCap("fire", bad); + assertEquals(10, s.getCap("fire")); + assertFalse(s.adjustSelectedCap(bad)); + assertEquals(10, s.getCap("fire")); + } + } +} diff --git a/src/test/java/net/tfminecraft/magic/ArtifactCreateTest.java b/src/test/java/net/tfminecraft/magic/ArtifactCreateTest.java new file mode 100644 index 0000000..1bbe420 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/ArtifactCreateTest.java @@ -0,0 +1,147 @@ +package net.tfminecraft.magic; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.*; +import net.tfminecraft.magic.artifact.config.*; +import net.tfminecraft.magic.artifact.create.ArtifactCreateSession; +import net.tfminecraft.magic.artifact.create.ArtifactCreateSession.ElementRole; +import net.tfminecraft.magic.artifact.generate.ArtifactItemBuilder; +import net.tfminecraft.magic.artifact.sacrifice.SacrificeRegistry; +import net.tfminecraft.magic.artifact.shrine.ShrineRegistry; +import org.junit.jupiter.api.*; + +class ArtifactCreateTest { + @BeforeEach + void setup() { + ArtifactRarityRegistry.clear(); + ArtifactTypeRegistry.clear(); + ArtifactAffinityRegistry.clear(); + ShrineRegistry.clear(); + SacrificeRegistry.clear(); + ArtifactRarityRegistry.register(ArtifactGenerationTest.rarity("common", 1, 1, 3)); + ArtifactTypeRegistry.register(ArtifactGenerationTest.type("fire", true, 1)); + ArtifactTypeRegistry.register(ArtifactGenerationTest.type("water", true, 1)); + ArtifactTypeRegistry.register(ArtifactGenerationTest.type("disabled", false, 1)); + } + + @AfterEach + void cleanup() { + ArtifactRarityRegistry.clear(); + ArtifactTypeRegistry.clear(); + ArtifactAffinityRegistry.clear(); + } + + @Test + void selectionCyclesOffSecondaryPrimaryAndBackOff() { + var s = new ArtifactCreateSession(); + assertEquals("common", s.getRarityId()); + assertNull(s.toRoll()); + assertEquals(0, s.getCap(null)); + s.setCap(null, 2); + s.setCap(" ", 2); + assertEquals(Map.of(), s.getCaps()); + assertEquals(ElementRole.OFF, s.roleOf(null)); + assertEquals(ElementRole.OFF, s.roleOf(" ")); + assertFalse(s.cycleElement(null)); + assertFalse(s.cycleElement(" ")); + assertFalse(s.cycleElement("missing")); + assertFalse(s.cycleElement("disabled")); + assertTrue(s.cycleElement("fire")); + assertEquals("fire", s.getSelectedElementId()); + assertEquals(ElementRole.SECONDARY, s.roleOf("fire")); + assertEquals(20, s.getCap("fire")); + assertTrue(s.cycleElement("fire")); + assertEquals("fire", s.getPrimaryId()); + assertEquals(ElementRole.PRIMARY, s.roleOf("fire")); + assertNotNull(s.toRoll()); + assertTrue(s.cycleElement("water")); + assertEquals(5, s.getCap("water")); + assertEquals(2, s.toRoll().getSlots().size()); + assertTrue(s.cycleElement("fire")); + assertNull(s.getPrimaryId()); + assertEquals(0, s.getCap("fire")); + assertNull(s.toRoll()); + } + + @Test + void capAdjustmentsRespectPrimarySecondaryBoundsAndRarityChanges() { + var s = new ArtifactCreateSession(); + assertFalse(s.adjustSelectedCap(1)); + s.setSelectedElementId(" "); + assertFalse(s.adjustSelectedCap(1)); + s.setSelectedElementId("fire"); + assertFalse(s.adjustSelectedCap(1)); + s.setCap("fire", 20); + s.setPrimaryId("fire"); + assertTrue(s.adjustSelectedCap(-1)); + assertEquals(19, s.getCap("fire")); + assertTrue(s.adjustSelectedCap(-100)); + assertEquals(10, s.getCap("fire")); + assertFalse(s.adjustSelectedCap(-1)); + s.setCap("water", 100); + s.setRarityId("common"); + assertEquals(5, s.getCap("water")); + assertEquals(1, s.capRange("water").getMin()); + assertEquals(20, s.capRange("fire").getMax()); + s.setRarityId("missing"); + assertNull(s.toRoll()); + s.setRarityId("common"); + s.setCap("fire", 0); + assertNull(s.toRoll()); + s.setPrimaryId("missing"); + assertNull(s.toRoll()); + s.setCap("disabled", 10); + s.setPrimaryId("disabled"); + assertNull(s.toRoll()); + assertEquals("disabled", s.getPrimaryId()); + } + + @Test + void exclusionsPreventConflictingSelectionAndPreviewLocksUntilChanged() { + var s = new ArtifactCreateSession(); + s.cycleElement("fire"); + s.cycleElement("fire"); + ArtifactAffinityRegistry.registerExclude(List.of("fire"), List.of("water")); + assertFalse(s.cycleElement("water")); + var builder = mock(ArtifactItemBuilder.class); + var model = new ArtifactModelEntry("wand", "v.STICK", null, null); + var type = ArtifactTypeRegistry.getById("fire"); + when(builder.pickModel(type, "common")).thenReturn(model); + when(builder.pickBaseName(type, "wand", "common")).thenReturn("Spark"); + s.ensurePreviewLock(builder); + assertEquals("Spark", s.getPreviewBaseName()); + assertEquals("v.STICK", s.getPreviewModelPath()); + s.ensurePreviewLock(builder); + verify(builder, times(1)).pickModel(type, "common"); + s.setPrimaryId("fire"); + assertEquals("Spark", s.getPreviewBaseName()); + s.setRarityId("common"); + assertNull(s.getPreviewBaseName()); + s.ensurePreviewLock(null); + assertNull(s.getPreviewModelPath()); + } + + @Test + void missingDefaultsAndRemovedTypesDoNotProduceArtifacts() { + ArtifactRarityRegistry.clear(); + assertEquals("", new ArtifactCreateSession().getRarityId()); + ArtifactRarityRegistry.register(ArtifactGenerationTest.rarity("rare", 1, 1, 1)); + assertEquals("rare", new ArtifactCreateSession().getRarityId()); + var s = new ArtifactCreateSession(); + s.ensurePreviewLock(mock(ArtifactItemBuilder.class)); + s.setCap("missing", 10); + s.setPrimaryId("missing"); + assertEquals("missing", s.getPrimaryId()); + s.setCap("fire", 10); + s.setPrimaryId("fire"); + ArtifactTypeRegistry.clear(); + s.ensurePreviewLock(mock(ArtifactItemBuilder.class)); + assertNull(s.getPreviewBaseName()); + assertNull(s.toRoll()); + assertTrue(ArtifactCreateCache.reservedSlots().contains(ArtifactCreateCache.confirmSlot)); + assertThrows( + UnsupportedOperationException.class, () -> ArtifactCreateCache.reservedSlots().clear()); + } +} diff --git a/src/test/java/net/tfminecraft/magic/ArtifactGenerationTest.java b/src/test/java/net/tfminecraft/magic/ArtifactGenerationTest.java new file mode 100644 index 0000000..1493231 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/ArtifactGenerationTest.java @@ -0,0 +1,300 @@ +package net.tfminecraft.magic; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.*; +import net.tfminecraft.magic.artifact.*; +import net.tfminecraft.magic.artifact.config.*; +import net.tfminecraft.magic.artifact.generate.*; +import net.tfminecraft.magic.artifact.path.*; +import net.tfminecraft.magic.artifact.sacrifice.SacrificeRegistry; +import net.tfminecraft.magic.artifact.shrine.ShrineRegistry; +import org.junit.jupiter.api.*; + +class ArtifactGenerationTest { + @BeforeEach + void reset() { + ArtifactTypeRegistry.clear(); + ArtifactRarityRegistry.clear(); + ArtifactAffinityRegistry.clear(); + ArtifactGeneratorCache.reset(); + ShrineRegistry.clear(); + SacrificeRegistry.clear(); + Cache.artifactAuraCap = 150; + Magic.plugin = null; + } + + @AfterEach + void clear() { + reset(); + } + + static ArtifactTypeDef type(String id, boolean enabled, double weight) { + return new ArtifactTypeDef( + id, + "names", + "models", + enabled, + weight, + Map.of("common", new CapRange(10, 20)), + Map.of("common", new CapRange(1, 5))); + } + + static ArtifactRarityDef rarity(String id, double weight, int min, int max) { + return new ArtifactRarityDef(id, id, "#ffffff", weight, min, max, new CapRange(0, 100)); + } + + void defaults() { + ArtifactRarityRegistry.register(rarity("common", 1, 2, 3)); + ArtifactTypeRegistry.register(type("fire", true, 1)); + ArtifactTypeRegistry.register(type("water", true, 1)); + ArtifactTypeRegistry.register(type("earth", true, 1)); + ArtifactAffinityRegistry.registerGroup("all", List.of("fire", "water", "earth")); + } + + @Test + void capRangesAndDefaultsStayWithinServerLimit() { + for (String value : new String[] {null, " ", "3", "bad-3", "3-bad"}) + assertNull(CapRange.parse(value)); + var range = CapRange.parse(" 1.5 - 20 "); + assertEquals(1.5, range.getMin()); + assertEquals(20, range.getMax()); + assertEquals("1.5-20.0", range.toString()); + assertEquals(150, ArtifactAuraCaps.clampRange(null).getMax()); + assertEquals(0, ArtifactAuraCaps.clampRange(new CapRange(-1, 200)).getMin()); + assertEquals(150, ArtifactAuraCaps.clampRange(new CapRange(-1, 200)).getMax()); + assertEquals(2, ArtifactAuraCaps.clampRange(new CapRange(10, 2)).getMin()); + Cache.artifactAuraCap = 0; + assertEquals(150, ArtifactAuraCaps.clampRange(null).getMax()); + Cache.artifactAuraCap = 150; + defaults(); + assertEquals(100, ArtifactAuraCaps.forRarity("common")); + assertEquals(100, ArtifactAuraCaps.forRarity(" COMMON ")); + assertEquals(150, ArtifactAuraCaps.forRarity(null)); + assertEquals(150, ArtifactAuraCaps.forRarity(" ")); + assertEquals(150, ArtifactAuraCaps.forRarity("missing")); + assertEquals(20, ArtifactAuraCaps.primaryRange(" FIRE ", "common").getMax()); + assertEquals(5, ArtifactAuraCaps.secondaryRange("fire", "common").getMax()); + assertEquals(100, ArtifactAuraCaps.primaryRange(null, "common").getMax()); + assertEquals(100, ArtifactAuraCaps.secondaryRange("missing", "common").getMax()); + assertEquals(150, ArtifactAuraCaps.forItem(null)); + } + + @Test + void pathParserAcceptsTypedExtrasAndRejectsMalformedPaths() { + defaults(); + for (String path : + new String[] { + null, + " ", + "other.artifact", + "magic.", + "magic.bad", + "magic.(", + "magic.(primary)", + "magic.(primary=)", + "magic.(rarity)", + "magic.(rarity=)", + "magic.(fire=bad)" + }) assertNull(ArtifactPathParser.parse(path)); + assertTrue(ArtifactPathParser.parse(" MAGIC.ARTIFACT ").isFullyRandom()); + assertTrue(ArtifactPathParser.parse("magic.()").isFullyRandom()); + var spec = + ArtifactPathParser.parse("magic.(element=FIRE;rarity=COMMON;water=2;earth;unknown=1;;=2)"); + assertEquals("fire", spec.getPrimaryId()); + assertEquals("common", spec.getRarityId()); + assertEquals(2, spec.getExtras().get("water")); + assertTrue(spec.getExtras().containsKey("earth")); + assertNull(spec.getExtras().get("earth")); + assertFalse(spec.isFullyRandom()); + assertEquals("unknown", ArtifactPathParser.parse("magic.(primary=UNKNOWN)").getPrimaryId()); + assertThrows(UnsupportedOperationException.class, () -> spec.getExtras().clear()); + assertFalse(new ArtifactPathSpec(null, "common", null).isFullyRandom()); + assertFalse( + new ArtifactPathSpec(null, null, new LinkedHashMap<>(Map.of("fire", 1.))).isFullyRandom()); + } + + @Test + void affinityGroupsHonorSymmetricExclusionsAndEnabledTypes() { + defaults(); + ArtifactTypeRegistry.register(type("disabled", false, 1)); + ArtifactAffinityRegistry.registerGroup(null, List.of()); + ArtifactAffinityRegistry.registerGroup(" ", List.of()); + ArtifactAffinityRegistry.registerGroup("bad", null); + ArtifactAffinityRegistry.registerGroup( + "second", List.of("FIRE", "WATER", "missing", "disabled")); + ArtifactAffinityRegistry.registerGroup("unrelated", List.of("earth")); + assertEquals(3, ArtifactAffinityRegistry.size()); + assertEquals(List.of(), ArtifactAffinityRegistry.getGroup(null)); + assertEquals(List.of(), ArtifactAffinityRegistry.getGroup(" ")); + assertEquals(List.of(), ArtifactAffinityRegistry.getGroup("missing")); + assertEquals(List.of("fire", "water", "earth"), ArtifactAffinityRegistry.getGroup("ALL")); + assertEquals(3, ArtifactAffinityRegistry.getGroups().size()); + ArtifactAffinityRegistry.registerExclude(null, List.of("fire")); + ArtifactAffinityRegistry.registerExclude(List.of("fire"), List.of()); + ArtifactAffinityRegistry.registerExclude(Arrays.asList(null, " ", " fire "), List.of("earth")); + assertEquals(1, ArtifactAffinityRegistry.excludeSize()); + assertFalse(ArtifactAffinityRegistry.compatible(null, "a")); + assertFalse(ArtifactAffinityRegistry.compatible("a", null)); + assertFalse(ArtifactAffinityRegistry.compatible(" ", "a")); + assertFalse(ArtifactAffinityRegistry.compatible("a", " ")); + assertTrue(ArtifactAffinityRegistry.compatible("fire", "FIRE")); + assertFalse(ArtifactAffinityRegistry.compatible("fire", "earth")); + assertFalse(ArtifactAffinityRegistry.compatible("earth", "fire")); + assertTrue(ArtifactAffinityRegistry.compatible("water", "earth")); + assertFalse(ArtifactAffinityRegistry.compatibleWith(null, null)); + assertFalse(ArtifactAffinityRegistry.compatibleWith(null, " ")); + assertTrue(ArtifactAffinityRegistry.compatibleWith(null, "fire")); + assertTrue(ArtifactAffinityRegistry.compatibleWith(List.of(), "fire")); + assertTrue( + ArtifactAffinityRegistry.compatibleWith(Arrays.asList(null, " ", "fire", "water"), "fire")); + assertFalse(ArtifactAffinityRegistry.compatibleWith(List.of("earth"), "fire")); + assertEquals(List.of(), ArtifactAffinityRegistry.companions(null)); + assertEquals(List.of(), ArtifactAffinityRegistry.companions(" ")); + assertTrue(ArtifactAffinityRegistry.companions("fire").contains("water")); + assertFalse(ArtifactAffinityRegistry.companions("fire").contains("earth")); + } + + @Test + void randomPoolCopiesAndNormalizesLookups() { + assertTrue(ArtifactGeneratorCache.inRandomPool(null)); + ArtifactGeneratorCache.randomPool = null; + assertTrue(ArtifactGeneratorCache.inRandomPool("any")); + ArtifactGeneratorCache.setRandomPool(Arrays.asList(null, "fire")); + assertFalse(ArtifactGeneratorCache.inRandomPool(null)); + assertFalse(ArtifactGeneratorCache.inRandomPool(" ")); + assertFalse(ArtifactGeneratorCache.inRandomPool("water")); + assertTrue(ArtifactGeneratorCache.inRandomPool(" FIRE ")); + ArtifactGeneratorCache.setRandomPool(null); + assertTrue(ArtifactGeneratorCache.inRandomPool("any")); + ArtifactGeneratorCache.setRandomPool(List.of()); + } + + @Test + void rollsReportConfigurationErrorsPrecisely() { + var roller = new ArtifactRoller(new Random(1)); + assertEquals("artifact.roll.empty_rarities", roller.roll(null, null).getErrorKey()); + assertEquals("artifact.roll.unknown_rarity", roller.roll("random", "unknown").getErrorKey()); + assertEquals("artifact.roll.unknown_element", roller.roll("missing", "common").getErrorKey()); + ArtifactTypeRegistry.register(type("disabled", false, 1)); + assertEquals("artifact.roll.disabled", roller.roll("disabled", "common").getErrorKey()); + ArtifactTypeRegistry.register(type("fire", true, 1)); + assertEquals("artifact.roll.empty_rarities", roller.roll("fire", " ").getErrorKey()); + assertEquals("artifact.roll.unknown_rarity", roller.roll("fire", "missing").getErrorKey()); + ArtifactRarityRegistry.register(rarity("rare", 1, 1, 1)); + assertEquals("artifact.roll.no_bracket", roller.roll("fire", "rare").getErrorKey()); + assertEquals("artifact.roll.empty_types", roller.roll("random", "rare").getErrorKey()); + assertEquals( + "artifact.roll.unknown_element", + roller.roll(new ArtifactPathSpec("unknown", null, null)).getErrorKey()); + assertEquals( + "artifact.roll.disabled", + roller.roll(new ArtifactPathSpec("disabled", null, null)).getErrorKey()); + assertEquals( + "artifact.roll.empty_rarities", + roller.roll(new ArtifactPathSpec("fire", "unknown", null)).getErrorKey()); + assertEquals( + "artifact.roll.no_bracket", + roller.roll(new ArtifactPathSpec("fire", "rare", null)).getErrorKey()); + } + + @Test + void seededRandomRollsRespectRangesAffinitiesAndConfiguredPool() { + defaults(); + ArtifactGeneratorCache.setRandomPool(List.of("fire")); + ArtifactTypeRegistry.register(type("disabled", false, 20)); + ArtifactTypeRegistry.register(type("zero", true, 0)); + ArtifactRarityRegistry.register(rarity("zero", 0, 1, 1)); + var roller = new ArtifactRoller(new Random(234)); + for (int i = 0; i < 30; i++) { + var roll = roller.roll("random", "roll"); + assertFalse(roll.isError()); + assertEquals(ArtifactRoll.Status.OK, roll.getStatus()); + assertNull(roll.getErrorKey()); + assertEquals("fire", roll.getPrimaryId()); + assertEquals("common", roll.getRarityId()); + assertTrue(roll.getSlots().size() >= 2); + assertTrue(roll.getSlots().size() <= 3); + assertTrue(roll.getSlots().getFirst().getCap() >= 10); + assertTrue(roll.getSlots().getFirst().getCap() <= 20); + assertEquals( + roll.getSlots().size(), + roll.getSlots().stream().map(ArtifactAuraSlot::getElementId).distinct().count()); + } + assertFalse(roller.roll((ArtifactPathSpec) null).isError()); + assertFalse(roller.roll(new ArtifactPathSpec(" ", "common", null)).isError()); + assertFalse(roller.roll("FIRE", "COMMON").isError()); + assertNotNull(new ArtifactRoller()); + assertNotNull(new ArtifactRoller(null)); + } + + @Test + void explicitRollsClampRequestedCapsAndSkipUnavailableExtras() { + defaults(); + ArtifactTypeRegistry.register(type("disabled", false, 1)); + ArtifactTypeRegistry.register( + new ArtifactTypeDef("nobracket", "", "", true, 1, Map.of(), Map.of())); + var extras = new LinkedHashMap(); + extras.put("fire", 100.); + extras.put("water", -1.); + extras.put("earth", null); + extras.put(null, 1.); + extras.put("missing", 1.); + extras.put("disabled", 1.); + extras.put("nobracket", 1.); + var roll = + new ArtifactRoller(new Random(1)).roll(new ArtifactPathSpec("fire", "common", extras)); + assertFalse(roll.isError()); + assertEquals(3, roll.getSlots().size()); + assertEquals(20, roll.getSlots().getFirst().getCap()); + assertEquals(1, roll.getSlots().get(1).getCap()); + ArtifactAffinityRegistry.registerExclude(List.of("fire"), List.of("water", "earth")); + roll = new ArtifactRoller(new Random(1)).roll(new ArtifactPathSpec("fire", "common", extras)); + assertEquals(1, roll.getSlots().size()); + assertEquals(List.of(), ArtifactRoll.ok("fire", "common", null).getSlots()); + assertTrue(ArtifactRoll.error("bad").isError()); + } + + @Test + void namingAndModelSchemesValidateAndCopyContent() { + defaults(); + ArtifactRarityRegistry.register(rarity("rare", 1, 1, 1)); + ArtifactRarityRegistry.register(rarity("epic", 1, 1, 1)); + var names = new LinkedHashMap>(); + names.put(null, List.of("bad")); + names.put(" ", List.of("bad")); + names.put("missing", null); + names.put("empty", List.of()); + names.put("WAND", List.of("Spark")); + var scheme = new ArtifactNamingScheme("fire", names); + assertEquals("fire", scheme.getId()); + assertFalse(scheme.isEmpty()); + assertEquals(List.of("Spark"), scheme.namesFor("wand")); + assertEquals(List.of(), scheme.namesFor(null)); + assertEquals(List.of(), scheme.namesFor(" ")); + assertEquals(List.of(), scheme.namesFor("missing")); + assertEquals(List.of("Spark"), scheme.firstNonEmptyKindNames()); + assertTrue(new ArtifactNamingScheme("empty", null).isEmpty()); + assertEquals(List.of(), new ArtifactNamingScheme("empty", null).firstNonEmptyKindNames()); + for (String raw : new String[] {null, " ", "bad", "(path)", "wand(", "wand()", " (path)"}) + assertNull(ArtifactModelEntry.parse(raw)); + var model = ArtifactModelEntry.parse("wand(v.STICK)epic-common"); + assertEquals("wand", model.getKind()); + assertEquals("v.STICK", model.getPath()); + assertEquals("epic", model.getMinRarityId()); + assertEquals("common", model.getMaxRarityId()); + assertTrue(model.eligible("rare")); + assertFalse(model.eligible(null)); + assertFalse(model.eligible(" ")); + assertFalse(model.eligible("unknown")); + assertTrue(ArtifactModelEntry.parse("wand(v.STICK)all").eligible("common")); + assertFalse(ArtifactModelEntry.parse("wand(v.STICK)").eligible("unknown")); + assertTrue(ArtifactModelEntry.parse("wand(v.STICK)rare").eligible("rare")); + assertFalse(ArtifactModelEntry.parse("wand(v.STICK)rare").eligible("common")); + assertFalse(ArtifactModelEntry.parse("wand(v.STICK)rare").eligible("epic")); + var models = new ArtifactModelScheme("x", List.of(model)); + assertEquals("x", models.getId()); + assertEquals(List.of(model), models.getModels()); + assertThrows(UnsupportedOperationException.class, () -> models.getModels().clear()); + } +} diff --git a/src/test/java/net/tfminecraft/magic/ArtifactGuiCoverageTest.java b/src/test/java/net/tfminecraft/magic/ArtifactGuiCoverageTest.java new file mode 100644 index 0000000..4081dbe --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/ArtifactGuiCoverageTest.java @@ -0,0 +1,229 @@ +package net.tfminecraft.magic; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.*; +import net.tfminecraft.magic.artifact.config.*; +import net.tfminecraft.magic.artifact.create.*; +import net.tfminecraft.magic.artifact.generate.*; +import net.tfminecraft.magic.gui.*; +import net.tfminecraft.magic.manager.*; +import net.tfminecraft.magic.model.*; +import net.tfminecraft.magic.registry.*; +import org.bukkit.*; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.entity.*; +import org.bukkit.event.inventory.*; +import org.bukkit.event.player.PlayerQuitEvent; +import org.bukkit.inventory.*; +import org.junit.jupiter.api.*; +import org.mockito.*; + +class ArtifactGuiCoverageTest extends GearCoverageSupport { + org.mockbukkit.mockbukkit.entity.PlayerMock p; + MockedConstruction builders; + ItemStack result; + + @BeforeEach + void guiSetup() { + p = server.addPlayer(); + p.openInventory(Bukkit.createInventory(null, 9)); + ArtifactRarityRegistry.clear(); + ArtifactTypeRegistry.clear(); + ArtifactAffinityRegistry.clear(); + ElementRegistry.clear(); + ArtifactRarityRegistry.register(ArtifactGenerationTest.rarity("common", 1, 1, 3)); + ArtifactRarityRegistry.register( + new ArtifactRarityDef("rare", "Rare", "", 1, 1, 3, new CapRange(1, 20))); + ArtifactRarityRegistry.register( + new ArtifactRarityDef("epic", "Epic", null, 1, 1, 3, new CapRange(1, 20))); + for (String id : List.of("fire", "water", "disabled")) + ArtifactTypeRegistry.register(ArtifactGenerationTest.type(id, !id.equals("disabled"), 1)); + element("fire", 21); + element("water", 23); + element("disabled", 24); + element("missing", 25); + element("hidden", -1); + result = item(); + builders = + mockConstruction( + ArtifactItemBuilder.class, + (mock, context) -> { + when(mock.pickModel(any(), any())) + .thenReturn(new ArtifactModelEntry("wand", "v.STICK", null, null)); + when(mock.pickBaseName(any(), any(), any())).thenReturn("Spark"); + when(mock.build(any(), any(), any())).thenAnswer(a -> result); + when(mock.build(any(), any(), any(), anyBoolean())).thenAnswer(a -> result); + }); + } + + @AfterEach + void guiCleanup() { + builders.close(); + ArtifactRarityRegistry.clear(); + ArtifactTypeRegistry.clear(); + ArtifactAffinityRegistry.clear(); + ElementRegistry.clear(); + } + + static ElementDef element(String id, int slot) { + var yaml = new YamlConfiguration(); + yaml.set("slot", slot); + var def = new ElementDef(id, yaml); + ElementRegistry.register(def); + return def; + } + + InventoryClickEvent click(int slot) { + var event = mock(InventoryClickEvent.class); + when(event.getWhoClicked()).thenReturn(p); + when(event.getView()).thenReturn(p.getOpenInventory()); + when(event.getRawSlot()).thenReturn(slot); + return event; + } + + @Test + void builderRendersSelectionsCapsRaritiesAndLockedPreview() { + var holder = new ArtifactCreateGuiHolder(p.getUniqueId()); + assertEquals(p.getUniqueId(), holder.getPlayerUuid()); + var inventory = ArtifactCreateGuiBuilder.build(holder, null); + holder.setInventory(inventory); + assertSame(inventory, holder.getInventory()); + assertEquals(Material.BARRIER, inventory.getItem(ArtifactCreateCache.previewSlot).getType()); + var session = new ArtifactCreateSession(); + session.cycleElement("water"); + session.cycleElement("fire"); + session.cycleElement("fire"); + ArtifactCreateGuiBuilder.populate(inventory, session); + assertEquals(Material.STICK, inventory.getItem(ArtifactCreateCache.previewSlot).getType()); + assertEquals(1, inventory.getItem(ArtifactCreateCache.previewSlot).getAmount()); + assertTrue( + inventory.getItem(21).getItemMeta().getLore().stream() + .anyMatch(x -> x.contains("Primary"))); + assertTrue( + inventory.getItem(23).getItemMeta().getLore().stream() + .anyMatch(x -> x.contains("Secondary"))); + result = null; + ArtifactCreateGuiBuilder.populate(inventory, session); + assertEquals(Material.BARRIER, inventory.getItem(ArtifactCreateCache.previewSlot).getType()); + result = new ItemStack(Material.AIR); + ArtifactCreateGuiBuilder.populate(inventory, session); + assertEquals(Material.BARRIER, inventory.getItem(ArtifactCreateCache.previewSlot).getType()); + } + + @Test + void managerRoutesSelectionCapsAndLifecycleWithoutBottomInventoryActions() { + var manager = new ArtifactCreateGuiManager(); + assertNull(manager.getSession((Player) null)); + assertNull(manager.getSession((UUID) null)); + var nonplayer = click(0); + when(nonplayer.getWhoClicked()).thenReturn(mock(HumanEntity.class)); + manager.onInventoryClick(nonplayer); + manager.onInventoryClick(click(0)); + manager.open(p); + var session = manager.getSession(p); + assertSame(session, manager.getSession(p.getUniqueId())); + manager.onInventoryClick(click(60)); + manager.onInventoryClick(click(-999)); + manager.onInventoryClick(click(ArtifactCreateCache.raritySlots.get(0))); + manager.onInventoryClick(click(ArtifactCreateCache.raritySlots.get(1))); + assertEquals("rare", session.getRarityId()); + manager.onInventoryClick(click(ArtifactCreateCache.capPlusSlot)); + manager.onInventoryClick(click(21)); + manager.onInventoryClick(click(21)); + assertEquals("fire", session.getPrimaryId()); + session.setCap("fire", 10); + var plus = click(ArtifactCreateCache.capPlusSlot); + when(plus.isShiftClick()).thenReturn(true); + manager.onInventoryClick(plus); + assertEquals(15, session.getCap("fire")); + manager.onInventoryClick(click(ArtifactCreateCache.capMinusSlot)); + assertEquals(14, session.getCap("fire")); + session.setCap("fire", 20); + manager.onInventoryClick(click(ArtifactCreateCache.capPlusSlot)); + var drag = mock(InventoryDragEvent.class); + when(drag.getView()).thenReturn(p.getOpenInventory()); + manager.onInventoryDrag(drag); + verify(drag).setCancelled(true); + var close = mock(InventoryCloseEvent.class); + when(close.getPlayer()).thenReturn(p); + when(close.getView()).thenReturn(p.getOpenInventory()); + manager.onInventoryClose(close); + assertNull(manager.getSession(p)); + manager.onInventoryClick(click(21)); + when(close.getPlayer()).thenReturn(mock(HumanEntity.class)); + manager.onInventoryClose(close); + manager.open(p); + manager.onPlayerQuit(new PlayerQuitEvent(p, "quit")); + assertNull(manager.getSession(p)); + manager.open(p); + manager.onInventoryClick(click(ArtifactCreateCache.cancelSlot)); + assertNull(p.getOpenInventory().getTopInventory()); + p.openInventory(Bukkit.createInventory(null, 9)); + when(drag.getView()).thenReturn(p.getOpenInventory()); + manager.onInventoryDrag(drag); + when(close.getView()).thenReturn(p.getOpenInventory()); + when(close.getPlayer()).thenReturn(p); + manager.onInventoryClose(close); + } + + @Test + void confirmRejectsInvalidPreviewsAndGivesOrDropsOneArtifact() { + var manager = new ArtifactCreateGuiManager(); + manager.open(p); + manager.onInventoryClick(click(ArtifactCreateCache.confirmSlot)); + assertTrue( + p.getOpenInventory().getTopInventory().getHolder() instanceof ArtifactCreateGuiHolder); + var session = manager.getSession(p); + session.cycleElement("fire"); + session.cycleElement("fire"); + for (ItemStack invalid : Arrays.asList(null, new ItemStack(Material.AIR))) { + result = invalid; + manager.onInventoryClick(click(ArtifactCreateCache.confirmSlot)); + assertTrue( + p.getOpenInventory().getTopInventory().getHolder() instanceof ArtifactCreateGuiHolder); + } + result = new ItemStack(Material.DIAMOND, 32); + manager.onInventoryClick(click(ArtifactCreateCache.confirmSlot)); + assertEquals( + 1, + Arrays.stream(p.getInventory().getContents()) + .filter(Objects::nonNull) + .filter(i -> i.getType() == Material.DIAMOND) + .mapToInt(ItemStack::getAmount) + .sum()); + manager.open(p); + session = manager.getSession(p); + session.cycleElement("fire"); + session.cycleElement("fire"); + for (int i = 0; i < p.getInventory().getSize(); i++) + p.getInventory().setItem(i, new ItemStack(Material.STONE, 64)); + manager.onInventoryClick(click(ArtifactCreateCache.confirmSlot)); + assertTrue( + p.getWorld().getEntitiesByClass(Item.class).stream() + .anyMatch( + e -> + e.getItemStack().getType() == Material.DIAMOND + && e.getItemStack().getAmount() == 1)); + } + + @Test + void clickOutsideRaritySlotsAndDisabledElementsKeepsSelection() { + var old = ArtifactCreateCache.raritySlots; + try { + ArtifactCreateCache.raritySlots = List.of(10); + var manager = new ArtifactCreateGuiManager(); + manager.open(p); + manager.getSession(p).setRarityId("common"); + manager.onInventoryClick(click(10)); + manager.onInventoryClick(click(1)); + manager.onInventoryClick(click(24)); + assertNull(manager.getSession(p).getPrimaryId()); + ArtifactCreateCache.raritySlots = List.of(); + manager.onInventoryClick(click(1)); + } finally { + ArtifactCreateCache.raritySlots = old; + } + } +} diff --git a/src/test/java/net/tfminecraft/magic/ArtifactListenerTest.java b/src/test/java/net/tfminecraft/magic/ArtifactListenerTest.java new file mode 100644 index 0000000..a8d03b9 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/ArtifactListenerTest.java @@ -0,0 +1,249 @@ +package net.tfminecraft.magic; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.*; +import net.tfminecraft.interactiblefurniture.events.*; +import net.tfminecraft.interactiblefurniture.furniture.*; +import net.tfminecraft.magic.artifact.*; +import net.tfminecraft.magic.artifact.aura.*; +import net.tfminecraft.magic.artifact.fillchest.ArtifactFillChestService; +import net.tfminecraft.magic.artifact.sacrifice.*; +import net.tfminecraft.magic.artifact.shrine.ShrineChargeService; +import net.tfminecraft.magic.attunement.ArtifactDisplayIndex; +import net.tfminecraft.magic.listener.*; +import net.tfminecraft.magic.meditation.*; +import net.tfminecraft.rpcharacters.chat.CharacterChatEvent; +import org.bukkit.*; +import org.bukkit.entity.*; +import org.bukkit.event.block.Action; +import org.bukkit.event.entity.*; +import org.bukkit.event.inventory.*; +import org.bukkit.event.player.*; +import org.bukkit.event.world.ChunkLoadEvent; +import org.bukkit.inventory.*; +import org.junit.jupiter.api.*; +import org.mockbukkit.mockbukkit.*; + +class ArtifactListenerTest { + ServerMock server; + + @BeforeEach + void setup() { + server = MockBukkit.mock(); + server.addSimpleWorld("listeners"); + Magic.plugin = mock(Magic.class); + when(Magic.plugin.namespace()).thenReturn("magic"); + when(Magic.plugin.getServer()).thenReturn(server); + when(Magic.plugin.isEnabled()).thenReturn(true); + SacrificeRegistry.clear(); + MeditationCache.pedestalId = "pedestal"; + } + + @AfterEach + void cleanup() { + ArtifactFillChestService.clearAll(); + SacrificeRegistry.clear(); + Magic.plugin = null; + MockBukkit.unmock(); + } + + @Test + void furnitureEventsStartStopDisplayTrackAndRespectMeditationLocks() { + var listener = new ArtifactListener(); + var furniture = mock(Furniture.class); + when(furniture.getEntityId()).thenReturn(UUID.randomUUID()); + var slot = mock(SlotDefinition.class); + when(slot.getId()).thenReturn("main"); + var player = server.addPlayer(); + var item = new ItemStack(Material.STONE); + var data = Artifact.create(); + data.setCap("fire", 10); + data.persistPdc(item); + var add = mock(FurnitureSlotItemAddEvent.class); + var take = mock(FurnitureSlotItemTakeEvent.class); + var broken = mock(FurnitureBreakEvent.class); + try (var display = mockStatic(ArtifactDisplayIndex.class); + var shrine = mockStatic(ShrineChargeService.class); + var sacrifice = mockStatic(SacrificeRiteService.class); + var meditation = mockStatic(MeditationService.class); + var lore = mockStatic(VesselLore.class)) { + listener.onFurnitureSlotAdd(add); + when(add.getFurniture()).thenReturn(furniture); + listener.onFurnitureSlotAdd(add); + when(add.getSlot()).thenReturn(slot); + listener.onFurnitureSlotAdd(add); + when(furniture.getId()).thenReturn("pedestal"); + listener.onFurnitureSlotAdd(add); + when(add.getItem()).thenReturn(item); + when(add.getPlayer()).thenReturn(player); + display.when(() -> ArtifactDisplayIndex.isDisplayFurniture(furniture)).thenReturn(true); + listener.onFurnitureSlotAdd(add); + display.verify(() -> ArtifactDisplayIndex.add(item)); + shrine.verifyNoInteractions(); + server.getScheduler().performOneTick(); + shrine.verify(() -> ShrineChargeService.tryStart(player, furniture, "main")); + listener.onFurnitureSlotTake(take); + when(take.getFurniture()).thenReturn(furniture); + listener.onFurnitureSlotTake(take); + when(take.getSlot()).thenReturn(slot); + when(take.getItem()).thenReturn(item); + lore.when(() -> VesselLore.updateItem(item)).thenReturn(item); + listener.onFurnitureSlotTake(take); + shrine.verify(() -> ShrineChargeService.stop(furniture.getEntityId(), "main")); + sacrifice.verify(() -> SacrificeRiteService.stop(furniture.getEntityId(), "main")); + verify(take).setItem(item); + listener.onFurnitureSlotTakeDisplay(take); + display.verify(() -> ArtifactDisplayIndex.remove(item)); + when(take.getFurniture()).thenReturn(null); + listener.onFurnitureSlotTakeDisplay(take); + when(take.getFurniture()).thenReturn(furniture); + meditation.when(() -> MeditationService.locksFurniture(furniture)).thenReturn(true); + listener.onFurnitureSlotTake(take); + verify(take).setCancelled(true); + listener.onFurnitureBreakLock(broken); + listener.onFurnitureBreak(broken); + when(broken.getFurniture()).thenReturn(furniture); + listener.onFurnitureBreakLock(broken); + verify(broken).setCancelled(true); + listener.onFurnitureBreak(broken); + shrine.verify(() -> ShrineChargeService.stopAll(furniture.getEntityId())); + sacrifice.verify(() -> SacrificeRiteService.stopAll(furniture.getEntityId())); + var chunk = server.getWorlds().getFirst().getChunkAt(0, 0); + listener.onChunkLoad(new ChunkLoadEvent(chunk, false)); + display.verify(() -> ArtifactDisplayIndex.ensureChunk(chunk)); + listener.onPickup(new EntityPickupItemEvent(mock(Zombie.class), mock(Item.class), 0)); + listener.onPickup(new EntityPickupItemEvent(player, mock(Item.class), 0)); + server.getScheduler().performOneTick(); + listener.onJoin(new PlayerJoinEvent(player, "joined")); + lore.verify(() -> VesselLore.updateInventory(player), times(2)); + } + } + + @Test + void inventoryCareRoutesClickedCursorHotbarAndDraggedSlots() { + var listener = new ArtifactListener(); + var player = server.addPlayer(); + var item = new ItemStack(Material.STONE); + ArtifactIds.writeNew(item); + player.getInventory().setItem(2, item); + var click = mock(InventoryClickEvent.class); + when(click.getWhoClicked()).thenReturn(player); + when(click.getHotbarButton()).thenReturn(2); + when(click.getClickedInventory()).thenReturn(player.getInventory()); + when(click.getCurrentItem()).thenReturn(item); + when(click.getCursor()).thenReturn(item); + try (var care = mockStatic(ArtifactCareStore.class); + var places = mockStatic(ArtifactCarePlaces.class)) { + listener.onCareClick(click); + care.verify( + () -> + ArtifactCareStore.apply( + eq(item), anyBoolean(), anyLong(), eq(ArtifactCareStore.Persist.ALWAYS)), + times(3)); + when(click.getHotbarButton()).thenReturn(-1); + when(click.getCurrentItem()).thenReturn(null); + when(click.getCursor()).thenReturn(null); + listener.onCareClick(click); + when(click.getHotbarButton()).thenReturn(1); + when(click.getWhoClicked()).thenReturn(mock(HumanEntity.class)); + listener.onCareClick(click); + var drag = mock(InventoryDragEvent.class); + when(drag.getWhoClicked()).thenReturn(player); + when(drag.getNewItems()).thenReturn(Map.of(1, item)); + when(drag.getCursor()).thenReturn(item); + var view = mock(InventoryView.class); + when(view.getInventory(1)).thenReturn(player.getInventory()); + when(drag.getView()).thenReturn(view); + listener.onCareDrag(drag); + when(drag.getView()).thenReturn(null); + when(drag.getWhoClicked()).thenReturn(mock(HumanEntity.class)); + listener.onCareDrag(drag); + care.verify( + () -> + ArtifactCareStore.apply( + eq(item), anyBoolean(), anyLong(), eq(ArtifactCareStore.Persist.ALWAYS)), + times(7)); + } + } + + @Test + void sacrificeChatOnlyStartsMatchingRpWordsAndHonorsVisibility() { + var listener = new SacrificeListener(); + var player = server.addPlayer(); + var chat = mock(CharacterChatEvent.class); + when(chat.getSender()).thenReturn(player); + try (var rites = mockStatic(SacrificeRiteService.class)) { + listener.onCharacterChat(chat); + SacrificeRegistry.setEnabled(true); + listener.onCharacterChat(chat); + SacrificeRegistry.register( + new SacrificeElementDef("fire", true, List.of("ignite"), "", "", "", 0)); + listener.onCharacterChat(chat); + when(chat.getChannel()).thenReturn(""); + listener.onCharacterChat(chat); + when(chat.getChannel()).thenReturn("global"); + listener.onCharacterChat(chat); + when(chat.getChannel()).thenReturn(" RP "); + when(chat.getMessage()).thenReturn("hello"); + listener.onCharacterChat(chat); + when(chat.getMessage()).thenReturn("ignite"); + listener.onCharacterChat(chat); + verify(chat, never()).setCancelled(true); + SacrificeRegistry.setHideIncantation(true); + listener.onCharacterChat(chat); + verify(chat).setCancelled(true); + rites.verify(() -> SacrificeRiteService.tryStart(player, "fire"), times(2)); + var death = mock(PlayerDeathEvent.class); + listener.onDeath(death); + when(death.getEntity()).thenReturn(player); + listener.onDeath(death); + rites.verify(() -> SacrificeRiteService.handleDeath(player)); + var quit = mock(PlayerQuitEvent.class); + listener.onQuit(quit); + when(quit.getPlayer()).thenReturn(player); + listener.onQuit(quit); + rites.verify(() -> SacrificeRiteService.cancelForPlayer(player.getUniqueId())); + } + } + + @Test + void fillChestInteractionConsumesOnlyMainHandChestClicksAndExpiresRequests() { + var listener = new ArtifactFillChestListener(); + var player = server.addPlayer(); + var event = mock(PlayerInteractEvent.class); + when(event.getPlayer()).thenReturn(player); + listener.onInteract(event); + when(event.getAction()).thenReturn(Action.RIGHT_CLICK_BLOCK); + listener.onInteract(event); + when(event.getHand()).thenReturn(EquipmentSlot.HAND); + listener.onInteract(event); + var block = player.getWorld().getBlockAt(0, 60, 0); + ArtifactFillChestService.arm(player.getUniqueId(), ArtifactFillChestService.Mode.ALL, null); + when(event.getClickedBlock()).thenReturn(block); + listener.onInteract(event); + block.setType(Material.CHEST); + try (var service = mockStatic(ArtifactFillChestService.class); + var messages = mockStatic(Messages.class, i -> i.getArgument(0))) { + service.when(() -> ArtifactFillChestService.isChestBlock(block)).thenReturn(true); + var expired = + new ArtifactFillChestService.Pending(ArtifactFillChestService.Mode.ALL, null, 0); + service.when(() -> ArtifactFillChestService.get(player.getUniqueId())).thenReturn(expired); + listener.onInteract(event); + assertEquals("fillchest.expired", player.nextMessage()); + service.verify(() -> ArtifactFillChestService.clear(player.getUniqueId())); + var ready = + new ArtifactFillChestService.Pending( + ArtifactFillChestService.Mode.ALL, null, Long.MAX_VALUE); + service.when(() -> ArtifactFillChestService.get(player.getUniqueId())).thenReturn(ready); + service.when(() -> ArtifactFillChestService.fill(block, ready)).thenReturn(27); + listener.onInteract(event); + verify(event).setCancelled(true); + assertEquals("fillchest.done", player.nextMessage()); + service.verify(() -> ArtifactFillChestService.consume(player.getUniqueId())); + listener.onQuit(new PlayerQuitEvent(player, "quit")); + service.verify(() -> ArtifactFillChestService.clear(player.getUniqueId()), times(2)); + } + } +} diff --git a/src/test/java/net/tfminecraft/magic/ArtifactPathFactoryTest.java b/src/test/java/net/tfminecraft/magic/ArtifactPathFactoryTest.java new file mode 100644 index 0000000..f78cf57 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/ArtifactPathFactoryTest.java @@ -0,0 +1,82 @@ +package net.tfminecraft.magic; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.logging.Logger; +import net.tfminecraft.magic.artifact.Artifact; +import net.tfminecraft.magic.artifact.config.*; +import net.tfminecraft.magic.artifact.generate.*; +import net.tfminecraft.magic.artifact.path.ArtifactPathFactory; +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; +import org.junit.jupiter.api.*; +import org.mockbukkit.mockbukkit.MockBukkit; + +class ArtifactPathFactoryTest { + @BeforeEach + void setup() { + MockBukkit.mock(); + Magic.plugin = mock(Magic.class); + when(Magic.plugin.namespace()).thenReturn("magic"); + when(Magic.plugin.getLogger()).thenReturn(Logger.getAnonymousLogger()); + ArtifactTypeRegistry.clear(); + ArtifactRarityRegistry.clear(); + } + + @AfterEach + void cleanup() { + Magic.plugin = null; + MockBukkit.unmock(); + ArtifactTypeRegistry.clear(); + ArtifactRarityRegistry.clear(); + } + + @Test + void invalidPathsAndUnconfiguredRollsDoNotCreateItems() { + var factory = ArtifactPathFactory.INSTANCE; + assertNull(factory.create("invalid")); + assertNull(factory.create("magic.artifact")); + Magic.plugin = null; + assertNull(factory.create("magic.artifact")); + } + + @Test + void buildFailuresArePropagatedAndSuccessfulItemsAreSingleStacks() { + ArtifactTypeRegistry.register(ArtifactGenerationTest.type("fire", true, 1)); + ArtifactRarityRegistry.register(ArtifactGenerationTest.rarity("common", 1, 1, 1)); + assertNull( + net.tfminecraft.magic.artifact.path.ArtifactPathParser.parse("magic.(fire=)") + .getExtras() + .get("fire")); + for (ItemStack result : + new ItemStack[] {null, new ItemStack(Material.AIR), new ItemStack(Material.STONE, 8)}) { + try (var builder = + mockConstruction( + ArtifactItemBuilder.class, (m, c) -> when(m.build(any())).thenReturn(result))) { + var item = ArtifactPathFactory.INSTANCE.create("magic.(primary=fire;rarity=common)"); + if (result == null || result.getType() == Material.AIR) assertNull(item); + else { + assertSame(result, item); + assertEquals(1, item.getAmount()); + } + } + } + } + + @Test + void matchesRequiresStoredAuraRatherThanAnArbitraryItem() { + var factory = ArtifactPathFactory.INSTANCE; + assertFalse(factory.matches(null, "magic.artifact")); + assertFalse(factory.matches(new ItemStack(Material.AIR), "magic.artifact")); + var stack = new ItemStack(Material.STONE); + assertFalse(factory.matches(stack, "magic.artifact")); + var artifact = Artifact.create(); + artifact.setCap("fire", 10); + artifact.persistPdc(stack); + assertFalse(factory.matches(stack, "magic.artifact")); + artifact.setFill("fire", 5); + artifact.persistPdc(stack); + assertTrue(factory.matches(stack, "magic.artifact")); + } +} diff --git a/src/test/java/net/tfminecraft/magic/ArtifactRollerEdgeTest.java b/src/test/java/net/tfminecraft/magic/ArtifactRollerEdgeTest.java new file mode 100644 index 0000000..e828522 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/ArtifactRollerEdgeTest.java @@ -0,0 +1,136 @@ +package net.tfminecraft.magic; + +import static net.tfminecraft.magic.ArtifactGenerationTest.rarity; +import static net.tfminecraft.magic.ArtifactGenerationTest.type; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.*; +import net.tfminecraft.magic.artifact.config.*; +import net.tfminecraft.magic.artifact.generate.*; +import net.tfminecraft.magic.artifact.path.*; +import net.tfminecraft.magic.artifact.shrine.*; +import org.junit.jupiter.api.*; + +class ArtifactRollerEdgeTest { + @BeforeEach + @AfterEach + void reset() { + new ArtifactGenerationTest().reset(); + } + + void defaults() { + new ArtifactGenerationTest().defaults(); + } + + @Test + void randomAndLockedErrorsHandleEachRandomRarityForm() { + var roller = new ArtifactRoller(new Random(1)); + for (String rarity : new String[] {null, " ", "roll"}) + assertEquals("artifact.roll.empty_rarities", roller.roll("random", rarity).getErrorKey()); + ArtifactTypeRegistry.register(type("fire", true, 1)); + for (String rarity : new String[] {null, " ", "roll"}) + assertEquals("artifact.roll.empty_rarities", roller.roll("fire", rarity).getErrorKey()); + assertEquals( + "artifact.roll.empty_rarities", + roller.roll(new ArtifactPathSpec(null, null, null)).getErrorKey()); + } + + @Test + void overflowedWeightsStillRespectProbabilities() { + var random = mock(Random.class); + when(random.nextDouble()).thenReturn(.25); + var roller = new ArtifactRoller(random); + ArtifactRarityRegistry.register(rarity("common", 1, 1, 1)); + ArtifactTypeRegistry.register(type("fire", true, Double.MAX_VALUE)); + ArtifactTypeRegistry.register(type("water", true, Double.MAX_VALUE)); + assertEquals("fire", roller.roll("random", "common").getPrimaryId()); + } + + @Test + void nonFiniteCompanionWeightsCannotWin() { + ArtifactRarityRegistry.register(rarity("common", 1, 2, 2)); + ArtifactTypeRegistry.register(type("fire", true, 1)); + ArtifactTypeRegistry.register(type("bad", true, Double.NaN)); + ArtifactTypeRegistry.register(type("water", true, 1)); + ArtifactTypeRegistry.register(type("earth", true, 1)); + ArtifactAffinityRegistry.registerGroup("all", List.of("fire", "bad", "water", "earth")); + var random = mock(Random.class); + when(random.nextDouble()).thenReturn(.1); + var result = new ArtifactRoller(random).roll("fire", "common"); + assertEquals( + List.of("fire", "water"), + result.getSlots().stream().map(ArtifactAuraSlot::getElementId).toList()); + } + + @Test + void nonPositiveCompanionPoolAndExclusionsStopSelection() { + defaults(); + ArtifactTypeRegistry.register(type("water", true, 0)); + ArtifactTypeRegistry.register(type("earth", true, -1)); + assertEquals(1, new ArtifactRoller(new Random(1)).roll("fire", "common").getSlots().size()); + ArtifactTypeRegistry.register(type("water", true, 1)); + ArtifactTypeRegistry.register(type("earth", true, 1)); + ArtifactAffinityRegistry.registerExclude(List.of("water"), List.of("earth")); + ArtifactRarityRegistry.register(rarity("common", 1, 3, 3)); + assertEquals(2, new ArtifactRoller(new Random(1)).roll("fire", "common").getSlots().size()); + } + + @Test + void hiddenCompanionsAndMissingBracketsAreSkippedForRandomAndLockedRolls() { + defaults(); + ArtifactTypeRegistry.register( + new ArtifactTypeDef("earth", "names", "models", true, 1, Map.of(), Map.of())); + ShrineRegistry.register(new ShrineElementDef("water", false, List.of())); + var roller = new ArtifactRoller(new Random(1)); + assertEquals(1, roller.roll("fire", "common").getSlots().size()); + assertEquals( + 1, + roller + .roll(new ArtifactPathSpec("fire", "common", new LinkedHashMap<>(Map.of("water", 1.)))) + .getSlots() + .size()); + } + + @Test + void zeroAndFixedCapRangesUsePositiveMinimumAndBoundedFallback() { + ArtifactRarityRegistry.register(rarity("common", 1, 1, 1)); + ArtifactTypeRegistry.register( + new ArtifactTypeDef( + "fire", "names", "models", true, 1, Map.of("common", new CapRange(0, 0)), Map.of())); + var roller = new ArtifactRoller(new Random(1)); + assertEquals(.01, roller.roll("fire", "common").getSlots().getFirst().getCap()); + assertEquals( + .01, + roller + .roll(new ArtifactPathSpec("fire", "common", new LinkedHashMap<>(Map.of("fire", 0.)))) + .getSlots() + .getFirst() + .getCap()); + ArtifactTypeRegistry.register( + new ArtifactTypeDef( + "fire", "names", "models", true, 1, Map.of("common", new CapRange(10, 10)), Map.of())); + assertEquals(10, roller.roll("fire", "common").getSlots().getFirst().getCap()); + assertEquals( + 10, + roller + .roll(new ArtifactPathSpec("fire", "common", new LinkedHashMap<>(Map.of("fire", 20.)))) + .getSlots() + .getFirst() + .getCap()); + } + + @Test + void infiniteWeightsAreIgnoredAndBlankPrimaryRollsRandomly() { + defaults(); + ArtifactTypeRegistry.register(type("infinite", true, Double.POSITIVE_INFINITY)); + ArtifactRarityRegistry.register(rarity("infinite", Double.POSITIVE_INFINITY, 1, 1)); + var roller = new ArtifactRoller(new Random(1)); + assertFalse(roller.roll(" ", "roll").isError()); + var result = + roller.roll( + new ArtifactPathSpec( + "fire", "common", new LinkedHashMap<>(Map.of("water", Double.NaN)))); + assertEquals(1, result.getSlots().size()); + } +} diff --git a/src/test/java/net/tfminecraft/magic/AttunementTest.java b/src/test/java/net/tfminecraft/magic/AttunementTest.java new file mode 100644 index 0000000..3f6afb2 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/AttunementTest.java @@ -0,0 +1,214 @@ +package net.tfminecraft.magic; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import com.google.gson.Gson; +import java.nio.file.*; +import java.util.*; +import java.util.function.Consumer; +import net.tfminecraft.interactiblefurniture.InteractibleFurniture; +import net.tfminecraft.interactiblefurniture.furniture.*; +import net.tfminecraft.magic.artifact.*; +import net.tfminecraft.magic.attunement.*; +import net.tfminecraft.magic.meditation.MeditationCache; +import org.bukkit.*; +import org.bukkit.inventory.ItemStack; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.io.TempDir; +import org.mockbukkit.mockbukkit.*; + +class AttunementTest { + @TempDir Path temp; + ServerMock server; + + @BeforeEach + void setup() { + server = MockBukkit.mock(); + server.addSimpleWorld("display"); + Magic.plugin = mock(Magic.class); + when(Magic.plugin.namespace()).thenReturn("magic"); + ArtifactDisplayIndex.rebuildFromSaved(); + } + + @AfterEach + void cleanup() { + ArtifactDisplayIndex.rebuildFromSaved(); + AuraLog.configure(false, false, null); + Magic.plugin = null; + MockBukkit.unmock(); + } + + @Test + void displayIndexCountsCopiesAndEnsuresReloadsWithoutDoubleCounting() { + var item = new ItemStack(Material.STONE); + ArtifactIds.writeNew(item); + var id = ArtifactIds.read(item); + assertFalse(ArtifactDisplayIndex.isDisplayed((UUID) null)); + for (String bad : Arrays.asList(null, "", "unbound", "bad")) + assertFalse(ArtifactDisplayIndex.isDisplayed(bad)); + ArtifactDisplayIndex.add((UUID) null); + ArtifactDisplayIndex.ensure((UUID) null); + ArtifactDisplayIndex.remove((UUID) null); + ArtifactDisplayIndex.remove(id); + ArtifactDisplayIndex.add(item); + ArtifactDisplayIndex.add(id); + assertTrue(ArtifactDisplayIndex.isDisplayed(id.toString())); + ArtifactDisplayIndex.ensure(item); + ArtifactDisplayIndex.remove(item); + assertTrue(ArtifactDisplayIndex.isDisplayed(id)); + ArtifactDisplayIndex.remove(id); + assertFalse(ArtifactDisplayIndex.isDisplayed(id)); + ArtifactDisplayIndex.ensure(id); + assertTrue(ArtifactDisplayIndex.isDisplayed(id)); + } + + @Test + void displayFurnitureRejectsCarriedObjectsAndTracksLoadedSavedSlots() { + var furniture = mock(Furniture.class); + var slot = mock(PlacedSlot.class); + var item = new ItemStack(Material.STONE); + ArtifactIds.writeNew(item); + var id = ArtifactIds.read(item); + when(slot.getCurrentItem()).thenReturn(item); + when(furniture.getActiveSlots()).thenReturn(Map.of("main", slot)); + assertFalse(ArtifactDisplayIndex.isDisplayFurniture(null)); + assertFalse(ArtifactDisplayIndex.isDisplayFurniture(furniture)); + ArtifactDisplayIndex.indexFurniture(null, true); + ArtifactDisplayIndex.removeFurniture(null); + ArtifactDisplayIndex.removeFurniture(furniture); + when(furniture.getId()).thenReturn("pedestal"); + MeditationCache.pedestalId = "pedestal"; + assertTrue(ArtifactDisplayIndex.isDisplayFurniture(furniture)); + when(furniture.isCarried()).thenReturn(true); + assertFalse(ArtifactDisplayIndex.isDisplayFurniture(furniture)); + when(furniture.isCarried()).thenReturn(false); + when(furniture.isPersistedCarried()).thenReturn(true); + assertFalse(ArtifactDisplayIndex.isDisplayFurniture(furniture)); + when(furniture.isPersistedCarried()).thenReturn(false); + when(furniture.getId()).thenReturn("shelf"); + ArtifactCareCache.displayFurnitureId = null; + assertFalse(ArtifactDisplayIndex.isDisplayFurniture(furniture)); + ArtifactCareCache.displayFurnitureId = ""; + assertFalse(ArtifactDisplayIndex.isDisplayFurniture(furniture)); + ArtifactCareCache.displayFurnitureId = "display"; + assertFalse(ArtifactDisplayIndex.isDisplayFurniture(furniture)); + ArtifactCareCache.displayFurnitureId = "shelf"; + assertTrue(ArtifactDisplayIndex.isDisplayFurniture(furniture)); + ArtifactDisplayIndex.indexFurniture(furniture, true); + assertTrue(ArtifactDisplayIndex.isDisplayed(id)); + ArtifactDisplayIndex.removeFurniture(furniture); + assertFalse(ArtifactDisplayIndex.isDisplayed(id)); + var chunk = server.getWorlds().getFirst().getChunkAt(0, 0); + ArtifactDisplayIndex.ensureChunk(null); + ArtifactDisplayIndex.ensureChunk(chunk); + var enabled = MockBukkit.createMockPlugin("InteractibleFurniture"); + try (var bridge = mockStatic(InteractibleFurniture.class)) { + ArtifactDisplayIndex.rebuildFromSaved(); + ArtifactDisplayIndex.ensureChunk(chunk); + var dependency = mock(InteractibleFurniture.class); + bridge.when(InteractibleFurniture::getInstance).thenReturn(dependency); + ArtifactDisplayIndex.rebuildFromSaved(); + ArtifactDisplayIndex.ensureChunk(chunk); + var manager = mock(net.tfminecraft.interactiblefurniture.manager.FurnitureManager.class); + when(dependency.getFurnitureManager()).thenReturn(manager); + when(manager.getFurnitureInChunk(chunk)).thenReturn(Set.of(furniture)); + doAnswer( + i -> { + Consumer visit = i.getArgument(0); + visit.accept(furniture); + return null; + }) + .when(manager) + .visitSavedFurniture(any()); + ArtifactDisplayIndex.rebuildFromSaved(); + ArtifactDisplayIndex.ensureChunk(chunk); + assertTrue(ArtifactDisplayIndex.isDisplayed(id)); + ArtifactDisplayIndex.remove(id); + assertFalse(ArtifactDisplayIndex.isDisplayed(id)); + server.getPluginManager().disablePlugin(enabled); + ArtifactDisplayIndex.rebuildFromSaved(); + ArtifactDisplayIndex.ensureChunk(chunk); + } + } + + @Test + void legacyClaimJsonRepairsNullMapsAndCopiesWithoutSharing() { + var gson = new Gson(); + var claim = gson.fromJson("{\"byElement\":null,\"displayName\":null}", ArtifactClaim.class); + assertTrue(claim.isEmpty()); + assertEquals(0, claim.getAmount("fire")); + assertEquals("", claim.getDisplayName()); + var copy = claim.copy(); + assertTrue(copy.getByElement().isEmpty()); + assertTrue(claim.getByElement().isEmpty()); + claim = gson.fromJson("{\"byElement\":null}", ArtifactClaim.class); + claim.setAmount(null, 2); + claim.setAmount(" ", 2); + claim.setAmount(" FIRE ", 1.234567); + assertEquals(1.234567, claim.getAmount("fire"), .000001); + copy = claim.copy(); + copy.setAmount("fire", 3); + assertNotEquals(copy.getAmount("fire"), claim.getAmount("fire")); + claim.setAmount("fire", 0); + assertTrue(claim.isEmpty()); + claim.setByElement(null); + var amounts = new HashMap(); + amounts.put("empty", null); + amounts.put("zero", 0d); + claim.setByElement(amounts); + assertFalse(claim.hasAnyAmount()); + assertEquals(0, claim.getAmount("empty")); + claim.setAmount("fire", 2); + assertTrue(claim.hasAnyAmount()); + claim.setOffDisplaySeconds(-1); + assertEquals(0, claim.getOffDisplaySeconds()); + claim.setOffDisplaySeconds(3); + assertEquals(3, claim.getOffDisplaySeconds()); + claim.setWaneWarned(true); + assertTrue(claim.copy().isWaneWarned()); + claim.setDisplayName(null); + assertEquals("", claim.getDisplayName()); + claim.setDisplayName("test"); + assertEquals("test", claim.copy().getDisplayName()); + assertEquals(0, claim.getAmount(null)); + } + + @Test + void auraLoggingAppendsFormatsWipesAndToleratesFilesystemFailures() throws Exception { + AuraLog.configure(false, true, temp.toFile()); + assertFalse(AuraLog.isEnabled()); + AuraLog.append("disabled"); + AuraLog.append("disabled %s", "arg"); + var file = temp.resolve("logs/aura.log"); + assertFalse(Files.exists(file)); + AuraLog.configure(true, false, temp.toFile()); + AuraLog.append((String) null); + AuraLog.append("plain"); + AuraLog.append("value %s", AuraLog.n(1.25)); + var lines = Files.readAllLines(file); + assertEquals(2, lines.size()); + assertTrue(lines.get(0).endsWith(" plain")); + assertTrue(lines.get(1).endsWith(" value 1.25")); + AuraLog.configure(true, true, null); + assertFalse(Files.exists(file)); + Files.createDirectory(file); + Files.writeString(file.resolve("child"), "keep"); + AuraLog.configure(true, true, temp.toFile()); + assertTrue(Files.exists(file.resolve("child"))); + AuraLog.append("cannot write directory"); + } + + @Test + void careAcceptsPlayerInventoryAndIndexedDisplaysButNotOrdinaryStorage() { + var player = server.addPlayer(); + var item = new ItemStack(Material.STONE); + ArtifactIds.writeNew(item); + var chest = server.createInventory(null, 9); + assertFalse(ArtifactCarePlaces.accepted(null, item)); + assertFalse(ArtifactCarePlaces.accepted(chest, item)); + assertTrue(ArtifactCarePlaces.accepted(player.getInventory(), item)); + ArtifactDisplayIndex.add(item); + assertTrue(ArtifactCarePlaces.accepted(chest, item)); + } +} diff --git a/src/test/java/net/tfminecraft/magic/AuraDataTest.java b/src/test/java/net/tfminecraft/magic/AuraDataTest.java new file mode 100644 index 0000000..26519d6 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/AuraDataTest.java @@ -0,0 +1,187 @@ +package net.tfminecraft.magic; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.*; +import net.tfminecraft.magic.artifact.ArtifactKeys; +import net.tfminecraft.magic.artifact.aura.AuraData; +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; +import org.bukkit.persistence.PersistentDataType; +import org.junit.jupiter.api.*; +import org.mockbukkit.mockbukkit.MockBukkit; + +class AuraDataTest { + @BeforeEach + void setup() { + MockBukkit.mock(); + Magic.plugin = mock(Magic.class); + when(Magic.plugin.getName()).thenReturn("Magic"); + when(Magic.plugin.namespace()).thenReturn("magic"); + } + + @AfterEach + void cleanup() { + Magic.plugin = null; + MockBukkit.unmock(); + } + + @Test + void removingAllAuraDoesNotResurrectBackupOnReload() { + var item = new ItemStack(Material.STONE); + var data = new AuraData(); + data.setCap("fire", 10); + data.setFill("fire", 8); + data.persistPdc(item); + data.setCap("fire", 0); + data.persistPdc(item); + assertTrue(AuraData.fromPersistentData(item).isEmpty()); + assertFalse( + item.getItemMeta() + .getPersistentDataContainer() + .has(ArtifactKeys.auraData(), PersistentDataType.STRING)); + } + + @Test + void capsNormalizeClampAndRoundTripAcrossItemRebuild() { + var item = new ItemStack(Material.STONE); + var data = new AuraData(); + assertTrue(data.isEmpty()); + assertFalse(data.hasStoredAura()); + assertEquals("", data.highestCapElementId()); + data.setCap(null, 4); + data.setCap(" ", 4); + data.setFill(null, 4); + data.setFill("missing", 4); + assertTrue(data.isEmpty()); + data.setCap(" FIRE ", 10); + data.setCap("water", 5); + data.setFill("fire", 15); + data.setFill("water", -1); + assertEquals(10, data.getFill("fire")); + assertEquals(0, data.getFill("water")); + assertEquals(10, data.totalFill()); + assertTrue(data.hasStoredAura()); + assertEquals("fire", data.highestCapElementId()); + assertEquals(Set.of("fire", "water"), data.getCappedElementIds()); + assertThrows(UnsupportedOperationException.class, () -> data.getCappedElementIds().clear()); + data.persistPdc(item); + var restored = AuraData.fromPersistentData(item); + assertEquals(10, restored.getCap("fire")); + assertEquals(10, restored.getFill("fire")); + assertEquals(5, restored.getCap("water")); + var meta = item.getItemMeta(); + meta.getPersistentDataContainer().remove(ArtifactKeys.auraCap()); + meta.getPersistentDataContainer().remove(ArtifactKeys.auraFill()); + item.setItemMeta(meta); + restored = AuraData.fromPersistentData(item); + assertEquals(10, restored.getFill("fire")); + assertEquals(5, restored.getCap("water")); + data.setCap("fire", 2); + assertEquals(2, data.getFill("fire")); + data.setCap("water", -1); + assertEquals(Set.of("fire"), data.getCappedElementIds()); + } + + @Test + void legacyFillOnlyItemsRecoverTheirCap() { + var item = new ItemStack(Material.STONE); + var meta = item.getItemMeta(); + var root = meta.getPersistentDataContainer(); + var fill = root.getAdapterContext().newPersistentDataContainer(); + fill.set(ArtifactKeys.element("fire"), PersistentDataType.DOUBLE, 3.); + fill.set(ArtifactKeys.element("water"), PersistentDataType.DOUBLE, 0.); + fill.set(ArtifactKeys.element("wrong"), PersistentDataType.STRING, "wrong-type"); + root.set(ArtifactKeys.auraFill(), PersistentDataType.TAG_CONTAINER, fill); + item.setItemMeta(meta); + var data = AuraData.fromPersistentData(item); + assertEquals(3, data.getCap("fire")); + assertEquals(3, data.totalFill()); + assertEquals(0, data.getCap("water")); + } + + @Test + void malformedBackupTokensAreSkippedAndFillIsBounded() { + var item = new ItemStack(Material.STONE); + var meta = item.getItemMeta(); + meta.getPersistentDataContainer() + .set( + ArtifactKeys.auraData(), + PersistentDataType.STRING, + "bad,:2:1,bad:number:1,fire:5:9,water:3,earth:-1:2,fire:8:1"); + item.setItemMeta(meta); + var data = AuraData.fromPersistentData(item); + assertEquals(5, data.getCap("fire")); + assertEquals(5, data.getFill("fire")); + assertEquals(3, data.getCap("water")); + assertEquals(0, data.getFill("water")); + assertTrue(AuraData.fromPersistentData(null).isEmpty()); + assertTrue(AuraData.fromPersistentData(new ItemStack(Material.AIR)).isEmpty()); + data.persistPdc(null); + data.persistPdc(new ItemStack(Material.AIR)); + data.setCap("invalid key!", 3); + assertDoesNotThrow(() -> data.persistPdc(item)); + assertEquals(0, AuraData.clampFill(-1, 10)); + assertEquals(0, AuraData.clampFill(1, -1)); + } + + @Test + void backupRecoversZeroedFillAndInvalidNestedCapsAreIgnored() { + var item = new ItemStack(Material.STONE); + var meta = item.getItemMeta(); + var root = meta.getPersistentDataContainer(); + var caps = root.getAdapterContext().newPersistentDataContainer(); + var fills = root.getAdapterContext().newPersistentDataContainer(); + caps.set(ArtifactKeys.element("fire"), PersistentDataType.DOUBLE, 10d); + caps.set(ArtifactKeys.element("zero"), PersistentDataType.DOUBLE, 0d); + caps.set(ArtifactKeys.element("bad"), PersistentDataType.STRING, "wrong"); + fills.set(ArtifactKeys.element("fire"), PersistentDataType.DOUBLE, 0d); + root.set(ArtifactKeys.auraCap(), PersistentDataType.TAG_CONTAINER, caps); + root.set(ArtifactKeys.auraFill(), PersistentDataType.TAG_CONTAINER, fills); + root.set(ArtifactKeys.auraData(), PersistentDataType.STRING, "fire:10:4"); + item.setItemMeta(meta); + var recovered = AuraData.fromPersistentData(item); + assertEquals(Set.of("fire"), recovered.getCappedElementIds()); + assertEquals(4, recovered.getFill("fire")); + root.set(ArtifactKeys.auraData(), PersistentDataType.STRING, " "); + item.setItemMeta(meta); + assertEquals(0, AuraData.fromPersistentData(item).getFill("fire")); + } + + @Test + void nonFiniteAuraNeverPoisonsLiveOrPersistedValues() { + for (double invalid : + new double[] {Double.NaN, Double.POSITIVE_INFINITY, Double.NEGATIVE_INFINITY}) { + var data = new AuraData(); + data.setCap("fire", 10); + data.setFill("fire", 4); + data.setCap("fire", invalid); + data.setFill("fire", invalid); + assertEquals(10, data.getCap("fire")); + assertEquals(4, data.getFill("fire")); + assertEquals(0, AuraData.clampFill(invalid, 10)); + assertEquals(0, AuraData.clampFill(4, invalid)); + var item = new ItemStack(Material.STONE); + var meta = item.getItemMeta(); + var root = meta.getPersistentDataContainer(); + var caps = root.getAdapterContext().newPersistentDataContainer(); + var fills = root.getAdapterContext().newPersistentDataContainer(); + caps.set(ArtifactKeys.element("badcap"), PersistentDataType.DOUBLE, invalid); + caps.set(ArtifactKeys.element("fire"), PersistentDataType.DOUBLE, 10d); + fills.set(ArtifactKeys.element("fire"), PersistentDataType.DOUBLE, invalid); + root.set(ArtifactKeys.auraCap(), PersistentDataType.TAG_CONTAINER, caps); + root.set(ArtifactKeys.auraFill(), PersistentDataType.TAG_CONTAINER, fills); + root.set( + ArtifactKeys.auraData(), + PersistentDataType.STRING, + "badblob:" + invalid + ":2,water:10:" + invalid); + item.setItemMeta(meta); + var restored = AuraData.fromPersistentData(item); + assertEquals(Set.of("fire", "water"), restored.getCappedElementIds()); + assertEquals(0, restored.getFill("fire")); + assertEquals(0, restored.getFill("water")); + assertTrue(Double.isFinite(restored.totalFill())); + } + } +} diff --git a/src/test/java/net/tfminecraft/magic/ChargeEdgeCoverageTest.java b/src/test/java/net/tfminecraft/magic/ChargeEdgeCoverageTest.java new file mode 100644 index 0000000..3385354 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/ChargeEdgeCoverageTest.java @@ -0,0 +1,214 @@ +package net.tfminecraft.magic; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.*; +import net.tfminecraft.magic.artifact.*; +import net.tfminecraft.magic.artifact.aura.*; +import net.tfminecraft.magic.artifact.shrine.*; +import net.tfminecraft.magic.attunement.*; +import net.tfminecraft.magic.charge.*; +import net.tfminecraft.magic.model.ElementVisibility; +import net.tfminecraft.magic.registry.ElementRegistry; +import net.tfminecraft.tlibs.TLibs; +import net.tfminecraft.tlibs.objects.api.ItemAPI; +import org.bukkit.*; +import org.bukkit.entity.ItemFrame; +import org.bukkit.inventory.*; +import org.bukkit.persistence.PersistentDataType; +import org.junit.jupiter.api.*; + +class ChargeEdgeCoverageTest extends GearCoverageSupport { + @BeforeEach + void registries() { + ChargeRegistry.clear(); + TierBands.clear(); + ElementRegistry.clear(); + net.tfminecraft.magic.artifact.config.ArtifactTypeRegistry.clear(); + ShrineRegistry.clear(); + net.tfminecraft.magic.artifact.sacrifice.SacrificeRegistry.clear(); + } + + @AfterEach + void cleanup() { + registries(); + ArtifactDisplayIndex.rebuildFromSaved(); + } + + ItemStack chargeItem() { + var s = item(); + tag(s, ChargeKeys.chargeTier(), PersistentDataType.INTEGER, 1); + return s; + } + + @Test + void identifiersRejectEmptyMetadataMalformedIdsAndZeroTiers() { + var noMeta = mock(ItemStack.class); + when(noMeta.getType()).thenReturn(Material.STONE); + assertFalse(ArtifactIds.hasKey(noMeta)); + assertFalse(ArtifactIds.hasKey(new ItemStack(Material.AIR))); + assertFalse(ChargeIds.isCharge(noMeta)); + assertNull(Charge.fromItem(noMeta)); + assertEquals("", AttunementCaptureService.displayNameOf(noMeta)); + assertNull(ArtifactIds.read(new ItemStack(Material.AIR))); + ArtifactIds.writeNew(null); + ArtifactIds.writeNew(new ItemStack(Material.AIR)); + assertFalse(ArtifactIds.hasId(item())); + var s = item(); + tag(s, ArtifactKeys.artifactId(), PersistentDataType.STRING, " "); + assertNull(ArtifactIds.read(s)); + tag(s, ArtifactKeys.artifactId(), PersistentDataType.STRING, "invalid"); + assertNull(ArtifactIds.read(s)); + ArtifactIds.writeNew(s); + assertTrue(ArtifactIds.hasId(s)); + tag(s, ChargeKeys.chargeTier(), PersistentDataType.INTEGER, 0); + assertFalse(ChargeIds.hasKey(s)); + assertFalse(Artifact.create().isCharge()); + assertFalse(Charge.fromItem(chargeItem()).isArtifact()); + var inventory = mock(Inventory.class); + when(inventory.getHolder()) + .thenReturn(mock(InventoryHolder.class, withSettings().extraInterfaces(ItemFrame.class))); + assertTrue(ArtifactCarePlaces.accepted(inventory, s)); + ArtifactDisplayIndex.add(s); + assertTrue(ArtifactDisplayIndex.isDisplayed(ArtifactIds.read(s).toString())); + } + + @Test + void fallbackMatchingSupportsUnstampedChargesAndUnresolvableMaterials() { + var def = new ChargeDef(1, "v.STONE", 10); + ChargeRegistry.register(def); + var s = item(); + try (var libs = mockStatic(TLibs.class)) { + var api = mock(ItemAPI.class, RETURNS_DEEP_STUBS); + libs.when(TLibs::getItemAPI).thenReturn(api); + when(api.getCreator().getItemFromPath("v.STONE")).thenReturn(new ItemStack(Material.AIR)); + when(api.getChecker().checkItemWithPath(s, "v.STONE")).thenReturn(true); + assertTrue(ChargeIds.isCharge(s)); + var charge = Charge.fromItem(s); + assertEquals(1, charge.getTier()); + tag(s, ArtifactKeys.artifactPrimary(), PersistentDataType.STRING, " "); + assertEquals("", Charge.fromItem(s).primaryElementId()); + tag(s, ArtifactKeys.artifactPrimary(), PersistentDataType.STRING, " FIRE "); + assertEquals("fire", Charge.fromItem(s).primaryElementId()); + } + } + + @Test + void registeringAnotherTierInvalidatesMaterialPrefilter() { + var stone = new ChargeDef(1, "v.STONE", 10); + var diamond = new ChargeDef(2, "v.DIAMOND", 20); + ChargeRegistry.register(stone); + var first = new ItemStack(Material.STONE); + var second = new ItemStack(Material.DIAMOND); + try (var libs = mockStatic(TLibs.class)) { + var api = mock(ItemAPI.class, RETURNS_DEEP_STUBS); + libs.when(TLibs::getItemAPI).thenReturn(api); + when(api.getCreator().getItemFromPath("v.STONE")).thenReturn(first); + when(api.getCreator().getItemFromPath("v.DIAMOND")).thenReturn(second); + when(api.getChecker().checkItemWithPath(first, "v.STONE")).thenReturn(true); + when(api.getChecker().checkItemWithPath(second, "v.DIAMOND")).thenReturn(true); + assertSame(stone, ChargeRegistry.match(first)); + ChargeRegistry.register(diamond); + assertSame(diamond, ChargeRegistry.match(second)); + } + } + + @Test + void publicImprintCallsTolerateAirDestination() { + ChargeRegistry.register(new ChargeDef(1, "v.STONE", 10)); + var charge = Charge.fromItem(chargeItem()); + assertTrue(charge.imprintElement(new ItemStack(Material.AIR), "fire")); + } + + @Test + void loreFiltersHiddenElementsAndSortsLatePrimaryFirst() throws Exception { + for (String id : List.of("alpha", "beta", "gamma", "hidden")) + ElementRegistry.register(DomainTest.element(id)); + var stack = chargeItem(); + var charge = Charge.fromItem(stack); + for (String id : List.of("alpha", "beta", "hidden")) { + charge.setCap(id, 10); + charge.setFill(id, 5); + } + charge.persistPdc(stack); + tag(stack, ArtifactKeys.artifactPrimary(), PersistentDataType.STRING, "beta"); + try (var visibility = mockStatic(ElementVisibility.class)) { + visibility.when(() -> ElementVisibility.shownOnCharge("alpha")).thenReturn(true); + visibility.when(() -> ElementVisibility.shownOnCharge("beta")).thenReturn(true); + ChargeLore.apply(stack); + } + var lore = stack.getItemMeta().getLore(); + assertTrue(ChatColor.stripColor(lore.get(1)).toLowerCase().contains("beta")); + assertEquals(3, lore.size()); + assertEquals( + Arrays.asList(null, ""), + invoke( + ChargeLore.class, + "stripChargeBlock", + new Class[] {List.class}, + Arrays.asList(null, ""))); + assertEquals( + List.of("flavor"), + invoke( + ChargeLore.class, + "stripChargeBlock", + new Class[] {List.class}, + List.of("flavor", "§0§1§3§rCharge", "obsolete"))); + } + + @Test + void displayReferenceCounterCannotOverflowAndHideVisibleArtifacts() throws Exception { + var id = UUID.randomUUID(); + var field = ArtifactDisplayIndex.class.getDeclaredField("displayed"); + field.setAccessible(true); + @SuppressWarnings("unchecked") + var counts = + (Map) + field.get(null); // Fast-forward a reachable positive counter without billions of + // registrations. + counts.put(id, Integer.MAX_VALUE); + ArtifactDisplayIndex.add(id); + assertTrue(ArtifactDisplayIndex.isDisplayed(id)); + } + + @Test + void nonfiniteShrineScoreCannotImprintACharge() { + ChargeRegistry.register(new ChargeDef(1, "v.STONE", 10)); + for (double invalid : new double[] {Double.NaN, Double.POSITIVE_INFINITY}) { + var s = chargeItem(); + var charge = Charge.fromItem(s); + assertFalse( + charge.imprint( + s, + new ShrineScore( + Map.of("fire", new ShrineElementScore("fire", invalid, 1, null, null))))); + assertTrue(charge.isBlank()); + } + } + + @Test + void emptyInputsAndDefaultVesselHelpersRemainConsistent() throws Exception { + assertNull(ArtifactIds.read(null)); + assertFalse(ChargeIds.isCharge(null)); + assertFalse(ChargeIds.isCharge(new ItemStack(Material.AIR))); + assertFalse(ChargeIds.hasKey(null)); + assertFalse(ChargeIds.hasKey(new ItemStack(Material.AIR))); + assertTrue(Artifact.create().isArtifact()); + assertTrue(Charge.fromItem(chargeItem()).isCharge()); + assertFalse(ArtifactDisplayIndex.isDisplayed((String) null)); + assertFalse(ArtifactDisplayIndex.isDisplayed("invalid")); + assertFalse(ArtifactDisplayIndex.isDisplayed(UUID.randomUUID().toString())); + var session = new net.tfminecraft.magic.session.ResonanceSession(); + AttunementCaptureService.credit( + server.addPlayer(), session, UUID.randomUUID().toString(), "unknown", 1); + assertEquals(0, session.getResonance("unknown")); + assertEquals( + List.of("flavor"), + invoke( + ChargeLore.class, + "stripChargeBlock", + new Class[] {List.class}, + List.of("flavor", "§0§1§3Old", "obsolete"))); + } +} diff --git a/src/test/java/net/tfminecraft/magic/ChargeLoreTest.java b/src/test/java/net/tfminecraft/magic/ChargeLoreTest.java new file mode 100644 index 0000000..1ddf304 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/ChargeLoreTest.java @@ -0,0 +1,122 @@ +package net.tfminecraft.magic; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.*; +import net.tfminecraft.magic.artifact.aura.*; +import net.tfminecraft.magic.charge.*; +import net.tfminecraft.magic.registry.ElementRegistry; +import org.bukkit.*; +import org.bukkit.inventory.ItemStack; +import org.bukkit.persistence.PersistentDataType; +import org.junit.jupiter.api.*; +import org.mockbukkit.mockbukkit.*; + +class ChargeLoreTest { + ServerMock server; + + @BeforeEach + void setup() { + server = MockBukkit.mock(); + Magic.plugin = mock(Magic.class); + when(Magic.plugin.namespace()).thenReturn("magic"); + ChargeRegistry.clear(); + ChargeRegistry.register(new ChargeDef(1, "v.STONE", 20)); + TierBands.clear(); + TierBands.register("default", 1, 5); + ElementRegistry.clear(); + for (String id : List.of("fire", "water", "earth", "air")) + ElementRegistry.register(DomainTest.element(id)); + net.tfminecraft.magic.artifact.config.ArtifactTypeRegistry.clear(); + Cache.artifactGlint = true; + } + + @AfterEach + void cleanup() { + Magic.plugin = null; + MockBukkit.unmock(); + ChargeRegistry.clear(); + ElementRegistry.clear(); + TierBands.clear(); + } + + ItemStack blank() { + var item = new ItemStack(Material.STONE); + var meta = item.getItemMeta(); + meta.getPersistentDataContainer().set(ChargeKeys.chargeTier(), PersistentDataType.INTEGER, 1); + item.setItemMeta(meta); + return item; + } + + List plain(ItemStack item) { + return item.getItemMeta().getLore().stream().map(ChatColor::stripColor).toList(); + } + + @Test + void blankChargesDescribePlacementAndRefreshingDoesNotDuplicateLore() { + assertNull(ChargeLore.updateItem(null)); + var ordinary = new ItemStack(Material.APPLE); + ChargeLore.apply(ordinary); + assertFalse(ordinary.getItemMeta().hasLore()); + var item = blank(); + var meta = item.getItemMeta(); + meta.setLore(List.of("Flavor", "")); + item.setItemMeta(meta); + ChargeLore.updateItem(item); + assertEquals(List.of("Flavor", "", "Uncharged", "Place on a shrine pedestal"), plain(item)); + ChargeLore.apply(item); + assertEquals(4, plain(item).size()); + assertFalse(item.getItemMeta().getEnchantmentGlintOverride()); + for (String old : List.of("Charge", "Uncharged", "§0§1§3Uncharged")) { + meta = item.getItemMeta(); + meta.setLore(List.of("Flavor", old, "outdated")); + item.setItemMeta(meta); + ChargeLore.apply(item); + assertEquals(List.of("Flavor", "Uncharged", "Place on a shrine pedestal"), plain(item)); + } + } + + @Test + void imprintedChargesSortPrimaryThenFillAndNameShowBandsAndGlint() { + var item = blank(); + var charge = Charge.fromItem(item); + for (String id : List.of("fire", "water", "earth", "air")) + assertTrue(charge.imprintElement(item, id)); + charge.setFill("fire", 0); + charge.setFill("water", 6); + charge.setFill("air", 6); + charge.setFill("earth", 4); + charge.persistPdc(item); + ChargeLore.apply(item); + var lines = plain(item); + assertEquals("Charge", lines.get(0)); + assertTrue(lines.get(1).toLowerCase(Locale.ROOT).contains("fire")); + assertTrue(lines.get(1).endsWith("-")); + assertTrue(lines.get(2).toLowerCase(Locale.ROOT).contains("air")); + assertTrue(lines.get(2).endsWith("I")); + assertTrue(lines.get(3).toLowerCase(Locale.ROOT).contains("water")); + assertTrue(lines.get(4).toLowerCase(Locale.ROOT).contains("earth")); + assertTrue(item.getItemMeta().getEnchantmentGlintOverride()); + Cache.artifactGlint = false; + ChargeLore.apply(item); + assertFalse(item.getItemMeta().getEnchantmentGlintOverride()); + } + + @Test + void vesselLoreRefreshesAllInventorySectionsAndHandlesEmptyInputs() { + VesselLore.updateInventory(null); + assertNull(VesselLore.updateItem(null)); + var air = new ItemStack(Material.AIR); + assertSame(air, VesselLore.updateItem(air)); + var player = server.addPlayer(); + var item = blank(); + player.getInventory().setItem(0, item); + player.getInventory().setHelmet(item); + player.getInventory().setItemInOffHand(item); + VesselLore.updateInventory(player); + assertTrue(player.getInventory().getItem(0).getItemMeta().hasLore()); + assertTrue(player.getInventory().getHelmet().getItemMeta().hasLore()); + assertTrue(player.getInventory().getItemInOffHand().getItemMeta().hasLore()); + } +} diff --git a/src/test/java/net/tfminecraft/magic/ChargeTest.java b/src/test/java/net/tfminecraft/magic/ChargeTest.java new file mode 100644 index 0000000..127c84a --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/ChargeTest.java @@ -0,0 +1,184 @@ +package net.tfminecraft.magic; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.*; +import net.tfminecraft.magic.artifact.*; +import net.tfminecraft.magic.artifact.aura.*; +import net.tfminecraft.magic.artifact.config.*; +import net.tfminecraft.magic.artifact.sacrifice.SacrificeRegistry; +import net.tfminecraft.magic.artifact.shrine.*; +import net.tfminecraft.magic.charge.*; +import net.tfminecraft.tlibs.TLibs; +import net.tfminecraft.tlibs.objects.api.ItemAPI; +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; +import org.bukkit.persistence.PersistentDataType; +import org.junit.jupiter.api.*; +import org.mockbukkit.mockbukkit.MockBukkit; + +class ChargeTest { + @BeforeEach + void setup() { + MockBukkit.mock(); + Magic.plugin = mock(Magic.class); + when(Magic.plugin.namespace()).thenReturn("magic"); + ChargeRegistry.clear(); + TierBands.clear(); + ArtifactTypeRegistry.clear(); + ShrineRegistry.clear(); + SacrificeRegistry.clear(); + } + + @AfterEach + void cleanup() { + Magic.plugin = null; + MockBukkit.unmock(); + ChargeRegistry.clear(); + TierBands.clear(); + ArtifactTypeRegistry.clear(); + ShrineRegistry.clear(); + SacrificeRegistry.clear(); + } + + ItemStack stamped(int tier) { + var item = new ItemStack(Material.STONE); + var meta = item.getItemMeta(); + meta.getPersistentDataContainer() + .set(ChargeKeys.chargeTier(), PersistentDataType.INTEGER, tier); + item.setItemMeta(meta); + return item; + } + + @Test + void registryRejectsInvalidTiersAndMatchesThroughRealMaterialFilter() { + ChargeRegistry.register(null); + ChargeRegistry.register(new ChargeDef(0, "stone", 10)); + ChargeRegistry.register(new ChargeDef(1, null, -1)); + assertTrue(ChargeRegistry.isEmpty()); + assertEquals("", new ChargeDef(1, null, -1).getItemPath()); + assertEquals(0, new ChargeDef(1, "stone", -1).getAuraCap()); + assertNull(ChargeRegistry.match(null)); + assertNull(ChargeRegistry.match(new ItemStack(Material.AIR))); + assertNull(ChargeRegistry.match(new ItemStack(Material.STONE))); + var def = new ChargeDef(1, " v.STONE ", 10); + ChargeRegistry.register(def); + assertEquals(1, ChargeRegistry.size()); + assertSame(def, ChargeRegistry.getByTier(1)); + assertEquals(List.of(def), new ArrayList<>(ChargeRegistry.getAll())); + try (var libs = mockStatic(TLibs.class)) { + ItemAPI api = mock(ItemAPI.class, RETURNS_DEEP_STUBS); + libs.when(TLibs::getItemAPI).thenReturn(api); + when(api.getCreator().getItemFromPath("v.STONE")).thenReturn(new ItemStack(Material.STONE)); + var stone = new ItemStack(Material.STONE); + when(api.getChecker().checkItemWithPath(stone, "v.STONE")).thenReturn(true); + assertNull(ChargeRegistry.match(new ItemStack(Material.APPLE))); + assertSame(def, ChargeRegistry.match(stone)); + verify(api.getCreator(), times(1)).getItemFromPath("v.STONE"); + when(api.getChecker().checkItemWithPath(stone, "v.STONE")).thenReturn(false); + assertNull(ChargeRegistry.match(stone)); + when(api.getChecker().checkItemWithPath(stone, "v.STONE")) + .thenThrow(new IllegalArgumentException("bad path")); + assertNull(ChargeRegistry.match(stone)); + ChargeRegistry.clear(); + ChargeRegistry.register(def); + when(api.getCreator().getItemFromPath("v.STONE")) + .thenThrow(new IllegalArgumentException("bad path")); + assertNull(ChargeRegistry.match(stone)); + } + } + + @Test + void bandsUseElementSpecificThresholdsFallbackAndRomanNumerals() { + assertTrue(TierBands.isEmpty()); + assertEquals(0, TierBands.bandOf("fire", 100)); + TierBands.register("fire", 0, 1); + assertTrue(TierBands.isEmpty()); + TierBands.register(null, 1, 10); + TierBands.register(" ", 2, 20); + TierBands.register(" FIRE ", 1, 5); + TierBands.register("fire", 3, 30); + assertEquals(2, TierBands.size()); + assertEquals(0, TierBands.bandOf("fire", 4)); + assertEquals(1, TierBands.bandOf("fire", 5)); + assertEquals(3, TierBands.bandOf("fire", 30)); + assertEquals(2, TierBands.bandOf("water", 20)); + assertEquals("III", TierBands.numeralFor("fire", 30)); + assertEquals("", TierBands.numeral(0)); + assertEquals("", TierBands.numeral(9)); + assertEquals("VIII", TierBands.numeral(8)); + } + + @Test + void chargeImprintPersistsTierPrimaryAndAuraWithoutArtifactIdentity() { + ChargeRegistry.register(new ChargeDef(2, "v.STONE", 25)); + var item = stamped(2); + var charge = Charge.fromItem(item); + assertNotNull(charge); + assertTrue(charge.isBlank()); + assertEquals(2, charge.getTier()); + assertEquals(25, charge.tierCap()); + assertEquals(VesselKind.CHARGE, charge.kind()); + assertFalse(charge.hasStoredAura()); + assertEquals("", charge.primaryElementId()); + assertFalse(charge.imprintElement(null, "fire")); + assertFalse(charge.imprintElement(item, null)); + assertFalse(charge.imprintElement(item, " ")); + assertTrue(charge.imprintElement(item, "fire")); + assertEquals("fire", charge.primaryElementId()); + assertTrue(charge.imprintElement(item, "water")); + assertEquals("fire", charge.primaryElementId()); + charge.setFill("fire", 8); + assertEquals(8, charge.getFill("fire")); + assertEquals(8, charge.totalFill()); + assertTrue(charge.hasStoredAura()); + assertEquals(25, charge.getCap("fire")); + assertEquals(Set.of("fire", "water"), charge.getCappedElementIds()); + charge.persistPdc(item); + assertEquals(8, Charge.fromItem(item).getFill("fire")); + assertEquals("fire", Charge.fromItem(item).primaryElementId()); + assertFalse(item.getItemMeta().getPersistentDataContainer().has(ArtifactKeys.artifactId())); + assertInstanceOf(Charge.class, AuraVessels.fromItem(item)); + assertNull(AuraVessels.fromItem(null)); + assertNull(AuraVessels.fromItem(new ItemStack(Material.AIR))); + assertNull(Charge.fromItem(null)); + assertNull(Charge.fromItem(new ItemStack(Material.AIR))); + assertNull(Charge.fromItem(new ItemStack(Material.STONE))); + try (var lore = mockStatic(ChargeLore.class)) { + lore.when(() -> ChargeLore.updateItem(item)).thenReturn(item); + assertSame(item, charge.write(item)); + } + } + + @Test + void shrineImprintSelectsHighestScoreAndSkipsDisabledElements() { + ChargeRegistry.register(new ChargeDef(1, "v.STONE", 10)); + ArtifactTypeRegistry.register(ArtifactGenerationTest.type("disabled", false, 1)); + var item = stamped(1); + var charge = Charge.fromItem(item); + assertFalse(charge.imprint(null, new ShrineScore(null))); + assertFalse(charge.imprint(item, null)); + assertFalse(charge.imprint(item, new ShrineScore(null))); + assertFalse(charge.imprintElement(item, "disabled")); + var scores = new LinkedHashMap(); + scores.put("missing", null); + scores.put("zero", new ShrineElementScore("zero", 0, 0, null, null)); + scores.put("disabled", new ShrineElementScore("disabled", 50, 1, null, null)); + scores.put("fire", new ShrineElementScore("fire", 10, 1, List.of("family"), List.of())); + scores.put("water", new ShrineElementScore("water", 20, 2, null, null)); + scores.put("earth", new ShrineElementScore("earth", 5, 1, null, null)); + var score = new ShrineScore(scores); + assertEquals("fire", score.get("fire").getElementId()); + assertEquals(1, score.get("fire").getAuraPerSecond()); + assertEquals(List.of("family"), score.get("fire").getActiveFamilyIds()); + assertEquals(List.of(), score.get("fire").getContributingBlocks()); + assertTrue(charge.imprint(item, score)); + assertEquals("water", charge.primaryElementId()); + assertEquals(Set.of("fire", "water", "earth"), charge.getCappedElementIds()); + ChargeRegistry.clear(); + assertEquals(0, charge.tierCap()); + assertFalse(charge.imprint(item, score)); + assertFalse(charge.imprintElement(item, "fire")); + } +} diff --git a/src/test/java/net/tfminecraft/magic/CommandEdgeCoverageTest.java b/src/test/java/net/tfminecraft/magic/CommandEdgeCoverageTest.java new file mode 100644 index 0000000..6f936f3 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/CommandEdgeCoverageTest.java @@ -0,0 +1,95 @@ +package net.tfminecraft.magic; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import io.lumine.mythic.lib.skill.trigger.TriggerType; +import java.util.*; +import net.tfminecraft.magic.artifact.*; +import net.tfminecraft.magic.artifact.config.*; +import net.tfminecraft.magic.command.MagicCommand; +import net.tfminecraft.magic.session.*; +import org.bukkit.Material; +import org.bukkit.command.CommandSender; +import org.bukkit.inventory.ItemStack; +import org.junit.jupiter.api.*; + +class CommandEdgeCoverageTest { + CommandTest f = new CommandTest(); + + @BeforeEach + void setup() { + f.setup(); + } + + @AfterEach + void cleanup() { + f.cleanup(); + } + + Object call(String name, Class[] types, Object... args) throws Exception { + var m = MagicCommand.class.getDeclaredMethod(name, types); + m.setAccessible(true); + return m.invoke(null, args); + } + + @Test + void nullableParserHelpersKeepSafeFallbacks() throws Exception { + assertEquals("-", call("dash", new Class[] {String.class}, " ")); + assertEquals("-", call("formatExtras", new Class[] {Map.class}, (Object) null)); + assertNull(call("resolveRuneKeybind", new Class[] {String.class}, (Object) null)); + assertEquals( + false, + call( + "applyResonance", + new Class[] { + org.bukkit.entity.Player.class, + ResonanceSession.class, + String.class, + double.class, + boolean.class, + CommandSender.class + }, + f.player, + new ResonanceSession(), + null, + 2d, + false, + f.player)); + assertEquals("resonance.admin.unknown_element", f.player.nextMessage()); + } + + @Test + void reloadPermissionOffersAdminTabsWithoutRuneAccess() { + var sender = mock(CommandSender.class); + when(sender.hasPermission("magic.admin.reload")).thenReturn(true); + assertTrue( + f.command.onTabComplete(sender, null, "magic", new String[] {""}).contains("reload")); + assertFalse(f.command.onTabComplete(sender, null, "magic", new String[] {""}).contains("rune")); + assertTrue(f.command.onTabComplete(sender, null, "magic", new String[] {"rune", ""}).isEmpty()); + assertTrue(f.tab("resonance", "set", "Mage", "fire", "").isEmpty()); + assertTrue(f.tab("shrine", "fill", "fire", "").isEmpty()); + ArtifactRarityRegistry.register(ArtifactGenerationTest.rarity("rare", 1, 1, 1)); + assertTrue(f.tab("artifact", "roll", "fire", "ra").contains("rare")); + } + + @Test + void malformedExternalTriggerEntriesAreSkipped() throws Exception { + var unnamed = mock(TriggerType.class); + when(unnamed.name()).thenReturn(null); + Cache.runeKeybinds = new LinkedHashSet<>(Arrays.asList(null, unnamed, TriggerType.RIGHT_CLICK)); + assertSame( + TriggerType.RIGHT_CLICK, + call("resolveRuneKeybind", new Class[] {String.class}, "right_click")); + assertEquals(List.of("RIGHT_CLICK"), f.tab("rune", "keybind", "")); + } + + @Test + void identifiedItemWithoutAuraCannotBeFilled() { + var item = new ItemStack(Material.STONE); + ArtifactIds.writeNew(item); + f.player.getInventory().setItemInMainHand(item); + f.expect("artifact.setfill.no_cap", "artifact", "setfill", "fire", "2"); + assertEquals(item, f.player.getInventory().getItemInMainHand()); + } +} diff --git a/src/test/java/net/tfminecraft/magic/CommandTest.java b/src/test/java/net/tfminecraft/magic/CommandTest.java new file mode 100644 index 0000000..becbe26 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/CommandTest.java @@ -0,0 +1,339 @@ +package net.tfminecraft.magic; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import io.lumine.mythic.lib.skill.trigger.TriggerType; +import java.util.*; +import net.tfminecraft.magic.artifact.*; +import net.tfminecraft.magic.artifact.aura.*; +import net.tfminecraft.magic.artifact.config.*; +import net.tfminecraft.magic.artifact.generate.*; +import net.tfminecraft.magic.artifact.shrine.ShrineChargeService; +import net.tfminecraft.magic.command.MagicCommand; +import net.tfminecraft.magic.gear.*; +import net.tfminecraft.magic.registry.ElementRegistry; +import net.tfminecraft.magic.session.*; +import org.bukkit.*; +import org.bukkit.command.CommandSender; +import org.bukkit.inventory.ItemStack; +import org.junit.jupiter.api.*; +import org.mockbukkit.mockbukkit.*; +import org.mockbukkit.mockbukkit.entity.PlayerMock; +import org.mockito.MockedStatic; + +class CommandTest { + ServerMock server; + PlayerMock player; + MagicCommand command = new MagicCommand(); + MockedStatic messages; + ResonanceSessionManager sessions; + + @BeforeEach + void setup() { + server = MockBukkit.mock(); + server.addSimpleWorld("world"); + player = server.addPlayer("Mage"); + player.setOp(true); + Magic.plugin = mock(Magic.class, RETURNS_DEEP_STUBS); + when(Magic.plugin.namespace()).thenReturn("magic"); + sessions = new ResonanceSessionManager(); + when(Magic.plugin.getResonanceGuiManager().getSessionManager()).thenReturn(sessions); + messages = mockStatic(Messages.class, i -> i.getArgument(0)); + ElementRegistry.clear(); + ElementRegistry.register(DomainTest.element("fire")); + ElementRegistry.register(DomainTest.element("water")); + ArtifactTypeRegistry.clear(); + ArtifactTypeRegistry.register(ArtifactGenerationTest.type("fire", true, 1)); + ArtifactTypeRegistry.register(ArtifactGenerationTest.type("water", false, 1)); + ArtifactRarityRegistry.clear(); + Cache.runeKeybinds = new LinkedHashSet<>(List.of(TriggerType.RIGHT_CLICK)); + Cache.defaultResonance = 0; + } + + @AfterEach + void cleanup() { + messages.close(); + Magic.plugin = null; + MockBukkit.unmock(); + ElementRegistry.clear(); + ArtifactTypeRegistry.clear(); + ArtifactRarityRegistry.clear(); + } + + void run(CommandSender sender, String... args) { + assertTrue(command.onCommand(sender, null, "magic", args)); + } + + void expect(String key, String... args) { + run(player, args); + assertEquals(key, player.nextMessage()); + assertNull(player.nextMessage()); + } + + List tab(String... args) { + return command.onTabComplete(player, null, "magic", args); + } + + ItemStack artifact() { + var item = new ItemStack(Material.STONE); + var a = Artifact.create(); + a.setCap("fire", 10); + a.setFill("fire", 4); + a.persistPdc(item); + ArtifactIds.writeNew(item); + return item; + } + + @Test + void permissionsRoutesAndReloadReportActualResult() { + player.setOp(false); + expect("admin.no_permission"); + expect("admin.no_permission", "reload"); + expect("rune.no_permission", "rune"); + assertTrue(tab("r").isEmpty()); + player.addAttachment(MockBukkit.createMockPlugin(), "magic.rune.keybind", true); + expect("rune.usage"); + assertEquals(List.of("rune"), tab("")); + assertTrue(tab("artifact", "").isEmpty()); + player.setOp(true); + expect("admin.usage"); + expect("admin.usage", "unknown"); + expect("reload.failed", "reload"); + when(Magic.plugin.reloadAll()).thenReturn(true); + expect("reload.success", "reload"); + run(player, "open"); + verify(Magic.plugin.getResonanceGuiManager()).tryOpen(player, true); + var console = mock(CommandSender.class); + when(console.hasPermission(anyString())).thenReturn(true); + for (String route : List.of("open", "refresh", "rune", "fillchest")) run(console, route); + verify(console).sendMessage("open.players_only"); + verify(console).sendMessage("gear.refresh.players_only"); + verify(console).sendMessage("rune.players_only"); + verify(console).sendMessage("fillchest.players_only"); + } + + @Test + void runeCommandsValidateAndReplaceOnlySuccessfulResults() { + expect("rune.usage", "rune"); + expect("rune.usage", "rune", "bad", "x"); + expect("rune.unknown_trigger", "rune", "keybind", ""); + expect("rune.unknown_trigger", "rune", "keybind", "invalid"); + try (var rune = mockStatic(RuneKeybind.class)) { + var replacement = new ItemStack(Material.BLAZE_ROD); + for (var result : RuneKeybind.Result.values()) { + rune.when(() -> RuneKeybind.apply(any(), eq(TriggerType.RIGHT_CLICK))) + .thenReturn(new RuneKeybind.Outcome(result, replacement)); + String key = + switch (result) { + case NOT_RUNE -> "not_a_rune"; + case NO_ABILITIES -> "no_abilities"; + case FAILED -> "failed"; + case OK -> "success"; + }; + expect("rune." + key, "rune", "keybind", "right_click"); + } + assertEquals(replacement, player.getInventory().getItemInMainHand()); + } + } + + @Test + void refreshReportsFailureOrReplacesAndResyncs() { + try (var refresh = mockStatic(GearRefresher.class)) { + expect("gear.refresh.not_gear", "refresh"); + refresh.when(() -> GearRefresher.isManaged(any())).thenReturn(true); + expect("gear.refresh.failed", "refresh"); + var item = new ItemStack(Material.DIAMOND_SWORD); + refresh.when(() -> GearRefresher.refresh(any(), eq(player), eq(true))).thenReturn(item); + expect("gear.refresh.ok", "refresh"); + assertEquals(item, player.getInventory().getItemInMainHand()); + verify(Magic.plugin).syncSpellModifiers(player, null); + } + } + + @Test + void resonanceCommandsValidateTargetsMutateAndPersist() { + expect("resonance.admin.usage", "resonance"); + expect("artifact.give.unknown_player", "resonance", "get", "missing"); + expect("resonance.admin.no_character", "resonance", "get", "Mage"); + sessions.setLoadedCharacterId(player, "character"); + expect("resonance.admin.no_character", "resonance", "get", "Mage"); + var s = sessions.getOrCreate(player); + run(player, "resonance", "get", "Mage"); + assertEquals("resonance.admin.get_header", player.nextMessage()); + assertEquals("resonance.admin.get_line", player.nextMessage()); + assertEquals("resonance.admin.get_line", player.nextMessage()); + expect("resonance.admin.usage", "resonance", "set", "Mage"); + expect("resonance.admin.invalid_amount", "resonance", "set", "Mage", "fire", "oops"); + for (String action : List.of("set", "add", "reset")) + expect("resonance.admin.unknown_element", "resonance", action, "Mage", "missing", "2"); + expect("resonance.admin.set_ok", "resonance", "set", "Mage", "fire", "12"); + assertEquals(12, s.getResonance("fire")); + expect("resonance.admin.add_ok", "resonance", "add", "Mage", "fire", "3"); + assertEquals(15, s.getResonance("fire")); + expect("resonance.admin.set_ok", "resonance", "set", "Mage", "all", "5"); + expect("resonance.admin.add_ok", "resonance", "add", "Mage", "all", "2"); + assertEquals(7, s.getResonance("water")); + expect("resonance.admin.reset_ok", "resonance", "reset", "Mage"); + assertEquals(0, s.getResonance("fire")); + when(Magic.plugin.getProfileService()).thenReturn(null); + expect("resonance.admin.reset_ok", "resonance", "reset", "Mage", "fire"); + expect("resonance.admin.usage", "resonance", "bogus", "Mage", "fire", "2"); + } + + @Test + void shrineCommandsValidateAndReportEveryServiceOutcome() { + expect("shrine.fill.usage", "shrine"); + expect("shrine.fill.usage", "shrine", "other"); + expect("shrine.fill.usage", "shrine", "fill"); + expect("shrine.fill.unknown_element", "shrine", "fill", "bad", "1"); + for (String amount : List.of("bad", "0", "-1")) + expect("shrine.fill.invalid_amount", "shrine", "fill", "fire", amount); + var console = mock(CommandSender.class); + when(console.hasPermission(anyString())).thenReturn(true); + run(console, "shrine", "fill"); + verify(console).sendMessage("shrine.fill.players_only"); + try (var service = mockStatic(ShrineChargeService.class)) { + for (var result : ShrineChargeService.AdminStart.values()) { + service.when(() -> ShrineChargeService.startAdmin(player, "fire", 5)).thenReturn(result); + expect( + "shrine.fill." + result.name().toLowerCase(Locale.ROOT), "shrine", "fill", "fire", "5"); + } + } + } + + @Test + void artifactCreationAndPathsValidateBeforeOpening() { + expect("artifact.usage", "artifact"); + expect("artifact.usage", "artifact", "bad"); + expect("artifact.create.usage", "artifact", "create", "extra"); + run(player, "artifact", "create"); + verify(Magic.plugin.getArtifactCreateGuiManager()).open(player); + var console = mock(CommandSender.class); + when(console.hasPermission(anyString())).thenReturn(true); + run(console, "artifact", "create"); + verify(console).sendMessage("artifact.create.players_only"); + expect("artifact.path.usage", "artifact", "path"); + expect("artifact.path.invalid", "artifact", "path", ""); + expect("artifact.path.spec", "artifact", "path", "magic.artifact"); + expect( + "artifact.path.spec", "artifact", "path", "magic.(primary=fire;rarity=rare;water=2;fire)"); + } + + @Test + void artifactFillValidatesIdentityCapAndAmount() { + expect("artifact.setfill.usage", "artifact", "setfill"); + var console = mock(CommandSender.class); + when(console.hasPermission(anyString())).thenReturn(true); + run(console, "artifact", "setfill", "fire", "1"); + verify(console).sendMessage("artifact.setfill.players_only"); + expect("artifact.setfill.no_item", "artifact", "setfill", "fire", "1"); + player.getInventory().setItemInMainHand(new ItemStack(Material.STONE)); + expect("artifact.setfill.no_id", "artifact", "setfill", "fire", "1"); + var item = artifact(); + player.getInventory().setItemInMainHand(item); + expect("artifact.setfill.unknown_element", "artifact", "setfill", "unknown", "1"); + expect("artifact.setfill.no_cap", "artifact", "setfill", "water", "1"); + expect("artifact.setfill.invalid_amount", "artifact", "setfill", "fire", "bad"); + expect("artifact.setfill.ok", "artifact", "setfill", "fire", "9"); + assertEquals( + 9, AuraVessels.fromItem(player.getInventory().getItemInMainHand()).getFill("fire")); + } + + @Test + void nonFiniteAmountsAreRejectedWithoutChangingArtifactOrResonance() { + player.getInventory().setItemInMainHand(artifact()); + sessions.setLoadedCharacterId(player, "character"); + var session = sessions.getOrCreate(player); + session.setResonance("fire", 7); + for (String amount : List.of("NaN", "Infinity", "-Infinity")) { + expect("artifact.setfill.invalid_amount", "artifact", "setfill", "fire", amount); + assertEquals( + 4, AuraVessels.fromItem(player.getInventory().getItemInMainHand()).getFill("fire")); + expect("resonance.admin.invalid_amount", "resonance", "set", "Mage", "fire", amount); + assertEquals(7, session.getResonance("fire")); + expect("shrine.fill.invalid_amount", "shrine", "fill", "fire", amount); + } + } + + @Test + void chestModesAndTabSuggestionsRespectPermissionsEnabledTypesAndPositions() { + expect("fillchest.usage", "fillchest"); + expect("fillchest.unknown", "fillchest", "bad"); + for (String mode : List.of("random", "all", "fire")) + expect("fillchest.armed", "fillchest", mode); + assertTrue(tab().isEmpty()); + assertTrue(tab("").containsAll(List.of("rune", "artifact", "shrine"))); + assertEquals(List.of("reload", "resonance", "refresh"), tab("re")); + assertEquals(List.of("keybind"), tab("rune", "")); + assertEquals(List.of("RIGHT_CLICK"), tab("rune", "keybind", "")); + assertTrue(tab("rune", "bad", "").isEmpty()); + assertTrue(tab("rune", "keybind", "", "extra").isEmpty()); + assertEquals(List.of("random", "all", "fire"), tab("fillchest", "")); + assertTrue(tab("fillchest", "all", "").isEmpty()); + assertEquals(List.of("get", "set", "add", "reset"), tab("resonance", "")); + assertEquals(List.of("Mage"), tab("resonance", "get", "")); + assertTrue(tab("resonance", "set", "Mage", "").containsAll(List.of("all", "fire", "water"))); + assertTrue(tab("resonance", "get", "Mage", "").isEmpty()); + assertEquals(List.of("fill"), tab("shrine", "")); + assertEquals(2, tab("shrine", "fill", "").size()); + assertTrue(tab("shrine", "other", "").isEmpty()); + assertEquals(5, tab("artifact", "").size()); + assertEquals(2, tab("artifact", "setfill", "").size()); + assertTrue(tab("artifact", "setfill", "fire", "").isEmpty()); + assertEquals(List.of("random", "fire"), tab("artifact", "roll", "")); + assertEquals(List.of("roll"), tab("artifact", "roll", "fire", "")); + assertTrue(tab("artifact", "roll", "fire", "roll", "").isEmpty()); + assertEquals(List.of("Mage"), tab("artifact", "give", "")); + assertEquals(List.of("random", "fire"), tab("artifact", "give", "Mage", "")); + assertEquals(List.of("roll"), tab("artifact", "give", "Mage", "fire", "")); + assertTrue(tab("artifact", "path", "").isEmpty()); + assertTrue(tab("unknown", "").isEmpty()); + } + + @Test + void artifactRollAndGiveReportErrorsBuildItemsAndDropOverflow() { + expect("artifact.give.usage", "artifact", "give"); + expect("artifact.give.unknown_player", "artifact", "give", "missing"); + ArtifactRoll[] roll = {ArtifactRoll.error("test.error", "fire", "rare")}; + ItemStack[] output = {null}; + try (var rolls = + mockConstruction( + ArtifactRoller.class, + (m, c) -> when(m.roll(anyString(), anyString())).thenAnswer(i -> roll[0])); + var builders = + mockConstruction( + ArtifactItemBuilder.class, + (m, c) -> when(m.build(any())).thenAnswer(i -> output[0]))) { + expect("test.error", "artifact", "roll", "fire", "rare"); + expect("test.error", "artifact", "give", "Mage", "fire", "rare"); + roll[0] = ArtifactRoll.error(null); + expect("artifact.roll.usage", "artifact", "roll"); + expect("artifact.roll.usage", "artifact", "give", "Mage"); + roll[0] = ArtifactRoll.ok("fire", "rare", List.of()); + expect("artifact.roll.usage", "artifact", "roll"); + roll[0] = + ArtifactRoll.ok( + "fire", + "rare", + List.of(new ArtifactAuraSlot("fire", 10), new ArtifactAuraSlot("water", 2))); + expect("artifact.give.failed", "artifact", "give", "Mage"); + output[0] = new ItemStack(Material.DIAMOND); + run(player, "artifact", "give", "Mage"); + assertEquals("artifact.roll.header", player.nextMessage()); + assertEquals("artifact.roll.secondary", player.nextMessage()); + assertEquals("artifact.give.ok", player.nextMessage()); + assertTrue(player.getInventory().contains(Material.DIAMOND)); + ArtifactRarityRegistry.register(ArtifactGenerationTest.rarity("rare", 1, 0, 1)); + run(player, "artifact", "roll"); + assertEquals("artifact.roll.header", player.nextMessage()); + assertEquals("artifact.roll.secondary", player.nextMessage()); + for (int slot = 0; slot < player.getInventory().getSize(); slot++) + player.getInventory().setItem(slot, new ItemStack(Material.STONE, 64)); + long before = player.getWorld().getEntitiesByClass(org.bukkit.entity.Item.class).size(); + run(player, "artifact", "give", "Mage", "fire", "rare"); + assertEquals( + before + 1, player.getWorld().getEntitiesByClass(org.bukkit.entity.Item.class).size()); + } + } +} diff --git a/src/test/java/net/tfminecraft/magic/ConfigurationTest.java b/src/test/java/net/tfminecraft/magic/ConfigurationTest.java new file mode 100644 index 0000000..97c9499 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/ConfigurationTest.java @@ -0,0 +1,263 @@ +package net.tfminecraft.magic; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.nio.file.*; +import java.util.*; +import java.util.logging.Logger; +import net.tfminecraft.magic.artifact.config.*; +import net.tfminecraft.magic.charge.*; +import net.tfminecraft.magic.gear.*; +import net.tfminecraft.magic.loader.*; +import net.tfminecraft.magic.registry.*; +import net.tfminecraft.magic.util.*; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.io.TempDir; + +class ConfigurationTest { + @TempDir Path temp; + + @BeforeEach + void plugin() { + org.mockbukkit.mockbukkit.MockBukkit.mock(); + Magic.plugin = mock(Magic.class); + when(Magic.plugin.getLogger()).thenReturn(Logger.getAnonymousLogger()); + when(Magic.plugin.getDataFolder()).thenReturn(temp.toFile()); + } + + @AfterEach + void cleanup() { + Magic.plugin = null; + org.mockbukkit.mockbukkit.MockBukkit.unmock(); + } + + Path write(String name, String data) throws Exception { + Path file = temp.resolve(name); + Files.writeString(file, data); + return file; + } + + @Test + void shippedConfigurationLoadsConsistentRegistries() { + Path root = Path.of("src/main/resources"); + assertTrue(new ConfigLoader().loadSafe(root.resolve("config.yml").toFile())); + assertTrue(new GuiLoader().loadSafe(root.resolve("gui.yml").toFile())); + assertTrue(new ElementsLoader().loadFolder(root.resolve("elements").toFile())); + assertTrue(ElementRegistry.size() > 0); + assertTrue(new SkillsLoader().loadSafe(root.resolve("skills.yml").toFile())); + assertTrue(SkillElementRegistry.size() > 0); + assertTrue(new ChargesLoader().loadSafe(root.resolve("charges.yml").toFile())); + assertTrue(ChargeRegistry.size() > 0); + assertTrue(new GearLoader().loadFolder(root.resolve("gear").toFile())); + assertTrue(ArtifactConfigLoader.loadFolder(root.resolve("artifacts").toFile())); + assertTrue(ArtifactTypeRegistry.size() > 0); + } + + @Test + void missingAndMalformedFilesReportFailure() throws Exception { + var missing = temp.resolve("missing").toFile(); + var malformed = write("bad.yml", "bad: [broken").toFile(); + for (var file : List.of(missing, malformed)) { + assertFalse(new ConfigLoader().loadSafe(file)); + assertFalse(new GuiLoader().loadSafe(file)); + assertFalse(new SkillsLoader().loadSafe(file)); + assertFalse(new ChargesLoader().loadSafe(file)); + } + assertFalse(new SkillsLoader().loadSafe(null)); + assertFalse(new ChargesLoader().loadSafe(null)); + new ConfigLoader().load(malformed); + new GuiLoader().load(malformed); + } + + @Test + void emptyConfigurationUsesDefaultsAndNoEntries() throws Exception { + var empty = write("empty.yml", "").toFile(); + assertTrue(new ConfigLoader().loadSafe(empty)); + assertTrue(new GuiLoader().loadSafe(empty)); + assertTrue(new SkillsLoader().loadSafe(empty)); + assertTrue(new ChargesLoader().loadSafe(empty)); + assertEquals(0, SkillElementRegistry.size()); + assertEquals(0, ChargeRegistry.size()); + } + + @Test + void skillBindingsNormalizeValidateAndClampTiers() throws Exception { + ElementRegistry.clear(); + ElementRegistry.register(DomainTest.element("fire")); + var file = + write( + "skills.yml", + "plain: fire\n" + + "high:\n" + + " element: ' FIRE '\n" + + " tier: 99\n" + + "low:\n" + + " element: fire\n" + + " tier: -1\n" + + "normal:\n" + + " element: fire\n" + + " tier: 2\n" + + "missing:\n" + + " tier: 1\n" + + "blank: ''\n" + + "unknown: water\n' ': fire\nno_tier: {element: fire}\n"); + assertTrue(new SkillsLoader().loadSafe(file.toFile())); + assertEquals(5, SkillElementRegistry.size()); + assertEquals(4, SkillElementRegistry.tierOf("high")); + assertEquals(1, SkillElementRegistry.tierOf("low")); + assertEquals(2, SkillElementRegistry.tierOf("normal")); + } + + @Test + void chargeLoaderSkipsMalformedTierAndBandEntries() throws Exception { + ElementRegistry.clear(); + ElementRegistry.register(DomainTest.element("fire")); + var file = + write( + "charges.yml", + "tiers:\n" + + " scalar: value\n" + + " text: {item: v.STONE, aura_cap: 10}\n" + + " 1: {item: v.STONE, aura_cap: 10}\n" + + " 2: {aura_cap: 10}\n" + + " 3: {item: '', aura_cap: 10}\n" + + " 4: {item: v.STONE, aura_cap: 0}\n" + + "bands:\n" + + " scalar: 2\n" + + " unknown: {1: 2}\n" + + " fire: {bad: 1, 1: 2}\n" + + " default: {1: 3}\n"); + assertTrue(new ChargesLoader().loadSafe(file.toFile())); + assertEquals(1, ChargeRegistry.size()); + assertEquals(1, TierBands.bandOf("fire", 2)); + } + + @Test + void guiRejectsInvalidAndDuplicateSlots() throws Exception { + var file = + write( + "gui.yml", + "slots: {character_head: -1, cast_mode_left: 54, cast_mode_right: 55, element_row: 9}\n" + + "artifact_create:\n" + + " slots: {preview: -1, cap_minus: 5, cap_plus: 5}\n"); + assertFalse(new GuiLoader().loadSafe(file.toFile())); + assertTrue(new GuiLoader().loadSafe(Path.of("src/main/resources/gui.yml").toFile())); + } + + @Test + void gridPartitionsEverySlotExactlyOnce() { + assertEquals(20, GridLayout.slot(2, 2)); + assertEquals(GuiCache.characterHeadSlot, GridLayout.characterHeadSlot()); + assertTrue(GridLayout.isCharacterHeadSlot(GuiCache.characterHeadSlot)); + assertFalse(GridLayout.isCharacterHeadSlot(-1)); + assertTrue(GridLayout.isCastModeSlot(GuiCache.castModeLeftSlot)); + assertTrue(GridLayout.isCastModeSlot(GuiCache.castModeRightSlot)); + assertFalse(GridLayout.isCastModeSlot(-1)); + Set all = new HashSet<>(GridLayout.reservedSlots()); + all.addAll(GridLayout.fillerSlots()); + assertEquals(54, all.size()); + assertEquals( + GridLayout.fillerSlots().size(), + GridLayout.borderFillerSlots().size() + GridLayout.innerFillerSlots().size()); + assertFalse(GridLayout.isBorderSlot(-1)); + assertFalse(GridLayout.isBorderSlot(54)); + for (int i = 0; i < 54; i++) { + assertEquals(GridLayout.elementSlots().contains(i), GridLayout.isElementSlot(i)); + assertEquals(GridLayout.fillerSlots().contains(i), GridLayout.isFillerSlot(i)); + } + assertTrue(GridLayout.validateSlots()); + } + + @Test + void elementFolderRejectsDuplicatesInvalidSlotsAndNonSections() throws Exception { + var loader = new ElementsLoader(); + assertFalse(loader.loadFolder(temp.resolve("missing").toFile())); + var scalar = write("plain.txt", "text"); + assertFalse(loader.loadFolder(scalar.toFile())); + Path folder = Files.createDirectory(temp.resolve("elements")); + assertFalse(loader.loadFolder(folder.toFile())); + Files.writeString( + folder.resolve("a.YAML"), "fire: {slot: 10, icon: v.STONE}\nblank: {slot: 11, icon: ''}\n"); + Files.writeString( + folder.resolve("b.yml"), + "fire: {slot: 12}\n" + + "duplicate: {slot: 10}\n" + + "negative: {slot: -1}\n" + + "overflow: {slot: 54}\n" + + "scalar: text\n" + + "' ': {slot: 13}\n" + + "water: {slot: 14}\n"); + Files.writeString(folder.resolve("ignore.txt"), "not yaml"); + Files.createDirectory(folder.resolve("directory.yml")); + assertFalse(loader.loadFolder(folder.toFile())); + assertEquals(3, ElementRegistry.size()); + assertEquals(2, YamlFolder.listYamlFiles(folder.toFile()).size()); + assertFalse(loader.loadSafe(write("malformed.yml", "bad: [").toFile())); + loader.load(temp.resolve("missing").toFile()); + assertEquals(List.of(), YamlFolder.listYamlFiles(null)); + assertEquals(List.of(), YamlFolder.listYamlFiles(temp.resolve("absent").toFile())); + assertEquals(List.of(), YamlFolder.listYamlFiles(scalar.toFile())); + } + + @Test + void absentArtifactConfigPreservesBothDisabledAndPositiveAuraCaps() throws Exception { + var loader = new ConfigLoader(); + var file = write("empty.yml", "{}").toFile(); + double previous = Cache.artifactAuraCap; + try { + Cache.artifactAuraCap = 0; + assertTrue(loader.loadSafe(file)); + assertEquals(0, Cache.artifactAuraCap); + Cache.artifactAuraCap = 25; + assertTrue(loader.loadSafe(file)); + assertEquals(25, Cache.artifactAuraCap); + } finally { + Cache.artifactAuraCap = previous; + } + } + + @Test + void runtimeConfigValidatesBoundsAndSupportsLegacyFields() throws Exception { + var loader = new ConfigLoader(); + assertTrue( + loader.loadSafe( + write( + "legacy.yml", + "default_corruption_tranquility: 7\n" + + "tick: {interval_ticks: -1, seconds_per_hour: 0}\n" + + "cast_drift: {mana_divisor: 0, min: 0}\n" + + "artifacts: {aura_cap: -1}\n" + + "runes: {types: [' ', ' STAFF '], keybinds: [' ', 'invalid', 'CAST']}\n" + + "gear: {station: ' ', output-slot: -1, alignment: {enabled: true, text:" + + " {mana: 1}, scalar: true, 2: {mana: 0.1}}}\n" + + "attunement: {display_furniture: ' ', muffled: {legacy: 2}}\n") + .toFile())); + assertEquals(7, Cache.defaultEquilibrium); + assertEquals(3600, Cache.secondsPerHour); + assertEquals(Set.of("staff"), Cache.runeTypes); + assertTrue(GearCache.alignmentEnabled); + assertEquals(.1, GearCache.alignment(2).mana()); + assertTrue( + loader.loadSafe( + write( + "fallback.yml", + "equilibrium: {surge: {}, tranquility: {}}\n" + + "gear: {output-slot: 99}\n" + + "attunement: {users: {ttl_days: 0}, muffled: {off_per_hour: -1," + + " recover_per_hour: -1}}\n") + .toFile())); + assertFalse(GearCache.alignmentEnabled); + assertEquals(1, net.tfminecraft.magic.attunement.ArtifactCareCache.usersTtlDays); + assertEquals(0, net.tfminecraft.magic.attunement.ArtifactCareCache.muffledOffPerHour); + loader.load(Path.of("src/main/resources/config.yml").toFile()); + } + + @Test + void attunementWithoutMuffledSectionDisablesMuffling() throws Exception { + assertTrue( + new ConfigLoader() + .loadSafe(write("no-muffle.yml", "attunement: {users: {ttl_days: 3}}\n").toFile())); + assertFalse(net.tfminecraft.magic.attunement.ArtifactCareCache.muffledEnabled); + } +} diff --git a/src/test/java/net/tfminecraft/magic/CurveEdgeCoverageTest.java b/src/test/java/net/tfminecraft/magic/CurveEdgeCoverageTest.java new file mode 100644 index 0000000..6f30bc8 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/CurveEdgeCoverageTest.java @@ -0,0 +1,48 @@ +package net.tfminecraft.magic; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.*; +import net.tfminecraft.magic.modifier.*; +import org.bukkit.configuration.file.YamlConfiguration; +import org.junit.jupiter.api.Test; + +class CurveEdgeCoverageTest { + @Test + void malformedNonFiniteKeyframesCannotPoisonInterpolation() { + var cfg = new YamlConfiguration(); + cfg.set("0.damage", 0); + cfg.set("NaN.damage", .5); + cfg.set("Infinity.damage", .7); + cfg.set(" .damage", .8); + assertEquals(1, KeyframeCurve.fromSection(cfg).size()); + assertEquals(0, KeyframeCurve.fromSection(cfg).sample(25).damage()); + var keys = new TreeMap(); + keys.put(0., ModifierTriple.ZERO); + keys.put(Double.NaN, new ModifierTriple(0, .5, 0)); + keys.put(Double.POSITIVE_INFINITY, new ModifierTriple(0, .7, 0)); + assertEquals(1, new KeyframeCurve(keys).size()); + assertEquals(0, new KeyframeCurve(keys).sample(25).damage()); + assertEquals( + 1, new KeyframeCurve(new TreeMap<>(Map.of(Double.NaN, ModifierTriple.ZERO))).size()); + } + + @Test + void exactInteriorKeysAndNanAmountsResolveToStableValues() { + var curve = + new KeyframeCurve( + new TreeMap<>( + Map.of( + 0., + ModifierTriple.ZERO, + 10., + new ModifierTriple(0, .2, 0), + 20., + new ModifierTriple(0, .4, 0)))); + assertEquals(.2, curve.sample(10).damage()); + assertEquals(.1, curve.sample(5).damage(), 1e-12); + assertEquals(0, curve.sample(Double.NaN).damage()); + assertEquals(0, curve.sample(Double.NEGATIVE_INFINITY).damage()); + assertEquals(.4, curve.sample(Double.POSITIVE_INFINITY).damage()); + } +} diff --git a/src/test/java/net/tfminecraft/magic/DomainTest.java b/src/test/java/net/tfminecraft/magic/DomainTest.java new file mode 100644 index 0000000..7d144f9 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/DomainTest.java @@ -0,0 +1,360 @@ +package net.tfminecraft.magic; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.*; +import net.tfminecraft.magic.meditation.*; +import net.tfminecraft.magic.model.*; +import net.tfminecraft.magic.modifier.*; +import net.tfminecraft.magic.profile.*; +import net.tfminecraft.magic.registry.*; +import net.tfminecraft.magic.service.*; +import net.tfminecraft.magic.session.*; +import net.tfminecraft.magic.util.*; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.*; + +class DomainTest { + @BeforeEach + void reset() { + ElementRegistry.clear(); + SkillElementRegistry.clear(); + Cache.defaultCastMode = "flow"; + Cache.defaultEquilibrium = 0; + Cache.secondsPerHour = 3600; + Cache.tickIntervalTicks = 20; + Cache.defaultResonanceDecayPerHour = -.02; + GuiCache.equilibriumMin = -100; + GuiCache.equilibriumMax = 100; + } + + @AfterEach + void clear() { + reset(); + Cache.setCastTriggers(null); + GuiCache.resetColors(null); + } + + static ElementDef element(String id) { + return new ElementDef(id, new YamlConfiguration()); + } + + @Test + void numbersRoundAndFormatWithoutLocaleOrNonfiniteLeakage() { + assertEquals(1.24, MagicNumbers.round(1.235, 2)); + assertEquals(-1.24, MagicNumbers.round(-1.235, 2)); + for (double invalid : + new double[] {Double.NaN, Double.NEGATIVE_INFINITY, Double.POSITIVE_INFINITY}) + assertEquals(0, MagicNumbers.round(invalid, 2)); + assertThrows(IllegalArgumentException.class, () -> MagicNumbers.round(1, -1)); + assertEquals(3, MagicNumbers.clamp(10, 0, 3)); + assertEquals(0, MagicNumbers.clamp(-1, 0, 3)); + assertEquals("12.3", MagicNumbers.format(12.30)); + assertEquals("1.235", MagicNumbers.format(1.2345, 3)); + assertEquals("-0.02/h", MagicNumbers.formatRatePerHour(-.02)); + assertEquals("0", MagicNumbers.format(-.001)); + } + + @Test + void meditationNeverPassesCircleOrElementCeilings() { + assertEquals(0, MeditationCeiling.allowed(10, 5, 100, 3)); + assertEquals(2, MeditationCeiling.allowed(8, 10, 100, 5)); + assertEquals(1, MeditationCeiling.allowed(8, 100, 9, 5)); + assertEquals(3, MeditationCeiling.allowed(0, 100, 100, 3)); + assertEquals(0, MeditationCeiling.allowed(0, 100, 100, 0)); + assertEquals(0, MeditationCeiling.allowed(0, 0, 100, 1)); + assertEquals(0, MeditationCeiling.allowed(0, 100, -1, 1)); + assertEquals(0, MeditationCeiling.allowed(9.999, 10, 100, 1)); + } + + @Test + void sitYieldCopiesInputsAndTracksIndividualArtifactExhaustion() { + Map caps = new HashMap<>(Map.of("a", 2., "b", 0.)); + var yield = new MeditationSitYield(caps, Map.of("a", 0, "b", 3)); + caps.put("a", 20.); + assertEquals(2, yield.sessionCap("a")); + assertEquals(0, yield.sessionCap(null)); + assertEquals(0, yield.sessionCap("unknown")); + assertEquals(1, yield.users(null)); + assertEquals(1, yield.users("a")); + assertEquals(3, yield.users("b")); + assertTrue(yield.hasAnyCap()); + assertFalse(yield.exhausted(null)); + assertFalse(yield.exhausted(Map.of("a", 1.))); + assertTrue(yield.exhausted(Map.of("a", 1.999))); + assertThrows(UnsupportedOperationException.class, () -> yield.sessionCaps().put("c", 1.)); + assertFalse(MeditationSitYield.empty().hasAnyCap()); + assertTrue(new MeditationSitYield(null, null).exhausted(null)); + assertFalse(new MeditationSitYield(Map.of("a", -.1), null).hasAnyCap()); + } + + @Test + void modifiersInterpolateMultiplyAndClamp() { + var a = new ModifierTriple(.2, -.2, .3); + var b = new ModifierTriple(.4, .2, -.1); + assertSame(a, a.lerp(null, .5)); + assertSame(a, a.lerp(b, 0)); + assertSame(b, a.lerp(b, 1)); + assertEquals(.3, a.lerp(b, .5).mana(), 1e-10); + assertEquals(0, a.lerp(b, .5).damage(), 1e-10); + assertEquals(.1, a.lerp(b, .5).cooldown(), 1e-10); + assertEquals(.68, a.combine(b).mana(), 1e-10); + assertEquals(-.04, a.combine(b).damage(), 1e-10); + assertEquals(.17, a.combine(b).cooldown(), 1e-10); + var clamped = new ModifierTriple(4, -4, -4).clamp(); + assertEquals(.9, clamped.mana()); + assertEquals(-.9, clamped.damage()); + assertEquals(-.5, clamped.cooldown()); + assertEquals(.2, a.combine(null).mana()); + assertTrue(ModifierTriple.ZERO.isZero()); + assertFalse(a.isZero()); + assertFalse(new ModifierTriple(0, 1, 0).isZero()); + assertFalse(new ModifierTriple(0, 0, 1).isZero()); + assertSame(ModifierTriple.ZERO, ModifierTriple.from(null)); + var config = new YamlConfiguration(); + config.set("mana", .5); + assertEquals(.5, ModifierTriple.from(config).mana()); + } + + @Test + void curvesUseEndpointsExactKeysAndInterpolation() { + assertTrue(new KeyframeCurve(null).sample(100).isZero()); + assertEquals(1, new KeyframeCurve(new TreeMap<>()).size()); + var curve = KeyframeCurve.defaultSurge(); + assertEquals(4, curve.size()); + assertTrue(curve.sample(-1).isZero()); + assertEquals(.1, curve.sample(15).damage()); + assertEquals(.125, curve.sample(37.5).damage(), 1e-10); + assertEquals(.1, curve.sample(1000).damage()); + assertEquals(.25, KeyframeCurve.defaultTranquility().sample(100).damage()); + assertEquals(0, KeyframeCurve.defaultResonance().sample(50).damage(), 1e-10); + assertTrue(KeyframeCurve.fromSection(null).sample(1).isZero()); + var config = new YamlConfiguration(); + config.set("not-number.mana", 1); + config.set("30", "not-section"); + assertEquals(1, KeyframeCurve.fromSection(config).size()); + config.set("0.mana", .2); + config.set("100.mana", -.2); + assertEquals(0, KeyframeCurve.fromSection(config).sample(50).mana(), 1e-10); + } + + @Test + void elementConfigurationSupportsColorsPermissionsAndCurves() { + var defaults = element("fire"); + assertEquals("fire", defaults.getName()); + assertEquals("v.BARRIER", defaults.getIcon()); + assertEquals("#ffffff", defaults.getColor()); + assertNull(defaults.getPermission()); + assertTrue(defaults.isUnlocked(null)); + assertEquals(100, defaults.getMaxResonance()); + assertEquals(-.02, defaults.getDecayPerHour()); + assertEquals(0, defaults.getAuraDecayPerHour()); + assertEquals(-1, defaults.getSlot()); + assertEquals(2, defaults.getResonanceCurve().size()); + var cfg = new YamlConfiguration(); + cfg.set("name", "Flame"); + cfg.set("icon", "v.BLAZE_ROD"); + cfg.set("color", List.of("FF0000", "#00FF00", "bad")); + cfg.set("permission", " magic.fire "); + cfg.set("max_resonance", 0); + cfg.set("decay_per_hour", 2); + cfg.set("aura_decay_per_hour", 3); + cfg.set("slot", 10); + cfg.set("resonance.0.damage", 0); + cfg.set("resonance.100.damage", 1); + var fire = new ElementDef("fire", cfg); + assertEquals(List.of("#ff0000", "#00ff00"), fire.getColors()); + assertEquals("Flame", fire.getName()); + assertEquals("v.BLAZE_ROD", fire.getIcon()); + assertEquals(1, fire.getMaxResonance()); + assertEquals(2, fire.getDecayPerHour()); + assertEquals(3, fire.getAuraDecayPerHour()); + assertEquals(10, fire.getSlot()); + assertFalse(fire.isUnlocked(null)); + Player p = mock(Player.class); + assertFalse(fire.isUnlocked(p)); + when(p.hasPermission("magic.fire")).thenReturn(true); + assertTrue(fire.isUnlocked(p)); + assertEquals(.5, fire.getResonanceCurve().sample(50).damage()); + cfg.set("color", "broken"); + cfg.set("resonance", null); + cfg.set("resonance.10.damage", 1); + assertEquals("#ffffff", new ElementDef("x", cfg).getColor()); + assertEquals(2, new ElementDef("x", cfg).getResonanceCurve().size()); + } + + @Test + void registryRejectsMissingIdsAndReturnsImmutableSnapshots() { + ElementRegistry.register(null); + ElementRegistry.register(element(null)); + ElementRegistry.register(element(" ")); + assertEquals(0, ElementRegistry.size()); + ElementRegistry.register(element("fire")); + var old = ElementRegistry.getAll(); + var cfg = new YamlConfiguration(); + cfg.set("slot", 12); + ElementRegistry.register(new ElementDef("water", cfg)); + assertEquals(1, old.size()); + assertEquals(2, ElementRegistry.size()); + assertTrue(ElementRegistry.contains("fire")); + assertEquals(List.of("fire", "water"), ElementRegistry.getAllIds()); + assertEquals(List.of(12), ElementRegistry.getOccupiedSlots()); + assertThrows(UnsupportedOperationException.class, () -> ElementRegistry.getAll().clear()); + } + + @Test + void skillRegistryNormalizesAndBoundsTiers() { + SkillElementRegistry.register(null, "a"); + SkillElementRegistry.register(" ", "a"); + SkillElementRegistry.register("a", null); + SkillElementRegistry.register("a", " "); + assertEquals(0, SkillElementRegistry.size()); + SkillElementRegistry.register(" FIRE_BALL ", " FIRE ", 9); + SkillElementRegistry.register("wave", "water"); + assertEquals("fire", SkillElementRegistry.elementOf(" Fire_Ball ")); + assertEquals(4, SkillElementRegistry.tierOf("fire_ball")); + assertEquals(1, SkillElementRegistry.tierOf("wave")); + assertNull(SkillElementRegistry.elementOf(null)); + assertNull(SkillElementRegistry.elementOf(" ")); + assertNull(SkillElementRegistry.elementOf("unknown")); + assertEquals(1, SkillElementRegistry.tierOf("unknown")); + assertEquals(1, SkillElementRegistry.clampTier(-1)); + assertEquals(2, SkillElementRegistry.clampTier(2)); + assertEquals(Map.of("fire_ball", "fire", "wave", "water"), SkillElementRegistry.bindings()); + assertThrows( + UnsupportedOperationException.class, () -> SkillElementRegistry.bindings().clear()); + } + + @Test + void sessionClampsRoundsAndInvalidatesOnlyChangedValues() { + ElementRegistry.register(element("fire")); + var s = new ResonanceSession(); + assertEquals("flow", s.getCastModeId()); + assertEquals(0, s.modifierRevision()); + s.setEquilibrium(0); + assertEquals(0, s.modifierRevision()); + s.setEquilibrium(1000); + assertEquals(100, s.getEquilibrium()); + s.setEquilibrium(-1000); + assertEquals(-100, s.getEquilibrium()); + s.setResonance("unknown", 1); + assertEquals(0, s.getResonance("unknown")); + s.setResonance("fire", 12.345); + assertEquals(12.35, s.getResonance("fire")); + long revision = s.modifierRevision(); + s.setResonance("fire", 12.35); + s.addResonance("fire", 0); + assertEquals(revision, s.modifierRevision()); + s.addResonance("fire", 1000); + assertEquals(100, s.getResonance("fire")); + assertEquals(Map.of("fire", 100.), s.copyResonance()); + s.setResonance("fire", -1); + assertEquals(0, s.getResonance("fire")); + revision = s.modifierRevision(); + s.setResonance("fire", 0); + assertEquals(revision, s.modifierRevision()); + for (String mode : new String[] {null, " ", "unknown", "SURGE"}) { + s.setCastModeId(mode); + assertEquals("surge", s.getCastModeId()); + } + s.setCastModeId("FLOW"); + assertEquals("flow", s.getCastModeId()); + Map next = new HashMap<>(); + next.put(null, 1.); + next.put("water", null); + next.put("fire", 5.); + s.setResonanceMap(next); + assertEquals(Map.of("fire", 5.), s.copyResonance()); + s.setResonanceMap(null); + assertTrue(s.copyResonance().isEmpty()); + s.setResonanceMap(Map.of()); + } + + @Test + void profileRoundTripsSessionAndPrunesInvalidAmounts() { + ElementRegistry.register(element("fire")); + UUID owner = UUID.randomUUID(); + var profile = MagicProfile.fromDefaults("character", owner); + assertEquals("character", profile.getCharacterId()); + assertEquals(owner.toString(), profile.getOwnerUuid()); + assertEquals("flow", profile.getCastMode()); + assertEquals(0, profile.getEquilibrium()); + profile.setCharacterId("second"); + profile.setOwnerUuid("owner"); + profile.setCastMode("surge"); + profile.setEquilibrium(-3.5); + assertEquals("second", profile.getCharacterId()); + assertEquals("owner", profile.getOwnerUuid()); + Map amounts = new HashMap<>(); + amounts.put(null, 4.); + amounts.put("empty", null); + amounts.put("low", 0.); + amounts.put("tiny", .0002); + amounts.put("fire", 1000.); + profile.setResonance(amounts); + assertEquals(2, profile.copyResonance().size()); + profile.pruneEmptyResonance(); + assertEquals(100, profile.getResonance().get("fire")); + var session = new ResonanceSession(); + profile.applyTo(session); + assertEquals("surge", session.getCastModeId()); + assertEquals(-3.5, session.getEquilibrium()); + assertEquals(100, session.getResonance("fire")); + var saved = MagicProfile.fromSession("saved", null, session); + assertNull(saved.getOwnerUuid()); + assertEquals(Map.of("fire", 100.), saved.copyResonance()); + profile.setResonance(null); + assertTrue(profile.getResonance().isEmpty()); + assertNull(MagicProfile.fromDefaults("x", null).getOwnerUuid()); + } + + @Test + void runtimeTickUnitsAndTriggerNamesAreStable() { + assertEquals(1, Cache.tickIntervalSeconds()); + assertEquals(1. / 3600, Cache.tickHours()); + Cache.tickIntervalTicks = 0; + assertEquals(.05, Cache.tickIntervalSeconds()); + Cache.secondsPerHour = 0; + assertEquals(.05 / 3600, Cache.tickHours()); + Cache.secondsPerHour = 1; + assertEquals(.05, Cache.tickHours()); + Cache.setCastTriggers(Arrays.asList(null, " ", " api ", "API", "cast")); + assertEquals(Set.of("API", "CAST"), Cache.castTriggers); + assertThrows(UnsupportedOperationException.class, () -> Cache.castTriggers.clear()); + Cache.setCastTriggers(null); + assertTrue(Cache.castTriggers.isEmpty()); + } + + @Test + void guiColorsAndModesKeepImmutableCopies() { + GuiCache.resetColors(Map.of()); + assertEquals("fallback", GuiCache.color("x", "fallback")); + var input = new HashMap<>(Map.of("x", "#fff", "empty", " ")); + GuiCache.resetColors(input); + input.clear(); + assertEquals("#fff", GuiCache.color("x", "")); + assertEquals("fallback", GuiCache.color("empty", "fallback")); + var mode = new CastModeDef(null, null, null, null); + assertEquals("", mode.getId()); + assertEquals("v.BARRIER", mode.getIcon()); + assertNull(mode.getName()); + assertEquals(List.of(), mode.getLore()); + mode = new CastModeDef("flow", "icon", "name", List.of("lore")); + assertEquals("flow", mode.getId()); + assertEquals("icon", mode.getIcon()); + assertEquals("name", mode.getName()); + assertEquals(List.of("lore"), mode.getLore()); + } + + @Test + void equilibriumRatesRespectSignAndDepth() { + assertEquals(0, EquilibriumRates.corruptionDriftPerHour(0)); + assertEquals(0, EquilibriumRates.tranquilityDecayPerHour(-1)); + assertEquals(.05 * 1.625, EquilibriumRates.corruptionDriftPerHour(-50)); + assertEquals(.03, EquilibriumRates.tranquilityDecayPerHour(50)); + EquilibriumService.tickOnlineSessions(null); + ResonanceService.tickOnlineSessions(null); + } +} diff --git a/src/test/java/net/tfminecraft/magic/ElementVisibilityEdgeTest.java b/src/test/java/net/tfminecraft/magic/ElementVisibilityEdgeTest.java new file mode 100644 index 0000000..f85cbc6 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/ElementVisibilityEdgeTest.java @@ -0,0 +1,65 @@ +package net.tfminecraft.magic; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.*; +import net.tfminecraft.magic.artifact.config.*; +import net.tfminecraft.magic.artifact.sacrifice.*; +import net.tfminecraft.magic.artifact.shrine.*; +import net.tfminecraft.magic.model.*; +import org.junit.jupiter.api.*; + +class ElementVisibilityEdgeTest { + @BeforeEach + @AfterEach + void reset() { + ArtifactTypeRegistry.clear(); + ShrineRegistry.clear(); + SacrificeRegistry.clear(); + } + + ArtifactTypeDef type(String id, boolean enabled) { + return new ArtifactTypeDef(id, "name", "model", enabled, 1, Map.of(), Map.of()); + } + + @Test + void enabledTypesResolveExactTrimmedAndCaseInsensitiveIds() { + assertTrue(ElementVisibility.typeEnabled(null)); + assertTrue(ElementVisibility.typeEnabled(" ")); + assertTrue(ElementVisibility.typeEnabled("unknown")); + ArtifactTypeRegistry.register(type("fire", false)); + assertFalse(ElementVisibility.typeEnabled("fire")); + assertFalse(ElementVisibility.typeEnabled(" FIRE ")); + ArtifactTypeRegistry.register(type("Water", true)); + assertTrue(ElementVisibility.typeEnabled("water")); + assertTrue(ElementVisibility.typeEnabled("earth")); + assertFalse(ElementVisibility.shownOnCharge("fire")); + assertFalse(ElementVisibility.shownOnArtifact("fire", "fire")); + } + + @Test + void shrineAndSacrificeAvailabilityDetermineVisibleElements() { + assertFalse(ElementVisibility.playerCanCharge(null)); + assertFalse(ElementVisibility.playerCanCharge(" ")); + assertTrue(ElementVisibility.shownOnCharge("fire")); + assertTrue(ElementVisibility.shownOnArtifact("fire", null)); + ShrineRegistry.register(new ShrineElementDef("fire", true, List.of())); + assertTrue(ElementVisibility.playerCanCharge("fire")); + ShrineRegistry.register(new ShrineElementDef("fire", false, List.of())); + SacrificeRegistry.setEnabled(false); + assertFalse(ElementVisibility.playerCanCharge("fire")); + assertFalse(ElementVisibility.shownOnArtifact("fire", null)); + assertFalse(ElementVisibility.shownOnArtifact("fire", " ")); + assertFalse(ElementVisibility.shownOnArtifact("fire", "water")); + assertTrue(ElementVisibility.shownOnArtifact("fire", "FIRE")); + SacrificeRegistry.setEnabled(true); + assertFalse(ElementVisibility.playerCanCharge("fire")); + SacrificeRegistry.register(new SacrificeElementDef("fire", false, List.of(), "", "", "", 0)); + assertFalse(ElementVisibility.playerCanCharge("fire")); + SacrificeRegistry.register(new SacrificeElementDef("fire", true, List.of(), "", "", "", 0)); + assertTrue(ElementVisibility.playerCanCharge("fire")); + SacrificeRegistry.setSceneryCharge(false); + ShrineRegistry.register(new ShrineElementDef("fire", true, List.of())); + assertTrue(ElementVisibility.playerCanCharge("fire")); + } +} diff --git a/src/test/java/net/tfminecraft/magic/FillChestTest.java b/src/test/java/net/tfminecraft/magic/FillChestTest.java new file mode 100644 index 0000000..2fcf95a --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/FillChestTest.java @@ -0,0 +1,136 @@ +package net.tfminecraft.magic; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.*; +import net.tfminecraft.magic.artifact.config.*; +import net.tfminecraft.magic.artifact.fillchest.ArtifactFillChestService; +import net.tfminecraft.magic.artifact.fillchest.ArtifactFillChestService.*; +import net.tfminecraft.magic.artifact.generate.*; +import org.bukkit.*; +import org.bukkit.block.*; +import org.bukkit.inventory.*; +import org.junit.jupiter.api.*; +import org.mockbukkit.mockbukkit.*; + +class FillChestTest { + ServerMock server; + Block block; + Inventory inventory; + + @BeforeEach + void setup() { + server = MockBukkit.mock(); + var world = server.addSimpleWorld("chests"); + block = world.getBlockAt(0, 60, 0); + block.setType(Material.CHEST); + inventory = ((Chest) block.getState()).getInventory(); + ArtifactTypeRegistry.clear(); + } + + @AfterEach + void cleanup() { + ArtifactFillChestService.clearAll(); + ArtifactTypeRegistry.clear(); + MockBukkit.unmock(); + } + + @Test + void pendingRequestsAndModesCanBeArmedConsumedAndCleared() { + var id = UUID.randomUUID(); + assertNull(ArtifactFillChestService.get(null)); + assertNull(ArtifactFillChestService.get(id)); + for (var mode : Mode.values()) { + ArtifactFillChestService.arm(id, mode, "fire"); + var pending = ArtifactFillChestService.get(id); + assertEquals(mode, pending.mode()); + assertEquals("fire", pending.typeId()); + assertTrue(pending.expireAtMs() > System.currentTimeMillis()); + ArtifactFillChestService.consume(id); + assertNull(ArtifactFillChestService.get(id)); + } + ArtifactFillChestService.arm(id, Mode.ALL, null); + ArtifactFillChestService.clear(null); + ArtifactFillChestService.clear(id); + assertNull(ArtifactFillChestService.get(id)); + assertNull(ArtifactFillChestService.parseMode(null)); + assertNull(ArtifactFillChestService.parseMode("")); + assertNull(ArtifactFillChestService.parseMode("missing")); + ArtifactTypeRegistry.register(ArtifactGenerationTest.type("fire", true, 1)); + ArtifactTypeRegistry.register(ArtifactGenerationTest.type("water", false, 1)); + assertEquals(Mode.TYPE, ArtifactFillChestService.parseMode("FIRE")); + assertNull(ArtifactFillChestService.parseMode("water")); + assertEquals(Mode.ALL, ArtifactFillChestService.parseMode("ALL")); + assertEquals(Mode.RANDOM, ArtifactFillChestService.parseMode("random")); + assertFalse(ArtifactFillChestService.isChestBlock(null)); + assertTrue(ArtifactFillChestService.isChestBlock(block)); + block.setType(Material.TRAPPED_CHEST); + assertTrue(ArtifactFillChestService.isChestBlock(block)); + block.setType(Material.STONE); + assertFalse(ArtifactFillChestService.isChestBlock(block)); + assertEquals(0, ArtifactFillChestService.fill(block, new Pending(Mode.ALL, null, 0))); + } + + @Test + void generationFailurePreservesExistingChestContents() { + var original = new ItemStack(Material.DIAMOND, 12); + inventory.setItem(0, original); + try (var rolls = + mockConstruction( + ArtifactRoller.class, + (m, c) -> + when(m.roll(anyString(), anyString())) + .thenReturn(ArtifactRoll.error("failure"))); + var builders = mockConstruction(ArtifactItemBuilder.class)) { + assertEquals(0, ArtifactFillChestService.fill(block, new Pending(Mode.ALL, null, 0))); + assertEquals(original, inventory.getItem(0)); + } + } + + @Test + void successfulFillReplacesChestAndClosesViewersAcrossAllModes() { + var roll = ArtifactRoll.ok("fire", "common", List.of(new ArtifactAuraSlot("fire", 10))); + var item = new ItemStack(Material.DIAMOND); + var viewer = server.addPlayer(); + viewer.openInventory(inventory); + try (var rolls = + mockConstruction( + ArtifactRoller.class, + (m, c) -> when(m.roll(anyString(), anyString())).thenReturn(roll)); + var builders = + mockConstruction( + ArtifactItemBuilder.class, + (m, c) -> when(m.build(roll, null, null, false)).thenReturn(item))) { + for (var mode : Mode.values()) { + assertEquals(27, ArtifactFillChestService.fill(block, new Pending(mode, "fire", 0))); + for (var actual : inventory.getContents()) assertEquals(item, actual); + } + assertTrue(inventory.getViewers().isEmpty()); + } + } + + @Test + void invalidGeneratedItemsNeverReplaceTheChest() { + var roll = ArtifactRoll.ok("fire", "common", List.of()); + ItemStack[] output = {null}; + ArtifactRoll[] generated = {ArtifactRoll.error("generation-failed")}; + try (var rolls = + mockConstruction( + ArtifactRoller.class, + (m, c) -> when(m.roll(anyString(), anyString())).thenAnswer(i -> generated[0])); + var builders = + mockConstruction( + ArtifactItemBuilder.class, + (m, c) -> + when(m.build(any(), isNull(), isNull(), eq(false))) + .thenAnswer(i -> output[0]))) { + assertEquals(0, ArtifactFillChestService.fill(block, new Pending(Mode.ALL, null, 0))); + assertEquals(0, ArtifactFillChestService.fill(block, new Pending(Mode.TYPE, null, 0))); + generated[0] = roll; + assertEquals(0, ArtifactFillChestService.fill(block, new Pending(Mode.RANDOM, null, 0))); + output[0] = new ItemStack(Material.AIR); + assertEquals(0, ArtifactFillChestService.fill(block, new Pending(Mode.TYPE, "fire", 0))); + } + } +} diff --git a/src/test/java/net/tfminecraft/magic/GearBrokenCoverageTest.java b/src/test/java/net/tfminecraft/magic/GearBrokenCoverageTest.java new file mode 100644 index 0000000..758cc87 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/GearBrokenCoverageTest.java @@ -0,0 +1,75 @@ +package net.tfminecraft.magic; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.*; +import net.Indyuce.mmoitems.MMOItems; +import net.tfminecraft.magic.gear.*; +import org.bukkit.*; +import org.bukkit.inventory.*; +import org.bukkit.persistence.PersistentDataType; +import org.junit.jupiter.api.*; +import org.mockbukkit.mockbukkit.MockBukkit; + +class GearBrokenCoverageTest extends GearMmoCoverageSupport { + @Test + void marksAndClearsFlagsWithoutLosingOrphans() { + GearBrokenMarker.clearWarningSession(); + for (ItemStack s : Arrays.asList(null, new ItemStack(Material.AIR))) { + assertFalse(GearBrokenMarker.isBroken(s)); + GearBrokenMarker.mark(s); + GearBrokenMarker.clear(s); + assertTrue(GearBrokenMarker.orphans(s).isEmpty()); + GearBrokenMarker.addOrphans(s, List.of(new GearBrokenMarker.Orphan("RUNE", "A"))); + } + var s = item(); + assertFalse(GearBrokenMarker.isBroken(s)); + tag(s, GearKeys.broken(), PersistentDataType.BYTE, (byte) 0); + assertFalse(GearBrokenMarker.isBroken(s)); + GearBrokenMarker.addOrphans(s, null); + GearBrokenMarker.addOrphans(s, List.of()); + assertTrue(GearBrokenMarker.orphans(s).isEmpty()); + tag(s, GearKeys.orphans(), PersistentDataType.STRING, " "); + assertTrue(GearBrokenMarker.orphans(s).isEmpty()); + tag(s, GearKeys.orphans(), PersistentDataType.STRING, "bad,:id,type:, RUNE : A "); + assertEquals(List.of(new GearBrokenMarker.Orphan("RUNE", "A")), GearBrokenMarker.orphans(s)); + GearBrokenMarker.addOrphans(s, List.of(new GearBrokenMarker.Orphan("RUNE", "B"))); + try (var lore = mockStatic(WeaponLore.class)) { + GearBrokenMarker.mark(s); + lore.verify(() -> WeaponLore.apply(s)); + } + assertTrue(GearBrokenMarker.isBroken(s)); + assertEquals(2, GearBrokenMarker.orphans(s).size()); + GearBrokenMarker.clear(s); + assertFalse(GearBrokenMarker.isBroken(s)); + assertTrue(GearBrokenMarker.orphans(s).isEmpty()); + } + + @Test + void rebuildsAvailableRunesAndWarnsForUnavailableOnes() { + var s = item(); + GearBrokenMarker.addOrphans( + s, + List.of( + new GearBrokenMarker.Orphan("RUNE", "good"), + new GearBrokenMarker.Orphan("RUNE", "missing"), + new GearBrokenMarker.Orphan("RUNE", "air"), + new GearBrokenMarker.Orphan("RUNE", "error"))); + assertTrue(GearBrokenMarker.buildOrphanItems(s).isEmpty()); + MockBukkit.createMockPlugin("MMOItems"); + var rune = item(); + when(MMOItems.plugin.getItem("RUNE", "missing")).thenReturn(null); + when(MMOItems.plugin.getItem("RUNE", "good")).thenReturn(rune); + when(MMOItems.plugin.getItem("RUNE", "air")).thenReturn(new ItemStack(Material.AIR)); + when(MMOItems.plugin.getItem("RUNE", "error")).thenThrow(new IllegalArgumentException()); + assertEquals(List.of(rune), GearBrokenMarker.buildOrphanItems(s)); + GearBrokenMarker.clearWarningSession(); + GearBrokenMarker.notifyOnce(null, s, null); + GearBrokenMarker.notifyOnce(null, s, List.of()); + GearBrokenMarker.clearWarningSession(); + GearBrokenMarker.notifyOnce(server.addPlayer(), s, List.of("missing")); + GearBrokenMarker.clearWarningSession(); + GearBrokenMarker.notifyOnce(null, s, List.of()); + } +} diff --git a/src/test/java/net/tfminecraft/magic/GearChargeCoverageTest.java b/src/test/java/net/tfminecraft/magic/GearChargeCoverageTest.java new file mode 100644 index 0000000..c0ccbd3 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/GearChargeCoverageTest.java @@ -0,0 +1,168 @@ +package net.tfminecraft.magic; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.*; +import net.tfminecraft.magic.charge.*; +import net.tfminecraft.magic.gear.*; +import net.tfminecraft.magic.gear.orb.GearOrbService; +import net.tfminecraft.magic.registry.ElementRegistry; +import net.tfminecraft.magic.session.ResonanceSession; +import org.bukkit.*; +import org.bukkit.entity.Player; +import org.bukkit.inventory.*; +import org.junit.jupiter.api.*; + +class GearChargeCoverageTest extends GearCoverageSupport { + long oldConfirm; + + @BeforeEach + void config() throws Exception { + oldConfirm = GearCache.confirmMillis; + GearCache.confirmMillis = 100000; + ElementRegistry.clear(); + TierBands.clear(); + TierBands.register("default", 1, 5); + var field = GearChargeService.class.getDeclaredField("CONFIRMS"); + field.setAccessible(true); + ((Map) field.get(null)).clear(); + } + + @AfterEach + void reset() { + GearCache.confirmMillis = oldConfirm; + ElementRegistry.clear(); + TierBands.clear(); + } + + Charge charge(int tier) { + var stack = item(); + tag(stack, ChargeKeys.chargeTier(), org.bukkit.persistence.PersistentDataType.INTEGER, tier); + return Charge.fromItem(stack); + } + + @Test + void guardsRejectInvalidEmptyBusyAndBrokenStations() { + var player = server.addPlayer(); + var loc = player.getLocation(); + var hand = item(); + var charge = charge(1); + var occupancy = mock(GearStationStore.Occupancy.class); + try (var charges = mockStatic(Charge.class); + var stations = mockStatic(GearStationStore.class); + var orbs = mockStatic(GearOrbService.class); + var broken = mockStatic(GearBrokenMarker.class)) { + GearChargeService.tryApply(player, loc, hand); + charges.when(() -> Charge.fromItem(hand)).thenReturn(charge); + GearChargeService.tryApply(player, loc, hand); + charge.setCap("fire", 5); + GearChargeService.tryApply(player, loc, hand); + charge.setFill("fire", 5); + GearChargeService.tryApply(player, loc, hand); + stations.when(() -> GearStationStore.get(loc)).thenReturn(occupancy); + when(occupancy.isOrbSessionActive()).thenReturn(true); + GearChargeService.tryApply(player, loc, hand); + when(occupancy.isOrbSessionActive()).thenReturn(false); + orbs.when(() -> GearOrbService.isActive(loc)).thenReturn(true); + GearChargeService.tryApply(player, loc, hand); + orbs.when(() -> GearOrbService.isActive(loc)).thenReturn(false); + var weapon = item(); + when(occupancy.getItem()).thenReturn(weapon); + broken.when(() -> GearBrokenMarker.isBroken(weapon)).thenReturn(true); + GearChargeService.tryApply(player, loc, hand); + orbs.verify(() -> GearOrbService.begin(any(), any(), any(), any(), anyInt()), never()); + } + } + + @Test + void successfulRunsConsumeOnlyAfterBeginAndSnapshotPositiveFill() { + var p = server.addPlayer(); + var loc = p.getLocation(); + var hand = item(); + hand.setAmount(2); + p.getInventory().setItemInMainHand(hand); + var charge = charge(2); + charge.setCap("fire", 5); + charge.setCap("water", 5); + charge.setFill("fire", 5); + var occupancy = mock(GearStationStore.Occupancy.class); + try (var charges = mockStatic(Charge.class); + var stations = mockStatic(GearStationStore.class); + var orbs = mockStatic(GearOrbService.class)) { + charges.when(() -> Charge.fromItem(hand)).thenReturn(charge); + stations.when(() -> GearStationStore.get(loc)).thenReturn(occupancy); + GearChargeService.tryApply(p, loc, hand); + assertEquals(2, hand.getAmount()); + verify(occupancy).setOrbSessionActive(false); + orbs.when(() -> GearOrbService.begin(p, loc, Map.of("fire", 5d), "fire", 2)).thenReturn(true); + GearChargeService.tryApply(p, loc, hand); + assertEquals(1, hand.getAmount()); + GearChargeService.tryApply(p, loc, hand); + assertTrue(p.getInventory().getItemInMainHand().getType().isAir()); + verify(occupancy, times(3)).setOrbSessionActive(true); + } + } + + @Test + void confirmationIsPlayerStationAndExpirySpecific() { + ElementRegistry.register(DomainTest.element("fire")); + TierBands.register("default", 1, 5); + TierBands.register("default", 2, 10); + var p = server.addPlayer(); + var loc = p.getLocation(); + var hand = item(); + var charge = charge(1); + charge.setCap("fire", 10); + charge.setFill("fire", 10); + var occupancy = mock(GearStationStore.Occupancy.class); + var sessions = Magic.plugin.getResonanceGuiManager().getSessionManager(); + when(sessions.get(p)).thenReturn(null); + try (var charges = mockStatic(Charge.class); + var stations = mockStatic(GearStationStore.class); + var orbs = mockStatic(GearOrbService.class)) { + charges.when(() -> Charge.fromItem(hand)).thenReturn(charge); + stations.when(() -> GearStationStore.get(loc)).thenReturn(occupancy); + stations.when(() -> GearStationStore.key(loc)).thenReturn("first"); + GearCache.confirmMillis = -1; + GearChargeService.tryApply(p, loc, hand); + GearCache.confirmMillis = 100000; + GearChargeService.tryApply(p, loc, hand); + stations.when(() -> GearStationStore.key(loc)).thenReturn("second"); + GearChargeService.tryApply(p, loc, hand); + orbs.verify(() -> GearOrbService.begin(any(), any(), any(), any(), anyInt()), never()); + GearChargeService.tryApply(p, loc, hand); + orbs.verify(() -> GearOrbService.begin(p, loc, Map.of("fire", 10d), "fire", 1)); + } + } + + @Test + void resonanceWarningsSkipUnchargedAndAdequatelyMatchedElements() throws Exception { + var p = server.addPlayer(); + var legacy = item(); + tag(legacy, ChargeKeys.chargeTier(), org.bukkit.persistence.PersistentDataType.INTEGER, 1); + tag( + legacy, + net.tfminecraft.magic.artifact.ArtifactKeys.auraData(), + org.bukkit.persistence.PersistentDataType.STRING, + "tiny:1:0,high:0:10,low:10:10,matched:10:10"); + var charge = Charge.fromItem(legacy); + for (String id : List.of("none", "tiny", "high", "low", "matched")) + ElementRegistry.register(DomainTest.element(id)); + TierBands.register("default", 1, 5); + TierBands.register("default", 2, 10); + var session = mock(ResonanceSession.class); + when(session.getResonance("low")).thenReturn(5d); + when(session.getResonance("matched")).thenReturn(10d); + when(Magic.plugin.getResonanceGuiManager().getSessionManager().get(p)).thenReturn(session); + var lines = + (List) + invoke( + GearChargeService.class, + "overResonance", + new Class[] {Player.class, Charge.class}, + p, + charge); + assertEquals(2, lines.size()); + } +} diff --git a/src/test/java/net/tfminecraft/magic/GearCostsCoverageTest.java b/src/test/java/net/tfminecraft/magic/GearCostsCoverageTest.java new file mode 100644 index 0000000..ecb5911 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/GearCostsCoverageTest.java @@ -0,0 +1,110 @@ +package net.tfminecraft.magic; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.*; +import net.tfminecraft.magic.gear.*; +import net.tfminecraft.magic.util.ItemRef; +import org.bukkit.*; +import org.bukkit.entity.*; +import org.bukkit.inventory.*; +import org.junit.jupiter.api.*; + +class GearCostsCoverageTest extends GearCoverageSupport { + PartDef cost(String id, int amount) { + return new PartDef( + id, + id, + "core", + 1, + Set.of(GearType.STAFF), + "v.STICK", + Map.of("v.STONE", amount), + List.of(), + Map.of(), + Map.of(), + List.of(), + null, + 1, + false); + } + + @Test + void combinedCostsCannotWrapNegativeAndPermitFreeCrafting() { + assertEquals( + Integer.MAX_VALUE, + GearCosts.total(List.of(cost("a", Integer.MAX_VALUE), cost("b", Integer.MAX_VALUE))) + .get("v.STONE")); + } + + @Test + void inventoryChecksAndConsumptionHandleBypassFailuresAndPartialStacks() { + assertTrue(GearCosts.total(null).isEmpty()); + assertTrue( + GearCosts.total( + Arrays.asList( + null, + new PartDef( + null, null, null, 0, null, null, null, null, null, null, null, null, 0, + false))) + .isEmpty()); + assertFalse(GearCosts.bypasses(null)); + var p = server.addPlayer(); + var parts = List.of(cost("core", 5)); + try (var libs = mockStatic(net.tfminecraft.tlibs.TLibs.class)) { + var api = mock(net.tfminecraft.tlibs.objects.api.ItemAPI.class, RETURNS_DEEP_STUBS); + libs.when(net.tfminecraft.tlibs.TLibs::getItemAPI).thenReturn(api); + when(api.getChecker().checkItemWithPath(any(), any())) + .thenAnswer(i -> ((ItemStack) i.getArgument(0)).getType() == Material.STONE); + p.getInventory().setItem(0, new ItemStack(Material.DIRT, 10)); + p.getInventory().setItem(1, new ItemStack(Material.STONE, 2)); + assertFalse(GearCosts.has(p, parts)); + p.getInventory().setItem(2, new ItemStack(Material.STONE, 4)); + assertTrue(GearCosts.has(p, parts)); + GearCosts.take(p, parts); + assertEquals(1, p.getInventory().getItem(2).getAmount()); + assertEquals(10, p.getInventory().getItem(0).getAmount()); + GearCosts.take(null, parts); + GearCosts.take(p, List.of(cost("zero", 0))); + p.setOp(true); + assertTrue(GearCosts.has(p, parts)); + GearCosts.take(p, parts); + assertEquals(1, p.getInventory().getItem(2).getAmount()); + p.setOp(false); + var checker = api.getChecker(); + doThrow(new IllegalArgumentException("bad path")) + .when(checker) + .checkItemWithPath(any(), any()); + assertFalse(GearCosts.has(p, parts)); + GearCosts.take(p, parts); + assertEquals(1, p.getInventory().getItem(2).getAmount()); + } + } + + @Test + void refundsSplitStacksAndDropOnlyInventoryOverflow() { + var p = server.addPlayer(); + GearCosts.refund(null, Map.of(), null); + GearCosts.refund(p, null, null); + GearCosts.refund(p, Map.of(), null); + try (var refs = mockStatic(ItemRef.class)) { + refs.when(() -> ItemRef.build("stone")).thenAnswer(i -> new ItemStack(Material.STONE)); + GearCosts.refund(p, Map.of("stone", 65), null); + assertEquals(64, p.getInventory().getItem(0).getAmount()); + assertEquals(1, p.getInventory().getItem(1).getAmount()); + GearCosts.refund(p, Map.of("missing", 1), null); + refs.when(() -> ItemRef.build("air")).thenReturn(new ItemStack(Material.AIR)); + GearCosts.refund(p, Map.of("air", 1, "stone", 0), null); + var world = mock(World.class); + for (int i = 0; i < p.getInventory().getSize(); i++) + p.getInventory().setItem(i, new ItemStack(Material.DIRT, 64)); + GearCosts.refund(p, Map.of("stone", 2), new Location(world, 1, 2, 3)); + verify(world) + .dropItem( + eq(new Location(world, 1.5, 3, 3.5)), + argThat(it -> it.getType() == Material.STONE && it.getAmount() == 2)); + GearCosts.refund(p, Map.of("stone", 1), new Location(null, 1, 2, 3)); + } + } +} diff --git a/src/test/java/net/tfminecraft/magic/GearCoverageSupport.java b/src/test/java/net/tfminecraft/magic/GearCoverageSupport.java new file mode 100644 index 0000000..a940bf6 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/GearCoverageSupport.java @@ -0,0 +1,77 @@ +package net.tfminecraft.magic; + +import static org.mockito.Mockito.*; + +import java.nio.file.Path; +import java.util.*; +import java.util.logging.Logger; +import net.tfminecraft.magic.gear.*; +import net.tfminecraft.magic.gear.orb.OrbCache; +import org.bukkit.*; +import org.bukkit.inventory.*; +import org.bukkit.persistence.*; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.io.TempDir; +import org.mockbukkit.mockbukkit.*; + +abstract class GearCoverageSupport { + @TempDir Path temp; + ServerMock server; + Map orbConfig = new HashMap<>(); + + @BeforeEach + void setupGear() throws Exception { + server = MockBukkit.mock(); + Magic.plugin = mock(Magic.class, RETURNS_DEEP_STUBS); + when(Magic.plugin.getName()).thenReturn("Magic"); + when(Magic.plugin.namespace()).thenReturn("magic"); + when(Magic.plugin.getServer()).thenReturn(server); + when(Magic.plugin.isEnabled()).thenReturn(true); + when(Magic.plugin.getLogger()).thenReturn(Logger.getLogger("GearCoverage")); + when(Magic.plugin.getDataFolder()).thenReturn(temp.toFile()); + for (var f : OrbCache.class.getFields()) + if (!java.lang.reflect.Modifier.isFinal(f.getModifiers())) orbConfig.put(f, f.get(null)); + ArchetypeRegistry.clear(); + PartRegistry.clear(); + } + + @AfterEach + void cleanupGear() throws Exception { + try { + MockBukkit.unmock(); + } finally { + Magic.plugin = null; + ArchetypeRegistry.clear(); + PartRegistry.clear(); + for (var e : orbConfig.entrySet()) e.getKey().set(null, e.getValue()); + } + } + + static ItemStack item() { + var item = new ItemStack(Material.STICK); + var meta = item.getItemMeta(); + meta.setDisplayName("Gear"); + item.setItemMeta(meta); + return item; + } + + static void tag( + ItemStack item, NamespacedKey key, PersistentDataType type, Z value) { + var meta = item.getItemMeta(); + if (value == null) meta.getPersistentDataContainer().remove(key); + else meta.getPersistentDataContainer().set(key, type, value); + item.setItemMeta(meta); + } + + static Object invoke(Object target, String name, Class[] params, Object... args) + throws Exception { + Class type = target instanceof Class c ? c : target.getClass(); + var method = type.getDeclaredMethod(name, params); + method.setAccessible(true); + return method.invoke(target instanceof Class ? null : target, args); + } + + static PartDef part(String id, int tier) { + return GearDefinitionTest.part(id, tier, List.of(), Map.of("spell", 1)); + } +} diff --git a/src/test/java/net/tfminecraft/magic/GearDefinitionTest.java b/src/test/java/net/tfminecraft/magic/GearDefinitionTest.java new file mode 100644 index 0000000..eff0599 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/GearDefinitionTest.java @@ -0,0 +1,254 @@ +package net.tfminecraft.magic; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.*; +import net.tfminecraft.magic.gear.*; +import net.tfminecraft.magic.modifier.ModifierTriple; +import org.bukkit.Material; +import org.junit.jupiter.api.*; + +class GearDefinitionTest { + @BeforeEach + @AfterEach + void reset() { + SocketLayout.clearLabels(); + SocketColourRegistry.clear(); + GearCache.socketRarityPrefix = true; + GearCache.clearAlignment(); + GearCache.alignmentEnabled = false; + } + + static PartDef part(String id, int tier, List limit, Map sockets) { + return new PartDef( + id, + "Name", + "CORE", + tier, + Set.of(GearType.STAFF), + " v.STICK ", + Map.of("v.STONE", 2), + limit, + Map.of("damage", 1.), + sockets, + List.of("lore"), + " SCHEME ", + 2, + false); + } + + static ArchetypeDef archetype() { + return new ArchetypeDef( + GearType.STAFF, + "Name", + " template ", + " icon ", + false, + List.of("core", "head", "grip"), + new LinkedHashMap<>(Map.of("spell", "Spell", "support", "Support"))); + } + + @Test + void partDefinitionsNormalizeInputsAndExposeStableGameplayContent() { + var sockets = new HashMap(); + sockets.put(null, 1); + sockets.put("bad", null); + sockets.put("zero", 0); + sockets.put("SPELL", 2); + var p = part(" Test ", 2, Arrays.asList(null, " ", " HEAD ", "head"), sockets); + assertEquals("test", p.getId()); + assertEquals("Name", p.getName()); + assertEquals("core", p.getPartType()); + assertEquals(2, p.getTier()); + assertTrue(p.hasTier()); + assertEquals(Set.of(GearType.STAFF), p.getTypes()); + assertTrue(p.supports(GearType.STAFF)); + assertFalse(p.supports(GearType.WAND)); + assertFalse(p.supports(null)); + assertEquals("v.STICK", p.getItemPath()); + assertTrue(p.hasCost()); + assertEquals(Map.of("v.STONE", 2), p.getCost()); + assertEquals(List.of("head"), p.getPartLimit()); + assertTrue(p.hasPartLimit()); + assertTrue(p.allowsPart(" HEAD ")); + assertFalse(p.allowsPart(null)); + assertFalse(p.allowsPart(" ")); + assertFalse(p.allowsPart("grip")); + assertEquals(Map.of("damage", 1.), p.getStats()); + assertEquals(Map.of("spell", 2), p.getSockets()); + assertEquals(0, p.socketCount(null)); + assertEquals(0, p.socketCount("unknown")); + assertEquals(2, p.socketCount(" SPELL ")); + assertEquals(2, p.totalSocketCount()); + assertEquals(List.of("lore"), p.getLore()); + assertEquals("scheme", p.getSchemeId()); + assertEquals(2, p.getSchemeWeight()); + assertTrue(p.hasModelScheme()); + assertFalse(p.isDisabled()); + assertEquals(1, p.getRevision()); + p.setRevision(3); + assertEquals(3, p.getRevision()); + assertTrue(p.buildRevisionContent().contains("sockets={spell=2}")); + var empty = + new PartDef(null, null, null, 0, null, null, null, null, null, null, null, null, 0, true); + assertEquals("", empty.getId()); + assertEquals("", empty.getName()); + assertFalse(empty.hasTier()); + assertTrue(empty.getTypes().isEmpty()); + assertFalse(empty.hasCost()); + assertFalse(empty.hasPartLimit()); + assertTrue(empty.allowsPart(null)); + assertFalse(empty.hasModelScheme()); + assertEquals(1, empty.getSchemeWeight()); + assertTrue(empty.isDisabled()); + assertEquals( + part("x", 1, List.of(), Map.of()).buildRevisionContent(), + part("other", 1, List.of(), Map.of()).buildRevisionContent()); + } + + @Test + void archetypeCopiesConfigurationAndHashesGameplayFieldsOnly() { + var slots = new HashMap(); + slots.put(null, "bad"); + slots.put("missing", null); + slots.put("empty", " "); + slots.put(" SPELL ", " Spell "); + var a = new ArchetypeDef(GearType.STAFF, null, null, null, true, null, slots); + assertEquals(GearType.STAFF, a.getType()); + assertEquals("Staff", a.getName()); + assertEquals("", a.getTemplate()); + assertEquals("", a.getIcon()); + assertTrue(a.isMelee()); + assertEquals(List.of(), a.getRequired()); + assertEquals(Map.of("spell", "Spell"), a.getSlots()); + assertEquals(List.of("spell"), a.slotIds()); + assertEquals("", a.slotSuffix(null)); + assertEquals("", a.slotSuffix("unknown")); + assertEquals("Spell", a.slotSuffix(" SPELL ")); + assertEquals(1, a.getRevision()); + a.setRevision(2); + assertEquals(2, a.getRevision()); + assertTrue(a.buildRevisionContent().contains("melee=true")); + assertEquals( + Map.of(), new ArchetypeDef(GearType.WAND, " ", null, null, false, null, null).getSlots()); + var full = archetype(); + assertEquals("Name", full.getName()); + assertEquals("template", full.getTemplate()); + assertEquals("icon", full.getIcon()); + assertFalse(full.isMelee()); + assertEquals(List.of("core", "head", "grip"), full.getRequired()); + } + + @Test + void partSlotsIntersectCoreRestrictionsAndRequiredCategories() { + assertEquals(List.of(), PartSlots.open(null, null)); + assertEquals(List.of("core", "head", "grip"), PartSlots.open(archetype(), null)); + assertEquals( + List.of("core", "head", "grip"), + PartSlots.open(archetype(), part("a", 1, List.of(), Map.of()))); + assertEquals( + List.of("core", "head"), + PartSlots.open(archetype(), part("a", 1, List.of("core", "head", "unknown"), Map.of()))); + assertFalse(PartSlots.contains(null, "a")); + assertFalse(PartSlots.contains(List.of("a"), null)); + assertFalse(PartSlots.contains(List.of("a"), " ")); + assertTrue(PartSlots.contains(List.of("head"), " HEAD ")); + } + + @Test + void majorityTierUsesCountsAndBreaksTiesTowardHigherTier() { + assertEquals(0, MajorityTierResolver.resolve(null)); + assertEquals(0, MajorityTierResolver.resolve(List.of())); + var one = part("one", 1, List.of(), Map.of()); + var two = part("two", 2, List.of(), Map.of()); + var three = part("three", 3, List.of(), Map.of()); + assertEquals( + 1, + MajorityTierResolver.resolve( + Arrays.asList(null, part("zero", 0, List.of(), Map.of()), one, one, two))); + assertEquals(3, MajorityTierResolver.resolve(List.of(one, two, three))); + assertEquals( + List.of("0", "I", "II", "III", "IV", "5"), + java.util.stream.IntStream.rangeClosed(0, 5) + .mapToObj(MajorityTierResolver::toRoman) + .toList()); + } + + @Test + void socketLabelsColorsAndCapacityAreStable() { + SocketLayout.putLabel(null, "x"); + SocketLayout.putLabel(" ", "x"); + SocketLayout.putLabel("x", null); + SocketLayout.putLabel("x", " "); + SocketLayout.putLabel(" CAST ", " Custom "); + assertEquals("Custom", SocketLayout.label("cast")); + assertEquals("Minor Spell", SocketLayout.label("minor_spell")); + assertEquals("New Kind", SocketLayout.label("new_kind")); + assertEquals("", SocketLayout.label(null)); + assertEquals("", SocketLayout.label(" ")); + assertEquals("", SocketLayout.prettyId(null)); + assertEquals("", SocketLayout.prettyId(" ")); + assertEquals("A Long Name", SocketLayout.prettyId("a__long-name")); + assertEquals("Custom", SocketLayout.labels().get("cast")); + SocketColourRegistry.setDefaultPrefix(null); + SocketColourRegistry.setDefaultPrefix(" "); + SocketColourRegistry.setDefaultPrefix(" Rare "); + SocketColourRegistry.put(0, "x"); + SocketColourRegistry.put(1, null); + SocketColourRegistry.put(1, " "); + SocketColourRegistry.put(2, " Epic "); + assertEquals("Rare", SocketColourRegistry.defaultPrefix()); + assertEquals("Rare", SocketColourRegistry.prefix(0)); + assertEquals("Epic", SocketColourRegistry.prefix(2)); + assertEquals("Rare", SocketColourRegistry.prefix(3)); + assertEquals("", SocketColourRegistry.colour(1, null)); + assertEquals("", SocketColourRegistry.colour(1, " ")); + assertEquals("Epic Spell", SocketColourRegistry.colour(2, "Spell")); + assertEquals(Map.of(0, "Rare", 2, "Epic"), SocketColourRegistry.prefixes()); + GearCache.socketRarityPrefix = false; + assertEquals("Spell", SocketColourRegistry.colour(2, " Spell ")); + assertEquals(List.of(), SocketLayout.colours(null, null, 1)); + assertEquals(0, SocketLayout.rawTotal(null, null)); + assertEquals(0, SocketLayout.rawTotal(archetype(), null)); + assertEquals(List.of("§8No rune sockets"), SocketLayout.previewLines(null, null)); + assertEquals(List.of("§8No rune sockets"), SocketLayout.previewLines(archetype(), null)); + var parts = Arrays.asList(null, part("a", 1, List.of(), Map.of("spell", 6))); + assertEquals(6, SocketLayout.rawTotal(archetype(), parts)); + assertEquals(4, SocketLayout.colours(archetype(), parts, 1).size()); + var preview = SocketLayout.previewLines(archetype(), parts); + assertEquals(5, preview.size()); + assertEquals("§8Clamped to 4 (was 6)", preview.getLast()); + assertEquals( + 1, + SocketLayout.previewLines(archetype(), List.of(part("a", 1, List.of(), Map.of("spell", 1)))) + .size()); + } + + @Test + void gearTypesAndAlignmentAreExplicitlyBounded() { + assertNull(GearType.fromId(null)); + assertNull(GearType.fromId(" ")); + assertNull(GearType.fromId("bad")); + assertEquals(GearType.STAFF, GearType.fromId(" staff ")); + assertEquals(Material.STICK, GearType.STAFF.getIcon()); + assertEquals("Staff", GearType.STAFF.getDisplayName()); + GearCache.clearAlignment(); + GearCache.putAlignment(0, ModifierTriple.ZERO); + GearCache.putAlignment(1, null); + assertEquals(0, GearCache.alignmentBands()); + var triple = new ModifierTriple(.1, .2, .3); + GearCache.putAlignment(2, triple); + assertEquals(1, GearCache.alignmentBands()); + GearCache.alignmentEnabled = false; + assertSame(ModifierTriple.ZERO, GearCache.alignment(2)); + GearCache.alignmentEnabled = true; + assertSame(ModifierTriple.ZERO, GearCache.alignment(0)); + assertSame(ModifierTriple.ZERO, GearCache.alignment(1)); + assertSame(triple, GearCache.alignment(2)); + var type = new PartTypeDef(" head ", 4); + assertEquals("head", type.getId()); + assertEquals("Head", type.getName()); + assertEquals(4, type.getSlot()); + assertEquals("Fancy", new PartTypeDef(null, 0, " Fancy ").getName()); + } +} diff --git a/src/test/java/net/tfminecraft/magic/GearDomainEdgeCoverageTest.java b/src/test/java/net/tfminecraft/magic/GearDomainEdgeCoverageTest.java new file mode 100644 index 0000000..8f401a6 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/GearDomainEdgeCoverageTest.java @@ -0,0 +1,129 @@ +package net.tfminecraft.magic; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.*; +import net.tfminecraft.magic.gear.*; +import net.tfminecraft.magic.gear.orb.OrbCache; +import org.junit.jupiter.api.*; + +class GearDomainEdgeCoverageTest extends GearCoverageSupport { + @AfterEach + void registries() { + PartTypeRegistry.clear(); + OrbCache.clearTiers(); + SocketLayout.clearLabels(); + } + + @Test + void orbDifficultyFallsBackToLowestConfiguredTierRegardlessOfYamlOrder() { + OrbCache.clearTiers(); + var high = new OrbCache.Tier(4, 0.7, 2, 40, 4); + var low = new OrbCache.Tier(1, 1, 1, 30, 1); + OrbCache.putTier(4, high); + OrbCache.putTier(1, low); + assertSame(low, OrbCache.tier(0)); + } + + @Test + void invalidTiersAndNearestLowerRowsHaveDeterministicDefaults() { + OrbCache.clearTiers(); + assertEquals(4, OrbCache.tier(0).live()); + OrbCache.putTier(0, new OrbCache.Tier(1, 1, 0, 20, 1)); + OrbCache.putTier(1, null); + assertEquals(0, OrbCache.tierCount()); + var two = new OrbCache.Tier(2, .5, 1, 20, 2); + var one = new OrbCache.Tier(1, .5, -1, 20, 1); + var four = new OrbCache.Tier(4, .5, 1, 20, 4); + OrbCache.putTier(2, two); + OrbCache.putTier(1, one); + OrbCache.putTier(4, four); + assertSame(two, OrbCache.tier(3)); + assertSame(four, OrbCache.tier(4)); + assertEquals(1, one.speed()); + assertTrue(OrbCache.spawnGap(4, 0) > 0); + assertEquals(1, OrbCache.spawnGap(1, 1)); + } + + @Test + void registriesRejectMissingKeysAndApplyAllFilters() { + PartRegistry.register(null); + PartRegistry.register( + new PartDef( + null, " ", null, 0, Set.of(), null, null, null, null, null, null, null, 0, false)); + assertEquals(0, PartRegistry.size()); + assertNull(PartRegistry.get(null)); + var good = part("good", 1); + PartRegistry.register(good); + PartRegistry.register( + new PartDef( + "disabled", + "name", + "core", + 1, + null, + null, + null, + null, + null, + null, + null, + null, + 1, + true)); + assertEquals(List.of(good), PartRegistry.matching(null, null)); + assertTrue(PartRegistry.matching("other", null).isEmpty()); + assertTrue(PartRegistry.matching(null, GearType.WAND).isEmpty()); + assertEquals(List.of(good), PartRegistry.matching("core", GearType.STAFF)); + assertSame(good, PartRegistry.firstMatching("core", GearType.STAFF)); + assertNull(PartRegistry.firstMatching("missing", GearType.STAFF)); + assertNotNull(GearKeys.partPick()); + assertNotNull(GearKeys.stationDisplay()); + PartTypeRegistry.register(null); + PartTypeRegistry.register(new PartTypeDef(null, 0, null)); + assertNull(PartTypeRegistry.get(null)); + assertEquals(0, PartTypeRegistry.size()); + PartTypeRegistry.register(new PartTypeDef("core", 1, "Core")); + assertEquals(1, PartTypeRegistry.getAll().size()); + assertEquals("core", PartTypeRegistry.idForSlot(1)); + assertNull(PartTypeRegistry.idForSlot(2)); + ArchetypeRegistry.register(null); + ArchetypeRegistry.register(new ArchetypeDef(null, "name", null, null, false, null, null)); + assertEquals(0, ArchetypeRegistry.size()); + ArchetypeRegistry.register(GearDefinitionTest.archetype()); + assertEquals(1, ArchetypeRegistry.getAll().size()); + } + + @Test + void slotsHandleWhitespaceDuplicatesAndRestrictedCoreCategories() { + var archetype = + new ArchetypeDef( + GearType.STAFF, + "name", + null, + null, + false, + List.of(" head ", "HEAD", " ", "grip"), + null); + assertEquals(List.of("head", "grip"), PartSlots.open(archetype, null)); + var core = GearDefinitionTest.part("core", 1, List.of("core", "head", "absent"), Map.of()); + assertEquals(List.of("head"), PartSlots.open(archetype, core)); + assertEquals(17, MajorityTierResolver.resolve(List.of(part("a", 17), part("b", 2)))); + assertEquals(List.of(), SocketLayout.colours(GearDefinitionTest.archetype(), null, 1)); + assertEquals( + 1, SocketLayout.colours(GearDefinitionTest.archetype(), List.of(part("one", 1)), 1).size()); + assertEquals( + List.of(), + SocketLayout.colours(GearDefinitionTest.archetype(), Arrays.asList((PartDef) null), 1)); + } + + @Test + void socketCountsDoNotWrapAroundAndEraseSockets() { + var archetype = GearDefinitionTest.archetype(); + var a = GearDefinitionTest.part("a", 1, List.of(), Map.of("spell", Integer.MAX_VALUE)); + var b = GearDefinitionTest.part("b", 1, List.of(), Map.of("spell", Integer.MAX_VALUE)); + assertEquals(4, SocketLayout.colours(archetype, List.of(a, b), 1).size()); + assertEquals(Integer.MAX_VALUE, SocketLayout.rawTotal(archetype, List.of(a, b))); + assertEquals(5, SocketLayout.previewLines(archetype, List.of(a, b)).size()); + } +} diff --git a/src/test/java/net/tfminecraft/magic/GearGuiCoverageTest.java b/src/test/java/net/tfminecraft/magic/GearGuiCoverageTest.java new file mode 100644 index 0000000..9d958cd --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/GearGuiCoverageTest.java @@ -0,0 +1,379 @@ +package net.tfminecraft.magic; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.*; +import net.tfminecraft.magic.gear.*; +import net.tfminecraft.magic.gear.gui.*; +import net.tfminecraft.magic.util.ItemRef; +import org.bukkit.*; +import org.bukkit.entity.*; +import org.bukkit.event.inventory.*; +import org.bukkit.inventory.*; +import org.bukkit.persistence.PersistentDataType; +import org.junit.jupiter.api.*; +import org.mockito.MockedStatic; + +class GearGuiCoverageTest extends GearCoverageSupport { + org.mockito.MockedStatic libs; + net.tfminecraft.tlibs.objects.api.subapi.BlockChecker checker; + MockedStatic builder; + MockedStatic refs; + GearInventoryManager gui; + Player p; + int output; + + @BeforeEach + void setupGui() { + libs = mockStatic(net.tfminecraft.tlibs.TLibs.class); + var blockAPI = mock(net.tfminecraft.tlibs.objects.api.BlockAPI.class, RETURNS_DEEP_STUBS); + checker = blockAPI.getChecker(); + libs.when(net.tfminecraft.tlibs.TLibs::getBlockAPI).thenReturn(blockAPI); + when(checker.checkBlock(any(), any())).thenReturn(true); + p = server.addPlayer(); + gui = new GearInventoryManager(); + PartTypeRegistry.clear(); + output = GearCache.outputSlot; + GearCache.outputSlot = 22; + builder = mockStatic(GearItemBuilder.class); + builder + .when(() -> GearItemBuilder.preview(any(), anyCollection())) + .thenAnswer(a -> new ItemStack(Material.STICK)); + refs = mockStatic(ItemRef.class); + refs.when(() -> ItemRef.buildOrFallback(any(), any())) + .thenAnswer(a -> new ItemStack((Material) a.getArgument(1))); + } + + @AfterEach + void cleanupGui() { + libs.close(); + SelectedPartsManager.clear(p); + TypeSelectionManager.clear(p); + OpenStationManager.clear(p); + PartTypeRegistry.clear(); + GearCache.outputSlot = output; + refs.close(); + builder.close(); + } + + PartDef part( + String id, String category, Set types, boolean disabled, List limit) { + var part = + new PartDef( + id, + id, + category, + 1, + types, + "v.stick", + Map.of("v.stone", 2), + limit, + Map.of(), + Map.of("spell", 1), + List.of("Existing"), + null, + 1, + disabled); + PartRegistry.register(part); + return part; + } + + void config() { + ArchetypeRegistry.register( + new ArchetypeDef( + GearType.STAFF, + "Staff", + "SWORD.TEST", + "v.stick", + false, + List.of("core", "head", "grip", "missing", "invalid0", "invalidbig"), + Map.of())); + PartTypeRegistry.register(new PartTypeDef("core", 10, "Core")); + PartTypeRegistry.register(new PartTypeDef("head", 11)); + PartTypeRegistry.register(new PartTypeDef("grip", 12)); + PartTypeRegistry.register(new PartTypeDef("invalid0", 0)); + PartTypeRegistry.register(new PartTypeDef("invalidbig", 99)); + part("base", "core", Set.of(GearType.STAFF), false, List.of()); + part("head", "head", Set.of(GearType.STAFF), false, List.of()); + } + + Inventory top() { + return p.getOpenInventory().getTopInventory(); + } + + InventoryClickEvent event(Inventory inventory, int slot, ItemStack current) { + var e = mock(InventoryClickEvent.class); + when(e.getWhoClicked()).thenReturn(p); + when(e.getInventory()).thenReturn(inventory); + when(e.getClickedInventory()).thenReturn(inventory); + when(e.getSlot()).thenReturn(slot); + when(e.getCurrentItem()).thenReturn(current); + return e; + } + + @Test + void assemblyAndPartMenusUseValidDefaultsAndBoundedSlots() throws Exception { + assertTrue(gui.collectParts(p, GearType.STAFF).isEmpty()); + gui.openAssembly(p); + assertEquals(27, top().getSize()); + config(); + gui.openAssembly(p); + assertEquals(Material.PAPER, top().getItem(10).getType()); + assertEquals(Material.BARRIER, top().getItem(12).getType()); + assertEquals(2, gui.collectParts(p, GearType.STAFF).size()); + for (String selected : List.of("missing", "disabled", "wrongtype", "wrongcategory", "base")) { + part("disabled", "core", Set.of(GearType.STAFF), true, List.of()); + part("wrongtype", "core", Set.of(GearType.WAND), false, List.of()); + part("wrongcategory", "head", Set.of(GearType.STAFF), false, List.of()); + SelectedPartsManager.set(p, "core", selected); + gui.openAssembly(p); + assertEquals("base", top().getItem(10).getItemMeta().getDisplayName()); + } + gui.openPartSelection(p, "closed"); + assertTrue(top().getHolder() instanceof AssemblyHolder); + gui.openPartSelection(p, "grip"); + assertEquals(Material.BARRIER, top().getItem(4).getType()); + gui.openPartSelection(p, "head"); + assertTrue(top().getHolder() instanceof PartSelectionHolder); + assertEquals( + "head", + top() + .getItem(0) + .getItemMeta() + .getPersistentDataContainer() + .get(GearKeys.partPick(), PersistentDataType.STRING)); + for (int i = 0; i < 60; i++) part("many" + i, "head", Set.of(GearType.STAFF), false, List.of()); + gui.openPartSelection(p, "head"); + assertEquals(54, top().getSize()); + assertEquals(Material.ARROW, top().getItem(53).getType()); + gui.openTypeSelection(p); + assertEquals(9, top().getSize()); + ArchetypeRegistry.register( + new ArchetypeDef(GearType.WAND, "Wand", "template", "v.stick", true, List.of(), Map.of())); + gui.openTypeSelection(p); + assertTrue( + Arrays.stream(top().getContents()) + .filter(Objects::nonNull) + .anyMatch( + i -> + i.getItemMeta().hasLore() + && i.getItemMeta().getLore().contains("§8Melee weapon"))); + } + + @Test + void menuClickRoutingHonorsHoldersPartsAndTypeSelection() throws Exception { + var unrelated = Bukkit.createInventory(null, 9); + var e = event(unrelated, 0, null); + when(e.getWhoClicked()).thenReturn(mock(HumanEntity.class)); + gui.onClick(e); + gui.onClick(event(unrelated, 0, null)); + config(); + gui.openAssembly(p); + var assembly = top(); + for (ItemStack item : + Arrays.asList( + null, new ItemStack(Material.GRAY_STAINED_GLASS_PANE), new ItemStack(Material.BARRIER))) + gui.onClick(event(assembly, 1, item)); + gui.onClick(event(assembly, 25, item())); + gui.onClick(event(assembly, 0, item())); + assertTrue(top().getHolder() instanceof TypeSelectionHolder); + var selection = top(); + gui.onClick(event(selection, 0, null)); + var bare = mock(ItemStack.class); + gui.onClick(event(selection, 0, bare)); + var unknown = item(); + gui.onClick(event(selection, 0, unknown)); + gui.onClick(event(selection, 8, selection.getItem(8))); + assertTrue(top().getHolder() instanceof AssemblyHolder); + gui.openTypeSelection(p); + selection = top(); + gui.onClick(event(selection, 0, selection.getItem(0))); + assertEquals(GearType.STAFF, TypeSelectionManager.get(p)); + gui.openAssembly(p); + gui.onClick(event(top(), 11, top().getItem(11))); + assertTrue(top().getHolder() instanceof PartSelectionHolder); + selection = top(); + gui.onClick(event(selection, 0, null)); + gui.onClick(event(selection, 0, bare)); + gui.onClick(event(selection, 0, item())); + gui.onClick(event(selection, 0, selection.getItem(0))); + assertEquals("head", SelectedPartsManager.get(p, "head")); + gui.openPartSelection(p, "head"); + selection = top(); + gui.onClick( + event(selection, selection.getSize() - 1, selection.getItem(selection.getSize() - 1))); + assertTrue(top().getHolder() instanceof AssemblyHolder); + part("limited", "core", Set.of(GearType.STAFF), false, List.of("head")); + SelectedPartsManager.set(p, "grip", "removed"); + gui.openPartSelection(p, "core"); + var pick = item(); + tag(pick, GearKeys.partPick(), PersistentDataType.STRING, "limited"); + gui.onClick(event(top(), 0, pick)); + assertNull(SelectedPartsManager.get(p, "grip")); + assertEquals("limited", SelectedPartsManager.get(p, "core")); + gui.onClick(event(top(), 12, item())); + } + + @Test + void preparationReportsMissingStationsInvalidBuildsCostsAndStaffBypass() throws Exception { + config(); + gui.openAssembly(p); + var assembly = top(); + gui.onClick(event(assembly, GearCache.outputSlot, item())); + var location = new Location(server.addSimpleWorld("world"), 1, 2, 3); + OpenStationManager.set(p, location); + try (var stores = mockStatic(GearStationStore.class); + var costs = mockStatic(GearCosts.class)) { + stores.when(() -> GearStationStore.isOccupied(location)).thenReturn(true); + gui.onClick(event(assembly, GearCache.outputSlot, item())); + stores.when(() -> GearStationStore.isOccupied(location)).thenReturn(false); + builder + .when(() -> GearItemBuilder.preview(any(), anyCollection())) + .thenReturn(new ItemStack(Material.BARRIER)); + gui.onClick(event(assembly, GearCache.outputSlot, item())); + builder + .when(() -> GearItemBuilder.preview(any(), anyCollection())) + .thenAnswer(a -> new ItemStack(Material.STICK)); + gui.onClick(event(assembly, GearCache.outputSlot, item())); + costs.when(() -> GearCosts.has(eq(p), anyCollection())).thenReturn(true); + gui.onClick(event(assembly, GearCache.outputSlot, item())); + builder + .when(() -> GearItemBuilder.prepare(any(), anyCollection())) + .thenReturn(new ItemStack(Material.BARRIER)); + gui.onClick(event(assembly, GearCache.outputSlot, item())); + builder + .when(() -> GearItemBuilder.prepare(any(), anyCollection())) + .thenAnswer(a -> new ItemStack(Material.STICK)); + for (boolean bypass : List.of(false, true)) { + OpenStationManager.set(p, location); + costs.when(() -> GearCosts.bypasses(p)).thenReturn(bypass); + costs.when(() -> GearCosts.total(anyCollection())).thenReturn(Map.of("v.stone", 2)); + gui.onClick(event(assembly, GearCache.outputSlot, item())); + assertNull(OpenStationManager.get(p)); + } + costs.verify(() -> GearCosts.take(eq(p), anyCollection()), times(2)); + stores.verify( + () -> + GearStationStore.occupy( + eq(location), any(), eq(p.getUniqueId()), eq(Map.of("v.stone", 2)))); + stores.verify( + () -> GearStationStore.occupy(eq(location), any(), eq(p.getUniqueId()), eq(Map.of()))); + } + } + + @Test + void bottomInventoryClicksCannotTriggerAssemblyActions() { + config(); + gui.openAssembly(p); + var e = event(top(), 0, item()); + when(e.getClickedInventory()).thenReturn(p.getInventory()); + gui.onClick(e); + assertTrue(top().getHolder() instanceof AssemblyHolder); + verify(e).setCancelled(true); + } + + @Test + void selectionManagersHandleAbsentPlayersAndSnapshotCategories() { + assertNull(SelectedPartsManager.get(null, "core")); + assertNull(SelectedPartsManager.get(p, null)); + SelectedPartsManager.set(null, "core", "id"); + SelectedPartsManager.set(p, null, "id"); + SelectedPartsManager.set(p, "core", null); + SelectedPartsManager.remove(null, "core"); + SelectedPartsManager.remove(p, null); + SelectedPartsManager.remove(p, "core"); + assertTrue(SelectedPartsManager.categories(null).isEmpty()); + assertTrue(SelectedPartsManager.categories(p).isEmpty()); + SelectedPartsManager.set(p, "core", "id"); + var snapshot = SelectedPartsManager.categories(p); + SelectedPartsManager.remove(p, "core"); + assertEquals(Set.of("core"), snapshot); + assertTrue(SelectedPartsManager.categories(p).isEmpty()); + SelectedPartsManager.clear(null); + TypeSelectionManager.clear(null); + TypeSelectionManager.set(null, GearType.WAND); + TypeSelectionManager.set(p, null); + assertEquals(GearType.STAFF, TypeSelectionManager.get(null)); + TypeSelectionManager.set(p, GearType.WAND); + assertEquals(GearType.WAND, TypeSelectionManager.get(p)); + assertNull(new AssemblyHolder().getInventory()); + assertNull(new TypeSelectionHolder().getInventory()); + assertNull(new PartSelectionHolder("core").getInventory()); + } + + @Test + void bottomAndOutsideClicksCannotChangeAnyMenu() { + config(); + for (int mode = 0; mode < 3; mode++) { + if (mode == 0) gui.openAssembly(p); + if (mode == 1) gui.openTypeSelection(p); + if (mode == 2) gui.openPartSelection(p, "head"); + var top = top(); + for (Inventory clicked : Arrays.asList(p.getInventory(), null)) { + var e = event(top, 0, item()); + when(e.getClickedInventory()).thenReturn(clicked); + gui.onClick(e); + assertSame(top, top()); + verify(e).setCancelled(true); + } + } + } + + @Test + void iconsWithoutMetadataAndPartsWithoutSocketsOrCostsRemainUsable() throws Exception { + assertEquals( + "Missing Type", + invoke( + GearInventoryManager.class, + "partTypeName", + new Class[] {String.class}, + "missing_type")); + var free = + new PartDef( + "free", + "Free", + "head", + 1, + Set.of(GearType.STAFF), + "v.stick", + Map.of(), + List.of(), + Map.of(), + Map.of(), + List.of(), + null, + 1, + false); + var icon = + (ItemStack) + invoke( + GearInventoryManager.class, + "partIcon", + new Class[] {PartDef.class, boolean.class}, + free, + false); + assertEquals("Free", icon.getItemMeta().getDisplayName()); + assertThrows(IllegalArgumentException.class, () -> new ItemStack(Material.CAVE_AIR)); + } + + @Test + void removedStationCannotConsumeMaterialsOrPrepareAnItem() { + config(); + gui.openAssembly(p); + var location = new Location(p.getWorld(), 1, 2, 3); + OpenStationManager.set(p, location); + builder.when(() -> GearItemBuilder.prepare(any(), anyCollection())).thenReturn(item()); + try (var costs = mockStatic(GearCosts.class); + var store = mockStatic(GearStationStore.class)) { + costs.when(() -> GearCosts.has(eq(p), anyCollection())).thenReturn(true); + when(checker.checkBlock(any(), any())).thenReturn(false); + gui.onClick(event(top(), GearCache.outputSlot, item())); + costs.verify(() -> GearCosts.take(eq(p), anyCollection()), never()); + store.verify(() -> GearStationStore.occupy(any(), any(), any(), any()), never()); + when(checker.checkBlock(any(), any())).thenThrow(new IllegalStateException("unavailable")); + gui.onClick(event(top(), GearCache.outputSlot, item())); + costs.verify(() -> GearCosts.take(eq(p), anyCollection()), never()); + } + } +} diff --git a/src/test/java/net/tfminecraft/magic/GearHandStatCoverageTest.java b/src/test/java/net/tfminecraft/magic/GearHandStatCoverageTest.java new file mode 100644 index 0000000..3679936 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/GearHandStatCoverageTest.java @@ -0,0 +1,108 @@ +package net.tfminecraft.magic; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.*; +import net.Indyuce.mmoitems.ItemStats; +import net.Indyuce.mmoitems.MMOItems; +import net.Indyuce.mmoitems.api.item.mmoitem.MMOItem; +import net.Indyuce.mmoitems.stat.data.*; +import net.Indyuce.mmoitems.stat.type.StatHistory; +import net.tfminecraft.magic.gear.*; +import org.bukkit.*; +import org.bukkit.inventory.*; +import org.junit.jupiter.api.*; +import org.mockbukkit.mockbukkit.MockBukkit; + +class GearHandStatCoverageTest extends GearMmoCoverageSupport { + PartDef stats(String id, Map values) { + return new PartDef( + id, + id, + "core", + 1, + Set.of(GearType.STAFF), + "v.STICK", + Map.of(), + List.of(), + values, + Map.of(), + List.of(), + null, + 1, + false); + } + + @Test + void handSelectionPrioritizesMainAndStaffCountsIgnoreArmor() throws Exception { + assertEquals( + 0, invoke(GearHand.class, "staffAmount", new Class[] {ItemStack.class}, (Object) null)); + assertNull(GearHand.held(null)); + assertNull(GearHand.heldSlot(null)); + assertEquals(0, GearHand.staffCount(null)); + var p = server.addPlayer(); + assertEquals(0, GearHand.staffCount(p)); + assertNull(GearHand.heldSlot(p)); + assertNull(GearHand.held(p)); + var staff = item(); + staff.setAmount(2); + GearProvenance.stamp(staff, GearType.STAFF, null); + var wand = item(); + wand.setAmount(3); + GearProvenance.stamp(wand, GearType.WAND, null); + GearHand.setHeld(null, GearHand.HeldSlot.MAIN_HAND, staff); + GearHand.setHeld(p, null, staff); + GearHand.setHeld(p, GearHand.HeldSlot.OFF_HAND, staff); + assertEquals(GearHand.HeldSlot.OFF_HAND, GearHand.heldSlot(p)); + assertEquals(staff, GearHand.held(p)); + GearHand.setHeld(p, GearHand.HeldSlot.MAIN_HAND, wand); + assertEquals(GearHand.HeldSlot.MAIN_HAND, GearHand.heldSlot(p)); + assertEquals(wand, GearHand.held(p)); + p.getInventory().setItem(2, staff); + assertEquals(4, GearHand.staffCount(p)); + } + + @Test + void statTotalsSkipIncompleteValuesAndClearPriorManagedContributions() { + assertTrue(GearStatApplicator.sum(null).isEmpty()); + var malformed = new LinkedHashMap(); + malformed.put(null, 1d); + malformed.put("empty", null); + malformed.put("damage", 2d); + var a = stats("a", malformed); + var b = stats("b", Map.of("damage", 3d)); + assertEquals( + Map.of("damage", 5d), + GearStatApplicator.sum(Arrays.asList(null, stats("empty", Map.of()), a, b))); + var mmo = mock(MMOItem.class); + GearStatApplicator.apply(null, List.of()); + GearStatApplicator.apply(mmo, List.of()); + MockBukkit.createMockPlugin("MMOItems"); + MockBukkit.createMockPlugin("MythicLib"); + GearStatApplicator.apply(mmo, List.of()); + PartRegistry.register(a); + PartRegistry.register(b); + PartRegistry.register( + stats("unknown", new LinkedHashMap<>(Map.of("gear_test_unknown", 1d, " ", 1d)))); + when(MMOItems.plugin.getStats().get("GEAR_TEST_UNKNOWN")).thenReturn(null); + when(MMOItems.plugin.getStats().get("EMPTY")).thenReturn(null); + var attackDamage = ItemStats.ATTACK_DAMAGE; + when(MMOItems.plugin.getStats().get("DAMAGE")).thenReturn(attackDamage); + var hist = mock(StatHistory.class); + var original = new DoubleData(50); + when(hist.getOriginalData()).thenReturn(original); + when(mmo.computeStatHistory(ItemStats.ATTACK_DAMAGE)).thenReturn(hist); + GearStatApplicator.apply(mmo, List.of(a, b)); + assertEquals(0, original.getValue()); + verify(hist).clearExternalData(); + var values = org.mockito.ArgumentCaptor.forClass(DoubleData.class); + verify(mmo, times(2)).setData(eq(ItemStats.ATTACK_DAMAGE), values.capture()); + assertEquals(5, values.getValue().getValue()); + verify(hist).registerExternalData(values.getValue()); + when(hist.getOriginalData()).thenReturn(new StringData("not numeric")); + GearStatApplicator.apply(mmo, List.of()); + when(mmo.computeStatHistory(ItemStats.ATTACK_DAMAGE)).thenReturn(null); + GearStatApplicator.apply(mmo, List.of()); + } +} diff --git a/src/test/java/net/tfminecraft/magic/GearItemCoverageTest.java b/src/test/java/net/tfminecraft/magic/GearItemCoverageTest.java new file mode 100644 index 0000000..194cf17 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/GearItemCoverageTest.java @@ -0,0 +1,243 @@ +package net.tfminecraft.magic; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import io.lumine.mythic.lib.api.item.NBTItem; +import java.util.*; +import net.Indyuce.mmoitems.ItemStats; +import net.Indyuce.mmoitems.api.item.build.ItemStackBuilder; +import net.Indyuce.mmoitems.api.item.mmoitem.LiveMMOItem; +import net.Indyuce.mmoitems.stat.data.*; +import net.tfminecraft.magic.gear.*; +import net.tfminecraft.magic.util.ItemRef; +import org.bukkit.*; +import org.bukkit.inventory.*; +import org.bukkit.persistence.*; +import org.junit.jupiter.api.*; +import org.mockbukkit.mockbukkit.MockBukkit; + +class GearItemCoverageTest extends GearMmoCoverageSupport { + ArchetypeDef archetype(List required) { + return new ArchetypeDef( + GearType.STAFF, "Staff", "v.STICK", "v.STICK", false, required, Map.of("spell", "Spell")); + } + + @Test + void plainTemplatesStillReceiveGearIdentity() { + ArchetypeRegistry.register(archetype(List.of())); + var part = part("core", 1); + PartRegistry.register(part); + try (var refs = mockStatic(ItemRef.class); + var lore = mockStatic(WeaponLore.class); + var models = mockStatic(GearModelResolver.class)) { + refs.when(() -> ItemRef.build("v.STICK")).thenAnswer(i -> new ItemStack(Material.STICK)); + lore.when(() -> WeaponLore.updateItem(any())).thenAnswer(i -> i.getArgument(0)); + models + .when(() -> GearModelResolver.apply(any(), any(), any())) + .thenAnswer(i -> i.getArgument(0)); + var result = GearItemBuilder.prepare(GearType.STAFF, List.of(part)); + assertTrue(GearProvenance.isGear(result)); + assertEquals("core:1", GearProvenance.partsRaw(result)); + } + } + + @Test + void preparationRejectsUnknownMissingPartsAndTemplates() { + assertEquals(Material.BARRIER, GearItemBuilder.preview(GearType.STAFF, null).getType()); + ArchetypeRegistry.register(archetype(List.of())); + assertTrue( + GearItemBuilder.preview(GearType.STAFF, null) + .getItemMeta() + .getDisplayName() + .contains("Missing parts")); + assertEquals(Material.BARRIER, GearItemBuilder.prepare(GearType.STAFF, List.of()).getType()); + ArchetypeRegistry.register(archetype(List.of("core", "head"))); + var part = part("core", 1); + assertEquals(Material.BARRIER, GearItemBuilder.prepare(GearType.STAFF, null).getType()); + assertEquals( + Material.BARRIER, + GearItemBuilder.prepare(GearType.STAFF, Arrays.asList(null, part)).getType()); + ArchetypeRegistry.register(archetype(List.of("core"))); + try (var refs = mockStatic(ItemRef.class)) { + assertTrue( + GearItemBuilder.preview(GearType.STAFF, Arrays.asList(null, part)) + .getItemMeta() + .getDisplayName() + .contains("Missing template")); + assertEquals( + Material.BARRIER, GearItemBuilder.prepare(GearType.STAFF, List.of(part)).getType()); + refs.when(() -> ItemRef.build("v.STICK")).thenReturn(new ItemStack(Material.AIR)); + assertEquals( + Material.BARRIER, GearItemBuilder.preview(GearType.STAFF, List.of(part)).getType()); + } + } + + @Test + void previewsKeepTemplateNameAndLoreAndExplainRequiredTierAndDependencies() { + ArchetypeRegistry.register(archetype(List.of())); + try (var refs = mockStatic(ItemRef.class)) { + refs.when(() -> ItemRef.build("v.STICK")).thenAnswer(i -> new ItemStack(Material.STICK)); + var result = GearItemBuilder.preview(GearType.STAFF, List.of(part("core", 2))); + assertEquals("§6Staff", result.getItemMeta().getDisplayName()); + assertTrue(result.getItemMeta().getLore().contains("§eTier II")); + assertTrue(result.getItemMeta().getLore().contains("§cMMOItems is not loaded")); + MockBukkit.createMockPlugin("MMOItems"); + MockBukkit.createMockPlugin("MythicLib"); + var named = item(); + var meta = named.getItemMeta(); + meta.setLore(List.of("Existing")); + named.setItemMeta(meta); + refs.when(() -> ItemRef.build("v.STICK")).thenReturn(named); + result = GearItemBuilder.preview(GearType.STAFF, List.of(part("core", 0))); + assertEquals("Gear", result.getItemMeta().getDisplayName()); + assertEquals("Existing", result.getItemMeta().getLore().getFirst()); + assertFalse(result.getItemMeta().getLore().contains("§cMMOItems is not loaded")); + } + } + + @Test + void mmoRewritesUseBuiltItemOrSafelyKeepTemplateOnFailure() throws Exception { + MockBukkit.createMockPlugin("MMOItems"); + MockBukkit.createMockPlugin("MythicLib"); + ArchetypeRegistry.register(archetype(List.of())); + var p = part("core", 1); + PartRegistry.register(p); + for (int scenario = 0; scenario < 4; scenario++) { + var base = item(); + var built = item(); + int mode = scenario; + try (var refs = mockStatic(ItemRef.class); + var nbt = mockStatic(NBTItem.class); + var stats = mockStatic(GearStatApplicator.class); + var models = mockStatic(GearModelResolver.class); + var lore = mockStatic(WeaponLore.class); + var constructors = + mockConstruction( + LiveMMOItem.class, + (m, c) -> { + var builder = mock(ItemStackBuilder.class); + when(m.newBuilder()).thenReturn(builder); + if (mode == 0) when(builder.build()).thenReturn(built); + if (mode == 1) when(builder.build()).thenReturn(null); + if (mode == 2) when(builder.build()).thenReturn(new ItemStack(Material.AIR)); + if (mode == 3) + when(builder.build()).thenThrow(new IllegalStateException("failed")); + })) { + refs.when(() -> ItemRef.build("v.STICK")).thenReturn(base); + models + .when(() -> GearModelResolver.apply(any(), any(), any())) + .thenAnswer(i -> i.getArgument(0)); + lore.when(() -> WeaponLore.updateItem(any())).thenAnswer(i -> i.getArgument(0)); + var result = GearItemBuilder.prepare(GearType.STAFF, List.of(p)); + assertSame(mode == 0 ? built : base, result); + assertTrue(GearProvenance.isGear(result)); + verify(constructors.constructed().getFirst()) + .setData(eq(ItemStats.GEM_SOCKETS), any(GemSocketsData.class)); + } + } + try (var nbt = mockStatic(NBTItem.class); + var stats = mockStatic(GearStatApplicator.class); + var constructors = + mockConstruction( + LiveMMOItem.class, + (m, c) -> { + when(m.newBuilder()).thenReturn(mock(ItemStackBuilder.class)); + })) { + var stack = item(); + assertNull( + invoke( + GearItemBuilder.class, + "applyMmoData", + new Class[] {ItemStack.class, List.class, Collection.class}, + null, + List.of(), + List.of())); + for (List colours : Arrays.asList(null, List.of())) + assertSame( + stack, + invoke( + GearItemBuilder.class, + "applyMmoData", + new Class[] {ItemStack.class, List.class, Collection.class}, + stack, + colours, + List.of())); + } + } + + @Test + void socketRewriteKeepsIdentityAndLocksFirstAttunement() throws Exception { + assertNull(GearItemBuilder.rewriteSockets(null, 1)); + var raw = item(); + assertSame(raw, GearItemBuilder.rewriteSockets(raw, 1)); + ArchetypeRegistry.register(archetype(List.of())); + var p = part("core", 2); + PartRegistry.register(p); + var stack = item(); + GearProvenance.stamp(stack, GearType.STAFF, List.of(p)); + GearProvenance.lockSockets(stack); + assertSame(stack, GearItemBuilder.rewriteSockets(stack, 1)); + tag(stack, GearKeys.socketsLocked(), PersistentDataType.BYTE, null); + try (var models = mockStatic(GearModelResolver.class); + var lore = mockStatic(WeaponLore.class)) { + models + .when(() -> GearModelResolver.apply(any(), any(), any())) + .thenAnswer(i -> i.getArgument(0)); + lore.when(() -> WeaponLore.updateItem(any())).thenAnswer(i -> i.getArgument(0)); + assertSame(stack, GearItemBuilder.rewriteSockets(stack, 1)); + assertTrue(GearProvenance.socketsLocked(stack)); + assertEquals("core:1", GearProvenance.partsRaw(stack)); + } + var minimal = item(); + tag(minimal, GearKeys.archetype(), PersistentDataType.STRING, "staff"); + var dest = item(); + invoke( + GearItemBuilder.class, + "copyGearPdc", + new Class[] {ItemStack.class, ItemStack.class}, + minimal, + dest); + assertEquals(GearType.STAFF, GearProvenance.archetypeOf(dest)); + for (ItemStack[] pair : + new ItemStack[][] { + {null, dest}, + {minimal, null}, + {new ItemStack(Material.AIR), dest}, + {minimal, new ItemStack(Material.AIR)}, + {raw, dest} + }) + invoke( + GearItemBuilder.class, + "copyGearPdc", + new Class[] {ItemStack.class, ItemStack.class}, + pair[0], + pair[1]); + } + + @Test + void nonCorePartsCannotSatisfyRequiredCore() { + ArchetypeRegistry.register(archetype(List.of("core"))); + var head = + new PartDef( + "head", + "Head", + "head", + 1, + Set.of(GearType.STAFF), + "v.STICK", + Map.of(), + List.of(), + Map.of(), + Map.of(), + List.of(), + null, + 1, + false); + assertTrue( + GearItemBuilder.prepare(GearType.STAFF, List.of(head)) + .getItemMeta() + .getDisplayName() + .contains("Missing parts")); + } +} diff --git a/src/test/java/net/tfminecraft/magic/GearLoaderCoverageTest.java b/src/test/java/net/tfminecraft/magic/GearLoaderCoverageTest.java new file mode 100644 index 0000000..451dd4b --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/GearLoaderCoverageTest.java @@ -0,0 +1,193 @@ +package net.tfminecraft.magic; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.nio.file.*; +import java.util.*; +import net.tfminecraft.magic.gear.*; +import net.tfminecraft.magic.gear.orb.OrbCache; +import net.tfminecraft.magic.loader.GearLoader; +import org.bukkit.*; +import org.bukkit.configuration.file.YamlConfiguration; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.io.TempDir; +import org.mockbukkit.mockbukkit.MockBukkit; + +class GearLoaderCoverageTest { + @TempDir Path temp; + List names = + List.of( + "part-types.yml", + "archetypes.yml", + "socket-colours.yml", + "model-schemes.yml", + "parts.yml", + "orbs.yml"); + Map orbDefaults = new HashMap<>(); + + @BeforeEach + void setup() throws Exception { + MockBukkit.mock(); + Magic.plugin = mock(Magic.class); + when(Magic.plugin.getLogger()).thenReturn(java.util.logging.Logger.getLogger("MagicTest")); + for (var field : OrbCache.class.getFields()) + if (java.lang.reflect.Modifier.isStatic(field.getModifiers()) + && !java.lang.reflect.Modifier.isFinal(field.getModifiers())) + orbDefaults.put(field, field.get(null)); + base(); + } + + @AfterEach + void cleanup() throws Exception { + for (var entry : orbDefaults.entrySet()) entry.getKey().set(null, entry.getValue()); + OrbCache.clearTiers(); + ArchetypeRegistry.clear(); + PartTypeRegistry.clear(); + PartRegistry.clear(); + GearModelSchemeRegistry.clear(); + SocketColourRegistry.clear(); + SocketLayout.clearLabels(); + Magic.plugin = null; + MockBukkit.unmock(); + } + + void write(String name, String text) throws Exception { + Files.writeString(temp.resolve(name), text); + } + + void base() throws Exception { + for (String name : names) write(name, "{}"); + write("part-types.yml", "core: {slot: 1, name: Core}\n"); + write("archetypes.yml", "staff: {template: m.STAFF.TEST, slots: {core: Core}}\n"); + write("model-schemes.yml", "scheme: {models: ['staff(v.STICK)']}\n"); + } + + @Test + void missingMalformedAndUnreadableFilesReportFailureWithoutStaleEntries() throws Exception { + var loader = new GearLoader(); + assertFalse(loader.loadFolder(null)); + assertFalse(loader.loadFolder(temp.resolve("missing").toFile())); + for (String name : names) { + base(); + Files.delete(temp.resolve(name)); + assertFalse(loader.loadFolder(temp.toFile()), name); + base(); + write(name, "bad: ["); + assertFalse(loader.loadFolder(temp.toFile()), name); + } + base(); + Files.delete(temp.resolve("parts.yml")); + Files.createDirectory(temp.resolve("parts.yml")); + assertFalse(loader.loadFolder(temp.toFile())); + } + + @Test + void schemasSkipInvalidRowsAndPreserveValidDefinitions() throws Exception { + write("part-types.yml", "core: {slot: 1, name: Core}\nscalar: 5\n"); + write("archetypes.yml", "staff: {slots: {core: Core}}\nwand: {}\nsword: scalar\nunknown: {}\n"); + write( + "socket-colours.yml", + "labels: {core: Core}\nprefix: {default: Common, 2: Rare, bad: Bad}\n"); + write( + "model-schemes.yml", + "' ': {}\n" + + "scalar: nope\n" + + "empty: {models: [' ', bad, '(bad)', 'staff(', 'bad(v.STICK)', 'staff()']}\n" + + "SCHEME: {models: ['staff(v.STICK)']}\n" + + "scheme: {models: ['wand(v.BLAZE_ROD)']}\n"); + var cfg = new YamlConfiguration(); + cfg.set("scalar", "text"); + cfg.set("unknown.part-type", "missing"); + cfg.set("no-types.part-type", "core"); + cfg.set("no-types.type", List.of("unknown")); + int n = 0; + for (String scheme : List.of("scheme(2)", "scheme(nope)", "scheme(", "scheme", "")) { + String key = "part" + (n++); + cfg.set(key + ".part-type", "core"); + cfg.set(key + ".type", List.of("staff", "invalid")); + cfg.set(key + ".part-limit", List.of(" ", "unknown", "core", " CORE ")); + cfg.set(key + ".model-scheme", scheme); + cfg.set(key + ".cost", List.of(" ", "v.STONE(2)", "v.APPLE", "bad(x)", "thing(")); + cfg.set( + key + ".stats", + List.of(" ", "bad", "(2)", "attack(", " (2)", "damage(bad)", "damage(2.5)")); + cfg.set(key + ".lore", List.of("A line")); + if (n == 1) cfg.set(key + ".sockets.core", 5); + } + cfg.save(temp.resolve("parts.yml").toFile()); + assertTrue(new GearLoader().loadFolder(temp.toFile())); + assertEquals(2, ArchetypeRegistry.size()); + assertEquals(2, PartTypeRegistry.size()); + assertEquals(1, GearModelSchemeRegistry.size()); + assertEquals(5, PartRegistry.size()); + assertEquals(2, PartRegistry.get("part0").getSchemeWeight()); + assertEquals(1, PartRegistry.get("part1").getSchemeWeight()); + assertEquals("", PartRegistry.get("part2").getSchemeId()); + assertEquals(Map.of("damage", 2.5), PartRegistry.get("part0").getStats()); + assertEquals(2, PartRegistry.get("part0").getCost().get("v.STONE")); + } + + @Test + void orbSettingsClampBoundsAndRejectUnknownParticlesAndTiers() throws Exception { + write( + "orbs.yml", + "hit_radius: -1\n" + + "click_range: -1\n" + + "intro_ticks: -2\n" + + "good: {dust: [-10, 300, 25], particle: flame}\n" + + "bad: {dust: [1], particle: invalid}\n" + + "tiers:\n" + + " scalar: 1\n" + + " bad: {}\n" + + " 1: {live: 3, good_ratio: 0.5, speed: 2, window_ticks: 40, good_target: 2}\n"); + assertTrue(new GearLoader().loadFolder(temp.toFile())); + assertEquals(.1, OrbCache.hitRadius); + assertEquals(1, OrbCache.clickRange); + assertEquals(0, OrbCache.introTicks); + assertEquals(Color.fromRGB(0, 255, 25), OrbCache.goodColor); + assertEquals(Particle.FLAME, OrbCache.goodParticle); + assertEquals(1, OrbCache.tierCount()); + write("orbs.yml", "good: {particle: ' '}\n"); + assertTrue(new GearLoader().loadFolder(temp.toFile())); + assertEquals(0, OrbCache.tierCount()); + } + + @Test + void negativeCostsDisableTheAffectedPartAndNonFiniteStatsAreIgnored() throws Exception { + write( + "parts.yml", + "bad:\n" + + " part-type: core\n" + + " type: [staff]\n" + + " cost: ['v.STONE(-2)']\n" + + "good:\n" + + " part-type: core\n" + + " type: [staff]\n" + + " cost: ['v.STONE(2)']\n" + + " stats: ['damage(NaN)', 'speed(Infinity)', 'attack(3)']\n"); + assertTrue(new GearLoader().loadFolder(temp.toFile())); + assertNull(PartRegistry.get("bad")); + assertNotNull(PartRegistry.get("good")); + assertEquals(Map.of("attack", 3.), PartRegistry.get("good").getStats()); + } + + @Test + void emptyRequiredRegistriesFailAndNullableParserInputsAreSafe() throws Exception { + write("archetypes.yml", "{}"); + write("model-schemes.yml", "{}"); + assertFalse(new GearLoader().loadFolder(temp.toFile())); + for (String method : List.of("parseCost", "parseStats")) { + var m = GearLoader.class.getDeclaredMethod(method, List.class); + m.setAccessible(true); + assertEquals(Map.of(), m.invoke(null, (Object) null)); + assertEquals(Map.of(), m.invoke(null, Arrays.asList(null, " "))); + } + var read = GearLoader.class.getDeclaredMethod("read", java.io.File.class, String.class); + read.setAccessible(true); + assertNull(read.invoke(null, null, "test")); + var color = GearLoader.class.getDeclaredMethod("colorOf", List.class, Color.class); + color.setAccessible(true); + assertEquals(Color.RED, color.invoke(null, null, Color.RED)); + } +} diff --git a/src/test/java/net/tfminecraft/magic/GearMmoCoverageSupport.java b/src/test/java/net/tfminecraft/magic/GearMmoCoverageSupport.java new file mode 100644 index 0000000..68444c2 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/GearMmoCoverageSupport.java @@ -0,0 +1,28 @@ +package net.tfminecraft.magic; + +import static org.mockito.Mockito.*; + +import io.lumine.mythic.lib.MythicLib; +import net.Indyuce.mmoitems.MMOItems; +import org.junit.jupiter.api.*; + +abstract class GearMmoCoverageSupport extends GearCoverageSupport { + MMOItems oldMmo; + MythicLib oldLib; + + @BeforeEach + void setupMmo() { + oldMmo = MMOItems.plugin; + oldLib = MythicLib.plugin; + MMOItems.plugin = mock(MMOItems.class, RETURNS_DEEP_STUBS); + when(MMOItems.plugin.namespace()).thenReturn("mmoitems"); + MythicLib.plugin = mock(MythicLib.class, RETURNS_DEEP_STUBS); + when(MythicLib.plugin.namespace()).thenReturn("mythiclib"); + } + + @AfterEach + void cleanupMmo() { + MMOItems.plugin = oldMmo; + MythicLib.plugin = oldLib; + } +} diff --git a/src/test/java/net/tfminecraft/magic/GearModelCoverageTest.java b/src/test/java/net/tfminecraft/magic/GearModelCoverageTest.java new file mode 100644 index 0000000..073657f --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/GearModelCoverageTest.java @@ -0,0 +1,197 @@ +package net.tfminecraft.magic; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.*; +import net.tfminecraft.magic.gear.*; +import org.bukkit.*; +import org.junit.jupiter.api.*; + +class GearModelCoverageTest extends GearCoverageSupport { + @AfterEach + void resetModels() { + GearModelSchemeRegistry.clear(); + } + + PartDef modelPart(String id, String type, String scheme, int weight) { + return new PartDef( + id, + id, + type, + 1, + Set.of(GearType.STAFF), + "v.STICK", + Map.of(), + List.of(), + Map.of(), + Map.of(), + List.of(), + scheme, + weight, + false); + } + + void scheme(String id, String path) { + GearModelSchemeRegistry.register(new GearModelScheme(id, Map.of(GearType.STAFF, path))); + } + + @Test + void largeModelWeightsCannotOverflowIntoLosingVotes() { + scheme("heavy", "v.STICK"); + scheme("light", "v.BLAZE_ROD"); + var parts = + List.of( + modelPart("a", "head", "heavy", Integer.MAX_VALUE), + modelPart("b", "grip", "heavy", Integer.MAX_VALUE), + modelPart("c", "core", "light", 1)); + assertEquals("heavy", GearModelResolver.winner(parts).getId()); + } + + @Test + void malformedOrNonItemModelsCannotDestroyOrPartiallyChangeGear() { + for (String path : List.of("v.AIR", "v.WATER", "v.DIAMOND.bad-number")) { + scheme("model", path); + var item = item(); + var original = item.clone(); + GearModelResolver.apply(item, GearType.STAFF, List.of(modelPart("core", "core", "model", 1))); + assertEquals(original, item, path); + } + } + + @Test + void invalidCustomModelNumberDoesNotPartiallyChangeItem() { + scheme("model", "v.DIAMOND.bad-number"); + var item = item(); + var original = item.clone(); + GearModelResolver.apply(item, GearType.STAFF, List.of(modelPart("core", "core", "model", 1))); + assertEquals(original, item); + } + + @Test + void weightedModelsUseCoreToResolveOnlyLeadingTies() { + assertNull(GearModelResolver.winner(null)); + assertNull(GearModelResolver.path(GearType.STAFF, List.of())); + assertNull( + GearModelResolver.winner( + Arrays.asList( + null, + modelPart("none", "head", null, 1), + modelPart("unknown", "head", "missing", 1)))); + scheme("a", "v.STICK"); + scheme("b", "v.BLAZE_ROD"); + scheme("c", "v.DIAMOND"); + assertEquals( + "a", + GearModelResolver.winner( + List.of(modelPart("a", "head", "a", 2), modelPart("b", "grip", "b", 1))) + .getId()); + assertEquals( + "a", + GearModelResolver.winner( + List.of(modelPart("a", "head", "a", 1), modelPart("b", "grip", "b", 1))) + .getId()); + assertEquals( + "b", + GearModelResolver.winner( + List.of(modelPart("a", "head", "a", 1), modelPart("b", "core", "b", 1))) + .getId()); + assertEquals( + "a", + GearModelResolver.winner( + List.of( + modelPart("a", "head", "a", 2), + modelPart("b", "grip", "b", 2), + modelPart("c", "core", "c", 1))) + .getId()); + assertEquals( + "c", + GearModelResolver.winner( + List.of( + modelPart("a", "head", "a", 1), + modelPart("b", "grip", "b", 1), + modelPart("c", "core", "c", 2))) + .getId()); + assertEquals( + "v.STICK", GearModelResolver.path(GearType.STAFF, List.of(modelPart("a", "core", "a", 1)))); + assertNull(GearModelResolver.path(GearType.WAND, List.of(modelPart("a", "core", "a", 1)))); + } + + @Test + void modelApplicationHandlesVanillaExternalMissingAndUnsupportedPaths() { + assertNull(GearModelResolver.apply(null, GearType.STAFF, List.of())); + var item = item(); + assertSame(item, GearModelResolver.apply(item, GearType.STAFF, List.of())); + var parts = List.of(modelPart("core", "core", "model", 1)); + for (String path : List.of("v", "v.NO_MATERIAL", "other.path")) { + scheme("model", path); + assertSame(item, GearModelResolver.apply(item, GearType.STAFF, parts)); + assertEquals(Material.STICK, item.getType()); + } + scheme("model", "v.DIAMOND"); + assertSame(item, GearModelResolver.apply(item, GearType.STAFF, parts)); + assertEquals(Material.DIAMOND, item.getType()); + scheme("model", "v.DIAMOND.17"); + try (var models = mockStatic(net.tfminecraft.magic.util.LegacyModelData.class)) { + GearModelResolver.apply(item, GearType.STAFF, parts); + models.verify(() -> net.tfminecraft.magic.util.LegacyModelData.set(any(), eq(17))); + } + scheme("model", "ia.namespace:item"); + try (var libs = mockStatic(net.tfminecraft.tlibs.TLibs.class)) { + var api = mock(net.tfminecraft.tlibs.objects.api.ItemAPI.class, RETURNS_DEEP_STUBS); + libs.when(net.tfminecraft.tlibs.TLibs::getItemAPI).thenReturn(api); + var merged = item(); + when(api.getArmorMerger().merge(eq(item), any(), anyString())).thenReturn(merged); + assertSame(merged, GearModelResolver.apply(item, GearType.STAFF, parts)); + when(api.getArmorMerger().merge(eq(item), any(), anyString())) + .thenThrow(new IllegalStateException("external model failure")); + assertSame(item, GearModelResolver.apply(item, GearType.STAFF, parts)); + } + } + + @Test + void schemeRegistryNormalizesAndRejectsEmptyData() { + GearModelSchemeRegistry.clear(); + GearModelSchemeRegistry.register(null); + GearModelSchemeRegistry.register(new GearModelScheme(null, null)); + assertEquals(0, GearModelSchemeRegistry.size()); + assertNull(GearModelSchemeRegistry.get(null)); + assertNull(GearModelSchemeRegistry.get(" ")); + var paths = new LinkedHashMap(); + paths.put(null, "v.STONE"); + paths.put(GearType.STAFF, null); + paths.put(GearType.WAND, " "); + var empty = new GearModelScheme(" Empty ", paths); + assertNull(empty.pathFor(null)); + assertNull(empty.pathFor(GearType.STAFF)); + GearModelSchemeRegistry.register(empty); + assertEquals(empty, GearModelSchemeRegistry.all().get("empty")); + assertThrows(UnsupportedOperationException.class, () -> GearModelSchemeRegistry.all().clear()); + } + + @Test + void emptyExternalModelCannotDeleteCraftedGear() { + scheme("model", "ia.namespace:item"); + var parts = List.of(modelPart("core", "core", "model", 1)); + var original = item(); + try (var libs = mockStatic(net.tfminecraft.tlibs.TLibs.class)) { + var api = mock(net.tfminecraft.tlibs.objects.api.ItemAPI.class, RETURNS_DEEP_STUBS); + libs.when(net.tfminecraft.tlibs.TLibs::getItemAPI).thenReturn(api); + when(api.getArmorMerger().merge(eq(original), any(), anyString())).thenReturn(null); + assertSame(original, GearModelResolver.apply(original, GearType.STAFF, parts)); + when(api.getArmorMerger().merge(eq(original), any(), anyString())) + .thenReturn(new org.bukkit.inventory.ItemStack(Material.AIR)); + assertSame(original, GearModelResolver.apply(original, GearType.STAFF, parts)); + } + } + + @Test + void punctuationOnlyModelCannotCrashCrafting() { + scheme("model", "."); + var original = item(); + assertSame( + original, + GearModelResolver.apply( + original, GearType.STAFF, List.of(modelPart("core", "core", "model", 1)))); + } +} diff --git a/src/test/java/net/tfminecraft/magic/GearOrbServiceCoverageTest.java b/src/test/java/net/tfminecraft/magic/GearOrbServiceCoverageTest.java new file mode 100644 index 0000000..c8de83e --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/GearOrbServiceCoverageTest.java @@ -0,0 +1,325 @@ +package net.tfminecraft.magic; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.*; +import net.tfminecraft.magic.gear.*; +import net.tfminecraft.magic.gear.orb.*; +import org.bukkit.*; +import org.bukkit.entity.*; +import org.bukkit.event.block.Action; +import org.bukkit.event.player.*; +import org.bukkit.inventory.*; +import org.bukkit.util.*; +import org.junit.jupiter.api.*; +import org.mockito.*; + +class GearOrbServiceCoverageTest extends GearCoverageSupport { + World world; + Location station; + MockedStatic stores; + MockedConstruction sessions; + + @BeforeEach + void setupService() { + world = mock(World.class); + when(world.getName()).thenReturn("world"); + station = new Location(world, 0, 0, 0); + stores = mockStatic(GearStationStore.class); + stores.when(() -> GearStationStore.key(any())).thenCallRealMethod(); + sessions = + mockConstruction( + GearOrbSession.class, + (s, c) -> { + when(s.getStation()).thenReturn(((Location) c.arguments().get(0)).clone()); + when(s.getPlayerId()).thenReturn((UUID) c.arguments().get(1)); + when(s.displayPoint()).thenReturn(station); + when(s.capturedFill()).thenReturn(Map.of("fire", 10d)); + when(s.goodTarget()).thenReturn(2); + }); + } + + @AfterEach + void cleanupService() { + GearOrbService.stop(); + sessions.close(); + stores.close(); + } + + GearOrbSession begin(Player p) { + assertTrue(GearOrbService.begin(p, station, Map.of(), "fire", 1)); + return sessions.constructed().getLast(); + } + + @Test + void startAbortAndOwnershipFollowStationLifecycle() { + var p = server.addPlayer(); + assertFalse(GearOrbService.isActive(null)); + assertFalse(GearOrbService.isActive(station)); + assertNull(GearOrbService.sessionOwner(null)); + assertNull(GearOrbService.sessionOwner(station)); + assertFalse(GearOrbService.begin(p, null, Map.of(), null, 1)); + assertFalse(GearOrbService.begin(p, new Location(null, 0, 0, 0), Map.of(), null, 1)); + var s = begin(p); + assertFalse(GearOrbService.begin(p, station, Map.of(), null, 1)); + assertTrue(GearOrbService.isActive(station)); + assertEquals(p.getUniqueId(), GearOrbService.sessionOwner(station)); + GearOrbService.abort(null); + GearOrbService.abort(station); + verify(s).end(); + assertFalse(GearOrbService.isActive(station)); + var occupancy = mock(GearStationStore.Occupancy.class); + stores.when(() -> GearStationStore.get(station)).thenReturn(occupancy); + GearOrbService.abort(station); + verify(occupancy).setOrbSessionActive(false); + GearOrbService.start(); + GearOrbService.start(); + server.getScheduler().performOneTick(); + GearOrbService.stop(); + } + + @Test + void timerHandlesOfflinePlayersFailedSessionsAndMissingStationContents() { + var p = server.addPlayer(); + var s = begin(p); + GearOrbService.start(); + verify(s).end(); + s = begin(p); + server.getScheduler().performOneTick(); + verify(s).tick(p); + when(s.isFinished()).thenReturn(true); + var occupancy = mock(GearStationStore.Occupancy.class); + stores.when(() -> GearStationStore.get(station)).thenReturn(occupancy); + server.getScheduler().performOneTick(); + verify(occupancy).setOrbSessionActive(false); + s = begin(p); + doThrow(new IllegalStateException("broken")).when(s).tick(p); + server.getScheduler().performOneTick(); + verify(s).end(); + s = begin(p); + p.disconnect(); + server.getScheduler().performOneTick(); + verify(s).end(); + } + + @Test + void completionPersistsCaptureRiftAndSocketLockDecisions() throws Exception { + var p = server.addPlayer(); + var weapon = item(); + var updated = item(); + var occupancy = mock(GearStationStore.Occupancy.class); + when(occupancy.getItem()).thenReturn(weapon); + stores.when(() -> GearStationStore.get(station)).thenReturn(occupancy); + try (var req = mockStatic(WeaponRequirement.class); + var rift = mockStatic(WeaponRift.class); + var provenance = mockStatic(GearProvenance.class); + var builder = mockStatic(GearItemBuilder.class); + var lore = mockStatic(WeaponLore.class); + var chat = mockStatic(WeaponAttunementChat.class)) { + var requirement = mock(WeaponRequirement.class); + req.when(() -> WeaponRequirement.fromItem(weapon)).thenReturn(requirement); + builder.when(() -> GearItemBuilder.rewriteSockets(weapon, 2)).thenReturn(updated); + lore.when(() -> WeaponLore.updateItem(weapon)).thenReturn(updated); + for (int mode = 0; mode < 4; mode++) { + var s = begin(p); + when(s.capturedFraction()).thenReturn(.75); + when(s.riftDelta()).thenReturn(5); + req.when(() -> WeaponRequirement.highestBand(anyMap())).thenReturn(mode == 0 ? 0 : 2); + provenance.when(() -> GearProvenance.socketsLocked(weapon)).thenReturn(mode == 2); + rift.when(() -> WeaponRift.get(weapon)).thenReturn(mode == 0 ? 0 : 20); + invoke( + GearOrbService.class, + "complete", + new Class[] {GearOrbSession.class, boolean.class}, + s, + mode != 3); + verify(s).end(); + if (mode != 3) verify(s).playComplete(); + } + verify(requirement, times(4)).mergeAmounts(Map.of("fire", 10d)); + verify(requirement, times(4)).persist(weapon); + builder.verify(() -> GearItemBuilder.rewriteSockets(weapon, 2), times(2)); + stores.verify(() -> GearStationStore.update(station, updated), times(4)); + chat.verify(() -> WeaponAttunementChat.sendPostChargeSummary(p, updated), times(3)); + } + } + + GearOrb orb(Location location) { + var orb = new GearOrb(location, true, 0, 1, 0, 0, 0, 0, 20); + orb.setLocation(location); + return orb; + } + + @Test + void hitscanUsesNearestVisibleOrbAndSkipsInvalidHistory() throws Exception { + var p = mock(Player.class); + when(p.getEyeLocation()).thenReturn(new Location(null, 0, 0, 0)); + var session = mock(GearOrbSession.class); + assertNull( + invoke( + GearOrbService.class, + "hitscan", + new Class[] {Player.class, GearOrbSession.class}, + p, + session)); + when(p.getEyeLocation()).thenReturn(station); + var other = mock(World.class); + var noHistory = new GearOrb(station, true, 0, 1, 0, 0, 0, 0, 20); + when(session.livingOrbs()) + .thenReturn( + List.of( + noHistory, + orb(new Location(null, 0, 0, 2)), + orb(new Location(other, 0, 0, 2)), + orb(new Location(world, 0, 0, -1)), + orb(new Location(world, 0, 0, 99)), + orb(new Location(world, 2, 0, 2)))); + assertNull( + invoke( + GearOrbService.class, + "hitscan", + new Class[] {Player.class, GearOrbSession.class}, + p, + session)); + var near = orb(new Location(world, 0, 0, 2)); + when(session.livingOrbs()).thenReturn(List.of(near, orb(new Location(world, 0, 0, 3)))); + assertSame( + near, + invoke( + GearOrbService.class, + "hitscan", + new Class[] {Player.class, GearOrbSession.class}, + p, + session)); + when(world.rayTraceBlocks(any(), any(), anyDouble(), any(), anyBoolean())) + .thenReturn(new RayTraceResult(new org.bukkit.util.Vector(0, 0, 1))); + assertNull( + invoke( + GearOrbService.class, + "hitscan", + new Class[] {Player.class, GearOrbSession.class}, + p, + session)); + when(world.rayTraceBlocks(any(), any(), anyDouble(), any(), anyBoolean())) + .thenReturn(new RayTraceResult(new org.bukkit.util.Vector(0, 0, 3))); + assertSame( + near, + invoke( + GearOrbService.class, + "hitscan", + new Class[] {Player.class, GearOrbSession.class}, + p, + session)); + } + + @Test + void swingAndInteractionEventsDeduplicateAndRespectHitCooldown() throws Exception { + var listener = new GearOrbService(); + var p = server.addPlayer(); + p.setSneaking(true); + listener.onInteract( + new PlayerInteractEvent( + p, Action.RIGHT_CLICK_AIR, null, null, org.bukkit.block.BlockFace.UP)); + listener.onSwing(new PlayerAnimationEvent(p, PlayerAnimationType.OFF_ARM_SWING)); + listener.onSwing(new PlayerAnimationEvent(p, PlayerAnimationType.ARM_SWING)); + p.setSneaking(false); + listener.onSwing(new PlayerAnimationEvent(p, PlayerAnimationType.ARM_SWING)); + var s = begin(p); + listener.onSwing(new PlayerAnimationEvent(p, PlayerAnimationType.ARM_SWING)); + when(s.claimSwing(anyLong())).thenReturn(true); + listener.onSwing(new PlayerAnimationEvent(p, PlayerAnimationType.ARM_SWING)); + when(s.canHit(anyLong())).thenReturn(true); + when(s.livingOrbs()).thenReturn(List.of()); + listener.onInteract( + new PlayerInteractEvent( + p, Action.LEFT_CLICK_BLOCK, null, null, org.bukkit.block.BlockFace.UP)); + var fake = mock(Player.class); + when(fake.getUniqueId()).thenReturn(p.getUniqueId()); + when(fake.getEyeLocation()).thenReturn(station); + var hit = orb(new Location(world, 0, 0, 2)); + when(s.livingOrbs()).thenReturn(List.of(hit)); + listener.onInteract( + new PlayerInteractEvent( + fake, Action.LEFT_CLICK_AIR, null, null, org.bukkit.block.BlockFace.UP)); + verify(s).onHit(fake, hit); + verify(s).markHit(anyLong()); + var different = server.addPlayer(); + listener.onSwing(new PlayerAnimationEvent(different, PlayerAnimationType.ARM_SWING)); + } + + @Test + void completionHandlesDisconnectedPlayers() throws Exception { + var player = mock(Player.class); + var id = UUID.randomUUID(); + when(player.getUniqueId()).thenReturn(id); + var weapon = item(); + var occupancy = mock(GearStationStore.Occupancy.class); + when(occupancy.getItem()).thenReturn(weapon); + stores.when(() -> GearStationStore.get(station)).thenReturn(occupancy); + try (var bukkit = mockStatic(Bukkit.class, CALLS_REAL_METHODS); + var req = mockStatic(WeaponRequirement.class); + var lore = mockStatic(WeaponLore.class); + var chat = mockStatic(WeaponAttunementChat.class)) { + var requirement = mock(WeaponRequirement.class); + req.when(() -> WeaponRequirement.fromItem(weapon)).thenReturn(requirement); + lore.when(() -> WeaponLore.updateItem(weapon)).thenReturn(weapon); + for (int mode = 0; mode < 2; mode++) { + var session = begin(player); + bukkit.when(() -> Bukkit.getPlayer(id)).thenReturn(mode == 0 ? null : player); + when(player.isOnline()).thenReturn(false); + invoke( + GearOrbService.class, + "complete", + new Class[] {GearOrbSession.class, boolean.class}, + session, + true); + verify(session).playComplete(); + } + var session = begin(player); + when(player.isOnline()).thenReturn(false); + invoke(GearOrbService.class, "tick", new Class[] {}); + verify(session).end(); + } + } + + @Test + void cancellationDuringOneSessionSkipsRemovedSessionsInSnapshot() throws Exception { + var p = server.addPlayer(); + for (boolean stopping : new boolean[] {false, true}) { + Location second = new Location(world, 1, 0, 0); + var first = begin(p); + assertTrue(GearOrbService.begin(p, second, Map.of(), "fire", 1)); + var last = sessions.constructed().getLast(); + var f = GearOrbService.class.getDeclaredField("SESSIONS"); + f.setAccessible(true); + var map = (Map) f.get(null); + var iterator = new ArrayList<>(map.values()); + var one = iterator.getFirst(); + var two = iterator.getLast(); + if (stopping) { + stores + .when(() -> GearStationStore.get(one.getStation())) + .thenAnswer( + i -> { + GearOrbService.abort(two.getStation()); + return null; + }); + GearOrbService.stop(); + stores.when(() -> GearStationStore.get(one.getStation())).thenReturn(null); + } else { + doAnswer( + i -> { + GearOrbService.abort(two.getStation()); + return null; + }) + .when(one) + .tick(p); + invoke(GearOrbService.class, "tick", new Class[] {}); + verify(two, never()).tick(any()); + GearOrbService.abort(one.getStation()); + } + verify(two).end(); + } + } +} diff --git a/src/test/java/net/tfminecraft/magic/GearOrbSessionCoverageTest.java b/src/test/java/net/tfminecraft/magic/GearOrbSessionCoverageTest.java new file mode 100644 index 0000000..6a43629 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/GearOrbSessionCoverageTest.java @@ -0,0 +1,176 @@ +package net.tfminecraft.magic; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.*; +import java.util.concurrent.ThreadLocalRandom; +import net.tfminecraft.magic.gear.orb.*; +import net.tfminecraft.magic.model.ElementDef; +import net.tfminecraft.magic.registry.ElementRegistry; +import org.bukkit.*; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.*; + +class GearOrbSessionCoverageTest extends GearCoverageSupport { + @AfterEach + void resetElements() { + ElementRegistry.clear(); + } + + GearOrb orb(Location at, boolean good, int life) { + return new GearOrb(at, good, 0, 1, 0, .1, 0, 0, life); + } + + @Test + void sessionCopiesInputsTracksHitsAndAppliesMissPenalty() { + World world = mock(World.class); + Location at = new Location(world, 1, 2, 3); + var fills = new LinkedHashMap(); + fills.put("fire", 100d); + fills.put("null", null); + fills.put("zero", 0d); + fills.put("negative", -1d); + var id = UUID.randomUUID(); + var s = new GearOrbSession(at, id, fills, null, new OrbCache.Tier(2, 1, 1, 20, 2)); + at.setX(99); + fills.put("fire", 999d); + assertEquals(1, s.getStation().getX()); + assertEquals(id, s.getPlayerId()); + assertEquals(1.5, s.anchor().getX()); + assertEquals(3.15, s.displayPoint().getY()); + assertEquals(0, s.ticks()); + assertFalse(s.isFinished()); + assertTrue(s.claimSwing(1)); + assertFalse(s.claimSwing(1)); + assertTrue(s.claimSwing(2)); + assertTrue(s.canHit(1)); + s.markHit(2); + assertFalse(s.canHit(5)); + assertTrue(s.canHit(6)); + assertEquals(2, s.goodTarget()); + assertEquals(0d, s.capturedFill().get("fire")); + var player = mock(Player.class, RETURNS_DEEP_STUBS); + when(player.getLocation()).thenReturn(at); + s.onHit(player, orb(at, true, 2)); + assertEquals(.5, s.capturedFraction()); + s.onHit(null, orb(at, true, 2)); + s.onHit(null, orb(at, true, 2)); + assertEquals(1, s.capturedFraction()); + assertEquals(100d, s.capturedFill().get("fire")); + s.onHit(player, orb(at, false, 2)); + assertEquals(OrbCache.riftPerBad, s.riftDelta()); + assertEquals(3, s.goodHits()); + assertEquals(0, s.misses()); + s.playComplete(); + verify(world) + .playSound(any(Location.class), eq(Sound.BLOCK_ENCHANTMENT_TABLE_USE), eq(1f), eq(1.2f)); + } + + @Test + void orbitSpawnsBothKindsExpiresAndPrunesConsumedOrbs() { + var rng = mock(ThreadLocalRandom.class); + when(rng.nextDouble()).thenReturn(.1); + when(rng.nextBoolean()).thenReturn(true, false); + OrbCache.introTicks = 1; + OrbCache.lifetimeTicks = 4; + OrbCache.spawnIntervalTicks = 1; + OrbCache.spawnBurstTicks = 1; + World world = mock(World.class); + Location at = new Location(world, 0, 0, 0); + var player = mock(Player.class, RETURNS_DEEP_STUBS); + when(player.getLocation()).thenReturn(at); + try (var random = mockStatic(ThreadLocalRandom.class)) { + random.when(ThreadLocalRandom::current).thenReturn(rng); + for (double ratio : new double[] {1, 0}) { + var s = + new GearOrbSession( + at, + UUID.randomUUID(), + Map.of("fire", 10d), + "unknown", + new OrbCache.Tier(2, ratio, 1, 20, 2)); + s.tick(null); + assertEquals(1, s.livingOrbs().size()); + var first = s.livingOrbs().getFirst(); + assertEquals(ratio == 1, first.isGood()); + s.onHit(null, first); + assertTrue(s.livingOrbs().isEmpty()); + s.tick(player); + for (int i = 0; i < 22; i++) s.tick(player); + assertTrue(s.isFinished()); + assertEquals(24, s.ticks()); + if (ratio == 1) { + assertTrue(s.misses() > 0); + assertEquals(0, s.capturedFraction()); + } + s.end(); + assertTrue(s.livingOrbs().isEmpty()); + } + } + } + + @Test + void missingWorldStillExpiresAndFinishesWithoutEffects() { + Location at = new Location(null, 0, 0, 0); + OrbCache.lifetimeTicks = 1; + var s = + new GearOrbSession(at, UUID.randomUUID(), Map.of(), "", new OrbCache.Tier(1, 1, 1, 20, 1)); + s.tick(null); + assertEquals(1, s.livingOrbs().size()); + s.tick(null); + assertEquals(1, s.misses()); + s.onHit(null, orb(at, false, 1)); + s.playComplete(); + s.end(); + assertTrue(s.livingOrbs().isEmpty()); + } + + @Test + void knownElementSuppliesBeamColor() { + var cfg = new YamlConfiguration(); + cfg.set("color", "#123456"); + ElementRegistry.register(new ElementDef("fire", cfg)); + var world = mock(World.class); + var s = + new GearOrbSession( + new Location(world, 0, 0, 0), + UUID.randomUUID(), + Map.of(), + "fire", + new OrbCache.Tier(1, 1, 1, 20, 1)); + s.playComplete(); + var dust = org.mockito.ArgumentCaptor.forClass(Particle.DustOptions.class); + verify(world) + .spawnParticle( + eq(Particle.DUST), + any(Location.class), + eq(20), + eq(.35), + eq(.4), + eq(.35), + dust.capture()); + assertEquals(Color.fromRGB(0x123456), dust.getValue().getColor()); + } + + @Test + void spawnPacingWaitsForIntervalAndCapacity() { + OrbCache.lifetimeTicks = 100; + OrbCache.spawnIntervalTicks = 4; + OrbCache.spawnBurstTicks = 20; + var s = + new GearOrbSession( + new Location(mock(World.class), 0, 0, 0), + UUID.randomUUID(), + Map.of(), + null, + new OrbCache.Tier(2, 1, 1, 20, 1)); + s.tick(null); + assertEquals(1, s.livingOrbs().size()); + s.tick(null); + assertEquals(1, s.livingOrbs().size()); + for (int i = 0; i < 8; i++) s.tick(null); + assertEquals(2, s.livingOrbs().size()); + } +} diff --git a/src/test/java/net/tfminecraft/magic/GearProvenanceCoverageTest.java b/src/test/java/net/tfminecraft/magic/GearProvenanceCoverageTest.java new file mode 100644 index 0000000..510e0a7 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/GearProvenanceCoverageTest.java @@ -0,0 +1,94 @@ +package net.tfminecraft.magic; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.*; +import net.tfminecraft.magic.gear.*; +import org.bukkit.*; +import org.bukkit.inventory.*; +import org.bukkit.persistence.*; +import org.junit.jupiter.api.*; + +class GearProvenanceCoverageTest extends GearCoverageSupport { + @Test + void unstampedItemsAndMissingMetadataAreSafe() { + for (ItemStack item : Arrays.asList(null, new ItemStack(Material.AIR))) { + GearProvenance.stamp(item, GearType.STAFF, null); + GearProvenance.applyMajority(item, null); + GearProvenance.syncRevisions(item); + GearProvenance.lockSockets(item); + assertEquals(0, GearProvenance.majorityOf(item)); + assertEquals("", GearProvenance.partsRaw(item)); + assertTrue(GearProvenance.resolveParts(item).isEmpty()); + assertFalse(GearProvenance.isGear(item)); + assertFalse(GearProvenance.isOutdated(item)); + assertFalse(GearProvenance.socketsLocked(item)); + } + var item = item(); + GearProvenance.stamp(item, null, null); + assertEquals(0, GearProvenance.majorityOf(item)); + assertFalse(GearProvenance.isOutdated(item)); + assertTrue(GearProvenance.missingPartIds(item).isEmpty()); + assertTrue(GearProvenance.resolveParts(item).isEmpty()); + assertFalse(GearProvenance.socketsLocked(item)); + tag(item, GearKeys.socketsLocked(), PersistentDataType.BYTE, (byte) 0); + assertFalse(GearProvenance.socketsLocked(item)); + GearProvenance.lockSockets(item); + assertTrue(GearProvenance.socketsLocked(item)); + } + + @Test + void stampingTracksPartAndArchetypeRevisions() { + var item = item(); + GearProvenance.stamp(item, GearType.STAFF, null); + assertEquals(GearType.STAFF, GearProvenance.archetypeOf(item)); + assertEquals("", GearProvenance.partsRaw(item)); + assertEquals( + 1, + item.getItemMeta() + .getPersistentDataContainer() + .get(GearKeys.archetypeRevision(), PersistentDataType.INTEGER)); + assertFalse(GearProvenance.isOutdated(item)); + var a = GearDefinitionTest.archetype(); + ArchetypeRegistry.register(a); + var p = part("core", 2); + PartRegistry.register(p); + GearProvenance.stamp(item, GearType.STAFF, Arrays.asList(null, p)); + assertEquals("core:1", GearProvenance.partsRaw(item)); + assertEquals(List.of(p), GearProvenance.resolveParts(item)); + assertEquals(2, GearProvenance.majorityOf(item)); + assertFalse(GearProvenance.isOutdated(item)); + p.setRevision(2); + assertTrue(GearProvenance.isOutdated(item)); + GearProvenance.syncRevisions(item); + assertFalse(GearProvenance.isOutdated(item)); + a.setRevision(3); + assertTrue(GearProvenance.isOutdated(item)); + GearProvenance.syncRevisions(item); + assertFalse(GearProvenance.isOutdated(item)); + GearProvenance.applyMajority(item, null); + assertEquals(0, GearProvenance.majorityOf(item)); + GearProvenance.applyMajority(item, List.of(p)); + assertEquals(2, GearProvenance.majorityOf(item)); + tag(item, GearKeys.archetypeRevision(), PersistentDataType.INTEGER, null); + assertTrue(GearProvenance.isOutdated(item)); + } + + @Test + void legacyMalformedAndMissingPartTokensStayRecoverable() { + var item = item(); + GearProvenance.stamp(item, GearType.STAFF, null); + tag(item, GearKeys.parts(), PersistentDataType.STRING, "old,missing:4, core:bad, ,:1"); + var p = part("core", 1); + PartRegistry.register(p); + assertEquals(List.of("old", "missing", ":1"), GearProvenance.missingPartIds(item)); + assertFalse(GearProvenance.isOutdated(item)); + GearProvenance.syncRevisions(item); + assertEquals("old:1,missing:4,core:1,:1:1", GearProvenance.partsRaw(item)); + assertEquals(List.of(p), GearProvenance.resolveParts(item)); + tag(item, GearKeys.parts(), PersistentDataType.STRING, " "); + assertTrue(GearProvenance.resolveParts(item).isEmpty()); + GearProvenance.syncRevisions(item); + assertEquals("", GearProvenance.partsRaw(item)); + } +} diff --git a/src/test/java/net/tfminecraft/magic/GearRefresherCoverageTest.java b/src/test/java/net/tfminecraft/magic/GearRefresherCoverageTest.java new file mode 100644 index 0000000..9e3f195 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/GearRefresherCoverageTest.java @@ -0,0 +1,208 @@ +package net.tfminecraft.magic; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import io.lumine.mythic.lib.api.item.NBTItem; +import java.util.*; +import net.Indyuce.mmoitems.ItemStats; +import net.Indyuce.mmoitems.api.item.build.ItemStackBuilder; +import net.Indyuce.mmoitems.api.item.mmoitem.LiveMMOItem; +import net.Indyuce.mmoitems.stat.data.*; +import net.tfminecraft.magic.gear.*; +import org.bukkit.*; +import org.bukkit.inventory.*; +import org.bukkit.persistence.*; +import org.junit.jupiter.api.*; +import org.mockbukkit.mockbukkit.MockBukkit; + +class GearRefresherCoverageTest extends GearMmoCoverageSupport { + ItemStack managed() { + var p = part("core", 2); + PartRegistry.register(p); + var a = + new ArchetypeDef( + GearType.STAFF, + "Staff", + "v.STICK", + "v.STICK", + false, + List.of(), + Map.of("spell", "Spell")); + ArchetypeRegistry.register(a); + var item = item(); + GearProvenance.stamp(item, GearType.STAFF, List.of(p)); + return item; + } + + @Test + void refreshGuardsAndMissingPartProtectionPreserveOriginals() { + assertFalse(GearRefresher.isManaged(null)); + assertNull(GearRefresher.refresh(null, null, true)); + var empty = item(); + GearProvenance.stamp(empty, GearType.STAFF, null); + assertFalse(GearRefresher.isManaged(empty)); + var item = managed(); + assertTrue(GearRefresher.isManaged(item)); + assertNull(GearRefresher.refresh(item, null, true)); + MockBukkit.createMockPlugin("MMOItems"); + MockBukkit.createMockPlugin("MythicLib"); + try (var broken = mockStatic(GearBrokenMarker.class)) { + broken.when(() -> GearBrokenMarker.isBroken(item)).thenReturn(true); + assertNull(GearRefresher.refresh(item, null, true)); + broken.when(() -> GearBrokenMarker.isBroken(item)).thenReturn(false); + assertNull(GearRefresher.refreshIfOutdated(item, null)); + PartRegistry.clear(); + var result = GearRefresher.refresh(item, null, true); + assertNotSame(item, result); + broken.verify(() -> GearBrokenMarker.mark(result)); + broken.verify(() -> GearBrokenMarker.notifyOnce(null, result, List.of("core"))); + } + } + + @Test + void missingMajorityCanRefreshWithoutRebuildingMmoData() { + var item = managed(); + MockBukkit.createMockPlugin("MMOItems"); + MockBukkit.createMockPlugin("MythicLib"); + tag(item, GearKeys.majorityTier(), PersistentDataType.INTEGER, null); + try (var lore = mockStatic(WeaponLore.class)) { + lore.when(() -> WeaponLore.updateItem(any())).thenAnswer(i -> i.getArgument(0)); + var result = GearRefresher.refreshIfOutdated(item, null); + assertEquals(2, GearProvenance.majorityOf(result)); + assertEquals(0, GearProvenance.majorityOf(item)); + } + PartRegistry.clear(); + var zero = part("core", 0); + PartRegistry.register(zero); + assertNull(GearRefresher.refreshIfOutdated(item, null)); + ArchetypeRegistry.clear(); + assertNull(GearRefresher.refresh(item, null, true)); + } + + @Test + void rebuildPreservesGearPdcAmountsAndExistingGems() { + var item = managed(); + item.setAmount(3); + tag(item, GearKeys.orphans(), PersistentDataType.STRING, "old"); + GearProvenance.lockSockets(item); + PartRegistry.get("core").setRevision(2); + MockBukkit.createMockPlugin("MMOItems"); + MockBukkit.createMockPlugin("MythicLib"); + var built = item(); + var good = mock(GemstoneData.class); + String colour = + SocketLayout.colours( + ArchetypeRegistry.get(GearType.STAFF), GearProvenance.resolveParts(item), 0) + .getFirst(); + when(good.getSocketColor()).thenReturn(colour); + var oldSockets = new GemSocketsData(List.of()); + oldSockets.add(good); + try (var nbt = mockStatic(NBTItem.class); + var stats = mockStatic(GearStatApplicator.class); + var model = mockStatic(GearModelResolver.class); + var lore = mockStatic(WeaponLore.class); + var items = + mockConstruction( + LiveMMOItem.class, + (m, c) -> { + when(m.hasData(ItemStats.GEM_SOCKETS)).thenReturn(true); + when(m.getData(ItemStats.GEM_SOCKETS)).thenReturn(oldSockets); + var builder = mock(ItemStackBuilder.class); + when(m.newBuilder()).thenReturn(builder); + when(builder.build()).thenReturn(built); + })) { + model + .when(() -> GearModelResolver.apply(any(), any(), any())) + .thenAnswer(i -> i.getArgument(0)); + lore.when(() -> WeaponLore.updateItem(any())).thenAnswer(i -> i.getArgument(0)); + var result = GearRefresher.refreshIfOutdated(item, null); + assertSame(built, result); + assertEquals(3, result.getAmount()); + assertEquals("core:2", GearProvenance.partsRaw(result)); + assertTrue(GearProvenance.socketsLocked(result)); + assertEquals( + "old", + result + .getItemMeta() + .getPersistentDataContainer() + .get(GearKeys.orphans(), PersistentDataType.STRING)); + var captured = org.mockito.ArgumentCaptor.forClass(GemSocketsData.class); + verify(items.constructed().getFirst()).setData(eq(ItemStats.GEM_SOCKETS), captured.capture()); + assertEquals(List.of(good), captured.getValue().getGems()); + } + } + + @Test + void incompatibleGemsAreHeldAndFailuresNeverReplaceOriginals() { + MockBukkit.createMockPlugin("MMOItems"); + MockBukkit.createMockPlugin("MythicLib"); + for (int mode = 0; mode < 7; mode++) { + var item = managed(); + int scenario = mode; + var built = item(); + var orphan = mock(GemstoneData.class); + when(orphan.getSocketColor()).thenReturn(mode == 0 ? null : "unmatched"); + when(orphan.getMMOItemType()).thenReturn("RUNE"); + when(orphan.getMMOItemID()).thenReturn("TEST"); + var sockets = new GemSocketsData(List.of()); + sockets.add(orphan); + try (var nbt = mockStatic(NBTItem.class); + var stats = mockStatic(GearStatApplicator.class); + var model = mockStatic(GearModelResolver.class); + var lore = mockStatic(WeaponLore.class); + var broken = mockStatic(GearBrokenMarker.class); + var items = + mockConstruction( + LiveMMOItem.class, + (m, c) -> { + when(m.hasData(ItemStats.GEM_SOCKETS)).thenReturn(scenario != 2); + when(m.getData(ItemStats.GEM_SOCKETS)) + .thenReturn(scenario == 3 ? new StringData("unexpected") : sockets); + var builder = mock(ItemStackBuilder.class); + when(m.newBuilder()).thenReturn(builder); + if (scenario == 4) when(builder.build()).thenReturn(null); + else if (scenario == 5) + when(builder.build()).thenReturn(new ItemStack(Material.AIR)); + else if (scenario == 6) + when(builder.build()).thenThrow(new IllegalStateException("bad")); + else when(builder.build()).thenReturn(built); + })) { + model + .when(() -> GearModelResolver.apply(any(), any(), any())) + .thenAnswer(i -> i.getArgument(0)); + lore.when(() -> WeaponLore.updateItem(any())).thenAnswer(i -> i.getArgument(0)); + var result = GearRefresher.refresh(item, null, true); + if (mode >= 4) assertNull(result); + else { + assertSame(built, result); + if (mode < 2) { + broken.verify(() -> GearBrokenMarker.mark(built)); + broken.verify(() -> GearBrokenMarker.addOrphans(eq(built), anyList())); + } else broken.verify(() -> GearBrokenMarker.mark(any()), never()); + } + } + } + } + + @Test + void metadataCopyHandlesAbsentOptionalTagsAndInvalidStacks() throws Exception { + var source = item(); + var destination = item(); + for (ItemStack[] pair : + new ItemStack[][] { + {null, destination}, + {source, null}, + {new ItemStack(Material.AIR), destination}, + {source, new ItemStack(Material.AIR)}, + {source, destination} + }) + invoke( + GearRefresher.class, + "copyGearPdc", + new Class[] {ItemStack.class, ItemStack.class}, + pair[0], + pair[1]); + assertEquals("Gear", destination.getItemMeta().getDisplayName()); + } +} diff --git a/src/test/java/net/tfminecraft/magic/GearRuneCoverageTest.java b/src/test/java/net/tfminecraft/magic/GearRuneCoverageTest.java new file mode 100644 index 0000000..00e3b98 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/GearRuneCoverageTest.java @@ -0,0 +1,142 @@ +package net.tfminecraft.magic; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import io.lumine.mythic.lib.api.item.NBTItem; +import io.lumine.mythic.lib.skill.trigger.TriggerType; +import java.util.*; +import net.Indyuce.mmoitems.ItemStats; +import net.Indyuce.mmoitems.api.item.mmoitem.LiveMMOItem; +import net.Indyuce.mmoitems.stat.data.*; +import net.tfminecraft.magic.gear.*; +import org.bukkit.*; +import org.bukkit.inventory.*; +import org.junit.jupiter.api.*; +import org.mockbukkit.mockbukkit.MockBukkit; + +class GearRuneCoverageTest extends GearMmoCoverageSupport { + Set oldRunes; + + @BeforeEach + void configureRunes() { + oldRunes = new HashSet<>(Cache.runeTypes); + Cache.runeTypes.clear(); + } + + @AfterEach + void cleanupRunes() { + Cache.runeTypes.clear(); + Cache.runeTypes.addAll(oldRunes); + } + + @Test + void runeDetectionRequiresRegisteredTypeAndBothPlugins() { + assertFalse(RuneKeybind.isRune(null)); + assertFalse(RuneKeybind.isRune(new ItemStack(Material.AIR))); + var item = item(); + assertFalse(RuneKeybind.isRune(item)); + Cache.runeTypes.add("rune"); + assertFalse(RuneKeybind.isRune(item)); + MockBukkit.createMockPlugin("MMOItems"); + assertFalse(RuneKeybind.isRune(item)); + MockBukkit.createMockPlugin("MythicLib"); + try (var nbt = mockStatic(NBTItem.class)) { + var data = mock(NBTItem.class); + nbt.when(() -> NBTItem.get(item)).thenReturn(data); + assertFalse(RuneKeybind.isRune(item)); + when(data.hasType()).thenReturn(true); + when(data.getType()).thenReturn("OTHER"); + assertFalse(RuneKeybind.isRune(item)); + when(data.getType()).thenReturn("RUNE"); + assertTrue(RuneKeybind.isRune(item)); + assertEquals(RuneKeybind.Result.NOT_RUNE, RuneKeybind.apply(item, null).result()); + } + assertEquals( + RuneKeybind.Result.NOT_RUNE, RuneKeybind.apply(null, TriggerType.RIGHT_CLICK).result()); + } + + @Test + void rebindingCopiesModifiersAndPreservesAmount() { + Cache.runeTypes.add("rune"); + MockBukkit.createMockPlugin("MMOItems"); + MockBukkit.createMockPlugin("MythicLib"); + var held = item(); + held.setAmount(4); + var rebuilt = item(); + var bad = mock(AbilityData.class); + var good = mock(AbilityData.class); + var ability = mock(net.Indyuce.mmoitems.skill.RegisteredSkill.class); + when(good.getAbility()).thenReturn(ability); + when(good.getModifiers()).thenReturn(Set.of("damage")); + when(good.getParameter("damage")).thenReturn(12d); + var list = new AbilityListData(Arrays.asList(null, bad, good)); + try (var nbt = mockStatic(NBTItem.class); + var abilities = mockConstruction(AbilityData.class); + var items = + mockConstruction( + LiveMMOItem.class, + (m, c) -> { + when(m.hasData(ItemStats.ABILITIES)).thenReturn(true); + when(m.getData(ItemStats.ABILITIES)).thenReturn(list); + var builder = mock(net.Indyuce.mmoitems.api.item.build.ItemStackBuilder.class); + when(m.newBuilder()).thenReturn(builder); + when(builder.build()).thenReturn(rebuilt); + })) { + var data = mock(NBTItem.class); + when(data.hasType()).thenReturn(true); + when(data.getType()).thenReturn("RUNE"); + nbt.when(() -> NBTItem.get(held)).thenReturn(data); + var result = RuneKeybind.apply(held, TriggerType.RIGHT_CLICK); + assertEquals(RuneKeybind.Result.OK, result.result()); + assertSame(rebuilt, result.item()); + assertEquals(4, rebuilt.getAmount()); + verify(abilities.constructed().getFirst()).setModifier("damage", 12); + } + } + + @Test + void invalidAbilityPayloadAndFailedBuildsReturnExplicitOutcomes() { + Cache.runeTypes.add("rune"); + MockBukkit.createMockPlugin("MMOItems"); + MockBukkit.createMockPlugin("MythicLib"); + var held = item(); + try (var nbt = mockStatic(NBTItem.class)) { + var data = mock(NBTItem.class); + when(data.hasType()).thenReturn(true); + when(data.getType()).thenReturn("RUNE"); + nbt.when(() -> NBTItem.get(held)).thenReturn(data); + for (int scenario = 0; scenario < 7; scenario++) { + int mode = scenario; + try (var abilities = mockConstruction(AbilityData.class); + var items = + mockConstruction( + LiveMMOItem.class, + (m, c) -> { + when(m.hasData(ItemStats.ABILITIES)).thenReturn(mode != 0); + var bad = mock(AbilityData.class); + var good = mock(AbilityData.class); + when(good.getAbility()) + .thenReturn(mock(net.Indyuce.mmoitems.skill.RegisteredSkill.class)); + if (mode == 1) when(m.getData(ItemStats.ABILITIES)).thenReturn(null); + else if (mode == 2) + when(m.getData(ItemStats.ABILITIES)).thenReturn(new AbilityListData()); + else if (mode == 3) + when(m.getData(ItemStats.ABILITIES)).thenReturn(new AbilityListData(bad)); + else + when(m.getData(ItemStats.ABILITIES)).thenReturn(new AbilityListData(good)); + var builder = + mock(net.Indyuce.mmoitems.api.item.build.ItemStackBuilder.class); + when(m.newBuilder()).thenReturn(builder); + if (mode == 5) when(builder.build()).thenReturn(new ItemStack(Material.AIR)); + if (mode == 6) + when(builder.build()).thenThrow(new IllegalStateException("broken")); + })) { + assertEquals( + mode < 4 ? RuneKeybind.Result.NO_ABILITIES : RuneKeybind.Result.FAILED, + RuneKeybind.apply(held, TriggerType.RIGHT_CLICK).result()); + } + } + } + } +} diff --git a/src/test/java/net/tfminecraft/magic/GearStationCoverageSupport.java b/src/test/java/net/tfminecraft/magic/GearStationCoverageSupport.java new file mode 100644 index 0000000..ed3dded --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/GearStationCoverageSupport.java @@ -0,0 +1,103 @@ +package net.tfminecraft.magic; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.nio.file.*; +import java.util.*; +import net.tfminecraft.magic.charge.TierBands; +import net.tfminecraft.magic.gear.*; +import net.tfminecraft.tlibs.TLibs; +import net.tfminecraft.tlibs.objects.api.BlockAPI; +import org.bukkit.*; +import org.bukkit.entity.*; +import org.bukkit.inventory.*; +import org.junit.jupiter.api.*; +import org.mockito.MockedStatic; + +abstract class GearStationCoverageSupport extends GearCoverageSupport { + MockedStatic entityTypes; + MockedStatic libs; + BlockAPI blocks; + net.tfminecraft.tlibs.objects.api.subapi.BlockChecker checker; + String oldStation; + + @SuppressWarnings("unchecked") + static Map occupied() throws Exception { + var field = GearStationStore.class.getDeclaredField("OCCUPIED"); + field.setAccessible(true); + return (Map) field.get(null); + } + + @BeforeEach + void setupStation() throws Exception { + occupied().clear(); + ((Map) field("retainedRows")).clear(); + setField("unreadableStore", false); + entityTypes = + mockStatic(org.mockbukkit.mockbukkit.entity.EntityTypesMock.class, CALLS_REAL_METHODS); + entityTypes + .when( + () -> + org.mockbukkit.mockbukkit.entity.EntityTypesMock.createEntity( + eq(ItemDisplay.class), same(server))) + .thenAnswer( + a -> { + var display = + spy( + new org.mockbukkit.mockbukkit.entity.ItemDisplayMock( + server, UUID.randomUUID())); + doNothing().when(display).setBillboard(any(Display.Billboard.class)); + return display; + }); + oldStation = GearCache.station; + GearCache.station = "iaf(test:station)"; + libs = mockStatic(TLibs.class); + blocks = mock(BlockAPI.class, RETURNS_DEEP_STUBS); + libs.when(TLibs::getBlockAPI).thenReturn(blocks); + checker = blocks.getChecker(); + when(checker.checkBlock(any(), anyString())).thenReturn(true); + TierBands.clear(); + TierBands.register("fire", 1, 1); + } + + @AfterEach + void cleanupStation() throws Exception { + occupied().clear(); + GearCache.station = oldStation; + entityTypes.close(); + libs.close(); + TierBands.clear(); + } + + static Object field(String name) throws Exception { + var f = GearStationStore.class.getDeclaredField(name); + f.setAccessible(true); + return f.get(null); + } + + static void setField(String name, Object value) throws Exception { + var f = GearStationStore.class.getDeclaredField(name); + f.setAccessible(true); + f.set(null, value); + } + + Location station() { + return new Location( + server.getWorld("world") == null + ? server.addSimpleWorld("world") + : server.getWorld("world"), + 1, + 2, + 3); + } + + ItemStack attuned() { + var weapon = item(); + var req = WeaponRequirement.fromItem(weapon); + req.aura().setCap("fire", 10); + req.aura().setFill("fire", 10); + req.persist(weapon); + return weapon; + } +} diff --git a/src/test/java/net/tfminecraft/magic/GearStationListenerCoverageTest.java b/src/test/java/net/tfminecraft/magic/GearStationListenerCoverageTest.java new file mode 100644 index 0000000..d80ebff --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/GearStationListenerCoverageTest.java @@ -0,0 +1,250 @@ +package net.tfminecraft.magic; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import dev.lone.itemsadder.api.Events.FurnitureBreakEvent; +import java.util.*; +import net.tfminecraft.magic.charge.ChargeKeys; +import net.tfminecraft.magic.gear.*; +import net.tfminecraft.magic.gear.gui.*; +import net.tfminecraft.magic.gear.orb.GearOrbService; +import org.bukkit.*; +import org.bukkit.entity.*; +import org.bukkit.event.block.Action; +import org.bukkit.event.player.PlayerInteractEvent; +import org.bukkit.event.world.ChunkLoadEvent; +import org.bukkit.inventory.*; +import org.bukkit.persistence.PersistentDataType; +import org.junit.jupiter.api.*; +import org.mockito.*; + +class GearStationListenerCoverageTest extends GearStationCoverageSupport { + GearStationListener listener; + org.mockbukkit.mockbukkit.entity.PlayerMock player; + Location loc; + MockedStatic orbs; + MockedConstruction menus; + + @BeforeEach + void listenerSetup() { + menus = mockConstruction(GearInventoryManager.class); + listener = new GearStationListener(); + orbs = mockStatic(GearOrbService.class); + player = server.addPlayer(); + loc = station(); + assertSame(menus.constructed().getFirst(), listener.inventory()); + } + + @AfterEach + void listenerCleanup() { + orbs.close(); + menus.close(); + OpenStationManager.clear(player); + } + + PlayerInteractEvent click(Action action, EquipmentSlot hand, org.bukkit.block.Block block) { + var e = mock(PlayerInteractEvent.class); + when(e.getAction()).thenReturn(action); + when(e.getHand()).thenReturn(hand); + when(e.getClickedBlock()).thenReturn(block); + when(e.getPlayer()).thenReturn(player); + return e; + } + + void right() { + listener.onInteract(click(Action.RIGHT_CLICK_BLOCK, EquipmentSlot.HAND, loc.getBlock())); + } + + void left() { + listener.onInteract(click(Action.LEFT_CLICK_BLOCK, EquipmentSlot.HAND, loc.getBlock())); + } + + FurnitureBreakEvent broken(String id, Entity entity) { + var e = mock(FurnitureBreakEvent.class); + when(e.getNamespacedID()).thenReturn(id); + when(e.getBukkitEntity()).thenReturn(entity); + return e; + } + + @SuppressWarnings("unchecked") + Map aborts() throws Exception { + var f = GearStationListener.class.getDeclaredField("recentAborts"); + f.setAccessible(true); + return (Map) f.get(listener); + } + + @Test + void interactionGuardsAndStationRouting() { + listener.onInteract(click(Action.PHYSICAL, EquipmentSlot.HAND, null)); + listener.onInteract(click(Action.RIGHT_CLICK_BLOCK, EquipmentSlot.OFF_HAND, loc.getBlock())); + listener.onInteract(click(Action.RIGHT_CLICK_BLOCK, EquipmentSlot.HAND, null)); + doReturn(false).when(checker).checkBlock(any(), anyString()); + right(); + when(blocks.getChecker().checkBlock(any(), anyString())).thenThrow(new IllegalStateException()); + right(); + doReturn(true).when(checker).checkBlock(any(), anyString()); + player.setSneaking(true); + right(); + player.setSneaking(false); + right(); + assertEquals(loc, OpenStationManager.get(player)); + verify(listener.inventory()).openAssembly(player); + var occupancy = GearStationStore.occupy(loc, item(), null, Map.of()); + right(); + assertTrue(GearStationStore.isOccupied(loc)); + occupancy.setOrbSessionActive(true); + right(); + occupancy.setOrbSessionActive(false); + orbs.when(() -> GearOrbService.isActive(loc)).thenReturn(true); + right(); + orbs.when(() -> GearOrbService.isActive(loc)).thenReturn(false); + player.getInventory().setItemInMainHand(item()); + player.setSneaking(true); + right(); + player.setSneaking(false); + right(); + var charge = item(); + tag(charge, ChargeKeys.chargeTier(), PersistentDataType.INTEGER, 1); + player.getInventory().setItemInMainHand(charge); + try (var service = mockStatic(GearChargeService.class)) { + right(); + service.verify(() -> GearChargeService.tryApply(eq(player), eq(loc), any())); + } + player.getInventory().setItemInMainHand(new ItemStack(Material.AIR)); + GearStationStore.update(loc, attuned()); + right(); + assertFalse(GearStationStore.isOccupied(loc)); + } + + @Test + void furnitureEventsRespectStationIdOccupancyAndRecentAbortGuard() throws Exception { + for (String setting : Arrays.asList(null, "v.stone", "iaf(broken", "iaf(test:station)")) { + GearCache.station = setting; + for (String id : Arrays.asList(null, "other")) { + listener.onFurnitureBreak(broken(id, null)); + listener.onFurnitureBroken(broken(id, null)); + } + } + GearCache.station = "iaf(test:station)"; + listener.onFurnitureBreak(broken("test:station", null)); + listener.onFurnitureBroken(broken("test:station", null)); + var entity = mock(Entity.class); + when(entity.getLocation()).thenReturn(loc.clone().add(.5, .5, .5)); + var e = broken("test:station", entity); + listener.onFurnitureBreak(e); + listener.onFurnitureBroken(e); + verify(e, never()).setCancelled(true); + GearStationStore.occupy(loc, item(), null, null); + assertSame(entity, e.getBukkitEntity()); + assertEquals(loc, GearStationStore.occupiedWithin(entity.getLocation(), .75)); + listener.onFurnitureBreak(e); + verify(e).setCancelled(true); + listener.onFurnitureBroken(e); + assertFalse(GearStationStore.isOccupied(loc)); + orbs.verify(() -> GearOrbService.abort(loc)); + aborts().put("missing,1,2,3", System.currentTimeMillis()); + aborts().put("world,20,2,3", System.currentTimeMillis()); + aborts().put("world,5,2,3", 0L); + listener.onFurnitureBreak(broken("test:station", entity)); + aborts().put(GearStationStore.key(loc), System.currentTimeMillis()); + var guarded = broken("test:station", entity); + listener.onFurnitureBreak(guarded); + verify(guarded).setCancelled(true); + assertFalse(aborts().containsKey("world,5,2,3")); + listener.onChunkLoad(new ChunkLoadEvent(loc.getChunk(), false)); + } + + @Test + void abortRespectsOwnershipAttunementAndRecordedRefunds() throws Exception { + listener.onInteract(click(Action.LEFT_CLICK_BLOCK, EquipmentSlot.OFF_HAND, loc.getBlock())); + left(); + player.setSneaking(true); + listener.onInteract(click(Action.LEFT_CLICK_BLOCK, EquipmentSlot.HAND, null)); + doReturn(false).when(checker).checkBlock(any(), anyString()); + left(); + when(blocks.getChecker().checkBlock(any(), anyString())).thenThrow(new IllegalStateException()); + left(); + doReturn(true).when(checker).checkBlock(any(), anyString()); + left(); + GearStationStore.occupy(loc, attuned(), null, null); + left(); + assertTrue(GearStationStore.isOccupied(loc)); + GearStationStore.occupy(loc, item(), UUID.randomUUID(), Map.of("v.stone", 2)); + left(); + assertTrue(GearStationStore.isOccupied(loc)); + try (var costs = mockStatic(GearCosts.class)) { + player.addAttachment(Magic.plugin, "magic.admin", true); + aborts().put("old", 0L); + aborts().put("new", System.currentTimeMillis()); + left(); + costs.verify(() -> GearCosts.refund(player, Map.of("v.stone", 2), loc)); + assertFalse(GearStationStore.isOccupied(loc)); + assertFalse(aborts().containsKey("old")); + GearStationStore.occupy(loc, item(), player.getUniqueId(), Map.of()); + left(); + GearStationStore.occupy(loc, item(), null, null); + costs.when(() -> GearCosts.total(anyCollection())).thenReturn(Map.of("v.gold_ingot", 1)); + left(); + costs.verify(() -> GearCosts.refund(player, Map.of("v.gold_ingot", 1), loc)); + } + } + + @Test + void reclaimRepairsMissingPartsAndReturnsOrphanItems() throws Exception { + try (var refresher = mockStatic(GearRefresher.class); + var marker = mockStatic(GearBrokenMarker.class, CALLS_REAL_METHODS)) { + var weapon = item(); + GearBrokenMarker.mark(weapon); + player.getInventory().setItemInMainHand(weapon); + right(); + assertFalse(GearBrokenMarker.isBroken(player.getInventory().getItemInMainHand())); + GearBrokenMarker.mark(weapon); + refresher.when(() -> GearRefresher.refresh(any(), eq(player), eq(true))).thenReturn(item()); + player.getInventory().setItemInMainHand(weapon); + right(); + assertFalse(GearBrokenMarker.isBroken(player.getInventory().getItemInMainHand())); + GearBrokenMarker.addOrphans(weapon, List.of(new GearBrokenMarker.Orphan("RUNE", "TEST"))); + tag(weapon, GearKeys.parts(), PersistentDataType.STRING, "core=missing"); + marker + .when(() -> GearBrokenMarker.buildOrphanItems(any())) + .thenReturn(List.of(new ItemStack(Material.DIAMOND))); + for (int i = 0; i < player.getInventory().getSize(); i++) + player.getInventory().setItem(i, new ItemStack(Material.STONE, 64)); + player.getInventory().setItemInMainHand(weapon); + right(); + assertTrue( + GearBrokenMarker.isBroken(player.getInventory().getItemInMainHand()), + "missing part stays broken"); + assertTrue( + loc.getWorld().getEntitiesByClass(Item.class).stream() + .anyMatch(e -> e.getItemStack().getType() == Material.DIAMOND), + "full inventory drops reclaimed rune"); + } + } + + @Test + void synchronousOrbCompletionCanAlreadyClearTheStation() throws Exception { + invoke( + GearStationListener.class, + "eject", + new Class[] {Player.class, Location.class}, + player, + loc); + player.setSneaking(true); + GearStationStore.occupy(loc, item(), null, null); + orbs.when(() -> GearOrbService.abort(loc)) + .thenAnswer( + a -> { + GearStationStore.takeForAbort(loc); + return null; + }); + left(); + assertFalse(GearStationStore.isOccupied(loc)); + GearStationStore.occupy(loc, item(), null, null); + var entity = mock(Entity.class); + when(entity.getLocation()).thenReturn(loc.clone().add(.5, .5, .5)); + listener.onFurnitureBroken(broken("test:station", entity)); + assertFalse(GearStationStore.isOccupied(loc)); + } +} diff --git a/src/test/java/net/tfminecraft/magic/GearStationStoreCoverageTest.java b/src/test/java/net/tfminecraft/magic/GearStationStoreCoverageTest.java new file mode 100644 index 0000000..c9b4676 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/GearStationStoreCoverageTest.java @@ -0,0 +1,551 @@ +package net.tfminecraft.magic; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.nio.file.*; +import java.util.*; +import net.tfminecraft.magic.gear.*; +import net.tfminecraft.magic.gear.gui.OpenStationManager; +import org.bukkit.*; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.entity.*; +import org.bukkit.inventory.*; +import org.junit.jupiter.api.*; + +class GearStationStoreCoverageTest extends GearStationCoverageSupport { + @Test + void occupancyPersistsOwnerCostsDisplayAndOnlyEjectsAttunedIdleWeapons() throws Exception { + var location = station(); + assertFalse(GearStationStore.isOccupied(null)); + assertNull(GearStationStore.get(null)); + assertNull(GearStationStore.occupy(null, item(), null, null)); + assertNull(GearStationStore.occupy(location, null, null, null)); + assertNull(GearStationStore.eject(location)); + assertNull(GearStationStore.takeForAbort(location)); + GearStationStore.update(location, item()); + assertFalse(GearStationStore.isAttuned(null)); + var owner = UUID.randomUUID(); + var weapon = item(); + var value = GearStationStore.occupy(location, weapon, owner, Map.of("v.iron_ingot", 2)); + assertTrue(GearStationStore.isOccupied(location)); + assertSame(value, GearStationStore.get(location)); + assertEquals(owner, value.getOwner()); + assertEquals(Map.of("v.iron_ingot", 2), value.getCharged()); + assertSame(weapon, value.getItem()); + assertNull(GearStationStore.eject(location)); + value.setOrbSessionActive(true); + assertTrue(value.isOrbSessionActive()); + assertNull(GearStationStore.eject(location)); + GearStationStore.update(location, null); + value.setOrbSessionActive(false); + var ready = attuned(); + GearStationStore.update(location, ready); + assertSame(ready, value.getItem()); + assertTrue(GearStationStore.isAttuned(ready)); + assertSame(ready, GearStationStore.eject(location)); + assertFalse(GearStationStore.isOccupied(location)); + assertTrue(Files.exists(temp.resolve("data/gear-stations.yml"))); + value = GearStationStore.occupy(location, weapon, null, null); + assertNull(value.getOwner()); + assertNull(value.getCharged()); + assertSame(weapon, GearStationStore.takeForAbort(location)); + } + + @Test + void locationAndOpenStationHelpersCloneStateAndRespectWorlds() throws Exception { + var location = station(); + assertEquals("", GearStationStore.key(null)); + assertEquals("", GearStationStore.key(new Location(null, 0, 0, 0))); + assertEquals("world,1,2,3", GearStationStore.key(location)); + assertEquals(location, GearStationStore.locationFromKey("world,1,2,3")); + for (String key : List.of("invalid", "missing,1,2,3", "world,NaN,2,3")) + assertNull(GearStationStore.locationFromKey(key)); + var origin = location.clone().add(.5, .5, .5); + assertEquals(0, GearStationStore.distanceSquaredToCenter(location, origin)); + for (Location missing : + Arrays.asList( + null, + new Location(null, 0, 0, 0), + new Location(server.addSimpleWorld("other"), 0, 0, 0))) + assertEquals(Double.MAX_VALUE, GearStationStore.distanceSquaredToCenter(location, missing)); + assertEquals(Double.MAX_VALUE, GearStationStore.distanceSquaredToCenter(null, origin)); + assertEquals( + Double.MAX_VALUE, + GearStationStore.distanceSquaredToCenter(new Location(null, 0, 0, 0), origin)); + assertNull(GearStationStore.occupiedWithin(null, 1)); + assertNull(GearStationStore.occupiedWithin(new Location(null, 0, 0, 0), 1)); + assertNull(GearStationStore.occupiedWithin(origin, -1)); + GearStationStore.occupy(location, item(), null, Map.of()); + assertEquals(location, GearStationStore.occupiedWithin(origin, .1)); + assertNull(GearStationStore.occupiedWithin(origin.clone().add(4, 0, 0), .1)); + GearStationStore.occupy(location.clone().add(3, 0, 0), item(), null, Map.of()); + assertEquals(location, GearStationStore.occupiedWithin(origin, 10)); + var p = server.addPlayer(); + OpenStationManager.set(null, location); + OpenStationManager.set(p, null); + assertNull(OpenStationManager.get(null)); + assertNull(OpenStationManager.get(p)); + OpenStationManager.set(p, location); + var copy = OpenStationManager.get(p); + copy.add(1, 0, 0); + assertEquals(location, OpenStationManager.get(p)); + OpenStationManager.clear(null); + OpenStationManager.clear(p); + assertNull(OpenStationManager.get(p)); + } + + @Test + void stationRoundTripReusesTaggedDisplayAndClearsSavedState() throws Exception { + GearStationStore.load(); + var location = station(); + GearStationStore.occupy(location, item(), UUID.randomUUID(), Map.of("v.iron_ingot", 2)); + assertEquals(1, location.getWorld().getEntitiesByClass(ItemDisplay.class).size()); + GearStationStore.load(); + assertTrue(GearStationStore.isOccupied(location)); + assertEquals(1, location.getWorld().getEntitiesByClass(ItemDisplay.class).size()); + GearStationStore.reconcile(null); + GearStationStore.reconcile(location.getChunk()); + GearStationStore.shutdown(); + assertFalse(GearStationStore.isOccupied(location)); + assertTrue( + location.getWorld().getEntitiesByClass(ItemDisplay.class).stream() + .noneMatch(Entity::isValid), + "no valid displays after shutdown"); + GearStationStore.load(); + assertTrue(GearStationStore.isOccupied(location)); + GearStationStore.clearAll(); + assertTrue( + YamlConfiguration.loadConfiguration(temp.resolve("data/gear-stations.yml").toFile()) + .getKeys(false) + .isEmpty(), + "empty saved data after clear"); + GearStationStore.load(); + assertFalse(GearStationStore.isOccupied(location)); + } + + @Test + void unknownWorldAndInvalidRowsSurviveAnOtherwiseSuccessfulSave() throws Exception { + var config = new YamlConfiguration(); + config.set("stations.offline.world", "offline"); + config.set("stations.offline.x", 1); + config.set("stations.offline.item", item()); + config.set("stations.scalar", "invalid but retained"); + var file = temp.resolve("data/gear-stations.yml"); + Files.createDirectories(file.getParent()); + config.save(file.toFile()); + GearStationStore.load(); + GearStationStore.save(); + var saved = YamlConfiguration.loadConfiguration(file.toFile()); + assertEquals("offline", saved.getString("stations.offline.world")); + assertEquals("invalid but retained", saved.getString("stations.scalar")); + } + + @Test + void malformedStoreIsPreservedBeforeTheNextSave() throws Exception { + var file = temp.resolve("data/gear-stations.yml"); + Files.createDirectories(file.getParent()); + String broken = "stations: [broken"; + Files.writeString(file, broken); + GearStationStore.load(); + GearStationStore.save(); + try (var files = Files.list(file.getParent())) { + assertTrue( + files + .filter(p -> p.getFileName().toString().contains("rejected")) + .anyMatch( + p -> { + try { + return Files.readString(p).equals(broken); + } catch (Exception e) { + throw new RuntimeException(e); + } + })); + } + } + + @Test + void metadataAndCostHelpersHandleLegacyAndMalformedValues() throws Exception { + assertNull(invoke(GearStationStore.class, "uuidOf", new Class[] {String.class}, (Object) null)); + assertNull(invoke(GearStationStore.class, "uuidOf", new Class[] {String.class}, " ")); + assertNull(invoke(GearStationStore.class, "uuidOf", new Class[] {String.class}, "bad")); + var id = UUID.randomUUID(); + assertEquals( + id, invoke(GearStationStore.class, "uuidOf", new Class[] {String.class}, id.toString())); + var config = new YamlConfiguration(); + assertNull( + invoke( + GearStationStore.class, + "readCharged", + new Class[] {org.bukkit.configuration.ConfigurationSection.class}, + config)); + config.set("charged.0.amount", 1); + config.set("charged.1.path", " "); + config.set("charged.1.amount", 1); + config.set("charged.2.path", "v.stone"); + config.set("charged.2.amount", 0); + config.set("charged.3.path", "v.stone"); + config.set("charged.3.amount", 2); + config.set("charged.4.path", "v.stone"); + config.set("charged.4.amount", 3); + assertEquals( + Map.of("v.stone", 5), + invoke( + GearStationStore.class, + "readCharged", + new Class[] {org.bukkit.configuration.ConfigurationSection.class}, + config)); + var noWorld = new Location(null, 0, 0, 0); + assertNull(invoke(GearStationStore.class, "findTagged", new Class[] {Location.class}, noWorld)); + assertNull( + invoke( + GearStationStore.class, + "ensureDisplay", + new Class[] {Location.class, ItemStack.class, UUID.class}, + noWorld, + item(), + null)); + GearStationStore.occupy(noWorld, item(), null, null).setItem(item()); + GearStationStore.save(); + GearStationStore.shutdown(); + invoke( + GearStationStore.class, + "removeDisplays", + new Class[] {Location.class, UUID.class, UUID.class}, + null, + null, + null); + invoke( + GearStationStore.class, + "drop", + new Class[] {Location.class, ItemStack.class}, + noWorld, + item()); + var loc = station(); + invoke( + GearStationStore.class, "drop", new Class[] {Location.class, ItemStack.class}, loc, null); + var parent = temp.resolve("blocked"); + Files.writeString(parent, "file"); + when(Magic.plugin.getDataFolder()).thenReturn(parent.toFile()); + assertDoesNotThrow(GearStationStore::save); + } + + @Test + void rejectedRowsKeepNumericKeysAndQuarantineFailureCannotOverwrite() throws Exception { + var config = new YamlConfiguration(); + config.set("stations.0.world", "offline"); + config.set("stations.0.item", item()); + config.set("stations.2.world", "world"); + config.set("stations.2.x", 2); + var file = temp.resolve("data/gear-stations.yml"); + Files.createDirectories(file.getParent()); + config.save(file.toFile()); + var location = station(); + GearStationStore.load(); + GearStationStore.occupy(location, item(), null, null); + var saved = YamlConfiguration.loadConfiguration(file.toFile()); + assertEquals("offline", saved.getString("stations.0.world")); + assertEquals("world", saved.getString("stations.1.world")); + assertEquals("world", saved.getString("stations.2.world")); + Files.writeString(file, "stations: [broken"); + GearStationStore.load(); + try (var files = mockStatic(Files.class, CALLS_REAL_METHODS)) { + files + .when(() -> Files.move(eq(file), any(Path.class))) + .thenThrow(new java.io.IOException("read only")); + GearStationStore.save(); + assertEquals("stations: [broken", Files.readString(file)); + } + GearStationStore.save(); + assertFalse((boolean) field("unreadableStore")); + } + + @Test + void absentFurnitureDropsOnceAndUnknownFurnitureRetainsWeapons() throws Exception { + var location = station(); + GearStationStore.occupy(location, item(), null, null); + doReturn(false).when(checker).checkBlock(any(), anyString()); + location.getBlock().setType(Material.BARRIER); + GearStationStore.load(); + assertTrue(GearStationStore.isOccupied(location)); + when(blocks.getChecker().checkBlock(any(), anyString())) + .thenThrow(new IllegalStateException("API unavailable")); + GearStationStore.reconcile(location.getChunk()); + assertTrue(GearStationStore.isOccupied(location)); + doReturn(false).when(checker).checkBlock(any(), anyString()); + location.getBlock().setType(Material.AIR); + GearStationStore.load(); + assertFalse(GearStationStore.isOccupied(location)); + assertEquals(1, location.getWorld().getEntitiesByClass(Item.class).size()); + GearStationStore.load(); + assertEquals(1, location.getWorld().getEntitiesByClass(Item.class).size()); + doReturn(true).when(checker).checkBlock(any(), anyString()); + GearStationStore.occupy(location, item(), null, null); + var display = + location.getWorld().getEntitiesByClass(ItemDisplay.class).stream() + .filter(Entity::isValid) + .findFirst() + .orElseThrow(); + display.remove(); + GearStationStore.reconcile(location.getChunk()); + assertTrue(GearStationStore.isOccupied(location)); + doReturn(false).when(checker).checkBlock(any(), anyString()); + GearStationStore.reconcile(location.getChunk()); + assertFalse(GearStationStore.isOccupied(location)); + } + + @Test + void displayReconciliationKeepsFurnitureRemovesDuplicatesAndRecoversMissingIds() + throws Exception { + var location = station(); + var world = location.getWorld(); + var at = location.clone().add(.5, 1.15, .5); + var nonDisplay = world.spawn(at, org.bukkit.entity.ArmorStand.class); + var furniture = world.spawn(at, ItemDisplay.class); + furniture.setItemStack(new ItemStack(Material.STONE)); + var gear = world.spawn(at, ItemDisplay.class); + var weapon = item(); + tag(weapon, GearKeys.parts(), org.bukkit.persistence.PersistentDataType.STRING, "part"); + gear.setItemStack(weapon); + var archetype = world.spawn(at, ItemDisplay.class); + var second = item(); + tag(second, GearKeys.archetype(), org.bukkit.persistence.PersistentDataType.STRING, "staff"); + archetype.setItemStack(second); + var plain = world.spawn(at, ItemDisplay.class); + plain.setItemStack(item()); + assertNull( + invoke(GearStationStore.class, "findTagged", new Class[] {Location.class}, location)); + GearStationStore.occupy(location, item(), null, null); + assertTrue(furniture.isValid()); + assertTrue(nonDisplay.isValid()); + assertTrue(plain.isValid()); + assertFalse(gear.isValid()); + assertFalse(archetype.isValid()); + var marked = + world.getEntitiesByClass(ItemDisplay.class).stream() + .filter(d -> d.getPersistentDataContainer().has(GearKeys.stationDisplay())) + .findFirst() + .orElseThrow(); + assertSame( + marked, + invoke(GearStationStore.class, "findTagged", new Class[] {Location.class}, location)); + assertEquals( + marked.getUniqueId(), + invoke( + GearStationStore.class, + "ensureDisplay", + new Class[] {Location.class, ItemStack.class, UUID.class}, + location, + item(), + UUID.randomUUID())); + invoke( + GearStationStore.class, + "removeDisplays", + new Class[] {Location.class, UUID.class, UUID.class}, + location, + marked.getUniqueId(), + marked.getUniqueId()); + assertTrue(marked.isValid()); + invoke( + GearStationStore.class, + "removeDisplays", + new Class[] {Location.class, UUID.class, UUID.class}, + location, + marked.getUniqueId(), + nonDisplay.getUniqueId()); + assertFalse(nonDisplay.isValid()); + invoke( + GearStationStore.class, + "removeDisplays", + new Class[] {Location.class, UUID.class, UUID.class}, + location, + null, + UUID.randomUUID()); + assertFalse(marked.isValid()); + assertSame( + plain, + invoke( + GearStationStore.class, + "displayOrNull", + new Class[] {UUID.class}, + plain.getUniqueId())); + } + + @Test + void unloadedWorldMissingItemsAndChunksAreHandledWithoutLosingLiveDisplays() throws Exception { + var location = station(); + var occupancy = GearStationStore.occupy(location, item(), null, null); + var display = + location.getWorld().getEntitiesByClass(ItemDisplay.class).stream() + .filter(Entity::isValid) + .findFirst() + .orElseThrow(); + display.remove(); + occupancy.setItem(item()); + occupancy.setItem(null); + GearStationStore.save(); + assertTrue( + YamlConfiguration.loadConfiguration(temp.resolve("data/gear-stations.yml").toFile()) + .getKeys(false) + .isEmpty()); + occupancy.setItem(item()); + GearStationStore.reconcile(location.getWorld().getChunkAt(1, 0)); + GearStationStore.reconcile(location.getWorld().getChunkAt(0, 1)); + GearStationStore.reconcile(server.addSimpleWorld("other").getChunkAt(0, 0)); + GearStationStore.occupy(new Location(null, 0, 0, 0), item(), null, null); + GearStationStore.reconcile(location.getChunk()); + GearStationStore.occupiedWithin(location.clone().add(.5, .5, .5), Double.MAX_VALUE); + when(checker.checkBlock(any(), anyString())) + .thenAnswer( + a -> { + GearStationStore.reconcile(location.getChunk()); + return true; + }); + GearStationStore.load(); + assertNull( + invoke(GearStationStore.class, "displayOrNull", new Class[] {UUID.class}, (Object) null)); + var armor = location.getWorld().spawn(location, ArmorStand.class); + assertNull( + invoke( + GearStationStore.class, + "displayOrNull", + new Class[] {UUID.class}, + armor.getUniqueId())); + GearStationStore.get(location).setItem(null); + doReturn(false).when(checker).checkBlock(any(), anyString()); + GearStationStore.reconcile(location.getChunk()); + assertFalse(GearStationStore.isOccupied(location)); + doReturn(true).when(checker).checkBlock(any(), anyString()); + var isolated = new Location(server.addSimpleWorld("unloaded"), 1, 2, 3); + GearStationStore.occupy(isolated, item(), null, null); + assertTrue(server.unloadWorld(isolated.getWorld(), false)); + GearStationStore.shutdown(); + } + + @Test + void displayBoundaryHandlesChunkLoadFailuresAndEmptyDisplays() throws Exception { + var world = mock(World.class); + when(world.getName()).thenReturn("boundary"); + var chunk = mock(Chunk.class); + when(world.getChunkAt(anyInt(), anyInt())).thenReturn(chunk); + when(world.getChunkAt(any(Location.class))).thenReturn(chunk); + when(chunk.load()).thenThrow(new IllegalStateException("cannot load")); + var loc = new Location(world, 1, 2, 3); + invoke( + GearStationStore.class, + "removeDisplays", + new Class[] {Location.class, UUID.class, UUID.class}, + loc, + null, + null); + verify(chunk).load(); + var display = mock(ItemDisplay.class); + when(display.getUniqueId()).thenReturn(UUID.randomUUID()); + when(display.getPersistentDataContainer()) + .thenReturn(item().getItemMeta().getPersistentDataContainer()); + when(display.getItemStack()).thenReturn(new ItemStack(Material.AIR)); + assertEquals( + false, + invoke(GearStationStore.class, "isGearDisplay", new Class[] {ItemDisplay.class}, display)); + doReturn(true).when(chunk).load(); + when(world.getNearbyEntities(any(Location.class), anyDouble(), anyDouble(), anyDouble())) + .thenReturn(List.of(display)); + display + .getPersistentDataContainer() + .set( + GearKeys.stationDisplay(), + org.bukkit.persistence.PersistentDataType.STRING, + GearStationStore.key(loc)); + assertEquals( + display.getUniqueId(), + invoke( + GearStationStore.class, + "ensureDisplay", + new Class[] {Location.class, ItemStack.class, UUID.class}, + loc, + item(), + null)); + } + + @Test + void staleEntityLookupAndRemovedUnloadedDisplayAreHarmless() throws Exception { + var invalid = mock(ItemDisplay.class); + var id = UUID.randomUUID(); + when(invalid.getUniqueId()).thenReturn(id); + when(invalid.isValid()).thenReturn(false); + try (var bukkit = mockStatic(Bukkit.class, CALLS_REAL_METHODS)) { + bukkit.when(() -> Bukkit.getEntity(id)).thenReturn(invalid); + assertNull(invoke(GearStationStore.class, "displayOrNull", new Class[] {UUID.class}, id)); + } + var location = new Location(server.addSimpleWorld("gone"), 0, 0, 0); + GearStationStore.occupy(location, item(), null, null); + location.getWorld().getEntitiesByClass(ItemDisplay.class).forEach(Entity::remove); + assertTrue(server.unloadWorld(location.getWorld(), false)); + GearStationStore.shutdown(); + assertTrue(occupied().isEmpty()); + } + + @Test + void reconciliationToleratesStationsRemovedBySpawnListeners() throws Exception { + var first = station(); + var second = first.clone().add(3, 0, 0); + GearStationStore.occupy(first, item(), null, null); + GearStationStore.occupy(second, item(), null, null); + first.getWorld().getEntitiesByClass(ItemDisplay.class).forEach(Entity::remove); + var fired = new java.util.concurrent.atomic.AtomicBoolean(); + server + .getPluginManager() + .registerEvent( + org.bukkit.event.entity.EntitySpawnEvent.class, + new org.bukkit.event.Listener() {}, + org.bukkit.event.EventPriority.NORMAL, + (listener, event) -> { + var entity = ((org.bukkit.event.entity.EntitySpawnEvent) event).getEntity(); + if (entity instanceof ItemDisplay && fired.compareAndSet(false, true)) { + String kept = + entity + .getPersistentDataContainer() + .get( + GearKeys.stationDisplay(), + org.bukkit.persistence.PersistentDataType.STRING); + GearStationStore.takeForAbort( + GearStationStore.key(first).equals(kept) ? second : first); + } + }, + Magic.plugin); + assertDoesNotThrow(() -> GearStationStore.reconcile(first.getChunk())); + assertTrue(fired.get()); + assertEquals(1, occupied().size()); + invoke( + GearStationStore.class, + "abandon", + new Class[] {Location.class}, + first.clone().add(8, 0, 0)); + } + + @Test + void reconciliationToleratesWorldUnloadFromSpawnListeners() throws Exception { + var first = station(); + var second = first.clone().add(3, 0, 0); + GearStationStore.occupy(first, item(), null, null); + GearStationStore.occupy(second, item(), null, null); + first.getWorld().getEntitiesByClass(ItemDisplay.class).forEach(Entity::remove); + var fired = new java.util.concurrent.atomic.AtomicBoolean(); + server + .getPluginManager() + .registerEvent( + org.bukkit.event.entity.EntitySpawnEvent.class, + new org.bukkit.event.Listener() {}, + org.bukkit.event.EventPriority.NORMAL, + (listener, event) -> { + if (((org.bukkit.event.entity.EntitySpawnEvent) event).getEntity() + instanceof ItemDisplay + && fired.compareAndSet(false, true)) + assertTrue(server.unloadWorld(first.getWorld(), false)); + }, + Magic.plugin); + assertDoesNotThrow(() -> GearStationStore.reconcile(first.getChunk())); + assertTrue(fired.get()); + assertNull(server.getWorld("world")); + } +} diff --git a/src/test/java/net/tfminecraft/magic/GearWeaponLoreCoverageTest.java b/src/test/java/net/tfminecraft/magic/GearWeaponLoreCoverageTest.java new file mode 100644 index 0000000..906f960 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/GearWeaponLoreCoverageTest.java @@ -0,0 +1,199 @@ +package net.tfminecraft.magic; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.*; +import net.tfminecraft.magic.charge.*; +import net.tfminecraft.magic.gear.*; +import net.tfminecraft.magic.model.ElementVisibility; +import net.tfminecraft.magic.registry.ElementRegistry; +import org.bukkit.*; +import org.bukkit.inventory.*; +import org.bukkit.persistence.PersistentDataType; +import org.junit.jupiter.api.*; + +class GearWeaponLoreCoverageTest extends GearCoverageSupport { + @BeforeEach + void elements() { + ElementRegistry.clear(); + TierBands.clear(); + for (String id : List.of("fire", "water", "earth", "air")) + ElementRegistry.register(DomainTest.element(id)); + TierBands.register("default", 1, 5); + } + + @AfterEach + void clear() { + ElementRegistry.clear(); + TierBands.clear(); + } + + List plain(ItemStack s) { + return s.getItemMeta().getLore().stream().map(ChatColor::stripColor).toList(); + } + + @Test + void loreUpdatesOnlyItsOwnBlockAndReflectsDamageRequirementsAndRift() { + assertNull(WeaponLore.updateItem(null)); + var s = item(); + WeaponLore.apply(s); + GearProvenance.stamp(s, GearType.STAFF, List.of(part("core", 2))); + PartRegistry.register(part("core", 2)); + var meta = s.getItemMeta(); + meta.setLore(List.of("Flavor", "")); + s.setItemMeta(meta); + assertSame(s, WeaponLore.updateItem(s)); + assertTrue(plain(s).contains("Tier II")); + assertTrue(plain(s).contains("Unattuned")); + WeaponLore.apply(s); + assertEquals(5, plain(s).size()); + var req = WeaponRequirement.fromItem(s); + req.mergeAmounts(Map.of("fire", 5d)); + req.aura().setCap("water", 5); + req.persist(s); + WeaponRift.set(s, 15); + tag(s, GearKeys.broken(), PersistentDataType.BYTE, (byte) 1); + try (var visibility = mockStatic(ElementVisibility.class)) { + visibility.when(() -> ElementVisibility.shownOnCharge("fire")).thenReturn(true); + visibility.when(() -> ElementVisibility.shownOnCharge("water")).thenReturn(true); + visibility.when(() -> ElementVisibility.shownOnCharge("earth")).thenReturn(true); + WeaponLore.apply(s); + } + assertTrue(plain(s).contains("Resonance")); + assertTrue(plain(s).contains("Damaged")); + assertTrue(plain(s).contains("Rift 15%")); + GearBrokenMarker.addOrphans(s, List.of(new GearBrokenMarker.Orphan("RUNE", "one"))); + PartRegistry.clear(); + WeaponLore.apply(s); + assertTrue(plain(s).contains("Holding 1 loose rune")); + assertTrue(plain(s).contains("Missing parts: core")); + GearBrokenMarker.addOrphans(s, List.of(new GearBrokenMarker.Orphan("RUNE", "two"))); + WeaponLore.apply(s); + assertTrue(plain(s).contains("Holding 2 loose runes")); + } + + @Test + void stripsBothMarkerFormatsAndLegacyLabelsWithoutTouchingFlavor() throws Exception { + for (String marker : + List.of("§0§1§4§rTier II", "§0§1§4 old", "Resonance", "Unattuned", "Tier IV")) { + assertEquals( + List.of("Flavor"), + invoke( + WeaponLore.class, + "stripBlock", + new Class[] {List.class}, + new ArrayList<>(List.of("Flavor", marker, "old")))); + } + var unchanged = Arrays.asList(null, "", "#ff00ff Flavor"); + assertEquals( + unchanged, invoke(WeaponLore.class, "stripBlock", new Class[] {List.class}, unchanged)); + assertEquals( + "name", + invoke(WeaponLore.class, "plain", new Class[] {String.class}, " §a#abcdef name ")); + } + + @Test + void requirementMergesHighestVisibleAmountsAndPreservesCaps() { + var s = item(); + var req = WeaponRequirement.fromItem(s); + assertFalse(req.hasStored()); + req.mergeFrom(null); + req.mergeAmounts(null); + var incoming = new LinkedHashMap(); + incoming.put("fire", null); + incoming.put("water", 0d); + incoming.put("earth", -1d); + incoming.put("hidden", 100d); + try (var visibility = mockStatic(ElementVisibility.class)) { + req.mergeAmounts(incoming); + visibility.when(() -> ElementVisibility.shownOnCharge("fire")).thenReturn(true); + req.mergeAmounts(Map.of("fire", 8d)); + req.mergeAmounts(Map.of("fire", 3d)); + } + assertEquals(8, req.aura().getFill("fire")); + assertEquals(8, req.aura().getCap("fire")); + req.persist(s); + assertTrue(WeaponRequirement.fromItem(s).hasStored()); + assertEquals(1, req.highestBand()); + assertEquals(0, WeaponRequirement.highestBand((Charge) null)); + assertEquals(0, WeaponRequirement.highestBand((Map) null)); + assertEquals(1, WeaponRequirement.highestBand(incoming)); + var charge = mock(Charge.class); + when(charge.getCappedElementIds()).thenReturn(Set.of("fire")); + when(charge.getFill("fire")).thenReturn(15d); + req.mergeFrom(charge); + assertEquals(15, req.aura().getFill("fire")); + assertEquals(1, WeaponRequirement.highestBand(charge)); + } + + @Test + void riftAndAttunementSummariesCoverUnattunedAndImbuedWeapons() throws Exception { + for (ItemStack empty : Arrays.asList(null, new ItemStack(Material.AIR))) { + assertEquals(0, WeaponRift.get(empty)); + WeaponRift.set(empty, 5); + } + var s = item(); + assertEquals(0, WeaponRift.get(s)); + WeaponRift.set(s, 0); + WeaponRift.copy(s, item()); + WeaponRift.set(s, 10); + var copy = item(); + WeaponRift.copy(s, copy); + assertEquals(10, WeaponRift.get(copy)); + var p = server.addPlayer(); + WeaponAttunementChat.sendPostChargeSummary(null, s); + WeaponAttunementChat.sendPostChargeSummary(p, null); + WeaponAttunementChat.sendPostChargeSummary(p, s); + WeaponRift.set(s, 0); + WeaponAttunementChat.sendPostChargeSummary(p, s); + var req = WeaponRequirement.fromItem(s); + var lines = new ArrayList(); + WeaponResonanceDisplay.appendElementLines(lines, null, null); + WeaponResonanceDisplay.appendElementLines(lines, req, null); + req.mergeAmounts(Map.of("fire", 5d)); + req.aura().setCap("water", 5); + req.persist(s); + try (var visibility = mockStatic(ElementVisibility.class)) { + for (String id : List.of("fire", "water", "earth")) + visibility.when(() -> ElementVisibility.shownOnCharge(id)).thenReturn(true); + WeaponResonanceDisplay.appendElementLines(lines, req, null); + assertEquals(2, lines.size()); + WeaponAttunementChat.sendPostChargeSummary(p, s); + } + assertFalse( + (boolean) + invoke( + WeaponResonanceDisplay.class, + "includeElement", + new Class[] {WeaponRequirement.class, String.class}, + null, + "fire")); + assertFalse( + (boolean) + invoke( + WeaponResonanceDisplay.class, + "includeElement", + new Class[] {WeaponRequirement.class, String.class}, + req, + null)); + assertTrue( + (boolean) + invoke( + WeaponResonanceDisplay.class, + "includeAuraEntry", + new Class[] {double.class, double.class}, + 0d, + 1d)); + } + + @Test + void loreAcceptsLegacyFillWithoutACorrespondingCap() { + var s = item(); + GearProvenance.stamp(s, GearType.WAND, List.of()); + tag(s, GearKeys.weaponReq(), PersistentDataType.STRING, "fire:10:10,water:0:5"); + WeaponLore.apply(s); + assertTrue(plain(s).contains("Resonance")); + assertEquals(3, plain(s).size()); + } +} diff --git a/src/test/java/net/tfminecraft/magic/GuiShrineConfigCoverageTest.java b/src/test/java/net/tfminecraft/magic/GuiShrineConfigCoverageTest.java new file mode 100644 index 0000000..86188eb --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/GuiShrineConfigCoverageTest.java @@ -0,0 +1,174 @@ +package net.tfminecraft.magic; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.io.*; +import java.lang.reflect.*; +import java.nio.file.*; +import java.util.*; +import net.tfminecraft.magic.artifact.shrine.*; +import net.tfminecraft.magic.loader.GuiLoader; +import net.tfminecraft.magic.registry.ElementRegistry; +import org.bukkit.*; +import org.bukkit.configuration.*; +import org.junit.jupiter.api.*; + +class GuiShrineConfigCoverageTest extends GearCoverageSupport { + Map saved = new LinkedHashMap<>(); + boolean debug; + + @BeforeEach + void configSetup() throws Exception { + for (Class type : List.of(GuiCache.class, ArtifactCreateCache.class)) { + for (Field field : type.getFields()) { + if (Modifier.isStatic(field.getModifiers()) && !Modifier.isFinal(field.getModifiers())) + saved.put(field, field.get(null)); + } + } + for (String name : List.of("config", "bundled")) { + var f = Messages.class.getDeclaredField(name); + f.setAccessible(true); + saved.put(f, f.get(null)); + } + debug = Cache.debug; + ShrineRegistry.clear(); + ElementRegistry.clear(); + } + + @AfterEach + void configCleanup() throws Exception { + for (var entry : saved.entrySet()) entry.getKey().set(null, entry.getValue()); + Cache.debug = debug; + ShrineRegistry.clear(); + ElementRegistry.clear(); + } + + File write(String content) throws Exception { + var p = temp.resolve("case.yml"); + Files.writeString(p, content); + return p.toFile(); + } + + @Test + void guiPartialModesButtonsAndInvalidSlotsAreValidated() throws Exception { + GuiCache.characterHeadSlot = 4; + GuiCache.castModeLeftSlot = 47; + GuiCache.castModeRightSlot = 51; + ArtifactCreateCache.previewSlot = 4; + ArtifactCreateCache.capMinusSlot = 12; + ArtifactCreateCache.capPlusSlot = 14; + ArtifactCreateCache.confirmSlot = 48; + ArtifactCreateCache.cancelSlot = 50; + ArtifactCreateCache.raritySlots = List.of(10, 11, 13, 15, 16); + var loader = new GuiLoader(); + loader.load( + write( + "cast_modes: {}\n" + + "artifact_create:\n" + + " items: {}\n" + + " slots: {}\n" + + "colors:\n" + + " blank: ''\n" + + " good: '#ffffff'\n")); + assertEquals("#ffffff", GuiCache.colors.get("good")); + assertFalse(GuiCache.colors.containsKey("blank")); + assertTrue(loader.loadSafe(write("artifact_create: {}\n"))); + ArtifactCreateCache.raritySlots = Arrays.asList(null, -1, 54); + assertFalse(loader.loadSafe(write(""))); + ArtifactCreateCache.raritySlots = List.of(10); + GuiCache.castModeLeftSlot = -1; + assertFalse(loader.loadSafe(write(""))); + } + + @Test + void shrineFxParserAndBlockTagsHandleMalformedAndEmptyOptionalValues() throws Exception { + Cache.debug = false; + assertTrue( + ShrineConfigLoader.load( + write("fx:\n ' ': {}\n scalar: invalid\n fire: {}\nelements: {}\n"))); + var logger = Magic.plugin.getLogger(); + assertNotNull( + invoke( + ShrineConfigLoader.class, + "readFx", + new Class[] { + ConfigurationSection.class, ShrineFxDef.class, java.util.logging.Logger.class + }, + null, + null, + logger)); + var materials = new LinkedHashSet(); + var types = + new Class[] { + Set.class, String.class, String.class, String.class, java.util.logging.Logger.class + }; + invoke(ShrineConfigLoader.class, "addBlock", types, materials, null, "fire", "family", logger); + invoke(ShrineConfigLoader.class, "addTag", types, materials, null, "fire", "family", logger); + var tag = mock(org.bukkit.Tag.class); + when(tag.getValues()) + .thenReturn(new LinkedHashSet<>(Arrays.asList(null, Material.STICK, Material.STONE))); + try (var bukkit = mockStatic(Bukkit.class, CALLS_REAL_METHODS)) { + bukkit + .when( + () -> + Bukkit.getTag( + eq(org.bukkit.Tag.REGISTRY_BLOCKS), + eq(NamespacedKey.minecraft("test")), + eq(Material.class))) + .thenReturn(tag); + invoke( + ShrineConfigLoader.class, "addTag", types, materials, "test", "fire", "family", logger); + } + assertEquals(Set.of(Material.STONE), materials); + } + + @Test + void bundledMessageReadAndCloseFailuresPreserveFallbackBehavior() throws Exception { + var input = + new InputStream() { + public int read() throws IOException { + throw new IOException("read failed"); + } + + public void close() throws IOException { + throw new IOException("close failed"); + } + }; + when(Magic.plugin.getResource("messages.yml")).thenReturn(input); + Messages.loadFromResources(); + assertEquals("", invoke(Messages.class, "format", new Class[] {String.class}, (Object) null)); + } + + @Test + void bundledStreamsHandleReadFailureWithSuccessfulCloseAndSuccessfulReadWithCloseFailure() + throws Exception { + var readFailure = + new InputStream() { + public int read() throws IOException { + throw new IOException("read failed"); + } + }; + when(Magic.plugin.getResource("messages.yml")).thenReturn(readFailure); + assertNull(invoke(Messages.class, "readBundled", new Class[] {})); + var closeFailure = + new ByteArrayInputStream( + "hello: world\n".getBytes(java.nio.charset.StandardCharsets.UTF_8)) { + public void close() throws IOException { + throw new IOException("close failed"); + } + }; + when(Magic.plugin.getResource("messages.yml")).thenReturn(closeFailure); + assertNull(invoke(Messages.class, "readBundled", new Class[] {})); + } + + @Test + void missingMessageConfigurationReturnsKeyWithoutThrowing() throws Exception { + for (String name : List.of("config", "bundled")) { + var field = Messages.class.getDeclaredField(name); + field.setAccessible(true); + field.set(null, null); + } + assertEquals("missing", Messages.getRaw("missing")); + } +} diff --git a/src/test/java/net/tfminecraft/magic/LifecycleTest.java b/src/test/java/net/tfminecraft/magic/LifecycleTest.java new file mode 100644 index 0000000..f42a2cd --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/LifecycleTest.java @@ -0,0 +1,55 @@ +package net.tfminecraft.magic; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import net.tfminecraft.magic.manager.MagicTickService; +import net.tfminecraft.magic.session.ResonanceSession; +import net.tfminecraft.tlibs.TLibs; +import net.tfminecraft.tlibs.objects.api.ItemAPI; +import org.junit.jupiter.api.Test; +import org.mockbukkit.mockbukkit.MockBukkit; + +class LifecycleTest { + @Test + void pluginLoadsReloadsAndStopsAllTimers() throws Exception { + var server = MockBukkit.mock(); + try (var libs = mockStatic(TLibs.class)) { + ItemAPI api = mock(ItemAPI.class, RETURNS_DEEP_STUBS); + libs.when(TLibs::getItemAPI).thenReturn(api); + String descriptor = + "name: Magic\n" + + "main: net.tfminecraft.magic.Magic\n" + + "version: test\n" + + "api-version: '1.21'\n" + + "commands:\n" + + " magic: {}\n" + + " resonance: {}\n"; + Magic plugin = + MockBukkit.loadWith( + Magic.class, new ByteArrayInputStream(descriptor.getBytes(StandardCharsets.UTF_8))); + assertSame(plugin, Magic.plugin); + assertNotNull(plugin.getProfileService()); + assertNotNull(plugin.getResonanceGuiManager()); + assertNotNull(plugin.getArtifactCreateGuiManager()); + assertNotNull(Magic.getRevisionTracker()); + assertTrue(MagicTickService.isRunning()); + assertTrue(Files.exists(plugin.getDataFolder().toPath().resolve("artifacts/generator.yml"))); + assertTrue(plugin.reloadAll()); + plugin.syncSpellModifiers(null, new ResonanceSession()); + plugin.clearSpellModifiers(null); + server.getScheduler().performTicks(40); + Files.writeString(plugin.getDataFolder().toPath().resolve("config.yml"), "bad: ["); + assertFalse(plugin.reloadAll()); + server.getPluginManager().disablePlugin(plugin); + assertFalse(MagicTickService.isRunning()); + verify(api).unregisterPathHandler("magic"); + } finally { + MockBukkit.unmock(); + Magic.plugin = null; + } + } +} diff --git a/src/test/java/net/tfminecraft/magic/ListenerTest.java b/src/test/java/net/tfminecraft/magic/ListenerTest.java new file mode 100644 index 0000000..20f4fc9 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/ListenerTest.java @@ -0,0 +1,183 @@ +package net.tfminecraft.magic; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.*; +import net.tfminecraft.magic.gear.*; +import net.tfminecraft.magic.listener.*; +import net.tfminecraft.magic.manager.ResonanceGuiManager; +import net.tfminecraft.magic.profile.MagicProfileService; +import net.tfminecraft.magic.session.ResonanceSessionManager; +import net.tfminecraft.rpcharacters.lifecycle.CharacterActivatedEvent; +import net.tfminecraft.rpcharacters.objects.RPCharacter; +import org.bukkit.Material; +import org.bukkit.entity.*; +import org.bukkit.event.entity.EntityPickupItemEvent; +import org.bukkit.event.inventory.*; +import org.bukkit.event.player.*; +import org.bukkit.inventory.*; +import org.junit.jupiter.api.*; +import org.mockbukkit.mockbukkit.*; + +class ListenerTest { + ServerMock server; + + @BeforeEach + void setup() { + server = MockBukkit.mock(); + Magic.plugin = mock(Magic.class, RETURNS_DEEP_STUBS); + when(Magic.plugin.getServer()).thenReturn(server); + when(Magic.plugin.isEnabled()).thenReturn(true); + GearCache.alignmentEnabled = true; + } + + @AfterEach + void cleanup() { + Magic.plugin = null; + MockBukkit.unmock(); + GearCache.alignmentEnabled = false; + } + + @Test + void heldAndDropRefreshWaitForSettledInventoryAndIgnoreOfflineOrRemovedItems() { + var listener = new GearRefreshListener(); + var player = server.addPlayer(); + var input = new ItemStack(Material.STICK); + var rebuilt = new ItemStack(Material.BLAZE_ROD); + player.getInventory().setItem(2, input); + try (var refresh = mockStatic(GearRefresher.class)) { + refresh.when(() -> GearRefresher.refreshIfOutdated(input, player)).thenReturn(rebuilt); + listener.onItemHeld(new PlayerItemHeldEvent(player, 0, 2)); + assertEquals(input, player.getInventory().getItem(2)); + server.getScheduler().performOneTick(); + assertEquals(rebuilt, player.getInventory().getItem(2)); + listener.onItemHeld(new PlayerItemHeldEvent(player, 2, 3)); + server.getScheduler().performOneTick(); + assertNull(player.getInventory().getItem(3)); + Item item = mock(Item.class); + when(item.isValid()).thenReturn(true); + when(item.getItemStack()).thenReturn(input); + listener.onDrop(new PlayerDropItemEvent(player, item)); + server.getScheduler().performOneTick(); + verify(item).setItemStack(rebuilt); + refresh.when(() -> GearRefresher.refreshIfOutdated(input, player)).thenReturn(null); + listener.onDrop(new PlayerDropItemEvent(player, item)); + server.getScheduler().performOneTick(); + when(item.isValid()).thenReturn(false); + listener.onDrop(new PlayerDropItemEvent(player, item)); + server.getScheduler().performOneTick(); + verify(item, times(1)).setItemStack(any()); + listener.onItemHeld(new PlayerItemHeldEvent(player, 2, 3)); + player.disconnect(); + server.getScheduler().performOneTick(); + Magic.plugin = null; + listener.onItemHeld(new PlayerItemHeldEvent(player, 2, 3)); + } + } + + @Test + void clickRefreshKeepsOriginalInventoryWhenPlayerClosesView() { + var listener = new GearRefreshListener(); + var player = server.addPlayer(); + Inventory original = server.createInventory(null, 9); + var old = new ItemStack(Material.STICK); + var rebuilt = new ItemStack(Material.BLAZE_ROD); + original.setItem(1, old); + var click = mock(InventoryClickEvent.class); + when(click.getWhoClicked()).thenReturn(player); + when(click.getClickedInventory()).thenReturn(original); + when(click.getSlot()).thenReturn(1); + try (var refresh = mockStatic(GearRefresher.class)) { + refresh.when(() -> GearRefresher.refreshIfOutdated(old, player)).thenReturn(rebuilt); + listener.onClick(click); + player.closeInventory(); + server.getScheduler().performOneTick(); + assertEquals(rebuilt, original.getItem(1)); + listener.onClick(click); + server.getScheduler().performOneTick(); + player.getOpenInventory().setCursor(old); + listener.onClick(click); + server.getScheduler().performOneTick(); + assertEquals(rebuilt, player.getOpenInventory().getCursor()); + when(click.getClickedInventory()).thenReturn(null); + listener.onClick(click); + server.getScheduler().performOneTick(); + when(click.getClickedInventory()).thenReturn(original); + for (int invalid : new int[] {-1, 9}) { + when(click.getSlot()).thenReturn(invalid); + listener.onClick(click); + server.getScheduler().performOneTick(); + } + when(click.getWhoClicked()).thenReturn(mock(HumanEntity.class)); + listener.onClick(click); + when(click.getWhoClicked()).thenReturn(player); + listener.onClick(click); + player.disconnect(); + server.getScheduler().performOneTick(); + } + } + + @Test + void alignmentEventsDeferResyncAndRespectDisabledOfflineAndNonPlayerCases() { + var listener = new GearAlignmentListener(); + var player = server.addPlayer(); + var sessions = new ResonanceSessionManager(); + var session = sessions.getOrCreate(player); + when(Magic.plugin.getResonanceGuiManager().getSessionManager()).thenReturn(sessions); + listener.onItemHeld(new PlayerItemHeldEvent(player, 0, 1)); + listener.onSwapHands(new PlayerSwapHandItemsEvent(player, null, null)); + listener.onDrop(new PlayerDropItemEvent(player, mock(Item.class))); + var click = mock(InventoryClickEvent.class); + when(click.getWhoClicked()).thenReturn(player); + listener.onInventoryClick(click); + var drag = mock(InventoryDragEvent.class); + when(drag.getWhoClicked()).thenReturn(player); + listener.onInventoryDrag(drag); + listener.onPickup(new EntityPickupItemEvent(player, mock(Item.class), 0)); + verify(Magic.plugin, never()).syncSpellModifiers(any(), any()); + server.getScheduler().performOneTick(); + verify(Magic.plugin, times(6)).syncSpellModifiers(player, session); + when(click.getWhoClicked()).thenReturn(mock(HumanEntity.class)); + listener.onInventoryClick(click); + when(drag.getWhoClicked()).thenReturn(mock(HumanEntity.class)); + listener.onInventoryDrag(drag); + listener.onPickup(new EntityPickupItemEvent(mock(LivingEntity.class), mock(Item.class), 0)); + GearCache.alignmentEnabled = false; + listener.onItemHeld(new PlayerItemHeldEvent(player, 0, 1)); + GearCache.alignmentEnabled = true; + listener.onItemHeld(new PlayerItemHeldEvent(player, 0, 1)); + player.disconnect(); + server.getScheduler().performOneTick(); + verify(Magic.plugin, times(6)).syncSpellModifiers(player, session); + var event = mock(PlayerItemHeldEvent.class); + listener.onItemHeld(event); + Magic.plugin = null; + listener.onItemHeld(new PlayerItemHeldEvent(player, 0, 1)); + } + + @Test + void roleplayCharacterEventsSavePreviousThenActivateAndCloseOldGui() { + var player = server.addPlayer(); + var profile = mock(MagicProfileService.class); + var gui = mock(ResonanceGuiManager.class, RETURNS_DEEP_STUBS); + var listener = new MagicSessionListener(profile, gui); + var event = mock(CharacterActivatedEvent.class); + listener.onCharacterActivated(event); + when(event.getOwner()).thenReturn(player); + listener.onCharacterActivated(event); + var character = mock(RPCharacter.class); + when(character.getId()).thenReturn("next"); + when(event.getCharacter()).thenReturn(character); + listener.onCharacterActivated(event); + verify(profile).activate(player, character); + verify(gui).closeIfCharacterChanged(player, "next"); + var previous = mock(RPCharacter.class); + when(event.getPrevious()).thenReturn(previous); + listener.onCharacterActivated(event); + verify(profile).savePrevious(player, previous); + listener.onPlayerQuit(new PlayerQuitEvent(player, "quit")); + verify(profile).deactivate(player); + verify(Magic.plugin).clearSpellModifiers(player); + } +} diff --git a/src/test/java/net/tfminecraft/magic/MagicLifecycleCoverageTest.java b/src/test/java/net/tfminecraft/magic/MagicLifecycleCoverageTest.java new file mode 100644 index 0000000..b35a46b --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/MagicLifecycleCoverageTest.java @@ -0,0 +1,136 @@ +package net.tfminecraft.magic; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import net.tfminecraft.magic.integration.*; +import net.tfminecraft.magic.loader.ConfigLoader; +import net.tfminecraft.magic.manager.MagicTickService; +import net.tfminecraft.magic.session.ResonanceSession; +import net.tfminecraft.tlibs.TLibs; +import net.tfminecraft.tlibs.itemscan.ItemScanService; +import net.tfminecraft.tlibs.objects.api.ItemAPI; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.io.TempDir; +import org.mockbukkit.mockbukkit.MockBukkit; + +class MagicLifecycleCoverageTest { + @TempDir Path temp; + + @Test + void optionalIntegrationsRunWithConfigErrorsAndAbsentCommandEntries() throws Exception { + var server = MockBukkit.mock(); + boolean oldDebug = Cache.debug; + try (var libs = mockStatic(TLibs.class); + var bridge = mockStatic(RpCharactersBridge.class); + var modifiers = mockStatic(SpellModifierApplyService.class); + var scans = mockStatic(ItemScanService.class); + var configs = + mockConstruction( + ConfigLoader.class, + (mock, context) -> when(mock.loadSafe(any())).thenReturn(false))) { + var api = mock(ItemAPI.class, RETURNS_DEEP_STUBS); + libs.when(TLibs::getItemAPI).thenReturn(api); + var scan = mock(ItemScanService.class); + scans.when(ItemScanService::get).thenReturn(scan); + bridge.when(RpCharactersBridge::isAvailable).thenReturn(true); + MockBukkit.createMockPlugin("MythicLib"); + MockBukkit.createMockPlugin("MMOCore"); + Cache.debug = true; + String descriptor = + "name: Magic\nmain: net.tfminecraft.magic.Magic\nversion: test\napi-version: '1.21'\n"; + var plugin = + MockBukkit.loadWith( + Magic.class, new ByteArrayInputStream(descriptor.getBytes(StandardCharsets.UTF_8))); + verify(scan).subscribe(any()); + var session = new ResonanceSession(); + plugin.syncSpellModifiers(null, session); + modifiers.verify(() -> SpellModifierApplyService.sync(null, session)); + plugin.clearSpellModifiers(null); + modifiers.verify(() -> SpellModifierApplyService.clear(null)); + server.getScheduler().performTicks(40); + modifiers.verify(() -> SpellModifierApplyService.syncChangedOnline(any()), atLeastOnce()); + MagicTickService.stop(); + assertFalse(plugin.reloadAll()); + server.getPluginManager().disablePlugin(plugin); + verify(scan).unsubscribe(any()); + for (String field : java.util.List.of("profileService", "meditationService")) { + var f = Magic.class.getDeclaredField(field); + f.setAccessible(true); + f.set(plugin, null); + } + plugin.onDisable(); + } finally { + Cache.debug = oldDebug; + MockBukkit.unmock(); + Magic.plugin = null; + } + } + + @Test + void resourcesPreserveExistingFilesAndLogReadFailures() throws Exception { + MockBukkit.mock(); + try (var libs = mockStatic(TLibs.class); + var bridge = mockStatic(RpCharactersBridge.class)) { + bridge.when(RpCharactersBridge::isAvailable).thenReturn(true); + var api = mock(ItemAPI.class, RETURNS_DEEP_STUBS); + libs.when(TLibs::getItemAPI).thenReturn(api); + String descriptor = + "name: Magic\nmain: net.tfminecraft.magic.Magic\nversion: test\napi-version: '1.21'\n"; + var loaded = + MockBukkit.loadWith( + Magic.class, new ByteArrayInputStream(descriptor.getBytes(StandardCharsets.UTF_8))); + var plugin = spy(loaded); + doReturn(temp.resolve("plugin").toFile()).when(plugin).getDataFolder(); + invoke(plugin, "createFolders", new Class[] {}); + invoke(plugin, "createFolders", new Class[] {}); + invoke(plugin, "copyResourceIfMissing", new Class[] {String.class}, "config.yml"); + invoke(plugin, "copyResourceIfMissing", new Class[] {String.class}, "config.yml"); + invoke(plugin, "copyResourceIfMissing", new Class[] {String.class}, "missing-resource.yml"); + InputStream broken = + new InputStream() { + @Override + public int read() throws IOException { + throw new IOException("unreadable"); + } + }; + doReturn(broken).when(plugin).getResource("broken.yml"); + invoke(plugin, "copyResourceIfMissing", new Class[] {String.class}, "broken.yml"); + assertFalse( + Files.exists(temp.resolve("plugin/broken.yml")), "failed copies must remain retryable"); + assertTrue(Files.exists(temp.resolve("plugin/config.yml"))); + InputStream racing = + new ByteArrayInputStream("default".getBytes(StandardCharsets.UTF_8)) { + @Override + public long transferTo(OutputStream output) throws IOException { + Files.writeString(temp.resolve("plugin/raced.yml"), "user config"); + return super.transferTo(output); + } + }; + doReturn(racing).when(plugin).getResource("raced.yml"); + invoke(plugin, "copyResourceIfMissing", new Class[] {String.class}, "raced.yml"); + assertEquals("user config", Files.readString(temp.resolve("plugin/raced.yml"))); + try (var files = Files.list(temp.resolve("plugin"))) { + assertFalse( + files.anyMatch(path -> path.getFileName().toString().startsWith(".magic-default-"))); + } + MockBukkit.createMockPlugin("MythicLib"); + assertFalse( + (boolean) GearCoverageSupport.invoke(Magic.class, "isMmoStackPresent", new Class[] {})); + loaded.getServer().getPluginManager().disablePlugin(loaded); + } finally { + MockBukkit.unmock(); + Magic.plugin = null; + } + } + + static Object invoke(Magic plugin, String name, Class[] types, Object... args) + throws Exception { + var method = Magic.class.getDeclaredMethod(name, types); + method.setAccessible(true); + return method.invoke(plugin, args); + } +} diff --git a/src/test/java/net/tfminecraft/magic/MeditationCircleTest.java b/src/test/java/net/tfminecraft/magic/MeditationCircleTest.java new file mode 100644 index 0000000..10bf1ef --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/MeditationCircleTest.java @@ -0,0 +1,343 @@ +package net.tfminecraft.magic; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.*; +import net.tfminecraft.interactiblefurniture.InteractibleFurniture; +import net.tfminecraft.interactiblefurniture.furniture.*; +import net.tfminecraft.magic.artifact.*; +import net.tfminecraft.magic.charge.*; +import net.tfminecraft.magic.meditation.*; +import net.tfminecraft.magic.registry.ElementRegistry; +import net.tfminecraft.magic.session.ResonanceSession; +import org.bukkit.*; +import org.bukkit.inventory.ItemStack; +import org.junit.jupiter.api.*; +import org.mockbukkit.mockbukkit.*; +import org.mockito.MockedStatic; + +class MeditationCircleTest { + ServerMock server; + World world; + Location center; + List furniture = new ArrayList<>(); + List slots = new ArrayList<>(); + MockedStatic bridge; + InteractibleFurniture dependency; + + @BeforeEach + void setup() { + server = MockBukkit.mock(); + world = server.addSimpleWorld("circle"); + center = new Location(world, .5, 70, .5); + Magic.plugin = mock(Magic.class); + when(Magic.plugin.namespace()).thenReturn("magic"); + MockBukkit.createMockPlugin("InteractibleFurniture"); + dependency = mock(InteractibleFurniture.class, RETURNS_DEEP_STUBS); + bridge = mockStatic(InteractibleFurniture.class); + bridge.when(InteractibleFurniture::getInstance).thenReturn(dependency); + when(dependency.getFurnitureManager().getFurnitureInChunk(any())) + .thenAnswer( + i -> { + Chunk chunk = i.getArgument(0); + return furniture.stream() + .filter( + f -> { + var l = f.getLoc(); + return l != null + && l.getWorld() != null + && (l.getBlockX() >> 4) == chunk.getX() + && (l.getBlockZ() >> 4) == chunk.getZ(); + }) + .collect(java.util.stream.Collectors.toSet()); + }); + MeditationCache.cardinalOffset = 4; + MeditationCache.diagonalOffset = 3; + MeditationCache.pedestalId = "pedestal"; + MeditationCache.pedestalSlot = "*"; + ElementRegistry.clear(); + ElementRegistry.register(DomainTest.element("fire")); + ChargeRegistry.clear(); + Cache.defaultEquilibrium = 0; + } + + @AfterEach + void cleanup() { + bridge.close(); + Magic.plugin = null; + MockBukkit.unmock(); + ElementRegistry.clear(); + ChargeRegistry.clear(); + MeditationCache.pedestalSlot = "*"; + } + + ItemStack artifact() { + var item = new ItemStack(Material.STONE); + var data = Artifact.create(); + data.setCap("fire", 10); + data.setFill("fire", 5); + data.persistPdc(item); + ArtifactIds.writeNew(item); + return item; + } + + void putArtifact() { + var item = artifact(); + when(slots.getFirst().getCurrentItem()).thenReturn(item); + } + + void ring() { + int[][] positions = {{4, 0}, {-4, 0}, {0, 4}, {0, -4}, {3, 3}, {3, -3}, {-3, 3}, {-3, -3}}; + for (var pos : positions) { + var f = mock(Furniture.class); + var l = new Location(world, pos[0] + .5, 70, pos[1] + .5); + when(f.getLoc()).thenReturn(l); + when(f.getOriginBlockLocation()).thenReturn(Optional.of(l)); + when(f.getId()).thenReturn("pedestal"); + when(f.getEntityId()).thenReturn(UUID.randomUUID()); + var slot = mock(PlacedSlot.class); + when(slot.getId()).thenReturn("top"); + when(f.getActiveSlots()).thenReturn(Map.of("top", slot)); + when(f.getActiveSlot("top")).thenReturn(Optional.of(slot)); + furniture.add(f); + slots.add(slot); + } + } + + @Test + void lonePreviewUserGetsFullYieldInsteadOfBeingCountedTwice() { + ring(); + putArtifact(); + var circle = MeditationCircle.detect(center); + assertNotNull(circle); + var yield = circle.snapshotYield(1000, "new-character"); + String id = circle.artifactIdOn(furniture.getFirst()); + assertEquals(1, yield.users(id)); + assertEquals(5, yield.sessionCap(id)); + } + + @Test + void completeRingDetectsArtifactPowerAndSnapshotsExistingUsers() { + assertNull(MeditationCircle.detect(null)); + assertNull(MeditationCircle.detect(new Location(null, 0, 0, 0))); + assertNull(MeditationCircle.detect(center)); + ring(); + var item = artifact(); + when(slots.getFirst().getCurrentItem()).thenReturn(item); + var circle = MeditationCircle.detect(center); + assertNotNull(circle); + assertEquals(center, circle.getCenter()); + assertEquals(8, circle.getPedestals().size()); + assertEquals(1, circle.getArtifactPedestals().size()); + assertEquals(5, circle.getTotalPower()); + assertEquals(Map.of("fire", 5.), circle.getPowerByElement()); + assertEquals(5, circle.elementPower("fire")); + assertEquals(5, circle.elementPower("FIRE")); + assertEquals(0, circle.elementPower(null)); + assertEquals(0, circle.elementPower(" ")); + assertEquals(0, circle.elementPower("missing")); + assertEquals("fire", circle.artifactFor(furniture.getFirst().getEntityId()).elementId); + assertSame(item, circle.itemOn(furniture.getFirst())); + assertNull(circle.itemOn(null)); + assertNull(circle.itemOn(furniture.get(1))); + assertTrue(circle.stillIntact()); + assertTrue(MeditationCircle.containing(furniture.getFirst())); + var yield = circle.stampAndSnapshot("user", 1000); + String id = circle.artifactIdOn(furniture.getFirst()); + assertEquals(1, yield.users(id)); + assertEquals(5, yield.sessionCap(id)); + assertEquals(1, circle.snapshotYield(1000, "user").users(id)); + assertEquals(2, circle.snapshotYield(1000, "second").users(id)); + assertEquals(2.5, circle.snapshotYield(1000, "second").sessionCap(id)); + assertFalse(circle.isFullyAttuned(Map.of(), yield)); + assertTrue(circle.isFullyAttuned(Map.of(id, 5.), yield)); + assertTrue(circle.isFullyAttuned(Map.of(), null)); + var session = new ResonanceSession(); + assertFalse(circle.canGainResonance(null, null, yield)); + assertFalse(circle.canGainResonance(null, session, null)); + assertTrue(circle.canGainResonance(null, session, yield)); + session.setResonance("fire", 5); + assertFalse(circle.canGainResonance(null, session, yield)); + assertFalse(circle.canGainResonance(null, session, MeditationSitYield.empty())); + circle.stampArtifacts(null, Long.MAX_VALUE); + circle.stampArtifacts(" ", Long.MAX_VALUE); + assertTrue(ArtifactCareStore.readUsers(item).isEmpty()); + } + + @Test + void carriedWrongTypeOrMisplacedPostBreaksDetection() { + assertFalse(MeditationCircle.containing(null)); + ring(); + when(furniture.getFirst().isCarried()).thenReturn(true); + assertNull(MeditationCircle.detect(center)); + assertFalse(MeditationCircle.containing(furniture.getFirst())); + when(furniture.getFirst().isCarried()).thenReturn(false); + when(furniture.getFirst().getId()).thenReturn("other"); + assertNull(MeditationCircle.detect(center)); + when(furniture.getFirst().getId()).thenReturn("pedestal"); + var far = new Location(world, 4.5, 75, .5); + when(furniture.getFirst().getOriginBlockLocation()).thenReturn(Optional.of(far)); + assertNull(MeditationCircle.detect(center)); + when(furniture.getFirst().getOriginBlockLocation()).thenReturn(Optional.empty()); + assertNotNull(MeditationCircle.detect(center)); + when(furniture.getFirst().getLoc()).thenReturn(null); + assertFalse(MeditationCircle.containing(furniture.getFirst())); + } + + @Test + void specificSlotAndEmptyArtifactsDoNotContributePower() { + ring(); + MeditationCache.pedestalSlot = "missing"; + assertEquals(0, MeditationCircle.detect(center).getTotalPower()); + MeditationCache.pedestalSlot = "top"; + when(slots.getFirst().getCurrentItem()).thenReturn(new ItemStack(Material.AIR)); + assertEquals(0, MeditationCircle.detect(center).getTotalPower()); + when(slots.getFirst().getCurrentItem()).thenReturn(new ItemStack(Material.STONE)); + assertEquals(0, MeditationCircle.detect(center).getTotalPower()); + var empty = artifact(); + var a = Artifact.fromItem(empty); + a.setFill("fire", 0); + a.persistPdc(empty); + when(slots.getFirst().getCurrentItem()).thenReturn(empty); + assertEquals(0, MeditationCircle.detect(center).getTotalPower()); + putArtifact(); + assertEquals(5, MeditationCircle.detect(center).getTotalPower()); + } + + @Test + void removedItemsInvalidateYieldAndUnlockedElementsControlGains() { + ring(); + putArtifact(); + var circle = MeditationCircle.detect(center); + var yield = circle.snapshotYield(1000); + var session = new ResonanceSession(); + var cfg = new org.bukkit.configuration.file.YamlConfiguration(); + cfg.set("permission", "secret.fire"); + ElementRegistry.register(new net.tfminecraft.magic.model.ElementDef("fire", cfg)); + assertFalse(circle.canGainResonance(server.addPlayer(), session, yield)); + ElementRegistry.clear(); + assertTrue(circle.canGainResonance(null, session, yield)); + when(slots.getFirst().getCurrentItem()).thenReturn(null); + circle.stampArtifacts("user", 1000); + assertTrue(circle.snapshotYield(1000).exhausted(Map.of())); + assertFalse(circle.canGainResonance(null, session, yield)); + when(slots.getFirst().getCurrentItem()).thenReturn(new ItemStack(Material.AIR)); + circle.stampArtifacts("user", 1000); + when(slots.getFirst().getCurrentItem()).thenReturn(new ItemStack(Material.STONE)); + circle.stampArtifacts("user", 1000); + } + + @Test + void displayFallbackAndMultipleElementsContributeOnlyStoredArtifactFill() { + ring(); + var item = artifact(); + var aura = Artifact.fromItem(item); + aura.setCap("water", 10); + aura.setFill("water", 3); + aura.setCap("unknown", 10); + aura.setFill("unknown", 2); + aura.setCap("empty", 10); + aura.persistPdc(item); + ElementRegistry.register(DomainTest.element("water")); + ElementRegistry.register(DomainTest.element("empty")); + var display = new org.mockbukkit.mockbukkit.entity.ItemDisplayMock(server, UUID.randomUUID()); + display.teleport(center); + server.registerEntity(display); + display.setItemStack(item); + when(slots.getFirst().getDisplayStandId()).thenReturn(display.getUniqueId()); + var circle = MeditationCircle.detect(center); + assertEquals(8, circle.getTotalPower()); + verify(slots.getFirst(), atLeastOnce()).setModel(item); + MeditationCache.pedestalSlot = "top"; + circle.stampArtifacts("user", 1000); + MeditationCache.pedestalSlot = ""; + assertEquals(8, MeditationCircle.detect(center).getTotalPower()); + MeditationCache.pedestalSlot = null; + assertEquals(8, MeditationCircle.detect(center).getTotalPower()); + display.setItemStack(new ItemStack(Material.AIR)); + assertEquals(0, MeditationCircle.detect(center).getTotalPower()); + var charge = artifact(); + var meta = charge.getItemMeta(); + meta.getPersistentDataContainer() + .set(ChargeKeys.chargeTier(), org.bukkit.persistence.PersistentDataType.INTEGER, 1); + charge.setItemMeta(meta); + when(slots.getFirst().getCurrentItem()).thenReturn(charge); + assertEquals(0, MeditationCircle.detect(center).getTotalPower()); + } + + @Test + void nonmemberPedestalsAndUnavailableDependencyCannotFormCircles() { + ring(); + var lone = mock(Furniture.class); + var far = new Location(world, 100, 70, 100); + when(lone.getLoc()).thenReturn(far); + when(lone.getOriginBlockLocation()).thenReturn(Optional.of(far)); + when(lone.getEntityId()).thenReturn(UUID.randomUUID()); + assertFalse(MeditationCircle.containing(lone)); + when(lone.getEntityId()).thenReturn(null); + assertFalse(MeditationCircle.containing(lone)); + when(lone.getLoc()).thenReturn(new Location(null, 0, 0, 0)); + when(lone.getOriginBlockLocation()).thenReturn(Optional.empty()); + assertFalse(MeditationCircle.containing(lone)); + var plugin = server.getPluginManager().getPlugin("InteractibleFurniture"); + server.getPluginManager().disablePlugin(plugin); + assertNull(MeditationCircle.detect(center)); + } + + @Test + void snapshotsTolerateItemChangesAndPublicPowerMapLegacyValues() { + ring(); + putArtifact(); + var circle = MeditationCircle.detect(center); + var charge = artifact(); + var meta = charge.getItemMeta(); + meta.getPersistentDataContainer() + .set(ChargeKeys.chargeTier(), org.bukkit.persistence.PersistentDataType.INTEGER, 1); + charge.setItemMeta(meta); + when(slots.getFirst().getCurrentItem()).thenReturn(charge); + assertFalse(circle.snapshotYield(1, " ").hasAnyCap()); + circle.getPowerByElement().put(null, 3d); + circle.getPowerByElement().put("Fire", null); + circle.getPowerByElement().remove("fire"); + assertEquals(0, circle.elementPower("FIRE")); + var alien = mock(Furniture.class); + var at = furniture.getFirst().getLoc(); + when(alien.getLoc()).thenReturn(at); + when(alien.getOriginBlockLocation()).thenReturn(Optional.of(at)); + when(alien.getEntityId()).thenReturn(UUID.randomUUID()); + assertFalse(MeditationCircle.containing(alien)); + when(furniture.getFirst().getOriginBlockLocation()) + .thenReturn(Optional.of(new Location(null, 0, 0, 0))); + assertFalse(circle.stillIntact()); + } + + @Test + void expiredPreviewUsersAndMutableIntegrationSlotsAreHandledSafely() { + ring(); + var item = artifact(); + when(slots.getFirst().getCurrentItem()).thenReturn(item); + var circle = MeditationCircle.detect(center); + ArtifactCareStore.stampUser(item, "expired", 1); + assertEquals( + 1, + circle + .snapshotYield(Long.MAX_VALUE, "expired") + .users(circle.artifactIdOn(furniture.getFirst()))); + var active = new HashMap(); + active.put("top", slots.getFirst()); + active.put("removed", null); + when(furniture.getFirst().getActiveSlots()).thenReturn(active); + for (String filter : new String[] {null, ""}) { + MeditationCache.pedestalSlot = filter; + circle.stampArtifacts("user", 1000); + } + var manager = dependency.getFurnitureManager(); + doReturn(new LinkedHashSet<>(furniture)).when(manager).getFurnitureInChunk(any()); + when(furniture.getFirst().getOriginBlockLocation()).thenReturn(Optional.empty()); + when(furniture.getFirst().getLoc()).thenReturn(null); + assertNull(MeditationCircle.detect(center)); + server.getPluginManager().clearPlugins(); + assertNull(MeditationCircle.detect(center)); + } +} diff --git a/src/test/java/net/tfminecraft/magic/MeditationServiceTest.java b/src/test/java/net/tfminecraft/magic/MeditationServiceTest.java new file mode 100644 index 0000000..1af23eb --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/MeditationServiceTest.java @@ -0,0 +1,346 @@ +package net.tfminecraft.magic; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.*; +import net.tfminecraft.interactiblefurniture.furniture.Furniture; +import net.tfminecraft.magic.meditation.*; +import net.tfminecraft.magic.profile.MagicProfileService; +import net.tfminecraft.magic.session.ResonanceSessionManager; +import net.tfminecraft.rpcharacters.RPCharacters; +import net.tfminecraft.rpcharacters.focus.FocusService; +import org.bukkit.*; +import org.bukkit.event.block.Action; +import org.bukkit.event.player.*; +import org.bukkit.util.RayTraceResult; +import org.junit.jupiter.api.*; +import org.mockbukkit.mockbukkit.*; +import org.mockbukkit.mockbukkit.entity.PlayerMock; +import org.mockbukkit.mockbukkit.world.WorldMock; +import org.mockito.*; + +class MeditationServiceTest { + ServerMock server; + PlayerMock player; + WorldMock world; + MeditationService service; + MeditationCircle circle; + MagicProfileService profiles; + ResonanceSessionManager sessions; + MockedStatic detection; + MockedStatic messages; + MockedConstruction created; + + @BeforeEach + void setup() { + server = MockBukkit.mock(); + world = spy(server.addSimpleWorld("meditate")); + player = server.addPlayer(); + player.teleport(new Location(world, 0, 64, 0)); + Magic.plugin = mock(Magic.class); + when(Magic.plugin.getServer()).thenReturn(server); + when(Magic.plugin.isEnabled()).thenReturn(true); + profiles = mock(MagicProfileService.class); + sessions = new ResonanceSessionManager(); + service = new MeditationService(sessions, profiles); + circle = mock(MeditationCircle.class); + when(circle.getCenter()).thenReturn(player.getLocation()); + when(circle.getTotalPower()).thenReturn(10d); + when(circle.stillIntact()).thenReturn(true); + var yield = new MeditationSitYield(Map.of("a", 10d), null); + when(circle.snapshotYield(anyLong(), any())).thenReturn(yield); + when(circle.stampAndSnapshot(any(), anyLong())).thenReturn(yield); + when(circle.canGainResonance(any(), any(), any())).thenReturn(true); + detection = mockStatic(MeditationCircle.class); + detection.when(() -> MeditationCircle.detect(any())).thenReturn(circle); + messages = mockStatic(Messages.class, i -> i.getArgument(0)); + created = + mockConstruction(MeditationSession.class, (m, c) -> when(m.getCircle()).thenReturn(circle)); + } + + @AfterEach + void cleanup() { + service.stop(); + created.close(); + messages.close(); + detection.close(); + Magic.plugin = null; + MockBukkit.unmock(); + } + + void sit() { + var chair = new org.mockbukkit.mockbukkit.entity.ArmorStandMock(server, UUID.randomUUID()); + chair.teleport(player.getLocation()); + assertTrue(chair.addPassenger(player)); + } + + void tick() { + server.getScheduler().performOneTick(); + } + + MeditationSession start() { + sit(); + service.start(); + tick(); + assertEquals(1, created.constructed().size()); + return created.constructed().getFirst(); + } + + @Test + void sittingStartsTicksLocksAndLeavingPersistsAfterWindDown() { + assertFalse(MeditationService.locksFurniture(null)); + var session = start(); + var post = mock(Furniture.class); + when(post.getEntityId()).thenReturn(UUID.randomUUID()); + when(circle.getPedestals()).thenReturn(List.of(post)); + assertTrue(MeditationService.locksFurniture(post)); + assertFalse(MeditationService.locksFurniture(mock(Furniture.class))); + var other = mock(Furniture.class); + when(other.getEntityId()).thenReturn(UUID.randomUUID()); + assertFalse(MeditationService.locksFurniture(other)); + tick(); + verify(session).tick(player, sessions.get(player)); + player.leaveVehicle(); + tick(); + verify(session).beginWindDown(); + verify(session).end(); + verify(profiles).savePlayer(player); + assertEquals("meditation.stop", player.nextMessage()); + assertFalse(MeditationService.locksFurniture(post)); + } + + @Test + void noticesAreDeduplicatedUntilPlayerLeavesAndInvalidCircleCannotStart() { + service.start(); + tick(); + assertTrue(created.constructed().isEmpty()); + sit(); + detection.when(() -> MeditationCircle.detect(any())).thenReturn(null); + tick(); + assertTrue(created.constructed().isEmpty()); + detection.when(() -> MeditationCircle.detect(any())).thenReturn(circle); + when(circle.getCenter()).thenReturn(new Location(world, 1, 64, 0)); + tick(); + assertTrue(created.constructed().isEmpty()); + when(circle.getCenter()).thenReturn(new Location(world, 0, 65, 0)); + tick(); + when(circle.getCenter()).thenReturn(new Location(world, 0, 64, 1)); + tick(); + when(circle.getCenter()).thenReturn(player.getLocation()); + when(circle.getTotalPower()).thenReturn(0d); + tick(); + tick(); + assertEquals("meditation.no_artifacts", player.nextMessage()); + assertNull(player.nextMessage()); + player.leaveVehicle(); + tick(); + sit(); + tick(); + assertEquals("meditation.no_artifacts", player.nextMessage()); + player.leaveVehicle(); + tick(); + sit(); + when(circle.getTotalPower()).thenReturn(10d); + when(circle.snapshotYield(anyLong(), any())).thenReturn(MeditationSitYield.empty()); + tick(); + assertEquals("meditation.nothing", player.nextMessage()); + player.leaveVehicle(); + tick(); + sit(); + when(circle.snapshotYield(anyLong(), any())) + .thenReturn(new MeditationSitYield(Map.of("a", 1d), null)); + when(circle.canGainResonance(any(), any(), any())).thenReturn(false); + tick(); + assertEquals("meditation.nothing", player.nextMessage()); + player.disconnect(); + tick(); + } + + @Test + void disconnectedAndWindingSessionsFinishOnlyAfterLastOrb() { + var session = start(); + when(session.isWindingDown()).thenReturn(true); + when(session.hasActiveOrbs()).thenReturn(true); + tick(); + verify(session, never()).end(); + when(session.hasActiveOrbs()).thenReturn(false); + player.disconnect(); + tick(); + verify(session).end(); + verify(profiles, never()).savePlayer(any()); + } + + @Test + void brokenCircleBeginsWindDownAndStopEndsActiveSessions() { + var session = start(); + when(circle.stillIntact()).thenReturn(false); + when(session.hasActiveOrbs()).thenReturn(true); + tick(); + verify(session).beginWindDown(); + service.stop(); + verify(session).end(); + service.start(); + service.stop(); + } + + @Test + void swingsRespectGatesFocusAndCompletion() { + var session = start(); + var orb = mock(MeditationOrb.class); + when(orb.getLocation(anyInt())).thenAnswer(i -> player.getEyeLocation().add(0, 0, 2)); + when(session.livingOrbs()).thenReturn(List.of(orb)); + when(session.claimSwing(anyLong())).thenReturn(true); + when(session.canHit(anyLong())).thenReturn(true); + doReturn(null).when(world).rayTraceBlocks(any(), any(), anyDouble(), any(), anyBoolean()); + try (var rp = mockStatic(RPCharacters.class)) { + when(session.claimTiredNotice()).thenReturn(true); + service.onSwing(new PlayerAnimationEvent(player)); + assertEquals("meditation.tired", player.nextMessage()); + when(session.claimTiredNotice()).thenReturn(false); + service.onSwing(new PlayerAnimationEvent(player)); + assertNull(player.nextMessage()); + var focus = mock(FocusService.class); + rp.when(RPCharacters::getFocusService).thenReturn(focus); + service.onSwing(new PlayerAnimationEvent(player)); + verify(focus).trySpend(player, MeditationCache.mentalCostPerHit); + when(focus.trySpend(any(), anyInt())).thenReturn(true); + service.onInteract(new PlayerInteractEvent(player, Action.LEFT_CLICK_AIR, null, null, null)); + verify(session).markHit(anyLong()); + when(session.onOrbHit(any(), any(), any(), any())).thenReturn(true); + service.onInteract( + new PlayerInteractEvent(player, Action.LEFT_CLICK_BLOCK, null, null, null)); + verify(session).playComplete(player); + verify(session).beginWindDown(); + assertEquals("meditation.complete", player.nextMessage()); + service.onInteract(new PlayerInteractEvent(player, Action.RIGHT_CLICK_AIR, null, null, null)); + service.onSwing(new PlayerAnimationEvent(player, PlayerAnimationType.OFF_ARM_SWING)); + when(session.isWindingDown()).thenReturn(true); + service.onSwing(new PlayerAnimationEvent(player)); + when(session.isWindingDown()).thenReturn(false); + when(session.claimSwing(anyLong())).thenReturn(false); + service.onSwing(new PlayerAnimationEvent(player)); + when(session.claimSwing(anyLong())).thenReturn(true); + when(session.canHit(anyLong())).thenReturn(false); + service.onSwing(new PlayerAnimationEvent(player)); + } + } + + @Test + void hitscanRejectsMissingFarBehindOffAxisAndOccludedOrbs() { + var session = start(); + when(session.claimSwing(anyLong())).thenReturn(true); + when(session.canHit(anyLong())).thenReturn(true); + var orb = mock(MeditationOrb.class); + when(session.livingOrbs()).thenReturn(List.of(orb)); + doReturn(null).when(world).rayTraceBlocks(any(), any(), anyDouble(), any(), anyBoolean()); + for (Location location : + Arrays.asList( + null, + new Location(null, 0, 0, 0), + new Location(server.addSimpleWorld("other"), 0, 64, 2), + player.getEyeLocation().add(0, 0, -2), + player.getEyeLocation().add(0, 0, 100), + player.getEyeLocation().add(10, 0, 2))) { + when(orb.getLocation(anyInt())).thenReturn(location); + service.onSwing(new PlayerAnimationEvent(player)); + } + when(orb.getLocation(anyInt())).thenAnswer(i -> player.getEyeLocation().add(0, 0, 2)); + doReturn(new RayTraceResult(player.getEyeLocation().add(0, 0, 1).toVector())) + .when(world) + .rayTraceBlocks(any(), any(), anyDouble(), any(), anyBoolean()); + service.onSwing(new PlayerAnimationEvent(player)); + verify(session, never()).markHit(anyLong()); + } + + Object call(String name, Class[] types, Object... args) throws Exception { + var method = MeditationService.class.getDeclaredMethod(name, types); + method.setAccessible(true); + return method.invoke(service, args); + } + + @Test + void lifecycleHandlesDisconnectedSessionsAbsentProfilesAndMutablePedestalLists() + throws Exception { + call("tryStart", new Class[] {org.bukkit.entity.Player.class}, player); + assertTrue(created.constructed().isEmpty()); + var session = start(); + assertFalse(MeditationService.locksFurniture(null)); + var post = mock(Furniture.class); + when(post.getEntityId()).thenReturn(UUID.randomUUID()); + when(circle.getPedestals()).thenReturn(Arrays.asList(null, post)); + assertTrue(MeditationService.locksFurniture(post)); + player.disconnect(); + tick(); + verify(session).beginWindDown(); + verify(session).end(); + player = server.addPlayer(); + player.teleport(new Location(world, 0, 64, 0)); + when(circle.getCenter()).thenReturn(player.getLocation()); + service.stop(); + service = new MeditationService(sessions, null); + sit(); + service.start(); + tick(); + var next = created.constructed().getLast(); + player.leaveVehicle(); + tick(); + verify(next).end(); + call( + "finishSession", + new Class[] {UUID.class, org.bukkit.entity.Player.class}, + player.getUniqueId(), + player); + } + + @Test + void movingAwayWhileSeatedInvalidatesTheSession() { + var session = start(); + when(circle.getCenter()).thenReturn(player.getLocation().add(2, 0, 0)); + tick(); + verify(session).beginWindDown(); + } + + @Test + void sessionSnapshotToleratesServiceStoppingFromAnOrbCallback() { + var first = start(); + var other = server.addPlayer(); + other.teleport(player.getLocation()); + var chair = new org.mockbukkit.mockbukkit.entity.ArmorStandMock(server, UUID.randomUUID()); + chair.teleport(other.getLocation()); + assertTrue(chair.addPassenger(other)); + tick(); + assertEquals(2, created.constructed().size()); + var second = created.constructed().getLast(); + org.mockito.stubbing.Answer stop = + a -> { + player.leaveVehicle(); + other.leaveVehicle(); + service.stop(); + return null; + }; + doAnswer(stop).when(first).tick(any(), any()); + doAnswer(stop).when(second).tick(any(), any()); + tick(); + verify(first).end(); + verify(second).end(); + } + + @Test + void swingsWithoutSessionAndBlocksBehindTheOrbAreHandled() { + service.onSwing(new PlayerAnimationEvent(player)); + var session = start(); + when(session.claimSwing(anyLong())).thenReturn(true); + when(session.canHit(anyLong())).thenReturn(true); + var orb = mock(MeditationOrb.class); + when(orb.getLocation(anyInt())).thenAnswer(a -> player.getEyeLocation().add(0, 0, 2)); + when(session.livingOrbs()).thenReturn(List.of(orb)); + doReturn(new RayTraceResult(player.getEyeLocation().add(0, 0, 3).toVector())) + .when(world) + .rayTraceBlocks(any(), any(), anyDouble(), any(), anyBoolean()); + try (var rp = mockStatic(RPCharacters.class)) { + service.onSwing(new PlayerAnimationEvent(player)); + verify(session).claimTiredNotice(); + } + } +} diff --git a/src/test/java/net/tfminecraft/magic/MeditationSessionTest.java b/src/test/java/net/tfminecraft/magic/MeditationSessionTest.java new file mode 100644 index 0000000..766f3cb --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/MeditationSessionTest.java @@ -0,0 +1,484 @@ +package net.tfminecraft.magic; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.*; +import net.tfminecraft.interactiblefurniture.furniture.Furniture; +import net.tfminecraft.magic.attunement.AuraLog; +import net.tfminecraft.magic.meditation.*; +import net.tfminecraft.magic.profile.MagicProfileService; +import net.tfminecraft.magic.registry.ElementRegistry; +import net.tfminecraft.magic.session.ResonanceSession; +import org.junit.jupiter.api.*; +import org.mockbukkit.mockbukkit.*; +import org.mockbukkit.mockbukkit.entity.PlayerMock; + +class MeditationSessionTest { + ServerMock server; + PlayerMock player; + MeditationCircle circle; + Furniture post; + UUID source; + ResonanceSession resonance; + + @BeforeEach + void setup() { + server = MockBukkit.mock(); + player = server.addPlayer(); + source = UUID.randomUUID(); + post = mock(Furniture.class); + when(post.getEntityId()).thenReturn(source); + var center = player.getLocation(); + when(post.getOriginBlockLocation()).thenReturn(Optional.of(center)); + when(post.getLoc()).thenReturn(center); + circle = mock(MeditationCircle.class); + when(circle.getCenter()).thenReturn(center); + when(circle.getPedestals()).thenReturn(List.of(post)); + when(circle.getArtifactPedestals()).thenReturn(List.of(post)); + when(circle.artifactFor(source)).thenReturn(new MeditationCache.ArtifactDef("fire", 5)); + when(circle.artifactIdOn(post)).thenReturn("artifact"); + when(circle.elementPower("fire")).thenReturn(5.); + ElementRegistry.clear(); + ElementRegistry.register(DomainTest.element("fire")); + Cache.defaultEquilibrium = 0; + resonance = new ResonanceSession(); + MeditationCache.orbIntroTicks = 2; + MeditationCache.orbLifetimeTicks = 20; + MeditationCache.resonancePerHit = 1; + MeditationCache.flowEquilibriumMin = .02; + MeditationCache.flowEquilibriumMax = .02; + MeditationCache.surgeEquilibriumMin = .03; + MeditationCache.surgeEquilibriumMax = .03; + MeditationCache.surgeLockSeconds = 1; + AuraLog.configure(false, false, null); + } + + @AfterEach + void cleanup() { + MockBukkit.unmock(); + ElementRegistry.clear(); + MeditationCache.orbIntroTicks = 20; + MeditationCache.orbLifetimeTicks = 100; + MeditationCache.resonancePerHit = 4; + MeditationCache.flowEquilibriumMin = .02; + MeditationCache.flowEquilibriumMax = .06; + MeditationCache.surgeEquilibriumMin = .02; + MeditationCache.surgeEquilibriumMax = .10; + MeditationCache.surgeLockSeconds = 10; + } + + MeditationSession session(double cap) { + return new MeditationSession( + player, + circle, + new MeditationSitYield(Map.of("artifact", cap), Map.of("artifact", 1)), + resonance); + } + + void activate(MeditationSession session) { + var starter = session.livingOrbs().getFirst(); + assertTrue(starter.isStarter()); + assertFalse(session.onOrbHit(player, starter, resonance, null)); + for (int i = 0; i < 5; i++) session.tick(player, resonance); + } + + @Test + void starterFollowsPlayerAndDebouncesSwingsHitsAndNotices() { + var session = session(2); + assertSame(circle, session.getCircle()); + assertEquals(MeditationSession.Phase.START_ORB, session.getPhase()); + assertTrue(session.hasActiveOrbs()); + assertFalse(session.claimSwing(0)); + assertTrue(session.claimSwing(1)); + assertFalse(session.claimSwing(1)); + assertFalse(session.canHit(3)); + assertTrue(session.canHit(4)); + session.markHit(4); + assertFalse(session.canHit(5)); + assertTrue(session.canHit(8)); + assertTrue(session.claimTiredNotice()); + assertFalse(session.claimTiredNotice()); + var before = session.livingOrbs().getFirst().getLocation().clone(); + player.teleport(player.getLocation().add(2, 0, 0)); + session.tick(player, resonance); + assertNotEquals(before, session.livingOrbs().getFirst().getLocation()); + session.beginWindDown(); + assertTrue(session.isWindingDown()); + assertFalse(session.hasActiveOrbs()); + session.beginWindDown(); + session.tick(player, resonance); + assertTrue(session.livingOrbs().isEmpty()); + session.end(); + assertFalse(session.isWindingDown()); + } + + @Test + void hitsCreditResonanceAndNeverExceedCircleTotalOrYield() { + var session = session(2); + activate(session); + assertEquals(MeditationSession.Phase.RUNNING, session.getPhase()); + assertEquals(2, session.livingOrbs().size()); + var profile = mock(MagicProfileService.class); + var flow = + session.livingOrbs().stream().filter(MeditationOrb::isFlow).findFirst().orElseThrow(); + assertFalse(session.onOrbHit(player, flow, resonance, profile)); + assertEquals(1, resonance.getResonance("fire")); + assertEquals(.02, resonance.getEquilibrium()); + var surge = session.livingOrbs().getFirst(); + assertFalse(surge.isFlow()); + assertTrue(session.onOrbHit(player, surge, resonance, profile)); + assertEquals(2, resonance.getResonance("fire")); + assertEquals(-.01, resonance.getEquilibrium()); + verify(profile, times(2)).savePlayer(player); + session.playComplete(player); + session.end(); + assertFalse(session.hasActiveOrbs()); + } + + @Test + void exhaustedElementsNeverSpawnAndCloseWhenResonanceReachesCeiling() { + resonance.setResonance("fire", 5); + var capped = session(10); + activate(capped); + assertTrue(capped.livingOrbs().isEmpty()); + resonance.setResonance("fire", 4.5); + var partial = session(10); + activate(partial); + var orb = partial.livingOrbs().getFirst(); + assertTrue(partial.onOrbHit(player, orb, resonance, null)); + assertEquals(5, resonance.getResonance("fire")); + for (int i = 0; i < 50; i++) partial.tick(player, resonance); + assertTrue(partial.livingOrbs().isEmpty()); + partial.end(); + } + + @Test + void orbExpirationAndWindDownReturnToPedestalWithoutReward() { + var session = session(10); + activate(session); + for (int i = 0; i < 4; i++) session.tick(player, resonance); + session.beginWindDown(); + assertTrue(session.isWindingDown()); + assertTrue(session.hasActiveOrbs()); + assertTrue(session.livingOrbs().isEmpty()); + for (int i = 0; i < 4; i++) session.tick(player, resonance); + assertFalse(session.hasActiveOrbs()); + assertEquals(0, resonance.getResonance("fire")); + session.end(); + var expiry = session(10); + activate(expiry); + for (int i = 0; i < 25; i++) expiry.tick(player, resonance); + assertTrue(expiry.livingOrbs().isEmpty()); + assertEquals(0, resonance.getResonance("fire")); + expiry.end(); + } + + @Test + void surgeLockRetintsRemainingOrbsAndLaterAnnouncesRecovery() throws Exception { + var session = session(10); + activate(session); + var surge = session.livingOrbs().stream().filter(o -> !o.isFlow()).findFirst().orElseThrow(); + session.onOrbHit(player, surge, resonance, null); + assertTrue(session.livingOrbs().stream().noneMatch(MeditationOrb::isFlow)); + session.tick(player, resonance); + assertNotNull(player.nextMessage()); + Thread.sleep(1100); + session.tick(player, resonance); + assertNotNull(player.nextMessage()); + session.end(); + } + + @Test + void missingYieldAndMissingPedestalItemCannotGrantResonance() { + var empty = new MeditationSession(player, circle, null, null); + empty.tick(player, null); + empty.end(); + when(circle.artifactIdOn(post)).thenReturn(null); + var missing = session(5); + activate(missing); + assertTrue(missing.livingOrbs().isEmpty()); + missing.end(); + } + + @Test + void multiplePedestalsSpawnOneOrbEachAndWindDownSkipsConsumedOrbs() { + var posts = new ArrayList(); + var caps = new LinkedHashMap(); + for (int i = 0; i < 3; i++) { + var p = mock(Furniture.class); + var id = UUID.randomUUID(); + var at = player.getLocation().add(i, 0, 0); + when(p.getEntityId()).thenReturn(id); + when(p.getOriginBlockLocation()).thenReturn(Optional.of(at)); + when(p.getLoc()).thenReturn(at); + when(circle.artifactFor(id)).thenReturn(new MeditationCache.ArtifactDef("fire", 10)); + when(circle.artifactIdOn(p)).thenReturn("a" + i); + caps.put("a" + i, 10d); + posts.add(p); + } + when(circle.getPedestals()).thenReturn(posts); + when(circle.getArtifactPedestals()).thenReturn(posts); + when(circle.elementPower("fire")).thenReturn(30d); + long oldSpawn = MeditationCache.spawnIntervalTicks; + int oldLife = MeditationCache.orbLifetimeTicks; + try { + MeditationCache.spawnIntervalTicks = 5; + MeditationCache.orbLifetimeTicks = 100; + var session = + new MeditationSession(player, circle, new MeditationSitYield(caps, null), resonance); + activate(session); + for (int i = 0; i < 15; i++) session.tick(player, resonance); + assertEquals(3, session.livingOrbs().size()); + assertEquals( + 3, session.livingOrbs().stream().map(MeditationOrb::getSourceId).distinct().count()); + var hit = session.livingOrbs().getFirst(); + session.onOrbHit(player, hit, resonance, null); + session.beginWindDown(); + for (int i = 0; i < 25; i++) session.tick(player, resonance); + assertFalse(session.hasActiveOrbs()); + for (double equilibrium : new double[] {-100, 100}) { + resonance.setEquilibrium(equilibrium); + var mixed = + new MeditationSession(player, circle, new MeditationSitYield(caps, null), resonance); + activate(mixed); + for (int i = 0; i < 15; i++) mixed.tick(player, resonance); + assertEquals( + equilibrium < 0 ? 1 : 2, + mixed.livingOrbs().stream().filter(MeditationOrb::isFlow).count()); + mixed.end(); + } + } finally { + MeditationCache.spawnIntervalTicks = oldSpawn; + MeditationCache.orbLifetimeTicks = oldLife; + } + } + + @Test + void itemRemovedBetweenOrbSpawnAndHitDoesNotCreditResonance() { + var profiles = mock(MagicProfileService.class); + var session = session(10); + activate(session); + var unknown = + new MeditationOrb( + player.getLocation(), + true, + false, + 0, + player.getLocation(), + 0, + 0, + 0, + 0, + 0, + 0, + UUID.randomUUID()); + session.onOrbHit(player, unknown, resonance, profiles); + assertEquals(0, resonance.getResonance("fire")); + verify(profiles).savePlayer(player); + when(circle.artifactFor(source)).thenReturn(new MeditationCache.ArtifactDef("fire", 5)); + when(circle.artifactIdOn(post)).thenReturn(null); + session.onOrbHit(player, session.livingOrbs().getFirst(), resonance, profiles); + assertEquals(0, resonance.getResonance("fire")); + verify(profiles, times(2)).savePlayer(player); + session.end(); + } + + @Test + void sessionsWithoutResonanceStillAllowVisualWindDownAndMissingSourcesClose() { + var visual = + new MeditationSession( + player, circle, new MeditationSitYield(Map.of("artifact", 2d), null), null); + var starter = visual.livingOrbs().getFirst(); + visual.onOrbHit(player, starter, resonance, null); + for (int i = 0; i < 5; i++) visual.tick(player, resonance); + visual.onOrbHit(player, visual.livingOrbs().getFirst(), null, null); + visual.beginWindDown(); + visual.end(); + when(circle.artifactFor(source)).thenReturn(new MeditationCache.ArtifactDef("fire", 5)); + when(circle.artifactIdOn(post)).thenReturn(null); + var missing = session(3); + activate(missing); + assertTrue(missing.livingOrbs().isEmpty()); + missing.end(); + } + + @Test + void externallyRaisedResonanceClosesPendingOrbsWithoutAdditionalCredit() { + var session = session(10); + activate(session); + resonance.setResonance("fire", 5); + session.onOrbHit(player, session.livingOrbs().getFirst(), resonance, null); + assertEquals(5, resonance.getResonance("fire")); + session.end(); + } + + @Test + void lockedElementsAndZeroYieldNeverOfferAnOrb() { + var config = new org.bukkit.configuration.file.YamlConfiguration(); + config.set("permission", "magic.secret"); + ElementRegistry.register(new net.tfminecraft.magic.model.ElementDef("fire", config)); + var denied = session(10); + activate(denied); + assertTrue(denied.livingOrbs().isEmpty()); + denied.end(); + ElementRegistry.clear(); + var noYield = session(0); + activate(noYield); + assertTrue(noYield.livingOrbs().isEmpty()); + noYield.end(); + var unregistered = session(2); + activate(unregistered); + unregistered.onOrbHit(player, unregistered.livingOrbs().getFirst(), resonance, null); + assertEquals(0, resonance.getResonance("fire")); + unregistered.end(); + } + + @Test + void repeatedSurgeHitsExtendLockAndSpawnOnlySurgeDuringIt() { + long oldSpawn = MeditationCache.spawnIntervalTicks; + try { + MeditationCache.spawnIntervalTicks = 5; + var session = session(10); + activate(session); + var surge = session.livingOrbs().stream().filter(o -> !o.isFlow()).findFirst().orElseThrow(); + session.onOrbHit(player, surge, resonance, null); + session.onOrbHit(player, session.livingOrbs().getFirst(), resonance, null); + assertNotNull(player.nextMessage()); + assertNull(player.nextMessage()); + for (int i = 0; i < 6; i++) session.tick(player, resonance); + assertFalse(session.livingOrbs().isEmpty()); + assertTrue(session.livingOrbs().stream().noneMatch(MeditationOrb::isFlow)); + session.end(); + } finally { + MeditationCache.spawnIntervalTicks = oldSpawn; + } + } + + @Test + void vanishedPedestalLocationsSkipSpawnAndCompletionEffects() { + when(post.getOriginBlockLocation()).thenReturn(Optional.empty()); + when(post.getLoc()).thenReturn(null); + var session = session(10); + activate(session); + assertTrue(session.livingOrbs().isEmpty()); + session.playComplete(player); + session.end(); + } + + @Test + void savedLocationsWithoutWorldSkipEffectsAndReturnSafely() { + var session = session(10); + activate(session); + var orbs = session.livingOrbs(); + orbs.getFirst().getSpawnLocation().setWorld(null); + orbs.get(1).getSpawnLocation().setWorld(null); + orbs.get(1).getLocation().setWorld(null); + session.beginWindDown(); + session.tick(null, null); + assertTrue(orbs.get(1).isConsumed()); + for (int i = 0; i < 5; i++) session.tick(null, null); + assertFalse(session.hasActiveOrbs()); + var complete = session(10); + circle.getCenter().setWorld(null); + complete.playComplete(player); + complete.onOrbHit(player, complete.livingOrbs().getFirst(), resonance, null); + complete.end(); + } + + @Test + void hitEffectsIgnoreInvalidatedSavedWorldsAndOneRemainingMixSpawnsItsCounterpart() { + long oldSpawn = MeditationCache.spawnIntervalTicks; + try { + MeditationCache.spawnIntervalTicks = 5; + var session = session(10); + activate(session); + var flow = + session.livingOrbs().stream().filter(MeditationOrb::isFlow).findFirst().orElseThrow(); + flow.getLocation().setWorld(null); + session.onOrbHit(player, flow, resonance, null); + for (int i = 0; i < 5; i++) session.tick(player, resonance); + assertEquals(2, session.livingOrbs().size()); + var next = + session.livingOrbs().stream().filter(MeditationOrb::isFlow).findFirst().orElseThrow(); + next.getSpawnLocation().setWorld(null); + session.onOrbHit(player, next, resonance, null); + session.end(); + } finally { + MeditationCache.spawnIntervalTicks = oldSpawn; + } + } + + @Test + void closingOneSchoolSkipsOtherSchoolsAndPedestalsWhoseItemsWereRemoved() { + var empty = mock(Furniture.class); + var emptyId = UUID.randomUUID(); + when(empty.getEntityId()).thenReturn(emptyId); + var water = mock(Furniture.class); + var waterId = UUID.randomUUID(); + when(water.getEntityId()).thenReturn(waterId); + when(circle.getPedestals()).thenReturn(List.of(empty, water, post)); + when(circle.getArtifactPedestals()).thenReturn(List.of(empty, water, post)); + when(circle.artifactFor(emptyId)).thenReturn(new MeditationCache.ArtifactDef("fire", 2)); + when(circle.artifactFor(waterId)).thenReturn(new MeditationCache.ArtifactDef("water", 2)); + when(circle.artifactIdOn(water)).thenReturn("water-item"); + var session = session(10); + activate(session); + resonance.setResonance("fire", 5); + session.onOrbHit(player, session.livingOrbs().getFirst(), resonance, null); + assertEquals(5, resonance.getResonance("fire")); + session.end(); + } + + @Test + void consumedOrbImmediatelyBeforeSpawnDoesNotUseItsPedestalCapacity() { + long oldSpawn = MeditationCache.spawnIntervalTicks; + try { + MeditationCache.spawnIntervalTicks = 5; + var session = session(10); + activate(session); + for (int i = 0; i < 4; i++) session.tick(player, resonance); + var flow = + session.livingOrbs().stream().filter(MeditationOrb::isFlow).findFirst().orElseThrow(); + session.onOrbHit(player, flow, resonance, null); + session.tick(player, resonance); + assertEquals(2, session.livingOrbs().size()); + session.end(); + } finally { + MeditationCache.spawnIntervalTicks = oldSpawn; + } + } + + @Test + void returningOrbStillOccupiesCapacityWhenTheOtherOrbIsHitBeforeRespawn() { + long oldSpawn = MeditationCache.spawnIntervalTicks; + try { + MeditationCache.spawnIntervalTicks = 5; + var session = session(10); + activate(session); + for (int i = 0; i < 4; i++) session.tick(player, resonance); + var flow = + session.livingOrbs().stream().filter(MeditationOrb::isFlow).findFirst().orElseThrow(); + session.onOrbHit(player, flow, resonance, null); + session.tick(player, resonance); + for (int i = 0; i < 14; i++) session.tick(player, resonance); + var replacement = + session.livingOrbs().stream().filter(MeditationOrb::isFlow).findFirst().orElseThrow(); + session.onOrbHit(player, replacement, resonance, null); + session.tick(player, resonance); + assertEquals(1, session.livingOrbs().size()); + session.end(); + } finally { + MeditationCache.spawnIntervalTicks = oldSpawn; + } + } + + @Test + void sourceLessPublicOrbCannotCreditArtifactResonance() { + var session = session(10); + var orb = new MeditationOrb(player.getLocation(), true, false, 0); + session.onOrbHit(player, orb, resonance, null); + assertEquals(0, resonance.getResonance("fire")); + session.end(); + } +} diff --git a/src/test/java/net/tfminecraft/magic/OrbDomainTest.java b/src/test/java/net/tfminecraft/magic/OrbDomainTest.java new file mode 100644 index 0000000..7280099 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/OrbDomainTest.java @@ -0,0 +1,148 @@ +package net.tfminecraft.magic; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.UUID; +import net.tfminecraft.magic.gear.orb.GearOrb; +import net.tfminecraft.magic.meditation.*; +import net.tfminecraft.magic.util.OrbTrail; +import org.bukkit.Location; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.Test; + +class OrbDomainTest { + @Test + void trailRetainsTwentySnapshotsWithoutAliasingInput() { + var trail = new OrbTrail(); + assertNull(trail.ticksAgo(0)); + var location = new Location(null, 0, 0, 0); + for (int i = 0; i < 25; i++) { + location.setX(i); + trail.push(location); + } + location.setX(999); + assertEquals(24, trail.ticksAgo(0).getX()); + assertEquals(24, trail.ticksAgo(-1).getX()); + assertEquals(5, trail.ticksAgo(19).getX()); + assertNull(trail.ticksAgo(20)); + } + + @Test + void pingRewindBoundsFramesAndNeverExceedsCapacity() { + assertArrayEquals(new int[] {0, 0}, OrbTrail.rewindTicks((Player) null, 20)); + assertArrayEquals(new int[] {0, 0}, OrbTrail.rewindTicks(-1, 20)); + assertArrayEquals(new int[] {0, 1}, OrbTrail.rewindTicks(25, 20)); + assertArrayEquals(new int[] {0, 1}, OrbTrail.rewindTicks(50, 20)); + assertArrayEquals(new int[] {1, 2}, OrbTrail.rewindTicks(75, 20)); + assertArrayEquals(new int[] {18, 19}, OrbTrail.rewindTicks(Integer.MAX_VALUE, 100)); + assertArrayEquals(new int[] {0, 0}, OrbTrail.rewindTicks(100, -1)); + Player player = mock(Player.class); + when(player.getPing()).thenReturn(100); + assertArrayEquals(new int[] {1, 2}, OrbTrail.rewindTicks(player, 20)); + } + + @Test + void meditationOrbLifecycleSeparatesIntroLifeReturnAndConsumption() { + Location source = new Location(null, 1, 2, 3); + UUID id = UUID.randomUUID(); + var orb = new MeditationOrb(source, true, false, 1, source, 2, 3, 4, 5, 6, 2, id); + source.setX(99); + assertEquals(1, orb.getLocation().getX()); + assertEquals(1, orb.getSpawnLocation().getX()); + assertEquals(id, orb.getSourceId()); + assertTrue(orb.isFlow()); + orb.setFlow(false); + assertFalse(orb.isFlow()); + assertFalse(orb.isStarter()); + assertEquals(1, orb.getAngle()); + orb.setAngle(2); + assertEquals(2, orb.getAngle()); + assertEquals(3, orb.getRadius()); + assertEquals(4, orb.getHeightBias()); + assertEquals(5, orb.getAngleSpeed()); + assertEquals(6, orb.getBobPhase()); + orb.addBobPhase(.5); + assertEquals(6.5, orb.getBobPhase()); + assertEquals(2, orb.getIntroMax()); + assertEquals(2, orb.getIntroRemaining()); + orb.tickIntro(); + orb.tickIntro(); + orb.tickIntro(); + assertEquals(0, orb.getIntroRemaining()); + assertFalse(orb.tickReturn()); + assertFalse(orb.tickLife()); + assertTrue(orb.tickLife()); + assertTrue(orb.tickLife()); + assertSame(orb.getLocation(), orb.getReturnFrom()); + orb.setLocation(new Location(null, 4, 5, 6)); + assertEquals(4, orb.getLocation(0).getX()); + assertNull(orb.getLocation(1)); + orb.beginReturn(); + assertTrue(orb.isReturning()); + assertEquals(2, orb.getReturnMax()); + assertEquals(2, orb.getReturnRemaining()); + assertEquals(4, orb.getReturnFrom().getX()); + orb.beginReturn(); + assertFalse(orb.tickLife()); + assertFalse(orb.tickReturn()); + assertTrue(orb.tickReturn()); + assertTrue(orb.tickReturn()); + orb.setConsumed(true); + assertTrue(orb.isConsumed()); + assertFalse(orb.tickReturn()); + assertFalse(orb.tickLife()); + } + + @Test + void starterOrbsNeverExpireOrReturnAndUseCurrentPositionForHits() { + var start = new MeditationOrb(new Location(null, 0, 0, 0), true, true, 0); + assertTrue(start.isStarter()); + assertSame(start.getLocation(), start.getLocation(19)); + assertFalse(start.tickLife()); + start.beginReturn(); + assertFalse(start.isReturning()); + var orb = new MeditationOrb(new Location(null, 0, 0, 0), true, false, 0); + assertFalse(orb.tickLife()); + orb.setConsumed(true); + orb.beginReturn(); + assertFalse(orb.isReturning()); + orb.setConsumed(false); + orb.beginReturn(); + assertEquals(Math.max(1, MeditationCache.orbIntroTicks), orb.getReturnMax()); + } + + @Test + void gearOrbLifetimeAndPositionHistoryAreIndependent() { + Location at = new Location(null, 1, 2, 3); + var orb = new GearOrb(at, true, 1, 2, 3, 4, 5, 1, 2); + at.setX(9); + assertEquals(1, orb.getAnchor().getX()); + assertTrue(orb.isGood()); + assertEquals(1, orb.getAngle()); + orb.setAngle(2); + assertEquals(2, orb.getAngle()); + assertEquals(2, orb.getRadius()); + assertEquals(3, orb.getHeightBias()); + assertEquals(4, orb.getAngleSpeed()); + assertEquals(5, orb.getBobPhase()); + orb.addBobPhase(1); + assertEquals(6, orb.getBobPhase()); + assertEquals(1, orb.getIntroMax()); + assertEquals(1, orb.getIntroRemaining()); + orb.tickIntro(); + orb.tickIntro(); + assertEquals(0, orb.getIntroRemaining()); + orb.setLocation(new Location(null, 4, 5, 6)); + assertEquals(4, orb.getLocation().getX()); + assertEquals(4, orb.getLocation(0).getX()); + assertNull(orb.getLocation(1)); + assertFalse(orb.tickLife()); + assertTrue(orb.tickLife()); + assertTrue(orb.tickLife()); + assertFalse(orb.isConsumed()); + orb.setConsumed(true); + assertTrue(orb.isConsumed()); + assertFalse(orb.tickLife()); + } +} diff --git a/src/test/java/net/tfminecraft/magic/PersistenceTest.java b/src/test/java/net/tfminecraft/magic/PersistenceTest.java new file mode 100644 index 0000000..5e9fd95 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/PersistenceTest.java @@ -0,0 +1,181 @@ +package net.tfminecraft.magic; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.nio.file.*; +import java.util.*; +import java.util.logging.Logger; +import net.tfminecraft.magic.profile.*; +import net.tfminecraft.magic.registry.ElementRegistry; +import net.tfminecraft.magic.util.RevisionTracker; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.io.TempDir; + +class PersistenceTest { + @TempDir Path temp; + + @BeforeEach + void setup() { + Magic.plugin = mock(Magic.class); + when(Magic.plugin.getLogger()).thenReturn(Logger.getAnonymousLogger()); + ElementRegistry.clear(); + ElementRegistry.register(DomainTest.element("fire")); + } + + @AfterEach + void cleanup() { + Magic.plugin = null; + ElementRegistry.clear(); + } + + @Test + void profileFilesRoundTripAndIgnoreMissingIds() throws Exception { + var store = new MagicProfileStore(temp.resolve("characters").toFile()); + assertFalse(store.exists(null)); + assertFalse(store.exists(" ")); + assertFalse(store.exists("one")); + assertNull(store.load(null)); + assertNull(store.load(" ")); + assertNull(store.load("one")); + store.save(null); + store.save(new MagicProfile()); + var profile = MagicProfile.fromDefaults("one", UUID.randomUUID()); + profile.setEquilibrium(2); + profile.setResonance(new HashMap<>(Map.of("fire", 12.3))); + store.save(profile); + assertTrue(store.exists("one")); + var loaded = store.load("one"); + assertEquals("one", loaded.getCharacterId()); + assertEquals(profile.getOwnerUuid(), loaded.getOwnerUuid()); + assertEquals(Map.of("fire", 12.3), loaded.copyResonance()); + assertEquals(2, loaded.getEquilibrium()); + Files.writeString(temp.resolve("characters/one.json"), "{}"); + assertEquals("one", store.load("one").getCharacterId()); + Files.writeString(temp.resolve("characters/one.json"), "{\"characterId\":\" \"}"); + assertEquals("one", store.load("one").getCharacterId()); + Files.writeString(temp.resolve("characters/one.json"), "null"); + assertNull(store.load("one")); + Files.writeString(temp.resolve("characters/one.json"), "{bad json"); + assertNull(store.load("one")); + profile.setCharacterId(" "); + store.save(profile); + } + + @Test + void oldClaimsFoldIntoResonanceOnce() throws Exception { + var store = new MagicProfileStore(temp.toFile()); + Files.writeString( + temp.resolve("one.json"), + """ +{"characterId":"one","resonance":{"fire":2},"claims":{"old":{"byElement":{" FIRE ":3," ":8,"water":null}},"bad":null}} +"""); + var profile = store.load("one"); + assertNotNull(profile); + assertEquals(5, profile.getResonance().get("fire")); + store.save(profile); + assertFalse(Files.readString(temp.resolve("one.json")).contains("claims")); + assertEquals(5, store.load("one").getResonance().get("fire")); + Files.writeString(temp.resolve("two.json"), "{\"resonance\":null}"); + assertTrue(store.load("two").copyResonance().isEmpty()); + } + + @Test + void profileWriteFailuresLeaveExistingDataUntouched() throws Exception { + var store = new MagicProfileStore(temp.toFile()); + var profile = MagicProfile.fromDefaults("one", null); + store.save(profile); + String original = Files.readString(temp.resolve("one.json")); + Files.createDirectory(temp.resolve("one.json.tmp")); + profile.setEquilibrium(50); + store.save(profile); + assertEquals(original, Files.readString(temp.resolve("one.json"))); + Files.delete(temp.resolve("one.json.tmp")); + Files.delete(temp.resolve("one.json")); + Files.createDirectory(temp.resolve("one.json")); + Files.writeString(temp.resolve("one.json/keep"), "keep"); + store.save(profile); + assertEquals("keep", Files.readString(temp.resolve("one.json/keep"))); + assertNull(store.load("one")); + } + + @Test + void revisionHashesPersistAndIncrementOnlyForContentChanges() throws Exception { + var tracker = new RevisionTracker(); + tracker.flush(); + tracker.load(temp.toFile()); + tracker.flush(); + assertEquals(0, tracker.resolvePart(null, "h")); + assertEquals(0, tracker.resolvePart(" ", "h")); + assertEquals(0, tracker.partRevision(null)); + assertEquals(0, tracker.partRevision(" ")); + assertEquals(0, tracker.partRevision("unknown")); + assertEquals(1, tracker.resolvePart(" Part ", "first")); + assertEquals(1, tracker.resolvePart("part", "first")); + assertEquals(2, tracker.resolvePart("part", "second")); + assertEquals(2, tracker.partRevision(" PART ")); + assertEquals(1, tracker.resolveArchetype("wand", null)); + assertEquals(1, tracker.resolveArchetype("wand", "")); + assertEquals(1, tracker.archetypeRevision("wand")); + tracker.flush(); + var loaded = new RevisionTracker(); + loaded.load(temp.toFile()); + assertEquals(2, loaded.partRevision("part")); + assertEquals(1, loaded.archetypeRevision("wand")); + assertEquals(2, loaded.resolvePart("part", "second")); + assertEquals( + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + RevisionTracker.sha256(null)); + assertEquals(RevisionTracker.sha256(""), RevisionTracker.sha256(null)); + assertNotEquals(RevisionTracker.sha256("abc"), RevisionTracker.sha256("ABC")); + } + + @Test + void revisionLoaderHandlesMissingSectionsDefaultsAndMalformedFiles() throws Exception { + Files.createDirectories(temp.resolve("data")); + Path file = temp.resolve("data/revisions.json"); + var tracker = new RevisionTracker(); + Files.writeString(file, "{\"parts\":{\"LEGACY\":{}},\"archetypes\":false}"); + tracker.load(temp.toFile()); + assertEquals(1, tracker.partRevision("legacy")); + assertEquals(1, tracker.resolvePart("legacy", null)); + Files.writeString(file, "{}"); + tracker.load(temp.toFile()); + assertEquals(0, tracker.partRevision("legacy")); + Files.writeString(file, "broken"); + assertDoesNotThrow(() -> tracker.load(temp.toFile())); + Files.delete(file); + Files.createDirectory(file); + Files.writeString(file.resolve("keep"), "keep"); + tracker.resolvePart("a", "hash"); + assertDoesNotThrow(tracker::flush); + assertEquals("keep", Files.readString(file.resolve("keep"))); + } + + @Test + void failedQuarantineBlocksOverwriteUntilOriginalCanBePreserved() throws Exception { + Path folder = Files.createDirectory(temp.resolve("protected")); + Path file = folder.resolve("one.json"); + String original = "{invalid profile"; + Files.writeString(file, original); + var store = new MagicProfileStore(folder.toFile()); + try (var files = mockStatic(Files.class, CALLS_REAL_METHODS)) { + files + .when(() -> Files.move(eq(file), any(Path.class))) + .thenThrow(new java.io.IOException("quarantine move unavailable")); + assertNull(store.load("one")); + files.verify(() -> Files.move(eq(file), any(Path.class))); + } + store.save(MagicProfile.fromDefaults("one", null)); + assertEquals(original, Files.readString(file)); + assertNull(store.load("one")); + store.save(MagicProfile.fromDefaults("one", null)); + assertNotNull(store.load("one")); + try (var files = Files.list(folder)) { + var preserved = + files.filter(p -> p.getFileName().toString().startsWith("one.json.rejected-")).toList(); + assertEquals(1, preserved.size()); + assertEquals(original, Files.readString(preserved.getFirst())); + } + } +} diff --git a/src/test/java/net/tfminecraft/magic/ProfileEdgeTest.java b/src/test/java/net/tfminecraft/magic/ProfileEdgeTest.java new file mode 100644 index 0000000..a5fb76b --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/ProfileEdgeTest.java @@ -0,0 +1,115 @@ +package net.tfminecraft.magic; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import com.google.gson.Gson; +import java.nio.file.*; +import java.util.*; +import java.util.logging.Logger; +import net.tfminecraft.magic.integration.RpCharactersBridge; +import net.tfminecraft.magic.profile.*; +import net.tfminecraft.magic.registry.ElementRegistry; +import net.tfminecraft.magic.session.*; +import net.tfminecraft.rpcharacters.objects.RPCharacter; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.io.TempDir; +import org.mockbukkit.mockbukkit.*; + +class ProfileEdgeTest { + @TempDir Path temp; + ServerMock server; + + @BeforeEach + void setup() { + server = MockBukkit.mock(); + Magic.plugin = mock(Magic.class); + when(Magic.plugin.getLogger()).thenReturn(Logger.getAnonymousLogger()); + ElementRegistry.clear(); + ElementRegistry.register(DomainTest.element("fire")); + } + + @AfterEach + void cleanup() { + Magic.plugin = null; + MockBukkit.unmock(); + ElementRegistry.clear(); + } + + @Test + void persistedNullMapsAndLegacyMalformedClaimsAreRepaired() { + var gson = new Gson(); + var profile = gson.fromJson("{\"resonance\":null}", MagicProfile.class); + assertTrue(profile.copyResonance().isEmpty()); + profile.pruneEmptyResonance(); + assertTrue(profile.getResonance().isEmpty()); + profile = gson.fromJson("{\"resonance\":null}", MagicProfile.class); + assertTrue(profile.getResonance().isEmpty()); + profile = + gson.fromJson( + "{\"claims\":{\"bad\":{\"byElement\":[[\"fire\",null],[\"\",2],[\" FIRE" + + " \",4]]}},\"resonance\":null}", + MagicProfile.class); + profile.migrateClaims(); + assertEquals(Map.of("fire", 4d), profile.copyResonance()); + var values = new HashMap(); + values.put(null, 2d); + values.put("water", null); + values.put("zero", 0d); + values.put("fire", .001); + profile.setResonance(values); + assertEquals(Map.of("fire", .001), profile.copyResonance()); + profile.pruneEmptyResonance(); + assertTrue(profile.copyResonance().isEmpty()); + } + + @Test + void sessionMapImportRejectsInvalidEntriesAndCopiesStayIndependent() { + var session = new ResonanceSession(); + session.setResonance("fire", 8); + var values = new HashMap(); + values.put(null, 2d); + values.put("water", null); + values.put("fire", 4d); + session.setResonanceMap(values); + assertEquals(Map.of("fire", 4d), session.copyResonance()); + var copied = session.copyResonance(); + copied.put("fire", 9d); + assertEquals(4, session.getResonance("fire")); + session.setResonanceMap(Map.of()); + assertTrue(session.copyResonance().isEmpty()); + session.setResonanceMap(null); + assertTrue(session.copyResonance().isEmpty()); + } + + @Test + void profileServiceHandlesLoadedIdWithoutSessionAndBlankBridgeCharacter() throws Exception { + var sessions = new ResonanceSessionManager(); + var store = new MagicProfileStore(temp.toFile()); + var service = new MagicProfileService(store, sessions); + var player = server.addPlayer(); + var active = mock(RPCharacter.class); + when(active.getId()).thenReturn("one"); + sessions.setLoadedCharacterId(player, "one"); + try (var bridge = mockStatic(RpCharactersBridge.class)) { + service.activate(player, active); + assertNotNull(sessions.get(player)); + assertTrue(store.exists("one")); + var unmatched = mock(RPCharacter.class); + when(unmatched.getId()).thenReturn("other"); + bridge.when(() -> RpCharactersBridge.getActiveCharacter(player)).thenReturn(unmatched); + assertNull(service.findOnlineWithCharacter("missing")); + sessions.remove(player.getUniqueId()); + service.saveAllOnline(); + when(unmatched.getId()).thenReturn(""); + service.saveAllOnline(); + service.loadOnlineCharacters(); + assertNull(sessions.get(player)); + } + var nested = temp.resolve("nested"); + var nestedStore = new MagicProfileStore(nested.toFile()); + Files.delete(nested); + nestedStore.save(MagicProfile.fromDefaults("saved", null)); + assertTrue(Files.exists(nested.resolve("saved.json"))); + } +} diff --git a/src/test/java/net/tfminecraft/magic/ProfileResidualTest.java b/src/test/java/net/tfminecraft/magic/ProfileResidualTest.java new file mode 100644 index 0000000..5923047 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/ProfileResidualTest.java @@ -0,0 +1,59 @@ +package net.tfminecraft.magic; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.nio.file.*; +import java.util.*; +import net.tfminecraft.magic.attunement.*; +import net.tfminecraft.magic.session.*; +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.io.TempDir; + +class ProfileResidualTest { + @TempDir Path temp; + + @Test + void loggingBeforeConfigurationDoesNotCreateAFile() throws Exception { + var field = AuraLog.class.getDeclaredField("logFile"); + field.setAccessible(true); + Object previous = field.get(null); + try { + field.set(null, null); + AuraLog.configure(true, true, null); + AuraLog.append("before path configuration"); + assertEquals(0, Files.list(temp).count()); + } finally { + field.set(null, previous); + AuraLog.configure(false, false, null); + } + } + + @Test + void negativeLegacyClaimsAreEmpty() { + var claim = new ArtifactClaim(); + claim.setByElement(Map.of("fire", -1d)); + assertFalse(claim.hasAnyAmount()); + claim.setByElement(Map.of("fire", 1d)); + assertFalse(claim.isEmpty()); + } + + @Test + void onlineLookupCanTurnOfflineBeforeIterationCallback() { + var sessions = new ResonanceSessionManager(); + var player = mock(Player.class); + var id = UUID.randomUUID(); + when(player.getUniqueId()).thenReturn(id); + sessions.getOrCreate(player); + try (var bukkit = mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.getPlayer(id)).thenReturn(player); + sessions.forEachOnlineSession((p, s) -> fail("offline player must not receive callbacks")); + when(player.isOnline()).thenReturn(true); + var seen = new ArrayList(); + sessions.forEachOnlineSession((p, s) -> seen.add(p)); + assertEquals(List.of(player), seen); + } + } +} diff --git a/src/test/java/net/tfminecraft/magic/ResonanceCastCoverageTest.java b/src/test/java/net/tfminecraft/magic/ResonanceCastCoverageTest.java new file mode 100644 index 0000000..a7d5e85 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/ResonanceCastCoverageTest.java @@ -0,0 +1,207 @@ +package net.tfminecraft.magic; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import io.lumine.mythic.lib.api.event.skill.PlayerCastSkillEvent; +import io.lumine.mythic.lib.api.item.NBTItem; +import io.lumine.mythic.lib.skill.*; +import java.util.*; +import java.util.concurrent.ThreadLocalRandom; +import net.Indyuce.mmoitems.api.interaction.util.DurabilityItem; +import net.tfminecraft.magic.charge.TierBands; +import net.tfminecraft.magic.gear.*; +import net.tfminecraft.magic.integration.SkillIdResolver; +import net.tfminecraft.magic.listener.ResonanceCastListener; +import net.tfminecraft.magic.registry.*; +import net.tfminecraft.magic.session.*; +import org.bukkit.*; +import org.bukkit.inventory.*; +import org.junit.jupiter.api.*; +import org.mockbukkit.mockbukkit.*; + +class ResonanceCastCoverageTest { + ServerMock server; + Magic plugin; + ResonanceCastListener listener; + + @BeforeEach + void setup() { + server = MockBukkit.mock(); + plugin = mock(Magic.class, RETURNS_DEEP_STUBS); + Magic.plugin = plugin; + when(plugin.namespace()).thenReturn("magic"); + when(plugin.getName()).thenReturn("Magic"); + ElementRegistry.clear(); + SkillElementRegistry.clear(); + TierBands.clear(); + ResonanceCastListener.clearAll(); + Cache.refuseChatMillis = 1000; + listener = new ResonanceCastListener(); + } + + @AfterEach + void cleanup() { + Magic.plugin = null; + ElementRegistry.clear(); + SkillElementRegistry.clear(); + TierBands.clear(); + ResonanceCastListener.clearAll(); + MockBukkit.unmock(); + } + + @Test + void irrelevantCastsMissingWeaponsAndBrokenWeaponsDoNotSpendAnything() { + var event = mock(PlayerCastSkillEvent.class); + Magic.plugin = null; + listener.onPlayerCastSkill(event); + Magic.plugin = plugin; + var skill = mock(Skill.class); + when(event.getCast()).thenReturn(skill); + var p = server.addPlayer(); + try (var ids = mockStatic(SkillIdResolver.class); + var hand = mockStatic(GearHand.class); + var broken = mockStatic(GearBrokenMarker.class)) { + listener.onPlayerCastSkill(event); + ids.when(() -> SkillIdResolver.isActiveCast(skill)).thenReturn(true); + ids.when(() -> SkillIdResolver.resolveSkillId(skill)).thenReturn("fireball"); + listener.onPlayerCastSkill(event); + SkillElementRegistry.register("fireball", "fire", 1); + listener.onPlayerCastSkill(event); + when(event.getPlayer()).thenReturn(p); + listener.onPlayerCastSkill(event); + hand.when(() -> GearHand.heldSlot(p)).thenReturn(GearHand.HeldSlot.MAIN_HAND); + listener.onPlayerCastSkill(event); + var weapon = new ItemStack(Material.STICK); + hand.when(() -> GearHand.held(p)).thenReturn(weapon); + broken.when(() -> GearBrokenMarker.isBroken(weapon)).thenReturn(true); + listener.onPlayerCastSkill(event); + listener.onPlayerCastSkill(event); + verify(event, times(2)).setCancelled(true); + verify(skill, never()).whenCast(any()); + } + } + + @Test + void refusalChecksForeignElementWeaponBandAndCasterBand() { + var event = mock(PlayerCastSkillEvent.class); + var skill = mock(Skill.class); + var p = server.addPlayer(); + var weapon = new ItemStack(Material.STICK); + when(event.getCast()).thenReturn(skill); + when(event.getPlayer()).thenReturn(p); + var session = new ResonanceSession(); + when(plugin.getResonanceGuiManager().getSessionManager().get(p)).thenReturn(session); + SkillElementRegistry.register("fireball", "fire", 2); + TierBands.register(null, 1, 1); + TierBands.register(null, 2, 10); + try (var ids = mockStatic(SkillIdResolver.class); + var hand = mockStatic(GearHand.class)) { + ids.when(() -> SkillIdResolver.isActiveCast(skill)).thenReturn(true); + ids.when(() -> SkillIdResolver.resolveSkillId(skill)).thenReturn("fireball"); + hand.when(() -> GearHand.heldSlot(p)).thenReturn(GearHand.HeldSlot.MAIN_HAND); + hand.when(() -> GearHand.held(p)).thenReturn(weapon); + listener.onPlayerCastSkill(event); + verify(event).setCancelled(true); + ElementRegistry.register(DomainTest.element("fire")); + var requirement = WeaponRequirement.fromItem(weapon); + requirement.mergeAmounts(Map.of("fire", 5.)); + requirement.persist(weapon); + listener.onPlayerCastSkill(event); + requirement.mergeAmounts(Map.of("fire", 20.)); + requirement.persist(weapon); + listener.onPlayerCastSkill(event); + verify(event, times(3)).setCancelled(true); + when(plugin.getResonanceGuiManager().getSessionManager().get(p)).thenReturn(null); + listener.onPlayerCastSkill(event); + Cache.refuseChatMillis = 0; + listener.onPlayerCastSkill(event); + } + } + + @Test + void successfulAndFumbledCastsWearTheHeldSlotExactlyOnce() { + var event = mock(PlayerCastSkillEvent.class); + var skill = mock(Skill.class); + var metadata = mock(SkillMetadata.class); + var p = server.addPlayer(); + var weapon = new ItemStack(Material.STICK); + when(event.getCast()).thenReturn(skill); + when(event.getPlayer()).thenReturn(p); + when(event.getMetadata()).thenReturn(metadata); + SkillElementRegistry.register("fireball", "fire", 1); + ElementRegistry.register(DomainTest.element("fire")); + TierBands.register(null, 1, 1); + var req = WeaponRequirement.fromItem(weapon); + req.mergeAmounts(Map.of("fire", 20.)); + req.persist(weapon); + var session = mock(ResonanceSession.class); + when(session.getResonance("fire")).thenReturn(20.); + when(plugin.getResonanceGuiManager().getSessionManager().get(p)).thenReturn(session); + var nbt = mock(NBTItem.class); + var durability = mock(DurabilityItem.class); + when(durability.decreaseDurability(1)).thenReturn(durability); + when(durability.toItem()).thenReturn(weapon); + try (var ids = mockStatic(SkillIdResolver.class); + var hand = mockStatic(GearHand.class); + var nbtApi = mockStatic(NBTItem.class); + var durabilityApi = mockStatic(DurabilityItem.class); + var rifts = mockStatic(WeaponRift.class); + var random = mockStatic(ThreadLocalRandom.class)) { + ids.when(() -> SkillIdResolver.isActiveCast(skill)).thenReturn(true); + ids.when(() -> SkillIdResolver.resolveSkillId(skill)).thenReturn("fireball"); + hand.when(() -> GearHand.heldSlot(p)).thenReturn(GearHand.HeldSlot.MAIN_HAND); + hand.when(() -> GearHand.held(p)).thenReturn(weapon); + nbtApi.when(() -> NBTItem.get(weapon)).thenReturn(nbt); + listener.onPlayerCastSkill(event); + verify(event, never()).setCancelled(true); + when(nbt.getInteger("MMOITEMS_MAX_DURABILITY")).thenReturn(10); + listener.onPlayerCastSkill(event); + durabilityApi + .when(() -> DurabilityItem.from(p, nbt, EquipmentSlot.HAND)) + .thenReturn(durability); + listener.onPlayerCastSkill(event); + hand.verify(() -> GearHand.setHeld(p, GearHand.HeldSlot.MAIN_HAND, weapon)); + hand.when(() -> GearHand.heldSlot(p)).thenReturn(GearHand.HeldSlot.OFF_HAND); + durabilityApi + .when(() -> DurabilityItem.from(p, nbt, EquipmentSlot.OFF_HAND)) + .thenReturn(durability); + hand.when(() -> GearHand.staffCount(p)).thenReturn(2); + listener.onPlayerCastSkill(event); + verify(skill).whenCast(metadata); + hand.verify(() -> GearHand.setHeld(p, GearHand.HeldSlot.OFF_HAND, weapon)); + hand.when(() -> GearHand.staffCount(p)).thenReturn(1); + var rng = mock(ThreadLocalRandom.class); + random.when(ThreadLocalRandom::current).thenReturn(rng); + rifts.when(() -> WeaponRift.get(weapon)).thenReturn(50); + when(rng.nextDouble()).thenReturn(.25, .75); + listener.onPlayerCastSkill(event); + listener.onPlayerCastSkill(event); + verify(skill, times(2)).whenCast(metadata); + verify(event, times(2)).setCancelled(true); + verify(durability, times(4)).decreaseDurability(1); + } + } + + @Test + void refusalRateLimiterExpiresOldEntriesButKeepsRecentOnes() throws Exception { + var field = ResonanceCastListener.class.getDeclaredField("lastRefuseChat"); + field.setAccessible(true); + var map = (Map) field.get(null); + for (int i = 0; i < 513; i++) map.put("old" + i, 0L); + map.put("recent", System.currentTimeMillis()); + var claim = + ResonanceCastListener.class.getDeclaredMethod( + "claimChat", org.bukkit.entity.Player.class, ItemStack.class, String.class); + claim.setAccessible(true); + var p = server.addPlayer(); + var weapon = new ItemStack(Material.STICK); + assertEquals(true, claim.invoke(null, p, weapon, "fire")); + assertEquals(2, map.size()); + assertEquals(false, claim.invoke(null, p, weapon, "fire")); + map.replaceAll((key, time) -> 0L); + assertEquals(true, claim.invoke(null, p, weapon, "fire")); + Cache.refuseChatMillis = 0; + assertEquals(true, claim.invoke(null, p, weapon, "fire")); + } +} diff --git a/src/test/java/net/tfminecraft/magic/ResonanceGuiCoverageTest.java b/src/test/java/net/tfminecraft/magic/ResonanceGuiCoverageTest.java new file mode 100644 index 0000000..7faac05 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/ResonanceGuiCoverageTest.java @@ -0,0 +1,252 @@ +package net.tfminecraft.magic; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.*; +import net.tfminecraft.magic.gui.*; +import net.tfminecraft.magic.integration.*; +import net.tfminecraft.magic.manager.*; +import net.tfminecraft.magic.model.*; +import net.tfminecraft.magic.modifier.*; +import net.tfminecraft.magic.registry.*; +import net.tfminecraft.magic.session.*; +import net.tfminecraft.magic.util.GridLayout; +import net.tfminecraft.rpcharacters.RPCharacters; +import net.tfminecraft.rpcharacters.api.CharacterSkull; +import net.tfminecraft.rpcharacters.focus.FocusService; +import net.tfminecraft.rpcharacters.objects.RPCharacter; +import org.bukkit.*; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.entity.*; +import org.bukkit.event.inventory.*; +import org.bukkit.inventory.*; +import org.junit.jupiter.api.*; +import org.mockito.*; + +class ResonanceGuiCoverageTest extends GearCoverageSupport { + org.mockbukkit.mockbukkit.entity.PlayerMock p; + MockedStatic bridge; + MockedStatic skull; + MockedStatic rp; + + @BeforeEach + void guiSetup() { + p = server.addPlayer(); + p.openInventory(Bukkit.createInventory(null, 9)); + ElementRegistry.clear(); + ArtifactGuiCoverageTest.element("fire", 21); + ArtifactGuiCoverageTest.element("hidden", -1); + var yaml = new YamlConfiguration(); + yaml.set("slot", 23); + yaml.set("permission", "magic.secret"); + ElementRegistry.register(new ElementDef("locked", yaml)); + bridge = mockStatic(RpCharactersBridge.class); + skull = mockStatic(CharacterSkull.class); + skull + .when(() -> CharacterSkull.ofOwner(p)) + .thenAnswer(a -> new ItemStack(Material.PLAYER_HEAD)); + rp = mockStatic(RPCharacters.class); + } + + @AfterEach + void guiCleanup() { + rp.close(); + skull.close(); + bridge.close(); + ElementRegistry.clear(); + } + + InventoryClickEvent click(int slot) { + var event = mock(InventoryClickEvent.class); + when(event.getWhoClicked()).thenReturn(p); + when(event.getView()).thenReturn(p.getOpenInventory()); + when(event.getRawSlot()).thenReturn(slot); + return event; + } + + @Test + void builderRendersCharacterFocusElementsAndBothModes() throws Exception { + var session = new ResonanceSession(); + var holder = new ResonanceGuiHolder(p.getUniqueId(), "character"); + assertEquals(p.getUniqueId(), holder.getPlayerUuid()); + assertEquals("character", holder.getCharacterId()); + var inventory = ResonanceGuiBuilder.build(p, holder, session); + holder.setInventory(inventory); + assertSame(inventory, holder.getInventory()); + assertEquals(Material.PLAYER_HEAD, inventory.getItem(GridLayout.characterHeadSlot()).getType()); + assertNotNull(inventory.getItem(21)); + var character = mock(RPCharacter.class); + when(character.getName()).thenReturn("Mage"); + when(character.getSkull()).thenAnswer(a -> new ItemStack(Material.PLAYER_HEAD)); + bridge.when(() -> RpCharactersBridge.getActiveCharacter(p)).thenReturn(character); + for (String tab : Arrays.asList(null, " ", "Display Name")) { + bridge.when(() -> RpCharactersBridge.resolveDisplayTab(p)).thenReturn(tab); + ResonanceGuiBuilder.populate(inventory, p, session); + assertTrue( + inventory + .getItem(GridLayout.characterHeadSlot()) + .getItemMeta() + .getDisplayName() + .contains(tab == null || tab.isBlank() ? "Mage" : "Display Name")); + } + var focus = mock(FocusService.class); + when(focus.getPoints(p)).thenReturn(7); + when(focus.getMax()).thenReturn(20); + rp.when(RPCharacters::getFocusService).thenReturn(focus); + session.setEquilibrium(-20); + session.setResonance("fire", 10); + ResonanceGuiBuilder.populate(inventory, p, session); + session.setEquilibrium(30); + session.setCastModeId(GuiCache.castModeRight.getId()); + ResonanceGuiBuilder.populate(inventory, p, session); + verify(focus, times(2)).getPoints(p); + assertEquals( + "Surge", + invoke( + ResonanceGuiBuilder.class, + "formatCastModeLabel", + new Class[] {String.class}, + (Object) null)); + assertEquals( + "Surge", + invoke(ResonanceGuiBuilder.class, "formatCastModeLabel", new Class[] {String.class}, " ")); + assertEquals( + "X", + invoke(ResonanceGuiBuilder.class, "formatCastModeLabel", new Class[] {String.class}, "x")); + for (String id : + Arrays.asList( + null, " ", "unknown", GuiCache.castModeLeft.getId(), GuiCache.castModeRight.getId())) + assertNotNull( + invoke(ResonanceGuiBuilder.class, "normalizeCastModeId", new Class[] {String.class}, id)); + invoke( + ResonanceGuiBuilder.class, + "buildElementItem", + new Class[] {ElementDef.class, ResonanceSession.class}, + ElementRegistry.getById("fire"), + null); + invoke( + ResonanceGuiBuilder.class, + "buildCastModeItem", + new Class[] {CastModeDef.class, boolean.class, ResonanceSession.class}, + new CastModeDef("other", "v.PAPER", "Other", List.of()), + false, + session); + } + + @Test + void managerHonorsCharacterGateChangesModeAndClosesStaleCharacters() throws Exception { + var manager = new ResonanceGuiManager(); + assertNotNull(manager.getSessionManager()); + bridge.when(RpCharactersBridge::isAvailable).thenReturn(true); + assertFalse(manager.tryOpen(p, false)); + assertTrue(manager.tryOpen(p, true)); + manager.refreshOpenInventories(); + var same = click(GridLayout.castModeLeftSlot()); + manager.onInventoryClick(same); + manager.onInventoryClick(same); + var event = click(GridLayout.castModeRightSlot()); + manager.onInventoryClick(event); + assertEquals( + GuiCache.castModeRight.getId(), manager.getSessionManager().get(p).getCastModeId()); + manager.onInventoryClick(click(GridLayout.castModeLeftSlot())); + manager.onInventoryClick(click(1)); + manager.onInventoryClick(click(60)); + manager.closeIfCharacterChanged(p, "new"); + assertNull(p.getOpenInventory().getTopInventory()); + p.openInventory(Bukkit.createInventory(null, 9)); + manager.closeIfCharacterChanged(p, "new"); + manager.refreshOpenInventories(); + var nonplayer = click(0); + when(nonplayer.getWhoClicked()).thenReturn(mock(HumanEntity.class)); + manager.onInventoryClick(nonplayer); + manager.onInventoryClick(click(0)); + var character = mock(RPCharacter.class); + when(character.getId()).thenReturn("c1"); + when(character.getName()).thenReturn("Mage"); + when(character.getSkull()).thenAnswer(a -> new ItemStack(Material.PLAYER_HEAD)); + bridge.when(() -> RpCharactersBridge.getActiveCharacter(p)).thenReturn(character); + assertTrue(manager.tryOpen(p, false)); + manager.closeIfCharacterChanged(p, "c1"); + assertTrue(p.getOpenInventory().getTopInventory().getHolder() instanceof ResonanceGuiHolder); + var drag = mock(InventoryDragEvent.class); + when(drag.getView()).thenReturn(p.getOpenInventory()); + manager.onInventoryDrag(drag); + verify(drag).setCancelled(true); + manager.closeIfCharacterChanged(p, "c2"); + p.openInventory(Bukkit.createInventory(null, 9)); + when(drag.getView()).thenReturn(p.getOpenInventory()); + manager.onInventoryDrag(drag); + bridge.when(RpCharactersBridge::isAvailable).thenReturn(false); + assertTrue(manager.tryOpen(p, false)); + for (String mode : Arrays.asList(null, " ", "surge", "flow", "OTHER")) + assertNotNull( + invoke(ResonanceGuiManager.class, "castModeChatLabel", new Class[] {String.class}, mode)); + } + + @Test + void modifierLoreRendersSignedPercentagesAndCurrentAxisOnlyWhenActive() throws Exception { + assertTrue(ModifierLore.linesForElement(null, null).isEmpty()); + var fire = ElementRegistry.getById("fire"); + assertEquals(8, ModifierLore.linesForElement(fire, null).size()); + var session = new ResonanceSession(); + session.setEquilibrium(0); + assertEquals(4, ModifierLore.linesForSurge(null).size()); + assertEquals(4, ModifierLore.linesForFlow(null).size()); + assertEquals(4, ModifierLore.linesForSurge(session).size()); + assertEquals(4, ModifierLore.linesForFlow(session).size()); + session.setEquilibrium(-20); + assertEquals(8, ModifierLore.linesForSurge(session).size()); + session.setEquilibrium(30); + assertEquals(8, ModifierLore.linesForFlow(session).size()); + assertEquals(8, ModifierLore.linesForElement(fire, session).size()); + for (double value : List.of(-.2, 0., .2)) { + assertEquals( + value < 0 ? "-20%" : value > 0 ? "+20%" : "0%", + invoke(ModifierLore.class, "formatPercent", new Class[] {double.class}, value)); + for (boolean higher : List.of(false, true)) + assertEquals( + value == 0 ? "§7" : (higher == (value > 0)) ? "§a" : "§c", + invoke( + ModifierLore.class, + "percentColor", + new Class[] {double.class, boolean.class}, + value, + higher)); + } + assertTrue( + ((String) + invoke( + ModifierLore.class, + "axisHeader", + new Class[] {String.class, double.class}, + null, + 12)) + .contains("12")); + var lines = new ArrayList(); + invoke( + ModifierLore.class, + "addBlock", + new Class[] {List.class, String.class, ModifierTriple.class}, + lines, + "heading", + null); + assertTrue(lines.isEmpty()); + } + + @Test + void zeroDecayElementOmitsDriftLine() throws Exception { + var config = new YamlConfiguration(); + config.set("decay_per_hour", 0); + var element = new ElementDef("steady", config); + var item = + (ItemStack) + invoke( + ResonanceGuiBuilder.class, + "buildElementItem", + new Class[] {ElementDef.class, ResonanceSession.class}, + element, + null); + assertNotNull(item.getItemMeta().getLore()); + } +} diff --git a/src/test/java/net/tfminecraft/magic/RpBridgeCoverageTest.java b/src/test/java/net/tfminecraft/magic/RpBridgeCoverageTest.java new file mode 100644 index 0000000..d1ace93 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/RpBridgeCoverageTest.java @@ -0,0 +1,131 @@ +package net.tfminecraft.magic; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.*; +import net.tfminecraft.magic.artifact.sacrifice.*; +import net.tfminecraft.magic.integration.RpCharactersBridge; +import net.tfminecraft.rpcharacters.RPCharacters; +import net.tfminecraft.rpcharacters.identity.DisplayIdentityService; +import net.tfminecraft.rpcharacters.mail.CharacterMailTarget; +import net.tfminecraft.rpcharacters.managers.PlayerManager; +import net.tfminecraft.rpcharacters.objects.*; +import net.tfminecraft.rpcharacters.permadeath.*; +import org.bukkit.Bukkit; +import org.bukkit.plugin.PluginManager; +import org.junit.jupiter.api.*; +import org.mockbukkit.mockbukkit.*; + +class RpBridgeCoverageTest { + ServerMock server; + + @BeforeEach + void setup() { + server = MockBukkit.mock(); + } + + @AfterEach + void stop() { + MockBukkit.unmock(); + } + + @Test + void missingDependencyAndNullInputsHaveSafeFallback() { + try (var b = mockStatic(Bukkit.class, CALLS_REAL_METHODS)) { + var plugins = mock(PluginManager.class); + b.when(Bukkit::getPluginManager).thenReturn(plugins); + var p = server.addPlayer(); + assertFalse(RpCharactersBridge.isAvailable()); + assertNull(RpCharactersBridge.getActiveCharacter(p)); + assertEquals("", RpCharactersBridge.resolveDisplayTab(p)); + assertEquals("", RpCharactersBridge.resolveCharacterName("id")); + when(plugins.isPluginEnabled("RPCharacters")).thenReturn(true); + assertNull(RpCharactersBridge.getActiveCharacter(null)); + assertEquals("", RpCharactersBridge.resolveDisplayTab(null)); + assertEquals("", RpCharactersBridge.resolveCharacterName(null)); + assertEquals("", RpCharactersBridge.resolveCharacterName(" ")); + } + } + + @Test + void onlineCharacterNamesAndOfflineMailTargetsResolve() { + var p = server.addPlayer(); + try (var b = mockStatic(Bukkit.class, CALLS_REAL_METHODS); + var rp = mockStatic(RPCharacters.class); + var pm = mockStatic(PlayerManager.class); + var names = mockStatic(DisplayIdentityService.class)) { + var plugins = mock(PluginManager.class); + when(plugins.isPluginEnabled("RPCharacters")).thenReturn(true); + b.when(Bukkit::getPluginManager).thenReturn(plugins); + var character = mock(RPCharacter.class); + rp.when(() -> RPCharacters.getActiveCharacter(p)).thenReturn(character); + assertSame(character, RpCharactersBridge.getActiveCharacter(p)); + names.when(() -> DisplayIdentityService.resolveDisplayTab(p)).thenReturn("name"); + names.when(() -> DisplayIdentityService.ensureDisplayTabWhite("name")).thenReturn("§fName"); + assertEquals("§fName", RpCharactersBridge.resolveDisplayTab(p)); + assertEquals("", RpCharactersBridge.resolveCharacterName("id")); + var data = mock(PlayerData.class); + pm.when(() -> PlayerManager.get(p)).thenReturn(data); + assertEquals("", RpCharactersBridge.resolveCharacterName("id")); + when(data.getCharacterById("id")).thenReturn(character); + assertEquals("", RpCharactersBridge.resolveCharacterName("id")); + when(character.getName()).thenReturn(" "); + assertEquals("", RpCharactersBridge.resolveCharacterName("id")); + when(character.getName()).thenReturn("Online name"); + assertEquals("Online name", RpCharactersBridge.resolveCharacterName("id")); + when(data.getCharacterById("id")).thenReturn(null); + var wrong = mock(CharacterMailTarget.class); + when(wrong.getCharacterId()).thenReturn("other"); + var unnamed = mock(CharacterMailTarget.class); + when(unnamed.getCharacterId()).thenReturn("id"); + var blank = mock(CharacterMailTarget.class); + when(blank.getCharacterId()).thenReturn("id"); + when(blank.getDisplayPlain()).thenReturn(" "); + var match = mock(CharacterMailTarget.class); + when(match.getCharacterId()).thenReturn("id"); + when(match.getDisplayPlain()).thenReturn("Offline name"); + rp.when(RPCharacters::listMailTargets) + .thenReturn(Arrays.asList(null, wrong, unnamed, blank, match)); + assertEquals("Offline name", RpCharactersBridge.resolveCharacterName("id")); + } + } + + @Test + void requiredCharacterConsequencesFailClosedAndApplyConfiguredInjuries() { + var victim = server.addPlayer(); + var caster = server.addPlayer(); + var healing = new SacrificeTierDef("heal", 0, 0, 0, "healing", false); + var permanent = new SacrificeTierDef("permanent", 0, 0, 0, "permanent", false); + var fatal = new SacrificeTierDef("death", 0, 0, 0, "none", true); + var none = new SacrificeTierDef("none", 0, 0, 0, null, false); + try (var b = mockStatic(Bukkit.class, CALLS_REAL_METHODS); + var rp = mockStatic(RPCharacters.class); + var registry = mockStatic(SacrificeRegistry.class); + var consequences = mockStatic(PermadeathService.class)) { + var plugins = mock(PluginManager.class); + b.when(Bukkit::getPluginManager).thenReturn(plugins); + registry.when(SacrificeRegistry::isRequireRpCharacters).thenReturn(true); + assertFalse(RpCharactersBridge.applySacrificeTier(victim, caster, null)); + assertFalse(RpCharactersBridge.applySacrificeTier(victim, caster, healing)); + registry.when(SacrificeRegistry::isRequireRpCharacters).thenReturn(false); + assertTrue(RpCharactersBridge.applySacrificeTier(victim, caster, null)); + assertTrue(RpCharactersBridge.applySacrificeTier(victim, caster, healing)); + when(plugins.isPluginEnabled("RPCharacters")).thenReturn(true); + assertTrue(RpCharactersBridge.applySacrificeTier(victim, caster, healing)); + registry.when(SacrificeRegistry::isRequireRpCharacters).thenReturn(true); + assertFalse(RpCharactersBridge.applySacrificeTier(victim, caster, healing)); + var character = mock(RPCharacter.class); + rp.when(() -> RPCharacters.getActiveCharacter(victim)).thenReturn(character); + assertTrue(RpCharactersBridge.applySacrificeTier(victim, caster, healing)); + consequences.verify(() -> PermadeathService.applyRandomInjury(victim, character)); + assertTrue(RpCharactersBridge.applySacrificeTier(victim, caster, permanent)); + consequences.verify(() -> PermadeathService.applyRandomPermanentInjury(victim, character)); + assertTrue(RpCharactersBridge.applySacrificeTier(victim, caster, fatal)); + consequences.verify( + () -> + PermadeathService.killCharacter(victim, character, PermakillCause.SACRIFICE, caster)); + assertTrue(RpCharactersBridge.applySacrificeTier(victim, caster, none)); + } + } +} diff --git a/src/test/java/net/tfminecraft/magic/SacrificeConfigEdgeTest.java b/src/test/java/net/tfminecraft/magic/SacrificeConfigEdgeTest.java new file mode 100644 index 0000000..cc5dc3e --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/SacrificeConfigEdgeTest.java @@ -0,0 +1,84 @@ +package net.tfminecraft.magic; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.nio.file.*; +import java.util.*; +import net.tfminecraft.magic.artifact.sacrifice.*; +import net.tfminecraft.magic.registry.ElementRegistry; +import net.tfminecraft.tlibs.TLibs; +import net.tfminecraft.tlibs.objects.api.ItemAPI; +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.io.TempDir; +import org.mockbukkit.mockbukkit.MockBukkit; + +class SacrificeConfigEdgeTest { + @TempDir Path temp; + + @BeforeEach + void setup() { + MockBukkit.mock(); + Magic.plugin = mock(Magic.class); + when(Magic.plugin.getLogger()).thenReturn(java.util.logging.Logger.getAnonymousLogger()); + SacrificeRegistry.clear(); + ElementRegistry.clear(); + ElementRegistry.register(DomainTest.element("fire")); + } + + @AfterEach + void cleanup() { + SacrificeRegistry.clear(); + ElementRegistry.clear(); + Magic.plugin = null; + MockBukkit.unmock(); + } + + @Test + void blankDaggerFallsBackAndCaseSensitiveWordsAndBlankSoundsLoad() throws Exception { + var file = temp.resolve("sacrifice.yml"); + Files.writeString( + file, + "dagger: ''\n" + + "case_insensitive: false\n" + + "pedestal_id: ' '\n" + + "elements:\n" + + " fire: {words: [Word], min_scenery_aura: -1}\n" + + "fx: {surge_sound: ' ', boom_sound: ' '}\n"); + assertTrue(SacrificeConfigLoader.load(file.toFile())); + assertEquals(SacrificeDaggerDef.DEFAULT_PATH, SacrificeRegistry.getDagger().getPath()); + assertEquals(50, SacrificeRegistry.minSceneryAura("fire")); + assertEquals(SacrificeDaggerDef.DEFAULT_PATH, new SacrificeDaggerDef(" ").getPath()); + assertEquals(SacrificeDaggerDef.DEFAULT_PATH, new SacrificeDaggerDef(null).getPath()); + Files.writeString(file, "elements: {}\nfx: {}\n"); + assertTrue(SacrificeConfigLoader.load(file.toFile())); + } + + @Test + void fxDefinitionsFilterBlankTiersAndExposeDefensiveLists() { + var fx = + new SacrificeFxDef( + new LinkedHashSet<>(Arrays.asList(null, " ", " SOUL ")), null, 1, 1, null, 1, 1); + assertEquals(List.of("soul"), fx.getSurgeTiers()); + assertFalse(fx.surges(null)); + assertFalse(fx.surges(" ")); + assertFalse(fx.surges("pain")); + assertTrue(fx.surges("SOUL")); + assertFalse(new SacrificeFxDef(null, null, 1, 1, null, 1, 1).surges("soul")); + } + + @Test + void daggerMatcherUsesConfiguredItemPath() { + var item = new ItemStack(Material.GOLDEN_SWORD); + try (var libs = mockStatic(TLibs.class)) { + var api = mock(ItemAPI.class, RETURNS_DEEP_STUBS); + libs.when(TLibs::getItemAPI).thenReturn(api); + when(api.getChecker().checkItemWithPath(item, SacrificeDaggerDef.DEFAULT_PATH)) + .thenReturn(true); + assertTrue(SacrificeDaggerMatcher.matches(item)); + assertFalse(SacrificeDaggerMatcher.matches(null)); + } + } +} diff --git a/src/test/java/net/tfminecraft/magic/SacrificeDomainTest.java b/src/test/java/net/tfminecraft/magic/SacrificeDomainTest.java new file mode 100644 index 0000000..4b6df65 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/SacrificeDomainTest.java @@ -0,0 +1,272 @@ +package net.tfminecraft.magic; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.*; +import net.tfminecraft.magic.artifact.ArtifactKeys; +import net.tfminecraft.magic.artifact.sacrifice.*; +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; +import org.bukkit.persistence.PersistentDataType; +import org.junit.jupiter.api.*; +import org.mockbukkit.mockbukkit.MockBukkit; + +class SacrificeDomainTest { + @BeforeEach + void setup() { + MockBukkit.mock(); + Magic.plugin = mock(Magic.class); + when(Magic.plugin.namespace()).thenReturn("magic"); + SacrificeRegistry.clear(); + } + + @AfterEach + void cleanup() { + SacrificeRegistry.clear(); + Magic.plugin = null; + MockBukkit.unmock(); + } + + SacrificeElementDef element(String id, boolean enabled, List words) { + return new SacrificeElementDef( + id, + enabled, + words, + "Pain of {character}", + "Screams of {character}", + "Soul of {character}", + 20); + } + + void globals(SacrificeWordMatch mode, boolean ignoreCase) { + SacrificeRegistry.setGlobals( + 5, + 3, + mode, + ignoreCase, + false, + 5, + 2, + false, + false, + false, + " altar ", + " slot ", + new SacrificeDaggerDef(" v.STONE ")); + } + + @Test + void wordModesStripFormattingAndRespectCaseAndEnabledSchools() { + assertNull(SacrificeWordMatcher.matchElementId("spell")); + SacrificeRegistry.setEnabled(true); + assertNull(SacrificeWordMatcher.matchElementId("spell")); + SacrificeRegistry.register(element("disabled", false, List.of("spell"))); + SacrificeRegistry.register(element("fire", true, Arrays.asList(null, " ", "Spell"))); + assertNull(SacrificeWordMatcher.matchElementId(null)); + assertNull(SacrificeWordMatcher.matchElementId(" ")); + assertEquals("fire", SacrificeWordMatcher.matchElementId(" §cSPELL now ")); + assertNull(SacrificeWordMatcher.matchElementId("say Spell")); + globals(SacrificeWordMatch.EXACT, false); + assertEquals("fire", SacrificeWordMatcher.matchElementId("Spell")); + assertNull(SacrificeWordMatcher.matchElementId("spell")); + assertNull(SacrificeWordMatcher.matchElementId("Spell now")); + globals(SacrificeWordMatch.CONTAINS, true); + assertEquals("fire", SacrificeWordMatcher.matchElementId("say SPELL now")); + assertEquals(SacrificeWordMatch.STARTS_WITH, SacrificeWordMatch.fromConfig(null)); + assertEquals(SacrificeWordMatch.STARTS_WITH, SacrificeWordMatch.fromConfig(" ")); + assertEquals(SacrificeWordMatch.STARTS_WITH, SacrificeWordMatch.fromConfig("starts-with")); + assertEquals(SacrificeWordMatch.EXACT, SacrificeWordMatch.fromConfig(" EXACT ")); + assertEquals(SacrificeWordMatch.CONTAINS, SacrificeWordMatch.fromConfig("contains")); + } + + @Test + void tiersUseInclusiveLowerExclusiveUpperBoundAndNormalizeConsequences() { + var tier = new SacrificeTierDef(" WOUND ", .2, .5, .3, " HEALING ", false); + assertEquals("wound", tier.getId()); + assertEquals(.2, tier.getMin()); + assertEquals(.5, tier.getBelow()); + assertEquals(.3, tier.getAuraFraction()); + assertEquals("healing", tier.getInjury()); + assertFalse(tier.isPermadeath()); + assertFalse(tier.matches(.1)); + assertTrue(tier.matches(.2)); + assertTrue(tier.matches(.49)); + assertFalse(tier.matches(.5)); + assertTrue(new SacrificeTierDef(null, 0, 0, 0, null, false).matches(10)); + assertEquals("none", new SacrificeTierDef("x", 0, 0, 0, " ", false).getInjury()); + assertNull(SacrificeImprint.stageForTier(null)); + assertEquals("pain", SacrificeImprint.stageForTier(tier)); + assertEquals( + "pain", + SacrificeImprint.stageForTier(new SacrificeTierDef("other", 0, 0, 0, "healing", false))); + assertEquals( + "screams", + SacrificeImprint.stageForTier(new SacrificeTierDef("maim", 0, 0, 0, "none", false))); + assertEquals( + "screams", + SacrificeImprint.stageForTier(new SacrificeTierDef("other", 0, 0, 0, "permanent", false))); + assertEquals( + "soul", SacrificeImprint.stageForTier(new SacrificeTierDef("death", 0, 0, 0, null, false))); + assertEquals( + "soul", SacrificeImprint.stageForTier(new SacrificeTierDef("other", 0, 0, 0, null, true))); + assertNull(SacrificeImprint.stageForTier(new SacrificeTierDef("none", 0, 0, 0, null, false))); + SacrificeRegistry.registerTier(null); + SacrificeRegistry.registerTier(new SacrificeTierDef(null, 0, 0, 0, null, false)); + assertEquals(List.of(), SacrificeRegistry.getTiers()); + assertEquals("none", SacrificeRegistry.tierForCharge(1).getId()); + SacrificeRegistry.registerTier(tier); + assertSame(tier, SacrificeRegistry.getTier(" WOUND ")); + assertNull(SacrificeRegistry.getTier(null)); + assertSame(tier, SacrificeRegistry.tierForCharge(.3)); + var none = new SacrificeTierDef("none", 9, 10, 0, null, false); + SacrificeRegistry.registerTier(none); + assertSame(none, SacrificeRegistry.tierForCharge(1)); + } + + @Test + void registryDefaultsOverridesAndChargeBlockingAreConsistent() { + assertFalse(SacrificeRegistry.isEnabled()); + assertTrue(SacrificeRegistry.isSceneryCharge()); + assertFalse(SacrificeRegistry.blocksSceneryCharge("fire")); + globals(null, false); + assertEquals(5, SacrificeRegistry.getChargeSeconds()); + assertEquals(3, SacrificeRegistry.getVictimRange()); + assertEquals(5, SacrificeRegistry.getMinSceneryAura()); + assertEquals(2, SacrificeRegistry.getHintMinAura()); + assertFalse(SacrificeRegistry.isRequireMinScore()); + assertFalse(SacrificeRegistry.isRequireDaggerInHand()); + assertFalse(SacrificeRegistry.isOneRitePerCaster()); + assertFalse(SacrificeRegistry.isRefuseNewWordsWhileActive()); + assertEquals("altar", SacrificeRegistry.getPedestalId()); + assertEquals("slot", SacrificeRegistry.getPedestalSlot()); + assertEquals("v.STONE", SacrificeRegistry.getDagger().getPath()); + SacrificeRegistry.setGlobals(0, 0, null, true, true, -1, -1, true, true, true, null, " ", null); + assertEquals(10, SacrificeRegistry.getChargeSeconds()); + assertEquals(4, SacrificeRegistry.getVictimRange()); + assertEquals(0, SacrificeRegistry.getMinSceneryAura()); + assertEquals("pedestal", SacrificeRegistry.getPedestalId()); + assertEquals("*", SacrificeRegistry.getPedestalSlot()); + SacrificeRegistry.setRpChannel(null); + assertEquals("rp", SacrificeRegistry.getRpChannel()); + SacrificeRegistry.setRpChannel(" "); + assertEquals("rp", SacrificeRegistry.getRpChannel()); + SacrificeRegistry.setRpChannel(" CHAT "); + assertEquals("chat", SacrificeRegistry.getRpChannel()); + SacrificeRegistry.setHideIncantation(true); + assertTrue(SacrificeRegistry.isHideIncantation()); + SacrificeRegistry.setRequireRpCharacters(false); + assertFalse(SacrificeRegistry.isRequireRpCharacters()); + SacrificeRegistry.setFx(null); + assertNotNull(SacrificeRegistry.getFx()); + var fx = SacrificeFxDef.empty(); + SacrificeRegistry.setFx(fx); + assertSame(fx, SacrificeRegistry.getFx()); + SacrificeRegistry.register(null); + SacrificeRegistry.register(element(null, true, null)); + assertEquals(0, SacrificeRegistry.size()); + var def = element(" FIRE ", true, List.of("word")); + SacrificeRegistry.register(def); + assertEquals(List.of(def), SacrificeRegistry.getAll()); + assertEquals(20, SacrificeRegistry.minSceneryAura("fire")); + assertEquals(0, SacrificeRegistry.minSceneryAura("missing")); + assertNull(SacrificeRegistry.getById(null)); + SacrificeRegistry.setEnabled(true); + assertFalse(SacrificeRegistry.blocksSceneryCharge("fire")); + SacrificeRegistry.setSceneryCharge(false); + assertFalse(SacrificeRegistry.blocksSceneryCharge(null)); + assertFalse(SacrificeRegistry.blocksSceneryCharge(" ")); + assertFalse(SacrificeRegistry.blocksSceneryCharge("missing")); + assertTrue(SacrificeRegistry.blocksSceneryCharge(" FIRE ")); + assertEquals("v.golden_sword", new SacrificeDaggerDef(" ").getPath()); + } + + @Test + void imprintsUpgradeSeverityAndPreserveKnownNameAndElement() { + var defaults = new SacrificeImprint(null, null, null, null); + assertEquals("", defaults.getCharacterId()); + assertEquals("", defaults.getCharacterName()); + assertEquals("", defaults.getElementId()); + assertEquals("pain", defaults.getStage()); + assertEquals(0, defaults.rank()); + assertEquals("pain", SacrificeImprint.normalizeStage("unknown")); + assertEquals("soul", SacrificeImprint.normalizeStage("death")); + assertEquals(1, SacrificeImprint.rankOf("screams")); + assertEquals(2, SacrificeImprint.rankOf("soul")); + var first = new SacrificeImprint("a", "Alice", "pain", " FIRE "); + var second = new SacrificeImprint("b", "Bob", "screams"); + var existing = List.of(first, second); + assertEquals(existing, SacrificeImprint.upsert(existing, null)); + assertEquals(existing, SacrificeImprint.upsert(existing, defaults)); + assertEquals(1, SacrificeImprint.upsert(null, first).size()); + var updated = SacrificeImprint.upsert(existing, new SacrificeImprint("a", "", "soul", "")); + assertEquals(2, updated.size()); + assertEquals("Alice", updated.getFirst().getCharacterName()); + assertEquals("fire", updated.getFirst().getElementId()); + assertEquals("soul", updated.getFirst().getStage()); + updated = + SacrificeImprint.upsert(updated, new SacrificeImprint("a", "New Name", "pain", "water")); + assertEquals("New Name", updated.getFirst().getCharacterName()); + assertEquals("soul", updated.getFirst().getStage()); + assertEquals("water", updated.getFirst().getElementId()); + assertEquals( + 3, SacrificeImprint.upsert(existing, new SacrificeImprint("c", "C", "pain")).size()); + } + + @Test + void imprintStorageRoundTripsSanitizedNamesAndSkipsMalformedEntries() { + var item = new ItemStack(Material.STONE); + assertEquals(List.of(), SacrificeImprintStore.read(null)); + assertEquals(List.of(), SacrificeImprintStore.read(item)); + SacrificeImprintStore.write(null, List.of()); + SacrificeImprintStore.write(new ItemStack(Material.AIR), List.of()); + var entries = + Arrays.asList( + null, + new SacrificeImprint(null, null, null), + new SacrificeImprint("id", "Alice|Bob;C", "soul", "fire"), + new SacrificeImprint("two", "Two", "pain")); + SacrificeImprintStore.write(item, entries); + var read = SacrificeImprintStore.read(item); + assertEquals(2, read.size()); + assertEquals("Alice Bob C", read.getFirst().getCharacterName()); + assertEquals("fire", read.getFirst().getElementId()); + var def = element("fire", true, List.of()); + assertEquals("Soul of Alice Bob C", SacrificeImprintStore.formatLine(read.getFirst(), def)); + assertEquals("Pain of Two", SacrificeImprintStore.formatLine(read.get(1), def)); + assertEquals( + "Screams of X", + SacrificeImprintStore.formatLine(new SacrificeImprint("x", "X", "screams"), def)); + assertEquals("", SacrificeImprintStore.formatLine(null, def)); + assertEquals("", SacrificeImprintStore.formatLine(read.getFirst(), null)); + assertEquals( + "", + SacrificeImprintStore.formatLine( + read.getFirst(), new SacrificeElementDef("x", null, null, null, null, 0))); + var meta = item.getItemMeta(); + meta.getPersistentDataContainer() + .set( + ArtifactKeys.sacrificeImprints(), + PersistentDataType.STRING, + "bad; ;|name|pain;old|Old|pain;new|New|soul|fire"); + item.setItemMeta(meta); + assertEquals(2, SacrificeImprintStore.read(item).size()); + SacrificeImprintStore.write(item, Arrays.asList(null, new SacrificeImprint(null, null, null))); + assertTrue(SacrificeImprintStore.read(item).isEmpty()); + SacrificeImprintStore.write(item, null); + SacrificeImprintStore.write(item, List.of()); + } + + @Test + void absentAndBlankStoredImprintsReadAsEmpty() { + assertTrue(SacrificeImprintStore.read(null).isEmpty()); + assertTrue(SacrificeImprintStore.read(new ItemStack(Material.AIR)).isEmpty()); + var item = new ItemStack(Material.STONE); + assertTrue(SacrificeImprintStore.read(item).isEmpty()); + var meta = item.getItemMeta(); + meta.getPersistentDataContainer() + .set(ArtifactKeys.sacrificeImprints(), PersistentDataType.STRING, " "); + item.setItemMeta(meta); + assertTrue(SacrificeImprintStore.read(item).isEmpty()); + } +} diff --git a/src/test/java/net/tfminecraft/magic/SessionServiceTest.java b/src/test/java/net/tfminecraft/magic/SessionServiceTest.java new file mode 100644 index 0000000..cb87069 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/SessionServiceTest.java @@ -0,0 +1,241 @@ +package net.tfminecraft.magic; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.nio.file.Path; +import java.util.*; +import java.util.logging.Logger; +import net.tfminecraft.magic.integration.RpCharactersBridge; +import net.tfminecraft.magic.model.ElementDef; +import net.tfminecraft.magic.profile.*; +import net.tfminecraft.magic.registry.ElementRegistry; +import net.tfminecraft.magic.service.*; +import net.tfminecraft.magic.session.*; +import net.tfminecraft.rpcharacters.objects.RPCharacter; +import org.bukkit.configuration.file.YamlConfiguration; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.io.TempDir; +import org.mockbukkit.mockbukkit.*; + +class SessionServiceTest { + @TempDir Path temp; + ServerMock server; + ResonanceSessionManager sessions; + MagicProfileStore store; + MagicProfileService service; + + @BeforeEach + void setup() { + server = MockBukkit.mock(); + Magic.plugin = mock(Magic.class); + when(Magic.plugin.getLogger()).thenReturn(Logger.getAnonymousLogger()); + sessions = new ResonanceSessionManager(); + store = new MagicProfileStore(temp.toFile()); + service = new MagicProfileService(store, sessions); + when(Magic.plugin.getProfileService()).thenReturn(service); + ElementRegistry.clear(); + ElementRegistry.register(DomainTest.element("fire")); + Cache.defaultEquilibrium = 0; + Cache.defaultCastMode = "flow"; + Cache.debug = false; + Cache.secondsPerHour = 1; + Cache.tickIntervalTicks = 20; + GuiCache.equilibriumMin = -100; + GuiCache.equilibriumMax = 100; + Cache.equilibriumPassiveCorruptPerHour = .05; + Cache.equilibriumCorruptCompoundStrength = 1.25; + Cache.equilibriumTranquilityDecayPerHour = .03; + } + + @AfterEach + void cleanup() { + Magic.plugin = null; + MockBukkit.unmock(); + ElementRegistry.clear(); + Cache.debug = false; + Cache.secondsPerHour = 3600; + } + + RPCharacter character(String id) { + RPCharacter character = mock(RPCharacter.class); + when(character.getId()).thenReturn(id); + return character; + } + + @Test + void sessionManagerTracksPlayersAndClearsCharacterIdentity() { + var player = server.addPlayer(); + assertNull(sessions.get(null)); + assertNull(sessions.get(player)); + assertNull(sessions.getLoadedCharacterId(null)); + sessions.setLoadedCharacterId(null, "id"); + var first = sessions.getOrCreate(player); + assertSame(first, sessions.getOrCreate(player)); + assertSame(first, sessions.get(player)); + sessions.setLoadedCharacterId(player, "id"); + assertEquals("id", sessions.getLoadedCharacterId(player)); + sessions.setLoadedCharacterId(player, null); + assertNull(sessions.getLoadedCharacterId(player)); + sessions.setLoadedCharacterId(player, " "); + assertNull(sessions.getLoadedCharacterId(player)); + List seen = new ArrayList<>(); + sessions.forEachOnlineSession( + (p, s) -> { + assertSame(player, p); + assertSame(first, s); + seen.add(s); + }); + assertEquals(1, seen.size()); + player.disconnect(); + sessions.forEachOnlineSession((p, s) -> fail("Offline sessions must not tick")); + sessions.remove(null); + sessions.remove(player.getUniqueId()); + assertNull(sessions.get(player)); + } + + @Test + void activationCreatesRestoresAndAvoidsOverwritingLiveSession() { + var player = server.addPlayer(); + service.activate(null, character("x")); + service.activate(player, null); + service.activate(player, character(null)); + service.activate(player, character(" ")); + assertNull(sessions.get(player)); + Cache.debug = true; + service.activate(player, character("one")); + assertTrue(store.exists("one")); + assertEquals("one", service.characterId(player)); + sessions.get(player).setResonance("fire", 25); + service.activate(player, character("one")); + assertEquals(25, sessions.get(player).getResonance("fire")); + service.savePrevious(null, character("one")); + service.savePrevious(player, null); + service.savePrevious(player, character(null)); + service.savePrevious(player, character(" ")); + service.savePrevious(player, character("one")); + assertEquals(25, store.load("one").getResonance().get("fire")); + service.deactivate(null); + service.deactivate(player); + assertNull(sessions.get(player)); + service.activate(player, character("one")); + assertEquals(25, sessions.get(player).getResonance("fire")); + assertSame(player, service.findOnlineWithCharacter("one")); + assertNull(service.findOnlineWithCharacter(null)); + assertNull(service.findOnlineWithCharacter(" ")); + assertNull(service.findOnlineWithCharacter("missing")); + service.saveAllOnline(); + service.deactivate(player); + service.savePrevious(player, character("one")); + } + + @Test + void onlineLoadingAndFallbackSavingUseActiveRoleplayCharacter() { + var player = server.addPlayer(); + var empty = server.addPlayer(); + try (var bridge = mockStatic(RpCharactersBridge.class)) { + var active = character("active"); + bridge.when(() -> RpCharactersBridge.getActiveCharacter(player)).thenReturn(active); + assertSame(player, service.findOnlineWithCharacter("active")); + service.loadOnlineCharacters(); + assertEquals("active", service.characterId(player)); + assertNull(service.characterId(empty)); + sessions.setLoadedCharacterId(player, null); + sessions.get(player).setResonance("fire", 17); + service.savePlayer(player); + assertEquals(17, store.load("active").getResonance().get("fire")); + service.savePlayer(empty); + sessions.remove(player.getUniqueId()); + service.savePlayer(player); + assertEquals(17, store.load("active").getResonance().get("fire")); + } + } + + @Test + void equilibriumTickClampsCorruptionAndStopsTranquilityAtZero() { + var player = server.addPlayer(); + var s = sessions.getOrCreate(player); + EquilibriumService.tickOnlineSessions(sessions); + assertEquals(0, s.getEquilibrium()); + s.setEquilibrium(-99.99); + EquilibriumService.tickOnlineSessions(sessions); + assertEquals(-100, s.getEquilibrium()); + s.setEquilibrium(.01); + EquilibriumService.tickOnlineSessions(sessions); + assertEquals(0, s.getEquilibrium()); + Cache.debug = true; + s.setEquilibrium(20); + EquilibriumService.tickOnlineSessions(sessions); + assertEquals(19.97, s.getEquilibrium()); + } + + @Test + void resonanceTicksDecayGrowUnlockedElementsAndPersistChanges() { + ElementRegistry.clear(); + var cfg = new YamlConfiguration(); + cfg.set("decay_per_hour", 0); + ElementRegistry.register(new ElementDef("constant", cfg)); + cfg.set("decay_per_hour", -.5); + ElementRegistry.register(new ElementDef("decay", cfg)); + cfg.set("decay_per_hour", .5); + cfg.set("permission", "magic.grow"); + ElementRegistry.register(new ElementDef("grow", cfg)); + var player = server.addPlayer(); + service.activate(player, character("one")); + var s = sessions.get(player); + s.setResonance("constant", 10); + s.setResonance("decay", 10); + ResonanceService.tickOnlineSessions(sessions); + assertEquals(9.5, s.getResonance("decay")); + assertEquals(0, s.getResonance("grow")); + assertEquals(10, s.getResonance("constant")); + assertEquals(9.5, store.load("one").getResonance().get("decay")); + player.addAttachment(MockBukkit.createMockPlugin(), "magic.grow", true); + Cache.debug = true; + ResonanceService.tickOnlineSessions(sessions); + assertEquals(.5, s.getResonance("grow")); + s.setResonance("decay", 0); + ResonanceService.tickOnlineSessions(sessions); + assertEquals(0, s.getResonance("decay")); + } + + @Test + void activatingCorruptProfilePreservesOriginalBytes() throws Exception { + var corrupt = temp.resolve("corrupt.json"); + String original = "{broken profile"; + java.nio.file.Files.writeString(corrupt, original); + var player = server.addPlayer(); + service.activate(player, character("corrupt")); + assertEquals("corrupt", service.characterId(player)); + try (var files = java.nio.file.Files.list(temp)) { + var backup = + files + .filter(p -> p.getFileName().toString().startsWith("corrupt.json.rejected-")) + .findFirst() + .orElseThrow(); + assertEquals(original, java.nio.file.Files.readString(backup)); + } + assertNotNull(store.load("corrupt")); + } + + @Test + void decayContinuesWhenPersistenceIsUnavailableAndCleanTicksDoNotSave() { + var player = server.addPlayer(); + var session = sessions.getOrCreate(player); + var profiles = mock(MagicProfileService.class); + when(Magic.plugin.getProfileService()).thenReturn(profiles); + ElementRegistry.clear(); + ResonanceService.tickOnlineSessions(sessions); + verifyNoInteractions(profiles); + var cfg = new YamlConfiguration(); + cfg.set("decay_per_hour", -.5); + ElementRegistry.register(new ElementDef("decay", cfg)); + session.setResonance("decay", 10); + when(Magic.plugin.getProfileService()).thenReturn(null); + ResonanceService.tickOnlineSessions(sessions); + assertEquals(9.5, session.getResonance("decay")); + Magic.plugin = null; + ResonanceService.tickOnlineSessions(sessions); + assertEquals(9, session.getResonance("decay")); + } +} diff --git a/src/test/java/net/tfminecraft/magic/ShrineScoringTest.java b/src/test/java/net/tfminecraft/magic/ShrineScoringTest.java new file mode 100644 index 0000000..2299ca5 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/ShrineScoringTest.java @@ -0,0 +1,164 @@ +package net.tfminecraft.magic; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.*; +import net.tfminecraft.magic.artifact.shrine.*; +import org.bukkit.*; +import org.junit.jupiter.api.*; +import org.mockbukkit.mockbukkit.*; + +class ShrineScoringTest { + ServerMock server; + + @BeforeEach + void setup() { + server = MockBukkit.mock(); + ShrineRegistry.clear(); + Cache.artifactAuraCap = 100; + } + + @AfterEach + void cleanup() { + ShrineRegistry.clear(); + MockBukkit.unmock(); + Cache.artifactAuraCap = 150; + } + + @Test + void invalidCentersAndSparseShrinesGiveNoAura() { + assertTrue(ShrineScorer.score(null).getByElement().isEmpty()); + assertTrue(ShrineScorer.score(new Location(null, 0, 0, 0)).getByElement().isEmpty()); + var world = server.addSimpleWorld("world"); + var center = new Location(world, 0, 70, 0); + ShrineRegistry.setScan(1, .2, 10, 2); + ShrineRegistry.register( + new ShrineElementDef( + "fire", + List.of( + new ShrineFamily("stone", 10, 1, Set.of(Material.STONE)), + new ShrineFamily("wood", 10, 1, Set.of(Material.OAK_PLANKS))))); + world.getBlockAt(0, 70, 0).setType(Material.STONE); + assertEquals(0, ShrineScorer.score(center).get("fire").getMaxAura()); + world.getBlockAt(1, 70, 0).setType(Material.STONE); + var score = ShrineScorer.score(center).get("fire"); + assertEquals(0, score.getMaxAura()); + assertEquals(List.of("stone"), score.getActiveFamilyIds()); + assertEquals(List.of(), score.getContributingBlocks()); + } + + @Test + void varietyAndFamilyCapsDetermineAuraAndEmittersAreUniqueStableAndBounded() { + var world = server.addSimpleWorld("world"); + var center = new Location(world, 0, 70, 0); + ShrineRegistry.setScan(2, 0, 10, 2); + var stone = new ShrineFamily("stone", 4, 2, Set.of(Material.STONE, Material.COBBLESTONE)); + var wood = new ShrineFamily("wood", 2, 1, Set.of(Material.OAK_PLANKS)); + ShrineRegistry.register(new ShrineElementDef("fire", List.of(stone, wood))); + ShrineRegistry.register(new ShrineElementDef("empty", null)); + for (int x = -2; x <= 2; x++) + for (int z = -2; z <= 2; z++) world.getBlockAt(x, 70, z).setType(Material.STONE); + world.getBlockAt(-2, 70, -2).setType(Material.OAK_PLANKS); + world.getBlockAt(2, 70, 2).setType(Material.OAK_PLANKS); + var first = ShrineScorer.score(center); + var fire = first.get("fire"); + assertEquals(100, fire.getMaxAura()); + assertEquals(10, fire.getAuraPerSecond()); + assertEquals(List.of("stone", "wood"), fire.getActiveFamilyIds()); + assertEquals(14, fire.getContributingBlocks().size()); + assertEquals(14, new HashSet<>(fire.getContributingBlocks()).size()); + assertEquals( + fire.getContributingBlocks(), + ShrineScorer.score(center).get("fire").getContributingBlocks()); + assertEquals(0, first.get("empty").getMaxAura()); + assertEquals(List.of(), first.get("empty").getContributingBlocks()); + } + + @Test + void familyAndRegistryDefinitionsNormalizeAndDefendCopies() { + var family = new ShrineFamily("f", 0, -1, null); + assertEquals("f", family.getId()); + assertEquals(1, family.getMaxCount()); + assertEquals(1, family.getWeight()); + assertFalse(family.matches(null)); + assertFalse(family.matches(Material.STONE)); + assertTrue(new ShrineFamily("s", 1, 1, Set.of(Material.STONE)).matches(Material.STONE)); + var def = + new ShrineElementDef( + " FIRE ", false, List.of(new ShrineFamily("s", 2, 3, Set.of(Material.STONE)))); + assertEquals("fire", def.getElementId()); + assertFalse(def.isSceneryCharge()); + assertEquals(6, def.perfectScore()); + assertEquals("", new ShrineElementDef(null, null).getElementId()); + ShrineRegistry.register(null); + ShrineRegistry.register(new ShrineElementDef(" ", null)); + assertEquals(0, ShrineRegistry.size()); + ShrineRegistry.register(def); + assertSame(def, ShrineRegistry.getById(" FIRE ")); + assertNull(ShrineRegistry.getById(null)); + assertNull(ShrineRegistry.getById(" ")); + assertEquals(List.of(def), ShrineRegistry.getAll()); + ShrineRegistry.setScan(0, -1, -1, 0); + assertEquals(1, ShrineRegistry.getRadius()); + assertEquals(0, ShrineRegistry.getMinScore()); + assertEquals(15, ShrineRegistry.getFullChargeSeconds()); + assertEquals(1, ShrineRegistry.getMinFamilies()); + ShrineRegistry.setDefaultFx(null); + var fallback = ShrineRegistry.fx(null); + assertSame(fallback, ShrineRegistry.fx(" ")); + assertSame(fallback, ShrineRegistry.fx("missing")); + ShrineRegistry.setElementFx(null, fallback); + ShrineRegistry.setElementFx(" ", fallback); + ShrineRegistry.setElementFx("fire", null); + var specific = ShrineFxDef.fallback(); + ShrineRegistry.setElementFx(" FIRE ", specific); + assertSame(specific, ShrineRegistry.fx("fire")); + ShrineRegistry.setDefaultFx(specific); + assertSame(specific, ShrineRegistry.fx(null)); + } + + static Object call(String name, Class[] types, Object... args) throws Exception { + var method = ShrineScorer.class.getDeclaredMethod(name, types); + method.setAccessible(true); + return method.invoke(null, args); + } + + @Test + void emitterSelectionDeduplicatesFamiliesAndHandlesSparseInvalidCoordinates() throws Exception { + var world = server.addSimpleWorld("world"); + var a = new Location(world, 0, 0, 0); + var b = new Location(world, 1, 0, 0); + var c = new Location(world, -1, 0, 0); + var groups = Arrays.asList(null, Arrays.asList(null, a, a, b, c)); + assertEquals(List.of(a, b, c), call("uniqueLocations", new Class[] {List.class}, groups)); + assertEquals( + b, + call( + "farthestFrom", + new Class[] {List.class, Location.class, Set.class}, + Arrays.asList(null, a, b, c), + a, + Set.of())); + assertEquals("|0|0|0", call("key", new Class[] {Location.class}, new Location(null, 0, 0, 0))); + assertEquals( + 3, + ((List) + call( + "pickEmitters", + new Class[] {List.class, Location.class, long.class}, + List.of(List.of(a, b, c), List.of(a, b, c)), + a, + 1L)) + .size()); + assertEquals( + 2, + ((List) + call( + "pickEmitters", + new Class[] {List.class, Location.class, long.class}, + List.of(List.of(a, b)), + new Location(world, Double.NaN, 0, 0), + 1L)) + .size()); + } +} diff --git a/src/test/java/net/tfminecraft/magic/SkillResolverEdgeTest.java b/src/test/java/net/tfminecraft/magic/SkillResolverEdgeTest.java new file mode 100644 index 0000000..b24a826 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/SkillResolverEdgeTest.java @@ -0,0 +1,87 @@ +package net.tfminecraft.magic; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import io.lumine.mythic.lib.skill.*; +import io.lumine.mythic.lib.skill.handler.SkillHandler; +import io.lumine.mythic.lib.skill.trigger.TriggerType; +import java.util.*; +import net.Indyuce.mmocore.MMOCore; +import net.Indyuce.mmocore.manager.SkillManager; +import net.Indyuce.mmocore.skill.*; +import net.tfminecraft.magic.integration.SkillIdResolver; +import org.junit.jupiter.api.*; + +class SkillResolverEdgeTest { + MMOCore previous; + + @BeforeEach + void setup() { + previous = MMOCore.plugin; + } + + @AfterEach + void cleanup() { + MMOCore.plugin = previous; + Cache.setCastTriggers(null); + } + + @Test + void customTriggerCanUseLowercaseIdentifierWithoutEnumName() { + var skill = mock(Skill.class); + var trigger = mock(TriggerType.class); + when(skill.getTrigger()).thenReturn(trigger); + Cache.setCastTriggers(List.of("custom")); + assertFalse(SkillIdResolver.isActiveCast(skill)); + when(trigger.getLowerCaseId()).thenReturn("custom"); + assertTrue(SkillIdResolver.isActiveCast(skill)); + when(trigger.name()).thenReturn("different"); + assertTrue(SkillIdResolver.isActiveCast(skill)); + when(trigger.getLowerCaseId()).thenReturn("different"); + assertFalse(SkillIdResolver.isActiveCast(skill)); + } + + @Test + void coreCastFallsBackToRegisteredNameWhenHandlerIdMissing() { + var cast = mock(CastableSkill.class, RETURNS_DEEP_STUBS); + when(cast.getHandler()).thenReturn(null); + when(cast.getSkill().getSkill()).thenReturn(null); + assertNull(SkillIdResolver.resolveSkillId(cast)); + var registered = mock(SkillHandler.class); + when(cast.getSkill().getSkill()).thenReturn(registered); + assertNull(SkillIdResolver.resolveSkillId(cast)); + when(registered.getName()).thenReturn(" "); + assertNull(SkillIdResolver.resolveSkillId(cast)); + when(registered.getName()).thenReturn("Fireball"); + assertEquals("Fireball", SkillIdResolver.resolveSkillId(cast)); + } + + @Test + void bindingLookupSupportsNamesAndHandlersCaseInsensitively() throws Exception { + MMOCore.plugin = mock(MMOCore.class); + var manager = mock(SkillManager.class); + var field = MMOCore.class.getDeclaredField("skillManager"); + field.setAccessible(true); + field.set(MMOCore.plugin, manager); + var exact = mock(RegisteredSkill.class); + var handler = mock(SkillHandler.class); + when(manager.getSkill("exact")).thenReturn(exact); + when(exact.getHandler()).thenReturn(handler); + assertSame(handler, SkillIdResolver.handlerForBinding("exact")); + when(exact.getHandler()).thenReturn(null); + assertNull(SkillIdResolver.handlerForBinding("exact")); + var named = mock(RegisteredSkill.class); + when(named.getName()).thenReturn("Named"); + when(named.getHandler()).thenReturn(handler); + var handlerMatch = mock(RegisteredSkill.class); + when(handlerMatch.getName()).thenReturn("different"); + var second = mock(SkillHandler.class); + when(handlerMatch.getHandler()).thenReturn(second); + when(second.getLowerCaseId()).thenReturn("ability"); + when(manager.getAll()).thenReturn(Arrays.asList(null, exact, named, handlerMatch)); + assertSame(handler, SkillIdResolver.handlerForBinding("NAMED")); + assertSame(second, SkillIdResolver.handlerForBinding("ABILITY")); + assertNull(SkillIdResolver.handlerForBinding("unknown")); + } +} diff --git a/src/test/java/net/tfminecraft/magic/SpellDomainTest.java b/src/test/java/net/tfminecraft/magic/SpellDomainTest.java new file mode 100644 index 0000000..9eed842 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/SpellDomainTest.java @@ -0,0 +1,225 @@ +package net.tfminecraft.magic; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import io.lumine.mythic.lib.api.event.skill.SkillCastEvent; +import io.lumine.mythic.lib.skill.Skill; +import io.lumine.mythic.lib.skill.SkillMetadata; +import io.lumine.mythic.lib.skill.handler.SkillHandler; +import io.lumine.mythic.lib.skill.trigger.TriggerType; +import java.util.*; +import net.tfminecraft.magic.artifact.config.ArtifactTypeRegistry; +import net.tfminecraft.magic.artifact.sacrifice.SacrificeRegistry; +import net.tfminecraft.magic.artifact.shrine.ShrineRegistry; +import net.tfminecraft.magic.charge.*; +import net.tfminecraft.magic.gear.*; +import net.tfminecraft.magic.integration.SkillIdResolver; +import net.tfminecraft.magic.listener.*; +import net.tfminecraft.magic.modifier.*; +import net.tfminecraft.magic.registry.ElementRegistry; +import net.tfminecraft.magic.session.*; +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; +import org.bukkit.persistence.PersistentDataType; +import org.junit.jupiter.api.*; +import org.mockbukkit.mockbukkit.*; + +class SpellDomainTest { + ServerMock server; + + @BeforeEach + void setup() { + server = MockBukkit.mock(); + Magic.plugin = mock(Magic.class, RETURNS_DEEP_STUBS); + when(Magic.plugin.namespace()).thenReturn("magic"); + ElementRegistry.clear(); + ElementRegistry.register(DomainTest.element("fire")); + ArtifactTypeRegistry.clear(); + ShrineRegistry.clear(); + SacrificeRegistry.clear(); + TierBands.clear(); + ChargeRegistry.clear(); + Cache.setCastTriggers(null); + Cache.defaultEquilibrium = 0; + Cache.defaultCastMode = "flow"; + Cache.castDriftManaDivisor = 1000; + Cache.castDriftMin = .01; + GuiCache.equilibriumMin = -100; + GuiCache.equilibriumMax = 100; + GearCache.alignmentEnabled = true; + GearCache.clearAlignment(); + } + + @AfterEach + void cleanup() { + Magic.plugin = null; + MockBukkit.unmock(); + ElementRegistry.clear(); + ArtifactTypeRegistry.clear(); + ShrineRegistry.clear(); + SacrificeRegistry.clear(); + TierBands.clear(); + ChargeRegistry.clear(); + Cache.setCastTriggers(null); + GearCache.alignmentEnabled = false; + GearCache.clearAlignment(); + } + + @Test + void castTriggersAndHandlerIdsResolveWithoutAssumingMmoCoreCastType() { + assertFalse(SkillIdResolver.isActiveCast(null)); + Skill skill = mock(Skill.class); + assertFalse(SkillIdResolver.isActiveCast(skill)); + when(skill.getTrigger()).thenReturn(TriggerType.CAST); + assertTrue(SkillIdResolver.isActiveCast(skill)); + when(skill.getTrigger()).thenReturn(TriggerType.API); + assertTrue(SkillIdResolver.isActiveCast(skill)); + when(skill.getTrigger()).thenReturn(TriggerType.RIGHT_CLICK); + assertFalse(SkillIdResolver.isActiveCast(skill)); + Cache.setCastTriggers(List.of("right_click")); + assertTrue(SkillIdResolver.isActiveCast(skill)); + assertNull(SkillIdResolver.resolveSkillId(null)); + assertNull(SkillIdResolver.resolveSkillId(skill)); + var handler = mock(SkillHandler.class); + when(skill.getHandler()).thenReturn(handler); + assertNull(SkillIdResolver.resolveSkillId(skill)); + when(handler.getLowerCaseId()).thenReturn(" "); + assertNull(SkillIdResolver.resolveSkillId(skill)); + when(handler.getLowerCaseId()).thenReturn("fireball"); + assertEquals("fireball", SkillIdResolver.resolveSkillId(skill)); + assertNull(SkillIdResolver.handlerForBinding(null)); + assertNull(SkillIdResolver.handlerForBinding(" ")); + assertNull(SkillIdResolver.handlerForBinding("fireball")); + } + + @Test + void spellGateRequiresBothWeaponAndCharacterTier() { + assertEquals(SpellTierGate.Refuse.FOREIGN, SpellTierGate.refuse(false, 4, 4, 1)); + assertEquals(SpellTierGate.Refuse.WEAPON, SpellTierGate.refuse(true, 1, 4, 2)); + assertEquals(SpellTierGate.Refuse.SPELL, SpellTierGate.refuse(true, 4, 1, 2)); + assertEquals(SpellTierGate.Refuse.NONE, SpellTierGate.refuse(true, 4, 4, 2)); + assertEquals(SpellTierGate.Refuse.NONE, SpellTierGate.refuse(true, 1, 1, -1)); + } + + @Test + void castManaDriftUsesActualSpentManaAndSavesOnlyLoadedSessions() { + var listener = new CastDriftListener(); + var event = mock(SkillCastEvent.class); + listener.onSkillCast(event); + var skill = mock(Skill.class); + when(skill.getTrigger()).thenReturn(TriggerType.CAST); + when(event.getCast()).thenReturn(skill); + listener.onSkillCast(event); + var player = server.addPlayer(); + when(event.getPlayer()).thenReturn(player); + listener.onSkillCast(event); + when(skill.getParameter("mana")).thenReturn(40.); + var sessions = new ResonanceSessionManager(); + when(Magic.plugin.getResonanceGuiManager().getSessionManager()).thenReturn(sessions); + listener.onSkillCast(event); + var session = sessions.getOrCreate(player); + listener.onSkillCast(event); + assertEquals(.04, session.getEquilibrium()); + verify(Magic.plugin).syncSpellModifiers(player, session); + var metadata = mock(SkillMetadata.class); + when(event.getMetadata()).thenReturn(metadata); + when(metadata.getParameter("mana")).thenReturn(100.); + session.setCastModeId("surge"); + listener.onSkillCast(event); + assertEquals(-.06, session.getEquilibrium()); + when(metadata.getParameter("mana")).thenReturn(0.); + when(skill.getParameter("mana")).thenReturn(.1); + when(Magic.plugin.getProfileService()).thenReturn(null); + listener.onSkillCast(event); + assertEquals(-.07, session.getEquilibrium()); + var oldLeft = GuiCache.castModeLeft; + var oldRight = GuiCache.castModeRight; + try { + GuiCache.castModeLeft = new net.tfminecraft.magic.model.CastModeDef("", null, null, null); + session.setCastModeId(null); + listener.onSkillCast(event); + assertEquals(-.06, session.getEquilibrium(), 1e-9); + GuiCache.castModeLeft = + new net.tfminecraft.magic.model.CastModeDef("custom", null, null, null); + GuiCache.castModeRight = + new net.tfminecraft.magic.model.CastModeDef("surge", null, null, null); + session.setCastModeId("surge"); + listener.onSkillCast(event); + assertEquals(-.07, session.getEquilibrium(), 1e-9); + } finally { + GuiCache.castModeLeft = oldLeft; + GuiCache.castModeRight = oldRight; + } + Magic.plugin = null; + assertDoesNotThrow(() -> listener.onSkillCast(event)); + } + + @Test + void weaponRequirementsMergeOnlyHigherPlayableAuraAndPersistSeparately() { + var item = new ItemStack(Material.STICK); + var req = WeaponRequirement.fromItem(item); + assertFalse(req.hasStored()); + assertEquals(0, req.highestBand()); + req.mergeAmounts(null); + req.mergeFrom(null); + var amounts = new HashMap(); + amounts.put(null, 10.); + amounts.put("missing", null); + amounts.put("zero", 0.); + amounts.put("fire", 10.); + req.mergeAmounts(amounts); + assertEquals(10, req.aura().getFill("fire")); + req.mergeAmounts(Map.of("fire", 5.)); + assertEquals(10, req.aura().getFill("fire")); + req.mergeAmounts(Map.of("fire", 20.)); + assertEquals(20, req.aura().getCap("fire")); + assertTrue(req.hasStored()); + req.persist(item); + assertEquals(20, WeaponRequirement.fromItem(item).aura().getFill("fire")); + TierBands.register(null, 1, 5); + TierBands.register(null, 2, 15); + assertEquals(2, req.highestBand()); + assertEquals(0, WeaponRequirement.highestBand((Map) null)); + assertEquals(1, WeaponRequirement.highestBand(amounts)); + assertEquals(0, WeaponRequirement.highestBand((Charge) null)); + ChargeRegistry.register(new ChargeDef(1, "v.STONE", 30)); + var chargeItem = new ItemStack(Material.STONE); + var meta = chargeItem.getItemMeta(); + meta.getPersistentDataContainer().set(ChargeKeys.chargeTier(), PersistentDataType.INTEGER, 1); + chargeItem.setItemMeta(meta); + var charge = Charge.fromItem(chargeItem); + charge.setCap("fire", 30); + charge.setFill("fire", 25); + req.mergeFrom(charge); + assertEquals(25, req.aura().getFill("fire")); + assertEquals(2, WeaponRequirement.highestBand(charge)); + } + + @Test + void spellModifiersCombineElementCurvesDriftAndAlignment() { + assertTrue(SpellModifiers.resonance(null, 20).isZero()); + assertTrue(SpellModifiers.resonance(" ", 20).isZero()); + assertTrue(SpellModifiers.resonance("missing", 20).isZero()); + assertEquals(.2, SpellModifiers.resonanceAt100(" FIRE ").damage()); + assertEquals(.15, SpellModifiers.surgeAt60().damage()); + assertEquals(.25, SpellModifiers.tranquilityAt100().damage()); + assertTrue(SpellModifiers.drift(0).isZero()); + assertEquals(.15, SpellModifiers.drift(-60).damage()); + assertEquals(.25, SpellModifiers.drift(100).damage()); + assertTrue(SpellModifiers.combine(null, null).isZero()); + assertEquals(.1, SpellModifiers.combine(new ModifierTriple(0, .1, 0), null).damage(), 1e-10); + assertTrue(SpellModifiers.alignment((ItemStack) null, "fire").isZero()); + assertTrue(SpellModifiers.alignment((WeaponRequirement) null, "fire").isZero()); + var req = WeaponRequirement.fromItem(new ItemStack(Material.STICK)); + assertTrue(SpellModifiers.alignment(req, "missing").isZero()); + assertTrue(SpellModifiers.alignment(req, "fire").isZero()); + req.mergeAmounts(Map.of("fire", 20.)); + TierBands.register(null, 1, 1); + var triple = new ModifierTriple(0, .2, 0); + GearCache.putAlignment(1, triple); + assertSame(triple, SpellModifiers.alignment(req, "fire")); + GearCache.alignmentEnabled = false; + assertTrue(SpellModifiers.alignment(req, "fire").isZero()); + } +} diff --git a/src/test/java/net/tfminecraft/magic/SpellModifierCoverageTest.java b/src/test/java/net/tfminecraft/magic/SpellModifierCoverageTest.java new file mode 100644 index 0000000..bcda2ac --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/SpellModifierCoverageTest.java @@ -0,0 +1,252 @@ +package net.tfminecraft.magic; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import io.lumine.mythic.lib.api.player.MMOPlayerData; +import io.lumine.mythic.lib.player.skillmod.SkillModifier; +import io.lumine.mythic.lib.skill.handler.SkillHandler; +import java.lang.reflect.*; +import java.util.*; +import java.util.function.BiConsumer; +import net.tfminecraft.magic.charge.TierBands; +import net.tfminecraft.magic.gear.*; +import net.tfminecraft.magic.integration.*; +import net.tfminecraft.magic.modifier.*; +import net.tfminecraft.magic.registry.*; +import net.tfminecraft.magic.session.*; +import org.bukkit.*; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.bukkit.plugin.PluginManager; +import org.junit.jupiter.api.*; +import org.mockbukkit.mockbukkit.*; + +class SpellModifierCoverageTest { + ServerMock server; + Magic plugin; + + @BeforeEach + void setup() throws Exception { + server = MockBukkit.mock(); + plugin = mock(Magic.class); + Magic.plugin = plugin; + when(plugin.namespace()).thenReturn("magic"); + when(plugin.getLogger()).thenReturn(java.util.logging.Logger.getLogger("MagicTest")); + SkillElementRegistry.clear(); + ElementRegistry.clear(); + TierBands.clear(); + GearCache.alignmentEnabled = true; + Cache.debug = false; + for (String name : List.of("applied", "handlers", "unknownWarned")) { + var f = SpellModifierApplyService.class.getDeclaredField(name); + f.setAccessible(true); + var value = f.get(null); + if (value instanceof Map m) m.clear(); + else ((Set) value).clear(); + } + var f = SpellModifierApplyService.class.getDeclaredField("missingLogged"); + f.setAccessible(true); + f.setBoolean(null, false); + } + + @AfterEach + void cleanup() { + SpellModifierApplyService.clearAll(); + Magic.plugin = null; + SkillElementRegistry.clear(); + ElementRegistry.clear(); + TierBands.clear(); + GearCache.alignmentEnabled = false; + MockBukkit.unmock(); + } + + @Test + void absentDependenciesAndOfflinePlayersNeverRegisterModifiers() { + var p = server.addPlayer(); + var session = new ResonanceSession(); + try (var b = mockStatic(Bukkit.class, CALLS_REAL_METHODS); + var players = mockStatic(MMOPlayerData.class)) { + var pm = mock(PluginManager.class); + b.when(Bukkit::getPluginManager).thenReturn(pm); + assertFalse(SpellModifierApplyService.isAvailable()); + SpellModifierApplyService.clear(null); + SpellModifierApplyService.clear(p); + SpellModifierApplyService.sync(null, session); + Magic.plugin = null; + SpellModifierApplyService.sync(p, session); + Magic.plugin = plugin; + SpellModifierApplyService.sync(p, session); + SpellModifierApplyService.sync(p, session); + SpellModifierApplyService.syncOnline(null); + SpellModifierApplyService.syncChangedOnline(null); + SpellModifierApplyService.syncOnline(new ResonanceSessionManager()); + SpellModifierApplyService.syncChangedOnline(new ResonanceSessionManager()); + when(pm.isPluginEnabled("MythicLib")).thenReturn(true); + assertFalse(SpellModifierApplyService.isAvailable()); + when(pm.isPluginEnabled("MMOCore")).thenReturn(true); + assertTrue(SpellModifierApplyService.isAvailable()); + SpellModifierApplyService.syncOnline(null); + SpellModifierApplyService.syncChangedOnline(null); + SpellModifierApplyService.sync(p, session); + var data = mock(MMOPlayerData.class); + players.when(() -> MMOPlayerData.getOrNull(p)).thenReturn(data); + SpellModifierApplyService.sync(p, session); + when(data.isOnline()).thenReturn(true); + SpellModifierApplyService.sync(p, null); + SpellModifierApplyService.clearAll(); + } + } + + @Test + void unchangedSnapshotReusesModifiersChangedValuesAndReloadReplaceThem() { + var p = server.addPlayer(); + var session = new ResonanceSession(); + var sessions = new ResonanceSessionManager(); + var live = sessions.getOrCreate(p); + var data = mock(MMOPlayerData.class); + when(data.isOnline()).thenReturn(true); + var handler = mock(SkillHandler.class); + var observed = new ArrayList>(); + try (var b = mockStatic(Bukkit.class, CALLS_REAL_METHODS); + var players = mockStatic(MMOPlayerData.class); + var ids = mockStatic(SkillIdResolver.class); + var hands = mockStatic(GearHand.class); + var mods = mockStatic(SpellModifiers.class, CALLS_REAL_METHODS); + var made = mockConstruction(SkillModifier.class, (m, c) -> observed.add(c.arguments()))) { + var pm = mock(PluginManager.class); + when(pm.isPluginEnabled(anyString())).thenReturn(true); + b.when(Bukkit::getPluginManager).thenReturn(pm); + players.when(() -> MMOPlayerData.getOrNull(p)).thenReturn(data); + SkillElementRegistry.register("fireball", "fire"); + ids.when(() -> SkillIdResolver.handlerForBinding("fireball")).thenReturn(handler); + mods.when(() -> SpellModifiers.resonance(eq("fire"), anyDouble())) + .thenReturn(new ModifierTriple(.1, .2, -.1)); + mods.when(() -> SpellModifiers.drift(anyDouble())).thenReturn(ModifierTriple.ZERO); + mods.when(() -> SpellModifiers.alignment(any(WeaponRequirement.class), eq("fire"))) + .thenReturn(ModifierTriple.ZERO); + SpellModifierApplyService.sync(p, session); + assertEquals(3, made.constructed().size()); + assertEquals("magic-mana-fireball", observed.getFirst().get(0)); + assertEquals(10., (Double) observed.getFirst().get(3), 1e-8); + for (var modifier : made.constructed()) verify(modifier).register(data); + SpellModifierApplyService.sync(p, session); + assertEquals(3, made.constructed().size()); + SpellModifierApplyService.sync(p, live); + assertEquals(3, made.constructed().size()); + SpellModifierApplyService.syncChangedOnline(sessions); + assertEquals(3, made.constructed().size()); + SpellModifierApplyService.syncOnline(sessions); + assertEquals(6, made.constructed().size()); + for (var modifier : made.constructed().subList(0, 3)) verify(modifier).unregister(data); + mods.when(() -> SpellModifiers.resonance(eq("fire"), anyDouble())) + .thenReturn(ModifierTriple.ZERO); + SpellModifierApplyService.sync(p, live); + assertEquals(6, made.constructed().size()); + for (var modifier : made.constructed().subList(3, 6)) verify(modifier).unregister(data); + SpellModifierApplyService.clear(p); + SpellModifierApplyService.syncChangedOnline(sessions); + SpellModifierApplyService.clear(p); + } + } + + @Test + void alignmentChangesRebuildSnapshotAndUnknownSkillsWarnOnce() throws Exception { + var p = server.addPlayer(); + var session = mock(ResonanceSession.class); + when(session.modifierRevision()).thenReturn(1L); + var sessions = mock(ResonanceSessionManager.class); + doAnswer( + a -> { + ((BiConsumer) a.getArgument(0)).accept(p, session); + return null; + }) + .when(sessions) + .forEachOnlineSession(any()); + var data = mock(MMOPlayerData.class); + when(data.isOnline()).thenReturn(true); + var item = new ItemStack(Material.STICK); + ElementRegistry.register(DomainTest.element("fire")); + ElementRegistry.register(DomainTest.element("water")); + ElementRegistry.register(DomainTest.element("earth")); + TierBands.register("fire", 1, 10); + TierBands.register("fire", 2, 20); + var req = WeaponRequirement.fromItem(item); + req.mergeAmounts(Map.of("fire", 15., "water", 5., "earth", 0.)); + req.aura().setCap("earth", 1); + req.persist(item); + try (var b = mockStatic(Bukkit.class, CALLS_REAL_METHODS); + var players = mockStatic(MMOPlayerData.class); + var ids = mockStatic(SkillIdResolver.class); + var hands = mockStatic(GearHand.class)) { + var pm = mock(PluginManager.class); + when(pm.isPluginEnabled(anyString())).thenReturn(true); + b.when(Bukkit::getPluginManager).thenReturn(pm); + players.when(() -> MMOPlayerData.getOrNull(p)).thenReturn(data); + hands.when(() -> GearHand.held(p)).thenReturn(item); + SkillElementRegistry.register("unknown", "fire"); + Magic.plugin = null; + var warn = SpellModifierApplyService.class.getDeclaredMethod("warnUnknown", String.class); + warn.setAccessible(true); + warn.invoke(null, "unknown"); + Magic.plugin = plugin; + SkillElementRegistry.register("unknown2", "fire"); + SpellModifierApplyService.sync(p, session); + Cache.debug = true; + SkillElementRegistry.register("unknown3", "fire"); + SpellModifierApplyService.sync(p, session); + SpellModifierApplyService.sync(p, session); + req.mergeAmounts(Map.of("fire", 25.)); + req.persist(item); + SpellModifierApplyService.sync(p, session); + GearCache.alignmentEnabled = false; + SpellModifierApplyService.sync(p, session); + when(session.modifierRevision()).thenReturn(2L); + SpellModifierApplyService.syncChangedOnline(sessions); + doAnswer( + a -> { + ((BiConsumer) a.getArgument(0)).accept(p, null); + return null; + }) + .when(sessions) + .forEachOnlineSession(any()); + SpellModifierApplyService.syncChangedOnline(sessions); + SpellModifierApplyService.sync(p, session); + players.when(() -> MMOPlayerData.getOrNull(p)).thenReturn(null); + SpellModifierApplyService.clear(p); + } + } + + @Test + void cachedValueObjectsCompareEveryModifierAndHaveStableHashCodes() throws Exception { + var planClass = + Class.forName("net.tfminecraft.magic.integration.SpellModifierApplyService$SkillPlan"); + var ctor = planClass.getDeclaredConstructors()[0]; + ctor.setAccessible(true); + var handler = mock(SkillHandler.class); + Object base = ctor.newInstance("skill", handler, .1, .2, .3); + Object same = ctor.newInstance("skill", handler, .1, .2, .3); + assertEquals(base, base); + assertNotEquals(base, null); + assertNotEquals(base, "other"); + assertEquals(base, same); + assertEquals(base.hashCode(), same.hashCode()); + assertNotEquals(base, ctor.newInstance("other", handler, .1, .2, .3)); + assertNotEquals(base, ctor.newInstance("skill", handler, .2, .2, .3)); + assertNotEquals(base, ctor.newInstance("skill", handler, .1, .3, .3)); + assertNotEquals(base, ctor.newInstance("skill", handler, .1, .2, .4)); + var snapshot = + Class.forName("net.tfminecraft.magic.integration.SpellModifierApplyService$DesiredSnapshot") + .getDeclaredConstructors()[0]; + snapshot.setAccessible(true); + Object first = snapshot.newInstance(new TreeMap<>(), List.of(base)); + Object equal = snapshot.newInstance(new TreeMap<>(), List.of(same)); + assertEquals(first, first); + assertEquals(first, equal); + assertEquals(first.hashCode(), equal.hashCode()); + assertNotEquals(first, null); + assertNotEquals(first, "other"); + assertNotEquals(first, snapshot.newInstance(new TreeMap<>(Map.of("fire", 1)), List.of(base))); + assertNotEquals(first, snapshot.newInstance(new TreeMap<>(), List.of())); + } +} diff --git a/src/test/java/net/tfminecraft/magic/TextTest.java b/src/test/java/net/tfminecraft/magic/TextTest.java new file mode 100644 index 0000000..ecc1699 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/TextTest.java @@ -0,0 +1,143 @@ +package net.tfminecraft.magic; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.*; +import net.md_5.bungee.api.ChatColor; +import net.tfminecraft.magic.model.ElementDef; +import net.tfminecraft.magic.util.*; +import org.bukkit.Color; +import org.bukkit.configuration.file.YamlConfiguration; +import org.junit.jupiter.api.*; + +class TextTest { + @AfterEach + void reset() { + GuiCache.resetColors(null); + GuiCache.equilibriumMax = 100; + GuiCache.resonanceDriftLore = "{sign}{rate}/h"; + } + + String plain(String text) { + return ChatColor.stripColor(text); + } + + @Test + void formattingSubstitutesKnownAndMissingColorTokens() { + GuiCache.resetColors(Map.of("title", "#ff0000")); + assertEquals("", MagicText.format(null)); + assertEquals("hello", plain(MagicText.format("{color:title}hello"))); + assertEquals("hello", plain(GuiText.format("{color:missing}hello"))); + assertEquals("#ff0000", GuiText.color("title")); + assertEquals("#ffffff", MagicText.color("missing")); + assertEquals("", plain(GuiText.text("title", null))); + assertEquals("hi", plain(GuiText.text("title", "hi"))); + assertEquals(List.of(), GuiText.formatLoreLines(null)); + assertEquals(List.of(), GuiText.formatLoreLines(List.of())); + assertEquals(List.of("hi", ""), GuiText.formatLoreLines(Arrays.asList("hi", null))); + } + + @Test + void elementNamesStripExistingColorsAndSupportGradients() { + assertEquals("", MagicText.elementName(null)); + assertEquals("hello", plain(MagicText.elementText(null, "hello"))); + assertEquals("", plain(MagicText.elementText(null, null))); + var cfg = new YamlConfiguration(); + cfg.set("name", "#ff0000Fire"); + cfg.set("color", "#abcdef"); + assertEquals("Fire", plain(MagicText.elementName(new ElementDef("fire", cfg)))); + cfg.set("name", "#ff0000"); + assertEquals("fire", plain(MagicText.elementName(new ElementDef("fire", cfg)))); + cfg.set("name", " "); + assertEquals("fire", plain(MagicText.elementName(new ElementDef("fire", cfg)))); + cfg.set("name", "Fire"); + cfg.set("color", List.of("#ff0000", "#00ff00")); + assertEquals("Fire", plain(MagicText.elementName(new ElementDef("fire", cfg)))); + } + + @Test + void gradientsAndColorParsingHandleEndpointsAndInvalidInputs() { + assertEquals("#7a1515", MagicText.gradientHex(-1)); + assertEquals("#e8c547", MagicText.gradientHex(.5)); + assertEquals("#8fd98a", MagicText.gradientHex(2)); + for (String bad : new String[] {null, " ", "#fff", "zzzzzz"}) + assertEquals(Color.BLUE, MagicText.bukkitColor(bad, Color.BLUE)); + assertEquals(Color.fromRGB(0xff0000), MagicText.bukkitColor("#ff0000", Color.BLUE)); + assertEquals(Color.fromRGB(0xabcdef), MagicText.bukkitColor("abcdef", Color.BLUE)); + } + + @Test + void barsAlwaysContainConfiguredSegmentsAndDisplayClampedValues() { + GuiCache.equilibriumMin = -100; + GuiCache.equilibriumMax = 100; + GuiCache.equilibriumBarSegments = 20; + for (double value : new double[] {-1000, -50, 0, 50, 1000}) { + assertEquals("[" + "|".repeat(20) + "]", plain(EquilibriumBar.build(value))); + assertFalse(plain(EquilibriumBar.formatLore(value)).contains("{")); + assertEquals( + value == 0 ? "0" : String.valueOf((int) Math.min(100, Math.abs(value))), + plain(EquilibriumBar.formatValue(value))); + } + assertNull(EquilibriumBar.formatDriftLore(0)); + assertTrue(plain(EquilibriumBar.formatDriftLore(-50)).contains("+")); + assertTrue(plain(EquilibriumBar.formatDriftLore(50)).contains("-")); + GuiCache.equilibriumMax = 0; + assertEquals("[" + "|".repeat(20) + "]", plain(EquilibriumBar.build(0))); + var element = DomainTest.element("fire"); + assertEquals("[||||]", plain(ResonanceBar.build(element, 500, 4))); + assertTrue(plain(ResonanceBar.formatLore(element, 500)).contains("100/100")); + assertNotNull(ResonanceBar.formatDriftLore(element)); + assertNull(ResonanceBar.formatDriftLore(null)); + var cfg = new YamlConfiguration(); + cfg.set("decay_per_hour", 0); + assertNull(ResonanceBar.formatDriftLore(new ElementDef("fire", cfg))); + cfg.set("decay_per_hour", 1); + assertTrue(plain(ResonanceBar.formatDriftLore(new ElementDef("fire", cfg))).contains("+")); + GuiCache.resonanceDriftLore = null; + assertNull(ResonanceBar.formatDriftLore(element)); + } + + @Test + void costsSkipMissingKeysAndQuantitiesAndRemainSorted() { + var costs = new HashMap(); + costs.put(null, 3); + costs.put("v.STONE", 2); + costs.put("v.AIR", null); + costs.put("v.APPLE", 1); + assertEquals( + List.of("§7- §fv.APPLE §e1", "§7- §fv.STONE §e2"), CostFormatter.getCostsFormatted(costs)); + assertEquals(List.of(), CostFormatter.getCostsFormatted(null)); + assertEquals(List.of(), CostFormatter.getCostsFormatted(Map.of())); + var lore = new ArrayList(); + CostFormatter.appendInput(null, costs); + CostFormatter.appendInput(lore, null); + CostFormatter.appendInput(lore, Map.of()); + assertTrue(lore.isEmpty()); + CostFormatter.appendInput(lore, costs); + assertEquals(4, lore.size()); + assertEquals("", lore.getFirst()); + } + + @Test + void legacyModelDataUsesFirstFloatAndReplacesOtherComponents() { + var meta = org.mockito.Mockito.mock(org.bukkit.inventory.meta.ItemMeta.class); + var component = + org.mockito.Mockito.mock( + org.bukkit.inventory.meta.components.CustomModelDataComponent.class); + org.mockito.Mockito.when(meta.getCustomModelDataComponent()).thenReturn(component); + org.mockito.Mockito.when(component.getFloats()).thenReturn(List.of()); + assertFalse(LegacyModelData.has(meta)); + assertThrows(IllegalStateException.class, () -> LegacyModelData.get(meta)); + org.mockito.Mockito.when(component.getFloats()).thenReturn(List.of(42f)); + assertTrue(LegacyModelData.has(meta)); + assertEquals(42, LegacyModelData.get(meta)); + LegacyModelData.set(meta, 99); + org.mockito.Mockito.verify(component).setFloats(List.of(99f)); + org.mockito.Mockito.verify(component).setFlags(List.of()); + org.mockito.Mockito.verify(component).setStrings(List.of()); + org.mockito.Mockito.verify(component).setColors(List.of()); + org.mockito.Mockito.verify(meta).setCustomModelDataComponent(component); + LegacyModelData.set(meta, null); + org.mockito.Mockito.verify(meta).setCustomModelDataComponent(null); + } +} diff --git a/src/test/java/net/tfminecraft/magic/TickAndMessagesTest.java b/src/test/java/net/tfminecraft/magic/TickAndMessagesTest.java new file mode 100644 index 0000000..ef459fd --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/TickAndMessagesTest.java @@ -0,0 +1,164 @@ +package net.tfminecraft.magic; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.util.*; +import java.util.logging.Logger; +import net.tfminecraft.magic.command.ResonanceCommand; +import net.tfminecraft.magic.manager.*; +import net.tfminecraft.magic.tick.*; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.io.TempDir; +import org.mockbukkit.mockbukkit.*; + +class TickAndMessagesTest { + @TempDir Path temp; + ServerMock server; + + @BeforeEach + void setup() { + server = MockBukkit.mock(); + Magic.plugin = mock(Magic.class); + when(Magic.plugin.getLogger()).thenReturn(Logger.getAnonymousLogger()); + when(Magic.plugin.getServer()).thenReturn(server); + when(Magic.plugin.isEnabled()).thenReturn(true); + when(Magic.plugin.getName()).thenReturn("Magic"); + Cache.tickIntervalTicks = 20; + Cache.debug = false; + MagicTickService.clearHandlers(); + } + + @AfterEach + void cleanup() { + MagicTickService.stop(); + MagicTickService.clearHandlers(); + Magic.plugin = null; + MockBukkit.unmock(); + Cache.debug = false; + Cache.tickIntervalTicks = 20; + } + + @Test + void contextsExposeTimeAndPeriodicBoundaries() { + var c = MagicTickContext.of(7200); + assertEquals(7200, c.seconds()); + assertEquals(120, c.minutes()); + assertEquals(2, c.hours()); + assertTrue(c.every(5)); + assertFalse(c.every(0)); + assertFalse(c.every(-1)); + assertFalse(c.every(7)); + assertTrue(c.everyMinutes(2)); + assertTrue(c.everyHours(2)); + assertFalse(c.everyHours(3)); + } + + @Test + void schedulerRunsAllHandlersDespiteFailureAndStopsCleanly() { + assertFalse(MagicTickService.isRunning()); + MagicTickService.stop(); + MagicTickService.register(null); + List calls = new ArrayList<>(); + MagicTickHandler recorder = c -> calls.add(c.seconds()); + MagicTickService.register( + c -> { + throw new IllegalStateException("test failure"); + }); + MagicTickService.register(recorder); + long before = MagicTickService.getElapsedSeconds(); + Cache.debug = true; + MagicTickService.start(); + assertTrue(MagicTickService.isRunning()); + server.getScheduler().performTicks(1200); + assertEquals(60, calls.size()); + assertEquals(before + 60, MagicTickService.getElapsedSeconds()); + MagicTickService.unregister(recorder); + server.getScheduler().performTicks(20); + assertEquals(60, calls.size()); + MagicTickService.start(); + MagicTickService.stop(); + assertFalse(MagicTickService.isRunning()); + long stopped = MagicTickService.getElapsedSeconds(); + server.getScheduler().performTicks(40); + assertEquals(stopped, MagicTickService.getElapsedSeconds()); + } + + @Test + void messagesLoadLiveOverrideAndBundledFallbackWithPlaceholders() throws Exception { + String resource = "fallback: 'Bundled {name}'\nblank: ''\nlive: Bundled\n"; + when(Magic.plugin.getResource("messages.yml")) + .thenAnswer(i -> new ByteArrayInputStream(resource.getBytes(StandardCharsets.UTF_8))); + Path file = temp.resolve("messages.yml"); + Files.writeString(file, "live: 'Hello {name}'\nfallback: ''\n"); + Messages.load(file.toFile()); + assertEquals("Hello Alice", Messages.get("live", "name", "Alice")); + assertEquals("Hello ", Messages.get("live", "name", null, "ignored")); + assertEquals("Bundled Alex", Messages.get("fallback", "name", "Alex")); + assertEquals("blank", Messages.get("blank")); + assertEquals("missing", Messages.get("missing")); + Messages.load(temp.resolve("missing").toFile()); + assertEquals("Bundled", Messages.get("live")); + Messages.loadFromResources(); + assertEquals("Bundled", Messages.getRaw("live")); + when(Magic.plugin.getResource("messages.yml")).thenReturn(null); + Messages.loadFromResources(); + Messages.load(temp.resolve("missing").toFile()); + assertEquals("live", Messages.getRaw("live")); + when(Magic.plugin.getResource("messages.yml")) + .thenThrow(new IllegalStateException("resource failure")); + Messages.loadFromResources(); + assertEquals("missing", Messages.getRaw("missing")); + } + + @Test + void resonanceCommandChecksPermissionAndPlayerBeforeOpening() { + var command = new ResonanceCommand(); + CommandSender sender = mock(CommandSender.class); + assertTrue(command.onCommand(sender, null, "resonance", new String[0])); + verify(sender).sendMessage(Messages.get("open.no_permission")); + when(sender.hasPermission("magic.use")).thenReturn(true); + assertTrue(command.onCommand(sender, null, "resonance", new String[0])); + verify(sender).sendMessage(Messages.get("open.players_only")); + Player player = mock(Player.class); + when(player.hasPermission("magic.use")).thenReturn(true); + var gui = mock(ResonanceGuiManager.class); + when(Magic.plugin.getResonanceGuiManager()).thenReturn(gui); + assertTrue(command.onCommand(player, null, "resonance", new String[0])); + verify(gui).tryOpen(player, false); + } + + @Test + void elapsedSecondsTrackActualSchedulerIntervals() { + Cache.tickIntervalTicks = 40; + long before = MagicTickService.getElapsedSeconds(); + List contexts = new ArrayList<>(); + MagicTickService.register(contexts::add); + MagicTickService.start(); + server.getScheduler().performTicks(39); + assertEquals(before, MagicTickService.getElapsedSeconds()); + server.getScheduler().performTicks(81); + assertEquals(before + 6, MagicTickService.getElapsedSeconds()); + assertEquals(3, contexts.size()); + } + + @Test + void subsecondCallbacksTriggerEachSecondOnlyOnce() { + Cache.tickIntervalTicks = 1; + long before = MagicTickService.getElapsedSeconds(); + List boundaries = new ArrayList<>(); + MagicTickService.register( + c -> { + if (c.every(1)) boundaries.add(c.seconds()); + }); + MagicTickService.start(); + server.getScheduler().performTicks(60); + assertEquals(before + 3, MagicTickService.getElapsedSeconds()); + assertEquals(List.of(before + 1, before + 2, before + 3), boundaries); + } +} diff --git a/src/test/java/net/tfminecraft/magic/TickServiceEdgeTest.java b/src/test/java/net/tfminecraft/magic/TickServiceEdgeTest.java new file mode 100644 index 0000000..99e017c --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/TickServiceEdgeTest.java @@ -0,0 +1,30 @@ +package net.tfminecraft.magic; + +import static org.junit.jupiter.api.Assertions.*; + +import net.tfminecraft.magic.manager.MagicTickService; +import org.junit.jupiter.api.*; + +class TickServiceEdgeTest { + TickAndMessagesTest f = new TickAndMessagesTest(); + + @BeforeEach + void setup() { + f.setup(); + } + + @AfterEach + void cleanup() { + f.cleanup(); + } + + @Test + void externallyCancelledPluginTasksAreReportedStopped() { + MagicTickService.start(); + assertTrue(MagicTickService.isRunning()); + f.server.getScheduler().cancelTasks(Magic.plugin); + assertFalse(MagicTickService.isRunning()); + MagicTickService.stop(); + assertFalse(MagicTickService.isRunning()); + } +} diff --git a/src/test/java/net/tfminecraft/magic/UtilityEdgeCoverageTest.java b/src/test/java/net/tfminecraft/magic/UtilityEdgeCoverageTest.java new file mode 100644 index 0000000..8db11d9 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/UtilityEdgeCoverageTest.java @@ -0,0 +1,127 @@ +package net.tfminecraft.magic; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.io.File; +import java.security.*; +import java.util.*; +import net.tfminecraft.magic.model.*; +import net.tfminecraft.magic.modifier.*; +import net.tfminecraft.magic.util.*; +import net.tfminecraft.tlibs.TLibs; +import net.tfminecraft.tlibs.objects.api.ItemAPI; +import net.tfminecraft.tlibs.objects.api.subapi.StringFormatter; +import org.bukkit.*; +import org.bukkit.inventory.*; +import org.junit.jupiter.api.*; +import org.mockbukkit.mockbukkit.*; + +class UtilityEdgeCoverageTest { + @BeforeEach + void setup() { + MockBukkit.mock(); + Magic.plugin = mock(Magic.class); + when(Magic.plugin.namespace()).thenReturn("magic"); + when(Magic.plugin.getLogger()).thenReturn(java.util.logging.Logger.getLogger("MagicTests")); + } + + @AfterEach + void cleanup() { + Magic.plugin = null; + MockBukkit.unmock(); + } + + @Test + void itemReferencesNormalizeCloneAndHandleUnavailableProviders() { + assertEquals("", ItemRef.normalize(null)); + assertEquals("", ItemRef.normalize(" ")); + assertEquals("v.stone", ItemRef.normalize(" VANILLA.STONE ")); + assertEquals("m.SWORD.TEST", ItemRef.normalize(" m.SWORD.TEST ")); + assertNull(ItemRef.build(" ")); + var api = mock(ItemAPI.class, RETURNS_DEEP_STUBS); + var stone = new ItemStack(Material.STONE); + try (var libs = mockStatic(TLibs.class)) { + libs.when(TLibs::getItemAPI).thenReturn(api); + when(api.getCreator().getItemFromPath("missing")).thenReturn(null); + assertNull(ItemRef.build("missing")); + when(api.getCreator().getItemFromPath("v.air")).thenReturn(new ItemStack(Material.AIR)); + assertNull(ItemRef.build("vanilla.air")); + when(api.getCreator().getItemFromPath("v.stone")).thenReturn(stone); + var result = ItemRef.build("vanilla.stone"); + assertEquals(stone, result); + assertNotSame(stone, result); + assertEquals(stone, ItemRef.buildOrFallback("vanilla.stone", Material.BARRIER)); + assertEquals( + Material.BARRIER, ItemRef.buildOrFallback("missing", Material.BARRIER).getType()); + when(api.getCreator().getItemFromPath("broken")) + .thenThrow(new IllegalStateException("unavailable")); + assertNull(ItemRef.build("broken")); + } + ItemRef.applyBlankDisplay(null); + ItemRef.applyBlankDisplay(new ItemStack(Material.AIR)); + ItemRef.applyBlankDisplay(stone); + assertEquals(" ", stone.getItemMeta().getDisplayName()); + assertFalse(stone.getItemMeta().hasLore()); + assertTrue(stone.getItemMeta().hasItemFlag(ItemFlag.HIDE_ATTRIBUTES)); + } + + @Test + void costNamesFallBackWhenItemsOrNamesAreMissing() { + var api = mock(ItemAPI.class, RETURNS_DEEP_STUBS); + var stone = new ItemStack(Material.STONE); + try (var libs = mockStatic(TLibs.class); + var formatter = mockStatic(StringFormatter.class, CALLS_REAL_METHODS)) { + libs.when(TLibs::getItemAPI).thenReturn(api); + when(api.getCreator().getItemFromPath("missing")).thenReturn(null); + assertEquals( + List.of("§7- §fmissing §e1"), CostFormatter.getCostsFormatted(Map.of("missing", 1))); + when(api.getCreator().getItemFromPath("air")).thenReturn(new ItemStack(Material.AIR)); + assertEquals(List.of("§7- §fair §e1"), CostFormatter.getCostsFormatted(Map.of("air", 1))); + when(api.getCreator().getItemFromPath("stone")).thenReturn(stone); + formatter.when(() -> StringFormatter.getName(stone)).thenReturn(null, " ", "Stone"); + assertEquals(List.of("§7- §fstone §e2"), CostFormatter.getCostsFormatted(Map.of("stone", 2))); + assertEquals(List.of("§7- §fstone §e2"), CostFormatter.getCostsFormatted(Map.of("stone", 2))); + assertEquals(List.of("§7- §fStone §e2"), CostFormatter.getCostsFormatted(Map.of("stone", 2))); + } + } + + @Test + void unavailableDirectoryAndHashProviderFailPredictably() { + var directory = mock(File.class); + when(directory.exists()).thenReturn(true); + when(directory.isDirectory()).thenReturn(true); + assertTrue(YamlFolder.listYamlFiles(directory).isEmpty()); + var tracker = new RevisionTracker(); + tracker.resolvePart("staff", "hash"); + assertDoesNotThrow(tracker::flush); + try (var hashes = mockStatic(MessageDigest.class)) { + hashes + .when(() -> MessageDigest.getInstance("SHA-256")) + .thenThrow(new NoSuchAlgorithmException("provider unavailable")); + assertInstanceOf( + NoSuchAlgorithmException.class, + assertThrows(IllegalStateException.class, () -> RevisionTracker.sha256("x")).getCause()); + } + } + + @Test + void gridRejectsRowsPastLastRow() { + int old = GuiCache.elementRow; + try { + GuiCache.elementRow = -1; + assertFalse(GridLayout.validateSlots()); + GuiCache.elementRow = 6; + assertFalse(GridLayout.validateSlots()); + } finally { + GuiCache.elementRow = old; + } + } + + @Test + void zeroRoundingAndItemAlignmentRemainNeutral() { + assertEquals("0", MagicNumbers.format(-0.0)); + assertEquals("0", MagicNumbers.format(-0.001)); + assertTrue(SpellModifiers.alignment(new ItemStack(Material.STICK), "missing").isZero()); + } +} diff --git a/src/test/java/net/tfminecraft/magic/artifact/ArtifactCareTest.java b/src/test/java/net/tfminecraft/magic/artifact/ArtifactCareTest.java new file mode 100644 index 0000000..60ee16c --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/artifact/ArtifactCareTest.java @@ -0,0 +1,230 @@ +package net.tfminecraft.magic.artifact; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.*; +import net.tfminecraft.magic.Cache; +import net.tfminecraft.magic.Magic; +import net.tfminecraft.magic.attunement.ArtifactCareCache; +import net.tfminecraft.magic.charge.*; +import net.tfminecraft.magic.model.ElementDef; +import net.tfminecraft.magic.registry.ElementRegistry; +import org.bukkit.Material; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.inventory.ItemStack; +import org.bukkit.persistence.PersistentDataType; +import org.junit.jupiter.api.*; +import org.mockbukkit.mockbukkit.MockBukkit; + +class ArtifactCareTest { + @BeforeEach + void setup() { + MockBukkit.mock(); + Magic.plugin = mock(Magic.class); + when(Magic.plugin.namespace()).thenReturn("magic"); + ChargeRegistry.clear(); + ElementRegistry.clear(); + ArtifactCareCache.muffledEnabled = true; + ArtifactCareCache.muffledOffPerHour = .1; + ArtifactCareCache.muffledRecoverPerHour = .2; + Cache.secondsPerHour = 1; + } + + @AfterEach + void cleanup() { + Magic.plugin = null; + MockBukkit.unmock(); + ChargeRegistry.clear(); + ElementRegistry.clear(); + ArtifactCareCache.muffledEnabled = false; + Cache.secondsPerHour = 3600; + } + + ItemStack artifact() { + var item = new ItemStack(Material.STONE); + var a = Artifact.create(); + a.setCap("fire", 10); + a.setFill("fire", 5); + a.persistPdc(item); + return item; + } + + void users(ItemStack item, String value) { + var meta = item.getItemMeta(); + meta.getPersistentDataContainer() + .set(ArtifactKeys.careUsers(), PersistentDataType.STRING, value); + item.setItemMeta(meta); + } + + @Test + void userStampsExpirePruneAndIgnoreMalformedEntries() { + assertEquals(Map.of(), ArtifactCareStore.readUsers(null)); + ArtifactCareStore.pruneUsers(null, 0); + assertEquals(0, ArtifactCareStore.readMuffle(null)); + assertEquals(0, ArtifactCareStore.readLastTickMs(null)); + var item = artifact(); + users( + item, + "missing;bad:;:1; :3;broken:nope;expired:10;active:100;active2:200; ;id:with:colon:300"); + assertEquals(4, ArtifactCareStore.readUsers(item).size()); + assertEquals(3, ArtifactCareStore.activeUserCount(item, 50)); + ArtifactCareStore.pruneUsers(item, 50); + assertEquals(3, ArtifactCareStore.readUsers(item).size()); + ArtifactCareStore.pruneUsers(item, 50); + ArtifactCareStore.pruneUsers(item, 300); + assertTrue(ArtifactCareStore.readUsers(item).isEmpty()); + assertFalse(item.getItemMeta().getPersistentDataContainer().has(ArtifactKeys.careUsers())); + ArtifactCareStore.stampUser(item, null, 0); + ArtifactCareStore.stampUser(item, " ", 0); + ArtifactCareStore.stampUser(null, "id", 0); + ArtifactCareStore.stampUser(new ItemStack(Material.AIR), "id", 0); + ArtifactCareCache.usersTtlDays = 0; + ArtifactCareStore.stampUser(item, " user ", 10); + assertEquals(Map.of("user", 86400010L), ArtifactCareStore.readUsers(item)); + ArtifactCareCache.usersTtlDays = 7; + ArtifactCareStore.stampUser(item, "second", 10); + assertEquals(2, ArtifactCareStore.readUsers(item).size()); + } + + @Test + void housedArtifactsRecoverAndUnhousedArtifactsMuffleOnlyAfterFirstTick() { + var item = artifact(); + try (var lore = mockStatic(ArtifactLore.class)) { + assertTrue(ArtifactCareStore.tick(item, false, 1000)); + assertEquals(0, ArtifactCareStore.readMuffle(item)); + assertEquals(1000, ArtifactCareStore.readLastTickMs(item)); + assertTrue(ArtifactCareStore.tick(item, false, 2000)); + assertEquals(.1, ArtifactCareStore.readMuffle(item), 1e-10); + assertTrue(ArtifactCareStore.tick(item, true, 3000)); + assertEquals(0, ArtifactCareStore.readMuffle(item)); + assertFalse(ArtifactCareStore.apply(item, false, 4000, ArtifactCareStore.Persist.IF_VISIBLE)); + assertEquals(3000, ArtifactCareStore.readLastTickMs(item)); + assertTrue(ArtifactCareStore.tick(item, false, 20000)); + assertEquals(1, ArtifactCareStore.readMuffle(item)); + assertTrue(ArtifactCareStore.tick(item, false, 1000)); + assertEquals(1, ArtifactCareStore.readMuffle(item)); + ArtifactCareCache.muffledEnabled = false; + Cache.secondsPerHour = 0; + assertTrue(ArtifactCareStore.tick(item, true, 30000)); + assertEquals(1, ArtifactCareStore.readMuffle(item)); + } + assertEquals(5, ArtifactCareStore.usableMax(10, .5)); + assertEquals(5, ArtifactCareStore.usableFill(8, 10, .5)); + assertEquals(0, ArtifactCareStore.usableFill(-1, 10, -1)); + assertEquals(0, ArtifactCareStore.usableMax(10, 2)); + } + + @Test + void chargeItemsNeverAcquireArtifactCareState() { + assertFalse(ArtifactCareStore.tick(null, false, 1000)); + assertFalse(ArtifactCareStore.tick(new ItemStack(Material.AIR), false, 1000)); + var item = artifact(); + var meta = item.getItemMeta(); + meta.getPersistentDataContainer().set(ChargeKeys.chargeTier(), PersistentDataType.INTEGER, 1); + item.setItemMeta(meta); + assertTrue(ChargeIds.isCharge(item)); + assertFalse(ArtifactCareStore.tick(item, false, 1000)); + ArtifactCareStore.stampUser(item, "user", 1000); + assertTrue(ArtifactCareStore.readUsers(item).isEmpty()); + assertNull(Artifact.fromItem(item)); + assertFalse(ChargeIds.isCharge(null)); + assertFalse(ChargeIds.hasKey(null)); + assertFalse(ChargeIds.hasKey(new ItemStack(Material.AIR))); + } + + @Test + void visibleCareTickDecaysOnlyConfiguredAuraAndWritesUpdatedLore() { + var item = artifact(); + var data = Artifact.fromItem(item); + data.setCap("water", 10); + data.setFill("water", 0); + data.setCap("unknown", 10); + data.setCap("constant", 10); + data.persistPdc(item); + var cfg = new YamlConfiguration(); + cfg.set("aura_decay_per_hour", -.5); + ElementRegistry.register(new ElementDef("fire", cfg)); + ElementRegistry.register(new ElementDef("water", cfg)); + ElementRegistry.register(new ElementDef("constant", new YamlConfiguration())); + try (var lore = mockStatic(ArtifactLore.class)) { + lore.when(() -> ArtifactLore.careVisibleWouldChange(any(), anyDouble(), anyDouble())) + .thenReturn(true); + ArtifactCareStore.tick(item, true, 1000); + ArtifactCareStore.tick(item, true, 2000); + assertEquals(4.5, Artifact.fromItem(item).getFill("fire")); + lore.verify(() -> ArtifactLore.updateItem(item)); + var depleted = Artifact.fromItem(item); + depleted.setFill("fire", 0); + depleted.persistPdc(item); + ArtifactCareStore.tick(item, true, 3000); + assertEquals(0, Artifact.fromItem(item).getFill("fire")); + var ordinary = new ItemStack(Material.STONE); + ArtifactCareStore.tick(ordinary, true, 1000); + ArtifactCareStore.tick(ordinary, true, 2000); + } + } + + @Test + void artifactAccessorsRoundTripPrimaryAndAura() { + assertNull(Artifact.fromItem(null)); + assertNull(Artifact.fromItem(new ItemStack(Material.AIR))); + var item = artifact(); + var a = Artifact.fromItem(item); + assertEquals(net.tfminecraft.magic.artifact.aura.VesselKind.ARTIFACT, a.kind()); + assertTrue(a.hasStoredAura()); + assertEquals(5, a.totalFill()); + assertEquals(10, a.getCap("fire")); + assertEquals(Set.of("fire"), a.getCappedElementIds()); + assertEquals("fire", a.primaryElementId()); + var meta = item.getItemMeta(); + meta.getPersistentDataContainer() + .set(ArtifactKeys.artifactPrimary(), PersistentDataType.STRING, " WATER "); + item.setItemMeta(meta); + assertEquals("water", Artifact.fromItem(item).primaryElementId()); + try (var lore = mockStatic(ArtifactLore.class)) { + lore.when(() -> ArtifactLore.updateItem(item)).thenReturn(item); + assertSame(item, a.write(item)); + } + } + + @Test + void visibleOnlyCareWritesWhenChangedAndStampsPruneExpiredUsers() { + var item = artifact(); + users(item, " "); + assertTrue(ArtifactCareStore.readUsers(item).isEmpty()); + users(item, "old:1;active:5000"); + ArtifactCareStore.stampUser(item, "new", 1000); + assertFalse(ArtifactCareStore.readUsers(item).containsKey("old")); + try (var lore = mockStatic(ArtifactLore.class)) { + lore.when(() -> ArtifactLore.careVisibleWouldChange(eq(item), anyDouble(), anyDouble())) + .thenReturn(true); + assertTrue(ArtifactCareStore.apply(item, true, 1000, ArtifactCareStore.Persist.IF_VISIBLE)); + } + } + + @Test + void positiveDecayAtCapAndNegligibleNegativeFillAvoidUnnecessaryRewrites() { + var item = artifact(); + var aura = Artifact.fromItem(item); + aura.setFill("fire", 10); + aura.persistPdc(item); + var config = new YamlConfiguration(); + config.set("aura_decay_per_hour", 1); + ElementRegistry.register(new ElementDef("fire", config)); + try (var lore = mockStatic(ArtifactLore.class)) { + lore.when(() -> ArtifactLore.careVisibleWouldChange(eq(item), anyDouble(), anyDouble())) + .thenReturn(true); + ArtifactCareStore.tick(item, true, 1000); + ArtifactCareStore.tick(item, true, 2000); + assertEquals(10, Artifact.fromItem(item).getFill("fire")); + config.set("aura_decay_per_hour", -1); + ElementRegistry.register(new ElementDef("fire", config)); + aura = Artifact.fromItem(item); + aura.setFill("fire", 0); + aura.persistPdc(item); + ArtifactCareStore.tick(item, true, 3000); + assertEquals(0, Artifact.fromItem(item).getFill("fire")); + } + } +} diff --git a/src/test/java/net/tfminecraft/magic/artifact/ArtifactLoreEdgeTest.java b/src/test/java/net/tfminecraft/magic/artifact/ArtifactLoreEdgeTest.java new file mode 100644 index 0000000..0953428 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/artifact/ArtifactLoreEdgeTest.java @@ -0,0 +1,218 @@ +package net.tfminecraft.magic.artifact; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.*; +import net.tfminecraft.magic.artifact.config.*; +import net.tfminecraft.magic.artifact.sacrifice.*; +import net.tfminecraft.magic.artifact.shrine.*; +import org.bukkit.persistence.PersistentDataType; +import org.junit.jupiter.api.*; + +class ArtifactLoreEdgeTest { + ArtifactLoreTest f = new ArtifactLoreTest(); + + @BeforeEach + void setup() { + f.setup(); + } + + @AfterEach + void cleanup() { + f.cleanup(); + ArtifactTypeRegistry.clear(); + SacrificeRegistry.clear(); + ShrineRegistry.clear(); + } + + @Test + void overflowingAttunementCountIsClampedToAvailableLore() { + var item = f.artifact(); + ArtifactIds.writeNew(item); + ArtifactLore.apply(item); + var meta = item.getItemMeta(); + meta.getPersistentDataContainer() + .set(ArtifactKeys.attuneCount(), PersistentDataType.INTEGER, Integer.MAX_VALUE); + item.setItemMeta(meta); + assertDoesNotThrow(() -> ArtifactLore.refreshAttune(item)); + assertEquals( + 0, + item.getItemMeta() + .getPersistentDataContainer() + .get(ArtifactKeys.attuneCount(), PersistentDataType.INTEGER)); + } + + @Test + void nonFiniteLoreNumbersDoNotCreateInvalidAura() { + for (String number : List.of("NaN", "Infinity", "-Infinity")) + assertNull(ArtifactLore.readAura(f.item(List.of("Fire 1 / " + number)))); + assertEquals( + 1, ArtifactLore.readAura(f.item(List.of("Muffled: NaN%", "Fire 1 / 10"))).getFill("fire")); + } + + @Test + void missingAuraMissingIndexesAndOversizedIndexesRebuildSafely() { + var item = f.item(List.of("Original")); + ArtifactIds.writeNew(item); + ArtifactLore.refreshAttune(item); + assertEquals(List.of("Original"), f.plain(item)); + item = f.artifact(); + ArtifactIds.writeNew(item); + ArtifactLore.apply(item); + var meta = item.getItemMeta(); + meta.getPersistentDataContainer().remove(ArtifactKeys.attuneCount()); + item.setItemMeta(meta); + ArtifactLore.refreshAttune(item); + assertEquals(4, f.plain(item).size()); + meta = item.getItemMeta(); + meta.getPersistentDataContainer() + .set(ArtifactKeys.attuneCount(), PersistentDataType.INTEGER, 1); + item.setItemMeta(meta); + ArtifactLore.refreshAttune(item); + assertEquals(4, f.plain(item).size()); + } + + @Test + void imprintLoreUsesKnownFallbackAndEmptyTemplates() { + var item = f.artifact(); + SacrificeImprintStore.write(item, List.of(new SacrificeImprint("id", "Name", "pain", "fire"))); + ArtifactLore.apply(item); + assertEquals(4, f.plain(item).size()); + SacrificeRegistry.register( + new SacrificeElementDef("water", List.of(), "Pain of {character}", "Screams", "Soul", 0)); + ArtifactLore.apply(item); + assertEquals("Pain of Name", f.plain(item).getLast()); + SacrificeRegistry.register( + new SacrificeElementDef("fire", List.of(), " ", "Screams", "Soul", 0)); + ArtifactLore.apply(item); + assertEquals(4, f.plain(item).size()); + SacrificeRegistry.register( + new SacrificeElementDef("fire", List.of(), "Fire of {character}", "Screams", "Soul", 0)); + ArtifactLore.apply(item); + assertEquals("Fire of Name", f.plain(item).getLast()); + } + + @Test + void raritiesUseFallbackColorsAndBlankRarityIsHidden() { + var item = f.artifact(); + for (String color : Arrays.asList(null, " ", "#ff0000")) { + ArtifactRarityRegistry.register(new ArtifactRarityDef("rare", "Rare", color, 1, 1, 1, null)); + var meta = item.getItemMeta(); + meta.getPersistentDataContainer() + .set(ArtifactKeys.artifactRarity(), PersistentDataType.STRING, "rare"); + item.setItemMeta(meta); + ArtifactLore.apply(item); + assertEquals("Rare", f.plain(item).get(1)); + } + var meta = item.getItemMeta(); + meta.getPersistentDataContainer() + .set(ArtifactKeys.artifactRarity(), PersistentDataType.STRING, " "); + item.setItemMeta(meta); + ArtifactLore.apply(item); + assertEquals("Aura", f.plain(item).get(1)); + } + + @Test + void sortingUsesPrimaryThenFillThenCapThenNameAndHidesDisabledSecondaries() { + f.element("air", "Air", 0); + f.element("earth", "Earth", 0); + f.element("zero", "Zero", 0); + var item = f.artifact(); + var a = Artifact.fromItem(item); + for (String id : List.of("fire", "water", "air", "earth")) { + a.setCap(id, 10); + a.setFill(id, 1); + } + a.setCap("earth", 20); + a.persistPdc(item); + var meta = item.getItemMeta(); + meta.getPersistentDataContainer() + .set(ArtifactKeys.artifactPrimary(), PersistentDataType.STRING, "fire"); + item.setItemMeta(meta); + ArtifactLore.apply(item); + assertEquals( + List.of( + "Original lore", "Aura", "Fire 1 / 10", "Earth 1 / 20", "Air 1 / 10", "Water 1 / 10"), + f.plain(item)); + ShrineRegistry.register(new ShrineElementDef("water", false, List.of())); + ArtifactLore.apply(item); + assertFalse(f.plain(item).stream().anyMatch(x -> x.startsWith("Water "))); + meta = item.getItemMeta(); + meta.getPersistentDataContainer() + .set(ArtifactKeys.artifactPrimary(), PersistentDataType.STRING, " "); + item.setItemMeta(meta); + ArtifactLore.apply(item); + assertEquals("Earth 1 / 20", f.plain(item).get(2)); + } + + @Test + void auraRecoverySkipsMalformedLabelsButAcceptsLongestMatchingName() { + f.element("other", "Fire", 0); + f.element("large", "Fire Storm", 0); + var aura = + ArtifactLore.readAura( + f.item(List.of("Muffled: %", "Fire 1..2 / 10", "Fire 2 / ", "Fire Storm 3 / 10"))); + assertNotNull(aura); + assertEquals(0, aura.getFill("fire")); + assertEquals(3, aura.getFill("large")); + var item = f.artifact(); + for (List old : + List.of( + List.of("Aura", "Fire 1 / 10"), + List.of("Fire 1 / 10"), + List.of("", "Aura", "Fire 1 / 10"), + List.of("§0§1§2old"))) { + var meta = item.getItemMeta(); + meta.setLore(old); + item.setItemMeta(meta); + ArtifactLore.apply(item); + assertEquals(1, f.plain(item).stream().filter(x -> x.equals("Aura")).count()); + } + f.element("growing", "Growing", 1); + var a = Artifact.fromItem(item); + a.setCap("growing", 100); + a.persistPdc(item); + assertTrue(ArtifactLore.careVisibleWouldChange(item, 0, 1)); + } + + @Test + void rawLegacyParserHandlesUnnormalisedMarkersAndEmptyInput() throws Exception { + var hidden = ArtifactLore.class.getDeclaredMethod("hasHiddenMarker", String.class); + hidden.setAccessible(true); + assertEquals(false, hidden.invoke(null, (String) null)); + assertEquals(true, hidden.invoke(null, "prefix§0§1§2§rAura")); + assertEquals(true, hidden.invoke(null, "prefix§0§1§2Aura")); + var plain = ArtifactLore.class.getDeclaredMethod("plain", String.class); + plain.setAccessible(true); + assertEquals("", plain.invoke(null, (String) null)); + var parse = ArtifactLore.class.getDeclaredMethod("parseAmount", String.class); + parse.setAccessible(true); + assertNull(parse.invoke(null, (String) null)); + var label = ArtifactLore.class.getDeclaredMethod("auraLabel", String.class, String.class); + label.setAccessible(true); + assertEquals(false, label.invoke(null, null, "fire")); + assertEquals(false, label.invoke(null, "fire 1", null)); + assertEquals(false, label.invoke(null, "fire 1", "")); + var number = ArtifactLore.class.getDeclaredMethod("leadingNumber", String.class); + number.setAccessible(true); + assertEquals("123", number.invoke(null, "123")); + var item = f.artifact(); + var meta = item.getItemMeta(); + meta.setLore(List.of("Armour 2 / 10", "Water 3 / 20")); + item.setItemMeta(meta); + ArtifactLore.apply(item); + assertEquals("Armour 2 / 10", f.plain(item).getFirst()); + } + + @Test + void legacyAuraLoreDoesNotTurnAChargeIntoAnArtifact() { + var item = f.item(List.of("Fire 1 / 10")); + var meta = item.getItemMeta(); + meta.getPersistentDataContainer() + .set(net.tfminecraft.magic.charge.ChargeKeys.chargeTier(), PersistentDataType.INTEGER, 1); + item.setItemMeta(meta); + assertNotNull(ArtifactLore.readAura(item)); + assertNull(Artifact.fromItem(item)); + assertFalse(item.getItemMeta().getPersistentDataContainer().has(ArtifactKeys.auraData())); + } +} diff --git a/src/test/java/net/tfminecraft/magic/artifact/ArtifactLoreTest.java b/src/test/java/net/tfminecraft/magic/artifact/ArtifactLoreTest.java new file mode 100644 index 0000000..45a23c8 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/artifact/ArtifactLoreTest.java @@ -0,0 +1,236 @@ +package net.tfminecraft.magic.artifact; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.*; +import net.tfminecraft.magic.*; +import net.tfminecraft.magic.artifact.config.*; +import net.tfminecraft.magic.artifact.sacrifice.SacrificeRegistry; +import net.tfminecraft.magic.artifact.shrine.ShrineRegistry; +import net.tfminecraft.magic.charge.ChargeRegistry; +import net.tfminecraft.magic.model.ElementDef; +import net.tfminecraft.magic.registry.ElementRegistry; +import org.bukkit.ChatColor; +import org.bukkit.Material; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.inventory.ItemStack; +import org.bukkit.persistence.PersistentDataType; +import org.junit.jupiter.api.*; +import org.mockbukkit.mockbukkit.MockBukkit; + +class ArtifactLoreTest { + @BeforeEach + void setup() { + MockBukkit.mock(); + Magic.plugin = mock(Magic.class); + when(Magic.plugin.namespace()).thenReturn("magic"); + ElementRegistry.clear(); + ArtifactTypeRegistry.clear(); + ArtifactRarityRegistry.clear(); + ShrineRegistry.clear(); + SacrificeRegistry.clear(); + ChargeRegistry.clear(); + element("fire", "Fire", -.5); + element("water", "Water", 0); + Cache.artifactGlint = true; + } + + @AfterEach + void cleanup() { + Magic.plugin = null; + MockBukkit.unmock(); + ElementRegistry.clear(); + ArtifactRarityRegistry.clear(); + ChargeRegistry.clear(); + } + + void element(String id, String name, double decay) { + var cfg = new YamlConfiguration(); + cfg.set("name", name); + cfg.set("aura_decay_per_hour", decay); + ElementRegistry.register(new ElementDef(id, cfg)); + } + + ItemStack item(List lines) { + var item = new ItemStack(Material.STONE); + var meta = item.getItemMeta(); + meta.setLore(lines); + item.setItemMeta(meta); + return item; + } + + ItemStack artifact() { + var item = item(List.of("Original lore")); + var a = Artifact.create(); + a.setCap("fire", 10); + a.setFill("fire", 5); + a.setCap("water", 20); + a.setFill("water", 8.25); + a.persistPdc(item); + return item; + } + + List plain(ItemStack item) { + return item.getItemMeta().getLore().stream().map(ChatColor::stripColor).toList(); + } + + @Test + void applyBuildsAuraAndKeepsOnlyOriginalPrefixOnRefresh() { + var item = artifact(); + ArtifactLore.apply(item); + assertEquals(List.of("Original lore", "Aura", "Water 8.25 / 20", "Fire 5 / 10"), plain(item)); + assertTrue(item.getItemMeta().getEnchantmentGlintOverride()); + ArtifactLore.apply(item); + assertEquals(4, plain(item).size()); + var meta = item.getItemMeta(); + meta.getPersistentDataContainer() + .set(ArtifactKeys.artifactPrimary(), PersistentDataType.STRING, "fire"); + meta.getPersistentDataContainer() + .set(ArtifactKeys.artifactRarity(), PersistentDataType.STRING, "unlisted"); + item.setItemMeta(meta); + Cache.artifactGlint = false; + assertSame(item, ArtifactLore.updateItem(item)); + assertEquals("unlisted", plain(item).get(1)); + assertEquals("Fire 5 / 10", plain(item).get(3)); + assertFalse(item.getItemMeta().getEnchantmentGlintOverride()); + ArtifactLore.apply(null); + ArtifactLore.apply(new ItemStack(Material.STONE)); + } + + @Test + void legacyLoreRestoresAuraAndMuffleWithoutTreatingOtherStatsAsAura() { + assertNull(ArtifactLore.readAura(null)); + assertNull(ArtifactLore.readAura(new ItemStack(Material.AIR))); + assertNull(ArtifactLore.readAura(new ItemStack(Material.STONE))); + assertNull(ArtifactLore.readAura(item(List.of("Other line", "Fire resistance 5 / 10")))); + var legacy = + item( + List.of( + "Muffled: 50%", + "Fire 2.5 / 10", + "Water 1 / 5", + "Fire resistance 3 / 10", + "Other 10 / 20", + "Fire 3 / bad", + "Fire 3 / 0")); + var recovered = ArtifactLore.readAura(legacy); + assertNotNull(recovered); + assertEquals(5, recovered.getFill("fire")); + assertEquals(2, recovered.getFill("water")); + assertEquals(10, recovered.getCap("fire")); + var loaded = Artifact.fromItem(legacy); + assertNotNull(loaded); + assertTrue(legacy.getItemMeta().getPersistentDataContainer().has(ArtifactKeys.auraData())); + for (String muffle : List.of("Muffled: bad%", "Muffled: 100%", "Muffled: 0%")) { + assertEquals( + 2.5, ArtifactLore.readAura(item(List.of(muffle, "Fire 2.5 / 10"))).getFill("fire")); + } + assertNull( + ArtifactLore.readAura( + item(List.of("Fire / 10", "Fire .5 / 10", "Fire 5 5 / 10", "Fire notnumber / 10")))); + element("blank", "", 0); + assertEquals(2, ArtifactLore.readAura(item(List.of("blank 2 / 10"))).getFill("blank")); + } + + @Test + void legacyMarkersAndRarityLinesDoNotAccumulate() { + ArtifactRarityRegistry.register( + new ArtifactRarityDef("rare", "Rare name", "#ff0000", 1, 1, 1, new CapRange(1, 10))); + for (List old : + List.of( + List.of("Prefix", "magic.aura.begin", "old"), + List.of("Prefix", "magic.aura.end"), + List.of("Prefix", "\u2063old"), + List.of("Prefix", "\u200bold"), + List.of("Prefix", "Rare name", "Aura", "Fire 2 / 10"), + List.of("Prefix", "rare", "Fire 2 / 10"), + List.of("Prefix", "Aura", "Fire 2 / 10"), + List.of("Prefix", "Fire 2 / 10"))) { + var item = artifact(); + var meta = item.getItemMeta(); + meta.setLore(old); + meta.getPersistentDataContainer() + .set(ArtifactKeys.artifactRarity(), PersistentDataType.STRING, "rare"); + item.setItemMeta(meta); + ArtifactLore.apply(item); + assertEquals("Prefix", plain(item).getFirst()); + assertEquals(5, plain(item).size()); + assertEquals("Rare name", plain(item).get(1)); + } + } + + @Test + void refreshAttunementRebuildsMissingIndexesThenOnlyReplacesMuffleLine() { + var item = artifact(); + ArtifactLore.refreshAttune(item); + var meta = item.getItemMeta(); + meta.getPersistentDataContainer() + .set(ArtifactKeys.artifactId(), PersistentDataType.STRING, UUID.randomUUID().toString()); + item.setItemMeta(meta); + ArtifactLore.refreshAttune(item); + assertEquals(4, plain(item).size()); + ArtifactLore.refreshAttune(item); + meta = item.getItemMeta(); + meta.getPersistentDataContainer() + .set(ArtifactKeys.careMuffle(), PersistentDataType.DOUBLE, .25); + item.setItemMeta(meta); + ArtifactLore.refreshAttune(item); + assertEquals("Muffled: 25%", plain(item).getLast()); + ArtifactLore.refreshAttune(item); + assertEquals(5, plain(item).size()); + meta = item.getItemMeta(); + meta.getPersistentDataContainer().set(ArtifactKeys.careMuffle(), PersistentDataType.DOUBLE, 0.); + item.setItemMeta(meta); + ArtifactLore.refreshAttune(item); + assertEquals(4, plain(item).size()); + for (int start : new int[] {-1, 999}) { + meta = item.getItemMeta(); + meta.getPersistentDataContainer() + .set(ArtifactKeys.attuneStart(), PersistentDataType.INTEGER, start); + item.setItemMeta(meta); + ArtifactLore.refreshAttune(item); + assertEquals(4, plain(item).size()); + } + meta = item.getItemMeta(); + meta.setLore(null); + item.setItemMeta(meta); + ArtifactLore.refreshAttune(item); + assertEquals(3, plain(item).size()); + } + + @Test + void careVisibilityTracksRenderedChangesRatherThanTinyNumericDrift() { + var item = artifact(); + assertFalse(ArtifactLore.careVisibleWouldChange(item, 0, 0)); + assertFalse(ArtifactLore.careVisibleWouldChange(new ItemStack(Material.STONE), 0, 1)); + assertTrue(ArtifactLore.careVisibleWouldChange(item, .1, 0)); + assertTrue(ArtifactLore.careVisibleWouldChange(item, 0, 1)); + assertFalse(ArtifactLore.careVisibleWouldChange(item, 0, .0001)); + var a = Artifact.fromItem(item); + a.setFill("fire", 0); + a.persistPdc(item); + assertFalse(ArtifactLore.careVisibleWouldChange(item, 0, 100)); + a.setFill("fire", 1); + a.persistPdc(item); + assertTrue(ArtifactLore.careVisibleWouldChange(item, 0, 100)); + } + + @Test + void malformedNegativeAttunementCountRebuildsLore() { + var item = artifact(); + ArtifactIds.writeNew(item); + ArtifactLore.apply(item); + var meta = item.getItemMeta(); + meta.getPersistentDataContainer() + .set(ArtifactKeys.attuneCount(), PersistentDataType.INTEGER, -1); + item.setItemMeta(meta); + assertDoesNotThrow(() -> ArtifactLore.refreshAttune(item)); + assertEquals(4, plain(item).size()); + assertEquals( + 0, + item.getItemMeta() + .getPersistentDataContainer() + .get(ArtifactKeys.attuneCount(), PersistentDataType.INTEGER)); + } +} diff --git a/src/test/java/net/tfminecraft/magic/artifact/ArtifactScanTest.java b/src/test/java/net/tfminecraft/magic/artifact/ArtifactScanTest.java new file mode 100644 index 0000000..b30e602 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/artifact/ArtifactScanTest.java @@ -0,0 +1,107 @@ +package net.tfminecraft.magic.artifact; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import net.tfminecraft.magic.*; +import net.tfminecraft.magic.artifact.aura.VesselLore; +import net.tfminecraft.magic.attunement.*; +import net.tfminecraft.magic.model.ElementDef; +import net.tfminecraft.magic.registry.ElementRegistry; +import net.tfminecraft.magic.session.ResonanceSession; +import org.bukkit.Material; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.inventory.ItemStack; +import org.junit.jupiter.api.*; +import org.mockbukkit.mockbukkit.*; + +class ArtifactScanTest { + ServerMock server; + + @BeforeEach + void setup() { + server = MockBukkit.mock(); + Magic.plugin = mock(Magic.class); + when(Magic.plugin.namespace()).thenReturn("magic"); + ElementRegistry.clear(); + AuraLog.configure(false, false, null); + } + + @AfterEach + void cleanup() { + ElementRegistry.clear(); + Magic.plugin = null; + MockBukkit.unmock(); + } + + @Test + void scanPersistsChangedSnapshotsToTheirInventorySlot() { + var handler = new ArtifactAttuneScanHandler(); + var player = server.addPlayer(); + var stack = new ItemStack(Material.STONE); + ArtifactIds.writeNew(stack); + var aura = Artifact.create(); + aura.setCap("fire", 10); + aura.setFill("fire", 5); + aura.persistPdc(stack); + assertTrue(handler.matches(stack)); + assertFalse(handler.matches(null)); + var inv = server.createInventory(null, 9); + inv.setItem(2, stack); + try (var places = mockStatic(ArtifactCarePlaces.class); + var care = mockStatic(ArtifactCareStore.class)) { + care.when(() -> ArtifactCareStore.apply(eq(stack), anyBoolean(), anyLong(), any())) + .thenReturn(true); + handler.update(player, inv, 2, stack); + assertEquals(stack, inv.getItem(2)); + handler.update(player, null, 0, stack); + handler.update(player, inv, -1, stack); + care.when(() -> ArtifactCareStore.apply(eq(stack), anyBoolean(), anyLong(), any())) + .thenReturn(false); + handler.update(player, inv, 2, stack); + } + } + + @Test + void captureRejectsInvalidAndLockedGainsAndSupportsAnonymousCredit() { + var session = new ResonanceSession(); + var player = server.addPlayer(); + for (String id : new String[] {null, " "}) + AttunementCaptureService.credit(player, session, null, id, 1); + AttunementCaptureService.credit(player, null, null, "fire", 1); + AttunementCaptureService.credit(player, session, null, "fire", 0); + var cfg = new YamlConfiguration(); + cfg.set("permission", "secret.fire"); + ElementRegistry.register(new ElementDef("fire", cfg)); + AttunementCaptureService.credit(player, session, null, "fire", 1); + assertEquals(0, session.getResonance("fire")); + ElementRegistry.register(new ElementDef("fire", new YamlConfiguration())); + AttunementCaptureService.credit(null, session, null, "fire", 1); + assertEquals(1, session.getResonance("fire")); + } + + @Test + void displayNamesArePlainAndInventoryLoreHandlesAllSlots() { + assertEquals("", AttunementCaptureService.displayNameOf(null)); + var stack = new ItemStack(Material.STONE); + assertEquals("", AttunementCaptureService.displayNameOf(stack)); + var meta = stack.getItemMeta(); + meta.setLore(java.util.List.of("Only lore")); + stack.setItemMeta(meta); + assertEquals("", AttunementCaptureService.displayNameOf(stack)); + meta.setDisplayName(" "); + stack.setItemMeta(meta); + assertEquals("", AttunementCaptureService.displayNameOf(stack)); + meta.setDisplayName("§aCrystal"); + stack.setItemMeta(meta); + assertEquals("Crystal", AttunementCaptureService.displayNameOf(stack)); + VesselLore.updateInventory(null); + var player = server.addPlayer(); + player.getInventory().setItem(0, stack); + player.getInventory().setHelmet(stack); + player.getInventory().setItemInOffHand(stack); + VesselLore.updateInventory(player); + assertEquals( + "Crystal", AttunementCaptureService.displayNameOf(player.getInventory().getItem(0))); + } +} diff --git a/src/test/java/net/tfminecraft/magic/artifact/FrameCareTest.java b/src/test/java/net/tfminecraft/magic/artifact/FrameCareTest.java new file mode 100644 index 0000000..73de402 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/artifact/FrameCareTest.java @@ -0,0 +1,76 @@ +package net.tfminecraft.magic.artifact; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.*; +import net.tfminecraft.magic.Magic; +import net.tfminecraft.magic.tick.MagicTickContext; +import org.bukkit.*; +import org.bukkit.entity.*; +import org.bukkit.event.player.PlayerInteractEntityEvent; +import org.bukkit.inventory.ItemStack; +import org.junit.jupiter.api.*; +import org.mockbukkit.mockbukkit.*; + +class FrameCareTest { + ServerMock server; + + @BeforeEach + void setup() { + server = MockBukkit.mock(); + Magic.plugin = mock(Magic.class); + when(Magic.plugin.namespace()).thenReturn("magic"); + } + + @AfterEach + void cleanup() { + Magic.plugin = null; + MockBukkit.unmock(); + } + + @Test + void framesOnlyRewriteValidArtifactItemsWhenCareChanged() { + ArtifactFrameCareTicker.tickFrame(null, ArtifactCareStore.Persist.ALWAYS); + var frame = mock(ItemFrame.class); + ArtifactFrameCareTicker.tickFrame(frame, ArtifactCareStore.Persist.ALWAYS); + when(frame.isValid()).thenReturn(true); + ArtifactFrameCareTicker.tickFrame(frame, ArtifactCareStore.Persist.ALWAYS); + var item = new ItemStack(Material.STONE); + ArtifactIds.writeNew(item); + when(frame.getItem()).thenReturn(item); + try (var care = mockStatic(ArtifactCareStore.class)) { + ArtifactFrameCareTicker.tickFrame(frame, ArtifactCareStore.Persist.ALWAYS); + verify(frame, never()).setItem(any(), anyBoolean()); + care.when( + () -> + ArtifactCareStore.apply( + eq(item), eq(true), anyLong(), eq(ArtifactCareStore.Persist.ALWAYS))) + .thenReturn(true); + ArtifactFrameCareTicker.tickFrame(frame, ArtifactCareStore.Persist.ALWAYS); + verify(frame).setItem(item, false); + } + } + + @Test + void tickingCyclesOnlinePlayersAndClicksOnlyInspectFrames() { + var ticker = new ArtifactFrameCareTicker(); + ticker.onTick(MagicTickContext.of(1)); + var player = mock(Player.class); + var frame = mock(ItemFrame.class); + when(frame.isValid()).thenReturn(true); + when(player.getNearbyEntities(anyDouble(), anyDouble(), anyDouble())) + .thenReturn(List.of(frame, mock(Zombie.class))); + try (var bukkit = mockStatic(Bukkit.class, CALLS_REAL_METHODS)) { + bukkit.when(Bukkit::getOnlinePlayers).thenReturn(List.of(player)); + ticker.onTick(MagicTickContext.of(2)); + ticker.onTick(MagicTickContext.of(3)); + verify(player, times(2)).getNearbyEntities(48, 48, 48); + ticker.onFrameInteract(new PlayerInteractEntityEvent(player, frame)); + ticker.onFrameInteract(new PlayerInteractEntityEvent(player, mock(Zombie.class))); + verify(frame, times(3)).getItem(); + bukkit.when(Bukkit::getOnlinePlayers).thenReturn(List.of()); + ticker.onTick(MagicTickContext.of(4)); + } + } +} diff --git a/src/test/java/net/tfminecraft/magic/artifact/sacrifice/SacrificeFxTest.java b/src/test/java/net/tfminecraft/magic/artifact/sacrifice/SacrificeFxTest.java new file mode 100644 index 0000000..a6d250b --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/artifact/sacrifice/SacrificeFxTest.java @@ -0,0 +1,139 @@ +package net.tfminecraft.magic.artifact.sacrifice; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.*; +import net.tfminecraft.interactiblefurniture.furniture.Furniture; +import net.tfminecraft.magic.Magic; +import net.tfminecraft.magic.artifact.shrine.ShrineScore; +import org.bukkit.*; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.*; +import org.mockbukkit.mockbukkit.*; + +class SacrificeFxTest { + ServerMock server; + World world; + Furniture furniture; + SacrificeRiteSession session; + + @BeforeEach + void setup() { + server = MockBukkit.mock(); + world = mock(World.class); + furniture = mock(Furniture.class); + when(furniture.getLoc()).thenReturn(new Location(world, 0, 64, 0)); + when(furniture.getOriginBlockLocation()).thenReturn(Optional.empty()); + session = + new SacrificeRiteSession( + UUID.randomUUID(), + UUID.randomUUID(), + furniture, + "main", + "fire", + new ShrineScore(null), + 0); + Magic.plugin = mock(Magic.class); + when(Magic.plugin.isEnabled()).thenReturn(true); + when(Magic.plugin.getServer()).thenReturn(server); + SacrificeRegistry.clear(); + } + + @AfterEach + void cleanup() { + SacrificeRiteService.clearAll(); + SacrificeRiteFx.stopIfIdle(); + Magic.plugin = null; + MockBukkit.unmock(); + } + + @Test + void resolutionSurgesOnlyForConfiguredSuccessfulTiers() { + var at = new Location(world, 2, 64, 2); + SacrificeRiteFx.resolve(null, at); + SacrificeRiteFx.resolve(session, at); + session.markResolved(true, "soul", 1); + SacrificeRiteFx.resolve(session, at); + verifyNoInteractions(world); + SacrificeRegistry.setFx( + new SacrificeFxDef( + Set.of("soul"), + Sound.ENTITY_LIGHTNING_BOLT_THUNDER, + 1, + 1, + Sound.ENTITY_GENERIC_EXPLODE, + 1, + 1)); + SacrificeRiteFx.resolve(session, at); + verify(world, times(2)) + .spawnParticle( + eq(Particle.DUST), + any(Location.class), + eq(36), + anyDouble(), + anyDouble(), + anyDouble(), + any(Particle.DustOptions.class)); + verify(world, times(2)) + .playSound(any(Location.class), any(Sound.class), anyFloat(), anyFloat()); + when(furniture.getLoc()).thenReturn(null); + SacrificeRiteFx.resolve(session, null); + SacrificeRiteFx.resolve(session, new Location(null, 0, 0, 0)); + when(furniture.getLoc()).thenReturn(at); + SacrificeRegistry.setFx(new SacrificeFxDef(Set.of("soul"), null, 1, 1, null, 1, 1)); + doThrow(new IllegalArgumentException("unsupported")) + .when(world) + .spawnParticle( + eq(Particle.SOUL), + any(Location.class), + anyInt(), + anyDouble(), + anyDouble(), + anyDouble(), + anyDouble()); + SacrificeRiteFx.resolve(session, at); + } + + @Test + void runningRiteAnimatesPullsAndStopsAfterServiceBecomesIdle() { + var victim = mock(Player.class); + when(victim.isOnline()).thenReturn(true); + when(victim.getLocation()).thenAnswer(i -> new Location(world, 2, 64, 2)); + try (var rites = mockStatic(SacrificeRiteService.class); + var bukkit = mockStatic(Bukkit.class, CALLS_REAL_METHODS)) { + rites.when(SacrificeRiteService::hasSessions).thenReturn(true); + rites.when(SacrificeRiteService::sessions).thenReturn(List.of(session)); + bukkit.when(() -> Bukkit.getPlayer(session.getVictimId())).thenReturn(victim); + SacrificeRiteFx.ensureRunning(); + SacrificeRiteFx.ensureRunning(); + server.getScheduler().performTicks(4); + SacrificeRiteFx.stopIfIdle(); + server.getScheduler().cancelTasks(Magic.plugin); + SacrificeRiteFx.ensureRunning(); + verify(world, atLeastOnce()) + .spawnParticle( + eq(Particle.DUST), + any(Location.class), + eq(1), + anyDouble(), + anyDouble(), + anyDouble(), + any(Particle.DustOptions.class)); + bukkit.when(() -> Bukkit.getPlayer(session.getVictimId())).thenReturn(null); + server.getScheduler().performTicks(2); + bukkit.when(() -> Bukkit.getPlayer(session.getVictimId())).thenReturn(victim); + when(victim.isOnline()).thenReturn(false); + server.getScheduler().performTicks(2); + when(victim.isOnline()).thenReturn(true); + when(furniture.getLoc()).thenReturn(null); + server.getScheduler().performTicks(2); + when(furniture.getLoc()).thenReturn(new Location(mock(World.class), 0, 0, 0)); + server.getScheduler().performTicks(2); + rites.when(SacrificeRiteService::hasSessions).thenReturn(false); + server.getScheduler().performTicks(2); + Magic.plugin = null; + SacrificeRiteFx.ensureRunning(); + } + } +} diff --git a/src/test/java/net/tfminecraft/magic/artifact/sacrifice/SacrificeServiceTest.java b/src/test/java/net/tfminecraft/magic/artifact/sacrifice/SacrificeServiceTest.java new file mode 100644 index 0000000..2666722 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/artifact/sacrifice/SacrificeServiceTest.java @@ -0,0 +1,523 @@ +package net.tfminecraft.magic.artifact.sacrifice; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.*; +import net.tfminecraft.interactiblefurniture.furniture.*; +import net.tfminecraft.magic.*; +import net.tfminecraft.magic.artifact.*; +import net.tfminecraft.magic.artifact.shrine.*; +import net.tfminecraft.magic.integration.RpCharactersBridge; +import net.tfminecraft.rpcharacters.objects.RPCharacter; +import org.bukkit.*; +import org.bukkit.event.entity.*; +import org.bukkit.inventory.ItemStack; +import org.junit.jupiter.api.*; +import org.mockbukkit.mockbukkit.*; +import org.mockbukkit.mockbukkit.entity.PlayerMock; +import org.mockito.MockedStatic; + +class SacrificeServiceTest { + ServerMock server; + PlayerMock caster, victim; + Furniture furniture; + PlacedSlot slot; + ItemStack item; + ShrineScore score; + MockedStatic targeting; + MockedStatic fx; + MockedStatic dagger; + MockedStatic messages; + + @BeforeEach + void setup() { + server = MockBukkit.mock(); + var world = server.addSimpleWorld("rite"); + caster = server.addPlayer(); + victim = server.addPlayer(); + caster.teleport(new Location(world, 0, 64, 0)); + victim.teleport(caster.getLocation()); + Magic.plugin = mock(Magic.class); + when(Magic.plugin.namespace()).thenReturn("magic"); + when(Magic.plugin.getServer()).thenReturn(server); + when(Magic.plugin.isEnabled()).thenReturn(true); + furniture = mock(Furniture.class); + when(furniture.getEntityId()).thenReturn(UUID.randomUUID()); + when(furniture.getLoc()).thenReturn(caster.getLocation()); + slot = mock(PlacedSlot.class); + when(furniture.getActiveSlot("main")).thenReturn(Optional.of(slot)); + item = new ItemStack(Material.STONE); + var artifact = Artifact.create(); + artifact.setCap("fire", 10); + artifact.persistPdc(item); + when(slot.getCurrentItem()).thenAnswer(i -> item); + score = new ShrineScore(Map.of()); + SacrificeRegistry.clear(); + SacrificeRegistry.setEnabled(true); + SacrificeRegistry.register( + new SacrificeElementDef("fire", true, List.of("word"), "pain", "fear", "soul", 0)); + targeting = mockStatic(SacrificeTargeting.class); + targeting + .when(() -> SacrificeTargeting.find(caster, "fire")) + .thenReturn(SacrificeTargeting.Result.ok(furniture, "main", victim, score)); + targeting + .when(() -> SacrificeTargeting.originCenter(furniture)) + .thenAnswer(i -> furniture.getLoc()); + fx = mockStatic(SacrificeRiteFx.class); + dagger = mockStatic(SacrificeDaggerMatcher.class); + dagger.when(() -> SacrificeDaggerMatcher.matches(any())).thenReturn(true); + messages = mockStatic(Messages.class, i -> i.getArgument(0)); + } + + @AfterEach + void cleanup() { + SacrificeRiteService.clearAll(); + messages.close(); + dagger.close(); + fx.close(); + targeting.close(); + SacrificeRegistry.clear(); + Magic.plugin = null; + MockBukkit.unmock(); + } + + void start() { + SacrificeRiteService.tryStart(caster, "fire"); + assertTrue(SacrificeRiteService.hasCaster(caster.getUniqueId())); + assertEquals("sacrifice.dread", victim.nextMessage()); + } + + void tick() { + server.getScheduler().performTicks(2); + } + + @Test + void startValidatesInputsConfigurationDaggerAndTargetFailures() { + SacrificeRiteService.tryStart(null, "fire"); + SacrificeRiteService.tryStart(caster, null); + SacrificeRiteService.tryStart(caster, ""); + SacrificeRiteService.tryStart(caster, "missing"); + assertFalse(SacrificeRiteService.hasSessions()); + assertFalse(SacrificeRiteService.hasCaster(null)); + SacrificeRegistry.setEnabled(false); + SacrificeRiteService.tryStart(caster, "fire"); + SacrificeRegistry.setEnabled(true); + dagger.when(() -> SacrificeDaggerMatcher.matches(any())).thenReturn(false); + SacrificeRiteService.tryStart(caster, "fire"); + assertEquals("sacrifice.fail.no_dagger", caster.nextMessage()); + dagger.when(() -> SacrificeDaggerMatcher.matches(any())).thenReturn(true); + for (var fail : + List.of( + SacrificeTargeting.Fail.NO_TARGET, + SacrificeTargeting.Fail.NO_SCORE, + SacrificeTargeting.Fail.MIN_SCORE, + SacrificeTargeting.Fail.FULL)) { + targeting + .when(() -> SacrificeTargeting.find(caster, "fire")) + .thenReturn(SacrificeTargeting.Result.fail(fail)); + SacrificeRiteService.tryStart(caster, "fire"); + assertEquals("sacrifice.fail." + fail.name().toLowerCase(Locale.ROOT), caster.nextMessage()); + } + } + + @Test + void activeRiteRejectsDuplicatesTicksAndCancellationNotifiesVictim() { + start(); + SacrificeRiteService.tryStart(caster, "fire"); + assertEquals("sacrifice.fail.active", caster.nextMessage()); + tick(); + assertTrue(SacrificeRiteService.hasSessions()); + SacrificeRiteService.stop(null, null); + SacrificeRiteService.stop(UUID.randomUUID(), null); + SacrificeRiteService.stop(furniture.getEntityId(), "wrong"); + SacrificeRiteService.cancelForPlayer(null); + SacrificeRiteService.cancelForPlayer(UUID.randomUUID()); + assertTrue(SacrificeRiteService.hasSessions()); + SacrificeRiteService.stop(furniture.getEntityId(), "main"); + assertEquals("sacrifice.subside", victim.nextMessage()); + assertFalse(SacrificeRiteService.hasSessions()); + start(); + SacrificeRiteService.cancelForPlayer(caster.getUniqueId()); + assertEquals("sacrifice.subside", victim.nextMessage()); + start(); + SacrificeRiteService.stopAll(furniture.getEntityId()); + assertFalse(SacrificeRiteService.hasSessions()); + } + + @Test + void missingArtifactCarriedFurnitureRangeAndDisconnectCancel() { + start(); + when(furniture.isCarried()).thenReturn(true); + tick(); + assertFalse(SacrificeRiteService.hasSessions()); + victim.nextMessage(); + when(furniture.isCarried()).thenReturn(false); + start(); + item = null; + tick(); + assertFalse(SacrificeRiteService.hasSessions()); + victim.nextMessage(); + var artifact = Artifact.create(); + artifact.setCap("fire", 10); + item = new ItemStack(Material.STONE); + artifact.persistPdc(item); + start(); + victim.teleport(caster.getLocation().add(20, 0, 0)); + tick(); + assertFalse(SacrificeRiteService.hasSessions()); + victim.nextMessage(); + victim.teleport(caster.getLocation()); + start(); + victim.disconnect(); + tick(); + assertFalse(SacrificeRiteService.hasSessions()); + } + + @Test + void offlineCasterAndChangedWorldCancel() { + start(); + caster.disconnect(); + tick(); + assertFalse(SacrificeRiteService.hasSessions()); + assertEquals("sacrifice.subside", victim.nextMessage()); + } + + @Test + void fullChargeSendsOneNearNoticeAndSessionStoresResolution() throws Exception { + var session = + new SacrificeRiteSession( + caster.getUniqueId(), + victim.getUniqueId(), + furniture, + "main", + "fire", + score, + System.currentTimeMillis() - 20_000); + assertEquals(1, session.charge()); + assertEquals(caster.getUniqueId(), session.getCasterId()); + assertEquals(victim.getUniqueId(), session.getVictimId()); + assertEquals(furniture.getEntityId(), session.getFurnitureId()); + assertEquals("main", session.getSlotId()); + assertEquals("fire", session.getElementId()); + assertSame(score, session.getScore()); + assertSame(furniture, session.getFurniture()); + assertTrue(session.getStartMillis() > 0); + assertFalse(session.isResolved()); + session.markResolved(true, "soul", .8); + assertTrue(session.isResolved()); + assertEquals("soul", session.getResolvedTierId()); + assertEquals(.8, session.getResolvedCharge()); + session.setNearSent(true); + assertTrue(session.isNearSent()); + var future = + new SacrificeRiteSession( + null, null, null, null, null, null, System.currentTimeMillis() + 10_000); + assertEquals(0, future.charge()); + assertNull(future.getFurnitureId()); + SacrificeRegistry.setGlobals( + .000001, 4, null, true, true, 0, 0, true, true, true, null, null, null); + start(); + // MockBukkit ticks do not advance the wall clock used by rite charging. + var active = SacrificeRiteService.sessions().iterator().next(); + var startMillis = SacrificeRiteSession.class.getDeclaredField("startMillis"); + startMillis.setAccessible(true); + startMillis.setLong(active, System.currentTimeMillis() - 1_000); + assertEquals(1, active.charge()); + tick(); + tick(); + assertEquals("sacrifice.near", victim.nextMessage()); + assertNull(victim.nextMessage()); + } + + @Test + void daggerDeathAppliesResolvedTierAndFillOnlyWhenRoleplayAccepts() { + try (var bridge = mockStatic(RpCharactersBridge.class); + var fill = mockStatic(SacrificeFillService.class)) { + var cause = mock(EntityDamageByEntityEvent.class); + when(cause.getDamager()).thenReturn(caster); + victim.setLastDamageCause(cause); + start(); + SacrificeRiteService.handleDeath(victim); + assertFalse(SacrificeRiteService.hasSessions()); + fill.verifyNoInteractions(); + var character = mock(RPCharacter.class); + when(character.getId()).thenReturn("victim-character"); + when(character.getName()).thenReturn("Victim"); + bridge.when(() -> RpCharactersBridge.getActiveCharacter(victim)).thenReturn(character); + bridge + .when(() -> RpCharactersBridge.applySacrificeTier(eq(victim), eq(caster), any())) + .thenReturn(true); + start(); + SacrificeRiteService.handleDeath(victim); + fill.verify( + () -> SacrificeFillService.apply(any(), any(), eq("victim-character"), eq("Victim"))); + assertFalse(SacrificeRiteService.hasSessions()); + } + } + + @Test + void otherDeathsOnlyCancelWithoutResolving() { + SacrificeRiteService.handleDeath(null); + start(); + SacrificeRiteService.handleDeath(victim); + assertFalse(SacrificeRiteService.hasSessions()); + victim.nextMessage(); + var damage = mock(EntityDamageByEntityEvent.class); + victim.setLastDamageCause(damage); + for (var entity : + List.of( + mock(org.bukkit.entity.Projectile.class), + mock(org.bukkit.entity.Zombie.class), + caster)) { + when(damage.getDamager()).thenReturn(entity); + dagger.when(() -> SacrificeDaggerMatcher.matches(any())).thenReturn(true); + start(); + dagger.when(() -> SacrificeDaggerMatcher.matches(any())).thenReturn(false); + SacrificeRiteService.handleDeath(victim); + assertFalse(SacrificeRiteService.hasSessions()); + victim.nextMessage(); + } + } + + @Test + void resolvedSacrificeAddsScaledAuraAndStoresCharacterHistory() { + Cache.artifactAuraCap = 100; + var shrine = new ShrineScore(Map.of("fire", new ShrineElementScore("fire", 50, 1, null, null))); + var session = + new SacrificeRiteSession( + caster.getUniqueId(), victim.getUniqueId(), furniture, "main", "fire", shrine, 0); + var tier = new SacrificeTierDef("soul", 0, 1, .1, "soul", true); + SacrificeFillService.apply(null, tier, "character", "Name"); + SacrificeFillService.apply(session, tier, "character", "Name"); + assertEquals(0, Artifact.fromItem(item).getFill("fire")); + session.markResolved(true, "soul", 1); + SacrificeFillService.apply(session, null, "character", "Name"); + SacrificeFillService.apply(session, tier, "character", "Name"); + assertEquals(5, Artifact.fromItem(item).getFill("fire")); + assertEquals(1, SacrificeImprintStore.read(item).size()); + SacrificeFillService.apply(session, tier, "character", "Name"); + assertEquals(10, Artifact.fromItem(item).getFill("fire")); + assertEquals(1, SacrificeImprintStore.read(item).size()); + verify(slot, times(2)).setCurrentItem(item); + } + + @Test + void sacrificeFillValidatesTargetAndHandlesMissingOrZeroScenery() { + Cache.artifactAuraCap = 100; + var tier = new SacrificeTierDef("pain", 0, 1, .1, "pain", false); + for (String id : Arrays.asList(null, "", "water", "fire")) { + var session = new SacrificeRiteSession(null, null, furniture, "main", id, null, 0); + session.markResolved(true, "pain", 1); + SacrificeFillService.apply(session, tier, null, null); + assertEquals(0, Artifact.fromItem(item).getFill("fire")); + } + var session = + new SacrificeRiteSession(null, null, furniture, "main", "fire", new ShrineScore(null), 0); + session.markResolved(true, "pain", 1); + SacrificeFillService.apply(session, tier, "", ""); + assertEquals(0, Artifact.fromItem(item).getFill("fire")); + Cache.artifactAuraCap = 0; + SacrificeFillService.apply(session, tier, null, null); + SacrificeFillService.apply( + session, new SacrificeTierDef("none", 0, 1, 0, "none", false), null, null); + item = null; + SacrificeFillService.apply(session, tier, null, null); + var absent = new SacrificeRiteSession(null, null, null, "main", "fire", score, 0); + absent.markResolved(true, "pain", 1); + SacrificeFillService.apply(absent, tier, null, null); + } + + @Test + void optionalDaggerAndConcurrencySettingsAllowSeveralCasters() { + SacrificeRegistry.setGlobals( + 10, 4, null, true, true, 0, 0, false, false, false, null, null, null); + dagger.when(() -> SacrificeDaggerMatcher.matches(any())).thenReturn(false); + start(); + var other = server.addPlayer(); + other.teleport(caster.getLocation()); + targeting + .when(() -> SacrificeTargeting.find(other, "fire")) + .thenReturn(SacrificeTargeting.Result.ok(furniture, "main", victim, score)); + SacrificeRiteService.tryStart(other, "fire"); + assertTrue(SacrificeRiteService.hasCaster(other.getUniqueId())); + SacrificeRiteService.cancelForPlayer(caster.getUniqueId()); + assertTrue(SacrificeRiteService.hasSessions()); + SacrificeRiteService.cancelForPlayer(victim.getUniqueId()); + assertFalse(SacrificeRiteService.hasSessions()); + SacrificeRegistry.clear(); + SacrificeRegistry.setEnabled(true); + SacrificeRiteService.tryStart(caster, "fire"); + SacrificeRegistry.register( + new SacrificeElementDef("fire", false, List.of("word"), "pain", "fear", "soul", 0)); + SacrificeRiteService.tryStart(caster, "fire"); + assertFalse(SacrificeRiteService.hasSessions()); + } + + @Test + void changedWorldAndMissingOriginCancelButDeadVictimWaitsForDeathHandler() { + start(); + victim.teleport(new Location(server.addSimpleWorld("elsewhere"), 0, 64, 0)); + tick(); + assertFalse(SacrificeRiteService.hasSessions()); + victim.nextMessage(); + victim.teleport(caster.getLocation()); + start(); + when(furniture.getLoc()).thenReturn(null); + tick(); + assertFalse(SacrificeRiteService.hasSessions()); + victim.nextMessage(); + var place = caster.getLocation(); + when(furniture.getLoc()).thenReturn(place); + start(); + victim.setHealth(0); + tick(); + assertTrue(SacrificeRiteService.hasSessions()); + } + + @Test + void daggerKillWithoutMatchingCasterOrVictimNeverResolves() { + var damage = mock(EntityDamageByEntityEvent.class); + var outsider = server.addPlayer(); + when(damage.getDamager()).thenReturn(outsider); + victim.setLastDamageCause(damage); + start(); + SacrificeRiteService.handleDeath(victim); + assertFalse(SacrificeRiteService.hasSessions()); + victim.nextMessage(); + start(); + outsider.setLastDamageCause(damage); + SacrificeRiteService.handleDeath(outsider); + assertTrue(SacrificeRiteService.hasSessions()); + } + + @Test + void reentrantVictimNotificationsCanRemoveOtherSessionsDuringSnapshotTraversal() { + var other = server.addPlayer(); + other.teleport(caster.getLocation()); + targeting + .when(() -> SacrificeTargeting.find(other, "fire")) + .thenReturn(SacrificeTargeting.Result.ok(furniture, "main", victim, score)); + var observed = spy(victim); + var victimId = victim.getUniqueId(); + var entered = new java.util.concurrent.atomic.AtomicBoolean(); + doAnswer( + call -> { + if (entered.compareAndSet(false, true)) SacrificeRiteService.clearAll(); + return null; + }) + .when(observed) + .sendMessage("sacrifice.subside"); + try (var bukkit = mockStatic(org.bukkit.Bukkit.class, CALLS_REAL_METHODS)) { + bukkit.when(() -> org.bukkit.Bukkit.getPlayer(victimId)).thenReturn(observed); + for (int operation = 0; operation < 3; operation++) { + when(furniture.isCarried()).thenReturn(false); + entered.set(false); + SacrificeRiteService.tryStart(caster, "fire"); + SacrificeRiteService.tryStart(other, "fire"); + assertEquals( + 2, + java.util.stream.StreamSupport.stream( + SacrificeRiteService.sessions().spliterator(), false) + .count()); + if (operation == 0) SacrificeRiteService.stopAll(furniture.getEntityId()); + else if (operation == 1) SacrificeRiteService.cancelForPlayer(victimId); + else { + when(furniture.isCarried()).thenReturn(true); + tick(); + } + assertFalse(SacrificeRiteService.hasSessions()); + } + } + } + + @Test + void fillsPersistHistoryOnlyForNamedArtifactCharacters() { + Cache.artifactAuraCap = 100; + ArtifactIds.writeNew(item); + var shrine = new ShrineScore(Map.of("fire", new ShrineElementScore("fire", 50, 1, null, null))); + var session = + new SacrificeRiteSession( + caster.getUniqueId(), victim.getUniqueId(), furniture, "main", "fire", shrine, 0); + session.markResolved(true, "soul", 1); + var tier = new SacrificeTierDef("soul", 0, 1, .1, "soul", true); + try (var external = + mockStatic(net.tfminecraft.interactiblefurniture.InteractibleFurniture.class)) { + var plugin = + mock( + net.tfminecraft.interactiblefurniture.InteractibleFurniture.class, + RETURNS_DEEP_STUBS); + external + .when(net.tfminecraft.interactiblefurniture.InteractibleFurniture::getInstance) + .thenReturn(plugin); + SacrificeFillService.apply(session, tier, null, null); + SacrificeFillService.apply(session, tier, " ", "Name"); + assertTrue(SacrificeImprintStore.read(item).isEmpty()); + verify(plugin.getFurnitureManager(), times(2)).persistFurniture(furniture); + var meta = item.getItemMeta(); + meta.getPersistentDataContainer() + .set( + net.tfminecraft.magic.charge.ChargeKeys.chargeTier(), + org.bukkit.persistence.PersistentDataType.INTEGER, + 1); + item.setItemMeta(meta); + SacrificeFillService.apply(session, tier, "id", "Name"); + assertTrue(SacrificeImprintStore.read(item).isEmpty()); + } + } + + @Test + void refusesActiveWordsIndependentlyAndRecreatesCancelledTicker() throws Exception { + SacrificeRegistry.setGlobals( + 10, 4, null, true, true, 0, 0, true, false, true, null, null, null); + start(); + SacrificeRiteService.tryStart(caster, "fire"); + assertEquals("sacrifice.fail.active", caster.nextMessage()); + server.getScheduler().cancelTasks(Magic.plugin); + var other = server.addPlayer(); + targeting + .when(() -> SacrificeTargeting.find(other, "fire")) + .thenReturn(SacrificeTargeting.Result.ok(furniture, "main", victim, score)); + SacrificeRiteService.tryStart(other, "fire"); + assertTrue(SacrificeRiteService.hasCaster(other.getUniqueId())); + SacrificeRiteService.clearAll(); + var saved = Magic.plugin; + Magic.plugin = null; + try { + var ensure = SacrificeRiteService.class.getDeclaredMethod("ensureTicker"); + ensure.setAccessible(true); + ensure.invoke(null); + } finally { + Magic.plugin = saved; + } + } + + @Test + void disconnectBetweenPlayerLookupAndUseStopsRitesWithoutNotification() { + for (int scenario = 0; scenario < 3; scenario++) { + var c = server.addPlayer(); + var v = server.addPlayer(); + c.teleport(caster.getLocation()); + v.teleport(caster.getLocation()); + targeting + .when(() -> SacrificeTargeting.find(c, "fire")) + .thenReturn(SacrificeTargeting.Result.ok(furniture, "main", v, score)); + SacrificeRiteService.tryStart(c, "fire"); + var subject = scenario == 2 ? c : v; + var id = subject.getUniqueId(); + try (var bukkit = mockStatic(org.bukkit.Bukkit.class, CALLS_REAL_METHODS)) { + bukkit + .when(() -> org.bukkit.Bukkit.getPlayer(id)) + .thenAnswer( + call -> { + var found = server.getPlayer(id); + subject.disconnect(); + return found; + }); + if (scenario == 0) SacrificeRiteService.cancelForPlayer(c.getUniqueId()); + else tick(); + assertFalse(SacrificeRiteService.hasCaster(c.getUniqueId())); + } + } + } +} diff --git a/src/test/java/net/tfminecraft/magic/artifact/sacrifice/SacrificeTargetingTest.java b/src/test/java/net/tfminecraft/magic/artifact/sacrifice/SacrificeTargetingTest.java new file mode 100644 index 0000000..1d5cc0f --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/artifact/sacrifice/SacrificeTargetingTest.java @@ -0,0 +1,241 @@ +package net.tfminecraft.magic.artifact.sacrifice; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.*; +import net.tfminecraft.interactiblefurniture.InteractibleFurniture; +import net.tfminecraft.interactiblefurniture.furniture.*; +import net.tfminecraft.magic.*; +import net.tfminecraft.magic.artifact.*; +import net.tfminecraft.magic.artifact.shrine.*; +import org.bukkit.*; +import org.bukkit.inventory.ItemStack; +import org.junit.jupiter.api.*; +import org.mockbukkit.mockbukkit.*; +import org.mockbukkit.mockbukkit.entity.PlayerMock; +import org.mockito.MockedStatic; + +class SacrificeTargetingTest { + ServerMock server; + World world; + PlayerMock caster, victim; + Furniture furniture; + PlacedSlot slot; + ItemStack item; + ShrineScore score; + List posts = new ArrayList<>(); + MockedStatic bridge; + MockedStatic scoring; + + @BeforeEach + void setup() { + server = MockBukkit.mock(); + world = server.addSimpleWorld("targeting"); + caster = server.addPlayer(); + victim = server.addPlayer(); + caster.teleport(new Location(world, .5, 65, .5)); + victim.teleport(caster.getLocation()); + Magic.plugin = mock(Magic.class); + when(Magic.plugin.namespace()).thenReturn("magic"); + var dependency = mock(InteractibleFurniture.class, RETURNS_DEEP_STUBS); + bridge = mockStatic(InteractibleFurniture.class); + bridge.when(InteractibleFurniture::getInstance).thenReturn(dependency); + when(dependency.getFurnitureManager().getFurnitureInChunk(any())) + .thenAnswer( + i -> { + Chunk chunk = i.getArgument(0); + return posts.stream() + .filter( + p -> + p.getLoc() == null + || ((p.getLoc().getBlockX() >> 4) == chunk.getX() + && (p.getLoc().getBlockZ() >> 4) == chunk.getZ())) + .collect(java.util.stream.Collectors.toSet()); + }); + furniture = mock(Furniture.class); + when(furniture.getLoc()).thenReturn(new Location(world, 0, 64, 0)); + when(furniture.getId()).thenReturn("pedestal"); + when(furniture.getOriginBlockLocation()).thenReturn(Optional.empty()); + slot = mock(PlacedSlot.class); + when(slot.getId()).thenReturn("main"); + when(slot.getCurrentItem()).thenAnswer(i -> item); + when(furniture.getActiveSlots()).thenReturn(Map.of("main", slot)); + posts.add(furniture); + artifact(0); + score = new ShrineScore(Map.of("fire", new ShrineElementScore("fire", 100, 1, null, null))); + scoring = mockStatic(ShrineScorer.class); + scoring.when(() -> ShrineScorer.score(any())).thenAnswer(i -> score); + SacrificeRegistry.clear(); + ShrineRegistry.clear(); + } + + @AfterEach + void cleanup() { + scoring.close(); + bridge.close(); + SacrificeRegistry.clear(); + ShrineRegistry.clear(); + Magic.plugin = null; + MockBukkit.unmock(); + } + + void artifact(double fill) { + item = new ItemStack(Material.STONE); + var a = Artifact.create(); + a.setCap("fire", 10); + a.setFill("fire", fill); + a.persistPdc(item); + } + + SacrificeTargeting.Result find() { + return SacrificeTargeting.find(caster, "fire"); + } + + @Test + void validTargetSelectsClosestPlayerAndAlphabeticalEqualDistanceSlot() { + var second = mock(PlacedSlot.class); + when(second.getId()).thenReturn("alpha"); + when(second.getCurrentItem()).thenAnswer(i -> item); + var ordered = new LinkedHashMap(); + ordered.put("main", slot); + ordered.put("alpha", second); + when(furniture.getActiveSlots()).thenReturn(ordered); + var distant = server.addPlayer(); + distant.teleport(new Location(world, 3, 65, 0)); + var result = find(); + assertTrue(result.isOk()); + assertSame(furniture, result.getFurniture()); + assertSame(victim, result.getVictim()); + assertEquals("alpha", result.getSlotId()); + assertSame(score, result.getScore()); + assertEquals(SacrificeTargeting.Fail.NONE, result.getFail()); + assertEquals( + 1, SacrificeTargeting.chebyshevBlocks(caster.getLocation(), new Location(world, 1, 65, 0))); + assertNotNull(SacrificeTargeting.originCenter(furniture)); + } + + @Test + void failurePrioritiesExplainFullMissingScoreWeakScoreAndAbsentVictim() { + artifact(10); + assertEquals(SacrificeTargeting.Fail.FULL, find().getFail()); + artifact(0); + score = new ShrineScore(null); + assertEquals(SacrificeTargeting.Fail.NO_SCORE, find().getFail()); + score = new ShrineScore(Map.of("fire", new ShrineElementScore("fire", 20, 1, null, null))); + assertEquals(SacrificeTargeting.Fail.MIN_SCORE, find().getFail()); + score = new ShrineScore(Map.of("fire", new ShrineElementScore("fire", 100, 1, null, null))); + victim.teleport(new Location(world, 20, 65, 0)); + assertEquals(SacrificeTargeting.Fail.NO_TARGET, find().getFail()); + victim.disconnect(); + assertEquals(SacrificeTargeting.Fail.NO_TARGET, find().getFail()); + } + + @Test + void invalidArgumentsPedestalsItemsAndSchoolAreRejected() { + assertEquals( + SacrificeTargeting.Fail.NO_TARGET, SacrificeTargeting.find(null, "fire").getFail()); + assertFalse(SacrificeTargeting.find(caster, null).isOk()); + assertFalse(SacrificeTargeting.find(caster, "").isOk()); + assertFalse(SacrificeTargeting.find(caster, "water").isOk()); + when(furniture.isCarried()).thenReturn(true); + assertFalse(find().isOk()); + when(furniture.isCarried()).thenReturn(false); + when(furniture.getId()).thenReturn("chair"); + assertFalse(find().isOk()); + when(furniture.getId()).thenReturn("pedestal"); + when(furniture.getLoc()).thenReturn(new Location(world, 20, 64, 0)); + assertFalse(find().isOk()); + when(furniture.getLoc()).thenReturn(null); + assertFalse(find().isOk()); + when(furniture.getLoc()).thenReturn(new Location(world, 0, 64, 0)); + item = null; + assertFalse(find().isOk()); + item = new ItemStack(Material.STONE); + assertFalse(find().isOk()); + artifact(0); + when(slot.getId()).thenReturn(null); + assertFalse(find().isOk()); + bridge.when(InteractibleFurniture::getInstance).thenReturn(null); + assertTrue(SacrificeTargeting.collectNearby(world, 0, 0).isEmpty()); + } + + @Test + void explicitSlotFilterAndDisabledSceneryFloorAreRespected() { + SacrificeRegistry.setGlobals( + 10, 4, null, true, false, 50, 20, true, true, true, "pedestal", "other", null); + assertFalse(find().isOk()); + SacrificeRegistry.setGlobals( + 10, 4, null, true, false, 50, 20, true, true, true, "pedestal", "MAIN", null); + score = null; + assertTrue(find().isOk()); + } + + @Test + void resultsRequireCompleteTargetsAndUnavailableOriginsAreSkipped() { + assertFalse(SacrificeTargeting.Result.ok(null, "main", victim, score).isOk()); + assertFalse(SacrificeTargeting.Result.ok(furniture, null, victim, score).isOk()); + assertFalse(SacrificeTargeting.Result.ok(furniture, "main", null, score).isOk()); + when(furniture.getLoc()).thenReturn(new Location(server.addSimpleWorld("foreign"), 0, 64, 0)); + assertFalse(find().isOk()); + when(furniture.getLoc()).thenReturn(new Location(null, 0, 64, 0)); + assertFalse(find().isOk()); + } + + @Test + void displayStandDistanceBeatsSlotOrderAndDeadVictimsAreExcluded() { + var second = mock(PlacedSlot.class); + when(second.getId()).thenReturn("zeta"); + when(second.getCurrentItem()).thenAnswer(i -> item); + var display = new org.mockbukkit.mockbukkit.entity.ItemDisplayMock(server, UUID.randomUUID()); + display.teleport(caster.getLocation()); + server.registerEntity(display); + when(second.getDisplayStandId()).thenReturn(display.getUniqueId()); + var ordered = new LinkedHashMap(); + ordered.put("main", slot); + ordered.put("zeta", second); + when(furniture.getActiveSlots()).thenReturn(ordered); + assertEquals("zeta", find().getSlotId()); + display.teleport(new Location(world, 3, 65, 0)); + assertEquals("main", find().getSlotId()); + display.teleport(new Location(server.addSimpleWorld("otherdisplay"), 0, 65, 0)); + assertEquals("main", find().getSlotId()); + when(second.getDisplayStandId()).thenReturn(UUID.randomUUID()); + assertEquals("main", find().getSlotId()); + victim.setHealth(0); + assertFalse(find().isOk()); + } + + @Test + void missingFurnitureAndSlotEntriesFromDependencyAreSkipped() { + var manager = InteractibleFurniture.getInstance().getFurnitureManager(); + doReturn(new LinkedHashSet<>(Arrays.asList(null, furniture))) + .when(manager) + .getFurnitureInChunk(any()); + var slots = new LinkedHashMap(); + slots.put("empty", null); + slots.put("main", slot); + when(furniture.getActiveSlots()).thenReturn(slots); + assertTrue(find().isOk()); + } + + @Test + void nearestPedestalWinsIndependentlyOfDependencyIterationOrder() { + var near = mock(Furniture.class); + when(near.getLoc()).thenReturn(new Location(world, 0, 64, 0)); + when(near.getOriginBlockLocation()).thenReturn(Optional.empty()); + when(near.getId()).thenReturn("pedestal"); + when(near.getActiveSlots()).thenReturn(Map.of("main", slot)); + when(furniture.getLoc()).thenReturn(new Location(world, 2, 64, 0)); + var manager = InteractibleFurniture.getInstance().getFurnitureManager(); + for (var order : List.of(List.of(furniture, near), List.of(near, furniture))) { + doReturn(new LinkedHashSet<>(order)).when(manager).getFurnitureInChunk(any()); + var result = find(); + assertTrue(result.isOk()); + assertSame(near, result.getFurniture()); + } + SacrificeRegistry.setGlobals( + 10, 4, null, true, false, 50, 20, true, true, true, "pedestal", "*", null); + assertTrue(SacrificeTargeting.find(caster, " FIRE ").isOk()); + } +} diff --git a/src/test/java/net/tfminecraft/magic/artifact/shrine/ShrineFxTest.java b/src/test/java/net/tfminecraft/magic/artifact/shrine/ShrineFxTest.java new file mode 100644 index 0000000..bb2fe96 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/artifact/shrine/ShrineFxTest.java @@ -0,0 +1,283 @@ +package net.tfminecraft.magic.artifact.shrine; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.*; +import net.tfminecraft.interactiblefurniture.furniture.*; +import net.tfminecraft.magic.*; +import net.tfminecraft.magic.artifact.*; +import net.tfminecraft.magic.artifact.aura.*; +import net.tfminecraft.magic.artifact.sacrifice.*; +import net.tfminecraft.magic.meditation.MeditationCircle; +import net.tfminecraft.magic.model.ElementDef; +import net.tfminecraft.magic.registry.ElementRegistry; +import net.tfminecraft.magic.util.PedestalFx; +import org.bukkit.*; +import org.bukkit.inventory.ItemStack; +import org.junit.jupiter.api.*; +import org.mockbukkit.mockbukkit.*; + +class ShrineFxTest { + ServerMock server; + World world; + Furniture furniture; + PlacedSlot slot; + ItemStack item; + + @BeforeEach + void setup() { + server = MockBukkit.mock(); + world = mock(World.class); + furniture = mock(Furniture.class); + when(furniture.getLoc()).thenReturn(new Location(world, 0, 64, 0)); + when(furniture.getOriginBlockLocation()).thenReturn(Optional.empty()); + when(furniture.getEntityId()).thenReturn(UUID.randomUUID()); + slot = mock(PlacedSlot.class); + when(furniture.getActiveSlot("main")).thenReturn(Optional.of(slot)); + Magic.plugin = mock(Magic.class); + when(Magic.plugin.namespace()).thenReturn("magic"); + when(Magic.plugin.getServer()).thenReturn(server); + when(Magic.plugin.isEnabled()).thenReturn(true); + ShrineRegistry.clear(); + SacrificeRegistry.clear(); + ElementRegistry.clear(); + item = new ItemStack(Material.STONE); + var a = Artifact.create(); + a.setCap("fire", 10); + a.persistPdc(item); + when(slot.getCurrentItem()).thenReturn(item); + } + + @AfterEach + void cleanup() { + ShrineChargeService.clearAll(); + ShrineChargeFx.stopIfIdle(); + ElementRegistry.clear(); + Magic.plugin = null; + MockBukkit.unmock(); + } + + @Test + void startCompleteAndSacrificeReadyEmitConfiguredEffectsAtArtifactPoint() { + assertEquals(Color.WHITE, ShrineChargeFx.elementColor("unknown")); + var element = mock(ElementDef.class); + when(element.getId()).thenReturn("fire"); + when(element.getColor()).thenReturn("#ff0000"); + ElementRegistry.register(element); + assertEquals(Color.RED, ShrineChargeFx.elementColor("fire")); + ShrineChargeFx.start(furniture, "fire"); + ShrineChargeFx.complete(furniture, "fire"); + ShrineChargeFx.sacrificeReady(furniture, "fire"); + verify(world, atLeastOnce()) + .spawnParticle( + eq(Particle.DUST), + any(Location.class), + anyInt(), + anyDouble(), + anyDouble(), + anyDouble(), + any(Particle.DustOptions.class)); + verify(world, atLeastOnce()) + .playSound( + any(Location.class), + any(Sound.class), + eq(SoundCategory.MASTER), + anyFloat(), + anyFloat()); + assertEquals(new Location(world, .5, 65.8, .5), PedestalFx.artifactPoint(furniture)); + when(furniture.getOriginBlockLocation()).thenReturn(Optional.of(new Location(world, 2, 60, 3))); + assertEquals(new Location(world, 2.5, 61.8, 3.5), PedestalFx.artifactPoint(furniture)); + } + + @Test + void unavailableFurnitureAndOptionalEffectsFailSafely() { + ShrineChargeFx.start(null, "fire"); + ShrineChargeFx.complete(null, "fire"); + ShrineChargeFx.sacrificeReady(null, "fire"); + assertNull(PedestalFx.artifactPoint(null)); + when(furniture.getLoc()).thenReturn(null); + ShrineChargeFx.start(furniture, "fire"); + ShrineChargeFx.complete(furniture, "fire"); + ShrineChargeFx.sacrificeReady(furniture, "fire"); + assertNull(PedestalFx.artifactPoint(furniture)); + when(furniture.getLoc()).thenReturn(new Location(null, 0, 0, 0)); + ShrineChargeFx.start(furniture, "fire"); + ShrineChargeFx.complete(furniture, "fire"); + ShrineChargeFx.sacrificeReady(furniture, "fire"); + assertNull(PedestalFx.artifactPoint(furniture)); + when(furniture.getLoc()).thenReturn(new Location(world, 0, 64, 0)); + ShrineRegistry.setDefaultFx( + new ShrineFxDef(null, 0, 1, 0, null, 0, 1, null, 0, 1, null, 0, 1, null, null, null)); + ShrineChargeFx.start(furniture, "fire"); + ShrineChargeFx.complete(furniture, "fire"); + ShrineChargeFx.sacrificeReady(furniture, "fire"); + ShrineRegistry.setDefaultFx(ShrineFxDef.fallback()); + doThrow(new IllegalArgumentException("unsupported particle")) + .when(world) + .spawnParticle( + any(Particle.class), + any(Location.class), + anyInt(), + anyDouble(), + anyDouble(), + anyDouble(), + anyDouble()); + ShrineChargeFx.start(furniture, "fire"); + ShrineChargeFx.complete(furniture, "fire"); + } + + @Test + void scheduledStreamsAnimateContributingBlocksAndStopWhenIdle() { + var locations = new ArrayList(); + locations.add(null); + locations.add(new Location(null, 0, 0, 0)); + for (int n = 0; n < 20; n++) locations.add(new Location(world, n, 64, 0)); + var score = + new ShrineScore(Map.of("fire", new ShrineElementScore("fire", 10, 1, null, locations))); + try (var scoring = mockStatic(ShrineScorer.class); + var circle = mockStatic(MeditationCircle.class)) { + scoring.when(() -> ShrineScorer.score(any())).thenReturn(score); + ShrineChargeService.tryStart(null, furniture, "main"); + assertTrue(ShrineChargeService.hasSessions()); + ShrineChargeFx.stopIfIdle(); + ShrineChargeFx.ensureRunning(); + server.getScheduler().performTicks(50); + verify(world, atLeastOnce()) + .spawnParticle( + eq(Particle.DUST), + any(Location.class), + eq(1), + eq(0d), + eq(0d), + eq(0d), + any(Particle.DustOptions.class)); + ShrineChargeFx.complete(furniture, AuraVessels.fromItem(item), item, score); + when(slot.getCurrentItem()).thenReturn(null); + ShrineChargeFx.tick(); + when(slot.getCurrentItem()).thenReturn(item); + var a = Artifact.fromItem(item); + a.setFill("fire", 10); + a.persistPdc(item); + ShrineChargeFx.tick(); + ShrineChargeService.clearAll(); + ShrineChargeFx.tick(); + Magic.plugin = null; + ShrineChargeFx.ensureRunning(); + } + } + + static Object call(String name, Class[] types, Object... args) throws Exception { + var method = ShrineChargeFx.class.getDeclaredMethod(name, types); + method.setAccessible(true); + return method.invoke(null, args); + } + + @Test + void originWithoutWorldAndMissingFurnitureCannotEmitPulls() throws Exception { + when(furniture.getOriginBlockLocation()).thenReturn(Optional.of(new Location(null, 0, 0, 0))); + ShrineChargeFx.start(furniture, "fire"); + ShrineChargeFx.complete(furniture, "fire"); + ShrineChargeFx.sacrificeReady(furniture, "fire"); + var session = new ShrineChargeSession(furniture, "main", new ShrineScore(Map.of())); + call("spawnPulls", new Class[] {ShrineChargeSession.class}, session); + when(furniture.getLoc()).thenReturn(null); + call("spawnPulls", new Class[] {ShrineChargeSession.class}, session); + when(furniture.getLoc()).thenReturn(new Location(null, 0, 0, 0)); + call("spawnPulls", new Class[] {ShrineChargeSession.class}, session); + } + + @Test + void forcedStreamsAndSceneryFiltersRespectCapsAndOptionalAmbient() throws Exception { + var empty = new ShrineScore(Map.of()); + var vessel = Artifact.fromItem(item); + var session = new ShrineChargeSession(furniture, "main", empty, "fire", 5, true); + call("spawnPulls", new Class[] {ShrineChargeSession.class}, session); + session = new ShrineChargeSession(furniture, "main", empty, null, 5, true); + call("spawnPulls", new Class[] {ShrineChargeSession.class}, session); + session = new ShrineChargeSession(furniture, "main", empty, "missing", 5, true); + call("spawnPulls", new Class[] {ShrineChargeSession.class}, session); + vessel.setFill("fire", 10); + vessel.persistPdc(item); + call( + "spawnPulls", + new Class[] {ShrineChargeSession.class}, + new ShrineChargeSession(furniture, "main", empty, "fire", 5, true)); + for (ShrineScore score : + List.of( + empty, + new ShrineScore(Map.of("fire", new ShrineElementScore("fire", 0, 1, null, null))), + new ShrineScore(Map.of("fire", new ShrineElementScore("fire", 10, 0, null, null))))) + call( + "spawnPulls", + new Class[] {ShrineChargeSession.class}, + new ShrineChargeSession(furniture, "main", score)); + ShrineRegistry.register(new ShrineElementDef("fire", false, List.of())); + call( + "spawnPulls", + new Class[] {ShrineChargeSession.class}, + new ShrineChargeSession(furniture, "main", empty)); + var blank = Artifact.create(); + blank.persistPdc(item); + call( + "spawnPulls", + new Class[] {ShrineChargeSession.class}, + new ShrineChargeSession(furniture, "main", empty)); + } + + @Test + void animationRestartAndOptionalParticleBoundariesAreSafe() throws Exception { + ShrineChargeFx.ensureRunning(); + var field = ShrineChargeFx.class.getDeclaredField("task"); + field.setAccessible(true); + ((org.bukkit.scheduler.BukkitTask) field.get(null)).cancel(); + ShrineChargeFx.ensureRunning(); + ShrineChargeFx.stopIfIdle(); + ShrineChargeFx.stopIfIdle(); + var at = new Location(world, 0, 0, 0); + var spawnTypes = + new Class[] { + World.class, + Particle.class, + Location.class, + int.class, + double.class, + double.class, + double.class, + double.class + }; + call("spawn", spawnTypes, null, Particle.CLOUD, at, 1, 0., 0., 0., 0.); + call("spawn", spawnTypes, world, Particle.CLOUD, null, 1, 0., 0., 0., 0.); + call("spawn", spawnTypes, world, Particle.CLOUD, at, 0, 0., 0., 0., 0.); + call( + "play", + new Class[] {World.class, Location.class, Sound.class, float.class, float.class}, + null, + at, + Sound.BLOCK_BEACON_ACTIVATE, + 1f, + 1f); + call( + "play", + new Class[] {World.class, Location.class, Sound.class, float.class, float.class}, + world, + null, + Sound.BLOCK_BEACON_ACTIVATE, + 1f, + 1f); + } + + @Test + void forcedExistingScoreAlsoAnimatesContributions() throws Exception { + var score = + new ShrineScore( + Map.of( + "fire", + new ShrineElementScore( + "fire", 10, 1, List.of(), List.of(new Location(world, 1, 1, 1))))); + call( + "spawnPulls", + new Class[] {ShrineChargeSession.class}, + new ShrineChargeSession(furniture, "main", score, "fire", 5, true)); + } +} diff --git a/src/test/java/net/tfminecraft/magic/artifact/shrine/ShrineServiceTest.java b/src/test/java/net/tfminecraft/magic/artifact/shrine/ShrineServiceTest.java new file mode 100644 index 0000000..89e57c3 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/artifact/shrine/ShrineServiceTest.java @@ -0,0 +1,815 @@ +package net.tfminecraft.magic.artifact.shrine; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.*; +import net.tfminecraft.interactiblefurniture.InteractibleFurniture; +import net.tfminecraft.interactiblefurniture.furniture.*; +import net.tfminecraft.magic.*; +import net.tfminecraft.magic.artifact.*; +import net.tfminecraft.magic.artifact.aura.*; +import net.tfminecraft.magic.artifact.sacrifice.*; +import net.tfminecraft.magic.meditation.*; +import net.tfminecraft.magic.tick.MagicTickContext; +import org.bukkit.*; +import org.bukkit.inventory.ItemStack; +import org.junit.jupiter.api.*; +import org.mockbukkit.mockbukkit.*; +import org.mockito.MockedStatic; + +class ShrineServiceTest { + ServerMock server; + Furniture furniture; + PlacedSlot slot; + ItemStack item; + ShrineScore score; + MockedStatic scoring; + MockedStatic fx; + MockedStatic circle; + MockedStatic bridge; + InteractibleFurniture dependency; + + @BeforeEach + void setup() { + server = MockBukkit.mock(); + var world = server.addSimpleWorld("shrine"); + Magic.plugin = mock(Magic.class); + when(Magic.plugin.namespace()).thenReturn("magic"); + furniture = mock(Furniture.class); + slot = mock(PlacedSlot.class); + when(furniture.getEntityId()).thenReturn(UUID.randomUUID()); + when(furniture.getLoc()).thenReturn(new Location(world, 0, 64, 0)); + when(furniture.getActiveSlot("main")).thenReturn(Optional.of(slot)); + when(furniture.getActiveSlots()).thenReturn(Map.of("main", slot)); + when(slot.getId()).thenReturn("main"); + when(furniture.getId()).thenReturn("pedestal"); + when(slot.getCurrentItem()).thenAnswer(i -> item); + doAnswer( + i -> { + item = i.getArgument(0); + return null; + }) + .when(slot) + .setCurrentItem(any()); + score = + new ShrineScore(Map.of("fire", new ShrineElementScore("fire", 5, 2, List.of(), List.of()))); + scoring = mockStatic(ShrineScorer.class); + scoring.when(() -> ShrineScorer.score(any())).thenAnswer(i -> score); + fx = mockStatic(ShrineChargeFx.class); + circle = mockStatic(MeditationCircle.class); + bridge = mockStatic(InteractibleFurniture.class); + dependency = mock(InteractibleFurniture.class, RETURNS_DEEP_STUBS); + bridge.when(InteractibleFurniture::getInstance).thenReturn(dependency); + ShrineRegistry.clear(); + SacrificeRegistry.clear(); + MeditationCache.pedestalId = "pedestal"; + Cache.artifactAuraCap = 30; + } + + @AfterEach + void cleanup() { + ShrineChargeService.clearAll(); + bridge.close(); + circle.close(); + fx.close(); + scoring.close(); + Magic.plugin = null; + MockBukkit.unmock(); + } + + void artifact(double fill) { + item = new ItemStack(Material.STONE); + var a = Artifact.create(); + a.setCap("fire", 10); + a.setFill("fire", fill); + a.persistPdc(item); + ArtifactIds.writeNew(item); + } + + double fill() { + return AuraVessels.fromItem(item).getFill("fire"); + } + + @Test + void naturalChargeCannotOvershootTheSceneryLimit() { + artifact(4); + ShrineChargeService.tryStart(null, furniture, "main"); + assertTrue(ShrineChargeService.hasSessions()); + ShrineChargeService.tick(MagicTickContext.of(1)); + assertEquals(5, fill()); + ShrineChargeService.tick(MagicTickContext.of(2)); + assertFalse(ShrineChargeService.hasSessions()); + } + + @Test + void naturalSessionsPersistProgressAndStopOnRemovalCarryOrCircle() { + artifact(0); + ShrineChargeService.tryStart(null, furniture, "main"); + assertTrue(ShrineChargeService.hasSessions()); + ShrineChargeService.tick(MagicTickContext.of(5)); + assertEquals(2, fill()); + verify(dependency.getFurnitureManager()).persistFurniture(furniture); + ShrineChargeService.stop(furniture.getEntityId(), "main"); + assertFalse(ShrineChargeService.hasSessions()); + ShrineChargeService.tryStart(null, furniture, "main"); + when(furniture.isCarried()).thenReturn(true); + ShrineChargeService.tick(null); + assertFalse(ShrineChargeService.hasSessions()); + when(furniture.isCarried()).thenReturn(false); + ShrineChargeService.tryStart(null, furniture, "main"); + circle.when(() -> MeditationCircle.containing(furniture)).thenReturn(true); + ShrineChargeService.tick(null); + assertFalse(ShrineChargeService.hasSessions()); + circle.when(() -> MeditationCircle.containing(furniture)).thenReturn(false); + ShrineChargeService.tryStart(null, furniture, "main"); + item = null; + ShrineChargeService.tick(null); + assertFalse(ShrineChargeService.hasSessions()); + artifact(0); + ShrineChargeService.tryStart(null, furniture, "main"); + item = new ItemStack(Material.STONE); + ShrineChargeService.tick(null); + assertFalse(ShrineChargeService.hasSessions()); + } + + @Test + void invalidOrFullItemsDoNotStartAndStopAllOnlyStopsMatchingFurniture() { + ShrineChargeService.tryStart(null, null, "main"); + ShrineChargeService.tryStart(null, furniture, null); + ShrineChargeService.tryStart(null, furniture, ""); + ShrineChargeService.tryStart(null, furniture, "missing"); + ShrineChargeService.tryStart(null, furniture, "main"); + item = new ItemStack(Material.AIR); + ShrineChargeService.tryStart(null, furniture, "main"); + item = new ItemStack(Material.STONE); + ShrineChargeService.tryStart(null, furniture, "main"); + assertFalse(ShrineChargeService.hasSessions()); + artifact(6); + var player = server.addPlayer(); + try (var messages = mockStatic(Messages.class, i -> i.getArgument(0))) { + ShrineChargeService.tryStart(player, furniture, "main"); + assertEquals("shrine.full", player.nextMessage()); + } + artifact(0); + circle.when(() -> MeditationCircle.containing(furniture)).thenReturn(true); + ShrineChargeService.tryStart(null, furniture, "main"); + assertFalse(ShrineChargeService.hasSessions()); + circle.when(() -> MeditationCircle.containing(furniture)).thenReturn(false); + ShrineChargeService.tryStart(null, furniture, "main"); + ShrineChargeService.stopAll(null); + ShrineChargeService.stopAll(UUID.randomUUID()); + assertTrue(ShrineChargeService.hasSessions()); + ShrineChargeService.stopAll(furniture.getEntityId()); + assertFalse(ShrineChargeService.hasSessions()); + assertEquals( + furniture.getEntityId() + "|", ShrineChargeService.key(furniture.getEntityId(), null)); + } + + @Test + void adminTargetsNearestEligiblePedestalAndClampsRequestedFill() { + var player = server.addPlayer(); + player.teleport(furniture.getLoc()); + assertEquals( + ShrineChargeService.AdminStart.NO_PEDESTAL, + ShrineChargeService.startAdmin(null, "fire", 5)); + assertEquals( + ShrineChargeService.AdminStart.NO_PEDESTAL, + ShrineChargeService.startAdmin(player, null, 5)); + assertEquals( + ShrineChargeService.AdminStart.NO_PEDESTAL, ShrineChargeService.startAdmin(player, "", 5)); + try (var targeting = mockStatic(SacrificeTargeting.class)) { + targeting + .when(() -> SacrificeTargeting.collectNearby(any(), anyInt(), anyInt())) + .thenReturn(List.of()); + assertEquals( + ShrineChargeService.AdminStart.NO_PEDESTAL, + ShrineChargeService.startAdmin(player, "fire", 5)); + targeting + .when(() -> SacrificeTargeting.collectNearby(any(), anyInt(), anyInt())) + .thenReturn(List.of(furniture)); + targeting + .when(() -> SacrificeTargeting.originCenter(furniture)) + .thenAnswer(i -> furniture.getLoc()); + assertEquals( + ShrineChargeService.AdminStart.NO_ARTIFACT, + ShrineChargeService.startAdmin(player, "fire", 5)); + artifact(0); + assertEquals( + ShrineChargeService.AdminStart.NO_CAP, + ShrineChargeService.startAdmin(player, "water", 5)); + artifact(5); + assertEquals( + ShrineChargeService.AdminStart.ALREADY_FULL, + ShrineChargeService.startAdmin(player, "fire", 5)); + artifact(0); + assertEquals( + ShrineChargeService.AdminStart.STARTED, + ShrineChargeService.startAdmin(player, "FIRE", 3)); + assertTrue(ShrineChargeService.hasSessions()); + ShrineChargeService.tick(MagicTickContext.of(5)); + assertEquals(2, fill()); + ShrineChargeService.tick(MagicTickContext.of(6)); + assertEquals(3, fill()); + assertFalse(ShrineChargeService.hasSessions()); + artifact(0); + assertEquals( + ShrineChargeService.AdminStart.STARTED, + ShrineChargeService.startAdmin(player, "fire", 15)); + ShrineChargeService.tick(null); + assertEquals(2, fill()); + Cache.artifactAuraCap = 0; + ShrineChargeService.tick(null); + assertFalse(ShrineChargeService.hasSessions()); + } + } + + @Test + void chargeSelectionUsesValidAllowedSceneryAndPrefersPrimaryOnTies() { + assertFalse(ShrineChargeService.hasChargeable(null, null, score)); + assertFalse(ShrineChargeService.shrineCanOffer(null, null, score)); + assertNull(ShrineChargeService.chargeFxElement(null, null, null, false)); + assertTrue(ShrineChargeService.allowedElements(null, null).isEmpty()); + artifact(0); + var vessel = AuraVessels.fromItem(item); + assertEquals(Set.of("fire"), ShrineChargeService.allowedElements(item, vessel)); + assertEquals("fire", ShrineChargeService.chargeFxElement(item, vessel, score, false)); + assertTrue(ShrineChargeService.hasChargeable(vessel, item, score)); + assertTrue(ShrineChargeService.shrineCanOffer(vessel, item, score)); + for (ShrineScore invalid : + List.of( + new ShrineScore(null), + new ShrineScore(Map.of("fire", new ShrineElementScore("fire", 0, 1, null, null))), + new ShrineScore(Map.of("fire", new ShrineElementScore("fire", 5, 0, null, null))))) { + assertFalse(ShrineChargeService.hasChargeable(vessel, item, invalid)); + assertFalse(ShrineChargeService.shrineCanOffer(vessel, item, invalid)); + assertEquals("fire", ShrineChargeService.chargeFxElement(item, vessel, invalid, false)); + } + assertFalse(ShrineChargeService.hasChargeable(vessel, item, null)); + assertFalse(ShrineChargeService.shrineCanOffer(vessel, item, null)); + assertEquals("fire", ShrineChargeService.chargeFxElement(item, vessel, null, false)); + } + + @Test + void slotFallbackReadsDisplayAndSessionAccessorsPreserveInputs() { + assertNull(ShrineChargeService.itemFromSlot(null)); + assertNull(ShrineChargeService.itemFromSlot(slot)); + var display = new org.mockbukkit.mockbukkit.entity.ItemDisplayMock(server, UUID.randomUUID()); + display.teleport(furniture.getLoc()); + server.registerEntity(display); + var shown = new ItemStack(Material.DIAMOND); + display.setItemStack(shown); + when(slot.getDisplayStandId()).thenReturn(display.getUniqueId()); + assertEquals(shown, ShrineChargeService.itemFromSlot(slot)); + verify(slot).setModel(shown); + var session = new ShrineChargeSession(furniture, "main", score, "fire", 8, true); + assertSame(furniture, session.getFurniture()); + assertEquals("main", session.getSlotId()); + assertSame(score, session.getScore()); + assertEquals("fire", session.getForcedElement()); + assertEquals(8, session.getTargetFill()); + assertTrue(session.isAdminForced()); + session.setLastPersistSeconds(9); + assertEquals(9, session.getLastPersistSeconds()); + } + + @Test + void blankChargesImprintFromSceneryAndAdminsCanImprintNamedSchool() { + net.tfminecraft.magic.charge.ChargeRegistry.clear(); + net.tfminecraft.magic.charge.ChargeRegistry.register( + new net.tfminecraft.magic.charge.ChargeDef(1, "v.STONE", 10)); + item = new ItemStack(Material.STONE); + var meta = item.getItemMeta(); + meta.getPersistentDataContainer() + .set( + net.tfminecraft.magic.charge.ChargeKeys.chargeTier(), + org.bukkit.persistence.PersistentDataType.INTEGER, + 1); + item.setItemMeta(meta); + var player = server.addPlayer(); + player.teleport(furniture.getLoc()); + var saved = score; + score = new ShrineScore(null); + try (var messages = mockStatic(Messages.class, i -> i.getArgument(0))) { + ShrineChargeService.tryStart(player, furniture, "main"); + assertEquals("charge.imprint.no_elements", player.nextMessage()); + score = saved; + ShrineChargeService.tryStart(player, furniture, "main"); + assertEquals("charge.imprint.done", player.nextMessage()); + assertTrue(ShrineChargeService.hasSessions()); + assertEquals(Set.of("fire"), AuraVessels.fromItem(item).getCappedElementIds()); + ShrineChargeService.clearAll(); + try (var targeting = mockStatic(SacrificeTargeting.class)) { + targeting + .when(() -> SacrificeTargeting.collectNearby(any(), anyInt(), anyInt())) + .thenReturn(List.of(furniture)); + targeting + .when(() -> SacrificeTargeting.originCenter(furniture)) + .thenAnswer(i -> furniture.getLoc()); + assertEquals( + ShrineChargeService.AdminStart.STARTED, + ShrineChargeService.startAdmin(player, "water", 5)); + assertTrue(AuraVessels.fromItem(item).getCap("water") > 0); + ShrineChargeService.tick(null); + ShrineChargeService.stopAll(furniture.getEntityId()); + } + } + } + + @Test + void adminSearchSkipsCarriedForeignFarAndWrongFurniture() { + var player = server.addPlayer(); + player.teleport(furniture.getLoc()); + artifact(0); + try (var targeting = mockStatic(SacrificeTargeting.class)) { + targeting + .when(() -> SacrificeTargeting.collectNearby(any(), anyInt(), anyInt())) + .thenReturn(List.of(furniture)); + targeting + .when(() -> SacrificeTargeting.originCenter(furniture)) + .thenAnswer(i -> furniture.getLoc()); + when(furniture.isCarried()).thenReturn(true); + assertEquals( + ShrineChargeService.AdminStart.NO_PEDESTAL, + ShrineChargeService.startAdmin(player, "fire", 5)); + when(furniture.isCarried()).thenReturn(false); + when(furniture.getId()).thenReturn("chair"); + assertEquals( + ShrineChargeService.AdminStart.NO_PEDESTAL, + ShrineChargeService.startAdmin(player, "fire", 5)); + when(furniture.getId()).thenReturn("pedestal"); + when(furniture.getLoc()).thenReturn(null); + assertEquals( + ShrineChargeService.AdminStart.NO_PEDESTAL, + ShrineChargeService.startAdmin(player, "fire", 5)); + when(furniture.getLoc()).thenReturn(new Location(server.addSimpleWorld("away"), 0, 64, 0)); + assertEquals( + ShrineChargeService.AdminStart.NO_PEDESTAL, + ShrineChargeService.startAdmin(player, "fire", 5)); + var at = player.getLocation(); + when(furniture.getLoc()).thenReturn(at); + targeting.when(() -> SacrificeTargeting.chebyshevBlocks(any(), any())).thenReturn(99); + assertEquals( + ShrineChargeService.AdminStart.NO_PEDESTAL, + ShrineChargeService.startAdmin(player, "fire", 5)); + targeting.when(() -> SacrificeTargeting.chebyshevBlocks(any(), any())).thenReturn(0); + item = new ItemStack(Material.STONE); + assertEquals( + ShrineChargeService.AdminStart.NO_ARTIFACT, + ShrineChargeService.startAdmin(player, "fire", 5)); + } + } + + @Test + void sacrificeHintsDistinguishWeakAndReadySceneryAndBlockNaturalCharging() { + artifact(0); + var player = server.addPlayer(); + SacrificeRegistry.setEnabled(true); + SacrificeRegistry.register( + new SacrificeElementDef("fire", true, List.of("word"), "", "", "", 10)); + SacrificeRegistry.setGlobals( + 10, 4, null, true, true, 10, 2, true, true, true, null, null, null); + try (var messages = mockStatic(Messages.class, i -> i.getArgument(0))) { + ShrineChargeService.tryStart(player, furniture, "main"); + assertEquals("sacrifice.place.weak", player.nextMessage()); + ShrineChargeService.clearAll(); + score = new ShrineScore(Map.of("fire", new ShrineElementScore("fire", 15, 1, null, null))); + ShrineChargeService.tryStart(player, furniture, "main"); + assertEquals("sacrifice.place.ready", player.nextMessage()); + ShrineChargeService.clearAll(); + SacrificeRegistry.setSceneryCharge(false); + ShrineChargeService.tryStart(player, furniture, "main"); + assertFalse(ShrineChargeService.hasSessions()); + assertFalse(ShrineChargeService.hasChargeable(AuraVessels.fromItem(item), item, score)); + assertFalse(ShrineChargeService.shrineCanOffer(AuraVessels.fromItem(item), item, score)); + assertEquals( + "fire", + ShrineChargeService.chargeFxElement(item, AuraVessels.fromItem(item), score, false)); + } + } + + @Test + void shrineGainScalesWithConfiguredTickInterval() { + try { + artifact(0); + Cache.tickIntervalTicks = 10; + ShrineChargeService.tryStart(null, furniture, "main"); + ShrineChargeService.tick(MagicTickContext.of(1)); + assertEquals(1, fill()); + ShrineChargeService.stopAll(furniture.getEntityId()); + artifact(0); + Cache.tickIntervalTicks = 40; + ShrineChargeService.tryStart(null, furniture, "main"); + ShrineChargeService.tick(MagicTickContext.of(2)); + assertEquals(4, fill()); + ShrineChargeService.stopAll(furniture.getEntityId()); + var player = server.addPlayer(); + player.teleport(furniture.getLoc()); + artifact(0); + Cache.tickIntervalTicks = 10; + try (var targeting = mockStatic(SacrificeTargeting.class)) { + targeting + .when(() -> SacrificeTargeting.collectNearby(any(), anyInt(), anyInt())) + .thenReturn(List.of(furniture)); + targeting + .when(() -> SacrificeTargeting.originCenter(furniture)) + .thenAnswer(i -> furniture.getLoc()); + assertEquals( + ShrineChargeService.AdminStart.STARTED, + ShrineChargeService.startAdmin(player, "fire", 5)); + ShrineChargeService.tick(MagicTickContext.of(1)); + assertEquals(1, fill()); + } + } finally { + Cache.tickIntervalTicks = 20; + } + } + + static Object call(String name, Class[] types, Object... args) throws Exception { + var method = ShrineChargeService.class.getDeclaredMethod(name, types); + method.setAccessible(true); + return method.invoke(null, args); + } + + /** + * Deliberately malformed external adapter: AuraVessel declares no normalized-ID postcondition. + */ + static final class ExternalVessel implements AuraVessel { + final Map caps = new LinkedHashMap<>(), fills = new HashMap<>(); + String primary; + boolean charge; + + public VesselKind kind() { + return charge ? VesselKind.CHARGE : VesselKind.ARTIFACT; + } + + public double getCap(String id) { + return caps.getOrDefault(id, 0.); + } + + public double getFill(String id) { + return fills.getOrDefault(id, 0.); + } + + public void setCap(String id, double v) { + caps.put(id, v); + } + + public void setFill(String id, double v) { + fills.put(id, v); + } + + public Set getCappedElementIds() { + return caps.keySet(); + } + + public boolean hasStoredAura() { + return !fills.isEmpty(); + } + + public double totalFill() { + return fills.values().stream().mapToDouble(x -> x).sum(); + } + + public String primaryElementId() { + return primary; + } + + public ItemStack write(ItemStack s) { + return s; + } + + public void persistPdc(ItemStack s) {} + } + + @Test + void malformedExternalVesselEntriesAreRejectedWithoutWritingUnsafeState() { + var adapter = new ExternalVessel(); + adapter.caps.put(null, 10.); + adapter.caps.put(" ", 10.); + adapter.caps.put("zero", 0.); + adapter.caps.put("fire", 10.); + adapter.caps.put("other", 10.); + adapter.charge = true; + assertEquals(Set.of("fire", "other"), ShrineChargeService.allowedElements(null, adapter)); + adapter.charge = false; + assertTrue(ShrineChargeService.allowedElements(null, adapter).isEmpty()); + adapter.primary = " "; + assertTrue(ShrineChargeService.allowedElements(null, adapter).isEmpty()); + adapter.primary = "fire"; + assertEquals(Set.of("fire"), ShrineChargeService.allowedElements(null, adapter)); + assertTrue(adapter.fills.isEmpty()); + } + + @Test + void sceneryRulesAndPrimaryTieBreaksSelectOnlyEligibleElements() throws Exception { + var adapter = new ExternalVessel(); + adapter.primary = "fire"; + adapter.charge = true; + adapter.caps.put("fire", 10.); + adapter.caps.put("water", 10.); + var equal = + new ShrineScore( + Map.of( + "fire", + new ShrineElementScore("fire", 10, 1, null, null), + "water", + new ShrineElementScore("water", 10, 1, null, null))); + assertEquals("fire", ShrineChargeService.chargeFxElement(null, adapter, equal, false)); + adapter.primary = "water"; + assertEquals("water", ShrineChargeService.chargeFxElement(null, adapter, equal, false)); + adapter.primary = null; + assertNotNull(ShrineChargeService.chargeFxElement(null, adapter, equal, false)); + adapter.caps.put("water", 20.); + assertEquals("water", ShrineChargeService.chargeFxElement(null, adapter, equal, false)); + adapter.fills.put("water", 10.); + assertEquals("fire", ShrineChargeService.chargeFxElement(null, adapter, equal, false)); + assertEquals("water", ShrineChargeService.chargeFxElement(null, adapter, equal, true)); + ShrineRegistry.register(new ShrineElementDef("fire", false, List.of())); + ShrineRegistry.register(new ShrineElementDef("water", false, List.of())); + assertTrue(ShrineChargeService.blocksVanillaCharge("fire")); + assertFalse(ShrineChargeService.hasChargeable(adapter, null, equal)); + assertFalse(ShrineChargeService.shrineCanOffer(adapter, null, equal)); + assertNull(ShrineChargeService.chargeFxElement(null, adapter, equal, false)); + ShrineRegistry.register(new ShrineElementDef("fire", true, List.of())); + assertFalse(ShrineChargeService.blocksVanillaCharge("fire")); + SacrificeRegistry.setEnabled(true); + SacrificeRegistry.setSceneryCharge(false); + SacrificeRegistry.register(new SacrificeElementDef("fire", List.of(), "", "", "", 1)); + assertTrue(ShrineChargeService.blocksVanillaCharge("fire")); + } + + @Test + void privateSessionBoundariesHandleInvalidAdminTargetsAndChangedScores() throws Exception { + artifact(0); + var types = new Class[] {ShrineChargeSession.class, MagicTickContext.class}; + for (String id : Arrays.asList(null, " ", "water")) { + var session = new ShrineChargeSession(furniture, "main", score, id, 10, true); + assertEquals(false, call("tickOne", types, session, null)); + } + var session = new ShrineChargeSession(furniture, "main", score, "fire", 0, true); + assertEquals(false, call("tickOne", types, session, null)); + for (ShrineScore changed : + List.of( + new ShrineScore(Map.of()), + new ShrineScore(Map.of("fire", new ShrineElementScore("fire", 0, 1, null, null))), + new ShrineScore(Map.of("fire", new ShrineElementScore("fire", 10, 0, null, null))))) + assertEquals( + false, call("tickOne", types, new ShrineChargeSession(furniture, "main", changed), null)); + ShrineRegistry.register(new ShrineElementDef("fire", false, List.of())); + assertEquals( + false, call("tickOne", types, new ShrineChargeSession(furniture, "main", score), null)); + item = new ItemStack(Material.AIR); + assertEquals( + false, call("tickOne", types, new ShrineChargeSession(furniture, "main", score), null)); + call("persist", new Class[] {ShrineChargeSession.class, boolean.class}, null, false); + call("persistFurniture", new Class[] {Furniture.class}, (Object) null); + bridge + .when(InteractibleFurniture::getInstance) + .thenThrow(new IllegalStateException("disabled")); + call("persistFurniture", new Class[] {Furniture.class}, furniture); + } + + @Test + void hintsDistinguishWeakReadyDisabledAndMissingScenery() throws Exception { + var player = server.addPlayer(); + var types = + new Class[] { + org.bukkit.entity.Player.class, Furniture.class, String.class, ShrineScore.class + }; + call("hintSacrificePlace", types, player, furniture, null, score); + call("hintSacrificePlace", types, player, furniture, " ", score); + SacrificeRegistry.setEnabled(false); + call("hintSacrificePlace", types, player, furniture, "fire", score); + SacrificeRegistry.setEnabled(true); + call("hintSacrificePlace", types, player, furniture, "fire", score); + SacrificeRegistry.register(new SacrificeElementDef("fire", false, List.of(), "", "", "", 50)); + call("hintSacrificePlace", types, player, furniture, "fire", score); + SacrificeRegistry.register(new SacrificeElementDef("fire", true, List.of(), "", "", "", 50)); + for (ShrineScore next : + Arrays.asList( + null, + new ShrineScore(Map.of()), + score, + new ShrineScore(Map.of("fire", new ShrineElementScore("fire", 30, 1, null, null))), + new ShrineScore(Map.of("fire", new ShrineElementScore("fire", 50, 1, null, null))))) + call("hintSacrificePlace", types, player, furniture, "fire", next); + fx.verify(() -> ShrineChargeFx.sacrificeReady(furniture, "fire")); + } + + @Test + void slotRecoveryRejectsAirAndNonDisplayEntities() { + item = new ItemStack(Material.AIR); + when(slot.getDisplayStandId()).thenReturn(UUID.randomUUID()); + assertEquals(Material.AIR, ShrineChargeService.itemFromSlot(slot).getType()); + var player = server.addPlayer(); + when(slot.getDisplayStandId()).thenReturn(player.getUniqueId()); + assertEquals(Material.AIR, ShrineChargeService.itemFromSlot(slot).getType()); + var display = new org.mockbukkit.mockbukkit.entity.ItemDisplayMock(server, UUID.randomUUID()); + server.registerEntity(display); + when(slot.getDisplayStandId()).thenReturn(display.getUniqueId()); + assertEquals(Material.AIR, ShrineChargeService.itemFromSlot(slot).getType()); + } + + @Test + void nonNormalizedExternalCapsFailSafelyAfterElementNormalization() { + var adapter = new ExternalVessel(); + adapter.charge = true; + adapter.caps.put(" FIRE ", 10.); + assertEquals(Set.of("fire"), ShrineChargeService.allowedElements(null, adapter)); + assertFalse(ShrineChargeService.hasChargeable(adapter, null, score)); + assertNull(ShrineChargeService.chargeFxElement(null, adapter, score, false)); + } + + @Test + void companionsComeFromTheValidatedAffinityRegistry() { + net.tfminecraft.magic.artifact.config.ArtifactAffinityRegistry.clear(); + net.tfminecraft.magic.artifact.config.ArtifactTypeRegistry.clear(); + try { + var config = new org.bukkit.configuration.file.YamlConfiguration(); + config.set("enabled", true); + config.set("model_scheme", "test"); + net.tfminecraft.magic.artifact.config.ArtifactTypeRegistry.register( + new net.tfminecraft.magic.artifact.config.ArtifactTypeDef( + "water", "test", "test", true, 1, Map.of(), Map.of())); + net.tfminecraft.magic.artifact.config.ArtifactAffinityRegistry.registerGroup( + "pair", List.of("fire", "water")); + var adapter = new ExternalVessel(); + adapter.primary = "fire"; + adapter.caps.put("fire", 10.); + adapter.caps.put("water", 10.); + assertEquals(Set.of("fire", "water"), ShrineChargeService.allowedElements(null, adapter)); + } finally { + net.tfminecraft.magic.artifact.config.ArtifactAffinityRegistry.clear(); + net.tfminecraft.magic.artifact.config.ArtifactTypeRegistry.clear(); + } + } + + @Test + void blankChargeImprintWorksWithoutAnInitiatingPlayer() { + net.tfminecraft.magic.charge.ChargeRegistry.clear(); + net.tfminecraft.magic.charge.ChargeRegistry.register( + new net.tfminecraft.magic.charge.ChargeDef(1, "v.STONE", 10)); + item = new ItemStack(Material.STONE); + var meta = item.getItemMeta(); + meta.getPersistentDataContainer() + .set( + net.tfminecraft.magic.charge.ChargeKeys.chargeTier(), + org.bukkit.persistence.PersistentDataType.INTEGER, + 1); + item.setItemMeta(meta); + var saved = score; + score = new ShrineScore(Map.of()); + ShrineChargeService.tryStart(null, furniture, "main"); + score = saved; + ShrineChargeService.tryStart(null, furniture, "main"); + ShrineChargeService.tryStart(null, furniture, "main"); + assertTrue(ShrineChargeService.hasSessions()); + score = new ShrineScore(Map.of()); + ShrineChargeService.tryStart(server.addPlayer(), furniture, "main"); + } + + @Test + void adminSelectionHandlesAirNullRowsTiesAndSlotsRemovedDuringSelection() { + var player = server.addPlayer(); + player.teleport(furniture.getLoc()); + try (var targeting = mockStatic(SacrificeTargeting.class)) { + targeting + .when(() -> SacrificeTargeting.collectNearby(any(), anyInt(), anyInt())) + .thenReturn(Arrays.asList(null, furniture)); + targeting + .when(() -> SacrificeTargeting.originCenter(furniture)) + .thenAnswer(a -> furniture.getLoc()); + item = new ItemStack(Material.AIR); + assertEquals( + ShrineChargeService.AdminStart.NO_ARTIFACT, + ShrineChargeService.startAdmin(player, "fire", 5)); + artifact(0); + var second = mock(PlacedSlot.class); + when(second.getCurrentItem()).thenAnswer(a -> item); + when(second.getId()).thenReturn("second"); + when(furniture.getActiveSlots()) + .thenReturn(new LinkedHashMap<>(Map.of("main", slot, "second", second))); + ShrineChargeService.startAdmin(player, "fire", 5); + ShrineChargeService.clearAll(); + when(furniture.getActiveSlots()).thenReturn(Map.of("main", slot)); + when(slot.getId()).thenReturn(null); + assertEquals( + ShrineChargeService.AdminStart.NO_ARTIFACT, + ShrineChargeService.startAdmin(player, "fire", 5)); + var removed = new java.util.concurrent.atomic.AtomicBoolean(); + when(slot.getId()) + .thenAnswer( + a -> { + removed.set(true); + return "main"; + }); + when(furniture.getActiveSlot("main")) + .thenAnswer(a -> removed.get() ? Optional.empty() : Optional.of(slot)); + assertEquals( + ShrineChargeService.AdminStart.NO_ARTIFACT, + ShrineChargeService.startAdmin(player, "fire", 5)); + } + } + + @Test + void sessionSnapshotsTolerateReentrantRemovalDuringPersistence() { + artifact(0); + var second = mock(PlacedSlot.class); + when(second.getId()).thenReturn("second"); + when(second.getCurrentItem()).thenAnswer(a -> item); + when(furniture.getActiveSlot("second")).thenReturn(Optional.of(second)); + var once = new java.util.concurrent.atomic.AtomicBoolean(); + var furnitureManager = dependency.getFurnitureManager(); + doAnswer( + a -> { + if (once.compareAndSet(false, true)) ShrineChargeService.clearAll(); + return null; + }) + .when(furnitureManager) + .persistFurniture(furniture); + ShrineChargeService.tryStart(null, furniture, "main"); + ShrineChargeService.tryStart(null, furniture, "second"); + ShrineChargeService.stopAll(furniture.getEntityId()); + assertFalse(ShrineChargeService.hasSessions()); + once.set(false); + ShrineChargeService.tryStart(null, furniture, "main"); + ShrineChargeService.tryStart(null, furniture, "second"); + when(furniture.isCarried()).thenReturn(true); + ShrineChargeService.tick(null); + assertFalse(ShrineChargeService.hasSessions()); + } + + @Test + void adminChargingExistingSchoolsAndHiddenSchoolsRespectImprintRules() { + net.tfminecraft.magic.charge.ChargeRegistry.clear(); + net.tfminecraft.magic.charge.ChargeRegistry.register( + new net.tfminecraft.magic.charge.ChargeDef(1, "v.STONE", 10)); + item = new ItemStack(Material.STONE); + var meta = item.getItemMeta(); + meta.getPersistentDataContainer() + .set( + net.tfminecraft.magic.charge.ChargeKeys.chargeTier(), + org.bukkit.persistence.PersistentDataType.INTEGER, + 1); + item.setItemMeta(meta); + var charge = net.tfminecraft.magic.charge.Charge.fromItem(item); + charge.setCap("fire", 10); + charge.persistPdc(item); + var player = server.addPlayer(); + player.teleport(furniture.getLoc()); + var yaml = new org.bukkit.configuration.file.YamlConfiguration(); + yaml.set("permission", "magic.hidden"); + ShrineRegistry.register(new ShrineElementDef("hidden", false, List.of())); + SacrificeRegistry.setEnabled(false); + net.tfminecraft.magic.registry.ElementRegistry.register( + new net.tfminecraft.magic.model.ElementDef("hidden", yaml)); + try (var targeting = mockStatic(SacrificeTargeting.class)) { + targeting + .when(() -> SacrificeTargeting.collectNearby(any(), anyInt(), anyInt())) + .thenReturn(List.of(furniture)); + targeting + .when(() -> SacrificeTargeting.originCenter(furniture)) + .thenAnswer(a -> furniture.getLoc()); + assertEquals( + ShrineChargeService.AdminStart.STARTED, + ShrineChargeService.startAdmin(player, "fire", 5)); + ShrineChargeService.clearAll(); + assertEquals( + ShrineChargeService.AdminStart.NO_CAP, + ShrineChargeService.startAdmin(player, "hidden", 5)); + } finally { + net.tfminecraft.magic.registry.ElementRegistry.clear(); + } + } + + @Test + void lowerCapCandidateCannotDisplaceAStrongerElement() { + var adapter = new ExternalVessel(); + adapter.charge = true; + var scores = + new ShrineScore( + Map.of( + "fire", + new ShrineElementScore("fire", 20, 1, null, null), + "water", + new ShrineElementScore("water", 20, 1, null, null))); + adapter.caps.put("fire", 20.); + adapter.caps.put("water", 10.); + assertEquals("fire", ShrineChargeService.chargeFxElement(null, adapter, scores, false)); + adapter.caps.put("water", 30.); + assertEquals("water", ShrineChargeService.chargeFxElement(null, adapter, scores, false)); + } + + @Test + void naturalProgressUsesCurrentElapsedTimeWhenNoContextIsProvided() { + artifact(0); + ShrineChargeService.tryStart(null, furniture, "main"); + ShrineChargeService.tick(null); + assertEquals(2, fill()); + ShrineChargeService.clearAll(); + score = new ShrineScore(Map.of()); + ShrineChargeService.tryStart(null, furniture, "main"); + assertFalse(ShrineChargeService.hasSessions()); + } +}