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:
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) {
|
||||
|
||||
Reference in new issue
Block a user