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

@@ -9,6 +9,8 @@ import dev.cobblesurvival.dev.OreVeins
import dev.cobblesurvival.ore.BaseLoot
import dev.cobblesurvival.ore.OreNodes
import dev.cobblesurvival.permadeath.Permadeath
import dev.cobblesurvival.boss.BossFights
import dev.cobblesurvival.boss.EndBoss
import dev.cobblesurvival.machine.MachineShowcase
import dev.cobblesurvival.machine.Machines
import dev.cobblesurvival.party.ShoulderPassives
@@ -74,6 +76,8 @@ class CobbleSurvival {
ZoneCondition.register()
NeoForge.EVENT_BUS.addListener(Zones::onAddReloadListeners)
Guardians.register()
BossFights.register()
EndBoss.register()
Gates.register()
ShoulderPassives.register()
OreNodes.register()
@@ -0,0 +1,172 @@
package dev.cobblesurvival.boss
import com.google.gson.Gson
import com.google.gson.JsonElement
import com.google.gson.JsonObject
import dev.cobblesurvival.CobbleSurvival
import net.minecraft.resources.ResourceLocation
import net.minecraft.server.packs.resources.ResourceManager
import net.minecraft.server.packs.resources.SimpleJsonResourceReloadListener
import net.minecraft.util.profiling.ProfilerFiller
import net.minecraft.world.phys.Vec3
import net.neoforged.neoforge.common.NeoForge
import net.neoforged.neoforge.event.AddReloadListenerEvent
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.hypot
import kotlin.math.sin
/**
* Boss fights (roadmap Phase 7), `data/cobblesurvival/bosses/<id>.json` (tools/zone_bosses.py): which fight (a zone's
* guardian, or the End boss), the arena radius and the area attacks. See docs/design.md.
*/
object BossDefs : SimpleJsonResourceReloadListener(Gson(), "bosses") {
enum class Shape { CIRCLE, RING, LINE, CONE }
enum class Aim { PLAYER, BOSS }
enum class Effect { NONE, FIRE, ICE, POISON }
class Trigger(val everyTicks: Int = 0, val offset: Int = 0, val everyTurns: Int = 0, val belowHealth: Double = 0.0)
class Pattern(
val shape: Shape,
val aim: Aim,
val radius: Double, val inner: Double, val outer: Double, val length: Double, val width: Double, val angle: Double,
val telegraphTicks: Int,
val damage: Float,
val effect: Effect,
val trigger: Trigger,
) {
/** Where this pattern lands, aimed from [boss] at [target]. */
fun area(boss: Vec3, target: Vec3): Area {
val origin = if (aim == Aim.BOSS) boss else target
return when (shape) {
Shape.CIRCLE -> Area.Circle(origin, radius)
Shape.RING -> Area.Ring(origin, inner, outer)
Shape.LINE -> Area.Line(boss, heading(boss, target), length, width / 2)
Shape.CONE -> Area.Cone(boss, heading(boss, target), length, Math.toRadians(angle / 2))
}
}
private fun heading(from: Vec3, to: Vec3) = atan2(to.z - from.z, to.x - from.x)
}
class EndBoss(val species: String, val level: Int, val scale: Float, val respawnHours: Double, val xpFirstWin: Int, val xp: Int)
class Def(val id: ResourceLocation, val guardian: String?, val end: EndBoss?, val arena: Double, val patterns: List<Pattern>)
var byId: Map<ResourceLocation, Def> = emptyMap()
private set
/** For the self-tests: a boss with a 24-block arena and no attacks of its own (they cast theirs). */
val SELFTEST = Def(ResourceLocation.fromNamespaceAndPath(CobbleSurvival.MOD_ID, "selftest"), null, null, 24.0, emptyList())
var byGuardian: Map<String, Def> = emptyMap()
private set
var end: Def? = null
private set
fun register() {
NeoForge.EVENT_BUS.addListener<AddReloadListenerEvent> { it.addListener(this) }
}
override fun apply(files: Map<ResourceLocation, JsonElement>, resources: ResourceManager, profiler: ProfilerFiller) {
val defs = files.mapNotNull { (id, element) ->
runCatching { parse(id, element.asJsonObject) }
.onFailure { CobbleSurvival.LOGGER.error("Bad boss definition {}: {}", id, it.message) }
.getOrNull()
}
byId = defs.associateBy { it.id }
byGuardian = defs.filter { it.guardian != null }.associateBy { it.guardian!! }
end = defs.firstOrNull { it.end != null }
CobbleSurvival.LOGGER.info("Loaded {} boss fights ({} guardians, end: {})", defs.size, byGuardian.size, end?.end?.species)
}
private fun parse(id: ResourceLocation, json: JsonObject): Def {
val end = json.getAsJsonObject("end")?.let {
EndBoss(it.get("species").asString, it.get("level").asInt, it.get("scale").asFloat, it.get("respawn_hours").asDouble,
it.get("xp_first_win").asInt, it.get("xp").asInt)
}
val patterns = json.getAsJsonArray("patterns").map { it.asJsonObject }.map { p ->
fun num(key: String) = p.get(key)?.asDouble ?: 0.0
val t = p.getAsJsonObject("trigger")
Pattern(
Shape.valueOf(p.get("shape").asString.uppercase()), Aim.valueOf(p.get("aim").asString.uppercase()),
num("radius"), num("inner"), num("outer"), num("length"), num("width"), num("angle"),
p.get("telegraph_ticks").asInt, p.get("damage").asFloat, Effect.valueOf(p.get("effect")?.asString?.uppercase() ?: "NONE"),
Trigger(t.get("every_ticks")?.asInt ?: 0, t.get("offset")?.asInt ?: 0, t.get("every_turns")?.asInt ?: 0, t.get("below_health")?.asDouble ?: 0.0),
)
}
return Def(id, json.get("guardian")?.asString, end, json.get("arena").asDouble, patterns)
}
}
/** Where an area attack lands, flat on the ground at its origin's height (±[REACH_Y] blocks). */
sealed class Area(val origin: Vec3) {
abstract fun containsFlat(x: Double, z: Double): Boolean
/** Points along the outline, for the telegraph's particles. */
abstract fun outline(): List<Vec3>
fun contains(pos: Vec3) = kotlin.math.abs(pos.y - origin.y) <= REACH_Y && containsFlat(pos.x, pos.z)
protected fun at(x: Double, z: Double) = Vec3(x, origin.y + 0.1, z)
protected fun circle(r: Double) = (0 until maxOf(12, (r * 2 * Math.PI / STEP).toInt())).map { i ->
val a = i * 2 * Math.PI / maxOf(12, (r * 2 * Math.PI / STEP).toInt())
at(origin.x + r * cos(a), origin.z + r * sin(a))
}
class Circle(origin: Vec3, val radius: Double) : Area(origin) {
override fun containsFlat(x: Double, z: Double) = hypot(x - origin.x, z - origin.z) <= radius
override fun outline() = circle(radius) + circle(radius / 2)
}
class Ring(origin: Vec3, val inner: Double, val outer: Double) : Area(origin) {
override fun containsFlat(x: Double, z: Double) = hypot(x - origin.x, z - origin.z).let { it in inner..outer }
override fun outline() = circle(inner) + circle(outer)
}
/** From [origin] along [heading] (radians, x towards z), [length] long, [halfWidth] to each side. */
class Line(origin: Vec3, val heading: Double, val length: Double, val halfWidth: Double) : Area(origin) {
override fun containsFlat(x: Double, z: Double): Boolean {
val dx = x - origin.x
val dz = z - origin.z
val along = dx * cos(heading) + dz * sin(heading)
val across = -dx * sin(heading) + dz * cos(heading)
return along in 0.0..length && kotlin.math.abs(across) <= halfWidth
}
override fun outline() = (0..(length / STEP).toInt()).flatMap { i ->
val along = i * STEP
listOf(-halfWidth, halfWidth).map { side ->
at(origin.x + along * cos(heading) - side * sin(heading), origin.z + along * sin(heading) + side * cos(heading))
}
}
}
/** From [origin] along [heading], [length] far, [halfAngle] radians to each side. */
class Cone(origin: Vec3, val heading: Double, val length: Double, val halfAngle: Double) : Area(origin) {
override fun containsFlat(x: Double, z: Double): Boolean {
val distance = hypot(x - origin.x, z - origin.z)
if (distance > length) return false
if (distance < 0.5) return true
var off = atan2(z - origin.z, x - origin.x) - heading
while (off > Math.PI) off -= 2 * Math.PI
while (off < -Math.PI) off += 2 * Math.PI
return kotlin.math.abs(off) <= halfAngle
}
override fun outline(): List<Vec3> {
val edges = (0..(length / STEP).toInt()).flatMap { i ->
listOf(heading - halfAngle, heading + halfAngle).map { a -> at(origin.x + i * STEP * cos(a), origin.z + i * STEP * sin(a)) }
}
val arcSteps = maxOf(4, (length * 2 * halfAngle / STEP).toInt())
val arc = (0..arcSteps).map { i ->
val a = heading - halfAngle + 2 * halfAngle * i / arcSteps
at(origin.x + length * cos(a), origin.z + length * sin(a))
}
return edges + arc
}
}
companion object {
const val REACH_Y = 3.0
private const val STEP = 0.6
}
}
@@ -0,0 +1,193 @@
package dev.cobblesurvival.boss
import com.cobblemon.mod.common.api.Priority
import com.cobblemon.mod.common.api.battles.model.PokemonBattle
import com.cobblemon.mod.common.api.events.CobblemonEvents
import com.cobblemon.mod.common.api.events.battles.BattleStartedEvent
import com.cobblemon.mod.common.entity.pokemon.PokemonEntity
import dev.cobblesurvival.CobbleSurvival
import dev.cobblesurvival.zone.Guardians
import net.minecraft.ChatFormatting
import net.minecraft.core.particles.DustParticleOptions
import net.minecraft.core.particles.ParticleOptions
import net.minecraft.core.particles.ParticleTypes
import net.minecraft.network.chat.Component
import net.minecraft.server.level.ServerLevel
import net.minecraft.server.level.ServerPlayer
import net.minecraft.sounds.SoundEvents
import net.minecraft.sounds.SoundSource
import net.minecraft.world.effect.MobEffectInstance
import net.minecraft.world.effect.MobEffects
import net.minecraft.world.phys.Vec3
import net.neoforged.neoforge.common.NeoForge
import net.neoforged.neoforge.event.tick.ServerTickEvent
import org.joml.Vector3f
import kotlin.math.hypot
/**
* Boss fights (roadmap Phase 7; Adrian, 2026-11-02). A battle between players and a boss Pokémon (a zone's guardian or
* the End boss, [BossDefs]) becomes a fight: its area attacks telegraph on the ground, then hit the battling players
* standing inside. Everything stops when the battle ends. The arena is a circle around where the boss stood when the
* fight began: a player who leaves it, or dies, loses the fight for everyone in it (the battle ends, the boss heals
* and stays, nothing drops). Area attacks hit players only, never Pokémon.
*/
object BossFights {
/** Telegraph particles every so many ticks. */
private const val SHOW_EVERY = 4
/** Persistent-data key naming a boss file for any Pokémon (the self-tests use it). */
const val TAG = "cobblesurvival_boss"
class Telegraph(val pattern: BossDefs.Pattern, val area: Area, val landsAt: Long)
class Fight(val battle: PokemonBattle, val def: BossDefs.Def, val boss: PokemonEntity, val center: Vec3, val start: Long) {
var lastTurn = battle.turn
val fired = HashSet<Int>()
val telegraphs = ArrayList<Telegraph>()
}
private val fights = ArrayList<Fight>()
fun register() {
BossDefs.register()
CobblemonEvents.BATTLE_STARTED_POST.subscribe(Priority.NORMAL) { start(it.battle) }
NeoForge.EVENT_BUS.addListener<ServerTickEvent.Post> { fights.toList().forEach(::tick) }
}
fun fightOf(battle: PokemonBattle): Fight? = fights.firstOrNull { it.battle === battle }
/** The boss fight a Pokémon would start, if it is a boss. */
fun defOf(entity: PokemonEntity): BossDefs.Def? {
val data = entity.pokemon.persistentData
val zone = data.getString(Guardians.TAG)
return when {
zone.isNotEmpty() -> BossDefs.byGuardian[zone]
data.contains(EndBoss.TAG) -> BossDefs.end
data.contains(TAG) -> net.minecraft.resources.ResourceLocation.parse(data.getString(TAG)).let { id ->
if (id == BossDefs.SELFTEST.id) BossDefs.SELFTEST else BossDefs.byId[id]
}
else -> null
}
}
fun start(battle: PokemonBattle): Fight? {
if (battle.players.isEmpty() || fightOf(battle) != null) return null
val boss = battle.actors.flatMap { it.pokemonList }.mapNotNull { it.entity }.firstOrNull { defOf(it) != null } ?: return null
val def = defOf(boss) ?: return null
val level = boss.level() as? ServerLevel ?: return null
val fight = Fight(battle, def, boss, boss.position(), level.gameTime)
fights += fight
battle.players.forEach {
it.sendSystemMessage(Component.translatableWithFallback("message.cobblesurvival.boss.start",
"Boss fight: watch the ground and dodge its attacks. Leaving the arena (%s blocks) loses the fight.",
def.arena.toInt()).withStyle(ChatFormatting.GOLD))
}
CobbleSurvival.LOGGER.info("Boss fight {} begins against {}", def.id, battle.players.joinToString { it.gameProfile.name })
return fight
}
private fun tick(fight: Fight) {
val level = fight.boss.level() as? ServerLevel
if (level == null || fight.battle.ended || !fight.boss.isAlive) {
fights.remove(fight)
return
}
for (player in fight.battle.players) {
when {
player.isDeadOrDying || player.isRemoved -> return lose(fight, player, "fell")
flat(player.position(), fight.center) > fight.def.arena -> return lose(fight, player, "left the arena")
}
}
val now = level.gameTime
val elapsed = now - fight.start
val turnChanged = fight.battle.turn != fight.lastTurn
fight.lastTurn = fight.battle.turn
val health = fight.boss.pokemon.currentHealth.toDouble() / fight.boss.pokemon.maxHealth.coerceAtLeast(1)
fight.def.patterns.forEachIndexed { i, pattern ->
val t = pattern.trigger
val due = when {
t.everyTicks > 0 -> elapsed >= t.offset && (elapsed - t.offset) % t.everyTicks == 0L
t.everyTurns > 0 -> turnChanged && fight.battle.turn % t.everyTurns == 0
t.belowHealth > 0 -> i !in fight.fired && health < t.belowHealth
else -> false
}
if (due) {
fight.fired += i
cast(fight, pattern, target(fight))
}
}
fight.telegraphs.removeIf { telegraph ->
if (now >= telegraph.landsAt) {
land(fight, level, telegraph)
true
} else {
if ((telegraph.landsAt - now) % SHOW_EVERY == 0L) show(level, telegraph)
false
}
}
}
private fun flat(a: Vec3, b: Vec3) = hypot(a.x - b.x, a.z - b.z)
/** A random battling player's position, else the boss's own. */
private fun target(fight: Fight): Vec3 = fight.battle.players.filter { it.isAlive }.randomOrNull()?.position() ?: fight.boss.position()
/** Starts [pattern], aimed at [target]: it telegraphs, then lands. */
fun cast(fight: Fight, pattern: BossDefs.Pattern, target: Vec3): Telegraph {
val level = fight.boss.level() as ServerLevel
val telegraph = Telegraph(pattern, pattern.area(fight.boss.position(), target), level.gameTime + pattern.telegraphTicks)
fight.telegraphs += telegraph
show(level, telegraph)
return telegraph
}
private fun color(effect: BossDefs.Effect) = when (effect) {
BossDefs.Effect.FIRE -> Vector3f(1f, 0.45f, 0f)
BossDefs.Effect.ICE -> Vector3f(0.5f, 0.85f, 1f)
BossDefs.Effect.POISON -> Vector3f(0.6f, 0.2f, 0.85f)
BossDefs.Effect.NONE -> Vector3f(1f, 0.15f, 0.1f)
}
private fun show(level: ServerLevel, telegraph: Telegraph) {
val dust = DustParticleOptions(color(telegraph.pattern.effect), 1.2f)
telegraph.area.outline().forEach { level.sendParticles(dust, it.x, it.y, it.z, 1, 0.0, 0.0, 0.0, 0.0) }
}
private fun land(fight: Fight, level: ServerLevel, telegraph: Telegraph) {
val pattern = telegraph.pattern
val burst: ParticleOptions = when (pattern.effect) {
BossDefs.Effect.FIRE -> ParticleTypes.FLAME
BossDefs.Effect.ICE -> ParticleTypes.SNOWFLAKE
BossDefs.Effect.POISON -> ParticleTypes.WITCH
BossDefs.Effect.NONE -> ParticleTypes.CRIT
}
telegraph.area.outline().forEach { level.sendParticles(burst, it.x, it.y + 0.2, it.z, 2, 0.1, 0.2, 0.1, 0.02) }
val origin = telegraph.area.origin
level.playSound(null, origin.x, origin.y, origin.z, SoundEvents.GENERIC_EXPLODE.value(), SoundSource.HOSTILE, 0.6f, 1.3f)
for (player in fight.battle.players) {
if (!player.isAlive || !telegraph.area.contains(player.position())) continue
player.hurt(level.damageSources().mobAttack(fight.boss), pattern.damage)
when (pattern.effect) {
BossDefs.Effect.FIRE -> player.igniteForSeconds(4f)
BossDefs.Effect.ICE -> {
player.ticksFrozen = player.ticksRequiredToFreeze + 60
player.addEffect(MobEffectInstance(MobEffects.MOVEMENT_SLOWDOWN, 60, 1))
}
BossDefs.Effect.POISON -> player.addEffect(MobEffectInstance(MobEffects.POISON, 100, 0))
BossDefs.Effect.NONE -> {}
}
}
}
/** [player] [why] (left the arena, fell): the fight is lost for everyone in it; the boss heals and stays. */
fun lose(fight: Fight, player: ServerPlayer, why: String) {
fights.remove(fight)
val bossName = fight.boss.pokemon.getDisplayName()
fight.battle.players.forEach {
it.sendSystemMessage(Component.translatableWithFallback("message.cobblesurvival.boss.lost",
"%s %s: the fight against %s is lost.", player.gameProfile.name, why, bossName).withStyle(ChatFormatting.RED))
}
fight.battle.end()
fight.boss.pokemon.heal()
CobbleSurvival.LOGGER.info("Boss fight {} lost: {} {}", fight.def.id, player.gameProfile.name, why)
}
}
@@ -0,0 +1,127 @@
package dev.cobblesurvival.boss
import com.cobblemon.mod.common.api.Priority
import com.cobblemon.mod.common.api.events.CobblemonEvents
import com.cobblemon.mod.common.api.events.battles.BattleFaintedEvent
import com.cobblemon.mod.common.api.pokemon.PokemonProperties
import com.cobblemon.mod.common.entity.pokemon.PokemonEntity
import dev.cobblesurvival.CobbleSurvival
import dev.cobblesurvival.zone.GuardianState
import net.minecraft.ChatFormatting
import net.minecraft.core.BlockPos
import net.minecraft.network.chat.Component
import net.minecraft.server.level.ServerBossEvent
import net.minecraft.server.level.ServerLevel
import net.minecraft.world.entity.EntityType
import net.minecraft.world.entity.boss.enderdragon.EnderDragon
import net.minecraft.world.level.Level
import net.minecraft.world.level.dimension.end.EndDragonFight
import net.minecraft.world.level.levelgen.Heightmap
import net.neoforged.neoforge.common.NeoForge
import net.neoforged.neoforge.event.tick.ServerTickEvent
import java.util.UUID
/**
* The End's boss (Adrian, 2026-11-02): a Pokémon replaces the Ender Dragon, which may no longer join a world (it left
* the tag `cobblesurvival:allowed_vanilla_mobs`). Eternatus stands in for Adrian's Giratina until Cobblemon has its
* model ([BossDefs.end]). While a player is near the main island and the boss isn't there (and wasn't beaten within its
* respawn time), it appears; vanilla's dragon boss bar shows its name and HP. Beating it does what killing the dragon
* did: exit portal and gateway, the egg on the first win, and experience for everyone in the battle.
*/
object EndBoss {
const val TAG = "cobblesurvival_end_boss"
/** Its entry in [GuardianState]. */
private const val STATE = "the_end"
private const val CHECK_TICKS = 40
private const val NEAR = 160.0
private const val LEASH = 24.0
private const val TICKS_PER_HOUR = 72000.0
/** Where it stands: south of the exit portal, on the island's top. */
private val SPOT_XZ = BlockPos(0, 0, 24)
private val dragonUuid by lazy { EndDragonFight::class.java.getDeclaredField("dragonUUID").apply { isAccessible = true } }
private val bossBar by lazy { EndDragonFight::class.java.getDeclaredField("dragonEvent").apply { isAccessible = true } }
fun register() {
NeoForge.EVENT_BUS.addListener<ServerTickEvent.Post> { event ->
if (event.server.tickCount % CHECK_TICKS == 0) event.server.getLevel(Level.END)?.let(::check)
}
CobblemonEvents.BATTLE_FAINTED.subscribe(Priority.NORMAL, ::onFainted)
}
private fun state(level: ServerLevel) = GuardianState.of(level.server.overworld()).entry(STATE)
fun current(level: ServerLevel): PokemonEntity? =
state(level).entity?.let { level.getEntity(it) as? PokemonEntity }?.takeIf { it.isAlive }
private fun check(level: ServerLevel) {
val def = BossDefs.end?.end ?: return
val near = level.players().any { it.horizontalDistanceToSqr0() < NEAR * NEAR }
val boss = current(level)
if (boss != null) {
val spot = spot(level)
if (boss.distanceToSqr(spot.x + 0.5, spot.y.toDouble(), spot.z + 0.5) > LEASH * LEASH) boss.teleportTo(spot.x + 0.5, spot.y.toDouble(), spot.z + 0.5)
level.dragonFight?.let { fight ->
(bossBar.get(fight) as ServerBossEvent).apply {
name = boss.pokemon.getDisplayName()
progress = boss.pokemon.currentHealth.toFloat() / boss.pokemon.maxHealth.coerceAtLeast(1)
}
}
return
}
val defeatedAt = state(level).defeatedAt
if (!near || (defeatedAt != null && level.gameTime - defeatedAt < def.respawnHours * TICKS_PER_HOUR)) return
spawn(level)
}
private fun net.minecraft.world.entity.player.Player.horizontalDistanceToSqr0() = x * x + z * z
private fun spot(level: ServerLevel): BlockPos = level.getHeightmapPos(Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, SPOT_XZ)
/** Puts the End boss on the main island; null without End boss data. */
fun spawn(level: ServerLevel): PokemonEntity? {
val def = BossDefs.end?.end ?: return null
val pokemon = PokemonProperties.parse("${def.species} level=${def.level} uncatchable=yes").create()
pokemon.scaleModifier = def.scale
pokemon.persistentData.putBoolean(TAG, true)
val entity = PokemonEntity(level, pokemon)
val spot = spot(level)
entity.moveTo(spot.x + 0.5, spot.y.toDouble(), spot.z + 0.5, 180f, 0f)
entity.setPersistenceRequired()
if (!level.addFreshEntity(entity)) return null
state(level).entity = entity.uuid
CobbleSurvival.LOGGER.info("The End boss {} appeared at {}", def.species, spot.toShortString())
return entity
}
private fun onFainted(event: BattleFaintedEvent) {
if (!event.killed.effectedPokemon.persistentData.contains(TAG)) return
val players = event.battle.players
val level = players.firstOrNull()?.server?.getLevel(Level.END) ?: return
defeated(level, players)
}
/** What killing the dragon did: exit portal, gateway, the egg the first time; experience for [players]. */
fun defeated(level: ServerLevel, players: List<net.minecraft.server.level.ServerPlayer>) {
val def = BossDefs.end?.end
state(level).apply {
defeatedAt = level.gameTime
entity = null
}
val fight = level.dragonFight
val firstWin = fight?.hasPreviouslyKilledDragon() == false
if (fight != null) {
val dummy = EnderDragon(EntityType.ENDER_DRAGON, level)
dummy.uuid = UUID.randomUUID()
dragonUuid.set(fight, dummy.uuid)
fight.setDragonKilled(dummy)
}
val xp = if (firstWin) def?.xpFirstWin ?: 0 else def?.xp ?: 0
players.forEach {
it.giveExperiencePoints(xp)
it.sendSystemMessage(Component.translatableWithFallback("message.cobblesurvival.boss.end_beaten",
"The End's guardian is beaten! The way home is open.").withStyle(ChatFormatting.GOLD))
}
CobbleSurvival.LOGGER.info("The End boss was beaten by {} (first win: {})", players.joinToString { it.gameProfile.name }, firstWin)
}
}
@@ -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()