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:
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 })
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"message.cobblesurvival.permadeath.saved": "%s's Revival Charm saved it and crumbled to dust.",
|
||||
"message.cobblesurvival.guardian.beaten": "The guardian of %s is beaten! It returns in %s hours.",
|
||||
"message.cobblesurvival.hazard.mire": "The Mire's fumes are poisoning you. Mire Waders would keep them off.",
|
||||
"message.cobblesurvival.gate.block": "Beat the guardian of %s to use this.",
|
||||
"message.cobblesurvival.gate.unlocked": "Unlocked at your base: %s"
|
||||
"message.cobblesurvival.gate.requirement": "Lv %s, or to hold %s (%s) or a material of a harder zone",
|
||||
"message.cobblesurvival.gate.skill": "%s won't do %s at your base yet. It needs %s.",
|
||||
"message.cobblesurvival.gate.field_move": "%s can't give you %s yet. It needs %s."
|
||||
}
|
||||
Reference in new issue
Block a user