Phase 8: the pack's own JEI pages for every drop source

Adrian, 2026-11-02/03: every item that can drop gets a JEI page naming its
sources, alongside the Cobblemon Info add-on (which shows drops and biomes,
not zones). The companion mod's JEI plugin (jei/DropSourcesJei.kt) adds:
- Pokemon drops on the island: 794 species of lines that live on the
  island, with chances, amounts and the zones they spawn in (or where their
  line lives); spawns with their own drops (Beheeyem in the End). The Drop
  Synthesizer core is the catalyst.
- Guardians and the End boss; Sacrifice Altar tiers and specials; Rebirth
  Chamber; zone ore veins with regrow times; Cobblebase pasture finds per
  skill and zone.

JEI runs on the client, so the pages read assets/cobblesurvival/jei/
drop_sources.json inside the mod, written by tools/jei_sources.py from the
pack's files. Self-tests drop_sources_match_the_server (file vs live server:
drops, zones, own-drop spawns, guardians, End boss, altar) and
jei_pages_build (every page builds, every item exists). The ore a vein gives
moved into world/zones.json (ores -> item), shared with the quest book.
JEI's API (19.64.0.455) is a compile-only dependency from JEI's own Maven.
Self-tests and the island check pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
claudeandClaude Opus 5.5 committed 2026-11-03 11:58:18 +01:00
1 parent c5d1cf93f4
commit 72965caedf
13 files changed
+19637 -49

No files matched your search

+7
View File
@@ -118,6 +118,13 @@ python3 tools/quests.py
``` ```
Generates the quest book (Phase 8) from `docs/quests.yaml` into `pack/config/ftbquests/quests/`, and the hidden advancements quests check. `tools/build.py` runs it. `--pull <FTB Quests folder>` first copies node positions moved in the in-game editor back into `docs/quests.yaml` (`tools/prism_instance.py` does it from the instance before each sync); `--check` fails if the generated files are out of date. Generates the quest book (Phase 8) from `docs/quests.yaml` into `pack/config/ftbquests/quests/`, and the hidden advancements quests check. `tools/build.py` runs it. `--pull <FTB Quests folder>` first copies node positions moved in the in-game editor back into `docs/quests.yaml` (`tools/prism_instance.py` does it from the instance before each sync); `--check` fails if the generated files are out of date.
## JEI drop sources
```bash
python3 tools/jei_sources.py
```
Writes the data behind the pack's own JEI pages (Phase 8) into the companion mod (`mod/src/main/resources/assets/cobblesurvival/jei/drop_sources.json`): every Pokémon's drops and zones, guardians, the End boss, the altar, ore veins, pasture finds. Run it after changing species drops, spawns, guardians, machines, ores or Cobblebase tables, then rebuild; the self-test `drop_sources_match_the_server` fails while it's stale. Needs the Cobblemon jar in `build/server/mods` (any test-server run).
## Adding a mod ## Adding a mod
1. Read its license and its Cobblemon 1.8 status; add both to `docs/licenses.md` and `docs/addon-status.md`. 1. Read its license and its Cobblemon 1.8 status; add both to `docs/licenses.md` and `docs/addon-status.md`.
+3
View File
@@ -8,12 +8,15 @@
- **Add-ons in the pack:** Cobblemon Quests Reloaded 2.0.2 (Cobblemon task type for FTB Quests) and Cobblemon Info for REI / JEI / EMI 2.32.14. Automated: `quests_have_cobblemon_tasks`, `jei_addon_shows_island_spawns`. - **Add-ons in the pack:** Cobblemon Quests Reloaded 2.0.2 (Cobblemon task type for FTB Quests) and Cobblemon Info for REI / JEI / EMI 2.32.14. Automated: `quests_have_cobblemon_tasks`, `jei_addon_shows_island_spawns`.
- **Spawn info in JEI:** right for the pack (species, levels, rarity come from the zone pools), but it shows biomes, not zones; our own pages will add the zone. - **Spawn info in JEI:** right for the pack (species, levels, rarity come from the zone pools), but it shows biomes, not zones; our own pages will add the zone.
- **JEI pages** (ours): look an item up in JEI (its recipes, key G) for "Pokémon drops on the island" (each Pokémon with its zone and levels), "Guardians and the End boss", "Sacrifice Altar", "Rebirth Chamber", "Zone ore veins" and "Pasture finds". Automated: `drop_sources_match_the_server`, `jei_pages_build`.
- **Quest book:** 18 chapters, 74 quests, generated from `docs/quests.yaml`: Getting Started, one chapter per zone and the End, Pokémon Machines, Base and Jobs. No rewards, nothing locked. Zone chapters: reach the zone, catch three of its Pokémon, mine its ores, beat its guardian. Automated: `quest_book_loads`, `quest_events_mark_zones` (and the guardian and furnace tests check their quest events). - **Quest book:** 18 chapters, 74 quests, generated from `docs/quests.yaml`: Getting Started, one chapter per zone and the End, Pokémon Machines, Base and Jobs. No rewards, nothing locked. Zone chapters: reach the zone, catch three of its Pokémon, mine its ores, beat its guardian. Automated: `quest_book_loads`, `quest_events_mark_zones` (and the guardian and furnace tests check their quest events).
## In game (Prism instance) ## In game (Prism instance)
- [ ] Open JEI, look up a Pokémon (e.g. Pikachu) in Cobblemon Info's pages: the spawn levels match its zone (`docs/zone-species.md`), and its drops are listed. - [ ] Open JEI, look up a Pokémon (e.g. Pikachu) in Cobblemon Info's pages: the spawn levels match its zone (`docs/zone-species.md`), and its drops are listed.
- [ ] In JEI, look up the recipes of Light Ball: Pikachu's page shows its picture, "Lives in Westmarch (Lv 16–25)" and its drops with chances; Raichu's page says where its line lives. Look up Ender Pearl: Beheeyem in the End has a page of its own.
- [ ] Look up the recipes of Rare Candy (Sacrifice Altar tiers), Great Ball (Southwold's guardian), Raw Copper (Southwold's veins, and pasture mining in Southwold).
- [ ] Open the quest book (FTB Quests' book item, or its key): every chapter shows, nodes are joined by lines, none shows a lock. - [ ] Open the quest book (FTB Quests' book item, or its key): every chapter shows, nodes are joined by lines, none shows a lock.
- [ ] Getting Started: tick "Welcome to the Island"; catch a Pokémon: "A Partner" completes. - [ ] Getting Started: tick "Welcome to the Island"; catch a Pokémon: "A Partner" completes.
- [ ] Soft-lock check: get 16 Poké Balls before ticking "Welcome to the Island", then tick it: "Poké Balls" completes too (if it stays stuck, tell me: there's a known flexible-mode bug in older FTB Quests). - [ ] Soft-lock check: get 16 Poké Balls before ticking "Welcome to the Island", then tick it: "Poké Balls" completes too (if it stays stuck, tell me: there's a known flexible-mode bug in older FTB Quests).
+8 -1
View File
@@ -311,7 +311,14 @@ Adrian's choices: Cobblemon tasks come from **Cobblemon Quests Reloaded** (a mai
- **Node positions:** Adrian moves nodes in the in-game editor; `tools/quests.py --pull <folder>` records every node that isn't where the generator would put it in the `layout` block at the end of `docs/quests.yaml`. `tools/prism_instance.py` does that from the instance before each sync, and rebuilds if something moved, so a sync never undoes his layout (docs/quests.yaml then needs committing). Titles, texts, tasks and dependencies edited in game are overwritten. - **Node positions:** Adrian moves nodes in the in-game editor; `tools/quests.py --pull <folder>` records every node that isn't where the generator would put it in the `layout` block at the end of `docs/quests.yaml`. `tools/prism_instance.py` does that from the instance before each sync, and rebuilds if something moved, so a sync never undoes his layout (docs/quests.yaml then needs committing). Titles, texts, tasks and dependencies edited in game are overwritten.
- **Self-tests:** `quest_book_loads` (FTB Quests on the server has the book in flexible mode, every task type is known, every item and advancement exists), `quest_events_mark_zones`, and the guardian and furnace tests check their events. - **Self-tests:** `quest_book_loads` (FTB Quests on the server has the book in flexible mode, every task type is known, every item and advancement exists), `quest_events_mark_zones`, and the guardian and furnace tests check their events.
- Still open: the flexible-mode soft-lock a fix mod reports for 1.20.1 (tasks done before their dependencies); checked in game for now. - Still open: the flexible-mode soft-lock a fix mod reports for 1.20.1 (tasks done before their dependencies); checked in game for now.
- JEI (next): our plugin adds, per item, every place on the island it comes from that Cobblemon Info doesn't cover: guardian and End boss rewards, Sacrifice Altar tiers, ore veins by zone, pasture mining and finder tables by zone; and per Pokémon its zone. **Drop sources in JEI** (Adrian, 2026-11-02/03). The companion mod adds its own JEI pages (`jei/DropSourcesJei.kt`); look an item up (its recipes) to see every place on the island it comes from:
- **Pokémon drops on the island:** one page per species of a line that lives on the island (794): its drops with chance and amount, the zones it spawns in with their levels, or for a form that isn't wild, where its line lives ("Not wild; its line lives in Westmarch (Pichu)"). The Drop Synthesizer core is the page's catalyst (it rolls the same drops). Spawns with drops of their own get a page too (Beheeyem in the End: ender pearls). This is the zone information the Cobblemon Info add-on can't show.
- **Guardians and the End boss:** each guardian's species, level, zone, drops and return time; Eternatus with its experience and the dragon egg on the first win.
- **Sacrifice Altar** (each level tier and the Dragon-type special) and **Rebirth Chamber** (the Ability Patch), with their cores as catalysts.
- **Zone ore veins:** per zone, what its veins give and how fast they regrow.
- **Pasture finds:** per Cobblebase skill (mining, ore finder, evolution-item finder) and zone, what a pastured Pokémon can find there; the Pasture Block is the catalyst.
JEI runs on the client, which on a dedicated server doesn't have the server's data, so the pages are built from `assets/cobblesurvival/jei/drop_sources.json` inside the mod, written by `tools/jei_sources.py` from the pack's own files (Cobblemon's species drops with the pack's species additions, the zone spawn pools, guardians, the End boss, the machines, `world/zones.json` ores, Cobblebase's zone tables). Which ore a vein gives now lives in `world/zones.json` → `ores` → `item` (the quest book reads it too). Self-tests: `drop_sources_match_the_server` compares the file with the live server (every listed Pokémon's drops and zones, spawns with own drops, guardians, the End boss, altar tiers) and fails when it's stale; `jei_pages_build` builds every page with the same code JEI calls and checks every item exists.
## Open decisions for Adrian (proceeding with the default, flagged) ## Open decisions for Adrian (proceeding with the default, flagged)
-24
View File
@@ -21,30 +21,6 @@ book:
title: Cobblemon Survival title: Cobblemon Survival
icon: cobblemon:poke_ball icon: cobblemon:poke_ball
# What each zone's ore veins give when mined (pack/config/regenerating_ore_veins/veins.json).
ore_items:
coal_node: minecraft:coal
copper_node: minecraft:raw_copper
iron_node: minecraft:raw_iron
redstone_node: minecraft:redstone
lapis_node: minecraft:lapis_lazuli
gold_node: minecraft:raw_gold
emerald_node: minecraft:emerald
amethyst_node: minecraft:amethyst_shard
diamond_node: minecraft:diamond
quartz_node: minecraft:quartz
netherite_node: minecraft:ancient_debris
thunder_stone_node: cobblemon:thunder_stone
shiny_stone_node: cobblemon:shiny_stone
leaf_stone_node: cobblemon:leaf_stone
water_stone_node: cobblemon:water_stone
moon_stone_node: cobblemon:moon_stone
sun_stone_node: cobblemon:sun_stone
fire_stone_node: cobblemon:fire_stone
ice_stone_node: cobblemon:ice_stone
dawn_stone_node: cobblemon:dawn_stone
dusk_stone_node: cobblemon:dusk_stone
zone_chapter: zone_chapter:
- id: arrive - id: arrive
title: "{zone}" title: "{zone}"
+4
View File
@@ -12,6 +12,7 @@ val minecraftVersion = property("minecraft_version") as String
val neoforgeVersion = property("neoforge_version") as String val neoforgeVersion = property("neoforge_version") as String
val cobblemonVersion = property("cobblemon_version") as String val cobblemonVersion = property("cobblemon_version") as String
val kotlinforforgeVersion = property("kotlinforforge_version") as String val kotlinforforgeVersion = property("kotlinforforge_version") as String
val jeiVersion = property("jei_version") as String
group = "dev.cobblesurvival" group = "dev.cobblesurvival"
version = modVersion version = modVersion
@@ -32,6 +33,8 @@ repositories {
maven("https://thedarkcolour.github.io/KotlinForForge/") maven("https://thedarkcolour.github.io/KotlinForForge/")
// Cobblemon's coordinates come from its official MDK, not guessed. // Cobblemon's coordinates come from its official MDK, not guessed.
maven("https://artefacts.cobblemon.com/releases/") maven("https://artefacts.cobblemon.com/releases/")
// JEI's own Maven, as its README gives it (Phase 8: the pack's drop sources as JEI pages).
maven("https://maven.blamejared.com/")
} }
dependencies { dependencies {
@@ -41,6 +44,7 @@ dependencies {
modCompileOnly("com.cobblemon:mod:$cobblemonVersion") { isTransitive = false } modCompileOnly("com.cobblemon:mod:$cobblemonVersion") { isTransitive = false }
modImplementation("com.cobblemon:neoforge:$cobblemonVersion") modImplementation("com.cobblemon:neoforge:$cobblemonVersion")
modCompileOnly("mezz.jei:jei-$minecraftVersion-neoforge-api:$jeiVersion")
implementation("thedarkcolour:kotlinforforge-neoforge:$kotlinforforgeVersion") { implementation("thedarkcolour:kotlinforforge-neoforge:$kotlinforforgeVersion") {
exclude("net.neoforged.fancymodloader", "loader") exclude("net.neoforged.fancymodloader", "loader")
} }
+1
View File
@@ -9,3 +9,4 @@ minecraft_version=1.21.1
neoforge_version=21.1.265 neoforge_version=21.1.265
cobblemon_version=1.8.4+1.21.1 cobblemon_version=1.8.4+1.21.1
kotlinforforge_version=5.12.0 kotlinforforge_version=5.12.0
jei_version=19.64.0.455
@@ -0,0 +1,61 @@
package dev.cobblesurvival.jei
import com.google.gson.Gson
import net.minecraft.core.registries.BuiltInRegistries
import net.minecraft.resources.ResourceLocation
import net.minecraft.world.item.ItemStack
import net.minecraft.world.item.Items
/**
* Where items come from on the island (roadmap Phase 8), for the pack's JEI pages ([DropSourcesJei]):
* `assets/cobblesurvival/jei/drop_sources.json`, written by tools/jei_sources.py from the pack's data. It ships inside
* the mod because JEI runs on the client, which on a dedicated server has none of the server's data; the self-test
* `drop_sources_match_the_server` checks it against the live server instead. Fields as tools/jei_sources.py writes them.
*/
object DropSources {
class Drop(val item: String, val count: String?, val chance: Double?)
class Reward(val item: String, val count: Int)
class Spawned(val zone: String, val levels: String)
class LineMember(val zone: String, val species: String)
class Pokemon(val species: String, val drops: List<Drop>, val zones: List<Spawned>, val line: List<LineMember>?)
class SpawnDrops(val species: String, val zone: String, val drops: List<Drop>)
class Guardian(val zone: String, val species: String, val level: Int, val drops: List<Reward>, val respawn_hours: Double)
class EndBoss(val species: String, val level: Int, val xp_first_win: Int, val xp: Int, val respawn_hours: Double, val first_win: List<Reward>)
class AltarTier(val min_level: Int, val rewards: List<Reward>)
class AltarSpecial(val min_level: Int, val types: List<String>, val rewards: List<Reward>)
class Altar(val tiers: List<AltarTier>, val specials: List<AltarSpecial>)
class VeinItem(val item: String, val regen_minutes: Int)
class Veins(val zone: String, val items: List<VeinItem>)
class Find(val item: String, val tiers: List<String>)
class Finds(val skill: String, val zone: String?, val items: List<Find>)
class Zone(val name: String, val levels: List<Int>)
class Data(
val zones: Map<String, Zone>,
val pokemon: List<Pokemon>,
val spawn_drops: List<SpawnDrops>,
val guardians: List<Guardian>,
val end_boss: EndBoss,
val altar: Altar,
val rebirth: List<Reward>,
val ore_veins: List<Veins>,
val pasture_finds: List<Finds>,
)
const val PATH = "/assets/cobblesurvival/jei/drop_sources.json"
val data: Data by lazy {
val stream = DropSources::class.java.getResourceAsStream(PATH) ?: error("$PATH is missing from the mod")
stream.reader(Charsets.UTF_8).use { Gson().fromJson(it, Data::class.java) }
}
fun zoneName(zone: String?): String = zone?.let { data.zones[it]?.name ?: it } ?: "anywhere"
fun stack(item: String, count: Int = 1): ItemStack {
val found = BuiltInRegistries.ITEM.getOptional(ResourceLocation.parse(item)).orElse(Items.BARRIER)
return ItemStack(found, count.coerceIn(1, 99))
}
/** The most a drop gives: "0-2" → 2. */
fun most(count: String?): Int = count?.substringAfter('-')?.toIntOrNull() ?: 1
}
@@ -0,0 +1,203 @@
package dev.cobblesurvival.jei
import com.cobblemon.mod.common.api.pokemon.PokemonProperties
import com.cobblemon.mod.common.api.pokemon.PokemonSpecies
import com.cobblemon.mod.common.item.PokemonItem
import dev.cobblesurvival.CobbleSurvival
import mezz.jei.api.IModPlugin
import mezz.jei.api.JeiPlugin
import mezz.jei.api.gui.builder.IRecipeLayoutBuilder
import mezz.jei.api.gui.builder.ITooltipBuilder
import mezz.jei.api.gui.drawable.IDrawable
import mezz.jei.api.gui.ingredient.IRecipeSlotsView
import mezz.jei.api.recipe.IFocusGroup
import mezz.jei.api.recipe.RecipeIngredientRole
import mezz.jei.api.recipe.RecipeType
import mezz.jei.api.recipe.category.IRecipeCategory
import mezz.jei.api.registration.IRecipeCatalystRegistration
import mezz.jei.api.registration.IRecipeCategoryRegistration
import mezz.jei.api.registration.IRecipeRegistration
import net.minecraft.ChatFormatting
import net.minecraft.client.Minecraft
import net.minecraft.client.gui.GuiGraphics
import net.minecraft.network.chat.Component
import net.minecraft.resources.ResourceLocation
import net.minecraft.world.item.ItemStack
/**
* The pack's own JEI pages (roadmap Phase 8; Adrian, 2026-11-02: every item that can drop gets a page naming where it
* drops). Look an item up (its recipes) to see each place on the island it comes from: the Pokémon that drop it and
* the zones they live in (the Cobblemon Info add-on shows drops and biomes, not zones), guardians and the End boss,
* the Sacrifice Altar and the Rebirth Chamber, zone ore veins, and what pastured Pokémon find. Built from
* [DropSources]; JEI finds this class by its annotation, on the client only.
*/
@JeiPlugin
class DropSourcesJei : IModPlugin {
/** One page: a picture (a Pokémon or a block), up to three lines of text, and the items it gives. */
class Page(val icon: ItemStack?, val lines: List<Component>, val outputs: List<Output>)
class Output(val stack: ItemStack, val notes: List<Component>)
private class Kind(val path: String, val title: String, val icon: String, val catalysts: List<String>, val pages: () -> List<Page>) {
val type: RecipeType<Page> = RecipeType.create(CobbleSurvival.MOD_ID, path, Page::class.java)
val built by lazy(pages)
}
private val kinds = listOf(
Kind("pokemon_drops", "Pokémon drops on the island", "cobblemon:poke_ball", listOf("cobblesurvival:synthesis_core"), ::pokemonPages),
Kind("boss_rewards", "Guardians and the End boss", "minecraft:netherite_sword", emptyList(), ::bossPages),
Kind("altar", "Sacrifice Altar", "cobblesurvival:altar_core", listOf("cobblesurvival:altar_core"), ::altarPages),
Kind("rebirth", "Rebirth Chamber", "cobblesurvival:rebirth_core", listOf("cobblesurvival:rebirth_core"), ::rebirthPages),
Kind("ore_veins", "Zone ore veins", "minecraft:iron_pickaxe", emptyList(), ::veinPages),
Kind("pasture_finds", "Pasture finds", "cobblemon:pasture", listOf("cobblemon:pasture"), ::findPages),
)
override fun getPluginUid(): ResourceLocation = ResourceLocation.fromNamespaceAndPath(CobbleSurvival.MOD_ID, "drop_sources")
override fun registerCategories(registration: IRecipeCategoryRegistration) {
val gui = registration.jeiHelpers.guiHelper
registration.addRecipeCategories(*kinds.map { kind ->
val rows = kind.built.maxOfOrNull { (it.outputs.size + PER_ROW - 1) / PER_ROW }?.coerceAtLeast(1) ?: 1
PageCategory(kind.type, Component.literal(kind.title), gui.createDrawableItemStack(DropSources.stack(kind.icon)), rows)
}.toTypedArray())
}
override fun registerRecipes(registration: IRecipeRegistration) {
kinds.forEach { registration.addRecipes(it.type, it.built) }
}
override fun registerRecipeCatalysts(registration: IRecipeCatalystRegistration) {
kinds.forEach { kind -> kind.catalysts.forEach { registration.addRecipeCatalyst(DropSources.stack(it), kind.type) } }
}
class PageCategory(private val type: RecipeType<Page>, private val title: Component, private val icon: IDrawable, private val rows: Int) : IRecipeCategory<Page> {
override fun getRecipeType() = type
override fun getTitle() = title
override fun getIcon() = icon
override fun getWidth() = WIDTH
override fun getHeight() = TEXT_HEIGHT + rows * SLOT
override fun setRecipe(builder: IRecipeLayoutBuilder, page: Page, focuses: IFocusGroup) {
page.icon?.let { builder.addSlot(RecipeIngredientRole.RENDER_ONLY, 1, 1).addItemStack(it) }
page.outputs.forEachIndexed { i, out ->
builder.addSlot(RecipeIngredientRole.OUTPUT, 1 + (i % PER_ROW) * SLOT, TEXT_HEIGHT + 1 + (i / PER_ROW) * SLOT)
.setStandardSlotBackground()
.addItemStack(out.stack)
.addRichTooltipCallback { _, tooltip -> out.notes.forEach(tooltip::add) }
}
}
private fun textLeft(page: Page) = if (page.icon != null) SLOT + 4 else 0
override fun draw(page: Page, view: IRecipeSlotsView, graphics: GuiGraphics, mouseX: Double, mouseY: Double) {
val font = Minecraft.getInstance().font
val left = textLeft(page)
page.lines.forEachIndexed { i, line ->
font.split(line, WIDTH - left).firstOrNull()?.let { graphics.drawString(font, it, left, 1 + i * LINE, TEXT_COLOR, false) }
}
}
/** Lines too long for the page show in full when hovered. */
override fun getTooltip(tooltip: ITooltipBuilder, page: Page, view: IRecipeSlotsView, mouseX: Double, mouseY: Double) {
if (mouseX >= textLeft(page) && mouseY < TEXT_HEIGHT - 2) page.lines.forEach(tooltip::add)
}
}
companion object {
private const val WIDTH = 162
private const val SLOT = 18
private const val PER_ROW = 9
private const val LINE = 10
private const val TEXT_HEIGHT = 32
private const val TEXT_COLOR = 0x404040
private fun gray(text: String): Component = Component.literal(text).withStyle(ChatFormatting.GRAY)
private fun speciesName(id: String): Component =
PokemonSpecies.getByIdentifier(ResourceLocation.parse(id))?.translatedName ?: Component.literal(id.substringAfter(':'))
private fun picture(id: String): ItemStack =
runCatching { PokemonItem.from(PokemonProperties.parse(id.substringAfter(':'))) }.getOrNull()?.takeIf { !it.isEmpty }
?: DropSources.stack("cobblemon:poke_ball")
private fun dropOutput(drop: DropSources.Drop): Output {
val notes = listOfNotNull(
drop.chance?.let { gray("%s%% chance".format(if (it % 1.0 == 0.0) it.toInt() else it)) } ?: gray("always"),
drop.count?.takeIf { it != "1" }?.let { gray("×${it.replace("-", "–")}") },
)
return Output(DropSources.stack(drop.item, DropSources.most(drop.count)), notes)
}
private fun rewardOutputs(rewards: List<DropSources.Reward>, note: Component? = null) =
rewards.map { Output(DropSources.stack(it.item, it.count), listOfNotNull(note)) }
private fun levels(zone: String, levels: String) = "${DropSources.zoneName(zone)} (Lv ${levels.replace("-", "–")})"
fun pokemonPages(): List<Page> {
val data = DropSources.data
val wild = data.pokemon.map { p ->
val where = if (p.zones.isNotEmpty()) {
"Lives in " + p.zones.joinToString(", ") { levels(it.zone, it.levels) }
} else {
"Not wild; its line lives in " + p.line.orEmpty().joinToString(", ") {
"${DropSources.zoneName(it.zone)} (${speciesName(it.species).string})"
}
}
Page(picture(p.species), listOf(speciesName(p.species).copy().withStyle(ChatFormatting.BOLD), Component.literal(where),
gray("The Drop Synthesizer makes the same drops")), p.drops.map(::dropOutput))
}
val special = data.spawn_drops.map { s ->
Page(picture(s.species), listOf(speciesName(s.species).copy().withStyle(ChatFormatting.BOLD),
Component.literal("In ${DropSources.zoneName(s.zone)}: drops these instead")), s.drops.map(::dropOutput))
}
return wild + special
}
fun bossPages(): List<Page> {
val data = DropSources.data
val guardians = data.guardians.map { g ->
Page(picture(g.species), listOf(
speciesName(g.species).copy().append(", Lv ${g.level}").withStyle(ChatFormatting.BOLD),
Component.literal("Guardian of ${DropSources.zoneName(g.zone)}"),
gray("Back %s hours after a win".format(g.respawn_hours.let { if (it % 1.0 == 0.0) it.toInt() else it })),
), rewardOutputs(g.drops, gray("for everyone in the winning battle")))
}
val end = data.end_boss
val boss = Page(picture(end.species), listOf(
speciesName(end.species).copy().append(", Lv ${end.level}").withStyle(ChatFormatting.BOLD),
Component.literal("The End's boss"),
gray("${end.xp_first_win} XP the first time, ${end.xp} after"),
), rewardOutputs(end.first_win, gray("first win only")))
return guardians + boss
}
fun altarPages(): List<Page> {
val altar = DropSources.data.altar
return altar.tiers.map { t ->
Page(null, listOf(Component.literal("Sacrificing a Pokémon of Lv ${t.min_level}+").withStyle(ChatFormatting.BOLD),
gray("The highest tier it reaches decides")), rewardOutputs(t.rewards))
} + altar.specials.map { s ->
Page(null, listOf(Component.literal("A ${s.types.joinToString("/") { it.replaceFirstChar(Char::uppercase) }} type of Lv ${s.min_level}+").withStyle(ChatFormatting.BOLD),
gray("also gives")), rewardOutputs(s.rewards))
}
}
fun rebirthPages(): List<Page> = listOf(
Page(null, listOf(Component.literal("A Lv 100 Pokémon reborn").withStyle(ChatFormatting.BOLD),
gray("as a shiny Lv 1 of its first stage,"), gray("comes back with")), rewardOutputs(DropSources.data.rebirth)),
)
fun veinPages(): List<Page> = DropSources.data.ore_veins.map { v ->
Page(null, listOf(Component.literal("Ore veins of ${DropSources.zoneName(v.zone)}").withStyle(ChatFormatting.BOLD),
gray("Only there; they grow back after mining")),
v.items.map { Output(DropSources.stack(it.item), listOf(gray("grows back after ${it.regen_minutes} min"))) })
}
fun findPages(): List<Page> = DropSources.data.pasture_finds.map { f ->
val skill = f.skill.split('_').joinToString(" ") { it.replaceFirstChar(Char::uppercase) }
Page(null, listOf(Component.literal(skill).withStyle(ChatFormatting.BOLD),
Component.literal(if (f.zone == null) "In any zone" else "Only in ${DropSources.zoneName(f.zone)}"),
gray("Cobblebase skill of a pastured Pokémon")),
f.items.map { Output(DropSources.stack(it.item), listOf(gray(it.tiers.joinToString(", ")))) })
}
}
}
@@ -1,14 +1,24 @@
package dev.cobblesurvival.selftest package dev.cobblesurvival.selftest
import com.cobblemon.mod.common.api.drop.DropTable
import com.cobblemon.mod.common.api.drop.ItemDropEntry
import com.cobblemon.mod.common.api.pokemon.PokemonSpecies
import com.cobblemon.mod.common.api.spawning.CobblemonSpawnPools import com.cobblemon.mod.common.api.spawning.CobblemonSpawnPools
import com.cobblemon.mod.common.api.spawning.detail.PokemonSpawnDetail import com.cobblemon.mod.common.api.spawning.detail.PokemonSpawnDetail
import dev.cobblesurvival.boss.BossDefs
import dev.cobblesurvival.jei.DropSources
import dev.cobblesurvival.jei.DropSourcesJei
import dev.cobblesurvival.machine.MachineDefs
import dev.cobblesurvival.progress.QuestEvents import dev.cobblesurvival.progress.QuestEvents
import dev.cobblesurvival.zone.Guardians
import dev.cobblesurvival.zone.Zones import dev.cobblesurvival.zone.Zones
import java.util.function.Consumer
import net.minecraft.core.registries.BuiltInRegistries
import net.minecraft.gametest.framework.GameTestAssertException import net.minecraft.gametest.framework.GameTestAssertException
import net.minecraft.resources.ResourceLocation import net.minecraft.resources.ResourceLocation
import net.minecraft.world.item.ItemStack import net.minecraft.world.item.ItemStack
import net.minecraft.world.item.Items
import net.minecraft.world.level.levelgen.Heightmap import net.minecraft.world.level.levelgen.Heightmap
import java.util.function.Consumer
/** /**
* Roadmap Phase 8 (Adrian, 2026-11-03): the add-ons the quest book and JEI lean on work with the pack. Read by * Roadmap Phase 8 (Adrian, 2026-11-03): the add-ons the quest book and JEI lean on work with the pack. Read by
@@ -20,8 +30,73 @@ object QuestTests {
SelfTestCase("jei_addon_shows_island_spawns", 20, ::addonSpawns), SelfTestCase("jei_addon_shows_island_spawns", 20, ::addonSpawns),
SelfTestCase("quest_book_loads", 20, ::bookLoads), SelfTestCase("quest_book_loads", 20, ::bookLoads),
SelfTestCase("quest_events_mark_zones", 200, ::zoneEvent), SelfTestCase("quest_events_mark_zones", 200, ::zoneEvent),
SelfTestCase("drop_sources_match_the_server", 20, ::dropSources),
SelfTestCase("jei_pages_build", 20, ::jeiPages),
) )
/**
* Every JEI page builds (the same code JEI calls on the client), one per Pokémon, guardian, altar tier, vein zone
* and pasture list, and every item on them exists (an unknown id would show as a barrier).
*/
private fun jeiPages(s: Scenario) {
val data = DropSources.data
val pages = mapOf(
"pokemon" to DropSourcesJei.pokemonPages(), "bosses" to DropSourcesJei.bossPages(), "altar" to DropSourcesJei.altarPages(),
"rebirth" to DropSourcesJei.rebirthPages(), "veins" to DropSourcesJei.veinPages(), "finds" to DropSourcesJei.findPages(),
)
s.check(pages.getValue("pokemon").size == data.pokemon.size + data.spawn_drops.size) { "${pages.getValue("pokemon").size} Pokémon pages for ${data.pokemon.size} Pokémon" }
s.check(pages.getValue("bosses").size == data.guardians.size + 1) { "${pages.getValue("bosses").size} boss pages" }
val unknown = pages.values.flatten().flatMap { it.outputs }.map { it.stack }.filter { it.`is`(Items.BARRIER) || it.isEmpty }
s.check(unknown.isEmpty()) { "${unknown.size} items on the JEI pages don't exist" }
val noPicture = pages.getValue("pokemon").count { it.icon?.`is`(Items.BARRIER) != false }
s.check(noPicture == 0) { "$noPicture Pokémon pages without a picture" }
s.helper.succeed()
}
/** A drop as "item chance% ×count", the same way from the JEI data and from Cobblemon's live tables. */
private fun dropKey(item: String, chance: Double?, count: String?) = "$item ${"%.2f".format(chance ?: 100.0)}% ×${count ?: "1"}"
private fun liveDrops(table: DropTable?): Set<String> = table?.entries.orEmpty().filterIsInstance<ItemDropEntry>().map { e ->
dropKey(e.item.toString(), e.percentage.toDouble().takeIf { it < 100.0 }, e.quantityRange?.let { if (it.first == it.last) "${it.first}" else "${it.first}-${it.last}" } ?: e.quantity.toString())
}.toSet()
/**
* The JEI pages' data (assets/cobblesurvival/jei/drop_sources.json, tools/jei_sources.py) still says what the
* server does: every Pokémon's drops and zones, the spawns with drops of their own, the guardians, the End boss
* and the Sacrifice Altar. When this fails, run tools/jei_sources.py and rebuild.
*/
private fun dropSources(s: Scenario) {
val data = DropSources.data
val problems = mutableListOf<String>()
val details = CobblemonSpawnPools.WORLD_SPAWN_POOL.details.filterIsInstance<PokemonSpawnDetail>()
val zonesOf = details.groupBy({ "cobblemon:${it.pokemon.species?.lowercase()}" }, { it.id.substringBefore('-') }).mapValues { it.value.toSet() }
for (p in data.pokemon) {
val species = PokemonSpecies.getByIdentifier(ResourceLocation.parse(p.species)) ?: continue.also { problems += "no species ${p.species}" }
val shown = p.drops.map { dropKey(it.item, it.chance, it.count) }.toSet()
if (shown != liveDrops(species.drops)) problems += "${p.species} drops ${liveDrops(species.drops)}, the JEI data says $shown"
if (p.zones.map { it.zone }.toSet() != zonesOf[p.species].orEmpty()) problems += "${p.species} spawns in ${zonesOf[p.species]}, the JEI data says ${p.zones.map { it.zone }}"
}
val ownDrops = details.filter { it.drops != null }.map { "cobblemon:${it.pokemon.species?.lowercase()} in ${it.id.substringBefore('-')}" }.toSet()
val shownOwn = data.spawn_drops.map { "${it.species} in ${it.zone}" }.toSet()
if (ownDrops != shownOwn) problems += "spawns with their own drops: $ownDrops, the JEI data says $shownOwn"
for (g in data.guardians) {
val def = Guardians.definitions[g.zone] ?: continue.also { problems += "no guardian for ${g.zone}" }
val live = "${def.pokemon.substringBefore(' ')} ${Regex("level=(\\d+)").find(def.pokemon)?.groupValues?.get(1)} ${def.drops.map { "${it.first} ×${it.second}" }}"
val shown = "${g.species.substringAfter(':')} ${g.level} ${g.drops.map { "${it.item} ×${it.count}" }}"
if (live != shown) problems += "guardian of ${g.zone}: $live, the JEI data says $shown"
}
if (data.guardians.size != Guardians.definitions.size) problems += "${Guardians.definitions.size} guardians, the JEI data has ${data.guardians.size}"
BossDefs.end?.end?.let { end ->
if ("cobblemon:${end.species}" != data.end_boss.species || end.level != data.end_boss.level) problems += "the End boss is ${end.species} Lv ${end.level}"
}
val altar = MachineDefs.byCore.values.firstNotNullOfOrNull { it.altar }
val liveTiers = altar?.tiers.orEmpty().map { t -> "${t.minLevel}: ${t.rewards.map { "${BuiltInRegistries.ITEM.getKey(it.item)} ×${it.count}" }}" }
val shownTiers = data.altar.tiers.map { t -> "${t.min_level}: ${t.rewards.map { "${it.item} ×${it.count}" }}" }
if (liveTiers != shownTiers) problems += "altar tiers $liveTiers, the JEI data says $shownTiers"
s.check(problems.isEmpty()) { "${problems.size} differences (run tools/jei_sources.py): ${problems.take(4).joinToString("; ")}" }
s.helper.succeed()
}
/** At least this many quests in the book (docs/quests.yaml: 74 on 2026-11-03). */ /** At least this many quests in the book (docs/quests.yaml: 74 on 2026-11-03). */
private const val MIN_QUESTS = 70 private const val MIN_QUESTS = 70
File diff suppressed because it is too large. Load diff
+174
View File
@@ -0,0 +1,174 @@
#!/usr/bin/env python3
"""Write the data behind the pack's own JEI pages (roadmap Phase 8; Adrian, 2026-11-02: every item that can drop gets
a JEI page naming its sources; 2026-11-03: alongside the Cobblemon Info add-on, which shows spawns and drops but not
the island's zones).
JEI runs on the client, which on a dedicated server doesn't have the pack's server data, so the pages are built from
this file inside the companion mod: mod/src/main/resources/assets/cobblesurvival/jei/drop_sources.json. It holds:
pokemon every species of a line that lives on the island: its drops (as the Drop Synthesizer rolls them)
and the zones it spawns in, or the zones where its line lives
spawn_drops zone spawns that drop something else than their species (e.g. Beheeyem in the End)
guardians each zone guardian: species, level, drops, return time
end_boss the End boss and what beating it gives
altar the Sacrifice Altar's level tiers and type specials; rebirth: the Rebirth Chamber's extra
ore_veins per zone, what its regenerating veins give and how fast they regrow
pasture_finds per Cobblebase skill and zone, what a pasture's Pokémon can find (the pack's zone-bound tables)
The self-test drop_sources_match_the_server compares it with the live server, so run this after changing species
drops, spawns, guardians, the End boss, the altar, ore veins or Cobblebase's tables, then rebuild the mod.
Needs the Cobblemon jar in build/server/mods (any test-server run).
Usage: tools/jei_sources.py
"""
import json
import re
import zipfile
from collections import defaultdict
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
DATA = ROOT / "datapacks/core/data"
OUT = ROOT / "mod/src/main/resources/assets/cobblesurvival/jei/drop_sources.json"
SKILLS = {"mining": "mining", "finder_ore": "ore_finder", "finder_evo": "evolution_item_finder"}
def drop_entries(drops):
"""Cobblemon drop table → [{item, chance (percent, absent = always), count ("a-b")}]."""
out = []
for e in (drops or {}).get("entries", []):
if "item" not in e:
continue
entry = {"item": e["item"], "count": str(e.get("quantityRange", e.get("quantity", 1)))}
if "percentage" in e and float(e["percentage"]) < 100:
entry["chance"] = float(e["percentage"])
out.append(entry)
return out
def species_data(jar):
species = {}
with zipfile.ZipFile(jar) as z:
for name in z.namelist():
m = re.fullmatch(r"data/cobblemon/species/[^/]+/([^/]+)\.json", name)
if not m:
continue
d = json.loads(z.read(name))
pre = d.get("preEvolution")
species[f"cobblemon:{m[1]}"] = {
"name": d["name"], "implemented": d.get("implemented", False), "drops": d.get("drops"),
"pre": f"cobblemon:{pre.split()[0].lower()}" if pre else None,
}
# The pack's species additions replace a species' drops (datapacks/core/data/cobblesurvival/species_additions).
for path in sorted((DATA / "cobblesurvival/species_additions").glob("*.json")):
d = json.loads(path.read_text())
if "drops" in d and d["target"] in species:
species[d["target"]]["drops"] = d["drops"]
return species
def root_of(sid, species):
seen = set()
while species.get(sid, {}).get("pre") and sid not in seen:
seen.add(sid)
sid = species[sid]["pre"]
return sid
def main():
jar = next((ROOT / "build/server/mods").glob("Cobblemon-*.jar"))
species = species_data(jar)
plan = json.loads((ROOT / "world/zones.json").read_text())
zones = {z["id"]: z for z in plan["zones"] + plan["caves"] + plan.get("dimensions", [])}
# Where each species spawns, and spawns with drops of their own.
where = defaultdict(dict)
spawn_drops = []
for zid in zones:
pool = DATA / f"cobblesurvival/spawn_pool_world/{zid}.json"
if not pool.exists():
continue
for spawn in json.loads(pool.read_text())["spawns"]:
sid = f"cobblemon:{spawn['pokemon'].split()[0].lower()}"
a, _, b = str(spawn["level"]).partition("-")
low, high = where[sid].get(zid, (999, 0))
where[sid][zid] = (min(low, int(a)), max(high, int(b or a)))
if "drops" in spawn:
entry = {"species": sid, "zone": zid, "drops": drop_entries(spawn["drops"])}
if entry not in spawn_drops:
spawn_drops.append(entry)
line_zones = defaultdict(dict)
for sid, zs in where.items():
for zid, levels in zs.items():
line_zones[root_of(sid, species)].setdefault(zid, sid)
pokemon = []
for sid, s in sorted(species.items()):
drops = drop_entries(s["drops"])
root = root_of(sid, species)
if not drops or not s["implemented"] or not (where.get(sid) or line_zones.get(root)):
continue
entry = {"species": sid, "drops": drops,
"zones": [{"zone": z, "levels": f"{lo}-{hi}"} for z, (lo, hi) in sorted(where.get(sid, {}).items())]}
if not entry["zones"]:
entry["line"] = [{"zone": z, "species": member} for z, member in sorted(line_zones[root].items())]
pokemon.append(entry)
guardians = []
for path in sorted((DATA / "cobblesurvival/guardians").glob("*.json")):
g = json.loads(path.read_text())
name, *props = g["pokemon"].split()
guardians.append({"zone": g["zone"], "species": f"cobblemon:{name}",
"level": int(next(p.split("=")[1] for p in props if p.startswith("level="))),
"drops": g["drops"], "respawn_hours": g["respawn_hours"]})
end = json.loads((DATA / "cobblesurvival/bosses/end.json").read_text())["end"]
end_boss = {"species": f"cobblemon:{end['species']}", "level": end["level"], "xp_first_win": end["xp_first_win"],
"xp": end["xp"], "respawn_hours": end["respawn_hours"],
"first_win": [{"item": "minecraft:dragon_egg", "count": 1}]}
machines = DATA / "cobblesurvival/machines"
altar = json.loads((machines / "altar_core.json").read_text())["altar"]
rebirth = json.loads((machines / "rebirth_core.json").read_text())["rebirth"]
ore_veins = defaultdict(list)
for ore, spec in plan["ores"].items():
ore_veins[spec["zone"]].append({"item": spec["item"], "regen_minutes": spec["regen_minutes"]})
finds = defaultdict(lambda: defaultdict(set))
for path in sorted((DATA / "cobblebase/loot_table").glob("*.json")):
prefix, _, tier = path.stem.rpartition("_")
skill = SKILLS.get(prefix)
if not skill:
continue
for pool in json.loads(path.read_text()).get("pools", []):
for e in pool.get("entries", []):
if e.get("type") != "minecraft:item":
continue
zone_conditions = [c for c in e.get("conditions", []) if c.get("condition") == "cobblesurvival:in_zone"]
for zid in (zone_conditions[0]["zones"] if zone_conditions else ["*"]):
finds[(skill, zid)][e["name"]].add(tier)
pasture_finds = [{"skill": skill, "zone": None if zid == "*" else zid,
"items": [{"item": item, "tiers": sorted(tiers)} for item, tiers in sorted(items.items())]}
for (skill, zid), items in sorted(finds.items(), key=lambda kv: (kv[0][0], kv[0][1] != "*", kv[0][1]))]
data = {
"_comment": "Generated by tools/jei_sources.py from the pack's data; do not edit by hand. Read by the companion "
"mod's JEI plugin (jei/DropSourcesJei.kt); checked against the live server by the self-test "
"drop_sources_match_the_server.",
"zones": {zid: {"name": z["name"], "levels": z["levels"]} for zid, z in zones.items()},
"pokemon": pokemon, "spawn_drops": spawn_drops, "guardians": guardians, "end_boss": end_boss,
"altar": {"tiers": altar["tiers"], "specials": altar.get("specials", [])}, "rebirth": rebirth.get("extra", []),
"ore_veins": [{"zone": z, "items": items} for z, items in sorted(ore_veins.items())],
"pasture_finds": pasture_finds,
}
OUT.parent.mkdir(parents=True, exist_ok=True)
OUT.write_text(json.dumps(data, indent=1, ensure_ascii=False) + "\n")
print(f"{len(pokemon)} Pokémon, {len(spawn_drops)} spawns with own drops, {len(guardians)} guardians, "
f"{len(ore_veins)} zones with veins, {len(pasture_finds)} pasture find lists; wrote {OUT.relative_to(ROOT)}")
if __name__ == "__main__":
main()
+3 -2
View File
@@ -161,7 +161,8 @@ def zone_data():
pool = DATA / f"spawn_pool_world/{zid}.json" pool = DATA / f"spawn_pool_world/{zid}.json"
spawns = json.loads(pool.read_text())["spawns"] if pool.exists() else [] spawns = json.loads(pool.read_text())["spawns"] if pool.exists() else []
z["species"] = sorted({f"cobblemon:{s['pokemon'].split()[0].lower()}" for s in spawns}) z["species"] = sorted({f"cobblemon:{s['pokemon'].split()[0].lower()}" for s in spawns})
z["veins"] = sorted({n["vein"] for n in nodes if n["zone"] == zid}) # What its ore veins give when mined (world/zones.json → ores → item; veins are named <ore>_node).
z["ore_items"] = [plan["ores"][v.removesuffix("_node")]["item"] for v in sorted({n["vein"] for n in nodes if n["zone"] == zid})]
guardian = DATA / f"guardians/{zid}.json" guardian = DATA / f"guardians/{zid}.json"
z["guardian_data"] = json.loads(guardian.read_text()) if guardian.exists() else None z["guardian_data"] = json.loads(guardian.read_text()) if guardian.exists() else None
return zones return zones
@@ -194,7 +195,7 @@ def title_case(item):
def zone_fields(zone, chapter, book): def zone_fields(zone, chapter, book):
g = zone["guardian_data"] g = zone["guardian_data"]
ores = [book["ore_items"][v] for v in zone["veins"] if v in book["ore_items"]] ores = zone["ore_items"]
return { return {
"zone": zone["name"], "zone_id": zone["id"], "low": zone["levels"][0], "high": zone["levels"][1], "zone": zone["name"], "zone_id": zone["id"], "low": zone["levels"][0], "high": zone["levels"][1],
"hazard_text": chapter.get("hazard_text", ""), "hazard_text": chapter.get("hazard_text", ""),
+21 -21
View File
@@ -7,27 +7,27 @@
"wanderers": {"grace_levels": 10}, "wanderers": {"grace_levels": 10},
"ore_defaults": {"fill": 0.7, "node_fill": 0.8, "attempts": 1, "jitter": 0.1}, "ore_defaults": {"fill": 0.7, "node_fill": 0.8, "attempts": 1, "jitter": 0.1},
"ores": { "ores": {
"coal": {"zone": "button_isle", "stone": "minecraft:coal_ore", "deepslate": "minecraft:deepslate_coal_ore", "regen_minutes": 20, "nodes": {"chance": 0.5, "radius": [0, 1]}, "veins": {"radius": [2, 4], "y": [-32, 100], "spacing": 6, "attempts": 2}}, "coal": {"zone": "button_isle", "item": "minecraft:coal", "stone": "minecraft:coal_ore", "deepslate": "minecraft:deepslate_coal_ore", "regen_minutes": 20, "nodes": {"chance": 0.5, "radius": [0, 1]}, "veins": {"radius": [2, 4], "y": [-32, 100], "spacing": 6, "attempts": 2}},
"copper": {"zone": "southwold", "stone": "minecraft:copper_ore", "deepslate": "minecraft:deepslate_copper_ore", "regen_minutes": 30, "nodes": {"chance": 0.2, "radius": [0, 1]}, "veins": {"radius": [2, 4], "y": [-32, 100], "spacing": 8, "attempts": 2}}, "copper": {"zone": "southwold", "item": "minecraft:raw_copper", "stone": "minecraft:copper_ore", "deepslate": "minecraft:deepslate_copper_ore", "regen_minutes": 30, "nodes": {"chance": 0.2, "radius": [0, 1]}, "veins": {"radius": [2, 4], "y": [-32, 100], "spacing": 8, "attempts": 2}},
"iron": {"zone": "westmarch", "stone": "minecraft:iron_ore", "deepslate": "minecraft:deepslate_iron_ore", "regen_minutes": 40, "nodes": {"chance": 0.2, "radius": [0, 1]}, "veins": {"radius": [2, 4], "y": [-48, 90], "spacing": 8, "attempts": 2}}, "iron": {"zone": "westmarch", "item": "minecraft:raw_iron", "stone": "minecraft:iron_ore", "deepslate": "minecraft:deepslate_iron_ore", "regen_minutes": 40, "nodes": {"chance": 0.2, "radius": [0, 1]}, "veins": {"radius": [2, 4], "y": [-48, 90], "spacing": 8, "attempts": 2}},
"redstone": {"zone": "jungle", "stone": "minecraft:redstone_ore", "deepslate": "minecraft:deepslate_redstone_ore", "regen_minutes": 45, "nodes": {"chance": 0.2, "radius": [0, 1]}, "veins": {"radius": [2, 3], "y": [-56, 60], "spacing": 9, "attempts": 1}}, "redstone": {"zone": "jungle", "item": "minecraft:redstone", "stone": "minecraft:redstone_ore", "deepslate": "minecraft:deepslate_redstone_ore", "regen_minutes": 45, "nodes": {"chance": 0.2, "radius": [0, 1]}, "veins": {"radius": [2, 3], "y": [-56, 60], "spacing": 9, "attempts": 1}},
"lapis": {"zone": "highlands", "stone": "minecraft:lapis_ore", "deepslate": "minecraft:deepslate_lapis_ore", "regen_minutes": 45, "nodes": {"chance": 0.15, "radius": [0, 1]}, "veins": {"radius": [2, 3], "y": [-56, 80], "spacing": 9, "attempts": 1}}, "lapis": {"zone": "highlands", "item": "minecraft:lapis_lazuli", "stone": "minecraft:lapis_ore", "deepslate": "minecraft:deepslate_lapis_ore", "regen_minutes": 45, "nodes": {"chance": 0.15, "radius": [0, 1]}, "veins": {"radius": [2, 3], "y": [-56, 80], "spacing": 9, "attempts": 1}},
"gold": {"zone": "sunscorch", "stone": "minecraft:gold_ore", "deepslate": "minecraft:deepslate_gold_ore", "regen_minutes": 60, "nodes": {"chance": 0.3, "radius": [0, 1]}, "veins": {"radius": [2, 3], "y": [-48, 120], "spacing": 7, "attempts": 2}}, "gold": {"zone": "sunscorch", "item": "minecraft:raw_gold", "stone": "minecraft:gold_ore", "deepslate": "minecraft:deepslate_gold_ore", "regen_minutes": 60, "nodes": {"chance": 0.3, "radius": [0, 1]}, "veins": {"radius": [2, 3], "y": [-48, 120], "spacing": 7, "attempts": 2}},
"emerald": {"zone": "frostpeak", "stone": "minecraft:emerald_ore", "deepslate": "minecraft:deepslate_emerald_ore", "regen_minutes": 75, "nodes": {"chance": 0.15, "radius": [0, 1]}, "veins": {"radius": [1, 2], "y": [-16, 320], "spacing": 8, "attempts": 2}}, "emerald": {"zone": "frostpeak", "item": "minecraft:emerald", "stone": "minecraft:emerald_ore", "deepslate": "minecraft:deepslate_emerald_ore", "regen_minutes": 75, "nodes": {"chance": 0.15, "radius": [0, 1]}, "veins": {"radius": [1, 2], "y": [-16, 320], "spacing": 8, "attempts": 2}},
"amethyst": {"zone": "dripstone", "blocks": [["minecraft:amethyst_block", 3], ["minecraft:budding_amethyst", 1]], "regen_minutes": 120, "nodes": {"chance": 0.5, "tries": 8, "radius": [1, 1], "y": [0, 40]}}, "amethyst": {"zone": "dripstone", "item": "minecraft:amethyst_shard", "blocks": [["minecraft:amethyst_block", 3], ["minecraft:budding_amethyst", 1]], "regen_minutes": 120, "nodes": {"chance": 0.5, "tries": 8, "radius": [1, 1], "y": [0, 40]}},
"diamond": {"zone": "deep_dark", "deepslate": "minecraft:deepslate_diamond_ore", "regen_minutes": 90, "nodes": {"chance": 0.15, "tries": 8, "radius": [1, 1], "y": [-64, -24]}}, "diamond": {"zone": "deep_dark", "item": "minecraft:diamond", "deepslate": "minecraft:deepslate_diamond_ore", "regen_minutes": 90, "nodes": {"chance": 0.15, "tries": 8, "radius": [1, 1], "y": [-64, -24]}},
"quartz": {"zone": "basalt", "blocks": [["minecraft:nether_quartz_ore", 1]], "regen_minutes": 60, "nodes": {"chance": 0.5, "tries": 8, "radius": [1, 1], "y": [-64, 40]}}, "quartz": {"zone": "basalt", "item": "minecraft:quartz", "blocks": [["minecraft:nether_quartz_ore", 1]], "regen_minutes": 60, "nodes": {"chance": 0.5, "tries": 8, "radius": [1, 1], "y": [-64, 40]}},
"netherite": {"zone": "basalt", "blocks": [["minecraft:ancient_debris", 1]], "regen_minutes": 180, "nodes": {"chance": 0.06, "tries": 8, "radius": [0, 0], "y": [-64, 40]}}, "netherite": {"zone": "basalt", "item": "minecraft:ancient_debris", "blocks": [["minecraft:ancient_debris", 1]], "regen_minutes": 180, "nodes": {"chance": 0.06, "tries": 8, "radius": [0, 0], "y": [-64, 40]}},
"thunder_stone": {"zone": "westmarch", "stone": "cobblemon:thunder_stone_ore", "deepslate": "cobblemon:deepslate_thunder_stone_ore", "regen_minutes": 60, "nodes": {"chance": 0.03, "radius": [0, 0]}, "veins": {"radius": [1, 2], "y": [-48, 100], "spacing": 11, "attempts": 1}}, "thunder_stone": {"zone": "westmarch", "item": "cobblemon:thunder_stone", "stone": "cobblemon:thunder_stone_ore", "deepslate": "cobblemon:deepslate_thunder_stone_ore", "regen_minutes": 60, "nodes": {"chance": 0.03, "radius": [0, 0]}, "veins": {"radius": [1, 2], "y": [-48, 100], "spacing": 11, "attempts": 1}},
"shiny_stone": {"zone": "lush_caves", "stone": "cobblemon:shiny_stone_ore", "deepslate": "cobblemon:deepslate_shiny_stone_ore", "regen_minutes": 60, "nodes": {"chance": 0.15, "tries": 8, "radius": [0, 1], "y": [-16, 40]}}, "shiny_stone": {"zone": "lush_caves", "item": "cobblemon:shiny_stone", "stone": "cobblemon:shiny_stone_ore", "deepslate": "cobblemon:deepslate_shiny_stone_ore", "regen_minutes": 60, "nodes": {"chance": 0.15, "tries": 8, "radius": [0, 1], "y": [-16, 40]}},
"leaf_stone": {"zone": "jungle", "stone": "cobblemon:leaf_stone_ore", "deepslate": "cobblemon:deepslate_leaf_stone_ore", "regen_minutes": 60, "nodes": {"chance": 0.04, "radius": [0, 0]}, "veins": {"radius": [1, 2], "y": [-48, 100], "spacing": 11, "attempts": 1}}, "leaf_stone": {"zone": "jungle", "item": "cobblemon:leaf_stone", "stone": "cobblemon:leaf_stone_ore", "deepslate": "cobblemon:deepslate_leaf_stone_ore", "regen_minutes": 60, "nodes": {"chance": 0.04, "radius": [0, 0]}, "veins": {"radius": [1, 2], "y": [-48, 100], "spacing": 11, "attempts": 1}},
"water_stone": {"zone": "mire", "stone": "cobblemon:water_stone_ore", "deepslate": "cobblemon:deepslate_water_stone_ore", "regen_minutes": 60, "nodes": {"chance": 0.04, "radius": [0, 0]}, "veins": {"radius": [1, 2], "y": [-48, 100], "spacing": 11, "attempts": 1}}, "water_stone": {"zone": "mire", "item": "cobblemon:water_stone", "stone": "cobblemon:water_stone_ore", "deepslate": "cobblemon:deepslate_water_stone_ore", "regen_minutes": 60, "nodes": {"chance": 0.04, "radius": [0, 0]}, "veins": {"radius": [1, 2], "y": [-48, 100], "spacing": 11, "attempts": 1}},
"moon_stone": {"zone": "dripstone", "blocks": [["cobblemon:dripstone_moon_stone_ore", 1], ["cobblemon:moon_stone_ore", 1], ["cobblemon:deepslate_moon_stone_ore", 1]], "regen_minutes": 60, "nodes": {"chance": 0.25, "tries": 8, "radius": [0, 1], "y": [0, 40]}}, "moon_stone": {"zone": "dripstone", "item": "cobblemon:moon_stone", "blocks": [["cobblemon:dripstone_moon_stone_ore", 1], ["cobblemon:moon_stone_ore", 1], ["cobblemon:deepslate_moon_stone_ore", 1]], "regen_minutes": 60, "nodes": {"chance": 0.25, "tries": 8, "radius": [0, 1], "y": [0, 40]}},
"sun_stone": {"zone": "highlands", "stone": "cobblemon:sun_stone_ore", "deepslate": "cobblemon:deepslate_sun_stone_ore", "regen_minutes": 60, "nodes": {"chance": 0.02, "radius": [0, 0]}, "veins": {"radius": [1, 2], "y": [-48, 100], "spacing": 11, "attempts": 1}}, "sun_stone": {"zone": "highlands", "item": "cobblemon:sun_stone", "stone": "cobblemon:sun_stone_ore", "deepslate": "cobblemon:deepslate_sun_stone_ore", "regen_minutes": 60, "nodes": {"chance": 0.02, "radius": [0, 0]}, "veins": {"radius": [1, 2], "y": [-48, 100], "spacing": 11, "attempts": 1}},
"fire_stone": {"zone": "sunscorch", "stone": "cobblemon:fire_stone_ore", "deepslate": "cobblemon:deepslate_fire_stone_ore", "regen_minutes": 60, "nodes": {"chance": 0.08, "radius": [0, 0]}, "veins": {"radius": [1, 2], "y": [-48, 120], "spacing": 9, "attempts": 2}}, "fire_stone": {"zone": "sunscorch", "item": "cobblemon:fire_stone", "stone": "cobblemon:fire_stone_ore", "deepslate": "cobblemon:deepslate_fire_stone_ore", "regen_minutes": 60, "nodes": {"chance": 0.08, "radius": [0, 0]}, "veins": {"radius": [1, 2], "y": [-48, 120], "spacing": 9, "attempts": 2}},
"ice_stone": {"zone": "frostpeak", "stone": "cobblemon:ice_stone_ore", "deepslate": "cobblemon:deepslate_ice_stone_ore", "regen_minutes": 60, "nodes": {"chance": 0.06, "radius": [0, 0]}, "veins": {"radius": [1, 2], "y": [-16, 320], "spacing": 9, "attempts": 2}}, "ice_stone": {"zone": "frostpeak", "item": "cobblemon:ice_stone", "stone": "cobblemon:ice_stone_ore", "deepslate": "cobblemon:deepslate_ice_stone_ore", "regen_minutes": 60, "nodes": {"chance": 0.06, "radius": [0, 0]}, "veins": {"radius": [1, 2], "y": [-16, 320], "spacing": 9, "attempts": 2}},
"dawn_stone": {"zone": "frostpeak", "stone": "cobblemon:dawn_stone_ore", "deepslate": "cobblemon:deepslate_dawn_stone_ore", "regen_minutes": 75, "nodes": {"chance": 0.04, "radius": [0, 0]}, "veins": {"radius": [1, 2], "y": [-16, 320], "spacing": 10, "attempts": 2}}, "dawn_stone": {"zone": "frostpeak", "item": "cobblemon:dawn_stone", "stone": "cobblemon:dawn_stone_ore", "deepslate": "cobblemon:deepslate_dawn_stone_ore", "regen_minutes": 75, "nodes": {"chance": 0.04, "radius": [0, 0]}, "veins": {"radius": [1, 2], "y": [-16, 320], "spacing": 10, "attempts": 2}},
"dusk_stone": {"zone": "deep_dark", "deepslate": "cobblemon:deepslate_dusk_stone_ore", "regen_minutes": 75, "nodes": {"chance": 0.1, "tries": 8, "radius": [0, 1], "y": [-64, -24]}} "dusk_stone": {"zone": "deep_dark", "item": "cobblemon:dusk_stone", "deepslate": "cobblemon:deepslate_dusk_stone_ore", "regen_minutes": 75, "nodes": {"chance": 0.1, "tries": 8, "radius": [0, 1], "y": [-64, -24]}}
}, },
"species": {"durant": "basalt", "torkoal": "basalt", "heatmor": "basalt", "species": {"durant": "basalt", "torkoal": "basalt", "heatmor": "basalt",
"ditto": {"zone": "deep_dark", "all_entries": true}}, "ditto": {"zone": "deep_dark", "all_entries": true}},