JEI pages styled like Cobblemon Info's; every Pokemon obtainable on the roadmap

Adrian, 2026-11-09, after testing: he likes having both drop pages, but they
should look alike. Our JEI pages now use Cobblemon Info's look: its dark
panel and border, header with icon, title, accent tag and divider, its text
colours and spacing. Long text wraps instead of being cut off (a zone's
levels were lost). Shorter page names (Island Drops, Boss Rewards, Ore
Veins, Pasture Finds), since JEI cut the long title too. The line about the
Drop Synthesizer is gone; its core is the page's catalyst.

Roadmap Phase 9: every implemented Pokemon must be obtainable without
commands, even if rarely or through crafting. 95 of 905 implemented species
aren't reachable by a wild spawn or by evolving a wild line today. The
roster-size default changes to match.

Self-tests pass (drop_sources_match_the_server, jei_pages_build).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
claudeandClaude Opus 5.5 committed 2026-11-09 12:09:07 +01:00
1 parent 72965caedf
commit 0be2c2e5c6
4 files changed
+103 -65

No files matched your search

@@ -7,7 +7,6 @@ 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
@@ -19,6 +18,7 @@ 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.Font
import net.minecraft.client.gui.GuiGraphics
import net.minecraft.network.chat.Component
import net.minecraft.resources.ResourceLocation
@@ -30,11 +30,16 @@ import net.minecraft.world.item.ItemStack
* 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.
*
* Styled like Cobblemon Info's pages, which sit next to these (Adrian, 2026-11-09): the same dark panel, header with
* icon, title, accent tag and divider, and the same text colours.
*/
@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>)
/** One page: an icon and title with a short [tag], rows of text, and the items it gives. */
class Page(val icon: ItemStack, val title: Component, val tag: String, val rows: List<Row>, val outputs: List<Output>)
/** A line of text, wrapped if long, with optional [right]-aligned text on its first line. */
class Row(val left: Component, val right: Component? = null)
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>) {
@@ -43,12 +48,12 @@ class DropSourcesJei : IModPlugin {
}
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("pokemon_drops", "Island Drops", "cobblemon:poke_ball", listOf("cobblesurvival:synthesis_core"), ::pokemonPages),
Kind("boss_rewards", "Boss Rewards", "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),
Kind("ore_veins", "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")
@@ -56,8 +61,7 @@ class DropSourcesJei : IModPlugin {
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)
PageCategory(kind.type, Component.literal(kind.title), gui.createDrawableItemStack(DropSources.stack(kind.icon)), kind.built)
}.toTypedArray())
}
@@ -69,48 +73,89 @@ class DropSourcesJei : IModPlugin {
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> {
class PageCategory(private val type: RecipeType<Page>, private val title: Component, private val icon: IDrawable, pages: List<Page>) : IRecipeCategory<Page> {
/** JEI gives every page of a category the same size: that of its tallest. */
private val height = pages.maxOf { slotsTop(it) + (it.outputs.size + PER_ROW - 1) / PER_ROW * SLOT } + PADDING - 2
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 getHeight() = height
private val font: Font get() = Minecraft.getInstance().font
private fun rowWidth(row: Row) = INNER - (row.right?.let { font.width(it) + RIGHT_GAP } ?: 0)
private fun rowLines(row: Row) = font.split(row.left, rowWidth(row))
private fun slotsTop(page: Page): Int {
val lines = page.rows.sumOf { rowLines(it).size.coerceAtLeast(1) }
return BODY_TOP + lines * LINE + if (lines > 0) ROWS_GAP else 0
}
override fun setRecipe(builder: IRecipeLayoutBuilder, page: Page, focuses: IFocusGroup) {
page.icon?.let { builder.addSlot(RecipeIngredientRole.RENDER_ONLY, 1, 1).addItemStack(it) }
builder.addSlot(RecipeIngredientRole.RENDER_ONLY, PADDING, ICON_TOP).addItemStack(page.icon)
val top = slotsTop(page)
page.outputs.forEachIndexed { i, out ->
builder.addSlot(RecipeIngredientRole.OUTPUT, 1 + (i % PER_ROW) * SLOT, TEXT_HEIGHT + 1 + (i / PER_ROW) * SLOT)
.setStandardSlotBackground()
builder.addSlot(RecipeIngredientRole.OUTPUT, PADDING + 1 + (i % PER_ROW) * SLOT, top + 1 + (i / PER_ROW) * SLOT)
.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) }
}
}
// An opaque panel of our own, so the text reads the same whatever JEI's theme is.
graphics.fill(0, 0, WIDTH, height, SURFACE)
graphics.fill(0, 0, WIDTH, 1, BORDER)
graphics.fill(0, height - 1, WIDTH, height, BORDER)
graphics.fill(0, 0, 1, height, BORDER)
graphics.fill(WIDTH - 1, 0, WIDTH, height, BORDER)
/** 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)
val right = WIDTH - PADDING
val tagWidth = font.width(page.tag)
graphics.drawString(font, page.tag, right - tagWidth, TITLE_TOP, ACCENT, true)
font.split(page.title, right - TITLE_LEFT - tagWidth - RIGHT_GAP).firstOrNull()?.let { graphics.drawString(font, it, TITLE_LEFT, TITLE_TOP, TITLE, true) }
graphics.fill(PADDING, DIVIDER_TOP, right, DIVIDER_TOP + 1, DIVIDER)
var y = BODY_TOP
for (row in page.rows) {
row.right?.let { graphics.drawString(font, it, right - font.width(it), y, SUBTLE, true) }
for (line in rowLines(row)) {
graphics.drawString(font, line, PADDING, y, BODY, true)
y += LINE
}
}
}
}
companion object {
private const val WIDTH = 162
// Cobblemon Info's panel metrics and colours (its PanelLayout and DexColors).
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 const val PADDING = 6
private const val WIDTH = PADDING * 2 + PER_ROW * SLOT
private const val INNER = WIDTH - PADDING * 2
private const val LINE = 11
private const val ICON_TOP = 3
private const val TITLE_LEFT = PADDING + 22
private const val TITLE_TOP = 6
private const val DIVIDER_TOP = 20
private const val BODY_TOP = 24
private const val ROWS_GAP = 2
private const val RIGHT_GAP = 6
private const val SURFACE = 0xFF17171B.toInt()
private const val BORDER = 0xFF4A4A52.toInt()
private const val TITLE = 0xFFFFFFFF.toInt()
private const val BODY = 0xFFDDDDDD.toInt()
private const val SUBTLE = 0xFFBBBBBB.toInt()
private const val MUTED = 0xFF888888.toInt()
private const val ACCENT = 0xFFDDCC99.toInt()
private const val DIVIDER = 0x50FFFFFF
private fun gray(text: String): Component = Component.literal(text).withStyle(ChatFormatting.GRAY)
private fun muted(text: String): Component = Component.literal(text).withColor(MUTED)
private fun text(value: String): Component = Component.literal(value)
private fun speciesName(id: String): Component =
PokemonSpecies.getByIdentifier(ResourceLocation.parse(id))?.translatedName ?: Component.literal(id.substringAfter(':'))
@@ -130,24 +175,20 @@ class DropSourcesJei : IModPlugin {
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("-", "–")})"
private fun zoneRow(zone: String, levels: String) = Row(text(DropSources.zoneName(zone)), text("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) }
val rows = if (p.zones.isNotEmpty()) {
p.zones.map { zoneRow(it.zone, it.levels) }
} else {
"Not wild; its line lives in " + p.line.orEmpty().joinToString(", ") {
"${DropSources.zoneName(it.zone)} (${speciesName(it.species).string})"
}
listOf(Row(muted("Not wild. Its line lives in:"))) + p.line.orEmpty().map { Row(text(DropSources.zoneName(it.zone)), speciesName(it.species)) }
}
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))
Page(picture(p.species), speciesName(p.species), "Drops", rows, 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))
Page(picture(s.species), speciesName(s.species), "Drops here", listOf(Row(text(DropSources.zoneName(s.zone)), text("instead"))), s.drops.map(::dropOutput))
}
return wild + special
}
@@ -155,48 +196,42 @@ class DropSourcesJei : IModPlugin {
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 })),
val hours = g.respawn_hours.let { if (it % 1.0 == 0.0) it.toInt() else it }
Page(picture(g.species), speciesName(g.species), "Guardian", listOf(
Row(text(DropSources.zoneName(g.zone)), text("Lv ${g.level}")), Row(muted("Back $hours hours after a win")),
), 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"),
val boss = Page(picture(end.species), speciesName(end.species), "End boss", listOf(
Row(text("The End"), text("Lv ${end.level}")), Row(muted("${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
val core = DropSources.stack("cobblesurvival:altar_core")
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))
Page(core, text("Lv ${t.min_level} or more"), "Sacrifice", listOf(Row(muted("The highest tier reached 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))
Page(core, text("Lv ${s.min_level} ${s.types.joinToString("/") { it.replaceFirstChar(Char::uppercase) }} type"), "Also gives", emptyList(), 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)),
Page(DropSources.stack("cobblesurvival:rebirth_core"), text("Lv 100 Pokémon"), "Rebirth",
listOf(Row(muted("Reborn as a shiny Lv 1 of its first stage"))), 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")),
Page(DropSources.stack("minecraft:iron_pickaxe"), text(DropSources.zoneName(v.zone)), "Ore veins", listOf(Row(muted("Only here; 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")),
Page(DropSources.stack("cobblemon:pasture"), text(if (f.zone == null) "Any zone" else DropSources.zoneName(f.zone)), skill,
listOf(Row(muted("Found by a pastured Pokémon with this skill"))),
f.items.map { Output(DropSources.stack(it.item), listOf(gray(it.tiers.joinToString(", ")))) })
}
}