diff --git a/CHANGELOG.md b/CHANGELOG.md index ad52704a..92118df4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,9 +31,6 @@ All major changes brought to **Cobblemon Cards** in this update. * Fixed `ItemStack` instances being shared between a cabinet's container and the `BINDER_CONTENTS` component when broken or placed. -### Master Album Config for Disabling Stats -* **New Config Option**: `doesMasterBinderProvideStats` — Set to `false` to disable stats from master tier binders. Due to their large card capacity. - --- ## 🚀 Version 1.0.1 (Multiloader & Easter Eggs Update) diff --git a/common/src/main/java/com/howlite/cobblemoncards/CobblemonCardsConfig.java b/common/src/main/java/com/howlite/cobblemoncards/CobblemonCardsConfig.java index 7fee4ff6..eae92867 100644 --- a/common/src/main/java/com/howlite/cobblemoncards/CobblemonCardsConfig.java +++ b/common/src/main/java/com/howlite/cobblemoncards/CobblemonCardsConfig.java @@ -13,6 +13,18 @@ public class CobblemonCardsConfig extends MidnightConfig { @Entry public static boolean enableSpawnBoostStats = true; + /** Master toggle for the Exp / Catch / Shiny "trainer" stats. */ + @Entry + public static boolean enableTrainerStats = true; + + /** Single toggle for all 15 egg-group spawn influences. Also requires {@link #enableSpawnBoostStats}. */ + @Entry + public static boolean enableEggGroupStats = false; + + /** Single toggle for all 6 EV-yield spawn influences. Also requires {@link #enableSpawnBoostStats}. */ + @Entry + public static boolean enableEvYieldStats = false; + @Entry(min = 0.0f, max = 100.0f) public static float globalStatMultiplier = 10.0f; @@ -22,9 +34,47 @@ public class CobblemonCardsConfig extends MidnightConfig { @Entry(min = 0.0f, max = 100.0f) public static float spawnBoostStatMultiplier = 1.0f; + @Entry(min = 0.0f, max = 100.0f) + public static float trainerStatMultiplier = 1.0f; + @Entry(min = 0.0f, max = 10000.0f) public static float maxSpawnBoostMultiplier = 100.0f; + // --- Trainer stat safety caps (mirrors the maxSpawnBoostMultiplier idiom) --- + + @Entry(min = 1.0f, max = 100.0f) + public static float maxExpBoostMultiplier = 5.0f; + + @Entry(min = 1.0f, max = 100.0f) + public static float maxCatchBoostMultiplier = 5.0f; + + /** Shiny rate is "1-in-N", so a boost divides it. This caps the divisor. */ + @Entry(min = 1.0f, max = 100.0f) + public static float maxShinyBoostDivisor = 10.0f; + + // --- How trainer stats are earned --- + // Trainer stats never roll on a normal card. They are granted either by grading a card to + // trainerStatMinGrade or higher, or by the small lucky chance on Legendary/Mythic/Shiny cards. + + @Entry + public static boolean enableTrainerStatGrading = true; + + /** Minimum grade that can earn a trainer stat. */ + @Entry(min = 1, max = 10) + public static int trainerStatMinGrade = 9; + + /** Chance (%) that a card graded at or above {@link #trainerStatMinGrade} converts to a trainer stat. */ + @Entry(min = 0.0f, max = 100.0f) + public static float trainerStatGradeChance = 50.0f; + + /** Chance (%) that a Legendary / Mythic / Shiny card rolls a trainer stat on drop. */ + @Entry(min = 0.0f, max = 100.0f) + public static float trainerStatLuckyChance = 5.0f; + + /** Lucky trainer stats roll at this fraction of the normal rarity-based value. */ + @Entry(min = 0.0f, max = 1.0f) + public static float trainerStatLuckyValueMultiplier = 0.25f; + @Entry(min = 1, max = 1200) public static int recyclerProcessTime = 40; @@ -107,6 +157,18 @@ public class CobblemonCardsConfig extends MidnightConfig { @Entry(min = 0.0f, max = 100.0f) public static float cardDropChanceStatMultiplier = 1.0f; + // --- Per-stat multipliers for the trainer stats --- + /** Trainer stats are incredibly powerful, so they are damped by default. */ + + @Entry(min = 0.0f, max = 100.0f) + public static float expBoostStatMultiplier = 0.8f; + + @Entry(min = 0.0f, max = 100.0f) + public static float catchBoostStatMultiplier = 0.25f; + + @Entry(min = 0.0f, max = 100.0f) + public static float shinyChanceStatMultiplier = 0.1f; + /** * When false (default), species whose National Pokédex number is outside [1, 1025] * (i.e. Fakemon added by addon mods) are excluded from card drops and booster packs. @@ -117,14 +179,23 @@ public class CobblemonCardsConfig extends MidnightConfig { public static float getStatMultiplier(CardStat stat) { - if (stat == null || !enableCardStats || (!enablePlayerStats && isPlayerStat(stat)) || (!enableSpawnBoostStats && isSpawnStat(stat))) { + if (stat == null || !enableCardStats + || (!enablePlayerStats && isPlayerStat(stat)) + || (!enableSpawnBoostStats && isSpawnBoostStat(stat)) + || (!enableTrainerStats && isTrainerStat(stat)) + || (!enableEggGroupStats && isEggGroupStat(stat)) + || (!enableEvYieldStats && isEvYieldStat(stat))) { return 0.0f; } if (isPlayerStat(stat)) { return globalStatMultiplier * playerStatMultiplier * getPerStatMultiplier(stat); } - if (isSpawnStat(stat)) { + if (isTrainerStat(stat)) { + return globalStatMultiplier * trainerStatMultiplier * getPerStatMultiplier(stat); + } + // Elemental-type, egg-group and EV-yield boosts all feed the same spawn weighting pipeline. + if (isSpawnBoostStat(stat)) { return globalStatMultiplier * spawnBoostStatMultiplier; } @@ -147,6 +218,9 @@ public static float getPerStatMultiplier(CardStat stat) { case ARMOR -> armorStatMultiplier; case MAX_HEALTH -> maxHealthStatMultiplier; case CARD_DROP_CHANCE -> cardDropChanceStatMultiplier; + case EXP_BOOST -> expBoostStatMultiplier; + case CATCH_BOOST -> catchBoostStatMultiplier; + case SHINY_CHANCE -> shinyChanceStatMultiplier; default -> 1.0f; }; } @@ -158,10 +232,34 @@ public static boolean isPlayerStat(CardStat stat) { || stat == CardStat.MAX_HEALTH || stat == CardStat.CARD_DROP_CHANCE; } + /** Elemental-type spawn boosts only (the original {@code *_spawn} stats). */ public static boolean isSpawnStat(CardStat stat) { return stat != null && stat.getSerializedName().endsWith("_spawn"); } + /** The Exp / Catch / Shiny global player boosts. */ + public static boolean isTrainerStat(CardStat stat) { + return stat == CardStat.EXP_BOOST || stat == CardStat.CATCH_BOOST || stat == CardStat.SHINY_CHANCE; + } + + /** Egg-group spawn influences ({@code *_egg}). */ + public static boolean isEggGroupStat(CardStat stat) { + return stat != null && stat.getSerializedName().endsWith("_egg"); + } + + /** EV-yield spawn influences ({@code *_yield}). */ + public static boolean isEvYieldStat(CardStat stat) { + return stat != null && stat.getSerializedName().endsWith("_yield"); + } + + /** + * Any stat that feeds Cobblemon's spawn weighting: elemental type, egg group or EV yield. + * All three are gated by {@link #enableSpawnBoostStats} so the master toggle stays meaningful. + */ + public static boolean isSpawnBoostStat(CardStat stat) { + return isSpawnStat(stat) || isEggGroupStat(stat) || isEvYieldStat(stat); + } + public static int getBinderPages(com.howlite.cobblemoncards.item.custom.BinderTier tier, int defaultPages) { if (tier == null) return defaultPages; return switch (tier) { diff --git a/common/src/main/java/com/howlite/cobblemoncards/block/entity/CardRestorerBlockEntity.java b/common/src/main/java/com/howlite/cobblemoncards/block/entity/CardRestorerBlockEntity.java index 26b1e91d..9f16c7d8 100644 --- a/common/src/main/java/com/howlite/cobblemoncards/block/entity/CardRestorerBlockEntity.java +++ b/common/src/main/java/com/howlite/cobblemoncards/block/entity/CardRestorerBlockEntity.java @@ -2,6 +2,7 @@ import com.howlite.cobblemoncards.CobblemonCardsConfig; import com.howlite.cobblemoncards.component.CardData; +import com.howlite.cobblemoncards.component.CardStat; import com.howlite.cobblemoncards.component.ModDataComponents; import com.howlite.cobblemoncards.item.ModItems; import com.howlite.cobblemoncards.block.ModBlocks; @@ -29,6 +30,8 @@ public class CardRestorerBlockEntity extends BlockEntity implements ImplementedInventory, MenuProvider { + private static final java.util.Random RANDOM = new java.util.Random(); + // Slots: 0 = card input, 1-4 = card dust input private final NonNullList inventory = NonNullList.withSize(5, ItemStack.EMPTY); @@ -217,11 +220,26 @@ public boolean performRestore(Player player) { : data.statValue(); float newStatValue = baseStatValue * (1f + targetBonus); + // Restoring can lift a card to grade 9+, which is the main way to earn a "trainer" stat. + // Without this, a card restored to grade 10 could never obtain one (the Grading Station only + // ever runs on grade-0 cards). Gated on newly CROSSING the threshold, so repeatedly restoring + // an already-9 card to 10 isn't a cheap re-roll. + CardStat finalStat = data.stat(); + if (currentGrade < CobblemonCardsConfig.trainerStatMinGrade + && target >= CobblemonCardsConfig.trainerStatMinGrade + && !com.howlite.cobblemoncards.util.CardUtil.isCosmeticCard(data.pokemonId())) { + CardStat trainerStat = com.howlite.cobblemoncards.util.CardStatUtil + .rollTrainerStatForGrade(target, RANDOM); + if (trainerStat != null) { + finalStat = trainerStat; + } + } + CardData newData = new CardData( data.pokemonId(), data.isShiny(), data.rarity(), - data.stat(), + finalStat, newStatValue, target, data.background(), diff --git a/common/src/main/java/com/howlite/cobblemoncards/block/entity/GradingStationBlockEntity.java b/common/src/main/java/com/howlite/cobblemoncards/block/entity/GradingStationBlockEntity.java index b0ef5cc7..ce234ff3 100644 --- a/common/src/main/java/com/howlite/cobblemoncards/block/entity/GradingStationBlockEntity.java +++ b/common/src/main/java/com/howlite/cobblemoncards/block/entity/GradingStationBlockEntity.java @@ -2,6 +2,7 @@ import com.howlite.cobblemoncards.CobblemonCardsConfig; import com.howlite.cobblemoncards.component.CardData; +import com.howlite.cobblemoncards.component.CardStat; import com.howlite.cobblemoncards.component.ModDataComponents; import net.minecraft.core.BlockPos; import net.minecraft.core.HolderLookup; @@ -172,11 +173,22 @@ private void finalizeGrading() { float bonusMultiplier = newGrade * 0.03f; float newStatValue = oldData.statValue() + (oldData.statValue() * bonusMultiplier); + // A high grade is the main way to earn a "trainer" stat (Exp / Catch / Shiny). + // Cosmetic cards are excluded: they are meant to carry no stats at all. + CardStat finalStat = oldData.stat(); + if (!com.howlite.cobblemoncards.util.CardUtil.isCosmeticCard(oldData.pokemonId())) { + CardStat trainerStat = com.howlite.cobblemoncards.util.CardStatUtil + .rollTrainerStatForGrade(newGrade, RANDOM); + if (trainerStat != null) { + finalStat = trainerStat; + } + } + CardData newData = new CardData( oldData.pokemonId(), oldData.isShiny(), oldData.rarity(), - oldData.stat(), + finalStat, newStatValue, newGrade, oldData.background(), diff --git a/common/src/main/java/com/howlite/cobblemoncards/component/CardStat.java b/common/src/main/java/com/howlite/cobblemoncards/component/CardStat.java index bc4c3233..b1aafb8d 100644 --- a/common/src/main/java/com/howlite/cobblemoncards/component/CardStat.java +++ b/common/src/main/java/com/howlite/cobblemoncards/component/CardStat.java @@ -1,5 +1,8 @@ package com.howlite.cobblemoncards.component; +import com.cobblemon.mod.common.api.pokemon.egg.EggGroup; +import com.cobblemon.mod.common.api.pokemon.stats.Stat; +import com.cobblemon.mod.common.api.pokemon.stats.Stats; import com.mojang.serialization.Codec; import net.minecraft.network.chat.Component; import net.minecraft.network.chat.MutableComponent; @@ -34,7 +37,39 @@ public enum CardStat implements StringRepresentable { DRAGON_SPAWN("dragon_spawn"), STEEL_SPAWN("steel_spawn"), FAIRY_SPAWN("fairy_spawn"), - DARK_SPAWN("dark_spawn"); + DARK_SPAWN("dark_spawn"), + + // Stats Dresseur (boosts globaux du joueur) + // Ces stats sont volontairement absentes du tirage normal : elles s'obtiennent uniquement + // via un grade >= 9 ou la petite chance sur les cartes Legendary / Mythic / Shiny. + EXP_BOOST("exp_boost"), + CATCH_BOOST("catch_boost"), + SHINY_CHANCE("shiny_chance"), + + // Influences de spawn par groupe d'oeuf (suffixe _egg, 16 au total) + MONSTER_EGG("monster_egg"), + WATER_1_EGG("water_1_egg"), + BUG_EGG("bug_egg"), + FLYING_EGG("flying_egg"), + FIELD_EGG("field_egg"), + FAIRY_EGG("fairy_egg"), + GRASS_EGG("grass_egg"), + HUMAN_LIKE_EGG("human_like_egg"), + WATER_3_EGG("water_3_egg"), + MINERAL_EGG("mineral_egg"), + AMORPHOUS_EGG("amorphous_egg"), + WATER_2_EGG("water_2_egg"), + DITTO_EGG("ditto_egg"), + DRAGON_EGG("dragon_egg"), + UNDISCOVERED_EGG("undiscovered_egg"), + + // Influences de spawn par rendement d'EV (suffixe _yield, 6 au total) + HP_YIELD("hp_yield"), + ATTACK_YIELD("attack_yield"), + DEFENCE_YIELD("defence_yield"), + SPECIAL_ATTACK_YIELD("special_attack_yield"), + SPECIAL_DEFENCE_YIELD("special_defence_yield"), + SPEED_YIELD("speed_yield"); public static final Codec CODEC = StringRepresentable.fromEnum(CardStat::values); @@ -52,4 +87,49 @@ public MutableComponent getTranslatedName() { public String getSerializedName() { return name; } + + /** + * Resolves the Cobblemon {@link EggGroup} boosted by an {@code *_egg} stat. + * Kept next to the constants so the stat -> Cobblemon mapping lives in one place, mirroring + * how {@code BinderSpawnModifier} derives {@code ElementalTypes.get(...)} from the serialized name. + * + * @return the matching egg group, or {@code null} if this stat is not an egg-group stat. + */ + public EggGroup getEggGroup() { + return switch (this) { + case MONSTER_EGG -> EggGroup.MONSTER; + case WATER_1_EGG -> EggGroup.WATER_1; + case BUG_EGG -> EggGroup.BUG; + case FLYING_EGG -> EggGroup.FLYING; + case FIELD_EGG -> EggGroup.FIELD; + case FAIRY_EGG -> EggGroup.FAIRY; + case GRASS_EGG -> EggGroup.GRASS; + case HUMAN_LIKE_EGG -> EggGroup.HUMAN_LIKE; + case WATER_3_EGG -> EggGroup.WATER_3; + case MINERAL_EGG -> EggGroup.MINERAL; + case AMORPHOUS_EGG -> EggGroup.AMORPHOUS; + case WATER_2_EGG -> EggGroup.WATER_2; + case DITTO_EGG -> EggGroup.DITTO; + case DRAGON_EGG -> EggGroup.DRAGON; + case UNDISCOVERED_EGG -> EggGroup.UNDISCOVERED; + default -> null; + }; + } + + /** + * Resolves the Cobblemon {@link Stat} whose EV yield is boosted by a {@code *_yield} stat. + * + * @return the matching stat, or {@code null} if this stat is not an EV-yield stat. + */ + public Stat getEvYieldStat() { + return switch (this) { + case HP_YIELD -> Stats.HP; + case ATTACK_YIELD -> Stats.ATTACK; + case DEFENCE_YIELD -> Stats.DEFENCE; + case SPECIAL_ATTACK_YIELD -> Stats.SPECIAL_ATTACK; + case SPECIAL_DEFENCE_YIELD -> Stats.SPECIAL_DEFENCE; + case SPEED_YIELD -> Stats.SPEED; + default -> null; + }; + } } \ No newline at end of file diff --git a/common/src/main/java/com/howlite/cobblemoncards/event/BinderSpawnModifier.java b/common/src/main/java/com/howlite/cobblemoncards/event/BinderSpawnModifier.java index 467286d4..ef3e3f8c 100644 --- a/common/src/main/java/com/howlite/cobblemoncards/event/BinderSpawnModifier.java +++ b/common/src/main/java/com/howlite/cobblemoncards/event/BinderSpawnModifier.java @@ -2,6 +2,8 @@ import com.cobblemon.mod.common.api.pokemon.PokemonProperties; import com.cobblemon.mod.common.api.pokemon.PokemonSpecies; +import com.cobblemon.mod.common.api.pokemon.egg.EggGroup; +import com.cobblemon.mod.common.api.pokemon.stats.Stat; import com.cobblemon.mod.common.api.spawning.detail.PokemonSpawnDetail; import com.cobblemon.mod.common.api.spawning.detail.SpawnDetail; import com.cobblemon.mod.common.api.spawning.influence.SpawningInfluence; @@ -12,17 +14,13 @@ import com.cobblemon.mod.common.pokemon.Species; import com.howlite.cobblemoncards.CobblemonCardsConfig; import com.howlite.cobblemoncards.component.CardStat; -import com.howlite.cobblemoncards.item.custom.BinderItem; import com.howlite.cobblemoncards.util.CardStatUtil; -import com.howlite.cobblemoncards.util.EquippedAccessory; -import com.howlite.cobblemoncards.util.PlatformHelper; import net.minecraft.resources.ResourceLocation; import net.minecraft.server.level.ServerPlayer; import org.jetbrains.annotations.NotNull; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.util.EnumMap; import java.util.HashMap; import java.util.Locale; import java.util.Map; @@ -53,6 +51,10 @@ public class BinderSpawnModifier implements SpawningInfluence { /** Per-type weight multipliers derived from the binder contents, refreshed on a timer. */ private final Map typeMultipliers = new HashMap<>(); + /** Per-egg-group weight multipliers, refreshed alongside {@link #typeMultipliers}. */ + private final Map eggGroupMultipliers = new HashMap<>(); + /** Per-EV-yield weight multipliers, refreshed alongside {@link #typeMultipliers}. */ + private final Map evYieldMultipliers = new HashMap<>(); private long lastRefreshTick = -REFRESH_INTERVAL_TICKS; public BinderSpawnModifier(@NotNull ServerPlayer player) { @@ -84,7 +86,7 @@ public float affectWeight(@NotNull SpawnDetail detail, @NotNull SpawnablePositio } refreshIfNeeded(); - if (typeMultipliers.isEmpty()) { + if (typeMultipliers.isEmpty() && eggGroupMultipliers.isEmpty() && evYieldMultipliers.isEmpty()) { return weight; } @@ -96,9 +98,46 @@ public float affectWeight(@NotNull SpawnDetail detail, @NotNull SpawnablePositio float multiplier = Math.max( multiplierFor(species.getPrimaryType()), multiplierFor(species.getSecondaryType())); + + // Egg group and EV yield are independent boost families, so they stack multiplicatively with + // the elemental-type boost. Within each family we take the max rather than the product, so a + // species in two boosted egg groups isn't double-counted. + multiplier *= eggGroupMultiplier(species); + multiplier *= evYieldMultiplier(species); + return multiplier == 1.0f ? weight : weight * multiplier; } + /** Highest boost among the egg groups this species belongs to, or 1.0 if none are boosted. */ + private float eggGroupMultiplier(Species species) { + if (eggGroupMultipliers.isEmpty()) { + return 1.0f; + } + float best = 1.0f; + for (EggGroup group : species.getEggGroups()) { + Float mult = eggGroupMultipliers.get(group); + if (mult != null && mult > best) { + best = mult; + } + } + return best; + } + + /** Highest boost among the EV yields this species actually gives, or 1.0 if none are boosted. */ + private float evYieldMultiplier(Species species) { + if (evYieldMultipliers.isEmpty()) { + return 1.0f; + } + float best = 1.0f; + for (Map.Entry entry : evYieldMultipliers.entrySet()) { + Integer yield = species.getEvYield().get(entry.getKey()); + if (yield != null && yield > 0 && entry.getValue() > best) { + best = entry.getValue(); + } + } + return best; + } + @Override public boolean isExpired() { return player.isRemoved(); @@ -119,42 +158,60 @@ private void refreshIfNeeded() { } lastRefreshTick = now; - Map spawnStats = new EnumMap<>(CardStat.class); - for (EquippedAccessory equipped : PlatformHelper.INSTANCE.getEquippedAccessories(player)) { - String slotId = equipped.slotName(); - if (!slotId.equals("binder") && !slotId.equals("legs/binder")) { - continue; - } - if (equipped.stack().getItem() instanceof BinderItem) { - // MasterAlbumItem extends BinderItem, so albums are intentionally included here. - CardStatUtil.collectStats(equipped.stack(), CobblemonCardsConfig::isSpawnStat, spawnStats); - } - } + // Shared equipped-binder scan (handles slot ids, master-album gating and cosmetic cards). + Map spawnStats = + CardStatUtil.collectEquippedStats(player, CobblemonCardsConfig::isSpawnBoostStat); typeMultipliers.clear(); + eggGroupMultipliers.clear(); + evYieldMultipliers.clear(); + for (Map.Entry entry : spawnStats.entrySet()) { - ElementalType type = getElementalType(entry.getKey()); - if (type == null) { - continue; - } + CardStat stat = entry.getKey(); // Single source of truth for stat -> percentage (config multipliers applied exactly once). - float percent = CardStatUtil.getEffectiveValue(entry.getKey(), entry.getValue()); + float percent = CardStatUtil.getEffectiveValue(stat, entry.getValue()); if (percent <= 0f) { - // Never reduce weights: a non-positive bonus simply means "no boost for this type". + // Never reduce weights: a non-positive bonus simply means "no boost for this stat". continue; } float multiplier = Math.min(CobblemonCardsConfig.maxSpawnBoostMultiplier, 1.0f + (percent / 100.0f)); - typeMultipliers.merge(type, multiplier, Math::max); + + if (CobblemonCardsConfig.isEggGroupStat(stat)) { + EggGroup group = stat.getEggGroup(); + if (group != null) { + eggGroupMultipliers.merge(group, multiplier, Math::max); + } + } else if (CobblemonCardsConfig.isEvYieldStat(stat)) { + Stat evStat = stat.getEvYieldStat(); + if (evStat != null) { + evYieldMultipliers.merge(evStat, multiplier, Math::max); + } + } else { + ElementalType type = getElementalType(stat); + if (type != null) { + typeMultipliers.merge(type, multiplier, Math::max); + } + } } if (DEBUG) { - LOGGER.info("[BinderSpawn] {} binder boosts: {}", player.getName().getString(), - typeMultipliers.isEmpty() ? "none" : typeMultipliers.entrySet().stream() - .map(e -> e.getKey().getName() + " x" + String.format(Locale.ROOT, "%.2f", e.getValue())) - .collect(Collectors.joining(", "))); + LOGGER.info("[BinderSpawn] {} binder boosts: types={} eggGroups={} evYields={}", + player.getName().getString(), + describe(typeMultipliers, ElementalType::getName), + describe(eggGroupMultipliers, EggGroup::name), + describe(evYieldMultipliers, Stat::getShowdownId)); + } + } + + private static String describe(Map multipliers, java.util.function.Function naming) { + if (multipliers.isEmpty()) { + return "none"; } + return multipliers.entrySet().stream() + .map(e -> naming.apply(e.getKey()) + " x" + String.format(Locale.ROOT, "%.2f", e.getValue())) + .collect(Collectors.joining(", ")); } // ------------------------------------------------------------------ diff --git a/common/src/main/java/com/howlite/cobblemoncards/event/ModEvents.java b/common/src/main/java/com/howlite/cobblemoncards/event/ModEvents.java index beef26cd..bacbbd84 100644 --- a/common/src/main/java/com/howlite/cobblemoncards/event/ModEvents.java +++ b/common/src/main/java/com/howlite/cobblemoncards/event/ModEvents.java @@ -45,6 +45,68 @@ public static void registerEvents() { } return Unit.INSTANCE; }); + + registerTrainerStatEvents(); + } + + /** + * Hooks the Exp / Catch / Shiny "trainer" stats into Cobblemon. + * + *

Each handler reads the bonus from the player's equipped binder via + * {@link CardStatUtil#getEquippedBonus}, which returns {@code 0} when the stat's config toggle is + * off — so {@code enableTrainerStats} gates all three without extra checks.

+ * + *

All three fire on the server thread.

+ */ + private static void registerTrainerStatEvents() { + // --- Exp boost --- + // Pre is cancelable, but we only ever adjust the amount and never cancel. + CobblemonEvents.EXPERIENCE_GAINED_EVENT_PRE.subscribe(Priority.NORMAL, event -> { + ServerPlayer owner = event.getPokemon().getOwnerPlayer(); + if (owner != null) { + float percent = CardStatUtil.getEquippedBonus(owner, CardStat.EXP_BOOST); + if (percent > 0f) { + float multiplier = Math.min(CobblemonCardsConfig.maxExpBoostMultiplier, + 1.0f + (percent / 100.0f)); + // Keep the multiplication in floating point and round at the end. Casting the + // multiplier to an int first would floor a 1.5x boost to 1x, silently doing nothing. + event.setExperience(Math.round(event.getExperience() * multiplier)); + } + } + return Unit.INSTANCE; + }); + + // --- Catch rate boost --- + CobblemonEvents.POKEMON_CATCH_RATE.subscribe(Priority.NORMAL, event -> { + if (event.getThrower() instanceof ServerPlayer thrower) { + float percent = CardStatUtil.getEquippedBonus(thrower, CardStat.CATCH_BOOST); + if (percent > 0f) { + float multiplier = Math.min(CobblemonCardsConfig.maxCatchBoostMultiplier, + 1.0f + (percent / 100.0f)); + event.setCatchRate(event.getCatchRate() * multiplier); + } + } + return Unit.INSTANCE; + }); + + // --- Shiny chance boost --- + // The rate is a "1 in N" value, so boosting it means DIVIDING: a smaller N is more likely. + CobblemonEvents.SHINY_CHANCE_CALCULATION.subscribe(Priority.NORMAL, event -> { + event.addModificationFunction((currentRate, player, pokemon) -> { + if (player == null) { + return currentRate; + } + float percent = CardStatUtil.getEquippedBonus(player, CardStat.SHINY_CHANCE); + if (percent <= 0f) { + return currentRate; + } + // Clamped to >= 1 so we can never divide by zero, and never make shinies rarer. + float divisor = Math.max(1.0f, Math.min(CobblemonCardsConfig.maxShinyBoostDivisor, + 1.0f + (percent / 100.0f))); + return currentRate / divisor; + }); + return Unit.INSTANCE; + }); } private static void handlePokemonDrop(ServerPlayer player, Pokemon pokemon) { @@ -81,8 +143,9 @@ private static void handlePokemonDrop(ServerPlayer player, Pokemon pokemon) { // Species-aware rarity: catching a legendary leans toward higher-rarity drops String rarity = BoosterLootTable.getRandomRarity(pokemon.getSpecies()); - CardStat randomStat = CardStat.values()[RANDOM.nextInt(CardStat.values().length)]; - + // Config-aware pool: never mints a disabled stat, and never a trainer stat. + CardStat randomStat = CardStatUtil.randomStat(RANDOM); + // Stat value cohérente avec la rareté (même barème que les Boosters) float statValue = switch (rarity) { case "mythic" -> 0.20f + RANDOM.nextFloat() * 0.05f; @@ -93,7 +156,14 @@ private static void handlePokemonDrop(ServerPlayer player, Pokemon pokemon) { default -> 0.005f + RANDOM.nextFloat() * 0.005f; // common }; if (isShiny) statValue += 0.03f; - + + // Small chance for Legendary / Mythic / Shiny cards to carry a trainer stat instead, + // at a reduced value. Otherwise trainer stats are only earned by grading to 9+. + CardStatUtil.RolledStat rolled = + CardStatUtil.applyLuckyTrainerStat(randomStat, statValue, rarity, isShiny, RANDOM); + randomStat = rolled.stat(); + statValue = rolled.value(); + ItemStack cardStack = new ItemStack(ModItems.CARD); CardData cardData = new CardData( pokemonId, diff --git a/common/src/main/java/com/howlite/cobblemoncards/item/ModCreativeTabs.java b/common/src/main/java/com/howlite/cobblemoncards/item/ModCreativeTabs.java index 1b430bd3..de03fe7a 100644 --- a/common/src/main/java/com/howlite/cobblemoncards/item/ModCreativeTabs.java +++ b/common/src/main/java/com/howlite/cobblemoncards/item/ModCreativeTabs.java @@ -41,7 +41,7 @@ public static void register() { randomPokemon, RANDOM.nextBoolean(), RARITIES[RANDOM.nextInt(RARITIES.length)], - CardStat.values()[RANDOM.nextInt(CardStat.values().length)], + com.howlite.cobblemoncards.util.CardStatUtil.randomStat(RANDOM), (RANDOM.nextFloat() * 2.0f) - 1.0f, // Valeur entre -1.0 et 1.0 RANDOM.nextInt(11), // Grade entre 0 et 10 Optional.empty(), diff --git a/common/src/main/java/com/howlite/cobblemoncards/item/custom/BinderItem.java b/common/src/main/java/com/howlite/cobblemoncards/item/custom/BinderItem.java index 7ac7038e..bef26b1b 100644 --- a/common/src/main/java/com/howlite/cobblemoncards/item/custom/BinderItem.java +++ b/common/src/main/java/com/howlite/cobblemoncards/item/custom/BinderItem.java @@ -1,5 +1,6 @@ package com.howlite.cobblemoncards.item.custom; +import com.howlite.cobblemoncards.CobblemonCardsConfig; import com.howlite.cobblemoncards.component.CardStat; import com.howlite.cobblemoncards.util.PlatformHelper; import net.minecraft.ChatFormatting; diff --git a/common/src/main/java/com/howlite/cobblemoncards/item/custom/InstantDexItem.java b/common/src/main/java/com/howlite/cobblemoncards/item/custom/InstantDexItem.java index 413f620a..0314781f 100644 --- a/common/src/main/java/com/howlite/cobblemoncards/item/custom/InstantDexItem.java +++ b/common/src/main/java/com/howlite/cobblemoncards/item/custom/InstantDexItem.java @@ -30,6 +30,8 @@ public class InstantDexItem extends Item { private static final int MAX_SCANS = 5; + private static final java.util.Random RANDOM = new java.util.Random(); + public InstantDexItem(Properties properties) { super(properties); } @@ -192,7 +194,7 @@ private ItemStack generateCard(Player player, Pokemon pokemon, int dustAmount) { species = "missingno"; isShiny = true; rarity = "mythic"; - stat = CardStat.values()[(int) (Math.random() * CardStat.values().length)]; + stat = com.howlite.cobblemoncards.util.CardStatUtil.randomStat(RANDOM); statValue = 0.666f; // Glitched 66.6% value grade = 10; // Perfect Grade 10 @@ -274,7 +276,7 @@ private ItemStack generateCard(Player player, Pokemon pokemon, int dustAmount) { rarity = "uncommon"; } - stat = CardStat.values()[(int) (Math.random() * CardStat.values().length)]; + stat = com.howlite.cobblemoncards.util.CardStatUtil.randomStat(RANDOM); // Stat value cohérente avec la rareté déterminée — toujours positive statValue = switch (rarity) { case "mythic" -> 0.20f + (float) Math.random() * 0.05f; // ×10 → +2.0 à +2.5 diff --git a/common/src/main/java/com/howlite/cobblemoncards/item/custom/loot/BoosterLootTable.java b/common/src/main/java/com/howlite/cobblemoncards/item/custom/loot/BoosterLootTable.java index 3f1d1830..8abd97ef 100644 --- a/common/src/main/java/com/howlite/cobblemoncards/item/custom/loot/BoosterLootTable.java +++ b/common/src/main/java/com/howlite/cobblemoncards/item/custom/loot/BoosterLootTable.java @@ -245,7 +245,7 @@ private static ItemStack createCard(String rarity, boolean forceGodPack, String } // Sélection de la statistique - CardStat stat = CardStat.values()[RANDOM.nextInt(CardStat.values().length)]; + CardStat stat = com.howlite.cobblemoncards.util.CardStatUtil.randomStat(RANDOM); float statValue = 0f; // Probabilités pour Background et Effect @@ -332,6 +332,12 @@ private static ItemStack createCard(String rarity, boolean forceGodPack, String } } + com.howlite.cobblemoncards.util.CardStatUtil.RolledStat rolled = + com.howlite.cobblemoncards.util.CardStatUtil.applyLuckyTrainerStat( + stat, statValue, rarity, isShiny, RANDOM); + stat = rolled.stat(); + statValue = rolled.value(); + ItemStack cardStack = new ItemStack(ModItems.CARD); CardData data = new CardData(pokemonId, isShiny, rarity, stat, statValue, 0, background, effect); cardStack.set(ModDataComponents.CARD_DATA, data); @@ -409,4 +415,4 @@ private static boolean matchesFilter(com.cobblemon.mod.common.pokemon.Species sp return true; } } -} \ No newline at end of file +} diff --git a/common/src/main/java/com/howlite/cobblemoncards/util/CardStatUtil.java b/common/src/main/java/com/howlite/cobblemoncards/util/CardStatUtil.java index d0c93af0..2507b70c 100644 --- a/common/src/main/java/com/howlite/cobblemoncards/util/CardStatUtil.java +++ b/common/src/main/java/com/howlite/cobblemoncards/util/CardStatUtil.java @@ -13,10 +13,16 @@ import net.minecraft.world.item.ItemStack; import net.minecraft.world.item.component.ItemContainerContents; +import com.howlite.cobblemoncards.item.custom.BinderItem; +import com.howlite.cobblemoncards.item.custom.BinderTier; +import com.howlite.cobblemoncards.item.custom.MasterAlbumItem; + +import java.util.ArrayList; import java.util.Collections; import java.util.EnumMap; import java.util.List; import java.util.Map; +import java.util.Random; import java.util.function.Predicate; public class CardStatUtil { @@ -31,7 +37,7 @@ public class CardStatUtil { * vanilla {@link DataComponents#CONTAINER} component for saves that predate the migration. */ public static Iterable getBinderContents(ItemStack binderStack) { - if (binderStack == null || binderStack.isEmpty()) { + if (binderStack == null) { return Collections.emptyList(); } List binderItems = binderStack.get(ModDataComponents.BINDER_CONTENTS); @@ -73,6 +79,52 @@ public static void collectStats(ItemStack binderStack, Predicate filte } } + // ------------------------------------------------------------------ + // Equipped binder scanning + // ------------------------------------------------------------------ + + /** Accessory slot ids that count as "wearing a binder". */ + private static boolean isBinderSlot(String slotId) { + return slotId.equals("binder") || slotId.equals("legs/binder"); + } + + /** + * Sums the raw stat values held in the binder(s) the player currently has equipped. + * Single source of truth for "what is this player wearing", shared by the spawn influence and the + * trainer-stat event handlers. + * + *

{@link MasterAlbumItem} extends {@link BinderItem}, so albums are included only when + * + * @param filter optional stat filter, e.g. {@code CobblemonCardsConfig::isSpawnBoostStat} + */ + public static Map collectEquippedStats(ServerPlayer player, Predicate filter) { + Map totals = new EnumMap<>(CardStat.class); + if (player == null) { + return totals; + } + for (EquippedAccessory equipped : PlatformHelper.INSTANCE.getEquippedAccessories(player)) { + if (!isBinderSlot(equipped.slotName())) { + continue; + } + if (!(equipped.stack().getItem() instanceof BinderItem binder)) { + continue; + } + collectStats(equipped.stack(), filter, totals); + } + return totals; + } + + /** + * Effective bonus for a single stat across the player's equipped binder(s). + * The config multiplier is applied exactly once, via {@link #getEffectiveValue(CardStat, float)}, + * so a disabled stat correctly reads as {@code 0}. + */ + public static float getEquippedBonus(ServerPlayer player, CardStat stat) { + if (player == null || stat == null) return 0f; + float raw = collectEquippedStats(player, s -> s == stat).getOrDefault(stat, 0f); + return getEffectiveValue(stat, raw); + } + /** * How a stat is applied in game. *

    @@ -162,6 +214,117 @@ public static MutableComponent formatValueComponent(CardStat stat, float totalSt return Component.literal(formatValue(stat, totalStatValue)); } + // ------------------------------------------------------------------ + // Card generation: roll pool and trainer-stat gating + // ------------------------------------------------------------------ + + /** + * Stats eligible for a normal card roll: currently enabled in the config, and never a trainer stat. + *

    + * Trainer stats (Exp / Catch / Shiny) are excluded by design — they are far stronger than the rest + * and are only obtainable by grading a card to {@link CobblemonCardsConfig#trainerStatMinGrade} or + * above, or through the small lucky chance on Legendary / Mythic / Shiny cards. + *

    + * Filtering on the config here means a disabled stat can never be minted onto a card, which would + * otherwise produce a "dead" card showing a permanent +0 bonus. + */ + public static List rollableStats() { + List pool = new ArrayList<>(); + for (CardStat stat : CardStat.values()) { + if (CobblemonCardsConfig.isTrainerStat(stat)) { + continue; + } + if (CobblemonCardsConfig.getStatMultiplier(stat) > 0f) { + pool.add(stat); + } + } + return pool; + } + + /** + * Uniform pick from {@link #rollableStats()}. Falls back to {@link CardStat#MOVEMENT_SPEED} when the + * pool is empty (i.e. every stat category is disabled) so card generation can never fail outright. + */ + public static CardStat randomStat(Random random) { + List pool = rollableStats(); + if (pool.isEmpty()) { + return CardStat.MOVEMENT_SPEED; + } + return pool.get(random.nextInt(pool.size())); + } + + /** Trainer stats that are currently enabled in the config. */ + private static List enabledTrainerStats() { + List pool = new ArrayList<>(); + for (CardStat stat : CardStat.values()) { + if (CobblemonCardsConfig.isTrainerStat(stat) && CobblemonCardsConfig.getStatMultiplier(stat) > 0f) { + pool.add(stat); + } + } + return pool; + } + + /** Uniform pick from the enabled trainer stats, or {@code null} if none are enabled. */ + public static CardStat randomTrainerStat(Random random) { + List pool = enabledTrainerStats(); + return pool.isEmpty() ? null : pool.get(random.nextInt(pool.size())); + } + + /** + * Whether a card qualifies for the small "lucky" trainer-stat chance at generation time. + *

    + * Mythic is the tier above legendary, so it is included: excluding it would leave the + * rarest cards in the game worse off than legendary ones. + */ + public static boolean isLuckyCard(String rarity, boolean isShiny) { + if (isShiny) return true; + if (rarity == null) return false; + String r = rarity.toLowerCase(java.util.Locale.ROOT); + return r.equals("legendary") || r.equals("mythic"); + } + + /** + * Rolls the trainer stat a card earns on reaching {@code newGrade}, or {@code null} to keep the + * card's existing stat. Null-safe and self-gating: returns {@code null} when the feature is + * disabled, when the grade is below the threshold, or when the chance roll fails, so callers need + * no extra guards. + */ + public static CardStat rollTrainerStatForGrade(int newGrade, Random random) { + if (!CobblemonCardsConfig.enableTrainerStatGrading + || newGrade < CobblemonCardsConfig.trainerStatMinGrade) { + return null; + } + if (random.nextFloat() * 100f >= CobblemonCardsConfig.trainerStatGradeChance) { + return null; + } + return randomTrainerStat(random); + } + + /** + * Result of the lucky trainer-stat roll: the stat the card should carry and its value. + * Returned as a pair so the two generation sites (mob drops and booster packs) stay in sync. + */ + public record RolledStat(CardStat stat, float value) {} + + /** + * Applies the small Legendary / Mythic / Shiny chance to grant a trainer stat at a reduced value. + * Returns the original stat and value untouched when the card does not qualify or the roll fails. + */ + public static RolledStat applyLuckyTrainerStat(CardStat stat, float value, String rarity, + boolean isShiny, Random random) { + if (!isLuckyCard(rarity, isShiny) + || random.nextFloat() * 100f >= CobblemonCardsConfig.trainerStatLuckyChance) { + return new RolledStat(stat, value); + } + CardStat lucky = randomTrainerStat(random); + if (lucky == null) { + return new RolledStat(stat, value); + } + // "A very small amount": a fraction of the normal rarity-based value, so a properly earned + // grade 9+ trainer stat is always clearly stronger than a lucky drop. + return new RolledStat(lucky, value * CobblemonCardsConfig.trainerStatLuckyValueMultiplier); + } + public static float getPlayerDropBonus(ServerPlayer player) { float totalBonus = 0f; diff --git a/common/src/main/resources/assets/cobblemon-cards/lang/en_us.json b/common/src/main/resources/assets/cobblemon-cards/lang/en_us.json index 78e52d3e..333e3040 100644 --- a/common/src/main/resources/assets/cobblemon-cards/lang/en_us.json +++ b/common/src/main/resources/assets/cobblemon-cards/lang/en_us.json @@ -99,6 +99,30 @@ "stat.cobblemon-cards.steel_spawn": "Steel Spawn", "stat.cobblemon-cards.fairy_spawn": "Fairy Spawn", "stat.cobblemon-cards.dark_spawn": "Dark Spawn", + "stat.cobblemon-cards.exp_boost": "Exp Boost", + "stat.cobblemon-cards.catch_boost": "Catch Boost", + "stat.cobblemon-cards.shiny_chance": "Shiny Chance", + "stat.cobblemon-cards.monster_egg": "Monster Egg Group Spawn", + "stat.cobblemon-cards.water_1_egg": "Water 1 Egg Group Spawn", + "stat.cobblemon-cards.bug_egg": "Bug Egg Group Spawn", + "stat.cobblemon-cards.flying_egg": "Flying Egg Group Spawn", + "stat.cobblemon-cards.field_egg": "Field Egg Group Spawn", + "stat.cobblemon-cards.fairy_egg": "Fairy Egg Group Spawn", + "stat.cobblemon-cards.grass_egg": "Grass Egg Group Spawn", + "stat.cobblemon-cards.human_like_egg": "Human-Like Egg Group Spawn", + "stat.cobblemon-cards.water_3_egg": "Water 3 Egg Group Spawn", + "stat.cobblemon-cards.mineral_egg": "Mineral Egg Group Spawn", + "stat.cobblemon-cards.amorphous_egg": "Amorphous Egg Group Spawn", + "stat.cobblemon-cards.water_2_egg": "Water 2 Egg Group Spawn", + "stat.cobblemon-cards.ditto_egg": "Ditto Egg Group Spawn", + "stat.cobblemon-cards.dragon_egg": "Dragon Egg Group Spawn", + "stat.cobblemon-cards.undiscovered_egg": "Undiscovered Egg Group Spawn", + "stat.cobblemon-cards.hp_yield": "HP EV Yield Spawn", + "stat.cobblemon-cards.attack_yield": "Attack EV Yield Spawn", + "stat.cobblemon-cards.defence_yield": "Defence EV Yield Spawn", + "stat.cobblemon-cards.special_attack_yield": "Sp. Atk EV Yield Spawn", + "stat.cobblemon-cards.special_defence_yield": "Sp. Def EV Yield Spawn", + "stat.cobblemon-cards.speed_yield": "Speed EV Yield Spawn", "rarity.cobblemon-cards.common": "Common", "rarity.cobblemon-cards.uncommon": "Uncommon", "rarity.cobblemon-cards.rare": "Rare", diff --git a/fabric/src/main/java/com/howlite/cobblemoncards/fabric/item/FabricMasterAlbumItem.java b/fabric/src/main/java/com/howlite/cobblemoncards/fabric/item/FabricMasterAlbumItem.java index 9db46f08..95b51c6c 100644 --- a/fabric/src/main/java/com/howlite/cobblemoncards/fabric/item/FabricMasterAlbumItem.java +++ b/fabric/src/main/java/com/howlite/cobblemoncards/fabric/item/FabricMasterAlbumItem.java @@ -1,5 +1,6 @@ package com.howlite.cobblemoncards.fabric.item; +import com.howlite.cobblemoncards.CobblemonCardsConfig; import com.howlite.cobblemoncards.component.CardStat; import com.howlite.cobblemoncards.item.custom.MasterAlbumItem; import io.wispforest.accessories.api.Accessory; @@ -22,6 +23,7 @@ public FabricMasterAlbumItem(Properties properties) { @Override public void getDynamicModifiers(ItemStack stack, SlotReference reference, AccessoryAttributeBuilder builder) { + // Shared reader: BINDER_CONTENTS with a fallback to vanilla CONTAINER for unmigrated saves. Map statTotals = com.howlite.cobblemoncards.util.CardStatUtil.collectStats(stack); diff --git a/neoforge/src/main/java/com/howlite/cobblemoncards/neoforge/item/NeoForgeMasterAlbumItem.java b/neoforge/src/main/java/com/howlite/cobblemoncards/neoforge/item/NeoForgeMasterAlbumItem.java index ebeb000c..1b2a2b5f 100644 --- a/neoforge/src/main/java/com/howlite/cobblemoncards/neoforge/item/NeoForgeMasterAlbumItem.java +++ b/neoforge/src/main/java/com/howlite/cobblemoncards/neoforge/item/NeoForgeMasterAlbumItem.java @@ -1,5 +1,6 @@ package com.howlite.cobblemoncards.neoforge.item; +import com.howlite.cobblemoncards.CobblemonCardsConfig; import com.howlite.cobblemoncards.item.custom.MasterAlbumItem; import com.howlite.cobblemoncards.component.CardStat; import io.wispforest.accessories.api.Accessory; @@ -22,6 +23,7 @@ public NeoForgeMasterAlbumItem(Properties properties) { @Override public void getDynamicModifiers(ItemStack stack, SlotReference reference, AccessoryAttributeBuilder builder) { + // Shared reader: BINDER_CONTENTS with a fallback to vanilla CONTAINER for unmigrated saves. Map statTotals = com.howlite.cobblemoncards.util.CardStatUtil.collectStats(stack);