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

@@ -33,6 +33,7 @@ object BossTests {
fun all() = listOf(
SelfTestCase("bosses_cover_every_guardian", 20, ::coverage),
SelfTestCase("boss_attack_shapes", 20, ::shapes),
SelfTestCase("bosses_only_take_battle_damage", 40, ::onlyBattleDamage),
SelfTestCase("boss_attacks_land_and_stop_with_the_battle", 400, ::landAndStop),
SelfTestCase("boss_arena_leaving_loses", 400, ::arena),
SelfTestCase("end_boss_replaces_the_dragon", 200, ::endBoss),
@@ -109,6 +110,19 @@ object BossTests {
s.helper.succeed()
}
private fun onlyBattleDamage(s: Scenario) {
val player = s.player()
val boss = s.wild(s.pokemon("snorlax", 40), s.at(5.5, 1.0, 5.5))
boss.pokemon.persistentData.putBoolean(Guardians.TAG, true)
val health = boss.health
val sources = boss.damageSources()
s.check(!boss.hurt(sources.playerAttack(player), 8f)) { "a player's hit hurt a guardian" }
s.check(!boss.hurt(sources.inFire(), 8f)) { "fire hurt a guardian" }
s.check(!boss.hurt(sources.drown(), 8f)) { "drowning hurt a guardian" }
s.check(boss.health == health) { "the guardian lost health" }
s.helper.succeed()
}
private fun shapes(s: Scenario) {
val o = Vec3(0.0, 64.0, 0.0)
val circle = Area.Circle(o, 3.0)
@@ -1,5 +1,6 @@
package dev.cobblesurvival.selftest
import com.cobblemon.mod.common.battles.BattleRegistry
import com.cobblemon.mod.common.api.events.CobblemonEvents
import com.cobblemon.mod.common.api.events.pokeball.PokeBallCaptureCalculatedEvent
import com.cobblemon.mod.common.api.events.pokeball.PokemonCatchRateEvent
@@ -19,9 +20,14 @@ import kotlin.math.abs
* scan switched on only there (other tests put weak trainers next to wild Pokémon).
*/
object CatchingTests {
/** A player who has just joined can't be hurt for this long. */
private const val SPAWN_PROTECTION_TICKS = 70
fun all() = listOf(
SelfTestCase("catch_rate_falls_with_level_gap", 20, ::catchRate, batch = "catching"),
SelfTestCase("failed_catch_enrages", 400, ::failedCatch, batch = "catching"),
SelfTestCase("wild_attack_starts_a_battle", 300, ::attackBattle, batch = "attack_battle"),
SelfTestCase("wild_attack_hurts_without_a_party", 120, ::attackHurts, batch = "attack_battle"),
SelfTestCase("strong_wild_pokemon_hunt_weak_trainers", 400, { s -> hostility(s, strongest = 5, hostile = true) },
batch = "catching_weak"),
SelfTestCase("wild_pokemon_ignore_stronger_trainers", 120, { s -> hostility(s, strongest = 50, hostile = false) },
@@ -44,6 +50,37 @@ object CatchingTests {
s.helper.succeed()
}
private fun attackBattle(s: Scenario) {
val player = s.player()
s.give(player, s.pokemon("charizard", 50, "ember"))
val wild = s.wild(s.pokemon("rattata", 5, "tackle"), s.at(5.5, 1.0, 5.5))
val health = player.health
s.helper.startSequence()
.thenWaitUntil { s.check(wild.pokemon.entity === wild) { "the wild Pokémon has not ticked yet" } }
.thenIdle(SPAWN_PROTECTION_TICKS)
.thenExecute {
s.check(!player.hurt(wild.damageSources().mobAttack(wild), 6f)) { "the hit went through" }
// A second hit while the Pokémon is still being sent out doesn't hurt either.
s.check(!player.hurt(wild.damageSources().mobAttack(wild), 6f)) { "the second hit went through" }
s.check(player.health == health) { "the player lost health" }
}
.thenWaitUntil { s.check(BattleRegistry.getBattleByParticipatingPlayer(player) != null) { "no battle started" } }
.thenSucceed()
}
private fun attackHurts(s: Scenario) {
val player = s.player()
val wild = s.wild(s.pokemon("rattata", 5, "tackle"), s.at(5.5, 1.0, 5.5))
val health = player.health
s.helper.startSequence()
.thenIdle(SPAWN_PROTECTION_TICKS)
.thenExecute {
s.check(player.hurt(wild.damageSources().mobAttack(wild), 6f)) { "the hit was cancelled for a player with no Pokémon" }
s.check(player.health < health) { "the player lost no health" }
}
.thenSucceed()
}
private fun failedCatch(s: Scenario) {
val player = s.player()
s.give(player, s.pokemon("pidgeot", 60)) // stronger than the wild one: only the failed catch angers it
@@ -0,0 +1,188 @@
package dev.cobblesurvival.selftest
import com.cobblemon.mod.common.Cobblemon
import com.cobblemon.mod.common.CobblemonBlocks
import com.cobblemon.mod.common.api.pokemon.PokemonSpecies
import com.cobblemon.mod.common.block.PastureBlock
import com.cobblemon.mod.common.block.entity.PokemonPastureBlockEntity
import com.cobblemon.mod.common.entity.pokemon.PokemonEntity
import com.mojang.brigadier.arguments.IntegerArgumentType
import com.mojang.brigadier.builder.LiteralArgumentBuilder
import dev.cobblesurvival.machine.MachineBlock
import dev.cobblesurvival.machine.MachineBlockEntity
import dev.cobblesurvival.machine.MachineShowcase
import io.netty.channel.embedded.EmbeddedChannel
import net.minecraft.commands.CommandSourceStack
import net.minecraft.commands.Commands
import net.minecraft.core.BlockPos
import net.minecraft.core.Direction
import net.minecraft.network.chat.Component
import net.minecraft.server.MinecraftServer
import net.minecraft.server.level.ServerLevel
import net.minecraft.server.level.ServerPlayer
import net.minecraft.world.level.levelgen.Heightmap
import net.neoforged.neoforge.common.NeoForge
import net.neoforged.neoforge.event.tick.ServerTickEvent
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.sin
/**
* A realistic load for tools/perf_test.py (roadmap Phase 9): `/cobblesurvival perf start <players> <pastures> <pokémon> <machines>`
* puts that many players on the island, each with a base of full pastures and working machines. They are the self-tests'
* fake clients, so the server ticks them as real players: Cobblemon spawns around them and their chunks load, while the
* first walks a long way, as an explorer would. Only there with -Dcobblesurvival.selftest=true.
*/
object PerfLoad {
private class Bot(val player: ServerPlayer, val channel: EmbeddedChannel, val home: BlockPos, val explorer: Boolean)
private const val PASTURE_LEVEL = 30
private const val PASTURE_SPACING = 14
private const val MACHINE_ROW = 24
private const val BOT_DISTANCE = 420.0
private const val EXPLORER_RING = 900.0
private const val EXPLORER_BLOCKS_PER_SECOND = 5.0
private const val WALK_RADIUS = 18.0
private const val SPIKE_NANOS = 50_000_000L
private const val BUILD_DELAY_TICKS = 200
private val SPECIES = listOf("pidgey", "rattata", "geodude", "oddish", "ponyta", "magnemite", "eevee", "snorlax",
"machop", "slowpoke", "psyduck", "growlithe", "abra", "gastly", "onix", "scyther")
private val bots = ArrayList<Bot>()
private var tethered = 0
private val machineCores = ArrayList<BlockPos>()
/** The bases, built once the players' chunks are active: a Pokémon added to a chunk about to unload is lost with it. */
private var pendingBases: (() -> Unit)? = null
private var buildAtTick = 0
private var tickStarted = 0L
private val samples = ArrayList<Long>()
fun register() {
if (!SelfTest.enabled) return
NeoForge.EVENT_BUS.addListener<ServerTickEvent.Pre> { tickStarted = System.nanoTime() }
NeoForge.EVENT_BUS.addListener(::onTickDone)
}
fun command(): LiteralArgumentBuilder<CommandSourceStack> =
Commands.literal("perf")
.requires { it.hasPermission(4) }
.then(Commands.literal("start").then(Commands.argument("players", IntegerArgumentType.integer(1)).then(
Commands.argument("pastures", IntegerArgumentType.integer(0)).then(Commands.argument("pokemon", IntegerArgumentType.integer(0)).then(
Commands.argument("machines", IntegerArgumentType.integer(0)).executes { context ->
val reply = start(context.source.server, IntegerArgumentType.getInteger(context, "players"),
IntegerArgumentType.getInteger(context, "pastures"), IntegerArgumentType.getInteger(context, "pokemon"),
IntegerArgumentType.getInteger(context, "machines"))
context.source.sendSuccess({ Component.literal(reply) }, false)
1
})))))
.then(Commands.literal("measure").executes { context ->
samples.clear()
context.source.sendSuccess({ Component.literal("measuring") }, false)
1
})
.then(Commands.literal("report").executes { context ->
context.source.sendSuccess({ Component.literal(report(context.source.server)) }, false)
1
})
private fun start(server: MinecraftServer, players: Int, pastures: Int, pokemon: Int, machineCount: Int): String {
val level = server.overworld()
repeat(players) { i ->
val home = land(level, i)
val (player, channel) = FakeClient.join(server, level, "perf_$i")
player.abilities.invulnerable = true
player.teleportTo(level, home.x + 0.5, home.y + 1.0, home.z + 0.5, 0f, 0f)
level.chunkSource.move(player) // a client's movement packets would do this: it's what activates the chunks around a player
bots += Bot(player, channel, home, i == 0 && players > 1)
}
pendingBases = {
for (bot in bots) {
repeat(pastures) { p -> pasture(level, bot.player, surface(level, bot.home.x + p * PASTURE_SPACING, bot.home.z - PASTURE_SPACING), pokemon, p) }
repeat(machineCount) { m ->
val ground = surface(level, bot.home.x + m * MachineShowcase.SPACING, bot.home.z + MACHINE_ROW)
MachineShowcase.working(level, ground, m)
machineCores += ground.above()
}
}
}
buildAtTick = server.tickCount + BUILD_DELAY_TICKS
return "perf started: ${bots.size} players, bases follow in ${BUILD_DELAY_TICKS / 20} s"
}
private fun pasture(level: ServerLevel, player: ServerPlayer, at: BlockPos, count: Int, index: Int) {
val pos = at.above()
val block = CobblemonBlocks.PASTURE.defaultBlockState()
level.setBlockAndUpdate(pos, block.setValue(PastureBlock.PART, PastureBlock.PasturePart.BOTTOM))
level.setBlockAndUpdate(pos.above(), block.setValue(PastureBlock.PART, PastureBlock.PasturePart.TOP))
val entity = level.getBlockEntity(pos) as? PokemonPastureBlockEntity ?: return
val pc = Cobblemon.storage.getPC(player)
repeat(count) { i ->
val pokemon = PokemonSpecies.getByName(SPECIES[(index * 5 + i) % SPECIES.size])!!.create(PASTURE_LEVEL)
pc.add(pokemon)
if (entity.tether(player, pokemon, Direction.NORTH)) tethered++
}
}
private fun surface(level: ServerLevel, x: Int, z: Int): BlockPos {
level.getChunk(x shr 4, z shr 4)
return BlockPos(x, level.getHeight(Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, x, z), z)
}
/** Dry land for the [index]th player: the first of the points around spawn that isn't water. */
private fun land(level: ServerLevel, index: Int): BlockPos {
val spawn = level.sharedSpawnPos
for (attempt in 0 until 16) {
val angle = 2 * PI * (index + attempt / 16.0) / 8
val candidate = surface(level, spawn.x + (cos(angle) * BOT_DISTANCE).toInt(), spawn.z + (sin(angle) * BOT_DISTANCE).toInt())
if (level.getFluidState(candidate.below()).isEmpty && level.getFluidState(candidate).isEmpty) return candidate
}
return surface(level, spawn.x, spawn.z)
}
private fun onTickDone(event: ServerTickEvent.Post) {
samples += System.nanoTime() - tickStarted
val server = event.server
if (pendingBases != null && server.tickCount >= buildAtTick) {
pendingBases?.invoke()
pendingBases = null
}
bots.removeIf { it.player.hasDisconnected() }
for ((i, bot) in bots.withIndex()) {
bot.player.doTick()
bot.player.connection.chunkSender.onChunkBatchReceivedByClient(25f)
bot.channel.releaseOutbound()
if (server.tickCount % 20 == 0) walk(server, bot, i)
}
}
private fun walk(server: MinecraftServer, bot: Bot, index: Int) {
val level = bot.player.serverLevel()
val seconds = server.tickCount / 20.0
val spawn = level.sharedSpawnPos
val (x, z) = if (bot.explorer) {
val angle = seconds * EXPLORER_BLOCKS_PER_SECOND / EXPLORER_RING
spawn.x + cos(angle) * EXPLORER_RING to spawn.z + sin(angle) * EXPLORER_RING
} else {
val angle = seconds * 0.2 + index
bot.home.x + cos(angle) * WALK_RADIUS to bot.home.z + sin(angle) * WALK_RADIUS
}
val y = surface(level, x.toInt(), z.toInt()).y + 1.0
bot.player.teleportTo(level, x, y, z, 0f, 0f)
level.chunkSource.move(bot.player)
}
private fun report(server: MinecraftServer): String {
val sorted = samples.sorted()
fun at(share: Double) = if (sorted.isEmpty()) 0.0 else sorted[((sorted.size - 1) * share).toInt()] / 1e6
val pokemon = server.allLevels.flatMap { level -> level.allEntities.filterIsInstance<PokemonEntity>() }
val pastured = pokemon.count { it.tethering != null }
val overworld = server.overworld()
val working = machineCores.count { core -> (overworld.getBlockEntity(core) is MachineBlockEntity) && overworld.getBlockState(core).getValue(MachineBlock.ACTIVE) }
val entityTicking = bots.count { overworld.isPositionEntityTicking(it.player.blockPosition()) }
val entities = server.allLevels.sumOf { it.allEntities.count() }
val chunks = server.allLevels.sumOf { it.chunkSource.loadedChunksCount }
return "perf ticks=${sorted.size} mean_ms=%.2f p50_ms=%.2f p95_ms=%.2f p99_ms=%.2f max_ms=%.2f spikes_over_50ms=${sorted.count { it > SPIKE_NANOS }} "
.format(sorted.average().takeIf { !it.isNaN() }?.div(1e6) ?: 0.0, at(.5), at(.95), at(.99), at(1.0)) +
"bots=${bots.size} tethered=$tethered pastured=$pastured wild=${pokemon.size - pastured} machines=${machineCores.size} working=$working entities=$entities chunks=$chunks entity_ticking=$entityTicking"
}
}
@@ -26,6 +26,7 @@ object PermadeathTests {
fun all() = listOf(
SelfTestCase("no_healing_or_auto_revive", 40, ::noHealingOrAutoRevive),
SelfTestCase("starter_only_faints", 120, ::starterOnlyFaints),
SelfTestCase("faint_outside_battle", 5, ::faintOutsideBattle),
SelfTestCase("fall", 200, ::fall),
SelfTestCase("lava", 300, ::lava),
@@ -39,6 +40,23 @@ object PermadeathTests {
)
/** Cobblemon heals the party while the player sleeps and wakes fainted Pokémon after a while; both are off. */
/** Adrian, 2026-11-09: the starter is the one Pokémon that is never removed, and a charm it holds isn't used up. */
private fun starterOnlyFaints(s: Scenario) {
val player = s.player()
val starter = s.give(player, s.pokemon("bulbasaur", 5))
val other = s.give(player, s.pokemon("rattata", 5))
starter.swapHeldItem(ItemStack(s.charm()))
Cobblemon.playerDataManager.getGenericData(player).starterUUID = starter.uuid
starter.currentHealth = 0
other.currentHealth = 0
s.helper.startSequence().thenExecuteAfter(80) {
s.assertGone(player, other)
s.check(Cobblemon.storage.getParty(player).any { it.uuid == starter.uuid }) { "the starter was removed" }
s.check(starter.isFainted()) { "the starter was healed" }
s.check(starter.heldItem().`is`(s.charm())) { "the starter's charm was used up" }
}.thenSucceed()
}
private fun noHealingOrAutoRevive(s: Scenario) {
val config = Cobblemon.config
s.check(config.healPercent == 0.0) { "healPercent is ${config.healPercent}, so sleeping heals the party" }
@@ -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 }}"
}
@@ -25,6 +25,7 @@ import net.minecraft.gametest.framework.GameTestSequence
import net.minecraft.network.Connection
import net.minecraft.network.protocol.PacketFlow
import net.minecraft.resources.ResourceLocation
import net.minecraft.server.MinecraftServer
import net.minecraft.server.level.ServerLevel
import net.minecraft.server.level.ServerPlayer
import net.minecraft.server.network.CommonListenerCookie
@@ -67,14 +68,7 @@ class Scenario(val case: SelfTestCase, private val index: Int) : GameTestListene
*/
fun player(): ServerPlayer {
val server = level.server
val cookie = CommonListenerCookie.createInitial(GameProfile(UUID.randomUUID(), "selftest_${index}_${players++}"), false)
val player = ServerPlayer(server, level, cookie.gameProfile(), cookie.clientInformation())
val connection = Connection(PacketFlow.SERVERBOUND)
EmbeddedChannel(connection)
ChannelAttributes.setPayloadSetup(connection, NetworkPayloadSetup.empty())
ChannelAttributes.setConnectionType(connection, ConnectionType.NEOFORGE)
connection.channel().attr(ChannelAttributes.ADHOC_CHANNELS).set(EveryChannel())
server.playerList.placeNewPlayer(connection, player, cookie)
val (player, _) = FakeClient.join(server, level, "selftest_${index}_${players++}")
val pos = at(3.5, 0.0, 7.5)
player.teleportTo(level, pos.x, pos.y, pos.z, 0f, 0f)
cleanups += { server.playerList.remove(player) }
@@ -220,8 +214,23 @@ class Scenario(val case: SelfTestCase, private val index: Int) : GameTestListene
if (passed) CobbleSurvival.LOGGER.info("selftest PASS {}", case.name)
else CobbleSurvival.LOGGER.error("selftest FAIL {}: {}", case.name, failure)
}
}
/** A client as the server sees one: its connection goes nowhere and accepts every mod's network channel, since NeoForge refuses to send mod packets on an unnegotiated one. */
internal object FakeClient {
private class EveryChannel : HashSet<ResourceLocation>() {
override fun contains(element: ResourceLocation) = true
}
fun join(server: MinecraftServer, level: ServerLevel, name: String): Pair<ServerPlayer, EmbeddedChannel> {
val cookie = CommonListenerCookie.createInitial(GameProfile(UUID.randomUUID(), name), false)
val player = ServerPlayer(server, level, cookie.gameProfile(), cookie.clientInformation())
val connection = Connection(PacketFlow.SERVERBOUND)
val channel = EmbeddedChannel(connection)
ChannelAttributes.setPayloadSetup(connection, NetworkPayloadSetup.empty())
ChannelAttributes.setConnectionType(connection, ConnectionType.NEOFORGE)
connection.channel().attr(ChannelAttributes.ADHOC_CHANNELS).set(EveryChannel())
server.playerList.placeNewPlayer(connection, player, cookie)
return player to channel
}
}