Phase 3: Alpha guardians, one per zone, back 5 hours after a defeat
CI / build-and-smoke-test (push) Failing after 7m6s

- Companion mod (zone/Guardians.kt): each zone's guardian stands at the
  zone's sample point, appears when a player is within 96 blocks, stays
  within 24 blocks, is uncatchable and half again as big. Beating it in a
  battle gives every player in the battle its drops; it returns after 5
  hours of game time (Adrian: fixed spots, 5 hours). State lives in the
  world save; /cobblesurvival guardians lists it.
- tools/zone_spawns.py writes data/cobblesurvival/guardians/<zone>.json:
  the strongest walking (or swimming) Pokemon of the zone at band top + 5,
  with the drops from world/zones.json. Boss drops follow Adrian's rule:
  Icy Rocks before Sunscorch, Heat Rocks before Frostpeak.
- Goat Fur and Hoglin Hide recipes make 8 per rock, so one guardian's 3
  rocks pay for one full insulating set.
- Self-test guardian_appears_and_returns; hazard tests cleaned up; the
  zone generator reads Cobblemon's own entries from the field guide's
  hidden_spawns.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
claudeandClaude Opus 5.5 committed 2026-10-29 19:01:21 +01:00
1 parent 398301f0be
commit 73fe71918f
25 files changed
+607 -23

No files matched your search

@@ -7,6 +7,7 @@ import dev.cobblesurvival.dev.BiomePreview
import dev.cobblesurvival.permadeath.Permadeath
import dev.cobblesurvival.selftest.SelfTest
import dev.cobblesurvival.world.OnlyPokemon
import dev.cobblesurvival.zone.Guardians
import dev.cobblesurvival.zone.ZoneCondition
import dev.cobblesurvival.zone.Zones
import net.minecraft.commands.Commands
@@ -55,6 +56,7 @@ class CobbleSurvival {
Permadeath.register()
ZoneCondition.register()
NeoForge.EVENT_BUS.addListener(Zones::onAddReloadListeners)
Guardians.register()
SelfTest.register()
}
@@ -93,6 +95,11 @@ class CobbleSurvival {
source.sendSuccess({ Component.literal(text) }, false)
1
})
.then(Commands.literal("guardians").executes { context ->
val lines = Guardians.status(context.source.server.overworld())
context.source.sendSuccess({ Component.literal(lines.joinToString("\n").ifEmpty { "no guardians loaded" }) }, false)
1
})
if (SelfTest.enabled) root.then(SelfTest.command())
event.dispatcher.register(root)
}
@@ -0,0 +1,75 @@
package dev.cobblesurvival.selftest
import com.cobblemon.mod.common.battles.BattleBuilder
import com.cobblemon.mod.common.battles.MoveActionResponse
import com.cobblemon.mod.common.entity.pokemon.PokemonEntity
import com.cobblemon.mod.common.util.isInBattle
import dev.cobblesurvival.zone.Guardians
import dev.cobblesurvival.zone.Zones
import net.minecraft.core.registries.BuiltInRegistries
import net.minecraft.gametest.framework.GameTestAssertException
import net.minecraft.world.level.levelgen.Heightmap
import kotlin.math.abs
/**
* Roadmap Phase 3, Alpha guardians (Adrian: fixed spots, back 5 hours after being beaten). On Button Isle's spot:
* a player arriving makes the guardian appear, big and uncatchable at its level; beating it in a battle gives the
* player its drops; it stays away, and returns once its time is up. Runs in a batch of its own, since the guardian
* state is shared.
*/
object GuardianTests {
private const val ZONE = "button_isle"
fun all() = listOf(SelfTestCase("guardian_appears_and_returns", 2400, ::guardian, batch = "guardian"))
private fun guardian(s: Scenario) {
val definition = Guardians.definitions[ZONE] ?: throw GameTestAssertException("no guardian defined for $ZONE")
val sample = Zones.samples[ZONE] ?: throw GameTestAssertException("no sample point for $ZONE")
val level = s.level
Guardians.resetForTest(level, ZONE)
s.afterTest { Guardians.resetForTest(level, ZONE) }
val player = s.player()
s.give(player, s.pokemon("mewtwo", 100, "earthquake")) // a spread move needs no target
s.answerBattles(player) { _, _ -> MoveActionResponse("earthquake") }
lateinit var guardian: PokemonEntity
val expectedLevel = Regex("level=(\\d+)").find(definition.pokemon)?.groupValues?.get(1)?.toInt()
s.helper.startSequence()
.thenExecute {
level.getChunk(sample.x shr 4, sample.z shr 4)
val y = level.getHeight(Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, sample.x, sample.z)
s.moveTo(player, sample.x + 6.5, y.toDouble(), sample.z + 0.5)
}
.thenWaitUntil {
guardian = Guardians.guardianOf(level, ZONE) ?: throw GameTestAssertException("no guardian appeared near the player")
}
.thenExecute {
val pokemon = guardian.pokemon
s.check(pokemon.isUncatchable()) { "the guardian can be caught" }
s.check(abs(pokemon.scaleModifier - definition.scale) < 0.01f) { "the guardian is size ${pokemon.scaleModifier}, not ${definition.scale}" }
s.check(expectedLevel == null || pokemon.level == expectedLevel) { "the guardian is level ${pokemon.level}, not $expectedLevel" }
s.moveTo(player, guardian.x + 3, guardian.y, guardian.z)
}
.thenWaitUntil { s.check(guardian.pokemon.entity === guardian) { "the guardian has not ticked yet" } }
.thenExecute { s.started(BattleBuilder.pve(player, guardian)) }
.thenWaitUntil {
s.check(!player.isInBattle() && Guardians.defeatedAt(level, ZONE) != null) { "the guardian isn't beaten yet: ${s.battleState(player)}" }
}
.thenExecute {
for ((item, count) in definition.drops) {
val have = player.inventory.countItem(BuiltInRegistries.ITEM.get(item))
s.check(have >= count) { "the player got $have of $item from the guardian, not $count" }
}
}
.thenIdle(100)
.thenExecute {
s.check(Guardians.guardianOf(level, ZONE) == null) { "the guardian came back right away" }
Guardians.expireDefeatForTest(level, ZONE)
}
.thenWaitUntil {
s.check(Guardians.guardianOf(level, ZONE) != null) { "the guardian didn't come back after its time" }
}
.thenSucceed()
}
}
@@ -48,7 +48,7 @@ object SelfTest {
private const val FORCED_CHUNKS = 9
private val cases: List<SelfTestCase> get() =
PermadeathTests.all() + SurvivalRulesTests.all() + ZoneTests.all() + HazardTests.all()
PermadeathTests.all() + SurvivalRulesTests.all() + ZoneTests.all() + HazardTests.all() + GuardianTests.all()
private var running: List<Scenario> = emptyList()
private var forced: List<ChunkPos> = emptyList()
@@ -0,0 +1,263 @@
package dev.cobblesurvival.zone
import com.cobblemon.mod.common.api.Priority
import com.cobblemon.mod.common.api.events.CobblemonEvents
import com.cobblemon.mod.common.api.events.battles.BattleFaintedEvent
import com.cobblemon.mod.common.api.pokemon.PokemonProperties
import com.cobblemon.mod.common.entity.pokemon.PokemonEntity
import com.google.gson.Gson
import com.google.gson.JsonElement
import dev.cobblesurvival.CobbleSurvival
import net.minecraft.ChatFormatting
import net.minecraft.core.BlockPos
import net.minecraft.core.HolderLookup
import net.minecraft.core.SectionPos
import net.minecraft.core.registries.BuiltInRegistries
import net.minecraft.nbt.CompoundTag
import net.minecraft.nbt.NbtUtils
import net.minecraft.network.chat.Component
import net.minecraft.resources.ResourceLocation
import net.minecraft.server.level.ServerLevel
import net.minecraft.server.packs.resources.ResourceManager
import net.minecraft.server.packs.resources.SimpleJsonResourceReloadListener
import net.minecraft.util.profiling.ProfilerFiller
import net.minecraft.world.item.ItemStack
import net.minecraft.world.level.ChunkPos
import net.minecraft.world.level.levelgen.Heightmap
import net.minecraft.world.level.saveddata.SavedData
import net.neoforged.neoforge.common.NeoForge
import net.neoforged.neoforge.event.AddReloadListenerEvent
import net.neoforged.neoforge.event.entity.EntityJoinLevelEvent
import net.neoforged.neoforge.event.tick.ServerTickEvent
import java.util.UUID
import kotlin.math.abs
/**
* One Alpha guardian per zone (roadmap Phase 3): a big, uncatchable Pokémon standing at the zone's sample point.
* Beating it in a battle gives every player in that battle its drops, which help with the next zone; it returns
* `respawn_hours` of game time later (Adrian: fixed spots, 5 hours). It only appears while a player is near, and
* wanders no further than [LEASH] blocks from its spot. Definitions: `data/cobblesurvival/guardians/<zone>.json`,
* written by tools/zone_spawns.py; state (which entity, when beaten, where it stands) lives in the world save.
*/
object Guardians : SimpleJsonResourceReloadListener(Gson(), "guardians") {
/** Key in the guardian Pokémon's persistent data; its value is the zone id. */
const val TAG = "cobblesurvival_guardian"
private const val CHECK_TICKS = 40
private const val NEAR = 96.0
private const val LEASH = 24.0
private const val TICKS_PER_HOUR = 72_000L
class Definition(
val zone: String,
val pokemon: String,
val scale: Float,
val drops: List<Pair<ResourceLocation, Int>>,
val respawnTicks: Long,
)
var definitions: Map<String, Definition> = emptyMap()
private set
fun register() {
NeoForge.EVENT_BUS.addListener<AddReloadListenerEvent> { it.addListener(this) }
NeoForge.EVENT_BUS.addListener(::onServerTick)
NeoForge.EVENT_BUS.addListener(::onEntityJoin)
CobblemonEvents.BATTLE_FAINTED.subscribe(Priority.NORMAL, ::onFainted)
}
override fun apply(files: Map<ResourceLocation, JsonElement>, resources: ResourceManager, profiler: ProfilerFiller) {
definitions = files.values.map { it.asJsonObject }.associate { json ->
val zone = json.get("zone").asString
zone to Definition(
zone = zone,
pokemon = json.get("pokemon").asString,
scale = json.get("scale")?.asFloat ?: 1f,
drops = json.getAsJsonArray("drops").map { drop ->
val d = drop.asJsonObject
ResourceLocation.parse(d.get("item").asString) to d.get("count").asInt
},
respawnTicks = (json.get("respawn_hours").asDouble * TICKS_PER_HOUR).toLong(),
)
}
CobbleSurvival.LOGGER.info("Loaded {} zone guardians", definitions.size)
}
private fun onServerTick(event: ServerTickEvent.Post) {
val server = event.server
if (server.tickCount % CHECK_TICKS != 0 || definitions.isEmpty()) return
val level = server.overworld()
val state = GuardianState.of(level)
for ((zone, definition) in definitions) {
val sample = Zones.samples[zone] ?: continue
val entry = state.entry(zone)
val guardian = entry.entity?.let { level.getEntity(it) as? PokemonEntity }?.takeIf { it.isAlive }
if (guardian != null) {
entry.spot?.let { leash(guardian, it) }
continue
}
val beaten = entry.defeatedAt
if (beaten != null && level.gameTime - beaten < definition.respawnTicks) continue
if (level.players().none { abs(it.x - sample.x) < NEAR && abs(it.z - sample.z) < NEAR }) continue
// The old guardian may still be loading from disk with its chunk; wait for that.
val chunk = ChunkPos(SectionPos.blockToSectionCoord(sample.x), SectionPos.blockToSectionCoord(sample.z))
if (!level.areEntitiesLoaded(chunk.toLong())) continue
val spot = entry.spot ?: spotFor(level, zone, sample)?.also { entry.spot = it } ?: continue
val spawned = spawn(level, definition, spot) ?: continue
entry.entity = spawned.uuid
}
}
private fun spawn(level: ServerLevel, definition: Definition, spot: BlockPos): PokemonEntity? {
val pokemon = PokemonProperties.parse(definition.pokemon).create()
pokemon.scaleModifier = definition.scale
pokemon.persistentData.putString(TAG, definition.zone)
val entity = PokemonEntity(level, pokemon)
entity.moveTo(spot.x + 0.5, spot.y.toDouble(), spot.z + 0.5, level.random.nextFloat() * 360f, 0f)
entity.setPersistenceRequired()
if (!level.addFreshEntity(entity)) return null
CobbleSurvival.LOGGER.info("Guardian of {} appeared at {}", definition.zone, spot.toShortString())
return entity
}
private fun leash(guardian: PokemonEntity, spot: BlockPos) {
if (guardian.isBattling) return
if (guardian.distanceToSqr(spot.x + 0.5, spot.y.toDouble(), spot.z + 0.5) > LEASH * LEASH) {
guardian.teleportTo(spot.x + 0.5, spot.y.toDouble(), spot.z + 0.5)
}
}
/** Where the guardian stands: the surface at the sample point, or for a cave zone open floor near it. */
private fun spotFor(level: ServerLevel, zone: String, sample: ZoneSample): BlockPos? {
val caveY = sample.y ?: return BlockPos(sample.x, level.getHeight(Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, sample.x, sample.z), sample.z)
for (radius in 0..16 step 2) for (dx in -radius..radius) for (dz in -radius..radius) {
if (maxOf(abs(dx), abs(dz)) != radius) continue
for (dy in 0..24) for (sign in intArrayOf(1, -1)) {
val pos = BlockPos(sample.x + dx, caveY + sign * dy, sample.z + dz)
val open = (0 until 3).all { level.getBlockState(pos.above(it)).isAir }
if (open && level.getBlockState(pos.below()).isSolid && Zones.at(level, pos)?.id == zone) return pos
}
}
return null
}
/** A second copy of a zone's guardian (e.g. one that wandered into a chunk loading late) never joins. */
private fun onEntityJoin(event: EntityJoinLevelEvent) {
val entity = event.entity as? PokemonEntity ?: return
val level = event.level as? ServerLevel ?: return
val zone = entity.pokemon.persistentData.getString(TAG).takeIf { it.isNotEmpty() } ?: return
val entry = GuardianState.of(level.server.overworld()).entry(zone)
val current = entry.entity
if (current == null || current == entity.uuid) {
entry.entity = entity.uuid
return
}
if ((level.getEntity(current) as? PokemonEntity)?.isAlive == true) event.isCanceled = true else entry.entity = entity.uuid
}
private fun onFainted(event: BattleFaintedEvent) {
val pokemon = event.killed.effectedPokemon
val zone = pokemon.persistentData.getString(TAG).takeIf { it.isNotEmpty() } ?: return
val definition = definitions[zone] ?: return
val players = event.battle.players
val level = players.firstOrNull()?.server?.overworld() ?: return
val state = GuardianState.of(level)
state.entry(zone).apply {
defeatedAt = level.gameTime
entity = null
}
val zoneName = Zones.byId[zone]?.name ?: zone
val hours = definition.respawnTicks / TICKS_PER_HOUR
for (player in players) {
for ((item, count) in definition.drops) {
val stack = ItemStack(BuiltInRegistries.ITEM.get(item), count)
if (!player.inventory.add(stack)) player.drop(stack, false)
}
player.sendSystemMessage(
Component.translatableWithFallback(
"message.cobblesurvival.guardian.beaten",
"The guardian of %s is beaten! It returns in %s hours.", zoneName, hours,
).withStyle(ChatFormatting.GOLD)
)
}
CobbleSurvival.LOGGER.info("Guardian of {} beaten by {}", zone, players.joinToString { it.gameProfile.name })
}
/** One line per zone for `/cobblesurvival guardians`. */
fun status(level: ServerLevel): List<String> {
val state = GuardianState.of(level)
return definitions.keys.sorted().map { zone ->
val entry = state.entry(zone)
val alive = entry.entity?.let { level.getEntity(it) }?.isAlive == true
val beaten = entry.defeatedAt
val text = when {
alive -> "here"
beaten != null && level.gameTime - beaten < definitions.getValue(zone).respawnTicks -> {
val left = definitions.getValue(zone).respawnTicks - (level.gameTime - beaten)
"beaten, back in %dh %02dm".format(left / TICKS_PER_HOUR, left % TICKS_PER_HOUR / 1200)
}
else -> "waiting for a player to come near"
}
"$zone: $text" + (entry.spot?.let { " at ${it.toShortString()}" } ?: "")
}
}
/** For the self-tests: forget the zone's guardian (despawning it) or pretend it was beaten long enough ago. */
fun resetForTest(level: ServerLevel, zone: String) {
val entry = GuardianState.of(level).entry(zone)
entry.entity?.let { level.getEntity(it) }?.discard()
entry.entity = null
entry.defeatedAt = null
}
fun expireDefeatForTest(level: ServerLevel, zone: String) {
val entry = GuardianState.of(level).entry(zone)
entry.defeatedAt = entry.defeatedAt?.minus(definitions.getValue(zone).respawnTicks)
}
fun guardianOf(level: ServerLevel, zone: String): PokemonEntity? =
GuardianState.of(level).entry(zone).entity?.let { level.getEntity(it) as? PokemonEntity }?.takeIf { it.isAlive }
fun defeatedAt(level: ServerLevel, zone: String): Long? = GuardianState.of(level).entry(zone).defeatedAt
}
/** Per zone: the guardian entity, when it was last beaten (game time), and its spot. Saved with the overworld. */
class GuardianState : SavedData() {
class Entry(var entity: UUID? = null, var defeatedAt: Long? = null, var spot: BlockPos? = null)
private val entries = HashMap<String, Entry>()
fun entry(zone: String): Entry = entries.getOrPut(zone) { Entry() }
override fun save(tag: CompoundTag, registries: HolderLookup.Provider): CompoundTag {
for ((zone, entry) in entries) {
val e = CompoundTag()
entry.entity?.let { e.putUUID("entity", it) }
entry.defeatedAt?.let { e.putLong("defeated_at", it) }
entry.spot?.let { e.put("spot", NbtUtils.writeBlockPos(it)) }
tag.put(zone, e)
}
return tag
}
// Changes to entries happen in place; mark dirty generously so they get saved.
override fun isDirty() = true
companion object {
private val FACTORY = Factory(::GuardianState, ::load)
fun of(level: ServerLevel): GuardianState = level.dataStorage.computeIfAbsent(FACTORY, "cobblesurvival_guardians")
private fun load(tag: CompoundTag, registries: HolderLookup.Provider): GuardianState {
val state = GuardianState()
for (zone in tag.allKeys) {
val e = tag.getCompound(zone)
state.entries[zone] = Entry(
entity = if (e.hasUUID("entity")) e.getUUID("entity") else null,
defeatedAt = if (e.contains("defeated_at")) e.getLong("defeated_at") else null,
spot = NbtUtils.readBlockPos(e, "spot").orElse(null),
)
}
return state
}
}
}