Files
cobblemon-survival-pack/mod/src/main/kotlin/dev/cobblesurvival/selftest/QuestTests.kt
T
claudeandClaude Opus 5.5 4083791944 Phase 9: reproducible builds, balance from the server, spark check, play feedback
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>
2026-11-09 16:26:30 +01:00

337 lines
22 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()
}
}