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:
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()
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@
|
||||
"block.cobblesurvival.rebirth_core": "Rebirth Core",
|
||||
"message.cobblesurvival.machine.shell_broke": "The shell of the %s broke.",
|
||||
"message.cobblesurvival.machine.not_yours": "This %s holds %s's %s.",
|
||||
"message.cobblesurvival.machine.incomplete": "The %s's shell isn't complete: %s belongs at %s %s %s.",
|
||||
"message.cobblesurvival.machine.incomplete": "The %s's shell isn't complete; the ghost blocks show where for %s seconds. Missing: %s.",
|
||||
"message.cobblesurvival.machine.send_out": "Send out %s near the %s, then use it again.",
|
||||
"message.cobblesurvival.machine.inserted": "%s went into the %s.",
|
||||
"message.cobblesurvival.machine.returned": "%s came back to your %s.",
|
||||
@@ -24,5 +24,16 @@
|
||||
"message.cobblesurvival.machine.sacrificed": "%s is gone. You received %s.",
|
||||
"message.cobblesurvival.machine.sacrifice_confirm": "Sacrificing %s (Lv %s) gives %s. Sneak-use again within %s seconds to sacrifice it: it's gone for good.",
|
||||
"message.cobblesurvival.machine.reborn": "%s was reborn in the %s.",
|
||||
"message.cobblesurvival.machine.status_empty": "%s: empty. Send out %s near it and use it to put the Pokémon in."
|
||||
"message.cobblesurvival.machine.status_empty": "%s: empty. Send out %s near it and use it to put the Pokémon in.",
|
||||
"tooltip.cobblesurvival.machine_shell": "A 3×3×3 shell with this core in the middle of a wall and an empty centre; glass may go in the middle of each wall and on top. Place or use the core to see the shell as ghost blocks.",
|
||||
"tooltip.cobblesurvival.heat_core": "Large Furnace: a Fire-type Pokémon inside heats furnaces, blast furnaces and smokers standing against the shell.",
|
||||
"tooltip.cobblesurvival.heat_core.shell": "Shell: bricks, mud bricks or deepslate bricks.",
|
||||
"tooltip.cobblesurvival.dynamo_core": "Generator: an Electric-type Pokémon inside makes Forge Energy for cables touching the core.",
|
||||
"tooltip.cobblesurvival.dynamo_core.shell": "Shell: copper blocks (any weathering, cut or not).",
|
||||
"tooltip.cobblesurvival.synthesis_core": "Drop Synthesizer: turns Forge Energy into the drops of the Pokémon inside; a hopper under the core takes them.",
|
||||
"tooltip.cobblesurvival.synthesis_core.shell": "Shell: iron, gold, diamond or netherite blocks; better metal, more looting.",
|
||||
"tooltip.cobblesurvival.altar_core": "Sacrifice Altar: trades a Pokémon of Lv 20 or more for items; sneak-use twice. It's gone for good.",
|
||||
"tooltip.cobblesurvival.altar_core.shell": "Shell: obsidian, crying obsidian, blackstone or polished blackstone bricks.",
|
||||
"tooltip.cobblesurvival.rebirth_core": "Rebirth Chamber: a Lv 100 Pokémon becomes a shiny Lv 1 of its first stage after a long time.",
|
||||
"tooltip.cobblesurvival.rebirth_core.shell": "Shell: amethyst, quartz or calcite blocks."
|
||||
}
|
||||
Reference in new issue
Block a user