diff --git a/main/kotlin/de/devin/cbbees/content/bee/MaterialSource.kt b/main/kotlin/de/devin/cbbees/content/bee/MaterialSource.kt new file mode 100644 index 0000000..0230ab1 --- /dev/null +++ b/main/kotlin/de/devin/cbbees/content/bee/MaterialSource.kt @@ -0,0 +1,198 @@ +package de.devin.cbbees.content.bee + +import net.minecraft.world.item.ItemStack +import net.minecraft.world.entity.player.Player +import net.minecraft.core.BlockPos +import net.minecraft.world.level.Level +import net.neoforged.neoforge.items.ItemHandlerHelper + +/** + * Abstraction for finding and extracting materials from various sources. + */ +interface MaterialSource { + /** + * Tries to find and extract the required item from this source. + * @param required The item stack representing the type of item needed. + * @param amount The maximum amount to extract. + * @return The extracted item stack. + */ + fun extractItems(required: ItemStack, amount: Int): ItemStack + + /** + * Tries to insert items into this source. + * @param stack The item stack to insert. + * @return The remaining items that could not be inserted. + */ + fun insertItems(stack: ItemStack): ItemStack +} + +/** + * Sources materials from a player's inventory. + */ +class PlayerMaterialSource(private val player: Player) : MaterialSource { + override fun extractItems(required: ItemStack, amount: Int): ItemStack { + for (i in 0 until player.inventory.containerSize) { + val stack = player.inventory.getItem(i) + if (ItemStack.isSameItemSameComponents(stack, required)) { + return player.inventory.removeItem(i, amount) + } + } + return ItemStack.EMPTY + } + + override fun insertItems(stack: ItemStack): ItemStack { + val copy = stack.copy() + if (player.inventory.add(copy)) { + return ItemStack.EMPTY + } + return copy + } +} + +/** + * Sources materials from external inventories via Wireless Link. + */ +class WirelessMaterialSource(private val level: Level, private val positions: List) : MaterialSource { + override fun extractItems(required: ItemStack, amount: Int): ItemStack { + for (pos in positions) { + val handler = de.devin.cbbees.compat.NeoForgeCapabilityCompat.getUnsidedItemHandler(level, pos) + if (handler != null) { + for (slot in 0 until handler.slots) { + val stack = handler.getStackInSlot(slot) + if (ItemStack.isSameItemSameComponents(stack, required)) { + return handler.extractItem(slot, amount, false) + } + } + } + } + return ItemStack.EMPTY + } + + override fun insertItems(stack: ItemStack): ItemStack { + var current = stack + for (pos in positions) { + val handler = de.devin.cbbees.compat.NeoForgeCapabilityCompat.getUnsidedItemHandler(level, pos) + if (handler != null) { + current = ItemHandlerHelper.insertItem(handler, current, false) + if (current.isEmpty) return ItemStack.EMPTY + } + } + return current + } +} + +/** + * Combines multiple material sources, checking them in order. + */ +class CompositeMaterialSource(private val sources: List) : MaterialSource { + override fun extractItems(required: ItemStack, amount: Int): ItemStack { + for (source in sources) { + val items = source.extractItems(required, amount) + if (!items.isEmpty) return items + } + return ItemStack.EMPTY + } + + override fun insertItems(stack: ItemStack): ItemStack { + var current = stack + for (source in sources) { + current = source.insertItems(current) + if (current.isEmpty) return ItemStack.EMPTY + } + return current + } +} + +/** + * Helper object for generating adjacent block positions. + */ +object AdjacentPositions { + /** + * Gets all positions adjacent to a center position within a given range. + * For range=1, this returns all 26 neighboring blocks (3x3x3 cube minus center). + * + * @param center The center position. + * @param range The range in blocks (default 1). + * @return List of all adjacent positions. + */ + fun getAdjacentPositions(center: BlockPos, range: Int = 1): List { + val positions = mutableListOf() + for (x in -range..range) { + for (y in -range..range) { + for (z in -range..range) { + if (x != 0 || y != 0 || z != 0) { + positions.add(center.offset(x, y, z)) + } + } + } + } + return positions + } + + /** + * Gets the 6 directly adjacent positions (faces only, no diagonals). + */ + fun getDirectlyAdjacentPositions(center: BlockPos): List { + return listOf( + center.above(), + center.below(), + center.north(), + center.south(), + center.east(), + center.west() + ) + } +} + +/** + * Caches inventory handlers for adjacent blocks to avoid scanning every tick. + * Rescans periodically to detect new or removed inventories. + * + * @param center The center position to scan around. + * @param level The level to scan in. + * @param range The range in blocks (default 1 for 26 neighbors). + * @param rescanInterval How often to rescan in game ticks (default 20 = 1 second). + */ +class AdjacentInventoryCache( + private val center: BlockPos, + private val level: Level, + private val range: Int = 1, + private val rescanInterval: Long = 20 +) { + private var cachedPositions: List? = null + private var lastScanTick: Long = 0 + + /** + * Gets positions of blocks with inventory capabilities. + * Results are cached and refreshed periodically. + */ + fun getInventoryPositions(): List { + val currentTick = level.gameTime + if (cachedPositions == null || currentTick - lastScanTick > rescanInterval) { + cachedPositions = scanForInventories() + lastScanTick = currentTick + } + return cachedPositions ?: emptyList() + } + + private fun scanForInventories(): List { + val positions = AdjacentPositions.getAdjacentPositions(center, range) + return positions.filter { pos -> + de.devin.cbbees.compat.NeoForgeCapabilityCompat.getUnsidedItemHandler(level, pos) != null + } + } + + /** + * Creates a WirelessMaterialSource using the cached inventory positions. + */ + fun createMaterialSource(): WirelessMaterialSource { + return WirelessMaterialSource(level, getInventoryPositions()) + } + + /** + * Forces a rescan on the next access. + */ + fun invalidateCache() { + cachedPositions = null + } +} diff --git a/main/kotlin/de/devin/cbbees/content/domain/beehive/PortableBeeHive.kt b/main/kotlin/de/devin/cbbees/content/domain/beehive/PortableBeeHive.kt new file mode 100644 index 0000000..1c001b3 --- /dev/null +++ b/main/kotlin/de/devin/cbbees/content/domain/beehive/PortableBeeHive.kt @@ -0,0 +1,401 @@ +package de.devin.cbbees.content.domain.beehive + +import de.devin.cbbees.CreateBuzzyBeez +import de.devin.cbbees.content.backpack.PortableBeehiveItem +import de.devin.cbbees.content.bee.MechanicalBeeEntity +import de.devin.cbbees.content.bee.server.ServerBeeManager +import de.devin.cbbees.content.domain.GlobalJobPool +import de.devin.cbbees.content.domain.logistics.LogisticsPort +import de.devin.cbbees.content.domain.logistics.PortReservationManager +import de.devin.cbbees.content.domain.task.BeeTask +import de.devin.cbbees.content.domain.task.TaskBatch +import de.devin.cbbees.content.logistics.ports.PortType +import de.devin.cbbees.content.upgrades.BeeContext +import de.devin.cbbees.config.CBBeesConfig +import de.devin.cbbees.registry.AllDataComponents +import net.minecraft.core.BlockPos +import net.minecraft.server.level.ServerLevel +import net.minecraft.world.entity.ai.memory.WalkTarget +import net.minecraft.world.entity.player.Player +import net.minecraft.world.item.ItemStack +import net.minecraft.world.level.Level +import net.neoforged.neoforge.items.IItemHandler +import de.devin.cbbees.compat.CuriosCompat +import java.util.* + +/** + * Implementation of IBeeHome that wraps a player and their portable beehive (backpack). + * + * Also implements [LogisticsPort] so the player's inventory acts as the highest-priority + * logistics port in the portable network. Bees can take items from the player or bring + * items to the player through the standard port-finding system. + */ +class PortableBeeHive(val player: Player) : BeeHive, LogisticsPort { + + companion object { + /** Networking range for portable beehives (blocks). */ + const val NETWORKING_RANGE = 6.0 + + /** Maximum work/logistics range for portable beehives (blocks). */ + const val MAX_WORK_RANGE = 64.0 + } + + /** Active bee UUIDs grouped by job ID, mirroring MechanicalBeehiveBlockEntity's tracking. */ + private val activeBeesByJob = mutableMapOf>() + + /** Tracks item reservations made by bees picking up from the player's inventory. */ + private val reservationManager = PortReservationManager() + + override fun getActiveBeeCount(): Int = activeBeesByJob.values.sumOf { it.size } + + fun getActiveBeeCountForJob(jobId: UUID): Int = activeBeesByJob[jobId]?.size ?: 0 + + override fun acceptBatch(batch: TaskBatch): Boolean { + if (getAvailableBeeCount() <= 0) return false + if (getActiveBeeCount() >= getBeeContext().maxActiveBees) return false + if (!hasBeeOfType(batch.beeType)) return false + if (getActiveBeeCountForJob(batch.job.jobId) >= getMaxContributionBees()) return false + + val ctx = getBeeContext() + val honeyCost = (CBBeesConfig.portableHoneyPerRewind.get() * ctx.fuelConsumptionMultiplier).toInt().coerceAtLeast(1) + if (!hasHoney(honeyCost)) return false + + val beeItem = consumeBeeOfType(batch.beeType) + if (beeItem.isEmpty) return false + CreateBuzzyBeez.LOGGER.debug("[PortableHive] Spawning bee for batch at ${batch.targetPosition}") + val spawned = spawnBee(beeItem, batch) + if (!spawned) { + addBee(beeItem) + } + return spawned + } + + private fun spawnBee(beeItem: ItemStack, batch: TaskBatch): Boolean { + val ctx = getBeeContext() + val honeyCost = (CBBeesConfig.portableHoneyPerRewind.get() * ctx.fuelConsumptionMultiplier).toInt().coerceAtLeast(1) + consumeHoney(honeyCost) + + val spawnPos = player.position().add(0.0, 2.0, 0.0) + val bee = ServerBeeManager.spawnConstructionBee( + hive = this, + batch = batch, + networkId = network().id, + spawnPos = spawnPos, + context = ctx, + ownerId = player.uuid, + ) + + activeBeesByJob.getOrPut(batch.job.jobId) { mutableSetOf() }.add(bee.id) + return true + } + + override fun onBeeRemoved(bee: net.minecraft.world.entity.Entity) { + onBeeRemovedById(bee.uuid) + } + + override fun onBeeRemovedById(beeId: UUID) { + val iter = activeBeesByJob.iterator() + while (iter.hasNext()) { + val (_, bees) = iter.next() + if (bees.remove(beeId)) { + if (bees.isEmpty()) iter.remove() + break + } + } + } + + /** + * Removes active bee entries for entities that no longer exist in the world. + * Called by the watchdog to prevent ghost bee counts from blocking new dispatches. + */ + fun cleanupOrphanedBees() { + val level = player.level() as? ServerLevel ?: return + val iter = activeBeesByJob.iterator() + while (iter.hasNext()) { + val (_, bees) = iter.next() + bees.removeIf { beeId -> + ServerBeeManager.getBee(beeId) == null && + ((level.getEntity(beeId)?.isAlive) != true) + } + if (bees.isEmpty()) iter.remove() + } + } + + override fun notifyTaskCompleted(task: BeeTask, beeId: UUID): TaskBatch? { + if (!isValid()) return null + val nextBatch = GlobalJobPool.workBacklog(this) + nextBatch?.assignToBee(beeId, player.level().gameTime) + return nextBatch + } + + override val id: UUID get() = player.uuid + override val world: Level get() = player.level() + override val pos: BlockPos get() = player.blockPosition() + override var networkId: UUID = UUID.randomUUID() + set(value) { + if (field == value) return + val old = field + field = value + onNetworkIdChanged(old, value) + } + + override fun getBeeContext(): BeeContext { + val backpack = getBackpackStack() + if (backpack.isEmpty) return BeeContext() + return (backpack.item as PortableBeehiveItem).getBeeContext(backpack) + } + + override fun getNetworkingRange(): Double = NETWORKING_RANGE + + override fun rechargeSpring(ctx: BeeContext): Int { + val honeyCost = + (CBBeesConfig.portableHoneyPerRewind.get() * ctx.fuelConsumptionMultiplier).toInt().coerceAtLeast(1) + consumeHoney(honeyCost) + return super.rechargeSpring(ctx) + } + + override fun chargeReturnFuel(springDeficit: Float, ctx: BeeContext) { + if (springDeficit <= 0f) return + val honeyCost = + (springDeficit * CBBeesConfig.portableHoneyPerRewind.get() * ctx.fuelConsumptionMultiplier).toInt() + .coerceAtLeast(1) + consumeHoney(honeyCost) + } + + fun consumeHoney(amount: Int): Int { + if (player.isCreative) return amount + val backpack = getBackpackStack() + if (backpack.isEmpty) return 0 + val stored = backpack.getOrDefault(AllDataComponents.HONEY_FUEL.get(), 0) + val toConsume = minOf(amount, stored) + backpack.set(AllDataComponents.HONEY_FUEL.get(), stored - toConsume) + return toConsume + } + + fun hasHoney(amount: Int): Boolean { + if (player.isCreative) return true + val backpack = getBackpackStack() + if (backpack.isEmpty) return false + return backpack.getOrDefault(AllDataComponents.HONEY_FUEL.get(), 0) >= amount + } + + fun addBee(item: ItemStack): Boolean { + val backpackItemStack = getBackpackStack() + if (backpackItemStack.isEmpty) return false + return (backpackItemStack.item as PortableBeehiveItem).addBee(backpackItemStack, item) + } + + override fun consumeBee(): ItemStack { + val backpack = getBackpackStack() + if (backpack.isEmpty) return ItemStack.EMPTY + return (backpack.item as PortableBeehiveItem).consumeBee(backpack) + } + + fun consumeBeeOfType(beeType: de.devin.cbbees.content.bee.server.BeeType): ItemStack { + val backpack = getBackpackStack() + if (backpack.isEmpty) return ItemStack.EMPTY + return (backpack.item as PortableBeehiveItem).consumeBeeOfType(backpack, beeType.itemClass) + } + + override fun hasBeeOfType(beeType: de.devin.cbbees.content.bee.server.BeeType): Boolean { + val backpack = getBackpackStack() + if (backpack.isEmpty) return false + val contents = backpack.get(net.minecraft.core.component.DataComponents.CONTAINER) ?: return false + val items = net.minecraft.core.NonNullList.withSize(PortableBeehiveItem.TOTAL_SLOTS, ItemStack.EMPTY) + contents.copyInto(items) + return (0 until PortableBeehiveItem.BEE_SLOTS).any { !items[it].isEmpty && beeType.itemClass.isInstance(items[it].item) } + } + + override fun getAvailableBeeCount(): Int { + val backpack = getBackpackStack() + if (backpack.isEmpty) return 0 + return (backpack.item as PortableBeehiveItem).getTotalBeeCount(backpack) + } + + override fun returnBee(item: ItemStack): Boolean { + return addBee(item) + } + + override fun walkTarget(): WalkTarget { + return WalkTarget(player.blockPosition().above(2), 1.0f, 1) + } + + override fun gatherPos(): BlockPos = player.blockPosition().above(3) + + override fun approachPos(): BlockPos = player.blockPosition().above(3) + + /** + * Portable beehive is always an anchor (BeeHive contract). + * This takes precedence over [LogisticsPort]'s default of false. + */ + override fun isAnchor(): Boolean = true + + override fun getWorkRange(): Double = minOf(super.getWorkRange(), MAX_WORK_RANGE) + + override fun isInWorkRange(pos: BlockPos): Boolean = isInRange(pos) + + override fun sync() {} + + override fun getPortType(): PortType = PortType.INSERT // Both, but INSERT as default + + override fun getFilter(): ItemStack = ItemStack.EMPTY + + override fun isValidForPickup(): Boolean = true + + override fun isValidForDropOff(): Boolean = true + + override fun testFilter(stack: ItemStack): Boolean = true + + override fun canBeeDropOffItem(bee: MechanicalBeeEntity): Boolean = true + + override fun getItemHandler(level: Level): IItemHandler? = null + + /** Lowest priority — bees prefer network logistics ports; player inventory is the fallback. */ + override fun priority(): Int = Int.MIN_VALUE + + override fun hasItemStack(stack: ItemStack): Boolean { + if (player.isCreative) return true + for (i in 0 until player.inventory.containerSize) { + val slot = player.inventory.getItem(i) + if (!slot.isEmpty && ItemStack.isSameItemSameComponents(slot, stack) && slot.count >= stack.count) { + return true + } + } + if (getBeeContext().inventoryAccessEnabled) { + if (hasItemInContainers(stack)) return true + } + return false + } + + override fun hasAvailableItemStack(stack: ItemStack, excludeBeeId: UUID?): Boolean { + if (player.isCreative) return true + val physical = countItem(stack) + val reserved = reservationManager.getReservedCount(stack, excludeBeeId) + return physical - reserved >= stack.count + } + + private fun countItem(stack: ItemStack): Int { + var count = 0 + for (i in 0 until player.inventory.containerSize) { + val slot = player.inventory.getItem(i) + if (!slot.isEmpty && ItemStack.isSameItemSameComponents(slot, stack)) { + count += slot.count + } + } + if (getBeeContext().inventoryAccessEnabled) { + count += countItemInContainers(stack) + } + return count + } + + private fun countItemInContainers(stack: ItemStack): Int { + var count = 0 + for (i in 0 until player.inventory.containerSize) { + val container = player.inventory.getItem(i) + val handler = container.getCapability(net.neoforged.neoforge.capabilities.Capabilities.ItemHandler.ITEM) + ?: continue + for (slot in 0 until handler.slots) { + val slotStack = handler.getStackInSlot(slot) + if (!slotStack.isEmpty && ItemStack.isSameItemSameComponents(slotStack, stack)) { + count += slotStack.count + } + } + } + return count + } + + override fun reserve(beeId: UUID, items: List, tick: Long) { + reservationManager.reserve(beeId, items, tick) + } + + override fun releaseReservation(beeId: UUID) { + reservationManager.release(beeId) + } + + override fun cleanupReservations(currentTick: Long, maxAge: Long) { + reservationManager.cleanup(currentTick, maxAge) + } + + override fun clearReservations() { + reservationManager.clear() + } + + override fun removeItemStack(stack: ItemStack): Boolean { + if (player.isCreative) return true + var remaining = stack.count + for (i in 0 until player.inventory.containerSize) { + val slot = player.inventory.getItem(i) + if (!slot.isEmpty && ItemStack.isSameItemSameComponents(slot, stack)) { + val take = minOf(remaining, slot.count) + slot.shrink(take) + if (slot.isEmpty) player.inventory.setItem(i, ItemStack.EMPTY) + remaining -= take + if (remaining <= 0) return true + } + } + if (remaining > 0 && getBeeContext().inventoryAccessEnabled) { + remaining = removeItemFromContainers(stack, remaining) + } + return remaining <= 0 + } + + private fun hasItemInContainers(stack: ItemStack): Boolean { + for (i in 0 until player.inventory.containerSize) { + val container = player.inventory.getItem(i) + if (container.isEmpty) continue + val handler = container.getCapability(net.neoforged.neoforge.capabilities.Capabilities.ItemHandler.ITEM) + ?: continue + for (slot in 0 until handler.slots) { + val slotStack = handler.getStackInSlot(slot) + if (!slotStack.isEmpty && ItemStack.isSameItemSameComponents(slotStack, stack) && slotStack.count >= stack.count) { + return true + } + } + } + return false + } + + private fun removeItemFromContainers(stack: ItemStack, count: Int): Int { + var remaining = count + for (i in 0 until player.inventory.containerSize) { + if (remaining <= 0) break + val container = player.inventory.getItem(i) + if (container.isEmpty) continue + val handler = container.getCapability(net.neoforged.neoforge.capabilities.Capabilities.ItemHandler.ITEM) + ?: continue + for (slot in 0 until handler.slots) { + if (remaining <= 0) break + val slotStack = handler.getStackInSlot(slot) + if (!slotStack.isEmpty && ItemStack.isSameItemSameComponents(slotStack, stack)) { + val extracted = handler.extractItem(slot, remaining, false) + remaining -= extracted.count + } + } + } + return remaining + } + + override fun addItemStack(stack: ItemStack): ItemStack { + val copy = stack.copy() + if (player.inventory.add(copy)) { + return ItemStack.EMPTY + } + return copy + } + + /** + * Returns true if the player still has the portable beehive equipped + * (in Curios back slot or chestplate slot). + */ + fun isValid(): Boolean = !getBackpackStack().isEmpty + + private fun getBackpackStack(): ItemStack { + val curiosStack = CuriosCompat.findFirstCurio(player) { it.item is PortableBeehiveItem } + if (!curiosStack.isEmpty) return curiosStack + val chestplate = player.inventory.armor[2] + if (chestplate.item is PortableBeehiveItem) { + return chestplate + } + return ItemStack.EMPTY + } +} diff --git a/main/kotlin/de/devin/cbbees/content/schematics/SchematicCreateBridge.kt b/main/kotlin/de/devin/cbbees/content/schematics/SchematicCreateBridge.kt new file mode 100644 index 0000000..16ff534 --- /dev/null +++ b/main/kotlin/de/devin/cbbees/content/schematics/SchematicCreateBridge.kt @@ -0,0 +1,576 @@ +package de.devin.cbbees.content.schematics + +import com.simibubi.create.AllDataComponents +import com.simibubi.create.AllBlocks +import com.simibubi.create.AllItems +import com.simibubi.create.content.schematics.SchematicPrinter +import com.simibubi.create.content.schematics.requirement.ItemRequirement +import com.simibubi.create.foundation.utility.BlockHelper +import com.simibubi.create.content.kinetics.belt.BeltBlock +import com.simibubi.create.content.kinetics.belt.BeltBlockEntity +import com.simibubi.create.content.kinetics.belt.BeltPart +import de.devin.cbbees.CreateBuzzyBeez +import de.devin.cbbees.config.CBBeesConfig +import de.devin.cbbees.content.bee.server.BeeType +import de.devin.cbbees.content.domain.job.BeeJob +import de.devin.cbbees.content.domain.job.JobCalculationProgress +import de.devin.cbbees.content.domain.task.BeeTask +import de.devin.cbbees.content.domain.task.TaskBatch +import de.devin.cbbees.util.ServerTickScheduler +import net.minecraft.core.BlockPos +import net.minecraft.server.MinecraftServer +import net.minecraft.core.Direction +import net.minecraft.world.item.ItemStack +import net.minecraft.world.level.Level +import net.minecraft.world.level.block.RotatedPillarBlock +import net.minecraft.world.level.block.entity.BlockEntity +import net.minecraft.world.level.block.state.BlockState +import de.devin.cbbees.util.ServerSide + +/** + * Handler that bridges Create's schematic system with our bee task system. + * + * This utility class is responsible for: + * - Loading Create's `.nbt` schematic files into a [SchematicPrinter]. + * - Iterating through the schematic's volume and identifying blocks that need to be placed. + * - Generating [BeeTask] objects that represent construction actions. + * - Identifying item requirements for each block using Create's [ItemRequirement] API. + * - Providing utilities for area deconstruction task generation. + */ +@ServerSide +class SchematicCreateBridge( + private val level: Level +) { + private val printer = SchematicPrinter() + private var isLoaded = false + private var schematicStack: ItemStack = ItemStack.EMPTY + private val handledPositions = mutableSetOf() + + /** + * Load a schematic from an ItemStack (Create's SchematicItem) + * @param blueprint The schematic item stack + * @return true if loaded successfully + */ + fun loadSchematic(blueprint: ItemStack): Boolean { + if (blueprint.isEmpty) { + CreateBuzzyBeez.LOGGER.warn("Cannot load empty schematic") + return false + } + + // Check if this is a valid schematic item + if (!blueprint.has(AllDataComponents.SCHEMATIC_FILE)) { + CreateBuzzyBeez.LOGGER.warn("ItemStack is not a valid schematic") + return false + } + + // Check if schematic is deployed (has anchor position) + if (blueprint.getOrDefault(AllDataComponents.SCHEMATIC_DEPLOYED, false) == false) { + CreateBuzzyBeez.LOGGER.warn("Schematic is not deployed - place it in the world first") + return false + } + + try { + printer.loadSchematic(blueprint, level, true) + + if (printer.isErrored) { + CreateBuzzyBeez.LOGGER.error("Failed to load schematic - printer reported error") + return false + } + + if (printer.isWorldEmpty) { + CreateBuzzyBeez.LOGGER.warn("Schematic is empty") + return false + } + + schematicStack = blueprint.copy() + isLoaded = true + CreateBuzzyBeez.LOGGER.info("Schematic loaded successfully") + return true + + } catch (e: Exception) { + CreateBuzzyBeez.LOGGER.error("Exception loading schematic: ${e.message}") + return false + } + } + + /** + * Generate all placement tasks from the loaded schematic. + * + * Uses a two-pass priority system mirroring Create's SchematiCannon: + * 1. **Normal blocks** (high priority): Solid support blocks placed bottom-up. + * 2. **Deferred blocks** (low priority): Brittle/dependent blocks placed after supports. + * + * Special handling: + * - Multi-block duplicates (upper door halves, bed heads) are skipped. + * - Belt placement mirrors SchematiCannon: middle segments are ignored, and the controller + * segment triggers a single belt-placement action that uses [BeltConnectorItem.createBelts] + * and reapplies casing/cover data from the schematic. + * + * @param job The job to assign the tasks to + * @return List of TaskBatches for building the schematic + */ + /** + * Generates build tasks synchronously (blocking). Used for small schematics. + */ + fun generateBuildTasks(job: BeeJob, onProgress: ((processed: Int, total: Int) -> Unit)? = null): List { + if (!isLoaded) { + CreateBuzzyBeez.LOGGER.warn("No schematic loaded") + return emptyList() + } + + handledPositions.clear() + val batches = mutableListOf() + var processed = 0 + + while (printer.isLoaded && !printer.isErrored && printer.advanceCurrentPos()) { + processed++ + processCurrentBlock(job, batches) + } + + return batches + } + + /** + * Generates build tasks spread across multiple server ticks. + * + * Processes [blocksPerTick] blocks per tick, advances [progress] between chunks, + * and invokes [onComplete] with the final batch list when done. This prevents + * the server from freezing on large schematics. + * + * @param job the parent job + * @param server the Minecraft server (for scheduling) + * @param blocksPerTick how many blocks to process per tick (default 500) + * @param progress optional tracker advanced once per chunk; the caller is + * responsible for calling [JobCalculationProgress.Tracker.start] before + * and [JobCalculationProgress.Tracker.complete]/[JobCalculationProgress.Tracker.fail] + * after based on [onComplete] result + * @param onComplete called when all blocks are processed + */ + fun generateBuildTasksAsync( + job: BeeJob, + server: MinecraftServer, + blocksPerTick: Int = 500, + progress: JobCalculationProgress.Tracker? = null, + onComplete: (List) -> Unit, + ) { + if (!isLoaded) { + CreateBuzzyBeez.LOGGER.warn("No schematic loaded") + onComplete(emptyList()) + return + } + + handledPositions.clear() + val batches = mutableListOf() + + fun processChunk() { + var blocksThisChunk = 0 + var hasMore = true + while (blocksThisChunk < blocksPerTick) { + if (!printer.isLoaded || printer.isErrored || !printer.advanceCurrentPos()) { + hasMore = false + break + } + blocksThisChunk++ + processCurrentBlock(job, batches) + } + + progress?.advance(blocksThisChunk) + + if (hasMore) { + ServerTickScheduler.nextTick { processChunk() } + } else { + onComplete(batches) + } + } + + ServerTickScheduler.nextTick { processChunk() } + } + + /** Processes the printer's current block position into a task batch. */ + private fun processCurrentBlock(job: BeeJob, batches: MutableList) { + if (!printer.shouldPlaceCurrent(level)) return + + val requirement = printer.currentRequirement + val items = getItemsFromRequirement(requirement) + + printer.handleCurrentTarget({ pos, state, blockEntity -> + if (state == null || state.isAir) return@handleCurrentTarget + if (handledPositions.contains(pos)) return@handleCurrentTarget + if (BlockPlacementClassifier.shouldSkipBlock(state)) return@handleCurrentTarget + + if (AllBlocks.BELT.has(state)) { + handleBeltPlacement(pos, state, blockEntity as? BeltBlockEntity, items, job, batches) + } else { + handleRegularBlock(pos, state, blockEntity, items, job, batches) + } + }, { _, _ -> + // TODO Add entity handling (armor stands, item frames, etc.) + }) + } + + /** + * Prepares block entity data for placement using Create's safe NBT processing. + */ + private fun prepareBlockEntityData(state: BlockState, blockEntity: BlockEntity?): net.minecraft.nbt.CompoundTag? { + if (blockEntity == null) return null + return BlockHelper.prepareBlockEntityData(blockEntity.level ?: level, state, blockEntity) + } + + /** + * Generate removal tasks for an area + * @param corner1 First corner of the area + * @param corner2 Second corner of the area + * @param job The job to assign the tasks to + * @return List of TaskBatches for removing blocks in the area + */ + fun generateRemovalTasks( + corner1: BlockPos, + corner2: BlockPos, + job: BeeJob, + onProgress: ((processed: Int, total: Int) -> Unit)? = null, + ): List { + val batches = mutableListOf() + val positions = buildRemovalPositions(corner1, corner2) + + positions.forEachIndexed { index, pos -> + onProgress?.let { if (index % 2_000 == 0) it(index, positions.size) } + processRemovalBlock(pos, corner1, corner2, job, batches) + } + + return batches + } + + /** + * Generates removal tasks spread across multiple server ticks. + * + * @param progress optional tracker advanced once per chunk; the caller starts + * and completes/fails it. + */ + fun generateRemovalTasksAsync( + corner1: BlockPos, + corner2: BlockPos, + job: BeeJob, + server: MinecraftServer, + blocksPerTick: Int = 1000, + progress: JobCalculationProgress.Tracker? = null, + onComplete: (List) -> Unit, + ) { + val batches = mutableListOf() + val positions = buildRemovalPositions(corner1, corner2) + val total = positions.size + var index = 0 + + fun processChunk() { + val chunkStart = index + val end = (index + blocksPerTick).coerceAtMost(total) + while (index < end) { + processRemovalBlock(positions[index], corner1, corner2, job, batches) + index++ + } + progress?.advance(index - chunkStart) + + if (index < total) { + ServerTickScheduler.nextTick { processChunk() } + } else { + onComplete(batches) + } + } + + ServerTickScheduler.nextTick { processChunk() } + } + + // ── Pickup (item collection) ── + + /** + * Scans the area between [corner1] and [corner2] for loose [ItemEntity] objects + * and creates pickup + drop-off task batches grouped by position. + */ + data class PickupResult(val batches: List, val totalItems: Int) + + fun generatePickupBatches(corner1: BlockPos, corner2: BlockPos, job: BeeJob): PickupResult { + val minPos = BlockPos(minOf(corner1.x, corner2.x), minOf(corner1.y, corner2.y), minOf(corner1.z, corner2.z)) + val maxPos = BlockPos(maxOf(corner1.x, corner2.x), maxOf(corner1.y, corner2.y), maxOf(corner1.z, corner2.z)) + val bounds = net.minecraft.world.phys.AABB( + minPos.x.toDouble(), minPos.y.toDouble(), minPos.z.toDouble(), + maxPos.x + 1.0, maxPos.y + 1.0, maxPos.z + 1.0, + ) + val items = level.getEntitiesOfClass(net.minecraft.world.entity.item.ItemEntity::class.java, bounds) { it.isAlive } + if (items.isEmpty()) return PickupResult(emptyList(), 0) + + val totalItems = items.sumOf { it.item.count } + val piles = items.groupBy { BlockPos.containing(it.position()) } + val batches = piles.map { (pos, _) -> + val pickupTask = BeeTask.pickup(pos = pos, job = job) + val dropOffTask = BeeTask.dropOff(fallbackPos = pos, job = job) + TaskBatch(listOf(pickupTask, dropOffTask), job, pos, beeType = BeeType.TRANSPORT) + } + return PickupResult(batches, totalItems) + } + + private fun buildRemovalPositions(corner1: BlockPos, corner2: BlockPos): List { + val minX = minOf(corner1.x, corner2.x); val maxX = maxOf(corner1.x, corner2.x) + val minY = minOf(corner1.y, corner2.y); val maxY = maxOf(corner1.y, corner2.y) + val minZ = minOf(corner1.z, corner2.z); val maxZ = maxOf(corner1.z, corner2.z) + + return buildList { + for (y in maxY downTo minY) + for (x in minX..maxX) + for (z in minZ..maxZ) + add(BlockPos(x, y, z)) + } + } + + private fun processRemovalBlock(pos: BlockPos, corner1: BlockPos, corner2: BlockPos, job: BeeJob, batches: MutableList) { + val maxY = maxOf(corner1.y, corner2.y) + val state = level.getBlockState(pos) + if (!state.isAir && state.getDestroySpeed(level, pos) >= 0) { + val priority = BlockPlacementClassifier.calculateRemovalPriority(pos, state, maxY) + val removeTask = BeeTask.remove(pos = pos, priority = priority, job = job) + val tasks = if (CBBeesConfig.beePickupItems.get()) { + listOf(removeTask, BeeTask.dropOff(fallbackPos = pos, priority = priority, job = job)) + } else { + listOf(removeTask) + } + val phase = BlockPlacementClassifier.removalPhase(state) + batches.add(TaskBatch(tasks, job, pos, phase)) + } + } + + /** + * Get the anchor position of the loaded schematic + */ + fun getAnchor(): BlockPos? { + return if (isLoaded) printer.anchor else null + } + + /** + * Convert Create's ItemRequirement to a list of ItemStacks. + * + * Create can return both a loose StackRequirement for the block item and a + * StrictNbtStackRequirement for the same item when the block/entity needs its + * saved components. CBBees stores requirements as ItemStacks only, so a naïve + * copy of both entries makes bees collect/consume the plain item plus the NBT + * item. Collapse those pairs by letting strict/component stacks satisfy the + * matching loose same-item requirement. + */ + private fun getItemsFromRequirement(requirement: ItemRequirement): List { + if (requirement.isInvalid) return emptyList() + + data class RequirementEntry( + val stack: ItemStack, + val strictComponents: Boolean + ) + + val entries = requirement.requiredItems + .mapNotNull { reqItem -> + val stack = reqItem.stack.copy() + if (stack.isEmpty) null + else RequirementEntry(stack, reqItem is ItemRequirement.StrictNbtStackRequirement) + } + + if (entries.isEmpty()) return emptyList() + + val result = mutableListOf() + val groupedByItem = entries.groupBy { it.stack.item } + + for (group in groupedByItem.values) { + val strictEntries = group.filter { it.strictComponents } + val looseEntries = group.filterNot { it.strictComponents } + + if (strictEntries.isEmpty()) { + looseEntries.forEach { mergeRequirementStack(result, it.stack) } + continue + } + + strictEntries.forEach { mergeRequirementStack(result, it.stack) } + + val strictCount = strictEntries.sumOf { it.stack.count } + val looseCount = looseEntries.sumOf { it.stack.count } + val looseRemainder = looseCount - strictCount + if (looseRemainder > 0) { + val looseStack = looseEntries.first().stack.copy() + looseStack.count = looseRemainder + mergeRequirementStack(result, looseStack) + } + } + + return result + } + + private fun mergeRequirementStack(result: MutableList, stack: ItemStack) { + val existing = result.firstOrNull { ItemStack.isSameItemSameComponents(it, stack) } + if (existing != null) { + existing.grow(stack.count) + } else { + result.add(stack.copy()) + } + } + + + private fun handleRegularBlock( + pos: BlockPos, + state: BlockState, + blockEntity: BlockEntity?, + items: List, + job: BeeJob, + batches: MutableList + ) { + val priority = BlockPlacementClassifier.calculatePriority(pos, state) + val tag = prepareBlockEntityData(state, blockEntity) + val buildTask = BeeTask.place( + pos = pos, + state = state, + items = items, + priority = priority, + tag = tag, + job = job + ) + + batches.add(TaskBatch(listOf(buildTask), job, buildTask.targetPos)) + } + + private fun handleBeltPlacement( + pos: BlockPos, + state: BlockState, + beltBE: BeltBlockEntity?, + items: List, + job: BeeJob, + batches: MutableList + ) { + if (beltBE == null || !beltBE.isController) return + + val beltWorld = beltBE.level ?: level + val controllerPos = beltBE.controller ?: pos + val chain = BeltBlock.getBeltChain(beltWorld, controllerPos) + if (chain.isEmpty()) return + + val beltAxis = state.getValue(BeltBlock.HORIZONTAL_FACING).axis + + val startDir = chain.getOrNull(1)?.let { it.subtract(chain.first()) } + val endDir = chain.getOrNull(chain.size - 1)?.let { last -> + chain.getOrNull(chain.size - 2)?.let { prev -> last.subtract(prev) } + } + + val startShaftPos = startDir?.let { chain.first() } + val endShaftPos = endDir?.let { chain.last() } + + val chainStates = chain.map { beltWorld.getBlockState(it) } + val casings = mutableListOf() + val covers = mutableListOf() + chain.forEach { chainPos -> + val segment = beltWorld.getBlockEntity(chainPos) as? BeltBlockEntity + casings.add(segment?.casing ?: BeltBlockEntity.CasingType.NONE) + covers.add(segment?.covered ?: false) + } + + val priority = BlockPlacementClassifier.calculatePriority(pos, state) + val startItems = mutableListOf() + val endItems = mutableListOf() + + val startShaftTask = startShaftPos?.let { shaftPos -> + createShaftTaskIfPresent(beltWorld, shaftPos, beltAxis, priority, job, startItems) + } + + val endShaftTask = endShaftPos?.let { shaftPos -> + createShaftTaskIfPresent(beltWorld, shaftPos, beltAxis, priority, job, endItems) + } + + // Create shaft tasks for middle pulley positions (shafts inside belts) + // BeltConnectorItem.createBelts checks for existing shafts at middle positions + // and converts them to PULLEY parts — we need to place those shafts first + val middleShaftTasks = mutableListOf() + for (i in 1 until chain.size - 1) { + val chainPos = chain[i] + val chainState = chainStates[i] + if (AllBlocks.BELT.has(chainState) + && chainState.hasProperty(BeltBlock.PART) + && chainState.getValue(BeltBlock.PART) == BeltPart.PULLEY + ) { + val pulleyItems = mutableListOf() + val shaftTask = createShaftTaskIfPresent(beltWorld, chainPos, beltAxis, priority, job, pulleyItems) + if (shaftTask != null) { + middleShaftTasks.add(shaftTask) + } + } + } + + val beltItem = ItemStack(AllItems.BELT_CONNECTOR.get(), 1) + + val buildTask = BeeTask.belt( + controllerPos = chain.first(), + endPos = chain.last(), + chain = chain, + chainStates = chainStates, + casings = casings, + covers = covers, + items = listOf(beltItem), + priority = priority, + job = job + ) + + val tasksInBatch = listOfNotNull(startShaftTask, endShaftTask) + middleShaftTasks + buildTask + + handledPositions.addAll(chain) + if (startShaftTask != null) startShaftPos.let(handledPositions::add) + if (endShaftTask != null) endShaftPos.let(handledPositions::add) + + batches.add(TaskBatch(tasksInBatch, job, buildTask.targetPos)) + } + + private fun createShaftTaskIfPresent( + beltWorld: Level, + shaftPos: BlockPos, + beltAxis: Direction.Axis, + priority: Int, + job: BeeJob, + itemCollector: MutableList + ): BeeTask? { + val existingState = beltWorld.getBlockState(shaftPos) + val baseShaftState = if (AllBlocks.SHAFT.has(existingState)) existingState else AllBlocks.SHAFT.defaultState + + val shaftAxis = when (beltAxis) { + Direction.Axis.X -> Direction.Axis.Z + Direction.Axis.Z -> Direction.Axis.X + else -> beltAxis + } + + val shaftState = baseShaftState.setValue(RotatedPillarBlock.AXIS, shaftAxis) + if (!AllBlocks.SHAFT.has(shaftState)) return null + + val shaftItem = ItemStack(shaftState.block) + if (!shaftItem.isEmpty) itemCollector.add(shaftItem) + + val shaftTag = prepareBlockEntityData(shaftState, beltWorld.getBlockEntity(shaftPos)) + return BeeTask.place( + pos = shaftPos, + state = shaftState, + items = itemCollector, + priority = priority, + tag = shaftTag, + job = job + ) + } + + companion object { + /** + * Check if an ItemStack is a valid Create schematic + */ + fun isValidSchematic(stack: ItemStack): Boolean { + return stack.has(AllDataComponents.SCHEMATIC_FILE) + } + + /** + * Check if a schematic is deployed (placed in world) + */ + fun isSchematicDeployed(stack: ItemStack): Boolean { + return stack.getOrDefault(AllDataComponents.SCHEMATIC_DEPLOYED, false) + } + + /** + * Get the schematic file name + */ + fun getSchematicName(stack: ItemStack): String? { + return stack.get(AllDataComponents.SCHEMATIC_FILE) + } + } +} diff --git a/src/main/java/de/devin/cbbees/compat/NeoForgeCapabilityCompat.java b/src/main/java/de/devin/cbbees/compat/NeoForgeCapabilityCompat.java new file mode 100644 index 0000000..1e3e8d6 --- /dev/null +++ b/src/main/java/de/devin/cbbees/compat/NeoForgeCapabilityCompat.java @@ -0,0 +1,49 @@ +package de.devin.cbbees.compat; + +import de.devin.cbbees.content.backpack.PortableBeehiveFluidHandler; +import de.devin.cbbees.content.deployer.SchematicDeployerItemHandler; +import de.devin.cbbees.items.AllItems; +import de.devin.cbbees.registry.AllBlockEntityTypes; +import net.minecraft.core.BlockPos; +import net.minecraft.world.level.Level; +import net.neoforged.neoforge.capabilities.Capabilities; +import net.neoforged.neoforge.capabilities.RegisterCapabilitiesEvent; +import net.neoforged.neoforge.items.IItemHandler; +import org.jetbrains.annotations.Nullable; + +/** + * Java interop bridge for NeoForge's nullable capability context type-use annotations. + * Kotlin 2.0 reports false-positive JAVA_TYPE_MISMATCH warnings for these APIs. + */ +/** Kotlin/Java nullability bridge for NeoForge capability APIs. */ +public final class NeoForgeCapabilityCompat { + private NeoForgeCapabilityCompat() {} + + public static void register(RegisterCapabilitiesEvent event) { + event.registerBlockEntity( + Capabilities.ItemHandler.BLOCK, + AllBlockEntityTypes.INSTANCE.getMECHANICAL_BEEHIVE().get(), + (be, side) -> be.getInventory() + ); + event.registerBlockEntity( + Capabilities.ItemHandler.BLOCK, + AllBlockEntityTypes.INSTANCE.getLOGISTICS_PORT().get(), + (be, side) -> be.getItemHandler(be.getWorld()) + ); + event.registerBlockEntity( + Capabilities.ItemHandler.BLOCK, + AllBlockEntityTypes.INSTANCE.getSCHEMATIC_DEPLOYER().get(), + (be, side) -> new SchematicDeployerItemHandler(be) + ); + event.registerItem( + Capabilities.FluidHandler.ITEM, + (stack, context) -> new PortableBeehiveFluidHandler(stack), + AllItems.INSTANCE.getPORTABLE_BEEHIVE().get() + ); + } + + @Nullable + public static IItemHandler getUnsidedItemHandler(Level level, BlockPos pos) { + return level.getCapability(Capabilities.ItemHandler.BLOCK, pos, null); + } +} diff --git a/src/main/java/de/devin/cbbees/compat/PlayerFlightCompat.java b/src/main/java/de/devin/cbbees/compat/PlayerFlightCompat.java new file mode 100644 index 0000000..a21add9 --- /dev/null +++ b/src/main/java/de/devin/cbbees/compat/PlayerFlightCompat.java @@ -0,0 +1,23 @@ +package de.devin.cbbees.compat; + +import net.minecraft.world.entity.player.Player; + +/** Compatibility access for clearing flight state saved by pre-1.3 CBBees versions. */ +public final class PlayerFlightCompat { + private PlayerFlightCompat() {} + + /** + * This deliberately accesses the deprecated raw field. The supported mayFly() API also + * includes NeoForge's creative-flight attribute, which must not be cleared by this migration. + */ + @SuppressWarnings("deprecation") + public static void clearLegacyFlight(Player player) { + if (!player.getAbilities().mayfly) { + return; + } + + player.getAbilities().mayfly = false; + player.getAbilities().flying = false; + player.onUpdateAbilities(); + } +} diff --git a/src/main/java/de/devin/cbbees/content/backpack/client/PortableBeehiveRenderProvider.java b/src/main/java/de/devin/cbbees/content/backpack/client/PortableBeehiveRenderProvider.java new file mode 100644 index 0000000..0ce8e6a --- /dev/null +++ b/src/main/java/de/devin/cbbees/content/backpack/client/PortableBeehiveRenderProvider.java @@ -0,0 +1,27 @@ +package de.devin.cbbees.content.backpack.client; + +import net.minecraft.client.model.HumanoidModel; +import net.minecraft.world.entity.EquipmentSlot; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.item.ItemStack; +import org.jetbrains.annotations.Nullable; +import software.bernie.geckolib.animatable.client.GeoRenderProvider; + +/** Provides the cached GeckoLib armor renderer for the portable beehive. */ +public final class PortableBeehiveRenderProvider implements GeoRenderProvider { + private PortableBeehiveRenderer renderer; + + @Override + public HumanoidModel getGeoArmorRenderer( + @Nullable T livingEntity, + ItemStack itemStack, + @Nullable EquipmentSlot equipmentSlot, + @Nullable HumanoidModel original + ) { + if (renderer == null) { + renderer = new PortableBeehiveRenderer(); + } + + return renderer; + } +} diff --git a/src/main/java/de/devin/cbbees/mixin/SchematicHandlerHudMixin.java b/src/main/java/de/devin/cbbees/mixin/SchematicHandlerHudMixin.java index 70247ea..fdcb805 100644 --- a/src/main/java/de/devin/cbbees/mixin/SchematicHandlerHudMixin.java +++ b/src/main/java/de/devin/cbbees/mixin/SchematicHandlerHudMixin.java @@ -116,9 +116,14 @@ public abstract class SchematicHandlerHudMixin { String owner = mainHand.get(AllDataComponents.SCHEMATIC_OWNER); if (schematicFile == null || owner == null) return; - BlockPos anchor = mainHand.getOrDefault(AllDataComponents.SCHEMATIC_ANCHOR, BlockPos.ZERO); - Rotation rotation = mainHand.getOrDefault(AllDataComponents.SCHEMATIC_ROTATION, Rotation.NONE); - Mirror mirror = mainHand.getOrDefault(AllDataComponents.SCHEMATIC_MIRROR, Mirror.NONE); + // Read the live preview transformation instead of the planner item components. + // Create only syncs those components after a delay, so programming immediately + // after moving/rotating/mirroring could store the previous placement. + var transform = CreateClient.SCHEMATIC_HANDLER.getTransformation(); + var settings = transform.toSettings(); + BlockPos anchor = transform.getAnchor(); + Rotation rotation = settings.getRotation(); + Mirror mirror = settings.getMirror(); SchematicProgram program = new SchematicProgram.Construction( schematicFile, anchor, rotation, mirror, owner diff --git a/src/main/kotlin/de/devin/cbbees/CreateBuzzyBeez.kt b/src/main/kotlin/de/devin/cbbees/CreateBuzzyBeez.kt index 96a7e4b..ed6f718 100644 --- a/src/main/kotlin/de/devin/cbbees/CreateBuzzyBeez.kt +++ b/src/main/kotlin/de/devin/cbbees/CreateBuzzyBeez.kt @@ -39,7 +39,6 @@ import de.devin.cbbees.registry.AllEntityTypes import de.devin.cbbees.registry.AllKeys import de.devin.cbbees.registry.AllMenuTypes import de.devin.cbbees.tabs.AllCreativeModeTabs -import net.neoforged.neoforge.capabilities.Capabilities import net.neoforged.neoforge.capabilities.RegisterCapabilitiesEvent import net.createmod.catnip.lang.FontHelper import net.createmod.ponder.foundation.PonderIndex @@ -153,26 +152,7 @@ object CreateBuzzyBeez { MOD_BUS.addListener { onCommonSetup(it) } MOD_BUS.addListener { event -> - event.registerBlockEntity( - Capabilities.ItemHandler.BLOCK, - AllBlockEntityTypes.MECHANICAL_BEEHIVE.get(), - { be, side -> be.inventory } - ) - event.registerBlockEntity( - Capabilities.ItemHandler.BLOCK, - AllBlockEntityTypes.LOGISTICS_PORT.get(), - { be, side -> be.getItemHandler(be.world) } - ) - event.registerBlockEntity( - Capabilities.ItemHandler.BLOCK, - AllBlockEntityTypes.SCHEMATIC_DEPLOYER.get(), - { be, _ -> de.devin.cbbees.content.deployer.SchematicDeployerItemHandler(be) } - ) - event.registerItem( - Capabilities.FluidHandler.ITEM, - { stack, _ -> de.devin.cbbees.content.backpack.PortableBeehiveFluidHandler(stack) }, - de.devin.cbbees.items.AllItems.PORTABLE_BEEHIVE.get() - ) + de.devin.cbbees.compat.NeoForgeCapabilityCompat.register(event) } NeoForge.EVENT_BUS.addListener { diff --git a/src/main/kotlin/de/devin/cbbees/compat/CuriosCompat.kt b/src/main/kotlin/de/devin/cbbees/compat/CuriosCompat.kt index db318ba..fab1663 100644 --- a/src/main/kotlin/de/devin/cbbees/compat/CuriosCompat.kt +++ b/src/main/kotlin/de/devin/cbbees/compat/CuriosCompat.kt @@ -29,8 +29,10 @@ object CuriosCompat { */ private object CuriosCompatImpl { fun findFirstCurio(player: Player, predicate: (ItemStack) -> Boolean): ItemStack { - val result = top.theillusivec4.curios.api.CuriosApi.getCuriosHelper() - .findFirstCurio(player, predicate) - return if (result.isPresent) result.get().stack() else ItemStack.EMPTY + val result = top.theillusivec4.curios.api.CuriosApi.getCuriosInventory(player) + .flatMap { handler -> + handler.findFirstCurio(java.util.function.Predicate { stack -> predicate(stack) }) + } + return result.map { it.stack() }.orElse(ItemStack.EMPTY) } } diff --git a/src/main/kotlin/de/devin/cbbees/compat/SableCompanionCompat.kt b/src/main/kotlin/de/devin/cbbees/compat/SableCompanionCompat.kt new file mode 100644 index 0000000..3b405d8 --- /dev/null +++ b/src/main/kotlin/de/devin/cbbees/compat/SableCompanionCompat.kt @@ -0,0 +1,181 @@ +package de.devin.cbbees.compat + +import com.mojang.blaze3d.vertex.PoseStack +import net.minecraft.core.BlockPos +import net.minecraft.core.Vec3i +import net.minecraft.world.level.Level +import net.minecraft.world.phys.AABB +import net.minecraft.world.phys.Vec3 +import org.joml.Matrix4d +import org.joml.Matrix4f +import org.joml.Vector3d +import org.joml.Vector3dc +import java.lang.reflect.Method + +/** + * Optional Sable Companion bridge. + * + * This file intentionally uses reflection instead of a compile-time dependency. + * When Sable/Sable Companion is not present, every method safely returns null. + */ +object SableCompanionCompat { + private val companionClass: Class<*>? by lazy { + try { + Class.forName("dev.ryanhcode.sable.companion.SableCompanion") + } catch (_: Throwable) { + null + } + } + + private val instance: Any? by lazy { + try { + companionClass?.getField("INSTANCE")?.get(null) + } catch (_: Throwable) { + null + } + } + + private val getContainingMethod: Method? by lazy { + try { + companionClass?.methods?.firstOrNull { method -> + method.name == "getContaining" && + method.parameterTypes.size == 2 && + Level::class.java.isAssignableFrom(method.parameterTypes[0]) && + Vec3i::class.java.isAssignableFrom(method.parameterTypes[1]) + } + } catch (_: Throwable) { + null + } + } + + private fun getSubLevel(level: Level, pos: BlockPos): Any? { + val inst = instance ?: return null + val method = getContainingMethod ?: return null + return try { + method.invoke(inst, level, pos) + } catch (_: Throwable) { + null + } + } + + private fun getRenderPose(subLevel: Any, partialTicks: Float): Any? { + return try { + subLevel.javaClass.methods.firstOrNull { method -> + method.name == "renderPose" && method.parameterTypes.size == 1 && + method.parameterTypes[0] == java.lang.Float.TYPE + }?.invoke(subLevel, partialTicks) + ?: subLevel.javaClass.methods.firstOrNull { method -> + method.name == "renderPose" && method.parameterTypes.isEmpty() + }?.invoke(subLevel) + ?: subLevel.javaClass.methods.firstOrNull { method -> + method.name == "logicalPose" && method.parameterTypes.isEmpty() + }?.invoke(subLevel) + } catch (_: Throwable) { + null + } + } + + private fun bakePoseMatrix(pose: Any): Matrix4f? { + return try { + val matrix = Matrix4d() + val method = pose.javaClass.methods.firstOrNull { m -> + m.name == "bakeIntoMatrix" && m.parameterTypes.size == 1 && + m.parameterTypes[0].name == "org.joml.Matrix4d" + } ?: return null + val result = method.invoke(pose, matrix) as? Matrix4d ?: matrix + Matrix4f(result) + } catch (_: Throwable) { + null + } + } + + fun getRenderMatrix(level: Level, pos: BlockPos, partialTicks: Float): Matrix4f? { + val subLevel = getSubLevel(level, pos) ?: return null + val pose = getRenderPose(subLevel, partialTicks) ?: return null + return bakePoseMatrix(pose) + } + + fun applyRenderTransform(poseStack: PoseStack, level: Level, pos: BlockPos, partialTicks: Float): Boolean { + val matrix = getRenderMatrix(level, pos, partialTicks) ?: return false + poseStack.mulPose(matrix) + return true + } + + fun projectPosition(level: Level, pos: Vec3, partialTicks: Float): Vec3? { + return projectPosition(level, pos, BlockPos.containing(pos), partialTicks) + } + + /** + * Projects [pos] through the Sable render pose found from [samplePos]. + * + * This is important for fake/client-only bee rendering: the bee flies through + * air, so asking Sable what contains the bee position can flicker between a + * valid sub-level and null. The construction frame/ghost is block based and + * already has a stable sample position, so bee render can reuse that context. + */ + fun projectPosition(level: Level, pos: Vec3, samplePos: BlockPos, partialTicks: Float): Vec3? { + val subLevel = getSubLevel(level, samplePos) ?: return null + val pose = getRenderPose(subLevel, partialTicks) ?: return null + return try { + val src = Vector3d(pos.x, pos.y, pos.z) + val dest = Vector3d() + val method = pose.javaClass.methods.firstOrNull { m -> + m.name == "transformPosition" && m.parameterTypes.size == 2 && + Vector3dc::class.java.isAssignableFrom(m.parameterTypes[0]) && + Vector3d::class.java.isAssignableFrom(m.parameterTypes[1]) + } ?: return null + method.invoke(pose, src, dest) + Vec3(dest.x, dest.y, dest.z) + } catch (_: Throwable) { + null + } + } + + fun projectAabb(level: Level, bounds: AABB, partialTicks: Float): AABB? { + val samplePos = BlockPos.containing( + (bounds.minX + bounds.maxX) * 0.5, + (bounds.minY + bounds.maxY) * 0.5, + (bounds.minZ + bounds.maxZ) * 0.5 + ) + return projectAabb(level, bounds, samplePos, partialTicks) + } + + fun projectAabb(level: Level, bounds: AABB, samplePos: BlockPos, partialTicks: Float): AABB? { + val subLevel = getSubLevel(level, samplePos) ?: return null + val pose = getRenderPose(subLevel, partialTicks) ?: return null + + try { + val method = pose.javaClass.methods.firstOrNull { m -> + m.name == "transformPosition" && m.parameterTypes.size == 2 && + Vector3dc::class.java.isAssignableFrom(m.parameterTypes[0]) && + Vector3d::class.java.isAssignableFrom(m.parameterTypes[1]) + } ?: return null + + var minX = Double.POSITIVE_INFINITY + var minY = Double.POSITIVE_INFINITY + var minZ = Double.POSITIVE_INFINITY + var maxX = Double.NEGATIVE_INFINITY + var maxY = Double.NEGATIVE_INFINITY + var maxZ = Double.NEGATIVE_INFINITY + + for (x in listOf(bounds.minX, bounds.maxX)) { + for (y in listOf(bounds.minY, bounds.maxY)) { + for (z in listOf(bounds.minZ, bounds.maxZ)) { + val dest = Vector3d() + method.invoke(pose, Vector3d(x, y, z), dest) + minX = minOf(minX, dest.x) + minY = minOf(minY, dest.y) + minZ = minOf(minZ, dest.z) + maxX = maxOf(maxX, dest.x) + maxY = maxOf(maxY, dest.y) + maxZ = maxOf(maxZ, dest.z) + } + } + } + + return AABB(minX, minY, minZ, maxX, maxY, maxZ) + } catch (_: Throwable) { + return null + } + } +} diff --git a/src/main/kotlin/de/devin/cbbees/content/backpack/PortableBeehiveFluidHandler.kt b/src/main/kotlin/de/devin/cbbees/content/backpack/PortableBeehiveFluidHandler.kt index f48a473..0126892 100644 --- a/src/main/kotlin/de/devin/cbbees/content/backpack/PortableBeehiveFluidHandler.kt +++ b/src/main/kotlin/de/devin/cbbees/content/backpack/PortableBeehiveFluidHandler.kt @@ -48,7 +48,7 @@ class PortableBeehiveFluidHandler(private var stack: ItemStack) : IFluidHandlerI } private fun isHoney(fluid: FluidStack): Boolean { - return fluid.fluid.`is`(Tags.Fluids.HONEY) + return fluid.`is`(Tags.Fluids.HONEY) } override fun getTanks(): Int = 1 diff --git a/src/main/kotlin/de/devin/cbbees/content/backpack/PortableBeehiveItem.kt b/src/main/kotlin/de/devin/cbbees/content/backpack/PortableBeehiveItem.kt index ca1d32e..b981a38 100644 --- a/src/main/kotlin/de/devin/cbbees/content/backpack/PortableBeehiveItem.kt +++ b/src/main/kotlin/de/devin/cbbees/content/backpack/PortableBeehiveItem.kt @@ -2,6 +2,7 @@ package de.devin.cbbees.content.backpack import de.devin.cbbees.content.bee.MechanicalBeeItem import de.devin.cbbees.content.bee.MechanicalBumbleBeeItem +import de.devin.cbbees.content.backpack.client.PortableBeehiveRenderProvider import de.devin.cbbees.content.upgrades.BeeUpgradeItem import de.devin.cbbees.content.upgrades.BeeContext import de.devin.cbbees.content.upgrades.UpgradeGrid @@ -31,14 +32,13 @@ import java.util.Optional import net.minecraft.world.item.ArmorItem import net.minecraft.world.item.ArmorMaterials import software.bernie.geckolib.animatable.GeoItem +import software.bernie.geckolib.animatable.client.GeoRenderProvider import software.bernie.geckolib.animatable.instance.AnimatableInstanceCache import software.bernie.geckolib.animation.AnimatableManager import software.bernie.geckolib.animation.AnimationController import software.bernie.geckolib.animation.RawAnimation import software.bernie.geckolib.util.GeckoLibUtil import java.util.function.Consumer -import net.neoforged.neoforge.client.extensions.common.IClientItemExtensions -import de.devin.cbbees.content.backpack.client.PortableBeehiveRenderer import kotlin.math.roundToInt /** @@ -61,24 +61,8 @@ class PortableBeehiveItem(properties: Properties) : ArmorItem(ArmorMaterials.IRO private val cache: AnimatableInstanceCache = GeckoLibUtil.createInstanceCache(this) - override fun initializeClient(consumer: Consumer) { - consumer.accept(object : IClientItemExtensions { - private var renderer: PortableBeehiveRenderer? = null - - override fun getHumanoidArmorModel( - livingEntity: net.minecraft.world.entity.LivingEntity, - itemStack: ItemStack, - armorSlot: net.minecraft.world.entity.EquipmentSlot, - original: net.minecraft.client.model.HumanoidModel<*> - ): net.minecraft.client.model.HumanoidModel<*> { - if (this.renderer == null) { - this.renderer = PortableBeehiveRenderer() - } - this.renderer!!.updateRenderState(itemStack, livingEntity) - this.renderer!!.prepForRender(livingEntity, itemStack, armorSlot, original) - return this.renderer!! - } - }) + override fun createGeoRenderer(consumer: Consumer) { + consumer.accept(PortableBeehiveRenderProvider()) } override fun registerControllers(controllers: AnimatableManager.ControllerRegistrar) { diff --git a/src/main/kotlin/de/devin/cbbees/content/backpack/client/PortableBeehiveRenderer.kt b/src/main/kotlin/de/devin/cbbees/content/backpack/client/PortableBeehiveRenderer.kt index 86e646d..d43e426 100644 --- a/src/main/kotlin/de/devin/cbbees/content/backpack/client/PortableBeehiveRenderer.kt +++ b/src/main/kotlin/de/devin/cbbees/content/backpack/client/PortableBeehiveRenderer.kt @@ -1,13 +1,6 @@ package de.devin.cbbees.content.backpack.client import de.devin.cbbees.content.backpack.PortableBeehiveItem -import net.minecraft.world.entity.LivingEntity -import net.minecraft.world.item.ItemStack import software.bernie.geckolib.renderer.GeoArmorRenderer -class PortableBeehiveRenderer : GeoArmorRenderer(PortableBeehiveModel()) { - - fun updateRenderState(stack: ItemStack, entity: LivingEntity) { - // No-op — model selection no longer depends on stack data - } -} +class PortableBeehiveRenderer : GeoArmorRenderer(PortableBeehiveModel()) diff --git a/src/main/kotlin/de/devin/cbbees/content/bee/MaterialSource.kt b/src/main/kotlin/de/devin/cbbees/content/bee/MaterialSource.kt index e24fa91..0230ab1 100644 --- a/src/main/kotlin/de/devin/cbbees/content/bee/MaterialSource.kt +++ b/src/main/kotlin/de/devin/cbbees/content/bee/MaterialSource.kt @@ -4,7 +4,6 @@ import net.minecraft.world.item.ItemStack import net.minecraft.world.entity.player.Player import net.minecraft.core.BlockPos import net.minecraft.world.level.Level -import net.neoforged.neoforge.capabilities.Capabilities import net.neoforged.neoforge.items.ItemHandlerHelper /** @@ -34,7 +33,7 @@ class PlayerMaterialSource(private val player: Player) : MaterialSource { override fun extractItems(required: ItemStack, amount: Int): ItemStack { for (i in 0 until player.inventory.containerSize) { val stack = player.inventory.getItem(i) - if (ItemStack.isSameItem(stack, required)) { + if (ItemStack.isSameItemSameComponents(stack, required)) { return player.inventory.removeItem(i, amount) } } @@ -56,11 +55,11 @@ class PlayerMaterialSource(private val player: Player) : MaterialSource { class WirelessMaterialSource(private val level: Level, private val positions: List) : MaterialSource { override fun extractItems(required: ItemStack, amount: Int): ItemStack { for (pos in positions) { - val handler = level.getCapability(Capabilities.ItemHandler.BLOCK, pos, null) + val handler = de.devin.cbbees.compat.NeoForgeCapabilityCompat.getUnsidedItemHandler(level, pos) if (handler != null) { for (slot in 0 until handler.slots) { val stack = handler.getStackInSlot(slot) - if (ItemStack.isSameItem(stack, required)) { + if (ItemStack.isSameItemSameComponents(stack, required)) { return handler.extractItem(slot, amount, false) } } @@ -72,7 +71,7 @@ class WirelessMaterialSource(private val level: Level, private val positions: Li override fun insertItems(stack: ItemStack): ItemStack { var current = stack for (pos in positions) { - val handler = level.getCapability(Capabilities.ItemHandler.BLOCK, pos, null) + val handler = de.devin.cbbees.compat.NeoForgeCapabilityCompat.getUnsidedItemHandler(level, pos) if (handler != null) { current = ItemHandlerHelper.insertItem(handler, current, false) if (current.isEmpty) return ItemStack.EMPTY @@ -179,7 +178,7 @@ class AdjacentInventoryCache( private fun scanForInventories(): List { val positions = AdjacentPositions.getAdjacentPositions(center, range) return positions.filter { pos -> - level.getCapability(Capabilities.ItemHandler.BLOCK, pos, null) != null + de.devin.cbbees.compat.NeoForgeCapabilityCompat.getUnsidedItemHandler(level, pos) != null } } diff --git a/src/main/kotlin/de/devin/cbbees/content/bee/client/BeeWorldRenderer.kt b/src/main/kotlin/de/devin/cbbees/content/bee/client/BeeWorldRenderer.kt index d3d0911..e0e59af 100644 --- a/src/main/kotlin/de/devin/cbbees/content/bee/client/BeeWorldRenderer.kt +++ b/src/main/kotlin/de/devin/cbbees/content/bee/client/BeeWorldRenderer.kt @@ -5,6 +5,7 @@ import com.mojang.blaze3d.vertex.VertexConsumer import com.mojang.math.Axis import de.devin.cbbees.CreateBuzzyBeez import de.devin.cbbees.content.bee.server.BeeType +import de.devin.cbbees.content.schematics.client.ConstructionRenderer import de.devin.cbbees.util.ClientSide import net.minecraft.client.Minecraft import net.minecraft.client.renderer.MultiBufferSource @@ -63,7 +64,13 @@ object BeeWorldRenderer { val time = System.nanoTime() / 1_000_000_000.0f flightBees.forEach { bee -> - val pos = bee.lerpPos(partialTick) + val rawPos = bee.lerpPos(partialTick) + val pos = if (bee.type == BeeType.CONSTRUCTION) { + ConstructionRenderer.getBeeRenderPosition(bee.id, rawPos, level, partialTick) ?: rawPos + } else { + rawPos + } + val dx = pos.x - camPos.x val dy = pos.y - camPos.y val dz = pos.z - camPos.z diff --git a/src/main/kotlin/de/devin/cbbees/content/bee/flight/ClientBeeFlightData.kt b/src/main/kotlin/de/devin/cbbees/content/bee/flight/ClientBeeFlightData.kt index 26d98a7..bbdfb7d 100644 --- a/src/main/kotlin/de/devin/cbbees/content/bee/flight/ClientBeeFlightData.kt +++ b/src/main/kotlin/de/devin/cbbees/content/bee/flight/ClientBeeFlightData.kt @@ -155,6 +155,32 @@ class ClientBeeFlightData( fun yRot(): Float = currentYRot + /** + * Stable block positions the renderer may use to resolve optional Sable + * render context. Do not sample the bee's interpolated air position for + * this; Sable's getContaining(...) can return null for air/not-yet-expanded + * cells and cause transformed/raw render flicker. + * + * Current segment endpoints are tried first, then the rest of the plan as + * fallback anchors. + */ + fun renderSampleCandidates(): List { + if (checkpoints.isEmpty()) return emptyList() + + val (segIndex, _, _) = findSegment(elapsedNano()) + val result = mutableListOf() + + fun add(pos: BlockPos?) { + if (pos != null && pos !in result) result.add(pos) + } + + add(checkpoints.getOrNull(segIndex)?.pos) + add(checkpoints.getOrNull(segIndex + 1)?.pos) + checkpoints.forEach { add(it.pos) } + + return result + } + // ── Position computation ── private fun computePositionAtNano(nano: Long): Vec3 { diff --git a/src/main/kotlin/de/devin/cbbees/content/bee/state/ConstructionBeeStateMachine.kt b/src/main/kotlin/de/devin/cbbees/content/bee/state/ConstructionBeeStateMachine.kt index 8d18889..0951e64 100644 --- a/src/main/kotlin/de/devin/cbbees/content/bee/state/ConstructionBeeStateMachine.kt +++ b/src/main/kotlin/de/devin/cbbees/content/bee/state/ConstructionBeeStateMachine.kt @@ -269,8 +269,10 @@ object ConstructionBeeStateMachine { } else { bee.hiveEntryRetries++ if (bee.hiveEntryRetries >= 3) { + // Do not dump carried construction materials as loose ItemEntity objects. + // Some modded stacks (for example loaded shells) can arm/explode when dropped. + // Drop only the bee item; keep construction materials from being spawned as entities. level.addFreshEntity(ItemEntity(level, bee.pos.x, bee.pos.y, bee.pos.z, beeItem)) - bee.dropInventory() hive.onBeeRemovedById(bee.id) ServerBeeManager.removeBee(bee.id) } @@ -300,18 +302,21 @@ object ConstructionBeeStateMachine { if (excess.isEmpty()) { bee.constructionState = ConstructionBeeState.FLYING_HOME; bee.walkTarget = bee.hivePos; return } val port = bee.network()?.findDropOff(excess.first(), bee.hiveId) if (port == null) { - excess.forEach { item -> - bee.removeFromInventory(item, item.count) - level.addFreshEntity(ItemEntity(level, bee.pos.x, bee.pos.y, bee.pos.z, item.copy())) - } + // Safety: never spawn carried construction materials as loose items. + // If there is no valid INSERT/drop-off port, keep the items inside the bee + // and try again later after the network/configuration is fixed. bee.constructionState = ConstructionBeeState.FLYING_HOME; bee.walkTarget = bee.hivePos return } if (bee.blockPosition().closerThan(port.pos, 2.5)) { excess.forEach { item -> val remainder = port.addItemStack(item.copy()) - if (!remainder.isEmpty) level.addFreshEntity(ItemEntity(level, port.pos.x + 0.5, port.pos.y + 0.5, port.pos.z + 0.5, remainder)) - bee.removeFromInventory(item, item.count) + val remainingCount = if (remainder.isEmpty) 0 else remainder.count + val deposited = item.count - remainingCount + if (deposited > 0) { + bee.removeFromInventory(item.copyWithCount(deposited), deposited) + } + // If the port is full, keep the remainder inside the bee instead of dropping it. } bee.constructionState = ConstructionBeeState.FLYING_HOME; bee.walkTarget = bee.hivePos } else { @@ -776,20 +781,9 @@ object ConstructionBeeStateMachine { val dropOffPort = network?.findDropOff(excess.first(), bee.homeId) if (dropOffPort == null) { - val owner = bee.getOwnerPlayer() - if (owner != null) { - for (item in excess) { - bee.removeFromInventory(item, item.count) - if (!owner.inventory.add(item.copy())) { - level.addFreshEntity(ItemEntity(level, owner.x, owner.y, owner.z, item.copy())) - } - } - } else { - for (item in excess) { - bee.removeFromInventory(item, item.count) - level.addFreshEntity(ItemEntity(level, bee.x, bee.y, bee.z, item.copy())) - } - } + // No loose ItemEntity fallback for construction materials. Some modded stacks + // are unsafe when dropped as items. Keep the items inside the bee until a + // valid drop-off path exists or the duplicate requirement bug is fixed. transitionToHome(bee) return } @@ -797,10 +791,12 @@ object ConstructionBeeStateMachine { if (bee.blockPosition().closerThan(dropOffPort.pos, bee.workRange)) { for (item in excess) { val remainder = dropOffPort.addItemStack(item.copy()) - if (!remainder.isEmpty) { - level.addFreshEntity(ItemEntity(level, dropOffPort.pos.x + 0.5, dropOffPort.pos.y + 0.5, dropOffPort.pos.z + 0.5, remainder)) + val remainingCount = if (remainder.isEmpty) 0 else remainder.count + val deposited = item.count - remainingCount + if (deposited > 0) { + bee.removeFromInventory(item.copyWithCount(deposited), deposited) } - bee.removeFromInventory(item, item.count) + // If the port is full, keep the remainder inside the bee instead of dropping it. } transitionToHome(bee) } else { @@ -886,9 +882,13 @@ object ConstructionBeeStateMachine { private fun playerHasItem(player: net.minecraft.world.entity.player.Player, stack: ItemStack): Boolean { if (player.isCreative) return true + var found = 0 for (i in 0 until player.inventory.containerSize) { val slot = player.inventory.getItem(i) - if (!slot.isEmpty && ItemStack.isSameItemSameComponents(slot, stack)) return true + if (!slot.isEmpty && ItemStack.isSameItemSameComponents(slot, stack)) { + found += slot.count + if (found >= stack.count) return true + } } return false } diff --git a/src/main/kotlin/de/devin/cbbees/content/deployer/SchematicDeployerBlockEntity.kt b/src/main/kotlin/de/devin/cbbees/content/deployer/SchematicDeployerBlockEntity.kt index 2d39d7d..b2d9307 100644 --- a/src/main/kotlin/de/devin/cbbees/content/deployer/SchematicDeployerBlockEntity.kt +++ b/src/main/kotlin/de/devin/cbbees/content/deployer/SchematicDeployerBlockEntity.kt @@ -20,6 +20,7 @@ import net.minecraft.core.HolderLookup import net.minecraft.core.particles.ParticleTypes import net.minecraft.nbt.CompoundTag import net.minecraft.server.level.ServerLevel +import net.minecraft.server.level.ServerPlayer import net.minecraft.sounds.SoundEvents import net.minecraft.sounds.SoundSource import net.minecraft.world.item.ItemStack @@ -316,18 +317,29 @@ class SchematicDeployerBlockEntity( } /** - * Sends the newly deployed job to nearby clients via [HiveJobsSyncPacket] + * Sends the newly deployed job to clients via [HiveJobsSyncPacket] * so [ConstructionRenderer] can render ghost blocks immediately. + * + * Sable/physics worlds can expose block entities through a level wrapper that is + * not the exact same ServerLevel instance as the player's normal world. The old + * `player.level() == level` check could therefore prevent the client from ever + * receiving the active deployer job, causing the unfinished-job frame/ghost to + * disappear only when the deployer was on Sable. */ private fun syncJobToClients(job: BeeJob, batches: List) { - val level = level as? ServerLevel ?: return + val serverLevel = level as? ServerLevel ?: return val clientBatches = batches.map { b -> + val ghostBlocks = b.tasks.mapNotNull { task -> + val action = task.action + if (action is PlaceBlockAction) action.pos to action.blockState else null + }.toMap() + ClientBatchInfo( status = b.status.name, target = b.targetPosition, required = emptyList(), assignedBeeIds = emptyList(), - ghostBlocks = emptyMap() + ghostBlocks = ghostBlocks ) } val clientJob = ClientJobInfo( @@ -338,15 +350,11 @@ class SchematicDeployerBlockEntity( total = job.tasks.size, reason = null, batches = clientBatches, - schematicPlacement = job.schematicPlacement + schematicPlacement = job.schematicPlacement, + jobType = job.jobType ) val snapshot = HiveSnapshot(ClientNetworkInfo("Deployer", 0, 0, 0), listOf(clientJob)) - val packet = HiveJobsSyncPacket(blockPos, snapshot) - for (player in level.server.playerList.players) { - if (player.level() == level && player.blockPosition().closerThan(blockPos, 128.0)) { - PacketDistributor.sendToPlayer(player, packet) - } - } + sendDeployerSnapshotToClients(serverLevel, snapshot) } /** @@ -354,14 +362,43 @@ class SchematicDeployerBlockEntity( * from [ClientJobCache] when the job completes. */ private fun syncJobClearedToClients() { - val level = level as? ServerLevel ?: return + val serverLevel = level as? ServerLevel ?: return val snapshot = HiveSnapshot(ClientNetworkInfo("Deployer", 0, 0, 0), emptyList()) + sendDeployerSnapshotToClients(serverLevel, snapshot) + } + + private fun sendDeployerSnapshotToClients(serverLevel: ServerLevel, snapshot: HiveSnapshot) { val packet = HiveJobsSyncPacket(blockPos, snapshot) - for (player in level.server.playerList.players) { - if (player.level() == level && player.blockPosition().closerThan(blockPos, 128.0)) { - PacketDistributor.sendToPlayer(player, packet) - } + val players = serverLevel.server.playerList.players + + val nearbyPlayers = players.filter { player -> + shouldReceiveDeployerSnapshot(player, serverLevel) + } + + // If Sable/shipyard coordinates prevent a normal distance match, still send + // this small snapshot to all connected clients so the owner can render/clear + // the active deployer job. This only runs on deploy and on job clear. + val targets = nearbyPlayers.ifEmpty { players } + + for (player in targets) { + PacketDistributor.sendToPlayer(player, packet) + } + } + + private fun shouldReceiveDeployerSnapshot(player: ServerPlayer, serverLevel: ServerLevel): Boolean { + if (player.level() == serverLevel && player.blockPosition().closerThan(blockPos, 128.0)) { + return true } + + // Some Sable/physics wrappers keep the same dimension key but do not compare + // equal as level instances. Accept same-dimension nearby players too. + if (player.level().dimension() == serverLevel.dimension() && player.blockPosition().closerThan(blockPos, 128.0)) { + return true + } + + // Final coordinate-space fallback for wrapper levels that report a different + // dimension object but still use visible world-space coordinates. + return player.blockPosition().closerThan(blockPos, 128.0) } override fun write(tag: CompoundTag, registries: HolderLookup.Provider, clientPacket: Boolean) { diff --git a/src/main/kotlin/de/devin/cbbees/content/deployer/client/SchematicDeployerScreen.kt b/src/main/kotlin/de/devin/cbbees/content/deployer/client/SchematicDeployerScreen.kt index b433708..9822e76 100644 --- a/src/main/kotlin/de/devin/cbbees/content/deployer/client/SchematicDeployerScreen.kt +++ b/src/main/kotlin/de/devin/cbbees/content/deployer/client/SchematicDeployerScreen.kt @@ -280,11 +280,21 @@ class SchematicDeployerScreen( } private fun confirm() { + val offset = BlockPos(offsetX, offsetY, offsetZ) + + // Optimistically update the client-side BE so reopening the GUI in Sable + // immediately shows the selected mode even if the server BE lives in a + // shipyard/sub-level coordinate space and vanilla chunk sync is delayed. + be.deployMode = currentMode + be.relativeOffset = offset + be.relativeRotation = currentRotation + be.relativeMirror = currentMirror + CatnipServices.NETWORK.sendToServer( DeployerSettingsPacket( be.blockPos, currentMode, - BlockPos(offsetX, offsetY, offsetZ), + offset, currentRotation, currentMirror ) diff --git a/src/main/kotlin/de/devin/cbbees/content/domain/action/impl/DropOffItemsAction.kt b/src/main/kotlin/de/devin/cbbees/content/domain/action/impl/DropOffItemsAction.kt index 43818c2..0aa143f 100644 --- a/src/main/kotlin/de/devin/cbbees/content/domain/action/impl/DropOffItemsAction.kt +++ b/src/main/kotlin/de/devin/cbbees/content/domain/action/impl/DropOffItemsAction.kt @@ -5,7 +5,6 @@ import de.devin.cbbees.content.domain.action.BeeAction import de.devin.cbbees.content.upgrades.BeeContext import net.minecraft.core.BlockPos import net.minecraft.nbt.CompoundTag -import net.minecraft.world.entity.item.ItemEntity import net.minecraft.world.item.ItemStack import net.minecraft.world.level.Level @@ -34,14 +33,14 @@ class DropOffItemsAction(initialPos: BlockPos) : BeeAction { val port = worker.network()?.findDropOff(item, worker.hiveId) if (port != null) { val remainder = port.addItemStack(item.copy()) - worker.removeFromInventory(item, item.count) - if (!remainder.isEmpty) { - level.addFreshEntity(ItemEntity(level, port.pos.x + 0.5, port.pos.y + 0.5, port.pos.z + 0.5, remainder)) + val remainingCount = if (remainder.isEmpty) 0 else remainder.count + val deposited = item.count - remainingCount + if (deposited > 0) { + worker.removeFromInventory(item.copyWithCount(deposited), deposited) } - } else { - worker.removeFromInventory(item, item.count) - level.addFreshEntity(ItemEntity(level, worker.getWorkerX(), worker.getWorkerY(), worker.getWorkerZ(), item.copy())) + // If the port is full, keep the remainder inside the bee instead of dropping it. } + // No loose ItemEntity fallback. Some modded construction items are unsafe when dropped. } return true } diff --git a/src/main/kotlin/de/devin/cbbees/content/domain/beehive/PortableBeeHive.kt b/src/main/kotlin/de/devin/cbbees/content/domain/beehive/PortableBeeHive.kt index cf39aa2..1c001b3 100644 --- a/src/main/kotlin/de/devin/cbbees/content/domain/beehive/PortableBeeHive.kt +++ b/src/main/kotlin/de/devin/cbbees/content/domain/beehive/PortableBeeHive.kt @@ -257,7 +257,7 @@ class PortableBeeHive(val player: Player) : BeeHive, LogisticsPort { if (player.isCreative) return true for (i in 0 until player.inventory.containerSize) { val slot = player.inventory.getItem(i) - if (!slot.isEmpty && ItemStack.isSameItem(slot, stack) && slot.count >= stack.count) { + if (!slot.isEmpty && ItemStack.isSameItemSameComponents(slot, stack) && slot.count >= stack.count) { return true } } @@ -278,7 +278,7 @@ class PortableBeeHive(val player: Player) : BeeHive, LogisticsPort { var count = 0 for (i in 0 until player.inventory.containerSize) { val slot = player.inventory.getItem(i) - if (!slot.isEmpty && ItemStack.isSameItem(slot, stack)) { + if (!slot.isEmpty && ItemStack.isSameItemSameComponents(slot, stack)) { count += slot.count } } @@ -296,7 +296,7 @@ class PortableBeeHive(val player: Player) : BeeHive, LogisticsPort { ?: continue for (slot in 0 until handler.slots) { val slotStack = handler.getStackInSlot(slot) - if (!slotStack.isEmpty && ItemStack.isSameItem(slotStack, stack)) { + if (!slotStack.isEmpty && ItemStack.isSameItemSameComponents(slotStack, stack)) { count += slotStack.count } } @@ -325,7 +325,7 @@ class PortableBeeHive(val player: Player) : BeeHive, LogisticsPort { var remaining = stack.count for (i in 0 until player.inventory.containerSize) { val slot = player.inventory.getItem(i) - if (!slot.isEmpty && ItemStack.isSameItem(slot, stack)) { + if (!slot.isEmpty && ItemStack.isSameItemSameComponents(slot, stack)) { val take = minOf(remaining, slot.count) slot.shrink(take) if (slot.isEmpty) player.inventory.setItem(i, ItemStack.EMPTY) @@ -347,7 +347,7 @@ class PortableBeeHive(val player: Player) : BeeHive, LogisticsPort { ?: continue for (slot in 0 until handler.slots) { val slotStack = handler.getStackInSlot(slot) - if (!slotStack.isEmpty && ItemStack.isSameItem(slotStack, stack) && slotStack.count >= stack.count) { + if (!slotStack.isEmpty && ItemStack.isSameItemSameComponents(slotStack, stack) && slotStack.count >= stack.count) { return true } } @@ -366,7 +366,7 @@ class PortableBeeHive(val player: Player) : BeeHive, LogisticsPort { for (slot in 0 until handler.slots) { if (remaining <= 0) break val slotStack = handler.getStackInSlot(slot) - if (!slotStack.isEmpty && ItemStack.isSameItem(slotStack, stack)) { + if (!slotStack.isEmpty && ItemStack.isSameItemSameComponents(slotStack, stack)) { val extracted = handler.extractItem(slot, remaining, false) remaining -= extracted.count } diff --git a/src/main/kotlin/de/devin/cbbees/content/domain/events/PlayerTickEvent.kt b/src/main/kotlin/de/devin/cbbees/content/domain/events/PlayerTickEvent.kt index d10c27e..27f67ca 100644 --- a/src/main/kotlin/de/devin/cbbees/content/domain/events/PlayerTickEvent.kt +++ b/src/main/kotlin/de/devin/cbbees/content/domain/events/PlayerTickEvent.kt @@ -81,11 +81,7 @@ class PlayerTickEvent { if (player.isCreative || player.isSpectator) return // Mechanical Wings upgrade was removed in 1.3.0. Gracefully disable flight // for players who had it active from a previous version. - if (player.abilities.mayfly && !player.isCreative && !player.isSpectator) { - player.abilities.mayfly = false - player.abilities.flying = false - player.onUpdateAbilities() - } + de.devin.cbbees.compat.PlayerFlightCompat.clearLegacyFlight(player) } private fun hasPortableHive(player: net.minecraft.world.entity.player.Player): Boolean { diff --git a/src/main/kotlin/de/devin/cbbees/content/logistics/ports/LogisticsPortBlockEntity.kt b/src/main/kotlin/de/devin/cbbees/content/logistics/ports/LogisticsPortBlockEntity.kt index 217d69f..61db3f1 100644 --- a/src/main/kotlin/de/devin/cbbees/content/logistics/ports/LogisticsPortBlockEntity.kt +++ b/src/main/kotlin/de/devin/cbbees/content/logistics/ports/LogisticsPortBlockEntity.kt @@ -171,12 +171,14 @@ class LogisticPortBlockEntity(type: BlockEntityType<*>, pos: BlockPos, state: Bl if (hasCreativeCrate()) return true val handler = getItemHandler(level) ?: return false + var found = 0 for (i in 0 until handler.slots) { val stackInSlot = handler.getStackInSlot(i) if (!stackInSlot.isEmpty && ItemStack.isSameItemSameComponents(stackInSlot, stack)) { - return true + found += stackInSlot.count + if (found >= stack.count) return true } } @@ -189,18 +191,23 @@ class LogisticPortBlockEntity(type: BlockEntityType<*>, pos: BlockPos, state: Bl val handler = getItemHandler(level) ?: return false + if (!hasItemStack(stack)) return false + + var remaining = stack.count for (i in 0 until handler.slots) { + if (remaining <= 0) break val inSlot = handler.getStackInSlot(i) if (!inSlot.isEmpty && ItemStack.isSameItemSameComponents(inSlot, stack)) { - val extracted = handler.extractItem(i, stack.count, false) - - if (!extracted.isEmpty) { - pulseBusy() - return true - } + val extracted = handler.extractItem(i, remaining, false) + remaining -= extracted.count } } + + if (remaining <= 0) { + pulseBusy() + return true + } return false } diff --git a/src/main/kotlin/de/devin/cbbees/content/logistics/transport/TransportPortBlockEntity.kt b/src/main/kotlin/de/devin/cbbees/content/logistics/transport/TransportPortBlockEntity.kt index 82a69a3..1488a0d 100644 --- a/src/main/kotlin/de/devin/cbbees/content/logistics/transport/TransportPortBlockEntity.kt +++ b/src/main/kotlin/de/devin/cbbees/content/logistics/transport/TransportPortBlockEntity.kt @@ -141,10 +141,12 @@ class TransportPortBlockEntity(type: BlockEntityType<*>, pos: BlockPos, state: B if (hasCreativeCrate()) return true val handler = getItemHandler(level) ?: return false + var found = 0 for (i in 0 until handler.slots) { val stackInSlot = handler.getStackInSlot(i) if (!stackInSlot.isEmpty && ItemStack.isSameItemSameComponents(stackInSlot, stack)) { - return true + found += stackInSlot.count + if (found >= stack.count) return true } } return false @@ -171,14 +173,18 @@ class TransportPortBlockEntity(type: BlockEntityType<*>, pos: BlockPos, state: B if (hasCreativeCrate()) return true val handler = getItemHandler(level) ?: return false + if (!hasItemStack(stack)) return false + + var remaining = stack.count for (i in 0 until handler.slots) { + if (remaining <= 0) break val inSlot = handler.getStackInSlot(i) if (!inSlot.isEmpty && ItemStack.isSameItemSameComponents(inSlot, stack)) { - val extracted = handler.extractItem(i, stack.count, false) - if (!extracted.isEmpty) return true + val extracted = handler.extractItem(i, remaining, false) + remaining -= extracted.count } } - return false + return remaining <= 0 } override fun addItemStack(stack: ItemStack): ItemStack { diff --git a/src/main/kotlin/de/devin/cbbees/content/schematics/SchematicCreateBridge.kt b/src/main/kotlin/de/devin/cbbees/content/schematics/SchematicCreateBridge.kt index d6b3672..2b534ab 100644 --- a/src/main/kotlin/de/devin/cbbees/content/schematics/SchematicCreateBridge.kt +++ b/src/main/kotlin/de/devin/cbbees/content/schematics/SchematicCreateBridge.kt @@ -344,20 +344,59 @@ class SchematicCreateBridge( } /** - * Convert Create's ItemRequirement to a list of ItemStacks + * Convert Create's ItemRequirement to a list of ItemStacks. + * + * Some modded blocks with saved data can produce two same-item requirements: + * - a loose/default block item requirement, and + * - a component/NBT-bearing item requirement for the actual saved block. + * + * CBBees stores requirements as ItemStacks only. If both entries are copied as-is, + * the bee collects one plain item plus one NBT item. Collapse same-item default + + * component stacks so the component-bearing stack satisfies the loose default cost. */ private fun getItemsFromRequirement(requirement: ItemRequirement): List { if (requirement.isInvalid) return emptyList() - val items = mutableListOf() + val entries = requirement.requiredItems + .map { it.stack.copy() } + .filterNot { it.isEmpty } + + if (entries.isEmpty()) return emptyList() + + val result = mutableListOf() + val groupedByItem = entries.groupBy { it.item } + + for (group in groupedByItem.values) { + val componentStacks = group.filter { hasNonDefaultComponents(it) } + val defaultStacks = group.filterNot { hasNonDefaultComponents(it) } + + if (componentStacks.isEmpty()) { + defaultStacks.forEach { mergeRequirementStack(result, it) } + continue + } - // Get required items from the requirement - // StackRequirement has a 'stack' field containing the ItemStack - requirement.requiredItems.forEach { reqItem -> - items.add(reqItem.stack.copy()) + // If a same-item requirement has any component/NBT-bearing stack, keep only + // the exact component stacks. Create/modded blocks can report both the loose + // default stack and the saved component stack for the same block item; counting + // the loose stack again makes bees collect/consume the plain variant too. + componentStacks.forEach { mergeRequirementStack(result, it) } } - return items + return result + } + + private fun hasNonDefaultComponents(stack: ItemStack): Boolean { + if (stack.isEmpty) return false + return !ItemStack.isSameItemSameComponents(stack, ItemStack(stack.item)) + } + + private fun mergeRequirementStack(result: MutableList, stack: ItemStack) { + val existing = result.firstOrNull { ItemStack.isSameItemSameComponents(it, stack) } + if (existing != null) { + existing.grow(stack.count) + } else { + result.add(stack.copy()) + } } diff --git a/src/main/kotlin/de/devin/cbbees/content/schematics/client/ConstructionRenderer.kt b/src/main/kotlin/de/devin/cbbees/content/schematics/client/ConstructionRenderer.kt index 93a33b7..5c65f34 100644 --- a/src/main/kotlin/de/devin/cbbees/content/schematics/client/ConstructionRenderer.kt +++ b/src/main/kotlin/de/devin/cbbees/content/schematics/client/ConstructionRenderer.kt @@ -10,8 +10,10 @@ import com.simibubi.create.foundation.blockEntity.IMultiBlockEntityContainer import com.simibubi.create.foundation.blockEntity.SmartBlockEntity import de.devin.cbbees.CreateBuzzyBeez import de.devin.cbbees.config.CBBeesClientConfig +import de.devin.cbbees.compat.SableCompanionCompat import de.devin.cbbees.content.beehive.client.ClientJobCache import de.devin.cbbees.content.domain.job.ClientJobInfo +import de.devin.cbbees.content.domain.job.JobType import de.devin.cbbees.util.ClientSide import net.minecraft.world.phys.Vec3 import net.createmod.catnip.animation.AnimationTickHolder @@ -57,6 +59,10 @@ object ConstructionRenderer { private val rendererCache = mutableMapOf() + /** Last known Sable visual offset per job. Used only as a short fallback + * when Sable temporarily cannot return a pose for a frame. */ + private val jobRenderOffsetCache = mutableMapOf() + private var lastDataVersion = -1L private var lastBlockCheckTick = 0L @@ -113,6 +119,7 @@ object ConstructionRenderer { outlineCache.clear() outlineBoundsCache.clear() rendererCache.clear() + jobRenderOffsetCache.clear() lastDataVersion = -1L return } @@ -146,6 +153,7 @@ object ConstructionRenderer { for ((_, jobRenderer) in rendererCache) { poseStack.pushPose() poseStack.translate(-camera.x, -camera.y, -camera.z) + SableCompanionCompat.applyRenderTransform(poseStack, level, jobRenderer.anchor, AnimationTickHolder.getPartialTicks()) jobRenderer.renderer.render(poseStack, transparentBuffer) poseStack.popPose() } @@ -153,8 +161,27 @@ object ConstructionRenderer { profiler.push("renderOutlines") val pt = AnimationTickHolder.getPartialTicks() - for ((_, outline) in outlineCache) { - outline.render(poseStack, superBuffer, camera, pt) + for ((jobId, outline) in outlineCache) { + val localBounds = outlineBoundsCache[jobId] + val samplePos = rendererCache[jobId]?.anchor + val transformedBounds = if (localBounds != null && samplePos != null) { + SableCompanionCompat.projectAabb(level, localBounds, samplePos, pt) + } else { + localBounds?.let { bounds -> SableCompanionCompat.projectAabb(level, bounds, pt) } + } + + if (transformedBounds != null) { + val job = jobs.firstOrNull { it.jobId == jobId } + val color = if (job?.reason != null) STUCK_COLOR else NORMAL_COLOR + val transformedOutline = AABBOutline(transformedBounds) + transformedOutline.params + .colored(color) + .withFaceTexture(AllSpecialTextures.CHECKERED) + .lineWidth(1 / 16f) + transformedOutline.render(poseStack, superBuffer, camera, pt) + } else { + outline.render(poseStack, superBuffer, camera, pt) + } } profiler.pop() @@ -168,6 +195,7 @@ object ConstructionRenderer { outlineCache.keys.removeAll { it !in activeJobIds } outlineBoundsCache.keys.removeAll { it !in activeJobIds } rendererCache.keys.removeAll { it !in activeJobIds } + jobRenderOffsetCache.keys.removeAll { it !in activeJobIds } for (job in jobs) { val existing = rendererCache[job.jobId] @@ -301,7 +329,8 @@ object ConstructionRenderer { val ghostLevel = GhostBlockLevel(clientLevel) ghostLevel.populate(allGhosts) - return JobRenderer(GhostSchematicRenderer(ghostLevel), ghostLevel, BlockPos.ZERO) + val anchor = allGhosts.keys.firstOrNull() ?: BlockPos.ZERO + return JobRenderer(GhostSchematicRenderer(ghostLevel), ghostLevel, anchor) } /** @@ -331,6 +360,109 @@ object ConstructionRenderer { return bestJob?.let { it to bestDist } } + /** + * Converts a fake/client-only construction bee position into the same visual + * Sable space as the unfinished-job frame. + * + * The bee itself is not an entity and its path is only rendered client-side. + * Do not ask Sable for a transform at the bee's current air position: that + * can flicker or fail because air cells are often not registered. Instead, + * resolve the active construction job/frame and use its stable block anchor + * as the Sable render context. + */ + fun getBeeRenderPosition(beeId: UUID, rawPos: Vec3, level: Level, partialTicks: Float): Vec3? { + val job = resolveBeeJob(beeId, rawPos) ?: return null + val samplePos = samplePosFor(job) ?: return null + + val projected = SableCompanionCompat.projectPosition(level, rawPos, samplePos, partialTicks) + if (projected != null) { + jobRenderOffsetCache[job.jobId] = projected.subtract(rawPos) + return projected + } + + // AABB-center fallback: approximate the bee path into the same visible + // area as the job frame. This is less perfect for rotation, but prevents + // jumping back to hidden sub-level coordinates if Sable misses one frame. + val bounds = boundsFor(job) + if (bounds != null) { + val projectedBounds = SableCompanionCompat.projectAabb(level, bounds, samplePos, partialTicks) + if (projectedBounds != null) { + val delta = centerOf(projectedBounds).subtract(centerOf(bounds)) + jobRenderOffsetCache[job.jobId] = delta + return rawPos.add(delta) + } + } + + return jobRenderOffsetCache[job.jobId]?.let { rawPos.add(it) } + } + + private fun resolveBeeJob(beeId: UUID, rawPos: Vec3): ClientJobInfo? { + val jobs = ClientJobCache.getAllJobs().filter { it.jobType == JobType.Construction } + if (jobs.isEmpty()) return null + + jobs.firstOrNull { job -> + job.batches.any { batch -> beeId in batch.assignedBeeIds } + }?.let { return it } + + // Deployer-start snapshots can arrive before assignedBeeIds are known. + // In that case, choose a nearby active construction frame. + val nearby = jobs.mapNotNull { job -> + val bounds = boundsFor(job)?.inflate(32.0) ?: return@mapNotNull null + if (bounds.contains(rawPos)) job else null + } + if (nearby.size == 1) return nearby.first() + + if (jobs.size == 1) return jobs.first() + + return jobs.minByOrNull { job -> + val center = boundsFor(job)?.let { centerOf(it) } + ?: Vec3.atCenterOf(job.batches.firstOrNull()?.target ?: BlockPos.ZERO) + center.distanceToSqr(rawPos) + } + } + + private fun samplePosFor(job: ClientJobInfo): BlockPos? { + return rendererCache[job.jobId]?.anchor + ?: job.schematicPlacement?.anchor + ?: boundsFor(job)?.let { BlockPos.containing(centerOf(it)) } + ?: job.batches.firstOrNull()?.target + } + + private fun boundsFor(job: ClientJobInfo): AABB? { + outlineBoundsCache[job.jobId]?.let { return it } + + val renderer = rendererCache[job.jobId] + if (renderer != null && renderer.schematicLevel.blockMap.isNotEmpty()) { + val positions = renderer.schematicLevel.blockMap.keys.map { it.offset(renderer.schematicLevel.anchor) } + return boundsFromPositions(positions) + } + + val ghosts = job.batches.flatMap { batch -> + if (batch.ghostBlocks.isNotEmpty()) batch.ghostBlocks.keys else listOf(batch.target) + } + return boundsFromPositions(ghosts) + } + + private fun boundsFromPositions(positions: Collection): AABB? { + if (positions.isEmpty()) return null + return AABB( + positions.minOf { it.x }.toDouble(), + positions.minOf { it.y }.toDouble(), + positions.minOf { it.z }.toDouble(), + (positions.maxOf { it.x } + 1).toDouble(), + (positions.maxOf { it.y } + 1).toDouble(), + (positions.maxOf { it.z } + 1).toDouble() + ) + } + + private fun centerOf(bounds: AABB): Vec3 { + return Vec3( + (bounds.minX + bounds.maxX) * 0.5, + (bounds.minY + bounds.maxY) * 0.5, + (bounds.minZ + bounds.maxZ) * 0.5 + ) + } + /** * Returns the cached [ClientJobInfo] for a given job ID, or null. */ diff --git a/src/main/kotlin/de/devin/cbbees/content/schematics/client/GhostSchematicRenderer.kt b/src/main/kotlin/de/devin/cbbees/content/schematics/client/GhostSchematicRenderer.kt index dd79ffb..02e0e30 100644 --- a/src/main/kotlin/de/devin/cbbees/content/schematics/client/GhostSchematicRenderer.kt +++ b/src/main/kotlin/de/devin/cbbees/content/schematics/client/GhostSchematicRenderer.kt @@ -1,3 +1,5 @@ +@file:Suppress("DEPRECATION") + package de.devin.cbbees.content.schematics.client import com.mojang.blaze3d.vertex.PoseStack diff --git a/src/main/kotlin/de/devin/cbbees/gametest/GameTestSetup.kt b/src/main/kotlin/de/devin/cbbees/gametest/GameTestSetup.kt index 1875a3f..8a50e11 100644 --- a/src/main/kotlin/de/devin/cbbees/gametest/GameTestSetup.kt +++ b/src/main/kotlin/de/devin/cbbees/gametest/GameTestSetup.kt @@ -19,7 +19,6 @@ import net.minecraft.server.level.ServerLevel import net.minecraft.world.item.Item import net.minecraft.world.item.ItemStack import net.minecraft.world.level.block.Block -import net.neoforged.neoforge.capabilities.Capabilities import java.util.UUID /** @@ -174,7 +173,7 @@ object GameTestSetup { // ── Helpers ── fun countItem(level: ServerLevel, pos: BlockPos, item: Item): Int { - val handler = level.getCapability(Capabilities.ItemHandler.BLOCK, pos, null) ?: return 0 + val handler = de.devin.cbbees.compat.NeoForgeCapabilityCompat.getUnsidedItemHandler(level, pos) ?: return 0 var count = 0 for (slot in 0 until handler.slots) { val stack = handler.getStackInSlot(slot) diff --git a/src/main/kotlin/de/devin/cbbees/gametest/dsl/BeeTestDsl.kt b/src/main/kotlin/de/devin/cbbees/gametest/dsl/BeeTestDsl.kt index a65ccc3..5216c1d 100644 --- a/src/main/kotlin/de/devin/cbbees/gametest/dsl/BeeTestDsl.kt +++ b/src/main/kotlin/de/devin/cbbees/gametest/dsl/BeeTestDsl.kt @@ -19,7 +19,6 @@ import net.minecraft.server.level.ServerLevel import net.minecraft.world.entity.player.Player import net.minecraft.world.item.Item import net.minecraft.world.item.ItemStack -import net.neoforged.neoforge.capabilities.Capabilities import net.neoforged.neoforge.common.util.FakePlayerFactory import net.neoforged.neoforge.items.ItemHandlerHelper import net.minecraft.world.level.block.state.BlockState @@ -188,7 +187,7 @@ class BeeTestScope( * Inserts items into the inventory at the given position (e.g., a vault). */ fun insertItems(pos: BlockPos, item: Item, count: Int) { - val handler = level.getCapability(Capabilities.ItemHandler.BLOCK, pos, null) + val handler = de.devin.cbbees.compat.NeoForgeCapabilityCompat.getUnsidedItemHandler(level, pos) ?: throw GameTestAssertException("No item handler at $pos") val remainder = ItemHandlerHelper.insertItemStacked(handler, ItemStack(item, count), false) if (!remainder.isEmpty) { diff --git a/src/main/kotlin/de/devin/cbbees/network/DeployerSettingsPacket.kt b/src/main/kotlin/de/devin/cbbees/network/DeployerSettingsPacket.kt index 935c649..4a21f44 100644 --- a/src/main/kotlin/de/devin/cbbees/network/DeployerSettingsPacket.kt +++ b/src/main/kotlin/de/devin/cbbees/network/DeployerSettingsPacket.kt @@ -7,6 +7,7 @@ import net.minecraft.core.BlockPos import net.minecraft.network.RegistryFriendlyByteBuf import net.minecraft.network.codec.StreamCodec import net.minecraft.network.protocol.common.custom.CustomPacketPayload +import net.minecraft.server.level.ServerLevel import net.minecraft.server.level.ServerPlayer import net.minecraft.world.level.block.Mirror import net.minecraft.world.level.block.Rotation @@ -51,12 +52,7 @@ class DeployerSettingsPacket( fun handle(payload: DeployerSettingsPacket, context: IPayloadContext) { context.enqueueWork { val player = context.player() as? ServerPlayer ?: return@enqueueWork - val level = player.serverLevel() - - if (player.blockPosition().distSqr(payload.deployerPos) > 64.0) return@enqueueWork - - val be = level.getBlockEntity(payload.deployerPos) as? SchematicDeployerBlockEntity - ?: return@enqueueWork + val be = findDeployer(player, payload.deployerPos) ?: return@enqueueWork be.deployMode = payload.mode be.relativeOffset = payload.relativeOffset @@ -64,8 +60,48 @@ class DeployerSettingsPacket( be.relativeMirror = payload.relativeMirror be.setChanged() be.sendData() + + // Make the setting visible to comparators/renderers immediately when the + // block entity is in a Sable/physics wrapper or a far shipyard coordinate. + be.level?.sendBlockUpdated(be.blockPos, be.blockState, be.blockState, 3) } } + + /** + * Normal-world deployers are at the player's ServerLevel and close to the + * player. Sable/physics-world deployers can be stored in a shipyard/sub-level + * coordinate space, so the old distance check rejected the settings packet + * before the block entity could be found. + * + * First try the player's current level for normal usage. Then try every loaded + * ServerLevel at the same block position so Sable wrapper/sub-level storage can + * still receive Absolute/Relative updates. + */ + private fun findDeployer(player: ServerPlayer, pos: BlockPos): SchematicDeployerBlockEntity? { + val playerLevel = player.serverLevel() + + findInLevel(playerLevel, pos)?.let { be -> + // Normal-world safety: if the BE is actually near the player, accept it. + if (player.blockPosition().distSqr(pos) <= 4096.0) return be + + // Sable/shipyard coordinates can be far from the player's visible + // position. Still accept the direct BE match because the client could + // only send this packet after opening that BE's screen. + return be + } + + val server = player.server + for (level in server.allLevels) { + if (level === playerLevel) continue + findInLevel(level, pos)?.let { return it } + } + + return null + } + + private fun findInLevel(level: ServerLevel, pos: BlockPos): SchematicDeployerBlockEntity? { + return level.getBlockEntity(pos) as? SchematicDeployerBlockEntity + } } override fun type(): CustomPacketPayload.Type = TYPE