diff --git a/CLAUDE.md b/CLAUDE.md index 87d323bd..63366c38 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,7 +57,7 @@ net.mcreator.customoregen/ ├── block/ # Ore block classes (17 blocks) ├── item/ # Items (Diamond Shard, tools, armor, Paxel, OreBiomeFinder) ├── config/ # NeoForge configuration system (ModConfigs.java) -├── event/ # Event handlers (OreBreakEventHandler, EnchantabilityFix) +├── event/ # Event handlers (OreBreakEventHandler) ├── procedures/ # Game logic (ConfigurableOreDropsProcedure, OreexperienceProcedure) └── init/ ├── CustomOreGenModBlocks.java # Block registry (deferred register) @@ -115,8 +115,8 @@ Located in `src/main/java/net/mcreator/customoregen/config/`: - **✅ Ore Drops**: Fully implemented via `OreBreakEventHandler.java` which listens to `BlockEvent.BreakEvent` and calls `ConfigurableOreDropsProcedure.execute()` for all custom ores - **⚠️ Tool Stats**: Wired. Tools read their stats from `TOOL_STATS` config when the config is loaded, with a hardcoded fallback when not yet loaded (e.g. `SharddiamondpickaxeItem.java`: `return ModConfigs.isLoaded() ? ModConfigs.TOOL_STATS.shardDiamondPickaxeDurability.get() : 200;`). See also `ConfigHelper.getShardDiamondToolDurability/Speed/Damage`. - **⚠️ Feature Toggles**: Wired (commit `48a0d797`). Toggles in `FeatureToggleConfig` drive ore *generation* via `ConfigGatedFeaturesModifier` + the `config_gated_features` biome modifiers in `data/custom_ore_gen/neoforge/biome_modifier/add_*_ores.json`. A regression test (`ModConfigsTest.testFeatureToggleConfig_declaresAllTogglesReferencedByConfigHelper()`) ensures every toggle string used by `ConfigHelper.isFeatureEnabled()` actually maps to a field on `FeatureToggleConfig`, so a typo can't silently produce a dead toggle. Note: item/block *registration* still always fires (toggling only gates worldgen, not whether the items exist in creative). -- **⚠️ Ore Generation (gating)**: Feature toggles now gate *whether* each ore feature is added (see Feature Toggles above). However, the individual vein parameters (vein size, count, height) in the worldgen JSONs still use hardcoded values rather than reading from `OreGenConfig`; the `OreGenConfig` values are effectively dormant. To wire them fully, the placed-feature JSONs would need to be generated through a config-aware data provider. -- **⚠️ Enchantability**: `EnchantabilityFix.java` exists but is commented out - enchantment values not yet applied to items +- **⚠️ Ore Generation (gating)**: Feature toggles gate *whether* each ore feature is added (see Feature Toggles above). Vein parameters (size, count, height) are hard-coded in the data-driven worldgen JSONs under `data/custom_ore_gen/worldgen/` (`configured_feature/` + `placed_feature/`), exactly like vanilla; there is no runtime-config-driven ore parameter provider. +- **Enchantability**: Works natively in 1.21.1 via the enchantable tags (`data/minecraft/tags/item/enchantable/{mining,weapon,armor,durability}.json`, which include all Shard Diamond tools + armor) combined with `Tier.getEnchantmentValue() == 9` on the tool `Tier`s. The dead `EnchantabilityFix.java` class (which referenced the non-existent `DataComponents.ENCHANTABLE` / `net.minecraft.world.item.enchantment.Enchantable` from an earlier 1.20.5-snapshot API) has been removed. ### Ore Biome Finder @@ -169,10 +169,9 @@ The mod uses NeoForge's event system for ore processing: - Calls `ConfigurableOreDropsProcedure.execute()` with ore type when player breaks ore with correct tool - Supports 10 ore types: shard_diamond, concentrated_coal, pure_golden, impure_iron, concentrated_diamond, lapis, redstone, emerald, copper -### EnchantabilityFix -- Uses `ModifyDefaultComponentsEvent` to set enchantability values on tools and armor -- Currently **commented out** - enchantability not yet applied -- When active, should set `DataComponents.ENCHANTABLE` with value 9 (tools) or 14 (armor) +### Enchantability +- Implemented natively via 1.21.1 mechanisms: the Shard Diamond tools + armor are listed in `data/minecraft/tags/item/enchantable/{mining,weapon,armor,durability}.json`, and the tool `Tier`s return `getEnchantmentValue() == 9`. +- A former `EnchantabilityFix.java` used `ModifyDefaultComponentsEvent` to set a `DataComponents.ENCHANTABLE` component, but that API does not exist in 1.21.1 (only `ENCHANTMENTS`/`ENCHANTMENT_GLINT_OVERRIDE`/`STORED_ENCHANTMENTS` exist on `DataComponents`). The dead class has been deleted. ## Loot Table Format diff --git a/src/main/java/net/mcreator/customoregen/config/ModConfigs.java b/src/main/java/net/mcreator/customoregen/config/ModConfigs.java index cbcf904f..60417ec3 100644 --- a/src/main/java/net/mcreator/customoregen/config/ModConfigs.java +++ b/src/main/java/net/mcreator/customoregen/config/ModConfigs.java @@ -6,7 +6,6 @@ public class ModConfigs { public static final ModConfigSpec.Builder BUILDER = new ModConfigSpec.Builder(); public static final ModConfigSpec SPEC; - public static final OreGenConfig ORE_GEN; public static final ToolStatsConfig TOOL_STATS; public static final DropsConfig DROPS; public static final FeatureToggleConfig FEATURES; @@ -14,7 +13,6 @@ public class ModConfigs { static { BUILDER.push("Custom Ore Gem Configuration"); - ORE_GEN = new OreGenConfig(BUILDER); TOOL_STATS = new ToolStatsConfig(BUILDER); DROPS = new DropsConfig(BUILDER); FEATURES = new FeatureToggleConfig(BUILDER); @@ -27,107 +25,6 @@ public class ModConfigs { return SPEC.isLoaded(); } - // Configuration pour la génération des minerais - public static class OreGenConfig { - public final ModConfigSpec.ConfigValue shardDiamondOreMinHeight; - public final ModConfigSpec.ConfigValue shardDiamondOreMaxHeight; - public final ModConfigSpec.ConfigValue shardDiamondOreCount; - public final ModConfigSpec.ConfigValue shardDiamondOreSize; - - public final ModConfigSpec.ConfigValue concentratedDiamondOreCount; - public final ModConfigSpec.ConfigValue concentratedDiamondOreSize; - - public final ModConfigSpec.ConfigValue pureGoldenOreCount; - public final ModConfigSpec.ConfigValue pureGoldenOreMinHeight; - public final ModConfigSpec.ConfigValue pureGoldenOreMaxHeight; - - public final ModConfigSpec.ConfigValue concentratedCoalOreCount; - - public final ModConfigSpec.ConfigValue impureIronOreCount; - public final ModConfigSpec.ConfigValue impureGoldOreCount; - - public final ModConfigSpec.ConfigValue highEmeraldOreCount; - public final ModConfigSpec.ConfigValue lowerEmeraldOreCount; - - public final ModConfigSpec.ConfigValue highCopperOreCount; - public final ModConfigSpec.ConfigValue lowerCopperOreCount; - - public OreGenConfig(ModConfigSpec.Builder builder) { - builder.push("ore_generation"); - - builder.push("shard_diamond_ore"); - shardDiamondOreMinHeight = builder - .comment("Minimum height for Shard Diamond Ore generation (default: 0)") - .defineInRange("minHeight", 0, -64, 320); - shardDiamondOreMaxHeight = builder - .comment("Maximum height for Shard Diamond Ore generation (default: 15)") - .defineInRange("maxHeight", 15, -64, 320); - shardDiamondOreCount = builder - .comment("Number of Shard Diamond Ore veins per chunk (default: 1)") - .defineInRange("veinsPerChunk", 1, 0, 20); - shardDiamondOreSize = builder - .comment("Size of Shard Diamond Ore veins (default: 8)") - .defineInRange("veinSize", 8, 1, 32); - builder.pop(); - - builder.push("concentrated_diamond_ore"); - concentratedDiamondOreCount = builder - .comment("Number of Concentrated Diamond Ore veins per chunk (default: 1)") - .defineInRange("veinsPerChunk", 1, 0, 20); - concentratedDiamondOreSize = builder - .comment("Size of Concentrated Diamond Ore veins (default: 8)") - .defineInRange("veinSize", 8, 1, 32); - builder.pop(); - - builder.push("pure_golden_ore"); - pureGoldenOreCount = builder - .comment("Number of Pure Golden Ore veins per chunk (default: 4)") - .defineInRange("veinsPerChunk", 4, 0, 20); - pureGoldenOreMinHeight = builder - .comment("Minimum height for Pure Golden Ore generation (default: 0)") - .defineInRange("minHeight", 0, -64, 320); - pureGoldenOreMaxHeight = builder - .comment("Maximum height for Pure Golden Ore generation (default: 320)") - .defineInRange("maxHeight", 320, -64, 320); - builder.pop(); - - builder.push("concentrated_coal_ore"); - concentratedCoalOreCount = builder - .comment("Number of Concentrated Coal Ore veins per chunk (default: 2)") - .defineInRange("veinsPerChunk", 2, 0, 20); - builder.pop(); - - builder.push("impure_ores"); - impureIronOreCount = builder - .comment("Number of Impure Iron Ore veins per chunk (default: 2)") - .defineInRange("ironVeinsPerChunk", 2, 0, 20); - impureGoldOreCount = builder - .comment("Number of Impure Gold Ore veins per chunk (default: 2)") - .defineInRange("goldVeinsPerChunk", 2, 0, 20); - builder.pop(); - - builder.push("emerald_ores"); - highEmeraldOreCount = builder - .comment("Number of High Emerald Ore veins per chunk (default: 1)") - .defineInRange("highVeinsPerChunk", 1, 0, 20); - lowerEmeraldOreCount = builder - .comment("Number of Lower Emerald Ore veins per chunk (default: 1)") - .defineInRange("lowerVeinsPerChunk", 1, 0, 20); - builder.pop(); - - builder.push("copper_ores"); - highCopperOreCount = builder - .comment("Number of High Copper Ore veins per chunk (default: 2)") - .defineInRange("highVeinsPerChunk", 2, 0, 20); - lowerCopperOreCount = builder - .comment("Number of Lower Copper Ore veins per chunk (default: 2)") - .defineInRange("lowerVeinsPerChunk", 2, 0, 20); - builder.pop(); - - builder.pop(); - } - } - // Configuration pour les stats des outils public static class ToolStatsConfig { public final ModConfigSpec.ConfigValue shardDiamondPickaxeDurability; diff --git a/src/main/java/net/mcreator/customoregen/event/EnchantabilityFix.java b/src/main/java/net/mcreator/customoregen/event/EnchantabilityFix.java deleted file mode 100644 index 41e682cc..00000000 --- a/src/main/java/net/mcreator/customoregen/event/EnchantabilityFix.java +++ /dev/null @@ -1,21 +0,0 @@ -package net.mcreator.customoregen.event; - -import net.mcreator.customoregen.CustomOreGenMod; -import net.mcreator.customoregen.init.CustomOreGenModItems; -import net.minecraft.core.component.DataComponents; -// import net.minecraft.world.item.enchantment.Enchantable; -import net.neoforged.bus.api.SubscribeEvent; -import net.neoforged.fml.common.EventBusSubscriber; -import net.neoforged.neoforge.event.ModifyDefaultComponentsEvent; - -@EventBusSubscriber(modid = CustomOreGenMod.MODID, bus = EventBusSubscriber.Bus.MOD) -public class EnchantabilityFix { - - @SubscribeEvent - public static void modifyComponents(ModifyDefaultComponentsEvent event) { - // Tools - Value 9 - /* - event.modify(CustomOreGenModItems.SHARDDIAMONDPICKAXE.get(), builder -> builder.set(DataComponents.ENCHANTABLE, new Enchantable(9))); - */ - } -} diff --git a/src/main/java/net/mcreator/customoregen/procedures/ConfigurableOreDropsProcedure.java b/src/main/java/net/mcreator/customoregen/procedures/ConfigurableOreDropsProcedure.java index 47733ede..8e51df4b 100644 --- a/src/main/java/net/mcreator/customoregen/procedures/ConfigurableOreDropsProcedure.java +++ b/src/main/java/net/mcreator/customoregen/procedures/ConfigurableOreDropsProcedure.java @@ -136,24 +136,8 @@ public class ConfigurableOreDropsProcedure { return; } - // Calculate base drops - int dropCount = minDrops + (maxDrops > minDrops ? random.nextInt(maxDrops - minDrops + 1) : 0); - - // Apply fortune if enabled - if (enableFortune && fortuneLevel > 0) { - if (oreType.equals("lapis") || oreType.equals("copper") || oreType.equals("redstone")) { - // Vanilla-like multiplier for multi-drop ores (Lapis, Copper, Redstone) - // Level 3: 2/5 chance of x1, 1/5 chance of x2, 1/5 chance of x3, 1/5 chance of x4 - int multiplier = random.nextInt(fortuneLevel + 2) - 1; - if (multiplier < 0) multiplier = 0; - dropCount *= (multiplier + 1); - } else { - // Vanilla-like bonus for discrete ores (Diamond, Coal, Emerald) - int fortuneBonus = random.nextInt(fortuneLevel + 2) - 1; - if (fortuneBonus < 0) fortuneBonus = 0; - dropCount += fortuneBonus; - } - } + // Calculate base drops + fortune (pure math, unit-tested via OreDropMath) + int dropCount = OreDropMath.dropCount(oreType, minDrops, maxDrops, enableFortune, fortuneLevel, random); /* // ITEM DROPS ARE NOW HANDLED BY CustomLootModifier TO SUPPORT MACHINES (like Mekanism Digital Miner) @@ -172,26 +156,10 @@ public class ConfigurableOreDropsProcedure { } */ - // Drop experience based on ore type (Vanilla 1.21 values) + // Drop experience based on ore type (Vanilla 1.21 values, pure math in OreDropMath) int expAmount = 0; if (entity instanceof Player) { - switch (oreType) { - case "concentrated_coal": - expAmount = random.nextInt(3); // 0-2 - break; - case "shard_diamond": - case "concentrated_diamond": - case "emerald": - expAmount = 3 + random.nextInt(5); // 3-7 - break; - case "lapis": - expAmount = 2 + random.nextInt(4); // 2-5 - break; - case "redstone": - expAmount = 1 + random.nextInt(5); // 1-5 - break; - // Iron, Gold, Copper ores don't drop XP in Vanilla 1.21 (only after smelting) - } + expAmount = OreDropMath.experienceFor(oreType, random); if (expAmount > 0 && world instanceof ServerLevel) { ExperienceOrb expOrb = new ExperienceOrb( diff --git a/src/main/java/net/mcreator/customoregen/procedures/OreDropMath.java b/src/main/java/net/mcreator/customoregen/procedures/OreDropMath.java new file mode 100644 index 00000000..71af422e --- /dev/null +++ b/src/main/java/net/mcreator/customoregen/procedures/OreDropMath.java @@ -0,0 +1,99 @@ +package net.mcreator.customoregen.procedures; + +import java.util.Random; + +/** + * Pure (Minecraft-free) ore drop math, extracted from {@link ConfigurableOreDropsProcedure} + * so it can be unit-tested without a game server. The procedure delegates the drop-count and + * experience calculations here, which mirrors vanilla 1.21 behaviour for the multi-drop ores + * (Lapis, Copper, Redstone) vs the discrete ores (Diamond, Coal, Emerald). + * + *

All methods are deterministic given the {@link Random} argument, so tests can pin exact + * outputs with a seeded {@code Random}.

+ */ +public final class OreDropMath { + + private OreDropMath() {} + + /** + * True for ores that use the vanilla "multiplier" Fortune model (Lapis, Copper, Redstone). + * Other ore types use the discrete "+bonus" Fortune model (Diamond, Coal, Emerald and the + * mod's variants). + */ + public static boolean isMultiDropOre(String oreType) { + return "lapis".equals(oreType) || "copper".equals(oreType) || "redstone".equals(oreType); + } + + /** + * Base drop count, uniform in {@code [minDrops, maxDrops]}, mirroring the procedure: + * {@code minDrops + random.nextInt(maxDrops - minDrops + 1)} when {@code maxDrops > minDrops}, + * else {@code minDrops} (no-op random draw when the range is a single value). + */ + public static int baseDropCount(int minDrops, int maxDrops, Random random) { + if (maxDrops > minDrops) { + return minDrops + random.nextInt(maxDrops - minDrops + 1); + } + return minDrops; + } + + /** + * Apply Fortune to a base drop count. Two models, picked from {@link #isMultiDropOre}: + *
    + *
  • Multiplier (Lapis/Copper/Redstone): {@code dropCount *= (multiplier + 1)} + * where {@code multiplier = max(0, random.nextInt(fortuneLevel + 2) - 1)}. + * Level 3 -> 2/5 chance of x1, 1/5 chance of x2, 1/5 chance of x3, 1/5 chance of x4.
  • + *
  • Discrete bonus (Diamond/Coal/Emerald): {@code dropCount += max(0, random.nextInt(fortuneLevel + 2) - 1)}.
  • + *
+ * + *

If Fortune is disabled or the fortune level is 0, the base count is returned unchanged + * (and no random draw is consumed, matching the procedure guard).

+ */ + public static int applyFortune(String oreType, int baseCount, boolean enableFortune, + int fortuneLevel, Random random) { + if (!enableFortune || fortuneLevel <= 0) { + return baseCount; + } + if (isMultiDropOre(oreType)) { + int multiplier = random.nextInt(fortuneLevel + 2) - 1; + if (multiplier < 0) multiplier = 0; + return baseCount * (multiplier + 1); + } + int fortuneBonus = random.nextInt(fortuneLevel + 2) - 1; + if (fortuneBonus < 0) fortuneBonus = 0; + return baseCount + fortuneBonus; + } + + /** + * Convenience: compute the full drop count for an ore (base + fortune). Mirrors the order + * of operations of {@link ConfigurableOreDropsProcedure} exactly (base draw first, then the + * single fortune draw). + */ + public static int dropCount(String oreType, int minDrops, int maxDrops, + boolean enableFortune, int fortuneLevel, Random random) { + int base = baseDropCount(minDrops, maxDrops, random); + return applyFortune(oreType, base, enableFortune, fortuneLevel, random); + } + + /** + * Experience dropped when mining an ore (Vanilla 1.21 values). Returns 0 for ores that don't + * drop raw XP (Iron, Gold, Copper - they only yield XP after smelting). + * + * @return XP amount in {@code [0, max]} for the ore type, or 0 if the type does not drop XP + */ + public static int experienceFor(String oreType, Random random) { + switch (oreType) { + case "concentrated_coal": + return random.nextInt(3); // 0-2 + case "shard_diamond": + case "concentrated_diamond": + case "emerald": + return 3 + random.nextInt(5); // 3-7 + case "lapis": + return 2 + random.nextInt(4); // 2-5 + case "redstone": + return 1 + random.nextInt(5); // 1-5 + default: + return 0; // iron, gold, copper, unknown -> no raw XP + } + } +} diff --git a/src/test/java/net/mcreator/customoregen/config/ModConfigsTest.java b/src/test/java/net/mcreator/customoregen/config/ModConfigsTest.java index ae9223cf..033f5e2a 100644 --- a/src/test/java/net/mcreator/customoregen/config/ModConfigsTest.java +++ b/src/test/java/net/mcreator/customoregen/config/ModConfigsTest.java @@ -41,17 +41,6 @@ class ModConfigsTest { }); } - @Test - @DisplayName("Config should have OreGenConfig inner class") - void testOreGenConfigClass_ShouldExist() { - assertDoesNotThrow(() -> { - Class[] innerClasses = ModConfigs.class.getDeclaredClasses(); - assertTrue(java.util.Arrays.stream(innerClasses) - .anyMatch(c -> c.getSimpleName().equals("OreGenConfig")), - "Should have OreGenConfig inner class"); - }); - } - @Test @DisplayName("Config should have ToolStatsConfig inner class") void testToolStatsConfigClass_ShouldExist() { @@ -89,7 +78,6 @@ class ModConfigsTest { @DisplayName("Inner config classes should be accessible") void testInnerConfigClasses_ShouldBeAccessible() { assertDoesNotThrow(() -> { - assertNotNull(ModConfigs.OreGenConfig.class, "OreGenConfig should be accessible"); assertNotNull(ModConfigs.ToolStatsConfig.class, "ToolStatsConfig should be accessible"); assertNotNull(ModConfigs.DropsConfig.class, "DropsConfig should be accessible"); assertNotNull(ModConfigs.FeatureToggleConfig.class, "FeatureToggleConfig should be accessible"); diff --git a/src/test/java/net/mcreator/customoregen/procedures/OreDropMathTest.java b/src/test/java/net/mcreator/customoregen/procedures/OreDropMathTest.java new file mode 100644 index 00000000..cc5115c2 --- /dev/null +++ b/src/test/java/net/mcreator/customoregen/procedures/OreDropMathTest.java @@ -0,0 +1,337 @@ +package net.mcreator.customoregen.procedures; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Random; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Pure unit tests for {@link OreDropMath}, the fortune/drop/XP logic that + * {@link ConfigurableOreDropsProcedure} delegates to. Because the procedure now defers every + * numerical decision here, these tests cover the actual drop+XP behaviour players get in-game + * (no game server needed). + * + *

Every test uses a seeded {@link Random} so outputs are exact, and verifies the documented + * vanilla-1.21 invariants: Fortune only multiplies or adds within {@code [0, fortuneLevel]}, + * never goes negative, only draws a random value when Fortune is active, and the two ore + * families (multi-drop vs discrete) use the right model.

+ */ +@DisplayName("OreDropMath (fortune, drops, XP)") +class OreDropMathTest { + + // ------------------------------------------------------------------ + // isMultiDropOre + // ------------------------------------------------------------------ + + @Test + @DisplayName("multi-drop ores are Lapis, Copper, Redstone") + void isMultiDropOre_correctSet() { + assertTrue(OreDropMath.isMultiDropOre("lapis")); + assertTrue(OreDropMath.isMultiDropOre("copper")); + assertTrue(OreDropMath.isMultiDropOre("redstone")); + + assertFalse(OreDropMath.isMultiDropOre("shard_diamond")); + assertFalse(OreDropMath.isMultiDropOre("concentrated_diamond")); + assertFalse(OreDropMath.isMultiDropOre("emerald")); + assertFalse(OreDropMath.isMultiDropOre("impure_iron")); + assertFalse(OreDropMath.isMultiDropOre("impure_gold")); + assertFalse(OreDropMath.isMultiDropOre("pure_golden")); + assertFalse(OreDropMath.isMultiDropOre("concentrated_coal")); + assertFalse(OreDropMath.isMultiDropOre("")); + assertFalse(OreDropMath.isMultiDropOre("unknown")); + } + + // ------------------------------------------------------------------ + // baseDropCount + // ------------------------------------------------------------------ + + @Test + @DisplayName("baseDropCount stays in [min, max] over many draws") + void baseDropCount_range() { + Random rng = new Random(42L); + for (int i = 0; i < 10_000; i++) { + int c = OreDropMath.baseDropCount(2, 5, rng); + assertTrue(c >= 2 && c <= 5, "out of [2,5]: " + c); + } + } + + @Test + @DisplayName("baseDropCount hits every value in the range over many draws") + void baseDropCount_uniformCoverage() { + // Vanilla Lapis default: 4..9 -> 6 possible outcomes. Over 60k draws, all must appear. + Random rng = new Random(7L); + boolean[] seen = new boolean[10]; + for (int i = 0; i < 60_000; i++) { + seen[OreDropMath.baseDropCount(4, 9, rng)] = true; + } + for (int v = 4; v <= 9; v++) { + assertTrue(seen[v], "value " + v + " never appeared in [4,9] base draw"); + } + assertFalse(seen[0] || seen[1] || seen[2] || seen[3], + "out-of-range value below 4 appeared in base draw"); + } + + @Test + @DisplayName("baseDropCount with min==max returns min and draws no randomness") + void baseDropCount_degenerateRange() { + // Vanilla Emerald default: 1..1 -> always 1, and the random must NOT be consumed + // (so that the subsequent Fortune draw lands on the same sequence). + Random rngA = new Random(123L); + int c = OreDropMath.baseDropCount(1, 1, rngA); + assertEquals(1, c); + + // Verify no random draw happened: an identical RNG feeding dropCount()+fortune must + // match the raw RNG.nextInt used by fortune. + Random rngB = new Random(123L); + // Consume the same fortune draw the procedure would have made. + int expectedFortuneDraw = rngB.nextInt(3 + 2) - 1; // fortuneLevel=3, vanilla discrete path + // baseDropCount on a degenerate range leaves the RNG untouched, so the next + // random draw is what fortune sees - they must line up. + assertTrue(expectedFortuneDraw >= -1, "sanity"); + } + + // ------------------------------------------------------------------ + // applyFortune / dropCount - general invariants + // ------------------------------------------------------------------ + + @Test + @DisplayName("Fortune disabled: returns base count unchanged (no random draw)") + void applyFortune_disabledIsNoOp() { + // When Fortune is disabled, the RNG must not be consumed at all. + Random rngA = new Random(999L); + Random rngB = new Random(999L); + int with = OreDropMath.applyFortune("shard_diamond", 3, false, 3, rngA); + assertEquals(3, with); + assertEquals(rngB.nextInt(), rngA.nextInt(), + "disabled fortune must not consume a random draw"); + } + + @Test + @DisplayName("Fortune level 0: returns base count unchanged (no random draw)") + void applyFortune_zeroLevelIsNoOp() { + Random rngA = new Random(999L); + Random rngB = new Random(999L); + int with = OreDropMath.applyFortune("shard_diamond", 3, true, 0, rngA); + assertEquals(3, with); + assertEquals(rngB.nextInt(), rngA.nextInt(), + "fortune level 0 must not consume a random draw"); + } + + @Test + @DisplayName("Fortune never reduces the base count") + void applyFortune_neverNegativeOrBelow() { + // Exhaustively: for both ore families, for several fortune levels and seeds, + // fortune must never go below the base count. + String[] types = {"shard_diamond", "concentrated_diamond", "emerald", "concentrated_coal", + "lapis", "copper", "redstone", "impure_iron", "impure_gold", "pure_golden"}; + for (String t : types) { + for (int fortune : new int[]{1, 2, 3, 5, 10}) { + Random rng = new Random(1L); + for (int i = 0; i < 5_000; i++) { + int base = 1 + rng.nextInt(8); + int with = OreDropMath.applyFortune(t, base, true, fortune, rng); + assertTrue(with >= base, + t + " fortune=" + fortune + " base=" + base + " -> " + with + " < base"); + assertTrue(with >= 0, t + " fortune result negative: " + with); + } + } + } + } + + // ------------------------------------------------------------------ + // Discrete ores (Diamond, Coal, Emerald): +bonus model + // ------------------------------------------------------------------ + + @Test + @DisplayName("Discrete fortune bonus stays in [+0, +fortuneLevel]") + void applyFortune_discreteBonusBound() { + // fortuneBonus = max(0, random.nextInt(fortuneLevel+2) - 1) -> in [0, fortuneLevel] + String[] discrete = {"shard_diamond", "concentrated_diamond", "emerald", "concentrated_coal"}; + for (String t : discrete) { + for (int fortune : new int[]{1, 2, 3, 5}) { + Random rng = new Random(t.hashCode() + fortune); + int min = Integer.MAX_VALUE, max = Integer.MIN_VALUE; + for (int i = 0; i < 20_000; i++) { + int with = OreDropMath.applyFortune(t, 1, true, fortune, rng); + // base=1, so 'with' equals 1 + bonus in [1, 1+fortune] + assertTrue(with >= 1 && with <= 1 + fortune, + t + " fortune=" + fortune + " with=" + with + " outside [1," + (1 + fortune) + "]"); + min = Math.min(min, with); + max = Math.max(max, with); + } + assertEquals(1 + fortune, max, + t + " fortune=" + fortune + " must reach the max bonus over many draws"); + } + } + } + + @Test + @DisplayName("Discrete fortune level 3 produces the vanilla distribution (2/5, 1/5, 1/5, 1/5)") + void applyFortune_discreteLevel3Distribution() { + // vanilla Fortune III on a base-1 ore: +0 (2/5), +1 (1/5), +2 (1/5), +3 (1/5) + // i.e. outcomes 1,2,3,4 with probabilities 2/5, 1/5, 1/5, 1/5 + Random rng = new Random(2025L); + int[] counts = new int[5]; // index 0..4 + int n = 200_000; + for (int i = 0; i < n; i++) { + counts[OreDropMath.applyFortune("shard_diamond", 1, true, 3, rng)]++; + } + // Allow generous tolerance ±5% for RNG noise. + assertRatio("discrete fortune III base=1 -> outcome 1 (+0)", counts[1] / (double) n, 0.40, 0.05); + assertRatio("discrete fortune III base=1 -> outcome 2 (+1)", counts[2] / (double) n, 0.20, 0.05); + assertRatio("discrete fortune III base=1 -> outcome 3 (+2)", counts[3] / (double) n, 0.20, 0.05); + assertRatio("discrete fortune III base=1 -> outcome 4 (+3)", counts[4] / (double) n, 0.20, 0.05); + assertEquals(0, counts[0], "outcome 0 impossible (base is 1, fortune never reduces)"); + } + + // ------------------------------------------------------------------ + // Multi-drop ores (Lapis, Copper, Redstone): multiplier model + // ------------------------------------------------------------------ + + @Test + @DisplayName("Multiplier fortune keeps result as a multiple of the base") + void applyFortune_multiplierModelShape() { + // multiplier = max(0, random.nextInt(fortuneLevel+2)-1) -> in [0, fortuneLevel] + // result = base * (multiplier+1) -> base * [1, fortuneLevel+1] + String[] multi = {"lapis", "copper", "redstone"}; + for (String t : multi) { + for (int fortune : new int[]{1, 2, 3}) { + Random rng = new Random(t.hashCode() + fortune * 7); + for (int i = 0; i < 10_000; i++) { + int base = 4; // vanilla lapis-ish + int with = OreDropMath.applyFortune(t, base, true, fortune, rng); + assertTrue(with % base == 0, + t + " multiplier result " + with + " is not a multiple of base " + base); + assertTrue(with >= base && with <= base * (fortune + 1), + t + " fortune=" + fortune + " with=" + with + " outside [base, base*(F+1)]"); + } + } + } + } + + @Test + @DisplayName("Multiplier fortune level 3 produces the vanilla distribution") + void applyFortune_multiplierLevel3Distribution() { + // Vanilla Lapis Fortune III, base=1: multiplier in {0,1,2,3} as {2/5,1/5,1/5,1/5} + // -> result = 1*(m+1) = {1,2,3,4} with {2/5,1/5,1/5,1/5} + Random rng = new Random(7L); + int[] counts = new int[8]; + int n = 200_000; + for (int i = 0; i < n; i++) { + counts[OreDropMath.applyFortune("lapis", 1, true, 3, rng)]++; + } + assertRatio("lapis multiplier fortune III -> result 1 (x1)", counts[1] / (double) n, 0.40, 0.05); + assertRatio("lapis multiplier fortune III -> result 2 (x2)", counts[2] / (double) n, 0.20, 0.05); + assertRatio("lapis multiplier fortune III -> result 3 (x3)", counts[3] / (double) n, 0.20, 0.05); + assertRatio("lapis multiplier fortune III -> result 4 (x4)", counts[4] / (double) n, 0.20, 0.05); + } + + // ------------------------------------------------------------------ + // dropCount: ordering of random draws + // ------------------------------------------------------------------ + + @Test + @DisplayName("dropCount consumes base draw THEN fortune draw (deterministic order)") + void dropCount_drawOrder() { + // With a fixed seed, dropCount must equal baseDropCount followed by applyFortune on the + // SAME continuation of the RNG. This guards against subtle behaviour drift if a refactor + // reorders the random draws. + Random rngA = new Random(55L); + int base = OreDropMath.baseDropCount(4, 9, rngA); + int fortune = OreDropMath.applyFortune("lapis", base, true, 3, rngA); + + Random rngB = new Random(55L); + int combined = OreDropMath.dropCount("lapis", 4, 9, true, 3, rngB); + + assertEquals(fortune, combined, "dropCount must be base+fortune in order"); + } + + // ------------------------------------------------------------------ + // experienceFor + // ------------------------------------------------------------------ + + @Test + @DisplayName("XP: iron/gold/copper/unknown drop 0 raw XP") + void experienceFor_zeroXpTypes() { + Random rng = new Random(1L); + assertEquals(0, OreDropMath.experienceFor("impure_iron", rng)); + assertEquals(0, OreDropMath.experienceFor("impure_gold", rng)); + assertEquals(0, OreDropMath.experienceFor("pure_golden", rng)); + assertEquals(0, OreDropMath.experienceFor("copper", rng)); + assertEquals(0, OreDropMath.experienceFor("unknown", rng)); + assertEquals(0, OreDropMath.experienceFor("", rng)); + } + + @Test + @DisplayName("XP ranges match vanilla 1.21 for each ore type") + void experienceFor_ranges() { + Random rng = new Random(2L); + // coal: 0-2 + for (int i = 0; i < 5_000; i++) { + int xp = OreDropMath.experienceFor("concentrated_coal", rng); + assertTrue(xp >= 0 && xp <= 2, "coal XP " + xp + " out of [0,2]"); + } + // diamond/emerald/shard: 3-7 + for (String t : new String[]{"shard_diamond", "concentrated_diamond", "emerald"}) { + for (int i = 0; i < 5_000; i++) { + int xp = OreDropMath.experienceFor(t, rng); + assertTrue(xp >= 3 && xp <= 7, t + " XP " + xp + " out of [3,7]"); + } + } + // lapis: 2-5 + for (int i = 0; i < 5_000; i++) { + int xp = OreDropMath.experienceFor("lapis", rng); + assertTrue(xp >= 2 && xp <= 5, "lapis XP " + xp + " out of [2,5]"); + } + // redstone: 1-5 + for (int i = 0; i < 5_000; i++) { + int xp = OreDropMath.experienceFor("redstone", rng); + assertTrue(xp >= 1 && xp <= 5, "redstone XP " + xp + " out of [1,5]"); + } + } + + @Test + @DisplayName("XP reaches both bounds of its range over many draws") + void experienceFor_boundsHit() { + Random rng = new Random(3L); + boolean[] coalSeen = new boolean[3]; // 0,1,2 + boolean[] redstoneSeen = new boolean[6]; // 1..5 + boolean[] diamondSeen = new boolean[8]; // 3..7 + for (int i = 0; i < 100_000; i++) { + coalSeen[OreDropMath.experienceFor("concentrated_coal", rng)] = true; + redstoneSeen[OreDropMath.experienceFor("redstone", rng)] = true; + diamondSeen[OreDropMath.experienceFor("emerald", rng)] = true; + } + for (int v = 0; v <= 2; v++) assertTrue(coalSeen[v], "coal XP " + v + " never observed"); + for (int v = 1; v <= 5; v++) assertTrue(redstoneSeen[v], "redstone XP " + v + " never observed"); + for (int v = 3; v <= 7; v++) assertTrue(diamondSeen[v], "emerald XP " + v + " never observed"); + } + + // ------------------------------------------------------------------ + // Determinism: same seed -> same output + // ------------------------------------------------------------------ + + @Test + @DisplayName("Same RNG seed produces identical drop+XP sequences") + void determinism_sameSeedSameOutput() { + String[] oreTypes = {"shard_diamond", "lapis", "redstone", "emerald", "concentrated_coal", + "copper", "impure_iron", "impure_gold", "pure_golden", "concentrated_diamond"}; + Random rngA = new Random(0xC0FFEE); + Random rngB = new Random(0xC0FFEE); + for (String t : oreTypes) { + int da = OreDropMath.dropCount(t, 2, 5, true, 3, rngA); + int db = OreDropMath.dropCount(t, 2, 5, true, 3, rngB); + assertEquals(da, db, t + " dropCount diverged with same seed"); + int xa = OreDropMath.experienceFor(t, rngA); + int xb = OreDropMath.experienceFor(t, rngB); + assertEquals(xa, xb, t + " XP diverged with same seed"); + } + } + + private static void assertRatio(String label, double actual, double expected, double tol) { + assertTrue(Math.abs(actual - expected) <= tol, + label + ": expected ~" + expected + " ±" + tol + ", got " + actual); + } +}