package dev.cobblesurvival.selftest import dev.cobblesurvival.CobbleSurvival import dev.cobblesurvival.zone.ZoneSample import dev.cobblesurvival.zone.Zones import net.minecraft.core.registries.BuiltInRegistries import net.minecraft.gametest.framework.GameTestAssertException import net.minecraft.resources.ResourceLocation import net.minecraft.server.level.ServerPlayer import net.minecraft.world.effect.MobEffects import net.minecraft.world.entity.EquipmentSlot import net.minecraft.world.entity.LivingEntity import net.minecraft.world.item.ItemStack import net.minecraft.world.item.Items import net.minecraft.world.level.levelgen.Heightmap /** * Roadmap Phase 3, hazards as gates, on the island itself (real biomes, real altitude): in Frostpeak and Sunscorch * a player without the right gear freezes or overheats within a minute or two, while the fur (cold) or hide (heat) * set holds them off for many minutes; leather armour alone doesn't help. The zones without a hazard are livable * without gear by day and by night. The Mangrove Mire poisons anyone without Mire Waders. * * Cold Sweat's insulation doesn't change what counts as too cold or too hot; it slows how fast the body drifts * there. So the tests reset each player's core temperature to 0, wait, and turn the drift into minutes until the * body reaches ±100, where Cold Sweat starts to hurt. Each case changes the time of day, so it runs in a batch of * its own. */ object HazardTests { private val FUR = listOf("goat_fur_helmet", "goat_fur_chestplate", "goat_fur_leggings", "goat_fur_boots") private val HIDE = listOf("hoglin_helmet", "hoglin_chestplate", "hoglin_leggings", "hoglin_boots") private val LEATHER = listOf("leather_helmet", "leather_chestplate", "leather_leggings", "leather_boots") private const val NOON = 6000L private const val MIDNIGHT = 18000L private const val MEASURE_TICKS = 60 /** Cold Sweat eases the felt temperature towards a new place; let it arrive before measuring. */ private const val SETTLE_TICKS = 120 /** Without gear the hazard must hurt within this many minutes... */ private const val UNPROTECTED_MAX_MINUTES = 3.0 /** ...and with the right set not before this many. */ private const val PROTECTED_MIN_MINUTES = 10.0 fun all() = listOf( SelfTestCase("cold_needs_fur", 600, ::cold, islandOnly = true, batch = "hazard_cold"), SelfTestCase("heat_needs_hide", 600, ::heat, islandOnly = true, batch = "hazard_heat"), SelfTestCase("lowlands_are_mild", 4000, ::lowlands, islandOnly = true, batch = "hazard_lowlands"), SelfTestCase("mire_poisons_without_waders", 400, ::mire), ) /** Frostpeak at noon, its warmest hour. */ private fun cold(s: Scenario) { val bare = s.player() val leather = s.player().also { dress(it, "minecraft", LEATHER) } val fur = s.player().also { dress(it, "cold_sweat", FUR) } val players = listOf(bare, leather, fur) s.helper.startSequence() .thenExecute { exposeAt(s, Zones.biomeSamples["frostpeak"], NOON, players) } .thenIdle(SETTLE_TICKS) .thenExecute { players.forEach { ColdSweat.setCore(it, 0.0) } } .thenIdle(MEASURE_TICKS) .thenExecute { val (bareMin, leatherMin, furMin) = players.map { ColdSweat.minutesToLimit(it) } report(s, "Frostpeak at noon (%s), minutes until the body freezes: none %.1f, leather %.1f, fur %.1f".format(ColdSweat.describe(bare), bareMin, leatherMin, furMin)) s.check(bareMin <= UNPROTECTED_MAX_MINUTES) { "without gear Frostpeak takes %.1f min to freeze".format(bareMin) } s.check(leatherMin <= UNPROTECTED_MAX_MINUTES) { "in leather Frostpeak takes %.1f min to freeze".format(leatherMin) } s.check(furMin >= PROTECTED_MIN_MINUTES) { "in the fur set Frostpeak freezes in %.1f min".format(furMin) } // And it really hurts: a frozen body without the new-player grace takes damage. ColdSweat.endGrace(bare) ColdSweat.setCore(bare, -150.0) } .thenExecuteAfter(60) { s.check(bare.health < bare.maxHealth) { "a frozen player took no damage (health ${bare.health})" } } .thenSucceed() } /** Sunscorch at noon, its hottest hour. */ private fun heat(s: Scenario) { val bare = s.player() val hide = s.player().also { dress(it, "cold_sweat", HIDE) } val players = listOf(bare, hide) s.helper.startSequence() .thenExecute { exposeAt(s, Zones.biomeSamples["sunscorch"], NOON, players) } .thenIdle(SETTLE_TICKS) .thenExecute { players.forEach { ColdSweat.setCore(it, 0.0) } } .thenIdle(MEASURE_TICKS) .thenExecute { val (bareMin, hideMin) = players.map { ColdSweat.minutesToLimit(it) } report(s, "Sunscorch at noon (%s), minutes until the body overheats: none %.1f, hide %.1f".format(ColdSweat.describe(bare), bareMin, hideMin)) s.check(bareMin <= UNPROTECTED_MAX_MINUTES) { "without gear Sunscorch takes %.1f min to overheat".format(bareMin) } s.check(hideMin >= PROTECTED_MIN_MINUTES) { "in the hide set Sunscorch overheats in %.1f min".format(hideMin) } } .thenSucceed() } /** Every zone without a hazard, at noon and at midnight, without gear. */ private fun lowlands(s: Scenario) { val player = s.player() val problems = mutableListOf() val readings = mutableListOf() var sequence = s.helper.startSequence() // Nights are the cold risk everywhere; the warm zones are checked at noon too. val checks = listOf("button_isle", "southwold", "westmarch", "jungle", "highlands", "mire").map { it to MIDNIGHT } + listOf("jungle" to NOON, "mire" to NOON) for ((zone, time) in checks) { run { val label = "$zone at ${if (time == NOON) "noon" else "midnight"}" sequence = sequence .thenExecute { exposeAt(s, Zones.samples[zone], time, listOf(player)) } .thenIdle(SETTLE_TICKS) .thenExecute { ColdSweat.setCore(player, 0.0) } .thenIdle(MEASURE_TICKS) .thenExecute { val minutes = ColdSweat.minutesToLimit(player) readings += "$label %.0f (world %.2f)".format(minutes, ColdSweat.get(player, "WORLD")) if (minutes < PROTECTED_MIN_MINUTES) problems += "$label: limit in %.1f min (%s)".format(minutes, ColdSweat.describe(player)) } } } sequence.thenExecute { report(s, "minutes until too hot or cold without gear: ${readings.joinToString()}") s.check(problems.isEmpty()) { problems.joinToString("; ") } }.thenSucceed() } /** The Mire's fumes (companion mod zone hazard) poison a player without Mire Waders, and nobody outside it. */ private fun mire(s: Scenario) { val level = s.level val mire = Zones.samples["mire"] ?: throw GameTestAssertException("no sample point for the Mire") val elsewhere = Zones.samples["button_isle"] ?: throw GameTestAssertException("no sample point for Button Isle") val waders = BuiltInRegistries.ITEM.get(ResourceLocation.fromNamespaceAndPath("cobblesurvival", "mire_waders")) s.check(waders != Items.AIR) { "the Mire Waders item does not exist" } val bare = s.player() val wading = s.player().also { it.setItemSlot(EquipmentSlot.FEET, ItemStack(waders)) } val outside = 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() } s.helper.startSequence() .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) } .thenIdle(220) .thenExecute { val poison = MobEffects.POISON 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" } } .thenSucceed() } private fun dress(player: ServerPlayer, namespace: String, pieces: List) { val slots = listOf(EquipmentSlot.HEAD, EquipmentSlot.CHEST, EquipmentSlot.LEGS, EquipmentSlot.FEET) for ((slot, piece) in slots.zip(pieces)) { player.setItemSlot(slot, ItemStack(BuiltInRegistries.ITEM.get(ResourceLocation.fromNamespaceAndPath(namespace, piece)))) } } /** Stands the players on the surface at the sample point at that time of day. */ private fun exposeAt(s: Scenario, sample: ZoneSample?, time: Long, players: List) { if (sample == null) throw GameTestAssertException("no sample point (run tools/zone_layout.py)") val level = s.level for (dx in -2..2) for (dz in -2..2) level.getChunk((sample.x shr 4) + dx, (sample.z shr 4) + dz) val y = level.getHeight(Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, sample.x, sample.z) level.dayTime = time for (player in players) { s.moveTo(player, sample.x + 0.5, y.toDouble(), sample.z + 0.5) } } private fun report(s: Scenario, text: String) = CobbleSurvival.LOGGER.info("selftest {}: {}", s.case.name, text) /** Cold Sweat's temperature API, by reflection: the companion mod doesn't compile against Cold Sweat. */ private object ColdSweat { private val api = Class.forName("com.momosoftworks.coldsweat.api.util.Temperature") private val traits = Class.forName("com.momosoftworks.coldsweat.api.util.Temperature\$Trait") private val get = api.getMethod("get", LivingEntity::class.java, traits) private val set = api.getMethod("set", LivingEntity::class.java, traits, Double::class.javaPrimitiveType) private fun trait(name: String) = traits.getField(name).get(null) fun get(entity: LivingEntity, name: String) = get.invoke(null, entity, trait(name)) as Double fun setCore(player: ServerPlayer, value: Double) { set.invoke(null, player, trait("CORE"), value) } /** Minutes from now until the core reaches ±100 at the drift since it was reset to 0. */ fun minutesToLimit(player: ServerPlayer): Double { val perTick = kotlin.math.abs(get(player, "CORE")) / MEASURE_TICKS return if (perTick == 0.0) Double.POSITIVE_INFINITY else 100.0 / perTick / 20.0 / 60.0 } fun endGrace(player: ServerPlayer) { BuiltInRegistries.MOB_EFFECT.getHolder(ResourceLocation.fromNamespaceAndPath("cold_sweat", "grace")) .ifPresent { player.removeEffect(it) } } fun describe(player: ServerPlayer) = "world %.2f, comfortable %.2f..%.2f, core %.1f".format( get(player, "WORLD"), get(player, "FREEZING_POINT"), get(player, "BURNING_POINT"), get(player, "CORE"), ) } }