- 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>
211 lines
12 KiB
Kotlin
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"),
|
|
)
|
|
}
|
|
}
|