Files
cobblemon-survival-pack/mod/src/main/kotlin/dev/cobblesurvival/permadeath/Permadeath.kt
T
claudeandClaude Opus 5.5 4083791944 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>
2026-11-09 16:26:30 +01:00

131 lines
6.9 KiB
Kotlin

package dev.cobblesurvival.permadeath
import com.cobblemon.mod.common.Cobblemon
import com.cobblemon.mod.common.api.Priority
import com.cobblemon.mod.common.api.events.CobblemonEvents
import com.cobblemon.mod.common.api.events.battles.BattleEndedEvent
import com.cobblemon.mod.common.api.events.battles.BattleFaintedEvent
import com.cobblemon.mod.common.api.storage.party.PlayerPartyStore
import com.cobblemon.mod.common.api.storage.pc.PCStore
import com.cobblemon.mod.common.pokemon.Pokemon
import com.cobblemon.mod.common.util.isInBattle
import dev.cobblesurvival.CobbleSurvival
import net.minecraft.ChatFormatting
import net.minecraft.core.registries.Registries
import net.minecraft.network.chat.Component
import net.minecraft.resources.ResourceLocation
import net.minecraft.server.level.ServerPlayer
import net.minecraft.sounds.SoundEvents
import net.minecraft.sounds.SoundSource
import net.minecraft.tags.TagKey
import net.minecraft.world.item.Item
import net.neoforged.neoforge.common.NeoForge
import net.neoforged.neoforge.event.tick.ServerTickEvent
import java.util.UUID
import java.util.concurrent.ConcurrentLinkedQueue
/**
* A fainted Pokémon is gone for good. CobbleHardcoreMon settles faints in battles; this closes the gaps around it:
* - a Pokémon that faints outside a battle (falling, lava, poison, ...) is settled at the end of the same server
* tick. CobbleHardcoreMon only looks once a second, and waits a full minute for a Pokémon whose charm already
* saved it once, which left time to heal it or move it to the PC.
* - a Pokémon that fainted in a battle and was revived before the battle ended (a Revive, Revival Blessing, ...)
* is fainted again when the battle ends, just before CobbleHardcoreMon settles the battle.
* - 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. 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> =
TagKey.create(Registries.ITEM, ResourceLocation.fromNamespaceAndPath(CobbleSurvival.MOD_ID, "revival_charms"))
private const val SWEEP_TICKS = 20
private val fainted = ConcurrentLinkedQueue<Pokemon>()
private val faintedInBattle = HashMap<UUID, MutableSet<Pokemon>>()
fun register() {
CobblemonEvents.POKEMON_FAINTED.subscribe(Priority.NORMAL) { fainted += it.pokemon }
CobblemonEvents.BATTLE_FAINTED.subscribe(Priority.NORMAL, ::onBattleFainted)
// 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) {
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
faintedInBattle.getOrPut(event.battle.battleId) { HashSet() } += killed.originalPokemon
}
private fun onBattleEnded(event: BattleEndedEvent) {
val faintedHere = faintedInBattle.remove(event.battle.battleId) ?: return
for (pokemon in faintedHere) {
if (!pokemon.isFainted() && pokemon.storeCoordinates.get() != null) {
pokemon.currentHealth = 0
}
}
}
private fun onServerTick(event: ServerTickEvent.Post) {
while (true) {
val pokemon = fainted.poll() ?: break
val owner = pokemon.getOwnerPlayer() ?: continue
settle(owner, pokemon)
}
if (event.server.tickCount % SWEEP_TICKS == 0) {
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) }
}
}
}
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
try {
val name = pokemon.getDisplayName(false)
if (pokemon.heldItem().`is`(REVIVAL_CHARMS)) {
pokemon.heal()
pokemon.removeHeldItem()
player.sendSystemMessage(
Component.translatableWithFallback(
"message.cobblesurvival.permadeath.saved",
"%s's Revival Charm saved it and crumbled to dust.", name,
).withStyle(ChatFormatting.GOLD)
)
player.playNotifySound(SoundEvents.RESPAWN_ANCHOR_DEPLETE.value(), SoundSource.PLAYERS, 1f, 1f)
CobbleSurvival.LOGGER.info("Permadeath: a Revival Charm saved {} of {}", name.string, player.gameProfile.name)
} else if (store?.remove(pokemon) == true) {
player.sendSystemMessage(
Component.translatableWithFallback(
"message.cobblesurvival.permadeath.lost",
"%s fainted and is gone for good.", name,
).withStyle(ChatFormatting.RED)
)
CobbleSurvival.LOGGER.info("Permadeath: {} of {} fainted and was removed", name.string, player.gameProfile.name)
}
} catch (e: Exception) {
CobbleSurvival.LOGGER.error("Permadeath: could not settle a fainted Pokémon of {}", player.gameProfile.name, e)
}
}
}