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:
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()
|
||||
|
||||
Reference in new issue
Block a user