diff --git a/docs/checklists/phase-8.md b/docs/checklists/phase-8.md index 34bd867..581d96d 100644 --- a/docs/checklists/phase-8.md +++ b/docs/checklists/phase-8.md @@ -8,14 +8,14 @@ - **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. -- **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`. +- **JEI pages** (ours): look an item up in JEI (its recipes, key G) for "Island Drops" (each Pokémon with its zone and levels), "Boss Rewards", "Sacrifice Altar", "Rebirth Chamber", "Ore Veins" and "Pasture Finds". Styled like Cobblemon Info's pages (2026-11-09). 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). ## 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. -- [ ] 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. +- [ ] In JEI, look up the recipes of Light Ball: Pikachu's page shows its picture, Westmarch with Lv 16–25 and its drops with chances; Raichu's page says where its line lives. Same dark panel as Cobblemon Info's "Item Drops", no text cut off. 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. - [ ] Getting Started: tick "Welcome to the Island"; catch a Pokémon: "A Partner" completes. diff --git a/docs/design.md b/docs/design.md index 1f16348..f5fa5d4 100644 --- a/docs/design.md +++ b/docs/design.md @@ -312,12 +312,14 @@ Adrian's choices: Cobblemon tasks come from **Cobblemon Quests Reloaded** (a mai - **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. **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. +- **Island Drops:** 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. +- **Boss Rewards:** 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. +- **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. +The pages sit next to Cobblemon Info's own, and Adrian likes having both, so they share its look (2026-11-09): the same dark panel with a thin border, a header with icon, white title, warm tag on the right and a divider, the same text colours and spacing. Long text wraps. JEI gives all pages of a category the height of the tallest. + 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) diff --git a/docs/roadmap.md b/docs/roadmap.md index f8c9d5a..298adca 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -190,11 +190,12 @@ Goal: a quest book that points players onward, laid out like a research tree who ## Phase 9 – Balancing and release - **Balance pass** on levels, drop rates, vein timers, job cooldowns, hazard damage and machine output, all from `docs/balance.md`. +- **Every implemented Pokémon is obtainable** (Adrian, 2026-11-09). Every species Cobblemon has a model for must be reachable somewhere in the world, even if rarely or through crafting: a wild spawn, evolving a wild line, a fossil, or a crafted or summoned encounter. Legendaries and the like count too; they can be very rare or expensive. Checked on 2026-11-09: 95 of 905 implemented species are not reachable by a wild spawn or by evolving a wild line (legendaries, fossils, and lines the zone assignment left out, such as Aron, Litwick and Piplup). Report what is missing with a tool or self-test so new Cobblemon versions can't reopen the gap. - **Performance.** Profile with spark on a server with realistic pen and machine counts; cap entity density through pasture config if needed. - **Multiplayer test** with Adrian's group. - **Release.** Final `docs/licenses.md`, credits and non-affiliation disclaimer; publish client and server packs on Modrinth and CurseForge as dependency-based packs. -**Done when:** a full playthrough from spawn to the last zone is possible without commands, and a server runs a multi-hour session without tick-time spikes from Pokémon entities. +**Done when:** a full playthrough from spawn to the last zone is possible without commands, every implemented Pokémon can be obtained without commands, and a server runs a multi-hour session without tick-time spikes from Pokémon entities. ## Open decisions for Adrian @@ -208,7 +209,7 @@ The agent should proceed with the default in each row and flag it, rather than b | Do boss area attacks hit party Pokémon? | No, player only | | Player dies during a battle | The battle ends as a loss; Pokémon that had not fainted survive | | Separate storage per base | No; all PC blocks share one storage | -| Roster size for v1 | Curated 150–250 species | +| Roster size for v1 | Every species Cobblemon has a model for (Adrian, 2026-11-09; see Phase 9), replacing the curated 150–250 | | Pen footprint rule | Lower pasture limits only; no size-based rule in v1 | | Multiplayer target | Small private server group | diff --git a/mod/src/main/kotlin/dev/cobblesurvival/jei/DropSourcesJei.kt b/mod/src/main/kotlin/dev/cobblesurvival/jei/DropSourcesJei.kt index d84fedf..3ce2c1e 100644 --- a/mod/src/main/kotlin/dev/cobblesurvival/jei/DropSourcesJei.kt +++ b/mod/src/main/kotlin/dev/cobblesurvival/jei/DropSourcesJei.kt @@ -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, val outputs: List) + /** 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, val outputs: List) + /** 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) private class Kind(val path: String, val title: String, val icon: String, val catalysts: List, val pages: () -> List) { @@ -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, private val title: Component, private val icon: IDrawable, private val rows: Int) : IRecipeCategory { + class PageCategory(private val type: RecipeType, private val title: Component, private val icon: IDrawable, pages: List) : IRecipeCategory { + /** 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, 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 { 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 { 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 { 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 = 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 = 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 = 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(", ")))) }) } }