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