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

@@ -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
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.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.zone.Guardians
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.resources.ResourceLocation
import net.minecraft.world.item.ItemStack
import net.minecraft.world.item.Items
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
@@ -20,8 +30,73 @@ object QuestTests {
SelfTestCase("jei_addon_shows_island_spawns", 20, ::addonSpawns),
SelfTestCase("quest_book_loads", 20, ::bookLoads),
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). */
private const val MIN_QUESTS = 70
File diff suppressed because it is too large. Load diff