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