Cobblemon Survival Pack: phases 0-2 and Phase 3 zones and hazards
Free NeoForge 1.21.1 modpack on Cobblemon 1.8.4 built with packwiz, plus the Kotlin companion mod (cobblesurvival) and its built-in data pack. - Phase 0: packwiz pack with Modrinth/CurseForge cross-links, build and export tooling, Prism instance sync, test server with smoke checks. - Phase 1: Poke Ball shaped island (WorldGen Editor preset, seed isle-26), Adrian's biome list, no natural ore, structures without ore or vanilla mobs, no Nether portals, world template builder and world report. - Phase 2: permadeath (CobbleHardcoreMon plus the companion mod's gap fixes), Revival Charm, expensive Healing Machine, Lootr refreshing loot, in-game self-tests on Minecraft's GameTest framework. - Phase 3 so far: 14 zones with their own spawn pools and level bands (tools/zone_layout.py, tools/zone_spawns.py, "zone" spawn condition), Cold Sweat heat and cold as gates with boss-drop insulation recipes, spawn survey and hazard self-tests in tools/quick_check.py. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
398301f0be
232 files changed
+72665
No files matched your search
@@ -0,0 +1,170 @@
|
||||
package dev.cobblesurvival.selftest
|
||||
|
||||
import dev.cobblesurvival.CobbleSurvival
|
||||
import dev.cobblesurvival.zone.ZoneSample
|
||||
import dev.cobblesurvival.zone.Zones
|
||||
import net.minecraft.gametest.framework.GameTestAssertException
|
||||
import net.minecraft.core.registries.BuiltInRegistries
|
||||
import net.minecraft.resources.ResourceLocation
|
||||
import net.minecraft.server.level.ServerPlayer
|
||||
import net.minecraft.world.entity.EquipmentSlot
|
||||
import net.minecraft.world.entity.LivingEntity
|
||||
import net.minecraft.world.item.ItemStack
|
||||
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.
|
||||
*
|
||||
* 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"),
|
||||
)
|
||||
|
||||
/** 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()
|
||||
}
|
||||
|
||||
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"),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
package dev.cobblesurvival.selftest
|
||||
|
||||
import com.cobblemon.mod.common.Cobblemon
|
||||
import com.cobblemon.mod.common.api.npc.NPCClasses
|
||||
import com.cobblemon.mod.common.api.pokemon.status.Statuses
|
||||
import com.cobblemon.mod.common.api.storage.party.NPCPartyStore
|
||||
import com.cobblemon.mod.common.battles.BattleBuilder
|
||||
import com.cobblemon.mod.common.battles.MoveActionResponse
|
||||
import com.cobblemon.mod.common.battles.SwitchActionResponse
|
||||
import com.cobblemon.mod.common.entity.npc.NPCEntity
|
||||
import com.cobblemon.mod.common.entity.pokemon.PokemonEntity
|
||||
import com.cobblemon.mod.common.pokemon.Pokemon
|
||||
import com.cobblemon.mod.common.util.isInBattle
|
||||
import net.minecraft.world.item.ItemStack
|
||||
import net.minecraft.world.level.block.Blocks
|
||||
import kotlin.random.Random
|
||||
|
||||
/**
|
||||
* Roadmap Phase 2: a fainted Pokémon is gone for good, unless it holds a Revival Charm, which saves it once.
|
||||
* Each case plays one way to faint for real (falls, lava, poison, wild and trainer battles) on the live server.
|
||||
* The player's Pokémon in battles knows only Explosion, which always knocks out its user; the opponents know only
|
||||
* Splash, so nothing else decides the battle.
|
||||
*/
|
||||
object PermadeathTests {
|
||||
private const val BATTLE_TICKS = 1200
|
||||
|
||||
fun all() = listOf(
|
||||
SelfTestCase("no_healing_or_auto_revive", 40, ::noHealingOrAutoRevive),
|
||||
SelfTestCase("faint_outside_battle", 5, ::faintOutsideBattle),
|
||||
SelfTestCase("fall", 200, ::fall),
|
||||
SelfTestCase("lava", 300, ::lava),
|
||||
SelfTestCase("poison", 5, ::poison),
|
||||
SelfTestCase("charm_saves_once", 40, ::charmSavesOnce),
|
||||
SelfTestCase("fainted_in_pc", 60, ::faintedInPc),
|
||||
SelfTestCase("wild_battle", BATTLE_TICKS, { wildBattle(it, charm = false) }),
|
||||
SelfTestCase("wild_battle_charm", BATTLE_TICKS, { wildBattle(it, charm = true) }),
|
||||
SelfTestCase("revived_during_battle", BATTLE_TICKS, ::revivedDuringBattle),
|
||||
SelfTestCase("trainer_battle", BATTLE_TICKS, ::trainerBattle),
|
||||
)
|
||||
|
||||
/** Cobblemon heals the party while the player sleeps and wakes fainted Pokémon after a while; both are off. */
|
||||
private fun noHealingOrAutoRevive(s: Scenario) {
|
||||
val config = Cobblemon.config
|
||||
s.check(config.healPercent == 0.0) { "healPercent is ${config.healPercent}, so sleeping heals the party" }
|
||||
s.check(config.defaultFaintTimer >= 100_000_000) { "defaultFaintTimer is ${config.defaultFaintTimer} s, so fainted Pokémon wake up" }
|
||||
val player = s.player()
|
||||
val hurt = s.give(player, s.pokemon("bidoof", 20))
|
||||
hurt.currentHealth = hurt.maxHealth / 2
|
||||
val party = Cobblemon.storage.getParty(player)
|
||||
repeat(600) { party.onSecondPassed(player) } // ten minutes
|
||||
s.check(hurt.currentHealth == hurt.maxHealth / 2) { "the party healed by itself: HP ${hurt.currentHealth}/${hurt.maxHealth}" }
|
||||
s.helper.succeed()
|
||||
}
|
||||
|
||||
/** Settled within a tick or two; CobbleHardcoreMon alone takes up to a second, time enough to heal it. */
|
||||
private fun faintOutsideBattle(s: Scenario) {
|
||||
val player = s.player()
|
||||
val pokemon = s.give(player, s.pokemon("bidoof", 10))
|
||||
pokemon.currentHealth = 0
|
||||
s.helper.succeedWhen { s.assertGone(player, pokemon) }
|
||||
}
|
||||
|
||||
/**
|
||||
* A 10-block drop does 7 damage. Kept short: a party Pokémon over 32 blocks away teleports to its trainer, and the
|
||||
* test area has a barrier roof 12 blocks up.
|
||||
*/
|
||||
private fun fall(s: Scenario) {
|
||||
val player = s.player()
|
||||
val pokemon = s.give(player, s.pokemon("bidoof", 5))
|
||||
lateinit var entity: PokemonEntity
|
||||
s.helper.startSequence()
|
||||
.thenExecute { entity = sendOut(s, pokemon, 6.5, 10.5) }
|
||||
.thenWaitUntil { s.check(readyToGetHurt(s, entity)) { "still being sent out" } }
|
||||
.thenExecute {
|
||||
pokemon.currentHealth = 3
|
||||
entity.teleportTo(entity.x, entity.y + 10, entity.z)
|
||||
}
|
||||
.thenWaitUntil { s.assertGone(player, pokemon) }
|
||||
.thenSucceed()
|
||||
}
|
||||
|
||||
private fun lava(s: Scenario) {
|
||||
val player = s.player()
|
||||
val pokemon = s.give(player, s.pokemon("bidoof", 5))
|
||||
// A pool walled in two blocks high, so the Pokémon can't climb out.
|
||||
for (x in 8..12) for (z in 2..6) for (y in 0..1) {
|
||||
val inside = x in 9..11 && z in 3..5
|
||||
if (!inside) s.helper.setBlock(x, y, z, Blocks.GLASS)
|
||||
else if (y == 0) s.helper.setBlock(x, y, z, Blocks.LAVA)
|
||||
}
|
||||
s.helper.startSequence()
|
||||
.thenExecute { sendOut(s, pokemon, 10.5, 4.5) }
|
||||
.thenWaitUntil { s.assertGone(player, pokemon) }
|
||||
.thenSucceed()
|
||||
}
|
||||
|
||||
/** Out of battle, poison takes 5 % HP now and then (1 in 15 each second) and can knock a Pokémon out. */
|
||||
private fun poison(s: Scenario) {
|
||||
val player = s.player()
|
||||
val pokemon = s.give(player, s.pokemon("bidoof", 10))
|
||||
pokemon.applyStatus(Statuses.POISON)
|
||||
pokemon.currentHealth = 1
|
||||
val unlucky = object : Random() {
|
||||
override fun nextBits(bitCount: Int) = 0
|
||||
}
|
||||
Statuses.POISON.onSecondPassed(player, pokemon, unlucky)
|
||||
s.check(pokemon.isFainted()) { "poison did not knock the Pokémon out (HP ${pokemon.currentHealth})" }
|
||||
s.helper.succeedWhen { s.assertGone(player, pokemon) }
|
||||
}
|
||||
|
||||
/** Saved once; fainting again right after takes it at once (CobbleHardcoreMon alone would wait 60 s). */
|
||||
private fun charmSavesOnce(s: Scenario) {
|
||||
val player = s.player()
|
||||
val pokemon = s.give(player, s.pokemon("bidoof", 10))
|
||||
pokemon.swapHeldItem(ItemStack(s.charm()), false)
|
||||
pokemon.currentHealth = 0
|
||||
s.helper.startSequence()
|
||||
.thenWaitUntil { s.assertSaved(player, pokemon) }
|
||||
.thenExecute { pokemon.currentHealth = 0 }
|
||||
.thenWaitUntil { s.assertGone(player, pokemon) }
|
||||
.thenSucceed()
|
||||
}
|
||||
|
||||
/** A fainted Pokémon that turns up in the PC later (here: fainted before it was stored) is caught by the sweep. */
|
||||
private fun faintedInPc(s: Scenario) {
|
||||
val player = s.player()
|
||||
val lost = s.pokemon("bidoof", 10)
|
||||
val saved = s.pokemon("bidoof", 10)
|
||||
saved.swapHeldItem(ItemStack(s.charm()), false)
|
||||
lost.currentHealth = 0
|
||||
saved.currentHealth = 0
|
||||
val pc = Cobblemon.storage.getPC(player)
|
||||
s.helper.startSequence()
|
||||
.thenIdle(2)
|
||||
.thenExecute {
|
||||
s.check(pc.add(lost) && pc.add(saved)) { "could not add to the PC" }
|
||||
}
|
||||
.thenWaitUntil {
|
||||
s.assertGone(player, lost)
|
||||
s.assertSaved(player, saved)
|
||||
}
|
||||
.thenSucceed()
|
||||
}
|
||||
|
||||
private fun wildBattle(s: Scenario, charm: Boolean) {
|
||||
val player = s.player()
|
||||
val pokemon = s.give(player, s.pokemon("bidoof", 5, "explosion"))
|
||||
if (charm) pokemon.swapHeldItem(ItemStack(s.charm()), false)
|
||||
val wild = s.wild(s.pokemon("bidoof", 50, "splash"), s.at(10.5, 0.0, 7.5))
|
||||
s.answerBattles(player) { _, _ -> MoveActionResponse("explosion") }
|
||||
s.startWildBattle(player, wild)
|
||||
.thenWaitUntil {
|
||||
s.check(!player.isInBattle()) { "the battle is still running: ${s.battleState(player)}" }
|
||||
if (charm) s.assertSaved(player, pokemon) else s.assertGone(player, pokemon)
|
||||
}
|
||||
.thenSucceed()
|
||||
}
|
||||
|
||||
/**
|
||||
* The lead faints and is healed while the battle goes on (as a Revive would), then the second one faints and
|
||||
* the battle is lost. Both fainted in the battle, so both are gone.
|
||||
*/
|
||||
private fun revivedDuringBattle(s: Scenario) {
|
||||
val player = s.player()
|
||||
val first = s.give(player, s.pokemon("bidoof", 5, "explosion"))
|
||||
val second = s.give(player, s.pokemon("bidoof", 5, "explosion"))
|
||||
val wild = s.wild(s.pokemon("bidoof", 50, "splash"), s.at(10.5, 0.0, 7.5))
|
||||
s.answerBattles(player) { _, request ->
|
||||
if (request.forceSwitch.any { it }) {
|
||||
first.heal()
|
||||
SwitchActionResponse(second.uuid)
|
||||
} else {
|
||||
MoveActionResponse("explosion")
|
||||
}
|
||||
}
|
||||
s.startWildBattle(player, wild)
|
||||
.thenWaitUntil {
|
||||
s.check(!player.isInBattle()) { "the battle is still running: ${s.battleState(player)}" }
|
||||
s.assertGone(player, second)
|
||||
s.assertGone(player, first)
|
||||
}
|
||||
.thenSucceed()
|
||||
}
|
||||
|
||||
private fun trainerBattle(s: Scenario) {
|
||||
val player = s.player()
|
||||
val pokemon = s.give(player, s.pokemon("bidoof", 5, "explosion"))
|
||||
val npc = NPCEntity(s.level)
|
||||
npc.npc = NPCClasses.getByName("standard") ?: error("no NPC class standard")
|
||||
npc.initialize(50)
|
||||
s.spawn(npc, s.at(10.5, 0.0, 7.5))
|
||||
npc.party = NPCPartyStore(npc).also { it.add(s.pokemon("magikarp", 50, "splash")) }
|
||||
s.answerBattles(player) { _, _ -> MoveActionResponse("explosion") }
|
||||
s.started(BattleBuilder.pvn(player, npc))
|
||||
s.helper.succeedWhen {
|
||||
s.check(!player.isInBattle()) { "the battle is still running" }
|
||||
s.assertGone(player, pokemon)
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendOut(s: Scenario, pokemon: Pokemon, x: Double, z: Double): PokemonEntity =
|
||||
pokemon.sendOut(s.level, s.at(x, 0.0, z), null) {} ?: error("could not send out ${pokemon.species.name}")
|
||||
|
||||
/** Cobblemon makes a Pokémon invulnerable while the send-out beam plays. */
|
||||
private fun readyToGetHurt(s: Scenario, entity: PokemonEntity) =
|
||||
entity.isAlive && !entity.isInvulnerableTo(s.level.damageSources().fall())
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
package dev.cobblesurvival.selftest
|
||||
|
||||
import com.cobblemon.mod.common.Cobblemon
|
||||
import com.cobblemon.mod.common.api.battles.model.actor.BattleActor
|
||||
import com.cobblemon.mod.common.api.moves.Moves
|
||||
import com.cobblemon.mod.common.api.pokemon.PokemonSpecies
|
||||
import com.cobblemon.mod.common.battles.BattleBuilder
|
||||
import com.cobblemon.mod.common.battles.BattleStartResult
|
||||
import com.cobblemon.mod.common.battles.ErroredBattleStart
|
||||
import com.cobblemon.mod.common.battles.ShowdownActionRequest
|
||||
import com.cobblemon.mod.common.battles.ShowdownActionResponse
|
||||
import com.cobblemon.mod.common.entity.pokemon.PokemonEntity
|
||||
import com.cobblemon.mod.common.pokemon.Pokemon
|
||||
import com.cobblemon.mod.common.util.getBattleState
|
||||
import com.mojang.authlib.GameProfile
|
||||
import dev.cobblesurvival.CobbleSurvival
|
||||
import dev.cobblesurvival.permadeath.Permadeath
|
||||
import io.netty.channel.embedded.EmbeddedChannel
|
||||
import net.minecraft.core.registries.BuiltInRegistries
|
||||
import net.minecraft.gametest.framework.GameTestHelper
|
||||
import net.minecraft.gametest.framework.GameTestInfo
|
||||
import net.minecraft.gametest.framework.GameTestListener
|
||||
import net.minecraft.gametest.framework.GameTestRunner
|
||||
import net.minecraft.gametest.framework.GameTestSequence
|
||||
import net.minecraft.network.Connection
|
||||
import net.minecraft.network.protocol.PacketFlow
|
||||
import net.minecraft.resources.ResourceLocation
|
||||
import net.minecraft.server.level.ServerLevel
|
||||
import net.minecraft.server.level.ServerPlayer
|
||||
import net.minecraft.server.network.CommonListenerCookie
|
||||
import net.minecraft.world.entity.Entity
|
||||
import net.minecraft.world.item.Item
|
||||
import net.minecraft.world.item.Items
|
||||
import net.minecraft.world.phys.Vec3
|
||||
import net.neoforged.neoforge.network.connection.ConnectionType
|
||||
import net.neoforged.neoforge.network.registration.ChannelAttributes
|
||||
import net.neoforged.neoforge.network.registration.NetworkPayloadSetup
|
||||
import java.util.UUID
|
||||
|
||||
/** What one running test works with: its GameTest helper, a fake player, Pokémon, and cleanup when it ends. */
|
||||
class Scenario(val case: SelfTestCase, private val index: Int) : GameTestListener {
|
||||
lateinit var helper: GameTestHelper
|
||||
private set
|
||||
val level: ServerLevel get() = helper.level
|
||||
|
||||
/** null while running, then "passed" or the failure. */
|
||||
var result: String? = null
|
||||
private set
|
||||
var passed = false
|
||||
private set
|
||||
private val cleanups = mutableListOf<() -> Unit>()
|
||||
private var players = 0
|
||||
|
||||
fun begin(helper: GameTestHelper) {
|
||||
this.helper = helper
|
||||
case.body(this)
|
||||
}
|
||||
|
||||
/** A position inside the test area (x and z 0..15, y 0..12; y 0 is the floor). */
|
||||
fun at(x: Double, y: Double, z: Double): Vec3 = helper.absoluteVec(Vec3(x, y, z))
|
||||
|
||||
fun check(condition: Boolean, message: () -> String) = helper.assertTrue(condition, message())
|
||||
|
||||
/**
|
||||
* A player as the server sees one: in the player list, ticked every tick. Its connection goes nowhere and
|
||||
* accepts every mod's network channel, since NeoForge refuses to send mod packets on an unnegotiated one.
|
||||
*/
|
||||
fun player(): ServerPlayer {
|
||||
val server = level.server
|
||||
val cookie = CommonListenerCookie.createInitial(GameProfile(UUID.randomUUID(), "selftest_${index}_${players++}"), false)
|
||||
val player = ServerPlayer(server, level, cookie.gameProfile(), cookie.clientInformation())
|
||||
val connection = Connection(PacketFlow.SERVERBOUND)
|
||||
EmbeddedChannel(connection)
|
||||
ChannelAttributes.setPayloadSetup(connection, NetworkPayloadSetup.empty())
|
||||
ChannelAttributes.setConnectionType(connection, ConnectionType.NEOFORGE)
|
||||
connection.channel().attr(ChannelAttributes.ADHOC_CHANNELS).set(EveryChannel())
|
||||
server.playerList.placeNewPlayer(connection, player, cookie)
|
||||
val pos = at(3.5, 0.0, 7.5)
|
||||
player.teleportTo(level, pos.x, pos.y, pos.z, 0f, 0f)
|
||||
cleanups += { server.playerList.remove(player) }
|
||||
helper.onEachTick { if (!player.hasDisconnected()) player.doTick() }
|
||||
return player
|
||||
}
|
||||
|
||||
/** Runs [action] when the test ends, pass or fail. */
|
||||
fun afterTest(action: () -> Unit) {
|
||||
cleanups += action
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves a test player somewhere else and keeps the world there running for it. A real client reports the move,
|
||||
* which updates chunk tracking; without that, the player sits in chunks that don't tick and mods that act on
|
||||
* player ticks (Cold Sweat's temperature) stop.
|
||||
*/
|
||||
fun moveTo(player: ServerPlayer, x: Double, y: Double, z: Double) {
|
||||
val chunkX = Math.floorDiv(x.toInt(), 16)
|
||||
val chunkZ = Math.floorDiv(z.toInt(), 16)
|
||||
for (dx in -1..1) for (dz in -1..1) {
|
||||
if (level.setChunkForced(chunkX + dx, chunkZ + dz, true)) {
|
||||
afterTest { level.setChunkForced(chunkX + dx, chunkZ + dz, false) }
|
||||
}
|
||||
}
|
||||
player.teleportTo(level, x, y, z, 0f, 0f)
|
||||
level.chunkSource.move(player)
|
||||
}
|
||||
|
||||
fun pokemon(species: String, level: Int, vararg moves: String): Pokemon {
|
||||
val pokemon = (PokemonSpecies.getByName(species) ?: error("no species $species")).create(level)
|
||||
if (moves.isNotEmpty()) {
|
||||
pokemon.moveSet.clear()
|
||||
moves.forEachIndexed { slot, move ->
|
||||
pokemon.moveSet.setMove(slot, (Moves.getByName(move) ?: error("no move $move")).create())
|
||||
}
|
||||
}
|
||||
return pokemon
|
||||
}
|
||||
|
||||
fun give(player: ServerPlayer, pokemon: Pokemon): Pokemon {
|
||||
check(Cobblemon.storage.getParty(player).add(pokemon)) { "could not add ${pokemon.species.name} to the party" }
|
||||
return pokemon
|
||||
}
|
||||
|
||||
fun <T : Entity> spawn(entity: T, pos: Vec3): T {
|
||||
entity.moveTo(pos.x, pos.y, pos.z, 0f, 0f)
|
||||
check(level.addFreshEntity(entity)) { "${entity.type} did not join the world" }
|
||||
cleanups += { entity.discard() }
|
||||
return entity
|
||||
}
|
||||
|
||||
fun wild(pokemon: Pokemon, pos: Vec3): PokemonEntity = spawn(PokemonEntity(level, pokemon), pos)
|
||||
|
||||
/** The pack's Revival Charm, a KubeJS item; it must be in the tag the companion mod checks. */
|
||||
fun charm(): Item {
|
||||
val item = BuiltInRegistries.ITEM.get(ResourceLocation.fromNamespaceAndPath(CobbleSurvival.MOD_ID, "revival_charm"))
|
||||
check(item != Items.AIR) { "the Revival Charm item does not exist" }
|
||||
check(item.defaultInstance.`is`(Permadeath.REVIVAL_CHARMS)) { "the Revival Charm is not in #cobblesurvival:revival_charms" }
|
||||
return item
|
||||
}
|
||||
|
||||
fun assertGone(player: ServerPlayer, pokemon: Pokemon) {
|
||||
val name = pokemon.species.name
|
||||
check(Cobblemon.storage.getParty(player).none { it.uuid == pokemon.uuid }) { "$name is still in the party (HP ${pokemon.currentHealth})" }
|
||||
check(Cobblemon.storage.getPC(player).none { it.uuid == pokemon.uuid }) { "$name is still in the PC (HP ${pokemon.currentHealth})" }
|
||||
}
|
||||
|
||||
fun assertSaved(player: ServerPlayer, pokemon: Pokemon) {
|
||||
val name = pokemon.species.name
|
||||
val stored = Cobblemon.storage.getParty(player).any { it.uuid == pokemon.uuid } ||
|
||||
Cobblemon.storage.getPC(player).any { it.uuid == pokemon.uuid }
|
||||
check(stored) { "$name was removed although it held a Revival Charm" }
|
||||
check(pokemon.currentHealth == pokemon.maxHealth) { "$name was not healed (HP ${pokemon.currentHealth}/${pokemon.maxHealth})" }
|
||||
check(pokemon.heldItem().isEmpty) { "$name still holds ${pokemon.heldItem()}" }
|
||||
}
|
||||
|
||||
/** Answers the player's battle requests each tick, as the battle screen would; null waits instead. */
|
||||
fun answerBattles(player: ServerPlayer, choose: (BattleActor, ShowdownActionRequest) -> ShowdownActionResponse?) {
|
||||
helper.onEachTick {
|
||||
val actor = player.getBattleState()?.second ?: return@onEachTick
|
||||
val request = actor.request ?: return@onEachTick
|
||||
if (actor.mustChoose) choose(actor, request)?.let { actor.setActionResponses(listOf(it)) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A wild Pokémon is tied to its entity on the entity's first tick; a battle started before that never asks the
|
||||
* wild side for a move. Natural spawns are always older than that.
|
||||
*/
|
||||
fun startWildBattle(player: ServerPlayer, wild: PokemonEntity): GameTestSequence =
|
||||
helper.startSequence()
|
||||
.thenWaitUntil { check(wild.pokemon.entity === wild) { "the wild Pokémon has not ticked yet" } }
|
||||
.thenExecute { started(BattleBuilder.pve(player, wild)) }
|
||||
|
||||
fun started(result: BattleStartResult) {
|
||||
if (result is ErroredBattleStart) {
|
||||
helper.fail("the battle did not start: ${result.errors.joinToString { it.javaClass.simpleName }}")
|
||||
}
|
||||
}
|
||||
|
||||
/** For failure messages: where the player's battle is stuck (the full Showdown log goes to the server log). */
|
||||
private var battleDump = ""
|
||||
|
||||
fun battleState(player: ServerPlayer): String {
|
||||
val (battle, actor) = player.getBattleState() ?: return "no battle"
|
||||
battleDump = battle.battleLog.joinToString("\n")
|
||||
val recent = battle.showdownMessages.takeLast(3).joinToString(" | ") { it.replace('\n', ' ').take(160) }
|
||||
val entities = Cobblemon.storage.getParty(player).joinToString { pokemon ->
|
||||
val e = pokemon.entity ?: return@joinToString "${pokemon.species.name} not out"
|
||||
"${pokemon.species.name} at %.1f %.1f %.1f beam ${e.beamMode} busy ${e.isBusy}".format(e.x, e.y, e.z)
|
||||
}
|
||||
return "turn ${battle.turn}, started ${battle.started}, ended ${battle.ended}, must choose ${actor.mustChoose}, " +
|
||||
"request ${actor.request != null}, dispatches ${battle.dispatches.size}, party: $entities, " +
|
||||
"last showdown messages: $recent"
|
||||
}
|
||||
|
||||
override fun testStructureLoaded(info: GameTestInfo) {}
|
||||
|
||||
override fun testPassed(info: GameTestInfo, runner: GameTestRunner) = finish(null)
|
||||
|
||||
override fun testFailed(info: GameTestInfo, runner: GameTestRunner) =
|
||||
finish(info.error?.let { it.message ?: it.toString() } ?: "failed")
|
||||
|
||||
override fun testAddedForRerun(original: GameTestInfo, rerun: GameTestInfo, runner: GameTestRunner) {}
|
||||
|
||||
/** Ends the test from outside, when the test code threw something the GameTest framework doesn't catch. */
|
||||
fun abort(error: Throwable) {
|
||||
if (result == null) finish("crashed: $error")
|
||||
}
|
||||
|
||||
private fun finish(failure: String?) {
|
||||
if (failure != null && battleDump.isNotEmpty()) CobbleSurvival.LOGGER.warn("selftest {} battle log:\n{}", case.name, battleDump)
|
||||
for (cleanup in cleanups.asReversed()) {
|
||||
try {
|
||||
cleanup()
|
||||
} catch (e: Exception) {
|
||||
CobbleSurvival.LOGGER.warn("selftest {}: cleanup failed", case.name, e)
|
||||
}
|
||||
}
|
||||
passed = failure == null
|
||||
result = failure ?: "passed"
|
||||
if (passed) CobbleSurvival.LOGGER.info("selftest PASS {}", case.name)
|
||||
else CobbleSurvival.LOGGER.error("selftest FAIL {}: {}", case.name, failure)
|
||||
}
|
||||
|
||||
private class EveryChannel : HashSet<ResourceLocation>() {
|
||||
override fun contains(element: ResourceLocation) = true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package dev.cobblesurvival.selftest
|
||||
|
||||
import com.mojang.brigadier.builder.LiteralArgumentBuilder
|
||||
import dev.cobblesurvival.CobbleSurvival
|
||||
import net.minecraft.commands.CommandSourceStack
|
||||
import net.minecraft.commands.Commands
|
||||
import net.minecraft.core.BlockPos
|
||||
import net.minecraft.gametest.framework.GameTestInfo
|
||||
import net.minecraft.gametest.framework.GameTestRunner
|
||||
import net.minecraft.gametest.framework.GameTestTicker
|
||||
import net.minecraft.gametest.framework.RetryOptions
|
||||
import net.minecraft.gametest.framework.StructureGridSpawner
|
||||
import net.minecraft.gametest.framework.TestFunction
|
||||
import net.minecraft.network.chat.Component
|
||||
import net.minecraft.server.MinecraftServer
|
||||
import net.minecraft.world.level.ChunkPos
|
||||
import net.minecraft.world.level.biome.Biomes
|
||||
import net.minecraft.world.level.block.Rotation
|
||||
import net.neoforged.neoforge.common.NeoForge
|
||||
import net.neoforged.neoforge.event.tick.ServerTickEvent
|
||||
|
||||
/**
|
||||
* One in-game test: [body] sets it up through its [Scenario] and must end in a pass or a failure within [maxTicks].
|
||||
* [islandOnly] tests need the island world and are skipped elsewhere (tools/selftest.py uses a flat world).
|
||||
* Tests of one [batch] run side by side; batches run one after another (a test that changes the world around it,
|
||||
* like the biome or the time of day, gets a batch of its own).
|
||||
*/
|
||||
class SelfTestCase(
|
||||
val name: String,
|
||||
val maxTicks: Int,
|
||||
val body: (Scenario) -> Unit,
|
||||
val islandOnly: Boolean = false,
|
||||
val batch: String = CobbleSurvival.MOD_ID,
|
||||
)
|
||||
|
||||
/**
|
||||
* In-game tests on a real server with all of the pack's mods and configs: `/cobblesurvival selftest run` starts them
|
||||
* on a platform high above spawn, `/cobblesurvival selftest status` reports. They use the vanilla GameTest
|
||||
* framework, which NeoForge only switches on in a development environment, so this object creates and ticks the
|
||||
* tests itself. Only there when the server runs with `-Dcobblesurvival.selftest=true` (tools/quick_check.py);
|
||||
* players never see the command.
|
||||
*/
|
||||
object SelfTest {
|
||||
val enabled: Boolean = java.lang.Boolean.getBoolean("cobblesurvival.selftest")
|
||||
private const val TEMPLATE = "cobblesurvival:selftest/empty"
|
||||
private const val PLATFORM_Y = 200
|
||||
private const val TESTS_PER_ROW = 4
|
||||
private const val FORCED_CHUNKS = 9
|
||||
|
||||
private val cases: List<SelfTestCase> get() =
|
||||
PermadeathTests.all() + SurvivalRulesTests.all() + ZoneTests.all() + HazardTests.all()
|
||||
|
||||
private var running: List<Scenario> = emptyList()
|
||||
private var forced: List<ChunkPos> = emptyList()
|
||||
private var summary = "selftest not run"
|
||||
|
||||
fun register() {
|
||||
if (enabled) NeoForge.EVENT_BUS.addListener(::onServerTick)
|
||||
}
|
||||
|
||||
fun command(): LiteralArgumentBuilder<CommandSourceStack> =
|
||||
Commands.literal("selftest")
|
||||
.requires { it.hasPermission(4) }
|
||||
.then(Commands.literal("run").executes { context ->
|
||||
val reply = start(context.source.server)
|
||||
context.source.sendSuccess({ Component.literal(reply) }, false)
|
||||
1
|
||||
})
|
||||
.then(Commands.literal("status").executes { context ->
|
||||
val done = running.count { it.result != null }
|
||||
val reply = if (running.isNotEmpty()) "selftest running: $done/${running.size} done" else summary
|
||||
context.source.sendSuccess({ Component.literal(reply) }, false)
|
||||
1
|
||||
})
|
||||
|
||||
private fun start(server: MinecraftServer): String {
|
||||
if (running.isNotEmpty()) return "selftest already running"
|
||||
val level = server.overworld()
|
||||
val spawn = level.sharedSpawnPos
|
||||
val origin = BlockPos(spawn.x, PLATFORM_Y, spawn.z)
|
||||
val corner = ChunkPos(origin)
|
||||
forced = (0 until FORCED_CHUNKS).flatMap { dx -> (0 until FORCED_CHUNKS).map { dz -> ChunkPos(corner.x + dx, corner.z + dz) } }
|
||||
forced.forEach { level.setChunkForced(it.x, it.z, true) }
|
||||
|
||||
// Only the island world type puts basalt deltas into the overworld.
|
||||
val island = level.chunkSource.generator.biomeSource.possibleBiomes().any { it.`is`(Biomes.BASALT_DELTAS) }
|
||||
val (runnable, skipped) = cases.partition { island || !it.islandOnly }
|
||||
val scenarios = runnable.mapIndexed { index, case -> Scenario(case, index) }
|
||||
val infos = scenarios.map { scenario ->
|
||||
val function = TestFunction(scenario.case.batch, "${CobbleSurvival.MOD_ID}.${scenario.case.name}", TEMPLATE,
|
||||
scenario.case.maxTicks, 0L, true) { helper -> scenario.begin(helper) }
|
||||
GameTestInfo(function, Rotation.NONE, level, RetryOptions.noRetries()).also { it.addListener(scenario) }
|
||||
}
|
||||
running = scenarios
|
||||
GameTestTicker.SINGLETON.clear()
|
||||
GameTestRunner.Builder.fromInfo(infos, level)
|
||||
.newStructureSpawner(StructureGridSpawner(origin, TESTS_PER_ROW, false))
|
||||
.build()
|
||||
.start()
|
||||
val skippedNote = if (skipped.isEmpty()) "" else ", skipped ${skipped.size} that need the island world"
|
||||
CobbleSurvival.LOGGER.info("selftest started {} tests at {}{}", infos.size, origin.toShortString(), skippedNote)
|
||||
return "selftest started ${infos.size} tests$skippedNote"
|
||||
}
|
||||
|
||||
private fun onServerTick(event: ServerTickEvent.Post) {
|
||||
if (running.isEmpty()) return
|
||||
try {
|
||||
GameTestTicker.SINGLETON.tick()
|
||||
} catch (e: Exception) {
|
||||
// Only assertion failures are handled by the framework; anything else would crash the server.
|
||||
CobbleSurvival.LOGGER.error("selftest: a test threw; ending the run", e)
|
||||
running.forEach { it.abort(e) }
|
||||
}
|
||||
if (running.any { it.result == null }) return
|
||||
|
||||
val failed = running.filter { !it.passed }
|
||||
summary = "selftest finished: ${running.size - failed.size}/${running.size} passed" +
|
||||
failed.joinToString("") { "; FAILED ${it.case.name}: ${it.result}" }
|
||||
if (failed.isEmpty()) CobbleSurvival.LOGGER.info(summary) else CobbleSurvival.LOGGER.error(summary)
|
||||
GameTestTicker.SINGLETON.clear()
|
||||
val level = event.server.overworld()
|
||||
forced.forEach { level.setChunkForced(it.x, it.z, false) }
|
||||
running = emptyList()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package dev.cobblesurvival.selftest
|
||||
|
||||
import com.cobblemon.mod.common.Cobblemon
|
||||
import com.cobblemon.mod.common.CobblemonBlocks
|
||||
import com.cobblemon.mod.common.CobblemonItems
|
||||
import com.cobblemon.mod.common.api.storage.pc.link.PCLinkManager
|
||||
import com.cobblemon.mod.common.battles.MoveActionResponse
|
||||
import com.cobblemon.mod.common.util.getBattleState
|
||||
import com.cobblemon.mod.common.util.isInBattle
|
||||
import dev.cobblesurvival.CobbleSurvival
|
||||
import net.minecraft.core.BlockPos
|
||||
import net.minecraft.core.Direction
|
||||
import net.minecraft.resources.ResourceLocation
|
||||
import net.minecraft.world.InteractionHand
|
||||
import net.minecraft.world.item.ItemStack
|
||||
import net.minecraft.world.item.Items
|
||||
import net.minecraft.world.level.block.Blocks
|
||||
import net.minecraft.world.phys.BlockHitResult
|
||||
import net.minecraft.world.phys.Vec3
|
||||
|
||||
/** Roadmap Phase 2, besides permadeath: healing friction, storage only at PC blocks, free movement in battles. */
|
||||
object SurvivalRulesTests {
|
||||
/** Cobblemon's own value; the pack's must be slower. */
|
||||
private const val COBBLEMON_SECONDS_TO_CHARGE = 900.0
|
||||
|
||||
fun all() = listOf(
|
||||
SelfTestCase("healing_machine_is_expensive", 20, ::healingMachineIsExpensive),
|
||||
SelfTestCase("no_remote_pc", 40, ::noRemotePc),
|
||||
SelfTestCase("free_movement_in_battle", 1200, ::freeMovementInBattle),
|
||||
)
|
||||
|
||||
/** The pack replaces Cobblemon's recipe (kubejs/server_scripts/healing_machine.js) and slows the recharge. */
|
||||
private fun healingMachineIsExpensive(s: Scenario) {
|
||||
val recipes = s.level.server.recipeManager
|
||||
s.check(recipes.byKey(ResourceLocation.fromNamespaceAndPath("cobblemon", "healing_machine")).isEmpty) {
|
||||
"Cobblemon's own Healing Machine recipe is still there"
|
||||
}
|
||||
val recipe = recipes.byKey(ResourceLocation.fromNamespaceAndPath(CobbleSurvival.MOD_ID, "healing_machine")).orElse(null)
|
||||
s.check(recipe != null) { "the pack's Healing Machine recipe is missing" }
|
||||
s.check(recipe.value().getResultItem(s.level.registryAccess()).`is`(CobblemonItems.HEALING_MACHINE)) {
|
||||
"the pack's recipe doesn't make a Healing Machine"
|
||||
}
|
||||
val config = Cobblemon.config
|
||||
s.check(!config.infiniteHealerCharge) { "Healing Machines never run out of charge" }
|
||||
s.check(config.secondsToChargeHealingMachine > COBBLEMON_SECONDS_TO_CHARGE) {
|
||||
"Healing Machines recharge as fast as in Cobblemon (${config.secondsToChargeHealingMachine} s)"
|
||||
}
|
||||
s.helper.succeed()
|
||||
}
|
||||
|
||||
/** `/pc` needs cheats, and a PC opened at a block stops working once the player walks away from it. */
|
||||
private fun noRemotePc(s: Scenario) {
|
||||
val player = s.player()
|
||||
val server = s.level.server
|
||||
// Cobblemon asks NeoForge's permission API, which looks at the player's operator status.
|
||||
fun pcCommandWorks(): Boolean {
|
||||
val parse = server.commands.dispatcher.parse("pc", player.createCommandSourceStack())
|
||||
return parse.context.nodes.isNotEmpty() && !parse.reader.canRead()
|
||||
}
|
||||
s.check(!pcCommandWorks()) { "a player without cheats can open the PC anywhere with /pc" }
|
||||
server.playerList.op(player.gameProfile)
|
||||
val asOperator = pcCommandWorks()
|
||||
server.playerList.deop(player.gameProfile)
|
||||
s.check(asOperator) { "/pc doesn't work for operators either, so this test can't tell" }
|
||||
|
||||
val pos = s.helper.absolutePos(BlockPos(5, 0, 7))
|
||||
val state = CobblemonBlocks.PC.defaultBlockState()
|
||||
s.level.setBlockAndUpdate(pos, state)
|
||||
CobblemonBlocks.PC.setPlacedBy(s.level, pos, state, player, ItemStack.EMPTY)
|
||||
state.useWithoutItem(s.level, player, BlockHitResult(Vec3.atCenterOf(pos), Direction.WEST, pos, false))
|
||||
s.check(PCLinkManager.getPC(player) != null) { "using a PC block didn't open the PC" }
|
||||
|
||||
s.helper.startSequence()
|
||||
.thenExecute { player.teleportTo(s.level, player.x + 40, player.y, player.z, 0f, 0f) }
|
||||
.thenWaitUntil { s.check(PCLinkManager.getPC(player) == null) { "the PC still works 40 blocks away from it" } }
|
||||
.thenSucceed()
|
||||
}
|
||||
|
||||
/**
|
||||
* Mid-battle the player can still break and place blocks and walk a few blocks away (Phase 7 bosses rely on it);
|
||||
* the battle keeps going until the player answers. Cobblemon's own key to leave the battle screen is a client
|
||||
* thing and stays a manual check.
|
||||
*/
|
||||
private fun freeMovementInBattle(s: Scenario) {
|
||||
val player = s.player()
|
||||
s.give(player, s.pokemon("bidoof", 5, "explosion"))
|
||||
val wild = s.wild(s.pokemon("bidoof", 50, "splash"), s.at(10.5, 0.0, 7.5))
|
||||
val toBreak = s.helper.absolutePos(BlockPos(3, 0, 10))
|
||||
val floor = s.helper.absolutePos(BlockPos(3, -1, 4))
|
||||
s.helper.setBlock(BlockPos(3, 0, 10), Blocks.DIRT)
|
||||
var done = false
|
||||
s.answerBattles(player) { _, _ -> if (done) MoveActionResponse("explosion") else null }
|
||||
s.startWildBattle(player, wild)
|
||||
.thenWaitUntil {
|
||||
s.check(player.isInBattle() && player.getBattleState()?.second?.mustChoose == true) { "the battle hasn't asked for a move yet" }
|
||||
}
|
||||
.thenExecute {
|
||||
s.check(player.gameMode.destroyBlock(toBreak)) { "can't break a block during a battle" }
|
||||
player.setItemInHand(InteractionHand.MAIN_HAND, ItemStack(Items.DIRT))
|
||||
player.gameMode.useItemOn(player, s.level, player.mainHandItem, InteractionHand.MAIN_HAND,
|
||||
BlockHitResult(Vec3.atCenterOf(floor).add(0.0, 0.5, 0.0), Direction.UP, floor, false))
|
||||
s.check(s.level.getBlockState(floor.above()).`is`(Blocks.DIRT)) { "can't place a block during a battle" }
|
||||
player.teleportTo(s.level, player.x + 6, player.y, player.z, 0f, 0f)
|
||||
}
|
||||
.thenIdle(40)
|
||||
.thenExecute {
|
||||
s.check(player.isInBattle()) { "breaking, placing or walking 6 blocks ended the battle" }
|
||||
done = true
|
||||
}
|
||||
.thenWaitUntil { s.check(!player.isInBattle()) { "the battle is still running: ${s.battleState(player)}" } }
|
||||
.thenSucceed()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package dev.cobblesurvival.selftest
|
||||
|
||||
import com.cobblemon.mod.common.Cobblemon
|
||||
import com.cobblemon.mod.common.api.spawning.CobblemonSpawnPools
|
||||
import com.cobblemon.mod.common.api.spawning.SpawnCause
|
||||
import com.cobblemon.mod.common.api.spawning.detail.PokemonSpawnDetail
|
||||
import com.cobblemon.mod.common.api.spawning.detail.SpawnDetail
|
||||
import com.cobblemon.mod.common.api.spawning.position.calculators.SpawnablePositionCalculator
|
||||
import com.cobblemon.mod.common.api.spawning.spawner.SpawningZoneInput
|
||||
import com.cobblemon.mod.common.util.spawner
|
||||
import dev.cobblesurvival.CobbleSurvival
|
||||
import dev.cobblesurvival.zone.Zone
|
||||
import dev.cobblesurvival.zone.ZoneSample
|
||||
import dev.cobblesurvival.zone.Zones
|
||||
import net.minecraft.core.BlockPos
|
||||
import net.minecraft.gametest.framework.GameTestAssertException
|
||||
import net.minecraft.server.level.ServerPlayer
|
||||
import net.minecraft.util.Mth
|
||||
import net.minecraft.world.level.levelgen.Heightmap
|
||||
|
||||
/**
|
||||
* Roadmap Phase 3: each zone has its own Pokémon and levels. The spawn pools come from tools/zone_spawns.py;
|
||||
* Cobblemon's own spawn files are filtered out by datapacks/core/pack.mcmeta.
|
||||
*/
|
||||
object ZoneTests {
|
||||
/** How far (blocks, each way) the survey looks for an open cave around a cave zone's sample point. */
|
||||
private const val CAVE_SEARCH = 32
|
||||
/** Air above a cave floor: big Pokémon (Basalt Furnace has Charizard, Turtonator) only spawn where they fit. */
|
||||
private const val HEADROOM = 4
|
||||
fun all() = listOf(
|
||||
SelfTestCase("spawn_pools_are_zoned", 20, ::spawnPoolsAreZoned),
|
||||
SelfTestCase("spawn_survey", 2400, ::spawnSurvey, islandOnly = true),
|
||||
)
|
||||
|
||||
private fun zoneOf(detail: SpawnDetail): Zone? = Zones.byId[detail.id.substringBefore('-')]
|
||||
|
||||
/** Every loaded spawn entry belongs to a zone and spawns within its level band; none of Cobblemon's is left. */
|
||||
private fun spawnPoolsAreZoned(s: Scenario) {
|
||||
val details = CobblemonSpawnPools.WORLD_SPAWN_POOL.details
|
||||
s.check(Zones.byId.isNotEmpty()) { "no zones loaded" }
|
||||
s.check(details.isNotEmpty()) { "no spawn entries loaded at all" }
|
||||
val unzoned = details.filter { zoneOf(it) == null }
|
||||
s.check(unzoned.isEmpty()) { "${unzoned.size} spawn entries belong to no zone, e.g. ${unzoned.take(5).map { it.id }}" }
|
||||
val outOfBand = details.filterIsInstance<PokemonSpawnDetail>().filter { detail ->
|
||||
val zone = zoneOf(detail)!!
|
||||
val levels = detail.levelRange
|
||||
levels == null || levels.first < zone.minLevel || levels.last > zone.maxLevel
|
||||
}
|
||||
s.check(outOfBand.isEmpty()) { "${outOfBand.size} entries spawn outside their zone's levels, e.g. ${outOfBand.take(5).map { "${it.id} ${it.levelRange}" }}" }
|
||||
s.helper.succeed()
|
||||
}
|
||||
|
||||
/**
|
||||
* Stands a player deep inside every zone and asks Cobblemon what could spawn around them right now, the way
|
||||
* `/checkspawn` does. Everything offered must belong to that zone.
|
||||
*/
|
||||
private fun spawnSurvey(s: Scenario) {
|
||||
val player = s.player()
|
||||
val home = player.position()
|
||||
val report = mutableListOf<String>()
|
||||
val problems = mutableListOf<String>()
|
||||
s.helper.startSequence().thenExecute {
|
||||
for ((zoneId, sample) in Zones.samples) {
|
||||
try {
|
||||
val (offered, positions) = survey(s, player, zoneId, sample)
|
||||
val strangers = offered.filter { zoneOf(it)?.id != zoneId }
|
||||
report += "$zoneId ${offered.size} (${positions} positions at ${player.blockPosition().toShortString()})"
|
||||
if (offered.isEmpty()) problems += "nothing can spawn at the $zoneId sample ($positions spawnable positions)"
|
||||
if (strangers.isNotEmpty()) problems += "$zoneId offers other zones' spawns: ${strangers.take(5).map { it.id }}"
|
||||
} catch (e: GameTestAssertException) {
|
||||
problems += e.message ?: "$zoneId failed"
|
||||
}
|
||||
}
|
||||
player.teleportTo(s.level, home.x, home.y, home.z, 0f, 0f)
|
||||
CobbleSurvival.LOGGER.info("selftest spawn_survey: spawn entries per zone sample: {}", report.joinToString())
|
||||
s.check(problems.isEmpty()) { problems.joinToString("; ") }
|
||||
}.thenSucceed()
|
||||
}
|
||||
|
||||
/** What can spawn around the sample right now, and how many spawnable positions Cobblemon found there. */
|
||||
private fun survey(s: Scenario, player: ServerPlayer, zoneId: String, sample: ZoneSample): Pair<Set<SpawnDetail>, Int> {
|
||||
val level = s.level
|
||||
// Generate the chunks around the sample, then find where to stand.
|
||||
for (dx in -2..2) for (dz in -2..2) level.getChunk((sample.x shr 4) + dx, (sample.z shr 4) + dz)
|
||||
val stand = if (sample.y == null) {
|
||||
BlockPos(sample.x, level.getHeight(Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, sample.x, sample.z), sample.z)
|
||||
} else {
|
||||
caveFloor(s, zoneId, sample, sample.y)
|
||||
?: throw GameTestAssertException("no open $zoneId cave near ${sample.x} ${sample.y} ${sample.z}")
|
||||
}
|
||||
player.teleportTo(level, stand.x + 0.5, stand.y.toDouble(), stand.z + 0.5, 0f, 0f)
|
||||
|
||||
val spawner = player.spawner
|
||||
val config = Cobblemon.config
|
||||
val diameter = config.spawningZoneDiameter
|
||||
val height = config.spawningZoneHeight
|
||||
val input = SpawningZoneInput(
|
||||
SpawnCause(spawner, player), level,
|
||||
Mth.ceil(player.x - diameter / 2.0), Mth.ceil(player.y - height / 2.0), Mth.ceil(player.z - diameter / 2.0),
|
||||
diameter, height, diameter,
|
||||
)
|
||||
val zone = Cobblemon.spawningZoneGenerator.generate(spawner, input)
|
||||
val positions = Cobblemon.areaSpawnablePositionResolver.resolve(
|
||||
spawner, SpawnablePositionCalculator.prioritizedAreaCalculators, zone,
|
||||
)
|
||||
val offered = CobblemonSpawnPools.WORLD_SPAWN_POOL.buckets.flatMap { bucket ->
|
||||
spawner.selector.getProbabilities(spawner, bucket, positions).filterValues { it > 0f }.keys
|
||||
}.toSet()
|
||||
if (offered.isEmpty()) {
|
||||
// Why nothing: per entry of this zone, does any position satisfy it, and if not, its conditions.
|
||||
for (detail in CobblemonSpawnPools.WORLD_SPAWN_POOL.details.filter { zoneOf(it)?.id == zoneId }) {
|
||||
val typed = positions.filter { detail.spawnablePositionType.clazz.isInstance(it) }
|
||||
val satisfied = typed.count { detail.isSatisfiedBy(it) }
|
||||
val conditions = typed.firstOrNull()?.let { p -> detail.conditions.map { it.isSatisfiedBy(p) } }
|
||||
val anti = typed.firstOrNull()?.let { p -> detail.anticonditions.map { it.isSatisfiedBy(p) } }
|
||||
val p = typed.firstOrNull()
|
||||
CobbleSurvival.LOGGER.warn("selftest survey {}: {} positions of its type {}, satisfied {}, conditions {}, anticonditions {}, light {} sky {} biome {}",
|
||||
detail.id, typed.size, detail.spawnablePositionType, satisfied, conditions, anti, p?.light, p?.skyLight, p?.biomeHolder?.unwrapKey()?.orElse(null))
|
||||
}
|
||||
}
|
||||
return offered to positions.size
|
||||
}
|
||||
|
||||
/** Whether Cobblemon's spawning area around [pos] (sampled per 4-block biome cell) lies inside [zoneId]. */
|
||||
private fun wholeAreaIn(s: Scenario, pos: BlockPos, zoneId: String): Boolean {
|
||||
val half = Cobblemon.config.spawningZoneDiameter / 2
|
||||
val halfHeight = Cobblemon.config.spawningZoneHeight / 2
|
||||
for (dx in -half..half step 4) for (dz in -half..half step 4) for (dy in -halfHeight..halfHeight step 4) {
|
||||
if (Zones.at(s.level, pos.offset(dx, dy, dz))?.id != zoneId) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** An open cave floor (HEADROOM blocks of air above something solid) near the sample, its spawning area all in the zone. */
|
||||
private fun caveFloor(s: Scenario, zoneId: String, sample: ZoneSample, y: Int): BlockPos? {
|
||||
val level = s.level
|
||||
for (radius in 0..CAVE_SEARCH step 2) {
|
||||
for (dx in -radius..radius) for (dz in -radius..radius) {
|
||||
if (maxOf(kotlin.math.abs(dx), kotlin.math.abs(dz)) != radius) continue
|
||||
for (dy in 0..CAVE_SEARCH) for (sign in intArrayOf(1, -1)) {
|
||||
val pos = BlockPos(sample.x + dx, y + sign * dy, sample.z + dz)
|
||||
val open = (0 until HEADROOM).all { level.getBlockState(pos.above(it)).isAir }
|
||||
if (open && level.getBlockState(pos.below()).isSolid && wholeAreaIn(s, pos, zoneId)) return pos
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user