Phase 6: shell preview, core tooltips, machine test world, faster furnace
Adrian (2026-11-02): double the furnace speed; there must be a way in game to see how a machine is built; a test world with the machines. - Large Furnace speed doubled (2 + 0.08 x level + 0.02 x SpA). - Ghost preview: placing a core, using it with an incomplete shell, or sneak-using it empty shows every wrong spot as a small glowing block display for 10 s (never saved), in the core's orientation, with a chat line counting what's missing. - Core tooltips: what the machine does, what its shell is made of. - /cobblesurvival machines showcase builds every machine three times (working with a Showcase-owned Pokemon, empty, bare core) with signs, hoppers, chests and cables; tools/make_machine_world.py makes a superflat creative world from it (level.dat via tools/nbtfile.py) and --prism installs it as "Machine Test (Phase 6)". - Self-tests machine_preview_shows_the_shell and machine_showcase_builds_every_machine; docs and checklist. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0877a6a179
commit
c2a87f178e
17 files changed
+558
-27
No files matched your search
@@ -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".
|
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
|
```bash
|
||||||
python3 tools/zone_layout.py
|
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",
|
"core": "cobblesurvival:heat_core",
|
||||||
"kind": "furnace",
|
"kind": "furnace",
|
||||||
"types": [
|
"types": [
|
||||||
@@ -34,9 +34,9 @@
|
|||||||
"max": 8,
|
"max": 8,
|
||||||
"levels_per_furnace": 15,
|
"levels_per_furnace": 15,
|
||||||
"speed": {
|
"speed": {
|
||||||
"base": 1.0,
|
"base": 2.0,
|
||||||
"per_level": 0.04,
|
"per_level": 0.08,
|
||||||
"per_special_attack": 0.01
|
"per_special_attack": 0.02
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+1
-1
@@ -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 |
|
| 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) |
|
| 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` | |
|
| 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 |
|
| 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` | |
|
| 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) |
|
| 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) |
|
||||||
|
|||||||
@@ -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)
|
## 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)
|
- [ ] 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.
|
- [ ] 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 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.
|
- [ ] Put a Lv 20+ Pokémon in an Altar Core shell: sneak-use shows the reward, sneak-use again within 5 seconds sacrifices it.
|
||||||
|
|||||||
@@ -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.
|
**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`.
|
**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).
|
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.BaseLoot
|
||||||
import dev.cobblesurvival.ore.OreNodes
|
import dev.cobblesurvival.ore.OreNodes
|
||||||
import dev.cobblesurvival.permadeath.Permadeath
|
import dev.cobblesurvival.permadeath.Permadeath
|
||||||
|
import dev.cobblesurvival.machine.MachineShowcase
|
||||||
import dev.cobblesurvival.machine.Machines
|
import dev.cobblesurvival.machine.Machines
|
||||||
import dev.cobblesurvival.party.ShoulderPassives
|
import dev.cobblesurvival.party.ShoulderPassives
|
||||||
import dev.cobblesurvival.progress.Gates
|
import dev.cobblesurvival.progress.Gates
|
||||||
@@ -115,6 +116,7 @@ class CobbleSurvival {
|
|||||||
})
|
})
|
||||||
.then(BiomePreview.command())
|
.then(BiomePreview.command())
|
||||||
.then(OreVeins.command())
|
.then(OreVeins.command())
|
||||||
|
.then(MachineShowcase.command())
|
||||||
.then(Commands.literal("zone").executes { context ->
|
.then(Commands.literal("zone").executes { context ->
|
||||||
val source = context.source
|
val source = context.source
|
||||||
val zone = Zones.at(source.level, BlockPos.containing(source.position))
|
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.ServerLevel
|
||||||
import net.minecraft.server.level.ServerPlayer
|
import net.minecraft.server.level.ServerPlayer
|
||||||
import net.minecraft.world.InteractionResult
|
import net.minecraft.world.InteractionResult
|
||||||
|
import net.minecraft.world.entity.LivingEntity
|
||||||
import net.minecraft.world.entity.player.Player
|
import net.minecraft.world.entity.player.Player
|
||||||
|
import net.minecraft.world.item.ItemStack
|
||||||
import net.minecraft.world.item.context.BlockPlaceContext
|
import net.minecraft.world.item.context.BlockPlaceContext
|
||||||
import net.minecraft.world.level.Level
|
import net.minecraft.world.level.Level
|
||||||
import net.minecraft.world.level.block.BaseEntityBlock
|
import net.minecraft.world.level.block.BaseEntityBlock
|
||||||
@@ -43,6 +45,13 @@ class MachineBlock(properties: Properties) : BaseEntityBlock(properties) {
|
|||||||
|
|
||||||
override fun getRenderShape(state: BlockState) = RenderShape.MODEL
|
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 newBlockEntity(pos: BlockPos, state: BlockState): BlockEntity = MachineBlockEntity(pos, state)
|
||||||
|
|
||||||
override fun <T : BlockEntity> getTicker(level: Level, state: BlockState, type: BlockEntityType<T>): BlockEntityTicker<T>? =
|
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
|
val inside = pokemon
|
||||||
when {
|
when {
|
||||||
player.isShiftKeyDown && inside != null && def.kind == MachineDefs.Kind.ALTAR && owner == player.uuid -> MachineWork.altarUse(this, level, def, player, inside)
|
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(
|
inside != null && owner != player.uuid -> tell(player, Component.translatableWithFallback(
|
||||||
"message.cobblesurvival.machine.not_yours", "This %s holds %s's %s.", machineName, ownerName, inside.getDisplayName()))
|
"message.cobblesurvival.machine.not_yours", "This %s holds %s's %s.", machineName, ownerName, inside.getDisplayName()))
|
||||||
inside != null -> giveBack(level, null)
|
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) {
|
private fun insert(level: ServerLevel, def: MachineDefs.Def, player: ServerPlayer) {
|
||||||
val match = def.shell.match(level, worldPosition)
|
val match = def.shell.match(level, worldPosition)
|
||||||
if (match == null) {
|
if (match == null) {
|
||||||
val (pos, wanted) = def.shell.firstMismatch(level, worldPosition) ?: return
|
preview(level, def, player)
|
||||||
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))
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val party = Cobblemon.storage.getParty(player)
|
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))
|
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. */
|
/** 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 {
|
fun giveBack(level: ServerLevel, why: Component?): Boolean {
|
||||||
val pokemon = pokemon ?: return true
|
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)
|
bus.addListener(::onCreativeTab)
|
||||||
MachineDefs.register()
|
MachineDefs.register()
|
||||||
MachineDisplay.register()
|
MachineDisplay.register()
|
||||||
|
MachineGhosts.register()
|
||||||
|
MachineTooltips.register()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onCapabilities(event: RegisterCapabilitiesEvent) {
|
private fun onCapabilities(event: RegisterCapabilitiesEvent) {
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
package dev.cobblesurvival.machine
|
package dev.cobblesurvival.machine
|
||||||
|
|
||||||
import net.minecraft.core.BlockPos
|
import net.minecraft.core.BlockPos
|
||||||
|
import net.minecraft.core.Direction
|
||||||
import net.minecraft.core.registries.BuiltInRegistries
|
import net.minecraft.core.registries.BuiltInRegistries
|
||||||
import net.minecraft.core.registries.Registries
|
import net.minecraft.core.registries.Registries
|
||||||
import net.minecraft.resources.ResourceLocation
|
import net.minecraft.resources.ResourceLocation
|
||||||
import net.minecraft.tags.TagKey
|
import net.minecraft.tags.TagKey
|
||||||
import net.minecraft.world.level.Level
|
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.Rotation
|
||||||
import net.minecraft.world.level.block.state.BlockState
|
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.
|
* 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 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). */
|
/** 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>)
|
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()
|
.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). */
|
/** 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 }
|
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 CORE = 'C'
|
||||||
private const val CHAMBER = '.'
|
private const val CHAMBER = '.'
|
||||||
const val AIR = "air"
|
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
|
/** [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. */
|
* 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 chamber = cells.single { it.second == CHAMBER }.first.subtract(core)
|
||||||
val parts = cells.filter { it.second != CORE && it.second != ' ' }.associate { (pos, c) ->
|
val parts = cells.filter { it.second != CORE && it.second != ' ' }.associate { (pos, c) ->
|
||||||
pos.subtract(core) to when (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'"))
|
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 {
|
private fun anyOf(specs: List<String>): BlockRule {
|
||||||
val rules = specs.map(::rule)
|
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("#")) {
|
private fun rule(spec: String): BlockRule = if (spec.startsWith("#")) {
|
||||||
val tag = TagKey.create(Registries.BLOCK, ResourceLocation.parse(spec.substring(1)))
|
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 {
|
} else {
|
||||||
val id = ResourceLocation.parse(spec)
|
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.MachineBlockEntity
|
||||||
import dev.cobblesurvival.machine.MachineDefs
|
import dev.cobblesurvival.machine.MachineDefs
|
||||||
import dev.cobblesurvival.machine.MachineDisplay
|
import dev.cobblesurvival.machine.MachineDisplay
|
||||||
|
import dev.cobblesurvival.machine.MachineGhosts
|
||||||
|
import dev.cobblesurvival.machine.MachineShowcase
|
||||||
import dev.cobblesurvival.machine.MachineWork
|
import dev.cobblesurvival.machine.MachineWork
|
||||||
import dev.cobblesurvival.machine.Shell
|
import dev.cobblesurvival.machine.Shell
|
||||||
import net.minecraft.core.BlockPos
|
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). */
|
/** 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 UNLOAD_OFFSET = 352
|
||||||
private const val RESTART_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(
|
fun all() = listOf(
|
||||||
SelfTestCase("machine_pokemon_goes_in_and_out", 60, ::inAndOut),
|
SelfTestCase("machine_pokemon_goes_in_and_out", 60, ::inAndOut),
|
||||||
@@ -56,18 +60,13 @@ object MachineTests {
|
|||||||
SelfTestCase("synthesis_core_rolls_drops", 100, ::synthesis),
|
SelfTestCase("synthesis_core_rolls_drops", 100, ::synthesis),
|
||||||
SelfTestCase("altar_core_sacrifice_needs_confirmation", 60, ::altar),
|
SelfTestCase("altar_core_sacrifice_needs_confirmation", 60, ::altar),
|
||||||
SelfTestCase("rebirth_core_makes_a_shiny_first_stage", 100, ::rebirth),
|
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 {
|
fun build(level: ServerLevel, core: BlockPos, name: String, casing: Block): MachineBlockEntity = MachineShowcase.build(level, core, name, casing)
|
||||||
val def = MachineDefs.byCore[id(name)] ?: throw GameTestAssertException("no machine data for ${id(name)}")
|
|
||||||
for ((offset, text) in def.shell.layout()) {
|
|
||||||
level.setBlockAndUpdate(core.offset(offset), if (text == Shell.AIR) Blocks.AIR.defaultBlockState() else casing.defaultBlockState())
|
|
||||||
}
|
|
||||||
level.setBlockAndUpdate(core, BuiltInRegistries.BLOCK.get(id(name)).defaultBlockState().setValue(MachineBlock.FACING, Direction.SOUTH))
|
|
||||||
return level.getBlockEntity(core) as? MachineBlockEntity ?: throw GameTestAssertException("no machine at $core")
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun build(s: Scenario, name: String, casing: Block) = build(s.level, s.helper.absolutePos(CORE), name, casing)
|
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()
|
.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",
|
"block.cobblesurvival.rebirth_core": "Rebirth Core",
|
||||||
"message.cobblesurvival.machine.shell_broke": "The shell of the %s broke.",
|
"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.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.send_out": "Send out %s near the %s, then use it again.",
|
||||||
"message.cobblesurvival.machine.inserted": "%s went into the %s.",
|
"message.cobblesurvival.machine.inserted": "%s went into the %s.",
|
||||||
"message.cobblesurvival.machine.returned": "%s came back to your %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.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.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.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."
|
||||||
}
|
}
|
||||||
@@ -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()
|
||||||
@@ -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)))
|
||||||
Reference in new issue
Block a user