Files
cobblemon-survival-pack/mod/src/main/kotlin/dev/cobblesurvival/zone/ZoneHazards.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

103 lines
4.6 KiB
Kotlin

package dev.cobblesurvival.zone
import com.google.gson.Gson
import com.google.gson.JsonElement
import dev.cobblesurvival.CobbleSurvival
import net.minecraft.ChatFormatting
import net.minecraft.core.Holder
import net.minecraft.core.registries.BuiltInRegistries
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.server.packs.resources.ResourceManager
import net.minecraft.server.packs.resources.SimpleJsonResourceReloadListener
import net.minecraft.tags.TagKey
import net.minecraft.util.profiling.ProfilerFiller
import net.minecraft.world.effect.MobEffect
import net.minecraft.world.effect.MobEffectInstance
import net.minecraft.world.entity.EquipmentSlot
import net.minecraft.world.item.Item
import net.neoforged.neoforge.common.NeoForge
import net.neoforged.neoforge.event.AddReloadListenerEvent
import net.neoforged.neoforge.event.tick.ServerTickEvent
import java.util.UUID
/**
* Zone hazards that Cold Sweat doesn't cover (roadmap Phase 3: "a poison swamp ... as area effects"): while a player
* is in the zone and wears nothing from the protection tag, they keep getting the effect. Potions, milk and golden
* 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>,
val amplifier: Int,
val durationTicks: Int,
val everyTicks: Int,
val protection: TagKey<Item>,
val warning: String?,
)
var hazards: List<Hazard> = emptyList()
private set
private val warned = HashMap<UUID, String>()
fun register() {
NeoForge.EVENT_BUS.addListener<AddReloadListenerEvent> { it.addListener(this) }
NeoForge.EVENT_BUS.addListener(::onServerTick)
}
override fun apply(files: Map<ResourceLocation, JsonElement>, resources: ResourceManager, profiler: ProfilerFiller) {
hazards = files.mapNotNull { (id, element) ->
val json = element.asJsonObject
val effectId = ResourceLocation.parse(json.get("effect").asString)
val effect = BuiltInRegistries.MOB_EFFECT.getHolder(effectId).orElse(null)
if (effect == null) {
CobbleSurvival.LOGGER.error("Zone hazard {}: unknown effect {}", id, effectId)
return@mapNotNull null
}
Hazard(
zone = json.get("zone").asString,
effect = effect,
amplifier = json.get("amplifier")?.asInt ?: 0,
durationTicks = json.get("duration_ticks").asInt,
everyTicks = json.get("every_ticks").asInt,
protection = TagKey.create(Registries.ITEM, ResourceLocation.parse(json.get("protection").asString)),
warning = json.get("warning")?.asString,
)
}
CobbleSurvival.LOGGER.info("Loaded {} zone hazards", hazards.size)
}
private fun onServerTick(event: ServerTickEvent.Post) {
if (hazards.isEmpty()) return
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
if (zone == null || hazards.none { it.zone == zone }) {
warned.remove(player.uuid)
continue
}
for (hazard in hazards) {
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
player.displayClientMessage(Component.translatable(hazard.warning).withStyle(ChatFormatting.DARK_GREEN), true)
}
}
}
}
fun isProtected(player: ServerPlayer, hazard: Hazard) =
listOf(EquipmentSlot.HEAD, EquipmentSlot.CHEST, EquipmentSlot.LEGS, EquipmentSlot.FEET)
.any { player.getItemBySlot(it).`is`(hazard.protection) }
}