Files
cobblemon-survival-pack/mod/src/main/kotlin/dev/cobblesurvival/selftest/HazardTests.kt
T
claudeandClaude Opus 5.5 d29e177f9d 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>
2026-11-10 15:04:09 +01:00

211 lines
12 KiB
Kotlin

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
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<String>()
val readings = mutableListOf<String>()
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()
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()
}
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)
// 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 {
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" }
s.check(!below.hasEffect(poison)) { "a player deep under the Mire got poisoned" }
}
.thenSucceed()
}
private fun dress(player: ServerPlayer, namespace: String, pieces: List<String>) {
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<ServerPlayer>) {
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"),
)
}
}