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:
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 }}"
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user