Phase 6: shell preview, core tooltips, machine test world, faster furnace

Adrian (2026-11-02): double the furnace speed; there must be a way in
game to see how a machine is built; a test world with the machines.

- Large Furnace speed doubled (2 + 0.08 x level + 0.02 x SpA).
- Ghost preview: placing a core, using it with an incomplete shell, or
  sneak-using it empty shows every wrong spot as a small glowing block
  display for 10 s (never saved), in the core's orientation, with a
  chat line counting what's missing.
- Core tooltips: what the machine does, what its shell is made of.
- /cobblesurvival machines showcase builds every machine three times
  (working with a Showcase-owned Pokemon, empty, bare core) with signs,
  hoppers, chests and cables; tools/make_machine_world.py makes a
  superflat creative world from it (level.dat via tools/nbtfile.py)
  and --prism installs it as "Machine Test (Phase 6)".
- Self-tests machine_preview_shows_the_shell and
  machine_showcase_builds_every_machine; docs and checklist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
claudeandClaude Opus 5.5 committed 2026-11-02 15:42:14 +01:00
1 parent 0877a6a179
commit c2a87f178e
17 files changed
+558 -27

No files matched your search

@@ -9,6 +9,7 @@ import dev.cobblesurvival.dev.OreVeins
import dev.cobblesurvival.ore.BaseLoot
import dev.cobblesurvival.ore.OreNodes
import dev.cobblesurvival.permadeath.Permadeath
import dev.cobblesurvival.machine.MachineShowcase
import dev.cobblesurvival.machine.Machines
import dev.cobblesurvival.party.ShoulderPassives
import dev.cobblesurvival.progress.Gates
@@ -115,6 +116,7 @@ class CobbleSurvival {
})
.then(BiomePreview.command())
.then(OreVeins.command())
.then(MachineShowcase.command())
.then(Commands.literal("zone").executes { context ->
val source = context.source
val zone = Zones.at(source.level, BlockPos.containing(source.position))
@@ -6,7 +6,9 @@ import net.minecraft.core.Direction
import net.minecraft.server.level.ServerLevel
import net.minecraft.server.level.ServerPlayer
import net.minecraft.world.InteractionResult
import net.minecraft.world.entity.LivingEntity
import net.minecraft.world.entity.player.Player
import net.minecraft.world.item.ItemStack
import net.minecraft.world.item.context.BlockPlaceContext
import net.minecraft.world.level.Level
import net.minecraft.world.level.block.BaseEntityBlock
@@ -43,6 +45,13 @@ class MachineBlock(properties: Properties) : BaseEntityBlock(properties) {
override fun getRenderShape(state: BlockState) = RenderShape.MODEL
/** A freshly placed core shows where its shell goes. */
override fun setPlacedBy(level: Level, pos: BlockPos, state: BlockState, placer: LivingEntity?, stack: ItemStack) {
super.setPlacedBy(level, pos, state, placer, stack)
val core = level.getBlockEntity(pos) as? MachineBlockEntity ?: return
if (level is ServerLevel && placer is ServerPlayer) core.def?.let { core.preview(level, it, placer) }
}
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>? =
@@ -93,7 +93,10 @@ class MachineBlockEntity(pos: BlockPos, state: BlockState) : BlockEntity(Machine
val inside = pokemon
when {
player.isShiftKeyDown && inside != null && def.kind == MachineDefs.Kind.ALTAR && owner == player.uuid -> MachineWork.altarUse(this, level, def, player, inside)
player.isShiftKeyDown -> tell(player, MachineWork.status(this, level, def))
player.isShiftKeyDown -> {
tell(player, MachineWork.status(this, level, def))
if (inside == null) preview(level, def, player)
}
inside != null && owner != player.uuid -> tell(player, Component.translatableWithFallback(
"message.cobblesurvival.machine.not_yours", "This %s holds %s's %s.", machineName, ownerName, inside.getDisplayName()))
inside != null -> giveBack(level, null)
@@ -104,9 +107,7 @@ class MachineBlockEntity(pos: BlockPos, state: BlockState) : BlockEntity(Machine
private fun insert(level: ServerLevel, def: MachineDefs.Def, player: ServerPlayer) {
val match = def.shell.match(level, worldPosition)
if (match == null) {
val (pos, wanted) = def.shell.firstMismatch(level, worldPosition) ?: return
tell(player, Component.translatableWithFallback("message.cobblesurvival.machine.incomplete",
"The %s's shell isn't complete: %s belongs at %s %s %s.", machineName, wanted, pos.x, pos.y, pos.z).withStyle(ChatFormatting.YELLOW))
preview(level, def, player)
return
}
val party = Cobblemon.storage.getParty(player)
@@ -134,6 +135,33 @@ class MachineBlockEntity(pos: BlockPos, state: BlockState) : BlockEntity(Machine
tell(player, MachineWork.status(this, level, def))
}
/** Shows what's missing from the shell as ghost blocks ([MachineGhosts]) and says so; false when it's complete. */
fun preview(level: ServerLevel, def: MachineDefs.Def, player: ServerPlayer): Boolean {
val wrong = def.shell.mismatches(level, worldPosition, facing())
if (wrong.isEmpty()) return false
MachineGhosts.show(level, wrong.mapNotNull { (pos, rule) -> rule.example()?.let { pos to it } })
val needed = Component.empty()
wrong.groupBy { it.second.text }.values.forEachIndexed { i, spots ->
if (i > 0) needed.append(", ")
val names = spots.first().second.names()
needed.append("${spots.size}× ${names.take(4).joinToString("/")}${if (names.size > 4) "/…" else ""}")
}
tell(player, Component.translatableWithFallback("message.cobblesurvival.machine.incomplete",
"The %s's shell isn't complete; the ghost blocks show where for %s seconds. Missing: %s.",
machineName, MachineGhosts.TICKS / 20, needed).withStyle(ChatFormatting.YELLOW))
return true
}
/** Puts [pokemon] in without a player (the showcase world): it belongs to [ownerId]. */
fun fill(level: ServerLevel, pokemon: Pokemon, ownerId: UUID, owner: String) {
val def = def ?: return
this.pokemon = pokemon
this.owner = ownerId
ownerName = owner
MachineWork.onInsert(this, level, def)
setChanged()
}
/** Sends the Pokémon back to its owner (party, else PC), online or not; [why] is told to the owner. */
fun giveBack(level: ServerLevel, why: Component?): Boolean {
val pokemon = pokemon ?: return true
@@ -0,0 +1,67 @@
package dev.cobblesurvival.machine
import net.minecraft.core.BlockPos
import net.minecraft.nbt.CompoundTag
import net.minecraft.nbt.FloatTag
import net.minecraft.nbt.ListTag
import net.minecraft.nbt.NbtUtils
import net.minecraft.server.level.ServerLevel
import net.minecraft.world.entity.Display
import net.minecraft.world.entity.EntityType
import net.minecraft.world.level.block.state.BlockState
import net.neoforged.neoforge.common.NeoForge
import net.neoforged.neoforge.event.entity.EntityJoinLevelEvent
import net.neoforged.neoforge.event.tick.ServerTickEvent
/**
* The in-world preview of a machine's shell (Adrian, 2026-11-02: there must be a way in game to see how a machine is
* built). Using a core whose shell is incomplete shows a small glowing block at every spot that is missing or wrong,
* for [TICKS]: vanilla block displays, so the client needs nothing. They are never kept in the world save.
*/
object MachineGhosts {
const val TAG = "cobblesurvival_ghost"
const val TICKS = 200
private const val SIZE = 0.6f
private class Ghost(val entity: Display.BlockDisplay, val until: Long)
private val ghosts = ArrayList<Ghost>()
fun register() {
NeoForge.EVENT_BUS.addListener(::onTick)
NeoForge.EVENT_BUS.addListener<EntityJoinLevelEvent> {
if (it.loadedFromDisk() && it.entity.tags.contains(TAG)) it.isCanceled = true
}
}
/** Shows [blocks] (position, block that belongs there); earlier ghosts at the same spots go first. */
fun show(level: ServerLevel, blocks: List<Pair<BlockPos, BlockState>>) {
val spots = blocks.map { it.first }.toSet()
ghosts.removeIf { ghost -> (ghost.entity.blockPosition() in spots).also { if (it) ghost.entity.discard() } }
for ((pos, state) in blocks) {
val entity = Display.BlockDisplay(EntityType.BLOCK_DISPLAY, level)
entity.load(CompoundTag().apply {
put("block_state", NbtUtils.writeBlockState(state))
val offset = (1 - SIZE) / 2
put("transformation", CompoundTag().apply {
put("left_rotation", floats(0f, 0f, 0f, 1f))
put("right_rotation", floats(0f, 0f, 0f, 1f))
put("translation", floats(offset, offset, offset))
put("scale", floats(SIZE, SIZE, SIZE))
})
})
entity.setPos(pos.x.toDouble(), pos.y.toDouble(), pos.z.toDouble())
entity.setGlowingTag(true)
entity.addTag(TAG)
level.addFreshEntity(entity)
ghosts += Ghost(entity, level.gameTime + TICKS)
}
}
private fun floats(vararg values: Float) = ListTag().apply { values.forEach { add(FloatTag.valueOf(it)) } }
private fun onTick(event: ServerTickEvent.Post) {
if (ghosts.isEmpty()) return
val now = event.server.overworld().gameTime
ghosts.removeIf { ghost -> (ghost.until <= now || ghost.entity.isRemoved).also { if (it) ghost.entity.discard() } }
}
}
@@ -0,0 +1,127 @@
package dev.cobblesurvival.machine
import com.cobblemon.mod.common.api.pokemon.PokemonSpecies
import com.mojang.brigadier.builder.LiteralArgumentBuilder
import dev.cobblesurvival.CobbleSurvival
import net.minecraft.commands.CommandSourceStack
import net.minecraft.commands.Commands
import net.minecraft.core.BlockPos
import net.minecraft.core.Direction
import net.minecraft.core.registries.BuiltInRegistries
import net.minecraft.network.chat.Component
import net.minecraft.resources.ResourceLocation
import net.minecraft.server.level.ServerLevel
import net.minecraft.world.Container
import net.minecraft.world.item.ItemStack
import net.minecraft.world.item.Items
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.StandingSignBlock
import net.minecraft.world.level.block.entity.SignBlockEntity
import net.minecraft.world.level.block.entity.SignText
import java.util.UUID
/**
* Builds machines (Phase 6): one at a time for the self-tests, and the showcase for the machine test world
* (`/cobblesurvival machines showcase`, tools/make_machine_world.py; Adrian, 2026-11-02): every machine three times
* in rows going south, at the command's position (the first air above the ground): working, with a Pokémon inside (it belongs to a "Showcase" owner);
* empty, to try putting a Pokémon in; and the bare core, whose use shows the shell as ghost blocks.
*/
object MachineShowcase {
val OWNER: UUID = UUID.nameUUIDFromBytes("cobblesurvival:showcase".toByteArray())
private const val SPACING = 10
private const val ROW = 12
private class Entry(val core: String, val casing: Block, val species: String, val level: Int, val sign: List<String>)
private val ENTRIES = listOf(
Entry("heat_core", Blocks.BRICKS, "charmander", 50, listOf("Large Furnace", "Fire types", "heat furnaces", "outside the shell")),
Entry("dynamo_core", Blocks.COPPER_BLOCK, "pikachu", 50, listOf("Generator", "Electric types", "make FE: cables", "to the synthesizer")),
Entry("synthesis_core", Blocks.GOLD_BLOCK, "magikarp", 20, listOf("Drop Synthesizer", "any Pokémon", "FE into drops", "gold: Looting I")),
Entry("altar_core", Blocks.OBSIDIAN, "dragonite", 100, listOf("Sacrifice Altar", "Lv 20 or more", "sneak-use twice", "to sacrifice")),
Entry("rebirth_core", Blocks.AMETHYST_BLOCK, "charizard", 100, listOf("Rebirth Chamber", "Lv 100 becomes", "shiny Lv 1", "after 2 hours")),
)
fun id(name: String): ResourceLocation = ResourceLocation.fromNamespaceAndPath(CobbleSurvival.MOD_ID, name)
/** Builds the [name] machine around [core] (core facing south, shell north of it) of [casing], windows of glass if [glass]. */
fun build(level: ServerLevel, core: BlockPos, name: String, casing: Block, glass: Boolean = false): MachineBlockEntity {
val def = MachineDefs.byCore[id(name)] ?: error("no machine data for ${id(name)}")
for ((offset, text) in def.shell.layout()) {
val state = when {
text == Shell.AIR -> Blocks.AIR.defaultBlockState()
glass && "glass" in text -> Blocks.GLASS.defaultBlockState()
else -> casing.defaultBlockState()
}
level.setBlockAndUpdate(core.offset(offset), state)
}
placeCore(level, core, name)
return level.getBlockEntity(core) as? MachineBlockEntity ?: error("no machine at $core")
}
private fun placeCore(level: ServerLevel, core: BlockPos, name: String) =
level.setBlockAndUpdate(core, BuiltInRegistries.BLOCK.get(id(name)).defaultBlockState().setValue(MachineBlock.FACING, Direction.SOUTH))
/** The showcase at [origin] (ground level, its north-west corner); returns one line per machine. */
fun showcase(level: ServerLevel, origin: BlockPos): List<String> = ENTRIES.mapIndexed { i, entry ->
val working = origin.offset(i * SPACING + 1, 1, 2)
val core = build(level, working, entry.core, entry.casing, glass = true)
core.fill(level, PokemonSpecies.getByName(entry.species)!!.create(entry.level), OWNER, "Showcase")
extras(level, entry, working)
sign(level, working.south(2).below(), entry.sign)
val empty = working.south(ROW)
build(level, empty, entry.core, entry.casing, glass = true)
sign(level, empty.south(2).below(), listOf(entry.sign[0], "Send out a fitting", "Pokémon and use", "the core"))
val bare = empty.south(ROW)
placeCore(level, bare, entry.core)
sign(level, bare.south(2).below(), listOf(entry.sign[0], "Use the core: its", "shell shows up", "as ghost blocks"))
"${entry.core}: working at ${working.toShortString()}, empty at ${empty.toShortString()}, bare core at ${bare.toShortString()}"
}
/** What makes a machine show its work: furnaces with hoppers and chests, cables, a hopper under the synthesizer. */
private fun extras(level: ServerLevel, entry: Entry, core: BlockPos) {
when (entry.core) {
"heat_core" -> {
feed(level, core.offset(2, 0, -1), Blocks.FURNACE, ItemStack(Items.RAW_IRON, 64))
feed(level, core.offset(-2, 0, -1), Blocks.SMOKER, ItemStack(Items.BEEF, 64))
}
"dynamo_core" -> {
val cable = BuiltInRegistries.BLOCK.get(ResourceLocation.parse("poke_power:power_cable"))
if (cable != Blocks.AIR) for (dx in 0..SPACING) level.setBlockAndUpdate(core.offset(dx, 0, 1), cable.defaultBlockState())
}
"synthesis_core" -> collect(level, core.below())
}
}
/** [furnace] with a hopper of [input] above and a hopper and chest below. */
private fun feed(level: ServerLevel, pos: BlockPos, furnace: Block, input: ItemStack) {
level.setBlockAndUpdate(pos, furnace.defaultBlockState())
level.setBlockAndUpdate(pos.above(), Blocks.HOPPER.defaultBlockState().setValue(HopperBlock.FACING, Direction.DOWN))
(level.getBlockEntity(pos.above()) as? Container)?.setItem(0, input)
collect(level, pos.below())
}
/** A hopper at [pos] emptying into a chest under it. */
private fun collect(level: ServerLevel, pos: BlockPos) {
level.setBlockAndUpdate(pos, Blocks.HOPPER.defaultBlockState().setValue(HopperBlock.FACING, Direction.DOWN))
level.setBlockAndUpdate(pos.below(), Blocks.CHEST.defaultBlockState())
}
private fun sign(level: ServerLevel, pos: BlockPos, lines: List<String>) {
level.setBlockAndUpdate(pos, Blocks.OAK_SIGN.defaultBlockState().setValue(StandingSignBlock.ROTATION, 0))
val sign = level.getBlockEntity(pos) as? SignBlockEntity ?: return
var text = SignText()
lines.take(4).forEachIndexed { i, line -> text = text.setMessage(i, Component.literal(line)) }
sign.setText(text, true)
sign.setWaxed(true)
}
fun command(): LiteralArgumentBuilder<CommandSourceStack> =
Commands.literal("machines").then(Commands.literal("showcase").executes { context ->
val source = context.source
val lines = showcase(source.level, BlockPos.containing(source.position))
lines.forEach { line -> source.sendSuccess({ Component.literal(line) }, false) }
lines.size
})
}
@@ -0,0 +1,26 @@
package dev.cobblesurvival.machine
import net.minecraft.ChatFormatting
import net.minecraft.core.registries.BuiltInRegistries
import net.minecraft.network.chat.Component
import net.neoforged.neoforge.common.NeoForge
import net.neoforged.neoforge.event.entity.player.ItemTooltipEvent
/**
* Tooltips on the machine cores (Adrian, 2026-11-02: a way in game to learn how a machine is built): what it does,
* what its shell is made of, and that placing or using the core shows the shell as ghost blocks. Lang keys
* `tooltip.cobblesurvival.<core>` and `tooltip.cobblesurvival.machine_shell`.
*/
object MachineTooltips {
fun register() {
NeoForge.EVENT_BUS.addListener(::onTooltip)
}
private fun onTooltip(event: ItemTooltipEvent) {
val id = BuiltInRegistries.ITEM.getKey(event.itemStack.item)
if (Machines.CORES.none { it.id == id }) return
event.toolTip.add(Component.translatable("tooltip.cobblesurvival.${id.path}").withStyle(ChatFormatting.GRAY))
event.toolTip.add(Component.translatable("tooltip.cobblesurvival.${id.path}.shell").withStyle(ChatFormatting.DARK_GRAY))
event.toolTip.add(Component.translatable("tooltip.cobblesurvival.machine_shell").withStyle(ChatFormatting.DARK_GRAY))
}
}
@@ -50,6 +50,8 @@ object Machines {
bus.addListener(::onCreativeTab)
MachineDefs.register()
MachineDisplay.register()
MachineGhosts.register()
MachineTooltips.register()
}
private fun onCapabilities(event: RegisterCapabilitiesEvent) {
@@ -1,11 +1,13 @@
package dev.cobblesurvival.machine
import net.minecraft.core.BlockPos
import net.minecraft.core.Direction
import net.minecraft.core.registries.BuiltInRegistries
import net.minecraft.core.registries.Registries
import net.minecraft.resources.ResourceLocation
import net.minecraft.tags.TagKey
import net.minecraft.world.level.Level
import net.minecraft.world.level.block.Blocks
import net.minecraft.world.level.block.Rotation
import net.minecraft.world.level.block.state.BlockState
@@ -14,7 +16,8 @@ import net.minecraft.world.level.block.state.BlockState
* chamber where the Pokémon stands. [match] tries the four horizontal rotations around the core.
*/
class Shell(private val parts: Map<BlockPos, BlockRule>, private val chamber: BlockPos) {
class BlockRule(val text: String, val test: (BlockState) -> Boolean)
/** What may stand at one spot: [names] for messages, [example] for the ghost preview (tags are read when asked). */
class BlockRule(val text: String, val test: (BlockState) -> Boolean, val names: () -> List<String> = { listOf(text) }, val example: () -> BlockState? = { null })
/** The shell found around a core: where the chamber is and the shell's blocks (the core not included). */
class Match(val chamber: BlockPos, val blocks: Set<BlockPos>)
@@ -33,6 +36,19 @@ class Shell(private val parts: Map<BlockPos, BlockRule>, private val chamber: Bl
}
.minByOrNull { it.size }?.firstOrNull()
/**
* Every spot that is wrong, with what belongs there: in the rotation where the core's front faces out of the shell
* ([facing]), unless another rotation is closer to what was built.
*/
fun mismatches(level: Level, core: BlockPos, facing: Direction): List<Pair<BlockPos, BlockRule>> {
val preferred = ROTATIONS.first { it.rotate(Direction.SOUTH) == facing }
val byRotation = (listOf(preferred) + ROTATIONS.filter { it != preferred }).map { rotation ->
parts.entries.filterNot { (offset, rule) -> rule.test(level.getBlockState(core.offset(offset.rotate(rotation)))) }
.map { (offset, rule) -> core.offset(offset.rotate(rotation)) to rule }
}
return byRotation.minBy { it.size }
}
/** Every block position of the shell in the orientation where the core is in the south wall (for building it). */
fun layout(): Map<BlockPos, String> = parts.mapValues { it.value.text }
@@ -41,6 +57,7 @@ class Shell(private val parts: Map<BlockPos, BlockRule>, private val chamber: Bl
private const val CORE = 'C'
private const val CHAMBER = '.'
const val AIR = "air"
private const val GLASS = "#c:glass_blocks"
/** [layers] bottom to top, each a list of rows north to south, each row west to east; ' ' = anything. A key
* lists the blocks (ids or #tags) allowed for its character. */
@@ -50,7 +67,7 @@ class Shell(private val parts: Map<BlockPos, BlockRule>, private val chamber: Bl
val chamber = cells.single { it.second == CHAMBER }.first.subtract(core)
val parts = cells.filter { it.second != CORE && it.second != ' ' }.associate { (pos, c) ->
pos.subtract(core) to when (c) {
CHAMBER -> BlockRule(AIR) { it.isAir }
CHAMBER -> BlockRule(AIR, { it.isAir }, { listOf("nothing (empty)") }, { Blocks.RED_STAINED_GLASS.defaultBlockState() })
else -> anyOf(keys[c] ?: error("no key for '$c'"))
}
}
@@ -59,15 +76,21 @@ class Shell(private val parts: Map<BlockPos, BlockRule>, private val chamber: Bl
private fun anyOf(specs: List<String>): BlockRule {
val rules = specs.map(::rule)
return BlockRule(specs.joinToString(" or ")) { state -> rules.any { it.test(state) } }
return BlockRule(specs.joinToString(" or "), { state -> rules.any { it.test(state) } },
{ rules.flatMap { it.names() }.distinct() }, { rules.firstNotNullOfOrNull { it.example() } })
}
private fun rule(spec: String): BlockRule = if (spec.startsWith("#")) {
val tag = TagKey.create(Registries.BLOCK, ResourceLocation.parse(spec.substring(1)))
BlockRule(spec) { it.`is`(tag) }
fun blocks() = BuiltInRegistries.BLOCK.getTag(tag).map { set -> set.map { it.value() } }.orElse(emptyList())
// A tag of glass blocks would list every stained pane colour; say "glass".
BlockRule(spec, { it.`is`(tag) },
{ if (spec == GLASS) listOf("glass") else blocks().map { it.name.string } },
{ (if (spec == GLASS) Blocks.GLASS else blocks().firstOrNull())?.defaultBlockState() })
} else {
val id = ResourceLocation.parse(spec)
BlockRule(spec) { BuiltInRegistries.BLOCK.getKey(it.block) == id }
val block = { BuiltInRegistries.BLOCK.get(id) }
BlockRule(spec, { BuiltInRegistries.BLOCK.getKey(it.block) == id }, { listOf(block().name.string) }, { block().defaultBlockState() })
}
}
}
@@ -9,6 +9,8 @@ import dev.cobblesurvival.machine.MachineBlock
import dev.cobblesurvival.machine.MachineBlockEntity
import dev.cobblesurvival.machine.MachineDefs
import dev.cobblesurvival.machine.MachineDisplay
import dev.cobblesurvival.machine.MachineGhosts
import dev.cobblesurvival.machine.MachineShowcase
import dev.cobblesurvival.machine.MachineWork
import dev.cobblesurvival.machine.Shell
import net.minecraft.core.BlockPos
@@ -42,6 +44,8 @@ object MachineTests {
/** 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
/** The showcase test: south of the unload spot. */
private const val SHOWCASE_OFFSET = 448
fun all() = listOf(
SelfTestCase("machine_pokemon_goes_in_and_out", 60, ::inAndOut),
@@ -56,18 +60,13 @@ object MachineTests {
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),
SelfTestCase("machine_preview_shows_the_shell", 300, ::preview),
SelfTestCase("machine_showcase_builds_every_machine", 100, ::showcase),
)
private fun id(name: String) = ResourceLocation.fromNamespaceAndPath(CobbleSurvival.MOD_ID, name)
private fun id(name: String) = MachineShowcase.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")
}
fun build(level: ServerLevel, core: BlockPos, name: String, casing: Block): MachineBlockEntity = MachineShowcase.build(level, core, name, casing)
private fun build(s: Scenario, name: String, casing: Block) = build(s.level, s.helper.absolutePos(CORE), name, casing)
@@ -357,4 +356,52 @@ object MachineTests {
}
.thenSucceed()
}
private fun ghosts(level: ServerLevel, core: BlockPos) =
level.getEntitiesOfClass(net.minecraft.world.entity.Display.BlockDisplay::class.java, AABB(core).inflate(3.0)) { it.tags.contains(MachineGhosts.TAG) }
/** Using a core with a broken shell shows the wrong spots as ghost blocks; they go away again. */
private fun preview(s: Scenario) {
val level = s.level
val player = s.player()
val core = build(s, "heat_core", Blocks.BRICKS)
val pos = core.blockPos
level.setBlockAndUpdate(pos.offset(-1, 1, -2), Blocks.AIR.defaultBlockState())
level.setBlockAndUpdate(pos.offset(1, -1, -1), Blocks.DIRT.defaultBlockState())
level.setBlockAndUpdate(pos.offset(0, 0, -1), Blocks.STONE.defaultBlockState())
core.use(player)
s.check(ghosts(level, pos).size == 3) { "${ghosts(level, pos).size} ghost blocks for 3 wrong spots" }
s.helper.startSequence()
.thenWaitUntil { s.check(ghosts(level, pos).isEmpty()) { "the ghost blocks didn't go away" } }
.thenSucceed()
}
/** The test world's showcase: every machine works (Pokémon inside, complete shell), every bare core is there. */
private fun showcase(s: Scenario) {
val level = s.level
val origin = spot(level, SHOWCASE_OFFSET)
val area = BlockPos.betweenClosed(origin.offset(-3, -3, -3), origin.offset(5 * 11 + 3, 4, 2 * 12 + 6))
val chunkSet = area.map { ChunkPos(it) }.distinct()
chunkSet.forEach { level.setChunkForced(it.x, it.z, true) }
s.afterTest {
BlockPos.betweenClosed(origin.offset(-3, -3, -3), origin.offset(5 * 11 + 3, 4, 2 * 12 + 6)).forEach {
level.setBlockAndUpdate(it, Blocks.AIR.defaultBlockState())
}
chunkSet.forEach { level.setChunkForced(it.x, it.z, false) }
}
val lines = MachineShowcase.showcase(level, origin)
s.check(lines.size == MachineDefs.byCore.size) { "the showcase built ${lines.size} machines, not ${MachineDefs.byCore.size}" }
val cores = BlockPos.betweenClosed(origin, origin.offset(5 * 11, 2, 2 * 12 + 3))
.mapNotNull { level.getBlockEntity(it) as? MachineBlockEntity }
s.check(cores.size == 3 * lines.size) { "${cores.size} cores in the showcase, not ${3 * lines.size}" }
s.helper.startSequence()
.thenWaitUntil {
val working = cores.filter { it.pokemon != null }
s.check(working.size == lines.size && working.all { it.def!!.shell.match(level, it.blockPos) != null }) {
"${working.size} working machines with complete shells, not ${lines.size}"
}
s.check(working.sumOf { displays(level, it.blockPos).size } == lines.size) { "not every working machine shows its Pokémon" }
}
.thenSucceed()
}
}