Phase 3: Alpha guardians, one per zone, back 5 hours after a defeat
CI / build-and-smoke-test (push) Failing after 7m6s
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:
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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user