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

@@ -8,8 +8,8 @@ import dev.cobblesurvival.dev.BiomePreview
import dev.cobblesurvival.dev.OreVeins
import dev.cobblesurvival.ore.OreNodes
import dev.cobblesurvival.permadeath.Permadeath
import dev.cobblesurvival.party.ShoulderPassives
import dev.cobblesurvival.progress.Gates
import dev.cobblesurvival.progress.Progress
import dev.cobblesurvival.selftest.SelfTest
import dev.cobblesurvival.world.OnlyPokemon
import dev.cobblesurvival.world.SharedLoot
@@ -68,6 +68,7 @@ class CobbleSurvival {
NeoForge.EVENT_BUS.addListener(Zones::onAddReloadListeners)
Guardians.register()
Gates.register()
ShoulderPassives.register()
OreNodes.register()
Catching.register()
Wanderers.register()
@@ -106,7 +107,6 @@ class CobbleSurvival {
})
.then(BiomePreview.command())
.then(OreVeins.command())
.then(Progress.command())
.then(Commands.literal("zone").executes { context ->
val source = context.source
val zone = Zones.at(source.level, BlockPos.containing(source.position))
@@ -0,0 +1,119 @@
package dev.cobblesurvival.party
import com.cobblemon.mod.common.Cobblemon
import com.cobblemon.mod.common.pokemon.Pokemon
import com.cobblemon.mod.common.pokemon.activestate.ShoulderedState
import com.google.gson.Gson
import com.google.gson.JsonElement
import dev.cobblesurvival.CobbleSurvival
import dev.cobblesurvival.progress.Gates
import net.minecraft.ChatFormatting
import net.minecraft.core.Holder
import net.minecraft.core.registries.BuiltInRegistries
import net.minecraft.network.chat.Component
import net.minecraft.resources.ResourceLocation
import net.minecraft.server.level.ServerPlayer
import net.minecraft.server.packs.resources.ResourceManager
import net.minecraft.server.packs.resources.SimpleJsonResourceReloadListener
import net.minecraft.util.profiling.ProfilerFiller
import net.minecraft.world.effect.MobEffect
import net.minecraft.world.effect.MobEffectInstance
import net.neoforged.neoforge.common.NeoForge
import net.neoforged.neoforge.event.AddReloadListenerEvent
import net.neoforged.neoforge.event.server.ServerStoppedEvent
import net.neoforged.neoforge.event.tick.ServerTickEvent
import java.util.UUID
/**
* Party passives from shoulder Pokémon (Adrian, 2026-10-31): a Pokémon riding a player's shoulder gives an effect by
* its type, e.g. slow falling from a Flying type or warmth in the cold from a Fire type. An effect that gets around a
* zone hazard has a soft gate, like Cobblemon HMs' auras: the shoulder Pokémon needs that gate's level or material
* ([Gates]); the player is told once what it needs.
*
* Data, `data/cobblesurvival/shoulder_passives/<name>.json`, all files adding up:
* `{"passives": [{"types": ["fire", ...], "effect": "<mob effect id>", "amplifier": 0, "gate": "<zone>" (optional)}]}`.
*/
object ShoulderPassives : SimpleJsonResourceReloadListener(Gson(), "shoulder_passives") {
private const val CHECK_TICKS = 20
/** Longer than the refresh, and above the 200 ticks below which night vision flickers. */
const val DURATION = 260
class Passive(val types: Set<String>, val effect: Holder<MobEffect>, val amplifier: Int, val gate: String?)
var passives: List<Passive> = emptyList()
private set
private val given = HashMap<UUID, Set<Holder<MobEffect>>>()
private val noticed = HashSet<Pair<UUID, Holder<MobEffect>>>()
fun register() {
NeoForge.EVENT_BUS.addListener<AddReloadListenerEvent> { it.addListener(this) }
NeoForge.EVENT_BUS.addListener(::onServerTick)
NeoForge.EVENT_BUS.addListener<ServerStoppedEvent> { given.clear(); noticed.clear() }
}
override fun apply(files: Map<ResourceLocation, JsonElement>, resources: ResourceManager, profiler: ProfilerFiller) {
passives = files.toSortedMap().values.flatMap { file ->
file.asJsonObject.getAsJsonArray("passives").mapNotNull { element ->
val json = element.asJsonObject
val id = ResourceLocation.parse(json.get("effect").asString)
val effect = BuiltInRegistries.MOB_EFFECT.getHolder(id).orElse(null)
?: return@mapNotNull null.also { CobbleSurvival.LOGGER.warn("Shoulder passive with unknown effect {}", id) }
Passive(
types = json.getAsJsonArray("types").map { it.asString.lowercase() }.toSet(),
effect = effect,
amplifier = json.get("amplifier")?.asInt ?: 0,
gate = json.get("gate")?.asString,
)
}
}
CobbleSurvival.LOGGER.info("Loaded {} shoulder passives", passives.size)
}
private fun onServerTick(event: ServerTickEvent.Post) {
if (event.server.tickCount % CHECK_TICKS != 0) return
event.server.playerList.players.forEach { apply(it) }
}
/** The Pokémon riding [player]'s shoulders. */
fun shouldered(player: ServerPlayer): List<Pokemon> =
Cobblemon.storage.getParty(player).filter { (it.state as? ShoulderedState)?.isStillShouldered(player) == true }
private fun matches(passive: Passive, pokemon: Pokemon) = pokemon.types.any { it.name.lowercase() in passive.types }
/** Effect → amplifier that [shouldered] Pokémon give, gates passed. */
fun effects(shouldered: List<Pokemon>): Map<Holder<MobEffect>, Int> {
val result = HashMap<Holder<MobEffect>, Int>()
for (passive in passives) {
if (shouldered.none { matches(passive, it) && (passive.gate == null || Gates.passes(it, passive.gate)) }) continue
result.merge(passive.effect, passive.amplifier, ::maxOf)
}
return result
}
/** Gives [player] the effects of [shouldered] and takes back the ones it gave before that no longer apply. */
fun apply(player: ServerPlayer, shouldered: List<Pokemon> = shouldered(player)) {
val effects = effects(shouldered)
for ((effect, amplifier) in effects) player.addEffect(MobEffectInstance(effect, DURATION, amplifier, true, false, true))
val before = if (effects.isEmpty()) given.remove(player.uuid) else given.put(player.uuid, effects.keys)
for (effect in before.orEmpty() - effects.keys) {
val current = player.getEffect(effect)
if (current != null && current.isAmbient && current.duration <= DURATION) player.removeEffect(effect)
}
noticeGated(player, shouldered)
}
/** Tells [player] once per Pokémon and effect what a shoulder Pokémon still needs for a gated passive. */
private fun noticeGated(player: ServerPlayer, shouldered: List<Pokemon>) {
for (pokemon in shouldered) for (passive in passives) {
val gate = passive.gate ?: continue
if (!matches(passive, pokemon) || Gates.passes(pokemon, gate) || !noticed.add(pokemon.uuid to passive.effect)) continue
player.sendSystemMessage(
Component.translatableWithFallback(
"message.cobblesurvival.gate.field_move", "%s can't give you %s yet. It needs %s.",
pokemon.getDisplayName(), passive.effect.value().displayName, Gates.requirement(gate),
).withStyle(ChatFormatting.YELLOW)
)
}
}
}
@@ -2,7 +2,6 @@ package dev.cobblesurvival.progress
import com.cobblemon.mod.common.entity.pokemon.PokemonEntity
import dev.cobblesurvival.CobbleSurvival
import net.minecraft.world.level.Level
import net.neoforged.fml.ModList
import java.lang.reflect.InvocationHandler
import java.lang.reflect.InvocationTargetException
@@ -11,8 +10,8 @@ import java.lang.reflect.Proxy
/**
* Puts [Gates] in front of Cobblebase's skills, by reflection (Cobblebase is not a compile dependency): every skill
* executor in Cobblebase's `ExecutorRegistry` is replaced by a proxy that runs it only when the pastured Pokémon's owner
* has unlocked that skill. Cobblebase looks the executor up on every tick, so the proxies take effect at once. One
* executor in Cobblebase's `ExecutorRegistry` is replaced by a proxy that runs it only when the pastured Pokémon passes
* that skill's soft gate (level or held material); otherwise its owner is told what it needs. Cobblebase looks the executor up on every tick, so the proxies take effect at once. One
* executor can serve several skills (all the finders), so the check is per skill, not per executor.
*/
object CobblebaseGate {
@@ -68,11 +67,12 @@ object CobblebaseGate {
private class Gate(private val original: Any) : InvocationHandler {
override fun invoke(proxy: Any, method: Method, args: Array<out Any?>?): Any? {
if (method.name == "tick" && args != null && args.size == 5) {
val level = args[0] as Level
val owner = (args[2] as PokemonEntity).pokemon.getOwnerUUID()
val entity = args[2] as PokemonEntity
val skill = skillId.invoke(args[3]) as String
val server = level.server ?: return null
if (!Gates.skillOpen(Progress.of(server), owner, skill)) return null
if (!Gates.skillOpen(entity.pokemon, skill)) {
Gates.noticeIdle(entity, skill)
return null
}
}
return try {
method.invoke(original, *(args ?: emptyArray()))
@@ -1,5 +1,7 @@
package dev.cobblesurvival.progress
import com.cobblemon.mod.common.entity.pokemon.PokemonEntity
import com.cobblemon.mod.common.pokemon.Pokemon
import com.google.gson.Gson
import com.google.gson.JsonElement
import dev.cobblesurvival.CobbleSurvival
@@ -8,85 +10,122 @@ import net.minecraft.ChatFormatting
import net.minecraft.core.registries.BuiltInRegistries
import net.minecraft.network.chat.Component
import net.minecraft.resources.ResourceLocation
import net.minecraft.server.level.ServerPlayer
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.Item
import net.minecraft.world.item.ItemStack
import net.minecraft.world.item.Items
import net.neoforged.neoforge.common.NeoForge
import net.neoforged.neoforge.event.AddReloadListenerEvent
import net.neoforged.neoforge.event.level.BlockEvent
import net.neoforged.neoforge.event.server.ServerStartedEvent
import net.neoforged.neoforge.event.server.ServerStoppedEvent
import java.util.UUID
/**
* Progression gates (Adrian, 2026-10-30: Cobblebase's skills and the power source stay in, "gated behind
* progression"): a Cobblebase skill works for a pastured Pokémon, and a gated block can be placed, only once its owner
* has beaten a given zone's Alpha guardian ([Progress]).
* Soft gates (Adrian, 2026-10-31): Cobblebase's base skills and the Cobblemon HMs auras that get around zone hazards
* open up through the Pokémon doing the work, never through flags a player earns. A gate names a zone; a Pokémon passes
* it when its level reaches the gate's level (by default the top of the zone's band), or when it holds one of the
* zone's materials or a material of a zone with a higher gate. Guardians drop materials but unlock nothing, so a gifted
* Pokémon or material does the same as an earned one.
*
* The table is data, `data/cobblesurvival/progression_gates/<name>.json`:
* `{"cobblebase_skills": {"<skill id>": "<zone>" or null}, "blocks": {"<block id>": "<zone>"}}`; null means from the
* start, later files override earlier ones. A Cobblebase skill missing from the table stays off, so an update can't
* slip new skills past the gates; the self-test `gates_cover_every_cobblebase_skill` names any that are missing.
* Data, `data/cobblesurvival/progression_gates/<name>.json`, later files overriding earlier ones:
* - `zones`: `{"<zone>": {"items": ["<item id>", ...], "level": <optional>}}`;
* - `cobblebase_skills`: `{"<skill id>": "<zone>" or null}`, null meaning from the start. A Cobblebase skill missing
* here stays off, so an update can't slip new skills past the gates (self-test `gates_cover_every_cobblebase_skill`);
* - `field_moves`: `{"<Cobblemon HMs aura id>": "<zone>"}`, enforced by [HmsGate].
*/
object Gates : SimpleJsonResourceReloadListener(Gson(), "progression_gates") {
/** Owners are told once per Pokémon and skill (per server run) why a pastured Pokémon idles, if they are this near. */
private const val NOTICE_RANGE = 64.0
class ZoneGate(val zone: String, private val fixedLevel: Int?, val items: List<Item>) {
val level: Int get() = fixedLevel ?: Zones.byId[zone]?.maxLevel ?: Int.MAX_VALUE
}
var zones: Map<String, ZoneGate> = emptyMap()
private set
var skills: Map<String, String?> = emptyMap()
private set
var blocks: Map<ResourceLocation, String> = emptyMap()
var fieldMoves: Map<String, String> = emptyMap()
private set
private val noticed = HashSet<Pair<UUID, String>>()
fun register() {
NeoForge.EVENT_BUS.addListener<AddReloadListenerEvent> { it.addListener(this) }
NeoForge.EVENT_BUS.addListener(::onPlace)
NeoForge.EVENT_BUS.addListener<ServerStartedEvent> { CobblebaseGate.install() }
NeoForge.EVENT_BUS.addListener<ServerStoppedEvent> { noticed.clear() }
HmsGate.register()
}
override fun apply(files: Map<ResourceLocation, JsonElement>, resources: ResourceManager, profiler: ProfilerFiller) {
val zoneGates = LinkedHashMap<String, ZoneGate>()
val skillGates = LinkedHashMap<String, String?>()
val blockGates = LinkedHashMap<ResourceLocation, String>()
val moveGates = LinkedHashMap<String, String>()
for ((_, element) in files.toSortedMap()) {
val json = element.asJsonObject
json.getAsJsonObject("zones")?.entrySet()?.forEach { (zone, value) ->
val gate = value.asJsonObject
val items = gate.getAsJsonArray("items").mapNotNull { id ->
BuiltInRegistries.ITEM.get(ResourceLocation.parse(id.asString)).takeIf { it != Items.AIR }
?: null.also { CobbleSurvival.LOGGER.warn("Gate for {} names an unknown item {}", zone, id.asString) }
}
zoneGates[zone] = ZoneGate(zone, gate.get("level")?.asInt, items)
}
json.getAsJsonObject("cobblebase_skills")?.entrySet()?.forEach { (skill, zone) ->
skillGates[skill] = if (zone.isJsonNull) null else zone.asString
}
json.getAsJsonObject("blocks")?.entrySet()?.forEach { (block, zone) ->
blockGates[ResourceLocation.parse(block)] = zone.asString
}
json.getAsJsonObject("field_moves")?.entrySet()?.forEach { (move, zone) -> moveGates[move] = zone.asString }
}
zones = zoneGates
skills = skillGates
blocks = blockGates
CobbleSurvival.LOGGER.info("Loaded progression gates: {} Cobblebase skills, {} blocks", skills.size, blocks.size)
fieldMoves = moveGates
CobbleSurvival.LOGGER.info("Loaded soft gates: {} zones, {} Cobblebase skills, {} field moves", zones.size, skills.size, fieldMoves.size)
}
fun skillOpen(progress: Progress, owner: UUID?, skill: String): Boolean {
/** Whether [pokemon] passes [zone]'s gate: high enough level, or holding that zone's material or a harder zone's. */
fun passes(pokemon: Pokemon, zone: String): Boolean {
val gate = zones[zone] ?: return false
if (pokemon.level >= gate.level) return true
val held = pokemon.heldItem()
return !held.isEmpty && zones.values.any { it.level >= gate.level && held.item in it.items }
}
fun skillOpen(pokemon: Pokemon, skill: String): Boolean {
if (!skills.containsKey(skill)) return false
val zone = skills[skill] ?: return true
return owner != null && zone in progress.beaten(owner)
return passes(pokemon, zone)
}
private fun onPlace(event: BlockEvent.EntityPlaceEvent) {
val player = event.entity as? ServerPlayer ?: return
if (player.isCreative) return
val zone = blocks[BuiltInRegistries.BLOCK.getKey(event.placedBlock.block)] ?: return
if (zone in Progress.of(player.server).beaten(player.uuid)) return
event.isCanceled = true
player.displayClientMessage(
Component.translatableWithFallback(
"message.cobblesurvival.gate.block", "Beat the guardian of %s to use this.", zoneName(zone),
).withStyle(ChatFormatting.RED), true
/** "Lv 25, or to hold Raw Iron, Iron Ingot or Thunder Stone (Westmarch) or a material of a harder zone". */
fun requirement(zone: String): Component {
val gate = zones[zone]
val items = Component.empty()
gate?.items?.forEachIndexed { i, item ->
if (i > 0) items.append(if (i == gate.items.lastIndex) " or " else ", ")
items.append(ItemStack(item).hoverName)
}
return Component.translatableWithFallback(
"message.cobblesurvival.gate.requirement", "Lv %s, or to hold %s (%s) or a material of a harder zone",
gate?.level ?: "?", items, Zones.byId[zone]?.name ?: zone,
)
}
/** Tells [player] what beating [zone]'s guardian unlocked for them. */
fun announceUnlocks(player: ServerPlayer, zone: String) {
val unlocked = skills.filterValues { it == zone }.keys.map { CobblebaseGate.skillName(it) ?: it } +
blocks.filterValues { it == zone }.keys.map { BuiltInRegistries.BLOCK.get(it).name.string }
if (unlocked.isEmpty()) return
player.sendSystemMessage(
/** Tells a pastured Pokémon's owner, once, why it doesn't do [skill]; only when the owner is near. */
fun noticeIdle(entity: PokemonEntity, skill: String) {
val pokemon = entity.pokemon
val key = pokemon.uuid to skill
if (key in noticed) return
val owner = pokemon.getOwnerPlayer() ?: return
if (owner.level() != entity.level() || owner.distanceToSqr(entity) > NOTICE_RANGE * NOTICE_RANGE) return
noticed += key
val name = CobblebaseGate.skillName(skill) ?: skill
owner.sendSystemMessage(
Component.translatableWithFallback(
"message.cobblesurvival.gate.unlocked", "Unlocked at your base: %s", unlocked.joinToString(),
).withStyle(ChatFormatting.GREEN)
"message.cobblesurvival.gate.skill", "%s won't do %s at your base yet. It needs %s.",
pokemon.getDisplayName(), name, requirement(skills[skill] ?: return),
).withStyle(ChatFormatting.YELLOW)
)
}
private fun zoneName(zone: String) = Zones.byId[zone]?.name ?: zone
}
@@ -0,0 +1,95 @@
package dev.cobblesurvival.progress
import com.cobblemon.mod.common.Cobblemon
import net.minecraft.ChatFormatting
import net.minecraft.network.chat.Component
import net.minecraft.server.MinecraftServer
import net.minecraft.server.level.ServerPlayer
import net.neoforged.fml.ModList
import net.neoforged.neoforge.common.NeoForge
import net.neoforged.neoforge.event.tick.ServerTickEvent
import java.util.UUID
/**
* Puts [Gates] in front of the Cobblemon HMs auras that get around zone hazards (Adrian, 2026-10-31: soft gates, e.g.
* Flash in the Deep Dark, Lava Plume in the Basalt Furnace). HMs keeps a switch per player, Pokémon and aura; every few
* ticks this switches off a gated aura whose Pokémon doesn't pass its gate and tells the player what the Pokémon needs.
* HMs' other abilities stay open (Adrian). By reflection: HMs is not a compile dependency.
*/
object HmsGate {
private const val PACKAGE = "com.shier.cobblemonhms"
private const val CHECK_TICKS = 5
val installed: Boolean get() = ModList.get().isLoaded("cobblemon_hms")
private val abilityType by lazy { Class.forName("$PACKAGE.ability.HMAbility") }
private val abilities by lazy { abilityType.getField("Companion").get(null) }
private val dataType by lazy { Class.forName("$PACKAGE.storage.HMsSavedData") }
private val dataCompanion by lazy { dataType.getField("Companion").get(null) }
private val partners by lazy { Class.forName("$PACKAGE.ability.Partners").let { it to it.getField("INSTANCE").get(null) } }
private val isOn by lazy { dataType.getMethod("isMountToggleOn", UUID::class.java, UUID::class.java, Int::class.javaPrimitiveType) }
fun register() {
NeoForge.EVENT_BUS.addListener(::onServerTick)
}
/** HMs' ability for [id] (as in `config/cobblemon_hms.json`), or null. */
fun ability(id: String): Enum<*>? =
abilities.javaClass.getMethod("fromId", String::class.java).invoke(abilities, id) as Enum<*>?
/** The ids of HMs' auras, the abilities a player switches on and off. */
@Suppress("UNCHECKED_CAST")
fun auraIds(): Set<String> = (abilities.javaClass.getMethod("getTOGGLES").invoke(abilities) as List<Any>)
.map { abilityType.getMethod("getId").invoke(it) as String }.toSet()
private fun data(server: MinecraftServer): Any = dataCompanion.javaClass.getMethod("get", MinecraftServer::class.java).invoke(dataCompanion, server)
fun isOn(player: ServerPlayer, pokemon: UUID, id: String): Boolean {
val ability = ability(id) ?: return false
return isOn.invoke(data(player.server), player.uuid, pokemon, ability.ordinal) as Boolean
}
/** Switches [player]'s aura [id] from [pokemon] on, as HMs' interaction wheel does (for the self-tests). */
fun switchOn(player: ServerPlayer, pokemon: UUID, id: String) {
if (isOn(player, pokemon, id)) return
val ordinal = (ability(id) ?: return).ordinal
dataType.getMethod("toggleMount", UUID::class.java, UUID::class.java, Int::class.javaPrimitiveType)
.invoke(data(player.server), player.uuid, pokemon, ordinal)
}
private fun onServerTick(event: ServerTickEvent.Post) {
if (!installed || Gates.fieldMoves.isEmpty() || event.server.tickCount % CHECK_TICKS != 0) return
event.server.playerList.players.forEach(::check)
}
/** Switches off [player]'s gated auras whose Pokémon don't pass their gates; returns how many. */
fun check(player: ServerPlayer): Int {
val data = data(player.server)
val toggles = dataType.getMethod("mountTogglesOf", UUID::class.java).invoke(data, player.uuid) as Map<*, *>
if (toggles.isEmpty()) return 0
val party = Cobblemon.storage.getParty(player)
var switchedOff = 0
for (pokemonId in toggles.keys) {
val pokemon = party[pokemonId as UUID] ?: continue
for ((id, zone) in Gates.fieldMoves) {
val ability = ability(id) ?: continue
if (!(isOn.invoke(data, player.uuid, pokemonId, ability.ordinal) as Boolean) || Gates.passes(pokemon, zone)) continue
dataType.getMethod("switchOff", UUID::class.java, UUID::class.java, Collection::class.java)
.invoke(data, player.uuid, pokemonId, listOf(ability.ordinal))
switchedOff++
val name = abilityType.getMethod("getDisplayName").invoke(ability) as String
player.sendSystemMessage(
Component.translatableWithFallback(
"message.cobblesurvival.gate.field_move", "%s can't give you %s yet. It needs %s.",
pokemon.getDisplayName(), name, Gates.requirement(zone),
).withStyle(ChatFormatting.YELLOW)
)
}
}
if (switchedOff > 0) {
val (type, instance) = partners
type.getMethod("refresh", ServerPlayer::class.java).invoke(instance, player)
}
return switchedOff
}
}
@@ -1,83 +0,0 @@
package dev.cobblesurvival.progress
import com.mojang.brigadier.arguments.StringArgumentType
import com.mojang.brigadier.builder.LiteralArgumentBuilder
import dev.cobblesurvival.zone.Guardians
import net.minecraft.commands.CommandSourceStack
import net.minecraft.commands.Commands
import net.minecraft.commands.SharedSuggestionProvider
import net.minecraft.commands.arguments.EntityArgument
import net.minecraft.core.HolderLookup
import net.minecraft.nbt.CompoundTag
import net.minecraft.nbt.ListTag
import net.minecraft.nbt.StringTag
import net.minecraft.nbt.Tag
import net.minecraft.network.chat.Component
import net.minecraft.server.MinecraftServer
import net.minecraft.world.level.saveddata.SavedData
import java.util.UUID
/**
* A player's progression: the zones whose Alpha guardian they have helped beat. [Guardians] records a win for every
* player in the battle; [Gates] unlocks base skills and blocks from it. Saved with the overworld.
*
* Operators: `/cobblesurvival progress <player>` lists it, `... grant|revoke <player> <zone>` changes it.
*/
class Progress : SavedData() {
private val beaten = HashMap<UUID, MutableSet<String>>()
fun beaten(player: UUID): Set<String> = beaten[player].orEmpty()
/** Records that [player] beat [zone]'s guardian; false if they had already. */
fun grant(player: UUID, zone: String): Boolean =
beaten.getOrPut(player) { HashSet() }.add(zone).also { if (it) setDirty() }
fun revoke(player: UUID, zone: String): Boolean = (beaten[player]?.remove(zone) == true).also { if (it) setDirty() }
override fun save(tag: CompoundTag, registries: HolderLookup.Provider): CompoundTag {
for ((player, zones) in beaten) {
tag.put(player.toString(), ListTag().apply { zones.sorted().forEach { add(StringTag.valueOf(it)) } })
}
return tag
}
companion object {
private val FACTORY = Factory(::Progress, ::load)
fun of(server: MinecraftServer): Progress = server.overworld().dataStorage.computeIfAbsent(FACTORY, "cobblesurvival_progress")
private fun load(tag: CompoundTag, registries: HolderLookup.Provider): Progress {
val progress = Progress()
for (key in tag.allKeys) {
val zones = tag.getList(key, Tag.TAG_STRING.toInt()).map { it.asString }
progress.beaten[UUID.fromString(key)] = zones.toHashSet()
}
return progress
}
fun command(): LiteralArgumentBuilder<CommandSourceStack> {
val zone = Commands.argument("zone", StringArgumentType.word())
.suggests { _, builder -> SharedSuggestionProvider.suggest(Guardians.definitions.keys, builder) }
fun change(grant: Boolean) = Commands.literal(if (grant) "grant" else "revoke")
.then(Commands.argument("player", EntityArgument.player()).then(zone.executes { context ->
val player = EntityArgument.getPlayer(context, "player")
val id = StringArgumentType.getString(context, "zone")
val progress = of(context.source.server)
val changed = if (grant) progress.grant(player.uuid, id) else progress.revoke(player.uuid, id)
context.source.sendSuccess({ Component.literal(
"${player.gameProfile.name}: $id ${if (grant) "granted" else "revoked"}${if (changed) "" else " (no change)"}") }, true)
1
}))
return Commands.literal("progress")
.then(Commands.argument("player", EntityArgument.player()).executes { context ->
val player = EntityArgument.getPlayer(context, "player")
val zones = of(context.source.server).beaten(player.uuid).sorted()
context.source.sendSuccess({ Component.literal(
"${player.gameProfile.name} beat: ${zones.joinToString().ifEmpty { "no guardian yet" }}") }, false)
1
})
.then(change(grant = true))
.then(change(grant = false))
}
}
}
@@ -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()
}
}
@@ -8,8 +8,6 @@ import com.cobblemon.mod.common.entity.pokemon.PokemonEntity
import com.google.gson.Gson
import com.google.gson.JsonElement
import dev.cobblesurvival.CobbleSurvival
import dev.cobblesurvival.progress.Gates
import dev.cobblesurvival.progress.Progress
import net.minecraft.ChatFormatting
import net.minecraft.core.BlockPos
import net.minecraft.core.HolderLookup
@@ -40,7 +38,8 @@ import kotlin.math.abs
* `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.
* Every player in the winning battle gets the drops and the zone in their [Progress], which unlocks base skills.
* Every player in the winning battle gets the drops. A win unlocks nothing by itself (Adrian, 2026-10-31: no flags);
* the drops are materials for the soft gates ([dev.cobblesurvival.progress.Gates]).
*/
object Guardians : SimpleJsonResourceReloadListener(Gson(), "guardians") {
/** Key in the guardian Pokémon's persistent data; its value is the zone id. */
@@ -181,7 +180,6 @@ object Guardians : SimpleJsonResourceReloadListener(Gson(), "guardians") {
"The guardian of %s is beaten! It returns in %s hours.", zoneName, hours,
).withStyle(ChatFormatting.GOLD)
)
if (Progress.of(player.server).grant(player.uuid, zone)) Gates.announceUnlocks(player, zone)
}
CobbleSurvival.LOGGER.info("Guardian of {} beaten by {}", zone, players.joinToString { it.gameProfile.name })
}