Phase 6: Pokémon machines (five cores, shells as data)

Adrian (2026-10-31): a Large Furnace where a Fire Pokémon heats real
furnaces (hoppers in and out), our own Electric generator, no upkeep;
then a drop synthesizer (FE into full drop rolls, looting tiers), a
sacrifice altar (level tiers, rare candies only from Lv 80) and a
rebirth chamber (Lv 100 -> shiny Lv 1 first stage, three perfect IVs,
moves kept, Ability Patch so the hidden ability is optional).

- machine/: data-driven 3x3x3 shells (window spots for glass, free
  spot under the core), checked in four rotations; the Pokémon walks in
  from the party and lives in the core; breaking core or shell sends it
  home (party, else PC, offline too); a real Cobblemon entity copy is
  shown in the chamber (no AI, invulnerable, uncatchable, never saved).
- Heat Core keeps vanilla furnaces lit and faster; Dynamo Core makes
  and pushes FE; Synthesis Core takes FE and rolls drops into a hopper-
  extractable buffer; Altar Core sacrifices on a confirmed sneak-use;
  Rebirth Core runs on server time.
- Recipes, casing tags, block loot, textures (tools/machine_assets.py),
  models, blockstates, names.
- Self-tests: in/out, refusal, shell and core breaks (PC when full),
  chunk unload, server restart, and each machine's work.
- Docs: design, balance table, Phase 6 checklist; Phase 5 approved.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
claudeandClaude Opus 5.5 committed 2026-11-02 15:18:03 +01:00
1 parent 5cae9252c3
commit 0877a6a179
75 files changed
+2321 -7

No files matched your search

@@ -0,0 +1,360 @@
package dev.cobblesurvival.selftest
import com.cobblemon.mod.common.Cobblemon
import com.cobblemon.mod.common.api.pokemon.stats.Stats
import com.cobblemon.mod.common.entity.pokemon.PokemonEntity
import com.cobblemon.mod.common.pokemon.Pokemon
import dev.cobblesurvival.CobbleSurvival
import dev.cobblesurvival.machine.MachineBlock
import dev.cobblesurvival.machine.MachineBlockEntity
import dev.cobblesurvival.machine.MachineDefs
import dev.cobblesurvival.machine.MachineDisplay
import dev.cobblesurvival.machine.MachineWork
import dev.cobblesurvival.machine.Shell
import net.minecraft.core.BlockPos
import net.minecraft.core.Direction
import net.minecraft.core.registries.BuiltInRegistries
import net.minecraft.gametest.framework.GameTestAssertException
import net.minecraft.resources.ResourceLocation
import net.minecraft.server.level.ServerLevel
import net.minecraft.server.level.ServerPlayer
import net.minecraft.world.item.Item
import net.minecraft.world.item.ItemStack
import net.minecraft.world.item.Items
import net.minecraft.world.level.ChunkPos
import net.minecraft.world.level.block.Block
import net.minecraft.world.level.block.Blocks
import net.minecraft.world.level.block.HopperBlock
import net.minecraft.world.level.block.entity.AbstractFurnaceBlockEntity
import net.minecraft.world.level.block.entity.HopperBlockEntity
import net.minecraft.world.phys.AABB
import net.minecraft.world.phys.Vec3
import net.neoforged.neoforge.capabilities.Capabilities
/**
* Pokémon machines (roadmap Phase 6). The roadmap's done-when: putting a Pokémon in and taking it out, a broken shell or
* core, chunk unload and server restart, all without losing or duplicating the Pokémon; and each machine's work.
* Machines are built with the core in the south wall, the chamber north of it.
*/
object MachineTests {
/** Core position in a test's area; the shell spans x 6-8, y 0-2, z 5-7. */
private val CORE = BlockPos(7, 1, 7)
/** Blocks south (unload) and north (restart) of spawn: far beyond view distance, clear of the vein tests (east, west). */
private const val UNLOAD_OFFSET = 352
private const val RESTART_OFFSET = -352
fun all() = listOf(
SelfTestCase("machine_pokemon_goes_in_and_out", 60, ::inAndOut),
SelfTestCase("machine_refuses_what_doesnt_fit", 20, ::refuses),
SelfTestCase("machine_shell_break_returns_pokemon", 100, ::shellBreak),
SelfTestCase("machine_core_break_returns_pokemon_to_pc", 60, ::coreBreakToPc),
SelfTestCase("machine_survives_unload", 600, ::survivesUnload),
SelfTestCase("machine_filled_before_restart", 100, ::beforeRestart),
SelfTestCase("machine_survives_restart", 200, ::afterRestart, afterRestart = true),
SelfTestCase("heat_core_cooks_with_hoppers", 800, ::heatCore),
SelfTestCase("dynamo_core_powers_cables", 100, ::dynamo),
SelfTestCase("synthesis_core_rolls_drops", 100, ::synthesis),
SelfTestCase("altar_core_sacrifice_needs_confirmation", 60, ::altar),
SelfTestCase("rebirth_core_makes_a_shiny_first_stage", 100, ::rebirth),
)
private fun id(name: String) = ResourceLocation.fromNamespaceAndPath(CobbleSurvival.MOD_ID, name)
fun build(level: ServerLevel, core: BlockPos, name: String, casing: Block): MachineBlockEntity {
val def = MachineDefs.byCore[id(name)] ?: throw GameTestAssertException("no machine data for ${id(name)}")
for ((offset, text) in def.shell.layout()) {
level.setBlockAndUpdate(core.offset(offset), if (text == Shell.AIR) Blocks.AIR.defaultBlockState() else casing.defaultBlockState())
}
level.setBlockAndUpdate(core, BuiltInRegistries.BLOCK.get(id(name)).defaultBlockState().setValue(MachineBlock.FACING, Direction.SOUTH))
return level.getBlockEntity(core) as? MachineBlockEntity ?: throw GameTestAssertException("no machine at $core")
}
private fun build(s: Scenario, name: String, casing: Block) = build(s.level, s.helper.absolutePos(CORE), name, casing)
/** Sends [pokemon] (in [player]'s party) out two blocks in front of the core. */
private fun sendOut(s: Scenario, core: BlockPos, pokemon: Pokemon): PokemonEntity {
val spot = core.south(2)
if (s.level.getBlockState(spot.below()).isAir) s.level.setBlockAndUpdate(spot.below(), Blocks.STONE.defaultBlockState())
return s.spawn(PokemonEntity(s.level, pokemon), Vec3.atBottomCenterOf(spot))
}
private fun insert(s: Scenario, core: MachineBlockEntity, player: ServerPlayer, pokemon: Pokemon) {
sendOut(s, core.blockPos, pokemon)
core.use(player)
s.check(core.pokemon?.uuid == pokemon.uuid) { "${pokemon.species.name} didn't go into the ${core.machineName.string}" }
}
private fun displays(level: ServerLevel, core: BlockPos) =
level.getEntitiesOfClass(PokemonEntity::class.java, AABB(core).inflate(3.0)) { MachineDisplay.isDisplay(it) }
private fun inParty(player: ServerPlayer, pokemon: Pokemon) = Cobblemon.storage.getParty(player).any { it.uuid == pokemon.uuid }
private fun inPc(player: ServerPlayer, pokemon: Pokemon) = Cobblemon.storage.getPC(player).any { it.uuid == pokemon.uuid }
private fun inAndOut(s: Scenario) {
val player = s.player()
val core = build(s, "heat_core", Blocks.BRICKS)
val pokemon = s.give(player, s.pokemon("charmander", 20))
s.helper.startSequence()
.thenExecute {
insert(s, core, player, pokemon)
s.check(!inParty(player, pokemon)) { "Charmander is in the machine and still in the party" }
}
.thenWaitUntil { s.check(displays(s.level, core.blockPos).size == 1) { "${displays(s.level, core.blockPos).size} Pokémon shown in the chamber, not 1" } }
.thenExecute {
val shown = displays(s.level, core.blockPos).single()
s.check(shown.pokemon.uuid != pokemon.uuid) { "the shown Pokémon is the real one, not a copy" }
s.check(shown.isNoAi && shown.isInvulnerable && shown.pokemon.isUncatchable()) { "the shown Pokémon can move, be hurt or be caught" }
core.use(player)
s.check(core.pokemon == null) { "Charmander is still in the machine after taking it out" }
s.check(inParty(player, pokemon)) { "Charmander didn't come back to the party" }
}
.thenWaitUntil { s.check(displays(s.level, core.blockPos).isEmpty()) { "the chamber still shows a Pokémon" } }
.thenSucceed()
}
private fun refuses(s: Scenario) {
val player = s.player()
val core = build(s, "heat_core", Blocks.BRICKS)
val pikachu = s.give(player, s.pokemon("pikachu", 20))
sendOut(s, core.blockPos, pikachu)
core.use(player)
s.check(core.pokemon == null && inParty(player, pikachu)) { "a Pikachu went into the Heat Core (Fire types only)" }
val charmander = s.give(player, s.pokemon("charmander", 20))
sendOut(s, core.blockPos, charmander)
s.level.setBlockAndUpdate(core.blockPos.offset(-1, 1, -2), Blocks.AIR.defaultBlockState())
core.use(player)
s.check(core.pokemon == null && inParty(player, charmander)) { "Charmander went into a Heat Core with a hole in its shell" }
s.helper.succeed()
}
private fun shellBreak(s: Scenario) {
val player = s.player()
val core = build(s, "heat_core", Blocks.BRICKS)
val pokemon = s.give(player, s.pokemon("charmander", 20))
s.helper.startSequence()
.thenExecute {
insert(s, core, player, pokemon)
s.level.setBlockAndUpdate(core.blockPos.offset(1, 1, -2), Blocks.AIR.defaultBlockState())
}
.thenWaitUntil { s.check(core.pokemon == null) { "Charmander stayed in a machine with a broken shell" } }
.thenExecute { s.check(inParty(player, pokemon) && !inPc(player, pokemon)) { "Charmander didn't come back to the party (only)" } }
.thenWaitUntil { s.check(displays(s.level, core.blockPos).isEmpty()) { "the broken machine still shows a Pokémon" } }
.thenSucceed()
}
private fun coreBreakToPc(s: Scenario) {
val player = s.player()
val core = build(s, "heat_core", Blocks.BRICKS)
val pokemon = s.give(player, s.pokemon("charmander", 20))
insert(s, core, player, pokemon)
repeat(6) { s.give(player, s.pokemon("rattata", 5)) }
s.level.destroyBlock(core.blockPos, true)
s.check(!inParty(player, pokemon) && inPc(player, pokemon)) { "with a full party, Charmander didn't go to the PC when the core broke" }
s.helper.startSequence()
.thenWaitUntil { s.check(displays(s.level, core.blockPos).isEmpty()) { "the broken machine still shows a Pokémon" } }
.thenSucceed()
}
private fun spot(level: ServerLevel, offset: Int) = level.sharedSpawnPos.let { BlockPos(it.x, SelfTest.PLATFORM_Y, it.z + offset) }
private fun chunks(core: BlockPos) = listOf(core.offset(-2, 0, -3), core.offset(2, 0, 3), core.offset(-2, 0, 3), core.offset(2, 0, -3))
.map(::ChunkPos).distinct()
private fun force(level: ServerLevel, core: BlockPos, on: Boolean) = chunks(core).forEach { level.setChunkForced(it.x, it.z, on) }
private fun clearMachine(level: ServerLevel, core: BlockPos) {
BlockPos.betweenClosed(core.offset(-1, -1, -2), core.offset(1, 1, 2)).forEach { level.setBlockAndUpdate(it, Blocks.AIR.defaultBlockState()) }
level.setBlockAndUpdate(core.south(2).below(), Blocks.AIR.defaultBlockState())
}
private fun survivesUnload(s: Scenario) {
val level = s.level
val pos = spot(level, UNLOAD_OFFSET)
val chunk = ChunkPos(pos)
val player = s.player()
val pokemon = s.give(player, s.pokemon("charmander", 20))
s.afterTest {
force(level, pos, true)
clearMachine(level, pos)
force(level, pos, false)
}
s.helper.startSequence()
.thenExecute { force(level, pos, true) }
.thenWaitUntil { s.check(level.isPositionEntityTicking(pos.south(2))) { "the machine's spot doesn't tick entities yet" } }
.thenExecute {
insert(s, build(level, pos, "heat_core", Blocks.BRICKS), player, pokemon)
force(level, pos, false)
}
.thenWaitUntil { s.check(!level.hasChunk(chunk.x, chunk.z)) { "the machine's chunk is still loaded" } }
.thenExecute { force(level, pos, true) }
.thenWaitUntil {
val back = level.getBlockEntity(pos) as? MachineBlockEntity
s.check(back?.pokemon?.uuid == pokemon.uuid) { "after unload the core holds ${back?.pokemon?.species?.name}, not Charmander" }
s.check(displays(level, pos).size == 1) { "after unload ${displays(level, pos).size} Pokémon are shown, not 1" }
}
.thenExecute {
val back = level.getBlockEntity(pos) as MachineBlockEntity
back.use(player)
s.check(inParty(player, pokemon) && back.pokemon == null) { "Charmander didn't come out after the unload" }
}
.thenSucceed()
}
private fun beforeRestart(s: Scenario) {
val level = s.level
val pos = spot(level, RESTART_OFFSET)
val player = s.player()
val pokemon = s.give(player, s.pokemon("charmander", 20))
s.helper.startSequence()
.thenExecute { force(level, pos, true) }
.thenWaitUntil { s.check(level.isPositionEntityTicking(pos.south(2))) { "the machine's spot doesn't tick entities yet" } }
.thenExecute {
insert(s, build(level, pos, "heat_core", Blocks.BRICKS), player, pokemon)
force(level, pos, false)
}
.thenSucceed()
}
private fun afterRestart(s: Scenario) {
val level = s.level
val pos = spot(level, RESTART_OFFSET)
s.afterTest {
clearMachine(level, pos)
force(level, pos, false)
}
s.helper.startSequence()
.thenExecute { force(level, pos, true) }
.thenWaitUntil {
val core = level.getBlockEntity(pos) as? MachineBlockEntity
s.check(core?.pokemon?.species?.name == "Charmander") { "after the restart the core holds ${core?.pokemon?.species?.name}, not Charmander" }
s.check(displays(level, pos).size == 1) { "after the restart ${displays(level, pos).size} Pokémon are shown, not 1" }
}
.thenExecute {
val core = level.getBlockEntity(pos) as MachineBlockEntity
s.check(core.giveBack(level, null)) { "Charmander couldn't go back to its (offline) owner after the restart" }
}
.thenSucceed()
}
private fun heatCore(s: Scenario) {
val level = s.level
val player = s.player()
val core = build(s, "heat_core", Blocks.BRICKS)
insert(s, core, player, s.give(player, s.pokemon("charmander", 50)))
// A furnace against the east wall, a hopper with raw iron above it and an empty one below; no fuel anywhere.
val furnace = core.blockPos.offset(2, 0, -1)
level.setBlockAndUpdate(furnace, Blocks.FURNACE.defaultBlockState())
level.setBlockAndUpdate(furnace.above(), Blocks.HOPPER.defaultBlockState().setValue(HopperBlock.FACING, Direction.DOWN))
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
s.helper.startSequence()
.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" }
}
.thenExecute {
val fuel = (level.getBlockEntity(furnace) as AbstractFurnaceBlockEntity).getItem(1)
s.check(fuel.isEmpty) { "the furnace got fuel: $fuel" }
}
.thenSucceed()
}
private fun dynamo(s: Scenario) {
val level = s.level
val player = s.player()
val core = build(s, "dynamo_core", Blocks.COPPER_BLOCK)
insert(s, core, player, s.give(player, s.pokemon("pikachu", 30)))
val cable = BuiltInRegistries.BLOCK.get(ResourceLocation.parse("poke_power:power_cable"))
s.check(cable != Blocks.AIR) { "Poke Power's cable isn't installed" }
val cablePos = core.blockPos.south()
level.setBlockAndUpdate(cablePos, cable.defaultBlockState())
s.helper.startSequence()
.thenWaitUntil {
val stored = level.getCapability(Capabilities.EnergyStorage.BLOCK, cablePos, Direction.NORTH)?.energyStored ?: 0
s.check(stored > 0) { "the cable in front of the Dynamo Core got no energy (core: ${core.energy} FE)" }
}
.thenSucceed()
}
private fun synthesis(s: Scenario) {
val level = s.level
val player = s.player()
val core = build(s, "synthesis_core", Blocks.GOLD_BLOCK)
insert(s, core, player, s.give(player, s.pokemon("magikarp", 10)))
val rules = MachineDefs.byCore[id("synthesis_core")]!!.synthesis!!
val energy = level.getCapability(Capabilities.EnergyStorage.BLOCK, core.blockPos, Direction.SOUTH)
?: throw GameTestAssertException("the Synthesis Core takes no energy")
val taken = energy.receiveEnergy(Int.MAX_VALUE, false)
s.check(taken == rules.maxInput) { "the Synthesis Core took $taken FE in one tick, not its cap ${rules.maxInput}" }
level.setBlockAndUpdate(core.blockPos.below(), Blocks.HOPPER.defaultBlockState().setValue(HopperBlock.FACING, Direction.DOWN))
val hopper = level.getBlockEntity(core.blockPos.below()) as HopperBlockEntity
s.helper.startSequence()
.thenWaitUntil { s.check(core.tier == 1) { "a gold shell is tier ${core.tier}, not 1 (looting I)" } }
.thenExecute { core.energy = rules.fePerRoll }
.thenWaitUntil {
val salmon = (0 until hopper.containerSize).sumOf { hopper.getItem(it).takeIf { s -> s.`is`(Items.SALMON) }?.count ?: 0 }
s.check(salmon > 0) { "no salmon from Magikarp's drops reached the hopper under the core" }
}
.thenSucceed()
}
private fun count(player: ServerPlayer, item: Item) = player.inventory.items.sumOf { if (it.`is`(item)) it.count else 0 }
private fun altar(s: Scenario) {
val level = s.level
val player = s.player()
val rules = MachineDefs.byCore[id("altar_core")]!!.altar!!
val rareCandy = BuiltInRegistries.ITEM.get(ResourceLocation.parse("cobblemon:rare_candy"))
s.check(MachineWork.altarRewards(rules, s.pokemon("geodude", 79)).none { it.`is`(rareCandy) }) { "a Lv 79 Pokémon gives rare candies" }
val core = build(s, "altar_core", Blocks.OBSIDIAN)
val pokemon = s.give(player, s.pokemon("dragonite", 100))
insert(s, core, player, pokemon)
player.isShiftKeyDown = true
core.use(player)
s.check(core.pokemon != null) { "Dragonite was sacrificed without confirming" }
core.use(player)
player.isShiftKeyDown = false
s.check(core.pokemon == null && !inParty(player, pokemon) && !inPc(player, pokemon)) { "Dragonite wasn't sacrificed" }
s.check(count(player, rareCandy) >= 8) { "no rare candies for a Lv 100 sacrifice" }
s.check(count(player, Items.DRAGON_EGG) == 1) { "no dragon egg for a Lv 100 Dragon type" }
s.check(count(player, Items.NETHER_STAR) == 1) { "no nether star for a Lv 100 sacrifice" }
s.helper.startSequence()
.thenWaitUntil { s.check(displays(level, core.blockPos).isEmpty()) { "the altar still shows the sacrificed Pokémon" } }
.thenSucceed()
}
private fun rebirth(s: Scenario) {
val level = s.level
// The rebirth itself, on its own.
val test = s.pokemon("charizard", 100)
Stats.PERMANENT.forEach { test.setIV(it, 10) }
test.setIV(Stats.SPEED, 31)
val moves = test.moveSet.getMoves().map { it.name }
val ability = test.ability.name
MachineWork.rebirth(test, 3)
s.check(test.species.name == "Charmander" && test.level == 1 && test.shiny) { "rebirth gave ${test.species.name} Lv ${test.level} shiny=${test.shiny}" }
val perfect = Stats.PERMANENT.count { test.ivs[it] == 31 }
s.check(perfect == 4 && Stats.PERMANENT.all { (test.ivs[it] ?: 0) >= 10 }) { "after rebirth $perfect IVs are 31 (wanted 4) or one went down: ${test.ivs}" }
s.check(test.moveSet.getMoves().map { it.name } == moves) { "rebirth changed the moves: $moves -> ${test.moveSet.getMoves().map { it.name }}" }
s.check(test.ability.name == ability) { "rebirth changed the ability: $ability -> ${test.ability.name}" }
// And in the machine: the time runs out, the owner takes it out with an Ability Patch.
val player = s.player()
val core = build(s, "rebirth_core", Blocks.AMETHYST_BLOCK)
val pokemon = s.give(player, s.pokemon("charizard", 100))
insert(s, core, player, pokemon)
s.check(core.rebirthAt > level.gameTime) { "the rebirth didn't start" }
val patch = BuiltInRegistries.ITEM.get(ResourceLocation.parse("cobblemon:ability_patch"))
s.helper.startSequence()
.thenExecute { core.rebirthAt = level.gameTime }
.thenWaitUntil { s.check(core.reborn) { "the rebirth didn't finish" } }
.thenExecute {
core.use(player)
val back = Cobblemon.storage.getParty(player).firstOrNull { it.uuid == pokemon.uuid }
s.check(back?.species?.name == "Charmander" && back.level == 1 && back.shiny) { "${back?.species?.name} came out, not a shiny Lv 1 Charmander" }
s.check(count(player, patch) == 1) { "no Ability Patch came with the reborn Pokémon" }
}
.thenSucceed()
}
}