WIP: ore nodes, catching rules, bee nest fix, smaller Deep Dark

Done and checked: surface and cave-floor ore nodes (companion mod
ore/OreNodes.kt), bigger and rarer underground veins; template with nodes
passed the world check and the island quick check (node self-tests).

Built, not yet tested: catching rules (catch rate falls with the level
gap to the trainer's strongest Pokemon, failed catch enrages, strong wild
Pokemon hunt weaker trainers; catching/, Fight or Flight config), bee
nests emptied when their bee is turned away.

In progress: the Deep Dark no longer fills the deepslate layer (split by
temperature: cold = Deep Dark, hot = basalt deltas, warm = ordinary caves;
world/biomes.json), zone layout regenerated; cave zone types added to
world/zones.json but tools/zone_spawns.py doesn't use them yet; template
not rebuilt for the new caves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
claudeandClaude Opus 5.5 committed 2026-10-30 15:44:17 +01:00
1 parent 362d06aa4b
commit 6361636da7
29 files changed
+1733 -357

No files matched your search

@@ -3,8 +3,10 @@ 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.catching.Catching
import dev.cobblesurvival.dev.BiomePreview
import dev.cobblesurvival.dev.OreVeins
import dev.cobblesurvival.ore.OreNodes
import dev.cobblesurvival.permadeath.Permadeath
import dev.cobblesurvival.progress.Gates
import dev.cobblesurvival.progress.Progress
@@ -58,11 +60,14 @@ class CobbleSurvival {
NeoForge.EVENT_BUS.addListener(::onPortalSpawn)
NeoForge.EVENT_BUS.addListener(OnlyPokemon::onChunkLoad)
NeoForge.EVENT_BUS.addListener(OnlyPokemon::onEntityJoin)
NeoForge.EVENT_BUS.addListener(OnlyPokemon::onServerTick)
Permadeath.register()
ZoneCondition.register()
NeoForge.EVENT_BUS.addListener(Zones::onAddReloadListeners)
Guardians.register()
Gates.register()
OreNodes.register()
Catching.register()
ZoneHazards.register()
LevelPressure.register()
SelfTest.register()
@@ -0,0 +1,113 @@
package dev.cobblesurvival.catching
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.pokeball.PokeBallCaptureCalculatedEvent
import com.cobblemon.mod.common.api.events.pokeball.PokemonCatchRateEvent
import com.cobblemon.mod.common.entity.pokemon.PokemonEntity
import com.google.gson.Gson
import com.google.gson.JsonElement
import dev.cobblesurvival.CobbleSurvival
import net.minecraft.resources.ResourceLocation
import net.minecraft.server.level.ServerPlayer
import net.minecraft.server.packs.resources.ResourceManager
import net.minecraft.server.packs.resources.SimpleJsonResourceReloadListener
import net.minecraft.util.profiling.ProfilerFiller
import net.neoforged.neoforge.common.NeoForge
import net.neoforged.neoforge.event.AddReloadListenerEvent
import net.neoforged.neoforge.event.tick.ServerTickEvent
/**
* Catching (Adrian, 2026-10-30): high-level Pokémon are hard to catch. Everything is measured against the trainer's
* strongest party Pokémon:
* - the catch rate falls with the level gap, down to a small share at 20 levels above ("very improbable");
* - a Pokémon that breaks out of a ball attacks the thrower instead of fleeing;
* - a wild Pokémon above a nearby player's strongest Pokémon is hostile to that player.
*
* The attacking is Fight or Flight Reborn's: an angry Pokémon carries the aspect [ENRAGED], which the pack's Fight or
* Flight config lists in `always_aggro_aspects` (so it fights rather than flees), and its target is set through Fight
* or Flight's own "caught by" mark ([FightOrFlight]). While it has a target, Fight or Flight doesn't let thrown balls
* catch it (`aggressive_pokemon_catchable` false): it has to be battled. Values: `data/cobblesurvival/catching/`.
*/
object Catching : SimpleJsonResourceReloadListener(Gson(), "catching") {
const val ENRAGED = "cobblesurvival_enraged"
private const val SCAN_TICKS = 20
class Rules(
val fullUpTo: Int = 0,
val minimumAt: Int = 20,
val minimum: Double = 0.02,
val enrageOnFailedCatch: Boolean = true,
val hostileWhenStronger: Boolean = true,
val hostileRange: Double = 16.0,
)
var rules = Rules()
private set
/** The self-tests switch the hostility scan off, except in their own batch: other tests put weak trainers near wild Pokémon. */
var hostilityPaused = false
fun register() {
NeoForge.EVENT_BUS.addListener<AddReloadListenerEvent> { it.addListener(this) }
CobblemonEvents.POKEMON_CATCH_RATE.subscribe(Priority.LOWEST, ::onCatchRate)
CobblemonEvents.POKE_BALL_CAPTURE_CALCULATED.subscribe(Priority.NORMAL, ::onCaptureCalculated)
NeoForge.EVENT_BUS.addListener(::onServerTick)
}
override fun apply(files: Map<ResourceLocation, JsonElement>, resources: ResourceManager, profiler: ProfilerFiller) {
val json = files[ResourceLocation.fromNamespaceAndPath(CobbleSurvival.MOD_ID, "rules")]?.asJsonObject ?: return
val rate = json.getAsJsonObject("catch_rate")
rules = Rules(
fullUpTo = rate.get("full_up_to_levels_above").asInt,
minimumAt = rate.get("minimum_at_levels_above").asInt,
minimum = rate.get("minimum").asDouble,
enrageOnFailedCatch = json.get("failed_catch_enrages").asBoolean,
hostileWhenStronger = json.get("hostile_when_stronger").asBoolean,
hostileRange = json.get("hostile_range").asDouble,
)
}
fun strongest(player: ServerPlayer): Int = Cobblemon.storage.getParty(player).maxOfOrNull { it.level } ?: 0
/** The share of its catch rate a Pokémon keeps [gap] levels above the thrower's strongest Pokémon. */
fun catchFactor(gap: Int, r: Rules = rules): Double = when {
gap <= r.fullUpTo -> 1.0
gap >= r.minimumAt -> r.minimum
else -> 1.0 - (gap - r.fullUpTo).toDouble() / (r.minimumAt - r.fullUpTo) * (1.0 - r.minimum)
}
private fun onCatchRate(event: PokemonCatchRateEvent) {
val player = event.thrower as? ServerPlayer ?: return
val factor = catchFactor(event.pokemonEntity.pokemon.level - strongest(player))
if (factor < 1.0) event.catchRate = (event.catchRate * factor).toFloat()
}
private fun onCaptureCalculated(event: PokeBallCaptureCalculatedEvent) {
if (!rules.enrageOnFailedCatch || event.captureResult.isSuccessfulCapture) return
enrage(event.pokemonEntity, event.thrower as? ServerPlayer ?: return)
}
private fun onServerTick(event: ServerTickEvent.Post) {
if (!rules.hostileWhenStronger || hostilityPaused || event.server.tickCount % SCAN_TICKS != 0) return
for (player in event.server.playerList.players) {
if (player.isCreative || player.isSpectator) continue
val strongest = strongest(player)
val near = player.boundingBox.inflate(rules.hostileRange)
player.serverLevel().getEntitiesOfClass(PokemonEntity::class.java, near) {
it.pokemon.isWild() && !it.isBattling && it.pokemon.level > strongest
}.forEach { enrage(it, player) }
}
}
/** Makes [wild] fight [player] (Fight or Flight does the fighting). */
fun enrage(wild: PokemonEntity, player: ServerPlayer) {
val pokemon = wild.pokemon
if (ENRAGED !in pokemon.forcedAspects) {
pokemon.forcedAspects = pokemon.forcedAspects + ENRAGED
pokemon.updateAspects()
}
FightOrFlight.setTarget(wild, player)
}
}
@@ -0,0 +1,33 @@
package dev.cobblesurvival.catching
import com.cobblemon.mod.common.entity.pokemon.PokemonEntity
import net.minecraft.server.level.ServerPlayer
import net.neoforged.fml.ModList
/**
* Fight or Flight Reborn, by reflection (not a compile dependency). [setTarget] uses its "caught by" mark, the same
* one a failed catch sets: its sensor then makes the player the Pokémon's attack target. [aggression] is its
* fight-or-flight value (above its neutral threshold the Pokémon fights, below it flees).
*/
object FightOrFlight {
val installed: Boolean get() = ModList.get().isLoaded("fightorflight")
private val setCapturedBy by lazy {
Class.forName("me.rufia.fightorflight.PokemonInterface").getMethod("setCapturedBy", Int::class.javaPrimitiveType)
}
private val coefficient by lazy {
Class.forName("me.rufia.fightorflight.CobblemonFightOrFlight").getMethod("getFightOrFlightCoefficient", PokemonEntity::class.java)
}
fun setTarget(wild: PokemonEntity, player: ServerPlayer) {
if (installed) setCapturedBy.invoke(wild, player.id)
}
fun aggression(wild: PokemonEntity): Double = coefficient.invoke(null, wild) as Double
/** Fight or Flight's `neutral_threshold`: at or above it a provoked Pokémon fights. */
fun neutralThreshold(): Double {
val config = Class.forName("me.rufia.fightorflight.CobblemonFightOrFlight").getMethod("commonConfig").invoke(null)
return (config.javaClass.getField("neutral_threshold").get(config) as Number).toDouble()
}
}
@@ -1,6 +1,7 @@
package dev.cobblesurvival.dev
import com.mojang.brigadier.builder.LiteralArgumentBuilder
import dev.cobblesurvival.ore.OreNodes
import net.minecraft.commands.CommandSourceStack
import net.minecraft.commands.Commands
import net.minecraft.commands.arguments.coordinates.ColumnPosArgument
@@ -8,7 +9,10 @@ import net.minecraft.core.BlockPos
import net.minecraft.network.chat.Component
import net.minecraft.server.level.ColumnPos
import net.minecraft.server.level.ServerLevel
import net.minecraft.util.RandomSource
import net.minecraft.world.level.ChunkPos
import net.neoforged.fml.ModList
import java.util.Optional
import kotlin.math.max
import kotlin.math.min
@@ -17,20 +21,47 @@ import kotlin.math.min
* dependency of the companion mod).
*
* Dev tool: `/cobblesurvival veins unfilled <x1> <z1> <x2> <z2>` counts the loaded chunks in a block rectangle that
* the mod hasn't put veins into yet. It fills a new chunk only while that chunk is loaded, 8 chunks a tick, and
* forgets the rest when the server stops, so a pregeneration run outpaces it; tools/make_world.py loads the island
* again row by row and waits until this count is 0 around each row (loading a row also fully loads the chunks next
* to it). The self-tests use [markMined] to wind a vein block's clock back.
* the mod hasn't put veins into yet, or the companion mod its ore nodes ([OreNodes]). Both fill a new chunk only
* while that chunk is loaded, a few chunks a tick, and the vein mod forgets the rest when the server stops, so a
* pregeneration run outpaces them; tools/make_world.py loads the island again row by row and waits until this count
* is 0 around each row (loading a row also fully loads the chunks next to it). [place] puts a vein down the way
* `/regenerating_ore_veins place` does (the ore nodes); the self-tests use [markMined] to wind a vein block's clock
* back and [veinAt] to see what was placed.
*/
object OreVeins {
private val savedData by lazy { Class.forName("net.stargazer.regenerating_ore_veins.VeinSavedData") }
private const val ROV = "net.stargazer.regenerating_ore_veins"
val installed: Boolean get() = ModList.get().isLoaded("regenerating_ore_veins")
private val savedData by lazy { Class.forName("$ROV.VeinSavedData") }
private val get by lazy { savedData.getMethod("get", ServerLevel::class.java) }
private val isChunkGenerated by lazy { savedData.getMethod("isChunkGenerated", Long::class.javaPrimitiveType) }
private val markMined by lazy { savedData.getMethod("markMined", BlockPos::class.java, Long::class.javaPrimitiveType) }
private val getEntry by lazy { savedData.getMethod("getEntry", BlockPos::class.java) }
private val getVein by lazy { Class.forName("$ROV.VeinConfig").getMethod("getVein", String::class.java) }
private val placeManagedVein by lazy {
Class.forName("$ROV.VeinRuntime").getMethod("placeManagedVein", ServerLevel::class.java, BlockPos::class.java,
Class.forName("$ROV.VeinConfig\$VeinDefinition"), RandomSource::class.java, Boolean::class.javaPrimitiveType)
}
fun isChunkFilled(level: ServerLevel, chunk: ChunkPos): Boolean =
isChunkGenerated.invoke(get.invoke(null, level), chunk.toLong()) as Boolean
/**
* Places vein [id] around [center] whatever is there (forced, like the mod's place command), as a tracked vein
* that grows back; returns how many blocks it placed (0 if there's no such vein).
*/
fun place(level: ServerLevel, center: BlockPos, id: String, random: RandomSource): Int {
val definition = (getVein.invoke(null, id) as Optional<*>).orElse(null) ?: return 0
return placeManagedVein.invoke(null, level, center, definition, random, true) as Int
}
/** The id of the vein that tracks the block at [pos], or null. */
fun veinAt(level: ServerLevel, pos: BlockPos): String? {
val entry = getEntry.invoke(get.invoke(null, level), pos) ?: return null
return entry.javaClass.getMethod("veinId").invoke(entry) as String
}
/** Sets when the vein block at [pos] was last mined, in seconds since the epoch (the mod counts real time). */
fun markMined(level: ServerLevel, pos: BlockPos, epochSecond: Long) {
markMined.invoke(get.invoke(null, level), pos, epochSecond)
@@ -46,7 +77,7 @@ object OreVeins {
val level = context.source.server.overworld()
val unfilled = chunks(ColumnPosArgument.getColumnPos(context, "from"),
ColumnPosArgument.getColumnPos(context, "to"))
.count { level.chunkSource.hasChunk(it.x, it.z) && !isChunkFilled(level, it) }
.count { level.chunkSource.hasChunk(it.x, it.z) && !(isChunkFilled(level, it) && OreNodes.isDone(level, it)) }
context.source.sendSuccess({ Component.literal("unfilled chunks: $unfilled") }, false)
1
})))
@@ -0,0 +1,201 @@
package dev.cobblesurvival.ore
import com.google.gson.Gson
import com.google.gson.JsonElement
import dev.cobblesurvival.CobbleSurvival
import dev.cobblesurvival.dev.OreVeins
import dev.cobblesurvival.zone.Zones
import it.unimi.dsi.fastutil.longs.LongOpenHashSet
import net.minecraft.core.BlockPos
import net.minecraft.core.HolderLookup
import net.minecraft.core.registries.Registries
import net.minecraft.nbt.CompoundTag
import net.minecraft.nbt.LongArrayTag
import net.minecraft.resources.ResourceKey
import net.minecraft.resources.ResourceLocation
import net.minecraft.server.level.ServerLevel
import net.minecraft.server.packs.resources.ResourceManager
import net.minecraft.server.packs.resources.SimpleJsonResourceReloadListener
import net.minecraft.tags.TagKey
import net.minecraft.util.RandomSource
import net.minecraft.util.profiling.ProfilerFiller
import net.minecraft.world.level.ChunkPos
import net.minecraft.world.level.Level
import net.minecraft.world.level.biome.Biome
import net.minecraft.world.level.block.Block
import net.minecraft.world.level.levelgen.Heightmap
import net.minecraft.world.level.saveddata.SavedData
import net.neoforged.neoforge.common.NeoForge
import net.neoforged.neoforge.event.AddReloadListenerEvent
import net.neoforged.neoforge.event.level.ChunkEvent
import net.neoforged.neoforge.event.server.ServerStoppedEvent
import net.neoforged.neoforge.event.tick.LevelTickEvent
import kotlin.math.max
/**
* Ore nodes (Adrian, 2026-10-30): ore shows up "mainly on the surface" as small nodes, while the larger veins
* underground are harder to find, and the cave zones' ores sit as nodes in the cave, "not in the walls". In every new
* overworld chunk the companion mod places its zone's nodes: a lump of ore in the natural ground (never in water or
* under leaves), or on a cave floor in the tier's cave biome. They are Regenerating Ore Veins veins placed through
* the mod ([OreVeins.place]), so they grow back like any vein. The underground veins are the vein mod's own.
*
* The node table comes from tools/zone_ores.py: `data/cobblesurvival/ore_nodes/<name>.json` (which node vein, zone,
* chance per chunk, size; cave nodes also the biome, the depth and how many columns to search). Ground is the block
* tag `cobblesurvival:ore_node_ground`. Like the vein mod, nodes go into a new chunk once it is loaded, a few chunks a
* tick; the chunks done are kept in the world save ([NodeState]), and `/cobblesurvival veins unfilled` counts both.
*/
object OreNodes : SimpleJsonResourceReloadListener(Gson(), "ore_nodes") {
class Node(
val vein: String,
val zone: String,
val cave: Boolean,
val chance: Double,
val radius: Int,
val biome: ResourceKey<Biome>?,
val tries: Int,
val minY: Int,
val maxY: Int,
)
private const val CHUNKS_PER_TICK = 8
private const val SURFACE_TRIES = 4
/** A self-test placing a node on purpose searches this many times longer. */
private const val FORCED_TRIES = 8
val GROUND: TagKey<Block> = TagKey.create(Registries.BLOCK, ResourceLocation.fromNamespaceAndPath(CobbleSurvival.MOD_ID, "ore_node_ground"))
var nodes: List<Node> = emptyList()
private set
private val pending = LongOpenHashSet()
fun register() {
NeoForge.EVENT_BUS.addListener<AddReloadListenerEvent> { it.addListener(this) }
NeoForge.EVENT_BUS.addListener(::onChunkLoad)
NeoForge.EVENT_BUS.addListener(::onLevelTick)
NeoForge.EVENT_BUS.addListener<ServerStoppedEvent> { pending.clear() }
}
override fun apply(files: Map<ResourceLocation, JsonElement>, resources: ResourceManager, profiler: ProfilerFiller) {
nodes = files.toSortedMap().values.flatMap { file ->
file.asJsonObject.getAsJsonArray("nodes").map { element ->
val json = element.asJsonObject
val y = json.getAsJsonArray("y")
Node(
vein = json.get("vein").asString,
zone = json.get("zone").asString,
cave = json.get("where").asString == "cave",
chance = json.get("chance").asDouble,
radius = json.get("radius").asInt,
biome = json.get("biome")?.asString?.let { ResourceKey.create(Registries.BIOME, ResourceLocation.parse(it)) },
tries = json.get("tries")?.asInt ?: SURFACE_TRIES,
minY = y?.get(0)?.asInt ?: 0,
maxY = y?.get(1)?.asInt ?: 0,
)
}
}
CobbleSurvival.LOGGER.info("Loaded {} ore node kinds", nodes.size)
}
fun isDone(level: ServerLevel, chunk: ChunkPos) = NodeState.of(level).isDone(chunk)
private fun onChunkLoad(event: ChunkEvent.Load) {
val level = event.level as? ServerLevel ?: return
if (event.isNewChunk && level.dimension() == Level.OVERWORLD) pending.add(event.chunk.pos.toLong())
}
private fun onLevelTick(event: LevelTickEvent.Post) {
val level = event.level as? ServerLevel ?: return
if (level.dimension() != Level.OVERWORLD || pending.isEmpty() || !OreVeins.installed) return
val state = NodeState.of(level)
var filled = 0
val chunks = pending.iterator()
while (chunks.hasNext() && filled < CHUNKS_PER_TICK) {
val chunk = ChunkPos(chunks.nextLong())
if (state.isDone(chunk)) {
chunks.remove()
} else if (level.chunkSource.hasChunk(chunk.x, chunk.z)) {
fill(level, chunk)
state.markDone(chunk)
chunks.remove()
filled++
}
}
}
/**
* Places the nodes of [chunk] (only [only], if given) and returns where each one sits: the ground or cave-floor
* block it was put into. [force] ignores the chance and searches longer (self-tests).
*/
fun fill(level: ServerLevel, chunk: ChunkPos, only: Node? = null, force: Boolean = false): List<BlockPos> {
val placed = ArrayList<BlockPos>()
for (node in only?.let(::listOf) ?: nodes) {
val random = RandomSource.create(level.seed xor (chunk.toLong() * 0x9E3779B97F4A7C15uL.toLong()) xor node.vein.hashCode().toLong())
if (!force && random.nextDouble() >= node.chance) continue
val tries = node.tries * (if (force) FORCED_TRIES else 1)
val ground = (if (node.cave) caveFloor(level, chunk, node, random, tries) else surface(level, chunk, node, random, tries)) ?: continue
// Sunk so that at most one block stands out: radius r sits r-1 below the ground block.
if (OreVeins.place(level, ground.below(max(0, node.radius - 1)), node.vein, random) > 0) placed.add(ground)
}
return placed
}
/** A random column, kept [Node.radius] blocks inside the chunk so a node never reaches into the next one. */
private fun column(chunk: ChunkPos, node: Node, random: RandomSource): Pair<Int, Int> {
val span = 16 - 2 * node.radius
return chunk.minBlockX + node.radius + random.nextInt(span) to chunk.minBlockZ + node.radius + random.nextInt(span)
}
private fun surface(level: ServerLevel, chunk: ChunkPos, node: Node, random: RandomSource, tries: Int): BlockPos? {
repeat(tries) {
val (x, z) = column(chunk, node, random)
val ground = BlockPos(x, level.getHeight(Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, x, z) - 1, z)
val state = level.getBlockState(ground)
val above = level.getBlockState(ground.above())
if (state.`is`(GROUND) && state.fluidState.isEmpty && (above.isAir || above.canBeReplaced()) && above.fluidState.isEmpty &&
inZone(level, ground, node)) return ground
}
return null
}
/** The whole footprint in the node's zone: zone cells are squares, so checking the corners is enough. */
private fun inZone(level: ServerLevel, ground: BlockPos, node: Node): Boolean =
listOf(-node.radius, node.radius).all { dx -> listOf(-node.radius, node.radius).all { dz -> Zones.at(level, ground.offset(dx, 0, dz))?.id == node.zone } }
private fun caveFloor(level: ServerLevel, chunk: ChunkPos, node: Node, random: RandomSource, tries: Int): BlockPos? {
val biome = node.biome ?: return null
repeat(tries) {
val (x, z) = column(chunk, node, random)
for (y in node.maxY downTo node.minY + 1) {
val air = BlockPos(x, y, z)
if (!level.getBlockState(air).isAir || !level.getBlockState(air.below()).`is`(GROUND)) continue
if (level.getBiome(air).`is`(biome)) return air.below()
}
}
return null
}
}
/** The overworld chunks [OreNodes] has filled. Saved with the overworld. */
class NodeState : SavedData() {
private val done = LongOpenHashSet()
fun isDone(chunk: ChunkPos) = done.contains(chunk.toLong())
fun markDone(chunk: ChunkPos) {
if (done.add(chunk.toLong())) setDirty()
}
override fun save(tag: CompoundTag, registries: HolderLookup.Provider): CompoundTag {
tag.put("done", LongArrayTag(done.toLongArray()))
return tag
}
companion object {
private val FACTORY = Factory(::NodeState, ::load)
fun of(level: ServerLevel): NodeState = level.dataStorage.computeIfAbsent(FACTORY, "cobblesurvival_ore_nodes")
private fun load(tag: CompoundTag, registries: HolderLookup.Provider): NodeState =
NodeState().apply { done.addAll(LongOpenHashSet(tag.getLongArray("done"))) }
}
}
@@ -0,0 +1,90 @@
package dev.cobblesurvival.selftest
import com.cobblemon.mod.common.api.events.CobblemonEvents
import com.cobblemon.mod.common.api.events.pokeball.PokeBallCaptureCalculatedEvent
import com.cobblemon.mod.common.api.events.pokeball.PokemonCatchRateEvent
import com.cobblemon.mod.common.api.pokeball.catching.CaptureContext
import com.cobblemon.mod.common.entity.pokeball.EmptyPokeBallEntity
import com.cobblemon.mod.common.entity.pokemon.PokemonEntity
import dev.cobblesurvival.catching.Catching
import dev.cobblesurvival.catching.FightOrFlight
import net.minecraft.server.level.ServerPlayer
import net.minecraft.world.entity.ai.memory.MemoryModuleType
import kotlin.math.abs
/**
* Catching (Adrian, 2026-10-30), against the trainer's strongest party Pokémon: the catch rate falls with the level
* gap (20+ levels above: very improbable), a failed catch makes the Pokémon attack the thrower, and a wild Pokémon
* above a nearby trainer's strongest is hostile to them. The hostility cases run in batches of their own, with the
* scan switched on only there (other tests put weak trainers next to wild Pokémon).
*/
object CatchingTests {
fun all() = listOf(
SelfTestCase("catch_rate_falls_with_level_gap", 20, ::catchRate, batch = "catching"),
SelfTestCase("failed_catch_enrages", 400, ::failedCatch, batch = "catching"),
SelfTestCase("strong_wild_pokemon_hunt_weak_trainers", 400, { s -> hostility(s, strongest = 5, hostile = true) },
batch = "catching_weak"),
SelfTestCase("wild_pokemon_ignore_stronger_trainers", 120, { s -> hostility(s, strongest = 50, hostile = false) },
batch = "catching_strong"),
)
private const val WILD_LEVEL = 30
private fun catchRate(s: Scenario) {
val player = s.player()
s.give(player, s.pokemon("pidgey", 10))
s.check(Catching.catchFactor(20) <= 0.05) { "20 levels above still keeps ${Catching.catchFactor(20)} of the catch rate" }
for (level in listOf(10, 15, 20, 30, 45)) {
val wild = PokemonEntity(s.level, s.pokemon("rattata", level))
val event = PokemonCatchRateEvent(player, EmptyPokeBallEntity(s.level), wild, 100f)
CobblemonEvents.POKEMON_CATCH_RATE.post(event)
val expected = 100.0 * Catching.catchFactor(level - 10)
s.check(abs(event.catchRate - expected) < 0.01) { "Lv $level against Lv 10: catch rate ${event.catchRate}, expected $expected" }
}
s.helper.succeed()
}
private fun failedCatch(s: Scenario) {
val player = s.player()
s.give(player, s.pokemon("pidgeot", 60)) // stronger than the wild one: only the failed catch angers it
val wild = s.wild(s.pokemon("bidoof", 20), s.at(7.5, 1.0, 7.5))
s.helper.startSequence()
.thenWaitUntil { s.check(wild.pokemon.entity === wild) { "the wild Pokémon hasn't ticked yet" } }
.thenExecute {
CobblemonEvents.POKE_BALL_CAPTURE_CALCULATED.post(
PokeBallCaptureCalculatedEvent(player, wild, EmptyPokeBallEntity(s.level), CaptureContext(1, false, false))
)
s.check(Catching.ENRAGED in wild.pokemon.aspects) { "a failed catch didn't anger the Pokémon" }
val aggression = FightOrFlight.aggression(wild)
s.check(aggression >= FightOrFlight.neutralThreshold()) { "the angry Pokémon would flee (aggression $aggression)" }
FightOrFlight.setTarget(wild, player) // what Fight or Flight does when the ball breaks open
}
.thenWaitUntil { checkTarget(s, wild, player) }
.thenSucceed()
}
private fun hostility(s: Scenario, strongest: Int, hostile: Boolean) {
Catching.hostilityPaused = false
s.afterTest { Catching.hostilityPaused = true }
val player = s.player()
s.give(player, s.pokemon("pidgey", strongest))
val wild = s.wild(s.pokemon("machop", WILD_LEVEL), s.at(9.5, 1.0, 9.5))
val sequence = s.helper.startSequence()
.thenWaitUntil { s.check(wild.pokemon.entity === wild) { "the wild Pokémon hasn't ticked yet" } }
if (hostile) {
sequence.thenWaitUntil {
s.check(Catching.ENRAGED in wild.pokemon.aspects) { "a Lv $WILD_LEVEL wild Pokémon isn't angry at a trainer whose best is Lv $strongest" }
checkTarget(s, wild, player)
}.thenSucceed()
} else {
sequence.thenIdle(80).thenExecute {
s.check(Catching.ENRAGED !in wild.pokemon.aspects) { "a Lv $WILD_LEVEL wild Pokémon got angry at a trainer with a Lv $strongest" }
}.thenSucceed()
}
}
private fun checkTarget(s: Scenario, wild: PokemonEntity, player: ServerPlayer) {
val target = wild.brain.getMemory(MemoryModuleType.ATTACK_TARGET).orElse(null)
s.check(target === player) { "the angry Pokémon targets ${target?.name?.string ?: "nobody"}, not the player" }
}
}
@@ -0,0 +1,54 @@
package dev.cobblesurvival.selftest
import dev.cobblesurvival.dev.OreVeins
import dev.cobblesurvival.ore.OreNodes
import dev.cobblesurvival.zone.Zones
import net.minecraft.core.BlockPos
import net.minecraft.core.Direction
import net.minecraft.gametest.framework.GameTestAssertException
import net.minecraft.world.level.ChunkPos
/**
* Ore nodes on the island (Adrian, 2026-10-30): a surface node is a small lump of ore in the ground of its zone with
* open air above it; a cave node lies on the floor of its cave biome, touching the cave's air, not buried in a wall.
* Each test places one node on purpose near its zone's sample point and checks it as Regenerating Ore Veins tracks it.
*/
object OreNodeTests {
fun all() = listOf(
SelfTestCase("surface_nodes_lie_in_the_ground", 20, { s -> nodeTest(s, "button_isle", cave = false) }, islandOnly = true),
SelfTestCase("cave_nodes_lie_on_the_cave_floor", 20, { s -> nodeTest(s, "deep_dark", cave = true) }, islandOnly = true),
)
private fun nodeTest(s: Scenario, zone: String, cave: Boolean) {
val level = s.level
val node = OreNodes.nodes.firstOrNull { it.zone == zone && it.cave == cave }
?: throw GameTestAssertException("no ${if (cave) "cave" else "surface"} node kind in $zone")
val sample = Zones.samples[zone] ?: throw GameTestAssertException("no sample point for $zone")
val home = ChunkPos(BlockPos(sample.x, 0, sample.z))
// Next to the sample chunk first (the guardian stands on the sample point), then around it.
val chunks = listOf(ChunkPos(home.x + 2, home.z)) + (-1..1).flatMap { dx -> (-1..1).map { dz -> ChunkPos(home.x + dx, home.z + dz) } }
val grounds = chunks.asSequence()
.onEach { level.getChunk(it.x, it.z) }
.map { OreNodes.fill(level, it, only = node, force = true) }
.firstOrNull { it.isNotEmpty() }
?: throw GameTestAssertException("no spot for a ${node.vein} near $zone's sample point")
for (ground in grounds) {
val blocks = BlockPos.betweenClosed(ground.offset(-node.radius - 1, -node.radius - 1, -node.radius - 1),
ground.offset(node.radius + 1, node.radius + 1, node.radius + 1))
.map { it.immutable() }
.filter { OreVeins.veinAt(level, it) == node.vein }
s.check(blocks.isNotEmpty()) { "the ${node.vein} at ${ground.toShortString()} isn't tracked by Regenerating Ore Veins" }
val exposed = blocks.any { block -> Direction.entries.any { level.getBlockState(block.relative(it)).isAir } }
s.check(exposed) { "the ${node.vein} at ${ground.toShortString()} touches no air: it's buried" }
if (cave) {
s.check(level.getBiome(ground.above()).`is`(node.biome!!)) { "the ${node.vein} at ${ground.toShortString()} isn't in ${node.biome}" }
} else {
val top = blocks.maxBy { it.y }
s.check(level.canSeeSky(top.above())) { "the ${node.vein} at ${ground.toShortString()} is under cover" }
s.check(blocks.all { Zones.at(level, it)?.id == zone }) { "the ${node.vein} at ${ground.toShortString()} reaches out of $zone" }
}
}
s.helper.succeed()
}
}
@@ -2,6 +2,7 @@ package dev.cobblesurvival.selftest
import com.mojang.brigadier.builder.LiteralArgumentBuilder
import dev.cobblesurvival.CobbleSurvival
import dev.cobblesurvival.catching.Catching
import net.minecraft.commands.CommandSourceStack
import net.minecraft.commands.Commands
import net.minecraft.core.BlockPos
@@ -51,7 +52,8 @@ object SelfTest {
private val cases: List<SelfTestCase> get() =
PermadeathTests.all() + SurvivalRulesTests.all() + ZoneTests.all() + HazardTests.all() + GuardianTests.all() +
VeinTests.all() + DropTests.all() + ProgressionTests.all()
VeinTests.all() + OreNodeTests.all() + DropTests.all() + ProgressionTests.all() +
CatchingTests.all()
private var running: List<Scenario> = emptyList()
private var forced: List<ChunkPos> = emptyList()
@@ -99,6 +101,7 @@ object SelfTest {
GameTestInfo(function, Rotation.NONE, level, RetryOptions.noRetries()).also { it.addListener(scenario) }
}
running = scenarios
Catching.hostilityPaused = true
GameTestTicker.SINGLETON.clear()
GameTestRunner.Builder.fromInfo(infos, level)
.newStructureSpawner(StructureGridSpawner(origin, TESTS_PER_ROW, false))
@@ -128,5 +131,6 @@ object SelfTest {
val level = event.server.overworld()
forced.forEach { level.setChunkForced(it.x, it.z, false) }
running = emptyList()
Catching.hostilityPaused = false
}
}
@@ -9,20 +9,24 @@ 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.entity.animal.Bee
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.block.entity.BeehiveBlockEntity
import net.minecraft.world.level.chunk.LevelChunk
import net.neoforged.neoforge.event.entity.EntityJoinLevelEvent
import net.neoforged.neoforge.event.level.ChunkEvent
import net.neoforged.neoforge.event.tick.ServerTickEvent
/**
* 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`.
* entity type tag `cobblesurvival:allowed_vanilla_mobs`. A bee nest whose bee is turned away is emptied:
* otherwise it keeps the bee, tries to let it out again every tick and plays its sound each time.
* - 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.
@@ -34,12 +38,36 @@ object OnlyPokemon {
TagKey.create(Registries.BLOCK, ResourceLocation.fromNamespaceAndPath(CobbleSurvival.MOD_ID, "removed_on_generation"))
private const val FLAGS = Block.UPDATE_CLIENTS or Block.UPDATE_KNOWN_SHAPE
/** Nests whose bees were just turned away; emptied at the end of the tick, since the nest is mid-loop over them. */
private val hivesToEmpty = HashSet<Pair<ServerLevel, BlockPos>>()
fun onEntityJoin(event: EntityJoinLevelEvent) {
val mob = event.entity as? Mob ?: return
if (event.level !is ServerLevel || event.loadedFromDisk()) return
val level = event.level as? ServerLevel ?: return
if (event.loadedFromDisk()) return
if (BuiltInRegistries.ENTITY_TYPE.getKey(mob.type).namespace == "cobblemon") return
if (mob.type.`is`(ALLOWED_MOBS)) return
event.isCanceled = true
if (mob is Bee) {
// A nest keeps a bee it can't release and tries again every tick, with its sound each time; empty it.
val at = mob.blockPosition()
BlockPos.betweenClosed(at.offset(-2, -2, -2), at.offset(2, 2, 2))
.filter { level.getBlockEntity(it) is BeehiveBlockEntity }
.forEach { hivesToEmpty.add(level to it.immutable()) }
}
}
fun onServerTick(event: ServerTickEvent.Post) {
if (hivesToEmpty.isEmpty()) return
for ((level, pos) in hivesToEmpty) {
val hive = level.getBlockEntity(pos) as? BeehiveBlockEntity ?: continue
// Loading a nest's data clears its bees and reads them back from "bees"; without that list it stays empty.
val data = hive.saveWithoutMetadata(level.registryAccess())
data.remove("bees")
hive.loadWithComponents(data, level.registryAccess())
hive.setChanged()
}
hivesToEmpty.clear()
}
fun onChunkLoad(event: ChunkEvent.Load) {