diff --git a/README.md b/README.md index 6080138..c0ceee8 100644 --- a/README.md +++ b/README.md @@ -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 ``` diff --git a/datapacks/core/data/cobblesurvival/machines/heat_core.json b/datapacks/core/data/cobblesurvival/machines/heat_core.json index cd29c59..90bf912 100644 --- a/datapacks/core/data/cobblesurvival/machines/heat_core.json +++ b/datapacks/core/data/cobblesurvival/machines/heat_core.json @@ -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 } } } diff --git a/docs/balance.md b/docs/balance.md index 6f626fe..7a08671 100644 --- a/docs/balance.md +++ b/docs/balance.md @@ -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) | diff --git a/docs/checklists/phase-6.md b/docs/checklists/phase-6.md index 64bd38c..89e72be 100644 --- a/docs/checklists/phase-6.md +++ b/docs/checklists/phase-6.md @@ -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. diff --git a/docs/design.md b/docs/design.md index 1dcda6c..08dce70 100644 --- a/docs/design.md +++ b/docs/design.md @@ -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). diff --git a/mod/src/main/kotlin/dev/cobblesurvival/CobbleSurvival.kt b/mod/src/main/kotlin/dev/cobblesurvival/CobbleSurvival.kt index 3d475b2..850d89d 100644 --- a/mod/src/main/kotlin/dev/cobblesurvival/CobbleSurvival.kt +++ b/mod/src/main/kotlin/dev/cobblesurvival/CobbleSurvival.kt @@ -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)) diff --git a/mod/src/main/kotlin/dev/cobblesurvival/machine/MachineBlock.kt b/mod/src/main/kotlin/dev/cobblesurvival/machine/MachineBlock.kt index e84ce2b..449e3ff 100644 --- a/mod/src/main/kotlin/dev/cobblesurvival/machine/MachineBlock.kt +++ b/mod/src/main/kotlin/dev/cobblesurvival/machine/MachineBlock.kt @@ -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 getTicker(level: Level, state: BlockState, type: BlockEntityType): BlockEntityTicker? = diff --git a/mod/src/main/kotlin/dev/cobblesurvival/machine/MachineBlockEntity.kt b/mod/src/main/kotlin/dev/cobblesurvival/machine/MachineBlockEntity.kt index 8d47e5f..219af69 100644 --- a/mod/src/main/kotlin/dev/cobblesurvival/machine/MachineBlockEntity.kt +++ b/mod/src/main/kotlin/dev/cobblesurvival/machine/MachineBlockEntity.kt @@ -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 diff --git a/mod/src/main/kotlin/dev/cobblesurvival/machine/MachineGhosts.kt b/mod/src/main/kotlin/dev/cobblesurvival/machine/MachineGhosts.kt new file mode 100644 index 0000000..a2adc33 --- /dev/null +++ b/mod/src/main/kotlin/dev/cobblesurvival/machine/MachineGhosts.kt @@ -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() + + fun register() { + NeoForge.EVENT_BUS.addListener(::onTick) + NeoForge.EVENT_BUS.addListener { + 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>) { + 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() } } + } +} diff --git a/mod/src/main/kotlin/dev/cobblesurvival/machine/MachineShowcase.kt b/mod/src/main/kotlin/dev/cobblesurvival/machine/MachineShowcase.kt new file mode 100644 index 0000000..e029439 --- /dev/null +++ b/mod/src/main/kotlin/dev/cobblesurvival/machine/MachineShowcase.kt @@ -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) + + 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 = 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) { + 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 = + 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 + }) +} diff --git a/mod/src/main/kotlin/dev/cobblesurvival/machine/MachineTooltips.kt b/mod/src/main/kotlin/dev/cobblesurvival/machine/MachineTooltips.kt new file mode 100644 index 0000000..605d23e --- /dev/null +++ b/mod/src/main/kotlin/dev/cobblesurvival/machine/MachineTooltips.kt @@ -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.` 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)) + } +} diff --git a/mod/src/main/kotlin/dev/cobblesurvival/machine/Machines.kt b/mod/src/main/kotlin/dev/cobblesurvival/machine/Machines.kt index 71d60ca..8cea5f0 100644 --- a/mod/src/main/kotlin/dev/cobblesurvival/machine/Machines.kt +++ b/mod/src/main/kotlin/dev/cobblesurvival/machine/Machines.kt @@ -50,6 +50,8 @@ object Machines { bus.addListener(::onCreativeTab) MachineDefs.register() MachineDisplay.register() + MachineGhosts.register() + MachineTooltips.register() } private fun onCapabilities(event: RegisterCapabilitiesEvent) { diff --git a/mod/src/main/kotlin/dev/cobblesurvival/machine/Shell.kt b/mod/src/main/kotlin/dev/cobblesurvival/machine/Shell.kt index 3286d6d..d3474c7 100644 --- a/mod/src/main/kotlin/dev/cobblesurvival/machine/Shell.kt +++ b/mod/src/main/kotlin/dev/cobblesurvival/machine/Shell.kt @@ -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, 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 = { 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) @@ -33,6 +36,19 @@ class Shell(private val parts: Map, 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> { + 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 = parts.mapValues { it.value.text } @@ -41,6 +57,7 @@ class Shell(private val parts: Map, 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, 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, private val chamber: Bl private fun anyOf(specs: List): 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() }) } } } diff --git a/mod/src/main/kotlin/dev/cobblesurvival/selftest/MachineTests.kt b/mod/src/main/kotlin/dev/cobblesurvival/selftest/MachineTests.kt index f671ce4..c82fcbf 100644 --- a/mod/src/main/kotlin/dev/cobblesurvival/selftest/MachineTests.kt +++ b/mod/src/main/kotlin/dev/cobblesurvival/selftest/MachineTests.kt @@ -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() + } } diff --git a/mod/src/main/resources/assets/cobblesurvival/lang/en_us.json b/mod/src/main/resources/assets/cobblesurvival/lang/en_us.json index 76c2c95..875b15d 100644 --- a/mod/src/main/resources/assets/cobblesurvival/lang/en_us.json +++ b/mod/src/main/resources/assets/cobblesurvival/lang/en_us.json @@ -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." } diff --git a/tools/make_machine_world.py b/tools/make_machine_world.py new file mode 100644 index 0000000..9702ada --- /dev/null +++ b/tools/make_machine_world.py @@ -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() diff --git a/tools/nbtfile.py b/tools/nbtfile.py new file mode 100644 index 0000000..7238ef9 --- /dev/null +++ b/tools/nbtfile.py @@ -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)))