Build: every tool pinned (JDK, packwiz, Gradle plugins, Python packages), exports normalised, tools/check_reproducible.py proves two clean builds are byte-identical. Server-side balance: a server can load config/cobblesurvival/datapack.zip in place of the jar's data, /reload applies it, tools/server_update.py packages it and refuses when a client change is needed. JEI drop pages are sent by the server. Code-held balance numbers moved to tuning/server.json. Performance: spark in the pack (server only), tools/perf_test.py with fake players whose chunks really are active, docs/performance.md. Adrian's feedback: default keys R, U, J, §; drop pages link to Cobblemon Info and show real chances; bosses only hurt in battles; battle XP halved; channels between the islands low level; temperature ×3 with insulation doubled; hazards start at once; no item dots on the map; a wild attack starts a battle; the starter only faints; quest book on join with a basics page and an HMs page; regional forms obtainable. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
337 lines
22 KiB
Kotlin
337 lines
22 KiB
Kotlin
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<Species>()
|
||
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<PokemonSpawnDetail>().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<BiomeRequirement>().all { r ->
|
||
biomes.any { b -> r.biomeCondition?.fits(b) != false && r.biomeAnticondition?.fits(b) != true }
|
||
}
|
||
val reached = HashSet<FormData>()
|
||
for (d in CobblemonSpawnPools.WORLD_SPAWN_POOL.details.filterIsInstance<PokemonSpawnDetail>()) {
|
||
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<String>()
|
||
for (p in DropSources.data.pokemon) {
|
||
val species = PokemonSpecies.getByIdentifier(ResourceLocation.parse(p.species)) ?: continue
|
||
val got = HashMap<String, Int>()
|
||
repeat(rolls) {
|
||
species.drops.getDrops().filterIsInstance<ItemDropEntry>().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<String> = table?.entries.orEmpty().filterIsInstance<ItemDropEntry>().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<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 { 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<Any>()
|
||
file.javaClass.getMethod("forAllQuests", Consumer::class.java).invoke(file, Consumer<Any> { 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<String>()
|
||
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<PokemonSpawnDetail>().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()
|
||
}
|
||
}
|