Big, tough bosses; working machines show it; JEI drop pages on the roadmap

Bosses (Adrian, 2026-11-02: Eternatus too small and too easy; guardians way
bigger and tougher; only defensive stats up; no attack takes more than 25 %):
- Guardians scale to about 8 blocks tall (1.5x-12x), Eternatus to 15 blocks
  (2.5x, was 0.2x). Cave guardians look for a spot with room above them.
  Bosses already in a world grow on the next check outside battle.
- Perfect IVs and 170 EVs each in HP, Defense and Sp. Def, nothing offensive.
- The Guardian's Ward, a Showdown-only held item (held_items/guardiansward.js)
  handed to every boss by a HeldItemProvider: Defense and Sp. Def x2 while
  defending, and one attack (all hits together) takes at most 25 % of max HP.
- Area attacks that start at the boss reach half its width further.

Machines (Adrian, 2026-11-02): a working core is active: it glows, its front
animates (flickering fire on the furnace core, pulsing emblems elsewhere), and
particles show in the chamber and at the core (client side). The Pokemon
inside flinches about every 15 s (red flash, Cobblemon's hit sound, no damage).

Roadmap Phase 8: every item that can drop gets a JEI page naming its sources.

Self-tests: practice_bosses_spawn checks size and training,
boss_takes_a_quarter_per_attack runs a real battle (each Lv 100 Bullet Seed
takes exactly a quarter), heat_core_cooks_with_hoppers checks the active state
and the flinch. Self-tests and the island check pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
claudeandClaude Opus 5.5 committed 2026-11-03 10:00:57 +01:00
1 parent 4f0bc03919
commit 120c664cb0
58 files changed
+681 -86

No files matched your search

@@ -11,6 +11,7 @@ import dev.cobblesurvival.ore.OreNodes
import dev.cobblesurvival.permadeath.Permadeath
import dev.cobblesurvival.boss.BossCommands
import dev.cobblesurvival.boss.BossFights
import dev.cobblesurvival.boss.BossToughness
import dev.cobblesurvival.boss.EndBoss
import dev.cobblesurvival.machine.MachineShowcase
import dev.cobblesurvival.machine.Machines
@@ -78,6 +79,7 @@ class CobbleSurvival {
NeoForge.EVENT_BUS.addListener(Zones::onAddReloadListeners)
Guardians.register()
BossFights.register()
BossToughness.register()
EndBoss.register()
Gates.register()
ShoulderPassives.register()
@@ -53,8 +53,9 @@ object BossCommands {
Triple(guardian.pokemon, guardian.scale, BossDefs.byGuardian[which] ?: return null)
}
val pokemon = PokemonProperties.parse("$properties uncatchable=yes").create()
pokemon.scaleModifier = scale
pokemon.persistentData.putString(BossFights.TAG, def.id.toString())
BossToughness.toughen(pokemon, scale)
pokemon.heal()
val entity = PokemonEntity(level, pokemon)
entity.moveTo(pos.x, pos.y, pos.z, 0f, 0f)
entity.setPersistenceRequired()
@@ -36,14 +36,18 @@ object BossDefs : SimpleJsonResourceReloadListener(Gson(), "bosses") {
val effect: Effect,
val trigger: Trigger,
) {
/** Where this pattern lands, aimed from [boss] at [target]. */
fun area(boss: Vec3, target: Vec3): Area {
/**
* Where this pattern lands, aimed from [boss] at [target]. Shapes that start at the boss reach [body] further
* (the boss's half width): a big boss's ring starts at its edge, not inside it.
*/
fun area(boss: Vec3, target: Vec3, body: Double = 0.0): Area {
val origin = if (aim == Aim.BOSS) boss else target
val grow = if (aim == Aim.BOSS) body else 0.0
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))
Shape.CIRCLE -> Area.Circle(origin, radius + grow)
Shape.RING -> Area.Ring(origin, inner + grow, outer + grow)
Shape.LINE -> Area.Line(boss, heading(boss, target), length + body, width / 2)
Shape.CONE -> Area.Cone(boss, heading(boss, target), length + body, Math.toRadians(angle / 2))
}
}
@@ -79,7 +79,7 @@ object BossFights {
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.",
"Boss fight: watch the ground and dodge its attacks. Leaving the arena (%s blocks) loses the fight. No single attack takes more than a quarter of its HP.",
def.arena.toInt()).withStyle(ChatFormatting.GOLD))
}
CobbleSurvival.LOGGER.info("Boss fight {} begins against {}", def.id, battle.players.joinToString { it.gameProfile.name })
@@ -135,7 +135,7 @@ object BossFights {
/** 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)
val telegraph = Telegraph(pattern, pattern.area(fight.boss.position(), target, fight.boss.bbWidth / 2.0), 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)
@@ -0,0 +1,78 @@
package dev.cobblesurvival.boss
import com.cobblemon.mod.common.api.Priority
import com.cobblemon.mod.common.api.battles.interpreter.BattleMessage
import com.cobblemon.mod.common.api.battles.model.PokemonBattle
import com.cobblemon.mod.common.api.pokemon.helditem.HeldItemManager
import com.cobblemon.mod.common.api.pokemon.helditem.HeldItemProvider
import com.cobblemon.mod.common.api.pokemon.stats.Stats
import com.cobblemon.mod.common.battles.pokemon.BattlePokemon
import com.cobblemon.mod.common.entity.pokemon.PokemonEntity
import com.cobblemon.mod.common.pokemon.IVs
import com.cobblemon.mod.common.pokemon.Pokemon
import dev.cobblesurvival.zone.Guardians
import net.minecraft.network.chat.Component
/**
* Bosses are big and tough, not strong (Adrian, 2026-11-02: Eternatus was too small and too easy; guardians should be
* way bigger and tougher; only their defensive stats go up, never Attack or Speed; no single attack may take more
* than a quarter of their HP). Every boss (zone guardian, End boss, practice copy) gets:
* - its size ([toughen]'s scale, from tools/zone_spawns.py and tools/zone_bosses.py);
* - perfect IVs and [EVS] EVs in HP, Defense and Sp. Def, nothing else (510 in all, the games' limit);
* - in battle the Guardian's Ward, a Showdown held item no Minecraft item stands for
* (`data/cobblesurvival/held_items/guardiansward.js`): Defense and Sp. Def multiplied, and at most a quarter of
* its max HP lost to one attack. This provider hands it out ahead of whatever the boss really holds.
*/
object BossToughness : HeldItemManager {
const val WARD = "guardiansward"
const val EVS = 170
private val DEFENSIVE = listOf(Stats.HP, Stats.DEFENCE, Stats.SPECIAL_DEFENCE)
fun register() = HeldItemProvider.register(this, Priority.HIGHEST)
fun isBoss(pokemon: Pokemon): Boolean {
val data = pokemon.persistentData
return data.contains(Guardians.TAG) || data.contains(EndBoss.TAG) || data.contains(BossFights.TAG)
}
/** Gives [pokemon] its boss [scale] and training; true if anything changed (a boss from before these rules). */
fun toughen(pokemon: Pokemon, scale: Float): Boolean {
var changed = false
if (pokemon.scaleModifier != scale) {
pokemon.scaleModifier = scale
changed = true
}
for (stat in DEFENSIVE) {
if (pokemon.ivs[stat] != IVs.MAX_VALUE) {
pokemon.setIV(stat, IVs.MAX_VALUE)
changed = true
}
if (pokemon.evs[stat] != EVS) {
pokemon.setEV(stat, EVS)
changed = true
}
}
return changed
}
/** [toughen]s a boss already in the world, outside battle: at full health and its new size. */
fun refresh(entity: PokemonEntity, scale: Float): Boolean {
if (entity.isBattling || !toughen(entity.pokemon, scale)) return false
entity.pokemon.heal()
entity.refreshDimensions()
return true
}
override fun showdownId(pokemon: BattlePokemon): String? = WARD.takeIf { isBoss(pokemon.effectedPokemon) }
override fun nameOf(showdownId: String): Component =
Component.translatableWithFallback("item.cobblesurvival.guardian_ward", "Guardian's Ward")
override fun handleStartInstruction(pokemon: BattlePokemon, battle: PokemonBattle, message: BattleMessage) {}
override fun handleEndInstruction(pokemon: BattlePokemon, battle: PokemonBattle, message: BattleMessage) {}
override fun give(pokemon: BattlePokemon, itemID: String) {}
override fun take(pokemon: BattlePokemon, itemID: String) {}
}
@@ -89,6 +89,7 @@ object EndBoss {
.filter { it !== (boss ?: it) }
.forEach { it.discard() }
if (boss != null) {
BossToughness.refresh(boss, def.scale)
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 {
@@ -111,8 +112,9 @@ object EndBoss {
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)
BossToughness.toughen(pokemon, def.scale)
pokemon.heal()
val entity = PokemonEntity(level, pokemon)
val spot = spot(level)
entity.moveTo(spot.x + 0.5, spot.y.toDouble(), spot.z + 0.5, 180f, 0f)
@@ -22,18 +22,22 @@ import net.minecraft.world.level.block.entity.BlockEntityType
import net.minecraft.world.level.block.state.BlockState
import net.minecraft.world.level.block.state.StateDefinition
import net.minecraft.world.level.block.state.properties.BlockStateProperties
import net.minecraft.world.level.block.state.properties.BooleanProperty
import net.minecraft.world.phys.BlockHitResult
/** A machine's core block; which machine it is comes from its id ([MachineDefs]). It faces the player who placed it. */
/**
* A machine's core block; which machine it is comes from its id ([MachineDefs]). It faces the player who placed it.
* While its machine works it is [ACTIVE]: it glows, its face animates and particles show ([MachineEffects]).
*/
class MachineBlock(properties: Properties) : BaseEntityBlock(properties) {
init {
registerDefaultState(stateDefinition.any().setValue(FACING, Direction.NORTH))
registerDefaultState(stateDefinition.any().setValue(FACING, Direction.NORTH).setValue(ACTIVE, false))
}
override fun codec(): MapCodec<out BaseEntityBlock> = CODEC
override fun createBlockStateDefinition(builder: StateDefinition.Builder<Block, BlockState>) {
builder.add(FACING)
builder.add(FACING, ACTIVE)
}
override fun getStateForPlacement(context: BlockPlaceContext): BlockState =
@@ -55,7 +59,7 @@ class MachineBlock(properties: Properties) : BaseEntityBlock(properties) {
override fun newBlockEntity(pos: BlockPos, state: BlockState): BlockEntity = MachineBlockEntity(pos, state)
override fun <T : BlockEntity> getTicker(level: Level, state: BlockState, type: BlockEntityType<T>): BlockEntityTicker<T>? =
if (level.isClientSide) null
if (level.isClientSide) createTickerHelper(type, Machines.BLOCK_ENTITY.get(), BlockEntityTicker { tickLevel, pos, tickState, _ -> MachineEffects.clientTick(tickLevel, pos, tickState) })
else createTickerHelper(type, Machines.BLOCK_ENTITY.get(), BlockEntityTicker { tickLevel, _, _, core -> core.serverTick(tickLevel as ServerLevel) })
override fun useWithoutItem(state: BlockState, level: Level, pos: BlockPos, player: Player, hitResult: BlockHitResult): InteractionResult {
@@ -71,6 +75,9 @@ class MachineBlock(properties: Properties) : BaseEntityBlock(properties) {
companion object {
val FACING = BlockStateProperties.HORIZONTAL_FACING
val ACTIVE: BooleanProperty = BooleanProperty.create("active")
/** Light a working core gives off. */
const val ACTIVE_LIGHT = 10
val CODEC: MapCodec<MachineBlock> = simpleCodec(::MachineBlock)
}
}
@@ -15,6 +15,7 @@ import net.minecraft.server.level.ServerLevel
import net.minecraft.server.level.ServerPlayer
import net.minecraft.world.Containers
import net.minecraft.world.item.ItemStack
import net.minecraft.world.level.block.Block
import net.minecraft.world.level.block.entity.BlockEntity
import net.minecraft.world.level.block.state.BlockState
import net.minecraft.world.phys.AABB
@@ -57,6 +58,8 @@ class MachineBlockEntity(pos: BlockPos, state: BlockState) : BlockEntity(Machine
/** No Pokémon shown before this game time (the old one is still fainting). */
private var showFrom = 0L
private var ticks = 0
/** The core shows as working ([MachineBlock.ACTIVE]) until this game time; work keeps pushing it on. */
private var activeUntil = 0L
val def: MachineDefs.Def? get() = MachineDefs.byCore[BuiltInRegistries.BLOCK.getKey(blockState.block)]
val machineName: Component get() = blockState.block.name
@@ -73,9 +76,22 @@ class MachineBlockEntity(pos: BlockPos, state: BlockState) : BlockEntity(Machine
if (display?.isAlive != true && level.gameTime >= showFrom) display = MachineDisplay.spawn(level, pokemon, worldPosition, shell!!.chamber, facing())
}
MachineWork.tick(this, level, def)
showWork(level, def)
receivedThisTick = 0
}
/**
* Shows that the machine works (Adrian, 2026-11-02): the core turns [MachineBlock.ACTIVE] (it glows, its face
* animates, particles show) and stays so [ACTIVE_LINGER] ticks after the work stops, so it doesn't flicker; now and
* then the Pokémon inside flinches as if hit, without taking damage ([MachineDisplay.flinch]).
*/
private fun showWork(level: ServerLevel, def: MachineDefs.Def) {
if (MachineWork.working(this, level, def)) activeUntil = level.gameTime + ACTIVE_LINGER
val active = level.gameTime < activeUntil
if (blockState.getValue(MachineBlock.ACTIVE) != active) level.setBlock(worldPosition, blockState.setValue(MachineBlock.ACTIVE, active), Block.UPDATE_CLIENTS)
if (active && level.random.nextInt(FLINCH_ONE_IN) == 0) display?.takeIf { it.isAlive }?.let { MachineDisplay.flinch(level, it) }
}
private fun refreshShell(level: ServerLevel, def: MachineDefs.Def) {
val min = worldPosition.offset(-SHELL_REACH, -SHELL_REACH, -SHELL_REACH)
val max = worldPosition.offset(SHELL_REACH, SHELL_REACH, SHELL_REACH)
@@ -268,6 +284,10 @@ class MachineBlockEntity(pos: BlockPos, state: BlockState) : BlockEntity(Machine
private const val SHELL_REACH = 2
/** How near the core a sent-out Pokémon must be to walk in. */
private const val REACH = 16.0
/** Ticks a core keeps showing as working after its work stops. */
private const val ACTIVE_LINGER = 40
/** While working, the Pokémon inside flinches on average once in this many ticks (15 s). */
private const val FLINCH_ONE_IN = 300
fun tell(player: ServerPlayer, message: Component) = player.sendSystemMessage(message)
}
@@ -65,6 +65,18 @@ object MachineDisplay {
fading += Fading(entity, level.gameTime + FAINT_TICKS)
}
/**
* The shown Pokémon flinches as if hit (Adrian, 2026-11-02): it flashes red as a hurt entity does and a hit sounds,
* but it takes no damage (the display copy can't, and the Pokémon in the core is untouched).
*/
fun flinch(level: ServerLevel, entity: PokemonEntity) {
level.broadcastDamageEvent(entity, level.damageSources().generic())
level.playSound(null, entity.x, entity.y, entity.z, HIT, SoundSource.NEUTRAL, 0.7f, 0.9f + level.random.nextFloat() * 0.2f)
}
/** Cobblemon's own sound for a Pokémon taking a hit in battle. */
private val HIT = SoundEvent.createVariableRangeEvent(ResourceLocation.fromNamespaceAndPath("cobblemon", "impact.normal"))
fun isDisplay(entity: Entity?): Boolean = entity is PokemonEntity && entity.pokemon.persistentData.contains(TAG)
fun spawn(level: ServerLevel, pokemon: Pokemon, core: BlockPos, chamber: BlockPos, facing: Direction): PokemonEntity {
@@ -0,0 +1,68 @@
package dev.cobblesurvival.machine
import net.minecraft.core.BlockPos
import net.minecraft.core.particles.ParticleOptions
import net.minecraft.core.particles.ParticleTypes
import net.minecraft.core.registries.BuiltInRegistries
import net.minecraft.sounds.SoundEvent
import net.minecraft.sounds.SoundEvents
import net.minecraft.sounds.SoundSource
import net.minecraft.world.level.Level
import net.minecraft.world.level.block.state.BlockState
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.sin
/**
* What a working machine looks like (Adrian, 2026-11-02: particles around the Pokémon and an animated core show that
* something is happening, like the fire in a furnace). Client side, from the core's [MachineBlock.ACTIVE] state:
* particles rise around the Pokémon in the chamber and flicker at the core's face, now and then with a sound. The core's
* animated face and its glow come with the active state itself (models from tools/machine_assets.py, light in [Machines]).
*/
object MachineEffects {
/** [chamber] around the Pokémon, [face] at the core's front (with [smoke] beside it), [sound] now and then. */
private class Look(val chamber: ParticleOptions, val face: ParticleOptions, val smoke: ParticleOptions?, val sound: SoundEvent?, val volume: Float = 0.6f)
private val LOOKS = mapOf(
"heat_core" to Look(ParticleTypes.FLAME, ParticleTypes.FLAME, ParticleTypes.SMOKE, SoundEvents.FURNACE_FIRE_CRACKLE, 1f),
"dynamo_core" to Look(ParticleTypes.ELECTRIC_SPARK, ParticleTypes.ELECTRIC_SPARK, null, SoundEvents.COPPER_BULB_TURN_ON, 0.3f),
"synthesis_core" to Look(ParticleTypes.ENCHANT, ParticleTypes.HAPPY_VILLAGER, null, SoundEvents.BEACON_AMBIENT, 0.3f),
"altar_core" to Look(ParticleTypes.SOUL, ParticleTypes.SOUL_FIRE_FLAME, ParticleTypes.SMOKE, SoundEvents.SCULK_CATALYST_BLOOM, 0.5f),
"rebirth_core" to Look(ParticleTypes.END_ROD, ParticleTypes.ENCHANT, null, SoundEvents.AMETHYST_BLOCK_CHIME),
)
/** Per tick: chance of a particle around the Pokémon, at the face, and of a sound. */
private const val CHAMBER_CHANCE = 0.35
private const val FACE_CHANCE = 0.2
private const val SOUND_CHANCE = 0.02
fun clientTick(level: Level, pos: BlockPos, state: BlockState) {
if (!state.getValue(MachineBlock.ACTIVE)) return
val look = LOOKS[BuiltInRegistries.BLOCK.getKey(state.block).path] ?: return
val random = level.random
val facing = state.getValue(MachineBlock.FACING)
val chamber = pos.relative(facing.opposite)
if (random.nextDouble() < CHAMBER_CHANCE) {
val angle = random.nextDouble() * 2 * PI
val radius = 0.35 + random.nextDouble() * 0.15
val x = chamber.x + 0.5 + cos(angle) * radius
val y = chamber.y + 0.1 + random.nextDouble() * 0.8
val z = chamber.z + 0.5 + sin(angle) * radius
// Enchanting glyphs fly from where they start to the given offset's origin: here, into the Pokémon.
if (look.chamber == ParticleTypes.ENCHANT) level.addParticle(look.chamber, chamber.x + 0.5, chamber.y + 0.5, chamber.z + 0.5, x - chamber.x - 0.5, y - chamber.y - 0.5, z - chamber.z - 0.5)
else level.addParticle(look.chamber, x, y, z, 0.0, 0.02, 0.0)
}
if (random.nextDouble() < FACE_CHANCE) {
// Like a lit furnace: low on the core's front, a little in front of it.
val across = random.nextDouble() * 0.6 - 0.3
val x = pos.x + 0.5 + facing.stepX * 0.52 + facing.stepZ * across
val y = pos.y + 0.1 + random.nextDouble() * 0.4
val z = pos.z + 0.5 + facing.stepZ * 0.52 + facing.stepX * across
level.addParticle(look.face, x, y, z, 0.0, 0.0, 0.0)
look.smoke?.let { level.addParticle(it, x, y + 0.2, z, 0.0, 0.0, 0.0) }
}
if (look.sound != null && random.nextDouble() < SOUND_CHANCE) {
level.playLocalSound(pos, look.sound, SoundSource.BLOCKS, look.volume, 0.9f + random.nextFloat() * 0.2f, false)
}
}
}
@@ -86,6 +86,23 @@ object MachineWork {
}
}
/**
* Whether the machine is doing something this tick, for how it looks ([MachineBlockEntity.showWork]): a furnace it
* heats has something to cook; the generator isn't full; the synthesizer gets energy; a rebirth runs; the altar
* holds a Pokémon.
*/
fun working(core: MachineBlockEntity, level: ServerLevel, def: MachineDefs.Def): Boolean {
val pokemon = core.pokemon ?: return false
return when (def.kind) {
MachineDefs.Kind.FURNACE -> core.furnaces.take(def.furnaces!!.count(pokemon.level))
.any { (level.getBlockEntity(it) as? AbstractFurnaceBlockEntity)?.getItem(0)?.isEmpty == false }
MachineDefs.Kind.GENERATOR -> core.energy < def.energy!!.capacity
MachineDefs.Kind.SYNTHESIS -> core.receivedThisTick > 0
MachineDefs.Kind.REBIRTH -> core.rebirthAt > 0
MachineDefs.Kind.ALTAR -> true
}
}
// --- Large Furnace -------------------------------------------------------------------------------------------
private fun heat(core: MachineBlockEntity, level: ServerLevel, rules: MachineDefs.FurnaceRules, pokemon: Pokemon) {
@@ -41,7 +41,8 @@ object Machines {
private fun core(name: String, color: MapColor, sound: SoundType): DeferredBlock<MachineBlock> = BLOCKS.registerBlock(
name, ::MachineBlock, BlockBehaviour.Properties.of().mapColor(color).strength(3.5f).requiresCorrectToolForDrops().sound(sound)
// See-through, so the Pokémon in the chamber behind the core shows (Adrian, 2026-11-02).
.noOcclusion().isViewBlocking { _, _, _ -> false }.isSuffocating { _, _, _ -> false },
.noOcclusion().isViewBlocking { _, _, _ -> false }.isSuffocating { _, _, _ -> false }
.lightLevel { if (it.getValue(MachineBlock.ACTIVE)) MachineBlock.ACTIVE_LIGHT else 0 },
)
fun register(bus: IEventBus) {
@@ -2,10 +2,14 @@ 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.api.pokemon.stats.Stats
import com.cobblemon.mod.common.battles.MoveActionResponse
import com.cobblemon.mod.common.battles.BattleRegistry
import com.cobblemon.mod.common.util.getBattleState
import dev.cobblesurvival.boss.Area
import dev.cobblesurvival.boss.BossDefs
import dev.cobblesurvival.boss.BossFights
import dev.cobblesurvival.boss.BossToughness
import dev.cobblesurvival.boss.EndBoss
import dev.cobblesurvival.zone.Guardians
import net.minecraft.core.BlockPos
@@ -33,8 +37,13 @@ object BossTests {
SelfTestCase("boss_arena_leaving_loses", 400, ::arena),
SelfTestCase("end_boss_replaces_the_dragon", 200, ::endBoss),
SelfTestCase("practice_bosses_spawn", 20, ::practice),
SelfTestCase("boss_takes_a_quarter_per_attack", 1200, ::damageCap),
)
/** Guardians stand at least this tall (Morpeko, the smallest, reaches about 5 blocks), the End boss [END_HEIGHT]. */
private const val GUARDIAN_HEIGHT = 4.5f
private const val END_HEIGHT = 12f
/** `/cobblesurvival boss <zone|end>`: a copy that fights like the real boss, for every guardian and the End. */
private fun practice(s: Scenario) {
for (which in Guardians.definitions.keys + "end") {
@@ -42,11 +51,51 @@ object BossTests {
?: throw GameTestAssertException("no practice boss for $which")
s.check(BossFights.defOf(entity) != null && entity.pokemon.isUncatchable()) { "the practice $which is no uncatchable boss" }
s.check(entity.pokemon.persistentData.getString(Guardians.TAG).isEmpty()) { "the practice $which counts as the real guardian" }
// Adrian, 2026-11-02: way bigger, tougher but not stronger.
val tall = if (which == "end") END_HEIGHT else GUARDIAN_HEIGHT
s.check(entity.bbHeight >= tall) { "the $which boss is only %.1f blocks tall".format(entity.bbHeight) }
val p = entity.pokemon
val defensive = listOf(Stats.HP, Stats.DEFENCE, Stats.SPECIAL_DEFENCE)
s.check(defensive.all { p.ivs[it] == 31 && p.evs[it] == BossToughness.EVS }) { "the $which boss lacks its HP/Defense/Sp. Def training" }
s.check(listOf(Stats.ATTACK, Stats.SPECIAL_ATTACK, Stats.SPEED).all { p.evs[it] == 0 }) { "the $which boss has offensive EVs" }
s.check(p.currentHealth == p.maxHealth && BossToughness.isBoss(p)) { "the $which boss isn't at full health or isn't a boss" }
entity.discard()
}
s.helper.succeed()
}
/**
* The Guardian's Ward (Adrian, 2026-11-02: no single attack takes more than a quarter of a boss's HP): a Lv 100
* Garchomp's Bullet Seed, every hit enough to knock out a Lv 15 boss, takes a quarter at most per use.
*/
private fun damageCap(s: Scenario) {
val player = s.player()
s.give(player, s.pokemon("garchomp", 100, "bulletseed"))
val bossPokemon = s.pokemon("morpeko", 15, "splash")
bossPokemon.persistentData.putString(BossFights.TAG, TEST_BOSS)
val boss = s.wild(bossPokemon, s.at(7.5, 1.0, 3.5))
val battle = { BattleRegistry.getBattleByParticipatingPlayer(player) }
s.afterTest { battle()?.end() }
// Two attacks, then the battle waits for a third choice that never comes.
var attacks = 0
s.answerBattles(player) { actor, _ ->
if (attacks == 2) null else MoveActionResponse("bulletseed", actor.getSide().getOppositeSide().activePokemon.first().getPNX()).also { attacks++ }
}
s.startWildBattle(player, boss)
.thenWaitUntil {
val b = battle()
val waiting = player.getBattleState()?.second?.let { it.mustChoose && it.request != null } == true
s.check(b == null || (attacks == 2 && b.turn >= 3 && waiting)) { "two attacks aren't done yet: ${s.battleState(player)}" }
}
.thenExecute {
val max = boss.pokemon.maxHealth
val hp = boss.pokemon.currentHealth
s.check(hp in (max - 2 * (max / 4))..<max) { "after two attacks that each could knock it out, the boss has $hp of $max HP (a quarter at most per attack)" }
battle()?.end()
}
.thenSucceed()
}
private fun implemented(species: String) = PokemonSpecies.getByName(species)?.implemented == true
private fun coverage(s: Scenario) {
@@ -247,7 +247,17 @@ object MachineTests {
level.setBlockAndUpdate(furnace.below(), Blocks.HOPPER.defaultBlockState().setValue(HopperBlock.FACING, Direction.DOWN))
(level.getBlockEntity(furnace.above()) as HopperBlockEntity).setItem(0, ItemStack(Items.RAW_IRON, 4))
val output = level.getBlockEntity(furnace.below()) as HopperBlockEntity
val active = { core.blockState.getValue(MachineBlock.ACTIVE) }
s.helper.startSequence()
// Adrian, 2026-11-02: a working machine shows it (the core glows and animates, particles show).
.thenWaitUntil { s.check(active()) { "the Large Furnace's core doesn't show that it works" } }
.thenExecute {
// The Pokémon inside flinches as if hit, but takes no damage.
val shown = displays(level, core.blockPos).firstOrNull() ?: throw GameTestAssertException("no Pokémon shown in the chamber")
MachineDisplay.flinch(level, shown)
val inside = core.pokemon!!
s.check(shown.isAlive && inside.currentHealth == inside.maxHealth) { "a flinch hurt the Pokémon (${inside.currentHealth}/${inside.maxHealth})" }
}
.thenWaitUntil {
val ingots = (0 until output.containerSize).sumOf { output.getItem(it).takeIf { s -> s.`is`(Items.IRON_INGOT) }?.count ?: 0 }
s.check(ingots >= 4) { "the hopper under the furnace has $ingots iron ingots, not 4" }
@@ -256,6 +266,7 @@ object MachineTests {
val fuel = (level.getBlockEntity(furnace) as AbstractFurnaceBlockEntity).getItem(1)
s.check(fuel.isEmpty) { "the furnace got fuel: $fuel" }
}
.thenWaitUntil { s.check(!active()) { "the Large Furnace's core still shows work with nothing left to cook" } }
.thenSucceed()
}
@@ -8,6 +8,7 @@ import com.cobblemon.mod.common.entity.pokemon.PokemonEntity
import com.google.gson.Gson
import com.google.gson.JsonElement
import dev.cobblesurvival.CobbleSurvival
import dev.cobblesurvival.boss.BossToughness
import net.minecraft.ChatFormatting
import net.minecraft.core.BlockPos
import net.minecraft.core.HolderLookup
@@ -31,6 +32,7 @@ import net.neoforged.neoforge.event.entity.EntityJoinLevelEvent
import net.neoforged.neoforge.event.tick.ServerTickEvent
import java.util.UUID
import kotlin.math.abs
import kotlin.math.ceil
/**
* One Alpha guardian per zone (roadmap Phase 3): a big, uncatchable Pokémon standing at the zone's sample point.
@@ -48,6 +50,10 @@ object Guardians : SimpleJsonResourceReloadListener(Gson(), "guardians") {
private const val NEAR = 96.0
private const val LEASH = 24.0
private const val TICKS_PER_HOUR = 72_000L
/** How far (blocks, each way) a cave guardian's spot may lie from the zone's sample point. */
private const val CAVE_SEARCH = 24
/** A cave spot needs at least this much air above it, even when no spot fits the whole guardian. */
private const val MIN_ROOM = 3
class Definition(
val zone: String,
@@ -94,6 +100,11 @@ object Guardians : SimpleJsonResourceReloadListener(Gson(), "guardians") {
val entry = state.entry(zone)
val guardian = entry.entity?.let { level.getEntity(it) as? PokemonEntity }?.takeIf { it.isAlive }
if (guardian != null) {
// A guardian from before it grew (Adrian, 2026-11-02): grown, it may need a roomier cave spot.
if (BossToughness.refresh(guardian, definition.scale) && sample.y != null) {
spotFor(level, zone, sample, guardian.bbHeight)?.let { entry.spot = it }
entry.spot?.let { guardian.teleportTo(it.x + 0.5, it.y.toDouble(), it.z + 0.5) }
}
entry.spot?.let { leash(guardian, it) }
continue
}
@@ -103,21 +114,29 @@ object Guardians : SimpleJsonResourceReloadListener(Gson(), "guardians") {
// The old guardian may still be loading from disk with its chunk; wait for that.
val chunk = ChunkPos(SectionPos.blockToSectionCoord(sample.x), SectionPos.blockToSectionCoord(sample.z))
if (!level.areEntitiesLoaded(chunk.toLong())) continue
val spot = entry.spot ?: spotFor(level, zone, sample)?.also { entry.spot = it } ?: continue
val spawned = spawn(level, definition, spot) ?: continue
val entity = create(level, definition)
// A spot picked for a smaller guardian is kept only while this one still fits there.
val spot = entry.spot?.takeIf { sample.y == null || room(level, it, entity.bbHeight) >= ceil(entity.bbHeight) }
?: spotFor(level, zone, sample, entity.bbHeight)?.also { entry.spot = it } ?: continue
val spawned = spawn(level, entity, spot) ?: continue
entry.entity = spawned.uuid
}
}
private fun spawn(level: ServerLevel, definition: Definition, spot: BlockPos): PokemonEntity? {
private fun create(level: ServerLevel, definition: Definition): PokemonEntity {
val pokemon = PokemonProperties.parse(definition.pokemon).create()
pokemon.scaleModifier = definition.scale
pokemon.persistentData.putString(TAG, definition.zone)
val entity = PokemonEntity(level, pokemon)
BossToughness.toughen(pokemon, definition.scale)
pokemon.heal()
return PokemonEntity(level, pokemon)
}
private fun spawn(level: ServerLevel, entity: PokemonEntity, spot: BlockPos): PokemonEntity? {
val zone = entity.pokemon.persistentData.getString(TAG)
entity.moveTo(spot.x + 0.5, spot.y.toDouble(), spot.z + 0.5, level.random.nextFloat() * 360f, 0f)
entity.setPersistenceRequired()
if (!level.addFreshEntity(entity)) return null
CobbleSurvival.LOGGER.info("Guardian of {} appeared at {}", definition.zone, spot.toShortString())
CobbleSurvival.LOGGER.info("Guardian of {} appeared at {}, {} blocks tall", zone, spot.toShortString(), "%.1f".format(entity.bbHeight))
return entity
}
@@ -128,18 +147,33 @@ object Guardians : SimpleJsonResourceReloadListener(Gson(), "guardians") {
}
}
/** Where the guardian stands: the surface at the sample point, or for a cave zone open floor near it. */
private fun spotFor(level: ServerLevel, zone: String, sample: ZoneSample): BlockPos? {
/** Air above [pos], counted up to the [height] a guardian needs. */
private fun room(level: ServerLevel, pos: BlockPos, height: Float): Int {
val need = ceil(height).toInt()
return (0 until need).firstOrNull { !level.getBlockState(pos.above(it)).isAir } ?: need
}
/**
* Where the guardian stands: the surface at the sample point, or for a cave zone open floor near it with room for
* its [height]; when no floor has that much, the roomiest one found (at least [MIN_ROOM]).
*/
private fun spotFor(level: ServerLevel, zone: String, sample: ZoneSample, height: Float): BlockPos? {
val caveY = sample.y ?: return BlockPos(sample.x, level.getHeight(Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, sample.x, sample.z), sample.z)
for (radius in 0..16 step 2) for (dx in -radius..radius) for (dz in -radius..radius) {
var best: BlockPos? = null
var bestRoom = MIN_ROOM - 1
for (radius in 0..CAVE_SEARCH step 2) for (dx in -radius..radius) for (dz in -radius..radius) {
if (maxOf(abs(dx), abs(dz)) != radius) continue
for (dy in 0..24) for (sign in intArrayOf(1, -1)) {
val pos = BlockPos(sample.x + dx, caveY + sign * dy, sample.z + dz)
val open = (0 until 3).all { level.getBlockState(pos.above(it)).isAir }
if (open && level.getBlockState(pos.below()).isSolid && Zones.at(level, pos)?.id == zone) return pos
if (!level.getBlockState(pos).isAir || !level.getBlockState(pos.below()).isSolid) continue
val room = room(level, pos, height)
if (room <= bestRoom || Zones.at(level, pos)?.id != zone) continue
best = pos
bestRoom = room
if (room >= ceil(height)) return pos
}
}
return null
return best
}
/** A second copy of a zone's guardian (e.g. one that wandered into a chunk loading late) never joins. */
@@ -1,19 +1,34 @@
{
"variants": {
"facing=north": {
"active=false,facing=north": {
"model": "cobblesurvival:block/altar_core"
},
"facing=east": {
"active=false,facing=east": {
"model": "cobblesurvival:block/altar_core",
"y": 90
},
"facing=south": {
"active=false,facing=south": {
"model": "cobblesurvival:block/altar_core",
"y": 180
},
"facing=west": {
"active=false,facing=west": {
"model": "cobblesurvival:block/altar_core",
"y": 270
},
"active=true,facing=north": {
"model": "cobblesurvival:block/altar_core_on"
},
"active=true,facing=east": {
"model": "cobblesurvival:block/altar_core_on",
"y": 90
},
"active=true,facing=south": {
"model": "cobblesurvival:block/altar_core_on",
"y": 180
},
"active=true,facing=west": {
"model": "cobblesurvival:block/altar_core_on",
"y": 270
}
}
}
@@ -1,19 +1,34 @@
{
"variants": {
"facing=north": {
"active=false,facing=north": {
"model": "cobblesurvival:block/dynamo_core"
},
"facing=east": {
"active=false,facing=east": {
"model": "cobblesurvival:block/dynamo_core",
"y": 90
},
"facing=south": {
"active=false,facing=south": {
"model": "cobblesurvival:block/dynamo_core",
"y": 180
},
"facing=west": {
"active=false,facing=west": {
"model": "cobblesurvival:block/dynamo_core",
"y": 270
},
"active=true,facing=north": {
"model": "cobblesurvival:block/dynamo_core_on"
},
"active=true,facing=east": {
"model": "cobblesurvival:block/dynamo_core_on",
"y": 90
},
"active=true,facing=south": {
"model": "cobblesurvival:block/dynamo_core_on",
"y": 180
},
"active=true,facing=west": {
"model": "cobblesurvival:block/dynamo_core_on",
"y": 270
}
}
}
@@ -1,19 +1,34 @@
{
"variants": {
"facing=north": {
"active=false,facing=north": {
"model": "cobblesurvival:block/heat_core"
},
"facing=east": {
"active=false,facing=east": {
"model": "cobblesurvival:block/heat_core",
"y": 90
},
"facing=south": {
"active=false,facing=south": {
"model": "cobblesurvival:block/heat_core",
"y": 180
},
"facing=west": {
"active=false,facing=west": {
"model": "cobblesurvival:block/heat_core",
"y": 270
},
"active=true,facing=north": {
"model": "cobblesurvival:block/heat_core_on"
},
"active=true,facing=east": {
"model": "cobblesurvival:block/heat_core_on",
"y": 90
},
"active=true,facing=south": {
"model": "cobblesurvival:block/heat_core_on",
"y": 180
},
"active=true,facing=west": {
"model": "cobblesurvival:block/heat_core_on",
"y": 270
}
}
}
@@ -1,19 +1,34 @@
{
"variants": {
"facing=north": {
"active=false,facing=north": {
"model": "cobblesurvival:block/rebirth_core"
},
"facing=east": {
"active=false,facing=east": {
"model": "cobblesurvival:block/rebirth_core",
"y": 90
},
"facing=south": {
"active=false,facing=south": {
"model": "cobblesurvival:block/rebirth_core",
"y": 180
},
"facing=west": {
"active=false,facing=west": {
"model": "cobblesurvival:block/rebirth_core",
"y": 270
},
"active=true,facing=north": {
"model": "cobblesurvival:block/rebirth_core_on"
},
"active=true,facing=east": {
"model": "cobblesurvival:block/rebirth_core_on",
"y": 90
},
"active=true,facing=south": {
"model": "cobblesurvival:block/rebirth_core_on",
"y": 180
},
"active=true,facing=west": {
"model": "cobblesurvival:block/rebirth_core_on",
"y": 270
}
}
}
@@ -1,19 +1,34 @@
{
"variants": {
"facing=north": {
"active=false,facing=north": {
"model": "cobblesurvival:block/synthesis_core"
},
"facing=east": {
"active=false,facing=east": {
"model": "cobblesurvival:block/synthesis_core",
"y": 90
},
"facing=south": {
"active=false,facing=south": {
"model": "cobblesurvival:block/synthesis_core",
"y": 180
},
"facing=west": {
"active=false,facing=west": {
"model": "cobblesurvival:block/synthesis_core",
"y": 270
},
"active=true,facing=north": {
"model": "cobblesurvival:block/synthesis_core_on"
},
"active=true,facing=east": {
"model": "cobblesurvival:block/synthesis_core_on",
"y": 90
},
"active=true,facing=south": {
"model": "cobblesurvival:block/synthesis_core_on",
"y": 180
},
"active=true,facing=west": {
"model": "cobblesurvival:block/synthesis_core_on",
"y": 270
}
}
}
@@ -0,0 +1,9 @@
{
"parent": "minecraft:block/orientable",
"render_type": "minecraft:translucent",
"textures": {
"top": "cobblesurvival:block/altar_core_top",
"front": "cobblesurvival:block/altar_core_front_on",
"side": "cobblesurvival:block/altar_core_side"
}
}
@@ -0,0 +1,9 @@
{
"parent": "minecraft:block/orientable",
"render_type": "minecraft:translucent",
"textures": {
"top": "cobblesurvival:block/dynamo_core_top",
"front": "cobblesurvival:block/dynamo_core_front_on",
"side": "cobblesurvival:block/dynamo_core_side"
}
}
@@ -0,0 +1,9 @@
{
"parent": "minecraft:block/orientable",
"render_type": "minecraft:translucent",
"textures": {
"top": "cobblesurvival:block/heat_core_top",
"front": "cobblesurvival:block/heat_core_front_on",
"side": "cobblesurvival:block/heat_core_side"
}
}
@@ -0,0 +1,9 @@
{
"parent": "minecraft:block/orientable",
"render_type": "minecraft:translucent",
"textures": {
"top": "cobblesurvival:block/rebirth_core_top",
"front": "cobblesurvival:block/rebirth_core_front_on",
"side": "cobblesurvival:block/rebirth_core_side"
}
}
@@ -0,0 +1,9 @@
{
"parent": "minecraft:block/orientable",
"render_type": "minecraft:translucent",
"textures": {
"top": "cobblesurvival:block/synthesis_core_top",
"front": "cobblesurvival:block/synthesis_core_front_on",
"side": "cobblesurvival:block/synthesis_core_side"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -0,0 +1,6 @@
{
"animation": {
"frametime": 3,
"interpolate": true
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1010 B

@@ -0,0 +1,6 @@
{
"animation": {
"frametime": 3,
"interpolate": true
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1,6 @@
{
"animation": {
"frametime": 3,
"interpolate": true
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 996 B

@@ -0,0 +1,6 @@
{
"animation": {
"frametime": 3,
"interpolate": true
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 928 B

@@ -0,0 +1,6 @@
{
"animation": {
"frametime": 3,
"interpolate": true
}
}