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