Phase 9: reproducible builds, balance from the server, spark check, play feedback

Build: every tool pinned (JDK, packwiz, Gradle plugins, Python packages), exports
normalised, tools/check_reproducible.py proves two clean builds are byte-identical.

Server-side balance: a server can load config/cobblesurvival/datapack.zip in place of
the jar's data, /reload applies it, tools/server_update.py packages it and refuses
when a client change is needed. JEI drop pages are sent by the server. Code-held
balance numbers moved to tuning/server.json.

Performance: spark in the pack (server only), tools/perf_test.py with fake players
whose chunks really are active, docs/performance.md.

Adrian's feedback: default keys R, U, J, §; drop pages link to Cobblemon Info and show
real chances; bosses only hurt in battles; battle XP halved; channels between the
islands low level; temperature ×3 with insulation doubled; hazards start at once; no
item dots on the map; a wild attack starts a battle; the starter only faints; quest
book on join with a basics page and an HMs page; regional forms obtainable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
claudeandClaude Opus 5.5 committed 2026-11-09 16:26:30 +01:00
1 parent 0741c84077
commit 4083791944
133 files changed
+8350 -3273

No files matched your search

@@ -0,0 +1,33 @@
package dev.cobblesurvival.mixin;
import com.cobblemon.mod.common.pokemon.Pokemon;
import dev.cobblesurvival.permadeath.Permadeath;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.Pseudo;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
/**
* A player's starter only faints, it is never removed (Adrian, 2026-11-09). CobbleHardcoreMon has no exception of its
* own: this leaves its Revival Charm alone and skips its removal. Named by string, so the companion mod needs it only at run time.
*/
@Pseudo
@Mixin(targets = "dev.matthiesen.cobblehardcoremon.common.handlers.PlayerPokeParty", remap = false)
abstract class HardcorePartyMixin {
@Inject(method = "popPokemonTotem", at = @At("HEAD"), cancellable = true)
private void cobblesurvival$keepCharm(Pokemon pokemon, CallbackInfoReturnable<Boolean> result) {
if (Permadeath.INSTANCE.isStarter(pokemon)) result.setReturnValue(false);
}
@Inject(method = "alertPlayerAndRemovedPokemon(Lcom/cobblemon/mod/common/pokemon/Pokemon;)V", at = @At("HEAD"), cancellable = true)
private void cobblesurvival$keepStarter(Pokemon pokemon, CallbackInfo info) {
if (Permadeath.INSTANCE.isStarter(pokemon)) info.cancel();
}
@Inject(method = "alertPlayerAndRemovedPokemon(Lcom/cobblemon/mod/common/pokemon/Pokemon;ZLjava/lang/String;)V", at = @At("HEAD"), cancellable = true)
private void cobblesurvival$keepStarter(Pokemon pokemon, boolean soulLink, String partner, CallbackInfo info) {
if (Permadeath.INSTANCE.isStarter(pokemon)) info.cancel();
}
}
@@ -1,5 +1,14 @@
package dev.cobblesurvival
import dev.cobblesurvival.catching.AttackBattles
import dev.cobblesurvival.jei.DropSources
import net.neoforged.fml.loading.FMLPaths
import net.minecraft.server.packs.PackSelectionConfig
import net.minecraft.server.packs.PackLocationInfo
import net.minecraft.server.packs.FilePackResources
import java.util.Optional
import java.nio.file.Path
import java.nio.file.Files
import com.cobblemon.mod.common.Cobblemon
import com.cobblemon.mod.common.api.pokemon.PokemonSpecies
import com.mojang.logging.LogUtils
@@ -17,6 +26,7 @@ import dev.cobblesurvival.machine.MachineShowcase
import dev.cobblesurvival.machine.Machines
import dev.cobblesurvival.party.ShoulderPassives
import dev.cobblesurvival.progress.Gates
import dev.cobblesurvival.selftest.PerfLoad
import dev.cobblesurvival.selftest.SelfTest
import dev.cobblesurvival.world.OnlyPokemon
import dev.cobblesurvival.world.SharedLoot
@@ -54,6 +64,7 @@ import thedarkcolour.kotlinforforge.neoforge.forge.MOD_BUS
class CobbleSurvival {
companion object {
const val MOD_ID = "cobblesurvival"
private val serverData: Path by lazy { FMLPaths.CONFIGDIR.get().resolve("cobblesurvival/datapack.zip") }
val LOGGER: Logger = LogUtils.getLogger()
val version: String
@@ -61,6 +72,11 @@ class CobbleSurvival {
.map { it.modInfo.version.toString() }
.orElse("unknown")
/** Which balance is live: `/cobblesurvival data`, read over RCON by tools/check_server_update.py. */
fun dataLine() = "data ${if (Files.isRegularFile(serverData)) "server" else "jar"}; " +
"pages ${Integer.toHexString(DropSources.packed.contentHashCode())}; " +
"boss_evs ${Tuning.values.boss_evs}; pokemon_pages ${DropSources.data.pokemon.size}"
/** 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()}"
@@ -69,6 +85,7 @@ class CobbleSurvival {
init {
LOGGER.info("Cobblemon Survival companion {} loaded (Cobblemon {})", version, Cobblemon.VERSION)
MOD_BUS.addListener(::onAddPackFinders)
DropSources.register(MOD_BUS)
InZoneCondition.register(MOD_BUS)
Machines.register(MOD_BUS)
Summons.register(MOD_BUS)
@@ -78,6 +95,7 @@ class CobbleSurvival {
NeoForge.EVENT_BUS.addListener(OnlyPokemon::onChunkLoad)
NeoForge.EVENT_BUS.addListener(OnlyPokemon::onEntityJoin)
NeoForge.EVENT_BUS.addListener(OnlyPokemon::onServerTick)
Tuning.register()
Permadeath.register()
ZoneCondition.register()
NeoForge.EVENT_BUS.addListener(Zones::onAddReloadListeners)
@@ -92,12 +110,14 @@ class CobbleSurvival {
OreNodes.register()
BaseLoot.register()
Catching.register()
AttackBattles.register()
Wanderers.register()
SharedLoot.register()
SpecialPokemon.register()
ZoneHazards.register()
LevelPressure.register()
SelfTest.register()
PerfLoad.register()
}
/** The island world type has no Nether, so a lit obsidian frame must not turn into a dead portal. */
@@ -108,16 +128,27 @@ class CobbleSurvival {
}
}
/** Ships datapacks/core from the repo as a built-in data pack that is always on, in every world. */
/**
* Ships datapacks/core from the repo as a built-in data pack that is always on, in every world. A server with
* `config/cobblesurvival/datapack.zip` (tools/server_update.py; Adrian, 2026-11-09) loads that instead, under the
* same pack id: balance changes need no new jar for the server or anybody's client, and `/reload` applies them.
*/
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,
)
val title = Component.literal("Cobblemon Survival core data")
val core = ResourceLocation.fromNamespaceAndPath(MOD_ID, "datapacks/core")
if (event.packType != PackType.SERVER_DATA || !Files.isRegularFile(serverData)) {
event.addPackFinders(core, PackType.SERVER_DATA, title, PackSource.BUILT_IN, true, Pack.Position.TOP)
return
}
LOGGER.info("Loading the core data pack from {} instead of the mod jar", serverData)
event.addRepositorySource { packs ->
Pack.readMetaAndCreate(
PackLocationInfo("mod/$core", title, PackSource.BUILT_IN, Optional.empty()),
FilePackResources.FileResourcesSupplier(serverData),
PackType.SERVER_DATA,
PackSelectionConfig(true, Pack.Position.TOP, false),
)?.let { packs.accept(it) }
}
}
private fun onRegisterCommands(event: RegisterCommandsEvent) {
@@ -127,6 +158,11 @@ class CobbleSurvival {
context.source.sendSuccess({ Component.literal(statusLine()) }, false)
1
})
.then(Commands.literal("data").executes { context ->
context.source.sendSuccess({ Component.literal(dataLine()) }, false)
1
})
.then(PerfLoad.command())
.then(BiomePreview.command())
.then(OreVeins.command())
.then(MachineShowcase.command())
@@ -0,0 +1,42 @@
package dev.cobblesurvival
import com.google.gson.Gson
import com.google.gson.JsonElement
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.neoforged.neoforge.common.NeoForge
import net.neoforged.neoforge.event.AddReloadListenerEvent
/**
* The balance numbers the server's code reads that have no data file of their own: `data/cobblesurvival/tuning/server.json`.
* In the data pack so a server can change them without a new jar for anybody (tools/server_update.py); `/reload` applies them.
* A field the file leaves out keeps its default here.
*/
object Tuning : SimpleJsonResourceReloadListener(Gson(), "tuning") {
class Values(
val boss_evs: Int = 170,
val guardian_wake_range: Double = 96.0,
val guardian_leash: Double = 24.0,
val end_boss_wake_range: Double = 160.0,
val end_boss_leash: Double = 24.0,
val summon_spacing: Double = 96.0,
val wanderer_range: Double = 64.0,
val attack_battle_grace_ticks: Int = 100,
)
private val FILE = ResourceLocation.fromNamespaceAndPath(CobbleSurvival.MOD_ID, "server")
@Volatile
var values = Values()
private set
fun register() {
NeoForge.EVENT_BUS.addListener<AddReloadListenerEvent> { it.addListener(this) }
}
override fun apply(files: Map<ResourceLocation, JsonElement>, manager: ResourceManager, profiler: ProfilerFiller) {
values = files[FILE]?.let { Gson().fromJson(it, Values::class.java) } ?: Values()
}
}
@@ -10,8 +10,12 @@ import com.cobblemon.mod.common.battles.pokemon.BattlePokemon
import com.cobblemon.mod.common.entity.pokemon.PokemonEntity
import com.cobblemon.mod.common.pokemon.IVs
import com.cobblemon.mod.common.pokemon.Pokemon
import dev.cobblesurvival.Tuning
import dev.cobblesurvival.zone.Guardians
import net.minecraft.network.chat.Component
import net.minecraft.tags.DamageTypeTags
import net.neoforged.neoforge.common.NeoForge
import net.neoforged.neoforge.event.entity.living.LivingIncomingDamageEvent
/**
* Bosses are big and tough, not strong (Adrian, 2026-11-02: Eternatus was too small and too easy; guardians should be
@@ -25,10 +29,19 @@ import net.minecraft.network.chat.Component
*/
object BossToughness : HeldItemManager {
const val WARD = "guardiansward"
const val EVS = 170
val EVS get() = Tuning.values.boss_evs
private val DEFENSIVE = listOf(Stats.HP, Stats.DEFENCE, Stats.SPECIAL_DEFENCE)
fun register() = HeldItemProvider.register(this, Priority.HIGHEST)
fun register() {
HeldItemProvider.register(this, Priority.HIGHEST)
NeoForge.EVENT_BUS.addListener(::onDamage)
}
/** Only a battle may hurt a boss (Adrian, 2026-11-09): no players, Pokémon outside battle, fire, falls or drowning. `/kill` still works. */
private fun onDamage(event: LivingIncomingDamageEvent) {
val entity = event.entity as? PokemonEntity ?: return
if (isBoss(entity.pokemon) && !event.source.`is`(DamageTypeTags.BYPASSES_INVULNERABILITY)) event.isCanceled = true
}
fun isBoss(pokemon: Pokemon): Boolean {
val data = pokemon.persistentData
@@ -1,5 +1,6 @@
package dev.cobblesurvival.boss
import dev.cobblesurvival.Tuning
import com.cobblemon.mod.common.api.Priority
import com.cobblemon.mod.common.api.events.CobblemonEvents
import com.cobblemon.mod.common.api.events.battles.BattleFaintedEvent
@@ -38,8 +39,8 @@ object EndBoss {
/** Its entry in [GuardianState]. */
private const val STATE = "the_end"
private const val CHECK_TICKS = 40
private const val NEAR = 160.0
private const val LEASH = 24.0
private val NEAR get() = Tuning.values.end_boss_wake_range
private val LEASH get() = Tuning.values.end_boss_leash
private const val TICKS_PER_HOUR = 72000.0
/** Where it stands: south of the exit portal, on the island's top. */
private val SPOT_XZ = BlockPos(0, 0, 24)
@@ -0,0 +1,50 @@
package dev.cobblesurvival.catching
import com.cobblemon.mod.common.api.events.CobblemonEvents
import com.cobblemon.mod.common.battles.BattleBuilder
import com.cobblemon.mod.common.battles.BattleRegistry
import com.cobblemon.mod.common.battles.ErroredBattleStart
import com.cobblemon.mod.common.entity.pokemon.PokemonEntity
import dev.cobblesurvival.Tuning
import dev.cobblesurvival.boss.BossToughness
import net.minecraft.server.level.ServerPlayer
import net.neoforged.neoforge.common.NeoForge
import net.neoforged.neoforge.event.entity.living.LivingIncomingDamageEvent
import java.util.UUID
/**
* A wild Pokémon that attacks a player starts a battle instead of hurting them (Adrian, 2026-11-09). A player with no
* Pokémon able to fight still takes the hit, so the wilds stay dangerous to them. After fleeing, a moment's grace
* (`tuning/server.json` → `attack_battle_grace_ticks`) to get away, or the same Pokémon would pull them straight back.
* A boss in a battle hits through: those are its area attacks.
*/
object AttackBattles {
/** Game time until which a hit on this player does nothing: a battle is being set up, or they have just fled. */
private val graceUntil = HashMap<UUID, Long>()
fun register() {
NeoForge.EVENT_BUS.addListener(::onDamage)
CobblemonEvents.BATTLE_FLED.subscribe { event ->
event.battle.players.forEach { graceUntil[it.uuid] = it.serverLevel().gameTime + Tuning.values.attack_battle_grace_ticks }
}
}
private fun onDamage(event: LivingIncomingDamageEvent) {
val player = event.entity as? ServerPlayer ?: return
val attacker = event.source.entity as? PokemonEntity ?: return
if (attacker.pokemon.isPlayerOwned() || (attacker.isBattling && BossToughness.isBoss(attacker.pokemon))) return
if (BattleRegistry.getBattleByParticipatingPlayer(player) != null) {
event.isCanceled = true
return
}
val now = player.serverLevel().gameTime
if (now < (graceUntil[player.uuid] ?: 0L)) {
event.isCanceled = true
return
}
if (BattleBuilder.pve(player, attacker) !is ErroredBattleStart) {
graceUntil[player.uuid] = now + Tuning.values.attack_battle_grace_ticks
event.isCanceled = true
}
}
}
@@ -1,18 +1,40 @@
package dev.cobblesurvival.jei
import com.google.gson.Gson
import com.google.gson.JsonElement
import dev.cobblesurvival.CobbleSurvival
import io.netty.buffer.ByteBuf
import net.minecraft.core.registries.BuiltInRegistries
import net.minecraft.network.codec.ByteBufCodecs
import net.minecraft.network.codec.StreamCodec
import net.minecraft.network.protocol.common.custom.CustomPacketPayload
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.item.ItemStack
import net.minecraft.world.item.Items
import net.neoforged.bus.api.IEventBus
import net.neoforged.neoforge.common.NeoForge
import net.neoforged.neoforge.event.AddReloadListenerEvent
import net.neoforged.neoforge.event.OnDatapackSyncEvent
import net.neoforged.neoforge.network.PacketDistributor
import net.neoforged.neoforge.network.event.RegisterPayloadHandlersEvent
import net.neoforged.neoforge.network.handling.IPayloadContext
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.util.zip.GZIPInputStream
import java.util.zip.GZIPOutputStream
/**
* Where items come from on the island (roadmap Phase 8), for the pack's JEI pages ([DropSourcesJei]):
* `assets/cobblesurvival/jei/drop_sources.json`, written by tools/jei_sources.py from the pack's data. It ships inside
* the mod because JEI runs on the client, which on a dedicated server has none of the server's data; the self-test
* `drop_sources_match_the_server` checks it against the live server instead. Fields as tools/jei_sources.py writes them.
* `data/cobblesurvival/drop_sources/pages.json`, written by tools/jei_sources.py from the pack's data. JEI runs on the
* client, which on a dedicated server has none of the server's data, so the server sends this file to each player who
* joins and again after `/reload`: the pages show the server's balance whatever version a player installed (Adrian,
* 2026-11-09). The self-test `drop_sources_match_the_server` checks it against the live server. Fields as
* tools/jei_sources.py writes them.
*/
object DropSources {
object DropSources : SimpleJsonResourceReloadListener(Gson(), "drop_sources") {
class Drop(val item: String, val count: String?, val chance: Double?)
class Reward(val item: String, val count: Int)
class Spawned(val zone: String, val levels: String)
@@ -45,11 +67,65 @@ object DropSources {
val pasture_finds: List<Finds>,
)
const val PATH = "/assets/cobblesurvival/jei/drop_sources.json"
/** The file's text, gzipped, as sent to clients. */
class Payload(val packed: ByteArray) : CustomPacketPayload {
override fun type() = TYPE
}
val data: Data by lazy {
val stream = DropSources::class.java.getResourceAsStream(PATH) ?: error("$PATH is missing from the mod")
stream.reader(Charsets.UTF_8).use { Gson().fromJson(it, Data::class.java) }
val TYPE = CustomPacketPayload.Type<Payload>(ResourceLocation.fromNamespaceAndPath(CobbleSurvival.MOD_ID, "drop_sources"))
private val CODEC: StreamCodec<ByteBuf, Payload> = ByteBufCodecs.BYTE_ARRAY.map(::Payload, Payload::packed)
private val FILE = ResourceLocation.fromNamespaceAndPath(CobbleSurvival.MOD_ID, "pages")
private val NOTHING = Data(emptyMap(), emptyList(), emptyList(), emptyList(), emptyList(),
EndBoss("", 0, 0, 0, 0.0, emptyList()), Altar(emptyList(), emptyList()), emptyList(), emptyList(), emptyList())
/** What the pages show: on a server, its data pack; on a client, what the server last sent. */
@Volatile
var data: Data = NOTHING
private set
/** The current data as sent to clients. */
var packed = ByteArray(0)
private set
/** Set by the JEI plugin while it runs; called on the client thread after the server sent data. */
var onReceived: (() -> Unit)? = null
fun register(modBus: IEventBus) {
modBus.addListener<RegisterPayloadHandlersEvent> {
// Optional: a client from before this change can still join, it only misses the pages.
it.registrar("1").optional().playToClient(TYPE, CODEC, ::receive)
}
NeoForge.EVENT_BUS.addListener<AddReloadListenerEvent> { it.addListener(this) }
NeoForge.EVENT_BUS.addListener(::sync)
}
override fun apply(files: Map<ResourceLocation, JsonElement>, manager: ResourceManager, profiler: ProfilerFiller) {
val file = files[FILE] ?: return
packed = pack(file.toString())
data = parse(packed)
}
/** On joining and after `/reload`. */
private fun sync(event: OnDatapackSyncEvent) {
val payload = Payload(packed)
event.relevantPlayers.filter { it.connection.hasChannel(TYPE) }.forEach { PacketDistributor.sendToPlayer(it, payload) }
}
private fun receive(payload: Payload, context: IPayloadContext) {
// On a singleplayer client the integrated server has already put the same bytes here.
if (!payload.packed.contentEquals(packed)) {
packed = payload.packed
data = parse(packed)
}
onReceived?.invoke()
}
fun pack(text: String): ByteArray = ByteArrayOutputStream().also { out ->
GZIPOutputStream(out).use { it.write(text.toByteArray(Charsets.UTF_8)) }
}.toByteArray()
fun parse(packed: ByteArray): Data = GZIPInputStream(ByteArrayInputStream(packed)).reader(Charsets.UTF_8).use {
Gson().fromJson(it, Data::class.java)
}
fun zoneName(zone: String?): String = zone?.let { data.zones[it]?.name ?: it } ?: "anywhere"
@@ -7,9 +7,11 @@ import dev.cobblesurvival.CobbleSurvival
import dev.cobblesurvival.summon.Summons
import mezz.jei.api.IModPlugin
import mezz.jei.api.JeiPlugin
import mezz.jei.api.constants.VanillaTypes
import mezz.jei.api.gui.builder.IRecipeLayoutBuilder
import mezz.jei.api.gui.drawable.IDrawable
import mezz.jei.api.gui.ingredient.IRecipeSlotsView
import mezz.jei.api.ingredients.IIngredientType
import mezz.jei.api.ingredients.subtypes.ISubtypeInterpreter
import mezz.jei.api.ingredients.subtypes.UidContext
import mezz.jei.api.recipe.IFocusGroup
@@ -21,6 +23,7 @@ import mezz.jei.api.registration.IRecipeCategoryRegistration
import mezz.jei.api.registration.IExtraIngredientRegistration
import mezz.jei.api.registration.IRecipeRegistration
import mezz.jei.api.registration.ISubtypeRegistration
import mezz.jei.api.runtime.IJeiRuntime
import net.minecraft.ChatFormatting
import net.minecraft.client.Minecraft
import net.minecraft.client.gui.Font
@@ -46,16 +49,20 @@ import net.minecraft.world.item.component.ItemLore
@JeiPlugin
class DropSourcesJei : IModPlugin {
/** One page: an icon and title with a short [tag], rows of text, and the items it gives. */
class Page(val icon: ItemStack, val title: Component, val tag: String, val rows: List<Row>, val outputs: List<Output>)
class Page(val icon: ItemStack, val title: Component, val tag: String, val rows: List<Row>, val outputs: List<Output>, val species: String? = null)
/** A line of text, wrapped if long, with optional [right]-aligned text on its first line. */
class Row(val left: Component, val right: Component? = null)
class Output(val stack: ItemStack, val notes: List<Component>)
private class Kind(val path: String, val title: String, val icon: String, val catalysts: List<String>, val pages: () -> List<Page>) {
val type: RecipeType<Page> = RecipeType.create(CobbleSurvival.MOD_ID, path, Page::class.java)
val built by lazy(pages)
var built: List<Page> = emptyList()
}
private var runtime: IJeiRuntime? = null
/** The server data [Kind.built] was made from. */
private var builtFrom: DropSources.Data? = null
private val kinds = listOf(
Kind("pokemon_drops", "Island Drops", "cobblemon:poke_ball", listOf("cobblesurvival:synthesis_core"), ::pokemonPages),
Kind("boss_rewards", "Boss Rewards", "minecraft:netherite_sword", emptyList(), ::bossPages),
@@ -82,8 +89,10 @@ class DropSourcesJei : IModPlugin {
override fun registerCategories(registration: IRecipeCategoryRegistration) {
val gui = registration.jeiHelpers.guiHelper
builtFrom = DropSources.data
kinds.forEach { it.built = it.pages() }
registration.addRecipeCategories(*kinds.map { kind ->
PageCategory(kind.type, Component.literal(kind.title), gui.createDrawableItemStack(DropSources.stack(kind.icon)), kind.built)
PageCategory(kind.type, Component.literal(kind.title), gui.createDrawableItemStack(DropSources.stack(kind.icon))) { kind.built }
}.toTypedArray())
}
@@ -95,15 +104,65 @@ class DropSourcesJei : IModPlugin {
kinds.forEach { kind -> kind.catalysts.forEach { registration.addRecipeCatalyst(DropSources.stack(it), kind.type) } }
}
class PageCategory(private val type: RecipeType<Page>, private val title: Component, private val icon: IDrawable, pages: List<Page>) : IRecipeCategory<Page> {
override fun onRuntimeAvailable(jeiRuntime: IJeiRuntime) {
runtime = jeiRuntime
DropSources.onReceived = ::refresh
refresh()
}
override fun onRuntimeUnavailable() {
runtime = null
DropSources.onReceived = null
}
/** The server's data can arrive after JEI has started, and changes with `/reload`: swaps the pages in the running JEI. */
private fun refresh() {
val jei = runtime ?: return
if (builtFrom === DropSources.data) return
builtFrom = DropSources.data
val listed = jei.ingredientManager.getAllIngredients(VanillaTypes.ITEM_STACK).mapNotNull { Summons.speciesOf(it) }.toSet()
val newSigils = sigils().filter { Summons.speciesOf(it) !in listed }
if (newSigils.isNotEmpty()) jei.ingredientManager.addIngredientsAtRuntime(VanillaTypes.ITEM_STACK, newSigils)
for (kind in kinds) {
jei.recipeManager.hideRecipes(kind.type, kind.built)
kind.built = kind.pages()
jei.recipeManager.addRecipes(kind.type, kind.built)
}
}
/** Cobblemon Info's JEI ingredient for a Pokémon, by reflection: the add-on is a separate download, not a dependency. */
private object CobbledexLink {
@Suppress("UNCHECKED_CAST")
val type: IIngredientType<Any>? by lazy {
runCatching { Class.forName("com.cobbledex.jei.PokemonIngredientType").getField("INSTANCE").get(null) as IIngredientType<Any> }.getOrNull()
}
private val constructor by lazy {
runCatching { Class.forName("com.cobbledex.jei.PokemonIngredient").getConstructor(String::class.java, Set::class.java) }.getOrNull()
}
fun of(species: String): Any? = constructor?.takeIf { type != null }?.newInstance(species.substringAfter(':'), emptySet<String>())
}
class PageCategory(private val type: RecipeType<Page>, private val title: Component, private val icon: IDrawable, private val pages: () -> List<Page>) : IRecipeCategory<Page> {
private var measured: List<Page>? = null
private var tallest = 0
/** JEI gives every page of a category the same size: that of its tallest. */
private val height = pages.maxOf { slotsTop(it) + (it.outputs.size + PER_ROW - 1) / PER_ROW * SLOT } + PADDING - 2
private val pageHeight: Int
get() {
val now = pages()
if (now !== measured) {
measured = now
tallest = (now.maxOfOrNull { slotsTop(it) + (it.outputs.size + PER_ROW - 1) / PER_ROW * SLOT } ?: BODY_TOP) + PADDING - 2
}
return tallest
}
override fun getRecipeType() = type
override fun getTitle() = title
override fun getIcon() = icon
override fun getWidth() = WIDTH
override fun getHeight() = height
override fun getHeight() = pageHeight
private val font: Font get() = Minecraft.getInstance().font
@@ -117,7 +176,10 @@ class DropSourcesJei : IModPlugin {
}
override fun setRecipe(builder: IRecipeLayoutBuilder, page: Page, focuses: IFocusGroup) {
builder.addSlot(RecipeIngredientRole.RENDER_ONLY, PADDING, ICON_TOP).addItemStack(page.icon)
val linked = page.species?.let(CobbledexLink::of)
// As Cobblemon Info's own ingredient the Pokémon opens that mod's pages when clicked (Adrian, 2026-11-09).
if (linked != null) builder.addSlot(RecipeIngredientRole.INPUT, PADDING, ICON_TOP).addIngredient(CobbledexLink.type!!, linked)
else builder.addSlot(RecipeIngredientRole.RENDER_ONLY, PADDING, ICON_TOP).addItemStack(page.icon)
val top = slotsTop(page)
page.outputs.forEachIndexed { i, out ->
builder.addSlot(RecipeIngredientRole.OUTPUT, PADDING + 1 + (i % PER_ROW) * SLOT, top + 1 + (i / PER_ROW) * SLOT)
@@ -128,11 +190,11 @@ class DropSourcesJei : IModPlugin {
override fun draw(page: Page, view: IRecipeSlotsView, graphics: GuiGraphics, mouseX: Double, mouseY: Double) {
// An opaque panel of our own, so the text reads the same whatever JEI's theme is.
graphics.fill(0, 0, WIDTH, height, SURFACE)
graphics.fill(0, 0, WIDTH, pageHeight, SURFACE)
graphics.fill(0, 0, WIDTH, 1, BORDER)
graphics.fill(0, height - 1, WIDTH, height, BORDER)
graphics.fill(0, 0, 1, height, BORDER)
graphics.fill(WIDTH - 1, 0, WIDTH, height, BORDER)
graphics.fill(0, pageHeight - 1, WIDTH, pageHeight, BORDER)
graphics.fill(0, 0, 1, pageHeight, BORDER)
graphics.fill(WIDTH - 1, 0, WIDTH, pageHeight, BORDER)
val right = WIDTH - PADDING
val tagWidth = font.width(page.tag)
@@ -220,26 +282,27 @@ class DropSourcesJei : IModPlugin {
} else {
listOf(Row(muted("Not wild. Its line lives in:"))) + p.line.orEmpty().map { Row(text(DropSources.zoneName(it.zone)), speciesName(it.species)) }
}
Page(picture(p.species), speciesName(p.species), "Drops", rows, p.drops.filter { DropSources.exists(it.item) }.map(::dropOutput))
Page(picture(p.species), speciesName(p.species), "Drops", rows, p.drops.filter { DropSources.exists(it.item) }.map(::dropOutput), p.species)
}
val special = data.spawn_drops.map { s ->
Page(picture(s.species), speciesName(s.species), "Drops here", listOf(Row(text(DropSources.zoneName(s.zone)), text("instead"))), s.drops.filter { DropSources.exists(it.item) }.map(::dropOutput))
Page(picture(s.species), speciesName(s.species), "Drops here", listOf(Row(text(DropSources.zoneName(s.zone)), text("instead"))), s.drops.filter { DropSources.exists(it.item) }.map(::dropOutput), s.species)
}
return wild + special
}
fun bossPages(): List<Page> {
val data = DropSources.data
if (data.guardians.isEmpty()) return emptyList()
val guardians = data.guardians.map { g ->
val hours = g.respawn_hours.let { if (it % 1.0 == 0.0) it.toInt() else it }
Page(picture(g.species), speciesName(g.species), "Guardian", listOf(
Row(text(DropSources.zoneName(g.zone)), text("Lv ${g.level}")), Row(muted("Back $hours hours after a win")),
), rewardOutputs(g.drops, gray("for everyone in the winning battle")))
), rewardOutputs(g.drops, gray("for everyone in the winning battle")), g.species)
}
val end = data.end_boss
val boss = Page(picture(end.species), speciesName(end.species), "End boss", listOf(
Row(text("The End"), text("Lv ${end.level}")), Row(muted("${end.xp_first_win} XP the first time, ${end.xp} after")),
), rewardOutputs(end.first_win, gray("first win only")))
), rewardOutputs(end.first_win, gray("first win only")), end.species)
return guardians + boss
}
@@ -30,7 +30,7 @@ import java.util.UUID
*/
object MachineShowcase {
val OWNER: UUID = UUID.nameUUIDFromBytes("cobblesurvival:showcase".toByteArray())
private const val SPACING = 10
const val SPACING = 10
private const val ROW = 12
private class Entry(val core: String, val casing: Block, val species: String, val level: Int, val sign: List<String>)
@@ -60,6 +60,14 @@ object MachineShowcase {
return level.getBlockEntity(core) as? MachineBlockEntity ?: error("no machine at $core")
}
/** One working machine, the [index]th kind of the showcase, at ground level [at]: used by the load test. */
fun working(level: ServerLevel, at: BlockPos, index: Int) {
val entry = ENTRIES[index % ENTRIES.size]
val core = at.above()
build(level, core, entry.core, entry.casing, glass = true).fill(level, PokemonSpecies.getByName(entry.species)!!.create(entry.level), OWNER, "Showcase")
extras(level, entry, core)
}
private fun placeCore(level: ServerLevel, core: BlockPos, name: String) =
level.setBlockAndUpdate(core, BuiltInRegistries.BLOCK.get(id(name)).defaultBlockState().setValue(MachineBlock.FACING, Direction.SOUTH))
@@ -1,5 +1,6 @@
package dev.cobblesurvival.machine
import dev.cobblesurvival.permadeath.Permadeath
import com.cobblemon.mod.common.api.drop.ItemDropEntry
import com.cobblemon.mod.common.api.pokemon.stats.Stats
import com.cobblemon.mod.common.pokemon.FormData
@@ -177,6 +178,11 @@ object MachineWork {
}
fun altarUse(core: MachineBlockEntity, level: ServerLevel, def: MachineDefs.Def, player: ServerPlayer, pokemon: Pokemon) {
if (Permadeath.isStarter(pokemon)) {
MachineBlockEntity.tell(player, Component.translatableWithFallback("message.cobblesurvival.machine.starter_sacrifice",
"%s is your first partner: it can't be sacrificed.", pokemon.getDisplayName()).withStyle(ChatFormatting.YELLOW))
return
}
val rules = def.altar!!
val rewards = altarRewards(rules, pokemon)
if (core.confirmBy == player.uuid && level.gameTime <= core.confirmUntil) {
@@ -34,7 +34,8 @@ import java.util.concurrent.ConcurrentLinkedQueue
* - 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.
* any other is removed from its party or PC. The one exception is the player's starter (Adrian, 2026-11-09): it only
* faints, and can't be released or sacrificed either ([isStarter]; CobbleHardcoreMon's side is mixin/HardcorePartyMixin).
*/
object Permadeath {
val REVIVAL_CHARMS: TagKey<Item> =
@@ -50,6 +51,18 @@ object Permadeath {
// 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)
CobblemonEvents.POKEMON_RELEASED_EVENT_PRE.subscribe(Priority.NORMAL) { event ->
if (!isStarter(event.pokemon)) return@subscribe
event.cancel()
event.player.sendSystemMessage(Component.translatableWithFallback("message.cobblesurvival.permadeath.starter_release",
"%s is your first partner: it can't be released.", event.pokemon.getDisplayName(false)).withStyle(ChatFormatting.YELLOW))
}
}
/** The Pokémon this player chose as a starter, which Cobblemon remembers for each player. */
fun isStarter(pokemon: Pokemon): Boolean {
val owner = pokemon.getOwnerUUID() ?: return false
return Cobblemon.playerDataManager.getGenericData(owner).starterUUID == pokemon.uuid
}
private fun onBattleFainted(event: BattleFaintedEvent) {
@@ -85,7 +98,7 @@ object Permadeath {
private fun settle(player: ServerPlayer, pokemon: Pokemon) {
val store = pokemon.storeCoordinates.get()?.store
if (!pokemon.isFainted() || (store !is PlayerPartyStore && store !is PCStore)) return
if (!pokemon.isFainted() || isStarter(pokemon) || (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 {
@@ -33,6 +33,7 @@ object BossTests {
fun all() = listOf(
SelfTestCase("bosses_cover_every_guardian", 20, ::coverage),
SelfTestCase("boss_attack_shapes", 20, ::shapes),
SelfTestCase("bosses_only_take_battle_damage", 40, ::onlyBattleDamage),
SelfTestCase("boss_attacks_land_and_stop_with_the_battle", 400, ::landAndStop),
SelfTestCase("boss_arena_leaving_loses", 400, ::arena),
SelfTestCase("end_boss_replaces_the_dragon", 200, ::endBoss),
@@ -109,6 +110,19 @@ object BossTests {
s.helper.succeed()
}
private fun onlyBattleDamage(s: Scenario) {
val player = s.player()
val boss = s.wild(s.pokemon("snorlax", 40), s.at(5.5, 1.0, 5.5))
boss.pokemon.persistentData.putBoolean(Guardians.TAG, true)
val health = boss.health
val sources = boss.damageSources()
s.check(!boss.hurt(sources.playerAttack(player), 8f)) { "a player's hit hurt a guardian" }
s.check(!boss.hurt(sources.inFire(), 8f)) { "fire hurt a guardian" }
s.check(!boss.hurt(sources.drown(), 8f)) { "drowning hurt a guardian" }
s.check(boss.health == health) { "the guardian lost health" }
s.helper.succeed()
}
private fun shapes(s: Scenario) {
val o = Vec3(0.0, 64.0, 0.0)
val circle = Area.Circle(o, 3.0)
@@ -1,5 +1,6 @@
package dev.cobblesurvival.selftest
import com.cobblemon.mod.common.battles.BattleRegistry
import com.cobblemon.mod.common.api.events.CobblemonEvents
import com.cobblemon.mod.common.api.events.pokeball.PokeBallCaptureCalculatedEvent
import com.cobblemon.mod.common.api.events.pokeball.PokemonCatchRateEvent
@@ -19,9 +20,14 @@ import kotlin.math.abs
* scan switched on only there (other tests put weak trainers next to wild Pokémon).
*/
object CatchingTests {
/** A player who has just joined can't be hurt for this long. */
private const val SPAWN_PROTECTION_TICKS = 70
fun all() = listOf(
SelfTestCase("catch_rate_falls_with_level_gap", 20, ::catchRate, batch = "catching"),
SelfTestCase("failed_catch_enrages", 400, ::failedCatch, batch = "catching"),
SelfTestCase("wild_attack_starts_a_battle", 300, ::attackBattle, batch = "attack_battle"),
SelfTestCase("wild_attack_hurts_without_a_party", 120, ::attackHurts, batch = "attack_battle"),
SelfTestCase("strong_wild_pokemon_hunt_weak_trainers", 400, { s -> hostility(s, strongest = 5, hostile = true) },
batch = "catching_weak"),
SelfTestCase("wild_pokemon_ignore_stronger_trainers", 120, { s -> hostility(s, strongest = 50, hostile = false) },
@@ -44,6 +50,37 @@ object CatchingTests {
s.helper.succeed()
}
private fun attackBattle(s: Scenario) {
val player = s.player()
s.give(player, s.pokemon("charizard", 50, "ember"))
val wild = s.wild(s.pokemon("rattata", 5, "tackle"), s.at(5.5, 1.0, 5.5))
val health = player.health
s.helper.startSequence()
.thenWaitUntil { s.check(wild.pokemon.entity === wild) { "the wild Pokémon has not ticked yet" } }
.thenIdle(SPAWN_PROTECTION_TICKS)
.thenExecute {
s.check(!player.hurt(wild.damageSources().mobAttack(wild), 6f)) { "the hit went through" }
// A second hit while the Pokémon is still being sent out doesn't hurt either.
s.check(!player.hurt(wild.damageSources().mobAttack(wild), 6f)) { "the second hit went through" }
s.check(player.health == health) { "the player lost health" }
}
.thenWaitUntil { s.check(BattleRegistry.getBattleByParticipatingPlayer(player) != null) { "no battle started" } }
.thenSucceed()
}
private fun attackHurts(s: Scenario) {
val player = s.player()
val wild = s.wild(s.pokemon("rattata", 5, "tackle"), s.at(5.5, 1.0, 5.5))
val health = player.health
s.helper.startSequence()
.thenIdle(SPAWN_PROTECTION_TICKS)
.thenExecute {
s.check(player.hurt(wild.damageSources().mobAttack(wild), 6f)) { "the hit was cancelled for a player with no Pokémon" }
s.check(player.health < health) { "the player lost no health" }
}
.thenSucceed()
}
private fun failedCatch(s: Scenario) {
val player = s.player()
s.give(player, s.pokemon("pidgeot", 60)) // stronger than the wild one: only the failed catch angers it
@@ -0,0 +1,188 @@
package dev.cobblesurvival.selftest
import com.cobblemon.mod.common.Cobblemon
import com.cobblemon.mod.common.CobblemonBlocks
import com.cobblemon.mod.common.api.pokemon.PokemonSpecies
import com.cobblemon.mod.common.block.PastureBlock
import com.cobblemon.mod.common.block.entity.PokemonPastureBlockEntity
import com.cobblemon.mod.common.entity.pokemon.PokemonEntity
import com.mojang.brigadier.arguments.IntegerArgumentType
import com.mojang.brigadier.builder.LiteralArgumentBuilder
import dev.cobblesurvival.machine.MachineBlock
import dev.cobblesurvival.machine.MachineBlockEntity
import dev.cobblesurvival.machine.MachineShowcase
import io.netty.channel.embedded.EmbeddedChannel
import net.minecraft.commands.CommandSourceStack
import net.minecraft.commands.Commands
import net.minecraft.core.BlockPos
import net.minecraft.core.Direction
import net.minecraft.network.chat.Component
import net.minecraft.server.MinecraftServer
import net.minecraft.server.level.ServerLevel
import net.minecraft.server.level.ServerPlayer
import net.minecraft.world.level.levelgen.Heightmap
import net.neoforged.neoforge.common.NeoForge
import net.neoforged.neoforge.event.tick.ServerTickEvent
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.sin
/**
* A realistic load for tools/perf_test.py (roadmap Phase 9): `/cobblesurvival perf start <players> <pastures> <pokémon> <machines>`
* puts that many players on the island, each with a base of full pastures and working machines. They are the self-tests'
* fake clients, so the server ticks them as real players: Cobblemon spawns around them and their chunks load, while the
* first walks a long way, as an explorer would. Only there with -Dcobblesurvival.selftest=true.
*/
object PerfLoad {
private class Bot(val player: ServerPlayer, val channel: EmbeddedChannel, val home: BlockPos, val explorer: Boolean)
private const val PASTURE_LEVEL = 30
private const val PASTURE_SPACING = 14
private const val MACHINE_ROW = 24
private const val BOT_DISTANCE = 420.0
private const val EXPLORER_RING = 900.0
private const val EXPLORER_BLOCKS_PER_SECOND = 5.0
private const val WALK_RADIUS = 18.0
private const val SPIKE_NANOS = 50_000_000L
private const val BUILD_DELAY_TICKS = 200
private val SPECIES = listOf("pidgey", "rattata", "geodude", "oddish", "ponyta", "magnemite", "eevee", "snorlax",
"machop", "slowpoke", "psyduck", "growlithe", "abra", "gastly", "onix", "scyther")
private val bots = ArrayList<Bot>()
private var tethered = 0
private val machineCores = ArrayList<BlockPos>()
/** The bases, built once the players' chunks are active: a Pokémon added to a chunk about to unload is lost with it. */
private var pendingBases: (() -> Unit)? = null
private var buildAtTick = 0
private var tickStarted = 0L
private val samples = ArrayList<Long>()
fun register() {
if (!SelfTest.enabled) return
NeoForge.EVENT_BUS.addListener<ServerTickEvent.Pre> { tickStarted = System.nanoTime() }
NeoForge.EVENT_BUS.addListener(::onTickDone)
}
fun command(): LiteralArgumentBuilder<CommandSourceStack> =
Commands.literal("perf")
.requires { it.hasPermission(4) }
.then(Commands.literal("start").then(Commands.argument("players", IntegerArgumentType.integer(1)).then(
Commands.argument("pastures", IntegerArgumentType.integer(0)).then(Commands.argument("pokemon", IntegerArgumentType.integer(0)).then(
Commands.argument("machines", IntegerArgumentType.integer(0)).executes { context ->
val reply = start(context.source.server, IntegerArgumentType.getInteger(context, "players"),
IntegerArgumentType.getInteger(context, "pastures"), IntegerArgumentType.getInteger(context, "pokemon"),
IntegerArgumentType.getInteger(context, "machines"))
context.source.sendSuccess({ Component.literal(reply) }, false)
1
})))))
.then(Commands.literal("measure").executes { context ->
samples.clear()
context.source.sendSuccess({ Component.literal("measuring") }, false)
1
})
.then(Commands.literal("report").executes { context ->
context.source.sendSuccess({ Component.literal(report(context.source.server)) }, false)
1
})
private fun start(server: MinecraftServer, players: Int, pastures: Int, pokemon: Int, machineCount: Int): String {
val level = server.overworld()
repeat(players) { i ->
val home = land(level, i)
val (player, channel) = FakeClient.join(server, level, "perf_$i")
player.abilities.invulnerable = true
player.teleportTo(level, home.x + 0.5, home.y + 1.0, home.z + 0.5, 0f, 0f)
level.chunkSource.move(player) // a client's movement packets would do this: it's what activates the chunks around a player
bots += Bot(player, channel, home, i == 0 && players > 1)
}
pendingBases = {
for (bot in bots) {
repeat(pastures) { p -> pasture(level, bot.player, surface(level, bot.home.x + p * PASTURE_SPACING, bot.home.z - PASTURE_SPACING), pokemon, p) }
repeat(machineCount) { m ->
val ground = surface(level, bot.home.x + m * MachineShowcase.SPACING, bot.home.z + MACHINE_ROW)
MachineShowcase.working(level, ground, m)
machineCores += ground.above()
}
}
}
buildAtTick = server.tickCount + BUILD_DELAY_TICKS
return "perf started: ${bots.size} players, bases follow in ${BUILD_DELAY_TICKS / 20} s"
}
private fun pasture(level: ServerLevel, player: ServerPlayer, at: BlockPos, count: Int, index: Int) {
val pos = at.above()
val block = CobblemonBlocks.PASTURE.defaultBlockState()
level.setBlockAndUpdate(pos, block.setValue(PastureBlock.PART, PastureBlock.PasturePart.BOTTOM))
level.setBlockAndUpdate(pos.above(), block.setValue(PastureBlock.PART, PastureBlock.PasturePart.TOP))
val entity = level.getBlockEntity(pos) as? PokemonPastureBlockEntity ?: return
val pc = Cobblemon.storage.getPC(player)
repeat(count) { i ->
val pokemon = PokemonSpecies.getByName(SPECIES[(index * 5 + i) % SPECIES.size])!!.create(PASTURE_LEVEL)
pc.add(pokemon)
if (entity.tether(player, pokemon, Direction.NORTH)) tethered++
}
}
private fun surface(level: ServerLevel, x: Int, z: Int): BlockPos {
level.getChunk(x shr 4, z shr 4)
return BlockPos(x, level.getHeight(Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, x, z), z)
}
/** Dry land for the [index]th player: the first of the points around spawn that isn't water. */
private fun land(level: ServerLevel, index: Int): BlockPos {
val spawn = level.sharedSpawnPos
for (attempt in 0 until 16) {
val angle = 2 * PI * (index + attempt / 16.0) / 8
val candidate = surface(level, spawn.x + (cos(angle) * BOT_DISTANCE).toInt(), spawn.z + (sin(angle) * BOT_DISTANCE).toInt())
if (level.getFluidState(candidate.below()).isEmpty && level.getFluidState(candidate).isEmpty) return candidate
}
return surface(level, spawn.x, spawn.z)
}
private fun onTickDone(event: ServerTickEvent.Post) {
samples += System.nanoTime() - tickStarted
val server = event.server
if (pendingBases != null && server.tickCount >= buildAtTick) {
pendingBases?.invoke()
pendingBases = null
}
bots.removeIf { it.player.hasDisconnected() }
for ((i, bot) in bots.withIndex()) {
bot.player.doTick()
bot.player.connection.chunkSender.onChunkBatchReceivedByClient(25f)
bot.channel.releaseOutbound()
if (server.tickCount % 20 == 0) walk(server, bot, i)
}
}
private fun walk(server: MinecraftServer, bot: Bot, index: Int) {
val level = bot.player.serverLevel()
val seconds = server.tickCount / 20.0
val spawn = level.sharedSpawnPos
val (x, z) = if (bot.explorer) {
val angle = seconds * EXPLORER_BLOCKS_PER_SECOND / EXPLORER_RING
spawn.x + cos(angle) * EXPLORER_RING to spawn.z + sin(angle) * EXPLORER_RING
} else {
val angle = seconds * 0.2 + index
bot.home.x + cos(angle) * WALK_RADIUS to bot.home.z + sin(angle) * WALK_RADIUS
}
val y = surface(level, x.toInt(), z.toInt()).y + 1.0
bot.player.teleportTo(level, x, y, z, 0f, 0f)
level.chunkSource.move(bot.player)
}
private fun report(server: MinecraftServer): String {
val sorted = samples.sorted()
fun at(share: Double) = if (sorted.isEmpty()) 0.0 else sorted[((sorted.size - 1) * share).toInt()] / 1e6
val pokemon = server.allLevels.flatMap { level -> level.allEntities.filterIsInstance<PokemonEntity>() }
val pastured = pokemon.count { it.tethering != null }
val overworld = server.overworld()
val working = machineCores.count { core -> (overworld.getBlockEntity(core) is MachineBlockEntity) && overworld.getBlockState(core).getValue(MachineBlock.ACTIVE) }
val entityTicking = bots.count { overworld.isPositionEntityTicking(it.player.blockPosition()) }
val entities = server.allLevels.sumOf { it.allEntities.count() }
val chunks = server.allLevels.sumOf { it.chunkSource.loadedChunksCount }
return "perf ticks=${sorted.size} mean_ms=%.2f p50_ms=%.2f p95_ms=%.2f p99_ms=%.2f max_ms=%.2f spikes_over_50ms=${sorted.count { it > SPIKE_NANOS }} "
.format(sorted.average().takeIf { !it.isNaN() }?.div(1e6) ?: 0.0, at(.5), at(.95), at(.99), at(1.0)) +
"bots=${bots.size} tethered=$tethered pastured=$pastured wild=${pokemon.size - pastured} machines=${machineCores.size} working=$working entities=$entities chunks=$chunks entity_ticking=$entityTicking"
}
}
@@ -26,6 +26,7 @@ object PermadeathTests {
fun all() = listOf(
SelfTestCase("no_healing_or_auto_revive", 40, ::noHealingOrAutoRevive),
SelfTestCase("starter_only_faints", 120, ::starterOnlyFaints),
SelfTestCase("faint_outside_battle", 5, ::faintOutsideBattle),
SelfTestCase("fall", 200, ::fall),
SelfTestCase("lava", 300, ::lava),
@@ -39,6 +40,23 @@ object PermadeathTests {
)
/** Cobblemon heals the party while the player sleeps and wakes fainted Pokémon after a while; both are off. */
/** Adrian, 2026-11-09: the starter is the one Pokémon that is never removed, and a charm it holds isn't used up. */
private fun starterOnlyFaints(s: Scenario) {
val player = s.player()
val starter = s.give(player, s.pokemon("bulbasaur", 5))
val other = s.give(player, s.pokemon("rattata", 5))
starter.swapHeldItem(ItemStack(s.charm()))
Cobblemon.playerDataManager.getGenericData(player).starterUUID = starter.uuid
starter.currentHealth = 0
other.currentHealth = 0
s.helper.startSequence().thenExecuteAfter(80) {
s.assertGone(player, other)
s.check(Cobblemon.storage.getParty(player).any { it.uuid == starter.uuid }) { "the starter was removed" }
s.check(starter.isFainted()) { "the starter was healed" }
s.check(starter.heldItem().`is`(s.charm())) { "the starter's charm was used up" }
}.thenSucceed()
}
private fun noHealingOrAutoRevive(s: Scenario) {
val config = Cobblemon.config
s.check(config.healPercent == 0.0) { "healPercent is ${config.healPercent}, so sleeping heals the party" }
@@ -1,5 +1,9 @@
package dev.cobblesurvival.selftest
import com.cobblemon.mod.common.api.pokemon.PokemonProperties
import com.cobblemon.mod.common.pokemon.requirements.BiomeRequirement
import com.cobblemon.mod.common.pokemon.FormData
import com.cobblemon.mod.common.api.pokemon.evolution.Evolution
import com.cobblemon.mod.common.api.drop.DropTable
import com.cobblemon.mod.common.api.drop.ItemDropEntry
import com.cobblemon.mod.common.api.fossil.Fossils
@@ -40,6 +44,10 @@ object QuestTests {
SelfTestCase("quest_events_mark_zones", 200, ::zoneEvent),
SelfTestCase("drop_sources_match_the_server", 20, ::dropSources),
SelfTestCase("jei_pages_build", 20, ::jeiPages),
SelfTestCase("drop_sources_reach_the_client", 20, ::dropSourcesPacked),
SelfTestCase("drop_chances_match_cobblemon", 20, ::dropChances),
SelfTestCase("regional_forms_are_obtainable", 20, ::regionalForms, islandOnly = true),
SelfTestCase("new_players_get_the_quest_book", 20, ::questBook),
SelfTestCase("every_implemented_pokemon_is_obtainable", 20, ::obtainable),
SelfTestCase("sigil_summons_a_catchable_legend", 200, ::summon),
)
@@ -113,6 +121,79 @@ object QuestTests {
* Every JEI page builds (the same code JEI calls on the client), one per Pokémon, guardian, altar tier, vein zone
* and pasture list, and every item on them exists (an unknown id would show as a barrier).
*/
private val REGIONAL = setOf("alolan", "galarian", "hisuian", "paldean")
/**
* Adrian, 2026-11-09: the regional forms JEI lists can be had too: from a wild spawn, a Sigil, or by evolving
* something obtainable where a biome the evolution asks for exists on the island, or by breeding back down a line. Battle-only forms (Zen Mode) don't count. Other species count with any form; Shedinja comes with Nincada's evolution, which no requirement names.
*/
private fun regionalForms(s: Scenario) {
val biomes = s.level.chunkSource.generator.biomeSource.possibleBiomes()
fun possible(evolution: Evolution) = evolution.requirements.filterIsInstance<BiomeRequirement>().all { r ->
biomes.any { b -> r.biomeCondition?.fits(b) != false && r.biomeAnticondition?.fits(b) != true }
}
val reached = HashSet<FormData>()
for (d in CobblemonSpawnPools.WORLD_SPAWN_POOL.details.filterIsInstance<PokemonSpawnDetail>()) {
val species = d.pokemon.species?.let { PokemonSpecies.getByName(it.substringAfter(':')) } ?: continue
reached += if (d.pokemon.originalString.lowercase().split(' ').any { it in REGIONAL }) d.pokemon.create().form else species.standardForm
}
reached += Fossils.all().mapNotNull { it.result.species }.mapNotNull { PokemonSpecies.getByName(it.substringAfter(':'))?.standardForm }
reached += Summons.defs.values.map { PokemonProperties.parse(it.pokemon).create().form }
val queue = ArrayDeque(reached)
while (queue.isNotEmpty()) {
val current = queue.removeFirst()
// Breeding (Cobbreeding) brings the start of a line from any of its stages.
current.preEvolution?.form?.let { if (reached.add(it)) queue += it }
for (evolution in current.evolutions.filter(::possible)) {
val form = evolution.result.create().form
if (reached.add(form)) queue += form
}
}
val missing = PokemonSpecies.implemented.flatMap { species ->
species.forms.filter { f -> f.aspects.any { it in REGIONAL } && "zen-mode" !in f.aspects && f !in reached }.map { "${it.name} ${species.name}" } +
listOfNotNull(species.name.takeIf { species.forms.none(reached::contains) && species.standardForm !in reached && it != "Shedinja" })
}
s.check(missing.isEmpty()) { "${missing.size} forms can't be obtained: ${missing.take(15)}" }
s.helper.succeed()
}
private fun questBook(s: Scenario) {
val player = s.player()
val book = BuiltInRegistries.ITEM.get(ResourceLocation.parse("ftbquests:book"))
s.check(player.inventory.items.any { it.`is`(book) }) { "a new player has no quest book" }
s.helper.succeed()
}
/** The chance on each JEI page is what Cobblemon's own roll gives (Adrian, 2026-11-09): rolled here, for every Pokémon. */
private fun dropChances(s: Scenario) {
val rolls = 4000
val problems = mutableListOf<String>()
for (p in DropSources.data.pokemon) {
val species = PokemonSpecies.getByIdentifier(ResourceLocation.parse(p.species)) ?: continue
val got = HashMap<String, Int>()
repeat(rolls) {
species.drops.getDrops().filterIsInstance<ItemDropEntry>().filter { (it.quantityRange?.random() ?: it.quantity) > 0 }
.map { it.item.toString() }.toSet().forEach { got.merge(it, 1, Int::plus) }
}
for (d in p.drops) {
val seen = 100.0 * (got[d.item] ?: 0) / rolls
if (kotlin.math.abs(seen - (d.chance ?: 100.0)) > 4.0) problems += "${p.species}: ${d.item} page ${d.chance ?: 100.0}, rolled ${"%.1f".format(seen)}"
}
}
s.check(problems.isEmpty()) { "${problems.size} wrong chances: ${problems.take(5)}" }
s.helper.succeed()
}
/** What the server sends players on joining unpacks to what the server has (Adrian, 2026-11-09: balance without a client update). */
private fun dropSourcesPacked(s: Scenario) {
val sent = DropSources.parse(DropSources.packed)
val data = DropSources.data
s.check(data.pokemon.isNotEmpty()) { "the server has no drop sources" }
s.check(sent.pokemon.size == data.pokemon.size && sent.guardians.size == data.guardians.size && sent.summons.size == data.summons.size) { "the packed drop sources differ" }
s.check(DropSources.packed.size < 1_000_000) { "the drop sources are ${DropSources.packed.size} bytes packed, too long for one packet" }
s.helper.succeed()
}
private fun jeiPages(s: Scenario) {
val data = DropSources.data
val pages = mapOf(
@@ -129,14 +210,11 @@ object QuestTests {
}
/** A drop as "item chance% ×count", the same way from the JEI data and from Cobblemon's live tables. */
private fun dropKey(item: String, chance: Double?, count: String?) = "$item ${"%.2f".format(chance ?: 100.0)}% ×${count ?: "1"}"
private fun liveDrops(table: DropTable?): Set<String> = table?.entries.orEmpty().filterIsInstance<ItemDropEntry>().map { e ->
dropKey(e.item.toString(), e.percentage.toDouble().takeIf { it < 100.0 }, e.quantityRange?.let { if (it.first == it.last) "${it.first}" else "${it.first}-${it.last}" } ?: e.quantity.toString())
}.toSet()
private fun liveDrops(table: DropTable?): Set<String> = table?.entries.orEmpty().filterIsInstance<ItemDropEntry>().map { it.item.toString() }.toSet()
/**
* The JEI pages' data (assets/cobblesurvival/jei/drop_sources.json, tools/jei_sources.py) still says what the
* The JEI pages' data (data/cobblesurvival/drop_sources/pages.json, tools/jei_sources.py) still says what the
* server does: every Pokémon's drops and zones, the spawns with drops of their own, the guardians, the End boss
* and the Sacrifice Altar. When this fails, run tools/jei_sources.py and rebuild.
*/
@@ -147,7 +225,7 @@ object QuestTests {
val zonesOf = details.groupBy({ "cobblemon:${it.pokemon.species?.lowercase()}" }, { it.id.substringBefore('-') }).mapValues { it.value.toSet() }
for (p in data.pokemon) {
val species = PokemonSpecies.getByIdentifier(ResourceLocation.parse(p.species)) ?: continue.also { problems += "no species ${p.species}" }
val shown = p.drops.map { dropKey(it.item, it.chance, it.count) }.toSet()
val shown = p.drops.map { it.item }.toSet()
if (shown != liveDrops(species.drops)) problems += "${p.species} drops ${liveDrops(species.drops)}, the JEI data says $shown"
if (p.zones.map { it.zone }.toSet() != zonesOf[p.species].orEmpty()) problems += "${p.species} spawns in ${zonesOf[p.species]}, the JEI data says ${p.zones.map { it.zone }}"
}
@@ -25,6 +25,7 @@ import net.minecraft.gametest.framework.GameTestSequence
import net.minecraft.network.Connection
import net.minecraft.network.protocol.PacketFlow
import net.minecraft.resources.ResourceLocation
import net.minecraft.server.MinecraftServer
import net.minecraft.server.level.ServerLevel
import net.minecraft.server.level.ServerPlayer
import net.minecraft.server.network.CommonListenerCookie
@@ -67,14 +68,7 @@ class Scenario(val case: SelfTestCase, private val index: Int) : GameTestListene
*/
fun player(): ServerPlayer {
val server = level.server
val cookie = CommonListenerCookie.createInitial(GameProfile(UUID.randomUUID(), "selftest_${index}_${players++}"), false)
val player = ServerPlayer(server, level, cookie.gameProfile(), cookie.clientInformation())
val connection = Connection(PacketFlow.SERVERBOUND)
EmbeddedChannel(connection)
ChannelAttributes.setPayloadSetup(connection, NetworkPayloadSetup.empty())
ChannelAttributes.setConnectionType(connection, ConnectionType.NEOFORGE)
connection.channel().attr(ChannelAttributes.ADHOC_CHANNELS).set(EveryChannel())
server.playerList.placeNewPlayer(connection, player, cookie)
val (player, _) = FakeClient.join(server, level, "selftest_${index}_${players++}")
val pos = at(3.5, 0.0, 7.5)
player.teleportTo(level, pos.x, pos.y, pos.z, 0f, 0f)
cleanups += { server.playerList.remove(player) }
@@ -220,8 +214,23 @@ class Scenario(val case: SelfTestCase, private val index: Int) : GameTestListene
if (passed) CobbleSurvival.LOGGER.info("selftest PASS {}", case.name)
else CobbleSurvival.LOGGER.error("selftest FAIL {}: {}", case.name, failure)
}
}
/** A client as the server sees one: its connection goes nowhere and accepts every mod's network channel, since NeoForge refuses to send mod packets on an unnegotiated one. */
internal object FakeClient {
private class EveryChannel : HashSet<ResourceLocation>() {
override fun contains(element: ResourceLocation) = true
}
fun join(server: MinecraftServer, level: ServerLevel, name: String): Pair<ServerPlayer, EmbeddedChannel> {
val cookie = CommonListenerCookie.createInitial(GameProfile(UUID.randomUUID(), name), false)
val player = ServerPlayer(server, level, cookie.gameProfile(), cookie.clientInformation())
val connection = Connection(PacketFlow.SERVERBOUND)
val channel = EmbeddedChannel(connection)
ChannelAttributes.setPayloadSetup(connection, NetworkPayloadSetup.empty())
ChannelAttributes.setConnectionType(connection, ConnectionType.NEOFORGE)
connection.channel().attr(ChannelAttributes.ADHOC_CHANNELS).set(EveryChannel())
server.playerList.placeNewPlayer(connection, player, cookie)
return player to channel
}
}
@@ -1,5 +1,6 @@
package dev.cobblesurvival.summon
import dev.cobblesurvival.Tuning
import com.cobblemon.mod.common.api.Priority
import com.cobblemon.mod.common.api.events.CobblemonEvents
import com.cobblemon.mod.common.api.pokemon.PokemonProperties
@@ -54,11 +55,11 @@ object Summons : SimpleJsonResourceReloadListener(Gson(), "summons") {
/** How far ahead of the player it appears. */
private const val DISTANCE = 6.0
/** Only one at a time within this many blocks, so sigils can't be stacked into one place. */
private const val NEARBY = 96.0
private val NEARBY get() = Tuning.values.summon_spacing
private const val FLOOR_ABOVE = 4
private const val FLOOR_BELOW = 8
class Def(val species: String, val level: Int, val zone: String)
class Def(val species: String, val pokemon: String, val level: Int, val zone: String)
var defs: Map<String, Def> = emptyMap()
private set
@@ -74,7 +75,7 @@ object Summons : SimpleJsonResourceReloadListener(Gson(), "summons") {
override fun apply(files: Map<ResourceLocation, JsonElement>, resources: ResourceManager, profiler: ProfilerFiller) {
defs = files.values.map { it.asJsonObject }.associate {
it.get("species").asString to Def(it.get("species").asString, it.get("level").asInt, it.get("zone").asString)
it.get("species").asString to Def(it.get("species").asString, it.get("pokemon")?.asString ?: it.get("species").asString, it.get("level").asInt, it.get("zone").asString)
}
CobbleSurvival.LOGGER.info("Loaded {} summons", defs.size)
}
@@ -118,7 +119,7 @@ object Summons : SimpleJsonResourceReloadListener(Gson(), "summons") {
tell(player, Component.translatableWithFallback("message.cobblesurvival.summon.busy", "Another legend is already here.").withStyle(ChatFormatting.RED))
return false
}
val pokemon = PokemonProperties.parse("${def.species} level=${def.level}").create()
val pokemon = PokemonProperties.parse("${def.pokemon} level=${def.level}").create()
pokemon.persistentData.putBoolean(TAG, true)
pokemon.persistentData.putString(BossFights.TAG, "${CobbleSurvival.MOD_ID}:summon_${def.species}")
val entity = PokemonEntity(level, pokemon)
@@ -1,5 +1,6 @@
package dev.cobblesurvival.zone
import dev.cobblesurvival.Tuning
import com.cobblemon.mod.common.api.Priority
import com.cobblemon.mod.common.api.events.CobblemonEvents
import com.cobblemon.mod.common.api.events.battles.BattleFaintedEvent
@@ -48,8 +49,8 @@ object Guardians : SimpleJsonResourceReloadListener(Gson(), "guardians") {
/** Key in the guardian Pokémon's persistent data; its value is the zone id. */
const val TAG = "cobblesurvival_guardian"
private const val CHECK_TICKS = 40
private const val NEAR = 96.0
private const val LEASH = 24.0
private val NEAR get() = Tuning.values.guardian_wake_range
private val LEASH get() = Tuning.values.guardian_leash
private const val TICKS_PER_HOUR = 72_000L
/** How far (blocks, each way) a cave guardian's spot may lie from the zone's sample point. */
private const val CAVE_SEARCH = 24
@@ -1,5 +1,6 @@
package dev.cobblesurvival.zone
import dev.cobblesurvival.Tuning
import com.cobblemon.mod.common.entity.pokemon.PokemonEntity
import dev.cobblesurvival.world.SpecialPokemon
import net.minecraft.server.level.ServerLevel
@@ -18,7 +19,7 @@ import java.util.UUID
*/
object Wanderers {
private const val EVERY_TICKS = 100
private const val RANGE = 64.0
private val RANGE get() = Tuning.values.wanderer_range
/** The self-tests switch this off, except in their own batch: other tests put strong wild Pokémon on the platform. */
var paused = false
@@ -29,6 +29,8 @@ import java.util.UUID
* apples still work against it. Definitions: `data/cobblesurvival/zone_hazards/<name>.json`.
*/
object ZoneHazards : SimpleJsonResourceReloadListener(Gson(), "zone_hazards") {
private const val CHECK_TICKS = 10
class Hazard(
val zone: String,
val effect: Holder<MobEffect>,
@@ -72,7 +74,7 @@ object ZoneHazards : SimpleJsonResourceReloadListener(Gson(), "zone_hazards") {
private fun onServerTick(event: ServerTickEvent.Post) {
if (hazards.isEmpty()) return
val tick = event.server.tickCount
if (event.server.tickCount % CHECK_TICKS != 0) return
for (player in event.server.playerList.players) {
if (player.isCreative || player.isSpectator) continue
val zone = Zones.at(player.serverLevel(), player.blockPosition())?.id
@@ -81,7 +83,10 @@ object ZoneHazards : SimpleJsonResourceReloadListener(Gson(), "zone_hazards") {
continue
}
for (hazard in hazards) {
if (hazard.zone != zone || tick % hazard.everyTicks != 0 || isProtected(player, hazard)) continue
if (hazard.zone != zone || isProtected(player, hazard)) continue
// At once on entering the zone (Adrian, 2026-11-09), then renewed every every_ticks.
val current = player.getEffect(hazard.effect)
if (current != null && current.duration > hazard.durationTicks - hazard.everyTicks) continue
player.addEffect(MobEffectInstance(hazard.effect, hazard.durationTicks, hazard.amplifier))
if (hazard.warning != null && warned[player.uuid] != zone) {
warned[player.uuid] = zone
File diff suppressed because it is too large. Load diff
@@ -3,6 +3,6 @@
"minVersion": "0.8",
"package": "dev.cobblesurvival.mixin",
"compatibilityLevel": "JAVA_21",
"mixins": ["RegeneratorBlockMixin"],
"mixins": ["HardcorePartyMixin", "RegeneratorBlockMixin"],
"injectors": {"defaultRequire": 1}
}