Phase 9: safe start island, better quest book, every useful item obtainable

- Button Isle is peaceful: wild Pokémon only fight a player who hit them first
  (tuning/server.json peaceful_zones); its apricorns and coal make Poké Balls
  (4 apricorns + coal or charcoal), and the world report checks it has enough.
- Quest book: no boilerplate; each zone page says what is particular to it, and
  every zone that needs gear or a field move to be safe has its own quest. New
  page on the bigger mods (Cold Sweat, Fight or Flight, Cobblebase, breeding,
  veins, shared loot, maps and info).
- The Mire's poison stops 10 blocks below the surface.
- Player battles never cost a Pokémon (CobbleHardcoreMon config, mixins, and the
  companion mod's own permadeath).
- tools/item_audit.py: every item with a use has a source on the island; new
  sources for the gaps it found (species additions, three recipes).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
claudeandClaude Opus 5.5 committed 2026-11-10 15:04:09 +01:00
1 parent 6ace26e012
commit d29e177f9d
65 files changed
+1920 -364

No files matched your search

@@ -1,5 +1,15 @@
package dev.cobblesurvival.selftest
import net.minecraft.world.phys.Vec3
import net.minecraft.world.level.levelgen.Heightmap
import net.minecraft.gametest.framework.GameTestAssertException
import net.minecraft.world.item.crafting.RecipeType
import net.minecraft.world.item.crafting.CraftingInput
import net.minecraft.world.item.ItemStack
import net.minecraft.resources.ResourceLocation
import net.minecraft.core.registries.BuiltInRegistries
import dev.cobblesurvival.zone.Zones
import dev.cobblesurvival.zone.PeacefulZones
import com.cobblemon.mod.common.battles.BattleRegistry
import com.cobblemon.mod.common.api.events.CobblemonEvents
import com.cobblemon.mod.common.api.events.pokeball.PokeBallCaptureCalculatedEvent
@@ -26,6 +36,8 @@ object CatchingTests {
fun all() = listOf(
SelfTestCase("catch_rate_falls_with_level_gap", 20, ::catchRate, batch = "catching"),
SelfTestCase("failed_catch_enrages", 400, ::failedCatch, batch = "catching"),
SelfTestCase("button_isle_pokemon_leave_players_alone", 200, ::peaceful, islandOnly = true, batch = "peaceful"),
SelfTestCase("start_isle_materials_make_a_poke_ball", 20, ::startBall),
SelfTestCase("wild_attack_starts_a_battle", 300, ::attackBattle, batch = "attack_battle"),
SelfTestCase("wild_attack_hurts_without_a_party", 120, ::attackHurts, batch = "attack_battle"),
SelfTestCase("strong_wild_pokemon_hunt_weak_trainers", 400, { s -> hostility(s, strongest = 5, hostile = true) },
@@ -50,6 +62,57 @@ object CatchingTests {
s.helper.succeed()
}
/**
* Adrian, 2026-11-10: on Button Isle a wild Pokémon leaves a player alone, even
* one with no Pokémon whom it would otherwise hunt, unless the player hits it first.
*/
private fun peaceful(s: Scenario) {
PeacefulZones.paused = false
Catching.hostilityPaused = false
s.afterTest {
PeacefulZones.paused = true
Catching.hostilityPaused = true
}
// On the island's surface: the platform's height counts as a cave biome.
val sample = Zones.samples["button_isle"] ?: throw GameTestAssertException("no sample point for Button Isle")
s.level.getChunk(sample.x shr 4, sample.z shr 4)
val y = s.level.getHeight(Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, sample.x, sample.z).toDouble()
val player = s.player()
s.moveTo(player, sample.x + 0.5, y, sample.z + 0.5)
val wild = s.wild(s.pokemon("primeape", 40, "karatechop"), Vec3(sample.x + 3.5, y, sample.z + 0.5))
s.check(Zones.at(s.level, player.blockPosition())?.id == "button_isle") { "the player isn't on Button Isle" }
val health = player.health
s.helper.startSequence()
.thenIdle(SPAWN_PROTECTION_TICKS)
.thenExecute {
s.check(Catching.ENRAGED !in wild.pokemon.forcedAspects) { "a stronger Pokémon was angered on Button Isle" }
Catching.enrage(wild, player) // as if something else had set it on the player
}
.thenIdle(25)
.thenExecute {
s.check(!PeacefulZones.targets(wild, player)) { "the wild Pokémon still goes for the player" }
s.check(!player.hurt(wild.damageSources().mobAttack(wild), 6f)) { "an unprovoked hit went through on Button Isle" }
s.check(player.health == health) { "the player lost health" }
wild.hurt(player.damageSources().playerAttack(player), 1f)
s.check(player.hurt(wild.damageSources().mobAttack(wild), 6f)) { "a Pokémon the player hit couldn't hit back" }
}
.thenSucceed()
}
/** Adrian, 2026-11-10: the start island can make a Poké Ball: four apricorns of any colour and coal or charcoal. */
private fun startBall(s: Scenario) {
val level = s.level
fun item(id: String) = ItemStack(BuiltInRegistries.ITEM.get(ResourceLocation.parse(id)))
for (fuel in listOf("minecraft:coal", "minecraft:charcoal")) {
val grid = listOf(ItemStack.EMPTY, item("cobblemon:white_apricorn"), ItemStack.EMPTY, item("cobblemon:red_apricorn"), item(fuel),
item("cobblemon:pink_apricorn"), ItemStack.EMPTY, item("cobblemon:green_apricorn"), ItemStack.EMPTY)
val input = CraftingInput.of(3, 3, grid)
val made = level.recipeManager.getRecipeFor(RecipeType.CRAFTING, input, level).map { it.value.assemble(input, level.registryAccess()) }.orElse(ItemStack.EMPTY)
s.check(made.`is`(item("cobblemon:poke_ball").item)) { "four apricorns and $fuel make ${made.hoverName.string}, not a Poké Ball" }
}
s.helper.succeed()
}
private fun attackBattle(s: Scenario) {
val player = s.player()
s.give(player, s.pokemon("charizard", 50, "ember"))
@@ -1,5 +1,7 @@
package dev.cobblesurvival.selftest
import net.minecraft.world.level.block.Blocks
import net.minecraft.core.BlockPos
import dev.cobblesurvival.CobbleSurvival
import dev.cobblesurvival.zone.ZoneSample
import dev.cobblesurvival.zone.Zones
@@ -133,6 +135,7 @@ object HazardTests {
val bare = s.player()
val wading = s.player().also { it.setItemSlot(EquipmentSlot.FEET, ItemStack(waders)) }
val outside = s.player()
val below = s.player()
fun surface(sample: ZoneSample): Double {
level.getChunk(sample.x shr 4, sample.z shr 4)
return level.getHeight(Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, sample.x, sample.z).toDouble()
@@ -141,6 +144,10 @@ object HazardTests {
.thenExecute {
for (player in listOf(bare, wading)) s.moveTo(player, mire.x + 0.5, surface(mire), mire.z + 0.5)
s.moveTo(outside, elsewhere.x + 0.5, surface(elsewhere), elsewhere.z + 0.5)
// Deep in the rock under the Mire (a pocket cut for it), where the swamp's poison doesn't reach.
val deep = BlockPos(mire.x + 2, surface(mire).toInt() - 30, mire.z)
for (dy in 0..1) level.setBlockAndUpdate(deep.above(dy), Blocks.AIR.defaultBlockState())
s.moveTo(below, deep.x + 0.5, deep.y.toDouble(), deep.z + 0.5)
}
.thenIdle(220)
.thenExecute {
@@ -148,6 +155,7 @@ object HazardTests {
s.check(bare.hasEffect(poison)) { "a player without waders in the Mire isn't poisoned" }
s.check(!wading.hasEffect(poison)) { "a player in Mire Waders got poisoned" }
s.check(!outside.hasEffect(poison)) { "a player on Button Isle got poisoned" }
s.check(!below.hasEffect(poison)) { "a player deep under the Mire got poisoned" }
}
.thenSucceed()
}
@@ -1,5 +1,6 @@
package dev.cobblesurvival.selftest
import dev.cobblesurvival.permadeath.Permadeath
import com.cobblemon.mod.common.Cobblemon
import com.cobblemon.mod.common.api.npc.NPCClasses
import com.cobblemon.mod.common.api.pokemon.status.Statuses
@@ -37,6 +38,7 @@ object PermadeathTests {
SelfTestCase("wild_battle_charm", BATTLE_TICKS, { wildBattle(it, charm = true) }),
SelfTestCase("revived_during_battle", BATTLE_TICKS, ::revivedDuringBattle),
SelfTestCase("trainer_battle", BATTLE_TICKS, ::trainerBattle),
SelfTestCase("player_battle_only_faints", BATTLE_TICKS, ::playerBattle),
)
/** Cobblemon heals the party while the player sleeps and wakes fainted Pokémon after a while; both are off. */
@@ -160,6 +162,32 @@ object PermadeathTests {
.thenSucceed()
}
/** Adrian, 2026-11-10: a Pokémon that faints in a battle between players stays, fainted, until healed. */
private fun playerBattle(s: Scenario) {
val loser = s.player()
val winner = s.player()
val pokemon = s.give(loser, s.pokemon("bidoof", 5, "explosion"))
s.give(winner, s.pokemon("bidoof", 50, "splash"))
s.answerBattles(loser) { _, _ -> MoveActionResponse("explosion") }
s.answerBattles(winner) { _, _ -> MoveActionResponse("splash") }
var ended = -1L
s.helper.startSequence()
.thenExecute { s.started(BattleBuilder.pvp1v1(loser, winner)) }
.thenWaitUntil {
s.check(!loser.isInBattle()) { "the battle is still running: ${s.battleState(loser)}" }
if (ended < 0) ended = s.level.gameTime
s.check(s.level.gameTime - ended > 60) { "waiting for the permadeath sweeps" }
}
.thenExecute {
s.check(Cobblemon.storage.getParty(loser).any { it.uuid == pokemon.uuid }) { "the Pokémon that fainted in a player battle was removed" }
s.check(pokemon.isFainted()) { "it isn't fainted" }
pokemon.heal()
}
.thenIdle(30)
.thenExecute { s.check(!pokemon.persistentData.contains(Permadeath.PVP_FAINT)) { "healed, it still carries the player-battle mark" } }
.thenSucceed()
}
private fun wildBattle(s: Scenario, charm: Boolean) {
val player = s.player()
val pokemon = s.give(player, s.pokemon("bidoof", 5, "explosion"))
@@ -1,5 +1,6 @@
package dev.cobblesurvival.selftest
import dev.cobblesurvival.zone.PeacefulZones
import com.mojang.brigadier.builder.LiteralArgumentBuilder
import dev.cobblesurvival.CobbleSurvival
import dev.cobblesurvival.catching.Catching
@@ -104,6 +105,7 @@ object SelfTest {
running = scenarios
Catching.hostilityPaused = true
Wanderers.paused = true
PeacefulZones.paused = true
GameTestTicker.SINGLETON.clear()
GameTestRunner.Builder.fromInfo(infos, level)
.newStructureSpawner(StructureGridSpawner(origin, TESTS_PER_ROW, false))
@@ -135,5 +137,6 @@ object SelfTest {
running = emptyList()
Catching.hostilityPaused = false
Wanderers.paused = false
PeacefulZones.paused = false
}
}