Phase 3: soft level pressure, less experience far above a zone's band

- Companion mod (zone/LevelPressure.kt): a Pokemon more than 5 levels
  above the band of the zone its trainer is in gets 10% less experience
  per extra level, down to 10%. Exp. Candy and commands are exempt; no
  hard level caps (roadmap).
- Rules in world/zones.json -> level_pressure, shipped in the zone layout.
- Self-test level_pressure_cuts_xp.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
claudeandClaude Opus 5.5 committed 2026-10-30 01:50:56 +01:00
1 parent d105f6757d
commit 173203eeb5
10 files changed
+91 -3

No files matched your search

@@ -8,6 +8,7 @@ import dev.cobblesurvival.permadeath.Permadeath
import dev.cobblesurvival.selftest.SelfTest
import dev.cobblesurvival.world.OnlyPokemon
import dev.cobblesurvival.zone.Guardians
import dev.cobblesurvival.zone.LevelPressure
import dev.cobblesurvival.zone.ZoneCondition
import dev.cobblesurvival.zone.ZoneHazards
import dev.cobblesurvival.zone.Zones
@@ -59,6 +60,7 @@ class CobbleSurvival {
NeoForge.EVENT_BUS.addListener(Zones::onAddReloadListeners)
Guardians.register()
ZoneHazards.register()
LevelPressure.register()
SelfTest.register()
}
@@ -1,6 +1,8 @@
package dev.cobblesurvival.selftest
import com.cobblemon.mod.common.Cobblemon
import com.cobblemon.mod.common.api.pokemon.experience.CandyExperienceSource
import com.cobblemon.mod.common.api.pokemon.experience.SidemodExperienceSource
import com.cobblemon.mod.common.api.spawning.CobblemonSpawnPools
import com.cobblemon.mod.common.api.spawning.SpawnCause
import com.cobblemon.mod.common.api.spawning.detail.PokemonSpawnDetail
@@ -9,6 +11,7 @@ import com.cobblemon.mod.common.api.spawning.position.calculators.SpawnablePosit
import com.cobblemon.mod.common.api.spawning.spawner.SpawningZoneInput
import com.cobblemon.mod.common.util.spawner
import dev.cobblesurvival.CobbleSurvival
import dev.cobblesurvival.zone.LevelPressure
import dev.cobblesurvival.zone.Zone
import dev.cobblesurvival.zone.ZoneSample
import dev.cobblesurvival.zone.Zones
@@ -16,7 +19,9 @@ import net.minecraft.core.BlockPos
import net.minecraft.gametest.framework.GameTestAssertException
import net.minecraft.server.level.ServerPlayer
import net.minecraft.util.Mth
import net.minecraft.world.item.ItemStack
import net.minecraft.world.level.levelgen.Heightmap
import kotlin.math.roundToInt
/**
* Roadmap Phase 3: each zone has its own Pokémon and levels. The spawn pools come from tools/zone_spawns.py;
@@ -30,10 +35,36 @@ object ZoneTests {
fun all() = listOf(
SelfTestCase("spawn_pools_are_zoned", 20, ::spawnPoolsAreZoned),
SelfTestCase("spawn_survey", 2400, ::spawnSurvey, islandOnly = true),
SelfTestCase("level_pressure_cuts_xp", 100, ::levelPressure),
)
private fun zoneOf(detail: SpawnDetail): Zone? = Zones.byId[detail.id.substringBefore('-')]
/** On Button Isle (band up to 10, 5 levels' grace): a Lv 10 Pokémon keeps its experience, a Lv 25 one 10 %. */
private fun levelPressure(s: Scenario) {
val zone = Zones.byId["button_isle"] ?: throw GameTestAssertException("no Button Isle zone")
val sample = Zones.samples["button_isle"] ?: throw GameTestAssertException("no sample point for Button Isle")
val player = s.player()
val inBand = s.give(player, s.pokemon("bidoof", zone.maxLevel))
val farAbove = s.give(player, s.pokemon("bidoof", zone.maxLevel + 15))
val expected = (1000 * LevelPressure.factor(farAbove.level, zone)).roundToInt()
s.helper.startSequence()
.thenExecute {
s.level.getChunk(sample.x shr 4, sample.z shr 4)
s.moveTo(player, sample.x + 0.5, s.level.getHeight(Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, sample.x, sample.z).toDouble(), sample.z + 0.5)
}
.thenExecute {
val battle = SidemodExperienceSource(CobbleSurvival.MOD_ID)
val kept = inBand.addExperienceWithPlayer(player, battle, 1000).experienceAdded
val cut = farAbove.addExperienceWithPlayer(player, battle, 1000).experienceAdded
val candy = farAbove.addExperienceWithPlayer(player, CandyExperienceSource(player, ItemStack.EMPTY), 1000).experienceAdded
s.check(kept == 1000) { "a Pokémon at the band's top got $kept of 1000 experience" }
s.check(cut == expected) { "a Pokémon 15 levels above the band got $cut of 1000 experience, not $expected" }
s.check(candy == 1000) { "Exp. Candy was cut too: $candy of 1000" }
}
.thenSucceed()
}
/** Every loaded spawn entry belongs to a zone and spawns within its level band; none of Cobblemon's is left. */
private fun spawnPoolsAreZoned(s: Scenario) {
val details = CobblemonSpawnPools.WORLD_SPAWN_POOL.details
@@ -0,0 +1,35 @@
package dev.cobblesurvival.zone
import com.cobblemon.mod.common.api.Priority
import com.cobblemon.mod.common.api.events.CobblemonEvents
import com.cobblemon.mod.common.api.events.pokemon.ExperienceGainedEvent
import com.cobblemon.mod.common.api.pokemon.experience.CandyExperienceSource
import com.cobblemon.mod.common.api.pokemon.experience.CommandExperienceSource
import kotlin.math.max
import kotlin.math.roundToInt
/**
* Soft level pressure (roadmap Phase 3; no hard level caps): a Pokémon far above the level band of the zone its
* trainer is in learns less from battles there. Up to [LevelPressureRules.graceLevels] above the band's top it
* gets full experience; every level beyond costs [LevelPressureRules.perLevel] of it, down to
* [LevelPressureRules.minimum]. Exp. Candy and commands are never cut.
*/
object LevelPressure {
fun register() {
CobblemonEvents.EXPERIENCE_GAINED_EVENT_PRE.subscribe(Priority.NORMAL, ::onExperience)
}
/** The share of experience a Pokémon of [level] keeps in [zone]. */
fun factor(level: Int, zone: Zone, rules: LevelPressureRules = Zones.levelPressure): Double {
val over = level - (zone.maxLevel + rules.graceLevels)
return if (over <= 0) 1.0 else max(rules.minimum, 1.0 - over * rules.perLevel)
}
private fun onExperience(event: ExperienceGainedEvent.Pre) {
if (event.source is CandyExperienceSource || event.source is CommandExperienceSource) return
val player = event.pokemon.getOwnerPlayer() ?: return
val zone = Zones.at(player.serverLevel(), player.blockPosition()) ?: return
val keep = factor(event.pokemon.level, zone)
if (keep < 1.0) event.experience = max(1, (event.experience * keep).roundToInt())
}
}
@@ -19,6 +19,9 @@ import net.neoforged.neoforge.event.AddReloadListenerEvent
/** One zone of the island (world/zones.json). */
data class Zone(val id: String, val name: String, val minLevel: Int, val maxLevel: Int)
/** Soft level pressure (world/zones.json → level_pressure). */
data class LevelPressureRules(val graceLevels: Int = 5, val perLevel: Double = 0.1, val minimum: Double = 0.1)
/** A point deep inside a zone (y only for cave zones), used by the spawn survey. */
data class ZoneSample(val x: Int, val y: Int?, val z: Int)
@@ -36,6 +39,8 @@ object Zones : SimpleJsonResourceReloadListener(Gson(), "zones") {
private var caves: Map<ResourceLocation, Zone> = emptyMap()
var samples: Map<String, ZoneSample> = emptyMap()
private set
var levelPressure = LevelPressureRules()
private set
/** For zones made of biomes: a point inside their own hazard biome (Frostpeak's jagged peaks, ...). */
var biomeSamples: Map<String, ZoneSample> = emptyMap()
private set
@@ -58,6 +63,13 @@ object Zones : SimpleJsonResourceReloadListener(Gson(), "zones") {
val caveZones = json.getAsJsonArray("caves").map { it.asJsonObject }
caves = caveZones.associate { ResourceLocation.parse(it.get("biome").asString) to zone(it) }
byId = (byIndex + caves.values).associateBy { it.id }
levelPressure = json.getAsJsonObject("level_pressure")?.let { rules ->
LevelPressureRules(
graceLevels = rules.get("grace_levels")?.asInt ?: 5,
perLevel = rules.get("per_level")?.asDouble ?: 0.1,
minimum = rules.get("minimum")?.asDouble ?: 0.1,
)
} ?: LevelPressureRules()
samples = samplesOf(json, "samples")
biomeSamples = samplesOf(json, "biome_samples")
radius = json.get("radius").asInt