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>
131 lines
6.9 KiB
Kotlin
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)
|
|
}
|
|
}
|
|
}
|