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,103 @@
package dev.cobblesurvival
import com.cobblemon.mod.common.Cobblemon
import com.cobblemon.mod.common.api.pokemon.PokemonSpecies
import com.mojang.logging.LogUtils
import dev.cobblesurvival.dev.BiomePreview
import dev.cobblesurvival.permadeath.Permadeath
import dev.cobblesurvival.selftest.SelfTest
import dev.cobblesurvival.world.OnlyPokemon
import dev.cobblesurvival.zone.ZoneCondition
import dev.cobblesurvival.zone.Zones
import net.minecraft.commands.Commands
import net.minecraft.core.BlockPos
import net.minecraft.network.chat.Component
import net.minecraft.resources.ResourceLocation
import net.minecraft.server.level.ServerLevel
import net.minecraft.server.packs.PackType
import net.minecraft.server.packs.repository.Pack
import net.minecraft.server.packs.repository.PackSource
import net.minecraft.world.level.Level
import net.neoforged.fml.ModList
import net.neoforged.fml.common.Mod
import net.neoforged.neoforge.common.NeoForge
import net.neoforged.neoforge.event.AddPackFindersEvent
import net.neoforged.neoforge.event.RegisterCommandsEvent
import net.neoforged.neoforge.event.level.BlockEvent
import net.neoforged.neoforge.event.server.ServerStartedEvent
import org.slf4j.Logger
import thedarkcolour.kotlinforforge.neoforge.forge.MOD_BUS
@Mod(CobbleSurvival.MOD_ID)
class CobbleSurvival {
companion object {
const val MOD_ID = "cobblesurvival"
val LOGGER: Logger = LogUtils.getLogger()
val version: String
get() = ModList.get().getModContainerById(MOD_ID)
.map { it.modInfo.version.toString() }
.orElse("unknown")
/** One line the server smoke test reads over RCON to confirm the mod is live. */
fun statusLine() =
"$MOD_ID $version ok; cobblemon ${Cobblemon.VERSION}; species ${PokemonSpecies.count()}"
}
init {
LOGGER.info("Cobblemon Survival companion {} loaded (Cobblemon {})", version, Cobblemon.VERSION)
MOD_BUS.addListener(::onAddPackFinders)
NeoForge.EVENT_BUS.addListener(::onRegisterCommands)
NeoForge.EVENT_BUS.addListener(::onServerStarted)
NeoForge.EVENT_BUS.addListener(::onPortalSpawn)
NeoForge.EVENT_BUS.addListener(OnlyPokemon::onChunkLoad)
NeoForge.EVENT_BUS.addListener(OnlyPokemon::onEntityJoin)
Permadeath.register()
ZoneCondition.register()
NeoForge.EVENT_BUS.addListener(Zones::onAddReloadListeners)
SelfTest.register()
}
/** The island world type has no Nether, so a lit obsidian frame must not turn into a dead portal. */
private fun onPortalSpawn(event: BlockEvent.PortalSpawnEvent) {
val level = event.level as? ServerLevel ?: return
if (level.server.getLevel(Level.NETHER) == null) {
event.isCanceled = true
}
}
/** Ships datapacks/core from the repo as a built-in data pack that is always on, in every world. */
private fun onAddPackFinders(event: AddPackFindersEvent) {
event.addPackFinders(
ResourceLocation.fromNamespaceAndPath(MOD_ID, "datapacks/core"),
PackType.SERVER_DATA,
Component.literal("Cobblemon Survival core data"),
PackSource.BUILT_IN,
true,
Pack.Position.TOP,
)
}
private fun onRegisterCommands(event: RegisterCommandsEvent) {
val root = Commands.literal(MOD_ID)
.requires { it.hasPermission(2) }
.then(Commands.literal("status").executes { context ->
context.source.sendSuccess({ Component.literal(statusLine()) }, false)
1
})
.then(BiomePreview.command())
.then(Commands.literal("zone").executes { context ->
val source = context.source
val zone = Zones.at(source.level, BlockPos.containing(source.position))
val text = zone?.let { "${it.name} (${it.id}), Lv ${it.minLevel}-${it.maxLevel}" } ?: "no zone"
source.sendSuccess({ Component.literal(text) }, false)
1
})
if (SelfTest.enabled) root.then(SelfTest.command())
event.dispatcher.register(root)
}
private fun onServerStarted(event: ServerStartedEvent) {
LOGGER.info("Cobblemon Survival companion ready: {}", statusLine())
}
}
@@ -0,0 +1,110 @@
package dev.cobblesurvival.dev
import com.google.gson.GsonBuilder
import com.google.gson.JsonArray
import com.google.gson.JsonObject
import com.mojang.brigadier.arguments.IntegerArgumentType
import com.mojang.brigadier.arguments.StringArgumentType
import com.mojang.brigadier.builder.LiteralArgumentBuilder
import net.minecraft.commands.CommandSourceStack
import net.minecraft.commands.Commands
import net.minecraft.core.QuartPos
import net.minecraft.core.registries.Registries
import net.minecraft.network.chat.Component
import net.minecraft.server.level.ServerLevel
import net.minecraft.world.level.levelgen.Heightmap
import net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator
import net.minecraft.world.level.levelgen.RandomState
import net.minecraft.world.level.levelgen.WorldOptions
import net.minecraft.world.level.storage.LevelResource
import java.nio.file.Files
import java.util.concurrent.ConcurrentHashMap
import java.util.stream.IntStream
/**
* Dev tool: `/cobblesurvival biomepreview <seed|current> <radius> <step>` samples terrain height and
* biomes of the overworld generator on a grid around 0,0 for any seed, without generating chunks,
* and writes `<world>/cobblesurvival/biome_preview/<seed>.json` for tools/seed_search.py.
*
* WorldGen Editor's coastline noise always uses the open world's seed, so for another seed the
* coastline is approximate; terrain and biomes follow the requested seed.
*/
object BiomePreview {
/** Absolute y levels sampled for cave biomes. */
private val CAVE_LEVELS = intArrayOf(40, 0, -30, -52)
fun command(): LiteralArgumentBuilder<CommandSourceStack> =
Commands.literal("biomepreview")
.requires { it.hasPermission(4) }
.then(Commands.argument("seed", StringArgumentType.word())
.then(Commands.argument("radius", IntegerArgumentType.integer(16, 8192))
.then(Commands.argument("step", IntegerArgumentType.integer(4, 256))
.executes { context ->
val seed = StringArgumentType.getString(context, "seed")
val radius = IntegerArgumentType.getInteger(context, "radius")
val step = IntegerArgumentType.getInteger(context, "step")
val started = System.currentTimeMillis()
val path = run(context.source.server.overworld(), seed, radius, step)
val seconds = (System.currentTimeMillis() - started) / 1000.0
context.source.sendSuccess({ Component.literal("biome preview written: $path (${seconds}s)") }, false)
1
})))
private fun run(level: ServerLevel, seed: String, radius: Int, step: Int): String {
val generator = level.chunkSource.generator
val randomState = if (seed == "current") {
level.chunkSource.randomState()
} else {
val settings = (generator as NoiseBasedChunkGenerator).generatorSettings().value()
val noises = level.registryAccess().lookupOrThrow(Registries.NOISE)
RandomState.create(settings, noises, parseSeed(seed))
}
val sampler = randomState.sampler()
val biomeSource = generator.biomeSource
val names = ConcurrentHashMap<String, Int>()
fun biomeAt(x: Int, y: Int, z: Int): Int {
val holder = biomeSource.getNoiseBiome(QuartPos.fromBlock(x), QuartPos.fromBlock(y), QuartPos.fromBlock(z), sampler)
val name = holder.unwrapKey().map { it.location().toString() }.orElse("unknown")
return names[name] ?: synchronized(names) { names.getOrPut(name) { names.size } }
}
// Rows run in parallel: noise sampling is thread-safe, as chunk generation relies on it too.
val size = (2 * radius) / step + 1
val height = IntArray(size * size)
val surface = IntArray(size * size)
val caves = CAVE_LEVELS.associateWith { IntArray(size * size) }
IntStream.range(0, size).parallel().forEach { row ->
val z = -radius + row * step
for (col in 0 until size) {
val x = -radius + col * step
val i = row * size + col
val top = generator.getBaseHeight(x, z, Heightmap.Types.OCEAN_FLOOR_WG, level, randomState)
height[i] = top
surface[i] = biomeAt(x, maxOf(top - 1, level.seaLevel - 1), z)
for ((y, grid) in caves) grid[i] = biomeAt(x, y, z)
}
}
val out = JsonObject()
out.addProperty("seed", seed)
out.addProperty("radius", radius)
out.addProperty("step", step)
out.addProperty("size", size)
out.addProperty("sea_level", level.seaLevel)
out.add("biomes", JsonArray().apply { names.entries.sortedBy { it.value }.forEach { add(it.key) } })
out.add("height", toJson(height))
out.add("surface", toJson(surface))
out.add("caves", JsonObject().apply { caves.forEach { (y, grid) -> add(y.toString(), toJson(grid)) } })
val dir = level.server.getWorldPath(LevelResource.ROOT).resolve("cobblesurvival/biome_preview")
Files.createDirectories(dir)
val file = dir.resolve("$seed.json")
Files.writeString(file, GsonBuilder().create().toJson(out))
return file.toString()
}
private fun toJson(values: IntArray) = JsonArray(values.size).apply { values.forEach { add(it) } }
private fun parseSeed(seed: String): Long = WorldOptions.parseSeed(seed).orElse(seed.hashCode().toLong())
}
@@ -0,0 +1,117 @@
package dev.cobblesurvival.permadeath
import com.cobblemon.mod.common.Cobblemon
import com.cobblemon.mod.common.api.Priority
import com.cobblemon.mod.common.api.events.CobblemonEvents
import com.cobblemon.mod.common.api.events.battles.BattleEndedEvent
import com.cobblemon.mod.common.api.events.battles.BattleFaintedEvent
import com.cobblemon.mod.common.api.storage.party.PlayerPartyStore
import com.cobblemon.mod.common.api.storage.pc.PCStore
import com.cobblemon.mod.common.pokemon.Pokemon
import com.cobblemon.mod.common.util.isInBattle
import dev.cobblesurvival.CobbleSurvival
import net.minecraft.ChatFormatting
import net.minecraft.core.registries.Registries
import net.minecraft.network.chat.Component
import net.minecraft.resources.ResourceLocation
import net.minecraft.server.level.ServerPlayer
import net.minecraft.sounds.SoundEvents
import net.minecraft.sounds.SoundSource
import net.minecraft.tags.TagKey
import net.minecraft.world.item.Item
import net.neoforged.neoforge.common.NeoForge
import net.neoforged.neoforge.event.tick.ServerTickEvent
import java.util.UUID
import java.util.concurrent.ConcurrentLinkedQueue
/**
* A fainted Pokémon is gone for good. CobbleHardcoreMon settles faints in battles; this closes the gaps around it:
* - a Pokémon that faints outside a battle (falling, lava, poison, ...) is settled at the end of the same server
* tick. CobbleHardcoreMon only looks once a second, and waits a full minute for a Pokémon whose charm already
* saved it once, which left time to heal it or move it to the PC.
* - a Pokémon that fainted in a battle and was revived before the battle ended (a Revive, Revival Blessing, ...)
* is fainted again when the battle ends, just before CobbleHardcoreMon settles the battle.
* - once a second, every online player's party and PC are swept for fainted Pokémon (one whose trainer was offline
* or in a battle when it fainted).
* Settling: a Pokémon holding an item from `#cobblesurvival:revival_charms` is healed and the charm is used up;
* any other is removed from its party or PC.
*/
object Permadeath {
val REVIVAL_CHARMS: TagKey<Item> =
TagKey.create(Registries.ITEM, ResourceLocation.fromNamespaceAndPath(CobbleSurvival.MOD_ID, "revival_charms"))
private const val SWEEP_TICKS = 20
private val fainted = ConcurrentLinkedQueue<Pokemon>()
private val faintedInBattle = HashMap<UUID, MutableSet<Pokemon>>()
fun register() {
CobblemonEvents.POKEMON_FAINTED.subscribe(Priority.NORMAL) { fainted += it.pokemon }
CobblemonEvents.BATTLE_FAINTED.subscribe(Priority.NORMAL, ::onBattleFainted)
// CobbleHardcoreMon settles the battle at NORMAL priority; it has to see the revived Pokémon fainted.
CobblemonEvents.BATTLE_ENDED.subscribe(Priority.HIGHEST, ::onBattleEnded)
NeoForge.EVENT_BUS.addListener(::onServerTick)
}
private fun onBattleFainted(event: BattleFaintedEvent) {
val killed = event.killed
// In a battle on copies (exhibition rules) the real Pokémon is not hurt, so it does not count.
if (killed.effectedPokemon !== killed.originalPokemon || !killed.originalPokemon.isPlayerOwned()) return
faintedInBattle.getOrPut(event.battle.battleId) { HashSet() } += killed.originalPokemon
}
private fun onBattleEnded(event: BattleEndedEvent) {
val faintedHere = faintedInBattle.remove(event.battle.battleId) ?: return
for (pokemon in faintedHere) {
if (!pokemon.isFainted() && pokemon.storeCoordinates.get() != null) {
pokemon.currentHealth = 0
}
}
}
private fun onServerTick(event: ServerTickEvent.Post) {
while (true) {
val pokemon = fainted.poll() ?: break
val owner = pokemon.getOwnerPlayer() ?: continue
settle(owner, pokemon)
}
if (event.server.tickCount % SWEEP_TICKS == 0) {
for (player in event.server.playerList.players) {
val party = Cobblemon.storage.getParty(player)
val pc = Cobblemon.storage.getPC(player)
(party.filter { it.isFainted() } + pc.filter { it.isFainted() }).forEach { settle(player, it) }
}
}
}
private fun settle(player: ServerPlayer, pokemon: Pokemon) {
val store = pokemon.storeCoordinates.get()?.store
if (!pokemon.isFainted() || (store !is PlayerPartyStore && store !is PCStore)) return
// Faints during a battle are settled when it ends (above, and by CobbleHardcoreMon).
if (player.isInBattle()) return
try {
val name = pokemon.getDisplayName(false)
if (pokemon.heldItem().`is`(REVIVAL_CHARMS)) {
pokemon.heal()
pokemon.removeHeldItem()
player.sendSystemMessage(
Component.translatableWithFallback(
"message.cobblesurvival.permadeath.saved",
"%s's Revival Charm saved it and crumbled to dust.", name,
).withStyle(ChatFormatting.GOLD)
)
player.playNotifySound(SoundEvents.RESPAWN_ANCHOR_DEPLETE.value(), SoundSource.PLAYERS, 1f, 1f)
CobbleSurvival.LOGGER.info("Permadeath: a Revival Charm saved {} of {}", name.string, player.gameProfile.name)
} else if (store?.remove(pokemon) == true) {
player.sendSystemMessage(
Component.translatableWithFallback(
"message.cobblesurvival.permadeath.lost",
"%s fainted and is gone for good.", name,
).withStyle(ChatFormatting.RED)
)
CobbleSurvival.LOGGER.info("Permadeath: {} of {} fainted and was removed", name.string, player.gameProfile.name)
}
} catch (e: Exception) {
CobbleSurvival.LOGGER.error("Permadeath: could not settle a fainted Pokémon of {}", player.gameProfile.name, e)
}
}
}
@@ -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
}
}
@@ -0,0 +1,77 @@
package dev.cobblesurvival.world
import dev.cobblesurvival.CobbleSurvival
import net.minecraft.core.BlockPos
import net.minecraft.core.registries.BuiltInRegistries
import net.minecraft.core.registries.Registries
import net.minecraft.resources.ResourceLocation
import net.minecraft.server.level.ServerLevel
import net.minecraft.tags.TagKey
import net.minecraft.world.entity.EntityType
import net.minecraft.world.entity.Mob
import net.minecraft.world.level.block.Block
import net.minecraft.world.level.block.Blocks
import net.minecraft.world.level.block.InfestedBlock
import net.minecraft.world.level.block.SculkShriekerBlock
import net.minecraft.world.level.chunk.LevelChunk
import net.neoforged.neoforge.event.entity.EntityJoinLevelEvent
import net.neoforged.neoforge.event.level.ChunkEvent
/**
* Only Pokémon live on the island. Structures stay, but their vanilla mobs and mob sources go:
* - a mob that isn't Cobblemon's (Pokémon, trainers) never joins a world unless it is loaded from a save.
* That covers mobs placed by structures during world generation (villagers, guardians, shulkers, ...),
* other mods' animals (Cold Sweat's chameleons) and anything that would spawn later. Exceptions: the
* entity type tag `cobblesurvival:allowed_vanilla_mobs`.
* - when a chunk has just been generated, blocks in the tag `cobblesurvival:removed_on_generation`
* (spawners, trial spawners) become air, sculk shriekers can no longer summon the Warden, and
* infested blocks (silverfish) become their normal block. Chunks loaded from disk are never touched.
*/
object OnlyPokemon {
private val ALLOWED_MOBS: TagKey<EntityType<*>> =
TagKey.create(Registries.ENTITY_TYPE, ResourceLocation.fromNamespaceAndPath(CobbleSurvival.MOD_ID, "allowed_vanilla_mobs"))
private val REMOVED: TagKey<Block> =
TagKey.create(Registries.BLOCK, ResourceLocation.fromNamespaceAndPath(CobbleSurvival.MOD_ID, "removed_on_generation"))
private const val FLAGS = Block.UPDATE_CLIENTS or Block.UPDATE_KNOWN_SHAPE
fun onEntityJoin(event: EntityJoinLevelEvent) {
val mob = event.entity as? Mob ?: return
if (event.level !is ServerLevel || event.loadedFromDisk()) return
if (BuiltInRegistries.ENTITY_TYPE.getKey(mob.type).namespace == "cobblemon") return
if (mob.type.`is`(ALLOWED_MOBS)) return
event.isCanceled = true
}
fun onChunkLoad(event: ChunkEvent.Load) {
if (!event.isNewChunk) return
val level = event.level as? ServerLevel ?: return
val chunk = event.chunk as? LevelChunk ?: return
for (pos in chunk.blockEntitiesPos.toList()) {
val state = chunk.getBlockState(pos)
if (state.`is`(REMOVED)) {
// A fresh chunk still holds block entities as pending data. Create it first so that
// replacing the block removes it; otherwise the game later tries to load a spawner
// block entity on an air block and logs an error.
chunk.getBlockEntity(pos, LevelChunk.EntityCreationType.IMMEDIATE)
level.setBlock(pos, Blocks.AIR.defaultBlockState(), FLAGS)
} else if (state.`is`(Blocks.SCULK_SHRIEKER) && state.getValue(SculkShriekerBlock.CAN_SUMMON)) {
level.setBlock(pos, state.setValue(SculkShriekerBlock.CAN_SUMMON, false), FLAGS)
}
}
// Infested blocks have no block entity; the palette check skips sections without any.
chunk.sections.forEachIndexed { index, section ->
if (section.hasOnlyAir() || !section.states.maybeHas { it.block is InfestedBlock }) return@forEachIndexed
val baseY = chunk.getSectionYFromSectionIndex(index) shl 4
for (y in 0 until 16) for (z in 0 until 16) for (x in 0 until 16) {
val state = section.getBlockState(x, y, z)
val block = state.block
if (block is InfestedBlock) {
val pos = BlockPos(chunk.pos.minBlockX + x, baseY + y, chunk.pos.minBlockZ + z)
level.setBlock(pos, block.hostStateByInfested(state), FLAGS)
}
}
}
}
}
@@ -0,0 +1,117 @@
package dev.cobblesurvival.zone
import com.cobblemon.mod.common.api.spawning.condition.AppendageCondition
import com.cobblemon.mod.common.api.spawning.position.SpawnablePosition
import com.google.gson.Gson
import com.google.gson.JsonElement
import com.google.gson.JsonObject
import dev.cobblesurvival.CobbleSurvival
import net.minecraft.core.BlockPos
import net.minecraft.core.Holder
import net.minecraft.resources.ResourceLocation
import net.minecraft.server.packs.resources.ResourceManager
import net.minecraft.server.packs.resources.SimpleJsonResourceReloadListener
import net.minecraft.util.profiling.ProfilerFiller
import net.minecraft.world.level.Level
import net.minecraft.world.level.biome.Biome
import net.neoforged.neoforge.event.AddReloadListenerEvent
/** One zone of the island (world/zones.json). */
data class Zone(val id: String, val name: String, val minLevel: Int, val maxLevel: Int)
/** A point deep inside a zone (y only for cave zones), used by the spawn survey. */
data class ZoneSample(val x: Int, val y: Int?, val z: Int)
/**
* Which zone a position of the overworld belongs to. The layout comes from the data file
* `data/cobblesurvival/zones/island.json`, which tools/zone_layout.py writes from world/zones.json:
* a grid of surface zones over the island, plus cave zones that follow their cave biome everywhere.
*/
object Zones : SimpleJsonResourceReloadListener(Gson(), "zones") {
private val LAYOUT = ResourceLocation.fromNamespaceAndPath(CobbleSurvival.MOD_ID, "island")
private var byIndex: List<Zone> = emptyList()
var byId: Map<String, Zone> = emptyMap()
private set
private var caves: Map<ResourceLocation, Zone> = emptyMap()
var samples: Map<String, ZoneSample> = emptyMap()
private set
/** For zones made of biomes: a point inside their own hazard biome (Frostpeak's jagged peaks, ...). */
var biomeSamples: Map<String, ZoneSample> = emptyMap()
private set
private var grid = IntArray(0)
private var radius = 0
private var step = 1
private var size = 0
fun onAddReloadListeners(event: AddReloadListenerEvent) = event.addListener(this)
override fun apply(files: Map<ResourceLocation, JsonElement>, resources: ResourceManager, profiler: ProfilerFiller) {
val json = files[LAYOUT]?.asJsonObject
if (json == null) {
CobbleSurvival.LOGGER.warn("No zone layout ({}); no position belongs to a zone", LAYOUT)
byIndex = emptyList(); byId = emptyMap(); caves = emptyMap(); samples = emptyMap(); biomeSamples = emptyMap()
grid = IntArray(0); size = 0
return
}
byIndex = json.getAsJsonArray("zones").map { zone(it.asJsonObject) }
val caveZones = json.getAsJsonArray("caves").map { it.asJsonObject }
caves = caveZones.associate { ResourceLocation.parse(it.get("biome").asString) to zone(it) }
byId = (byIndex + caves.values).associateBy { it.id }
samples = samplesOf(json, "samples")
biomeSamples = samplesOf(json, "biome_samples")
radius = json.get("radius").asInt
step = json.get("step").asInt
size = json.get("size").asInt
// Run-length encoded rows: [zone index or -1, count], ...
val cells = ArrayList<Int>(size * size)
for (run in json.getAsJsonArray("grid_rle")) {
val pair = run.asJsonArray
repeat(pair[1].asInt) { cells += pair[0].asInt }
}
check(cells.size == size * size) { "zone grid has ${cells.size} cells, expected ${size * size}" }
grid = cells.toIntArray()
CobbleSurvival.LOGGER.info("Loaded {} zones ({} surface, {} cave)", byId.size, byIndex.size, caves.size)
}
private fun samplesOf(json: JsonObject, key: String): Map<String, ZoneSample> =
json.getAsJsonObject(key)?.entrySet()?.associate { (id, point) ->
val xyz = point.asJsonArray
id to ZoneSample(xyz[0].asInt, xyz[1].takeUnless { it.isJsonNull }?.asInt, xyz[2].asInt)
} ?: emptyMap()
private fun zone(json: JsonObject): Zone {
val levels = json.getAsJsonArray("levels")
return Zone(json.get("id").asString, json.get("name").asString, levels[0].asInt, levels[1].asInt)
}
/** The zone at [pos], or null outside the island's zones or outside the overworld. */
fun at(level: Level, pos: BlockPos, biome: Holder<Biome> = level.getBiome(pos)): Zone? {
if (level.dimension() != Level.OVERWORLD || size == 0) return null
biome.unwrapKey().orElse(null)?.let { caves[it.location()] }?.let { return it }
// Grid cells are centred on -radius + i * step.
val col = Math.floorDiv(pos.x + radius + step / 2, step)
val row = Math.floorDiv(pos.z + radius + step / 2, step)
if (col !in 0 until size || row !in 0 until size) return null
return byIndex.getOrNull(grid[row * size + col])
}
}
/**
* Spawn condition field `"zone": "<id>"` or `"zones": ["<id>", ...]` for Cobblemon spawn files: the spawn only
* happens inside those zones. Registered for every kind of spawning condition.
*/
class ZoneCondition : AppendageCondition {
var zone: String? = null
var zones: List<String>? = null
override fun fits(spawnablePosition: SpawnablePosition): Boolean {
val wanted = zones ?: zone?.let(::listOf) ?: return true
val here = Zones.at(spawnablePosition.world, spawnablePosition.position, spawnablePosition.biomeHolder)
return here != null && here.id in wanted
}
companion object {
fun register() = AppendageCondition.registerAppendage(ZoneCondition::class.java) { true }
}
}