Phase 5: soft gates instead of guardian flags, shoulder passives

Adrian (2026-10-31): HMs abilities stay open; the ones that get around
zone hazards get soft gates (materials or a high-level Pokemon, never
flags; gifted Pokemon and materials count the same); party passives from
shoulder Pokemon; Cobblebase skills and the Power Block move to soft
gates too.

- Gates: a zone gate passes at the top of the zone's band or while the
  Pokemon holds the zone's material (or a harder zone's). Cobblebase
  skills check the pastured Pokemon and tell the owner once what it needs.
- Progress record, guardian unlock messages, the Power Block placement
  gate and /cobblesurvival progress removed; the Power Block's recipe
  already needs Jungle redstone.
- HmsGate: switches off gated HMs auras (Flash, Dive, Mist, Rock Climb,
  Magnet Rise, Lucky Chant, Lava Plume) whose Pokemon doesn't pass.
- ShoulderPassives: type-based effects from shoulder Pokemon, hazard
  ones (warmth, frigidness, water breathing, night vision) gated.
- Self-tests for all of it; docs, balance table, Phase 5 checklist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
claudeandClaude Opus 5.5 committed 2026-10-31 13:41:04 +01:00
1 parent 56ffdd9f2e
commit 70cc2cad50
21 files changed
+578 -206

No files matched your search

@@ -1,13 +1,17 @@
package dev.cobblesurvival.selftest
import dev.cobblesurvival.dev.OreVeins
import dev.cobblesurvival.progress.Gates
import dev.cobblesurvival.progress.HmsGate
import net.minecraft.commands.arguments.EntityAnchorArgument
import net.minecraft.core.BlockPos
import net.minecraft.core.registries.BuiltInRegistries
import net.minecraft.gametest.framework.GameTestAssertException
import net.minecraft.resources.ResourceLocation
import net.minecraft.server.level.ServerPlayer
import net.minecraft.util.RandomSource
import net.minecraft.world.entity.item.ItemEntity
import net.minecraft.world.item.ItemStack
import net.minecraft.world.item.Items
import net.minecraft.world.level.GameType
import net.minecraft.world.level.block.Blocks
@@ -19,13 +23,42 @@ import net.minecraft.world.phys.Vec3
* must mine a regenerating vein the normal way: the ore drops and Regenerating Ore Veins turns the block into a
* regenerator, so the vein grows back. The test fires HMs' own Rock Smash handler (by reflection) at a one-block
* coal vein, away from spawn, since HMs respects spawn protection.
*
* The auras that get around zone hazards have soft gates (Adrian, 2026-10-31): the Pokémon giving one needs the level
* or a held material of the aura's gate zone ([HmsGate]).
*/
object FieldMoveTests {
private const val VEIN = "selftest"
private const val OFFSET = 64
private val REGENERATOR = ResourceLocation.parse("regenerating_ore_veins:regenerator_block")
fun all() = listOf(SelfTestCase("rock_smash_mines_veins", 100, ::rockSmashMinesVeins))
fun all() = listOf(
SelfTestCase("rock_smash_mines_veins", 100, ::rockSmashMinesVeins),
SelfTestCase("hazard_auras_need_level_or_material", 20, ::hazardAurasNeedLevelOrMaterial),
)
private fun hazardAurasNeedLevelOrMaterial(s: Scenario) {
s.check(HmsGate.installed) { "Cobblemon HMs isn't installed" }
val notAuras = Gates.fieldMoves.keys - HmsGate.auraIds()
s.check(notAuras.isEmpty()) { "field-move gates for abilities that aren't Cobblemon HMs auras: $notAuras" }
val (aura, zone) = Gates.fieldMoves.entries.firstOrNull() ?: throw GameTestAssertException("no aura is gated")
val gate = Gates.zones[zone] ?: throw GameTestAssertException("$aura is gated by $zone, which has no gate")
val player = s.player()
val pokemon = s.give(player, s.pokemon("ampharos", 1))
fun keptOn(holding: ItemStack): Boolean {
pokemon.swapHeldItem(holding, false)
HmsGate.switchOn(player, pokemon.uuid, aura)
s.check(HmsGate.isOn(player, pokemon.uuid, aura)) { "$aura couldn't be switched on" }
HmsGate.check(player)
return HmsGate.isOn(player, pokemon.uuid, aura)
}
s.check(!keptOn(ItemStack.EMPTY)) { "a Lv 1 Pokémon holding nothing gives $aura (gate: $zone)" }
s.check(keptOn(ItemStack(gate.items.first()))) { "$aura was switched off for a Pokémon holding ${gate.items.first()} ($zone)" }
pokemon.level = gate.level
s.check(keptOn(ItemStack.EMPTY)) { "$aura was switched off for a Lv ${gate.level} Pokémon ($zone)" }
s.helper.succeed()
}
private fun rockSmashMinesVeins(s: Scenario) {
val level = s.level
@@ -4,7 +4,6 @@ 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.progress.Progress
import dev.cobblesurvival.zone.Guardians
import dev.cobblesurvival.zone.Zones
import net.minecraft.core.registries.BuiltInRegistries
@@ -62,7 +61,6 @@ object GuardianTests {
val have = player.inventory.countItem(BuiltInRegistries.ITEM.get(item))
s.check(have >= count) { "the player got $have of $item from the guardian, not $count" }
}
s.check(ZONE in Progress.of(level.server).beaten(player.uuid)) { "beating the guardian didn't count as progress" }
}
.thenIdle(100)
.thenExecute {
@@ -3,14 +3,13 @@ package dev.cobblesurvival.selftest
import com.cobblemon.mod.common.entity.pokemon.PokemonEntity
import dev.cobblesurvival.progress.CobblebaseGate
import dev.cobblesurvival.progress.Gates
import dev.cobblesurvival.progress.Progress
import dev.cobblesurvival.zone.Guardians
import net.minecraft.core.BlockPos
import net.minecraft.core.Direction
import net.minecraft.core.registries.BuiltInRegistries
import net.minecraft.gametest.framework.GameTestAssertException
import net.minecraft.resources.ResourceLocation
import net.minecraft.world.InteractionHand
import net.minecraft.world.item.Item
import net.minecraft.world.item.ItemStack
import net.minecraft.world.item.Items
import net.minecraft.world.level.Level
@@ -20,16 +19,17 @@ import net.minecraft.world.phys.Vec3
import java.lang.reflect.Proxy
/**
* Progression gates (Adrian, 2026-10-30): Cobblebase's base skills and Poke Power's Power Block open up as a player
* beats the zones' Alpha guardians (`data/cobblesurvival/progression_gates/`).
* Soft gates (Adrian, 2026-10-31): Cobblebase's base skills open up through the pastured Pokémon's level or a material it
* holds, never through flags (`data/cobblesurvival/progression_gates/`). Poke Power's Power Block has no gate of its
* own: its recipe already takes the Verdant Jungle's redstone.
*/
object ProgressionTests {
private val POWER_BLOCK = ResourceLocation.parse("poke_power:power_block")
fun all() = listOf(
SelfTestCase("gates_cover_every_cobblebase_skill", 20, ::gatesCoverEverySkill),
SelfTestCase("cobblebase_skills_wait_for_guardians", 20, ::skillsWaitForGuardians),
SelfTestCase("power_block_waits_for_guardian", 20, ::powerBlockWaits),
SelfTestCase("cobblebase_skills_need_level_or_material", 20, ::skillsNeedLevelOrMaterial),
SelfTestCase("power_block_gated_by_its_recipe", 20, ::powerBlockGatedByRecipe),
)
private fun gatesCoverEverySkill(s: Scenario) {
@@ -39,73 +39,76 @@ object ProgressionTests {
s.check(ungated.isEmpty()) { "Cobblebase skills without a gate (they stay off): ${ungated.sorted()}" }
val stale = Gates.skills.keys - skills
s.check(stale.isEmpty()) { "gates for skills Cobblebase doesn't have: ${stale.sorted()}" }
val zones = (Gates.skills.values.filterNotNull() + Gates.blocks.values).toSet()
val noGuardian = zones - Guardians.definitions.keys
s.check(noGuardian.isEmpty()) { "gates name zones without a guardian: ${noGuardian.sorted()}" }
val noBlock = Gates.blocks.keys.filterNot { BuiltInRegistries.BLOCK.containsKey(it) }
s.check(noBlock.isEmpty()) { "gates for blocks that don't exist: $noBlock" }
val named = (Gates.skills.values.filterNotNull() + Gates.fieldMoves.values).toSet()
val noGate = named - Gates.zones.keys
s.check(noGate.isEmpty()) { "gates name zones without a level and materials: ${noGate.sorted()}" }
val broken = Gates.zones.values.filter { it.items.isEmpty() || it.level == Int.MAX_VALUE }.map { it.zone }
s.check(broken.isEmpty()) { "zone gates without materials or a level (unknown zone?): $broken" }
val open = CobblebaseGate.executorNames().filterNot { name -> CobblebaseGate.executor(name)?.let(CobblebaseGate::isGated) == true }
s.check(open.isEmpty()) { "Cobblebase executors not behind the gates: $open" }
s.helper.succeed()
}
/** A probe behind the same gate as Cobblebase's executors, called the way Cobblebase calls them. */
private fun skillsWaitForGuardians(s: Scenario) {
private fun skillsNeedLevelOrMaterial(s: Scenario) {
val level = s.level
val player = s.player()
val pokemon = s.give(player, s.pokemon("pikachu", 10))
s.check(pokemon.getOwnerUUID() == player.uuid) { "the test Pokémon has no owner" }
val pokemon = s.give(player, s.pokemon("pikachu", 1))
val entity = PokemonEntity(level, pokemon)
val (openSkill, _) = Gates.skills.entries.firstOrNull { it.value == null } ?: throw GameTestAssertException("no skill is open from the start")
val (lockedSkill, zone) = Gates.skills.entries.firstOrNull { it.value != null } ?: throw GameTestAssertException("no skill is gated")
val progress = Progress.of(level.server)
s.afterTest { progress.revoke(player.uuid, zone!!) }
val gated = Gates.skills.entries.mapNotNull { (skill, zone) -> zone?.let { skill to Gates.zones.getValue(it) } }.sortedBy { it.second.level }
s.check(gated.isNotEmpty()) { "no skill is gated" }
val (lockedSkill, gate) = gated[gated.size / 2]
val easier = Gates.zones.values.filter { it.level < gate.level && it.items.isNotEmpty() }.minByOrNull { it.level }
val harder = Gates.zones.values.filter { it.level > gate.level && it.items.isNotEmpty() }.maxByOrNull { it.level }
var calls = 0
val type = CobblebaseGate.executorType
val probe = Proxy.newProxyInstance(type.classLoader, arrayOf(type)) { _, method, _ -> if (method.name == "tick") calls++; null }
val gated = CobblebaseGate.wrap(probe)
val wrapped = CobblebaseGate.wrap(probe)
val tick = type.getMethod("tick", Level::class.java, BlockPos::class.java, PokemonEntity::class.java,
CobblebaseGate.skillDef, CobblebaseGate.skillEntry)
val entry = CobblebaseGate.skillEntry.getConstructor(String::class.java, Int::class.javaPrimitiveType)
fun run(skill: String) {
fun runs(skill: String, holding: Item? = null): Boolean {
pokemon.swapHeldItem(holding?.let(::ItemStack) ?: ItemStack.EMPTY, false)
val def = CobblebaseGate.skills()[skill] ?: throw GameTestAssertException("Cobblebase has no skill $skill")
tick.invoke(gated, level, entity.blockPosition(), entity, def, entry.newInstance(skill, 1))
val before = calls
tick.invoke(wrapped, level, entity.blockPosition(), entity, def, entry.newInstance(skill, 1))
return calls > before
}
run(openSkill)
s.check(calls == 1) { "$openSkill is open from the start but didn't run" }
run(lockedSkill)
s.check(calls == 1) { "$lockedSkill ran before the guardian of $zone was beaten" }
progress.grant(player.uuid, zone!!)
run(lockedSkill)
s.check(calls == 2) { "$lockedSkill didn't run after the guardian of $zone was beaten" }
s.check(runs(openSkill)) { "$openSkill is open from the start but didn't run" }
s.check(!runs(lockedSkill)) { "$lockedSkill (gate: ${gate.zone}) ran for a Lv 1 Pokémon holding nothing" }
s.check(runs(lockedSkill, gate.items.first())) { "$lockedSkill didn't run for a Pokémon holding ${gate.items.first()} (${gate.zone})" }
if (harder != null) {
s.check(runs(lockedSkill, harder.items.first())) { "$lockedSkill didn't run with ${harder.items.first()} from the harder ${harder.zone}" }
}
if (easier != null) {
s.check(!runs(lockedSkill, easier.items.first())) { "$lockedSkill ran with ${easier.items.first()} from the easier ${easier.zone}" }
}
pokemon.level = gate.level
s.check(runs(lockedSkill)) { "$lockedSkill didn't run for a Lv ${gate.level} Pokémon (${gate.zone})" }
s.helper.succeed()
}
private fun powerBlockWaits(s: Scenario) {
/** No flag stands between a player and the Power Block; its recipe needs the Jungle's redstone. */
private fun powerBlockGatedByRecipe(s: Scenario) {
val level = s.level
val zone = Gates.blocks[POWER_BLOCK] ?: throw GameTestAssertException("the Power Block has no gate")
val item = BuiltInRegistries.ITEM.get(POWER_BLOCK)
s.check(item != Items.AIR) { "Poke Power isn't installed" }
val recipe = level.server.recipeManager.byKey(POWER_BLOCK).orElse(null)
?: throw GameTestAssertException("the Power Block has no recipe")
s.check(recipe.value().ingredients.any { it.test(ItemStack(Items.REDSTONE_BLOCK)) }) {
"the Power Block recipe no longer takes redstone blocks (Verdant Jungle ore), so nothing gates it"
}
val player = s.player()
val progress = Progress.of(level.server)
s.afterTest { progress.revoke(player.uuid, zone) }
val floor = s.helper.absolutePos(BlockPos(7, 2, 7))
level.setBlockAndUpdate(floor, Blocks.STONE.defaultBlockState())
val target = floor.above()
fun place() {
player.setItemInHand(InteractionHand.MAIN_HAND, ItemStack(item))
val hit = BlockHitResult(Vec3.atCenterOf(floor).add(0.0, 0.5, 0.0), Direction.UP, floor, false)
player.gameMode.useItemOn(player, level, player.mainHandItem, InteractionHand.MAIN_HAND, hit)
}
fun placed() = BuiltInRegistries.BLOCK.getKey(level.getBlockState(target).block) == POWER_BLOCK
place()
s.check(!placed()) { "a player who hasn't beaten the guardian of $zone placed a Power Block" }
progress.grant(player.uuid, zone)
place()
s.check(placed()) { "the Power Block couldn't be placed after beating the guardian of $zone: ${level.getBlockState(target)}" }
player.setItemInHand(InteractionHand.MAIN_HAND, ItemStack(item))
val hit = BlockHitResult(Vec3.atCenterOf(floor).add(0.0, 0.5, 0.0), Direction.UP, floor, false)
player.gameMode.useItemOn(player, level, player.mainHandItem, InteractionHand.MAIN_HAND, hit)
val placed = level.getBlockState(floor.above())
s.check(BuiltInRegistries.BLOCK.getKey(placed.block) == POWER_BLOCK) { "a survival player couldn't place a Power Block: $placed" }
s.helper.succeed()
}
}
@@ -54,7 +54,7 @@ object SelfTest {
private val cases: List<SelfTestCase> get() =
PermadeathTests.all() + SurvivalRulesTests.all() + ZoneTests.all() + HazardTests.all() + GuardianTests.all() +
VeinTests.all() + OreNodeTests.all() + DropTests.all() + ProgressionTests.all() +
CatchingTests.all() + WorldRulesTests.all() + FieldMoveTests.all()
CatchingTests.all() + WorldRulesTests.all() + FieldMoveTests.all() + ShoulderTests.all()
private var running: List<Scenario> = emptyList()
private var forced: List<ChunkPos> = emptyList()
@@ -0,0 +1,49 @@
package dev.cobblesurvival.selftest
import com.cobblemon.mod.common.api.pokemon.PokemonSpecies
import com.cobblemon.mod.common.pokemon.Pokemon
import dev.cobblesurvival.party.ShoulderPassives
import dev.cobblesurvival.progress.Gates
import net.minecraft.gametest.framework.GameTestAssertException
import net.minecraft.world.item.ItemStack
/**
* Shoulder passives (Adrian, 2026-10-31): a shoulder Pokémon gives its type's effect; a gated one only with the gate's
* level or material. Calls the passives with a given list of shoulder Pokémon rather than seating them on a shoulder.
*/
object ShoulderTests {
fun all() = listOf(SelfTestCase("shoulder_pokemon_give_passives", 20, ::shoulderPokemonGivePassives))
private fun carrier(passive: ShoulderPassives.Passive): Pokemon {
val species = PokemonSpecies.implemented.firstOrNull { species ->
species.shoulderMountable && species.types.any { it.name.lowercase() in passive.types }
} ?: throw GameTestAssertException("no Pokémon of type ${passive.types} can ride a shoulder: ${passive.effect.registeredName} is out of reach")
return species.create(1)
}
private fun shoulderPokemonGivePassives(s: Scenario) {
val passives = ShoulderPassives.passives
s.check(passives.isNotEmpty()) { "no shoulder passives loaded" }
passives.forEach(::carrier)
val noGate = passives.mapNotNull { it.gate }.filterNot { it in Gates.zones }
s.check(noGate.isEmpty()) { "shoulder passives gated by zones without a gate: $noGate" }
val open = passives.first { it.gate == null }
val gated = passives.first { it.gate != null }
val player = s.player()
s.afterTest { ShoulderPassives.apply(player, emptyList()) }
ShoulderPassives.apply(player, listOf(carrier(open)))
s.check(player.hasEffect(open.effect)) { "a ${open.types} shoulder Pokémon didn't give ${open.effect.registeredName}" }
ShoulderPassives.apply(player, emptyList())
s.check(!player.hasEffect(open.effect)) { "${open.effect.registeredName} stayed after the shoulder Pokémon left" }
val pokemon = carrier(gated)
ShoulderPassives.apply(player, listOf(pokemon))
s.check(!player.hasEffect(gated.effect)) { "a Lv 1 shoulder Pokémon holding nothing gave ${gated.effect.registeredName} (gate: ${gated.gate})" }
val material = Gates.zones.getValue(gated.gate!!).items.first()
pokemon.swapHeldItem(ItemStack(material), false)
ShoulderPassives.apply(player, listOf(pokemon))
s.check(player.hasEffect(gated.effect)) { "a shoulder Pokémon holding $material didn't give ${gated.effect.registeredName}" }
s.helper.succeed()
}
}