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>
This commit is contained in:
claudeandClaude Opus 5.5 committed 2026-11-09 16:26:30 +01:00
1 parent 0741c84077
commit 4083791944
133 files changed
+8350 -3273

No files matched your search

@@ -1,5 +1,9 @@
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
@@ -40,6 +44,10 @@ object QuestTests {
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),
)
@@ -113,6 +121,79 @@ object QuestTests {
* 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(
@@ -129,14 +210,11 @@ object QuestTests {
}
/** A drop as "item chance% ×count", the same way from the JEI data and from Cobblemon's live tables. */
private fun dropKey(item: String, chance: Double?, count: String?) = "$item ${"%.2f".format(chance ?: 100.0)}% ×${count ?: "1"}"
private fun liveDrops(table: DropTable?): Set<String> = table?.entries.orEmpty().filterIsInstance<ItemDropEntry>().map { e ->
dropKey(e.item.toString(), e.percentage.toDouble().takeIf { it < 100.0 }, e.quantityRange?.let { if (it.first == it.last) "${it.first}" else "${it.first}-${it.last}" } ?: e.quantity.toString())
}.toSet()
private fun liveDrops(table: DropTable?): Set<String> = table?.entries.orEmpty().filterIsInstance<ItemDropEntry>().map { it.item.toString() }.toSet()
/**
* The JEI pages' data (assets/cobblesurvival/jei/drop_sources.json, tools/jei_sources.py) still says what the
* 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.
*/
@@ -147,7 +225,7 @@ object QuestTests {
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 { dropKey(it.item, it.chance, it.count) }.toSet()
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 }}"
}