Cobblemon Survival Pack: phases 0-2 and Phase 3 zones and hazards

Free NeoForge 1.21.1 modpack on Cobblemon 1.8.4 built with packwiz, plus
the Kotlin companion mod (cobblesurvival) and its built-in data pack.

- Phase 0: packwiz pack with Modrinth/CurseForge cross-links, build and
  export tooling, Prism instance sync, test server with smoke checks.
- Phase 1: Poke Ball shaped island (WorldGen Editor preset, seed isle-26),
  Adrian's biome list, no natural ore, structures without ore or vanilla
  mobs, no Nether portals, world template builder and world report.
- Phase 2: permadeath (CobbleHardcoreMon plus the companion mod's gap
  fixes), Revival Charm, expensive Healing Machine, Lootr refreshing
  loot, in-game self-tests on Minecraft's GameTest framework.
- Phase 3 so far: 14 zones with their own spawn pools and level bands
  (tools/zone_layout.py, tools/zone_spawns.py, "zone" spawn condition),
  Cold Sweat heat and cold as gates with boss-drop insulation recipes,
  spawn survey and hazard self-tests in tools/quick_check.py.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
claudeandClaude Opus 5.5 committed 2026-10-29 19:01:21 +01:00
commit 398301f0be
232 files changed
+72665

No files matched your search

@@ -0,0 +1,170 @@
package dev.cobblesurvival.selftest
import dev.cobblesurvival.CobbleSurvival
import dev.cobblesurvival.zone.ZoneSample
import dev.cobblesurvival.zone.Zones
import net.minecraft.gametest.framework.GameTestAssertException
import net.minecraft.core.registries.BuiltInRegistries
import net.minecraft.resources.ResourceLocation
import net.minecraft.server.level.ServerPlayer
import net.minecraft.world.entity.EquipmentSlot
import net.minecraft.world.entity.LivingEntity
import net.minecraft.world.item.ItemStack
import net.minecraft.world.level.levelgen.Heightmap
/**
* Roadmap Phase 3, hazards as gates, on the island itself (real biomes, real altitude): in Frostpeak and Sunscorch
* a player without the right gear freezes or overheats within a minute or two, while the fur (cold) or hide (heat)
* set holds them off for many minutes; leather armour alone doesn't help. The zones without a hazard are livable
* without gear by day and by night.
*
* Cold Sweat's insulation doesn't change what counts as too cold or too hot; it slows how fast the body drifts
* there. So the tests reset each player's core temperature to 0, wait, and turn the drift into minutes until the
* body reaches ±100, where Cold Sweat starts to hurt. Each case changes the time of day, so it runs in a batch of
* its own.
*/
object HazardTests {
private val FUR = listOf("goat_fur_helmet", "goat_fur_chestplate", "goat_fur_leggings", "goat_fur_boots")
private val HIDE = listOf("hoglin_helmet", "hoglin_chestplate", "hoglin_leggings", "hoglin_boots")
private val LEATHER = listOf("leather_helmet", "leather_chestplate", "leather_leggings", "leather_boots")
private const val NOON = 6000L
private const val MIDNIGHT = 18000L
private const val MEASURE_TICKS = 60
/** Cold Sweat eases the felt temperature towards a new place; let it arrive before measuring. */
private const val SETTLE_TICKS = 120
/** Without gear the hazard must hurt within this many minutes... */
private const val UNPROTECTED_MAX_MINUTES = 3.0
/** ...and with the right set not before this many. */
private const val PROTECTED_MIN_MINUTES = 10.0
fun all() = listOf(
SelfTestCase("cold_needs_fur", 600, ::cold, islandOnly = true, batch = "hazard_cold"),
SelfTestCase("heat_needs_hide", 600, ::heat, islandOnly = true, batch = "hazard_heat"),
SelfTestCase("lowlands_are_mild", 4000, ::lowlands, islandOnly = true, batch = "hazard_lowlands"),
)
/** Frostpeak at noon, its warmest hour. */
private fun cold(s: Scenario) {
val bare = s.player()
val leather = s.player().also { dress(it, "minecraft", LEATHER) }
val fur = s.player().also { dress(it, "cold_sweat", FUR) }
val players = listOf(bare, leather, fur)
s.helper.startSequence()
.thenExecute { exposeAt(s, Zones.biomeSamples["frostpeak"], NOON, players) }
.thenIdle(SETTLE_TICKS)
.thenExecute { players.forEach { ColdSweat.setCore(it, 0.0) } }
.thenIdle(MEASURE_TICKS)
.thenExecute {
val (bareMin, leatherMin, furMin) = players.map { ColdSweat.minutesToLimit(it) }
report(s, "Frostpeak at noon (%s), minutes until the body freezes: none %.1f, leather %.1f, fur %.1f".format(ColdSweat.describe(bare), bareMin, leatherMin, furMin))
s.check(bareMin <= UNPROTECTED_MAX_MINUTES) { "without gear Frostpeak takes %.1f min to freeze".format(bareMin) }
s.check(leatherMin <= UNPROTECTED_MAX_MINUTES) { "in leather Frostpeak takes %.1f min to freeze".format(leatherMin) }
s.check(furMin >= PROTECTED_MIN_MINUTES) { "in the fur set Frostpeak freezes in %.1f min".format(furMin) }
// And it really hurts: a frozen body without the new-player grace takes damage.
ColdSweat.endGrace(bare)
ColdSweat.setCore(bare, -150.0)
}
.thenExecuteAfter(60) {
s.check(bare.health < bare.maxHealth) { "a frozen player took no damage (health ${bare.health})" }
}
.thenSucceed()
}
/** Sunscorch at noon, its hottest hour. */
private fun heat(s: Scenario) {
val bare = s.player()
val hide = s.player().also { dress(it, "cold_sweat", HIDE) }
val players = listOf(bare, hide)
s.helper.startSequence()
.thenExecute { exposeAt(s, Zones.biomeSamples["sunscorch"], NOON, players) }
.thenIdle(SETTLE_TICKS)
.thenExecute { players.forEach { ColdSweat.setCore(it, 0.0) } }
.thenIdle(MEASURE_TICKS)
.thenExecute {
val (bareMin, hideMin) = players.map { ColdSweat.minutesToLimit(it) }
report(s, "Sunscorch at noon (%s), minutes until the body overheats: none %.1f, hide %.1f".format(ColdSweat.describe(bare), bareMin, hideMin))
s.check(bareMin <= UNPROTECTED_MAX_MINUTES) { "without gear Sunscorch takes %.1f min to overheat".format(bareMin) }
s.check(hideMin >= PROTECTED_MIN_MINUTES) { "in the hide set Sunscorch overheats in %.1f min".format(hideMin) }
}
.thenSucceed()
}
/** Every zone without a hazard, at noon and at midnight, without gear. */
private fun lowlands(s: Scenario) {
val player = s.player()
val problems = mutableListOf<String>()
val readings = mutableListOf<String>()
var sequence = s.helper.startSequence()
// Nights are the cold risk everywhere; the warm zones are checked at noon too.
val checks = listOf("button_isle", "southwold", "westmarch", "jungle", "highlands", "mire").map { it to MIDNIGHT } +
listOf("jungle" to NOON, "mire" to NOON)
for ((zone, time) in checks) {
run {
val label = "$zone at ${if (time == NOON) "noon" else "midnight"}"
sequence = sequence
.thenExecute { exposeAt(s, Zones.samples[zone], time, listOf(player)) }
.thenIdle(SETTLE_TICKS)
.thenExecute { ColdSweat.setCore(player, 0.0) }
.thenIdle(MEASURE_TICKS)
.thenExecute {
val minutes = ColdSweat.minutesToLimit(player)
readings += "$label %.0f (world %.2f)".format(minutes, ColdSweat.get(player, "WORLD"))
if (minutes < PROTECTED_MIN_MINUTES) problems += "$label: limit in %.1f min (%s)".format(minutes, ColdSweat.describe(player))
}
}
}
sequence.thenExecute {
report(s, "minutes until too hot or cold without gear: ${readings.joinToString()}")
s.check(problems.isEmpty()) { problems.joinToString("; ") }
}.thenSucceed()
}
private fun dress(player: ServerPlayer, namespace: String, pieces: List<String>) {
val slots = listOf(EquipmentSlot.HEAD, EquipmentSlot.CHEST, EquipmentSlot.LEGS, EquipmentSlot.FEET)
for ((slot, piece) in slots.zip(pieces)) {
player.setItemSlot(slot, ItemStack(BuiltInRegistries.ITEM.get(ResourceLocation.fromNamespaceAndPath(namespace, piece))))
}
}
/** Stands the players on the surface at the sample point at that time of day. */
private fun exposeAt(s: Scenario, sample: ZoneSample?, time: Long, players: List<ServerPlayer>) {
if (sample == null) throw GameTestAssertException("no sample point (run tools/zone_layout.py)")
val level = s.level
for (dx in -2..2) for (dz in -2..2) level.getChunk((sample.x shr 4) + dx, (sample.z shr 4) + dz)
val y = level.getHeight(Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, sample.x, sample.z)
level.dayTime = time
for (player in players) {
s.moveTo(player, sample.x + 0.5, y.toDouble(), sample.z + 0.5)
}
}
private fun report(s: Scenario, text: String) = CobbleSurvival.LOGGER.info("selftest {}: {}", s.case.name, text)
/** Cold Sweat's temperature API, by reflection: the companion mod doesn't compile against Cold Sweat. */
private object ColdSweat {
private val api = Class.forName("com.momosoftworks.coldsweat.api.util.Temperature")
private val traits = Class.forName("com.momosoftworks.coldsweat.api.util.Temperature\$Trait")
private val get = api.getMethod("get", LivingEntity::class.java, traits)
private val set = api.getMethod("set", LivingEntity::class.java, traits, Double::class.javaPrimitiveType)
private fun trait(name: String) = traits.getField(name).get(null)
fun get(entity: LivingEntity, name: String) = get.invoke(null, entity, trait(name)) as Double
fun setCore(player: ServerPlayer, value: Double) { set.invoke(null, player, trait("CORE"), value) }
/** Minutes from now until the core reaches ±100 at the drift since it was reset to 0. */
fun minutesToLimit(player: ServerPlayer): Double {
val perTick = kotlin.math.abs(get(player, "CORE")) / MEASURE_TICKS
return if (perTick == 0.0) Double.POSITIVE_INFINITY else 100.0 / perTick / 20.0 / 60.0
}
fun endGrace(player: ServerPlayer) {
BuiltInRegistries.MOB_EFFECT.getHolder(ResourceLocation.fromNamespaceAndPath("cold_sweat", "grace"))
.ifPresent { player.removeEffect(it) }
}
fun describe(player: ServerPlayer) = "world %.2f, comfortable %.2f..%.2f, core %.1f".format(
get(player, "WORLD"), get(player, "FREEZING_POINT"), get(player, "BURNING_POINT"), get(player, "CORE"),
)
}
}
@@ -0,0 +1,207 @@
package dev.cobblesurvival.selftest
import com.cobblemon.mod.common.Cobblemon
import com.cobblemon.mod.common.api.npc.NPCClasses
import com.cobblemon.mod.common.api.pokemon.status.Statuses
import com.cobblemon.mod.common.api.storage.party.NPCPartyStore
import com.cobblemon.mod.common.battles.BattleBuilder
import com.cobblemon.mod.common.battles.MoveActionResponse
import com.cobblemon.mod.common.battles.SwitchActionResponse
import com.cobblemon.mod.common.entity.npc.NPCEntity
import com.cobblemon.mod.common.entity.pokemon.PokemonEntity
import com.cobblemon.mod.common.pokemon.Pokemon
import com.cobblemon.mod.common.util.isInBattle
import net.minecraft.world.item.ItemStack
import net.minecraft.world.level.block.Blocks
import kotlin.random.Random
/**
* Roadmap Phase 2: a fainted Pokémon is gone for good, unless it holds a Revival Charm, which saves it once.
* Each case plays one way to faint for real (falls, lava, poison, wild and trainer battles) on the live server.
* The player's Pokémon in battles knows only Explosion, which always knocks out its user; the opponents know only
* Splash, so nothing else decides the battle.
*/
object PermadeathTests {
private const val BATTLE_TICKS = 1200
fun all() = listOf(
SelfTestCase("no_healing_or_auto_revive", 40, ::noHealingOrAutoRevive),
SelfTestCase("faint_outside_battle", 5, ::faintOutsideBattle),
SelfTestCase("fall", 200, ::fall),
SelfTestCase("lava", 300, ::lava),
SelfTestCase("poison", 5, ::poison),
SelfTestCase("charm_saves_once", 40, ::charmSavesOnce),
SelfTestCase("fainted_in_pc", 60, ::faintedInPc),
SelfTestCase("wild_battle", BATTLE_TICKS, { wildBattle(it, charm = false) }),
SelfTestCase("wild_battle_charm", BATTLE_TICKS, { wildBattle(it, charm = true) }),
SelfTestCase("revived_during_battle", BATTLE_TICKS, ::revivedDuringBattle),
SelfTestCase("trainer_battle", BATTLE_TICKS, ::trainerBattle),
)
/** Cobblemon heals the party while the player sleeps and wakes fainted Pokémon after a while; both are off. */
private fun noHealingOrAutoRevive(s: Scenario) {
val config = Cobblemon.config
s.check(config.healPercent == 0.0) { "healPercent is ${config.healPercent}, so sleeping heals the party" }
s.check(config.defaultFaintTimer >= 100_000_000) { "defaultFaintTimer is ${config.defaultFaintTimer} s, so fainted Pokémon wake up" }
val player = s.player()
val hurt = s.give(player, s.pokemon("bidoof", 20))
hurt.currentHealth = hurt.maxHealth / 2
val party = Cobblemon.storage.getParty(player)
repeat(600) { party.onSecondPassed(player) } // ten minutes
s.check(hurt.currentHealth == hurt.maxHealth / 2) { "the party healed by itself: HP ${hurt.currentHealth}/${hurt.maxHealth}" }
s.helper.succeed()
}
/** Settled within a tick or two; CobbleHardcoreMon alone takes up to a second, time enough to heal it. */
private fun faintOutsideBattle(s: Scenario) {
val player = s.player()
val pokemon = s.give(player, s.pokemon("bidoof", 10))
pokemon.currentHealth = 0
s.helper.succeedWhen { s.assertGone(player, pokemon) }
}
/**
* A 10-block drop does 7 damage. Kept short: a party Pokémon over 32 blocks away teleports to its trainer, and the
* test area has a barrier roof 12 blocks up.
*/
private fun fall(s: Scenario) {
val player = s.player()
val pokemon = s.give(player, s.pokemon("bidoof", 5))
lateinit var entity: PokemonEntity
s.helper.startSequence()
.thenExecute { entity = sendOut(s, pokemon, 6.5, 10.5) }
.thenWaitUntil { s.check(readyToGetHurt(s, entity)) { "still being sent out" } }
.thenExecute {
pokemon.currentHealth = 3
entity.teleportTo(entity.x, entity.y + 10, entity.z)
}
.thenWaitUntil { s.assertGone(player, pokemon) }
.thenSucceed()
}
private fun lava(s: Scenario) {
val player = s.player()
val pokemon = s.give(player, s.pokemon("bidoof", 5))
// A pool walled in two blocks high, so the Pokémon can't climb out.
for (x in 8..12) for (z in 2..6) for (y in 0..1) {
val inside = x in 9..11 && z in 3..5
if (!inside) s.helper.setBlock(x, y, z, Blocks.GLASS)
else if (y == 0) s.helper.setBlock(x, y, z, Blocks.LAVA)
}
s.helper.startSequence()
.thenExecute { sendOut(s, pokemon, 10.5, 4.5) }
.thenWaitUntil { s.assertGone(player, pokemon) }
.thenSucceed()
}
/** Out of battle, poison takes 5 % HP now and then (1 in 15 each second) and can knock a Pokémon out. */
private fun poison(s: Scenario) {
val player = s.player()
val pokemon = s.give(player, s.pokemon("bidoof", 10))
pokemon.applyStatus(Statuses.POISON)
pokemon.currentHealth = 1
val unlucky = object : Random() {
override fun nextBits(bitCount: Int) = 0
}
Statuses.POISON.onSecondPassed(player, pokemon, unlucky)
s.check(pokemon.isFainted()) { "poison did not knock the Pokémon out (HP ${pokemon.currentHealth})" }
s.helper.succeedWhen { s.assertGone(player, pokemon) }
}
/** Saved once; fainting again right after takes it at once (CobbleHardcoreMon alone would wait 60 s). */
private fun charmSavesOnce(s: Scenario) {
val player = s.player()
val pokemon = s.give(player, s.pokemon("bidoof", 10))
pokemon.swapHeldItem(ItemStack(s.charm()), false)
pokemon.currentHealth = 0
s.helper.startSequence()
.thenWaitUntil { s.assertSaved(player, pokemon) }
.thenExecute { pokemon.currentHealth = 0 }
.thenWaitUntil { s.assertGone(player, pokemon) }
.thenSucceed()
}
/** A fainted Pokémon that turns up in the PC later (here: fainted before it was stored) is caught by the sweep. */
private fun faintedInPc(s: Scenario) {
val player = s.player()
val lost = s.pokemon("bidoof", 10)
val saved = s.pokemon("bidoof", 10)
saved.swapHeldItem(ItemStack(s.charm()), false)
lost.currentHealth = 0
saved.currentHealth = 0
val pc = Cobblemon.storage.getPC(player)
s.helper.startSequence()
.thenIdle(2)
.thenExecute {
s.check(pc.add(lost) && pc.add(saved)) { "could not add to the PC" }
}
.thenWaitUntil {
s.assertGone(player, lost)
s.assertSaved(player, saved)
}
.thenSucceed()
}
private fun wildBattle(s: Scenario, charm: Boolean) {
val player = s.player()
val pokemon = s.give(player, s.pokemon("bidoof", 5, "explosion"))
if (charm) pokemon.swapHeldItem(ItemStack(s.charm()), false)
val wild = s.wild(s.pokemon("bidoof", 50, "splash"), s.at(10.5, 0.0, 7.5))
s.answerBattles(player) { _, _ -> MoveActionResponse("explosion") }
s.startWildBattle(player, wild)
.thenWaitUntil {
s.check(!player.isInBattle()) { "the battle is still running: ${s.battleState(player)}" }
if (charm) s.assertSaved(player, pokemon) else s.assertGone(player, pokemon)
}
.thenSucceed()
}
/**
* The lead faints and is healed while the battle goes on (as a Revive would), then the second one faints and
* the battle is lost. Both fainted in the battle, so both are gone.
*/
private fun revivedDuringBattle(s: Scenario) {
val player = s.player()
val first = s.give(player, s.pokemon("bidoof", 5, "explosion"))
val second = s.give(player, s.pokemon("bidoof", 5, "explosion"))
val wild = s.wild(s.pokemon("bidoof", 50, "splash"), s.at(10.5, 0.0, 7.5))
s.answerBattles(player) { _, request ->
if (request.forceSwitch.any { it }) {
first.heal()
SwitchActionResponse(second.uuid)
} else {
MoveActionResponse("explosion")
}
}
s.startWildBattle(player, wild)
.thenWaitUntil {
s.check(!player.isInBattle()) { "the battle is still running: ${s.battleState(player)}" }
s.assertGone(player, second)
s.assertGone(player, first)
}
.thenSucceed()
}
private fun trainerBattle(s: Scenario) {
val player = s.player()
val pokemon = s.give(player, s.pokemon("bidoof", 5, "explosion"))
val npc = NPCEntity(s.level)
npc.npc = NPCClasses.getByName("standard") ?: error("no NPC class standard")
npc.initialize(50)
s.spawn(npc, s.at(10.5, 0.0, 7.5))
npc.party = NPCPartyStore(npc).also { it.add(s.pokemon("magikarp", 50, "splash")) }
s.answerBattles(player) { _, _ -> MoveActionResponse("explosion") }
s.started(BattleBuilder.pvn(player, npc))
s.helper.succeedWhen {
s.check(!player.isInBattle()) { "the battle is still running" }
s.assertGone(player, pokemon)
}
}
private fun sendOut(s: Scenario, pokemon: Pokemon, x: Double, z: Double): PokemonEntity =
pokemon.sendOut(s.level, s.at(x, 0.0, z), null) {} ?: error("could not send out ${pokemon.species.name}")
/** Cobblemon makes a Pokémon invulnerable while the send-out beam plays. */
private fun readyToGetHurt(s: Scenario, entity: PokemonEntity) =
entity.isAlive && !entity.isInvulnerableTo(s.level.damageSources().fall())
}
@@ -0,0 +1,227 @@
package dev.cobblesurvival.selftest
import com.cobblemon.mod.common.Cobblemon
import com.cobblemon.mod.common.api.battles.model.actor.BattleActor
import com.cobblemon.mod.common.api.moves.Moves
import com.cobblemon.mod.common.api.pokemon.PokemonSpecies
import com.cobblemon.mod.common.battles.BattleBuilder
import com.cobblemon.mod.common.battles.BattleStartResult
import com.cobblemon.mod.common.battles.ErroredBattleStart
import com.cobblemon.mod.common.battles.ShowdownActionRequest
import com.cobblemon.mod.common.battles.ShowdownActionResponse
import com.cobblemon.mod.common.entity.pokemon.PokemonEntity
import com.cobblemon.mod.common.pokemon.Pokemon
import com.cobblemon.mod.common.util.getBattleState
import com.mojang.authlib.GameProfile
import dev.cobblesurvival.CobbleSurvival
import dev.cobblesurvival.permadeath.Permadeath
import io.netty.channel.embedded.EmbeddedChannel
import net.minecraft.core.registries.BuiltInRegistries
import net.minecraft.gametest.framework.GameTestHelper
import net.minecraft.gametest.framework.GameTestInfo
import net.minecraft.gametest.framework.GameTestListener
import net.minecraft.gametest.framework.GameTestRunner
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.level.ServerLevel
import net.minecraft.server.level.ServerPlayer
import net.minecraft.server.network.CommonListenerCookie
import net.minecraft.world.entity.Entity
import net.minecraft.world.item.Item
import net.minecraft.world.item.Items
import net.minecraft.world.phys.Vec3
import net.neoforged.neoforge.network.connection.ConnectionType
import net.neoforged.neoforge.network.registration.ChannelAttributes
import net.neoforged.neoforge.network.registration.NetworkPayloadSetup
import java.util.UUID
/** What one running test works with: its GameTest helper, a fake player, Pokémon, and cleanup when it ends. */
class Scenario(val case: SelfTestCase, private val index: Int) : GameTestListener {
lateinit var helper: GameTestHelper
private set
val level: ServerLevel get() = helper.level
/** null while running, then "passed" or the failure. */
var result: String? = null
private set
var passed = false
private set
private val cleanups = mutableListOf<() -> Unit>()
private var players = 0
fun begin(helper: GameTestHelper) {
this.helper = helper
case.body(this)
}
/** A position inside the test area (x and z 0..15, y 0..12; y 0 is the floor). */
fun at(x: Double, y: Double, z: Double): Vec3 = helper.absoluteVec(Vec3(x, y, z))
fun check(condition: Boolean, message: () -> String) = helper.assertTrue(condition, message())
/**
* A player as the server sees one: in the player list, ticked every tick. Its connection goes nowhere and
* accepts every mod's network channel, since NeoForge refuses to send mod packets on an unnegotiated one.
*/
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 pos = at(3.5, 0.0, 7.5)
player.teleportTo(level, pos.x, pos.y, pos.z, 0f, 0f)
cleanups += { server.playerList.remove(player) }
helper.onEachTick { if (!player.hasDisconnected()) player.doTick() }
return player
}
/** Runs [action] when the test ends, pass or fail. */
fun afterTest(action: () -> Unit) {
cleanups += action
}
/**
* Moves a test player somewhere else and keeps the world there running for it. A real client reports the move,
* which updates chunk tracking; without that, the player sits in chunks that don't tick and mods that act on
* player ticks (Cold Sweat's temperature) stop.
*/
fun moveTo(player: ServerPlayer, x: Double, y: Double, z: Double) {
val chunkX = Math.floorDiv(x.toInt(), 16)
val chunkZ = Math.floorDiv(z.toInt(), 16)
for (dx in -1..1) for (dz in -1..1) {
if (level.setChunkForced(chunkX + dx, chunkZ + dz, true)) {
afterTest { level.setChunkForced(chunkX + dx, chunkZ + dz, false) }
}
}
player.teleportTo(level, x, y, z, 0f, 0f)
level.chunkSource.move(player)
}
fun pokemon(species: String, level: Int, vararg moves: String): Pokemon {
val pokemon = (PokemonSpecies.getByName(species) ?: error("no species $species")).create(level)
if (moves.isNotEmpty()) {
pokemon.moveSet.clear()
moves.forEachIndexed { slot, move ->
pokemon.moveSet.setMove(slot, (Moves.getByName(move) ?: error("no move $move")).create())
}
}
return pokemon
}
fun give(player: ServerPlayer, pokemon: Pokemon): Pokemon {
check(Cobblemon.storage.getParty(player).add(pokemon)) { "could not add ${pokemon.species.name} to the party" }
return pokemon
}
fun <T : Entity> spawn(entity: T, pos: Vec3): T {
entity.moveTo(pos.x, pos.y, pos.z, 0f, 0f)
check(level.addFreshEntity(entity)) { "${entity.type} did not join the world" }
cleanups += { entity.discard() }
return entity
}
fun wild(pokemon: Pokemon, pos: Vec3): PokemonEntity = spawn(PokemonEntity(level, pokemon), pos)
/** The pack's Revival Charm, a KubeJS item; it must be in the tag the companion mod checks. */
fun charm(): Item {
val item = BuiltInRegistries.ITEM.get(ResourceLocation.fromNamespaceAndPath(CobbleSurvival.MOD_ID, "revival_charm"))
check(item != Items.AIR) { "the Revival Charm item does not exist" }
check(item.defaultInstance.`is`(Permadeath.REVIVAL_CHARMS)) { "the Revival Charm is not in #cobblesurvival:revival_charms" }
return item
}
fun assertGone(player: ServerPlayer, pokemon: Pokemon) {
val name = pokemon.species.name
check(Cobblemon.storage.getParty(player).none { it.uuid == pokemon.uuid }) { "$name is still in the party (HP ${pokemon.currentHealth})" }
check(Cobblemon.storage.getPC(player).none { it.uuid == pokemon.uuid }) { "$name is still in the PC (HP ${pokemon.currentHealth})" }
}
fun assertSaved(player: ServerPlayer, pokemon: Pokemon) {
val name = pokemon.species.name
val stored = Cobblemon.storage.getParty(player).any { it.uuid == pokemon.uuid } ||
Cobblemon.storage.getPC(player).any { it.uuid == pokemon.uuid }
check(stored) { "$name was removed although it held a Revival Charm" }
check(pokemon.currentHealth == pokemon.maxHealth) { "$name was not healed (HP ${pokemon.currentHealth}/${pokemon.maxHealth})" }
check(pokemon.heldItem().isEmpty) { "$name still holds ${pokemon.heldItem()}" }
}
/** Answers the player's battle requests each tick, as the battle screen would; null waits instead. */
fun answerBattles(player: ServerPlayer, choose: (BattleActor, ShowdownActionRequest) -> ShowdownActionResponse?) {
helper.onEachTick {
val actor = player.getBattleState()?.second ?: return@onEachTick
val request = actor.request ?: return@onEachTick
if (actor.mustChoose) choose(actor, request)?.let { actor.setActionResponses(listOf(it)) }
}
}
/**
* A wild Pokémon is tied to its entity on the entity's first tick; a battle started before that never asks the
* wild side for a move. Natural spawns are always older than that.
*/
fun startWildBattle(player: ServerPlayer, wild: PokemonEntity): GameTestSequence =
helper.startSequence()
.thenWaitUntil { check(wild.pokemon.entity === wild) { "the wild Pokémon has not ticked yet" } }
.thenExecute { started(BattleBuilder.pve(player, wild)) }
fun started(result: BattleStartResult) {
if (result is ErroredBattleStart) {
helper.fail("the battle did not start: ${result.errors.joinToString { it.javaClass.simpleName }}")
}
}
/** For failure messages: where the player's battle is stuck (the full Showdown log goes to the server log). */
private var battleDump = ""
fun battleState(player: ServerPlayer): String {
val (battle, actor) = player.getBattleState() ?: return "no battle"
battleDump = battle.battleLog.joinToString("\n")
val recent = battle.showdownMessages.takeLast(3).joinToString(" | ") { it.replace('\n', ' ').take(160) }
val entities = Cobblemon.storage.getParty(player).joinToString { pokemon ->
val e = pokemon.entity ?: return@joinToString "${pokemon.species.name} not out"
"${pokemon.species.name} at %.1f %.1f %.1f beam ${e.beamMode} busy ${e.isBusy}".format(e.x, e.y, e.z)
}
return "turn ${battle.turn}, started ${battle.started}, ended ${battle.ended}, must choose ${actor.mustChoose}, " +
"request ${actor.request != null}, dispatches ${battle.dispatches.size}, party: $entities, " +
"last showdown messages: $recent"
}
override fun testStructureLoaded(info: GameTestInfo) {}
override fun testPassed(info: GameTestInfo, runner: GameTestRunner) = finish(null)
override fun testFailed(info: GameTestInfo, runner: GameTestRunner) =
finish(info.error?.let { it.message ?: it.toString() } ?: "failed")
override fun testAddedForRerun(original: GameTestInfo, rerun: GameTestInfo, runner: GameTestRunner) {}
/** Ends the test from outside, when the test code threw something the GameTest framework doesn't catch. */
fun abort(error: Throwable) {
if (result == null) finish("crashed: $error")
}
private fun finish(failure: String?) {
if (failure != null && battleDump.isNotEmpty()) CobbleSurvival.LOGGER.warn("selftest {} battle log:\n{}", case.name, battleDump)
for (cleanup in cleanups.asReversed()) {
try {
cleanup()
} catch (e: Exception) {
CobbleSurvival.LOGGER.warn("selftest {}: cleanup failed", case.name, e)
}
}
passed = failure == null
result = failure ?: "passed"
if (passed) CobbleSurvival.LOGGER.info("selftest PASS {}", case.name)
else CobbleSurvival.LOGGER.error("selftest FAIL {}: {}", case.name, failure)
}
private class EveryChannel : HashSet<ResourceLocation>() {
override fun contains(element: ResourceLocation) = true
}
}
@@ -0,0 +1,125 @@
package dev.cobblesurvival.selftest
import com.mojang.brigadier.builder.LiteralArgumentBuilder
import dev.cobblesurvival.CobbleSurvival
import net.minecraft.commands.CommandSourceStack
import net.minecraft.commands.Commands
import net.minecraft.core.BlockPos
import net.minecraft.gametest.framework.GameTestInfo
import net.minecraft.gametest.framework.GameTestRunner
import net.minecraft.gametest.framework.GameTestTicker
import net.minecraft.gametest.framework.RetryOptions
import net.minecraft.gametest.framework.StructureGridSpawner
import net.minecraft.gametest.framework.TestFunction
import net.minecraft.network.chat.Component
import net.minecraft.server.MinecraftServer
import net.minecraft.world.level.ChunkPos
import net.minecraft.world.level.biome.Biomes
import net.minecraft.world.level.block.Rotation
import net.neoforged.neoforge.common.NeoForge
import net.neoforged.neoforge.event.tick.ServerTickEvent
/**
* One in-game test: [body] sets it up through its [Scenario] and must end in a pass or a failure within [maxTicks].
* [islandOnly] tests need the island world and are skipped elsewhere (tools/selftest.py uses a flat world).
* Tests of one [batch] run side by side; batches run one after another (a test that changes the world around it,
* like the biome or the time of day, gets a batch of its own).
*/
class SelfTestCase(
val name: String,
val maxTicks: Int,
val body: (Scenario) -> Unit,
val islandOnly: Boolean = false,
val batch: String = CobbleSurvival.MOD_ID,
)
/**
* In-game tests on a real server with all of the pack's mods and configs: `/cobblesurvival selftest run` starts them
* on a platform high above spawn, `/cobblesurvival selftest status` reports. They use the vanilla GameTest
* framework, which NeoForge only switches on in a development environment, so this object creates and ticks the
* tests itself. Only there when the server runs with `-Dcobblesurvival.selftest=true` (tools/quick_check.py);
* players never see the command.
*/
object SelfTest {
val enabled: Boolean = java.lang.Boolean.getBoolean("cobblesurvival.selftest")
private const val TEMPLATE = "cobblesurvival:selftest/empty"
private const val PLATFORM_Y = 200
private const val TESTS_PER_ROW = 4
private const val FORCED_CHUNKS = 9
private val cases: List<SelfTestCase> get() =
PermadeathTests.all() + SurvivalRulesTests.all() + ZoneTests.all() + HazardTests.all()
private var running: List<Scenario> = emptyList()
private var forced: List<ChunkPos> = emptyList()
private var summary = "selftest not run"
fun register() {
if (enabled) NeoForge.EVENT_BUS.addListener(::onServerTick)
}
fun command(): LiteralArgumentBuilder<CommandSourceStack> =
Commands.literal("selftest")
.requires { it.hasPermission(4) }
.then(Commands.literal("run").executes { context ->
val reply = start(context.source.server)
context.source.sendSuccess({ Component.literal(reply) }, false)
1
})
.then(Commands.literal("status").executes { context ->
val done = running.count { it.result != null }
val reply = if (running.isNotEmpty()) "selftest running: $done/${running.size} done" else summary
context.source.sendSuccess({ Component.literal(reply) }, false)
1
})
private fun start(server: MinecraftServer): String {
if (running.isNotEmpty()) return "selftest already running"
val level = server.overworld()
val spawn = level.sharedSpawnPos
val origin = BlockPos(spawn.x, PLATFORM_Y, spawn.z)
val corner = ChunkPos(origin)
forced = (0 until FORCED_CHUNKS).flatMap { dx -> (0 until FORCED_CHUNKS).map { dz -> ChunkPos(corner.x + dx, corner.z + dz) } }
forced.forEach { level.setChunkForced(it.x, it.z, true) }
// Only the island world type puts basalt deltas into the overworld.
val island = level.chunkSource.generator.biomeSource.possibleBiomes().any { it.`is`(Biomes.BASALT_DELTAS) }
val (runnable, skipped) = cases.partition { island || !it.islandOnly }
val scenarios = runnable.mapIndexed { index, case -> Scenario(case, index) }
val infos = scenarios.map { scenario ->
val function = TestFunction(scenario.case.batch, "${CobbleSurvival.MOD_ID}.${scenario.case.name}", TEMPLATE,
scenario.case.maxTicks, 0L, true) { helper -> scenario.begin(helper) }
GameTestInfo(function, Rotation.NONE, level, RetryOptions.noRetries()).also { it.addListener(scenario) }
}
running = scenarios
GameTestTicker.SINGLETON.clear()
GameTestRunner.Builder.fromInfo(infos, level)
.newStructureSpawner(StructureGridSpawner(origin, TESTS_PER_ROW, false))
.build()
.start()
val skippedNote = if (skipped.isEmpty()) "" else ", skipped ${skipped.size} that need the island world"
CobbleSurvival.LOGGER.info("selftest started {} tests at {}{}", infos.size, origin.toShortString(), skippedNote)
return "selftest started ${infos.size} tests$skippedNote"
}
private fun onServerTick(event: ServerTickEvent.Post) {
if (running.isEmpty()) return
try {
GameTestTicker.SINGLETON.tick()
} catch (e: Exception) {
// Only assertion failures are handled by the framework; anything else would crash the server.
CobbleSurvival.LOGGER.error("selftest: a test threw; ending the run", e)
running.forEach { it.abort(e) }
}
if (running.any { it.result == null }) return
val failed = running.filter { !it.passed }
summary = "selftest finished: ${running.size - failed.size}/${running.size} passed" +
failed.joinToString("") { "; FAILED ${it.case.name}: ${it.result}" }
if (failed.isEmpty()) CobbleSurvival.LOGGER.info(summary) else CobbleSurvival.LOGGER.error(summary)
GameTestTicker.SINGLETON.clear()
val level = event.server.overworld()
forced.forEach { level.setChunkForced(it.x, it.z, false) }
running = emptyList()
}
}
@@ -0,0 +1,113 @@
package dev.cobblesurvival.selftest
import com.cobblemon.mod.common.Cobblemon
import com.cobblemon.mod.common.CobblemonBlocks
import com.cobblemon.mod.common.CobblemonItems
import com.cobblemon.mod.common.api.storage.pc.link.PCLinkManager
import com.cobblemon.mod.common.battles.MoveActionResponse
import com.cobblemon.mod.common.util.getBattleState
import com.cobblemon.mod.common.util.isInBattle
import dev.cobblesurvival.CobbleSurvival
import net.minecraft.core.BlockPos
import net.minecraft.core.Direction
import net.minecraft.resources.ResourceLocation
import net.minecraft.world.InteractionHand
import net.minecraft.world.item.ItemStack
import net.minecraft.world.item.Items
import net.minecraft.world.level.block.Blocks
import net.minecraft.world.phys.BlockHitResult
import net.minecraft.world.phys.Vec3
/** Roadmap Phase 2, besides permadeath: healing friction, storage only at PC blocks, free movement in battles. */
object SurvivalRulesTests {
/** Cobblemon's own value; the pack's must be slower. */
private const val COBBLEMON_SECONDS_TO_CHARGE = 900.0
fun all() = listOf(
SelfTestCase("healing_machine_is_expensive", 20, ::healingMachineIsExpensive),
SelfTestCase("no_remote_pc", 40, ::noRemotePc),
SelfTestCase("free_movement_in_battle", 1200, ::freeMovementInBattle),
)
/** The pack replaces Cobblemon's recipe (kubejs/server_scripts/healing_machine.js) and slows the recharge. */
private fun healingMachineIsExpensive(s: Scenario) {
val recipes = s.level.server.recipeManager
s.check(recipes.byKey(ResourceLocation.fromNamespaceAndPath("cobblemon", "healing_machine")).isEmpty) {
"Cobblemon's own Healing Machine recipe is still there"
}
val recipe = recipes.byKey(ResourceLocation.fromNamespaceAndPath(CobbleSurvival.MOD_ID, "healing_machine")).orElse(null)
s.check(recipe != null) { "the pack's Healing Machine recipe is missing" }
s.check(recipe.value().getResultItem(s.level.registryAccess()).`is`(CobblemonItems.HEALING_MACHINE)) {
"the pack's recipe doesn't make a Healing Machine"
}
val config = Cobblemon.config
s.check(!config.infiniteHealerCharge) { "Healing Machines never run out of charge" }
s.check(config.secondsToChargeHealingMachine > COBBLEMON_SECONDS_TO_CHARGE) {
"Healing Machines recharge as fast as in Cobblemon (${config.secondsToChargeHealingMachine} s)"
}
s.helper.succeed()
}
/** `/pc` needs cheats, and a PC opened at a block stops working once the player walks away from it. */
private fun noRemotePc(s: Scenario) {
val player = s.player()
val server = s.level.server
// Cobblemon asks NeoForge's permission API, which looks at the player's operator status.
fun pcCommandWorks(): Boolean {
val parse = server.commands.dispatcher.parse("pc", player.createCommandSourceStack())
return parse.context.nodes.isNotEmpty() && !parse.reader.canRead()
}
s.check(!pcCommandWorks()) { "a player without cheats can open the PC anywhere with /pc" }
server.playerList.op(player.gameProfile)
val asOperator = pcCommandWorks()
server.playerList.deop(player.gameProfile)
s.check(asOperator) { "/pc doesn't work for operators either, so this test can't tell" }
val pos = s.helper.absolutePos(BlockPos(5, 0, 7))
val state = CobblemonBlocks.PC.defaultBlockState()
s.level.setBlockAndUpdate(pos, state)
CobblemonBlocks.PC.setPlacedBy(s.level, pos, state, player, ItemStack.EMPTY)
state.useWithoutItem(s.level, player, BlockHitResult(Vec3.atCenterOf(pos), Direction.WEST, pos, false))
s.check(PCLinkManager.getPC(player) != null) { "using a PC block didn't open the PC" }
s.helper.startSequence()
.thenExecute { player.teleportTo(s.level, player.x + 40, player.y, player.z, 0f, 0f) }
.thenWaitUntil { s.check(PCLinkManager.getPC(player) == null) { "the PC still works 40 blocks away from it" } }
.thenSucceed()
}
/**
* Mid-battle the player can still break and place blocks and walk a few blocks away (Phase 7 bosses rely on it);
* the battle keeps going until the player answers. Cobblemon's own key to leave the battle screen is a client
* thing and stays a manual check.
*/
private fun freeMovementInBattle(s: Scenario) {
val player = s.player()
s.give(player, s.pokemon("bidoof", 5, "explosion"))
val wild = s.wild(s.pokemon("bidoof", 50, "splash"), s.at(10.5, 0.0, 7.5))
val toBreak = s.helper.absolutePos(BlockPos(3, 0, 10))
val floor = s.helper.absolutePos(BlockPos(3, -1, 4))
s.helper.setBlock(BlockPos(3, 0, 10), Blocks.DIRT)
var done = false
s.answerBattles(player) { _, _ -> if (done) MoveActionResponse("explosion") else null }
s.startWildBattle(player, wild)
.thenWaitUntil {
s.check(player.isInBattle() && player.getBattleState()?.second?.mustChoose == true) { "the battle hasn't asked for a move yet" }
}
.thenExecute {
s.check(player.gameMode.destroyBlock(toBreak)) { "can't break a block during a battle" }
player.setItemInHand(InteractionHand.MAIN_HAND, ItemStack(Items.DIRT))
player.gameMode.useItemOn(player, s.level, player.mainHandItem, InteractionHand.MAIN_HAND,
BlockHitResult(Vec3.atCenterOf(floor).add(0.0, 0.5, 0.0), Direction.UP, floor, false))
s.check(s.level.getBlockState(floor.above()).`is`(Blocks.DIRT)) { "can't place a block during a battle" }
player.teleportTo(s.level, player.x + 6, player.y, player.z, 0f, 0f)
}
.thenIdle(40)
.thenExecute {
s.check(player.isInBattle()) { "breaking, placing or walking 6 blocks ended the battle" }
done = true
}
.thenWaitUntil { s.check(!player.isInBattle()) { "the battle is still running: ${s.battleState(player)}" } }
.thenSucceed()
}
}
@@ -0,0 +1,149 @@
package dev.cobblesurvival.selftest
import com.cobblemon.mod.common.Cobblemon
import com.cobblemon.mod.common.api.spawning.CobblemonSpawnPools
import com.cobblemon.mod.common.api.spawning.SpawnCause
import com.cobblemon.mod.common.api.spawning.detail.PokemonSpawnDetail
import com.cobblemon.mod.common.api.spawning.detail.SpawnDetail
import com.cobblemon.mod.common.api.spawning.position.calculators.SpawnablePositionCalculator
import com.cobblemon.mod.common.api.spawning.spawner.SpawningZoneInput
import com.cobblemon.mod.common.util.spawner
import dev.cobblesurvival.CobbleSurvival
import dev.cobblesurvival.zone.Zone
import dev.cobblesurvival.zone.ZoneSample
import dev.cobblesurvival.zone.Zones
import net.minecraft.core.BlockPos
import net.minecraft.gametest.framework.GameTestAssertException
import net.minecraft.server.level.ServerPlayer
import net.minecraft.util.Mth
import net.minecraft.world.level.levelgen.Heightmap
/**
* Roadmap Phase 3: each zone has its own Pokémon and levels. The spawn pools come from tools/zone_spawns.py;
* Cobblemon's own spawn files are filtered out by datapacks/core/pack.mcmeta.
*/
object ZoneTests {
/** How far (blocks, each way) the survey looks for an open cave around a cave zone's sample point. */
private const val CAVE_SEARCH = 32
/** Air above a cave floor: big Pokémon (Basalt Furnace has Charizard, Turtonator) only spawn where they fit. */
private const val HEADROOM = 4
fun all() = listOf(
SelfTestCase("spawn_pools_are_zoned", 20, ::spawnPoolsAreZoned),
SelfTestCase("spawn_survey", 2400, ::spawnSurvey, islandOnly = true),
)
private fun zoneOf(detail: SpawnDetail): Zone? = Zones.byId[detail.id.substringBefore('-')]
/** Every loaded spawn entry belongs to a zone and spawns within its level band; none of Cobblemon's is left. */
private fun spawnPoolsAreZoned(s: Scenario) {
val details = CobblemonSpawnPools.WORLD_SPAWN_POOL.details
s.check(Zones.byId.isNotEmpty()) { "no zones loaded" }
s.check(details.isNotEmpty()) { "no spawn entries loaded at all" }
val unzoned = details.filter { zoneOf(it) == null }
s.check(unzoned.isEmpty()) { "${unzoned.size} spawn entries belong to no zone, e.g. ${unzoned.take(5).map { it.id }}" }
val outOfBand = details.filterIsInstance<PokemonSpawnDetail>().filter { detail ->
val zone = zoneOf(detail)!!
val levels = detail.levelRange
levels == null || levels.first < zone.minLevel || levels.last > zone.maxLevel
}
s.check(outOfBand.isEmpty()) { "${outOfBand.size} entries spawn outside their zone's levels, e.g. ${outOfBand.take(5).map { "${it.id} ${it.levelRange}" }}" }
s.helper.succeed()
}
/**
* Stands a player deep inside every zone and asks Cobblemon what could spawn around them right now, the way
* `/checkspawn` does. Everything offered must belong to that zone.
*/
private fun spawnSurvey(s: Scenario) {
val player = s.player()
val home = player.position()
val report = mutableListOf<String>()
val problems = mutableListOf<String>()
s.helper.startSequence().thenExecute {
for ((zoneId, sample) in Zones.samples) {
try {
val (offered, positions) = survey(s, player, zoneId, sample)
val strangers = offered.filter { zoneOf(it)?.id != zoneId }
report += "$zoneId ${offered.size} (${positions} positions at ${player.blockPosition().toShortString()})"
if (offered.isEmpty()) problems += "nothing can spawn at the $zoneId sample ($positions spawnable positions)"
if (strangers.isNotEmpty()) problems += "$zoneId offers other zones' spawns: ${strangers.take(5).map { it.id }}"
} catch (e: GameTestAssertException) {
problems += e.message ?: "$zoneId failed"
}
}
player.teleportTo(s.level, home.x, home.y, home.z, 0f, 0f)
CobbleSurvival.LOGGER.info("selftest spawn_survey: spawn entries per zone sample: {}", report.joinToString())
s.check(problems.isEmpty()) { problems.joinToString("; ") }
}.thenSucceed()
}
/** What can spawn around the sample right now, and how many spawnable positions Cobblemon found there. */
private fun survey(s: Scenario, player: ServerPlayer, zoneId: String, sample: ZoneSample): Pair<Set<SpawnDetail>, Int> {
val level = s.level
// Generate the chunks around the sample, then find where to stand.
for (dx in -2..2) for (dz in -2..2) level.getChunk((sample.x shr 4) + dx, (sample.z shr 4) + dz)
val stand = if (sample.y == null) {
BlockPos(sample.x, level.getHeight(Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, sample.x, sample.z), sample.z)
} else {
caveFloor(s, zoneId, sample, sample.y)
?: throw GameTestAssertException("no open $zoneId cave near ${sample.x} ${sample.y} ${sample.z}")
}
player.teleportTo(level, stand.x + 0.5, stand.y.toDouble(), stand.z + 0.5, 0f, 0f)
val spawner = player.spawner
val config = Cobblemon.config
val diameter = config.spawningZoneDiameter
val height = config.spawningZoneHeight
val input = SpawningZoneInput(
SpawnCause(spawner, player), level,
Mth.ceil(player.x - diameter / 2.0), Mth.ceil(player.y - height / 2.0), Mth.ceil(player.z - diameter / 2.0),
diameter, height, diameter,
)
val zone = Cobblemon.spawningZoneGenerator.generate(spawner, input)
val positions = Cobblemon.areaSpawnablePositionResolver.resolve(
spawner, SpawnablePositionCalculator.prioritizedAreaCalculators, zone,
)
val offered = CobblemonSpawnPools.WORLD_SPAWN_POOL.buckets.flatMap { bucket ->
spawner.selector.getProbabilities(spawner, bucket, positions).filterValues { it > 0f }.keys
}.toSet()
if (offered.isEmpty()) {
// Why nothing: per entry of this zone, does any position satisfy it, and if not, its conditions.
for (detail in CobblemonSpawnPools.WORLD_SPAWN_POOL.details.filter { zoneOf(it)?.id == zoneId }) {
val typed = positions.filter { detail.spawnablePositionType.clazz.isInstance(it) }
val satisfied = typed.count { detail.isSatisfiedBy(it) }
val conditions = typed.firstOrNull()?.let { p -> detail.conditions.map { it.isSatisfiedBy(p) } }
val anti = typed.firstOrNull()?.let { p -> detail.anticonditions.map { it.isSatisfiedBy(p) } }
val p = typed.firstOrNull()
CobbleSurvival.LOGGER.warn("selftest survey {}: {} positions of its type {}, satisfied {}, conditions {}, anticonditions {}, light {} sky {} biome {}",
detail.id, typed.size, detail.spawnablePositionType, satisfied, conditions, anti, p?.light, p?.skyLight, p?.biomeHolder?.unwrapKey()?.orElse(null))
}
}
return offered to positions.size
}
/** Whether Cobblemon's spawning area around [pos] (sampled per 4-block biome cell) lies inside [zoneId]. */
private fun wholeAreaIn(s: Scenario, pos: BlockPos, zoneId: String): Boolean {
val half = Cobblemon.config.spawningZoneDiameter / 2
val halfHeight = Cobblemon.config.spawningZoneHeight / 2
for (dx in -half..half step 4) for (dz in -half..half step 4) for (dy in -halfHeight..halfHeight step 4) {
if (Zones.at(s.level, pos.offset(dx, dy, dz))?.id != zoneId) return false
}
return true
}
/** An open cave floor (HEADROOM blocks of air above something solid) near the sample, its spawning area all in the zone. */
private fun caveFloor(s: Scenario, zoneId: String, sample: ZoneSample, y: Int): BlockPos? {
val level = s.level
for (radius in 0..CAVE_SEARCH step 2) {
for (dx in -radius..radius) for (dz in -radius..radius) {
if (maxOf(kotlin.math.abs(dx), kotlin.math.abs(dz)) != radius) continue
for (dy in 0..CAVE_SEARCH) for (sign in intArrayOf(1, -1)) {
val pos = BlockPos(sample.x + dx, y + sign * dy, sample.z + dz)
val open = (0 until HEADROOM).all { level.getBlockState(pos.above(it)).isAir }
if (open && level.getBlockState(pos.below()).isSolid && wholeAreaIn(s, pos, zoneId)) return pos
}
}
}
return null
}
}