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

+6
View File
@@ -59,6 +59,12 @@ python3 tools/make_world.py
```
Builds the island world template (`build/world-template`, about 20 minutes: pregeneration, then a pass that waits until every chunk has its ore veins and nodes), runs the same world checks on the whole island plus border fit and End access, and renders `build/island-map.png`. Only needed when the island itself should change. Details in [world/README.md](world/README.md). Run `tools/prism_instance.py` afterwards to put it in Prism as the save "Cobblemon Survival Island".
```bash
python3 tools/make_machine_world.py --prism
```
Builds the machine test world (`build/machine-world`, a minute): superflat, creative, cheats on, every Phase 6 machine built three times (working with a Pokémon inside, empty, bare core). `--prism` copies it into the Prism instance as the save "Machine Test (Phase 6)", replacing an older copy.
```bash
python3 tools/zone_layout.py
```
@@ -1,5 +1,5 @@
{
"_comment": "Large Furnace (Adrian, 2026-10-31): a Fire Pokémon in the chamber heats vanilla furnaces, blast furnaces and smokers standing against the outside of the shell; they burn without fuel while they have something to cook, and cook faster. Shape: layers bottom to top, rows north to south, columns west to east, as seen with the core in the south wall; checked in all four rotations. 'C' = the core, '.' = the chamber (air; the Pokémon stands there), '#' = casing, ' ' = anything (under the core, so a hopper can go there), 'W' = a window spot: casing or glass (the middle of each side wall and the top centre). Furnaces heated: 1 + level / levels_per_furnace, at most max. Cooking speed: base + per_level × level + per_special_attack × Special Attack. Placeholder numbers; see docs/balance.md. Read by the companion mod (machine/).",
"_comment": "Large Furnace (Adrian, 2026-10-31): a Fire Pokémon in the chamber heats vanilla furnaces, blast furnaces and smokers standing against the outside of the shell; they burn without fuel while they have something to cook, and cook faster. Shape: layers bottom to top, rows north to south, columns west to east, as seen with the core in the south wall; checked in all four rotations. 'C' = the core, '.' = the chamber (air; the Pokémon stands there), '#' = casing, ' ' = anything (under the core, so a hopper can go there), 'W' = a window spot: casing or glass (the middle of each side wall and the top centre). Furnaces heated: 1 + level / levels_per_furnace, at most max. Cooking speed: base + per_level × level + per_special_attack × Special Attack. Speed doubled (Adrian, 2026-11-02). Placeholder numbers; see docs/balance.md. Read by the companion mod (machine/).",
"core": "cobblesurvival:heat_core",
"kind": "furnace",
"types": [
@@ -34,9 +34,9 @@
"max": 8,
"levels_per_furnace": 15,
"speed": {
"base": 1.0,
"per_level": 0.04,
"per_special_attack": 0.01
"base": 2.0,
"per_level": 0.08,
"per_special_attack": 0.02
}
}
}
+1 -1
View File
@@ -31,7 +31,7 @@ Every tunable lives in a config file or in this table. **Placeholder** means gue
| Breeding (Cobbreeding) | mod defaults: an egg every 8,000–14,000 ticks per breeding pasture, hatch multiplier 1.0 (about 10 min for 20 egg cycles), Masuda shiny boost ×4, no random eggs from two Dittos, no gate | Placeholder | `config/cobbreeding/main.json` (written by the mod; the pack ships none) | Adrian, 2026-10-31: breeding without a gate |
| Machine shells | 3×3×3, core in a wall, chamber in the centre; casing: Heat bricks/mud bricks/deepslate bricks; Dynamo copper blocks; Synthesis iron/gold/diamond/netherite blocks; Altar obsidian/crying obsidian/blackstone/polished blackstone bricks; Rebirth amethyst/quartz/calcite; glass allowed at the window spots | Placeholder | `datapacks/core/data/cobblesurvival/machines/*.json`, `tags/block/*_casing.json` | Adrian, 2026-10-31 (Phase 6) |
| Core recipes | Heat: 4 bricks, 4 iron ingots, blast furnace; Dynamo: 4 copper blocks, 4 redstone, thunder stone; Synthesis: 4 iron blocks, 2 redstone blocks, 2 hoppers, crafter; Altar: 4 obsidian, 4 gold blocks, echo shard; Rebirth: 4 amethyst blocks, 4 diamonds, shiny stone | Placeholder | `datapacks/core/data/cobblesurvival/recipe/*_core.json` | |
| Large Furnace | furnaces heated: 1 + level/15 (max 8); cooking speed 1 + 0.04 × level + 0.01 × Special Attack (e.g. Lv 50, SpA 70: 3.7×); no fuel | Placeholder | `machines/heat_core.json` | |
| Large Furnace | furnaces heated: 1 + level/15 (max 8); cooking speed 2 + 0.08 × level + 0.02 × Special Attack (e.g. Lv 50, SpA 70: 7.4×; doubled, Adrian 2026-11-02); no fuel | Placeholder | `machines/heat_core.json` | |
| Generator | 50 FE/t per level × (1 + 0.005 × Special Attack) (Lv 30, SpA 50: 1,875 FE/t); stores 200,000 FE, pushes up to 16,000 FE/t | Placeholder | `machines/dynamo_core.json` | After Poke Power's 50 FE per level |
| Drop Synthesizer | 2,000,000 FE per drop roll, takes up to 32,000 FE/t (one roll a minute at most), stores 4,000,000 FE; looting by casing tier: iron 0, gold I, diamond II, netherite III (+0–1 per drop per level; 25 % per level for a second roll); 9 output stacks | Placeholder | `machines/synthesis_core.json` | |
| Sacrifice Altar | from Lv 20: 3 Exp. Candy S; 35: 3 M; 50: 3 L; 65: 2 XL + Ability Capsule; 80: 3 rare candies + 2 XL; 90: 5 rare candies + Ability Patch; 100: 8 rare candies + Ability Patch + nether star; Lv 100 Dragon type: + dragon egg; confirm within 5 s | Placeholder | `machines/altar_core.json` | Rare candies above the highest natural level (Lv 75 guardians) |
+7 -2
View File
@@ -24,10 +24,15 @@ The middle of each side wall and the top centre may be glass, to see the Pokémo
## In game (Prism instance)
Creative mode gives the cores in the Functional Blocks tab; each has a recipe for survival.
**The save "Machine Test (Phase 6)"** (2026-11-02): superflat, creative, cheats on, no wild Pokémon. You start between two rows; looking north, the first row has every machine working with a Pokémon inside (they belong to a "Showcase" owner, so you can watch but not take them out): Large Furnace with a furnace and a smoker fed by hoppers, Generator cabled to the Drop Synthesizer (gold shell, hopper and chest under its core), Sacrifice Altar, Rebirth Chamber. Behind you, the same machines empty, to try with your own Pokémon (`/pokegive`); further south, the bare cores. Signs say which is which.
Elsewhere, creative mode gives the cores in the Functional Blocks tab; each has a recipe for survival.
- [ ] Use a bare core (third row): ghost blocks show its shell for ten seconds, and chat lists what's missing. Place a new core anywhere: the same.
- [ ] Hover a core in the inventory: the tooltip says what it does and what its shell is made of.
- [ ] Build a Heat Core shell of bricks with glass in a side window, send out a Fire type and use the core: the Pokémon stands in the chamber, small enough to fit, and doesn't move. **Does it look right?** (the roadmap asks you to confirm this)
- [ ] Put a furnace against the outside of that shell, a hopper with raw iron above it and a hopper with a chest below: the iron cooks without fuel, faster with a stronger Pokémon, and ends in the chest.
- [ ] Put a furnace against the outside of that shell, a hopper with raw iron above it and a hopper with a chest below: the iron cooks without fuel, faster with a stronger Pokémon (speed doubled on 2026-11-02: 7.4× at Lv 50), and ends in the chest.
- [ ] Build a Dynamo Core shell of copper with an Electric type, lay Poke Power cables from the core: sneak-use shows the energy made.
- [ ] Put a Synthesis Core shell of gold blocks next to it (cables between), with a Pokémon inside and a hopper and chest under its core: its drops arrive in the chest. Swap the shell for diamond blocks: sneak-use shows looting 2.
- [ ] Put a Lv 20+ Pokémon in an Altar Core shell: sneak-use shows the reward, sneak-use again within 5 seconds sacrifices it.
+2
View File
@@ -255,6 +255,8 @@ Adrian's choices: the Large Furnace has a Fire Pokémon heat real furnaces, and
**Rebirth Chamber.** A Lv 100 Pokémon put in starts a long rebirth (placeholder: two hours of server time, also while the chunk is unloaded). Then it is the first stage of its line at Lv 1 and shiny, keeps its nickname, original trainer, held item and every move it learned, gets three of its IVs set to 31 (no IV ever goes down), and keeps its ability; an Ability Patch comes with it, so taking the hidden ability is the player's choice. Taking it out before the end cancels the rebirth.
**Finding out how to build one** (Adrian, 2026-11-02: there must be a way in game). Placing a core, using it while its shell is incomplete, or sneak-using it while empty shows every missing or wrong spot of the shell as a small glowing ghost block for ten seconds (vanilla block displays, so clients need nothing; never saved), in the orientation the core faces, with a chat line counting what's missing. The cores' tooltips say what each machine does and what its shell is made of. The machine test world (`tools/make_machine_world.py`) has every machine built.
**Cores** are crafted (soft gates by material): Heat Core from bricks, iron and a blast furnace; Dynamo Core from copper blocks, redstone and a thunder stone; Synthesis Core from iron and redstone blocks, hoppers and a crafter; Altar Core from obsidian, gold blocks and an echo shard; Rebirth Core from amethyst blocks, diamonds and a shiny stone. Their textures are drawn by `tools/machine_assets.py`.
Self-tests (`MachineTests`): in and out, wrong Pokémon refused, broken shell and broken core (to the PC when the party is full), chunk unload, server restart, and each machine's work (a furnace fed and emptied by hoppers without fuel, a Poke Power cable charged by a Dynamo Core, Magikarp's salmon through a hopper under a gold Synthesis Core, a confirmed sacrifice with a Lv 100 Dragonite's rewards, a rebirth from Charizard to a shiny Lv 1 Charmander with its moves and ability).
@@ -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."
}
+91
View File
@@ -0,0 +1,91 @@
#!/usr/bin/env python3
"""Make the machine test world (Phase 6; Adrian, 2026-11-02): a superflat creative world, cheats on, with every
machine built by `/cobblesurvival machines showcase` (working with a Pokémon inside, empty, and the bare core),
no wild Pokémon spawning and fixed daytime. It lands in build/machine-world; with --prism it is also copied into the
Prism instance as the save "Machine Test (Phase 6)", replacing an older copy.
Needs the test server (tools/build.py first; the EULA accepted as for the other tools).
Usage: tools/make_machine_world.py [--prism] [--instance NAME]
"""
import argparse
import shutil
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import nbtfile # noqa: E402
from make_world import SKIP # noqa: E402
from mcserver import BUILD, SERVER, Server, eula_accepted, prepare, scan_log # noqa: E402
from prism_instance import PRISM_DIRS # noqa: E402
LEVEL = "machines"
NAME = "Machine Test (Phase 6)"
OUT = BUILD / "machine-world"
FLAT = ('{"layers":[{"block":"minecraft:bedrock","height":1},{"block":"minecraft:stone","height":3},'
'{"block":"minecraft:grass_block","height":1}],"biome":"minecraft:plains"}')
# Ground is y -60; the showcase starts at the first air above it.
ORIGIN = (0, -59, 0)
SPAWN = (24, -59, 8)
COMMANDS = [
"gamerule doPokemonSpawning false",
"gamerule doDaylightCycle false",
"gamerule doWeatherCycle false",
"time set day",
f"setworldspawn {SPAWN[0]} {SPAWN[1]} {SPAWN[2]}",
f"execute positioned {ORIGIN[0]} {ORIGIN[1]} {ORIGIN[2]} run cobblesurvival machines showcase",
]
def make_creative(level_dat):
"""Creative mode, cheats on, and a name the world list can tell apart."""
name, root = nbtfile.load(level_dat)
data = root["Data"][1]
data["LevelName"] = (nbtfile.STRING, NAME)
data["GameType"] = (nbtfile.INT, 1)
data["allowCommands"] = (nbtfile.BYTE, 1)
nbtfile.save(level_dat, name, root)
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--prism", action="store_true", help="copy the world into the Prism instance")
parser.add_argument("--instance", default="Cobblemon Survival Pack (dev)")
args = parser.parse_args()
if not eula_accepted():
sys.exit("build/server/eula.txt does not accept the Minecraft EULA yet (see tools/selftest.py --accept-eula).")
args_file = prepare()
world = SERVER / LEVEL
shutil.rmtree(world, ignore_errors=True)
props = {"level-name": LEVEL, "level-type": "minecraft:flat", "generator-settings": FLAT, "gamemode": "creative"}
with Server(args_file, props, stdout_name="machine-world-stdout.log") as server:
print(f"server started in {server.wait_ready():.0f}s")
for command in COMMANDS:
print(f" {command}: {server.command(command).strip()}")
server.command("save-all flush")
server.stop()
problems = scan_log(server.log)
if problems:
sys.exit("server log problems:\n" + "\n".join(f" - {p}" for p in problems))
shutil.rmtree(OUT, ignore_errors=True)
shutil.copytree(world, OUT, ignore=SKIP)
make_creative(OUT / "level.dat")
print(f"machine world: {OUT.relative_to(BUILD.parent)}")
if args.prism:
prism = next((d for d in PRISM_DIRS if d.exists()), None)
if prism is None:
sys.exit("no Prism Launcher data folder found")
saves = prism / "instances" / args.instance / "minecraft" / "saves"
if not saves.parent.exists():
sys.exit(f"no Prism instance {args.instance!r}; run tools/prism_instance.py first")
target = saves / NAME
shutil.rmtree(target, ignore_errors=True)
shutil.copytree(OUT, target)
print(f"installed as the save {NAME!r} in {args.instance}")
if __name__ == "__main__":
main()
+85
View File
@@ -0,0 +1,85 @@
"""Reads and writes gzipped NBT files (level.dat) keeping every tag's type, so a file can be changed and saved back.
A tag is (type, value): compounds are dicts of name -> tag, lists are (element type, [values]). Used by
tools/make_machine_world.py to make a world creative with cheats on.
"""
import gzip
import struct
END, BYTE, SHORT, INT, LONG, FLOAT, DOUBLE, BYTE_ARRAY, STRING, LIST, COMPOUND, INT_ARRAY, LONG_ARRAY = range(13)
_SCALARS = {BYTE: ">b", SHORT: ">h", INT: ">i", LONG: ">q", FLOAT: ">f", DOUBLE: ">d"}
class _Reader:
def __init__(self, data):
self.data, self.pos = data, 0
def take(self, n):
chunk = self.data[self.pos:self.pos + n]
self.pos += n
return chunk
def unpack(self, fmt):
return struct.unpack(fmt, self.take(struct.calcsize(fmt)))[0]
def string(self):
return self.take(self.unpack(">H")).decode("utf-8")
def payload(self, kind):
if kind in _SCALARS:
return self.unpack(_SCALARS[kind])
if kind == BYTE_ARRAY:
return list(self.take(self.unpack(">i")))
if kind == STRING:
return self.string()
if kind == LIST:
element, length = self.unpack(">b"), self.unpack(">i")
return element, [self.payload(element) for _ in range(length)]
if kind == COMPOUND:
result = {}
while (child := self.unpack(">b")) != END:
name = self.string()
result[name] = (child, self.payload(child))
return result
if kind == INT_ARRAY:
return [self.unpack(">i") for _ in range(self.unpack(">i"))]
if kind == LONG_ARRAY:
return [self.unpack(">q") for _ in range(self.unpack(">i"))]
raise ValueError(f"unknown NBT tag type {kind}")
def _string(text):
raw = text.encode("utf-8")
return struct.pack(">H", len(raw)) + raw
def _payload(kind, value):
if kind in _SCALARS:
return struct.pack(_SCALARS[kind], value)
if kind == BYTE_ARRAY:
return struct.pack(">i", len(value)) + bytes(v & 0xFF for v in value)
if kind == STRING:
return _string(value)
if kind == LIST:
element, items = value
return struct.pack(">bi", element, len(items)) + b"".join(_payload(element, v) for v in items)
if kind == COMPOUND:
return b"".join(struct.pack(">b", k) + _string(name) + _payload(k, v) for name, (k, v) in value.items()) + b"\x00"
if kind == INT_ARRAY:
return struct.pack(">i", len(value)) + b"".join(struct.pack(">i", v) for v in value)
if kind == LONG_ARRAY:
return struct.pack(">i", len(value)) + b"".join(struct.pack(">q", v) for v in value)
raise ValueError(f"unknown NBT tag type {kind}")
def load(path):
"""The root compound of a gzipped NBT file, as (name, compound dict)."""
reader = _Reader(gzip.decompress(path.read_bytes()))
if reader.unpack(">b") != COMPOUND:
raise ValueError(f"{path} doesn't start with a compound")
name = reader.string()
return name, reader.payload(COMPOUND)
def save(path, name, root):
path.write_bytes(gzip.compress(struct.pack(">b", COMPOUND) + _string(name) + _payload(COMPOUND, root)))