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:
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
|
||||
|
||||
Reference in new issue
Block a user