CAPABILITY_SOUL_CONTAINER = CapabilityManager.get(new CapabilityToken<>() {});
-
- public static void register(RegisterCapabilitiesEvent event) {
- event.register(ISoulContainer.class);
- }
-}
diff --git a/src/main/java/com/ghostipedia/cosmiccore/api/capability/HeatCapability.java b/src/main/java/com/ghostipedia/cosmiccore/api/capability/HeatCapability.java
new file mode 100644
index 000000000..d4d358eb8
--- /dev/null
+++ b/src/main/java/com/ghostipedia/cosmiccore/api/capability/HeatCapability.java
@@ -0,0 +1,20 @@
+package com.ghostipedia.cosmiccore.api.capability;
+
+public class HeatCapability {
+
+ public static float adjustTempTowards(float temp, float target, float delta) {
+ return adjustTempTowards(temp, target, delta, delta);
+ }
+
+ public static float adjustTempTowards(float temp, float target, float deltaPositive, float deltaNegative) {
+ // Get the Delta of the particular dimension, not sure how I want to do this yet, so let's just leave it as 1
+ final float delta = 1;
+ if (temp < target) {
+ return Math.min(temp + delta * deltaPositive, target);
+ } else if (temp > target) {
+ return Math.max(temp - delta * deltaNegative, target);
+ } else {
+ return target;
+ }
+ }
+}
diff --git a/src/main/java/com/ghostipedia/cosmiccore/api/capability/IHeatBlock.java b/src/main/java/com/ghostipedia/cosmiccore/api/capability/IHeatBlock.java
new file mode 100644
index 000000000..bf648de08
--- /dev/null
+++ b/src/main/java/com/ghostipedia/cosmiccore/api/capability/IHeatBlock.java
@@ -0,0 +1,8 @@
+package com.ghostipedia.cosmiccore.api.capability;
+
+public interface IHeatBlock {
+
+ float getTemperature();
+
+ void setTemperature(float temperature);
+}
diff --git a/src/main/java/com/ghostipedia/cosmiccore/api/capability/IHeatInfoProvider.java b/src/main/java/com/ghostipedia/cosmiccore/api/capability/IHeatInfoProvider.java
new file mode 100644
index 000000000..2fa71f2f1
--- /dev/null
+++ b/src/main/java/com/ghostipedia/cosmiccore/api/capability/IHeatInfoProvider.java
@@ -0,0 +1,11 @@
+package com.ghostipedia.cosmiccore.api.capability;
+
+public interface IHeatInfoProvider {
+
+ record HeatInfo(Long capacity, Long stored, boolean overload) {}
+
+ // I need some way to store all dimensions temps, idk do it in the next interface.
+ HeatInfo getHeatInfo();
+
+ boolean supportsImpossibleHeatValues();
+}
diff --git a/src/main/java/com/ghostipedia/cosmiccore/api/capability/ILinkedMultiblock.java b/src/main/java/com/ghostipedia/cosmiccore/api/capability/ILinkedMultiblock.java
new file mode 100644
index 000000000..6b534d57b
--- /dev/null
+++ b/src/main/java/com/ghostipedia/cosmiccore/api/capability/ILinkedMultiblock.java
@@ -0,0 +1,107 @@
+package com.ghostipedia.cosmiccore.api.capability;
+
+import com.gregtechceu.gtceu.api.machine.feature.IDataStickInteractable;
+
+import net.minecraft.core.GlobalPos;
+
+import org.jetbrains.annotations.Nullable;
+
+import java.util.Set;
+import java.util.UUID;
+
+/**
+ * Interface for multiblocks that support cross-dimensional linking.
+ * Extends GTCEu's IDataStickInteractable for datastick-based linking.
+ *
+ * SECURITY: Link validation MUST load and verify the partner machine.
+ * Never trust datastick NBT for ownership or compatibility checks.
+ */
+public interface ILinkedMultiblock extends IDataStickInteractable {
+
+ /**
+ * Role this machine prefers in links it creates.
+ * Actual role is determined by negotiation with partner.
+ */
+ enum LinkRole {
+ /** Bidirectional - both machines can query each other */
+ PEER,
+ /** This machine controls partners - can query them, they cannot query us */
+ CONTROLLER,
+ /** This machine is controlled by partners - they can query us, we cannot query them */
+ REMOTE
+ }
+
+ // ==================== Configuration ====================
+
+ /**
+ * Check if this machine can link to the given partner.
+ * Called AFTER partner machine is loaded and ownership is verified.
+ * Called AFTER role negotiation succeeds.
+ *
+ * Use for type compatibility, distance limits, dimension restrictions, etc.
+ * Ownership and role checks are handled by the linking system.
+ *
+ * @param partner The partner's position
+ * @param partnerMachine The actual loaded partner machine
+ */
+ boolean canLinkTo(GlobalPos partner, ILinkedMultiblock partnerMachine);
+
+ /**
+ * Get the role this machine prefers when linking.
+ * Actual effective role is determined by negotiation.
+ *
+ * @see com.ghostipedia.cosmiccore.common.machine.multiblock.LinkedMultiblockHelper#negotiateRoles
+ */
+ LinkRole getLinkRole();
+
+ /**
+ * Maximum number of partners this machine can link to.
+ * Default: 4
+ */
+ default int getMaxPartners() {
+ return 4;
+ }
+
+ // ==================== Lifecycle ====================
+
+ /**
+ * Called when a link is successfully established.
+ * May be called immediately (if partner loaded) or deferred (on this machine's load).
+ *
+ * @param partner The linked partner's position
+ */
+ void onLinkEstablished(GlobalPos partner);
+
+ /**
+ * Called when a link is broken.
+ * Reasons: partner destroyed, manual unlink, ownership change, etc.
+ *
+ * @param partner The unlinked partner's position
+ */
+ void onLinkBroken(GlobalPos partner);
+
+ /**
+ * Called during machine load to process deferred link notifications.
+ * Implementation should compare SavedData links vs known partners.
+ */
+ void processLinkNotifications();
+
+ // ==================== Query ====================
+
+ /**
+ * Get all currently linked partners from SavedData.
+ */
+ Set getLinkedPartners();
+
+ /**
+ * Get this machine's GlobalPos for link registration.
+ */
+ GlobalPos getGlobalPos();
+
+ /**
+ * Get the owner UUID (team or player) for access control.
+ * Should use FTB Teams integration via existing getTeamUUID() pattern.
+ */
+ @Nullable
+ UUID getTeamUUID();
+}
diff --git a/src/main/java/com/ghostipedia/cosmiccore/api/capability/ISoulContainer.java b/src/main/java/com/ghostipedia/cosmiccore/api/capability/ISoulContainer.java
index 472f01c68..5c058b663 100644
--- a/src/main/java/com/ghostipedia/cosmiccore/api/capability/ISoulContainer.java
+++ b/src/main/java/com/ghostipedia/cosmiccore/api/capability/ISoulContainer.java
@@ -1,21 +1,9 @@
package com.ghostipedia.cosmiccore.api.capability;
-import wayoftime.bloodmagic.core.data.SoulNetwork;
-
-import java.util.UUID;
+import com.ghostipedia.cosmiccore.api.data.souls.SoulNetwork;
public interface ISoulContainer {
- /**
- * @return the UUID of the player associated to the container's network
- */
- UUID getOwner();
-
- /**
- * @param playerUUID: the player to whom we attach the container
- */
- void setOwner(UUID playerUUID);
-
/**
* @return the soul network attached to the container
*/
diff --git a/src/main/java/com/ghostipedia/cosmiccore/api/capability/ITeleportOrigin.java b/src/main/java/com/ghostipedia/cosmiccore/api/capability/ITeleportOrigin.java
new file mode 100644
index 000000000..fb30e2e44
--- /dev/null
+++ b/src/main/java/com/ghostipedia/cosmiccore/api/capability/ITeleportOrigin.java
@@ -0,0 +1,27 @@
+package com.ghostipedia.cosmiccore.api.capability;
+
+import net.minecraft.resources.ResourceKey;
+import net.minecraft.world.level.Level;
+import net.minecraft.world.phys.Vec3;
+
+// Capability for storing teleportation origin of a player.
+public interface ITeleportOrigin {
+
+ void setOriginDimension(ResourceKey dimension); // Set the origin dimension the player teleported from.
+
+ ResourceKey getOriginDimension(); // Get the origin dimension, or null if not set.
+
+ void setOriginPosition(Vec3 position); // Set the origin position the player teleported from.
+
+ Vec3 getOriginPosition(); // Get the origin position, or null if not set.
+
+ void setOriginRotation(float yaw, float pitch); // Set the player's rotation when they teleported.
+
+ float getOriginYaw(); // Get the origin yaw rotation.
+
+ float getOriginPitch(); // Get the origin pitch rotation.
+
+ boolean hasValidOrigin(); // Check if this player has valid origin data.
+
+ void clearOriginData(); // Clear all origin data.
+}
diff --git a/src/main/java/com/ghostipedia/cosmiccore/api/capability/recipe/CosmicRecipeCapabilities.java b/src/main/java/com/ghostipedia/cosmiccore/api/capability/recipe/CosmicRecipeCapabilities.java
index b184a7ba6..d131e1b15 100644
--- a/src/main/java/com/ghostipedia/cosmiccore/api/capability/recipe/CosmicRecipeCapabilities.java
+++ b/src/main/java/com/ghostipedia/cosmiccore/api/capability/recipe/CosmicRecipeCapabilities.java
@@ -1,12 +1,22 @@
package com.ghostipedia.cosmiccore.api.capability.recipe;
+import com.ghostipedia.cosmiccore.CosmicCore;
+
import com.gregtechceu.gtceu.api.registry.GTRegistries;
+import static com.gregtechceu.gtceu.integration.kjs.recipe.components.GTRecipeComponents.FLUID;
+import static com.gregtechceu.gtceu.integration.kjs.recipe.components.GTRecipeComponents.VALID_CAPS;
+
public class CosmicRecipeCapabilities {
public static final SoulRecipeCapability SOUL = SoulRecipeCapability.CAP;
+ public static final SterileRecipeCapability STERILE = SterileRecipeCapability.CAP;
+ // TODO(embers): EMBER recipe capability shelved with Embers (bead cosmiccore-42.14)
public static void init() {
- GTRegistries.RECIPE_CAPABILITIES.register(SOUL.name, SOUL);
+ GTRegistries.register(GTRegistries.RECIPE_CAPABILITIES, CosmicCore.id(SOUL.name), SOUL);
+ GTRegistries.register(GTRegistries.RECIPE_CAPABILITIES, CosmicCore.id(STERILE.name), STERILE);
+
+ VALID_CAPS.put(STERILE, FLUID);
}
-}
\ No newline at end of file
+}
diff --git a/src/main/java/com/ghostipedia/cosmiccore/api/capability/recipe/HeatRecipeCapability.java b/src/main/java/com/ghostipedia/cosmiccore/api/capability/recipe/HeatRecipeCapability.java
new file mode 100644
index 000000000..1e6b4a913
--- /dev/null
+++ b/src/main/java/com/ghostipedia/cosmiccore/api/capability/recipe/HeatRecipeCapability.java
@@ -0,0 +1,63 @@
+package com.ghostipedia.cosmiccore.api.capability.recipe;
+
+import com.ghostipedia.cosmiccore.api.recipe.lookup.MapHeatIngredient;
+
+import com.gregtechceu.gtceu.api.capability.recipe.RecipeCapability;
+import com.gregtechceu.gtceu.api.recipe.GTRecipe;
+import com.gregtechceu.gtceu.api.recipe.content.Content;
+import com.gregtechceu.gtceu.api.recipe.content.ContentModifier;
+import com.gregtechceu.gtceu.api.recipe.content.SerializerLong;
+import com.gregtechceu.gtceu.api.recipe.lookup.ingredient.AbstractMapIngredient;
+
+import com.lowdragmc.lowdraglib.gui.widget.LabelWidget;
+import com.lowdragmc.lowdraglib.gui.widget.WidgetGroup;
+import com.lowdragmc.lowdraglib.utils.LocalizationUtils;
+
+import it.unimi.dsi.fastutil.objects.ObjectArrayList;
+import org.apache.commons.lang3.mutable.MutableInt;
+
+import java.util.List;
+
+public class HeatRecipeCapability extends RecipeCapability {
+
+ public final static HeatRecipeCapability CAP = new HeatRecipeCapability();
+
+ protected HeatRecipeCapability() {
+ super("thermia", 0x5E2129FF, true, 11, SerializerLong.INSTANCE);
+ }
+
+ @Override
+ public Long copyInner(Long content) {
+ return content;
+ }
+
+ @Override
+ public Long copyWithModifier(Long content, ContentModifier modifier) {
+ return modifier.apply(content);
+ }
+
+ // @Override
+ public List convertToMapIngredient(Object ingredient) {
+ List ingredients = new ObjectArrayList<>(1);
+ if (ingredient instanceof Long thermia) ingredients.add(new MapHeatIngredient(thermia));
+ return ingredients;
+ }
+
+ @Override
+ public boolean isRecipeSearchFilter() {
+ return true;
+ }
+
+ @Override
+ public void addXEIInfo(WidgetGroup group, int xOffset, GTRecipe recipe, List contents, boolean perTick,
+ boolean isInput, MutableInt yOffset) {
+ long thermia = contents.stream().map(Content::getContent).mapToLong(HeatRecipeCapability.CAP::of).sum();
+ if (isInput) {
+ group.addWidget(new LabelWidget(3 - xOffset, yOffset.addAndGet(10),
+ LocalizationUtils.format("cosmiccore.recipe.thermiaIn", thermia)));
+ } else {
+ group.addWidget(new LabelWidget(3 - xOffset, yOffset.addAndGet(10),
+ LocalizationUtils.format("cosmiccore.recipe.thermiaOut", thermia)));
+ }
+ }
+}
diff --git a/src/main/java/com/ghostipedia/cosmiccore/api/capability/recipe/IHeatContainer.java b/src/main/java/com/ghostipedia/cosmiccore/api/capability/recipe/IHeatContainer.java
new file mode 100644
index 000000000..da4b422f9
--- /dev/null
+++ b/src/main/java/com/ghostipedia/cosmiccore/api/capability/recipe/IHeatContainer.java
@@ -0,0 +1,123 @@
+package com.ghostipedia.cosmiccore.api.capability.recipe;
+
+import com.ghostipedia.cosmiccore.api.capability.IHeatInfoProvider;
+
+import net.minecraft.core.Direction;
+
+public interface IHeatContainer extends IHeatInfoProvider {
+
+ /*
+ * Basically just changeHeat(long) but should also handle overloads and capacity.
+ * This is what I probably want to use between blocks...
+ * fuck if I know
+ */
+ long acceptHeatFromNetwork(Direction side);
+
+ // Returns: if this container can accept heat from this side.
+ boolean inputsHeat(Direction side);
+
+ // Returns: if this container can eject heat from this side.
+ default boolean outputsHeat(Direction side) {
+ return false;
+ };
+
+ /*
+ * The Magic Sauce.
+ * for handling logic within machine running behaviors, DO NOT. Use this for the eventual pipenet
+ * update this desc to properly reflect interface methods once they are adapted.
+ */
+ long changeHeat(long heatDifference);
+
+ /*
+ * Adds a set amount of heat to this heat container
+ * Params : heatToAdd - amount of heat to add.
+ * Returns : amount of heat added.
+ */
+ default long addHeat(long heatToAdd) {
+ return changeHeat(heatToAdd);
+ }
+
+ /*
+ * Removes a set amount of heat to this heat container
+ * Params : heatToRemove - amount of heat to remove.
+ * Returns : amount of heat removed.
+ */
+ default long removeHeat(long heatToRemove) {
+ return -changeHeat(-heatToRemove);
+ }
+
+ // Heat Containers Do not have an insertion limit. Thus we melt the block if they overload.
+ // TODO : The Math that actually makes this behave less psychotic. And actually function.
+ default boolean getHeatCanBeOverloaded() {
+ if (getOverloadLimit() > getHeatStorage()) {
+ return getHeatInfo().overload();
+ }
+ return false;
+ }
+
+ // Reports the Current Thermal Maximum a container can withstand
+ long getOverloadLimit();
+
+ // Reports the Current Temperature.
+ long getHeatStorage();
+
+ @Override
+ default HeatInfo getHeatInfo() {
+ return new HeatInfo(getHeatStorage(), getHeatStorage(), getHeatCanBeOverloaded());
+ };
+
+ // Params needs to build the container.
+ // This Abomination - Allows Going below Absolute Zero
+ @Override
+ default boolean supportsImpossibleHeatValues() {
+ return false;
+ };
+
+ // Max amount of heat that can be output per tick
+ default long getEjectLimit() {
+ return 0L;
+ };
+
+ // Max amount of heat that can be accepted per tick
+ default long getAcceptLimit() {
+ return 0L;
+ }
+
+ // Input per second
+ default long getHeatInputPerSec() {
+ return 0L;
+ }
+
+ // Output per second
+ default long getHeatOutputPerSec() {
+ return 0L;
+ }
+
+ IHeatContainer DEFAULT = new IHeatContainer() {
+
+ @Override
+ public long acceptHeatFromNetwork(Direction side) {
+ return 0;
+ }
+
+ @Override
+ public boolean inputsHeat(Direction side) {
+ return false;
+ }
+
+ @Override
+ public long changeHeat(long heatDifference) {
+ return 0;
+ }
+
+ @Override
+ public long getOverloadLimit() {
+ return 0;
+ }
+
+ @Override
+ public long getHeatStorage() {
+ return 0;
+ }
+ };
+}
diff --git a/src/main/java/com/ghostipedia/cosmiccore/api/capability/recipe/SoulRecipeCapability.java b/src/main/java/com/ghostipedia/cosmiccore/api/capability/recipe/SoulRecipeCapability.java
index 73d84c73b..47b3534e7 100644
--- a/src/main/java/com/ghostipedia/cosmiccore/api/capability/recipe/SoulRecipeCapability.java
+++ b/src/main/java/com/ghostipedia/cosmiccore/api/capability/recipe/SoulRecipeCapability.java
@@ -1,63 +1,173 @@
package com.ghostipedia.cosmiccore.api.capability.recipe;
-import com.ghostipedia.cosmiccore.api.recipe.lookup.MapSoulIngredient;
-import com.gregtechceu.gtceu.api.capability.recipe.CWURecipeCapability;
+import com.ghostipedia.cosmiccore.api.capability.souls.SoulType;
+import com.ghostipedia.cosmiccore.api.machine.trait.NotifiableSoulContainer;
+import com.ghostipedia.cosmiccore.api.recipe.ingredient.SoulIngredient;
+import com.ghostipedia.cosmiccore.api.recipe.ingredient.SoulStack;
+
+import com.gregtechceu.gtceu.api.capability.recipe.IO;
+import com.gregtechceu.gtceu.api.capability.recipe.IRecipeCapabilityHolder;
import com.gregtechceu.gtceu.api.capability.recipe.RecipeCapability;
+import com.gregtechceu.gtceu.api.recipe.GTRecipe;
import com.gregtechceu.gtceu.api.recipe.content.Content;
import com.gregtechceu.gtceu.api.recipe.content.ContentModifier;
-import com.gregtechceu.gtceu.api.recipe.content.SerializerInteger;
-import com.gregtechceu.gtceu.api.recipe.lookup.AbstractMapIngredient;
+import com.gregtechceu.gtceu.api.recipe.content.IContentSerializer;
+
import com.lowdragmc.lowdraglib.gui.widget.LabelWidget;
import com.lowdragmc.lowdraglib.gui.widget.WidgetGroup;
import com.lowdragmc.lowdraglib.utils.LocalizationUtils;
-import it.unimi.dsi.fastutil.objects.ObjectArrayList;
+
+import com.mojang.serialization.Codec;
import org.apache.commons.lang3.mutable.MutableInt;
-import java.util.Collection;
-import java.util.List;
+import java.util.*;
+import java.util.stream.Collectors;
+
+public class SoulRecipeCapability extends RecipeCapability {
-public class SoulRecipeCapability extends RecipeCapability {
public final static SoulRecipeCapability CAP = new SoulRecipeCapability();
protected SoulRecipeCapability() {
- super("soul", 0x5E2129FF, true, 10, SerializerInteger.INSTANCE);
+ super("soul", 0x5E2129FF, true, 10, SerializerSoulIngredient.INSTANCE);
}
@Override
- public Integer copyInner(Integer content) {
- return content;
+ public boolean isRecipeSearchFilter() {
+ return true;
}
+ // TODO: try to remove
@Override
- public Integer copyWithModifier(Integer content, ContentModifier modifier) {
- return modifier.apply(content).intValue();
+ public SoulIngredient copyInner(SoulIngredient content) {
+ return super.copyInner(content);
}
@Override
- public List convertToMapIngredient(Object ingredient) {
- List ingredients = new ObjectArrayList<>(1);
- if (ingredient instanceof Integer essence) ingredients.add(new MapSoulIngredient(essence));
- return ingredients;
+ public SoulIngredient copyWithModifier(SoulIngredient content, ContentModifier modifier) {
+ var modifiedStack = content.stack().withAmount(modifier.apply(content.stack().amount()));
+ return SoulIngredient.of(modifiedStack);
}
@Override
public List compressIngredients(Collection ingredients) {
- //TODO: Figure out what it needs to do
- return super.compressIngredients(ingredients);
+ List list = new ArrayList<>(ingredients.size());
+ for (Object item : ingredients) {
+ if (item instanceof SoulIngredient soul) {
+ var isEqual = false;
+ for (Object obj : list) {
+ if (obj instanceof SoulIngredient soulIngredient &&
+ soul.stack().type().equals(soulIngredient.stack().type())) {
+ isEqual = true;
+ break;
+ }
+ }
+ if (isEqual) continue;
+ list.add(item);
+ }
+ }
+ return list;
+ }
+
+ /// Get the total available input of each soul type.
+ /// The value is the sum of the available amount in each hatch.
+ /// The available amount is the minimum between the contained souls and the available throughput.
+ private static Map getInputContents(IRecipeCapabilityHolder holder) {
+ var handlerLists = holder.getCapabilitiesForIO(IO.IN);
+ if (handlerLists.isEmpty()) return new HashMap<>();
+
+ var totalThroughput = 0;
+ var totalSouls = new HashMap();
+
+ for (var handlerList : handlerLists) {
+ if (!handlerList.hasCapability(SoulRecipeCapability.CAP)) continue;
+ var soulHandlers = handlerList.getCapability(SoulRecipeCapability.CAP);
+ for (var handler : soulHandlers) {
+ var soulHandler = (NotifiableSoulContainer) handler;
+ totalThroughput += soulHandler.getThroughput();
+ for (var content : soulHandler.getContents()) {
+ if (content instanceof SoulIngredient soulIngredient) {
+ totalSouls.put(soulIngredient.stack().type(), soulIngredient.stack().amount());
+ } else throw new IllegalArgumentException("Invalid content type");
+ }
+ }
+ }
+
+ final int finalTotalThroughput = totalThroughput;
+ return totalSouls.entrySet().stream()
+ .collect(Collectors.toMap(
+ Map.Entry::getKey,
+ entry -> Math.min(entry.getValue(), finalTotalThroughput)));
}
@Override
- public boolean isRecipeSearchFilter() {
- return true;
+ public int getMaxParallelByInput(IRecipeCapabilityHolder holder, GTRecipe recipe, int limit, boolean tick) {
+ if (!holder.hasCapabilityProxies()) return 0;
+
+ var inputs = (tick ? recipe.tickInputs : recipe.inputs).get(this);
+ if (inputs == null || inputs.isEmpty()) return 0;
+
+ var totalInputs = getInputContents(holder);
+
+ var parallelMap = inputs.stream()
+ .map(content -> (SoulIngredient) content.getContent())
+ .collect(Collectors.toMap(
+ ingredient -> ingredient.stack().type(),
+ ingredient -> {
+ int available = totalInputs.getOrDefault(ingredient.stack().type(), 0);
+ int required = ingredient.stack().amount();
+ return required == 0 ? Integer.MAX_VALUE : available / required;
+ },
+ Math::min));
+
+ int maxParallel = parallelMap.values().stream()
+ .mapToInt(Integer::intValue)
+ .min()
+ .orElse(0);
+
+ return Math.min(limit, maxParallel);
}
@Override
- public void addXEIInfo(WidgetGroup group, int xOffset, List contents, boolean perTick, boolean isInput, MutableInt yOffset) {
- int soul = contents.stream().map(Content::getContent).mapToInt(SoulRecipeCapability.CAP::of).sum();
+ public void addXEIInfo(WidgetGroup group, int xOffset, GTRecipe recipe, List contents, boolean perTick,
+ boolean isInput, MutableInt yOffset) {
+ String type = contents.stream().map(Content::getContent).map(SoulRecipeCapability.CAP::of)
+ .map(SoulIngredient::stack).map(SoulStack::type).map(SoulType::getSerializedName).findFirst()
+ .orElse("");
+ long soul = contents.stream().map(Content::getContent).map(SoulRecipeCapability.CAP::of)
+ .map(SoulIngredient::stack).mapToLong(SoulStack::amount).sum();
if (isInput) {
- group.addWidget(new LabelWidget(3 - xOffset, yOffset.addAndGet(10), LocalizationUtils.format("cosmiccore.recipe.soulIn", soul)));
+ group.addWidget(new LabelWidget(3 - xOffset, yOffset.addAndGet(10),
+ LocalizationUtils.format("recipe.cosmiccore." + type + "_soul_in", soul)));
} else {
- group.addWidget(new LabelWidget(3 - xOffset, yOffset.addAndGet(10), LocalizationUtils.format("cosmiccore.recipe.soulOut", soul)));
+ group.addWidget(new LabelWidget(3 - xOffset, yOffset.addAndGet(10),
+ LocalizationUtils.format("recipe.cosmiccore." + type + "_soul_out", soul)));
+ }
+ }
+
+ private static class SerializerSoulIngredient implements IContentSerializer {
+
+ public static SerializerSoulIngredient INSTANCE = new SerializerSoulIngredient();
+
+ @Override
+ public SoulIngredient of(Object o) {
+ if (o instanceof SoulStack stack) return SoulIngredient.of(stack);
+ else if (o instanceof SoulIngredient ingredient) return ingredient;
+ return SoulIngredient.of(new SoulStack(SoulType.Raw, 0));
+ }
+
+ @Override
+ public SoulIngredient defaultValue() {
+ return SoulIngredient.of(new SoulStack(SoulType.Raw, 0));
+ }
+
+ @Override
+ public Class contentClass() {
+ return SoulIngredient.class;
+ }
+
+ @Override
+ public Codec codec() {
+ return SoulIngredient.CODEC;
}
}
}
diff --git a/src/main/java/com/ghostipedia/cosmiccore/api/capability/recipe/SterileRecipeCapability.java b/src/main/java/com/ghostipedia/cosmiccore/api/capability/recipe/SterileRecipeCapability.java
new file mode 100644
index 000000000..899f63f49
--- /dev/null
+++ b/src/main/java/com/ghostipedia/cosmiccore/api/capability/recipe/SterileRecipeCapability.java
@@ -0,0 +1,83 @@
+package com.ghostipedia.cosmiccore.api.capability.recipe;
+
+import com.ghostipedia.cosmiccore.api.recipe.lookup.MapSterileIngredient;
+
+import com.gregtechceu.gtceu.api.capability.recipe.FluidRecipeCapability;
+import com.gregtechceu.gtceu.api.capability.recipe.RecipeCapability;
+import com.gregtechceu.gtceu.api.recipe.GTRecipe;
+import com.gregtechceu.gtceu.api.recipe.content.Content;
+import com.gregtechceu.gtceu.api.recipe.content.ContentModifier;
+import com.gregtechceu.gtceu.api.recipe.content.SerializerFluidIngredient;
+import com.gregtechceu.gtceu.api.recipe.ingredient.SizedIngredientExtensions;
+import com.gregtechceu.gtceu.api.recipe.lookup.ingredient.AbstractMapIngredient;
+
+import com.lowdragmc.lowdraglib.gui.widget.LabelWidget;
+import com.lowdragmc.lowdraglib.gui.widget.WidgetGroup;
+import com.lowdragmc.lowdraglib.utils.LocalizationUtils;
+
+import net.neoforged.neoforge.fluids.FluidStack;
+import net.neoforged.neoforge.fluids.crafting.SizedFluidIngredient;
+
+import it.unimi.dsi.fastutil.objects.ObjectArrayList;
+import org.apache.commons.lang3.mutable.MutableInt;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.Collection;
+import java.util.List;
+
+public class SterileRecipeCapability extends RecipeCapability {
+
+ public final static SterileRecipeCapability CAP = new SterileRecipeCapability();
+
+ protected SterileRecipeCapability() {
+ super("sterile", 0x5E2129FF, true, 10, SerializerFluidIngredient.INSTANCE);
+ }
+
+ @Override
+ public SizedFluidIngredient copyInner(SizedFluidIngredient content) {
+ return SizedIngredientExtensions.copy(content);
+ }
+
+ @Override
+ public SizedFluidIngredient copyWithModifier(SizedFluidIngredient content, ContentModifier modifier) {
+ return FluidRecipeCapability.CAP.copyWithModifier(content, modifier);
+ }
+
+ @Override
+ public @Nullable List getDefaultMapIngredient(Object ingredient) {
+ List ingredients = new ObjectArrayList<>(1);
+ if (ingredient instanceof FluidStack fluid) ingredients.add(new MapSterileIngredient(fluid));
+ return ingredients;
+ }
+
+ @Override
+ public List compressIngredients(Collection ingredients) {
+ // TODO: Figure out what it needs to do
+ return super.compressIngredients(ingredients);
+ }
+
+ @Override
+ public boolean isRecipeSearchFilter() {
+ return true;
+ }
+
+ @Override
+ public void addXEIInfo(WidgetGroup group, int xOffset, GTRecipe recipe, List contents, boolean perTick,
+ boolean isInput, MutableInt yOffset) {
+ for (var stack : contents) {
+ var sterileIngredient = SterileRecipeCapability.CAP.of(stack.getContent());
+ if (isInput) {
+ group.addWidget(new LabelWidget(3 - xOffset, yOffset.addAndGet(10),
+ LocalizationUtils.format("cosmiccore.recipe.sterile_in",
+ sterileIngredient.getFluids()[0].getHoverName().getString(),
+ sterileIngredient.getFluids()[0].getAmount() + (perTick ? "/t" : ""))));
+ } else {
+ group.addWidget(new LabelWidget(3 - xOffset, yOffset.addAndGet(10),
+ LocalizationUtils.format("cosmiccore.recipe.sterile_out",
+ sterileIngredient.getFluids()[0].getHoverName().getString(),
+ sterileIngredient.getFluids()[0].getAmount() + (perTick ? "/t" : ""))));
+ }
+
+ }
+ }
+}
diff --git a/src/main/java/com/ghostipedia/cosmiccore/api/capability/souls/SoulType.java b/src/main/java/com/ghostipedia/cosmiccore/api/capability/souls/SoulType.java
new file mode 100644
index 000000000..8e9e4a4a9
--- /dev/null
+++ b/src/main/java/com/ghostipedia/cosmiccore/api/capability/souls/SoulType.java
@@ -0,0 +1,66 @@
+package com.ghostipedia.cosmiccore.api.capability.souls;
+
+import net.minecraft.ChatFormatting;
+import net.minecraft.network.chat.Component;
+import net.minecraft.network.chat.MutableComponent;
+import net.minecraft.network.chat.Style;
+import net.minecraft.util.StringRepresentable;
+
+import com.mojang.serialization.Codec;
+import org.jetbrains.annotations.NotNull;
+
+import java.util.Arrays;
+import java.util.Map;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+public enum SoulType implements StringRepresentable {
+
+ Raw("raw", ChatFormatting.DARK_RED),
+ Refined("refined", ChatFormatting.GRAY),
+ Proud("proud", ChatFormatting.DARK_PURPLE),
+ Greedy("greedy", ChatFormatting.YELLOW),
+ Lustful("lustful", ChatFormatting.LIGHT_PURPLE),
+ Envious("envious", ChatFormatting.GREEN),
+ Gluttonous("gluttonous", ChatFormatting.GOLD),
+ Wrathful("wrathful", ChatFormatting.RED),
+ Slothful("slothful", ChatFormatting.AQUA),
+ Temporal("temporal", ChatFormatting.DARK_AQUA);
+
+ private final String name;
+ private final ChatFormatting color;
+
+ SoulType(String name, ChatFormatting color) {
+ this.name = name;
+ this.color = color;
+ }
+
+ @Override
+ public @NotNull String getSerializedName() {
+ return this.name;
+ }
+
+ public static final Codec CODEC = StringRepresentable.fromEnum(SoulType::values);
+
+ private static final Map BY_NAME = Arrays.stream(values())
+ .collect(Collectors.toMap(SoulType::getSerializedName, Function.identity()));
+
+ public static SoulType byName(String name) {
+ return BY_NAME.get(name);
+ }
+
+ public Component toComponent(int amount) {
+ return toComponent(amount, true);
+ }
+
+ public Component toComponent(int amount, boolean formatted) {
+ MutableComponent nameComp = Component.translatable("gui.cosmiccore.soul." + name + ".name");
+ MutableComponent amountComp = Component.literal(" : " + amount).withStyle(Style.EMPTY);
+ if (formatted) {
+ nameComp = nameComp.withStyle(ChatFormatting.BOLD, this.color);
+ amountComp = amountComp.withStyle(ChatFormatting.RESET);
+ }
+
+ return nameComp.append(amountComp);
+ }
+}
diff --git a/src/main/java/com/ghostipedia/cosmiccore/api/codec/CosmicCodecUtils.java b/src/main/java/com/ghostipedia/cosmiccore/api/codec/CosmicCodecUtils.java
new file mode 100644
index 000000000..fa965ca71
--- /dev/null
+++ b/src/main/java/com/ghostipedia/cosmiccore/api/codec/CosmicCodecUtils.java
@@ -0,0 +1,3 @@
+package com.ghostipedia.cosmiccore.api.codec;
+
+public class CosmicCodecUtils {}
diff --git a/src/main/java/com/ghostipedia/cosmiccore/api/data/CosmicCoreMaterialIconType.java b/src/main/java/com/ghostipedia/cosmiccore/api/data/CosmicCoreMaterialIconType.java
index 5a16723f9..9bcb6cfbd 100644
--- a/src/main/java/com/ghostipedia/cosmiccore/api/data/CosmicCoreMaterialIconType.java
+++ b/src/main/java/com/ghostipedia/cosmiccore/api/data/CosmicCoreMaterialIconType.java
@@ -3,8 +3,17 @@
import com.gregtechceu.gtceu.api.data.chemical.material.info.MaterialIconType;
public class CosmicCoreMaterialIconType {
+
+ public static final MaterialIconType rawOreCubic = new MaterialIconType("rawOreCubic");
public static final MaterialIconType crushedLeached = new MaterialIconType("crushedLeached");
public static final MaterialIconType prismaFrothed = new MaterialIconType("prismaFrothed");
- public static void init() {
- }
+ public static final MaterialIconType ultraDense = new MaterialIconType("ultraDense");
+ public static final MaterialIconType heavyBeam = new MaterialIconType("heavyBeam");
+ public static final MaterialIconType modularShelling = new MaterialIconType("modularShelling");
+ public static final MaterialIconType plasmites = new MaterialIconType("plasmites");
+ public static final MaterialIconType wireSpool = new MaterialIconType("wireSpool");
+ public static final MaterialIconType alveFoil = new MaterialIconType("alveFoilInsulator");
+ public static final MaterialIconType memoryFoil = new MaterialIconType("shapeMemoryFoil");
+
+ public static void init() {}
}
diff --git a/src/main/java/com/ghostipedia/cosmiccore/api/data/CosmicCoreTagPrefix.java b/src/main/java/com/ghostipedia/cosmiccore/api/data/CosmicCoreTagPrefix.java
deleted file mode 100644
index 793b56ff4..000000000
--- a/src/main/java/com/ghostipedia/cosmiccore/api/data/CosmicCoreTagPrefix.java
+++ /dev/null
@@ -1,30 +0,0 @@
-package com.ghostipedia.cosmiccore.api.data;
-
-import com.gregtechceu.gtceu.api.data.chemical.material.info.MaterialIconType;
-import com.gregtechceu.gtceu.api.data.tag.TagPrefix;
-import com.gregtechceu.gtceu.api.registry.GTRegistries;
-
-import static com.gregtechceu.gtceu.api.data.tag.TagPrefix.Conditions.hasOreProperty;
-
-public class CosmicCoreTagPrefix {
- public static TagPrefix crushedLeached;
- public static TagPrefix prismaFrothed;
- public static void initTagPrefixes() {
- crushedLeached = new TagPrefix("leachedOre")
- .idPattern("leached_%s_ore")
- .defaultTagPath("leached_ores/%s")
- .defaultTagPath("leached_ores")
- .materialIconType(CosmicCoreMaterialIconType.crushedLeached)
- .unificationEnabled(true)
- .generateItem(true)
- .generationCondition(hasOreProperty);
- prismaFrothed = new TagPrefix("prismaFrothedOre")
- .idPattern("prisma_frothed_%s_ore")
- .defaultTagPath("prisma_frothed_ores/%s")
- .defaultTagPath("prisma_frothed_ores")
- .materialIconType(CosmicCoreMaterialIconType.prismaFrothed)
- .unificationEnabled(true)
- .generateItem(true)
- .generationCondition(hasOreProperty);
- }
-}
diff --git a/src/main/java/com/ghostipedia/cosmiccore/api/data/CosmicTagPrefix.java b/src/main/java/com/ghostipedia/cosmiccore/api/data/CosmicTagPrefix.java
new file mode 100644
index 000000000..b291c6892
--- /dev/null
+++ b/src/main/java/com/ghostipedia/cosmiccore/api/data/CosmicTagPrefix.java
@@ -0,0 +1,140 @@
+package com.ghostipedia.cosmiccore.api.data;
+
+import com.ghostipedia.cosmiccore.common.data.tag.TagUtil;
+
+import com.gregtechceu.gtceu.api.data.chemical.material.Material;
+import com.gregtechceu.gtceu.api.data.chemical.material.info.MaterialFlags;
+import com.gregtechceu.gtceu.api.data.chemical.material.properties.PropertyKey;
+import com.gregtechceu.gtceu.api.data.tag.TagPrefix;
+
+import net.minecraft.tags.TagKey;
+import net.minecraft.world.item.Item;
+import net.minecraft.world.level.block.Block;
+
+import java.util.function.Predicate;
+
+import static com.gregtechceu.gtceu.api.data.chemical.material.info.MaterialFlags.GENERATE_ROD;
+import static com.gregtechceu.gtceu.api.data.tag.TagPrefix.Conditions.hasIngotProperty;
+import static com.gregtechceu.gtceu.api.data.tag.TagPrefix.Conditions.hasOreProperty;
+
+public class CosmicTagPrefix {
+
+ public static TagPrefix crushedLeached;
+ public static TagPrefix prismaFrothed;
+ public static TagPrefix ultraDense;
+ public static TagPrefix heavyBeam;
+ public static TagPrefix modularShelling;
+ public static TagPrefix plasmites;
+ public static TagPrefix largeWireSpool;
+ public static TagPrefix alveFoilInsulator;
+ public static TagPrefix shapeMemoryFoil;
+ public static TagPrefix rawOreCubic;
+ public static final TagKey STAR_LADDER_BLOCKS = TagUtil.createBlockTag("starladder_blocks");
+ public static final TagKey- STAR_LADDER_ITEMS = TagUtil.createItemTag("starladder_items");
+
+ public static final Predicate
hasWireProp = material -> material.hasProperty(PropertyKey.WIRE);
+ public static final Predicate hasPlateProp = material -> material.hasFlag(MaterialFlags.GENERATE_PLATE);
+ public static final Predicate hasRodProp = material -> material.hasFlag(GENERATE_ROD);
+ public static final Predicate hasFrameProp = material -> material.hasFlag(MaterialFlags.GENERATE_FRAME);
+ public static final Predicate hasBoltProp = material -> material
+ .hasFlag(MaterialFlags.GENERATE_BOLT_SCREW);
+ public static final Predicate hasFineWireProp = material -> material
+ .hasFlag(MaterialFlags.GENERATE_FINE_WIRE);
+
+ public static void initTagPrefixes() {
+ rawOreCubic = new TagPrefix("rawOreCubic")
+ .idPattern("raw_%s_cubic_ore")
+ .defaultTagPath("cubic_ores/%s")
+ .defaultTagPath("cubic_ores")
+ .materialIconType(CosmicCoreMaterialIconType.rawOreCubic)
+ .unificationEnabled(true)
+ .generateItem(true)
+ .generationCondition(hasOreProperty);
+
+ crushedLeached = new TagPrefix("leachedOre")
+ .idPattern("leached_%s_ore")
+ .defaultTagPath("leached_ores/%s")
+ .defaultTagPath("leached_ores")
+ .materialIconType(CosmicCoreMaterialIconType.crushedLeached)
+ .unificationEnabled(true)
+ .generateItem(true)
+ .generationCondition(hasOreProperty);
+ prismaFrothed = new TagPrefix("prismaFrothedOre")
+ .idPattern("prisma_frothed_%s_ore")
+ .defaultTagPath("prisma_frothed_ores/%s")
+ .defaultTagPath("prisma_frothed_ores")
+ .materialIconType(CosmicCoreMaterialIconType.prismaFrothed)
+ .unificationEnabled(true)
+ .generateItem(true)
+ .generationCondition(hasOreProperty);
+
+ ultraDense = new TagPrefix("ultradensePlate")
+ .idPattern("ultradense_%s_plate")
+ .defaultTagPath("ultra_dense_plates/%s")
+ .defaultTagPath("ultra_dense_plates")
+ .materialIconType(CosmicCoreMaterialIconType.ultraDense)
+ .unificationEnabled(true)
+ .generateItem(true)
+ .maxStackSize(1)
+ .generationCondition(hasPlateProp);
+
+ heavyBeam = new TagPrefix("heavyBeam")
+ .idPattern("heavy_%s_beam")
+ .defaultTagPath("heavy_beams/%s")
+ .defaultTagPath("heavy_beams")
+ .materialIconType(CosmicCoreMaterialIconType.heavyBeam)
+ .unificationEnabled(true)
+ .generateItem(true)
+ .maxStackSize(16)
+ .generationCondition(
+ hasPlateProp
+ .and(hasRodProp));
+ modularShelling = new TagPrefix("modular_shelling")
+ .idPattern("%s_modular_shelling")
+ .defaultTagPath("modular_shellings/%s")
+ .defaultTagPath("modular_shellings")
+ .materialIconType(CosmicCoreMaterialIconType.modularShelling)
+ .unificationEnabled(true)
+ .generateItem(true)
+ .maxStackSize(16)
+ .generationCondition(hasPlateProp.and(hasFrameProp).and(hasBoltProp));
+ plasmites = new TagPrefix("plasmites")
+ .idPattern("%s_plasmites")
+ .defaultTagPath("plasmites/%s")
+ .defaultTagPath("plasmites")
+ .materialIconType(CosmicCoreMaterialIconType.plasmites)
+ .unificationEnabled(true)
+ .generateItem(true)
+ .generationCondition(hasIngotProperty);
+
+ largeWireSpool = new TagPrefix("large_wire_spool")
+ .idPattern("%s_wire_spool")
+ .defaultTagPath("wire_spools/%s")
+ .defaultTagPath("wire_spools")
+ .materialIconType(CosmicCoreMaterialIconType.wireSpool)
+ .unificationEnabled(true)
+ .generateItem(true)
+ .maxStackSize(4)
+ .generationCondition(hasWireProp.or(hasFineWireProp));
+
+ alveFoilInsulator = new TagPrefix("alveFoilInsulator")
+ .idPattern("%s_alve_foil_insulator")
+ .defaultTagPath("alve_foil_insulators/%s")
+ .defaultTagPath("alve_foil_insulators")
+ .materialIconType(CosmicCoreMaterialIconType.alveFoil)
+ .unificationEnabled(true)
+ .generateItem(true)
+ .maxStackSize(64)
+ .generationCondition(hasPlateProp.and(hasFineWireProp));
+
+ shapeMemoryFoil = new TagPrefix("shapeMemoryFoil")
+ .idPattern("%s_shape_memory_foil")
+ .defaultTagPath("shape_memory_foils/%s")
+ .defaultTagPath("shape_memory_foils")
+ .materialIconType(CosmicCoreMaterialIconType.memoryFoil)
+ .unificationEnabled(true)
+ .generateItem(true)
+ .maxStackSize(64)
+ .generationCondition(hasPlateProp.and(hasBoltProp));
+ }
+}
diff --git a/src/main/java/com/ghostipedia/cosmiccore/api/data/DebugBlockPattern.java b/src/main/java/com/ghostipedia/cosmiccore/api/data/DebugBlockPattern.java
new file mode 100644
index 000000000..0879b81eb
--- /dev/null
+++ b/src/main/java/com/ghostipedia/cosmiccore/api/data/DebugBlockPattern.java
@@ -0,0 +1,222 @@
+package com.ghostipedia.cosmiccore.api.data;
+
+import com.gregtechceu.gtceu.api.pattern.util.RelativeDirection;
+
+import net.minecraft.core.BlockPos;
+import net.minecraft.core.Direction;
+import net.minecraft.resources.ResourceLocation;
+import net.minecraft.world.level.Level;
+import net.minecraft.world.level.block.Blocks;
+import net.minecraft.world.level.block.state.BlockState;
+import net.minecraft.core.registries.BuiltInRegistries;
+
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Set;
+
+public class DebugBlockPattern {
+
+ public RelativeDirection[] structureDir;
+ public String[][] pattern;
+ public int[][] aisleRepetitions;
+ public Map> symbolMap;
+ public Map charToBlockMap;
+
+ public DebugBlockPattern() {
+ symbolMap = new HashMap<>();
+ charToBlockMap = new LinkedHashMap<>();
+ structureDir = new RelativeDirection[] {
+ RelativeDirection.LEFT, RelativeDirection.UP, RelativeDirection.FRONT
+ };
+ }
+
+ public DebugBlockPattern(
+ Level world, int minX, int minY, int minZ, int maxX, int maxY, int maxZ) {
+ this();
+ pattern = new String[1 + maxX - minX][1 + maxY - minY];
+ aisleRepetitions = new int[pattern.length][2];
+ for (int[] aisleRepetition : aisleRepetitions) {
+ aisleRepetition[0] = 1;
+ aisleRepetition[1] = 1;
+ }
+
+ Map map = new HashMap<>();
+ map.put(Blocks.AIR.defaultBlockState(), ' ');
+ charToBlockMap.put(' ', BuiltInRegistries.BLOCK.getKey(Blocks.AIR));
+
+ char c = 'A'; // auto
+
+ for (int x = minX; x <= maxX; x++) {
+ for (int y = minY; y <= maxY; y++) {
+ StringBuilder builder = new StringBuilder();
+ for (int z = minZ; z <= maxZ; z++) {
+ BlockPos pos = new BlockPos(x, y, z);
+ BlockState state = world.getBlockState(pos);
+ if (!map.containsKey(state)) {
+ map.put(state, c);
+ String name = String.valueOf(c);
+ symbolMap.computeIfAbsent(c, key -> new HashSet<>()).add(name); // any
+ ResourceLocation blockKey = BuiltInRegistries.BLOCK.getKey(state.getBlock());
+ charToBlockMap.put(c, blockKey);
+ c++;
+ }
+ builder.append(map.get(state));
+ }
+ pattern[x - minX][y - minY] = builder.toString();
+ }
+ }
+ var dirs = getDir(Direction.NORTH);
+ changeDir(dirs[0], dirs[1], dirs[2]);
+ }
+
+ public static RelativeDirection[] getDir(Direction facing) {
+ if (facing == Direction.WEST) {
+ return new RelativeDirection[] {
+ RelativeDirection.LEFT, RelativeDirection.UP, RelativeDirection.BACK
+ };
+ } else if (facing == Direction.EAST) {
+ return new RelativeDirection[] {
+ RelativeDirection.RIGHT, RelativeDirection.UP, RelativeDirection.FRONT
+ };
+ } else if (facing == Direction.NORTH) {
+ return new RelativeDirection[] {
+ RelativeDirection.BACK, RelativeDirection.UP, RelativeDirection.RIGHT
+ };
+ } else if (facing == Direction.SOUTH) {
+ return new RelativeDirection[] {
+ RelativeDirection.FRONT, RelativeDirection.UP, RelativeDirection.LEFT
+ };
+ } else if (facing == Direction.DOWN) {
+ return new RelativeDirection[] {
+ RelativeDirection.RIGHT, RelativeDirection.BACK, RelativeDirection.UP
+ };
+ } else {
+ return new RelativeDirection[] {
+ RelativeDirection.LEFT, RelativeDirection.FRONT, RelativeDirection.UP
+ };
+ }
+ }
+
+ public void changeDir(
+ RelativeDirection charDir, RelativeDirection stringDir, RelativeDirection aisleDir) {
+ if (charDir.isSameAxis(stringDir) || stringDir.isSameAxis(aisleDir) || aisleDir.isSameAxis(charDir)) return;
+ char[][][] newPattern = new char[structureDir[0].isSameAxis(aisleDir) ? pattern[0][0].length() :
+ structureDir[1].isSameAxis(aisleDir) ? pattern[0].length : pattern.length][structureDir[0]
+ .isSameAxis(stringDir) ?
+ pattern[0][0].length() :
+ structureDir[1].isSameAxis(stringDir) ? pattern[0].length :
+ pattern.length][structureDir[0].isSameAxis(charDir) ? pattern[0][0].length() :
+ structureDir[1].isSameAxis(charDir) ? pattern[0].length :
+ pattern.length];
+ for (int i = 0; i < pattern.length; i++) {
+ for (int j = 0; j < pattern[0].length; j++) {
+ for (int k = 0; k < pattern[0][0].length(); k++) {
+ char c = pattern[i][j].charAt(k);
+ int x = 0, y = 0, z = 0;
+ if (structureDir[2].isSameAxis(aisleDir)) {
+ if (structureDir[2] == aisleDir) {
+ x = i;
+ } else {
+ x = pattern.length - i - 1;
+ }
+ } else if (structureDir[2].isSameAxis(stringDir)) {
+ if (structureDir[2] == stringDir) {
+ y = i;
+ } else {
+ y = pattern.length - i - 1;
+ }
+ } else if (structureDir[2].isSameAxis(charDir)) {
+ if (structureDir[2] == charDir) {
+ z = i;
+ } else {
+ z = pattern.length - i - 1;
+ }
+ }
+
+ if (structureDir[1].isSameAxis(aisleDir)) {
+ if (structureDir[1] == aisleDir) {
+ x = j;
+ } else {
+ x = pattern[0].length - j - 1;
+ }
+ } else if (structureDir[1].isSameAxis(stringDir)) {
+ if (structureDir[1] == stringDir) {
+ y = j;
+ } else {
+ y = pattern[0].length - j - 1;
+ }
+ } else if (structureDir[1].isSameAxis(charDir)) {
+ if (structureDir[1] == charDir) {
+ z = j;
+ } else {
+ z = pattern[0].length - j - 1;
+ }
+ }
+
+ if (structureDir[0].isSameAxis(aisleDir)) {
+ if (structureDir[0] == aisleDir) {
+ x = k;
+ } else {
+ x = pattern[0][0].length() - k - 1;
+ }
+ } else if (structureDir[0].isSameAxis(stringDir)) {
+ if (structureDir[0] == stringDir) {
+ y = k;
+ } else {
+ y = pattern[0][0].length() - k - 1;
+ }
+ } else if (structureDir[0].isSameAxis(charDir)) {
+ if (structureDir[0] == charDir) {
+ z = k;
+ } else {
+ z = pattern[0][0].length() - k - 1;
+ }
+ }
+ newPattern[x][y][z] = c;
+ }
+ }
+ }
+
+ pattern = new String[newPattern.length][newPattern[0].length];
+ for (int i = 0; i < pattern.length; i++) {
+ for (int j = 0; j < pattern[0].length; j++) {
+ StringBuilder builder = new StringBuilder();
+ for (char c : newPattern[i][j]) {
+ builder.append(c);
+ }
+ pattern[i][j] = builder.toString();
+ }
+ }
+
+ aisleRepetitions = new int[pattern.length][2];
+ for (int[] aisleRepetition : aisleRepetitions) {
+ aisleRepetition[0] = 1;
+ aisleRepetition[1] = 1;
+ }
+
+ structureDir = new RelativeDirection[] { charDir, stringDir, aisleDir };
+ }
+
+ public DebugBlockPattern copy() {
+ DebugBlockPattern newPattern = new DebugBlockPattern();
+ System.arraycopy(this.structureDir, 0, newPattern.structureDir, 0, this.structureDir.length);
+
+ newPattern.pattern = new String[pattern.length][pattern[0].length];
+ for (int i = 0; i < pattern.length; i++) {
+ System.arraycopy(pattern[i], 0, newPattern.pattern[i], 0, pattern[i].length);
+ }
+
+ newPattern.aisleRepetitions = new int[aisleRepetitions.length][2];
+ for (int i = 0; i < aisleRepetitions.length; i++) {
+ System.arraycopy(
+ aisleRepetitions[i], 0, newPattern.aisleRepetitions[i], 0, aisleRepetitions[i].length);
+ }
+
+ symbolMap.forEach((k, v) -> newPattern.symbolMap.put(k, new HashSet<>(v)));
+ newPattern.charToBlockMap.putAll(this.charToBlockMap);
+
+ return newPattern;
+ }
+}
diff --git a/src/main/java/com/ghostipedia/cosmiccore/api/data/material/property/CCoreMaterialIconSet.java b/src/main/java/com/ghostipedia/cosmiccore/api/data/material/property/CCoreMaterialIconSet.java
new file mode 100644
index 000000000..64dabd837
--- /dev/null
+++ b/src/main/java/com/ghostipedia/cosmiccore/api/data/material/property/CCoreMaterialIconSet.java
@@ -0,0 +1,82 @@
+package com.ghostipedia.cosmiccore.api.data.material.property;
+
+import com.ghostipedia.cosmiccore.CosmicCore;
+import com.ghostipedia.cosmiccore.client.renderer.item.HaloItemRenderer;
+import com.ghostipedia.cosmiccore.common.data.materials.CosmicMaterialSet;
+import com.ghostipedia.cosmiccore.utils.ColorUtil;
+
+import com.gregtechceu.gtceu.api.data.chemical.material.info.MaterialIconSet;
+import com.gregtechceu.gtceu.api.item.component.ICustomRenderer;
+
+import com.lowdragmc.lowdraglib.client.renderer.IRenderer;
+
+import net.minecraft.resources.ResourceLocation;
+
+import lombok.Getter;
+
+import java.util.function.Supplier;
+
+@Getter
+public class CCoreMaterialIconSet extends MaterialIconSet implements IRenderer {
+
+ private final ICustomRenderer customRender;
+
+ public CCoreMaterialIconSet(String name, MaterialIconSet parentIconset, boolean root, IRenderer renderer) {
+ this(name, parentIconset, root, renderer == null ? null : () -> renderer);
+ }
+
+ public CCoreMaterialIconSet(String name, MaterialIconSet parentIconset, boolean root, ICustomRenderer renderer) {
+ super(name, parentIconset, root);
+ this.customRender = renderer;
+ }
+
+ static final Supplier prismaticColor = () -> {
+ float v = (float) ((System.currentTimeMillis() / 500) % 10) / 10;
+ if (v > 0.5f)
+ v = 1 - v;
+ return (0xff << 24) | ColorUtil.lerpColorRGB(0xffc0cb, 0x000080, v * 2);
+ };
+
+ public static final CCoreMaterialIconSet VIBRANIUM = new CCoreMaterialIconSet("vibranium",
+ CosmicMaterialSet.NEUTRONITE, false,
+ HaloItemRenderer.create(4, 0xFF489BC3, ResourceLocation.fromNamespaceAndPath(CosmicCore.MOD_ID, "block/iris/rnd/halo"), true,
+ false));
+ public static final CCoreMaterialIconSet VIBRANIUM_NEUTRONIUM = new CCoreMaterialIconSet("vibranium_neutronium",
+ CosmicMaterialSet.NEUTRONIUM_CCORE, false,
+ HaloItemRenderer.create(3, 0xFFFFFFFF, ResourceLocation.fromNamespaceAndPath(CosmicCore.MOD_ID,
+ "block/iris/rnd/compression_halo_neutronium_faded"), true,
+ false));
+
+ // public static final CCoreMaterialIconSet VIBRANIUM_NEUTRONIUM = new CCoreMaterialIconSet("vibranium_neutronium",
+ // CosmicMaterialSet.NEUTRONIUM_CCORE, false, LensRender::new);
+
+ public static final CCoreMaterialIconSet PRISMATIC = new CCoreMaterialIconSet("prismatic", SHINY, false,
+ HaloItemRenderer.create(8, 0xFF1c1926, ResourceLocation.fromNamespaceAndPath(CosmicCore.MOD_ID, "block/iris/rnd/storm_halo"),
+ true,
+ false));
+
+ public static final CCoreMaterialIconSet CHRONIC = new CCoreMaterialIconSet("chronic", CosmicMaterialSet.CHRONON,
+ false,
+ HaloItemRenderer.create(8, 0xFF1c1926, ResourceLocation.fromNamespaceAndPath(CosmicCore.MOD_ID, "block/iris/rnd/time_halo"),
+ true,
+ false));
+
+ public static final CCoreMaterialIconSet VOIDSPARKICO = new CCoreMaterialIconSet("voidspark_special",
+ CosmicMaterialSet.VOIDSPARK, false,
+ HaloItemRenderer.create(4, 0xFFFFFFFF,
+ ResourceLocation.fromNamespaceAndPath(CosmicCore.MOD_ID, "block/iris/rnd/tentacle_halo"), true,
+ false));
+
+ public static final CCoreMaterialIconSet STARMETALICO = new CCoreMaterialIconSet("starmetal_special",
+ CosmicMaterialSet.STARMETAL, false,
+ HaloItemRenderer.create(2, 0xFFFFFFFF,
+ ResourceLocation.fromNamespaceAndPath(CosmicCore.MOD_ID, "block/iris/rnd/tentacle_halo_glass"), true,
+ true));
+
+ public static final CCoreMaterialIconSet SOL_STEEL = new CCoreMaterialIconSet("sol_steel", CosmicMaterialSet.SOL,
+ false,
+ HaloItemRenderer.create(4, 0xFFFFFFFF,
+ ResourceLocation.fromNamespaceAndPath(CosmicCore.MOD_ID, "block/iris/rnd/compression_halo_sol"),
+ true,
+ false));
+}
diff --git a/src/main/java/com/ghostipedia/cosmiccore/api/data/savedData/LinkEntry.java b/src/main/java/com/ghostipedia/cosmiccore/api/data/savedData/LinkEntry.java
new file mode 100644
index 000000000..742522fc7
--- /dev/null
+++ b/src/main/java/com/ghostipedia/cosmiccore/api/data/savedData/LinkEntry.java
@@ -0,0 +1,79 @@
+package com.ghostipedia.cosmiccore.api.data.savedData;
+
+import com.ghostipedia.cosmiccore.api.capability.ILinkedMultiblock.LinkRole;
+
+import net.minecraft.core.GlobalPos;
+import net.minecraft.nbt.CompoundTag;
+import net.minecraft.nbt.NbtOps;
+
+import java.util.Objects;
+
+/**
+ * Represents one end of a link from this machine's perspective.
+ * Stores the target position and this machine's EFFECTIVE role
+ * (after negotiation, not declared role).
+ */
+public final class LinkEntry {
+
+ private static final String TAG_TARGET = "Target";
+ private static final String TAG_ROLE = "Role";
+
+ private final GlobalPos target;
+ private final LinkRole effectiveRole;
+
+ public LinkEntry(GlobalPos target, LinkRole effectiveRole) {
+ this.target = target;
+ this.effectiveRole = effectiveRole;
+ }
+
+ public GlobalPos target() {
+ return target;
+ }
+
+ public LinkRole effectiveRole() {
+ return effectiveRole;
+ }
+
+ public CompoundTag save() {
+ CompoundTag tag = new CompoundTag();
+ GlobalPos.CODEC.encodeStart(NbtOps.INSTANCE, target)
+ .result()
+ .ifPresent(encoded -> tag.put(TAG_TARGET, encoded));
+ tag.putString(TAG_ROLE, effectiveRole.name());
+ return tag;
+ }
+
+ public static LinkEntry load(CompoundTag tag) {
+ GlobalPos target = GlobalPos.CODEC
+ .decode(NbtOps.INSTANCE, tag.get(TAG_TARGET))
+ .result()
+ .map(pair -> pair.getFirst())
+ .orElseThrow(() -> new IllegalStateException("Invalid LinkEntry: missing target"));
+
+ LinkRole role = LinkRole.valueOf(tag.getString(TAG_ROLE));
+ return new LinkEntry(target, role);
+ }
+
+ /**
+ * Equality is based ONLY on target, not role.
+ * This ensures only one link per target in a Set, and re-linking
+ * with a different role will replace the existing entry.
+ */
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ LinkEntry linkEntry = (LinkEntry) o;
+ return Objects.equals(target, linkEntry.target);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(target);
+ }
+
+ @Override
+ public String toString() {
+ return "LinkEntry{target=" + target + ", role=" + effectiveRole + "}";
+ }
+}
diff --git a/src/main/java/com/ghostipedia/cosmiccore/api/data/savedData/LinkedMultiblockSavedData.java b/src/main/java/com/ghostipedia/cosmiccore/api/data/savedData/LinkedMultiblockSavedData.java
new file mode 100644
index 000000000..a3e86600e
--- /dev/null
+++ b/src/main/java/com/ghostipedia/cosmiccore/api/data/savedData/LinkedMultiblockSavedData.java
@@ -0,0 +1,301 @@
+package com.ghostipedia.cosmiccore.api.data.savedData;
+
+import com.ghostipedia.cosmiccore.CosmicCore;
+import com.ghostipedia.cosmiccore.api.capability.ILinkedMultiblock.LinkRole;
+
+import net.minecraft.core.GlobalPos;
+import net.minecraft.core.HolderLookup;
+import net.minecraft.nbt.CompoundTag;
+import net.minecraft.nbt.ListTag;
+import net.minecraft.nbt.NbtOps;
+import net.minecraft.nbt.Tag;
+import net.minecraft.server.MinecraftServer;
+import net.minecraft.server.level.ServerLevel;
+import net.minecraft.world.level.saveddata.SavedData;
+
+import org.jetbrains.annotations.Nullable;
+
+import java.util.*;
+
+/**
+ * Persists multiblock link relationships across server restarts.
+ * Links are keyed by team UUID for access control.
+ *
+ * Each machine stores its own perspective with EFFECTIVE role
+ * (result of negotiation, not declared role).
+ */
+public class LinkedMultiblockSavedData extends SavedData {
+
+ private static final String DATA_NAME = "cosmiccore_linked_multiblocks";
+
+ // Owner UUID -> (Machine GlobalPos -> Set of LinkEntry)
+ private final Map>> links = new HashMap<>();
+
+ public LinkedMultiblockSavedData() {}
+
+ public LinkedMultiblockSavedData(CompoundTag tag) {
+ load(tag);
+ }
+
+ public static LinkedMultiblockSavedData load(CompoundTag tag, HolderLookup.Provider provider) {
+ return new LinkedMultiblockSavedData(tag);
+ }
+
+ // ==================== Access ====================
+
+ /**
+ * Get or create the SavedData instance.
+ * Stored in overworld to ensure single source of truth.
+ */
+ public static LinkedMultiblockSavedData getOrCreate(MinecraftServer server) {
+ ServerLevel overworld = server.overworld();
+ return overworld.getDataStorage().computeIfAbsent(
+ new SavedData.Factory<>(LinkedMultiblockSavedData::new, LinkedMultiblockSavedData::load),
+ DATA_NAME);
+ }
+
+ public static LinkedMultiblockSavedData getOrCreate(ServerLevel level) {
+ return getOrCreate(level.getServer());
+ }
+
+ // ==================== Link Management ====================
+
+ /**
+ * Establish a link between two machines with pre-negotiated roles.
+ *
+ * If a link already exists between these machines, it will be replaced
+ * with the new roles.
+ *
+ * IMPORTANT: Roles should be the result of negotiateRoles(), not raw declared roles.
+ *
+ * @param owner Team/player UUID (must match for both machines)
+ * @param a First machine's position
+ * @param b Second machine's position
+ * @param aEffectiveRole A's effective role after negotiation
+ * @param bEffectiveRole B's effective role after negotiation
+ */
+ public void link(UUID owner, GlobalPos a, GlobalPos b,
+ LinkRole aEffectiveRole, LinkRole bEffectiveRole) {
+ // A's perspective - remove existing then add (to handle role changes)
+ Set aLinks = links.computeIfAbsent(owner, k -> new HashMap<>())
+ .computeIfAbsent(a, k -> new HashSet<>());
+ aLinks.remove(new LinkEntry(b, null)); // equals only checks target
+ aLinks.add(new LinkEntry(b, aEffectiveRole));
+
+ // B's perspective
+ Set bLinks = links.get(owner)
+ .computeIfAbsent(b, k -> new HashSet<>());
+ bLinks.remove(new LinkEntry(a, null)); // equals only checks target
+ bLinks.add(new LinkEntry(a, bEffectiveRole));
+
+ setDirty();
+ }
+
+ /**
+ * Remove a specific link between two machines.
+ * Removes both perspectives.
+ */
+ public void unlink(UUID owner, GlobalPos a, GlobalPos b) {
+ Map> ownerLinks = links.get(owner);
+ if (ownerLinks == null) return;
+
+ // Remove A -> B
+ Set aLinks = ownerLinks.get(a);
+ if (aLinks != null) {
+ aLinks.removeIf(entry -> entry.target().equals(b));
+ if (aLinks.isEmpty()) {
+ ownerLinks.remove(a);
+ }
+ }
+
+ // Remove B -> A
+ Set bLinks = ownerLinks.get(b);
+ if (bLinks != null) {
+ bLinks.removeIf(entry -> entry.target().equals(a));
+ if (bLinks.isEmpty()) {
+ ownerLinks.remove(b);
+ }
+ }
+
+ // Cleanup empty owner entry
+ if (ownerLinks.isEmpty()) {
+ links.remove(owner);
+ }
+
+ setDirty();
+ }
+
+ /**
+ * Remove all links for a machine (called when multiblock is destroyed).
+ * Also removes reverse links from all partners.
+ */
+ public void removeAllLinks(UUID owner, GlobalPos pos) {
+ Map> ownerLinks = links.get(owner);
+ if (ownerLinks == null) return;
+
+ // Get partners before removal
+ Set myLinks = ownerLinks.remove(pos);
+
+ // Remove reverse links from partners
+ if (myLinks != null) {
+ for (LinkEntry entry : myLinks) {
+ Set partnerLinks = ownerLinks.get(entry.target());
+ if (partnerLinks != null) {
+ partnerLinks.removeIf(e -> e.target().equals(pos));
+ if (partnerLinks.isEmpty()) {
+ ownerLinks.remove(entry.target());
+ }
+ }
+ }
+ }
+
+ if (ownerLinks.isEmpty()) {
+ links.remove(owner);
+ }
+
+ setDirty();
+ }
+
+ // ==================== Queries ====================
+
+ /**
+ * Get all links for a machine.
+ * Returns an unmodifiable copy to prevent external mutation.
+ */
+ public Set getLinks(UUID owner, GlobalPos pos) {
+ Set result = links.getOrDefault(owner, Collections.emptyMap())
+ .getOrDefault(pos, Collections.emptySet());
+ return Collections.unmodifiableSet(new HashSet<>(result));
+ }
+
+ /**
+ * Get just the partner positions (without role info).
+ */
+ public Set getPartnerPositions(UUID owner, GlobalPos pos) {
+ Set result = new HashSet<>();
+ for (LinkEntry entry : getLinks(owner, pos)) {
+ result.add(entry.target());
+ }
+ return result;
+ }
+
+ /**
+ * Get the specific link to a partner, if it exists.
+ */
+ @Nullable
+ public LinkEntry getLinkTo(UUID owner, GlobalPos self, GlobalPos partner) {
+ for (LinkEntry entry : getLinks(owner, self)) {
+ if (entry.target().equals(partner)) {
+ return entry;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Check if this machine can query the partner (based on effective role).
+ * PEER and CONTROLLER can query; REMOTE cannot.
+ */
+ public boolean canQuery(UUID owner, GlobalPos self, GlobalPos partner) {
+ LinkEntry link = getLinkTo(owner, self, partner);
+ if (link == null) return false;
+
+ return link.effectiveRole() == LinkRole.PEER || link.effectiveRole() == LinkRole.CONTROLLER;
+ }
+
+ /**
+ * Check if this machine can be queried by the partner (based on effective role).
+ * PEER and REMOTE can be queried; CONTROLLER cannot.
+ */
+ public boolean canBeQueriedBy(UUID owner, GlobalPos self, GlobalPos partner) {
+ LinkEntry link = getLinkTo(owner, self, partner);
+ if (link == null) return false;
+
+ return link.effectiveRole() == LinkRole.PEER || link.effectiveRole() == LinkRole.REMOTE;
+ }
+
+ /**
+ * Check if two machines are linked (regardless of role).
+ */
+ public boolean isLinked(UUID owner, GlobalPos a, GlobalPos b) {
+ return getLinkTo(owner, a, b) != null;
+ }
+
+ // ==================== Serialization ====================
+
+ @Override
+ public CompoundTag save(CompoundTag root, HolderLookup.Provider provider) {
+ ListTag ownersList = new ListTag();
+
+ for (Map.Entry>> ownerEntry : links.entrySet()) {
+ CompoundTag ownerTag = new CompoundTag();
+ ownerTag.putUUID("Owner", ownerEntry.getKey());
+
+ ListTag machinesList = new ListTag();
+ for (Map.Entry> machineEntry : ownerEntry.getValue().entrySet()) {
+ CompoundTag machineTag = new CompoundTag();
+
+ GlobalPos.CODEC.encodeStart(NbtOps.INSTANCE, machineEntry.getKey())
+ .result()
+ .ifPresent(encoded -> machineTag.put("Pos", encoded));
+
+ ListTag linksList = new ListTag();
+ for (LinkEntry link : machineEntry.getValue()) {
+ linksList.add(link.save());
+ }
+ machineTag.put("Links", linksList);
+
+ machinesList.add(machineTag);
+ }
+ ownerTag.put("Machines", machinesList);
+
+ ownersList.add(ownerTag);
+ }
+
+ root.put("Owners", ownersList);
+ return root;
+ }
+
+ private void load(CompoundTag root) {
+ links.clear();
+
+ ListTag ownersList = root.getList("Owners", Tag.TAG_COMPOUND);
+ for (int i = 0; i < ownersList.size(); i++) {
+ CompoundTag ownerTag = ownersList.getCompound(i);
+ UUID owner = ownerTag.getUUID("Owner");
+
+ Map> ownerLinks = new HashMap<>();
+
+ ListTag machinesList = ownerTag.getList("Machines", Tag.TAG_COMPOUND);
+ for (int j = 0; j < machinesList.size(); j++) {
+ CompoundTag machineTag = machinesList.getCompound(j);
+
+ GlobalPos pos = GlobalPos.CODEC
+ .decode(NbtOps.INSTANCE, machineTag.get("Pos"))
+ .result()
+ .map(pair -> pair.getFirst())
+ .orElse(null);
+
+ if (pos == null) continue;
+
+ Set machineLinks = new HashSet<>();
+ ListTag linksList = machineTag.getList("Links", Tag.TAG_COMPOUND);
+ for (int k = 0; k < linksList.size(); k++) {
+ try {
+ machineLinks.add(LinkEntry.load(linksList.getCompound(k)));
+ } catch (Exception e) {
+ CosmicCore.LOGGER.warn("Failed to load link entry: {}", e.getMessage());
+ }
+ }
+
+ if (!machineLinks.isEmpty()) {
+ ownerLinks.put(pos, machineLinks);
+ }
+ }
+
+ if (!ownerLinks.isEmpty()) {
+ links.put(owner, ownerLinks);
+ }
+ }
+ }
+}
diff --git a/src/main/java/com/ghostipedia/cosmiccore/api/data/savedData/StarLadderSavedData.java b/src/main/java/com/ghostipedia/cosmiccore/api/data/savedData/StarLadderSavedData.java
new file mode 100644
index 000000000..291e2705b
--- /dev/null
+++ b/src/main/java/com/ghostipedia/cosmiccore/api/data/savedData/StarLadderSavedData.java
@@ -0,0 +1,82 @@
+package com.ghostipedia.cosmiccore.api.data.savedData;
+
+import com.ghostipedia.cosmiccore.CosmicCore;
+
+import net.minecraft.core.HolderLookup;
+import net.minecraft.nbt.CompoundTag;
+import net.minecraft.nbt.ListTag;
+import net.minecraft.nbt.Tag;
+import net.minecraft.server.MinecraftServer;
+import net.minecraft.server.level.ServerLevel;
+import net.minecraft.world.level.saveddata.SavedData;
+
+import org.jetbrains.annotations.NotNull;
+
+import java.util.HashSet;
+import java.util.Set;
+import java.util.UUID;
+
+public class StarLadderSavedData extends SavedData {
+
+ private static final String DATA_NAME = CosmicCore.MOD_ID + "_star_ladder_data";
+ private static final String ESTABLISHED_KEY = "established_teams";
+ private static final String UUID_KEY = "uuid";
+
+ private final Set establishedTeams = new HashSet<>();
+
+ public StarLadderSavedData() {}
+
+ public StarLadderSavedData(CompoundTag tag) {
+ var list = tag.getList(ESTABLISHED_KEY, Tag.TAG_COMPOUND);
+ for (Tag entry : list) {
+ if (entry instanceof CompoundTag compound) {
+ establishedTeams.add(UUID.fromString(compound.getString(UUID_KEY)));
+ }
+ }
+ }
+
+ public static StarLadderSavedData load(CompoundTag tag, HolderLookup.Provider provider) {
+ return new StarLadderSavedData(tag);
+ }
+
+ public static StarLadderSavedData getOrCreate(MinecraftServer server) {
+ ServerLevel overworld = server.overworld();
+ return overworld.getDataStorage().computeIfAbsent(
+ new SavedData.Factory<>(StarLadderSavedData::new, StarLadderSavedData::load),
+ DATA_NAME);
+ }
+
+ public static StarLadderSavedData getOrCreate(ServerLevel level) {
+ return getOrCreate(level.getServer());
+ }
+
+ public boolean isEstablished(UUID teamId) {
+ return establishedTeams.contains(teamId);
+ }
+
+ public void setEstablished(UUID teamId) {
+ if (establishedTeams.add(teamId)) {
+ setDirty();
+ }
+ }
+
+ public boolean resetEstablished(UUID teamId) {
+ if (establishedTeams.remove(teamId)) {
+ setDirty();
+ return true;
+ }
+ return false;
+ }
+
+ @Override
+ public @NotNull CompoundTag save(@NotNull CompoundTag tag, @NotNull HolderLookup.Provider provider) {
+ var list = new ListTag();
+ for (UUID uuid : establishedTeams) {
+ var entry = new CompoundTag();
+ entry.putString(UUID_KEY, uuid.toString());
+ list.add(entry);
+ }
+ tag.put(ESTABLISHED_KEY, list);
+ return tag;
+ }
+}
diff --git a/src/main/java/com/ghostipedia/cosmiccore/api/data/savedData/UniqueMultiblockData.java b/src/main/java/com/ghostipedia/cosmiccore/api/data/savedData/UniqueMultiblockData.java
new file mode 100644
index 000000000..1316deac0
--- /dev/null
+++ b/src/main/java/com/ghostipedia/cosmiccore/api/data/savedData/UniqueMultiblockData.java
@@ -0,0 +1,104 @@
+package com.ghostipedia.cosmiccore.api.data.savedData;
+
+import net.minecraft.core.BlockPos;
+import net.minecraft.nbt.CompoundTag;
+import net.minecraft.nbt.ListTag;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Objects;
+
+public class UniqueMultiblockData {
+
+ public static class UniqueMultiblockId {
+
+ private final String multiblockType;
+ private final String multiblockDimension;
+
+ protected UniqueMultiblockId(String multiblockType, String multiblockDimension) {
+ this.multiblockType = multiblockType;
+ this.multiblockDimension = multiblockDimension;
+ }
+
+ public String getMultiblockType() {
+ return multiblockType;
+ }
+
+ public String getMultiblockDimension() {
+ return multiblockDimension;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ UniqueMultiblockId that = (UniqueMultiblockId) o;
+ return Objects.equals(multiblockType, that.multiblockType) &&
+ Objects.equals(multiblockDimension, that.multiblockDimension);
+ }
+
+ @Override
+ public int hashCode() {
+ int result = 17; // Some arbitrary prime number
+ result = 31 * result + multiblockType.hashCode();
+ result = 31 * result + multiblockDimension.hashCode();
+ return result;
+ }
+ }
+
+ private static final String MULTIBLOCK_TYPE = "multiblockType";
+ private static final String MULTIBLOCK_DIMENSION = "multiblockDimension";
+ private static final String MULTIBLOCK_POS = "multiblockPos";
+
+ // Map a tuple of "Multiblock Type" and "Dimension Name" to a "BlockPos"
+ public Map data;
+
+ public UniqueMultiblockData() {
+ this.data = new HashMap<>();
+ }
+
+ public static UniqueMultiblockData fromTag(ListTag tag) {
+ var result = new UniqueMultiblockData();
+ for (int i = 0; i < tag.size(); ++i) {
+ CompoundTag entry = tag.getCompound(i);
+ var type = entry.getString(MULTIBLOCK_TYPE);
+ var dimension = entry.getString(MULTIBLOCK_DIMENSION);
+ var pos = BlockPos.of(entry.getLong(MULTIBLOCK_POS));
+ result.data.put(new UniqueMultiblockId(type, dimension), pos);
+ }
+ return result;
+ }
+
+ public ListTag toTag() {
+ var uniqueMultiblockData = new ListTag();
+ for (var entry : data.entrySet()) {
+ if (entry.getKey() == null || entry.getValue() == null) continue;
+ var entryTag = new CompoundTag();
+ entryTag.putString(MULTIBLOCK_TYPE, entry.getKey().getMultiblockType());
+ entryTag.putString(MULTIBLOCK_DIMENSION, entry.getKey().getMultiblockDimension());
+ entryTag.putLong(MULTIBLOCK_POS, entry.getValue().asLong());
+ uniqueMultiblockData.add(entryTag);
+ }
+ return uniqueMultiblockData;
+ }
+
+ public boolean hasData(String multiblockType, String dimension) {
+ return data.containsKey(new UniqueMultiblockId(multiblockType, dimension));
+ }
+
+ public boolean isUnique(String multiblockType, String dimension, BlockPos pos) {
+ var key = new UniqueMultiblockId(multiblockType, dimension);
+ if (!data.containsKey(key)) return true;
+ else return data.get(key).equals(pos);
+ }
+
+ public void addMultiblock(String multiblockType, String dimension, BlockPos pos) {
+ data.put(new UniqueMultiblockId(multiblockType, dimension), pos);
+ }
+
+ public void removeMultiblock(String multiblockType, String dimension, BlockPos pos) {
+ var key = new UniqueMultiblockId(multiblockType, dimension);
+ if (!hasData(multiblockType, dimension)) return;
+ if (data.get(key).equals(pos)) data.remove(key);
+ }
+}
diff --git a/src/main/java/com/ghostipedia/cosmiccore/api/data/savedData/UniqueMultiblockSavedData.java b/src/main/java/com/ghostipedia/cosmiccore/api/data/savedData/UniqueMultiblockSavedData.java
new file mode 100644
index 000000000..22549266c
--- /dev/null
+++ b/src/main/java/com/ghostipedia/cosmiccore/api/data/savedData/UniqueMultiblockSavedData.java
@@ -0,0 +1,84 @@
+package com.ghostipedia.cosmiccore.api.data.savedData;
+
+import net.minecraft.core.BlockPos;
+import net.minecraft.core.HolderLookup;
+import net.minecraft.nbt.CompoundTag;
+import net.minecraft.nbt.ListTag;
+import net.minecraft.nbt.Tag;
+import net.minecraft.server.level.ServerLevel;
+import net.minecraft.world.level.saveddata.SavedData;
+
+import org.jetbrains.annotations.NotNull;
+
+import java.util.HashMap;
+import java.util.UUID;
+
+public class UniqueMultiblockSavedData extends SavedData {
+
+ private static final String DATA_NAME = "cosmiccore_unique_multiblock_data";
+ private static final String UNIQUE_MULTI_MAPPING = "uniqueMultiMapping";
+ private static final String UNIQUE_MULTI_UUID = "uniqueMultiUuid";
+ private static final String UNIQUE_MULTI_DATA = "uniqueMultiData";
+
+ public static final HashMap UniqueMultiblockMapping = new HashMap<>(20, 0.9f);
+
+ private final ServerLevel serverLevel;
+
+ public static UniqueMultiblockSavedData getOrCreate(ServerLevel serverLevel) {
+ return serverLevel.getDataStorage().computeIfAbsent(
+ new SavedData.Factory<>(() -> new UniqueMultiblockSavedData(serverLevel),
+ (tag, provider) -> new UniqueMultiblockSavedData(serverLevel, tag)),
+ DATA_NAME);
+ }
+
+ private UniqueMultiblockSavedData(ServerLevel serverLevel) {
+ this.serverLevel = serverLevel;
+ }
+
+ private UniqueMultiblockSavedData(ServerLevel serverLevel, CompoundTag nbt) {
+ this(serverLevel);
+ var list = nbt.getList(UNIQUE_MULTI_MAPPING, CompoundTag.TAG_COMPOUND);
+ for (Tag tag : list) {
+ if (tag instanceof CompoundTag compoundTag) {
+ var uuid = UUID.fromString(compoundTag.getString(UNIQUE_MULTI_UUID));
+ var data = compoundTag.getList(UNIQUE_MULTI_DATA, Tag.TAG_COMPOUND);
+ UniqueMultiblockMapping.put(uuid, UniqueMultiblockData.fromTag(data));
+ }
+ }
+ }
+
+ @Override
+ public @NotNull CompoundTag save(@NotNull CompoundTag compoundTag, @NotNull HolderLookup.Provider provider) {
+ var uniqueMultiDataList = new ListTag();
+ for (var entry : UniqueMultiblockMapping.entrySet()) {
+ var tag = new CompoundTag();
+ tag.putString(UNIQUE_MULTI_UUID, entry.getKey().toString());
+ tag.put(UNIQUE_MULTI_DATA, entry.getValue().toTag());
+ uniqueMultiDataList.add(tag);
+ }
+ compoundTag.put(UNIQUE_MULTI_MAPPING, uniqueMultiDataList);
+ return compoundTag;
+ }
+
+ public boolean hasData(UUID owner, String multiblockType, String dimension) {
+ var data = UniqueMultiblockMapping.computeIfAbsent(owner, uuid -> new UniqueMultiblockData());
+ return data.hasData(multiblockType, dimension);
+ }
+
+ public boolean isUnique(UUID owner, String multiblockType, String dimension, BlockPos pos) {
+ var data = UniqueMultiblockMapping.computeIfAbsent(owner, uuid -> new UniqueMultiblockData());
+ return data.isUnique(multiblockType, dimension, pos);
+ }
+
+ public void addMultiblock(UUID owner, String multiblockType, String dimension, BlockPos pos) {
+ var data = UniqueMultiblockMapping.computeIfAbsent(owner, uuid -> new UniqueMultiblockData());
+ data.addMultiblock(multiblockType, dimension, pos);
+ setDirty();
+ }
+
+ public void removeMultiblock(UUID owner, String multiblockType, String dimension, BlockPos pos) {
+ var data = UniqueMultiblockMapping.computeIfAbsent(owner, uuid -> new UniqueMultiblockData());
+ data.removeMultiblock(multiblockType, dimension, pos);
+ setDirty();
+ }
+}
diff --git a/src/main/java/com/ghostipedia/cosmiccore/api/data/souls/SoulNetwork.java b/src/main/java/com/ghostipedia/cosmiccore/api/data/souls/SoulNetwork.java
new file mode 100644
index 000000000..f6f4b712b
--- /dev/null
+++ b/src/main/java/com/ghostipedia/cosmiccore/api/data/souls/SoulNetwork.java
@@ -0,0 +1,93 @@
+package com.ghostipedia.cosmiccore.api.data.souls;
+
+import com.ghostipedia.cosmiccore.api.capability.souls.SoulType;
+import com.ghostipedia.cosmiccore.api.recipe.ingredient.SoulStack;
+
+import net.minecraft.core.HolderLookup;
+import net.minecraft.nbt.CompoundTag;
+import net.minecraft.nbt.ListTag;
+import net.minecraft.nbt.Tag;
+import net.neoforged.neoforge.common.util.INBTSerializable;
+
+import lombok.Setter;
+
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+public class SoulNetwork implements INBTSerializable {
+
+ @Setter
+ private Runnable dirtyCallback;
+
+ private final Map contents = new ConcurrentHashMap<>();
+
+ public SoulNetwork() {}
+
+ public SoulStack add(SoulStack stack, int throughput, int capacity, boolean simulate) {
+ int currentAmount = this.contents.getOrDefault(stack.type(), 0);
+
+ int amountToAdd = Math.min(stack.amount(), throughput); // Respect throughput
+ amountToAdd = Math.min(amountToAdd, capacity - currentAmount); // Respect network capacity
+ amountToAdd = Math.max(0, amountToAdd); // Ensure we don't add a negative amount
+
+ if (!simulate && amountToAdd > 0) {
+ this.contents.put(stack.type(), currentAmount + amountToAdd);
+ if (dirtyCallback != null) dirtyCallback.run();
+ }
+
+ return stack.withAmount(amountToAdd);
+ }
+
+ public SoulStack syphon(SoulStack stack, boolean simulate) {
+ var currentSoulContent = this.contents.getOrDefault(stack.type(), 0);
+ int amountToSyphon = Math.min(stack.amount(), currentSoulContent);
+
+ if (!simulate && amountToSyphon > 0) {
+ this.contents.put(stack.type(), currentSoulContent - amountToSyphon);
+ if (dirtyCallback != null) dirtyCallback.run();
+ }
+
+ return stack.withAmount(amountToSyphon);
+ }
+
+ public void reset() {
+ this.contents.clear();
+ if (dirtyCallback != null) dirtyCallback.run();
+ }
+
+ public List getContents() {
+ return contents.entrySet().stream()
+ .map(kvp -> new SoulStack(kvp.getKey(), kvp.getValue()))
+ .toList();
+ }
+
+ @Override
+ public String toString() {
+ return "SoulNetwork{contents=" + contents + '}';
+ }
+
+ @Override
+ public CompoundTag serializeNBT(HolderLookup.Provider provider) {
+ var tag = new CompoundTag();
+ var listTag = new ListTag();
+ this.contents.forEach((soulType, amount) -> {
+ var contentTag = new CompoundTag();
+ contentTag.putString("type", soulType.getSerializedName());
+ contentTag.putInt("amount", amount);
+ listTag.add(contentTag);
+ });
+ tag.put("contents", listTag);
+ return tag;
+ }
+
+ @Override
+ public void deserializeNBT(HolderLookup.Provider provider, CompoundTag compoundTag) {
+ this.contents.clear();
+ ListTag listTag = compoundTag.getList("contents", Tag.TAG_COMPOUND);
+ for (Tag t : listTag) {
+ var contentTag = (CompoundTag) t;
+ this.contents.put(SoulType.byName(contentTag.getString("type")), contentTag.getInt("amount"));
+ }
+ }
+}
diff --git a/src/main/java/com/ghostipedia/cosmiccore/api/data/souls/SoulNetworkSavedData.java b/src/main/java/com/ghostipedia/cosmiccore/api/data/souls/SoulNetworkSavedData.java
new file mode 100644
index 000000000..e08791e8d
--- /dev/null
+++ b/src/main/java/com/ghostipedia/cosmiccore/api/data/souls/SoulNetworkSavedData.java
@@ -0,0 +1,75 @@
+package com.ghostipedia.cosmiccore.api.data.souls;
+
+import com.ghostipedia.cosmiccore.CosmicCore;
+
+import net.minecraft.core.HolderLookup;
+import net.minecraft.nbt.CompoundTag;
+import net.minecraft.nbt.ListTag;
+import net.minecraft.nbt.Tag;
+import net.minecraft.server.level.ServerLevel;
+import net.minecraft.world.level.saveddata.SavedData;
+
+import org.jetbrains.annotations.NotNull;
+
+import java.util.HashMap;
+import java.util.UUID;
+
+public class SoulNetworkSavedData extends SavedData {
+
+ private static final String DATA_NAME = CosmicCore.MOD_ID + "_soul_network_data";
+ private static final String SOUL_NETWORK_MAPPING = "soul_network_mapping";
+ private static final String SOUL_NETWORK_UUID = "soul_network_uuid";
+ private static final String SOUL_NETWORK_DATA = "soul_network_data";
+
+ private final HashMap soulNetworkMapping = new HashMap<>(20, 0.9f);
+
+ public static SoulNetworkSavedData getOrCreate(ServerLevel serverLevel) {
+ return serverLevel.getDataStorage().computeIfAbsent(
+ new SavedData.Factory<>(SoulNetworkSavedData::new, SoulNetworkSavedData::load),
+ DATA_NAME);
+ }
+
+ public static SoulNetwork getSoulNetwork(ServerLevel level, UUID owner) {
+ SoulNetworkSavedData savedData = getOrCreate(level);
+ return savedData.getNetwork(owner);
+ }
+
+ public SoulNetwork getNetwork(UUID owner) {
+ return soulNetworkMapping.computeIfAbsent(owner, id -> {
+ SoulNetwork network = new SoulNetwork();
+ network.setDirtyCallback(this::setDirty);
+ setDirty();
+ return network;
+ });
+ }
+
+ public SoulNetworkSavedData() {}
+
+ public static SoulNetworkSavedData load(CompoundTag nbt, HolderLookup.Provider provider) {
+ SoulNetworkSavedData savedData = new SoulNetworkSavedData();
+ var list = nbt.getList(SOUL_NETWORK_MAPPING, CompoundTag.TAG_COMPOUND);
+ for (Tag tag : list) {
+ if (tag instanceof CompoundTag compoundTag) {
+ var uuid = UUID.fromString(compoundTag.getString(SOUL_NETWORK_UUID));
+ var data = new SoulNetwork();
+ data.deserializeNBT(provider, compoundTag.getCompound(SOUL_NETWORK_DATA));
+ data.setDirtyCallback(savedData::setDirty);
+ savedData.soulNetworkMapping.put(uuid, data);
+ }
+ }
+ return savedData;
+ }
+
+ @Override
+ public @NotNull CompoundTag save(@NotNull CompoundTag compoundTag, @NotNull HolderLookup.Provider provider) {
+ var soulNetworkDataList = new ListTag();
+ for (var entry : soulNetworkMapping.entrySet()) {
+ var tag = new CompoundTag();
+ tag.putString(SOUL_NETWORK_UUID, entry.getKey().toString());
+ tag.put(SOUL_NETWORK_DATA, entry.getValue().serializeNBT(provider));
+ soulNetworkDataList.add(tag);
+ }
+ compoundTag.put(SOUL_NETWORK_MAPPING, soulNetworkDataList);
+ return compoundTag;
+ }
+}
diff --git a/src/main/java/com/ghostipedia/cosmiccore/api/data/wireless/WirelessEnergySavedData.java b/src/main/java/com/ghostipedia/cosmiccore/api/data/wireless/WirelessEnergySavedData.java
new file mode 100644
index 000000000..68c05596d
--- /dev/null
+++ b/src/main/java/com/ghostipedia/cosmiccore/api/data/wireless/WirelessEnergySavedData.java
@@ -0,0 +1,301 @@
+package com.ghostipedia.cosmiccore.api.data.wireless;
+
+import net.minecraft.core.BlockPos;
+import net.minecraft.core.HolderLookup;
+import net.minecraft.nbt.CompoundTag;
+import net.minecraft.nbt.ListTag;
+import net.minecraft.nbt.Tag;
+import net.minecraft.server.level.ServerLevel;
+import net.minecraft.util.Tuple;
+import net.minecraft.world.level.saveddata.SavedData;
+
+import org.jetbrains.annotations.NotNull;
+
+import java.math.BigInteger;
+import java.util.*;
+
+public class WirelessEnergySavedData extends SavedData {
+
+ public static class WirelessEnergyData {
+
+ public BigInteger energyStored;
+ public BigInteger energyCapacity;
+ public boolean isActive;
+ public Map energyInput;
+ public Map energyOutput;
+ public Map energyBuffered;
+ public Map passiveDrain;
+
+ public WirelessEnergyData() {
+ this(BigInteger.ZERO, BigInteger.valueOf(-1), false);
+ }
+
+ public WirelessEnergyData(BigInteger energyStored, BigInteger energyCapacity) {
+ this(energyStored, energyCapacity, false);
+ }
+
+ public WirelessEnergyData(BigInteger energyStored, BigInteger energyCapacity, boolean isActive) {
+ this.energyStored = energyStored;
+ this.energyCapacity = energyCapacity;
+ this.isActive = isActive;
+ this.energyInput = new HashMap<>();
+ this.energyOutput = new HashMap<>();
+ this.energyBuffered = new HashMap<>();
+ this.passiveDrain = new HashMap<>();
+ }
+
+ public static WirelessEnergyData fromNBT(CompoundTag nbt) {
+ var stored = new BigInteger(nbt.getByteArray("energyStored"));
+ var capacity = new BigInteger(nbt.getByteArray("energyCapacity"));
+ var active = nbt.getBoolean("isActive");
+ return new WirelessEnergyData(stored, capacity, active);
+ }
+
+ public CompoundTag toNBT() {
+ var tag = new CompoundTag();
+ tag.putByteArray("energyStored", energyStored.toByteArray());
+ tag.putByteArray("energyCapacity", energyCapacity.toByteArray());
+ tag.putBoolean("isActive", isActive);
+ return tag;
+ }
+ };
+
+ // FIXME why is this attributed to GT?
+ private static final String DATA_NAME = "gtceu_wireless_energy";
+ // FIXME this key is kind of weird. Search & replace mistake?
+ private static final String GlobalEnergyNBTTag = "gtceu_wireless_energy_MapNBTTag";
+ public static final HashMap GlobalWirelessEnergy = new HashMap<>(20, 0.9f);
+
+ public static WirelessEnergySavedData getOrCreate(ServerLevel serverLevel) {
+ return serverLevel.getDataStorage()
+ .computeIfAbsent(
+ new SavedData.Factory<>(WirelessEnergySavedData::new, WirelessEnergySavedData::load),
+ DATA_NAME);
+ }
+
+ private WirelessEnergySavedData() {}
+
+ private WirelessEnergySavedData(CompoundTag nbt) {
+ var list = nbt.getList(GlobalEnergyNBTTag, Tag.TAG_COMPOUND);
+ for (int i = 0; i < list.size(); i++) {
+ CompoundTag tag = list.getCompound(i);
+ var uuid = UUID.fromString(tag.getString("uuid"));
+ var data = tag.getCompound("energyData");
+ GlobalWirelessEnergy.put(uuid, WirelessEnergyData.fromNBT(data));
+ }
+ }
+
+ private static WirelessEnergySavedData load(CompoundTag nbt, HolderLookup.Provider provider) {
+ return new WirelessEnergySavedData(nbt);
+ }
+
+ @NotNull
+ @Override
+ public CompoundTag save(@NotNull CompoundTag nbt, @NotNull HolderLookup.Provider provider) {
+ var wirelessEnergyList = new ListTag();
+ for (var entry : GlobalWirelessEnergy.entrySet()) {
+ if (entry.getKey() == null) continue;
+ var tag = new CompoundTag();
+ tag.putString("uuid", entry.getKey().toString());
+ tag.put("energyData", entry.getValue().toNBT());
+ wirelessEnergyList.add(tag);
+ }
+ nbt.put(GlobalEnergyNBTTag, wirelessEnergyList);
+ return nbt;
+ }
+
+ public BigInteger getEnergyStored(UUID uuid) {
+ var data = GlobalWirelessEnergy.computeIfAbsent(uuid, k -> new WirelessEnergyData());
+ return data.energyStored;
+ }
+
+ public BigInteger getTotalNetworkEnergyStored(UUID uuid) {
+ return this.getEnergyStored(uuid).add(this.getEnergyBuffered(uuid));
+ }
+
+ public BigInteger getTotalNetworkEnergyStoredExceptLocalBuffer(UUID uuid, BlockPos localBufferPos) {
+ return this.getEnergyStored(uuid).add(this.getEnergyBufferedExceptLocal(uuid, localBufferPos));
+ }
+
+ public BigInteger getEnergyCapacity(UUID uuid) {
+ var data = GlobalWirelessEnergy.computeIfAbsent(uuid, k -> new WirelessEnergyData());
+ return data.energyCapacity;
+ }
+
+ public boolean isActive(UUID uuid) {
+ var data = GlobalWirelessEnergy.computeIfAbsent(uuid, k -> new WirelessEnergyData());
+ return data.isActive;
+ }
+
+ public long getEnergyInput(UUID uuid) {
+ var data = GlobalWirelessEnergy.computeIfAbsent(uuid, k -> new WirelessEnergyData());
+ return data.energyInput.values().stream().mapToLong(Long::longValue).sum();
+ }
+
+ public void setEnergyInput(UUID uuid, BlockPos blockPos, long input) {
+ var data = GlobalWirelessEnergy.computeIfAbsent(uuid, k -> new WirelessEnergyData());
+ data.energyInput.put(blockPos, input);
+ setDirty();
+ }
+
+ public void removeEnergyInput(UUID uuid, BlockPos blockPos) {
+ var data = GlobalWirelessEnergy.computeIfAbsent(uuid, k -> new WirelessEnergyData());
+ data.energyInput.remove(blockPos);
+ setDirty();
+ }
+
+ public long getEnergyOutput(UUID uuid) {
+ var data = GlobalWirelessEnergy.computeIfAbsent(uuid, k -> new WirelessEnergyData());
+ return data.energyOutput.values().stream().mapToLong(Long::longValue).sum();
+ }
+
+ public void setEnergyOutput(UUID uuid, BlockPos blockPos, long input) {
+ var data = GlobalWirelessEnergy.computeIfAbsent(uuid, k -> new WirelessEnergyData());
+ data.energyOutput.put(blockPos, input);
+ setDirty();
+ }
+
+ public void removeEnergyOutput(UUID uuid, BlockPos blockPos) {
+ var data = GlobalWirelessEnergy.computeIfAbsent(uuid, k -> new WirelessEnergyData());
+ data.energyOutput.remove(blockPos);
+ setDirty();
+ }
+
+ public BigInteger getEnergyBuffered(UUID uuid) {
+ var data = GlobalWirelessEnergy.computeIfAbsent(uuid, k -> new WirelessEnergyData());
+ var sum = BigInteger.ZERO;
+ for (var value : data.energyBuffered.values()) sum = sum.add(BigInteger.valueOf(value));
+ return sum;
+ }
+
+ public BigInteger getEnergyBufferedExceptLocal(UUID uuid, BlockPos localBufferPos) {
+ var data = GlobalWirelessEnergy.computeIfAbsent(uuid, k -> new WirelessEnergyData());
+ var sum = BigInteger.ZERO;
+ for (var entry : data.energyBuffered.entrySet()) {
+ if (!entry.getKey().equals(localBufferPos))
+ sum = sum.add(BigInteger.valueOf(entry.getValue()));
+ } ;
+ return sum;
+ }
+
+ public void setEnergyBuffered(UUID uuid, BlockPos blockPos, long input) {
+ var data = GlobalWirelessEnergy.computeIfAbsent(uuid, k -> new WirelessEnergyData());
+ data.energyBuffered.put(blockPos, input);
+ setDirty();
+ }
+
+ public void removeEnergyBuffered(UUID uuid, BlockPos blockPos) {
+ var data = GlobalWirelessEnergy.computeIfAbsent(uuid, k -> new WirelessEnergyData());
+ data.energyBuffered.remove(blockPos);
+ setDirty();
+ }
+
+ public long getPassiveDrain(UUID uuid) {
+ var data = GlobalWirelessEnergy.computeIfAbsent(uuid, k -> new WirelessEnergyData());
+ return data.passiveDrain.values().stream().mapToLong(Long::longValue).sum();
+ }
+
+ public void setPassiveDrain(UUID uuid, BlockPos blockPos, long input) {
+ var data = GlobalWirelessEnergy.computeIfAbsent(uuid, k -> new WirelessEnergyData());
+ data.passiveDrain.put(blockPos, input);
+ setDirty();
+ }
+
+ public void removePassiveDrain(UUID uuid, BlockPos blockPos) {
+ var data = GlobalWirelessEnergy.computeIfAbsent(uuid, k -> new WirelessEnergyData());
+ data.passiveDrain.remove(blockPos);
+ setDirty();
+ }
+
+ private boolean compareLocations(Tuple location1, Tuple location2) {
+ if (location1 == null || location2 == null) return false;
+ if (!location1.getA().equals(location2.getA())) return false;
+ if (!location1.getB().equals(location2.getB())) return false;
+ return true;
+ }
+
+ /**
+ * Add EU to the users global energy. You can enter a negative number to subtract it.
+ * If the value goes below 0, it will return the EU amount and no operation will be performed.
+ * If the value goes above the {@link WirelessEnergyData} capacity, it will return the energy that was not added to
+ * the network.
+ * If the operation is successful, return value will be 0.
+ * BigIntegers have a much slower operation than long/int. You should call these methods as infrequently as possible
+ * and bulk store values to add to the global map
+ *
+ * @param uuid UUID of the owner of the network
+ * @param EU The energy to add to the network of the owner
+ * @return The amount of EU left after the operation
+ */
+ public BigInteger addEUToGlobalWirelessEnergy(UUID uuid, BigInteger EU) {
+ var data = GlobalWirelessEnergy.computeIfAbsent(uuid, k -> new WirelessEnergyData());
+ var totalEU = data.energyStored.add(EU);
+ if (totalEU.signum() >= 0) {
+ if (totalEU.compareTo(data.energyCapacity) > 0) {
+ var leftover = totalEU.subtract(data.energyCapacity);
+ data.energyStored = data.energyCapacity;
+ GlobalWirelessEnergy.put(uuid, data);
+ setDirty();
+ return leftover;
+ }
+ data.energyStored = totalEU;
+ GlobalWirelessEnergy.put(uuid, data);
+ setDirty();
+ return BigInteger.ZERO;
+ }
+ return EU;
+ }
+
+ public int addEUToGlobalWirelessEnergy(UUID uuid, int energy) {
+ return addEUToGlobalWirelessEnergy(uuid, BigInteger.valueOf(energy)).intValue();
+ }
+
+ public long addEUToGlobalWirelessEnergy(UUID uuid, long energy) {
+ return addEUToGlobalWirelessEnergy(uuid, BigInteger.valueOf(energy)).longValue();
+ }
+
+ public void setEnergy(UUID uuid, BigInteger energy) {
+ var data = GlobalWirelessEnergy.computeIfAbsent(uuid, k -> new WirelessEnergyData());
+ data.energyStored = energy;
+ setDirty();
+ }
+
+ public void setEnergy(UUID uuid, long energy) {
+ setEnergy(uuid, BigInteger.valueOf(energy));
+ }
+
+ public void setEnergy(UUID uuid, int energy) {
+ setEnergy(uuid, BigInteger.valueOf(energy));
+ }
+
+ public void setCapacity(UUID uuid, BigInteger capacity) {
+ var data = GlobalWirelessEnergy.computeIfAbsent(uuid, k -> new WirelessEnergyData());
+ data.energyCapacity = capacity;
+ var isOvercharged = capacity.compareTo(data.energyStored) < 0;
+ if (isOvercharged) data.energyStored = capacity;
+ setDirty();
+ }
+
+ public void setCapacity(UUID uuid, long capacity) {
+ setCapacity(uuid, BigInteger.valueOf(capacity));
+ }
+
+ public void setCapacity(UUID uuid, int capacity) {
+ setCapacity(uuid, BigInteger.valueOf(capacity));
+ }
+
+ public void clearWirelessEnergy(UUID uuid) {
+ GlobalWirelessEnergy.put(uuid, new WirelessEnergyData());
+ }
+
+ public void clearGlobalWirelessEnergy() {
+ GlobalWirelessEnergy.clear();
+ }
+
+ public void setActive(UUID uuid, boolean isActive) {
+ GlobalWirelessEnergy.computeIfAbsent(uuid, k -> new WirelessEnergyData());
+ var data = GlobalWirelessEnergy.get(uuid);
+ data.isActive = isActive;
+ setDirty();
+ }
+}
diff --git a/src/main/java/com/ghostipedia/cosmiccore/api/item/LinkedTerminalBehavior.java b/src/main/java/com/ghostipedia/cosmiccore/api/item/LinkedTerminalBehavior.java
new file mode 100644
index 000000000..25fc669e2
--- /dev/null
+++ b/src/main/java/com/ghostipedia/cosmiccore/api/item/LinkedTerminalBehavior.java
@@ -0,0 +1,153 @@
+package com.ghostipedia.cosmiccore.api.item;
+
+import com.ghostipedia.cosmiccore.api.block.IBlockPattern;
+import com.ghostipedia.cosmiccore.common.data.CosmicItems;
+import com.ghostipedia.cosmiccore.utils.ItemData;
+
+import com.gregtechceu.gtceu.api.item.component.IAddInformation;
+import com.gregtechceu.gtceu.api.item.component.IInteractionItem;
+import com.gregtechceu.gtceu.api.machine.MetaMachine;
+import com.gregtechceu.gtceu.api.machine.multiblock.MultiblockControllerMachine;
+
+import net.minecraft.ChatFormatting;
+import net.minecraft.Util;
+import net.minecraft.core.GlobalPos;
+import net.minecraft.nbt.NbtOps;
+import net.minecraft.nbt.Tag;
+import net.minecraft.network.chat.Component;
+import net.minecraft.server.level.ServerLevel;
+import net.minecraft.world.InteractionHand;
+import net.minecraft.world.InteractionResult;
+import net.minecraft.world.InteractionResultHolder;
+import net.minecraft.world.entity.player.Player;
+import net.minecraft.world.item.Item;
+import net.minecraft.world.item.ItemStack;
+import net.minecraft.world.item.TooltipFlag;
+import net.minecraft.world.item.context.UseOnContext;
+import net.minecraft.world.level.Level;
+
+import appeng.api.features.IGridLinkableHandler;
+import appeng.api.implementations.blockentities.IWirelessAccessPoint;
+import appeng.api.networking.IGrid;
+import appeng.core.localization.GuiText;
+import appeng.core.localization.PlayerMessages;
+import appeng.core.localization.Tooltips;
+import appeng.util.Platform;
+import com.mojang.datafixers.util.Pair;
+import org.jetbrains.annotations.Nullable;
+import org.jline.utils.Log;
+
+import java.util.List;
+
+public class LinkedTerminalBehavior implements IInteractionItem, IAddInformation {
+
+ public static IGridLinkableHandler handler = new LinkedTerminalHandler();
+ private static final String TAG_ACCESS_POINT_POS = "accessPoint";
+
+ @Override
+ public InteractionResult useOn(UseOnContext context) {
+ var player = context.getPlayer();
+ if (player == null || !player.isShiftKeyDown()) return InteractionResult.PASS;
+ var level = context.getLevel();
+ var pos = context.getClickedPos();
+ var stack = context.getItemInHand();
+ if (!(MetaMachine.getMachine(level, pos) instanceof MultiblockControllerMachine controller)) return InteractionResult.PASS;
+ if (controller.isFormed() || level.isClientSide) return InteractionResult.PASS;
+ var grid = getLinkedGrid(stack, level, player);
+ if (grid == null) return InteractionResult.PASS;
+ ((IBlockPattern) controller.getPattern()).cosmiccore$autoBuild(player,
+ controller.getMultiblockState(), grid);
+ player.getCooldowns().addCooldown(CosmicItems.LINKED_TERMINAL.asItem(), 100);
+ return InteractionResult.sidedSuccess(false);
+ }
+
+ @Override
+ public void appendHoverText(ItemStack stack, Item.TooltipContext context, List lines, TooltipFlag isAdvanced) {
+ var position = linkedPosition(stack);
+ if (position == null) {
+ lines.add(Tooltips.of(GuiText.Unlinked, Tooltips.RED));
+ } else {
+ lines.add(Tooltips.of(GuiText.Linked, Tooltips.GREEN));
+ lines.add(Component.translatable("cosmiccore.item.linked_terminal.boundTo",
+ position.dimension().location().getPath() + "[" + position.pos().toShortString() + "]")
+ .withStyle(ChatFormatting.GOLD));
+ }
+ }
+
+ @Override
+ public InteractionResultHolder use(ItemStack item, Level level, Player player, InteractionHand usedHand) {
+ var stack = player.getItemInHand(usedHand);
+ return InteractionResultHolder.pass(stack);
+ }
+
+ private GlobalPos linkedPosition(ItemStack item) {
+ var tag = ItemData.readTag(item);
+ if (tag.contains(TAG_ACCESS_POINT_POS, Tag.TAG_COMPOUND)) {
+ return GlobalPos.CODEC.decode(NbtOps.INSTANCE, tag.get(TAG_ACCESS_POINT_POS))
+ .resultOrPartial(Util.prefix("Linked position", Log::error))
+ .map(Pair::getFirst)
+ .orElse(null);
+ } else {
+ return null;
+ }
+ }
+
+ @Nullable
+ public IGrid getLinkedGrid(ItemStack item, Level level, @Nullable Player sendMessagesTo) {
+ if (!(level instanceof ServerLevel serverLevel)) {
+ return null;
+ }
+
+ var linkedPos = linkedPosition(item);
+ if (linkedPos == null) {
+ if (sendMessagesTo != null) {
+ sendMessagesTo.displayClientMessage(PlayerMessages.DeviceNotLinked.text(), true);
+ }
+ return null;
+ }
+
+ var linkedLevel = serverLevel.getServer().getLevel(linkedPos.dimension());
+ if (linkedLevel == null) {
+ if (sendMessagesTo != null) {
+ sendMessagesTo.displayClientMessage(PlayerMessages.LinkedNetworkNotFound.text(), true);
+ }
+ return null;
+ }
+
+ var be = Platform.getTickingBlockEntity(linkedLevel, linkedPos.pos());
+ if (!(be instanceof IWirelessAccessPoint accessPoint)) {
+ if (sendMessagesTo != null) {
+ sendMessagesTo.displayClientMessage(PlayerMessages.LinkedNetworkNotFound.text(), true);
+ }
+ return null;
+ }
+
+ var grid = accessPoint.getGrid();
+ if (grid == null) {
+ if (sendMessagesTo != null) {
+ sendMessagesTo.displayClientMessage(PlayerMessages.LinkedNetworkNotFound.text(), true);
+ }
+ }
+ return grid;
+ }
+
+ static class LinkedTerminalHandler implements IGridLinkableHandler {
+
+ @Override
+ public boolean canLink(ItemStack stack) {
+ return true;
+ }
+
+ @Override
+ public void link(ItemStack itemStack, GlobalPos pos) {
+ GlobalPos.CODEC.encodeStart(NbtOps.INSTANCE, pos)
+ .result()
+ .ifPresent(tag -> ItemData.mutateTag(itemStack, root -> root.put(TAG_ACCESS_POINT_POS, tag)));
+ }
+
+ @Override
+ public void unlink(ItemStack itemStack) {
+ ItemData.mutateTag(itemStack, root -> root.remove(TAG_ACCESS_POINT_POS));
+ }
+ }
+}
diff --git a/src/main/java/com/ghostipedia/cosmiccore/api/item/MeldingOmniTool.java b/src/main/java/com/ghostipedia/cosmiccore/api/item/MeldingOmniTool.java
new file mode 100644
index 000000000..5acf2ff5d
--- /dev/null
+++ b/src/main/java/com/ghostipedia/cosmiccore/api/item/MeldingOmniTool.java
@@ -0,0 +1,33 @@
+package com.ghostipedia.cosmiccore.api.item;
+
+import com.gregtechceu.gtceu.api.item.tool.GTToolType;
+import com.gregtechceu.gtceu.common.item.tool.behavior.DisableShieldBehavior;
+import com.gregtechceu.gtceu.common.item.tool.behavior.ToolModeSwitchBehavior;
+import com.gregtechceu.gtceu.data.recipe.CustomTags;
+
+import net.minecraft.tags.BlockTags;
+import net.minecraft.tags.ItemTags;
+import net.neoforged.neoforge.common.Tags;
+
+public final class MeldingOmniTool {
+
+ public static final GTToolType MELD_TOOL_LUV = GTToolType.builder("luv_meld_tool")
+ .idFormat("%s_meld_tool")
+ .toolTag(Tags.Items.TOOLS_WRENCH)
+ .toolTag(CustomTags.TOOLS_WIRE_CUTTER)
+ .toolTag(ItemTags.PICKAXES)
+ .toolTag(ItemTags.SHOVELS)
+ .toolTag(ItemTags.HOES)
+ .toolTag(ItemTags.AXES)
+ .harvestTag(BlockTags.MINEABLE_WITH_AXE)
+ .harvestTag(BlockTags.MINEABLE_WITH_HOE)
+ .harvestTag(BlockTags.MINEABLE_WITH_PICKAXE)
+ .harvestTag(BlockTags.MINEABLE_WITH_SHOVEL)
+ .harvestTag(CustomTags.MINEABLE_WITH_CONFIG_VALID_PICKAXE_WIRE_CUTTER)
+ .harvestTag(CustomTags.MINEABLE_WITH_CONFIG_VALID_PICKAXE_WRENCH)
+ .definition(s -> s.crafting().blockBreaking().sneakBypassUse().attacking().attackSpeed(3.5F)
+ .behaviors(DisableShieldBehavior.INSTANCE, ToolModeSwitchBehavior.INSTANCE))
+ .toolClasses(GTToolType.WRENCH, GTToolType.WIRE_CUTTER, GTToolType.PICKAXE, GTToolType.SHEARS,
+ GTToolType.AXE)
+ .build();
+}
diff --git a/src/main/java/com/ghostipedia/cosmiccore/api/item/armor/ISpaceSuite.java b/src/main/java/com/ghostipedia/cosmiccore/api/item/armor/ISpaceSuite.java
deleted file mode 100644
index 11170007d..000000000
--- a/src/main/java/com/ghostipedia/cosmiccore/api/item/armor/ISpaceSuite.java
+++ /dev/null
@@ -1,67 +0,0 @@
-package com.ghostipedia.cosmiccore.api.item.armor;
-
-import com.ghostipedia.cosmiccore.common.data.tag.item.CosmicItemTags;
-import earth.terrarium.adastra.api.systems.OxygenApi;
-import earth.terrarium.adastra.common.constants.ConstantComponents;
-import earth.terrarium.adastra.common.registry.ModFluids;
-import earth.terrarium.adastra.common.utils.FluidUtils;
-import earth.terrarium.adastra.common.utils.TooltipUtils;
-import earth.terrarium.botarium.common.fluid.FluidConstants;
-import net.minecraft.network.chat.Component;
-import net.minecraft.tags.FluidTags;
-import net.minecraft.tags.TagKey;
-import net.minecraft.world.entity.LivingEntity;
-import net.minecraft.world.entity.player.Player;
-import net.minecraft.world.item.ArmorItem;
-import net.minecraft.world.item.Item;
-import net.minecraft.world.item.ItemStack;
-import net.minecraft.world.level.Level;
-
-import java.util.List;
-import java.util.stream.StreamSupport;
-
-public interface ISpaceSuite {
-
- default void tickOxygen(Level Level, Player player, ItemStack itemStack) {
- if (Level.isClientSide) return;
- if (player.isCreative() || player.isSpectator()) return;
- if (!(itemStack.getItem() instanceof SpaceArmorComponentItem suit)) return;
- player.setTicksFrozen(0);
- if (player.tickCount % 12 == 0 && suit.hasOxygen(player)) {
- if (!OxygenApi.API.hasOxygen(player)) suit.consumeOxygen(itemStack, 1);
- if (player.isEyeInFluid(FluidTags.WATER)) {
- suit.consumeOxygen(itemStack, 1);
- player.setAirSupply(Math.min(player.getMaxAirSupply(), player.getAirSupply() + 4 * 10));
- }
- }
- }
-
- static boolean hasFullNanoSet(LivingEntity entity) {
- return hasFullSet(entity, CosmicItemTags.NANOMUSCLE_SPACE_SUITE);
- }
-
- static boolean hasFullQuantumSet(LivingEntity entity) {
- return hasFullSet(entity, CosmicItemTags.QUARKTECH_SPACE_SUITE);
- }
-
- static boolean hasFullSet(LivingEntity entity, TagKey- tagKey) {
- return StreamSupport.stream(entity.getArmorSlots().spliterator(), false)
- .allMatch(itemStack -> itemStack.is(tagKey));
- }
-
-
- default void onArmorTick(Level Level, Player player, ItemStack itemStack, ArmorItem.Type type) {
- if (type == ArmorItem.Type.CHESTPLATE) this.tickOxygen(Level, player, itemStack);
- }
-
- default void addInfo(ItemStack itemStack, List
lines, ArmorItem.Type type) {
- if (type == ArmorItem.Type.CHESTPLATE && itemStack.getItem() instanceof SpaceArmorComponentItem suit) {
- lines.add(TooltipUtils.getFluidComponent(
- FluidUtils.getTank(itemStack),
- FluidConstants.fromMillibuckets(suit.getFluidContainer(itemStack).getTankCapacity(0)),
- ModFluids.OXYGEN.get()));
- TooltipUtils.addDescriptionComponent(lines, ConstantComponents.SPACE_SUIT_INFO);
- }
- }
-
-}
diff --git a/src/main/java/com/ghostipedia/cosmiccore/api/item/component/FluidStats.java b/src/main/java/com/ghostipedia/cosmiccore/api/item/component/FluidStats.java
deleted file mode 100644
index 7251a0934..000000000
--- a/src/main/java/com/ghostipedia/cosmiccore/api/item/component/FluidStats.java
+++ /dev/null
@@ -1,9 +0,0 @@
-package com.ghostipedia.cosmiccore.api.item.component;
-
-import com.gregtechceu.gtceu.api.capability.GTCapabilityHelper;
-
-public class FluidStats {
- public FluidStats() {
-
- }
-}
diff --git a/src/main/java/com/ghostipedia/cosmiccore/api/machine/feature/IStellarIrisProvider.java b/src/main/java/com/ghostipedia/cosmiccore/api/machine/feature/IStellarIrisProvider.java
new file mode 100644
index 000000000..6ec49974a
--- /dev/null
+++ b/src/main/java/com/ghostipedia/cosmiccore/api/machine/feature/IStellarIrisProvider.java
@@ -0,0 +1,54 @@
+package com.ghostipedia.cosmiccore.api.machine.feature;
+
+import com.ghostipedia.cosmiccore.api.machine.multiblock.IrisMultiblockMachine;
+
+import com.gregtechceu.gtceu.api.machine.feature.IMachineFeature;
+
+/**
+ * Interface for the Stellar Iris controller.
+ * Modules query this to get processing parameters and stage information.
+ */
+public interface IStellarIrisProvider extends IMachineFeature {
+
+ /**
+ * @return the current stage of the stellar iris
+ */
+ IrisMultiblockMachine.Stage getStage();
+
+ /**
+ * @return whether the iris multiblock is formed
+ */
+ boolean isFormed();
+
+ /**
+ * @return maximum heat provided to modules (affects recipe availability)
+ */
+ int getMaxHeat();
+
+ /**
+ * @return speed bonus multiplier for module recipes
+ */
+ double getSpeedBonus();
+
+ /**
+ * @return energy discount multiplier for module recipes (1.0 = no discount)
+ */
+ double getEnergyDiscount();
+
+ /**
+ * @return maximum parallel recipes for modules
+ */
+ int getParallelLimit();
+
+ /**
+ * Check if the stage allows processing
+ *
+ * @return true if the current stage can run module recipes
+ */
+ default boolean canProcess() {
+ IrisMultiblockMachine.Stage stage = getStage();
+ return stage == IrisMultiblockMachine.Stage.STAR ||
+ stage == IrisMultiblockMachine.Stage.SUPERSTAR ||
+ stage == IrisMultiblockMachine.Stage.BLACK_HOLE;
+ }
+}
diff --git a/src/main/java/com/ghostipedia/cosmiccore/api/machine/feature/IStellarModuleReceiver.java b/src/main/java/com/ghostipedia/cosmiccore/api/machine/feature/IStellarModuleReceiver.java
new file mode 100644
index 000000000..ccfba4072
--- /dev/null
+++ b/src/main/java/com/ghostipedia/cosmiccore/api/machine/feature/IStellarModuleReceiver.java
@@ -0,0 +1,32 @@
+package com.ghostipedia.cosmiccore.api.machine.feature;
+
+import org.jetbrains.annotations.Nullable;
+
+/**
+ * Interface for Stellar Iris modules.
+ * Modules receive connection from the Iris controller and processing parameters.
+ */
+public interface IStellarModuleReceiver {
+
+ /**
+ * @return the stellar iris this module is connected to, or null if not connected
+ */
+ @Nullable
+ IStellarIrisProvider getStellarIris();
+
+ /**
+ * Sets the stellar iris connection for this module.
+ * Called by the Iris controller when structure forms/invalidates.
+ *
+ * @param provider the iris provider to connect to, or null to disconnect
+ */
+ void setStellarIris(@Nullable IStellarIrisProvider provider);
+
+ /**
+ * @return true if this module is connected to a valid, formed Iris
+ */
+ default boolean isConnectedToIris() {
+ IStellarIrisProvider iris = getStellarIris();
+ return iris != null && iris.isFormed();
+ }
+}
diff --git a/src/main/java/com/ghostipedia/cosmiccore/api/machine/multiblock/BloodAltarMultiMachine.java b/src/main/java/com/ghostipedia/cosmiccore/api/machine/multiblock/BloodAltarMultiMachine.java
new file mode 100644
index 000000000..f1175ca7b
--- /dev/null
+++ b/src/main/java/com/ghostipedia/cosmiccore/api/machine/multiblock/BloodAltarMultiMachine.java
@@ -0,0 +1,3 @@
+package com.ghostipedia.cosmiccore.api.machine.multiblock;
+
+public class BloodAltarMultiMachine {}
diff --git a/src/main/java/com/ghostipedia/cosmiccore/api/machine/multiblock/DimensionalEnergyCapacitor.java b/src/main/java/com/ghostipedia/cosmiccore/api/machine/multiblock/DimensionalEnergyCapacitor.java
new file mode 100644
index 000000000..77b6b7f68
--- /dev/null
+++ b/src/main/java/com/ghostipedia/cosmiccore/api/machine/multiblock/DimensionalEnergyCapacitor.java
@@ -0,0 +1,177 @@
+package com.ghostipedia.cosmiccore.api.machine.multiblock;
+
+import com.ghostipedia.cosmiccore.CosmicCore;
+import com.ghostipedia.cosmiccore.api.data.savedData.UniqueMultiblockSavedData;
+import com.ghostipedia.cosmiccore.api.data.wireless.WirelessEnergySavedData;
+
+import com.gregtechceu.gtceu.api.gui.GuiTextures;
+import com.gregtechceu.gtceu.api.gui.fancy.IFancyTooltip;
+import com.gregtechceu.gtceu.api.gui.fancy.TooltipsPanel;
+import com.gregtechceu.gtceu.api.blockentity.BlockEntityCreationInfo;
+import com.gregtechceu.gtceu.api.machine.multiblock.IBatteryData;
+import com.gregtechceu.gtceu.api.machine.trait.RecipeLogic;
+import com.gregtechceu.gtceu.common.machine.multiblock.electric.PowerSubstationMachine;
+import com.gregtechceu.gtceu.common.machine.owner.MachineOwner;
+
+import com.lowdragmc.lowdraglib.syncdata.annotation.Persisted;
+import com.lowdragmc.lowdraglib.utils.DummyWorld;
+
+import net.minecraft.ChatFormatting;
+import net.minecraft.network.chat.Component;
+import net.minecraft.network.chat.Style;
+import net.minecraft.server.level.ServerLevel;
+
+import java.math.BigInteger;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+
+public class DimensionalEnergyCapacitor extends DimensionalEnergyInterface {
+
+
+ public static final int MAX_BATTERY_LAYER = 18;
+ public static final int MIN_CASINGS = 14;
+
+ // Passive Drain Constants
+ // 1% capacity per 24 hours
+ public static final long PASSIVE_DRAIN_DIVISOR = 20 * 60 * 60 * 24 * 100;
+ // no more than 100kEU/t per storage block
+ public static final long PASSIVE_DRAIN_MAX_PER_STORAGE = 100_000L;
+
+ // Used to make sure you cannot have more than one of this multiblock per player / team
+ @Persisted
+ public boolean isDuplicate = false;
+
+ @Persisted
+ private long[] capacities;
+
+ public DimensionalEnergyCapacitor(BlockEntityCreationInfo holder) {
+ super(holder);
+ this.localDisplay = false;
+ }
+
+ @Override
+ public void onStructureFormed() {
+ if (getLevel() instanceof DummyWorld) super.onStructureFormed();
+
+ if (getLevel() instanceof ServerLevel serverLevel) {
+ var owner = getTeamUUID();
+ if (owner == MachineOwner.EMPTY) {
+ CosmicCore.LOGGER.warn("DimensionalEnergyCapcitor tried to form with null team.");
+ return;
+ }
+ var multiblockId = getDefinition().getId().toString();
+ var wirelessData = WirelessEnergySavedData.getOrCreate(serverLevel);
+ var uniqueMultiblockMapping = UniqueMultiblockSavedData.getOrCreate(serverLevel);
+
+ if (uniqueMultiblockMapping.hasData(owner, multiblockId, getDimension())) {
+ this.isDuplicate = !uniqueMultiblockMapping.isUnique(owner, multiblockId, getDimension(), getBlockPos());
+ if (isDuplicate) {
+ recipeLogic.setStatus(RecipeLogic.Status.SUSPEND);
+ return;
+ }
+ } else uniqueMultiblockMapping.addMultiblock(owner, getDefinition().getId().toString(), getDimension(),
+ getBlockPos());
+
+ List batteries = new ArrayList<>();
+ for (Map.Entry battery : getMultiblockState().getMatchContext().entrySet()) {
+ if (battery.getKey().startsWith(PowerSubstationMachine.PMC_BATTERY_HEADER) &&
+ battery.getValue() instanceof PowerSubstationMachine.BatteryMatchWrapper wrapper) {
+ for (int i = 0; i < wrapper.getAmount(); i++) {
+ batteries.add(wrapper.getPartType());
+ }
+ }
+ }
+
+ this.capacities = batteries.stream().mapToLong(IBatteryData::getCapacity).toArray();
+
+ if (batteries.isEmpty()) {
+ onStructureInvalid();
+ return;
+ }
+
+ super.onStructureFormed(); // This order is important do not move
+
+ var capacity = batteries.stream().mapToLong(IBatteryData::getCapacity)
+ .mapToObj(BigInteger::valueOf).reduce(BigInteger.ZERO, BigInteger::add);
+
+ wirelessData.setCapacity(owner, capacity);
+ wirelessData.setActive(owner, true);
+ }
+ }
+
+ @Override
+ public void onStructureInvalid() {
+ super.onStructureInvalid();
+ if (getLevel() instanceof ServerLevel serverLevel) {
+ var owner = getTeamUUID();
+ if (owner != MachineOwner.EMPTY) {
+ var wirelessData = WirelessEnergySavedData.getOrCreate(serverLevel);
+ var uniqueMultiblockMapping = UniqueMultiblockSavedData.getOrCreate(serverLevel);
+ wirelessData.setActive(owner, false);
+ uniqueMultiblockMapping.removeMultiblock(owner, getDefinition().getId().toString(), getDimension(),
+ getBlockPos());
+ }
+ }
+ this.capacities = null;
+ }
+
+ @Override
+ public boolean isActive() {
+ if (isDuplicate) return false;
+ return super.isActive();
+ }
+
+ @Override
+ public long getPassiveDrainPerTick() {
+ long[] drains = Arrays.stream(capacities)
+ .map(cap -> Math.min(PASSIVE_DRAIN_MAX_PER_STORAGE, cap / PASSIVE_DRAIN_DIVISOR)).toArray();
+ return Arrays.stream(drains).sum();
+ }
+
+ @Override
+ public void addDisplayText(List textList) {
+ if (this.isDuplicate) {
+ textList.add(Component.translatable("cosmic.multiblock.capacitor.duplicate.multiblock.1")
+ .setStyle(Style.EMPTY.withColor(ChatFormatting.DARK_RED)));
+ textList.add(Component.translatable("cosmic.multiblock.capacitor.duplicate.multiblock.2")
+ .setStyle(Style.EMPTY.withColor(ChatFormatting.DARK_RED)));
+ } else super.addDisplayText(textList);
+ }
+
+ @Override
+ public void setWorkingEnabled(boolean isWorkingAllowed) {
+ super.setWorkingEnabled(isWorkingAllowed);
+ if (getLevel() instanceof ServerLevel serverLevel) {
+ var owner = getTeamUUID();
+ if (owner != MachineOwner.EMPTY) {
+ var wirelessData = WirelessEnergySavedData.getOrCreate(serverLevel);
+ wirelessData.setActive(owner, isWorkingAllowed);
+ }
+ }
+ }
+
+ private boolean hasOwner() {
+ var owner = getTeamUUID();
+ return owner != MachineOwner.EMPTY;
+ }
+
+ @Override
+ public void attachTooltips(TooltipsPanel tooltipsPanel) {
+ super.attachTooltips(tooltipsPanel);
+ tooltipsPanel.attachTooltips(new IFancyTooltip.Basic(
+ () -> GuiTextures.INDICATOR_NO_ENERGY,
+ () -> List.of(Component.translatable("cosmic.multiblock.capacitor.owner.null")
+ .setStyle(Style.EMPTY.withColor(ChatFormatting.RED))),
+ () -> (!this.hasOwner()),
+ () -> null));
+ }
+
+ private String getDimension() {
+ if (getLevel() instanceof ServerLevel serverLevel) {
+ return serverLevel.dimension().location().toString();
+ }
+ return null;
+ }
+}
diff --git a/src/main/java/com/ghostipedia/cosmiccore/api/machine/multiblock/DimensionalEnergyInterface.java b/src/main/java/com/ghostipedia/cosmiccore/api/machine/multiblock/DimensionalEnergyInterface.java
new file mode 100644
index 000000000..7bd4e37fc
--- /dev/null
+++ b/src/main/java/com/ghostipedia/cosmiccore/api/machine/multiblock/DimensionalEnergyInterface.java
@@ -0,0 +1,436 @@
+package com.ghostipedia.cosmiccore.api.machine.multiblock;
+
+import com.ghostipedia.cosmiccore.api.data.wireless.WirelessEnergySavedData;
+import com.ghostipedia.cosmiccore.utils.CosmicFormattingUtil;
+
+import com.gregtechceu.gtceu.api.capability.IEnergyContainer;
+import com.gregtechceu.gtceu.api.capability.recipe.EURecipeCapability;
+import com.gregtechceu.gtceu.api.capability.recipe.IO;
+import com.gregtechceu.gtceu.api.gui.GuiTextures;
+import com.gregtechceu.gtceu.api.gui.fancy.FancyMachineUIWidget;
+import com.gregtechceu.gtceu.api.machine.ConditionalSubscriptionHandler;
+import com.gregtechceu.gtceu.api.blockentity.BlockEntityCreationInfo;
+import com.gregtechceu.gtceu.api.machine.feature.IFancyUIMachine;
+import com.gregtechceu.gtceu.api.machine.feature.multiblock.IDisplayUIMachine;
+import com.gregtechceu.gtceu.api.machine.feature.multiblock.IMaintenanceMachine;
+import com.gregtechceu.gtceu.api.machine.feature.multiblock.IMultiPart;
+import com.gregtechceu.gtceu.api.machine.multiblock.WorkableMultiblockMachine;
+import com.gregtechceu.gtceu.api.machine.trait.NotifiableEnergyContainer;
+import com.gregtechceu.gtceu.api.machine.trait.RecipeLogic;
+import com.gregtechceu.gtceu.api.misc.EnergyContainerList;
+import com.gregtechceu.gtceu.common.machine.owner.FTBOwner;
+import com.gregtechceu.gtceu.common.machine.owner.MachineOwner;
+import com.gregtechceu.gtceu.config.ConfigHolder;
+import com.gregtechceu.gtceu.utils.FormattingUtil;
+
+import com.lowdragmc.lowdraglib.gui.modular.ModularUI;
+import com.lowdragmc.lowdraglib.gui.util.ClickData;
+import com.lowdragmc.lowdraglib.gui.widget.*;
+import com.lowdragmc.lowdraglib.syncdata.annotation.Persisted;
+import com.lowdragmc.lowdraglib.utils.DummyWorld;
+
+import net.minecraft.ChatFormatting;
+import net.minecraft.network.chat.Component;
+import net.minecraft.network.chat.HoverEvent;
+import net.minecraft.network.chat.MutableComponent;
+import net.minecraft.network.chat.Style;
+import net.minecraft.server.level.ServerLevel;
+import net.minecraft.world.entity.player.Player;
+
+import it.unimi.dsi.fastutil.longs.Long2ObjectMaps;
+
+import java.math.BigInteger;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+
+import static com.ghostipedia.cosmiccore.utils.CosmicFormattingUtil.combineWithConstantWidth;
+import static com.ghostipedia.cosmiccore.utils.CosmicFormattingUtil.formatWithConstantWidth;
+
+public class DimensionalEnergyInterface extends WorkableMultiblockMachine
+ implements IFancyUIMachine, IDisplayUIMachine {
+
+ protected static final long ticks_between_save_data_operations = 5L * 20L; // Once per 5s
+ private static final int uiWidth = 182;
+
+
+ private static final BigInteger BIG_INTEGER_MAX_LONG = BigInteger.valueOf(Long.MAX_VALUE);
+
+ protected IMaintenanceMachine maintenance;
+ protected EnergyContainerList inputHatches;
+ protected EnergyContainerList outputHatches;
+ protected long passiveDrain;
+
+ @Persisted
+ protected IEnergyContainer energyBuffer;
+
+ // Stats tracked for UI display
+ private long netInLastSec;
+ private long netOutLastSec;
+ private long averageInLastSec;
+ private long averageOutLastSec;
+ protected boolean localDisplay;
+
+ protected ConditionalSubscriptionHandler tickSubscription;
+
+ public DimensionalEnergyInterface(BlockEntityCreationInfo holder) {
+ super(holder);
+ this.tickSubscription = new ConditionalSubscriptionHandler(this, this::transferEnergyTick, this::isActive);
+ this.localDisplay = true;
+ }
+
+ @Override
+ public void onStructureFormed() {
+ super.onStructureFormed();
+ if (getLevel() instanceof DummyWorld) return;
+
+ initializeAbilities();
+ setEnergyBuffer();
+
+ tickSubscription.updateSubscription();
+ }
+
+ private void initializeAbilities() {
+ List inputs = new ArrayList<>();
+ List outputs = new ArrayList<>();
+
+ Map ioMap = getMultiblockState().getMatchContext().getOrCreate("ioMap", Long2ObjectMaps::emptyMap);
+ for (IMultiPart part : getParts()) {
+ IO io = ioMap.getOrDefault(part.self().getBlockPos().asLong(), IO.BOTH);
+ if (io == IO.NONE) continue;
+
+ var handlerLists = part.getRecipeHandlers();
+ for (var handlerList : handlerLists) {
+ if (!handlerList.isValid(io)) continue;
+ if (handlerList.getHandlerIO() == IO.IN) {
+ handlerList.getCapability(EURecipeCapability.CAP).stream()
+ .filter(IEnergyContainer.class::isInstance)
+ .map(IEnergyContainer.class::cast)
+ .forEach(inputs::add);
+ } else if (handlerList.getHandlerIO() == IO.OUT) {
+ handlerList.getCapability(EURecipeCapability.CAP).stream()
+ .filter(IEnergyContainer.class::isInstance)
+ .map(IEnergyContainer.class::cast)
+ .forEach(outputs::add);
+ }
+ }
+ }
+
+ this.inputHatches = new EnergyContainerList(inputs);
+ this.outputHatches = new EnergyContainerList(outputs);
+ }
+
+ protected UUID getTeamUUID() {
+ // CosmicCore.LOGGER.warn("getting team UUID");
+ var owner = getOwner();
+ var ownerUUID = getOwnerUUID();
+ // Faultcheck the Owner and OwnerUUID
+ if (owner == null) return MachineOwner.EMPTY;
+ if (ownerUUID == null) return MachineOwner.EMPTY;
+
+ // CosmicCore.LOGGER.warn("Owner UUID: " + ownerUUID.toString());
+ var team = owner instanceof FTBOwner ftbOwner ? ftbOwner.getPlayerTeam(ownerUUID) : null;
+ if (team == null) return ownerUUID;
+
+ // CosmicCore.LOGGER.warn("Team UUID: " + team);
+ // CosmicCore.LOGGER.warn("Team UUID: " + team.getTeamId());
+ return team.getTeamId();
+ }
+
+ @Override
+ public void onStructureInvalid() {
+ if (getLevel() instanceof ServerLevel serverLevel) { // Transfer buffer content to avoid losses
+ var data = WirelessEnergySavedData.getOrCreate(serverLevel);
+ var owner = getTeamUUID();
+ if (owner != MachineOwner.EMPTY) {
+ if (energyBuffer != null) {
+ data.addEUToGlobalWirelessEnergy(owner, energyBuffer.getEnergyStored());
+ energyBuffer.removeEnergy(energyBuffer.getEnergyStored());
+ }
+ data.removeEnergyBuffered(owner, getBlockPos());
+ data.removeEnergyInput(owner, getBlockPos());
+ data.removeEnergyOutput(owner, getBlockPos());
+ data.removePassiveDrain(owner, getBlockPos());
+ }
+ this.inputHatches = null;
+ this.outputHatches = null;
+ this.energyBuffer = null;
+ this.passiveDrain = 0;
+ this.netInLastSec = 0;
+ this.averageInLastSec = 0;
+ this.netOutLastSec = 0;
+ this.averageOutLastSec = 0;
+ }
+
+ tickSubscription.unsubscribe();
+ super.onStructureInvalid();
+ }
+
+ public boolean isActive() {
+ return isFormed();
+ }
+
+ private void setEnergyBuffer() {
+ long totalIOPerTick = (inputHatches.getInputVoltage() + outputHatches.getOutputVoltage());
+ // Size is the totalIOPerTick over the duration between operations doubled
+ long bufferSize = totalIOPerTick *
+ (ticks_between_save_data_operations + (ticks_between_save_data_operations / 2L)) * 2L;
+ bufferSize += (getPassiveDrainPerTick() * 8 * 2) * ticks_between_save_data_operations;
+ if (bufferSize < 0L)
+ throw new RuntimeException("DimensionalEnergyCapacitor: Calculated buffer size is too big.");
+ this.energyBuffer = new NotifiableEnergyContainer(this, bufferSize, Long.MAX_VALUE, Long.MAX_VALUE,
+ Long.MAX_VALUE, Long.MAX_VALUE);
+ }
+
+ public long getPassiveDrainPerTick() {
+ return 20_000L; // 0 in the interfaces, Overridden in the Capacitor
+ }
+
+ public long getPassiveDrain() {
+ if (ConfigHolder.INSTANCE.machines.enableMaintenance) {
+ if (maintenance == null) {
+ for (IMultiPart part : getParts()) {
+ if (part instanceof IMaintenanceMachine maintenanceMachine) {
+ this.maintenance = maintenanceMachine;
+ break;
+ }
+ }
+ }
+ if (maintenance == null) return getPassiveDrainPerTick();
+ int multiplier = 1 + maintenance.getNumMaintenanceProblems();
+ double modifier = maintenance.getDurationMultiplier();
+ return (long) (getPassiveDrainPerTick() * multiplier * modifier);
+ }
+ return getPassiveDrainPerTick();
+ }
+
+ private static MutableComponent getTimeToFillDrainText(BigInteger timeToFillSeconds) {
+ if (timeToFillSeconds.compareTo(BIG_INTEGER_MAX_LONG) > 0) {
+ // too large to represent in a java Duration
+ timeToFillSeconds = BIG_INTEGER_MAX_LONG;
+ }
+
+ Duration duration = Duration.ofSeconds(timeToFillSeconds.longValue());
+ String key;
+ long fillTime;
+ if (duration.getSeconds() <= 180) {
+ fillTime = duration.getSeconds();
+ key = "gtceu.multiblock.power_substation.time_seconds";
+ } else if (duration.toMinutes() <= 180) {
+ fillTime = duration.toMinutes();
+ key = "gtceu.multiblock.power_substation.time_minutes";
+ } else if (duration.toHours() <= 72) {
+ fillTime = duration.toHours();
+ key = "gtceu.multiblock.power_substation.time_hours";
+ } else if (duration.toDays() <= 730) { // 2 years
+ fillTime = duration.toDays();
+ key = "gtceu.multiblock.power_substation.time_days";
+ } else if (duration.toDays() / 365 < 1_000_000) {
+ fillTime = duration.toDays() / 365;
+ key = "gtceu.multiblock.power_substation.time_years";
+ } else {
+ return Component.translatable("gtceu.multiblock.power_substation.time_forever");
+ }
+
+ return Component.translatable(key, FormattingUtil.formatNumbers(fillTime));
+ }
+
+ protected void transferEnergyTick() {
+ if (getLevel() instanceof ServerLevel serverLevel) {
+ var data = WirelessEnergySavedData.getOrCreate(serverLevel);
+ var owner = getTeamUUID();
+
+ if (isWorkingEnabled() && isFormed() && owner != MachineOwner.EMPTY) {
+ if (getOffsetTimer() % 20 == 0) {
+ getRecipeLogic().setStatus((energyBuffer != null && energyBuffer.getEnergyStored() > 0) ?
+ RecipeLogic.Status.WORKING : RecipeLogic.Status.IDLE);
+
+ averageInLastSec = netInLastSec / 20;
+ averageOutLastSec = netOutLastSec / 20;
+ netInLastSec = 0;
+ netOutLastSec = 0;
+
+ // Send IO values to global Storage to display in the Dimensional Storage.
+ data.setEnergyInput(owner, getBlockPos(), averageInLastSec);
+ data.setEnergyOutput(owner, getBlockPos(), averageOutLastSec);
+ data.setEnergyBuffered(owner, getBlockPos(), energyBuffer.getEnergyStored());
+ }
+
+ // Handle inputs
+ long energyBuffered = energyBuffer.addEnergy(inputHatches.getEnergyStored());
+ inputHatches.changeEnergy(-energyBuffered);
+ netInLastSec += energyBuffered;
+
+ // Passive Drain
+ long energyPassiveDrained = energyBuffer.removeEnergy(getPassiveDrain());
+ netOutLastSec += energyPassiveDrained;
+
+ // Handle outputs
+ long energyNeed = outputHatches.getEnergyCapacity() - outputHatches.getEnergyStored();
+ long energyDeBuffered = energyBuffer.removeEnergy(energyNeed);
+ outputHatches.changeEnergy(energyDeBuffered);
+ netOutLastSec += energyDeBuffered;
+
+ // Handle buffer transfer to WirelessEnergySavedData
+ if (getOffsetTimer() % ticks_between_save_data_operations == 0) {
+ if (data.isActive(owner)) {
+ // After operation buffer should aim to be 50% full
+ var euToTransfer = energyBuffer.getEnergyStored() - (energyBuffer.getEnergyCapacity() / 2);
+ var euTransferred = data.addEUToGlobalWirelessEnergy(owner, euToTransfer);
+ energyBuffer.changeEnergy(-(euToTransfer - euTransferred));
+ data.setEnergyBuffered(owner, getBlockPos(), energyBuffer.getEnergyStored());
+ data.setPassiveDrain(owner, getBlockPos(), getPassiveDrain());
+ }
+ }
+ } else {
+ data.removeEnergyBuffered(owner, getBlockPos());
+ data.removeEnergyInput(owner, getBlockPos());
+ data.removeEnergyOutput(owner, getBlockPos());
+ data.removePassiveDrain(owner, getBlockPos());
+ }
+ }
+ }
+
+ @Override
+ public void addDisplayText(List textList) {
+ IDisplayUIMachine.super.addDisplayText(textList);
+ if (this.isFormed()) {
+ // Multiblock status
+ if (!isWorkingEnabled()) textList.add(Component.translatable("gtceu.multiblock.work_paused"));
+ else if (isActive()) textList.add(Component.translatable("gtceu.multiblock.running"));
+ else textList.add(Component.translatable("gtceu.multiblock.idling"));
+
+ if (recipeLogic.isWaiting()) {
+ textList.add(Component.translatable("gtceu.multiblock.waiting")
+ .setStyle(Style.EMPTY.withColor(ChatFormatting.RED)));
+ }
+
+ var owner = getTeamUUID();
+ if (energyBuffer != null && owner != MachineOwner.EMPTY) {
+ if (getLevel() instanceof ServerLevel serverLevel) {
+ var data = WirelessEnergySavedData.getOrCreate(serverLevel);
+
+ var STYLE_GOLD = Style.EMPTY.withColor(ChatFormatting.GOLD);
+ var STYLE_DARK_RED = Style.EMPTY.withColor(ChatFormatting.DARK_RED);
+ var STYLE_GREEN = Style.EMPTY.withColor(ChatFormatting.GREEN);
+ var STYLE_RED = Style.EMPTY.withColor(ChatFormatting.RED);
+
+ // Tittle
+ var buttonComponent = ComponentPanelWidget.withButton(Component.literal("[").append(localDisplay ?
+ Component.translatable("cosmic.multiblock.capacitor.info.global") :
+ Component.translatable("cosmic.multiblock.capacitor.info.local"))
+ .append(Component.literal("]")), "local_display");
+
+ var labelComponent = localDisplay ?
+ Component.translatable("cosmic.multiblock.capacitor.info.tittle.local") :
+ Component.translatable("cosmic.multiblock.capacitor.info.tittle.global");
+
+ textList.add(localDisplay ? combineWithConstantWidth(labelComponent, buttonComponent, uiWidth - 4) :
+ combineWithConstantWidth(buttonComponent, labelComponent, uiWidth - 4));
+
+ BigInteger energyCapacity = data.getEnergyCapacity(owner);;
+ BigInteger energyStored = data.getEnergyStored(owner);
+ energyStored = energyStored.add(BigInteger.valueOf(energyBuffer.getEnergyStored()));
+ BigInteger energyBuffered;
+ long passiveDrain;
+ long avgIn;
+ long avgOut;
+
+ if (localDisplay) {
+ energyBuffered = BigInteger.valueOf(energyBuffer.getEnergyStored());
+ avgIn = averageInLastSec;
+ avgOut = averageOutLastSec;
+ passiveDrain = getPassiveDrain();
+ } else {
+ energyBuffered = data.getEnergyBufferedExceptLocal(owner, getBlockPos());
+ energyBuffered = energyBuffered.add(BigInteger.valueOf(energyBuffer.getEnergyStored()));
+ avgIn = data.getEnergyInput(owner);
+ avgOut = data.getEnergyOutput(owner);
+ passiveDrain = data.getPassiveDrain(owner);
+ }
+
+ var storedComponent = Component
+ .literal(CosmicFormattingUtil.formatNumberWithCharacterLimit(energyStored, 12));
+ textList.add(formatWithConstantWidth("gtceu.multiblock.power_substation.stored",
+ storedComponent.setStyle(STYLE_GOLD), uiWidth - 4));
+
+ var capacityComponent = Component
+ .literal(CosmicFormattingUtil.formatNumberWithCharacterLimit(energyCapacity, 12));
+ textList.add(formatWithConstantWidth("gtceu.multiblock.power_substation.capacity",
+ capacityComponent.setStyle(STYLE_GOLD), uiWidth - 4));
+
+ var bufferedComponent = Component
+ .literal(CosmicFormattingUtil.formatNumberWithCharacterLimit(energyBuffered, 12));
+ textList.add(formatWithConstantWidth("cosmic.multiblock.capacitor.buffered",
+ bufferedComponent.setStyle(STYLE_GOLD), uiWidth - 4));
+
+ var passiveDrainComponent = Component.literal(
+ CosmicFormattingUtil.formatNumberWithCharacterLimit(BigInteger.valueOf(passiveDrain), 9));
+ textList.add(formatWithConstantWidth("gtceu.multiblock.power_substation.passive_drain",
+ passiveDrainComponent.setStyle(STYLE_DARK_RED), uiWidth - 4));
+
+ var avgInComponent = Component.literal(
+ CosmicFormattingUtil.formatNumberWithCharacterLimit(BigInteger.valueOf(avgIn), 10));
+ textList.add(formatWithConstantWidth("gtceu.multiblock.power_substation.average_in",
+ avgInComponent.setStyle(STYLE_GREEN), uiWidth - 4)
+ .withStyle(Style.EMPTY.withHoverEvent(new HoverEvent(HoverEvent.Action.SHOW_TEXT,
+ Component.translatable("gtceu.multiblock.power_substation.average_in_hover")))));
+
+ var avgOutComponent = Component.literal(CosmicFormattingUtil
+ .formatNumberWithCharacterLimit(BigInteger.valueOf(Math.abs(avgOut)), 10));
+ textList.add(formatWithConstantWidth("gtceu.multiblock.power_substation.average_out",
+ avgOutComponent.setStyle(STYLE_RED), uiWidth - 4)
+ .withStyle(Style.EMPTY.withHoverEvent(new HoverEvent(HoverEvent.Action.SHOW_TEXT,
+ Component.translatable("gtceu.multiblock.power_substation.average_out_hover")))));
+
+ if (!localDisplay) {
+ var avgInput = data.getEnergyInput(owner);
+ var avgOutput = data.getEnergyOutput(owner);
+ if (avgInput > avgOutput) {
+ BigInteger timeToFillSeconds = data.getEnergyCapacity(owner)
+ .subtract(data.getEnergyStored(owner))
+ .divide(BigInteger.valueOf((avgInput - avgOutput) * 20));
+ textList.add(formatWithConstantWidth("gtceu.multiblock.power_substation.time_to_fill",
+ getTimeToFillDrainText(timeToFillSeconds).setStyle(STYLE_GREEN), uiWidth - 4));
+ } else if (avgInput < avgOutput) {
+ BigInteger timeToDrainSeconds = energyStored
+ .divide(BigInteger.valueOf((avgOutput - avgInput) * 20));
+ textList.add(formatWithConstantWidth("gtceu.multiblock.power_substation.time_to_drain",
+ getTimeToFillDrainText(timeToDrainSeconds).setStyle(STYLE_RED), uiWidth - 4));
+ }
+ }
+ }
+ }
+ }
+
+ getDefinition().getAdditionalDisplay().accept(this, textList);
+ }
+
+ @Override
+ public void handleDisplayClick(String componentData, ClickData clickData) {
+ if (!clickData.isRemote) {
+ if (componentData.equals("local_display")) {
+ localDisplay = !localDisplay;
+ }
+ }
+ }
+
+ @Override
+ public Widget createUIWidget() {
+ var group = new WidgetGroup(0, 0, uiWidth + 8, 117 + 8);
+ group.addWidget(new DraggableScrollableWidgetGroup(4, 4, uiWidth, 117).setBackground(getScreenTexture())
+ .addWidget(new LabelWidget(4, 5, self().getBlockState().getBlock().getDescriptionId()))
+ .addWidget(new ComponentPanelWidget(4, 17, this::addDisplayText).setMaxWidthLimit(uiWidth - 4)
+ .clickHandler(this::handleDisplayClick)));
+ group.setBackground(GuiTextures.BACKGROUND_INVERSE);
+ return group;
+ }
+
+ @Override
+ public ModularUI createUI(Player entityPlayer) {
+ return new ModularUI(uiWidth + 8, 208, this, entityPlayer)
+ .widget(new FancyMachineUIWidget(this, uiWidth + 8, 208));
+ }
+}
diff --git a/src/main/java/com/ghostipedia/cosmiccore/api/machine/multiblock/DreamersBasinMachine.java b/src/main/java/com/ghostipedia/cosmiccore/api/machine/multiblock/DreamersBasinMachine.java
new file mode 100644
index 000000000..b440d89b9
--- /dev/null
+++ b/src/main/java/com/ghostipedia/cosmiccore/api/machine/multiblock/DreamersBasinMachine.java
@@ -0,0 +1,376 @@
+package com.ghostipedia.cosmiccore.api.machine.multiblock;
+
+import com.ghostipedia.cosmiccore.common.machine.multiblock.multi.logic.MultithreadedMachine;
+import com.ghostipedia.cosmiccore.common.machine.multiblock.multi.logic.MultithreadedRecipeLogic;
+
+import com.gregtechceu.gtceu.api.GTValues;
+import com.gregtechceu.gtceu.api.capability.recipe.FluidRecipeCapability;
+import com.gregtechceu.gtceu.api.capability.recipe.ItemRecipeCapability;
+import com.gregtechceu.gtceu.api.gui.GuiTextures;
+import com.gregtechceu.gtceu.api.blockentity.BlockEntityCreationInfo;
+import com.gregtechceu.gtceu.api.machine.feature.multiblock.IDisplayUIMachine;
+import com.gregtechceu.gtceu.api.machine.multiblock.MultiblockDisplayText;
+import com.gregtechceu.gtceu.api.recipe.GTRecipe;
+import com.gregtechceu.gtceu.api.recipe.content.Content;
+import com.gregtechceu.gtceu.utils.FormattingUtil;
+import com.gregtechceu.gtceu.utils.GTUtil;
+
+import com.lowdragmc.lowdraglib.gui.widget.*;
+import net.minecraft.ChatFormatting;
+import net.minecraft.network.chat.Component;
+import net.minecraft.network.chat.HoverEvent;
+import net.minecraft.network.chat.MutableComponent;
+import net.minecraft.network.chat.Style;
+import net.minecraft.world.item.DyeColor;
+import net.minecraft.world.item.ItemStack;
+import net.neoforged.neoforge.common.crafting.SizedIngredient;
+import net.neoforged.neoforge.fluids.FluidStack;
+import net.neoforged.neoforge.fluids.crafting.SizedFluidIngredient;
+
+import org.jetbrains.annotations.NotNull;
+
+import java.util.List;
+
+/**
+ * The Dreamer's Basin Machine - A multithreaded processing machine with custom UI.
+ *
+ * This machine extends MultithreadedMachine and provides a rich UI that displays:
+ * - Thread status with color-coded indicators
+ * - Per-thread recipe progress bars
+ * - Current recipe information for each thread
+ * - Energy consumption breakdown
+ * - Overclock levels per thread
+ */
+public class DreamersBasinMachine extends MultithreadedMachine implements IDisplayUIMachine {
+
+
+ public DreamersBasinMachine(BlockEntityCreationInfo holder) {
+ super(holder);
+ }
+
+
+ // ===== Custom UI Implementation =====
+
+ @Override
+ public Widget createUIWidget() {
+ var group = new WidgetGroup(0, 0, 256, 200);
+
+ // Main scrollable area
+ var scrollable = new DraggableScrollableWidgetGroup(4, 4, 248, 192)
+ .setBackground(getScreenTexture());
+
+ // Title
+ scrollable.addWidget(new LabelWidget(4, 5, self().getBlockState().getBlock().getDescriptionId()));
+
+ // Component panel for dynamic text (status, energy, etc.)
+ scrollable.addWidget(new ComponentPanelWidget(4, 17, this::addDisplayText)
+ .textSupplier(this.getLevel().isClientSide ? null : this::addDisplayText)
+ .setMaxWidthLimit(240)
+ .clickHandler(this::handleDisplayClick));
+
+ group.addWidget(scrollable);
+ group.setBackground(GuiTextures.BACKGROUND_INVERSE);
+
+ return group;
+ }
+
+ @Override
+ public void addDisplayText(List textList) {
+ // Use MultiblockDisplayText builder for consistent formatting
+ var builder = MultiblockDisplayText.builder(textList, isFormed())
+ .setWorkingStatus(isWorkingEnabled(), getRunningThreadCount() > 0);
+
+ if (isFormed()) {
+ // Energy info first
+ builder.addEnergyUsageLine(energyContainer);
+ builder.addEnergyTierLine(tier);
+
+ // Separator
+ builder.addCustom(tl -> tl.add(Component.empty()));
+
+ // Thread Status Header
+ builder.addCustom(tl -> {
+ tl.add(Component.translatable("cosmiccore.machine.dreamers_basin.thread_header")
+ .withStyle(ChatFormatting.AQUA, ChatFormatting.BOLD));
+
+ // Summary line
+ int running = getRunningThreadCount();
+ int total = getThreadLogics().size();
+ int max = getMaxThreads();
+
+ MutableComponent summary = Component.literal(" ")
+ .append(Component.translatable("cosmiccore.machine.dreamers_basin.threads_summary",
+ running, total, max));
+
+ if (running == total && total > 0) {
+ summary = summary.withStyle(ChatFormatting.GREEN);
+ } else if (running > 0) {
+ summary = summary.withStyle(ChatFormatting.YELLOW);
+ } else {
+ summary = summary.withStyle(ChatFormatting.GRAY);
+ }
+ tl.add(summary);
+ });
+
+ // Per-thread detailed status
+ builder.addCustom(tl -> {
+ tl.add(Component.empty());
+
+ for (MultithreadedRecipeLogic logic : getThreadLogics().values()) {
+ addThreadStatusLine(tl, logic);
+ }
+ });
+
+ // EU Budget info
+ builder.addCustom(tl -> {
+ tl.add(Component.empty());
+ tl.add(Component.translatable("cosmiccore.machine.dreamers_basin.eu_budget_header")
+ .withStyle(ChatFormatting.GOLD));
+
+ if (!getThreadLogics().isEmpty()) {
+ MultithreadedRecipeLogic firstThread = getThreadLogics().values().iterator().next();
+ long euPerThread = firstThread.getMaxEUtPerThread();
+ int voltageTier = GTUtil.getFloorTierByVoltage(euPerThread);
+ String tierName = GTValues.VNF[voltageTier];
+
+ tl.add(Component.literal(" ")
+ .append(Component.translatable("cosmiccore.machine.dreamers_basin.eu_per_thread",
+ FormattingUtil.formatNumbers(euPerThread), tierName))
+ .withStyle(ChatFormatting.GRAY));
+ }
+ });
+ }
+
+ // Additional display from definition
+ getDefinition().getAdditionalDisplay().accept(this, textList);
+ }
+
+ /**
+ * Add a detailed status line for a single thread.
+ */
+ private void addThreadStatusLine(List textList, MultithreadedRecipeLogic logic) {
+ int color = logic.getThreadColor();
+ String colorName = getColorDisplayName(color);
+ ChatFormatting colorFormat = getColorChatFormatting(color);
+
+ // Build the thread status line
+ MutableComponent line = Component.literal(" ");
+
+ // Color indicator [COLOR]
+ line.append(Component.literal("[" + colorName + "] ").withStyle(colorFormat));
+
+ if (logic.isWorking()) {
+ // Thread is actively processing
+ GTRecipe recipe = logic.getCurrentRecipe();
+ int progress = logic.getProgress();
+ int duration = logic.getDuration();
+ int percent = duration > 0 ? (progress * 100 / duration) : 0;
+
+ // Progress bar visualization
+ String progressBar = createProgressBar(percent);
+
+ // Build hover tooltip with recipe details
+ Component hoverTooltip = buildRecipeTooltip(recipe, duration);
+
+ // Create the progress portion with hover event
+ MutableComponent progressComponent = Component.literal(progressBar + " ")
+ .withStyle(Style.EMPTY
+ .withColor(ChatFormatting.GREEN)
+ .withHoverEvent(new HoverEvent(HoverEvent.Action.SHOW_TEXT, hoverTooltip)));
+
+ line.append(progressComponent);
+ line.append(Component.literal(percent + "%").withStyle(ChatFormatting.WHITE));
+
+ // Recipe EU/t info
+ if (recipe != null) {
+ long recipeEUt = recipe.getInputEUt().getTotalEU();
+ if (recipeEUt > 0) {
+ line.append(Component.literal(" (")
+ .append(Component.literal(FormattingUtil.formatNumbers(recipeEUt) + " EU/t")
+ .withStyle(ChatFormatting.YELLOW))
+ .append(Component.literal(")")));
+ }
+ }
+
+ textList.add(line);
+
+ // Add time remaining on next line (also with hover)
+ if (duration > 0) {
+ int ticksRemaining = duration - progress;
+ float secondsRemaining = ticksRemaining / 20.0f;
+ MutableComponent timeLine = Component.literal(" ")
+ .append(Component.translatable("cosmiccore.machine.dreamers_basin.time_remaining",
+ String.format("%.1fs", secondsRemaining))
+ .withStyle(Style.EMPTY
+ .withColor(ChatFormatting.DARK_GRAY)
+ .withHoverEvent(new HoverEvent(HoverEvent.Action.SHOW_TEXT, hoverTooltip))));
+ textList.add(timeLine);
+ }
+
+ } else if (logic.isIdle()) {
+ line.append(Component.translatable("cosmiccore.machine.dreamers_basin.status_idle")
+ .withStyle(ChatFormatting.GRAY));
+ textList.add(line);
+
+ } else if (logic.isWaiting()) {
+ line.append(Component.translatable("cosmiccore.machine.dreamers_basin.status_waiting")
+ .withStyle(ChatFormatting.YELLOW));
+ textList.add(line);
+
+ } else if (logic.isSuspend()) {
+ line.append(Component.translatable("cosmiccore.machine.dreamers_basin.status_suspended")
+ .withStyle(ChatFormatting.RED));
+ textList.add(line);
+
+ } else {
+ line.append(Component.translatable("cosmiccore.machine.dreamers_basin.status_unknown")
+ .withStyle(ChatFormatting.DARK_GRAY));
+ textList.add(line);
+ }
+ }
+
+ /**
+ * Build a hover tooltip showing the first recipe output and production rate.
+ * Due to Minecraft hover event limitations, this is kept to a single line.
+ */
+ private Component buildRecipeTooltip(GTRecipe recipe, int duration) {
+ if (recipe == null) {
+ return Component.translatable("cosmiccore.machine.dreamers_basin.tooltip.no_recipe");
+ }
+
+ // Calculate rate (items per second)
+ float recipesPerSecond = duration > 0 ? 20.0f / duration : 0;
+
+ MutableComponent tooltip = Component.translatable("cosmiccore.machine.dreamers_basin.tooltip.crafting")
+ .withStyle(ChatFormatting.GOLD)
+ .append(Component.literal(" ").withStyle(ChatFormatting.RESET));
+
+ // Try to find first item output
+ // Note: Content stores SizedIngredient, not raw ItemStack
+ List itemOutputs = recipe.getOutputContents(ItemRecipeCapability.CAP);
+ if (itemOutputs != null && !itemOutputs.isEmpty()) {
+ for (Content content : itemOutputs) {
+ Object contentObj = content.getContent();
+ if (contentObj instanceof SizedIngredient ingredient) {
+ ItemStack[] items = ingredient.getItems();
+ if (items.length > 0 && !items[0].isEmpty()) {
+ ItemStack stack = items[0];
+ int count = stack.getCount();
+ float perSecond = count * recipesPerSecond;
+
+ tooltip.append(stack.getHoverName().copy().withStyle(ChatFormatting.WHITE))
+ .append(Component.literal(" x" + count).withStyle(ChatFormatting.GRAY));
+
+ if (perSecond >= 0.1f) {
+ tooltip.append(Component.literal(String.format(" (%.1f/s)", perSecond))
+ .withStyle(ChatFormatting.AQUA));
+ }
+ return tooltip;
+ }
+ }
+ }
+ }
+
+ // Try fluid outputs if no items
+ // Note: Content stores SizedFluidIngredient, not raw FluidStack
+ List fluidOutputs = recipe.getOutputContents(FluidRecipeCapability.CAP);
+ if (fluidOutputs != null && !fluidOutputs.isEmpty()) {
+ for (Content content : fluidOutputs) {
+ Object contentObj = content.getContent();
+ if (contentObj instanceof SizedFluidIngredient ingredient) {
+ FluidStack[] fluids = ingredient.getFluids();
+ if (fluids.length > 0 && !fluids[0].isEmpty()) {
+ int amount = ingredient.amount();
+ float perSecond = amount * recipesPerSecond;
+
+ tooltip.append(fluids[0].getHoverName().copy().withStyle(ChatFormatting.BLUE))
+ .append(Component.literal(" " + FormattingUtil.formatNumbers(amount) + "mB")
+ .withStyle(ChatFormatting.GRAY));
+
+ if (perSecond >= 1f) {
+ tooltip.append(Component.literal(String.format(" (%.0f mB/s)", perSecond))
+ .withStyle(ChatFormatting.AQUA));
+ }
+ return tooltip;
+ }
+ }
+ }
+ }
+
+ // Generic fallback
+ return tooltip.append(Component.translatable("cosmiccore.machine.dreamers_basin.tooltip.processing")
+ .withStyle(ChatFormatting.GRAY));
+ }
+
+ /**
+ * Create a simple text-based progress bar.
+ */
+ private String createProgressBar(int percent) {
+ int filled = percent / 10;
+ int empty = 10 - filled;
+ return "[" + "=".repeat(filled) + "-".repeat(empty) + "]";
+ }
+
+ /**
+ * Get a display-friendly color name.
+ * GTCEu stores painting color as dye.getMapColor().col
+ */
+ private String getColorDisplayName(int color) {
+ if (color == -1) return "Default";
+
+ for (DyeColor dye : DyeColor.values()) {
+ // GTCEu uses getMapColor().col for painted colors
+ if (dye.getMapColor().col == color) {
+ // Capitalize first letter of each word
+ String name = dye.getName().replace("_", " ");
+ StringBuilder result = new StringBuilder();
+ for (String word : name.split(" ")) {
+ if (!result.isEmpty()) result.append(" ");
+ result.append(word.substring(0, 1).toUpperCase()).append(word.substring(1));
+ }
+ return result.toString();
+ }
+ }
+ return "Custom";
+ }
+
+ /**
+ * Get the ChatFormatting color that best matches the thread color.
+ * GTCEu stores painting color as dye.getMapColor().col
+ */
+ private ChatFormatting getColorChatFormatting(int color) {
+ if (color == -1) return ChatFormatting.WHITE;
+
+ for (DyeColor dye : DyeColor.values()) {
+ // GTCEu uses getMapColor().col for painted colors
+ if (dye.getMapColor().col == color) {
+ return switch (dye) {
+ case WHITE -> ChatFormatting.WHITE;
+ case ORANGE -> ChatFormatting.GOLD;
+ case MAGENTA -> ChatFormatting.LIGHT_PURPLE;
+ case LIGHT_BLUE -> ChatFormatting.AQUA;
+ case YELLOW -> ChatFormatting.YELLOW;
+ case LIME -> ChatFormatting.GREEN;
+ case PINK -> ChatFormatting.LIGHT_PURPLE;
+ case GRAY -> ChatFormatting.DARK_GRAY;
+ case LIGHT_GRAY -> ChatFormatting.GRAY;
+ case CYAN -> ChatFormatting.DARK_AQUA;
+ case PURPLE -> ChatFormatting.DARK_PURPLE;
+ case BLUE -> ChatFormatting.BLUE;
+ case BROWN -> ChatFormatting.GOLD;
+ case GREEN -> ChatFormatting.DARK_GREEN;
+ case RED -> ChatFormatting.RED;
+ case BLACK -> ChatFormatting.DARK_GRAY;
+ };
+ }
+ }
+ return ChatFormatting.WHITE;
+ }
+
+ /**
+ * Get all thread logics for iteration.
+ */
+ public Iterable getThreadLogicsIterable() {
+ return getThreadLogics().values();
+ }
+}
diff --git a/src/main/java/com/ghostipedia/cosmiccore/api/machine/multiblock/DroneStationMachine.java b/src/main/java/com/ghostipedia/cosmiccore/api/machine/multiblock/DroneStationMachine.java
new file mode 100644
index 000000000..9a0f4abf5
--- /dev/null
+++ b/src/main/java/com/ghostipedia/cosmiccore/api/machine/multiblock/DroneStationMachine.java
@@ -0,0 +1,357 @@
+package com.ghostipedia.cosmiccore.api.machine.multiblock;
+
+import com.ghostipedia.cosmiccore.api.machine.part.DroneMaintenanceInterfacePartMachine;
+import com.ghostipedia.cosmiccore.api.misc.DroneStationConnection;
+import com.ghostipedia.cosmiccore.common.data.CosmicItems;
+
+import com.gregtechceu.gtceu.api.GTValues;
+import com.gregtechceu.gtceu.api.capability.IControllable;
+import com.gregtechceu.gtceu.api.capability.recipe.IO;
+import com.gregtechceu.gtceu.api.gui.GuiTextures;
+import com.gregtechceu.gtceu.api.gui.fancy.FancyMachineUIWidget;
+import com.gregtechceu.gtceu.api.blockentity.BlockEntityCreationInfo;
+import com.gregtechceu.gtceu.api.machine.TickableSubscription;
+import com.gregtechceu.gtceu.api.machine.multiblock.MultiblockControllerMachine;
+import com.gregtechceu.gtceu.api.machine.multiblock.WorkableElectricMultiblockMachine;
+import com.gregtechceu.gtceu.api.machine.trait.RecipeLogic;
+import com.gregtechceu.gtceu.api.recipe.GTRecipe;
+import com.gregtechceu.gtceu.api.recipe.RecipeHelper;
+import com.gregtechceu.gtceu.data.recipe.builder.GTRecipeBuilder;
+import com.gregtechceu.gtceu.utils.ExtendedUseOnContext;
+
+import com.lowdragmc.lowdraglib.gui.modular.ModularUI;
+import com.lowdragmc.lowdraglib.gui.texture.GuiTextureGroup;
+import com.lowdragmc.lowdraglib.gui.texture.TextTexture;
+import com.lowdragmc.lowdraglib.gui.widget.*;
+
+import net.minecraft.ChatFormatting;
+import net.minecraft.network.chat.Component;
+import net.minecraft.network.chat.Style;
+import net.minecraft.resources.ResourceLocation;
+import net.minecraft.world.InteractionResult;
+import net.minecraft.world.entity.player.Player;
+import net.minecraft.world.item.ItemStack;
+
+import com.google.common.collect.HashMultimap;
+import org.jetbrains.annotations.NotNull;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+
+public class DroneStationMachine extends WorkableElectricMultiblockMachine {
+
+ // A MultiMap from Dimension -> DroneStation, such that all Drone Maintenance Interfaces can
+ // find their closest DroneStation in their world
+ public static final HashMultimap droneStations = HashMultimap.create();
+
+ private TickableSubscription tickSubscription;
+
+ public final List connections = new ArrayList<>();
+
+ public DroneTier currentTier = null;
+
+ public enum DroneTier {
+ // In this specific order so values are highest first
+ // for the case of having multiple drones in a hatch
+
+ // spotless:off
+ PLASMATIC( 4096, GTValues.V[GTValues.UV], 0f, CosmicItems.PLASMATIC_DRONE.asStack(1)),
+ SANGUINE( 2048, GTValues.V[GTValues.ZPM], 0.25f, CosmicItems.SANGUINE_DRONE.asStack(1)),
+ INDUSTRIAL( 1024, GTValues.V[GTValues.LuV], 0.5f, CosmicItems.INDUSTRIAL_DRONE.asStack(1)),
+ ROBUST( 512, GTValues.V[GTValues.IV], 0.75f, CosmicItems.ROBUST_DRONE.asStack(1)),
+ RUSTY( 256, GTValues.V[GTValues.EV], 1, CosmicItems.RUSTY_DRONE.asStack(1)),
+ // spotless:on
+ ;
+
+ public final long range;
+ public final long EUt;
+ public final float consumptionChance;
+ public final ItemStack item;
+
+ DroneTier(long range, long EUt, float consumptionChance, ItemStack item) {
+ this.range = range;
+ this.EUt = EUt;
+ this.consumptionChance = consumptionChance;
+ this.item = item;
+ }
+ }
+
+ static List droneTierRecipes = new ArrayList<>();
+ static final String TIER_KEY = "drone_tier";
+ static {
+ for (DroneTier tier : DroneTier.values()) {
+
+ droneTierRecipes.add(GTRecipeBuilder.ofRaw()
+ .notConsumable(tier.item) // we need this so it doesn't match empty stuff
+ .chancedInput(tier.item, (int) (tier.consumptionChance * 10000), 0)
+ .addData(TIER_KEY, tier.ordinal())
+ .build());
+ }
+ }
+
+ public DroneStationMachine(BlockEntityCreationInfo holder) {
+ super(holder);
+ }
+
+ @Override
+ public void onStructureFormed() {
+ super.onStructureFormed();
+ if (!isRemote()) {
+ droneStations.put(this.getLevel().dimension().location(), this);
+ tickSubscription = this.subscribeServerTick(this::updateDroneHatches);
+ }
+ }
+
+ @Override
+ public void onStructureInvalid() {
+ setWorkingEnabled(false);
+ super.onStructureInvalid();
+ if (!isRemote()) {
+ droneStations.remove(this.getLevel().dimension().location(), this);
+ tickSubscription.unsubscribe();
+ tickSubscription = null;
+ }
+ }
+
+ // This method is called every tick
+ public void updateDroneHatches() {
+ if (energyContainer != null) {
+ if (!drainEnergy(false)) {
+ this.currentTier = null;
+ }
+ }
+ if (getOffsetTimer() % 20 == 0) {
+ connections.removeIf(connection -> !connection.isValid());
+ updateDroneTier();
+ }
+ }
+
+ // Update the multi's currentTier
+ private void updateDroneTier() {
+ // Find current highest drone in bus
+ Optional maybeDroneRecipe = droneTierRecipes.stream().filter(
+ dr -> RecipeHelper.matchRecipe(this, dr).isSuccess()).findFirst();
+ if (maybeDroneRecipe.isEmpty()) return;
+ GTRecipe droneRecipe = maybeDroneRecipe.get();
+ currentTier = DroneTier.values()[droneRecipe.data.getInt(TIER_KEY)];
+ // var itemHandlers = getCapabilitiesFlat(IO.IN, ItemRecipeCapability.CAP);
+ // boolean found = false;
+ // for (DroneTier tier : DroneTier.values()) {
+ // for (var handler : itemHandlers) {
+ // if (!(handler instanceof NotifiableItemStackHandler itemHandler)) continue;
+ // for (var content : itemHandler.getContents()) {
+ // if (tier.item.equals(((ItemStack) content).getItem())) {
+ // this.currentTier = tier;
+ // found = true;
+ // break;
+ // }
+ // }
+ // }
+ // if (found) break;
+ // }
+ }
+
+ public long getBlockLimit() {
+ if (currentTier == null) return 0;
+ return currentTier.range;
+ }
+
+ public boolean drainEnergy(boolean simulate) {
+ if (currentTier == null) return false;
+ long powerCost = currentTier.EUt;
+ long resultEnergy = energyContainer.getEnergyStored() - powerCost;
+ if (resultEnergy >= 0L && resultEnergy <= energyContainer.getEnergyCapacity()) {
+ if (!simulate)
+ energyContainer.removeEnergy(powerCost);
+ setWorkingEnabled(true);
+ getRecipeLogic().setStatus(RecipeLogic.Status.WORKING);
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Attempt to fix a maintenance issue, potentially consuming the current max tier drone in the process
+ *
+ * @return whether the issue should be fixed
+ */
+ public boolean fixMaintenanceIssue() {
+ // Note that this tries to consume a drone of the currentTier, which is only updated once per second.
+ if (currentTier == null) return false;
+ return RecipeHelper.handleRecipeIO(this, droneTierRecipes.get(currentTier.ordinal()), IO.IN,
+ getRecipeLogic().getChanceCaches()).isSuccess();
+ // var itemHandlers = getCapabilitiesFlat(IO.IN, ItemRecipeCapability.CAP);
+ // for (var handler : itemHandlers) {
+ // if (!(handler instanceof NotifiableItemStackHandler itemHandler)) continue;
+ // for (int i = 0; i < itemHandler.getSlots(); i++) {
+ // ItemStack stack = itemHandler.getStackInSlot(i);
+ // if (stack.getItem().equals(currentTier.item)) {
+ // // We have found the stack with the drone, try consuming and return true
+ // if (currentTier.consumptionChance == 0) return true;
+ // float randomValue = GTValues.RNG.nextFloat();
+ // if (randomValue < currentTier.consumptionChance) {
+ // var stackTaken = itemHandler.extractItemInternal(i, 1, false);
+ // if (!stackTaken.getItem().equals(currentTier.item) || stackTaken.getCount() != 1) {
+ // CosmicCore.LOGGER.error("Something went wrong when extracting done for Drone Multi: " +
+ // stackTaken.getDisplayName());
+ // return false;
+ // }
+ // }
+ // return true;
+ // }
+ // }
+ //
+ // }
+ // return false;
+ }
+
+ @Override
+ public void setWorkingEnabled(boolean isWorkingAllowed) {
+ super.setWorkingEnabled(isWorkingAllowed);
+ }
+
+ @Override
+ public void addDisplayText(List textList) {
+ super.addDisplayText(textList);
+ if (!this.connections.isEmpty()) {
+ textList.add(Component
+ .translatable("cosmiccore.multiblock.drone_station_machine.drone_amount", this.connections.size())
+ .setStyle(Style.EMPTY.withColor(ChatFormatting.GREEN)));
+ } else {
+ textList.add(Component
+ .translatable("cosmiccore.multiblock.drone_station_machine.no_drones")
+ .setStyle(Style.EMPTY.withColor(ChatFormatting.RED)));
+
+ }
+ }
+
+ // EXAMPLE CODE, REMOVE LATER MAYBE?
+ // Or keep in, in which case, this should be a feature and remove this comment :eugeneThumbsUpCool:
+ @Override
+ protected InteractionResult onScrewdriverClick(ExtendedUseOnContext context) {
+ int i = 0;
+ System.out.println("Toggling all multis");
+ for (var con : connections) {
+ toggleMultiblock(i);
+ i++;
+ }
+ return InteractionResult.SUCCESS;
+ }
+
+ /**
+ * Force turns all connected machines in range on
+ */
+ public void turnAllMachinesOn() {
+ System.out.println("Toggling all multis");
+ for (int i = 0; i < connections.size(); i++) {
+ forceTurnOnMultiblock(i);
+ i++;
+ }
+ }
+
+ /**
+ * Force turns all connected machines in range on
+ */
+ public void turnAllMachinesOff() {
+ System.out.println("Toggling all multis");
+ for (int i = 0; i < connections.size(); i++) {
+ forceTurnOffMultiblock(i);
+ i++;
+ }
+ }
+
+ /**
+ * Disables a connected multi.
+ *
+ * @param index the index in the connections list
+ */
+ public boolean toggleMultiblock(int index) {
+ if (isRemote()) return false;
+ if (index > connections.size()) return false;
+ DroneStationConnection connection = connections.get(index);
+ if (!connection.isValid()) return false;
+ if (connection.machine == null) return false;
+ if (!(connection.machine instanceof DroneMaintenanceInterfacePartMachine droneInterface)) return false;
+ MultiblockControllerMachine controller = droneInterface.getControllers().first();
+ if (!(controller instanceof IControllable controllable)) return false;
+ controllable.setWorkingEnabled(!controllable.isWorkingEnabled());
+ return true;
+ }
+
+ /**
+ * Force turns all connected machines in range on
+ *
+ * @param index the index in the connections list
+ */
+ public boolean forceTurnOnMultiblock(int index) {
+ if (isRemote()) return false;
+ if (index > connections.size()) return false;
+ DroneStationConnection connection = connections.get(index);
+ if (!connection.isValid()) return false;
+ if (connection.machine == null) return false;
+ if (!(connection.machine instanceof DroneMaintenanceInterfacePartMachine droneInterface)) return false;
+ MultiblockControllerMachine controller = droneInterface.getControllers().first();
+ if (!(controller instanceof IControllable controllable)) return false;
+ if (controllable.isWorkingEnabled()) return false;
+ controllable.setWorkingEnabled(true);
+ return true;
+ }
+
+ /**
+ * Force turns all connected machines in range off
+ *
+ * @param index the index in the connections list
+ */
+ public boolean forceTurnOffMultiblock(int index) {
+ if (isRemote()) return false;
+ if (index > connections.size()) return false;
+ DroneStationConnection connection = connections.get(index);
+ if (!connection.isValid()) return false;
+ if (connection.machine == null) return false;
+ if (!(connection.machine instanceof DroneMaintenanceInterfacePartMachine droneInterface)) return false;
+ MultiblockControllerMachine controller = droneInterface.getControllers().first();
+ if (!(controller instanceof IControllable controllable)) return false;
+ if (!controllable.isWorkingEnabled()) return false;
+ controllable.setSuspendAfterFinish(true);
+ return true;
+ }
+
+ // TODO: Add functions for UI to disable/enable/read status/etc machines remotely
+
+ @Override
+ public @NotNull Widget createUIWidget() {
+ var group = new WidgetGroup(0, 0, 182 + 8, 117 + 8);
+ group.addWidget(new DraggableScrollableWidgetGroup(4, 4, 182, 117).setBackground(getScreenTexture())
+ .addWidget(new LabelWidget(4, 5, self().getBlockState().getBlock().getDescriptionId()))
+ .addWidget(new ComponentPanelWidget(4, 17, this::addDisplayText)
+ .textSupplier(this.getLevel().isClientSide ? null : this::addDisplayText)
+ .setMaxWidthLimit(150)
+ .clickHandler(this::handleDisplayClick)));
+ group.setBackground(GuiTextures.BACKGROUND_INVERSE);
+ group.addWidget(new ButtonWidget(
+ 6,
+ 80,
+ 178,
+ 20,
+ new GuiTextureGroup(
+ GuiTextures.BUTTON,
+ new TextTexture("cosmiccore.multiblock.reboot_powergrid")),
+ clickData -> turnAllMachinesOn()));
+ group.addWidget(new ButtonWidget(
+ 6,
+ 100,
+ 178,
+ 20,
+ new GuiTextureGroup(
+ GuiTextures.BUTTON,
+ new TextTexture("cosmiccore.multiblock.sleep_powergrid")),
+ clickData -> turnAllMachinesOff()));
+ return group;
+ }
+
+ @Override
+ public ModularUI createUI(Player entityPlayer) {
+ return new ModularUI(198, 208, this, entityPlayer).widget(new FancyMachineUIWidget(this, 198, 208));
+ }
+}
diff --git a/src/main/java/com/ghostipedia/cosmiccore/api/machine/multiblock/HeatWorkableElectricMultiblockMachine.java b/src/main/java/com/ghostipedia/cosmiccore/api/machine/multiblock/HeatWorkableElectricMultiblockMachine.java
new file mode 100644
index 000000000..1813d4bf7
--- /dev/null
+++ b/src/main/java/com/ghostipedia/cosmiccore/api/machine/multiblock/HeatWorkableElectricMultiblockMachine.java
@@ -0,0 +1,38 @@
+package com.ghostipedia.cosmiccore.api.machine.multiblock;
+
+import com.ghostipedia.cosmiccore.api.capability.recipe.IHeatContainer;
+
+import com.gregtechceu.gtceu.api.capability.recipe.IO;
+import com.gregtechceu.gtceu.api.blockentity.BlockEntityCreationInfo;
+import com.gregtechceu.gtceu.api.machine.feature.multiblock.IMultiPart;
+import com.gregtechceu.gtceu.api.machine.multiblock.WorkableElectricMultiblockMachine;
+
+import it.unimi.dsi.fastutil.longs.Long2ObjectMaps;
+import lombok.Getter;
+
+import java.util.Map;
+
+@Getter
+public class HeatWorkableElectricMultiblockMachine extends WorkableElectricMultiblockMachine {
+
+ @Getter
+ private IHeatContainer heatContainer = null;
+
+ public HeatWorkableElectricMultiblockMachine(BlockEntityCreationInfo holder) {
+ super(holder);
+ }
+
+ @Override
+ public void onStructureFormed() {
+ super.onStructureFormed();
+ Map ioMap = getMultiblockState().getMatchContext().getOrCreate("ioMap", Long2ObjectMaps::emptyMap);
+ for (IMultiPart part : getParts()) {
+ IO io = ioMap.getOrDefault(part.self().getBlockPos().asLong(), IO.BOTH);
+ if (part instanceof IHeatContainer container) {
+ this.heatContainer = container;
+ }
+ }
+ }
+
+ ;
+}
diff --git a/src/main/java/com/ghostipedia/cosmiccore/api/machine/multiblock/IMultithreadedMachine.java b/src/main/java/com/ghostipedia/cosmiccore/api/machine/multiblock/IMultithreadedMachine.java
new file mode 100644
index 000000000..b84c5ead6
--- /dev/null
+++ b/src/main/java/com/ghostipedia/cosmiccore/api/machine/multiblock/IMultithreadedMachine.java
@@ -0,0 +1,33 @@
+package com.ghostipedia.cosmiccore.api.machine.multiblock;
+
+import com.ghostipedia.cosmiccore.common.machine.multiblock.multi.logic.MultithreadedRecipeLogic;
+
+import it.unimi.dsi.fastutil.ints.Int2ObjectMap;
+
+/**
+ * Interface for machines that can run multiple independent recipe threads.
+ */
+public interface IMultithreadedMachine {
+
+ /**
+ * Get the map of thread color to recipe logic.
+ */
+ Int2ObjectMap getThreadLogicsMap();
+
+ /**
+ * Get the maximum number of threads this machine can support.
+ * Determined by energy hatch amperage.
+ */
+ int getMaxThreadCount();
+
+ /**
+ * Get the current number of configured threads.
+ * Limited by available color-coded input buses.
+ */
+ int getCurrentThreadCount();
+
+ /**
+ * Get the number of threads currently running recipes.
+ */
+ int getRunningThreadCount();
+}
diff --git a/src/main/java/com/ghostipedia/cosmiccore/api/machine/multiblock/IPBFMachine.java b/src/main/java/com/ghostipedia/cosmiccore/api/machine/multiblock/IPBFMachine.java
new file mode 100644
index 000000000..5432e079b
--- /dev/null
+++ b/src/main/java/com/ghostipedia/cosmiccore/api/machine/multiblock/IPBFMachine.java
@@ -0,0 +1,239 @@
+package com.ghostipedia.cosmiccore.api.machine.multiblock;
+
+import com.gregtechceu.gtceu.api.GTValues;
+import com.gregtechceu.gtceu.api.capability.recipe.EURecipeCapability;
+import com.gregtechceu.gtceu.api.capability.recipe.IO;
+import com.gregtechceu.gtceu.api.gui.GuiTextures;
+import com.gregtechceu.gtceu.api.gui.UITemplate;
+import com.gregtechceu.gtceu.api.blockentity.BlockEntityCreationInfo;
+import com.gregtechceu.gtceu.api.machine.MetaMachine;
+import com.gregtechceu.gtceu.api.machine.TickableSubscription;
+import com.gregtechceu.gtceu.api.machine.feature.multiblock.IDisplayUIMachine;
+import com.gregtechceu.gtceu.api.machine.feature.multiblock.IFluidRenderMulti;
+import com.gregtechceu.gtceu.api.machine.multiblock.WorkableMultiblockMachine;
+import com.gregtechceu.gtceu.api.machine.steam.SteamEnergyRecipeHandler;
+import com.gregtechceu.gtceu.api.machine.trait.RecipeLogic;
+import com.gregtechceu.gtceu.api.recipe.GTRecipe;
+import com.gregtechceu.gtceu.api.recipe.RecipeHelper;
+import com.gregtechceu.gtceu.api.recipe.content.ContentModifier;
+import com.gregtechceu.gtceu.api.recipe.modifier.ModifierFunction;
+import com.gregtechceu.gtceu.api.recipe.modifier.ParallelLogic;
+import com.gregtechceu.gtceu.config.ConfigHolder;
+import com.gregtechceu.gtceu.utils.GTUtil;
+
+import com.lowdragmc.lowdraglib.gui.modular.ModularUI;
+import com.lowdragmc.lowdraglib.gui.texture.IGuiTexture;
+import com.lowdragmc.lowdraglib.gui.widget.ComponentPanelWidget;
+import com.lowdragmc.lowdraglib.gui.widget.DraggableScrollableWidgetGroup;
+import com.lowdragmc.lowdraglib.gui.widget.LabelWidget;
+import com.lowdragmc.lowdraglib.syncdata.annotation.DescSynced;
+import com.lowdragmc.lowdraglib.syncdata.annotation.RequireRerender;
+
+import net.minecraft.ChatFormatting;
+import net.minecraft.core.BlockPos;
+import net.minecraft.core.Direction;
+import net.minecraft.core.particles.ParticleTypes;
+import net.minecraft.network.chat.Component;
+import net.minecraft.network.chat.Style;
+import net.minecraft.sounds.SoundEvents;
+import net.minecraft.sounds.SoundSource;
+import net.minecraft.util.RandomSource;
+import net.minecraft.world.entity.player.Player;
+import net.minecraft.world.level.block.state.BlockState;
+import net.minecraft.world.phys.AABB;
+import net.neoforged.api.distmarker.Dist;
+import net.neoforged.api.distmarker.OnlyIn;
+
+import org.jetbrains.annotations.NotNull;
+
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+public class IPBFMachine extends WorkableMultiblockMachine implements IDisplayUIMachine, IFluidRenderMulti {
+
+ public static final int MAX_PARALLELS = 8;
+
+ private TickableSubscription hurtSubscription;
+
+ @DescSynced
+ @RequireRerender
+ private @NotNull Set fluidBlockOffsets = new HashSet<>();
+
+ public IPBFMachine(BlockEntityCreationInfo holder) {
+ super(holder);
+ }
+
+ @Override
+ public @NotNull Set getFluidBlockOffsets() {
+ return fluidBlockOffsets;
+ }
+
+ @Override
+ public void setFluidBlockOffsets(@NotNull Set fluidBlockOffsets) {
+ this.fluidBlockOffsets = fluidBlockOffsets;
+ }
+
+ @Override
+ public void onUnload() {
+ super.onUnload();
+ unsubscribe(hurtSubscription);
+ hurtSubscription = null;
+ }
+
+ @Override
+ public void onStructureFormed() {
+ super.onStructureFormed();
+ IFluidRenderMulti.super.onStructureFormed();
+ }
+
+ @Override
+ public void onStructureInvalid() {
+ super.onStructureInvalid();
+ IFluidRenderMulti.super.onStructureInvalid();
+ }
+
+ @Override
+ public void addDisplayText(List textList) {
+ IDisplayUIMachine.super.addDisplayText(textList);
+ if (isFormed()) {
+ var handlers = getCapabilitiesFlat(IO.IN, EURecipeCapability.CAP);
+ if (!handlers.isEmpty() && handlers.get(0) instanceof SteamEnergyRecipeHandler steamHandler) {
+ if (steamHandler.getCapacity() > 0) {
+ long steamStored = steamHandler.getStored();
+ textList.add(Component.translatable("gtceu.multiblock.steam.steam_stored", steamStored,
+ steamHandler.getCapacity()));
+ }
+ }
+
+ if (!isWorkingEnabled()) {
+ textList.add(Component.translatable("gtceu.multiblock.work_paused"));
+
+ } else if (isActive()) {
+ textList.add(Component.translatable("gtceu.multiblock.running"));
+ double currentInSec = (float) recipeLogic.getProgress() / 20.0f;
+ double maxInSec = (float) recipeLogic.getDuration() / 20.0f;
+ int currentProgress = (int) (recipeLogic.getProgressPercent() * 100);
+ textList.add(Component.translatable("gtceu.multiblock.parallel", MAX_PARALLELS));
+ textList.add(
+ Component.translatable(
+ "gtceu.multiblock.progress",
+ String.format("%.2f", (float) currentInSec),
+ String.format("%.2f", (float) maxInSec),
+ currentProgress));
+ } else {
+ textList.add(Component.translatable("gtceu.multiblock.idling"));
+ }
+
+ if (recipeLogic.isWaiting()) {
+ textList.add(Component.translatable("gtceu.multiblock.steam.low_steam")
+ .setStyle(Style.EMPTY.withColor(ChatFormatting.RED)));
+ }
+ }
+ }
+
+ @Override
+ public ModularUI createUI(Player entityPlayer) {
+ var screen = new DraggableScrollableWidgetGroup(7, 4, 182, 121).setBackground(getScreenTexture());
+ screen.addWidget(new LabelWidget(4, 5, self().getBlockState().getBlock().getDescriptionId()));
+ screen.addWidget(new ComponentPanelWidget(4, 17, this::addDisplayText)
+ .setMaxWidthLimit(150)
+ .clickHandler(this::handleDisplayClick));
+ return new ModularUI(196, 216, this, entityPlayer)
+ .background(GuiTextures.BACKGROUND_STEAM.get(true))
+ .widget(screen)
+ .widget(UITemplate.bindPlayerInventory(entityPlayer.getInventory(),
+ GuiTextures.SLOT_STEAM.get(true), 7, 134,
+ true));
+ }
+
+ @Override
+ public void notifyStatusChanged(RecipeLogic.Status oldStatus, RecipeLogic.Status newStatus) {
+ super.notifyStatusChanged(oldStatus, newStatus);
+ if (newStatus == RecipeLogic.Status.WORKING) {
+ this.hurtSubscription = subscribeServerTick(this.hurtSubscription, this::hurtEntitiesAndBreakSnow);
+ } else if (oldStatus == RecipeLogic.Status.WORKING && hurtSubscription != null) {
+ unsubscribe(hurtSubscription);
+ hurtSubscription = null;
+ }
+ }
+
+ // Smoke VFX
+ @Override
+ @OnlyIn(Dist.CLIENT)
+ public void clientTick() {
+ super.clientTick();
+ if (recipeLogic.isWorking()) {
+ var pos = this.getBlockPos();
+ var facing = this.getFrontFacing().getOpposite();
+ float xPos = facing.getStepX() * 0.76F + pos.getX() + 0.5F;
+ float yPos = facing.getStepY() * 0.76F + pos.getY() + 0.25F;
+ float zPos = facing.getStepZ() * 0.76F + pos.getZ() + 0.5F;
+
+ float ySpd = facing.getStepY() * 0.1F + 0.2F + 0.1F * GTValues.RNG.nextFloat();
+ getLevel().addParticle(ParticleTypes.LARGE_SMOKE, xPos, yPos, zPos, 0, ySpd, 0);
+ }
+ }
+
+ public static ModifierFunction recipeModifier(@NotNull MetaMachine machine, @NotNull GTRecipe recipe) {
+ if (RecipeHelper.getRecipeEUtTier(recipe) > GTValues.LV) return ModifierFunction.NULL;
+ long euTick = RecipeHelper.getRecipeEUtTier(recipe);
+ int parallel = ParallelLogic.getParallelAmount(machine, recipe, 8);
+ return ModifierFunction.builder()
+ .inputModifier(ContentModifier.multiplier(parallel))
+ .outputModifier(ContentModifier.multiplier(parallel))
+ .durationMultiplier(parallel * 0.75)
+ .parallels(parallel)
+ .build();
+ }
+
+ @Override
+ public IGuiTexture getScreenTexture() {
+ return GuiTextures.DISPLAY_STEAM.get(true);
+ }
+
+ @Override
+ public void animateTick(RandomSource random) {
+ if (this.isActive()) {
+ final BlockPos pos = getBlockPos();
+ float x = pos.getX() + 0.5F;
+ float z = pos.getZ() + 0.5F;
+
+ final var facing = getFrontFacing();
+ final float horizontalOffset = GTValues.RNG.nextFloat() * 0.6F - 0.3F;
+ final float y = pos.getY() + GTValues.RNG.nextFloat() * 0.375F + 0.3F;
+
+ if (facing.getAxis() == Direction.Axis.X) {
+ if (facing.getAxisDirection() == Direction.AxisDirection.POSITIVE) x += 0.52F;
+ else x -= 0.52F;
+ z += horizontalOffset;
+ } else if (facing.getAxis() == Direction.Axis.Z) {
+ if (facing.getAxisDirection() == Direction.AxisDirection.POSITIVE) z += 0.52F;
+ else z -= 0.52F;
+ x += horizontalOffset;
+ }
+ if (ConfigHolder.INSTANCE.machines.machineSounds && GTValues.RNG.nextDouble() < 0.1) {
+ getLevel().playLocalSound(x, y, z, SoundEvents.FURNACE_FIRE_CRACKLE, SoundSource.BLOCKS, 1.0F, 1.0F,
+ false);
+ }
+ getLevel().addParticle(ParticleTypes.LARGE_SMOKE, x, y, z, 0, 0, 0);
+ getLevel().addParticle(ParticleTypes.FLAME, x, y, z, 0, 0, 0);
+ }
+ }
+
+ private void hurtEntitiesAndBreakSnow() {
+ BlockPos middlePos = self().getBlockPos().offset(getFrontFacing().getOpposite().getNormal());
+ getLevel().getEntities(null, new AABB(middlePos)).forEach(e -> e.hurt(e.damageSources().lava(), 3.0f));
+
+ if (getOffsetTimer() % 10 == 0) {
+ BlockState state = getLevel().getBlockState(middlePos);
+ GTUtil.tryBreakSnow(getLevel(), middlePos, state, true);
+ }
+ }
+
+ @Override
+ public @NotNull Set saveOffsets() {
+ return Collections.singleton(new BlockPos(getFrontFacing().getOpposite().getNormal()));
+ }
+}
diff --git a/src/main/java/com/ghostipedia/cosmiccore/api/machine/multiblock/IrisMultiblockMachine.java b/src/main/java/com/ghostipedia/cosmiccore/api/machine/multiblock/IrisMultiblockMachine.java
new file mode 100644
index 000000000..efe7c9a4a
--- /dev/null
+++ b/src/main/java/com/ghostipedia/cosmiccore/api/machine/multiblock/IrisMultiblockMachine.java
@@ -0,0 +1,689 @@
+package com.ghostipedia.cosmiccore.api.machine.multiblock;
+
+import com.ghostipedia.cosmiccore.api.machine.feature.IStellarIrisProvider;
+import com.ghostipedia.cosmiccore.api.machine.feature.IStellarModuleReceiver;
+import com.ghostipedia.cosmiccore.client.gui.widget.stellar.StellarFancyUIWidget;
+import com.ghostipedia.cosmiccore.client.gui.widget.stellar.StellarIrisWidget;
+import com.ghostipedia.cosmiccore.common.data.CosmicItems;
+import com.ghostipedia.cosmiccore.common.data.CosmicSounds;
+
+import com.gregtechceu.gtceu.api.capability.recipe.IO;
+import com.gregtechceu.gtceu.api.blockentity.BlockEntityCreationInfo;
+import com.gregtechceu.gtceu.api.machine.MetaMachine;
+import com.gregtechceu.gtceu.api.machine.multiblock.WorkableElectricMultiblockMachine;
+import com.gregtechceu.gtceu.api.machine.trait.NotifiableItemStackHandler;
+import com.gregtechceu.gtceu.api.machine.trait.RecipeLogic;
+import com.gregtechceu.gtceu.api.pattern.util.RelativeDirection;
+import com.gregtechceu.gtceu.api.sound.AutoReleasedSound;
+
+import com.lowdragmc.lowdraglib.gui.modular.ModularUI;
+import com.lowdragmc.lowdraglib.gui.widget.Widget;
+import com.lowdragmc.lowdraglib.syncdata.annotation.DescSynced;
+import com.lowdragmc.lowdraglib.syncdata.annotation.Persisted;
+import com.lowdragmc.lowdraglib.syncdata.annotation.UpdateListener;
+import net.minecraft.core.BlockPos;
+import net.minecraft.network.chat.Component;
+import net.minecraft.world.entity.player.Player;
+import net.minecraft.world.item.ItemStack;
+import net.neoforged.api.distmarker.Dist;
+import net.neoforged.api.distmarker.OnlyIn;
+
+import org.jetbrains.annotations.NotNull;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.EnumSet;
+import java.util.List;
+import java.util.Set;
+
+import static com.ghostipedia.cosmiccore.api.machine.multiblock.IrisMultiblockMachine.Stage.BLACK_HOLE;
+import static com.ghostipedia.cosmiccore.api.machine.multiblock.IrisMultiblockMachine.Stage.DEATH;
+
+public class IrisMultiblockMachine extends WorkableElectricMultiblockMachine implements IStellarIrisProvider {
+
+
+ @Persisted
+ private final NotifiableItemStackHandler inventory;
+
+ protected boolean ignite;
+ protected boolean isFuelable;
+ protected Object workingSound;
+
+ @Persisted
+ @DescSynced
+ @UpdateListener(methodName = "onStageSynced")
+ private Stage stage = Stage.EMPTY;
+
+ /**
+ * Called when the stage field is synced from server to client.
+ * Parameters must match the field type (Stage).
+ */
+ @OnlyIn(Dist.CLIENT)
+ @SuppressWarnings("unused")
+ protected void onStageSynced(Stage newValue, Stage oldValue) {
+ com.ghostipedia.cosmiccore.CosmicCore.LOGGER.warn(
+ "[IrisMultiblockMachine] CLIENT onStageSynced: {} -> {}", oldValue, newValue);
+ this.scheduleRenderUpdate();
+ soundTick();
+ }
+
+ /**
+ * Custom setter with debug logging to track stage changes.
+ */
+ public void setStage(Stage newStage) {
+ Stage oldStage = this.stage;
+ this.stage = newStage;
+ // Debug: log all stage changes with stack trace for unusual transitions
+ if (oldStage != newStage) {
+ com.ghostipedia.cosmiccore.CosmicCore.LOGGER.warn(
+ "[IrisMultiblockMachine] setStage: {} -> {}", oldStage, newStage);
+ // If transitioning TO DEATH from EMPTY, log stack trace to find the culprit
+ if (oldStage == Stage.EMPTY && newStage == Stage.DEATH) {
+ com.ghostipedia.cosmiccore.CosmicCore.LOGGER.warn(
+ "[IrisMultiblockMachine] SUSPICIOUS: EMPTY->DEATH transition! Stack trace:",
+ new Exception("Stack trace"));
+ }
+ }
+ }
+
+ @Override
+ public Stage getStage() {
+ return stage;
+ }
+
+ public NotifiableItemStackHandler getInventory() {
+ return inventory;
+ }
+
+ public boolean isIgnite() {
+ return ignite;
+ }
+
+ public boolean isFuelable() {
+ return isFuelable;
+ }
+
+ /**
+ * Custom star color (RGB, no alpha). -1 means use default stage-based color.
+ * Persisted and synced to client for rendering.
+ */
+ @Persisted
+ @DescSynced
+ private int customStarColor = -1;
+
+ public int getCustomStarColor() {
+ return customStarColor;
+ }
+
+ public void setCustomStarColor(int customStarColor) {
+ this.customStarColor = customStarColor;
+ }
+
+ @Persisted
+ @DescSynced
+ private int lifetimePrestigePoints = 0;
+
+ @Persisted
+ @DescSynced
+ private int spendablePoints = 0;
+
+ @Persisted
+ @DescSynced
+ private int prestigeTier = 0;
+
+ @Persisted
+ @DescSynced
+ private int ascensionLevel = 0;
+
+ @Persisted
+ @DescSynced
+ private Set