Phase 6: Pokémon machines (five cores, shells as data)

Adrian (2026-10-31): a Large Furnace where a Fire Pokémon heats real
furnaces (hoppers in and out), our own Electric generator, no upkeep;
then a drop synthesizer (FE into full drop rolls, looting tiers), a
sacrifice altar (level tiers, rare candies only from Lv 80) and a
rebirth chamber (Lv 100 -> shiny Lv 1 first stage, three perfect IVs,
moves kept, Ability Patch so the hidden ability is optional).

- machine/: data-driven 3x3x3 shells (window spots for glass, free
  spot under the core), checked in four rotations; the Pokémon walks in
  from the party and lives in the core; breaking core or shell sends it
  home (party, else PC, offline too); a real Cobblemon entity copy is
  shown in the chamber (no AI, invulnerable, uncatchable, never saved).
- Heat Core keeps vanilla furnaces lit and faster; Dynamo Core makes
  and pushes FE; Synthesis Core takes FE and rolls drops into a hopper-
  extractable buffer; Altar Core sacrifices on a confirmed sneak-use;
  Rebirth Core runs on server time.
- Recipes, casing tags, block loot, textures (tools/machine_assets.py),
  models, blockstates, names.
- Self-tests: in/out, refusal, shell and core breaks (PC when full),
  chunk unload, server restart, and each machine's work.
- Docs: design, balance table, Phase 6 checklist; Phase 5 approved.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
claudeandClaude Opus 5.5 committed 2026-11-02 15:18:03 +01:00
1 parent 5cae9252c3
commit 0877a6a179
75 files changed
+2321 -7

No files matched your search

@@ -9,6 +9,7 @@ import dev.cobblesurvival.dev.OreVeins
import dev.cobblesurvival.ore.BaseLoot
import dev.cobblesurvival.ore.OreNodes
import dev.cobblesurvival.permadeath.Permadeath
import dev.cobblesurvival.machine.Machines
import dev.cobblesurvival.party.ShoulderPassives
import dev.cobblesurvival.progress.Gates
import dev.cobblesurvival.selftest.SelfTest
@@ -61,6 +62,7 @@ class CobbleSurvival {
LOGGER.info("Cobblemon Survival companion {} loaded (Cobblemon {})", version, Cobblemon.VERSION)
MOD_BUS.addListener(::onAddPackFinders)
InZoneCondition.register(MOD_BUS)
Machines.register(MOD_BUS)
NeoForge.EVENT_BUS.addListener(::onRegisterCommands)
NeoForge.EVENT_BUS.addListener(::onServerStarted)
NeoForge.EVENT_BUS.addListener(::onPortalSpawn)
@@ -0,0 +1,67 @@
package dev.cobblesurvival.machine
import com.mojang.serialization.MapCodec
import net.minecraft.core.BlockPos
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.player.Player
import net.minecraft.world.item.context.BlockPlaceContext
import net.minecraft.world.level.Level
import net.minecraft.world.level.block.BaseEntityBlock
import net.minecraft.world.level.block.Block
import net.minecraft.world.level.block.Mirror
import net.minecraft.world.level.block.RenderShape
import net.minecraft.world.level.block.Rotation
import net.minecraft.world.level.block.entity.BlockEntity
import net.minecraft.world.level.block.entity.BlockEntityTicker
import net.minecraft.world.level.block.entity.BlockEntityType
import net.minecraft.world.level.block.state.BlockState
import net.minecraft.world.level.block.state.StateDefinition
import net.minecraft.world.level.block.state.properties.BlockStateProperties
import net.minecraft.world.phys.BlockHitResult
/** A machine's core block; which machine it is comes from its id ([MachineDefs]). It faces the player who placed it. */
class MachineBlock(properties: Properties) : BaseEntityBlock(properties) {
init {
registerDefaultState(stateDefinition.any().setValue(FACING, Direction.NORTH))
}
override fun codec(): MapCodec<out BaseEntityBlock> = CODEC
override fun createBlockStateDefinition(builder: StateDefinition.Builder<Block, BlockState>) {
builder.add(FACING)
}
override fun getStateForPlacement(context: BlockPlaceContext): BlockState =
defaultBlockState().setValue(FACING, context.horizontalDirection.opposite)
override fun rotate(state: BlockState, rotation: Rotation): BlockState = state.setValue(FACING, rotation.rotate(state.getValue(FACING)))
override fun mirror(state: BlockState, mirror: Mirror): BlockState = state.rotate(mirror.getRotation(state.getValue(FACING)))
override fun getRenderShape(state: BlockState) = RenderShape.MODEL
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>? =
if (level.isClientSide) null
else createTickerHelper(type, Machines.BLOCK_ENTITY.get(), BlockEntityTicker { tickLevel, _, _, core -> core.serverTick(tickLevel as ServerLevel) })
override fun useWithoutItem(state: BlockState, level: Level, pos: BlockPos, player: Player, hitResult: BlockHitResult): InteractionResult {
if (level.isClientSide) return InteractionResult.SUCCESS
(level.getBlockEntity(pos) as? MachineBlockEntity)?.use(player as ServerPlayer)
return InteractionResult.CONSUME
}
override fun onRemove(state: BlockState, level: Level, pos: BlockPos, newState: BlockState, movedByPiston: Boolean) {
if (!state.`is`(newState.block) && level is ServerLevel) (level.getBlockEntity(pos) as? MachineBlockEntity)?.onBroken(level)
super.onRemove(state, level, pos, newState, movedByPiston)
}
companion object {
val FACING = BlockStateProperties.HORIZONTAL_FACING
val CODEC: MapCodec<MachineBlock> = simpleCodec(::MachineBlock)
}
}
@@ -0,0 +1,279 @@
package dev.cobblesurvival.machine
import com.cobblemon.mod.common.Cobblemon
import com.cobblemon.mod.common.entity.pokemon.PokemonEntity
import com.cobblemon.mod.common.pokemon.Pokemon
import dev.cobblesurvival.CobbleSurvival
import net.minecraft.ChatFormatting
import net.minecraft.core.BlockPos
import net.minecraft.core.HolderLookup
import net.minecraft.core.RegistryAccess
import net.minecraft.core.registries.BuiltInRegistries
import net.minecraft.nbt.CompoundTag
import net.minecraft.network.chat.Component
import net.minecraft.server.level.ServerLevel
import net.minecraft.server.level.ServerPlayer
import net.minecraft.world.Containers
import net.minecraft.world.item.ItemStack
import net.minecraft.world.level.block.entity.BlockEntity
import net.minecraft.world.level.block.state.BlockState
import net.minecraft.world.phys.AABB
import net.neoforged.neoforge.energy.IEnergyStorage
import net.neoforged.neoforge.items.IItemHandler
import net.neoforged.neoforge.items.ItemStackHandler
import java.util.UUID
/**
* The core of a Pokémon machine (roadmap Phase 6). It keeps the Pokémon put in (its data lives only here while it's
* inside), checks the shell once a second, shows the Pokémon in the chamber ([MachineDisplay]) and runs the machine's
* work ([MachineWork]). Breaking the core or the shell sends the Pokémon back to its owner, party first, else PC.
*/
class MachineBlockEntity(pos: BlockPos, state: BlockState) : BlockEntity(Machines.BLOCK_ENTITY.get(), pos, state) {
var pokemon: Pokemon? = null
private set
var owner: UUID? = null
private set
private var ownerName = ""
/** Forge Energy: made by a generator, or taken in by a synthesizer. */
var energy = 0
internal set
internal var receivedThisTick = 0
/** What a synthesizer made, waiting for hoppers or the owner. */
val output = ItemStackHandler(OUTPUT_SLOTS)
/** Game time a rebirth finishes; 0 when none is running. */
var rebirthAt = 0L
internal set
/** The rebirth happened; its extras go out with the Pokémon. */
var reborn = false
internal set
internal var shell: Shell.Match? = null
internal var furnaces: List<BlockPos> = emptyList()
internal var tier = 0
internal val heatCarry = HashMap<BlockPos, Double>()
internal var confirmBy: UUID? = null
internal var confirmUntil = 0L
private var display: PokemonEntity? = null
private var ticks = 0
val def: MachineDefs.Def? get() = MachineDefs.byCore[BuiltInRegistries.BLOCK.getKey(blockState.block)]
val machineName: Component get() = blockState.block.name
fun serverTick(level: ServerLevel) {
val def = def ?: return
if (ticks++ % CHECK_TICKS == 0) refreshShell(level, def)
val pokemon = pokemon
if (pokemon != null) {
if (shell == null) {
giveBack(level, Component.translatableWithFallback("message.cobblesurvival.machine.shell_broke", "The shell of the %s broke.", machineName))
return
}
if (display?.isAlive != true) display = MachineDisplay.spawn(level, pokemon, worldPosition, shell!!.chamber, facing())
}
MachineWork.tick(this, level, def)
receivedThisTick = 0
}
private fun refreshShell(level: ServerLevel, def: MachineDefs.Def) {
val min = worldPosition.offset(-SHELL_REACH, -SHELL_REACH, -SHELL_REACH)
val max = worldPosition.offset(SHELL_REACH, SHELL_REACH, SHELL_REACH)
if (!level.hasChunksAt(min, max)) return // a neighbouring chunk isn't loaded: keep what we knew
val match = def.shell.match(level, worldPosition)
if (match?.chamber != shell?.chamber) removeDisplay()
shell = match
if (match != null) MachineWork.onShell(this, level, def, match)
}
fun facing() = blockState.getValue(MachineBlock.FACING)
/** Using the core with an empty hand: put a Pokémon in, take it out, or (sneaking) see the state. */
fun use(player: ServerPlayer) {
val level = level as? ServerLevel ?: return
val def = def ?: return tell(player, Component.literal("This core has no machine data.").withStyle(ChatFormatting.RED))
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))
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)
else -> insert(level, def, player)
}
}
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))
return
}
val party = Cobblemon.storage.getParty(player)
val entity = level.getEntitiesOfClass(PokemonEntity::class.java, AABB(worldPosition).inflate(REACH)) {
!it.isBattling && it.pokemon.getOwnerUUID() == player.uuid && party.any { p -> p.uuid == it.pokemon.uuid } && MachineWork.fits(def, it.pokemon)
}.minByOrNull { it.distanceToSqr(worldPosition.center) }
if (entity == null) {
tell(player, Component.translatableWithFallback("message.cobblesurvival.machine.send_out",
"Send out %s near the %s, then use it again.", MachineWork.wanted(def), machineName).withStyle(ChatFormatting.YELLOW))
return
}
val pokemon = entity.pokemon
pokemon.recall()
if (entity.isAlive) entity.discard()
if (!party.remove(pokemon)) return
this.pokemon = pokemon
owner = player.uuid
ownerName = player.gameProfile.name
shell = match
MachineWork.onInsert(this, level, def)
setChanged()
display = MachineDisplay.spawn(level, pokemon, worldPosition, match.chamber, facing())
tell(player, Component.translatableWithFallback("message.cobblesurvival.machine.inserted", "%s went into the %s.", pokemon.getDisplayName(), machineName)
.withStyle(ChatFormatting.GREEN))
tell(player, MachineWork.status(this, level, def))
}
/** 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
val owner = owner ?: return false
val registries = level.registryAccess()
MachineWork.onTakeOut(this, level, pokemon)
val where = when {
Cobblemon.storage.getParty(owner, registries).add(pokemon) -> "party"
Cobblemon.storage.getPC(owner, registries).add(pokemon) -> "PC"
else -> {
CobbleSurvival.LOGGER.error("Couldn't return {} from the machine at {} to {}", pokemon.species.name, worldPosition, owner)
return false
}
}
level.server.playerList.getPlayer(owner)?.let { player ->
why?.let { tell(player, it.copy().withStyle(ChatFormatting.YELLOW)) }
tell(player, Component.translatableWithFallback("message.cobblesurvival.machine.returned", "%s came back to your %s.", pokemon.getDisplayName(), where))
}
clear()
return true
}
/** The Pokémon is gone for good (sacrificed). */
internal fun consume() = clear()
private fun clear() {
pokemon = null
owner = null
ownerName = ""
rebirthAt = 0
reborn = false
confirmBy = null
removeDisplay()
setChanged()
}
internal fun respawnDisplay() = removeDisplay()
private fun removeDisplay() {
display?.discard()
display = null
}
/** The core is gone: the Pokémon goes home, what the machine made drops. */
fun onBroken(level: ServerLevel) {
giveBack(level, Component.translatableWithFallback("message.cobblesurvival.machine.core_broke", "The %s was broken.", machineName))
for (slot in 0 until output.slots) Containers.dropItemStack(level, worldPosition.x + 0.5, worldPosition.y + 0.5, worldPosition.z + 0.5, output.getStackInSlot(slot))
}
override fun onChunkUnloaded() {
super.onChunkUnloaded()
removeDisplay()
}
override fun setRemoved() {
super.setRemoved()
removeDisplay()
}
fun energyStorage(): IEnergyStorage? = when (def?.kind) {
MachineDefs.Kind.GENERATOR -> MachineEnergy.Output(this)
MachineDefs.Kind.SYNTHESIS -> MachineEnergy.Input(this)
else -> null
}
fun outputHandler(): IItemHandler? = if (def?.kind == MachineDefs.Kind.SYNTHESIS) MachineEnergy.ExtractOnly(output) else null
override fun saveAdditional(tag: CompoundTag, registries: HolderLookup.Provider) {
super.saveAdditional(tag, registries)
pokemon?.let { tag.put("pokemon", it.saveToNBT(registries as RegistryAccess, CompoundTag())) }
owner?.let { tag.putUUID("owner", it) }
tag.putString("owner_name", ownerName)
tag.putInt("energy", energy)
tag.put("output", output.serializeNBT(registries))
tag.putLong("rebirth_at", rebirthAt)
tag.putBoolean("reborn", reborn)
}
override fun loadAdditional(tag: CompoundTag, registries: HolderLookup.Provider) {
super.loadAdditional(tag, registries)
pokemon = if (tag.contains("pokemon")) Pokemon().loadFromNBT(registries as RegistryAccess, tag.getCompound("pokemon")) else null
owner = if (tag.hasUUID("owner")) tag.getUUID("owner") else null
ownerName = tag.getString("owner_name")
energy = tag.getInt("energy")
if (tag.contains("output")) output.deserializeNBT(registries, tag.getCompound("output"))
rebirthAt = tag.getLong("rebirth_at")
reborn = tag.getBoolean("reborn")
}
companion object {
const val OUTPUT_SLOTS = 9
private const val CHECK_TICKS = 20
/** How far from the core its shell can reach. */
private const val SHELL_REACH = 2
/** How near the core a sent-out Pokémon must be to walk in. */
private const val REACH = 16.0
fun tell(player: ServerPlayer, message: Component) = player.sendSystemMessage(message)
}
}
/** Energy and item views of a core, for other blocks (cables, hoppers). */
object MachineEnergy {
/** A generator: others can take energy, not put it in. */
class Output(private val core: MachineBlockEntity) : IEnergyStorage {
override fun receiveEnergy(amount: Int, simulate: Boolean) = 0
override fun extractEnergy(amount: Int, simulate: Boolean): Int {
val out = minOf(amount, core.energy, core.def?.energy?.maxOutput ?: 0)
if (!simulate && out > 0) { core.energy -= out; core.setChanged() }
return out
}
override fun getEnergyStored() = core.energy
override fun getMaxEnergyStored() = core.def?.energy?.capacity ?: 0
override fun canExtract() = true
override fun canReceive() = false
}
/** A synthesizer: takes energy up to its cap per tick, gives none back. */
class Input(private val core: MachineBlockEntity) : IEnergyStorage {
override fun receiveEnergy(amount: Int, simulate: Boolean): Int {
val rules = core.def?.synthesis ?: return 0
val room = minOf(rules.capacity - core.energy, rules.maxInput - core.receivedThisTick)
val taken = minOf(amount, room).coerceAtLeast(0)
if (!simulate && taken > 0) { core.energy += taken; core.receivedThisTick += taken; core.setChanged() }
return taken
}
override fun extractEnergy(amount: Int, simulate: Boolean) = 0
override fun getEnergyStored() = core.energy
override fun getMaxEnergyStored() = core.def?.synthesis?.capacity ?: 0
override fun canExtract() = false
override fun canReceive() = true
}
/** Hoppers and pipes may take what was made, never put anything in. */
class ExtractOnly(private val handler: ItemStackHandler) : IItemHandler {
override fun getSlots() = handler.slots
override fun getStackInSlot(slot: Int): ItemStack = handler.getStackInSlot(slot)
override fun insertItem(slot: Int, stack: ItemStack, simulate: Boolean): ItemStack = stack
override fun extractItem(slot: Int, amount: Int, simulate: Boolean): ItemStack = handler.extractItem(slot, amount, simulate)
override fun getSlotLimit(slot: Int) = handler.getSlotLimit(slot)
override fun isItemValid(slot: Int, stack: ItemStack) = false
}
}
@@ -0,0 +1,132 @@
package dev.cobblesurvival.machine
import com.google.gson.Gson
import com.google.gson.JsonElement
import com.google.gson.JsonObject
import dev.cobblesurvival.CobbleSurvival
import net.minecraft.core.registries.BuiltInRegistries
import net.minecraft.core.registries.Registries
import net.minecraft.resources.ResourceLocation
import net.minecraft.server.packs.resources.ResourceManager
import net.minecraft.server.packs.resources.SimpleJsonResourceReloadListener
import net.minecraft.tags.TagKey
import net.minecraft.util.profiling.ProfilerFiller
import net.minecraft.world.item.Item
import net.minecraft.world.level.block.Block
import net.neoforged.neoforge.common.NeoForge
import net.neoforged.neoforge.event.AddReloadListenerEvent
/**
* Pokémon machines (roadmap Phase 6), one file per machine in `data/cobblesurvival/machines/`: the core block, the shell
* around it ([Shell]), which Pokémon fit, and the numbers for what the machine does. The format is described in each
* file's comment.
*/
object MachineDefs : SimpleJsonResourceReloadListener(Gson(), "machines") {
enum class Kind { FURNACE, GENERATOR, SYNTHESIS, ALTAR, REBIRTH }
class FurnaceRules(val tag: TagKey<Block>, val max: Int, val levelsPerFurnace: Int, val base: Double, val perLevel: Double, val perSpecialAttack: Double) {
fun count(level: Int) = minOf(max, 1 + level / levelsPerFurnace)
fun speed(level: Int, specialAttack: Int) = base + perLevel * level + perSpecialAttack * specialAttack
}
class EnergyRules(val fePerLevel: Int, val perSpecialAttack: Double, val capacity: Int, val maxOutput: Int) {
fun perTick(level: Int, specialAttack: Int) = (fePerLevel * level * (1 + perSpecialAttack * specialAttack)).toInt()
}
/** Synthesis tiers: casing blocks of each tier, lowest first; a shell's tier is the lowest one among its blocks. */
class SynthesisRules(val fePerRoll: Int, val maxInput: Int, val capacity: Int, val outputSlots: Int, val tiers: List<TagKey<Block>>, val chanceBonusPerLooting: Double)
class Reward(val item: Item, val count: Int)
class AltarTier(val minLevel: Int, val rewards: List<Reward>)
class AltarSpecial(val types: Set<String>, val minLevel: Int, val rewards: List<Reward>)
class AltarRules(val tiers: List<AltarTier>, val specials: List<AltarSpecial>, val confirmTicks: Int)
class RebirthRules(val ticks: Long, val perfectIvs: Int, val extra: List<Reward>)
class Def(
val id: ResourceLocation,
val core: ResourceLocation,
val kind: Kind,
/** Empty = any type. */
val types: Set<String>,
val minLevel: Int,
val shell: Shell,
val furnaces: FurnaceRules? = null,
val energy: EnergyRules? = null,
val synthesis: SynthesisRules? = null,
val altar: AltarRules? = null,
val rebirth: RebirthRules? = null,
)
var byCore: Map<ResourceLocation, Def> = emptyMap()
private set
fun register() {
NeoForge.EVENT_BUS.addListener<AddReloadListenerEvent> { it.addListener(this) }
}
override fun apply(files: Map<ResourceLocation, JsonElement>, resources: ResourceManager, profiler: ProfilerFiller) {
byCore = files.mapNotNull { (id, element) ->
runCatching { parse(id, element.asJsonObject) }
.onFailure { CobbleSurvival.LOGGER.error("Bad machine definition {}: {}", id, it.message) }
.getOrNull()
}.associateBy { it.core }
CobbleSurvival.LOGGER.info("Loaded {} machines", byCore.size)
}
private fun blockTag(id: String) = TagKey.create(Registries.BLOCK, ResourceLocation.parse(id.removePrefix("#")))
private fun rewards(json: JsonObject, key: String = "rewards") = json.getAsJsonArray(key).map { it.asJsonObject }.map {
val id = ResourceLocation.parse(it.get("item").asString)
require(BuiltInRegistries.ITEM.containsKey(id)) { "unknown item $id" }
Reward(BuiltInRegistries.ITEM.get(id), it.get("count")?.asInt ?: 1)
}
private fun parse(id: ResourceLocation, json: JsonObject): Def {
val kind = Kind.valueOf(json.get("kind").asString.uppercase())
val shell = Shell.parse(
json.getAsJsonArray("layers").map { layer -> layer.asJsonArray.map { it.asString } },
json.getAsJsonObject("keys").entrySet().associate { (key, value) ->
key.single() to if (value.isJsonArray) value.asJsonArray.map { it.asString } else listOf(value.asString)
},
)
val def = Def(
id, ResourceLocation.parse(json.get("core").asString), kind,
json.getAsJsonArray("types")?.map { it.asString.lowercase() }?.toSet().orEmpty(),
json.get("min_level")?.asInt ?: 1,
shell,
furnaces = json.getAsJsonObject("furnaces")?.let { f ->
val speed = f.getAsJsonObject("speed")
FurnaceRules(blockTag(f.get("tag").asString), f.get("max").asInt, f.get("levels_per_furnace").asInt,
speed.get("base").asDouble, speed.get("per_level").asDouble, speed.get("per_special_attack").asDouble)
},
energy = json.getAsJsonObject("energy")?.let { e ->
EnergyRules(e.get("fe_per_level").asInt, e.get("per_special_attack").asDouble, e.get("capacity").asInt, e.get("max_output").asInt)
},
synthesis = json.getAsJsonObject("synthesis")?.let { s ->
SynthesisRules(s.get("fe_per_roll").asInt, s.get("max_input").asInt, s.get("capacity").asInt, s.get("output_slots").asInt,
s.getAsJsonArray("tiers").map { blockTag(it.asString) }, s.get("chance_bonus_per_looting").asDouble)
},
altar = json.getAsJsonObject("altar")?.let { a ->
AltarRules(
a.getAsJsonArray("tiers").map { it.asJsonObject }.map { AltarTier(it.get("min_level").asInt, rewards(it)) }.sortedBy { it.minLevel },
a.getAsJsonArray("specials")?.map { it.asJsonObject }?.map { s ->
AltarSpecial(s.getAsJsonArray("types").map { it.asString.lowercase() }.toSet(), s.get("min_level").asInt, rewards(s))
}.orEmpty(),
a.get("confirm_ticks").asInt,
)
},
rebirth = json.getAsJsonObject("rebirth")?.let { r ->
RebirthRules(r.get("ticks").asLong, r.get("perfect_ivs").asInt, if (r.has("extra")) rewards(r, "extra") else emptyList())
},
)
require(when (kind) {
Kind.FURNACE -> def.furnaces != null
Kind.GENERATOR -> def.energy != null
Kind.SYNTHESIS -> def.synthesis != null
Kind.ALTAR -> def.altar != null
Kind.REBIRTH -> def.rebirth != null
}) { "a ${kind.name.lowercase()} machine needs its settings (furnaces, energy, synthesis, altar or rebirth)" }
return def
}
}
@@ -0,0 +1,74 @@
package dev.cobblesurvival.machine
import com.cobblemon.mod.common.api.Priority
import com.cobblemon.mod.common.api.events.CobblemonEvents
import com.cobblemon.mod.common.api.events.battles.BattleStartedEvent
import com.cobblemon.mod.common.entity.pokemon.PokemonEntity
import com.cobblemon.mod.common.pokemon.Pokemon
import com.cobblemon.mod.common.pokemon.properties.UncatchableProperty
import net.minecraft.core.BlockPos
import net.minecraft.core.Direction
import net.minecraft.nbt.CompoundTag
import net.minecraft.server.level.ServerLevel
import net.minecraft.world.entity.Entity
import net.neoforged.neoforge.common.NeoForge
import net.neoforged.neoforge.event.entity.EntityJoinLevelEvent
import net.neoforged.neoforge.event.entity.player.AttackEntityEvent
import net.neoforged.neoforge.event.entity.player.PlayerInteractEvent
import java.util.UUID
/**
* The Pokémon a machine shows in its chamber (roadmap Phase 6: a real Cobblemon entity, so its own model and
* animations are used). It's a copy of the Pokémon kept in the core, with its own id, scaled to fit the chamber,
* without AI, invulnerable, silent, uncatchable, and it can't be used, hit or battled. It's never meant to be in the
* world save: one that comes back from disk is dropped, and the core puts a fresh one there.
*/
object MachineDisplay {
/** Key in the copy's persistent data; its value is the core's position. */
const val TAG = "cobblesurvival_machine"
/** The chamber is one block; leave a little room. */
private const val FIT = 0.9f
fun register() {
NeoForge.EVENT_BUS.addListener(::onJoin)
NeoForge.EVENT_BUS.addListener<PlayerInteractEvent.EntityInteract> { if (isDisplay(it.target)) it.isCanceled = true }
NeoForge.EVENT_BUS.addListener<PlayerInteractEvent.EntityInteractSpecific> { if (isDisplay(it.target)) it.isCanceled = true }
NeoForge.EVENT_BUS.addListener<AttackEntityEvent> { if (isDisplay(it.target)) it.isCanceled = true }
CobblemonEvents.BATTLE_STARTED_PRE.subscribe(Priority.HIGHEST, ::onBattle)
}
fun isDisplay(entity: Entity?): Boolean = entity is PokemonEntity && entity.pokemon.persistentData.contains(TAG)
fun spawn(level: ServerLevel, pokemon: Pokemon, core: BlockPos, chamber: BlockPos, facing: Direction): PokemonEntity {
val copy = Pokemon().loadFromNBT(level.registryAccess(), pokemon.saveToNBT(level.registryAccess(), CompoundTag()))
copy.uuid = UUID.randomUUID()
copy.persistentData.putLong(TAG, core.asLong())
UncatchableProperty.uncatchable().apply(copy)
val entity = PokemonEntity(level, copy)
val size = maxOf(entity.bbWidth, entity.bbHeight)
if (size > FIT) {
copy.scaleModifier = copy.scaleModifier * FIT / size
entity.refreshDimensions()
}
entity.setNoAi(true)
entity.setNoGravity(true)
entity.isInvulnerable = true
entity.isSilent = true
entity.setPersistenceRequired()
entity.busyLocks.add(this)
entity.moveTo(chamber.x + 0.5, chamber.y.toDouble(), chamber.z + 0.5, facing.toYRot(), 0f)
entity.yHeadRot = facing.toYRot()
entity.yBodyRot = facing.toYRot()
level.addFreshEntity(entity)
return entity
}
/** A copy saved with its chunk comes back without its core knowing it: drop it. */
private fun onJoin(event: EntityJoinLevelEvent) {
if (event.loadedFromDisk() && isDisplay(event.entity)) event.isCanceled = true
}
private fun onBattle(event: BattleStartedEvent.Pre) {
if (event.battle.actors.any { actor -> actor.pokemonList.any { isDisplay(it.entity) } }) event.cancel()
}
}
@@ -0,0 +1,251 @@
package dev.cobblesurvival.machine
import com.cobblemon.mod.common.api.drop.ItemDropEntry
import com.cobblemon.mod.common.api.pokemon.stats.Stats
import com.cobblemon.mod.common.pokemon.FormData
import com.cobblemon.mod.common.pokemon.Pokemon
import dev.cobblesurvival.CobbleSurvival
import net.minecraft.ChatFormatting
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.server.level.ServerLevel
import net.minecraft.server.level.ServerPlayer
import net.minecraft.world.inventory.ContainerData
import net.minecraft.world.item.ItemStack
import net.minecraft.world.level.block.AbstractFurnaceBlock
import net.minecraft.world.level.block.entity.AbstractFurnaceBlockEntity
import net.neoforged.neoforge.capabilities.Capabilities
import net.neoforged.neoforge.items.ItemHandlerHelper
import kotlin.random.Random
/** What each kind of machine does with the Pokémon inside ([MachineDefs.Kind]); the numbers come from its data file. */
object MachineWork {
private val furnaceData by lazy {
AbstractFurnaceBlockEntity::class.java.getDeclaredField("dataAccess").apply { isAccessible = true }
}
private const val LIT_TIME = 0
private const val LIT_DURATION = 1
private const val PROGRESS = 2
private const val TOTAL = 3
private const val KEEP_LIT = 40
private const val TICKS_PER_MINUTE = 1200
fun fits(def: MachineDefs.Def, pokemon: Pokemon): Boolean =
(def.types.isEmpty() || pokemon.types.any { it.name.lowercase() in def.types }) && pokemon.level >= def.minLevel
/** "a Fire-type Pokémon", "a Lv 100 Pokémon". */
fun wanted(def: MachineDefs.Def): String {
val type = def.types.joinToString("/") { it.replaceFirstChar(Char::titlecase) }
val level = if (def.minLevel > 1) "Lv ${def.minLevel}+ " else ""
return if (type.isEmpty()) "a ${level}Pokémon" else "a $level$type-type Pokémon"
}
fun onShell(core: MachineBlockEntity, level: ServerLevel, def: MachineDefs.Def, shell: Shell.Match) {
def.furnaces?.let { rules ->
core.furnaces = (shell.blocks + core.blockPos).asSequence()
.flatMap { block -> Direction.entries.asSequence().map { block.relative(it) } }
.filter { it !in shell.blocks && it != core.blockPos && it != shell.chamber }
.distinct()
.filter { level.getBlockState(it).`is`(rules.tag) }
.sortedWith(compareBy({ it.y }, { it.z }, { it.x }))
.toList()
}
def.synthesis?.let { rules ->
// Glass and anything outside the tier tags doesn't count; the lowest metal decides.
core.tier = shell.blocks.mapNotNull { pos -> rules.tiers.indexOfFirst { level.getBlockState(pos).`is`(it) }.takeIf { it >= 0 } }.minOrNull() ?: 0
}
}
fun onInsert(core: MachineBlockEntity, level: ServerLevel, def: MachineDefs.Def) {
def.rebirth?.let { core.rebirthAt = level.gameTime + it.ticks }
}
fun onTakeOut(core: MachineBlockEntity, level: ServerLevel, pokemon: Pokemon) {
val owner = core.owner?.let { level.server.playerList.getPlayer(it) }
if (core.rebirthAt > 0 && owner != null) {
MachineBlockEntity.tell(owner, Component.translatableWithFallback("message.cobblesurvival.machine.rebirth_cancelled",
"%s's rebirth was cancelled.", pokemon.getDisplayName()).withStyle(ChatFormatting.YELLOW))
}
if (core.reborn) core.def?.rebirth?.extra?.forEach { give(level, core, owner, ItemStack(it.item, it.count)) }
}
fun tick(core: MachineBlockEntity, level: ServerLevel, def: MachineDefs.Def) {
val pokemon = core.pokemon
when (def.kind) {
MachineDefs.Kind.FURNACE -> if (pokemon != null) heat(core, level, def.furnaces!!, pokemon)
MachineDefs.Kind.GENERATOR -> {
val rules = def.energy!!
if (pokemon != null) core.energy = minOf(rules.capacity, core.energy + rules.perTick(pokemon.level, pokemon.specialAttack))
push(core, level, rules)
}
MachineDefs.Kind.SYNTHESIS -> if (pokemon != null) synthesize(core, level, def.synthesis!!, pokemon)
MachineDefs.Kind.REBIRTH -> if (pokemon != null && core.rebirthAt > 0 && level.gameTime >= core.rebirthAt) rebirth(core, level, def.rebirth!!, pokemon)
MachineDefs.Kind.ALTAR -> {}
}
}
// --- Large Furnace -------------------------------------------------------------------------------------------
private fun heat(core: MachineBlockEntity, level: ServerLevel, rules: MachineDefs.FurnaceRules, pokemon: Pokemon) {
val speed = rules.speed(pokemon.level, pokemon.specialAttack)
for (pos in core.furnaces.take(rules.count(pokemon.level))) {
val furnace = level.getBlockEntity(pos) as? AbstractFurnaceBlockEntity ?: continue
if (furnace.getItem(0).isEmpty) {
core.heatCarry.remove(pos)
continue
}
val data = furnaceData.get(furnace) as ContainerData
if (data.get(LIT_TIME) < 2) {
data.set(LIT_TIME, KEEP_LIT)
data.set(LIT_DURATION, KEEP_LIT)
}
val state = level.getBlockState(pos)
if (state.hasProperty(AbstractFurnaceBlock.LIT) && !state.getValue(AbstractFurnaceBlock.LIT)) {
level.setBlock(pos, state.setValue(AbstractFurnaceBlock.LIT, true), 3)
}
// The furnace adds one step itself; the Pokémon adds the rest. Stop one short of done so the furnace's own
// tick finishes the item.
val total = data.get(TOTAL)
if (total > 0) {
val carry = (core.heatCarry[pos] ?: 0.0) + speed - 1
val extra = carry.toInt()
core.heatCarry[pos] = carry - extra
if (extra > 0) data.set(PROGRESS, minOf(data.get(PROGRESS) + extra, total - 1))
}
furnace.setChanged()
}
}
// --- Generator -------------------------------------------------------------------------------------------------
private fun push(core: MachineBlockEntity, level: ServerLevel, rules: MachineDefs.EnergyRules) {
for (side in Direction.entries) {
if (core.energy <= 0) break
val target = level.getCapability(Capabilities.EnergyStorage.BLOCK, core.blockPos.relative(side), side.opposite) ?: continue
if (!target.canReceive()) continue
core.energy -= target.receiveEnergy(minOf(core.energy, rules.maxOutput), false)
}
core.setChanged()
}
// --- Drop Synthesizer ------------------------------------------------------------------------------------------
private fun synthesize(core: MachineBlockEntity, level: ServerLevel, rules: MachineDefs.SynthesisRules, pokemon: Pokemon) {
if (core.energy < rules.fePerRoll) return
val used = (0 until rules.outputSlots.coerceAtMost(core.output.slots)).count { !core.output.getStackInSlot(it).isEmpty }
if (used >= rules.outputSlots) return
core.energy -= rules.fePerRoll
for (stack in roll(pokemon, core.tier, rules.chanceBonusPerLooting)) ItemHandlerHelper.insertItemStacked(core.output, stack, false)
core.setChanged()
}
/** One drop roll as if the Pokémon were defeated, with [looting] extra items per drop and a chance of a second roll. */
fun roll(pokemon: Pokemon, looting: Int, chanceBonus: Double): List<ItemStack> {
val rolls = if (looting > 0 && Random.nextDouble() < chanceBonus * looting) 2 else 1
return (1..rolls).flatMap { pokemon.form.drops.getDrops(pokemon = pokemon) }.filterIsInstance<ItemDropEntry>().mapNotNull { entry ->
val item = BuiltInRegistries.ITEM.getOptional(entry.item).orElse(null) ?: return@mapNotNull null
val count = (entry.quantityRange?.random() ?: entry.quantity) + (if (looting > 0) Random.nextInt(looting + 1) else 0)
ItemStack(item, count).takeIf { !it.isEmpty }
}
}
// --- Sacrifice Altar -------------------------------------------------------------------------------------------
fun altarRewards(rules: MachineDefs.AltarRules, pokemon: Pokemon): List<ItemStack> {
val tier = rules.tiers.lastOrNull { pokemon.level >= it.minLevel }?.rewards.orEmpty()
val specials = rules.specials.filter { s -> pokemon.level >= s.minLevel && pokemon.types.any { it.name.lowercase() in s.types } }.flatMap { it.rewards }
return (tier + specials).map { ItemStack(it.item, it.count) }
}
fun altarUse(core: MachineBlockEntity, level: ServerLevel, def: MachineDefs.Def, player: ServerPlayer, pokemon: Pokemon) {
val rules = def.altar!!
val rewards = altarRewards(rules, pokemon)
if (core.confirmBy == player.uuid && level.gameTime <= core.confirmUntil) {
core.consume()
rewards.forEach { give(level, core, player, it.copy()) }
CobbleSurvival.LOGGER.info("{} sacrificed {} (Lv {}) at {}", player.gameProfile.name, pokemon.species.name, pokemon.level, core.blockPos)
MachineBlockEntity.tell(player, Component.translatableWithFallback("message.cobblesurvival.machine.sacrificed",
"%s is gone. You received %s.", pokemon.getDisplayName(), list(rewards)).withStyle(ChatFormatting.DARK_PURPLE))
return
}
core.confirmBy = player.uuid
core.confirmUntil = level.gameTime + rules.confirmTicks
MachineBlockEntity.tell(player, Component.translatableWithFallback("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.",
pokemon.getDisplayName(), pokemon.level, list(rewards), rules.confirmTicks / 20).withStyle(ChatFormatting.RED))
}
// --- Rebirth Chamber -------------------------------------------------------------------------------------------
private fun rebirth(core: MachineBlockEntity, level: ServerLevel, rules: MachineDefs.RebirthRules, pokemon: Pokemon) {
rebirth(pokemon, rules.perfectIvs)
core.rebirthAt = 0
core.reborn = true
core.respawnDisplay()
core.setChanged()
core.owner?.let { level.server.playerList.getPlayer(it) }?.let {
MachineBlockEntity.tell(it, Component.translatableWithFallback("message.cobblesurvival.machine.reborn",
"%s was reborn in the %s.", pokemon.getDisplayName(), core.machineName).withStyle(ChatFormatting.LIGHT_PURPLE))
}
}
/** First stage of its line at Lv 1, shiny, [perfectIvs] IVs raised to 31 (none lowered); moves, ability, nickname kept. */
fun rebirth(pokemon: Pokemon, perfectIvs: Int) {
val ability = pokemon.ability
var form: FormData = pokemon.form
while (true) form = form.preEvolution?.form ?: break
if (form.species != pokemon.species) pokemon.species = form.species
if (pokemon.form != form) pokemon.form = form
pokemon.level = 1
pokemon.shiny = true
Stats.PERMANENT.filter { (pokemon.ivs[it] ?: 0) < 31 }.shuffled().take(perfectIvs).forEach { pokemon.setIV(it, 31) }
if (form.abilities.any { it.template == ability.template }) pokemon.updateAbility(ability) else pokemon.rollAbility()
}
// --- Status ----------------------------------------------------------------------------------------------------
fun status(core: MachineBlockEntity, level: ServerLevel, def: MachineDefs.Def): Component {
val pokemon = core.pokemon ?: return Component.translatableWithFallback("message.cobblesurvival.machine.status_empty",
"%s: empty. Send out %s near it and use it to put the Pokémon in.", core.machineName, wanted(def))
val who = Component.empty().append(pokemon.getDisplayName()).append(" (Lv ${pokemon.level})")
val line = when (def.kind) {
MachineDefs.Kind.FURNACE -> {
val rules = def.furnaces!!
val heated = minOf(core.furnaces.size, rules.count(pokemon.level))
if (core.furnaces.isEmpty()) "no furnaces stand against the shell yet; furnaces, blast furnaces and smokers there are heated"
else "heats $heated of ${core.furnaces.size} furnaces (up to ${rules.count(pokemon.level)}), cooking %.1f× as fast".format(rules.speed(pokemon.level, pokemon.specialAttack))
}
MachineDefs.Kind.GENERATOR -> {
val rules = def.energy!!
"makes ${rules.perTick(pokemon.level, pokemon.specialAttack)} FE/t; ${core.energy} of ${rules.capacity} FE stored"
}
MachineDefs.Kind.SYNTHESIS -> {
val rules = def.synthesis!!
"${core.energy} of ${rules.fePerRoll} FE toward the next drop roll (takes up to ${rules.maxInput} FE/t), looting ${core.tier}"
}
MachineDefs.Kind.ALTAR -> "sneak-use to see what sacrificing it gives"
MachineDefs.Kind.REBIRTH -> when {
core.reborn -> "reborn: take it out"
else -> "reborn in ${((core.rebirthAt - level.gameTime).coerceAtLeast(0) / TICKS_PER_MINUTE) + 1} minutes"
}
}
return Component.empty().append(core.machineName).append(": ").append(who).append(" $line.")
}
private fun list(stacks: List<ItemStack>): Component {
val text = Component.empty()
stacks.forEachIndexed { i, stack ->
if (i > 0) text.append(", ")
text.append("${stack.count}× ").append(stack.hoverName)
}
return if (stacks.isEmpty()) Component.literal("nothing") else text
}
private fun give(level: ServerLevel, core: MachineBlockEntity, player: ServerPlayer?, stack: ItemStack) {
if (player != null && player.inventory.add(stack)) return
val pos: BlockPos = core.blockPos.above()
net.minecraft.world.Containers.dropItemStack(level, pos.x + 0.5, pos.y + 0.5, pos.z + 0.5, stack)
}
}
@@ -0,0 +1,63 @@
package dev.cobblesurvival.machine
import dev.cobblesurvival.CobbleSurvival
import net.minecraft.core.registries.Registries
import net.minecraft.world.item.CreativeModeTabs
import net.minecraft.world.level.block.SoundType
import net.minecraft.world.level.block.entity.BlockEntityType
import net.minecraft.world.level.block.state.BlockBehaviour
import net.minecraft.world.level.material.MapColor
import net.neoforged.bus.api.IEventBus
import net.neoforged.neoforge.capabilities.Capabilities
import net.neoforged.neoforge.capabilities.RegisterCapabilitiesEvent
import net.neoforged.neoforge.event.BuildCreativeModeTabContentsEvent
import net.neoforged.neoforge.registries.DeferredBlock
import net.neoforged.neoforge.registries.DeferredHolder
import net.neoforged.neoforge.registries.DeferredRegister
import java.util.function.Supplier
/** The machine cores (roadmap Phase 6): blocks, their items and block entity, and the energy and item views. */
object Machines {
private val BLOCKS = DeferredRegister.createBlocks(CobbleSurvival.MOD_ID)
private val ITEMS = DeferredRegister.createItems(CobbleSurvival.MOD_ID)
private val BLOCK_ENTITIES = DeferredRegister.create(Registries.BLOCK_ENTITY_TYPE, CobbleSurvival.MOD_ID)
val CORES: List<DeferredBlock<MachineBlock>> = listOf(
core("heat_core", MapColor.COLOR_RED, SoundType.STONE),
core("dynamo_core", MapColor.COLOR_ORANGE, SoundType.COPPER),
core("synthesis_core", MapColor.METAL, SoundType.METAL),
core("altar_core", MapColor.COLOR_BLACK, SoundType.STONE),
core("rebirth_core", MapColor.COLOR_PURPLE, SoundType.AMETHYST),
)
init {
CORES.forEach { ITEMS.registerSimpleBlockItem(it) }
}
val BLOCK_ENTITY: DeferredHolder<BlockEntityType<*>, BlockEntityType<MachineBlockEntity>> = BLOCK_ENTITIES.register("machine", Supplier {
BlockEntityType.Builder.of(::MachineBlockEntity, *CORES.map { it.get() }.toTypedArray()).build(null)
})
private fun core(name: String, color: MapColor, sound: SoundType): DeferredBlock<MachineBlock> = BLOCKS.registerBlock(
name, ::MachineBlock, BlockBehaviour.Properties.of().mapColor(color).strength(3.5f).requiresCorrectToolForDrops().sound(sound),
)
fun register(bus: IEventBus) {
BLOCKS.register(bus)
ITEMS.register(bus)
BLOCK_ENTITIES.register(bus)
bus.addListener(::onCapabilities)
bus.addListener(::onCreativeTab)
MachineDefs.register()
MachineDisplay.register()
}
private fun onCapabilities(event: RegisterCapabilitiesEvent) {
event.registerBlockEntity(Capabilities.EnergyStorage.BLOCK, BLOCK_ENTITY.get()) { core, _ -> core.energyStorage() }
event.registerBlockEntity(Capabilities.ItemHandler.BLOCK, BLOCK_ENTITY.get()) { core, _ -> core.outputHandler() }
}
private fun onCreativeTab(event: BuildCreativeModeTabContentsEvent) {
if (event.tabKey == CreativeModeTabs.FUNCTIONAL_BLOCKS) CORES.forEach { event.accept(it) }
}
}
@@ -0,0 +1,73 @@
package dev.cobblesurvival.machine
import net.minecraft.core.BlockPos
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.Rotation
import net.minecraft.world.level.block.state.BlockState
/**
* A machine's shell: what must stand at each offset from the core, written with the core in the south wall, and the
* chamber where the Pokémon stands. [match] tries the four horizontal rotations around the core.
*/
class Shell(private val parts: Map<BlockPos, BlockRule>, private val chamber: BlockPos) {
class BlockRule(val text: String, val test: (BlockState) -> Boolean)
/** 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>)
fun match(level: Level, core: BlockPos): Match? = ROTATIONS.firstNotNullOfOrNull { rotation ->
if (parts.all { (offset, rule) -> rule.test(level.getBlockState(core.offset(offset.rotate(rotation)))) }) {
Match(core.offset(chamber.rotate(rotation)), parts.keys.filter { it != chamber }.mapTo(HashSet()) { core.offset(it.rotate(rotation)) })
} else null
}
/** For a message: the first wrong block in the rotation that comes closest, and what belongs there. */
fun firstMismatch(level: Level, core: BlockPos): Pair<BlockPos, String>? = ROTATIONS
.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.text }
}
.minByOrNull { it.size }?.firstOrNull()
/** 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 }
companion object {
private val ROTATIONS = listOf(Rotation.NONE, Rotation.CLOCKWISE_90, Rotation.CLOCKWISE_180, Rotation.COUNTERCLOCKWISE_90)
private const val CORE = 'C'
private const val CHAMBER = '.'
const val AIR = "air"
/** [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. */
fun parse(layers: List<List<String>>, keys: Map<Char, List<String>>): Shell {
val cells = layers.flatMapIndexed { y, rows -> rows.flatMapIndexed { z, row -> row.mapIndexed { x, c -> BlockPos(x, y, z) to c } } }
val core = cells.single { it.second == CORE }.first
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 }
else -> anyOf(keys[c] ?: error("no key for '$c'"))
}
}
return Shell(parts, chamber)
}
private fun anyOf(specs: List<String>): BlockRule {
val rules = specs.map(::rule)
return BlockRule(specs.joinToString(" or ")) { state -> rules.any { it.test(state) } }
}
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) }
} else {
val id = ResourceLocation.parse(spec)
BlockRule(spec) { BuiltInRegistries.BLOCK.getKey(it.block) == id }
}
}
}
@@ -0,0 +1,360 @@
package dev.cobblesurvival.selftest
import com.cobblemon.mod.common.Cobblemon
import com.cobblemon.mod.common.api.pokemon.stats.Stats
import com.cobblemon.mod.common.entity.pokemon.PokemonEntity
import com.cobblemon.mod.common.pokemon.Pokemon
import dev.cobblesurvival.CobbleSurvival
import dev.cobblesurvival.machine.MachineBlock
import dev.cobblesurvival.machine.MachineBlockEntity
import dev.cobblesurvival.machine.MachineDefs
import dev.cobblesurvival.machine.MachineDisplay
import dev.cobblesurvival.machine.MachineWork
import dev.cobblesurvival.machine.Shell
import net.minecraft.core.BlockPos
import net.minecraft.core.Direction
import net.minecraft.core.registries.BuiltInRegistries
import net.minecraft.gametest.framework.GameTestAssertException
import net.minecraft.resources.ResourceLocation
import net.minecraft.server.level.ServerLevel
import net.minecraft.server.level.ServerPlayer
import net.minecraft.world.item.Item
import net.minecraft.world.item.ItemStack
import net.minecraft.world.item.Items
import net.minecraft.world.level.ChunkPos
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.entity.AbstractFurnaceBlockEntity
import net.minecraft.world.level.block.entity.HopperBlockEntity
import net.minecraft.world.phys.AABB
import net.minecraft.world.phys.Vec3
import net.neoforged.neoforge.capabilities.Capabilities
/**
* Pokémon machines (roadmap Phase 6). The roadmap's done-when: putting a Pokémon in and taking it out, a broken shell or
* core, chunk unload and server restart, all without losing or duplicating the Pokémon; and each machine's work.
* Machines are built with the core in the south wall, the chamber north of it.
*/
object MachineTests {
/** Core position in a test's area; the shell spans x 6-8, y 0-2, z 5-7. */
private val CORE = BlockPos(7, 1, 7)
/** 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
fun all() = listOf(
SelfTestCase("machine_pokemon_goes_in_and_out", 60, ::inAndOut),
SelfTestCase("machine_refuses_what_doesnt_fit", 20, ::refuses),
SelfTestCase("machine_shell_break_returns_pokemon", 100, ::shellBreak),
SelfTestCase("machine_core_break_returns_pokemon_to_pc", 60, ::coreBreakToPc),
SelfTestCase("machine_survives_unload", 600, ::survivesUnload),
SelfTestCase("machine_filled_before_restart", 100, ::beforeRestart),
SelfTestCase("machine_survives_restart", 200, ::afterRestart, afterRestart = true),
SelfTestCase("heat_core_cooks_with_hoppers", 800, ::heatCore),
SelfTestCase("dynamo_core_powers_cables", 100, ::dynamo),
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),
)
private fun id(name: String) = ResourceLocation.fromNamespaceAndPath(CobbleSurvival.MOD_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")
}
private fun build(s: Scenario, name: String, casing: Block) = build(s.level, s.helper.absolutePos(CORE), name, casing)
/** Sends [pokemon] (in [player]'s party) out two blocks in front of the core. */
private fun sendOut(s: Scenario, core: BlockPos, pokemon: Pokemon): PokemonEntity {
val spot = core.south(2)
if (s.level.getBlockState(spot.below()).isAir) s.level.setBlockAndUpdate(spot.below(), Blocks.STONE.defaultBlockState())
return s.spawn(PokemonEntity(s.level, pokemon), Vec3.atBottomCenterOf(spot))
}
private fun insert(s: Scenario, core: MachineBlockEntity, player: ServerPlayer, pokemon: Pokemon) {
sendOut(s, core.blockPos, pokemon)
core.use(player)
s.check(core.pokemon?.uuid == pokemon.uuid) { "${pokemon.species.name} didn't go into the ${core.machineName.string}" }
}
private fun displays(level: ServerLevel, core: BlockPos) =
level.getEntitiesOfClass(PokemonEntity::class.java, AABB(core).inflate(3.0)) { MachineDisplay.isDisplay(it) }
private fun inParty(player: ServerPlayer, pokemon: Pokemon) = Cobblemon.storage.getParty(player).any { it.uuid == pokemon.uuid }
private fun inPc(player: ServerPlayer, pokemon: Pokemon) = Cobblemon.storage.getPC(player).any { it.uuid == pokemon.uuid }
private fun inAndOut(s: Scenario) {
val player = s.player()
val core = build(s, "heat_core", Blocks.BRICKS)
val pokemon = s.give(player, s.pokemon("charmander", 20))
s.helper.startSequence()
.thenExecute {
insert(s, core, player, pokemon)
s.check(!inParty(player, pokemon)) { "Charmander is in the machine and still in the party" }
}
.thenWaitUntil { s.check(displays(s.level, core.blockPos).size == 1) { "${displays(s.level, core.blockPos).size} Pokémon shown in the chamber, not 1" } }
.thenExecute {
val shown = displays(s.level, core.blockPos).single()
s.check(shown.pokemon.uuid != pokemon.uuid) { "the shown Pokémon is the real one, not a copy" }
s.check(shown.isNoAi && shown.isInvulnerable && shown.pokemon.isUncatchable()) { "the shown Pokémon can move, be hurt or be caught" }
core.use(player)
s.check(core.pokemon == null) { "Charmander is still in the machine after taking it out" }
s.check(inParty(player, pokemon)) { "Charmander didn't come back to the party" }
}
.thenWaitUntil { s.check(displays(s.level, core.blockPos).isEmpty()) { "the chamber still shows a Pokémon" } }
.thenSucceed()
}
private fun refuses(s: Scenario) {
val player = s.player()
val core = build(s, "heat_core", Blocks.BRICKS)
val pikachu = s.give(player, s.pokemon("pikachu", 20))
sendOut(s, core.blockPos, pikachu)
core.use(player)
s.check(core.pokemon == null && inParty(player, pikachu)) { "a Pikachu went into the Heat Core (Fire types only)" }
val charmander = s.give(player, s.pokemon("charmander", 20))
sendOut(s, core.blockPos, charmander)
s.level.setBlockAndUpdate(core.blockPos.offset(-1, 1, -2), Blocks.AIR.defaultBlockState())
core.use(player)
s.check(core.pokemon == null && inParty(player, charmander)) { "Charmander went into a Heat Core with a hole in its shell" }
s.helper.succeed()
}
private fun shellBreak(s: Scenario) {
val player = s.player()
val core = build(s, "heat_core", Blocks.BRICKS)
val pokemon = s.give(player, s.pokemon("charmander", 20))
s.helper.startSequence()
.thenExecute {
insert(s, core, player, pokemon)
s.level.setBlockAndUpdate(core.blockPos.offset(1, 1, -2), Blocks.AIR.defaultBlockState())
}
.thenWaitUntil { s.check(core.pokemon == null) { "Charmander stayed in a machine with a broken shell" } }
.thenExecute { s.check(inParty(player, pokemon) && !inPc(player, pokemon)) { "Charmander didn't come back to the party (only)" } }
.thenWaitUntil { s.check(displays(s.level, core.blockPos).isEmpty()) { "the broken machine still shows a Pokémon" } }
.thenSucceed()
}
private fun coreBreakToPc(s: Scenario) {
val player = s.player()
val core = build(s, "heat_core", Blocks.BRICKS)
val pokemon = s.give(player, s.pokemon("charmander", 20))
insert(s, core, player, pokemon)
repeat(6) { s.give(player, s.pokemon("rattata", 5)) }
s.level.destroyBlock(core.blockPos, true)
s.check(!inParty(player, pokemon) && inPc(player, pokemon)) { "with a full party, Charmander didn't go to the PC when the core broke" }
s.helper.startSequence()
.thenWaitUntil { s.check(displays(s.level, core.blockPos).isEmpty()) { "the broken machine still shows a Pokémon" } }
.thenSucceed()
}
private fun spot(level: ServerLevel, offset: Int) = level.sharedSpawnPos.let { BlockPos(it.x, SelfTest.PLATFORM_Y, it.z + offset) }
private fun chunks(core: BlockPos) = listOf(core.offset(-2, 0, -3), core.offset(2, 0, 3), core.offset(-2, 0, 3), core.offset(2, 0, -3))
.map(::ChunkPos).distinct()
private fun force(level: ServerLevel, core: BlockPos, on: Boolean) = chunks(core).forEach { level.setChunkForced(it.x, it.z, on) }
private fun clearMachine(level: ServerLevel, core: BlockPos) {
BlockPos.betweenClosed(core.offset(-1, -1, -2), core.offset(1, 1, 2)).forEach { level.setBlockAndUpdate(it, Blocks.AIR.defaultBlockState()) }
level.setBlockAndUpdate(core.south(2).below(), Blocks.AIR.defaultBlockState())
}
private fun survivesUnload(s: Scenario) {
val level = s.level
val pos = spot(level, UNLOAD_OFFSET)
val chunk = ChunkPos(pos)
val player = s.player()
val pokemon = s.give(player, s.pokemon("charmander", 20))
s.afterTest {
force(level, pos, true)
clearMachine(level, pos)
force(level, pos, false)
}
s.helper.startSequence()
.thenExecute { force(level, pos, true) }
.thenWaitUntil { s.check(level.isPositionEntityTicking(pos.south(2))) { "the machine's spot doesn't tick entities yet" } }
.thenExecute {
insert(s, build(level, pos, "heat_core", Blocks.BRICKS), player, pokemon)
force(level, pos, false)
}
.thenWaitUntil { s.check(!level.hasChunk(chunk.x, chunk.z)) { "the machine's chunk is still loaded" } }
.thenExecute { force(level, pos, true) }
.thenWaitUntil {
val back = level.getBlockEntity(pos) as? MachineBlockEntity
s.check(back?.pokemon?.uuid == pokemon.uuid) { "after unload the core holds ${back?.pokemon?.species?.name}, not Charmander" }
s.check(displays(level, pos).size == 1) { "after unload ${displays(level, pos).size} Pokémon are shown, not 1" }
}
.thenExecute {
val back = level.getBlockEntity(pos) as MachineBlockEntity
back.use(player)
s.check(inParty(player, pokemon) && back.pokemon == null) { "Charmander didn't come out after the unload" }
}
.thenSucceed()
}
private fun beforeRestart(s: Scenario) {
val level = s.level
val pos = spot(level, RESTART_OFFSET)
val player = s.player()
val pokemon = s.give(player, s.pokemon("charmander", 20))
s.helper.startSequence()
.thenExecute { force(level, pos, true) }
.thenWaitUntil { s.check(level.isPositionEntityTicking(pos.south(2))) { "the machine's spot doesn't tick entities yet" } }
.thenExecute {
insert(s, build(level, pos, "heat_core", Blocks.BRICKS), player, pokemon)
force(level, pos, false)
}
.thenSucceed()
}
private fun afterRestart(s: Scenario) {
val level = s.level
val pos = spot(level, RESTART_OFFSET)
s.afterTest {
clearMachine(level, pos)
force(level, pos, false)
}
s.helper.startSequence()
.thenExecute { force(level, pos, true) }
.thenWaitUntil {
val core = level.getBlockEntity(pos) as? MachineBlockEntity
s.check(core?.pokemon?.species?.name == "Charmander") { "after the restart the core holds ${core?.pokemon?.species?.name}, not Charmander" }
s.check(displays(level, pos).size == 1) { "after the restart ${displays(level, pos).size} Pokémon are shown, not 1" }
}
.thenExecute {
val core = level.getBlockEntity(pos) as MachineBlockEntity
s.check(core.giveBack(level, null)) { "Charmander couldn't go back to its (offline) owner after the restart" }
}
.thenSucceed()
}
private fun heatCore(s: Scenario) {
val level = s.level
val player = s.player()
val core = build(s, "heat_core", Blocks.BRICKS)
insert(s, core, player, s.give(player, s.pokemon("charmander", 50)))
// A furnace against the east wall, a hopper with raw iron above it and an empty one below; no fuel anywhere.
val furnace = core.blockPos.offset(2, 0, -1)
level.setBlockAndUpdate(furnace, Blocks.FURNACE.defaultBlockState())
level.setBlockAndUpdate(furnace.above(), Blocks.HOPPER.defaultBlockState().setValue(HopperBlock.FACING, Direction.DOWN))
level.setBlockAndUpdate(furnace.below(), Blocks.HOPPER.defaultBlockState().setValue(HopperBlock.FACING, Direction.DOWN))
(level.getBlockEntity(furnace.above()) as HopperBlockEntity).setItem(0, ItemStack(Items.RAW_IRON, 4))
val output = level.getBlockEntity(furnace.below()) as HopperBlockEntity
s.helper.startSequence()
.thenWaitUntil {
val ingots = (0 until output.containerSize).sumOf { output.getItem(it).takeIf { s -> s.`is`(Items.IRON_INGOT) }?.count ?: 0 }
s.check(ingots >= 4) { "the hopper under the furnace has $ingots iron ingots, not 4" }
}
.thenExecute {
val fuel = (level.getBlockEntity(furnace) as AbstractFurnaceBlockEntity).getItem(1)
s.check(fuel.isEmpty) { "the furnace got fuel: $fuel" }
}
.thenSucceed()
}
private fun dynamo(s: Scenario) {
val level = s.level
val player = s.player()
val core = build(s, "dynamo_core", Blocks.COPPER_BLOCK)
insert(s, core, player, s.give(player, s.pokemon("pikachu", 30)))
val cable = BuiltInRegistries.BLOCK.get(ResourceLocation.parse("poke_power:power_cable"))
s.check(cable != Blocks.AIR) { "Poke Power's cable isn't installed" }
val cablePos = core.blockPos.south()
level.setBlockAndUpdate(cablePos, cable.defaultBlockState())
s.helper.startSequence()
.thenWaitUntil {
val stored = level.getCapability(Capabilities.EnergyStorage.BLOCK, cablePos, Direction.NORTH)?.energyStored ?: 0
s.check(stored > 0) { "the cable in front of the Dynamo Core got no energy (core: ${core.energy} FE)" }
}
.thenSucceed()
}
private fun synthesis(s: Scenario) {
val level = s.level
val player = s.player()
val core = build(s, "synthesis_core", Blocks.GOLD_BLOCK)
insert(s, core, player, s.give(player, s.pokemon("magikarp", 10)))
val rules = MachineDefs.byCore[id("synthesis_core")]!!.synthesis!!
val energy = level.getCapability(Capabilities.EnergyStorage.BLOCK, core.blockPos, Direction.SOUTH)
?: throw GameTestAssertException("the Synthesis Core takes no energy")
val taken = energy.receiveEnergy(Int.MAX_VALUE, false)
s.check(taken == rules.maxInput) { "the Synthesis Core took $taken FE in one tick, not its cap ${rules.maxInput}" }
level.setBlockAndUpdate(core.blockPos.below(), Blocks.HOPPER.defaultBlockState().setValue(HopperBlock.FACING, Direction.DOWN))
val hopper = level.getBlockEntity(core.blockPos.below()) as HopperBlockEntity
s.helper.startSequence()
.thenWaitUntil { s.check(core.tier == 1) { "a gold shell is tier ${core.tier}, not 1 (looting I)" } }
.thenExecute { core.energy = rules.fePerRoll }
.thenWaitUntil {
val salmon = (0 until hopper.containerSize).sumOf { hopper.getItem(it).takeIf { s -> s.`is`(Items.SALMON) }?.count ?: 0 }
s.check(salmon > 0) { "no salmon from Magikarp's drops reached the hopper under the core" }
}
.thenSucceed()
}
private fun count(player: ServerPlayer, item: Item) = player.inventory.items.sumOf { if (it.`is`(item)) it.count else 0 }
private fun altar(s: Scenario) {
val level = s.level
val player = s.player()
val rules = MachineDefs.byCore[id("altar_core")]!!.altar!!
val rareCandy = BuiltInRegistries.ITEM.get(ResourceLocation.parse("cobblemon:rare_candy"))
s.check(MachineWork.altarRewards(rules, s.pokemon("geodude", 79)).none { it.`is`(rareCandy) }) { "a Lv 79 Pokémon gives rare candies" }
val core = build(s, "altar_core", Blocks.OBSIDIAN)
val pokemon = s.give(player, s.pokemon("dragonite", 100))
insert(s, core, player, pokemon)
player.isShiftKeyDown = true
core.use(player)
s.check(core.pokemon != null) { "Dragonite was sacrificed without confirming" }
core.use(player)
player.isShiftKeyDown = false
s.check(core.pokemon == null && !inParty(player, pokemon) && !inPc(player, pokemon)) { "Dragonite wasn't sacrificed" }
s.check(count(player, rareCandy) >= 8) { "no rare candies for a Lv 100 sacrifice" }
s.check(count(player, Items.DRAGON_EGG) == 1) { "no dragon egg for a Lv 100 Dragon type" }
s.check(count(player, Items.NETHER_STAR) == 1) { "no nether star for a Lv 100 sacrifice" }
s.helper.startSequence()
.thenWaitUntil { s.check(displays(level, core.blockPos).isEmpty()) { "the altar still shows the sacrificed Pokémon" } }
.thenSucceed()
}
private fun rebirth(s: Scenario) {
val level = s.level
// The rebirth itself, on its own.
val test = s.pokemon("charizard", 100)
Stats.PERMANENT.forEach { test.setIV(it, 10) }
test.setIV(Stats.SPEED, 31)
val moves = test.moveSet.getMoves().map { it.name }
val ability = test.ability.name
MachineWork.rebirth(test, 3)
s.check(test.species.name == "Charmander" && test.level == 1 && test.shiny) { "rebirth gave ${test.species.name} Lv ${test.level} shiny=${test.shiny}" }
val perfect = Stats.PERMANENT.count { test.ivs[it] == 31 }
s.check(perfect == 4 && Stats.PERMANENT.all { (test.ivs[it] ?: 0) >= 10 }) { "after rebirth $perfect IVs are 31 (wanted 4) or one went down: ${test.ivs}" }
s.check(test.moveSet.getMoves().map { it.name } == moves) { "rebirth changed the moves: $moves -> ${test.moveSet.getMoves().map { it.name }}" }
s.check(test.ability.name == ability) { "rebirth changed the ability: $ability -> ${test.ability.name}" }
// And in the machine: the time runs out, the owner takes it out with an Ability Patch.
val player = s.player()
val core = build(s, "rebirth_core", Blocks.AMETHYST_BLOCK)
val pokemon = s.give(player, s.pokemon("charizard", 100))
insert(s, core, player, pokemon)
s.check(core.rebirthAt > level.gameTime) { "the rebirth didn't start" }
val patch = BuiltInRegistries.ITEM.get(ResourceLocation.parse("cobblemon:ability_patch"))
s.helper.startSequence()
.thenExecute { core.rebirthAt = level.gameTime }
.thenWaitUntil { s.check(core.reborn) { "the rebirth didn't finish" } }
.thenExecute {
core.use(player)
val back = Cobblemon.storage.getParty(player).firstOrNull { it.uuid == pokemon.uuid }
s.check(back?.species?.name == "Charmander" && back.level == 1 && back.shiny) { "${back?.species?.name} came out, not a shiny Lv 1 Charmander" }
s.check(count(player, patch) == 1) { "no Ability Patch came with the reborn Pokémon" }
}
.thenSucceed()
}
}
@@ -54,7 +54,7 @@ object SelfTest {
private val cases: List<SelfTestCase> get() =
PermadeathTests.all() + SurvivalRulesTests.all() + ZoneTests.all() + HazardTests.all() + GuardianTests.all() +
VeinTests.all() + OreNodeTests.all() + DropTests.all() + ProgressionTests.all() +
CatchingTests.all() + WorldRulesTests.all() + FieldMoveTests.all() + ShoulderTests.all() + BaseLootTests.all() + BreedingTests.all()
CatchingTests.all() + WorldRulesTests.all() + FieldMoveTests.all() + ShoulderTests.all() + BaseLootTests.all() + BreedingTests.all() + MachineTests.all()
private var running: List<Scenario> = emptyList()
private var forced: List<ChunkPos> = emptyList()