Phase 7: one End boss at a time; more visible attack telegraphs

Adrian (2026-11-02): a second Eternatus appeared on his second visit
to the End; the attacks should be more visible.

- EndBoss waits until the entities around its spot are loaded before
  deciding it's missing, refuses a second copy while the recorded one
  is alive (join check, like the guardians), and removes extra copies
  near the spot (cleans up worlds that already have two).
- Telegraphs: 2.2-size dust redrawn every 2 ticks, the inside filled
  (Area.fill), rising sparks on the outline, a low bell on cast.
- Tests: no second End boss (after the End's middle is loaded), fill
  points inside their areas.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
claudeandClaude Opus 5.5 committed 2026-11-02 16:50:58 +01:00
1 parent c451c58483
commit 4e7b16abae
6 files changed
+76 -12

No files matched your search

+1
View File
@@ -18,6 +18,7 @@ On the island save (or a new one), in creative or survival:
- [ ] Fight the Button Isle guardian (Morpeko, `/cobblesurvival guardians` lists where they stand): red circles appear under you every few seconds; step out before they land. Close the battle screen to move (Cobblemon's own key), then go back to it.
- [ ] Fight a later guardian (e.g. Flygon in Sunscorch or Swampert in the Mire): lines and cones from the boss as well.
- [ ] Walk more than 24 blocks away from a guardian during its fight: the battle ends as lost, and the guardian is at full HP again.
- [ ] Leave the End and come back without beating Eternatus: still only one (fixed 2026-11-02).
- [ ] Go to the End: no dragon; Eternatus stands south of the portal, the boss bar shows its HP. Beat it: the exit portal opens, the egg appears.
- [ ] Find Regigigas in a Deep Dark cave and Moltres in the Basalt Furnace.
+2 -2
View File
@@ -282,13 +282,13 @@ The others fit and stay: Morpeko (Button Isle), Pinsir (Southwold), Scyther (Wes
**Bosses are data**, `datapacks/core/data/cobblesurvival/bosses/<id>.json`, written by `tools/zone_bosses.py` from a tier table per zone band: which fight it is (a zone's guardian, or the End boss), the arena radius, and the attack patterns. A pattern has a shape (circle, ring, line, cone) and its size, where it's aimed (at the player, or around the boss), a telegraph time, damage, an optional effect (fire: set alight; ice: slowed and freezing) and a trigger (every so many seconds, every so many turns, or once when the boss's HP falls below a share).
**Runtime** (companion mod). A battle between players and a boss Pokémon becomes a boss fight. While it lasts, due patterns telegraph with particles on the ground where they'll land, 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 (the battle ends, the boss stays, nothing drops). Cobblemon's own flee check only applies when a battle starts, which is why the arena is enforced here.
**Runtime** (companion mod). A battle between players and a boss Pokémon becomes a boss fight. While it lasts, due patterns telegraph on the ground where they'll land (made more visible after Adrian's first fight, 2026-11-02: large particles redrawn every 2 ticks over the whole area, a bright outline with rising sparks, and a low bell when an attack begins), 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 (the battle ends, the boss stays, nothing drops). Cobblemon's own flee check only applies when a battle starts, which is why the arena is enforced here.
**Moltres** becomes the Basalt Furnace's guardian like the others (Lv 75, band top + 5), with placeholder drops (3 Exp. Candy XL, 2 netherite scrap).
Self-tests (`BossTests`): every guardian has a fight and a Cobblemon model; each attack shape hits a player standing in it and misses one ten blocks away; attacks stop when the battle ends; leaving the arena ends the battle and the boss heals; the dragon can't join the End, Eternatus appears, and beating it opens the exit portal with the egg.
**Eternatus** replaces the Ender Dragon: the dragon no longer spawns; Eternatus (uncatchable, Lv 80, scaled down to fit) appears on the main End island while a player is there, and beating it does what killing the dragon did (exit portal, gateways, the egg on the first win).
**Eternatus** replaces the Ender Dragon: the dragon no longer spawns; Eternatus (uncatchable, Lv 80, scaled down to fit) appears on the main End island while a player is there, and beating it does what killing the dragon did (exit portal, gateways, the egg on the first win). Only one at a time: it waits until the entities around its spot have loaded before deciding it's missing, and a second copy never joins while the recorded one is alive (Adrian met a second Eternatus on his second visit, 2026-11-02); extra copies left in older worlds are removed.
## Open decisions for Adrian (proceeding with the default, flagged)
@@ -103,6 +103,16 @@ 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>
/** How far from [origin] the area reaches. */
abstract val reach: Double
/** Points spread over the inside, so the whole area shows, not just its edge. */
fun fill(): List<Vec3> {
val steps = (reach / FILL_STEP).toInt()
return (-steps..steps).flatMap { i -> (-steps..steps).map { j -> origin.x + i * FILL_STEP to origin.z + j * FILL_STEP } }
.filter { (x, z) -> containsFlat(x, z) }
.map { (x, z) -> at(x, z) }
}
fun contains(pos: Vec3) = kotlin.math.abs(pos.y - origin.y) <= REACH_Y && containsFlat(pos.x, pos.z)
@@ -114,16 +124,19 @@ sealed class Area(val origin: Vec3) {
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)
override fun outline() = circle(radius)
override val reach get() = radius
}
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)
override val reach get() = 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 val reach get() = length
override fun containsFlat(x: Double, z: Double): Boolean {
val dx = x - origin.x
val dz = z - origin.z
@@ -142,6 +155,7 @@ sealed class Area(val origin: Vec3) {
/** 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 val reach get() = length
override fun containsFlat(x: Double, z: Double): Boolean {
val distance = hypot(x - origin.x, z - origin.z)
if (distance > length) return false
@@ -167,6 +181,7 @@ sealed class Area(val origin: Vec3) {
companion object {
const val REACH_Y = 3.0
private const val STEP = 0.6
private const val STEP = 0.5
private const val FILL_STEP = 1.25
}
}
@@ -32,8 +32,9 @@ import kotlin.math.hypot
* 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
/** Telegraph particles every so many ticks (Adrian, 2026-11-02: make the attacks more visible). */
private const val SHOW_EVERY = 2
private const val DUST_SIZE = 2.2f
/** Persistent-data key naming a boss file for any Pokémon (the self-tests use it). */
const val TAG = "cobblesurvival_boss"
@@ -136,6 +137,8 @@ object BossFights {
val level = fight.boss.level() as ServerLevel
val telegraph = Telegraph(pattern, pattern.area(fight.boss.position(), target), level.gameTime + pattern.telegraphTicks)
fight.telegraphs += telegraph
val origin = telegraph.area.origin
level.playSound(null, origin.x, origin.y, origin.z, SoundEvents.NOTE_BLOCK_BELL.value(), SoundSource.HOSTILE, 1.2f, 0.6f)
show(level, telegraph)
return telegraph
}
@@ -147,9 +150,15 @@ object BossFights {
BossDefs.Effect.NONE -> Vector3f(1f, 0.15f, 0.1f)
}
/** The outline in the attack's colour, the inside a little paler and sparser, rising sparks on the outline. */
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) }
val color = color(telegraph.pattern.effect)
val edge = DustParticleOptions(color, DUST_SIZE)
val inside = DustParticleOptions(Vector3f(color).lerp(Vector3f(1f, 1f, 1f), 0.35f), DUST_SIZE * 0.7f)
telegraph.area.outline().forEach { level.sendParticles(edge, it.x, it.y, it.z, 1, 0.0, 0.0, 0.0, 0.0) }
telegraph.area.fill().forEach { level.sendParticles(inside, it.x, it.y, it.z, 1, 0.15, 0.0, 0.15, 0.0) }
telegraph.area.outline().filterIndexed { i, _ -> i % 4 == 0 }
.forEach { level.sendParticles(ParticleTypes.END_ROD, it.x, it.y, it.z, 0, 0.0, 0.08, 0.0, 1.0) }
}
private fun land(fight: Fight, level: ServerLevel, telegraph: Telegraph) {
@@ -14,7 +14,11 @@ 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.entity.Entity
import net.minecraft.world.level.ChunkPos
import net.minecraft.world.level.Level
import net.minecraft.world.phys.AABB
import net.neoforged.neoforge.event.entity.EntityJoinLevelEvent
import net.minecraft.world.level.dimension.end.EndDragonFight
import net.minecraft.world.level.levelgen.Heightmap
import net.neoforged.neoforge.common.NeoForge
@@ -46,9 +50,26 @@ object EndBoss {
NeoForge.EVENT_BUS.addListener<ServerTickEvent.Post> { event ->
if (event.server.tickCount % CHECK_TICKS == 0) event.server.getLevel(Level.END)?.let(::check)
}
NeoForge.EVENT_BUS.addListener(::onJoin)
CobblemonEvents.BATTLE_FAINTED.subscribe(Priority.NORMAL, ::onFainted)
}
fun isEndBoss(entity: Entity?) = entity is PokemonEntity && entity.pokemon.persistentData.contains(TAG)
/** A second End boss never joins while the recorded one is alive (Adrian, 2026-11-02: a second one appeared). */
private fun onJoin(event: EntityJoinLevelEvent) {
val entity = event.entity
val level = event.level as? ServerLevel ?: return
if (!isEndBoss(entity)) return
val entry = state(level)
val recorded = entry.entity
if (recorded == null || recorded == entity.uuid) {
entry.entity = entity.uuid
return
}
if ((level.getEntity(recorded) as? PokemonEntity)?.isAlive == true) event.isCanceled = true else entry.entity = entity.uuid
}
private fun state(level: ServerLevel) = GuardianState.of(level.server.overworld()).entry(STATE)
fun current(level: ServerLevel): PokemonEntity? =
@@ -57,9 +78,17 @@ object EndBoss {
private fun check(level: ServerLevel) {
val def = BossDefs.end?.end ?: return
val near = level.players().any { it.horizontalDistanceToSqr0() < NEAR * NEAR }
// Until the entities around the spot are loaded, the boss may be there unseen: don't put a second one.
val spotChunk = ChunkPos(spot(level))
val reach = (LEASH / 16).toInt() + 1
if ((-reach..reach).any { dx -> (-reach..reach).any { dz -> !level.areEntitiesLoaded(ChunkPos.asLong(spotChunk.x + dx, spotChunk.z + dz)) } }) return
val boss = current(level)
if (boss != null) {
// Copies from before this check existed: keep the recorded one (or the first), drop the rest.
val spot = spot(level)
level.getEntitiesOfClass(PokemonEntity::class.java, AABB(spot).inflate(LEASH * 2)) { isEndBoss(it) }
.filter { it !== (boss ?: it) }
.forEach { it.discard() }
if (boss != null) {
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 {
@@ -71,7 +71,8 @@ object BossTests {
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.check(listOf(circle, ring, line, cone).all { it.outline().isNotEmpty() && it.fill().isNotEmpty() }) { "a shape without telegraph points" }
s.check(listOf(circle, ring, line, cone).all { area -> area.fill().all { area.contains(it) } }) { "telegraph points outside their area" }
s.helper.succeed()
}
@@ -160,13 +161,16 @@ object BossTests {
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) } }
val chunks = (-3..3).flatMap { x -> (-3..3).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) }
}
s.helper.startSequence()
.thenWaitUntil { s.check(chunks.all { end.areEntitiesLoaded(it.toLong()) }) { "the End's middle isn't loaded yet" } }
.thenExecute {
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" }
@@ -174,12 +178,18 @@ object BossTests {
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}"
}
// A second one may not join while the first is alive (Adrian saw one appear on each visit).
val second = EndBoss.spawn(end)
s.check(second == null || second.isRemoved) { "a second End boss joined beside the first" }
s.check(EndBoss.current(end) === boss) { "the End boss on record isn't the first one" }
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()
boss.discard()
}
.thenSucceed()
}
}