Phase 9: safe start island, better quest book, every useful item obtainable

- Button Isle is peaceful: wild Pokémon only fight a player who hit them first
  (tuning/server.json peaceful_zones); its apricorns and coal make Poké Balls
  (4 apricorns + coal or charcoal), and the world report checks it has enough.
- Quest book: no boilerplate; each zone page says what is particular to it, and
  every zone that needs gear or a field move to be safe has its own quest. New
  page on the bigger mods (Cold Sweat, Fight or Flight, Cobblebase, breeding,
  veins, shared loot, maps and info).
- The Mire's poison stops 10 blocks below the surface.
- Player battles never cost a Pokémon (CobbleHardcoreMon config, mixins, and the
  companion mod's own permadeath).
- tools/item_audit.py: every item with a use has a source on the island; new
  sources for the gaps it found (species additions, three recipes).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
claudeandClaude Opus 5.5 committed 2026-11-10 15:04:09 +01:00
1 parent 6ace26e012
commit d29e177f9d
65 files changed
+1920 -364

No files matched your search

@@ -0,0 +1,21 @@
package dev.cobblesurvival.mixin;
import com.cobblemon.mod.common.api.battles.model.PokemonBattle;
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.CallbackInfoReturnable;
/**
* A battle between players never costs a Pokémon (Adrian, 2026-11-10). The pack's config switches CobbleHardcoreMon's
* tracking of player battles off, but a world keeps the server config it was created with: this holds in every world.
*/
@Pseudo
@Mixin(targets = "dev.matthiesen.cobblehardcoremon.common.handlers.PlayerBattles", remap = false)
abstract class HardcoreBattlesMixin {
@Inject(method = "battleTypeDisabled", at = @At("HEAD"), cancellable = true)
private static void cobblesurvival$ignorePlayerBattles(PokemonBattle battle, CallbackInfoReturnable<Boolean> result) {
if (battle.isPvP()) result.setReturnValue(true);
}
}
@@ -10,24 +10,25 @@ 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.
* A player's starter only faints, it is never removed (Adrian, 2026-11-09), and neither is a Pokémon that fainted in a
* battle between players (2026-11-10). CobbleHardcoreMon has no such exceptions, and also sweeps parties outside
* battles: this leaves their Revival Charm alone and skips the 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);
if (Permadeath.INSTANCE.isSpared(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();
if (Permadeath.INSTANCE.isSpared(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();
if (Permadeath.INSTANCE.isSpared(pokemon)) info.cancel();
}
}
@@ -1,5 +1,6 @@
package dev.cobblesurvival
import dev.cobblesurvival.zone.PeacefulZones
import dev.cobblesurvival.catching.AttackBattles
import dev.cobblesurvival.jei.DropSources
import net.neoforged.fml.loading.FMLPaths
@@ -111,6 +112,7 @@ class CobbleSurvival {
BaseLoot.register()
Catching.register()
AttackBattles.register()
PeacefulZones.register()
Wanderers.register()
SharedLoot.register()
SpecialPokemon.register()
@@ -24,6 +24,7 @@ object Tuning : SimpleJsonResourceReloadListener(Gson(), "tuning") {
val summon_spacing: Double = 96.0,
val wanderer_range: Double = 64.0,
val attack_battle_grace_ticks: Int = 100,
val peaceful_zones: List<String> = listOf("button_isle"),
)
private val FILE = ResourceLocation.fromNamespaceAndPath(CobbleSurvival.MOD_ID, "server")
@@ -7,6 +7,7 @@ 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 dev.cobblesurvival.zone.PeacefulZones
import net.minecraft.server.level.ServerPlayer
import net.neoforged.neoforge.common.NeoForge
import net.neoforged.neoforge.event.entity.living.LivingIncomingDamageEvent
@@ -16,7 +17,8 @@ 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.
* A boss in a battle hits through: those are its area attacks. On the start island an attack the player didn't provoke
* does nothing at all ([PeacefulZones]).
*/
object AttackBattles {
/** Game time until which a hit on this player does nothing: a battle is being set up, or they have just fled. */
@@ -33,6 +35,11 @@ object AttackBattles {
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 (PeacefulZones.isPeaceful(player) && !PeacefulZones.provoked(attacker, player)) {
event.isCanceled = true
PeacefulZones.calmDown(attacker)
return
}
if (BattleRegistry.getBattleByParticipatingPlayer(player) != null) {
event.isCanceled = true
return
@@ -10,6 +10,7 @@ import com.google.gson.Gson
import com.google.gson.JsonElement
import dev.cobblesurvival.CobbleSurvival
import dev.cobblesurvival.world.SpecialPokemon
import dev.cobblesurvival.zone.PeacefulZones
import net.minecraft.resources.ResourceLocation
import net.minecraft.server.level.ServerPlayer
import net.minecraft.server.packs.resources.ResourceManager
@@ -26,6 +27,7 @@ import net.neoforged.neoforge.event.tick.ServerTickEvent
* - a Pokémon that breaks out of a ball attacks the thrower instead of fleeing;
* - a wild Pokémon above a nearby player's strongest Pokémon is hostile to that player (not guardians and other
* special Pokémon, see [SpecialPokemon]).
* None of this angers a Pokémon in a peaceful zone (the start island, [PeacefulZones]).
*
* The attacking is Fight or Flight Reborn's: an angry Pokémon carries the aspect [ENRAGED], which the pack's Fight or
* Flight config lists in `always_aggro_aspects` (so it fights rather than flees), and its target is set through Fight
@@ -88,13 +90,14 @@ object Catching : SimpleJsonResourceReloadListener(Gson(), "catching") {
private fun onCaptureCalculated(event: PokeBallCaptureCalculatedEvent) {
if (!rules.enrageOnFailedCatch || event.captureResult.isSuccessfulCapture) return
enrage(event.pokemonEntity, event.thrower as? ServerPlayer ?: return)
val player = event.thrower as? ServerPlayer ?: return
if (!PeacefulZones.isPeaceful(player)) enrage(event.pokemonEntity, player)
}
private fun onServerTick(event: ServerTickEvent.Post) {
if (!rules.hostileWhenStronger || hostilityPaused || event.server.tickCount % SCAN_TICKS != 0) return
for (player in event.server.playerList.players) {
if (player.isCreative || player.isSpectator) continue
if (player.isCreative || player.isSpectator || PeacefulZones.isPeaceful(player)) continue
val strongest = strongest(player)
val near = player.boundingBox.inflate(rules.hostileRange)
player.serverLevel().getEntitiesOfClass(PokemonEntity::class.java, near) {
@@ -23,6 +23,11 @@ object FightOrFlight {
if (installed) setCapturedBy.invoke(wild, player.id)
}
/** Takes Fight or Flight's mark off again (0 is its "nobody"). */
fun clearTarget(wild: PokemonEntity) {
if (installed) setCapturedBy.invoke(wild, 0)
}
fun aggression(wild: PokemonEntity): Double = coefficient.invoke(null, wild) as Double
/** Fight or Flight's `neutral_threshold`: at or above it a provoked Pokémon fights. */
@@ -36,11 +36,14 @@ import java.util.concurrent.ConcurrentLinkedQueue
* 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. 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).
* A battle between players never costs a Pokémon (Adrian, 2026-11-10): one that faints there is marked [PVP_FAINT] and
* stays fainted until it is healed. It loses the mark once it is seen healthy outside a battle, or faints in any other battle.
*/
object Permadeath {
val REVIVAL_CHARMS: TagKey<Item> =
TagKey.create(Registries.ITEM, ResourceLocation.fromNamespaceAndPath(CobbleSurvival.MOD_ID, "revival_charms"))
private const val SWEEP_TICKS = 20
const val PVP_FAINT = "cobblesurvival_pvp_faint"
private val fainted = ConcurrentLinkedQueue<Pokemon>()
private val faintedInBattle = HashMap<UUID, MutableSet<Pokemon>>()
@@ -59,6 +62,9 @@ object Permadeath {
}
}
/** Never removed for fainting: the starter, and a Pokémon that fainted in a battle between players. */
fun isSpared(pokemon: Pokemon): Boolean = isStarter(pokemon) || pokemon.persistentData.getBoolean(PVP_FAINT)
/** 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
@@ -69,6 +75,11 @@ object Permadeath {
val killed = event.killed
// In a battle on copies (exhibition rules) the real Pokémon is not hurt, so it does not count.
if (killed.effectedPokemon !== killed.originalPokemon || !killed.originalPokemon.isPlayerOwned()) return
if (event.battle.isPvP) {
killed.originalPokemon.persistentData.putBoolean(PVP_FAINT, true)
return
}
killed.originalPokemon.persistentData.remove(PVP_FAINT)
faintedInBattle.getOrPut(event.battle.battleId) { HashSet() } += killed.originalPokemon
}
@@ -91,16 +102,21 @@ object Permadeath {
for (player in event.server.playerList.players) {
val party = Cobblemon.storage.getParty(player)
val pc = Cobblemon.storage.getPC(player)
(party.filter { it.isFainted() } + pc.filter { it.isFainted() }).forEach { settle(player, it) }
(party.filter { it.isFainted() || it.persistentData.contains(PVP_FAINT) } +
pc.filter { it.isFainted() || it.persistentData.contains(PVP_FAINT) }).forEach { settle(player, it) }
}
}
}
private fun settle(player: ServerPlayer, pokemon: Pokemon) {
val store = pokemon.storeCoordinates.get()?.store
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
if (!pokemon.isFainted()) {
pokemon.persistentData.remove(PVP_FAINT)
return
}
if (isStarter(pokemon) || pokemon.persistentData.getBoolean(PVP_FAINT) || (store !is PlayerPartyStore && store !is PCStore)) return
try {
val name = pokemon.getDisplayName(false)
if (pokemon.heldItem().`is`(REVIVAL_CHARMS)) {
@@ -1,5 +1,15 @@
package dev.cobblesurvival.selftest
import net.minecraft.world.phys.Vec3
import net.minecraft.world.level.levelgen.Heightmap
import net.minecraft.gametest.framework.GameTestAssertException
import net.minecraft.world.item.crafting.RecipeType
import net.minecraft.world.item.crafting.CraftingInput
import net.minecraft.world.item.ItemStack
import net.minecraft.resources.ResourceLocation
import net.minecraft.core.registries.BuiltInRegistries
import dev.cobblesurvival.zone.Zones
import dev.cobblesurvival.zone.PeacefulZones
import com.cobblemon.mod.common.battles.BattleRegistry
import com.cobblemon.mod.common.api.events.CobblemonEvents
import com.cobblemon.mod.common.api.events.pokeball.PokeBallCaptureCalculatedEvent
@@ -26,6 +36,8 @@ object CatchingTests {
fun all() = listOf(
SelfTestCase("catch_rate_falls_with_level_gap", 20, ::catchRate, batch = "catching"),
SelfTestCase("failed_catch_enrages", 400, ::failedCatch, batch = "catching"),
SelfTestCase("button_isle_pokemon_leave_players_alone", 200, ::peaceful, islandOnly = true, batch = "peaceful"),
SelfTestCase("start_isle_materials_make_a_poke_ball", 20, ::startBall),
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) },
@@ -50,6 +62,57 @@ object CatchingTests {
s.helper.succeed()
}
/**
* Adrian, 2026-11-10: on Button Isle a wild Pokémon leaves a player alone, even
* one with no Pokémon whom it would otherwise hunt, unless the player hits it first.
*/
private fun peaceful(s: Scenario) {
PeacefulZones.paused = false
Catching.hostilityPaused = false
s.afterTest {
PeacefulZones.paused = true
Catching.hostilityPaused = true
}
// On the island's surface: the platform's height counts as a cave biome.
val sample = Zones.samples["button_isle"] ?: throw GameTestAssertException("no sample point for Button Isle")
s.level.getChunk(sample.x shr 4, sample.z shr 4)
val y = s.level.getHeight(Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, sample.x, sample.z).toDouble()
val player = s.player()
s.moveTo(player, sample.x + 0.5, y, sample.z + 0.5)
val wild = s.wild(s.pokemon("primeape", 40, "karatechop"), Vec3(sample.x + 3.5, y, sample.z + 0.5))
s.check(Zones.at(s.level, player.blockPosition())?.id == "button_isle") { "the player isn't on Button Isle" }
val health = player.health
s.helper.startSequence()
.thenIdle(SPAWN_PROTECTION_TICKS)
.thenExecute {
s.check(Catching.ENRAGED !in wild.pokemon.forcedAspects) { "a stronger Pokémon was angered on Button Isle" }
Catching.enrage(wild, player) // as if something else had set it on the player
}
.thenIdle(25)
.thenExecute {
s.check(!PeacefulZones.targets(wild, player)) { "the wild Pokémon still goes for the player" }
s.check(!player.hurt(wild.damageSources().mobAttack(wild), 6f)) { "an unprovoked hit went through on Button Isle" }
s.check(player.health == health) { "the player lost health" }
wild.hurt(player.damageSources().playerAttack(player), 1f)
s.check(player.hurt(wild.damageSources().mobAttack(wild), 6f)) { "a Pokémon the player hit couldn't hit back" }
}
.thenSucceed()
}
/** Adrian, 2026-11-10: the start island can make a Poké Ball: four apricorns of any colour and coal or charcoal. */
private fun startBall(s: Scenario) {
val level = s.level
fun item(id: String) = ItemStack(BuiltInRegistries.ITEM.get(ResourceLocation.parse(id)))
for (fuel in listOf("minecraft:coal", "minecraft:charcoal")) {
val grid = listOf(ItemStack.EMPTY, item("cobblemon:white_apricorn"), ItemStack.EMPTY, item("cobblemon:red_apricorn"), item(fuel),
item("cobblemon:pink_apricorn"), ItemStack.EMPTY, item("cobblemon:green_apricorn"), ItemStack.EMPTY)
val input = CraftingInput.of(3, 3, grid)
val made = level.recipeManager.getRecipeFor(RecipeType.CRAFTING, input, level).map { it.value.assemble(input, level.registryAccess()) }.orElse(ItemStack.EMPTY)
s.check(made.`is`(item("cobblemon:poke_ball").item)) { "four apricorns and $fuel make ${made.hoverName.string}, not a Poké Ball" }
}
s.helper.succeed()
}
private fun attackBattle(s: Scenario) {
val player = s.player()
s.give(player, s.pokemon("charizard", 50, "ember"))
@@ -1,5 +1,7 @@
package dev.cobblesurvival.selftest
import net.minecraft.world.level.block.Blocks
import net.minecraft.core.BlockPos
import dev.cobblesurvival.CobbleSurvival
import dev.cobblesurvival.zone.ZoneSample
import dev.cobblesurvival.zone.Zones
@@ -133,6 +135,7 @@ object HazardTests {
val bare = s.player()
val wading = s.player().also { it.setItemSlot(EquipmentSlot.FEET, ItemStack(waders)) }
val outside = s.player()
val below = s.player()
fun surface(sample: ZoneSample): Double {
level.getChunk(sample.x shr 4, sample.z shr 4)
return level.getHeight(Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, sample.x, sample.z).toDouble()
@@ -141,6 +144,10 @@ object HazardTests {
.thenExecute {
for (player in listOf(bare, wading)) s.moveTo(player, mire.x + 0.5, surface(mire), mire.z + 0.5)
s.moveTo(outside, elsewhere.x + 0.5, surface(elsewhere), elsewhere.z + 0.5)
// Deep in the rock under the Mire (a pocket cut for it), where the swamp's poison doesn't reach.
val deep = BlockPos(mire.x + 2, surface(mire).toInt() - 30, mire.z)
for (dy in 0..1) level.setBlockAndUpdate(deep.above(dy), Blocks.AIR.defaultBlockState())
s.moveTo(below, deep.x + 0.5, deep.y.toDouble(), deep.z + 0.5)
}
.thenIdle(220)
.thenExecute {
@@ -148,6 +155,7 @@ object HazardTests {
s.check(bare.hasEffect(poison)) { "a player without waders in the Mire isn't poisoned" }
s.check(!wading.hasEffect(poison)) { "a player in Mire Waders got poisoned" }
s.check(!outside.hasEffect(poison)) { "a player on Button Isle got poisoned" }
s.check(!below.hasEffect(poison)) { "a player deep under the Mire got poisoned" }
}
.thenSucceed()
}
@@ -1,5 +1,6 @@
package dev.cobblesurvival.selftest
import dev.cobblesurvival.permadeath.Permadeath
import com.cobblemon.mod.common.Cobblemon
import com.cobblemon.mod.common.api.npc.NPCClasses
import com.cobblemon.mod.common.api.pokemon.status.Statuses
@@ -37,6 +38,7 @@ object PermadeathTests {
SelfTestCase("wild_battle_charm", BATTLE_TICKS, { wildBattle(it, charm = true) }),
SelfTestCase("revived_during_battle", BATTLE_TICKS, ::revivedDuringBattle),
SelfTestCase("trainer_battle", BATTLE_TICKS, ::trainerBattle),
SelfTestCase("player_battle_only_faints", BATTLE_TICKS, ::playerBattle),
)
/** Cobblemon heals the party while the player sleeps and wakes fainted Pokémon after a while; both are off. */
@@ -160,6 +162,32 @@ object PermadeathTests {
.thenSucceed()
}
/** Adrian, 2026-11-10: a Pokémon that faints in a battle between players stays, fainted, until healed. */
private fun playerBattle(s: Scenario) {
val loser = s.player()
val winner = s.player()
val pokemon = s.give(loser, s.pokemon("bidoof", 5, "explosion"))
s.give(winner, s.pokemon("bidoof", 50, "splash"))
s.answerBattles(loser) { _, _ -> MoveActionResponse("explosion") }
s.answerBattles(winner) { _, _ -> MoveActionResponse("splash") }
var ended = -1L
s.helper.startSequence()
.thenExecute { s.started(BattleBuilder.pvp1v1(loser, winner)) }
.thenWaitUntil {
s.check(!loser.isInBattle()) { "the battle is still running: ${s.battleState(loser)}" }
if (ended < 0) ended = s.level.gameTime
s.check(s.level.gameTime - ended > 60) { "waiting for the permadeath sweeps" }
}
.thenExecute {
s.check(Cobblemon.storage.getParty(loser).any { it.uuid == pokemon.uuid }) { "the Pokémon that fainted in a player battle was removed" }
s.check(pokemon.isFainted()) { "it isn't fainted" }
pokemon.heal()
}
.thenIdle(30)
.thenExecute { s.check(!pokemon.persistentData.contains(Permadeath.PVP_FAINT)) { "healed, it still carries the player-battle mark" } }
.thenSucceed()
}
private fun wildBattle(s: Scenario, charm: Boolean) {
val player = s.player()
val pokemon = s.give(player, s.pokemon("bidoof", 5, "explosion"))
@@ -1,5 +1,6 @@
package dev.cobblesurvival.selftest
import dev.cobblesurvival.zone.PeacefulZones
import com.mojang.brigadier.builder.LiteralArgumentBuilder
import dev.cobblesurvival.CobbleSurvival
import dev.cobblesurvival.catching.Catching
@@ -104,6 +105,7 @@ object SelfTest {
running = scenarios
Catching.hostilityPaused = true
Wanderers.paused = true
PeacefulZones.paused = true
GameTestTicker.SINGLETON.clear()
GameTestRunner.Builder.fromInfo(infos, level)
.newStructureSpawner(StructureGridSpawner(origin, TESTS_PER_ROW, false))
@@ -135,5 +137,6 @@ object SelfTest {
running = emptyList()
Catching.hostilityPaused = false
Wanderers.paused = false
PeacefulZones.paused = false
}
}
@@ -0,0 +1,57 @@
package dev.cobblesurvival.zone
import com.cobblemon.mod.common.entity.pokemon.PokemonEntity
import dev.cobblesurvival.Tuning
import dev.cobblesurvival.catching.FightOrFlight
import net.minecraft.server.level.ServerPlayer
import net.minecraft.world.entity.ai.memory.MemoryModuleType
import net.neoforged.neoforge.common.NeoForge
import net.neoforged.neoforge.event.tick.ServerTickEvent
/**
* Wild Pokémon leave players alone on the start island (Adrian, 2026-11-10): a player who has lost everything, with no
* tools, armour or Pokémon, can start over there. In a peaceful zone (`tuning/server.json` → `peaceful_zones`) a wild
* Pokémon only fights a player who has just hit it. Being stronger than the player's team doesn't anger it there, nor
* does a failed catch ([dev.cobblesurvival.catching.Catching]), and any other attack on a player there is called off
* and does nothing ([dev.cobblesurvival.catching.AttackBattles]).
*/
object PeacefulZones {
private const val CHECK_TICKS = 10
private const val SCAN_RANGE = 32.0
/** How long after being hit a Pokémon may hit back. */
private const val PROVOKED_TICKS = 200
/** The self-tests switch this off, except in their own batch: the platform they run on is above Button Isle. */
var paused = false
fun register() {
NeoForge.EVENT_BUS.addListener(::onServerTick)
}
fun isPeaceful(player: ServerPlayer): Boolean =
!paused && Zones.at(player.serverLevel(), player.blockPosition())?.id in Tuning.values.peaceful_zones
/** [player] hit [wild] lately, so it may hit back. */
fun provoked(wild: PokemonEntity, player: ServerPlayer): Boolean =
wild.lastHurtByMob === player && wild.tickCount - wild.lastHurtByMobTimestamp < PROVOKED_TICKS
fun targets(wild: PokemonEntity, player: ServerPlayer): Boolean =
wild.target === player ||
(wild.brain.hasMemoryValue(MemoryModuleType.ATTACK_TARGET) && wild.brain.getMemory(MemoryModuleType.ATTACK_TARGET).orElse(null) === player)
fun calmDown(wild: PokemonEntity) {
wild.target = null
wild.brain.eraseMemory(MemoryModuleType.ATTACK_TARGET)
FightOrFlight.clearTarget(wild)
}
private fun onServerTick(event: ServerTickEvent.Post) {
if (paused || event.server.tickCount % CHECK_TICKS != 0) return
for (player in event.server.playerList.players) {
if (!isPeaceful(player)) continue
player.serverLevel().getEntitiesOfClass(PokemonEntity::class.java, player.boundingBox.inflate(SCAN_RANGE)) {
it.pokemon.isWild() && !it.isBattling && targets(it, player) && !provoked(it, player)
}.forEach(::calmDown)
}
}
}
@@ -1,5 +1,6 @@
package dev.cobblesurvival.zone
import net.minecraft.world.level.levelgen.Heightmap
import com.google.gson.Gson
import com.google.gson.JsonElement
import dev.cobblesurvival.CobbleSurvival
@@ -39,6 +40,8 @@ object ZoneHazards : SimpleJsonResourceReloadListener(Gson(), "zone_hazards") {
val everyTicks: Int,
val protection: TagKey<Item>,
val warning: String?,
/** How far below the surface the hazard still reaches; null: everywhere in the zone. */
val maxDepth: Int?,
)
var hazards: List<Hazard> = emptyList()
@@ -67,6 +70,7 @@ object ZoneHazards : SimpleJsonResourceReloadListener(Gson(), "zone_hazards") {
everyTicks = json.get("every_ticks").asInt,
protection = TagKey.create(Registries.ITEM, ResourceLocation.parse(json.get("protection").asString)),
warning = json.get("warning")?.asString,
maxDepth = json.get("max_depth")?.asInt,
)
}
CobbleSurvival.LOGGER.info("Loaded {} zone hazards", hazards.size)
@@ -83,7 +87,7 @@ object ZoneHazards : SimpleJsonResourceReloadListener(Gson(), "zone_hazards") {
continue
}
for (hazard in hazards) {
if (hazard.zone != zone || isProtected(player, hazard)) continue
if (hazard.zone != zone || isProtected(player, hazard) || tooDeep(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
@@ -96,6 +100,13 @@ object ZoneHazards : SimpleJsonResourceReloadListener(Gson(), "zone_hazards") {
}
}
/** Adrian, 2026-11-10: the Mire's poison is the swamp's, not the rock's under it. Leaves don't count as surface, so the mangrove canopy doesn't shelter. */
fun tooDeep(player: ServerPlayer, hazard: Hazard): Boolean {
val depth = hazard.maxDepth ?: return false
val surface = player.serverLevel().getHeight(Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, player.blockX, player.blockZ)
return player.blockY < surface - depth
}
fun isProtected(player: ServerPlayer, hazard: Hazard) =
listOf(EquipmentSlot.HEAD, EquipmentSlot.CHEST, EquipmentSlot.LEGS, EquipmentSlot.FEET)
.any { player.getItemBySlot(it).`is`(hazard.protection) }
@@ -3,6 +3,6 @@
"minVersion": "0.8",
"package": "dev.cobblesurvival.mixin",
"compatibilityLevel": "JAVA_21",
"mixins": ["HardcorePartyMixin", "RegeneratorBlockMixin"],
"mixins": ["HardcoreBattlesMixin", "HardcorePartyMixin", "RegeneratorBlockMixin"],
"injectors": {"defaultRequire": 1}
}