Phase 4 play-test fixes: deep caves, fuller cave spawns, strays, shared loot
From Adrian's play-test (2026-10-30): - The Deep Dark no longer fills the deepslate layer: world/biomes.json splits it by temperature (Deep Dark under cold and temperate land, basalt deltas under the hottest, ordinary caves between); at y -52 it drops from 30 % to 14 % of the land, basalt 7 %. - Cave zones fill up to their targets with lines whose primary type fits the cave; top stages spawn up to the band's top (Deep Dark Lv 60, Basalt 70); no time-of-day conditions underground. - Wild Pokemon more than 10 levels above the band of the zone they wander into despawn (zone/Wanderers.kt). - Loot is shared: all players in one scoreboard team, Lootr team_loot. - Basalt ore nodes reach the deep basalt. Self-tests strays_despawn and loot_is_shared; the catching rules from the previous commit are now tested too. Template rebuilt (world check passed), island quick check passed (36 self-tests and the restart test). Shoulder Pokemon noted for Phase 5. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6361636da7
commit
d49684a2eb
40 files changed
+8212
-9412
No files matched your search
@@ -12,8 +12,10 @@ 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
|
||||
import dev.cobblesurvival.zone.Guardians
|
||||
import dev.cobblesurvival.zone.LevelPressure
|
||||
import dev.cobblesurvival.zone.Wanderers
|
||||
import dev.cobblesurvival.zone.ZoneCondition
|
||||
import dev.cobblesurvival.zone.ZoneHazards
|
||||
import dev.cobblesurvival.zone.Zones
|
||||
@@ -68,6 +70,8 @@ class CobbleSurvival {
|
||||
Gates.register()
|
||||
OreNodes.register()
|
||||
Catching.register()
|
||||
Wanderers.register()
|
||||
SharedLoot.register()
|
||||
ZoneHazards.register()
|
||||
LevelPressure.register()
|
||||
SelfTest.register()
|
||||
|
||||
@@ -3,6 +3,7 @@ package dev.cobblesurvival.selftest
|
||||
import com.mojang.brigadier.builder.LiteralArgumentBuilder
|
||||
import dev.cobblesurvival.CobbleSurvival
|
||||
import dev.cobblesurvival.catching.Catching
|
||||
import dev.cobblesurvival.zone.Wanderers
|
||||
import net.minecraft.commands.CommandSourceStack
|
||||
import net.minecraft.commands.Commands
|
||||
import net.minecraft.core.BlockPos
|
||||
@@ -53,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()
|
||||
CatchingTests.all() + WorldRulesTests.all()
|
||||
|
||||
private var running: List<Scenario> = emptyList()
|
||||
private var forced: List<ChunkPos> = emptyList()
|
||||
@@ -102,6 +103,7 @@ object SelfTest {
|
||||
}
|
||||
running = scenarios
|
||||
Catching.hostilityPaused = true
|
||||
Wanderers.paused = true
|
||||
GameTestTicker.SINGLETON.clear()
|
||||
GameTestRunner.Builder.fromInfo(infos, level)
|
||||
.newStructureSpawner(StructureGridSpawner(origin, TESTS_PER_ROW, false))
|
||||
@@ -132,5 +134,6 @@ object SelfTest {
|
||||
forced.forEach { level.setChunkForced(it.x, it.z, false) }
|
||||
running = emptyList()
|
||||
Catching.hostilityPaused = false
|
||||
Wanderers.paused = false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package dev.cobblesurvival.selftest
|
||||
|
||||
import dev.cobblesurvival.world.SharedLoot
|
||||
import dev.cobblesurvival.zone.Wanderers
|
||||
import dev.cobblesurvival.zone.Zones
|
||||
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.server.level.ServerPlayer
|
||||
import net.minecraft.world.InteractionHand
|
||||
import net.minecraft.world.RandomizableContainer
|
||||
import net.minecraft.world.inventory.ChestMenu
|
||||
import net.minecraft.world.item.ItemStack
|
||||
import net.minecraft.world.level.block.Blocks
|
||||
import net.minecraft.world.level.storage.loot.BuiltInLootTables
|
||||
import net.minecraft.world.phys.BlockHitResult
|
||||
import net.minecraft.world.phys.Vec3
|
||||
|
||||
/**
|
||||
* World rules from Adrian's play-test (2026-10-30): a wild Pokémon far above the band of the zone it wandered into
|
||||
* despawns (one from the Basalt Furnace turned up in the Lush Caves), and loot containers are shared by all players.
|
||||
* The stray case switches the stray rule on in a batch of its own.
|
||||
*/
|
||||
object WorldRulesTests {
|
||||
private val LOOTR_CHEST = ResourceLocation.parse("lootr:lootr_chest")
|
||||
|
||||
fun all() = listOf(
|
||||
SelfTestCase("strays_despawn", 400, ::straysDespawn, batch = "wanderers"),
|
||||
SelfTestCase("loot_is_shared", 40, ::lootIsShared),
|
||||
)
|
||||
|
||||
private fun straysDespawn(s: Scenario) {
|
||||
Wanderers.paused = false
|
||||
s.afterTest { Wanderers.paused = true }
|
||||
val level = s.level
|
||||
s.player() // the rule only looks near players
|
||||
val zone = Zones.at(level, s.helper.absolutePos(BlockPos(7, 1, 7)))
|
||||
?: throw GameTestAssertException("the test platform lies in no zone")
|
||||
val grace = Zones.wanderGrace ?: throw GameTestAssertException("no wanderers grace in the zone layout")
|
||||
val stray = s.wild(s.pokemon("geodude", zone.maxLevel + grace + 5), s.at(4.5, 1.0, 10.5))
|
||||
val local = s.wild(s.pokemon("geodude", zone.maxLevel), s.at(10.5, 1.0, 10.5))
|
||||
s.helper.startSequence()
|
||||
.thenWaitUntil { s.check(stray.isRemoved) { "a Lv ${stray.pokemon.level} wild Pokémon stays in ${zone.name} (Lv ${zone.minLevel}–${zone.maxLevel})" } }
|
||||
.thenExecute { s.check(!local.isRemoved) { "a Lv ${local.pokemon.level} wild Pokémon was removed from ${zone.name}, its own band" } }
|
||||
.thenSucceed()
|
||||
}
|
||||
|
||||
/** Player A empties a fresh Lootr chest; player B then finds it empty too. */
|
||||
private fun lootIsShared(s: Scenario) {
|
||||
val level = s.level
|
||||
val chest = BuiltInRegistries.BLOCK.get(LOOTR_CHEST)
|
||||
s.check(chest != Blocks.AIR) { "Lootr isn't installed" }
|
||||
val a = s.player()
|
||||
val b = s.player()
|
||||
val team = level.scoreboard.getPlayerTeam(SharedLoot.TEAM)
|
||||
s.check(team != null && a.team == team && b.team == team) { "the players aren't both in the team ${SharedLoot.TEAM}" }
|
||||
val pos = s.helper.absolutePos(BlockPos(7, 2, 7))
|
||||
level.setBlockAndUpdate(pos, chest.defaultBlockState())
|
||||
val container = level.getBlockEntity(pos) as? RandomizableContainer
|
||||
?: throw GameTestAssertException("the Lootr chest has no loot table to set")
|
||||
container.setLootTable(BuiltInLootTables.SIMPLE_DUNGEON, 1234L)
|
||||
|
||||
val found = open(s, a, pos)
|
||||
s.check(found > 0) { "the chest had no loot for the first player" }
|
||||
(a.containerMenu as ChestMenu).container.clearContent()
|
||||
a.closeContainer()
|
||||
val left = open(s, b, pos)
|
||||
b.closeContainer()
|
||||
s.check(left == 0) { "the second player found $left stacks in a chest the first one emptied: loot is per player" }
|
||||
s.helper.succeed()
|
||||
}
|
||||
|
||||
private fun open(s: Scenario, player: ServerPlayer, pos: BlockPos): Int {
|
||||
val hit = BlockHitResult(Vec3.atCenterOf(pos), Direction.UP, pos, false)
|
||||
player.gameMode.useItemOn(player, s.level, ItemStack.EMPTY, InteractionHand.MAIN_HAND, hit)
|
||||
val menu = player.containerMenu as? ChestMenu ?: throw GameTestAssertException("the Lootr chest didn't open for ${player.gameProfile.name}")
|
||||
return (0 until menu.container.containerSize).count { !menu.container.getItem(it).isEmpty }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package dev.cobblesurvival.world
|
||||
|
||||
import net.minecraft.server.level.ServerPlayer
|
||||
import net.neoforged.neoforge.common.NeoForge
|
||||
import net.neoforged.neoforge.event.entity.player.PlayerEvent
|
||||
|
||||
/**
|
||||
* Loot is shared, not per player (Adrian, 2026-10-30). Lootr gives every scoreboard team one inventory per loot
|
||||
* container when its `team_loot` is on (pack/config/lootr-common.toml), so every player joins the one team [TEAM] when
|
||||
* they log in: a container someone has emptied is empty for everyone until Lootr refills it (1 hour). The team keeps
|
||||
* Minecraft's defaults (friendly fire on, name tags as usual).
|
||||
*/
|
||||
object SharedLoot {
|
||||
const val TEAM = "islanders"
|
||||
|
||||
fun register() {
|
||||
NeoForge.EVENT_BUS.addListener<PlayerEvent.PlayerLoggedInEvent> { event -> (event.entity as? ServerPlayer)?.let(::join) }
|
||||
}
|
||||
|
||||
fun join(player: ServerPlayer) {
|
||||
val scoreboard = player.server.scoreboard
|
||||
val team = scoreboard.getPlayerTeam(TEAM) ?: scoreboard.addPlayerTeam(TEAM)
|
||||
if (scoreboard.getPlayersTeam(player.scoreboardName) != team) scoreboard.addPlayerToTeam(player.scoreboardName, team)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package dev.cobblesurvival.zone
|
||||
|
||||
import com.cobblemon.mod.common.entity.pokemon.PokemonEntity
|
||||
import net.minecraft.server.level.ServerLevel
|
||||
import net.minecraft.world.entity.ai.memory.MemoryModuleType
|
||||
import net.neoforged.neoforge.common.NeoForge
|
||||
import net.neoforged.neoforge.event.tick.ServerTickEvent
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Wild Pokémon keep to their zone's levels (Adrian, 2026-10-30: a Lv 61 Pokémon from the Basalt Furnace turned up in the
|
||||
* Lush Caves, Lv 12–28). Spawns follow the zones, but wild Pokémon wander and cave zones touch, so every few seconds a
|
||||
* wild Pokémon near a player that stands in a zone whose band it tops by more than [Zones.wanderGrace] levels
|
||||
* despawns, unless it is in a battle, angry at someone (it is chasing them) or a guardian. Grace: world/zones.json →
|
||||
* `wanderers`.
|
||||
*/
|
||||
object Wanderers {
|
||||
private const val EVERY_TICKS = 100
|
||||
private const val RANGE = 64.0
|
||||
|
||||
/** The self-tests switch this off, except in their own batch: other tests put strong wild Pokémon on the platform. */
|
||||
var paused = false
|
||||
|
||||
fun register() {
|
||||
NeoForge.EVENT_BUS.addListener(::onServerTick)
|
||||
}
|
||||
|
||||
private fun onServerTick(event: ServerTickEvent.Post) {
|
||||
if (paused || event.server.tickCount % EVERY_TICKS != 0) return
|
||||
val grace = Zones.wanderGrace ?: return
|
||||
val checked = HashSet<UUID>()
|
||||
for (player in event.server.playerList.players) {
|
||||
val level = player.serverLevel()
|
||||
level.getEntitiesOfClass(PokemonEntity::class.java, player.boundingBox.inflate(RANGE)) { it.pokemon.isWild() }
|
||||
.filter { checked.add(it.uuid) && strayed(level, it, grace) }
|
||||
.forEach { it.discard() }
|
||||
}
|
||||
}
|
||||
|
||||
fun strayed(level: ServerLevel, wild: PokemonEntity, grace: Int): Boolean {
|
||||
if (wild.isBattling || wild.pokemon.persistentData.contains(Guardians.TAG)) return false
|
||||
if (wild.brain.hasMemoryValue(MemoryModuleType.ATTACK_TARGET)) return false
|
||||
val zone = Zones.at(level, wild.blockPosition()) ?: return false
|
||||
return wild.pokemon.level > zone.maxLevel + grace
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,9 @@ object Zones : SimpleJsonResourceReloadListener(Gson(), "zones") {
|
||||
private set
|
||||
var levelPressure = LevelPressureRules()
|
||||
private set
|
||||
/** How far above its zone's band a wild Pokémon may stray before it despawns ([Wanderers]); null: never. */
|
||||
var wanderGrace: Int? = null
|
||||
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
|
||||
@@ -70,6 +73,7 @@ object Zones : SimpleJsonResourceReloadListener(Gson(), "zones") {
|
||||
minimum = rules.get("minimum")?.asDouble ?: 0.1,
|
||||
)
|
||||
} ?: LevelPressureRules()
|
||||
wanderGrace = json.getAsJsonObject("wanderers")?.get("grace_levels")?.asInt
|
||||
samples = samplesOf(json, "samples")
|
||||
biomeSamples = samplesOf(json, "biome_samples")
|
||||
radius = json.get("radius").asInt
|
||||
|
||||
Reference in new issue
Block a user