Phase 7: boss fights with area attacks, guardians reviewed, End boss

Adrian (2026-11-02): every zone guardian gets area attacks (harder in
harder zones); a boss for the Basalt Furnace; a Pokémon replaces the
Ender Dragon; dying or leaving the arena loses; the Deep Dark gets a
legendary and every guardian should fit its zone. Groudon, Giratina and
Meloetta have no model in Cobblemon 1.8.4: Moltres, Eternatus and
Regigigas stand in.

- Guardians: Mudsdale, Flygon, Abomasnow, Regigigas (normal size), new
  Moltres for the Basalt Furnace; zone_spawns.py takes a guardian
  "scale".
- tools/zone_bosses.py writes bosses/<zone>.json and end.json from a
  tier table (circle, line, cone, rings; telegraph, damage, triggers,
  fire/ice/poison effects, arena).
- boss/: BossFights runs patterns during a guardian or End boss battle
  (particle telegraphs, hits players inside, stops on battle end),
  enforces the arena and death as a loss (battle ends, boss heals);
  EndBoss keeps the dragon out (allowlist emptied), spawns Eternatus on
  the main island with the dragon's boss bar, and on defeat opens the
  exit portal, gateway and egg via the vanilla dragon fight, plus XP.
- Self-tests: coverage (incl. Cobblemon models), shapes, landing and
  stopping, arena loss, End boss. Docs, balance, Phase 7 checklist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
claudeandClaude Opus 5.5 committed 2026-11-02 16:22:10 +01:00
1 parent dc4568e023
commit cd232bdf6a
32 files changed
+1420 -24

No files matched your search

@@ -0,0 +1,172 @@
package dev.cobblesurvival.selftest
import com.cobblemon.mod.common.api.pokemon.PokemonProperties
import com.cobblemon.mod.common.api.pokemon.PokemonSpecies
import com.cobblemon.mod.common.battles.BattleRegistry
import dev.cobblesurvival.boss.Area
import dev.cobblesurvival.boss.BossDefs
import dev.cobblesurvival.boss.BossFights
import dev.cobblesurvival.boss.EndBoss
import dev.cobblesurvival.zone.Guardians
import net.minecraft.core.BlockPos
import net.minecraft.gametest.framework.GameTestAssertException
import net.minecraft.world.entity.EntityType
import net.minecraft.world.entity.boss.enderdragon.EnderDragon
import net.minecraft.world.level.ChunkPos
import net.minecraft.world.level.Level
import net.minecraft.world.level.block.Blocks
import net.minecraft.world.phys.Vec3
/**
* Boss fights (roadmap Phase 7; Adrian, 2026-11-02). Done-when: a test boss telegraphs and lands each pattern type,
* attacks stop on battle end, and leaving the arena forfeits. Also: every guardian has a fight and is a Pokémon Cobblemon
* has a model for, and a Pokémon replaces the Ender Dragon.
*/
object BossTests {
/** The test boss: a 24-block arena, no attacks of its own. */
private val TEST_BOSS = BossDefs.SELFTEST.id.toString()
fun all() = listOf(
SelfTestCase("bosses_cover_every_guardian", 20, ::coverage),
SelfTestCase("boss_attack_shapes", 20, ::shapes),
SelfTestCase("boss_attacks_land_and_stop_with_the_battle", 400, ::landAndStop),
SelfTestCase("boss_arena_leaving_loses", 400, ::arena),
SelfTestCase("end_boss_replaces_the_dragon", 200, ::endBoss),
)
private fun implemented(species: String) = PokemonSpecies.getByName(species)?.implemented == true
private fun coverage(s: Scenario) {
val missing = Guardians.definitions.keys - BossDefs.byGuardian.keys
s.check(missing.isEmpty()) { "guardians without a boss fight: $missing" }
val noModel = Guardians.definitions.values.map { PokemonProperties.parse(it.pokemon).species ?: "?" }.filterNot(::implemented)
s.check(noModel.isEmpty()) { "guardians Cobblemon has no model for: $noModel" }
val end = BossDefs.end?.end ?: throw GameTestAssertException("no End boss")
s.check(implemented(end.species)) { "the End boss ${end.species} has no model in Cobblemon" }
s.check(BossDefs.byId.values.all { it.patterns.isNotEmpty() }) { "a boss without attacks" }
s.helper.succeed()
}
private fun shapes(s: Scenario) {
val o = Vec3(0.0, 64.0, 0.0)
val circle = Area.Circle(o, 3.0)
s.check(circle.contains(Vec3(2.0, 64.0, 0.0)) && !circle.contains(Vec3(4.0, 64.0, 0.0))) { "circle" }
s.check(!circle.contains(Vec3(0.0, 70.0, 0.0))) { "the circle reaches 6 blocks up" }
val ring = Area.Ring(o, 3.0, 8.0)
s.check(ring.contains(Vec3(5.0, 64.0, 0.0)) && !ring.contains(Vec3(1.0, 64.0, 0.0)) && !ring.contains(Vec3(9.0, 64.0, 0.0))) { "ring" }
val line = Area.Line(o, 0.0, 10.0, 1.0) // along +x
s.check(line.contains(Vec3(8.0, 64.0, 0.5)) && !line.contains(Vec3(8.0, 64.0, 2.0)) && !line.contains(Vec3(-2.0, 64.0, 0.0))) { "line" }
val cone = Area.Cone(o, Math.PI / 2, 10.0, Math.toRadians(30.0)) // along +z
s.check(cone.contains(Vec3(2.0, 64.0, 8.0)) && !cone.contains(Vec3(8.0, 64.0, 2.0)) && !cone.contains(Vec3(0.0, 64.0, 11.0))) { "cone" }
s.check(listOf(circle, ring, line, cone).all { it.outline().isNotEmpty() }) { "a shape without telegraph points" }
s.helper.succeed()
}
private fun pattern(shape: BossDefs.Shape, aim: BossDefs.Aim) = BossDefs.Pattern(
shape, aim, radius = 2.0, inner = 1.0, outer = 4.0, length = 8.0, width = 2.0, angle = 60.0,
telegraphTicks = 10, damage = 4f, effect = BossDefs.Effect.NONE, trigger = BossDefs.Trigger(),
)
/** A wild Pokémon that fights as the [TEST_BOSS] boss, and a player with a Pokémon battling it. */
private fun battle(s: Scenario): Triple<net.minecraft.server.level.ServerPlayer, com.cobblemon.mod.common.entity.pokemon.PokemonEntity, () -> BossFights.Fight?> {
val player = s.player()
// New players are protected for their first 3 seconds; the test needn't wait for that.
net.minecraft.server.level.ServerPlayer::class.java.getDeclaredField("spawnInvulnerableTime").apply { isAccessible = true }.setInt(player, 0)
s.give(player, s.pokemon("pikachu", 50))
val bossPokemon = s.pokemon("morpeko", 15)
bossPokemon.persistentData.putString(BossFights.TAG, TEST_BOSS)
val boss = s.wild(bossPokemon, s.at(7.5, 1.0, 3.5))
val fight = { BattleRegistry.getBattleByParticipatingPlayer(player)?.let(BossFights::fightOf) }
return Triple(player, boss, fight)
}
private fun landAndStop(s: Scenario) {
val (player, boss, fight) = battle(s)
var health = 0f
val shapes = BossDefs.Shape.entries.toMutableList()
s.startWildBattle(player, boss)
.thenWaitUntil { s.check(fight() != null) { "the battle with the test boss is no boss fight" } }
.thenExecute {
player.health = player.maxHealth
health = player.health
}
.let { seq ->
// Each shape, aimed so the player stands inside, lands: the player loses health each time.
BossDefs.Shape.entries.fold(seq) { acc, shape ->
acc.thenExecute {
health = player.health
val aim = if (shape == BossDefs.Shape.RING) BossDefs.Aim.BOSS else BossDefs.Aim.PLAYER
val target = if (shape == BossDefs.Shape.RING) player.position() else player.position()
val f = fight()!!
// A ring around the boss: move the player into it first.
if (shape == BossDefs.Shape.RING) player.teleportTo(boss.x + 2.5, boss.y, boss.z)
BossFights.cast(f, pattern(shape, aim), if (shape == BossDefs.Shape.RING) player.position() else target)
}.thenIdle(15).thenExecute {
s.check(player.health < health) { "${shape.name.lowercase()} didn't hit the player standing in it (health $health -> ${player.health})" }
shapes.remove(shape)
}
}
}
.thenExecute {
health = player.health
BossFights.cast(fight()!!, pattern(BossDefs.Shape.CIRCLE, BossDefs.Aim.PLAYER), player.position().add(10.0, 0.0, 0.0))
}
.thenIdle(15)
.thenExecute {
// Health may only go up (natural regeneration), never down.
s.check(player.health >= health) { "a circle 10 blocks away hit the player" }
BossFights.cast(fight()!!, pattern(BossDefs.Shape.CIRCLE, BossDefs.Aim.PLAYER), player.position())
BattleRegistry.getBattleByParticipatingPlayer(player)!!.end()
health = player.health
}
.thenIdle(15)
.thenExecute {
s.check(player.health >= health) { "an attack landed after the battle ended" }
s.check(fight() == null) { "the boss fight goes on after the battle ended" }
}
.thenSucceed()
}
private fun arena(s: Scenario) {
val (player, boss, fight) = battle(s)
s.startWildBattle(player, boss)
.thenWaitUntil { s.check(fight() != null) { "the battle with the test boss is no boss fight" } }
.thenExecute {
boss.pokemon.currentHealth = 1
val arena = fight()!!.def.arena
player.teleportTo(boss.x + arena + 6, boss.y, boss.z)
}
.thenWaitUntil {
s.check(BattleRegistry.getBattleByParticipatingPlayer(player) == null) { "the battle goes on with the player outside the arena" }
}
.thenExecute {
s.check(boss.isAlive && boss.pokemon.currentHealth == boss.pokemon.maxHealth) { "the boss didn't stay and heal after the lost fight" }
}
.thenSucceed()
}
private fun endBoss(s: Scenario) {
val end = s.level.server.getLevel(Level.END) ?: throw GameTestAssertException("no End")
val chunks = (-2..2).flatMap { x -> (-2..2).map { z -> ChunkPos(x, z) } }
chunks.forEach { end.setChunkForced(it.x, it.z, true) }
val player = s.player()
s.afterTest {
EndBoss.current(end)?.discard()
chunks.forEach { end.setChunkForced(it.x, it.z, false) }
}
val dragon = EnderDragon(EntityType.ENDER_DRAGON, end)
dragon.moveTo(0.0, 80.0, 0.0)
s.check(!end.addFreshEntity(dragon) || dragon.isRemoved) { "an Ender Dragon could join the End" }
val boss = EndBoss.spawn(end) ?: throw GameTestAssertException("the End boss didn't appear")
s.check(boss.pokemon.species.name.equals(BossDefs.end!!.end!!.species, ignoreCase = true) && boss.pokemon.isUncatchable()) {
"the End boss is a catchable ${boss.pokemon.species.name}"
}
val xp = player.totalExperience
EndBoss.defeated(end, listOf(player))
s.check(player.totalExperience > xp) { "no experience for beating the End boss" }
val area = BlockPos.betweenClosed(BlockPos(-4, 0, -4), BlockPos(4, 255, 4))
s.check(area.any { end.getBlockState(it).`is`(Blocks.END_PORTAL) }) { "no exit portal after the End boss was beaten" }
s.check(area.any { end.getBlockState(it).`is`(Blocks.DRAGON_EGG) }) { "no dragon egg after the first win" }
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() + ShoulderTests.all() + BaseLootTests.all() + BreedingTests.all() + MachineTests.all()
CatchingTests.all() + WorldRulesTests.all() + FieldMoveTests.all() + ShoulderTests.all() + BaseLootTests.all() + BreedingTests.all() + MachineTests.all() + BossTests.all()
private var running: List<Scenario> = emptyList()
private var forced: List<ChunkPos> = emptyList()