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,103 @@
|
||||
package dev.cobblesurvival
|
||||
|
||||
import com.cobblemon.mod.common.Cobblemon
|
||||
import com.cobblemon.mod.common.api.pokemon.PokemonSpecies
|
||||
import com.mojang.logging.LogUtils
|
||||
import dev.cobblesurvival.dev.BiomePreview
|
||||
import dev.cobblesurvival.permadeath.Permadeath
|
||||
import dev.cobblesurvival.selftest.SelfTest
|
||||
import dev.cobblesurvival.world.OnlyPokemon
|
||||
import dev.cobblesurvival.zone.ZoneCondition
|
||||
import dev.cobblesurvival.zone.Zones
|
||||
import net.minecraft.commands.Commands
|
||||
import net.minecraft.core.BlockPos
|
||||
import net.minecraft.network.chat.Component
|
||||
import net.minecraft.resources.ResourceLocation
|
||||
import net.minecraft.server.level.ServerLevel
|
||||
import net.minecraft.server.packs.PackType
|
||||
import net.minecraft.server.packs.repository.Pack
|
||||
import net.minecraft.server.packs.repository.PackSource
|
||||
import net.minecraft.world.level.Level
|
||||
import net.neoforged.fml.ModList
|
||||
import net.neoforged.fml.common.Mod
|
||||
import net.neoforged.neoforge.common.NeoForge
|
||||
import net.neoforged.neoforge.event.AddPackFindersEvent
|
||||
import net.neoforged.neoforge.event.RegisterCommandsEvent
|
||||
import net.neoforged.neoforge.event.level.BlockEvent
|
||||
import net.neoforged.neoforge.event.server.ServerStartedEvent
|
||||
import org.slf4j.Logger
|
||||
import thedarkcolour.kotlinforforge.neoforge.forge.MOD_BUS
|
||||
|
||||
@Mod(CobbleSurvival.MOD_ID)
|
||||
class CobbleSurvival {
|
||||
companion object {
|
||||
const val MOD_ID = "cobblesurvival"
|
||||
val LOGGER: Logger = LogUtils.getLogger()
|
||||
|
||||
val version: String
|
||||
get() = ModList.get().getModContainerById(MOD_ID)
|
||||
.map { it.modInfo.version.toString() }
|
||||
.orElse("unknown")
|
||||
|
||||
/** One line the server smoke test reads over RCON to confirm the mod is live. */
|
||||
fun statusLine() =
|
||||
"$MOD_ID $version ok; cobblemon ${Cobblemon.VERSION}; species ${PokemonSpecies.count()}"
|
||||
}
|
||||
|
||||
init {
|
||||
LOGGER.info("Cobblemon Survival companion {} loaded (Cobblemon {})", version, Cobblemon.VERSION)
|
||||
MOD_BUS.addListener(::onAddPackFinders)
|
||||
NeoForge.EVENT_BUS.addListener(::onRegisterCommands)
|
||||
NeoForge.EVENT_BUS.addListener(::onServerStarted)
|
||||
NeoForge.EVENT_BUS.addListener(::onPortalSpawn)
|
||||
NeoForge.EVENT_BUS.addListener(OnlyPokemon::onChunkLoad)
|
||||
NeoForge.EVENT_BUS.addListener(OnlyPokemon::onEntityJoin)
|
||||
Permadeath.register()
|
||||
ZoneCondition.register()
|
||||
NeoForge.EVENT_BUS.addListener(Zones::onAddReloadListeners)
|
||||
SelfTest.register()
|
||||
}
|
||||
|
||||
/** The island world type has no Nether, so a lit obsidian frame must not turn into a dead portal. */
|
||||
private fun onPortalSpawn(event: BlockEvent.PortalSpawnEvent) {
|
||||
val level = event.level as? ServerLevel ?: return
|
||||
if (level.server.getLevel(Level.NETHER) == null) {
|
||||
event.isCanceled = true
|
||||
}
|
||||
}
|
||||
|
||||
/** Ships datapacks/core from the repo as a built-in data pack that is always on, in every world. */
|
||||
private fun onAddPackFinders(event: AddPackFindersEvent) {
|
||||
event.addPackFinders(
|
||||
ResourceLocation.fromNamespaceAndPath(MOD_ID, "datapacks/core"),
|
||||
PackType.SERVER_DATA,
|
||||
Component.literal("Cobblemon Survival core data"),
|
||||
PackSource.BUILT_IN,
|
||||
true,
|
||||
Pack.Position.TOP,
|
||||
)
|
||||
}
|
||||
|
||||
private fun onRegisterCommands(event: RegisterCommandsEvent) {
|
||||
val root = Commands.literal(MOD_ID)
|
||||
.requires { it.hasPermission(2) }
|
||||
.then(Commands.literal("status").executes { context ->
|
||||
context.source.sendSuccess({ Component.literal(statusLine()) }, false)
|
||||
1
|
||||
})
|
||||
.then(BiomePreview.command())
|
||||
.then(Commands.literal("zone").executes { context ->
|
||||
val source = context.source
|
||||
val zone = Zones.at(source.level, BlockPos.containing(source.position))
|
||||
val text = zone?.let { "${it.name} (${it.id}), Lv ${it.minLevel}-${it.maxLevel}" } ?: "no zone"
|
||||
source.sendSuccess({ Component.literal(text) }, false)
|
||||
1
|
||||
})
|
||||
if (SelfTest.enabled) root.then(SelfTest.command())
|
||||
event.dispatcher.register(root)
|
||||
}
|
||||
|
||||
private fun onServerStarted(event: ServerStartedEvent) {
|
||||
LOGGER.info("Cobblemon Survival companion ready: {}", statusLine())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package dev.cobblesurvival.dev
|
||||
|
||||
import com.google.gson.GsonBuilder
|
||||
import com.google.gson.JsonArray
|
||||
import com.google.gson.JsonObject
|
||||
import com.mojang.brigadier.arguments.IntegerArgumentType
|
||||
import com.mojang.brigadier.arguments.StringArgumentType
|
||||
import com.mojang.brigadier.builder.LiteralArgumentBuilder
|
||||
import net.minecraft.commands.CommandSourceStack
|
||||
import net.minecraft.commands.Commands
|
||||
import net.minecraft.core.QuartPos
|
||||
import net.minecraft.core.registries.Registries
|
||||
import net.minecraft.network.chat.Component
|
||||
import net.minecraft.server.level.ServerLevel
|
||||
import net.minecraft.world.level.levelgen.Heightmap
|
||||
import net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator
|
||||
import net.minecraft.world.level.levelgen.RandomState
|
||||
import net.minecraft.world.level.levelgen.WorldOptions
|
||||
import net.minecraft.world.level.storage.LevelResource
|
||||
import java.nio.file.Files
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.stream.IntStream
|
||||
|
||||
/**
|
||||
* Dev tool: `/cobblesurvival biomepreview <seed|current> <radius> <step>` samples terrain height and
|
||||
* biomes of the overworld generator on a grid around 0,0 for any seed, without generating chunks,
|
||||
* and writes `<world>/cobblesurvival/biome_preview/<seed>.json` for tools/seed_search.py.
|
||||
*
|
||||
* WorldGen Editor's coastline noise always uses the open world's seed, so for another seed the
|
||||
* coastline is approximate; terrain and biomes follow the requested seed.
|
||||
*/
|
||||
object BiomePreview {
|
||||
/** Absolute y levels sampled for cave biomes. */
|
||||
private val CAVE_LEVELS = intArrayOf(40, 0, -30, -52)
|
||||
|
||||
fun command(): LiteralArgumentBuilder<CommandSourceStack> =
|
||||
Commands.literal("biomepreview")
|
||||
.requires { it.hasPermission(4) }
|
||||
.then(Commands.argument("seed", StringArgumentType.word())
|
||||
.then(Commands.argument("radius", IntegerArgumentType.integer(16, 8192))
|
||||
.then(Commands.argument("step", IntegerArgumentType.integer(4, 256))
|
||||
.executes { context ->
|
||||
val seed = StringArgumentType.getString(context, "seed")
|
||||
val radius = IntegerArgumentType.getInteger(context, "radius")
|
||||
val step = IntegerArgumentType.getInteger(context, "step")
|
||||
val started = System.currentTimeMillis()
|
||||
val path = run(context.source.server.overworld(), seed, radius, step)
|
||||
val seconds = (System.currentTimeMillis() - started) / 1000.0
|
||||
context.source.sendSuccess({ Component.literal("biome preview written: $path (${seconds}s)") }, false)
|
||||
1
|
||||
})))
|
||||
|
||||
private fun run(level: ServerLevel, seed: String, radius: Int, step: Int): String {
|
||||
val generator = level.chunkSource.generator
|
||||
val randomState = if (seed == "current") {
|
||||
level.chunkSource.randomState()
|
||||
} else {
|
||||
val settings = (generator as NoiseBasedChunkGenerator).generatorSettings().value()
|
||||
val noises = level.registryAccess().lookupOrThrow(Registries.NOISE)
|
||||
RandomState.create(settings, noises, parseSeed(seed))
|
||||
}
|
||||
val sampler = randomState.sampler()
|
||||
val biomeSource = generator.biomeSource
|
||||
|
||||
val names = ConcurrentHashMap<String, Int>()
|
||||
fun biomeAt(x: Int, y: Int, z: Int): Int {
|
||||
val holder = biomeSource.getNoiseBiome(QuartPos.fromBlock(x), QuartPos.fromBlock(y), QuartPos.fromBlock(z), sampler)
|
||||
val name = holder.unwrapKey().map { it.location().toString() }.orElse("unknown")
|
||||
return names[name] ?: synchronized(names) { names.getOrPut(name) { names.size } }
|
||||
}
|
||||
|
||||
// Rows run in parallel: noise sampling is thread-safe, as chunk generation relies on it too.
|
||||
val size = (2 * radius) / step + 1
|
||||
val height = IntArray(size * size)
|
||||
val surface = IntArray(size * size)
|
||||
val caves = CAVE_LEVELS.associateWith { IntArray(size * size) }
|
||||
IntStream.range(0, size).parallel().forEach { row ->
|
||||
val z = -radius + row * step
|
||||
for (col in 0 until size) {
|
||||
val x = -radius + col * step
|
||||
val i = row * size + col
|
||||
val top = generator.getBaseHeight(x, z, Heightmap.Types.OCEAN_FLOOR_WG, level, randomState)
|
||||
height[i] = top
|
||||
surface[i] = biomeAt(x, maxOf(top - 1, level.seaLevel - 1), z)
|
||||
for ((y, grid) in caves) grid[i] = biomeAt(x, y, z)
|
||||
}
|
||||
}
|
||||
|
||||
val out = JsonObject()
|
||||
out.addProperty("seed", seed)
|
||||
out.addProperty("radius", radius)
|
||||
out.addProperty("step", step)
|
||||
out.addProperty("size", size)
|
||||
out.addProperty("sea_level", level.seaLevel)
|
||||
out.add("biomes", JsonArray().apply { names.entries.sortedBy { it.value }.forEach { add(it.key) } })
|
||||
out.add("height", toJson(height))
|
||||
out.add("surface", toJson(surface))
|
||||
out.add("caves", JsonObject().apply { caves.forEach { (y, grid) -> add(y.toString(), toJson(grid)) } })
|
||||
|
||||
val dir = level.server.getWorldPath(LevelResource.ROOT).resolve("cobblesurvival/biome_preview")
|
||||
Files.createDirectories(dir)
|
||||
val file = dir.resolve("$seed.json")
|
||||
Files.writeString(file, GsonBuilder().create().toJson(out))
|
||||
return file.toString()
|
||||
}
|
||||
|
||||
private fun toJson(values: IntArray) = JsonArray(values.size).apply { values.forEach { add(it) } }
|
||||
|
||||
private fun parseSeed(seed: String): Long = WorldOptions.parseSeed(seed).orElse(seed.hashCode().toLong())
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package dev.cobblesurvival.permadeath
|
||||
|
||||
import com.cobblemon.mod.common.Cobblemon
|
||||
import com.cobblemon.mod.common.api.Priority
|
||||
import com.cobblemon.mod.common.api.events.CobblemonEvents
|
||||
import com.cobblemon.mod.common.api.events.battles.BattleEndedEvent
|
||||
import com.cobblemon.mod.common.api.events.battles.BattleFaintedEvent
|
||||
import com.cobblemon.mod.common.api.storage.party.PlayerPartyStore
|
||||
import com.cobblemon.mod.common.api.storage.pc.PCStore
|
||||
import com.cobblemon.mod.common.pokemon.Pokemon
|
||||
import com.cobblemon.mod.common.util.isInBattle
|
||||
import dev.cobblesurvival.CobbleSurvival
|
||||
import net.minecraft.ChatFormatting
|
||||
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.sounds.SoundEvents
|
||||
import net.minecraft.sounds.SoundSource
|
||||
import net.minecraft.tags.TagKey
|
||||
import net.minecraft.world.item.Item
|
||||
import net.neoforged.neoforge.common.NeoForge
|
||||
import net.neoforged.neoforge.event.tick.ServerTickEvent
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ConcurrentLinkedQueue
|
||||
|
||||
/**
|
||||
* A fainted Pokémon is gone for good. CobbleHardcoreMon settles faints in battles; this closes the gaps around it:
|
||||
* - a Pokémon that faints outside a battle (falling, lava, poison, ...) is settled at the end of the same server
|
||||
* tick. CobbleHardcoreMon only looks once a second, and waits a full minute for a Pokémon whose charm already
|
||||
* saved it once, which left time to heal it or move it to the PC.
|
||||
* - a Pokémon that fainted in a battle and was revived before the battle ended (a Revive, Revival Blessing, ...)
|
||||
* is fainted again when the battle ends, just before CobbleHardcoreMon settles the battle.
|
||||
* - once a second, every online player's party and PC are swept for fainted Pokémon (one whose trainer was offline
|
||||
* or in a battle when it fainted).
|
||||
* Settling: a Pokémon holding an item from `#cobblesurvival:revival_charms` is healed and the charm is used up;
|
||||
* any other is removed from its party or PC.
|
||||
*/
|
||||
object Permadeath {
|
||||
val REVIVAL_CHARMS: TagKey<Item> =
|
||||
TagKey.create(Registries.ITEM, ResourceLocation.fromNamespaceAndPath(CobbleSurvival.MOD_ID, "revival_charms"))
|
||||
private const val SWEEP_TICKS = 20
|
||||
|
||||
private val fainted = ConcurrentLinkedQueue<Pokemon>()
|
||||
private val faintedInBattle = HashMap<UUID, MutableSet<Pokemon>>()
|
||||
|
||||
fun register() {
|
||||
CobblemonEvents.POKEMON_FAINTED.subscribe(Priority.NORMAL) { fainted += it.pokemon }
|
||||
CobblemonEvents.BATTLE_FAINTED.subscribe(Priority.NORMAL, ::onBattleFainted)
|
||||
// CobbleHardcoreMon settles the battle at NORMAL priority; it has to see the revived Pokémon fainted.
|
||||
CobblemonEvents.BATTLE_ENDED.subscribe(Priority.HIGHEST, ::onBattleEnded)
|
||||
NeoForge.EVENT_BUS.addListener(::onServerTick)
|
||||
}
|
||||
|
||||
private fun onBattleFainted(event: BattleFaintedEvent) {
|
||||
val killed = event.killed
|
||||
// In a battle on copies (exhibition rules) the real Pokémon is not hurt, so it does not count.
|
||||
if (killed.effectedPokemon !== killed.originalPokemon || !killed.originalPokemon.isPlayerOwned()) return
|
||||
faintedInBattle.getOrPut(event.battle.battleId) { HashSet() } += killed.originalPokemon
|
||||
}
|
||||
|
||||
private fun onBattleEnded(event: BattleEndedEvent) {
|
||||
val faintedHere = faintedInBattle.remove(event.battle.battleId) ?: return
|
||||
for (pokemon in faintedHere) {
|
||||
if (!pokemon.isFainted() && pokemon.storeCoordinates.get() != null) {
|
||||
pokemon.currentHealth = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onServerTick(event: ServerTickEvent.Post) {
|
||||
while (true) {
|
||||
val pokemon = fainted.poll() ?: break
|
||||
val owner = pokemon.getOwnerPlayer() ?: continue
|
||||
settle(owner, pokemon)
|
||||
}
|
||||
if (event.server.tickCount % SWEEP_TICKS == 0) {
|
||||
for (player in event.server.playerList.players) {
|
||||
val party = Cobblemon.storage.getParty(player)
|
||||
val pc = Cobblemon.storage.getPC(player)
|
||||
(party.filter { it.isFainted() } + pc.filter { it.isFainted() }).forEach { settle(player, it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun settle(player: ServerPlayer, pokemon: Pokemon) {
|
||||
val store = pokemon.storeCoordinates.get()?.store
|
||||
if (!pokemon.isFainted() || (store !is PlayerPartyStore && store !is PCStore)) return
|
||||
// Faints during a battle are settled when it ends (above, and by CobbleHardcoreMon).
|
||||
if (player.isInBattle()) return
|
||||
try {
|
||||
val name = pokemon.getDisplayName(false)
|
||||
if (pokemon.heldItem().`is`(REVIVAL_CHARMS)) {
|
||||
pokemon.heal()
|
||||
pokemon.removeHeldItem()
|
||||
player.sendSystemMessage(
|
||||
Component.translatableWithFallback(
|
||||
"message.cobblesurvival.permadeath.saved",
|
||||
"%s's Revival Charm saved it and crumbled to dust.", name,
|
||||
).withStyle(ChatFormatting.GOLD)
|
||||
)
|
||||
player.playNotifySound(SoundEvents.RESPAWN_ANCHOR_DEPLETE.value(), SoundSource.PLAYERS, 1f, 1f)
|
||||
CobbleSurvival.LOGGER.info("Permadeath: a Revival Charm saved {} of {}", name.string, player.gameProfile.name)
|
||||
} else if (store?.remove(pokemon) == true) {
|
||||
player.sendSystemMessage(
|
||||
Component.translatableWithFallback(
|
||||
"message.cobblesurvival.permadeath.lost",
|
||||
"%s fainted and is gone for good.", name,
|
||||
).withStyle(ChatFormatting.RED)
|
||||
)
|
||||
CobbleSurvival.LOGGER.info("Permadeath: {} of {} fainted and was removed", name.string, player.gameProfile.name)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
CobbleSurvival.LOGGER.error("Permadeath: could not settle a fainted Pokémon of {}", player.gameProfile.name, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package dev.cobblesurvival.world
|
||||
|
||||
import dev.cobblesurvival.CobbleSurvival
|
||||
import net.minecraft.core.BlockPos
|
||||
import net.minecraft.core.registries.BuiltInRegistries
|
||||
import net.minecraft.core.registries.Registries
|
||||
import net.minecraft.resources.ResourceLocation
|
||||
import net.minecraft.server.level.ServerLevel
|
||||
import net.minecraft.tags.TagKey
|
||||
import net.minecraft.world.entity.EntityType
|
||||
import net.minecraft.world.entity.Mob
|
||||
import net.minecraft.world.level.block.Block
|
||||
import net.minecraft.world.level.block.Blocks
|
||||
import net.minecraft.world.level.block.InfestedBlock
|
||||
import net.minecraft.world.level.block.SculkShriekerBlock
|
||||
import net.minecraft.world.level.chunk.LevelChunk
|
||||
import net.neoforged.neoforge.event.entity.EntityJoinLevelEvent
|
||||
import net.neoforged.neoforge.event.level.ChunkEvent
|
||||
|
||||
/**
|
||||
* Only Pokémon live on the island. Structures stay, but their vanilla mobs and mob sources go:
|
||||
* - a mob that isn't Cobblemon's (Pokémon, trainers) never joins a world unless it is loaded from a save.
|
||||
* That covers mobs placed by structures during world generation (villagers, guardians, shulkers, ...),
|
||||
* other mods' animals (Cold Sweat's chameleons) and anything that would spawn later. Exceptions: the
|
||||
* entity type tag `cobblesurvival:allowed_vanilla_mobs`.
|
||||
* - when a chunk has just been generated, blocks in the tag `cobblesurvival:removed_on_generation`
|
||||
* (spawners, trial spawners) become air, sculk shriekers can no longer summon the Warden, and
|
||||
* infested blocks (silverfish) become their normal block. Chunks loaded from disk are never touched.
|
||||
*/
|
||||
object OnlyPokemon {
|
||||
private val ALLOWED_MOBS: TagKey<EntityType<*>> =
|
||||
TagKey.create(Registries.ENTITY_TYPE, ResourceLocation.fromNamespaceAndPath(CobbleSurvival.MOD_ID, "allowed_vanilla_mobs"))
|
||||
private val REMOVED: TagKey<Block> =
|
||||
TagKey.create(Registries.BLOCK, ResourceLocation.fromNamespaceAndPath(CobbleSurvival.MOD_ID, "removed_on_generation"))
|
||||
private const val FLAGS = Block.UPDATE_CLIENTS or Block.UPDATE_KNOWN_SHAPE
|
||||
|
||||
fun onEntityJoin(event: EntityJoinLevelEvent) {
|
||||
val mob = event.entity as? Mob ?: return
|
||||
if (event.level !is ServerLevel || event.loadedFromDisk()) return
|
||||
if (BuiltInRegistries.ENTITY_TYPE.getKey(mob.type).namespace == "cobblemon") return
|
||||
if (mob.type.`is`(ALLOWED_MOBS)) return
|
||||
event.isCanceled = true
|
||||
}
|
||||
|
||||
fun onChunkLoad(event: ChunkEvent.Load) {
|
||||
if (!event.isNewChunk) return
|
||||
val level = event.level as? ServerLevel ?: return
|
||||
val chunk = event.chunk as? LevelChunk ?: return
|
||||
|
||||
for (pos in chunk.blockEntitiesPos.toList()) {
|
||||
val state = chunk.getBlockState(pos)
|
||||
if (state.`is`(REMOVED)) {
|
||||
// A fresh chunk still holds block entities as pending data. Create it first so that
|
||||
// replacing the block removes it; otherwise the game later tries to load a spawner
|
||||
// block entity on an air block and logs an error.
|
||||
chunk.getBlockEntity(pos, LevelChunk.EntityCreationType.IMMEDIATE)
|
||||
level.setBlock(pos, Blocks.AIR.defaultBlockState(), FLAGS)
|
||||
} else if (state.`is`(Blocks.SCULK_SHRIEKER) && state.getValue(SculkShriekerBlock.CAN_SUMMON)) {
|
||||
level.setBlock(pos, state.setValue(SculkShriekerBlock.CAN_SUMMON, false), FLAGS)
|
||||
}
|
||||
}
|
||||
|
||||
// Infested blocks have no block entity; the palette check skips sections without any.
|
||||
chunk.sections.forEachIndexed { index, section ->
|
||||
if (section.hasOnlyAir() || !section.states.maybeHas { it.block is InfestedBlock }) return@forEachIndexed
|
||||
val baseY = chunk.getSectionYFromSectionIndex(index) shl 4
|
||||
for (y in 0 until 16) for (z in 0 until 16) for (x in 0 until 16) {
|
||||
val state = section.getBlockState(x, y, z)
|
||||
val block = state.block
|
||||
if (block is InfestedBlock) {
|
||||
val pos = BlockPos(chunk.pos.minBlockX + x, baseY + y, chunk.pos.minBlockZ + z)
|
||||
level.setBlock(pos, block.hostStateByInfested(state), FLAGS)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package dev.cobblesurvival.zone
|
||||
|
||||
import com.cobblemon.mod.common.api.spawning.condition.AppendageCondition
|
||||
import com.cobblemon.mod.common.api.spawning.position.SpawnablePosition
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.JsonElement
|
||||
import com.google.gson.JsonObject
|
||||
import dev.cobblesurvival.CobbleSurvival
|
||||
import net.minecraft.core.BlockPos
|
||||
import net.minecraft.core.Holder
|
||||
import net.minecraft.resources.ResourceLocation
|
||||
import net.minecraft.server.packs.resources.ResourceManager
|
||||
import net.minecraft.server.packs.resources.SimpleJsonResourceReloadListener
|
||||
import net.minecraft.util.profiling.ProfilerFiller
|
||||
import net.minecraft.world.level.Level
|
||||
import net.minecraft.world.level.biome.Biome
|
||||
import net.neoforged.neoforge.event.AddReloadListenerEvent
|
||||
|
||||
/** One zone of the island (world/zones.json). */
|
||||
data class Zone(val id: String, val name: String, val minLevel: Int, val maxLevel: Int)
|
||||
|
||||
/** A point deep inside a zone (y only for cave zones), used by the spawn survey. */
|
||||
data class ZoneSample(val x: Int, val y: Int?, val z: Int)
|
||||
|
||||
/**
|
||||
* Which zone a position of the overworld belongs to. The layout comes from the data file
|
||||
* `data/cobblesurvival/zones/island.json`, which tools/zone_layout.py writes from world/zones.json:
|
||||
* a grid of surface zones over the island, plus cave zones that follow their cave biome everywhere.
|
||||
*/
|
||||
object Zones : SimpleJsonResourceReloadListener(Gson(), "zones") {
|
||||
private val LAYOUT = ResourceLocation.fromNamespaceAndPath(CobbleSurvival.MOD_ID, "island")
|
||||
|
||||
private var byIndex: List<Zone> = emptyList()
|
||||
var byId: Map<String, Zone> = emptyMap()
|
||||
private set
|
||||
private var caves: Map<ResourceLocation, Zone> = emptyMap()
|
||||
var samples: Map<String, ZoneSample> = emptyMap()
|
||||
private set
|
||||
/** For zones made of biomes: a point inside their own hazard biome (Frostpeak's jagged peaks, ...). */
|
||||
var biomeSamples: Map<String, ZoneSample> = emptyMap()
|
||||
private set
|
||||
private var grid = IntArray(0)
|
||||
private var radius = 0
|
||||
private var step = 1
|
||||
private var size = 0
|
||||
|
||||
fun onAddReloadListeners(event: AddReloadListenerEvent) = event.addListener(this)
|
||||
|
||||
override fun apply(files: Map<ResourceLocation, JsonElement>, resources: ResourceManager, profiler: ProfilerFiller) {
|
||||
val json = files[LAYOUT]?.asJsonObject
|
||||
if (json == null) {
|
||||
CobbleSurvival.LOGGER.warn("No zone layout ({}); no position belongs to a zone", LAYOUT)
|
||||
byIndex = emptyList(); byId = emptyMap(); caves = emptyMap(); samples = emptyMap(); biomeSamples = emptyMap()
|
||||
grid = IntArray(0); size = 0
|
||||
return
|
||||
}
|
||||
byIndex = json.getAsJsonArray("zones").map { zone(it.asJsonObject) }
|
||||
val caveZones = json.getAsJsonArray("caves").map { it.asJsonObject }
|
||||
caves = caveZones.associate { ResourceLocation.parse(it.get("biome").asString) to zone(it) }
|
||||
byId = (byIndex + caves.values).associateBy { it.id }
|
||||
samples = samplesOf(json, "samples")
|
||||
biomeSamples = samplesOf(json, "biome_samples")
|
||||
radius = json.get("radius").asInt
|
||||
step = json.get("step").asInt
|
||||
size = json.get("size").asInt
|
||||
// Run-length encoded rows: [zone index or -1, count], ...
|
||||
val cells = ArrayList<Int>(size * size)
|
||||
for (run in json.getAsJsonArray("grid_rle")) {
|
||||
val pair = run.asJsonArray
|
||||
repeat(pair[1].asInt) { cells += pair[0].asInt }
|
||||
}
|
||||
check(cells.size == size * size) { "zone grid has ${cells.size} cells, expected ${size * size}" }
|
||||
grid = cells.toIntArray()
|
||||
CobbleSurvival.LOGGER.info("Loaded {} zones ({} surface, {} cave)", byId.size, byIndex.size, caves.size)
|
||||
}
|
||||
|
||||
private fun samplesOf(json: JsonObject, key: String): Map<String, ZoneSample> =
|
||||
json.getAsJsonObject(key)?.entrySet()?.associate { (id, point) ->
|
||||
val xyz = point.asJsonArray
|
||||
id to ZoneSample(xyz[0].asInt, xyz[1].takeUnless { it.isJsonNull }?.asInt, xyz[2].asInt)
|
||||
} ?: emptyMap()
|
||||
|
||||
private fun zone(json: JsonObject): Zone {
|
||||
val levels = json.getAsJsonArray("levels")
|
||||
return Zone(json.get("id").asString, json.get("name").asString, levels[0].asInt, levels[1].asInt)
|
||||
}
|
||||
|
||||
/** The zone at [pos], or null outside the island's zones or outside the overworld. */
|
||||
fun at(level: Level, pos: BlockPos, biome: Holder<Biome> = level.getBiome(pos)): Zone? {
|
||||
if (level.dimension() != Level.OVERWORLD || size == 0) return null
|
||||
biome.unwrapKey().orElse(null)?.let { caves[it.location()] }?.let { return it }
|
||||
// Grid cells are centred on -radius + i * step.
|
||||
val col = Math.floorDiv(pos.x + radius + step / 2, step)
|
||||
val row = Math.floorDiv(pos.z + radius + step / 2, step)
|
||||
if (col !in 0 until size || row !in 0 until size) return null
|
||||
return byIndex.getOrNull(grid[row * size + col])
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn condition field `"zone": "<id>"` or `"zones": ["<id>", ...]` for Cobblemon spawn files: the spawn only
|
||||
* happens inside those zones. Registered for every kind of spawning condition.
|
||||
*/
|
||||
class ZoneCondition : AppendageCondition {
|
||||
var zone: String? = null
|
||||
var zones: List<String>? = null
|
||||
|
||||
override fun fits(spawnablePosition: SpawnablePosition): Boolean {
|
||||
val wanted = zones ?: zone?.let(::listOf) ?: return true
|
||||
val here = Zones.at(spawnablePosition.world, spawnablePosition.position, spawnablePosition.biomeHolder)
|
||||
return here != null && here.id in wanted
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun register() = AppendageCondition.registerAppendage(ZoneCondition::class.java) { true }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
modLoader = "kotlinforforge"
|
||||
loaderVersion = "[${kotlinforforge_version},)"
|
||||
license = "MIT"
|
||||
|
||||
[[mods]]
|
||||
modId = "${mod_id}"
|
||||
version = "${version}"
|
||||
displayName = "Cobblemon Survival Pack Companion"
|
||||
authors = "Adrian"
|
||||
description = '''
|
||||
Mechanics for the Cobblemon Survival Pack that no existing addon covers.
|
||||
Not affiliated with Cobblemon, The Pokémon Company, Nintendo, Game Freak or Creatures Inc.
|
||||
'''
|
||||
|
||||
[[dependencies.${mod_id}]]
|
||||
modId = "minecraft"
|
||||
type = "required"
|
||||
versionRange = "[1.21.1]"
|
||||
ordering = "NONE"
|
||||
side = "BOTH"
|
||||
|
||||
[[dependencies.${mod_id}]]
|
||||
modId = "neoforge"
|
||||
type = "required"
|
||||
versionRange = "[${neoforge_version},)"
|
||||
ordering = "NONE"
|
||||
side = "BOTH"
|
||||
|
||||
[[dependencies.${mod_id}]]
|
||||
modId = "cobblemon"
|
||||
type = "required"
|
||||
versionRange = "[${cobblemon_version},1.9)"
|
||||
ordering = "AFTER"
|
||||
side = "BOTH"
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"generator.cobblesurvival.island": "Cobblemon Survival Island",
|
||||
"message.cobblesurvival.permadeath.lost": "%s fainted and is gone for good.",
|
||||
"message.cobblesurvival.permadeath.saved": "%s's Revival Charm saved it and crumbled to dust."
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"pack": {
|
||||
"description": "Cobblemon Survival Pack Companion resources",
|
||||
"pack_format": 34
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user