package dev.cobblesurvival.selftest import com.cobblemon.mod.common.api.pokemon.PokemonProperties import com.cobblemon.mod.common.pokemon.requirements.BiomeRequirement import com.cobblemon.mod.common.pokemon.FormData import com.cobblemon.mod.common.api.pokemon.evolution.Evolution import com.cobblemon.mod.common.api.drop.DropTable import com.cobblemon.mod.common.api.drop.ItemDropEntry import com.cobblemon.mod.common.api.fossil.Fossils 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 com.cobblemon.mod.common.entity.pokemon.PokemonEntity import com.cobblemon.mod.common.pokemon.Species import dev.cobblesurvival.CobbleSurvival import dev.cobblesurvival.boss.BossDefs import dev.cobblesurvival.boss.BossFights import dev.cobblesurvival.boss.BossToughness import dev.cobblesurvival.jei.DropSources import dev.cobblesurvival.jei.DropSourcesJei import dev.cobblesurvival.machine.MachineDefs import dev.cobblesurvival.progress.QuestEvents import dev.cobblesurvival.summon.Summons import dev.cobblesurvival.world.SpecialPokemon 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 /** * Roadmap Phase 8 (Adrian, 2026-11-03): the add-ons the quest book and JEI lean on work with the pack. Read by * reflection, so the companion mod needs neither at compile time. */ object QuestTests { fun all() = listOf( SelfTestCase("quests_have_cobblemon_tasks", 20, ::cobblemonTasks), 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), SelfTestCase("drop_sources_reach_the_client", 20, ::dropSourcesPacked), SelfTestCase("drop_chances_match_cobblemon", 20, ::dropChances), SelfTestCase("regional_forms_are_obtainable", 20, ::regionalForms, islandOnly = true), SelfTestCase("new_players_get_the_quest_book", 20, ::questBook), SelfTestCase("every_implemented_pokemon_is_obtainable", 20, ::obtainable), SelfTestCase("sigil_summons_a_catchable_legend", 200, ::summon), ) private fun rootOf(species: Species): Species { var current = species val seen = HashSet() while (seen.add(current)) current = current.preEvolution?.species ?: return current return current } /** * Roadmap Phase 9 (Adrian, 2026-11-09): every species Cobblemon has a model for can be had without commands: its line * spawns somewhere, comes from a fossil, or has a Sigil. A new Cobblemon version adding species fails this until * they have a home. */ private fun obtainable(s: Scenario) { val wild = CobblemonSpawnPools.WORLD_SPAWN_POOL.details.filterIsInstance().mapNotNull { it.pokemon.species } val fossils = Fossils.all().mapNotNull { it.result.species } val summons = Summons.defs.keys val reachable = (wild + fossils + summons).mapNotNull { PokemonSpecies.getByName(it.substringAfter(':')) }.map(::rootOf).toSet() val missing = PokemonSpecies.implemented.filter { rootOf(it) !in reachable } s.check(missing.isEmpty()) { "${missing.size} implemented Pokémon can't be obtained: ${missing.take(12).joinToString { it.name }}" } s.helper.succeed() } /** A Sigil works only in its zone, summons a catchable legend that fights as a boss, and once caught it's ordinary. */ private fun summon(s: Scenario) { val level = s.level val player = s.player() val sigil = DropSourcesJei.sigils().first { Summons.speciesOf(it) == "zapdos" } val recipe = level.server.recipeManager.byKey(ResourceLocation.fromNamespaceAndPath(CobbleSurvival.MOD_ID, "summon_zapdos")).orElse(null) ?: throw GameTestAssertException("the Zapdos Sigil has no recipe") val made = recipe.value.getResultItem(level.registryAccess()) s.check(Summons.speciesOf(made) == "zapdos" && made.hoverName.string == "Zapdos Sigil") { "the recipe makes '${made.hoverName.string}' for ${Summons.speciesOf(made)}" } val missingRecipes = Summons.defs.keys.filter { level.server.recipeManager.byKey(ResourceLocation.fromNamespaceAndPath(CobbleSurvival.MOD_ID, "summon_$it")).isEmpty } s.check(missingRecipes.isEmpty()) { "no recipe for the Sigils of $missingRecipes" } val elsewhere = Zones.samples["button_isle"] ?: throw GameTestAssertException("no sample point for Button Isle") val home = Zones.samples["westmarch"] ?: throw GameTestAssertException("no sample point for Westmarch") fun ground(x: Int, z: Int) = level.getHeight(Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, x, z).toDouble() fun summoned() = level.getEntitiesOfClass(PokemonEntity::class.java, player.boundingBox.inflate(32.0)) { it.pokemon.persistentData.contains(Summons.TAG) } s.afterTest { summoned().forEach { it.discard() } } s.helper.startSequence() .thenExecute { level.getChunk(elsewhere.x shr 4, elsewhere.z shr 4) s.moveTo(player, elsewhere.x + 0.5, ground(elsewhere.x, elsewhere.z), elsewhere.z + 0.5) } .thenWaitUntil { s.check(level.isPositionEntityTicking(player.blockPosition())) { "Button Isle isn't loaded yet" } } .thenExecute { s.check(!Summons.summon(level, player, sigil) && summoned().isEmpty()) { "the Sigil worked outside its zone" } level.getChunk(home.x shr 4, home.z shr 4) s.moveTo(player, home.x + 0.5, ground(home.x, home.z), home.z + 0.5) } .thenWaitUntil { s.check(level.isPositionEntityTicking(player.blockPosition())) { "Westmarch isn't loaded yet" } } .thenExecute { s.check(Summons.summon(level, player, sigil)) { "the Sigil didn't work in its zone" } val legend = summoned().singleOrNull() ?: throw GameTestAssertException("${summoned().size} legends appeared") val pokemon = legend.pokemon s.check(pokemon.species.name == "Zapdos" && pokemon.level == Summons.defs.getValue("zapdos").level) { "summoned ${pokemon.species.name} Lv ${pokemon.level}" } s.check(!pokemon.isUncatchable() && BossFights.defOf(legend) != null && BossToughness.isBoss(pokemon)) { "the legend isn't a catchable boss" } s.check(SpecialPokemon.isExempt(pokemon)) { "the wild-Pokémon rules would remove the legend" } s.check(!Summons.summon(level, player, sigil)) { "a second legend came while the first was here" } Summons.released(pokemon) s.check(!BossToughness.isBoss(pokemon) && !SpecialPokemon.isExempt(pokemon)) { "a caught legend still counts as a boss" } legend.discard() } .thenSucceed() } /** * 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 val REGIONAL = setOf("alolan", "galarian", "hisuian", "paldean") /** * Adrian, 2026-11-09: the regional forms JEI lists can be had too: from a wild spawn, a Sigil, or by evolving * something obtainable where a biome the evolution asks for exists on the island, or by breeding back down a line. Battle-only forms (Zen Mode) don't count. Other species count with any form; Shedinja comes with Nincada's evolution, which no requirement names. */ private fun regionalForms(s: Scenario) { val biomes = s.level.chunkSource.generator.biomeSource.possibleBiomes() fun possible(evolution: Evolution) = evolution.requirements.filterIsInstance().all { r -> biomes.any { b -> r.biomeCondition?.fits(b) != false && r.biomeAnticondition?.fits(b) != true } } val reached = HashSet() for (d in CobblemonSpawnPools.WORLD_SPAWN_POOL.details.filterIsInstance()) { val species = d.pokemon.species?.let { PokemonSpecies.getByName(it.substringAfter(':')) } ?: continue reached += if (d.pokemon.originalString.lowercase().split(' ').any { it in REGIONAL }) d.pokemon.create().form else species.standardForm } reached += Fossils.all().mapNotNull { it.result.species }.mapNotNull { PokemonSpecies.getByName(it.substringAfter(':'))?.standardForm } reached += Summons.defs.values.map { PokemonProperties.parse(it.pokemon).create().form } val queue = ArrayDeque(reached) while (queue.isNotEmpty()) { val current = queue.removeFirst() // Breeding (Cobbreeding) brings the start of a line from any of its stages. current.preEvolution?.form?.let { if (reached.add(it)) queue += it } for (evolution in current.evolutions.filter(::possible)) { val form = evolution.result.create().form if (reached.add(form)) queue += form } } val missing = PokemonSpecies.implemented.flatMap { species -> species.forms.filter { f -> f.aspects.any { it in REGIONAL } && "zen-mode" !in f.aspects && f !in reached }.map { "${it.name} ${species.name}" } + listOfNotNull(species.name.takeIf { species.forms.none(reached::contains) && species.standardForm !in reached && it != "Shedinja" }) } s.check(missing.isEmpty()) { "${missing.size} forms can't be obtained: ${missing.take(15)}" } s.helper.succeed() } private fun questBook(s: Scenario) { val player = s.player() val book = BuiltInRegistries.ITEM.get(ResourceLocation.parse("ftbquests:book")) s.check(player.inventory.items.any { it.`is`(book) }) { "a new player has no quest book" } s.helper.succeed() } /** The chance on each JEI page is what Cobblemon's own roll gives (Adrian, 2026-11-09): rolled here, for every Pokémon. */ private fun dropChances(s: Scenario) { val rolls = 4000 val problems = mutableListOf() for (p in DropSources.data.pokemon) { val species = PokemonSpecies.getByIdentifier(ResourceLocation.parse(p.species)) ?: continue val got = HashMap() repeat(rolls) { species.drops.getDrops().filterIsInstance().filter { (it.quantityRange?.random() ?: it.quantity) > 0 } .map { it.item.toString() }.toSet().forEach { got.merge(it, 1, Int::plus) } } for (d in p.drops) { val seen = 100.0 * (got[d.item] ?: 0) / rolls if (kotlin.math.abs(seen - (d.chance ?: 100.0)) > 4.0) problems += "${p.species}: ${d.item} page ${d.chance ?: 100.0}, rolled ${"%.1f".format(seen)}" } } s.check(problems.isEmpty()) { "${problems.size} wrong chances: ${problems.take(5)}" } s.helper.succeed() } /** What the server sends players on joining unpacks to what the server has (Adrian, 2026-11-09: balance without a client update). */ private fun dropSourcesPacked(s: Scenario) { val sent = DropSources.parse(DropSources.packed) val data = DropSources.data s.check(data.pokemon.isNotEmpty()) { "the server has no drop sources" } s.check(sent.pokemon.size == data.pokemon.size && sent.guardians.size == data.guardians.size && sent.summons.size == data.summons.size) { "the packed drop sources differ" } s.check(DropSources.packed.size < 1_000_000) { "the drop sources are ${DropSources.packed.size} bytes packed, too long for one packet" } s.helper.succeed() } 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 liveDrops(table: DropTable?): Set = table?.entries.orEmpty().filterIsInstance().map { it.item.toString() }.toSet() /** * The JEI pages' data (data/cobblesurvival/drop_sources/pages.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() val details = CobblemonSpawnPools.WORLD_SPAWN_POOL.details.filterIsInstance() 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 { it.item }.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 private fun field(obj: Any, name: String): Any? { var c: Class<*>? = obj.javaClass while (c != null) { runCatching { return c.getDeclaredField(name).apply { isAccessible = true }.get(obj) } c = c.superclass } throw GameTestAssertException("${obj.javaClass.simpleName} has no field $name") } /** * FTB Quests loaded the generated book (tools/quests.py): enough quests, flexible progression, and every task * understood: no unknown task type, every item exists, every advancement exists on the server. */ private fun bookLoads(s: Scenario) { val file = Class.forName("dev.ftb.mods.ftbquests.quest.ServerQuestFile").getField("INSTANCE").get(null) ?: throw GameTestAssertException("FTB Quests has no server quest file") val quests = mutableListOf() file.javaClass.getMethod("forAllQuests", Consumer::class.java).invoke(file, Consumer { quests += it }) s.check(quests.size >= MIN_QUESTS) { "FTB Quests loaded ${quests.size} quests, not the book's $MIN_QUESTS or more" } s.check(field(file, "progressionMode").toString().equals("flexible", ignoreCase = true)) { "the book isn't in flexible mode" } val problems = mutableListOf() for (quest in quests) { for (task in quest.javaClass.getMethod("getTasks").invoke(quest) as Collection<*>) { when (task!!.javaClass.simpleName) { "CustomTask" -> problems += "unknown task type in quest ${quest}" "ItemTask" -> if ((field(task, "itemStack") as ItemStack).isEmpty) problems += "an item task with no item in $quest" "AdvancementTask" -> { val id = field(task, "advancement") as ResourceLocation if (s.level.server.advancements.get(id) == null) problems += "no advancement $id" } } } } s.check(problems.isEmpty()) { problems.take(5).joinToString("; ") } s.helper.succeed() } /** Walking into a zone marks the hidden advancement its "reach the zone" quest checks (progress/QuestEvents.kt). */ private fun zoneEvent(s: Scenario) { val sample = Zones.samples["button_isle"] ?: throw GameTestAssertException("no sample point for Button Isle") val player = s.player() s.helper.startSequence() .thenExecute { s.level.getChunk(sample.x shr 4, sample.z shr 4) s.moveTo(player, sample.x + 0.5, s.level.getHeight(Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, sample.x, sample.z).toDouble(), sample.z + 0.5) } .thenWaitUntil { s.check(QuestEvents.done(player, "zone/button_isle")) { "reaching Button Isle didn't mark zone/button_isle" } } .thenSucceed() } /** Cobblemon Quests Reloaded adds its task type (catch, defeat, evolve, level up, ...) to FTB Quests. */ private fun cobblemonTasks(s: Scenario) { val types = Class.forName("dev.ftb.mods.ftbquests.quest.task.TaskTypes").getField("TYPES").get(null) as Map<*, *> s.check(types.keys.any { it.toString() == "cobblemon_tasks:cobblemon_task" }) { "FTB Quests has no Cobblemon task type: ${types.keys}" } s.helper.succeed() } /** * Cobblemon Info for REI/JEI/EMI shows what the pack's own spawn pools say (Adrian: check that its spawn info is * right for the pack): every entry it lists is one of the zone pools' entries, at the zone's levels, and it lists * all of them. It can't show the zone itself, only the zone's biomes (docs/design.md, Phase 8). */ private fun addonSpawns(s: Scenario) { val loader = Class.forName("com.cobbledex.SpawnDataLoader") val shown = (loader.getMethod("loadFromRuntime").invoke(loader.getField("INSTANCE").get(null)) as Map<*, *>).values.flatMap { it as List<*> } fun field(info: Any?, name: String) = info!!.javaClass.getMethod("get" + name.replaceFirstChar { it.uppercase() }).invoke(info) as String val ours = CobblemonSpawnPools.WORLD_SPAWN_POOL.details.filterIsInstance().associateBy { it.id } s.check(shown.isNotEmpty()) { "the JEI add-on shows no spawns" } val strangers = shown.map { field(it, "id") }.filter { it !in ours } s.check(strangers.isEmpty()) { "the JEI add-on shows ${strangers.size} spawns the pack doesn't have, e.g. ${strangers.take(5)}" } val wrongLevels = shown.filter { info -> val zone = Zones.byId[field(info, "id").substringBefore('-')] ?: return@filter true val (low, high) = field(info, "levelRange").split("-").map { it.toInt() } low < zone.minLevel || high > zone.maxLevel } s.check(wrongLevels.isEmpty()) { "the JEI add-on shows levels outside the zone's band: ${wrongLevels.take(5).map { field(it, "id") + " " + field(it, "levelRange") }}" } val missing = ours.keys - shown.map { field(it, "id") }.toSet() s.check(missing.isEmpty()) { "the JEI add-on leaves out ${missing.size} of the pack's spawns, e.g. ${missing.take(5)}" } s.helper.succeed() } }