diff --git a/docs/checklists/phase-7.md b/docs/checklists/phase-7.md index f69fbfa..2bfd6a8 100644 --- a/docs/checklists/phase-7.md +++ b/docs/checklists/phase-7.md @@ -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. diff --git a/docs/design.md b/docs/design.md index 2645e10..4abe755 100644 --- a/docs/design.md +++ b/docs/design.md @@ -282,13 +282,13 @@ The others fit and stay: Morpeko (Button Isle), Pinsir (Southwold), Scyther (Wes **Bosses are data**, `datapacks/core/data/cobblesurvival/bosses/.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) diff --git a/mod/src/main/kotlin/dev/cobblesurvival/boss/BossDefs.kt b/mod/src/main/kotlin/dev/cobblesurvival/boss/BossDefs.kt index 197b5f3..8e42e16 100644 --- a/mod/src/main/kotlin/dev/cobblesurvival/boss/BossDefs.kt +++ b/mod/src/main/kotlin/dev/cobblesurvival/boss/BossDefs.kt @@ -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 + /** 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 { + 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 } } diff --git a/mod/src/main/kotlin/dev/cobblesurvival/boss/BossFights.kt b/mod/src/main/kotlin/dev/cobblesurvival/boss/BossFights.kt index cba2eb7..3550008 100644 --- a/mod/src/main/kotlin/dev/cobblesurvival/boss/BossFights.kt +++ b/mod/src/main/kotlin/dev/cobblesurvival/boss/BossFights.kt @@ -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) { diff --git a/mod/src/main/kotlin/dev/cobblesurvival/boss/EndBoss.kt b/mod/src/main/kotlin/dev/cobblesurvival/boss/EndBoss.kt index 3e843b7..2afd0cd 100644 --- a/mod/src/main/kotlin/dev/cobblesurvival/boss/EndBoss.kt +++ b/mod/src/main/kotlin/dev/cobblesurvival/boss/EndBoss.kt @@ -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 { 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) + // 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) { - 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 { diff --git a/mod/src/main/kotlin/dev/cobblesurvival/selftest/BossTests.kt b/mod/src/main/kotlin/dev/cobblesurvival/selftest/BossTests.kt index cebe652..b111867 100644 --- a/mod/src/main/kotlin/dev/cobblesurvival/selftest/BossTests.kt +++ b/mod/src/main/kotlin/dev/cobblesurvival/selftest/BossTests.kt @@ -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,26 +161,35 @@ 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) } } - 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() + 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" } + 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}" + } + // 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" } + boss.discard() + } + .thenSucceed() } }