diff --git a/docs/CARGO_MOTHS.md b/docs/CARGO_MOTHS.md new file mode 100644 index 000000000..fd6552b22 --- /dev/null +++ b/docs/CARGO_MOTHS.md @@ -0,0 +1,226 @@ +# Cargo Moths System + +## Current Status: **IN PROGRESS** + +Last updated: Debug output removed, core functionality working. + +--- + +## 1. Overview + +The Cargo Moths system provides local (same-dimension) item and fluid transport using a whimsical moth-based logistics network. Unlike the cross-dimensional linking system, Cargo Moths are designed for short-range automation within a single dimension. + +### Design Philosophy +- **No power required** - Moths work for free! +- **Scalable capacity** - Add more moth homes to increase throughput +- **Tiered progression** - Different beehive types provide different speeds/capacities +- **Feeding bonuses** - Optional honey/oil feeding for multipliers + +--- + +## 2. What's Implemented + +### 2.1 Core Components + +| Component | File | Status | +|-----------|------|--------| +| `MothCargoStation` | `common/machine/multiblock/multi/MothCargoStation.java` | ✅ Complete | +| `MothCargoStationMachine` | `common/machine/multiblock/multi/logic/MothCargoStationMachine.java` | ✅ Complete | +| `MothCargoDropOff` | `common/machine/multiblock/multi/MothCargoDropOff.java` | ✅ Complete | +| `MothCargoDropOffMachine` | `common/machine/multiblock/multi/logic/MothCargoDropOffMachine.java` | ✅ Complete | +| `LinkedWorkableMultiblockMachine` | `api/machine/multiblock/LinkedWorkableMultiblockMachine.java` | ✅ Complete | + +### 2.2 Feature Checklist + +- [x] Multiblock structure definitions +- [x] Datastick-based linking (reuses cross-dimensional linking infrastructure) +- [x] Same-dimension restriction +- [x] Item transfer from Station to Drop Off +- [x] Fluid transfer from Station to Drop Off +- [x] Tiered moth homes (Forestry beehives) +- [x] Cycle time based on moth tier +- [x] Capacity based on moth count × tier multiplier +- [x] Distribution modes (DIRECT, FILL_FIRST, ROUND_ROBIN) +- [x] GUI display (moth homes, cycle time, capacity, linked drop-offs) +- [x] Screwdriver to cycle distribution mode +- [ ] Feeding bonuses (honey/oil multipliers) + +--- + +## 3. Multiblock Structures + +### 3.1 Moth Cargo Station (Sender) + +Tower structure: 3×3 footprint, 6 blocks tall + +``` +Layer 0 (bottom): Layer 1-4: Layer 5 (top): +C C C C M C C C C +C C C C M C C C C +C Q C C M C C C C +``` + +Where: +- `C` = Steel Solid Casing (or input/output buses/hatches, maintenance hatch) +- `M` = Moth Home (Forestry beehive) OR Steel Solid Casing +- `Q` = Controller + +**Moth homes only go in the center column** (up to 4 can be placed). + +Allowed hatches: +- 1 Maintenance Hatch (required) +- Up to 4 Item Input Buses +- Up to 4 Item Output Buses +- Up to 4 Fluid Input Hatches +- Up to 4 Fluid Output Hatches + +### 3.2 Moth Cargo Drop Off (Receiver) + +Simple 3×3×2 structure: + +``` +Layer 0 (bottom): Layer 1 (top): +C C C C C C +C Q C C C C +C C C C C C +``` + +Where: +- `C` = Steel Solid Casing (or output buses/hatches, maintenance hatch) +- `Q` = Controller + +Allowed hatches: +- 1 Maintenance Hatch (required) +- Up to 4 Item Output Buses +- Up to 4 Fluid Output Hatches + +--- + +## 4. Moth Home Tiers + +Moth homes use Forestry beehive blocks: + +| Tier | Block | Cycle Time | Moths per Home | +|------|-------|------------|----------------| +| T1 | `forestry:beehive_forest` | 60s | 1 | +| T2 | `forestry:beehive_lush` | 30s | 2 | +| T3 | `forestry:beehive_desert` | 15s | 4 | +| T4 | `forestry:beehive_end` | 5s | 8 | + +**All moth homes must be the same tier.** Mixed tiers will trigger a warning. + +### Capacity Calculation + +``` +Items per cycle = Total Moths × 64 × Feeding Multiplier +Fluids per cycle = Total Moths × 1000mB × Feeding Multiplier +Total Moths = Moth Home Count × Moths per Home (by tier) +``` + +Example: 4× T3 beehives = 4 × 4 = 16 moths = 1024 items per cycle (every 15s) + +--- + +## 5. Distribution Modes + +Cycle through modes with screwdriver on the controller. + +| Mode | Behavior | +|------|----------| +| `DIRECT` | Ships to first linked drop-off only (1:1) | +| `FILL_FIRST` | Fills each drop-off in order until full, then moves to next | +| `ROUND_ROBIN` | Distributes evenly across all linked drop-offs | + +--- + +## 6. Feeding Bonuses (TODO) + +Planned multipliers for feeding moths: + +| Feed Item | Multiplier | +|-----------|------------| +| Regular Honey | 2× | +| Lofty Honey | 4× | +| Pale Oil | 8× | + +Feed is consumed per cycle from the Station's input bus. + +--- + +## 7. GUI Information + +The Station GUI displays: +- Moth Homes count and tier +- Total Moths +- Cycle Time (seconds) +- Distribution Mode +- Linked Drop-Offs count +- Capacity per cycle (items and fluids) + +The Drop Off GUI displays: +- Structure status +- Number of stations linked to it + +--- + +## 8. Linking + +Uses the same datastick-based linking as the cross-dimensional system: + +1. Shift+right-click the Moth Cargo Station with a datastick to copy link data +2. Right-click a Moth Cargo Drop Off to establish the link + +**Restrictions:** +- Same dimension only (moths can't fly between dimensions!) +- Station can link to up to 16 Drop Offs (1:N) +- Drop Off can receive from up to 16 Stations (N:1) + +--- + +## 9. How It Works + +1. Every tick, the Station checks if enough time has passed since the last cycle +2. When cycle time is reached: + - Get all linked, formed Drop Offs + - Calculate item/fluid capacity based on moths and feeding multiplier + - Extract items from Station's input buses (using internal methods to bypass IO checks) + - Insert items into Drop Off's output buses (using internal methods to bypass IO checks) + - Same process for fluids + - Consume feeding materials (TODO) + +The internal extraction/insertion methods bypass GTCEu's IO direction checks, which is the same pattern used by DroneStationMachine. + +--- + +## 10. File Structure + +``` +src/main/java/com/ghostipedia/cosmiccore/ +├── api/machine/multiblock/ +│ └── LinkedWorkableMultiblockMachine.java # Base class (no power requirement) +└── common/machine/multiblock/multi/ + ├── MothCargoStation.java # Station multiblock definition + ├── MothCargoDropOff.java # Drop Off multiblock definition + └── logic/ + ├── MothCargoStationMachine.java # Station logic (shipping cycles) + └── MothCargoDropOffMachine.java # Drop Off logic (receives items) +``` + +--- + +## 11. Known Limitations + +1. **Forestry dependency** - Falls back to vanilla beehive if Forestry not loaded +2. **No visual feedback** - No moth entity/particle flying between stations +3. **No feeding implementation** - Multiplier is always 1× currently +4. **No chunk loading** - Both Station and Drop Off must be loaded + +--- + +## 12. Future Work + +- [ ] Implement feeding bonuses (honey/oil consumption and multipliers) +- [ ] Add moth particle effects during transfers +- [ ] Consider cross-dimension variant (interdimensional moths?) +- [ ] Add JEI/EMI integration showing capacity calculations +- [ ] Custom textures/models for the multiblocks diff --git a/docs/CROSS_DIMENSIONAL_LINKING.md b/docs/CROSS_DIMENSIONAL_LINKING.md new file mode 100644 index 000000000..56f3cdbc2 --- /dev/null +++ b/docs/CROSS_DIMENSIONAL_LINKING.md @@ -0,0 +1,318 @@ +# Cross-Dimensional Multiblock Linking System + +## Current Status: **IMPLEMENTED & TESTED** + +Last updated: Session implementing partner query utilities and recipe conditions. + +--- + +## 1. Overview + +This system enables multiblock machines to communicate across dimensions using GTCEu's datastick as the linking mechanism. Links are persisted in SavedData and support role-based access control. + +### Use Cases +- **Star Ladder**: Manufacturing chains spanning multiple dimensions +- **Cross-dimensional recipes**: "Recipe requires partner in Sun Orbit with Solar Plasma" +- **Remote resource access**: Query partner's inventory/fluids/energy +- **Dimension-gated progression**: Certain recipes only available when linked to specific dimensions + +--- + +## 2. What's Implemented + +### 2.1 Core Infrastructure + +| Component | File | Status | +|-----------|------|--------| +| `ILinkedMultiblock` | `api/capability/ILinkedMultiblock.java` | ✅ Complete | +| `LinkEntry` | `api/data/savedData/LinkEntry.java` | ✅ Complete | +| `LinkedMultiblockSavedData` | `api/data/savedData/LinkedMultiblockSavedData.java` | ✅ Complete | +| `LinkedMultiblockHelper` | `common/machine/multiblock/LinkedMultiblockHelper.java` | ✅ Complete | +| `LinkedWorkableElectricMultiblockMachine` | `api/machine/multiblock/LinkedWorkableElectricMultiblockMachine.java` | ✅ Complete | + +### 2.2 Test Multiblock + +| Component | File | Status | +|-----------|------|--------| +| `LinkTestStation` | `common/machine/multiblock/multi/LinkTestStation.java` | ✅ Complete | +| `LinkTestStationMachine` | `common/machine/multiblock/multi/logic/LinkTestStationMachine.java` | ✅ Complete | + +### 2.3 Recipe Conditions + +| Condition | File | Description | +|-----------|------|-------------| +| `LinkedPartnerCondition` | `common/recipe/condition/LinkedPartnerCondition.java` | Requires N linked partners, optionally formed/working | +| `LinkedPartnerDimensionCondition` | `common/recipe/condition/LinkedPartnerDimensionCondition.java` | Requires partner in specific dimension | +| `LinkedPartnerDimensionItemCondition` | `common/recipe/condition/LinkedPartnerDimensionItemCondition.java` | Requires partner in dimension with specific item | +| `LinkedPartnerDimensionFluidCondition` | `common/recipe/condition/LinkedPartnerDimensionFluidCondition.java` | Requires partner in dimension with specific fluid | + +### 2.4 Partner Query Utilities + +Methods in `LinkedMultiblockHelper`: +- `queryPartner()` - Generic query with chunk loading +- `getPartnerItemHandlers()` / `getPartnerFluidHandlers()` / `getPartnerEnergyHandlers()` +- `partnerHasItem()` / `partnerHasFluid()` +- `getPartnerEnergyStored()` +- `isPartnerFormed()` / `isPartnerWorking()` + +Convenience methods in `LinkedWorkableElectricMultiblockMachine`: +- `partnerHasItem()` / `partnerHasFluid()` / `getPartnerEnergyStored()` +- `isPartnerFormed()` / `isPartnerWorking()` +- `anyPartnerHasItem()` / `anyPartnerHasFluid()` / `anyPartnerWorking()` +- `countFormedPartners()` + +--- + +## 3. How to Use + +### 3.1 Linking Machines + +1. **Copy link data**: Shift+right-click a linkable multiblock with a datastick +2. **Paste/establish link**: Right-click another linkable multiblock with the datastick +3. The system validates ownership, roles, and compatibility before establishing the link + +### 3.2 Creating a Linkable Multiblock + +Extend `LinkedWorkableElectricMultiblockMachine`: + +```java +public class MyLinkedMachine extends LinkedWorkableElectricMultiblockMachine { + + public MyLinkedMachine(IMachineBlockEntity holder, Object... args) { + super(holder, args); + } + + @Override + public LinkRole getLinkRole() { + return LinkRole.PEER; // or CONTROLLER, REMOTE + } + + @Override + public int getMaxPartners() { + return 4; // default + } + + @Override + public boolean canLinkTo(GlobalPos partner, ILinkedMultiblock partnerMachine) { + // Custom validation (e.g., only link to specific machine types) + return true; + } + + @Override + public void onLinkEstablished(GlobalPos partner) { + super.onLinkEstablished(partner); + // React to new link + } + + @Override + public void onLinkBroken(GlobalPos partner) { + super.onLinkBroken(partner); + // Cleanup when link breaks + } +} +``` + +### 3.3 Using Recipe Conditions + +```java +// Requires at least 1 linked partner +.addCondition(new LinkedPartnerCondition(1)) + +// Requires 2 partners, at least 1 formed +.addCondition(new LinkedPartnerCondition(2, true, false)) + +// Requires partner in specific dimension +.addCondition(new LinkedPartnerDimensionCondition("frontiers:sun_orbit")) + +// Requires partner in dimension with specific item +.addCondition(new LinkedPartnerDimensionItemCondition("frontiers:sun_orbit", Items.BUCKET, 1)) + +// Requires partner in dimension with specific fluid (1000mB) +.addCondition(new LinkedPartnerDimensionFluidCondition("frontiers:sun_orbit", SolarPlasma.getFluid(), 1000)) +``` + +### 3.4 Querying Partner Resources + +From within a `LinkedWorkableElectricMultiblockMachine`: + +```java +// Check if any partner has a specific item +if (anyPartnerHasItem(stack -> stack.is(Items.DIAMOND))) { + // ... +} + +// Check specific partner +for (GlobalPos partner : getLinkedPartners()) { + if (partnerHasFluid(partner, fluid -> fluid.getFluid().is(Fluids.LAVA))) { + // Partner has lava + } + + long energy = getPartnerEnergyStored(partner); + boolean working = isPartnerWorking(partner); +} + +// Custom queries +String partnerName = queryPartner(partner, machine -> + machine.getDefinition().getName()); +``` + +--- + +## 4. Link Roles + +### Role Types + +| Role | Can Query Partners | Can Be Queried | +|------|-------------------|----------------| +| `PEER` | ✅ | ✅ | +| `CONTROLLER` | ✅ | ❌ | +| `REMOTE` | ❌ | ✅ | + +### Role Negotiation + +When two machines link, their declared roles are negotiated: + +| A's Role | B's Role | Result | A's Effective | B's Effective | +|----------|----------|--------|---------------|---------------| +| PEER | PEER | ✅ Valid | PEER | PEER | +| PEER | CONTROLLER | ✅ Valid | REMOTE | CONTROLLER | +| PEER | REMOTE | ✅ Valid | CONTROLLER | REMOTE | +| CONTROLLER | REMOTE | ✅ Valid | CONTROLLER | REMOTE | +| CONTROLLER | CONTROLLER | ❌ Reject | - | - | +| REMOTE | REMOTE | ❌ Reject | - | - | + +--- + +## 5. Test Recipes + +The Link Test Station includes these recipes for testing: + +| Recipe | Input | Output | Condition | +|--------|-------|--------|-----------| +| `link_test_basic` | 1x Iron Ingot | 9x Iron Nugget | None | +| `link_test_linked` | 1x Gold Ingot | 1x Diamond | 1 linked partner | +| `link_test_formed_partner` | 1x Emerald | 1x Nether Star | 1 formed partner | +| `link_test_moon_partner` | 4x Lapis | 1x Ender Pearl | Partner in `ad_astra:moon` | +| `link_test_overworld_partner` | 4x Redstone | 4x Glowstone | Partner in `minecraft:overworld` | +| `link_test_dimension_item` | 8x Coal | 1x Diamond | Partner in Overworld with Diamond | +| `link_test_dimension_fluid` | 1x Sponge | 1x Wet Sponge | Partner in Overworld with Water | + +--- + +## 6. Translation Keys + +```properties +# Link operations +cosmiccore.datastick.link_copied=Link: %s +cosmiccore.link.copied=Link data copied from %s +cosmiccore.link.established=Link established: %s ↔ %s + +# Errors +cosmiccore.link.not_ready=Machine not ready for linking +cosmiccore.link.invalid_data=Invalid link data on datastick +cosmiccore.link.cannot_self_link=Cannot link a machine to itself +cosmiccore.link.partner_not_loaded=Partner machine must be loaded to establish link +cosmiccore.link.partner_missing=Partner machine no longer exists +cosmiccore.link.not_linkable=Target machine does not support linking +cosmiccore.link.different_owner=Cannot link machines owned by different teams +cosmiccore.link.incompatible_roles=Incompatible link roles: %s cannot link to %s +cosmiccore.link.limit_reached_self=This machine has reached its link limit +cosmiccore.link.limit_reached_partner=Partner machine has reached its link limit +cosmiccore.link.incompatible_self=This machine cannot link to that type +cosmiccore.link.incompatible_partner=Partner machine cannot link to this type +cosmiccore.link.already_linked=These machines are already linked + +# Recipe conditions +cosmiccore.recipe.condition.linked_partner.tooltip=Requires %s linked partner(s) +cosmiccore.recipe.condition.linked_partner.formed=Requires %s linked partner(s) with valid structure +cosmiccore.recipe.condition.linked_partner.working=Requires %s linked partner(s) actively working +cosmiccore.recipe.condition.linked_partner_dimension.tooltip=Requires linked partner in %s +cosmiccore.recipe.condition.linked_partner_dimension_item.tooltip=Requires %sx %s in partner in %s +cosmiccore.recipe.condition.linked_partner_dimension_fluid.tooltip=Requires %smB %s in partner in %s +``` + +--- + +## 7. File Structure + +``` +src/main/java/com/ghostipedia/cosmiccore/ +├── api/ +│ ├── capability/ +│ │ └── ILinkedMultiblock.java # Interface for linkable machines +│ ├── data/savedData/ +│ │ ├── LinkEntry.java # Single link record +│ │ └── LinkedMultiblockSavedData.java # Persistence layer +│ └── machine/multiblock/ +│ └── LinkedWorkableElectricMultiblockMachine.java # Base class +├── common/ +│ ├── machine/multiblock/ +│ │ ├── LinkedMultiblockHelper.java # Utilities, chunk loading, queries +│ │ └── multi/ +│ │ ├── LinkTestStation.java # Test multiblock registration +│ │ └── logic/ +│ │ └── LinkTestStationMachine.java # Test multiblock logic +│ └── recipe/condition/ +│ ├── CosmicConditions.java # Condition registration +│ ├── LinkedPartnerCondition.java +│ ├── LinkedPartnerDimensionCondition.java +│ ├── LinkedPartnerDimensionItemCondition.java +│ └── LinkedPartnerDimensionFluidCondition.java +└── gtbridge/ + ├── CosmicRecipeTypes.java # LINK_TEST_RECIPES type + └── CosmicCoreRecipes.java # Test recipes +``` + +--- + +## 8. Security Notes + +1. **Ownership is always verified at runtime** - Never trust datastick NBT for ownership +2. **Partner must be loaded for link validation** - Prevents linking to arbitrary positions +3. **Role negotiation prevents privilege escalation** - CONTROLLER+CONTROLLER rejected +4. **Chunk loading is capped** - MAX_FORCED_CHUNKS_PER_MACHINE = 4 + +--- + +## 9. Known Limitations + +1. **Partner must be loaded to establish link** - No config option for force-load during linking yet +2. **No GUI for link management** - Links can only be viewed via machine display text +3. **No visual feedback** - No particles/beams between linked machines +4. **No admin commands** - No way to inspect/remove links via commands + +--- + +## 10. Future Work + +### Phase 2: Recipe Integration +- [ ] Cross-dimensional ingredient consumption (consume from partner's inputs) +- [ ] Cross-dimensional output insertion (insert into partner's outputs) +- [ ] Recipe modifier based on partner state + +### Phase 3: Quality of Life +- [ ] Config option for force-load during linking +- [ ] Link management GUI +- [ ] Visual feedback (particles, beams) +- [ ] Admin commands (`/cosmiccore link list/remove/info`) + +### Phase 4: Advanced Features +- [ ] Energy/fluid transfer between linked machines +- [ ] Item teleportation through links +- [ ] Wireless redstone/data through links + +--- + +## 11. Testing Checklist + +- [x] Basic linking between two machines (same dimension) +- [x] Cross-dimensional linking (Overworld ↔ Moon) +- [x] Link persistence across server restart +- [x] Link broken when machine destroyed +- [x] Role negotiation (PEER+PEER, PEER+CONTROLLER, etc.) +- [x] Partner limit enforcement +- [x] Recipe condition: LinkedPartnerCondition +- [x] Recipe condition: LinkedPartnerDimensionCondition +- [x] Recipe condition: LinkedPartnerDimensionItemCondition +- [x] Recipe condition: LinkedPartnerDimensionFluidCondition +- [x] Partner query utilities (items, fluids, energy, formed, working) diff --git a/src/main/java/com/ghostipedia/cosmiccore/api/capability/CosmicCapabilities.java b/src/main/java/com/ghostipedia/cosmiccore/api/capability/CosmicCapabilities.java index 615f7bc62..734aa683f 100644 --- a/src/main/java/com/ghostipedia/cosmiccore/api/capability/CosmicCapabilities.java +++ b/src/main/java/com/ghostipedia/cosmiccore/api/capability/CosmicCapabilities.java @@ -10,7 +10,11 @@ public class CosmicCapabilities { public static Capability CAPABILITY_SOUL_CONTAINER = CapabilityManager .get(new CapabilityToken<>() {}); + public static Capability CAPABILITY_TELEPORT_ORIGIN = CapabilityManager + .get(new CapabilityToken<>() {}); + public static void register(RegisterCapabilitiesEvent event) { event.register(ISoulContainer.class); + event.register(ITeleportOrigin.class); } } 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/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/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..44ae168c5 --- /dev/null +++ b/src/main/java/com/ghostipedia/cosmiccore/api/data/savedData/LinkedMultiblockSavedData.java @@ -0,0 +1,297 @@ +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.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); + } + + // ==================== 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( + LinkedMultiblockSavedData::new, + LinkedMultiblockSavedData::new, + 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) { + 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/machine/multiblock/LinkedWorkableElectricMultiblockMachine.java b/src/main/java/com/ghostipedia/cosmiccore/api/machine/multiblock/LinkedWorkableElectricMultiblockMachine.java new file mode 100644 index 000000000..a4c81daf2 --- /dev/null +++ b/src/main/java/com/ghostipedia/cosmiccore/api/machine/multiblock/LinkedWorkableElectricMultiblockMachine.java @@ -0,0 +1,621 @@ +package com.ghostipedia.cosmiccore.api.machine.multiblock; + +import com.ghostipedia.cosmiccore.CosmicCore; +import com.ghostipedia.cosmiccore.api.capability.ILinkedMultiblock; +import com.ghostipedia.cosmiccore.api.data.savedData.LinkEntry; +import com.ghostipedia.cosmiccore.api.data.savedData.LinkedMultiblockSavedData; +import com.ghostipedia.cosmiccore.common.machine.multiblock.LinkedMultiblockHelper; +import com.ghostipedia.cosmiccore.common.machine.multiblock.LinkedMultiblockHelper.RolePair; + +import com.gregtechceu.gtceu.api.machine.IMachineBlockEntity; +import com.gregtechceu.gtceu.api.machine.MetaMachine; +import com.gregtechceu.gtceu.api.machine.feature.IMachineLife; +import com.gregtechceu.gtceu.api.machine.multiblock.WorkableElectricMultiblockMachine; +import com.gregtechceu.gtceu.common.machine.owner.FTBOwner; + +import com.lowdragmc.lowdraglib.syncdata.field.ManagedFieldHolder; + +import net.minecraft.ChatFormatting; +import net.minecraft.core.GlobalPos; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.NbtOps; +import net.minecraft.network.chat.Component; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraftforge.fluids.FluidStack; + +import org.jetbrains.annotations.Nullable; + +import java.util.*; +import java.util.function.Predicate; + +/** + * Base class for multiblocks that support cross-dimensional linking. + *

+ * Subclasses should override: + *

+ */ +public abstract class LinkedWorkableElectricMultiblockMachine extends WorkableElectricMultiblockMachine + implements ILinkedMultiblock, IMachineLife { + + protected static final ManagedFieldHolder MANAGED_FIELD_HOLDER = new ManagedFieldHolder( + LinkedWorkableElectricMultiblockMachine.class, + WorkableElectricMultiblockMachine.MANAGED_FIELD_HOLDER); + + private static final String DATASTICK_TAG_KEY = "cosmiccore:link_data"; + private static final String TAG_POS = "Pos"; + private static final String TAG_OWNER = "Owner"; + + /** + * Local cache of known partners, rebuilt from SavedData on structure form. + * Used to detect changes for lifecycle callbacks. + * NOT persisted - rebuilt from SavedData which is the source of truth. + */ + protected Set knownPartners = new HashSet<>(); + + public LinkedWorkableElectricMultiblockMachine(IMachineBlockEntity holder, Object... args) { + super(holder, args); + } + + @Override + public ManagedFieldHolder getFieldHolder() { + return MANAGED_FIELD_HOLDER; + } + + // ==================== ILinkedMultiblock Implementation ==================== + + @Override + public GlobalPos getGlobalPos() { + if (getLevel() instanceof ServerLevel serverLevel) { + return GlobalPos.of(serverLevel.dimension(), getPos()); + } + return null; + } + + @Override + @Nullable + public UUID getTeamUUID() { + var team = ((FTBOwner) getOwner()).getPlayerTeam(getOwnerUUID()); + return team != null ? team.getTeamId() : getOwnerUUID(); + } + + @Override + public Set getLinkedPartners() { + if (!(getLevel() instanceof ServerLevel serverLevel)) { + return Collections.emptySet(); + } + + UUID owner = getTeamUUID(); + if (owner == null) return Collections.emptySet(); + + LinkedMultiblockSavedData savedData = LinkedMultiblockSavedData.getOrCreate(serverLevel); + return savedData.getPartnerPositions(owner, getGlobalPos()); + } + + @Override + public void processLinkNotifications() { + if (!(getLevel() instanceof ServerLevel serverLevel)) return; + + UUID owner = getTeamUUID(); + if (owner == null) return; + + Set currentPartners = getLinkedPartners(); + + // Find new partners (in SavedData but not in our cache) + for (GlobalPos partner : currentPartners) { + if (!knownPartners.contains(partner)) { + onLinkEstablished(partner); + } + } + + // Find removed partners (in our cache but not in SavedData) + Set removed = new HashSet<>(knownPartners); + removed.removeAll(currentPartners); + for (GlobalPos partner : removed) { + onLinkBroken(partner); + } + + // Update cache + knownPartners = new HashSet<>(currentPartners); + } + + // ==================== Lifecycle ==================== + + @Override + public void onStructureFormed() { + super.onStructureFormed(); + // Process any pending link notifications from when we were unloaded + processLinkNotifications(); + } + + @Override + public void onStructureInvalid() { + super.onStructureInvalid(); + // Don't remove links when structure breaks - links persist to SavedData + // They'll be cleaned up when the machine is actually destroyed + } + + @Override + public void onMachineRemoved() { + IMachineLife.super.onMachineRemoved(); + + if (getLevel() instanceof ServerLevel serverLevel) { + UUID owner = getTeamUUID(); + GlobalPos myPos = getGlobalPos(); + + if (owner != null && myPos != null) { + // Release any force-loaded chunks + LinkedMultiblockHelper.releaseAllTickets(serverLevel.getServer(), myPos); + + // Remove all links from SavedData + LinkedMultiblockSavedData savedData = LinkedMultiblockSavedData.getOrCreate(serverLevel); + savedData.removeAllLinks(owner, myPos); + + // Notify partners (if loaded) + for (GlobalPos partner : knownPartners) { + ILinkedMultiblock partnerMachine = LinkedMultiblockHelper.getLinkedMachine( + serverLevel.getServer(), partner); + if (partnerMachine != null) { + partnerMachine.onLinkBroken(myPos); + } + } + } + } + } + + // ==================== Datastick Handling ==================== + + @Override + public InteractionResult onDataStickShiftUse(Player player, ItemStack dataStick) { + if (isRemote()) { + return InteractionResult.SUCCESS; + } + + GlobalPos myPos = getGlobalPos(); + UUID owner = getTeamUUID(); + + if (myPos == null || owner == null) { + player.sendSystemMessage(Component.translatable("cosmiccore.link.not_ready") + .withStyle(ChatFormatting.RED)); + return InteractionResult.FAIL; + } + + // Write link data to datastick + CompoundTag linkData = new CompoundTag(); + GlobalPos.CODEC.encodeStart(NbtOps.INSTANCE, myPos) + .result() + .ifPresent(encoded -> linkData.put(TAG_POS, encoded)); + linkData.putUUID(TAG_OWNER, owner); + + // Store in namespaced tag to preserve other datastick data + CompoundTag rootTag = dataStick.getOrCreateTag(); + rootTag.put(DATASTICK_TAG_KEY, linkData); + + // Update datastick name + String machineName = getDefinition().getName(); + dataStick.setHoverName(Component.translatable("cosmiccore.datastick.link_copied", machineName)); + + // Feedback + player.sendSystemMessage(Component.translatable("cosmiccore.link.copied", machineName) + .withStyle(ChatFormatting.GREEN)); + + return InteractionResult.SUCCESS; + } + + @Override + public InteractionResult onDataStickUse(Player player, ItemStack dataStick) { + if (isRemote()) { + return InteractionResult.sidedSuccess(true); + } + + CompoundTag rootTag = dataStick.getTag(); + if (rootTag == null || !rootTag.contains(DATASTICK_TAG_KEY)) { + return InteractionResult.PASS; // Not our data, let other handlers try + } + + CompoundTag linkData = rootTag.getCompound(DATASTICK_TAG_KEY); + + // Parse partner info from datastick + GlobalPos partnerPos = GlobalPos.CODEC + .decode(NbtOps.INSTANCE, linkData.get(TAG_POS)) + .result() + .map(pair -> pair.getFirst()) + .orElse(null); + + if (partnerPos == null) { + player.sendSystemMessage(Component.translatable("cosmiccore.link.invalid_data") + .withStyle(ChatFormatting.RED)); + return InteractionResult.FAIL; + } + + UUID partnerOwner = linkData.getUUID(TAG_OWNER); + + // Attempt to establish link + return tryLink(player, partnerPos, partnerOwner); + } + + /** + * Attempt to establish a link with the partner machine. + * Handles all validation, negotiation, and persistence. + */ + protected InteractionResult tryLink(Player player, GlobalPos partnerPos, UUID partnerOwner) { + if (!(getLevel() instanceof ServerLevel serverLevel)) { + return InteractionResult.FAIL; + } + + MinecraftServer server = serverLevel.getServer(); + GlobalPos myPos = getGlobalPos(); + UUID myOwner = getTeamUUID(); + + // === Validation === + + // Self-link check + if (myPos.equals(partnerPos)) { + player.sendSystemMessage(Component.translatable("cosmiccore.link.cannot_self_link") + .withStyle(ChatFormatting.RED)); + return InteractionResult.FAIL; + } + + // NOTE: We intentionally do NOT check partnerOwner from datastick NBT here. + // The datastick may be stale (team changed since it was written). + // Ownership is verified at runtime after loading the partner machine. + + // Partner limit check (this machine) + Set currentPartners = getLinkedPartners(); + if (currentPartners.size() >= getMaxPartners() && !currentPartners.contains(partnerPos)) { + player.sendSystemMessage(Component.translatable("cosmiccore.link.limit_reached_self") + .withStyle(ChatFormatting.RED)); + return InteractionResult.FAIL; + } + + // Already linked check + if (currentPartners.contains(partnerPos)) { + player.sendSystemMessage(Component.translatable("cosmiccore.link.already_linked") + .withStyle(ChatFormatting.YELLOW)); + return InteractionResult.FAIL; + } + + // === Load and verify partner === + // SECURITY: Always load partner to verify ownership and compatibility + boolean needsUnload = false; + if (!LinkedMultiblockHelper.isPartnerOnline(server, partnerPos)) { + // Try to force-load partner temporarily + if (!LinkedMultiblockHelper.forceLoadPartnerChunk(server, myPos, partnerPos)) { + player.sendSystemMessage(Component.translatable("cosmiccore.link.partner_not_loaded") + .withStyle(ChatFormatting.RED)); + return InteractionResult.FAIL; + } + needsUnload = true; + } + + try { + MetaMachine rawPartner = LinkedMultiblockHelper.getMachine(server, partnerPos); + if (rawPartner == null) { + player.sendSystemMessage(Component.translatable("cosmiccore.link.partner_missing") + .withStyle(ChatFormatting.RED)); + return InteractionResult.FAIL; + } + + if (!(rawPartner instanceof ILinkedMultiblock partnerMachine)) { + player.sendSystemMessage(Component.translatable("cosmiccore.link.not_linkable") + .withStyle(ChatFormatting.RED)); + return InteractionResult.FAIL; + } + + // Verify ownership matches at runtime + UUID actualPartnerOwner = partnerMachine.getTeamUUID(); + if (!Objects.equals(myOwner, actualPartnerOwner)) { + player.sendSystemMessage(Component.translatable("cosmiccore.link.different_owner") + .withStyle(ChatFormatting.RED)); + return InteractionResult.FAIL; + } + + // === Partner capacity check === + Set partnerLinks = partnerMachine.getLinkedPartners(); + if (partnerLinks.size() >= partnerMachine.getMaxPartners() && !partnerLinks.contains(myPos)) { + player.sendSystemMessage(Component.translatable("cosmiccore.link.limit_reached_partner") + .withStyle(ChatFormatting.RED)); + return InteractionResult.FAIL; + } + + // === Role Negotiation === + RolePair roles = LinkedMultiblockHelper.negotiateRoles(getLinkRole(), partnerMachine.getLinkRole()); + if (roles == null) { + player.sendSystemMessage(Component.translatable("cosmiccore.link.incompatible_roles", + getLinkRole().name(), partnerMachine.getLinkRole().name()) + .withStyle(ChatFormatting.RED)); + return InteractionResult.FAIL; + } + + // === Type compatibility check === + if (!canLinkTo(partnerPos, partnerMachine)) { + player.sendSystemMessage(Component.translatable("cosmiccore.link.incompatible_self") + .withStyle(ChatFormatting.RED)); + return InteractionResult.FAIL; + } + + if (!partnerMachine.canLinkTo(myPos, this)) { + player.sendSystemMessage(Component.translatable("cosmiccore.link.incompatible_partner") + .withStyle(ChatFormatting.RED)); + return InteractionResult.FAIL; + } + + // === Persist link === + LinkedMultiblockSavedData savedData = LinkedMultiblockSavedData.getOrCreate(serverLevel); + savedData.link(myOwner, myPos, partnerPos, roles.aRole(), roles.bRole()); + + // === Notify both machines === + onLinkEstablished(partnerPos); + knownPartners.add(partnerPos); + + partnerMachine.onLinkEstablished(myPos); + + // Success feedback + String myName = getDefinition().getName(); + String partnerName = rawPartner.getDefinition().getName(); + player.sendSystemMessage(Component.translatable("cosmiccore.link.established", myName, partnerName) + .withStyle(ChatFormatting.GREEN)); + + return InteractionResult.SUCCESS; + + } finally { + // Release temporary chunk load + if (needsUnload) { + LinkedMultiblockHelper.releasePartnerChunk(server, myPos, partnerPos); + } + } + } + + // ==================== Default Implementations ==================== + + @Override + public boolean canLinkTo(GlobalPos partner, ILinkedMultiblock partnerMachine) { + // Default: allow linking to any ILinkedMultiblock + // Subclasses should override for type-specific restrictions + return true; + } + + @Override + public LinkRole getLinkRole() { + // Default: bidirectional peer + return LinkRole.PEER; + } + + @Override + public void onLinkEstablished(GlobalPos partner) { + // Default: just log + CosmicCore.LOGGER.debug("Link established: {} -> {}", getGlobalPos(), partner); + } + + @Override + public void onLinkBroken(GlobalPos partner) { + // Default: just log and update cache + CosmicCore.LOGGER.debug("Link broken: {} -> {}", getGlobalPos(), partner); + knownPartners.remove(partner); + } + + // ==================== Utility Methods ==================== + + /** + * Get a linked partner's machine instance. + * Does NOT force-load chunks - returns null if partner is unloaded. + */ + @Nullable + protected ILinkedMultiblock getPartnerMachine(GlobalPos partner) { + if (!(getLevel() instanceof ServerLevel serverLevel)) { + return null; + } + return LinkedMultiblockHelper.getLinkedMachine(serverLevel.getServer(), partner); + } + + /** + * Check if this machine can query the given partner (based on effective role). + */ + protected boolean canQueryPartner(GlobalPos partner) { + if (!(getLevel() instanceof ServerLevel serverLevel)) { + return false; + } + UUID owner = getTeamUUID(); + if (owner == null) return false; + return LinkedMultiblockHelper.canQuery(serverLevel.getServer(), owner, getGlobalPos(), partner); + } + + /** + * Get the effective role for this machine in relation to a specific partner. + */ + @Nullable + protected LinkRole getEffectiveRole(GlobalPos partner) { + if (!(getLevel() instanceof ServerLevel serverLevel)) { + return null; + } + UUID owner = getTeamUUID(); + if (owner == null) return null; + + LinkedMultiblockSavedData savedData = LinkedMultiblockSavedData.getOrCreate(serverLevel); + LinkEntry link = savedData.getLinkTo(owner, getGlobalPos(), partner); + return link != null ? link.effectiveRole() : null; + } + + // ==================== Partner Resource Queries ==================== + + /** + * Check if a partner has a specific item in its input handlers. + * Handles chunk loading automatically. + * + * @param partner The partner to query + * @param itemPredicate Predicate to test items (e.g., stack -> stack.is(Items.DIAMOND)) + * @return true if partner has matching item, false otherwise or if unavailable + */ + protected boolean partnerHasItem(GlobalPos partner, Predicate itemPredicate) { + if (!(getLevel() instanceof ServerLevel serverLevel)) { + return false; + } + UUID owner = getTeamUUID(); + if (owner == null) return false; + + return LinkedMultiblockHelper.partnerHasItem( + serverLevel.getServer(), owner, getGlobalPos(), partner, itemPredicate); + } + + /** + * Check if a partner has a specific fluid in its input handlers. + * Handles chunk loading automatically. + * + * @param partner The partner to query + * @param fluidPredicate Predicate to test fluids (e.g., stack -> stack.getFluid().is(Fluids.LAVA)) + * @return true if partner has matching fluid, false otherwise or if unavailable + */ + protected boolean partnerHasFluid(GlobalPos partner, Predicate fluidPredicate) { + if (!(getLevel() instanceof ServerLevel serverLevel)) { + return false; + } + UUID owner = getTeamUUID(); + if (owner == null) return false; + + return LinkedMultiblockHelper.partnerHasFluid( + serverLevel.getServer(), owner, getGlobalPos(), partner, fluidPredicate); + } + + /** + * Get total energy stored in a partner's energy containers. + * Handles chunk loading automatically. + * + * @param partner The partner to query + * @return Energy stored in EU, or 0 if unavailable + */ + protected long getPartnerEnergyStored(GlobalPos partner) { + if (!(getLevel() instanceof ServerLevel serverLevel)) { + return 0L; + } + UUID owner = getTeamUUID(); + if (owner == null) return 0L; + + return LinkedMultiblockHelper.getPartnerEnergyStored( + serverLevel.getServer(), owner, getGlobalPos(), partner); + } + + /** + * Check if a partner's multiblock is formed. + * Handles chunk loading automatically. + * + * @param partner The partner to query + * @return true if partner is formed, false otherwise or if unavailable + */ + protected boolean isPartnerFormed(GlobalPos partner) { + if (!(getLevel() instanceof ServerLevel serverLevel)) { + return false; + } + UUID owner = getTeamUUID(); + if (owner == null) return false; + + return LinkedMultiblockHelper.isPartnerFormed( + serverLevel.getServer(), owner, getGlobalPos(), partner); + } + + /** + * Check if a partner is currently running a recipe. + * Handles chunk loading automatically. + * + * @param partner The partner to query + * @return true if partner is working, false otherwise or if unavailable + */ + protected boolean isPartnerWorking(GlobalPos partner) { + if (!(getLevel() instanceof ServerLevel serverLevel)) { + return false; + } + UUID owner = getTeamUUID(); + if (owner == null) return false; + + return LinkedMultiblockHelper.isPartnerWorking( + serverLevel.getServer(), owner, getGlobalPos(), partner); + } + + /** + * Execute a custom query on a partner machine. + * Handles chunk loading and permission checks automatically. + * + * @param partner The partner to query + * @param query The query function + * @return Query result, or null if unavailable + */ + @Nullable + protected T queryPartner(GlobalPos partner, LinkedMultiblockHelper.PartnerQuery query) { + if (!(getLevel() instanceof ServerLevel serverLevel)) { + return null; + } + UUID owner = getTeamUUID(); + if (owner == null) return null; + + return LinkedMultiblockHelper.queryPartner( + serverLevel.getServer(), owner, getGlobalPos(), partner, query); + } + + /** + * Check if ANY linked partner has a specific item. + * Useful for recipe conditions that require "a linked partner has X". + * + * @param itemPredicate Predicate to test items + * @return true if any partner has the item + */ + protected boolean anyPartnerHasItem(Predicate itemPredicate) { + for (GlobalPos partner : getLinkedPartners()) { + if (partnerHasItem(partner, itemPredicate)) { + return true; + } + } + return false; + } + + /** + * Check if ANY linked partner has a specific fluid. + * + * @param fluidPredicate Predicate to test fluids + * @return true if any partner has the fluid + */ + protected boolean anyPartnerHasFluid(Predicate fluidPredicate) { + for (GlobalPos partner : getLinkedPartners()) { + if (partnerHasFluid(partner, fluidPredicate)) { + return true; + } + } + return false; + } + + /** + * Check if ANY linked partner is formed and working. + * + * @return true if any partner is actively working + */ + public boolean anyPartnerWorking() { + for (GlobalPos partner : getLinkedPartners()) { + if (isPartnerWorking(partner)) { + return true; + } + } + return false; + } + + /** + * Count how many linked partners are currently formed. + * + * @return Number of formed partners + */ + public int countFormedPartners() { + int count = 0; + for (GlobalPos partner : getLinkedPartners()) { + if (isPartnerFormed(partner)) { + count++; + } + } + return count; + } +} diff --git a/src/main/java/com/ghostipedia/cosmiccore/api/machine/multiblock/LinkedWorkableMultiblockMachine.java b/src/main/java/com/ghostipedia/cosmiccore/api/machine/multiblock/LinkedWorkableMultiblockMachine.java new file mode 100644 index 000000000..c202e885b --- /dev/null +++ b/src/main/java/com/ghostipedia/cosmiccore/api/machine/multiblock/LinkedWorkableMultiblockMachine.java @@ -0,0 +1,624 @@ +package com.ghostipedia.cosmiccore.api.machine.multiblock; + +import com.ghostipedia.cosmiccore.CosmicCore; +import com.ghostipedia.cosmiccore.api.capability.ILinkedMultiblock; +import com.ghostipedia.cosmiccore.api.data.savedData.LinkEntry; +import com.ghostipedia.cosmiccore.api.data.savedData.LinkedMultiblockSavedData; +import com.ghostipedia.cosmiccore.common.machine.multiblock.LinkedMultiblockHelper; +import com.ghostipedia.cosmiccore.common.machine.multiblock.LinkedMultiblockHelper.RolePair; + +import com.gregtechceu.gtceu.api.machine.IMachineBlockEntity; +import com.gregtechceu.gtceu.api.machine.MetaMachine; +import com.gregtechceu.gtceu.api.machine.feature.IMachineLife; +import com.gregtechceu.gtceu.api.machine.feature.multiblock.IDisplayUIMachine; +import com.gregtechceu.gtceu.api.machine.multiblock.WorkableMultiblockMachine; +import com.gregtechceu.gtceu.common.machine.owner.FTBOwner; + +import com.lowdragmc.lowdraglib.syncdata.field.ManagedFieldHolder; + +import net.minecraft.ChatFormatting; +import net.minecraft.core.GlobalPos; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.NbtOps; +import net.minecraft.network.chat.Component; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraftforge.fluids.FluidStack; + +import org.jetbrains.annotations.Nullable; + +import java.util.*; +import java.util.function.Predicate; + +/** + * Base class for non-electric multiblocks that support cross-dimensional linking. + *

+ * For electric multiblocks, use {@link LinkedWorkableElectricMultiblockMachine} instead. + *

+ * Subclasses should override: + *

    + *
  • {@link #canLinkTo(GlobalPos, ILinkedMultiblock)} - Type compatibility checks
  • + *
  • {@link #getLinkRole()} - Define role preference (PEER, CONTROLLER, REMOTE)
  • + *
  • {@link #getMaxPartners()} - Override if more/fewer than 4 partners needed
  • + *
  • {@link #onLinkEstablished(GlobalPos)} - React to new links
  • + *
  • {@link #onLinkBroken(GlobalPos)} - Cleanup when links break
  • + *
+ */ +public abstract class LinkedWorkableMultiblockMachine extends WorkableMultiblockMachine + implements ILinkedMultiblock, IMachineLife, IDisplayUIMachine { + + protected static final ManagedFieldHolder MANAGED_FIELD_HOLDER = new ManagedFieldHolder( + LinkedWorkableMultiblockMachine.class, + WorkableMultiblockMachine.MANAGED_FIELD_HOLDER); + + private static final String DATASTICK_TAG_KEY = "cosmiccore:link_data"; + private static final String TAG_POS = "Pos"; + private static final String TAG_OWNER = "Owner"; + + /** + * Local cache of known partners, rebuilt from SavedData on structure form. + * Used to detect changes for lifecycle callbacks. + * NOT persisted - rebuilt from SavedData which is the source of truth. + */ + protected Set knownPartners = new HashSet<>(); + + public LinkedWorkableMultiblockMachine(IMachineBlockEntity holder, Object... args) { + super(holder, args); + } + + @Override + public ManagedFieldHolder getFieldHolder() { + return MANAGED_FIELD_HOLDER; + } + + // ==================== ILinkedMultiblock Implementation ==================== + + @Override + public GlobalPos getGlobalPos() { + if (getLevel() instanceof ServerLevel serverLevel) { + return GlobalPos.of(serverLevel.dimension(), getPos()); + } + return null; + } + + @Override + @Nullable + public UUID getTeamUUID() { + var team = ((FTBOwner) getOwner()).getPlayerTeam(getOwnerUUID()); + return team != null ? team.getTeamId() : getOwnerUUID(); + } + + @Override + public Set getLinkedPartners() { + if (!(getLevel() instanceof ServerLevel serverLevel)) { + return Collections.emptySet(); + } + + UUID owner = getTeamUUID(); + if (owner == null) return Collections.emptySet(); + + LinkedMultiblockSavedData savedData = LinkedMultiblockSavedData.getOrCreate(serverLevel); + return savedData.getPartnerPositions(owner, getGlobalPos()); + } + + @Override + public void processLinkNotifications() { + if (!(getLevel() instanceof ServerLevel serverLevel)) return; + + UUID owner = getTeamUUID(); + if (owner == null) return; + + Set currentPartners = getLinkedPartners(); + + // Find new partners (in SavedData but not in our cache) + for (GlobalPos partner : currentPartners) { + if (!knownPartners.contains(partner)) { + onLinkEstablished(partner); + } + } + + // Find removed partners (in our cache but not in SavedData) + Set removed = new HashSet<>(knownPartners); + removed.removeAll(currentPartners); + for (GlobalPos partner : removed) { + onLinkBroken(partner); + } + + // Update cache + knownPartners = new HashSet<>(currentPartners); + } + + // ==================== Lifecycle ==================== + + @Override + public void onStructureFormed() { + super.onStructureFormed(); + // Process any pending link notifications from when we were unloaded + processLinkNotifications(); + } + + @Override + public void onStructureInvalid() { + super.onStructureInvalid(); + // Don't remove links when structure breaks - links persist to SavedData + // They'll be cleaned up when the machine is actually destroyed + } + + @Override + public void onMachineRemoved() { + IMachineLife.super.onMachineRemoved(); + + if (getLevel() instanceof ServerLevel serverLevel) { + UUID owner = getTeamUUID(); + GlobalPos myPos = getGlobalPos(); + + if (owner != null && myPos != null) { + // Release any force-loaded chunks + LinkedMultiblockHelper.releaseAllTickets(serverLevel.getServer(), myPos); + + // Remove all links from SavedData + LinkedMultiblockSavedData savedData = LinkedMultiblockSavedData.getOrCreate(serverLevel); + savedData.removeAllLinks(owner, myPos); + + // Notify partners (if loaded) + for (GlobalPos partner : knownPartners) { + ILinkedMultiblock partnerMachine = LinkedMultiblockHelper.getLinkedMachine( + serverLevel.getServer(), partner); + if (partnerMachine != null) { + partnerMachine.onLinkBroken(myPos); + } + } + } + } + } + + // ==================== Datastick Handling ==================== + + @Override + public InteractionResult onDataStickShiftUse(Player player, ItemStack dataStick) { + if (isRemote()) { + return InteractionResult.SUCCESS; + } + + GlobalPos myPos = getGlobalPos(); + UUID owner = getTeamUUID(); + + if (myPos == null || owner == null) { + player.sendSystemMessage(Component.translatable("cosmiccore.link.not_ready") + .withStyle(ChatFormatting.RED)); + return InteractionResult.FAIL; + } + + // Write link data to datastick + CompoundTag linkData = new CompoundTag(); + GlobalPos.CODEC.encodeStart(NbtOps.INSTANCE, myPos) + .result() + .ifPresent(encoded -> linkData.put(TAG_POS, encoded)); + linkData.putUUID(TAG_OWNER, owner); + + // Store in namespaced tag to preserve other datastick data + CompoundTag rootTag = dataStick.getOrCreateTag(); + rootTag.put(DATASTICK_TAG_KEY, linkData); + + // Update datastick name + String machineName = getDefinition().getName(); + dataStick.setHoverName(Component.translatable("cosmiccore.datastick.link_copied", machineName)); + + // Feedback + player.sendSystemMessage(Component.translatable("cosmiccore.link.copied", machineName) + .withStyle(ChatFormatting.GREEN)); + + return InteractionResult.SUCCESS; + } + + @Override + public InteractionResult onDataStickUse(Player player, ItemStack dataStick) { + if (isRemote()) { + return InteractionResult.sidedSuccess(true); + } + + CompoundTag rootTag = dataStick.getTag(); + if (rootTag == null || !rootTag.contains(DATASTICK_TAG_KEY)) { + return InteractionResult.PASS; // Not our data, let other handlers try + } + + CompoundTag linkData = rootTag.getCompound(DATASTICK_TAG_KEY); + + // Parse partner info from datastick + GlobalPos partnerPos = GlobalPos.CODEC + .decode(NbtOps.INSTANCE, linkData.get(TAG_POS)) + .result() + .map(pair -> pair.getFirst()) + .orElse(null); + + if (partnerPos == null) { + player.sendSystemMessage(Component.translatable("cosmiccore.link.invalid_data") + .withStyle(ChatFormatting.RED)); + return InteractionResult.FAIL; + } + + UUID partnerOwner = linkData.getUUID(TAG_OWNER); + + // Attempt to establish link + return tryLink(player, partnerPos, partnerOwner); + } + + /** + * Attempt to establish a link with the partner machine. + * Handles all validation, negotiation, and persistence. + */ + protected InteractionResult tryLink(Player player, GlobalPos partnerPos, UUID partnerOwner) { + if (!(getLevel() instanceof ServerLevel serverLevel)) { + return InteractionResult.FAIL; + } + + MinecraftServer server = serverLevel.getServer(); + GlobalPos myPos = getGlobalPos(); + UUID myOwner = getTeamUUID(); + + // === Validation === + + // Self-link check + if (myPos.equals(partnerPos)) { + player.sendSystemMessage(Component.translatable("cosmiccore.link.cannot_self_link") + .withStyle(ChatFormatting.RED)); + return InteractionResult.FAIL; + } + + // NOTE: We intentionally do NOT check partnerOwner from datastick NBT here. + // The datastick may be stale (team changed since it was written). + // Ownership is verified at runtime after loading the partner machine. + + // Partner limit check (this machine) + Set currentPartners = getLinkedPartners(); + if (currentPartners.size() >= getMaxPartners() && !currentPartners.contains(partnerPos)) { + player.sendSystemMessage(Component.translatable("cosmiccore.link.limit_reached_self") + .withStyle(ChatFormatting.RED)); + return InteractionResult.FAIL; + } + + // Already linked check + if (currentPartners.contains(partnerPos)) { + player.sendSystemMessage(Component.translatable("cosmiccore.link.already_linked") + .withStyle(ChatFormatting.YELLOW)); + return InteractionResult.FAIL; + } + + // === Load and verify partner === + // SECURITY: Always load partner to verify ownership and compatibility + boolean needsUnload = false; + if (!LinkedMultiblockHelper.isPartnerOnline(server, partnerPos)) { + // Try to force-load partner temporarily + if (!LinkedMultiblockHelper.forceLoadPartnerChunk(server, myPos, partnerPos)) { + player.sendSystemMessage(Component.translatable("cosmiccore.link.partner_not_loaded") + .withStyle(ChatFormatting.RED)); + return InteractionResult.FAIL; + } + needsUnload = true; + } + + try { + MetaMachine rawPartner = LinkedMultiblockHelper.getMachine(server, partnerPos); + if (rawPartner == null) { + player.sendSystemMessage(Component.translatable("cosmiccore.link.partner_missing") + .withStyle(ChatFormatting.RED)); + return InteractionResult.FAIL; + } + + if (!(rawPartner instanceof ILinkedMultiblock partnerMachine)) { + player.sendSystemMessage(Component.translatable("cosmiccore.link.not_linkable") + .withStyle(ChatFormatting.RED)); + return InteractionResult.FAIL; + } + + // Verify ownership matches at runtime + UUID actualPartnerOwner = partnerMachine.getTeamUUID(); + if (!Objects.equals(myOwner, actualPartnerOwner)) { + player.sendSystemMessage(Component.translatable("cosmiccore.link.different_owner") + .withStyle(ChatFormatting.RED)); + return InteractionResult.FAIL; + } + + // === Partner capacity check === + Set partnerLinks = partnerMachine.getLinkedPartners(); + if (partnerLinks.size() >= partnerMachine.getMaxPartners() && !partnerLinks.contains(myPos)) { + player.sendSystemMessage(Component.translatable("cosmiccore.link.limit_reached_partner") + .withStyle(ChatFormatting.RED)); + return InteractionResult.FAIL; + } + + // === Role Negotiation === + RolePair roles = LinkedMultiblockHelper.negotiateRoles(getLinkRole(), partnerMachine.getLinkRole()); + if (roles == null) { + player.sendSystemMessage(Component.translatable("cosmiccore.link.incompatible_roles", + getLinkRole().name(), partnerMachine.getLinkRole().name()) + .withStyle(ChatFormatting.RED)); + return InteractionResult.FAIL; + } + + // === Type compatibility check === + if (!canLinkTo(partnerPos, partnerMachine)) { + player.sendSystemMessage(Component.translatable("cosmiccore.link.incompatible_self") + .withStyle(ChatFormatting.RED)); + return InteractionResult.FAIL; + } + + if (!partnerMachine.canLinkTo(myPos, this)) { + player.sendSystemMessage(Component.translatable("cosmiccore.link.incompatible_partner") + .withStyle(ChatFormatting.RED)); + return InteractionResult.FAIL; + } + + // === Persist link === + LinkedMultiblockSavedData savedData = LinkedMultiblockSavedData.getOrCreate(serverLevel); + savedData.link(myOwner, myPos, partnerPos, roles.aRole(), roles.bRole()); + + // === Notify both machines === + onLinkEstablished(partnerPos); + knownPartners.add(partnerPos); + + partnerMachine.onLinkEstablished(myPos); + + // Success feedback + String myName = getDefinition().getName(); + String partnerName = rawPartner.getDefinition().getName(); + player.sendSystemMessage(Component.translatable("cosmiccore.link.established", myName, partnerName) + .withStyle(ChatFormatting.GREEN)); + + return InteractionResult.SUCCESS; + + } finally { + // Release temporary chunk load + if (needsUnload) { + LinkedMultiblockHelper.releasePartnerChunk(server, myPos, partnerPos); + } + } + } + + // ==================== Default Implementations ==================== + + @Override + public boolean canLinkTo(GlobalPos partner, ILinkedMultiblock partnerMachine) { + // Default: allow linking to any ILinkedMultiblock + // Subclasses should override for type-specific restrictions + return true; + } + + @Override + public LinkRole getLinkRole() { + // Default: bidirectional peer + return LinkRole.PEER; + } + + @Override + public void onLinkEstablished(GlobalPos partner) { + // Default: just log + CosmicCore.LOGGER.debug("Link established: {} -> {}", getGlobalPos(), partner); + } + + @Override + public void onLinkBroken(GlobalPos partner) { + // Default: just log and update cache + CosmicCore.LOGGER.debug("Link broken: {} -> {}", getGlobalPos(), partner); + knownPartners.remove(partner); + } + + // ==================== Utility Methods ==================== + + /** + * Get a linked partner's machine instance. + * Does NOT force-load chunks - returns null if partner is unloaded. + */ + @Nullable + protected ILinkedMultiblock getPartnerMachine(GlobalPos partner) { + if (!(getLevel() instanceof ServerLevel serverLevel)) { + return null; + } + return LinkedMultiblockHelper.getLinkedMachine(serverLevel.getServer(), partner); + } + + /** + * Check if this machine can query the given partner (based on effective role). + */ + protected boolean canQueryPartner(GlobalPos partner) { + if (!(getLevel() instanceof ServerLevel serverLevel)) { + return false; + } + UUID owner = getTeamUUID(); + if (owner == null) return false; + return LinkedMultiblockHelper.canQuery(serverLevel.getServer(), owner, getGlobalPos(), partner); + } + + /** + * Get the effective role for this machine in relation to a specific partner. + */ + @Nullable + protected LinkRole getEffectiveRole(GlobalPos partner) { + if (!(getLevel() instanceof ServerLevel serverLevel)) { + return null; + } + UUID owner = getTeamUUID(); + if (owner == null) return null; + + LinkedMultiblockSavedData savedData = LinkedMultiblockSavedData.getOrCreate(serverLevel); + LinkEntry link = savedData.getLinkTo(owner, getGlobalPos(), partner); + return link != null ? link.effectiveRole() : null; + } + + // ==================== Partner Resource Queries ==================== + + /** + * Check if a partner has a specific item in its input handlers. + * Handles chunk loading automatically. + * + * @param partner The partner to query + * @param itemPredicate Predicate to test items (e.g., stack -> stack.is(Items.DIAMOND)) + * @return true if partner has matching item, false otherwise or if unavailable + */ + protected boolean partnerHasItem(GlobalPos partner, Predicate itemPredicate) { + if (!(getLevel() instanceof ServerLevel serverLevel)) { + return false; + } + UUID owner = getTeamUUID(); + if (owner == null) return false; + + return LinkedMultiblockHelper.partnerHasItem( + serverLevel.getServer(), owner, getGlobalPos(), partner, itemPredicate); + } + + /** + * Check if a partner has a specific fluid in its input handlers. + * Handles chunk loading automatically. + * + * @param partner The partner to query + * @param fluidPredicate Predicate to test fluids (e.g., stack -> stack.getFluid().is(Fluids.LAVA)) + * @return true if partner has matching fluid, false otherwise or if unavailable + */ + protected boolean partnerHasFluid(GlobalPos partner, Predicate fluidPredicate) { + if (!(getLevel() instanceof ServerLevel serverLevel)) { + return false; + } + UUID owner = getTeamUUID(); + if (owner == null) return false; + + return LinkedMultiblockHelper.partnerHasFluid( + serverLevel.getServer(), owner, getGlobalPos(), partner, fluidPredicate); + } + + /** + * Get total energy stored in a partner's energy containers. + * Handles chunk loading automatically. + * + * @param partner The partner to query + * @return Energy stored in EU, or 0 if unavailable + */ + protected long getPartnerEnergyStored(GlobalPos partner) { + if (!(getLevel() instanceof ServerLevel serverLevel)) { + return 0L; + } + UUID owner = getTeamUUID(); + if (owner == null) return 0L; + + return LinkedMultiblockHelper.getPartnerEnergyStored( + serverLevel.getServer(), owner, getGlobalPos(), partner); + } + + /** + * Check if a partner's multiblock is formed. + * Handles chunk loading automatically. + * + * @param partner The partner to query + * @return true if partner is formed, false otherwise or if unavailable + */ + protected boolean isPartnerFormed(GlobalPos partner) { + if (!(getLevel() instanceof ServerLevel serverLevel)) { + return false; + } + UUID owner = getTeamUUID(); + if (owner == null) return false; + + return LinkedMultiblockHelper.isPartnerFormed( + serverLevel.getServer(), owner, getGlobalPos(), partner); + } + + /** + * Check if a partner is currently running a recipe. + * Handles chunk loading automatically. + * + * @param partner The partner to query + * @return true if partner is working, false otherwise or if unavailable + */ + protected boolean isPartnerWorking(GlobalPos partner) { + if (!(getLevel() instanceof ServerLevel serverLevel)) { + return false; + } + UUID owner = getTeamUUID(); + if (owner == null) return false; + + return LinkedMultiblockHelper.isPartnerWorking( + serverLevel.getServer(), owner, getGlobalPos(), partner); + } + + /** + * Execute a custom query on a partner machine. + * Handles chunk loading and permission checks automatically. + * + * @param partner The partner to query + * @param query The query function + * @return Query result, or null if unavailable + */ + @Nullable + protected T queryPartner(GlobalPos partner, LinkedMultiblockHelper.PartnerQuery query) { + if (!(getLevel() instanceof ServerLevel serverLevel)) { + return null; + } + UUID owner = getTeamUUID(); + if (owner == null) return null; + + return LinkedMultiblockHelper.queryPartner( + serverLevel.getServer(), owner, getGlobalPos(), partner, query); + } + + /** + * Check if ANY linked partner has a specific item. + * Useful for recipe conditions that require "a linked partner has X". + * + * @param itemPredicate Predicate to test items + * @return true if any partner has the item + */ + protected boolean anyPartnerHasItem(Predicate itemPredicate) { + for (GlobalPos partner : getLinkedPartners()) { + if (partnerHasItem(partner, itemPredicate)) { + return true; + } + } + return false; + } + + /** + * Check if ANY linked partner has a specific fluid. + * + * @param fluidPredicate Predicate to test fluids + * @return true if any partner has the fluid + */ + protected boolean anyPartnerHasFluid(Predicate fluidPredicate) { + for (GlobalPos partner : getLinkedPartners()) { + if (partnerHasFluid(partner, fluidPredicate)) { + return true; + } + } + return false; + } + + /** + * Check if ANY linked partner is formed and working. + * + * @return true if any partner is actively working + */ + public boolean anyPartnerWorking() { + for (GlobalPos partner : getLinkedPartners()) { + if (isPartnerWorking(partner)) { + return true; + } + } + return false; + } + + /** + * Count how many linked partners are currently formed. + * + * @return Number of formed partners + */ + public int countFormedPartners() { + int count = 0; + for (GlobalPos partner : getLinkedPartners()) { + if (isPartnerFormed(partner)) { + count++; + } + } + return count; + } +} diff --git a/src/main/java/com/ghostipedia/cosmiccore/api/pattern/CosmicPredicates.java b/src/main/java/com/ghostipedia/cosmiccore/api/pattern/CosmicPredicates.java index 786c4a252..fb240b471 100644 --- a/src/main/java/com/ghostipedia/cosmiccore/api/pattern/CosmicPredicates.java +++ b/src/main/java/com/ghostipedia/cosmiccore/api/pattern/CosmicPredicates.java @@ -4,6 +4,7 @@ import com.ghostipedia.cosmiccore.api.block.IMagnetType; import com.ghostipedia.cosmiccore.api.machine.feature.IStellarModuleReceiver; import com.ghostipedia.cosmiccore.common.block.MagnetBlock; +import com.ghostipedia.cosmiccore.common.machine.multiblock.multi.MothCargoStation; import com.gregtechceu.gtceu.api.machine.IMachineBlockEntity; import com.gregtechceu.gtceu.api.machine.MetaMachine; @@ -14,7 +15,10 @@ import com.lowdragmc.lowdraglib.utils.BlockInfo; +import net.minecraft.core.registries.BuiltInRegistries; import net.minecraft.network.chat.Component; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.level.block.Block; import net.minecraft.world.level.block.Blocks; import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap; @@ -25,6 +29,8 @@ import java.util.Set; import java.util.function.Supplier; +import static com.gregtechceu.gtceu.common.data.GTBlocks.CASING_STEEL_SOLID; + public class CosmicPredicates { public static TraceabilityPredicate magnetCoils() { @@ -70,6 +76,39 @@ public static TraceabilityPredicate starLadderModules() { .addTooltips(Component.translatable("gtceu.multiblock.pattern.error.filters")); } + /** + * Predicate for Moth Cargo Station moth homes (Forestry beehives or steel casing). + * Looks up blocks lazily at match time so Forestry blocks are properly resolved. + */ + public static TraceabilityPredicate mothHomes() { + return new TraceabilityPredicate(blockWorldState -> { + var blockState = blockWorldState.getBlockState(); + // Check if it's steel casing (always allowed as placeholder) + if (blockState.is(CASING_STEEL_SOLID.get())) { + return true; + } + // Check if it's a valid Forestry beehive + return MothCargoStation.isMothHome(blockState); + }, () -> { + // Provide block previews for JEI - look up Forestry blocks at render time + return new BlockInfo[] { + BlockInfo.fromBlockState(getBlockOrFallback(MothCargoStation.BEEHIVE_FOREST)), + BlockInfo.fromBlockState(getBlockOrFallback(MothCargoStation.BEEHIVE_LUSH)), + BlockInfo.fromBlockState(getBlockOrFallback(MothCargoStation.BEEHIVE_DESERT)), + BlockInfo.fromBlockState(getBlockOrFallback(MothCargoStation.BEEHIVE_END)), + BlockInfo.fromBlockState(CASING_STEEL_SOLID.get().defaultBlockState()) + }; + }).addTooltips(Component.literal("Forestry Beehive or Steel Casing")); + } + + /** + * Get a block by ResourceLocation, or vanilla beehive as fallback. + */ + private static net.minecraft.world.level.block.state.BlockState getBlockOrFallback(ResourceLocation loc) { + Block block = BuiltInRegistries.BLOCK.get(loc); + return (block != Blocks.AIR ? block : Blocks.BEEHIVE).defaultBlockState(); + } + /** * Predicate for Stellar Iris module slots. * Accepts air (empty slot) or a Stellar Module controller (formed or not). diff --git a/src/main/java/com/ghostipedia/cosmiccore/common/block/DivingBellEscapePad.java b/src/main/java/com/ghostipedia/cosmiccore/common/block/DivingBellEscapePad.java new file mode 100644 index 000000000..3ee707755 --- /dev/null +++ b/src/main/java/com/ghostipedia/cosmiccore/common/block/DivingBellEscapePad.java @@ -0,0 +1,151 @@ +package com.ghostipedia.cosmiccore.common.block; + +import com.ghostipedia.cosmiccore.common.teleporter.SafeTeleporter; +import com.ghostipedia.cosmiccore.common.teleporter.TeleportOriginCap; + +import net.minecraft.core.BlockPos; +import net.minecraft.network.chat.Component; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.phys.BlockHitResult; +import net.minecraft.world.phys.Vec3; + +// Escape Pad block that teleports players back to their origin dimension. Placed automatically by the Diving Bell. +public class DivingBellEscapePad extends Block { + + public DivingBellEscapePad(Properties properties) { + super(properties); + } + + @Override + public InteractionResult use(BlockState state, Level level, BlockPos pos, Player player, + InteractionHand hand, BlockHitResult hit) { + if (level.isClientSide) { + return InteractionResult.SUCCESS; + } + + ServerPlayer serverPlayer = (ServerPlayer) player; + serverPlayer.getCapability(TeleportOriginCap.CAP).ifPresent(cap -> { + if (!cap.hasValidOrigin()) { + // No valid origin data - send to respawn point + serverPlayer.displayClientMessage( + Component.translatable("cosmiccore.divingbell.no_return"), true); + teleportToRespawn(serverPlayer); + return; + } + + // Get return destination + var originDim = cap.getOriginDimension(); + Vec3 originPos = cap.getOriginPosition(); + float originYaw = cap.getOriginYaw(); + float originPitch = cap.getOriginPitch(); + + MinecraftServer server = level.getServer(); + if (server == null) return; + + ServerLevel originLevel = server.getLevel(originDim); + if (originLevel == null) { + // Origin dimension doesn't exist + serverPlayer.displayClientMessage( + Component.translatable("cosmiccore.divingbell.invalid_origin"), true); + cap.clearOriginData(); + teleportToRespawn(serverPlayer); + return; + } + + // Validate origin is safe (chunk loaded, not void) + BlockPos originBlockPos = BlockPos.containing(originPos); + if (!isOriginSafe(originLevel, originBlockPos)) { + serverPlayer.displayClientMessage( + Component.translatable("cosmiccore.divingbell.unsafe_origin"), true); + cap.clearOriginData(); + teleportToRespawn(serverPlayer); + return; + } + + // Teleport back to origin + serverPlayer.changeDimension(originLevel, new SafeTeleporter(originBlockPos)); + + // Restore rotation + serverPlayer.setYRot(originYaw); + serverPlayer.setXRot(originPitch); + + // Clear Abyss decay flag + // Don't think this is needed... + // serverPlayer.getCapability(com.ghostipedia.cosmiccore.common.abyss.AbyssBudgetCap.CAP) + // .ifPresent(abyssCap -> { + // abyssCap.setDecaying(AbyssRules.DIM, false); + // }); + + // Success message + serverPlayer.displayClientMessage( + Component.translatable("cosmiccore.divingbell.returned"), true); + + // Clear origin data + cap.clearOriginData(); + }); + + return InteractionResult.CONSUME; + } + + // Check if the origin position is safe to teleport to. + private boolean isOriginSafe(ServerLevel level, BlockPos pos) { + // Check if chunk is loaded + if (!level.isLoaded(pos)) { + return false; + } + + // Check if it's not in void + if (pos.getY() < level.getMinBuildHeight()) { + return false; + } + + // Check 2-block tall air column where player stands + BlockState at = level.getBlockState(pos); + BlockState above = level.getBlockState(pos.above()); + + // if (at.isSuffocating(level, pos)) { + // return false; + // } + // + // if (!at.getFluidState().isEmpty()) { + // return false; + // } + + // Head level: same checks to ensure full 2-block clearance + if (above.isSuffocating(level, pos.above())) { + return false; + } + + if (!above.getFluidState().isEmpty()) { + return false; + } + + return true; + } + + // Teleport player to their respawn point as fallback. + private void teleportToRespawn(ServerPlayer player) { + BlockPos respawn = player.getRespawnPosition(); + ServerLevel respawnLevel = player.server.getLevel(player.getRespawnDimension()); + + if (respawn != null && respawnLevel != null) { + // Teleport to bed/respawn anchor + player.teleportTo(respawnLevel, respawn.getX() + 0.5, respawn.getY(), respawn.getZ() + 0.5, + player.getYRot(), player.getXRot()); + } else { + // Ultimate fallback - overworld spawn (should not fail under normal circumstances) + ServerLevel overworld = player.server.overworld(); + BlockPos spawn = overworld.getSharedSpawnPos(); + player.teleportTo(overworld, spawn.getX() + 0.5, spawn.getY(), spawn.getZ() + 0.5, + player.getYRot(), player.getXRot()); + } + } +} diff --git a/src/main/java/com/ghostipedia/cosmiccore/common/block/MothHomeBlock.java b/src/main/java/com/ghostipedia/cosmiccore/common/block/MothHomeBlock.java new file mode 100644 index 000000000..cb15dabcc --- /dev/null +++ b/src/main/java/com/ghostipedia/cosmiccore/common/block/MothHomeBlock.java @@ -0,0 +1,20 @@ +package com.ghostipedia.cosmiccore.common.block; + +import net.minecraft.world.level.block.Block; + +import lombok.Getter; + +/** + * Moth Home block - provides moths for the Cargo Moth system. + * Different tiers provide faster cycles and more moths per home. + */ +public class MothHomeBlock extends Block { + + @Getter + private final int tier; + + public MothHomeBlock(Properties properties, int tier) { + super(properties); + this.tier = tier; + } +} diff --git a/src/main/java/com/ghostipedia/cosmiccore/common/data/CosmicBlocks.java b/src/main/java/com/ghostipedia/cosmiccore/common/data/CosmicBlocks.java index 9590ada2a..0c520b2b2 100644 --- a/src/main/java/com/ghostipedia/cosmiccore/common/data/CosmicBlocks.java +++ b/src/main/java/com/ghostipedia/cosmiccore/common/data/CosmicBlocks.java @@ -4,7 +4,9 @@ import com.ghostipedia.cosmiccore.api.CosmicCoreAPI; import com.ghostipedia.cosmiccore.api.block.IMagnetType; import com.ghostipedia.cosmiccore.client.renderer.block.NebulaeCoilRenderer; +import com.ghostipedia.cosmiccore.common.block.DivingBellEscapePad; import com.ghostipedia.cosmiccore.common.block.MagnetBlock; +import com.ghostipedia.cosmiccore.common.block.MothHomeBlock; import com.ghostipedia.cosmiccore.common.blockentity.CosmicCoilBlockEntity; import com.ghostipedia.cosmiccore.ember.CosmicEmberEmitterBlock; import com.ghostipedia.cosmiccore.ember.CosmicEmberReceptorBlock; @@ -689,6 +691,32 @@ public class CosmicBlocks { public static final BlockEntry ZBLAN_REINFORCED_GLASS = createGlassCasingBlock( "zblan_glass", CosmicCore.id("block/casings/glass/zblan_glass"), () -> RenderType::translucent); + public static final BlockEntry DIVING_BELL_ESCAPE_PAD = REGISTRATE + .block("diving_bell_escape_pad", DivingBellEscapePad::new) + .initialProperties(() -> Blocks.STONE) + .simpleItem() + .register(); + + // MOTH HOME BLOCKS - For Cargo Moths system + public static final BlockEntry MOTH_HOME_T1 = createMothHomeBlock(1); + public static final BlockEntry MOTH_HOME_T2 = createMothHomeBlock(2); + public static final BlockEntry MOTH_HOME_T3 = createMothHomeBlock(3); + public static final BlockEntry MOTH_HOME_T4 = createMothHomeBlock(4); + + // Moth Station Casing + public static final BlockEntry MOTH_STATION_CASING = createCasingBlock("moth_station_casing", + CosmicCore.id("block/casings/solid/moth_station_casing")); + + private static BlockEntry createMothHomeBlock(int tier) { + return REGISTRATE + .block("moth_home_t" + tier, p -> new MothHomeBlock(p, tier)) + .lang("Moth Home (T" + tier + ")") + .initialProperties(() -> Blocks.IRON_BLOCK) + .properties(p -> p.strength(3.0f, 6.0f)) + .simpleItem() + .register(); + } + private static BlockEntry createGlassCasingBlock(String name, ResourceLocation texture, Supplier> type) { NonNullFunction supplier = GlassBlock::new; diff --git a/src/main/java/com/ghostipedia/cosmiccore/common/data/lang/CosmicLangHandler.java b/src/main/java/com/ghostipedia/cosmiccore/common/data/lang/CosmicLangHandler.java index 74b1111d0..274bc5b8c 100644 --- a/src/main/java/com/ghostipedia/cosmiccore/common/data/lang/CosmicLangHandler.java +++ b/src/main/java/com/ghostipedia/cosmiccore/common/data/lang/CosmicLangHandler.java @@ -162,6 +162,18 @@ public static void init(RegistrateLangProvider provider) { provider.add("cosmiccore.recipe.fieldSlam", "§fField Consumed: %sT"); provider.add("cosmiccore.recipe.condition.titan.tooltip", "Requires Titan Reactor Tier: %s"); + // Linked Partner Condition + provider.add("cosmiccore.recipe.condition.linked_partner.tooltip", "Requires %s linked partner(s)"); + provider.add("cosmiccore.recipe.condition.linked_partner.formed", + "Requires %s linked partner(s) with valid structure"); + provider.add("cosmiccore.recipe.condition.linked_partner.working", + "Requires %s linked partner(s) actively working"); + provider.add("cosmiccore.recipe.condition.linked_partner_dimension.tooltip", "Requires linked partner in %s"); + provider.add("cosmiccore.recipe.condition.linked_partner_dimension_item.tooltip", + "Requires %sx %s in partner in %s"); + provider.add("cosmiccore.recipe.condition.linked_partner_dimension_fluid.tooltip", + "Requires %smB %s in partner in %s"); + provider.add("cosmiccore.multiblock.heat_value", "§6Current Heat: %s"); provider.add("cosmiccore.multiblock.heat_capacity", "§cMax Heat: %s"); @@ -511,6 +523,30 @@ public static void init(RegistrateLangProvider provider) { "§b'If you're wondering how to parallel assembly lines§r", "§fthis is how. Welcome to subnets!§r"); + // Cross-Dimensional Multiblock Linking + provider.add("cosmiccore.datastick.link_copied", "Link: %s"); + provider.add("cosmiccore.link.copied", "Link data copied from %s"); + provider.add("cosmiccore.link.established", "Link established: %s ↔ %s"); + + // Link validation errors + provider.add("cosmiccore.link.not_ready", "Machine not ready for linking"); + provider.add("cosmiccore.link.invalid_data", "Invalid link data on datastick"); + provider.add("cosmiccore.link.cannot_self_link", "Cannot link a machine to itself"); + provider.add("cosmiccore.link.partner_not_loaded", "Partner machine must be loaded to establish link"); + provider.add("cosmiccore.link.partner_missing", "Partner machine no longer exists"); + provider.add("cosmiccore.link.not_linkable", "Target machine does not support linking"); + provider.add("cosmiccore.link.different_owner", "Cannot link machines owned by different teams"); + provider.add("cosmiccore.link.incompatible_roles", "Incompatible link roles: %s cannot link to %s"); + provider.add("cosmiccore.link.limit_reached_self", "This machine has reached its link limit"); + provider.add("cosmiccore.link.limit_reached_partner", "Partner machine has reached its link limit"); + provider.add("cosmiccore.link.incompatible_self", "This machine cannot link to that type"); + provider.add("cosmiccore.link.incompatible_partner", "Partner machine cannot link to this type"); + provider.add("cosmiccore.link.already_linked", "These machines are already linked"); + provider.add("cosmiccore.link.too_far", "Partner is too far away to force-load for linking"); + + // Link runtime status + provider.add("cosmiccore.recipe.waiting_for_partner", "Waiting for linked partner"); + provider.add("cosmiccore.link.partner_offline", "Linked partner offline"); provider.add("cosmiccore.multiblock.drone_station_machine.tier.0", "Plasmatic"); provider.add("cosmiccore.multiblock.drone_station_machine.tier.1", "Sanguine"); provider.add("cosmiccore.multiblock.drone_station_machine.tier.2", "Industrial"); diff --git a/src/main/java/com/ghostipedia/cosmiccore/common/machine/multiblock/LinkedMultiblockHelper.java b/src/main/java/com/ghostipedia/cosmiccore/common/machine/multiblock/LinkedMultiblockHelper.java new file mode 100644 index 000000000..3f37c4bbe --- /dev/null +++ b/src/main/java/com/ghostipedia/cosmiccore/common/machine/multiblock/LinkedMultiblockHelper.java @@ -0,0 +1,544 @@ +package com.ghostipedia.cosmiccore.common.machine.multiblock; + +import com.ghostipedia.cosmiccore.CosmicCore; +import com.ghostipedia.cosmiccore.api.capability.ILinkedMultiblock; +import com.ghostipedia.cosmiccore.api.capability.ILinkedMultiblock.LinkRole; +import com.ghostipedia.cosmiccore.api.data.savedData.LinkedMultiblockSavedData; + +import com.gregtechceu.gtceu.api.capability.recipe.EURecipeCapability; +import com.gregtechceu.gtceu.api.capability.recipe.FluidRecipeCapability; +import com.gregtechceu.gtceu.api.capability.recipe.IO; +import com.gregtechceu.gtceu.api.capability.recipe.IRecipeHandler; +import com.gregtechceu.gtceu.api.capability.recipe.ItemRecipeCapability; +import com.gregtechceu.gtceu.api.machine.MetaMachine; +import com.gregtechceu.gtceu.api.machine.multiblock.WorkableElectricMultiblockMachine; +import com.gregtechceu.gtceu.api.machine.trait.RecipeLogic; + +import net.minecraft.core.BlockPos; +import net.minecraft.core.GlobalPos; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.ChunkPos; +import net.minecraftforge.common.world.ForgeChunkManager; + +import org.jetbrains.annotations.Nullable; + +import java.util.*; + +/** + * Utilities for cross-dimensional multiblock access. + * Handles chunk loading with proper lifecycle management. + */ +public class LinkedMultiblockHelper { + + /** Maximum forced chunks per requesting machine */ + public static final int MAX_FORCED_CHUNKS_PER_MACHINE = 4; + + /** + * Tracks active force-load tickets. + * Maps: Requester GlobalPos -> (Target GlobalPos -> Owner BlockPos used for ticket) + *

+ * IMPORTANT: We use the requester's position as the ticket owner to ensure + * each requester has independent tickets. This prevents one requester from + * accidentally releasing another's ticket. + *

+ * NOTE: Tickets are tracked in memory only. A hard crash while tickets are active + * can leave chunks loaded until restart. Mitigation: tickets are short-lived + * (released after use), and Forge clears orphaned tickets on dimension unload. + */ + private static final Map> activeTickets = new HashMap<>(); + + // ==================== Role Negotiation ==================== + + /** + * Result of role negotiation between two machines. + */ + public record RolePair(LinkRole aRole, LinkRole bRole) {} + + /** + * Negotiate effective roles for a link between two machines. + *

+ * Rules: + *

    + *
  • PEER + PEER = PEER/PEER (bidirectional)
  • + *
  • PEER adapts to stricter partner: + *
      + *
    • PEER + CONTROLLER → REMOTE/CONTROLLER
    • + *
    • PEER + REMOTE → CONTROLLER/REMOTE
    • + *
    + *
  • + *
  • CONTROLLER + REMOTE = CONTROLLER/REMOTE (asymmetric)
  • + *
  • CONTROLLER + CONTROLLER = incompatible
  • + *
  • REMOTE + REMOTE = incompatible
  • + *
+ * + * @param aDeclared Machine A's declared role preference + * @param bDeclared Machine B's declared role preference + * @return Negotiated roles, or null if incompatible + */ + @Nullable + public static RolePair negotiateRoles(LinkRole aDeclared, LinkRole bDeclared) { + // PEER + PEER = PEER/PEER + if (aDeclared == LinkRole.PEER && bDeclared == LinkRole.PEER) { + return new RolePair(LinkRole.PEER, LinkRole.PEER); + } + + // PEER adapts to stricter partner (downgrade to preserve partner's intent) + if (aDeclared == LinkRole.PEER && bDeclared == LinkRole.CONTROLLER) { + return new RolePair(LinkRole.REMOTE, LinkRole.CONTROLLER); + } + if (aDeclared == LinkRole.PEER && bDeclared == LinkRole.REMOTE) { + return new RolePair(LinkRole.CONTROLLER, LinkRole.REMOTE); + } + if (aDeclared == LinkRole.CONTROLLER && bDeclared == LinkRole.PEER) { + return new RolePair(LinkRole.CONTROLLER, LinkRole.REMOTE); + } + if (aDeclared == LinkRole.REMOTE && bDeclared == LinkRole.PEER) { + return new RolePair(LinkRole.REMOTE, LinkRole.CONTROLLER); + } + + // CONTROLLER + REMOTE = valid asymmetric link + if (aDeclared == LinkRole.CONTROLLER && bDeclared == LinkRole.REMOTE) { + return new RolePair(LinkRole.CONTROLLER, LinkRole.REMOTE); + } + if (aDeclared == LinkRole.REMOTE && bDeclared == LinkRole.CONTROLLER) { + return new RolePair(LinkRole.REMOTE, LinkRole.CONTROLLER); + } + + // CONTROLLER + CONTROLLER = incompatible (conflict) + // REMOTE + REMOTE = incompatible (deadlock) + return null; + } + + // ==================== Machine Access ==================== + + /** + * Safely retrieve a machine from any dimension. + * Returns null if dimension doesn't exist or chunk isn't loaded. + * Does NOT force-load the chunk. + */ + @Nullable + public static MetaMachine getMachine(MinecraftServer server, GlobalPos pos) { + ServerLevel level = server.getLevel(pos.dimension()); + if (level == null) return null; + + if (!level.isLoaded(pos.pos())) { + return null; + } + + return MetaMachine.getMachine(level, pos.pos()); + } + + /** + * Get machine as ILinkedMultiblock if it implements the interface. + */ + @Nullable + public static ILinkedMultiblock getLinkedMachine(MinecraftServer server, GlobalPos pos) { + MetaMachine machine = getMachine(server, pos); + if (machine instanceof ILinkedMultiblock linked) { + return linked; + } + return null; + } + + /** + * Check if a linked partner is currently accessible (chunk loaded). + */ + public static boolean isPartnerOnline(MinecraftServer server, GlobalPos pos) { + return getMachine(server, pos) != null; + } + + // ==================== Chunk Loading ==================== + + /** + * Force-load a partner's chunk for cross-dimensional access. + * Tracks the ticket with proper owner position for later removal. + *

+ * Uses the REQUESTER's position as the ticket owner to ensure each + * requester has independent tickets. + * + * @param server The server + * @param requester The machine requesting the load (for ticket tracking) + * @param target The partner machine's position to load + * @return true if successfully loaded (or already loaded), false if at limit or failed + */ + public static boolean forceLoadPartnerChunk(MinecraftServer server, GlobalPos requester, GlobalPos target) { + // Check per-machine limit + Map existing = activeTickets.getOrDefault(requester, Collections.emptyMap()); + + // Already loaded by this requester? + if (existing.containsKey(target)) { + return true; + } + + if (existing.size() >= MAX_FORCED_CHUNKS_PER_MACHINE) { + CosmicCore.LOGGER.warn("Machine at {} has reached force-load limit of {}", + requester, MAX_FORCED_CHUNKS_PER_MACHINE); + return false; + } + + ServerLevel level = server.getLevel(target.dimension()); + if (level == null) { + CosmicCore.LOGGER.warn("Cannot force-load chunk: dimension {} does not exist", + target.dimension().location()); + return false; + } + + ChunkPos chunkPos = new ChunkPos(target.pos()); + + // Use REQUESTER position as the ticket owner (unique per requester) + // This prevents one requester from releasing another's ticket + BlockPos ownerPos = requester.pos(); + + boolean success = ForgeChunkManager.forceChunk( + level, + CosmicCore.MOD_ID, + ownerPos, + chunkPos.x, + chunkPos.z, + true, // add + true // ticking + ); + + if (success) { + activeTickets.computeIfAbsent(requester, k -> new HashMap<>()) + .put(target, ownerPos); + CosmicCore.LOGGER.debug("Force-loaded chunk {} in {} for machine at {} (owner: {})", + chunkPos, target.dimension().location(), requester, ownerPos); + } else { + CosmicCore.LOGGER.warn("Failed to force-load chunk {} in {}", + chunkPos, target.dimension().location()); + } + + return success; + } + + /** + * Release a specific force-loaded chunk. + * Uses the same owner position that was used when adding the ticket. + */ + public static void releasePartnerChunk(MinecraftServer server, GlobalPos requester, GlobalPos target) { + Map tickets = activeTickets.get(requester); + if (tickets == null) return; + + BlockPos ownerPos = tickets.remove(target); + if (ownerPos == null) return; // Wasn't loaded by this requester + + ServerLevel level = server.getLevel(target.dimension()); + if (level == null) return; + + ChunkPos chunkPos = new ChunkPos(target.pos()); + + // Use the SAME owner position that was used for add + ForgeChunkManager.forceChunk( + level, + CosmicCore.MOD_ID, + ownerPos, + chunkPos.x, + chunkPos.z, + false, // remove + true); + + CosmicCore.LOGGER.debug("Released chunk {} in {} for machine at {} (owner: {})", + chunkPos, target.dimension().location(), requester, ownerPos); + + if (tickets.isEmpty()) { + activeTickets.remove(requester); + } + } + + /** + * Release ALL force-loaded chunks for a machine. + * MUST be called in onMachineRemoved() to prevent ticket leaks. + */ + public static void releaseAllTickets(MinecraftServer server, GlobalPos requester) { + Map tickets = activeTickets.remove(requester); + if (tickets == null) return; + + int released = 0; + for (Map.Entry entry : tickets.entrySet()) { + GlobalPos target = entry.getKey(); + BlockPos ownerPos = entry.getValue(); + + ServerLevel level = server.getLevel(target.dimension()); + if (level != null) { + ChunkPos chunkPos = new ChunkPos(target.pos()); + ForgeChunkManager.forceChunk( + level, + CosmicCore.MOD_ID, + ownerPos, + chunkPos.x, + chunkPos.z, + false, + true); + released++; + } + } + + CosmicCore.LOGGER.debug("Released {} force-load tickets for machine at {}", + released, requester); + } + + /** + * Get number of active force-load tickets for a machine. + */ + public static int getActiveTicketCount(GlobalPos requester) { + return activeTickets.getOrDefault(requester, Collections.emptyMap()).size(); + } + + // ==================== Link Validation ==================== + + /** + * Validate that both machines still exist and link is valid. + * Does not force-load chunks - returns false if either is unloaded. + */ + public static boolean validateLink(MinecraftServer server, UUID owner, GlobalPos a, GlobalPos b) { + // Check SavedData + LinkedMultiblockSavedData savedData = LinkedMultiblockSavedData.getOrCreate(server); + if (!savedData.isLinked(owner, a, b)) { + return false; + } + + // Check machines exist (if chunks loaded) + MetaMachine machineA = getMachine(server, a); + MetaMachine machineB = getMachine(server, b); + + // If chunk is loaded but machine is gone, link is invalid + ServerLevel levelA = server.getLevel(a.dimension()); + if (levelA != null && levelA.isLoaded(a.pos()) && machineA == null) { + return false; + } + + ServerLevel levelB = server.getLevel(b.dimension()); + if (levelB != null && levelB.isLoaded(b.pos()) && machineB == null) { + return false; + } + + return true; + } + + // ==================== Permission Helpers ==================== + + /** + * Check if requester can query the target (convenience method). + */ + public static boolean canQuery(MinecraftServer server, UUID owner, GlobalPos requester, GlobalPos target) { + return LinkedMultiblockSavedData.getOrCreate(server).canQuery(owner, requester, target); + } + + // ==================== Partner Resource Query ==================== + + /** + * Functional interface for querying a partner machine. + */ + @FunctionalInterface + public interface PartnerQuery { + + T query(WorkableElectricMultiblockMachine partner); + } + + /** + * Query a partner machine with temporary chunk loading. + * Loads the partner's chunk if needed, executes the query, then releases. + *

+ * IMPORTANT: This method handles short-lived queries. For sustained access, + * use forceLoadPartnerChunk/releasePartnerChunk directly. + * + * @param server The server + * @param owner Team/player UUID for permission check + * @param requester The requesting machine's position + * @param target The partner machine's position + * @param query The query function to execute + * @return Query result, or null if partner unavailable or permission denied + */ + @Nullable + public static T queryPartner( + MinecraftServer server, + UUID owner, + GlobalPos requester, + GlobalPos target, + PartnerQuery query) { + // Permission check + if (!canQuery(server, owner, requester, target)) { + CosmicCore.LOGGER.debug("Query denied: {} cannot query {}", requester, target); + return null; + } + + boolean needsUnload = false; + if (!isPartnerOnline(server, target)) { + if (!forceLoadPartnerChunk(server, requester, target)) { + return null; + } + needsUnload = true; + } + + try { + MetaMachine machine = getMachine(server, target); + if (machine instanceof WorkableElectricMultiblockMachine workable) { + return query.query(workable); + } + return null; + } finally { + if (needsUnload) { + releasePartnerChunk(server, requester, target); + } + } + } + + /** + * Get a partner's item handler capabilities. + * Returns empty list if partner unavailable or permission denied. + * + * @param io IO.IN for input handlers, IO.OUT for output handlers + */ + public static List> getPartnerItemHandlers( + MinecraftServer server, + UUID owner, + GlobalPos requester, + GlobalPos target, + IO io) { + List> result = queryPartner(server, owner, requester, target, + partner -> partner.getCapabilitiesFlat(io, ItemRecipeCapability.CAP)); + return result != null ? result : Collections.emptyList(); + } + + /** + * Get a partner's fluid handler capabilities. + * Returns empty list if partner unavailable or permission denied. + * + * @param io IO.IN for input handlers, IO.OUT for output handlers + */ + public static List> getPartnerFluidHandlers( + MinecraftServer server, + UUID owner, + GlobalPos requester, + GlobalPos target, + IO io) { + List> result = queryPartner(server, owner, requester, target, + partner -> partner.getCapabilitiesFlat(io, FluidRecipeCapability.CAP)); + return result != null ? result : Collections.emptyList(); + } + + /** + * Get a partner's energy container capabilities. + * Returns empty list if partner unavailable or permission denied. + * + * @param io IO.IN for input energy, IO.OUT for output energy + */ + public static List> getPartnerEnergyHandlers( + MinecraftServer server, + UUID owner, + GlobalPos requester, + GlobalPos target, + IO io) { + List> result = queryPartner(server, owner, requester, target, + partner -> partner.getCapabilitiesFlat(io, EURecipeCapability.CAP)); + return result != null ? result : Collections.emptyList(); + } + + /** + * Check if a partner has a specific item in any of its input handlers. + */ + public static boolean partnerHasItem( + MinecraftServer server, + UUID owner, + GlobalPos requester, + GlobalPos target, + java.util.function.Predicate itemPredicate) { + Boolean result = queryPartner(server, owner, requester, target, partner -> { + var handlers = partner.getCapabilitiesFlat(IO.IN, ItemRecipeCapability.CAP); + if (handlers == null) return false; + + for (Object handler : handlers) { + if (handler instanceof net.minecraftforge.items.IItemHandler itemHandler) { + for (int i = 0; i < itemHandler.getSlots(); i++) { + if (itemPredicate.test(itemHandler.getStackInSlot(i))) { + return true; + } + } + } + } + return false; + }); + return result != null && result; + } + + /** + * Check if a partner has a specific fluid in any of its input handlers. + */ + public static boolean partnerHasFluid( + MinecraftServer server, + UUID owner, + GlobalPos requester, + GlobalPos target, + java.util.function.Predicate fluidPredicate) { + Boolean result = queryPartner(server, owner, requester, target, partner -> { + var handlers = partner.getCapabilitiesFlat(IO.IN, FluidRecipeCapability.CAP); + if (handlers == null) return false; + + for (Object handler : handlers) { + if (handler instanceof net.minecraftforge.fluids.capability.IFluidHandler fluidHandler) { + for (int i = 0; i < fluidHandler.getTanks(); i++) { + if (fluidPredicate.test(fluidHandler.getFluidInTank(i))) { + return true; + } + } + } + } + return false; + }); + return result != null && result; + } + + /** + * Get total energy stored across all of a partner's energy containers. + * Returns 0 if partner unavailable or permission denied. + */ + public static long getPartnerEnergyStored( + MinecraftServer server, + UUID owner, + GlobalPos requester, + GlobalPos target) { + Long result = queryPartner(server, owner, requester, target, partner -> { + var handlers = partner.getCapabilitiesFlat(IO.IN, EURecipeCapability.CAP); + if (handlers == null) return 0L; + + long total = 0; + for (Object handler : handlers) { + if (handler instanceof com.gregtechceu.gtceu.api.capability.IEnergyContainer energyContainer) { + total += energyContainer.getEnergyStored(); + } + } + return total; + }); + return result != null ? result : 0L; + } + + /** + * Check if partner's multiblock is formed and working. + */ + public static boolean isPartnerFormed( + MinecraftServer server, + UUID owner, + GlobalPos requester, + GlobalPos target) { + Boolean result = queryPartner(server, owner, requester, target, + WorkableElectricMultiblockMachine::isFormed); + return result != null && result; + } + + /** + * Check if partner is currently running a recipe. + */ + public static boolean isPartnerWorking( + MinecraftServer server, + UUID owner, + GlobalPos requester, + GlobalPos target) { + Boolean result = queryPartner(server, owner, requester, target, partner -> { + RecipeLogic logic = partner.getRecipeLogic(); + return logic != null && logic.isWorking(); + }); + return result != null && result; + } +} diff --git a/src/main/java/com/ghostipedia/cosmiccore/common/machine/multiblock/multi/DivingBell.java b/src/main/java/com/ghostipedia/cosmiccore/common/machine/multiblock/multi/DivingBell.java new file mode 100644 index 000000000..0fac80a63 --- /dev/null +++ b/src/main/java/com/ghostipedia/cosmiccore/common/machine/multiblock/multi/DivingBell.java @@ -0,0 +1,49 @@ +package com.ghostipedia.cosmiccore.common.machine.multiblock.multi; + +import com.ghostipedia.cosmiccore.CosmicCore; +import com.ghostipedia.cosmiccore.common.machine.multiblock.multi.logic.DivingBellMachine; + +import com.gregtechceu.gtceu.GTCEu; +import com.gregtechceu.gtceu.api.data.RotationState; +import com.gregtechceu.gtceu.api.machine.MultiblockMachineDefinition; +import com.gregtechceu.gtceu.api.machine.multiblock.PartAbility; +import com.gregtechceu.gtceu.api.pattern.FactoryBlockPattern; +import com.gregtechceu.gtceu.common.data.GTRecipeTypes; + +import static com.ghostipedia.cosmiccore.api.registries.CosmicRegistration.REGISTRATE; +import static com.ghostipedia.cosmiccore.common.data.CosmicBlocks.*; +import static com.gregtechceu.gtceu.api.pattern.Predicates.*; +import static com.gregtechceu.gtceu.common.data.models.GTMachineModels.createWorkableCasingMachineModel; + +public class DivingBell { + + public final static MultiblockMachineDefinition DIVING_BELL = REGISTRATE + .multiblock("diving_bell", DivingBellMachine::new) + .langValue("Diving Bell") + .rotationState(RotationState.NON_Y_AXIS) + .recipeType(GTRecipeTypes.DUMMY_RECIPES) + .appearanceBlock(REINFORCED_NAQUADRIA_CASING) + // spotless:off + .pattern(definition -> FactoryBlockPattern.start() + // Front row (all vertical layers bottom to top) + .aisle("CCC", "GGG", "GGG", "CCC", "CCC") + // Middle row (all vertical layers bottom to top) + .aisle("CQC", "G G", "G G", "C C", "C C") + // Back row (all vertical layers bottom to top) + .aisle("CCC", "GGG", "GGG", "CCC", "CCC") + .where(' ', any()) + .where('Q', controller(blocks(definition.getBlock()))) + .where('G', blocks(ZBLAN_REINFORCED_GLASS.get())) + .where('C', blocks(REINFORCED_NAQUADRIA_CASING.get()) + .or(abilities(PartAbility.INPUT_ENERGY).setMinGlobalLimited(1).setMaxGlobalLimited(2)) + .or(abilities(PartAbility.MAINTENANCE).setExactLimit(1))) + .build()) + // spotless:on + .model( + createWorkableCasingMachineModel( + CosmicCore.id("block/casings/solid/reinforced_naquadria_casing"), + GTCEu.id("block/multiblock/generator/large_gas_turbine"))) + .register(); + + public static void init() {} +} diff --git a/src/main/java/com/ghostipedia/cosmiccore/common/machine/multiblock/multi/LinkTestStation.java b/src/main/java/com/ghostipedia/cosmiccore/common/machine/multiblock/multi/LinkTestStation.java new file mode 100644 index 000000000..5b94e1908 --- /dev/null +++ b/src/main/java/com/ghostipedia/cosmiccore/common/machine/multiblock/multi/LinkTestStation.java @@ -0,0 +1,54 @@ +package com.ghostipedia.cosmiccore.common.machine.multiblock.multi; + +import com.ghostipedia.cosmiccore.common.machine.multiblock.multi.logic.LinkTestStationMachine; +import com.ghostipedia.cosmiccore.gtbridge.CosmicRecipeTypes; + +import com.gregtechceu.gtceu.GTCEu; +import com.gregtechceu.gtceu.api.data.RotationState; +import com.gregtechceu.gtceu.api.machine.MultiblockMachineDefinition; +import com.gregtechceu.gtceu.api.machine.multiblock.PartAbility; +import com.gregtechceu.gtceu.api.pattern.FactoryBlockPattern; +import com.gregtechceu.gtceu.common.data.GTBlocks; + +import net.minecraft.network.chat.Component; + +import static com.ghostipedia.cosmiccore.api.registries.CosmicRegistration.REGISTRATE; +import static com.gregtechceu.gtceu.api.pattern.Predicates.*; +import static com.gregtechceu.gtceu.common.data.models.GTMachineModels.createWorkableCasingMachineModel; + +/** + * Simple test multiblock for verifying cross-dimensional linking. + * Minimal 3x3x3 structure using steel casings. + */ +public class LinkTestStation { + + public final static MultiblockMachineDefinition LINK_TEST_STATION = REGISTRATE + .multiblock("link_test_station", LinkTestStationMachine::new) + .langValue("Link Test Station") + .tooltips( + Component.literal("Test multiblock for cross-dimensional linking"), + Component.literal("Use datastick: Shift+click to copy, click to link"), + Component.literal("Some recipes require linked partners")) + .rotationState(RotationState.NON_Y_AXIS) + .recipeType(CosmicRecipeTypes.LINK_TEST_RECIPES) + .appearanceBlock(GTBlocks.CASING_STEEL_SOLID) + .pattern(definition -> FactoryBlockPattern.start() + .aisle("CCC", "CCC", "CCC") + .aisle("CCC", "C C", "CCC") + .aisle("CCC", "CQC", "CCC") + .where(' ', any()) + .where('Q', controller(blocks(definition.getBlock()))) + .where('C', blocks(GTBlocks.CASING_STEEL_SOLID.get()) + .or(abilities(PartAbility.INPUT_ENERGY).setMinGlobalLimited(1).setMaxGlobalLimited(1)) + .or(abilities(PartAbility.MAINTENANCE).setExactLimit(1)) + .or(abilities(PartAbility.IMPORT_ITEMS).setMaxGlobalLimited(1)) + .or(abilities(PartAbility.EXPORT_ITEMS).setMaxGlobalLimited(1))) + .build()) + .model( + createWorkableCasingMachineModel( + GTCEu.id("block/casings/solid/machine_casing_solid_steel"), + GTCEu.id("block/multiblock/implosion_compressor"))) + .register(); + + public static void init() {} +} diff --git a/src/main/java/com/ghostipedia/cosmiccore/common/machine/multiblock/multi/MothCargoDropOff.java b/src/main/java/com/ghostipedia/cosmiccore/common/machine/multiblock/multi/MothCargoDropOff.java new file mode 100644 index 000000000..d2240c77d --- /dev/null +++ b/src/main/java/com/ghostipedia/cosmiccore/common/machine/multiblock/multi/MothCargoDropOff.java @@ -0,0 +1,55 @@ +package com.ghostipedia.cosmiccore.common.machine.multiblock.multi; + +import com.ghostipedia.cosmiccore.common.machine.multiblock.multi.logic.MothCargoDropOffMachine; + +import com.gregtechceu.gtceu.GTCEu; +import com.gregtechceu.gtceu.api.data.RotationState; +import com.gregtechceu.gtceu.api.machine.MultiblockMachineDefinition; +import com.gregtechceu.gtceu.api.machine.multiblock.PartAbility; +import com.gregtechceu.gtceu.api.pattern.FactoryBlockPattern; +import com.gregtechceu.gtceu.common.data.GTRecipeTypes; + +import net.minecraft.network.chat.Component; + +import static com.ghostipedia.cosmiccore.api.registries.CosmicRegistration.REGISTRATE; +import static com.gregtechceu.gtceu.api.pattern.Predicates.*; +import static com.gregtechceu.gtceu.common.data.GTBlocks.CASING_STEEL_SOLID; + +/** + * Moth Cargo Drop Off - Receiver multiblock for the Cargo Moths system. + * Receives items and fluids from linked Moth Cargo Stations. + * Small, compact design for easy placement at outposts. + */ +public class MothCargoDropOff { + + public static final MultiblockMachineDefinition MOTH_CARGO_DROP_OFF = REGISTRATE + .multiblock("moth_cargo_drop_off", MothCargoDropOffMachine::new) + .langValue("Moth Cargo Drop Off") + .tooltips( + Component.literal("Receives shipments from Moth Cargo Stations"), + Component.literal("Link to stations with a datastick"), + Component.literal("Small footprint for easy outpost placement")) + .rotationState(RotationState.NON_Y_AXIS) + .recipeType(GTRecipeTypes.DUMMY_RECIPES) + .appearanceBlock(CASING_STEEL_SOLID) + // spotless:off + .pattern(definition -> FactoryBlockPattern.start() + // Compact 3x3x3 structure + .aisle("CCC", "CCC", "C C") + .aisle("CCC", "C C", " ") + .aisle("CCC", "CQC", "C C") + .where(' ', any()) + .where('Q', controller(blocks(definition.getBlock()))) + .where('C', blocks(CASING_STEEL_SOLID.get()) + .or(abilities(PartAbility.MAINTENANCE).setExactLimit(1)) + .or(abilities(PartAbility.EXPORT_ITEMS).setMaxGlobalLimited(4)) + .or(abilities(PartAbility.EXPORT_FLUIDS).setMaxGlobalLimited(4))) + .build()) + // spotless:on + .workableCasingModel( + GTCEu.id("block/casings/solid/machine_casing_solid_steel"), + GTCEu.id("block/multiblock/implosion_compressor")) + .register(); + + public static void init() {} +} diff --git a/src/main/java/com/ghostipedia/cosmiccore/common/machine/multiblock/multi/MothCargoStation.java b/src/main/java/com/ghostipedia/cosmiccore/common/machine/multiblock/multi/MothCargoStation.java new file mode 100644 index 000000000..e02ac4067 --- /dev/null +++ b/src/main/java/com/ghostipedia/cosmiccore/common/machine/multiblock/multi/MothCargoStation.java @@ -0,0 +1,83 @@ +package com.ghostipedia.cosmiccore.common.machine.multiblock.multi; + +import com.ghostipedia.cosmiccore.api.pattern.CosmicPredicates; +import com.ghostipedia.cosmiccore.common.machine.multiblock.multi.logic.MothCargoStationMachine; + +import com.gregtechceu.gtceu.GTCEu; +import com.gregtechceu.gtceu.api.data.RotationState; +import com.gregtechceu.gtceu.api.machine.MultiblockMachineDefinition; +import com.gregtechceu.gtceu.api.machine.multiblock.PartAbility; +import com.gregtechceu.gtceu.api.pattern.FactoryBlockPattern; +import com.gregtechceu.gtceu.common.data.GTRecipeTypes; + +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.state.BlockState; + +import static com.ghostipedia.cosmiccore.api.registries.CosmicRegistration.REGISTRATE; +import static com.gregtechceu.gtceu.api.pattern.Predicates.*; +import static com.gregtechceu.gtceu.common.data.GTBlocks.CASING_STEEL_SOLID; + +/** + * Moth Cargo Station - Sender multiblock for the Cargo Moths system. + * Ships items and fluids to linked Moth Cargo Drop Off stations. + */ +public class MothCargoStation { + + // Forestry beehive blocks used as moth homes + public static final ResourceLocation BEEHIVE_FOREST = new ResourceLocation("forestry", "beehive_forest"); + public static final ResourceLocation BEEHIVE_LUSH = new ResourceLocation("forestry", "beehive_lush"); + public static final ResourceLocation BEEHIVE_DESERT = new ResourceLocation("forestry", "beehive_desert"); + public static final ResourceLocation BEEHIVE_END = new ResourceLocation("forestry", "beehive_end"); + + /** + * Check if a block is a valid moth home (any tier of Forestry beehive). + */ + public static boolean isMothHome(BlockState state) { + Block block = state.getBlock(); + ResourceLocation blockId = BuiltInRegistries.BLOCK.getKey(block); + return blockId.equals(BEEHIVE_FOREST) || + blockId.equals(BEEHIVE_LUSH) || + blockId.equals(BEEHIVE_DESERT) || + blockId.equals(BEEHIVE_END); + } + + public static final MultiblockMachineDefinition MOTH_CARGO_STATION = REGISTRATE + .multiblock("moth_cargo_station", MothCargoStationMachine::new) + .langValue("Moth Cargo Station") + .tooltips( + Component.literal("Ships items and fluids using cargo moths"), + Component.literal("Link to Moth Cargo Drop Offs with a datastick"), + Component.literal("Add Moth Homes to increase capacity and speed"), + Component.literal("Feed moths honey or pale oil for bonuses!")) + .rotationState(RotationState.NON_Y_AXIS) + .recipeType(GTRecipeTypes.DUMMY_RECIPES) + .appearanceBlock(CASING_STEEL_SOLID) + // spotless:off + .pattern(definition -> FactoryBlockPattern.start() + // Tower structure: 3x3 footprint, 6 blocks tall + // Moth homes (beehives) in center column - up to 4 can be placed + // Open walls (air in center) so beehives are visible from all sides + .aisle("CCC", "C C", "C C", "C C", "C C", "CCC") + .aisle("CCC", " M ", " M ", " M ", " M ", "CCC") + .aisle("CQC", "C C", "C C", "C C", "C C", "CCC") + .where(' ', any()) + .where('Q', controller(blocks(definition.getBlock()))) + .where('C', blocks(CASING_STEEL_SOLID.get()) + .or(abilities(PartAbility.MAINTENANCE).setExactLimit(1)) + .or(abilities(PartAbility.IMPORT_ITEMS).setMaxGlobalLimited(4)) + .or(abilities(PartAbility.EXPORT_ITEMS).setMaxGlobalLimited(4)) + .or(abilities(PartAbility.IMPORT_FLUIDS).setMaxGlobalLimited(4)) + .or(abilities(PartAbility.EXPORT_FLUIDS).setMaxGlobalLimited(4))) + .where('M', CosmicPredicates.mothHomes()) + .build()) + // spotless:on + .workableCasingModel( + GTCEu.id("block/casings/solid/machine_casing_solid_steel"), + GTCEu.id("block/multiblock/implosion_compressor")) + .register(); + + public static void init() {} +} diff --git a/src/main/java/com/ghostipedia/cosmiccore/common/machine/multiblock/multi/logic/DivingBellMachine.java b/src/main/java/com/ghostipedia/cosmiccore/common/machine/multiblock/multi/logic/DivingBellMachine.java new file mode 100644 index 000000000..3643535e0 --- /dev/null +++ b/src/main/java/com/ghostipedia/cosmiccore/common/machine/multiblock/multi/logic/DivingBellMachine.java @@ -0,0 +1,215 @@ +package com.ghostipedia.cosmiccore.common.machine.multiblock.multi.logic; + +import com.ghostipedia.cosmiccore.common.data.CosmicBlocks; +import com.ghostipedia.cosmiccore.common.teleporter.LandingZoneHelper; +import com.ghostipedia.cosmiccore.common.teleporter.SafeTeleporter; +import com.ghostipedia.cosmiccore.common.teleporter.TeleportOriginCap; +import com.ghostipedia.cosmiccore.common.teleporter.TeleportPadRegistry; + +import com.gregtechceu.gtceu.api.machine.IMachineBlockEntity; +import com.gregtechceu.gtceu.api.machine.TickableSubscription; +import com.gregtechceu.gtceu.api.machine.multiblock.WorkableElectricMultiblockMachine; + +import com.lowdragmc.lowdraglib.syncdata.annotation.Persisted; +import com.lowdragmc.lowdraglib.syncdata.field.ManagedFieldHolder; + +import net.minecraft.core.BlockPos; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.ResourceKey; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.phys.AABB; + +import org.jetbrains.annotations.NotNull; + +import java.util.List; + +/** + * Diving Bell Machine Controller + * + * Detects players standing directly on top of the controller block and teleports them to the Deep Below dimension. + * - Energy cost: 500,000 EU per teleport + * - Cooldown: 100 ticks (5 seconds) + * - Only teleports one player per activation + */ +public class DivingBellMachine extends WorkableElectricMultiblockMachine { + + protected static final ManagedFieldHolder MANAGED_FIELD_HOLDER = new ManagedFieldHolder( + DivingBellMachine.class, + WorkableElectricMultiblockMachine.MANAGED_FIELD_HOLDER); + + // Configuration values + private static final int TELEPORT_COST_EU = 500000; // 500k EU per teleport + private static final int COOLDOWN_TICKS = 100; // 5 seconds - idk just in case players try to abuse it. + + // Teleport destination settings + private static final String TARGET_DIMENSION = "frontiers:the_deep_below"; // Dimension to teleport to + private static final int DESTINATION_SEARCH_START_Y = 100; // Y level to start searching for safe ground + + // State + @Persisted + private int cooldownRemaining = 0; + + protected TickableSubscription tickSubscription; + + public DivingBellMachine(IMachineBlockEntity holder, Object... args) { + super(holder, args); + } + + @Override + @NotNull + public ManagedFieldHolder getFieldHolder() { + return MANAGED_FIELD_HOLDER; + } + + @Override + public void onStructureFormed() { + super.onStructureFormed(); + // Subscribe to server ticks when structure forms + tickSubscription = subscribeServerTick(tickSubscription, this::checkForPlayers); + } + + @Override + public void onStructureInvalid() { + super.onStructureInvalid(); + // Unsubscribe when structure breaks + if (tickSubscription != null) { + tickSubscription.unsubscribe(); + tickSubscription = null; + } + } + + // Tick handler that checks for players and teleports them. + private void checkForPlayers() { + // Decrement cooldown + if (cooldownRemaining > 0) { + cooldownRemaining--; + return; + } + + // Check if we have enough energy + if (!hasEnoughEnergy()) { + return; + } + + // Detect players on top platform + List players = getPlayersOnPlatform(); + if (players.isEmpty()) { + return; + } + + // Teleport first player only (one at a time) + ServerPlayer target = players.get(0); + if (teleportPlayerToDeepBelow(target)) { + // Consume energy and start cooldown + consumeEnergy(TELEPORT_COST_EU); + cooldownRemaining = COOLDOWN_TICKS; + } + } + + // Check if there is enough energy for a teleport. + private boolean hasEnoughEnergy() { + long available = energyContainer.getEnergyStored(); + return available >= TELEPORT_COST_EU; + } + + // Consume energy for teleportation. + private void consumeEnergy(long amount) { + energyContainer.removeEnergy(amount); + } + + // Get all players standing directly on top of the controller block. + private List getPlayersOnPlatform() { + if (!(getLevel() instanceof ServerLevel serverLevel)) { + return List.of(); + } + + // Detection zone is 1 block directly above the controller + BlockPos controllerPos = getPos(); + BlockPos detectionPos = controllerPos.above(1); + + // 1x1 detection area (requires players to stand on top of the controller) + AABB detectionZone = new AABB( + detectionPos, + detectionPos.offset(1, 1, 1)); + + return serverLevel.getEntitiesOfClass(ServerPlayer.class, detectionZone); + } + + // Teleport a player to the Deep Below dimension. + private boolean teleportPlayerToDeepBelow(ServerPlayer player) { + if (!(getLevel() instanceof ServerLevel currentLevel)) { + return false; + } + + // Save origin data to player capability + player.getCapability(TeleportOriginCap.CAP).ifPresent(cap -> { + cap.setOriginDimension(currentLevel.dimension()); + cap.setOriginPosition(player.position()); + cap.setOriginRotation(player.getYRot(), player.getXRot()); + }); + + // Get Deep Below dimension + ResourceKey targetDim = getTargetDimension(); + ServerLevel deepBelow = player.server.getLevel(targetDim); + + if (deepBelow == null) { + player.displayClientMessage( + Component.translatable("cosmiccore.divingbell.dimension_missing"), true); + return false; + } + + // Find or create safe landing + BlockPos landingPos = getOrCreateSafeLanding(deepBelow, player); + + // Teleport (SafeTeleporter handles safety effects) + player.changeDimension(deepBelow, new SafeTeleporter(landingPos)); + + // Success message + player.displayClientMessage( + Component.translatable("cosmiccore.divingbell.descended"), true); + + return true; + } + + // Get the target dimension (Deep Below). + private ResourceKey getTargetDimension() { + ResourceLocation dimLoc = new ResourceLocation(TARGET_DIMENSION); + return ResourceKey.create(net.minecraft.core.registries.Registries.DIMENSION, dimLoc); + } + + // Find or create a safe landing platform in the Deep Below. + private BlockPos getOrCreateSafeLanding(ServerLevel deepBelow, ServerPlayer player) { + TeleportPadRegistry registry = TeleportPadRegistry.get(deepBelow); + + // Landing pad uses same X/Z as player's current position (vertical teleport) + BlockPos currentPos = player.blockPosition(); + int targetX = currentPos.getX(); + int targetZ = currentPos.getZ(); + + // Find safe Y level for this X/Z position + BlockPos safePos = LandingZoneHelper.findSafeYLevel(deepBelow, targetX, targetZ, DESTINATION_SEARCH_START_Y); + + // Check if escape pad already exists at this position + if (registry.hasPadAt(safePos) && + LandingZoneHelper.isPadIntact(deepBelow, safePos, CosmicBlocks.DIVING_BELL_ESCAPE_PAD.get())) { + // Reuse existing pad + return safePos; + } + + // Need to create new platform + LandingZoneHelper.buildPlatform(deepBelow, safePos, new LandingZoneHelper.PlatformOptions( + Blocks.STONE, + CosmicBlocks.DIVING_BELL_ESCAPE_PAD.get(), + 1 // 3x3 platform + )); + + // Register in saved data + registry.registerPad(safePos); + + return safePos; + } +} diff --git a/src/main/java/com/ghostipedia/cosmiccore/common/machine/multiblock/multi/logic/LinkTestStationMachine.java b/src/main/java/com/ghostipedia/cosmiccore/common/machine/multiblock/multi/logic/LinkTestStationMachine.java new file mode 100644 index 000000000..eff877628 --- /dev/null +++ b/src/main/java/com/ghostipedia/cosmiccore/common/machine/multiblock/multi/logic/LinkTestStationMachine.java @@ -0,0 +1,114 @@ +package com.ghostipedia.cosmiccore.common.machine.multiblock.multi.logic; + +import com.ghostipedia.cosmiccore.CosmicCore; +import com.ghostipedia.cosmiccore.api.capability.ILinkedMultiblock; +import com.ghostipedia.cosmiccore.api.machine.multiblock.LinkedWorkableElectricMultiblockMachine; + +import com.gregtechceu.gtceu.api.machine.IMachineBlockEntity; + +import com.lowdragmc.lowdraglib.syncdata.field.ManagedFieldHolder; + +import net.minecraft.ChatFormatting; +import net.minecraft.core.GlobalPos; +import net.minecraft.network.chat.Component; + +import java.util.List; +import java.util.Set; + +/** + * Simple test multiblock for verifying cross-dimensional linking. + * Displays linked partners in UI and logs link events. + */ +public class LinkTestStationMachine extends LinkedWorkableElectricMultiblockMachine { + + protected static final ManagedFieldHolder MANAGED_FIELD_HOLDER = new ManagedFieldHolder( + LinkTestStationMachine.class, + LinkedWorkableElectricMultiblockMachine.MANAGED_FIELD_HOLDER); + + public LinkTestStationMachine(IMachineBlockEntity holder, Object... args) { + super(holder, args); + } + + @Override + public ManagedFieldHolder getFieldHolder() { + return MANAGED_FIELD_HOLDER; + } + + // ==================== Link Configuration ==================== + + @Override + public LinkRole getLinkRole() { + // Test station is a peer - can link bidirectionally + return LinkRole.PEER; + } + + @Override + public int getMaxPartners() { + // Allow up to 4 partners for testing + return 4; + } + + @Override + public boolean canLinkTo(GlobalPos partner, ILinkedMultiblock partnerMachine) { + // Accept links from any ILinkedMultiblock for testing + return true; + } + + // ==================== Link Lifecycle ==================== + + @Override + public void onLinkEstablished(GlobalPos partner) { + super.onLinkEstablished(partner); + CosmicCore.LOGGER.info("[LinkTestStation] Link established to {} in {}", + partner.pos(), partner.dimension().location()); + } + + @Override + public void onLinkBroken(GlobalPos partner) { + super.onLinkBroken(partner); + CosmicCore.LOGGER.info("[LinkTestStation] Link broken to {} in {}", + partner.pos(), partner.dimension().location()); + } + + // ==================== Display ==================== + + @Override + public void addDisplayText(List textList) { + super.addDisplayText(textList); + + if (!isFormed()) return; + + // Show link status + Set partners = getLinkedPartners(); + if (partners.isEmpty()) { + textList.add(Component.literal("No linked partners") + .withStyle(ChatFormatting.GRAY)); + textList.add(Component.literal("Use datastick to link") + .withStyle(ChatFormatting.DARK_GRAY)); + } else { + textList.add(Component.literal("Linked Partners: " + partners.size()) + .withStyle(ChatFormatting.GREEN)); + + for (GlobalPos partner : partners) { + String dim = partner.dimension().location().toString(); + String pos = String.format("[%d, %d, %d]", + partner.pos().getX(), + partner.pos().getY(), + partner.pos().getZ()); + + // Show if partner is online + boolean online = getPartnerMachine(partner) != null; + ChatFormatting color = online ? ChatFormatting.GREEN : ChatFormatting.RED; + String status = online ? "[+]" : "[-]"; + + textList.add(Component.literal(" " + status + " " + dim + " " + pos) + .withStyle(color)); + } + } + + // Show effective roles + LinkRole myRole = getLinkRole(); + textList.add(Component.literal("Role: " + myRole.name()) + .withStyle(ChatFormatting.AQUA)); + } +} diff --git a/src/main/java/com/ghostipedia/cosmiccore/common/machine/multiblock/multi/logic/MothCargoDropOffMachine.java b/src/main/java/com/ghostipedia/cosmiccore/common/machine/multiblock/multi/logic/MothCargoDropOffMachine.java new file mode 100644 index 000000000..222eaf65a --- /dev/null +++ b/src/main/java/com/ghostipedia/cosmiccore/common/machine/multiblock/multi/logic/MothCargoDropOffMachine.java @@ -0,0 +1,140 @@ +package com.ghostipedia.cosmiccore.common.machine.multiblock.multi.logic; + +import com.ghostipedia.cosmiccore.api.capability.ILinkedMultiblock; +import com.ghostipedia.cosmiccore.api.machine.multiblock.LinkedWorkableMultiblockMachine; + +import com.gregtechceu.gtceu.api.capability.recipe.FluidRecipeCapability; +import com.gregtechceu.gtceu.api.capability.recipe.IO; +import com.gregtechceu.gtceu.api.capability.recipe.ItemRecipeCapability; +import com.gregtechceu.gtceu.api.machine.IMachineBlockEntity; +import com.gregtechceu.gtceu.api.machine.trait.NotifiableFluidTank; +import com.gregtechceu.gtceu.api.machine.trait.NotifiableItemStackHandler; + +import com.lowdragmc.lowdraglib.syncdata.field.ManagedFieldHolder; + +import net.minecraft.ChatFormatting; +import net.minecraft.core.GlobalPos; +import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.Style; +import net.minecraftforge.fluids.capability.IFluidHandler; +import net.minecraftforge.items.IItemHandler; + +import java.util.ArrayList; +import java.util.List; + +/** + * Moth Cargo Drop Off - The "receiver" multiblock for the Cargo Moths system. + *

+ * Receives items and fluids from linked Moth Cargo Stations. + * Does NOT require power - just a place for moths to land! + *

+ * This is a simple, compact multiblock designed for easy placement at outposts. + */ +public class MothCargoDropOffMachine extends LinkedWorkableMultiblockMachine { + + protected static final ManagedFieldHolder MANAGED_FIELD_HOLDER = new ManagedFieldHolder( + MothCargoDropOffMachine.class, + LinkedWorkableMultiblockMachine.MANAGED_FIELD_HOLDER); + + // ==================== Constructor ==================== + + public MothCargoDropOffMachine(IMachineBlockEntity holder, Object... args) { + super(holder, args); + } + + @Override + public ManagedFieldHolder getFieldHolder() { + return MANAGED_FIELD_HOLDER; + } + + // ==================== Linking Overrides ==================== + + @Override + public LinkRole getLinkRole() { + // Drop Off is REMOTE - it receives from Stations but doesn't initiate + return LinkRole.REMOTE; + } + + @Override + public int getMaxPartners() { + // Can receive from multiple stations (N:1 support) + return 16; + } + + @Override + public boolean canLinkTo(GlobalPos partner, ILinkedMultiblock partnerMachine) { + // Only link to Cargo Stations + if (!(partnerMachine instanceof MothCargoStationMachine)) { + return false; + } + + // Same dimension only + GlobalPos myPos = getGlobalPos(); + if (myPos == null) return false; + + return myPos.dimension().equals(partner.dimension()); + } + + // ==================== Handler Access ==================== + + /** + * Get all item output handlers from the multiblock. + * Called by MothCargoStationMachine to insert items. + */ + public List getItemOutputHandlers() { + List handlers = new ArrayList<>(); + + var itemCaps = getCapabilitiesFlat(IO.OUT, ItemRecipeCapability.CAP); + if (itemCaps != null) { + for (var handler : itemCaps) { + if (handler instanceof NotifiableItemStackHandler itemHandler) { + handlers.add(itemHandler); + } + } + } + + return handlers; + } + + /** + * Get all fluid output handlers from the multiblock. + * Called by MothCargoStationMachine to insert fluids. + */ + public List getFluidOutputHandlers() { + List handlers = new ArrayList<>(); + + var fluidCaps = getCapabilitiesFlat(IO.OUT, FluidRecipeCapability.CAP); + if (fluidCaps != null) { + for (var handler : fluidCaps) { + if (handler instanceof NotifiableFluidTank fluidHandler) { + handlers.add(fluidHandler); + } + } + } + + return handlers; + } + + // ==================== UI ==================== + + @Override + public void addDisplayText(List textList) { + if (!isFormed()) { + textList.add(Component.literal("Structure not formed") + .setStyle(Style.EMPTY.withColor(ChatFormatting.RED))); + return; + } + + // Linked stations + int linkedCount = getLinkedPartners().size(); + if (linkedCount > 0) { + textList.add(Component.literal("Receiving from " + linkedCount + " station(s)") + .setStyle(Style.EMPTY.withColor(ChatFormatting.GREEN))); + } else { + textList.add(Component.literal("No stations linked!") + .setStyle(Style.EMPTY.withColor(ChatFormatting.RED))); + textList.add(Component.literal("Use a datastick to link to a Moth Cargo Station") + .setStyle(Style.EMPTY.withColor(ChatFormatting.GRAY))); + } + } +} diff --git a/src/main/java/com/ghostipedia/cosmiccore/common/machine/multiblock/multi/logic/MothCargoStationMachine.java b/src/main/java/com/ghostipedia/cosmiccore/common/machine/multiblock/multi/logic/MothCargoStationMachine.java new file mode 100644 index 000000000..1ae96a178 --- /dev/null +++ b/src/main/java/com/ghostipedia/cosmiccore/common/machine/multiblock/multi/logic/MothCargoStationMachine.java @@ -0,0 +1,657 @@ +package com.ghostipedia.cosmiccore.common.machine.multiblock.multi.logic; + +import com.ghostipedia.cosmiccore.CosmicCore; +import com.ghostipedia.cosmiccore.api.capability.ILinkedMultiblock; +import com.ghostipedia.cosmiccore.api.machine.multiblock.LinkedWorkableMultiblockMachine; +import com.ghostipedia.cosmiccore.common.machine.multiblock.LinkedMultiblockHelper; +import com.ghostipedia.cosmiccore.common.machine.multiblock.multi.MothCargoStation; + +import com.gregtechceu.gtceu.api.capability.recipe.FluidRecipeCapability; +import com.gregtechceu.gtceu.api.capability.recipe.IO; +import com.gregtechceu.gtceu.api.capability.recipe.ItemRecipeCapability; +import com.gregtechceu.gtceu.api.machine.IMachineBlockEntity; +import com.gregtechceu.gtceu.api.machine.MetaMachine; +import com.gregtechceu.gtceu.api.machine.TickableSubscription; +import com.gregtechceu.gtceu.api.machine.trait.NotifiableFluidTank; +import com.gregtechceu.gtceu.api.machine.trait.NotifiableItemStackHandler; + +import com.lowdragmc.lowdraglib.syncdata.annotation.DescSynced; +import com.lowdragmc.lowdraglib.syncdata.annotation.Persisted; +import com.lowdragmc.lowdraglib.syncdata.field.ManagedFieldHolder; + +import net.minecraft.ChatFormatting; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.core.GlobalPos; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.Style; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.phys.BlockHitResult; +import net.minecraftforge.fluids.FluidStack; +import net.minecraftforge.fluids.capability.IFluidHandler; +import net.minecraftforge.items.IItemHandler; + +import lombok.Getter; +import lombok.Setter; + +import java.util.*; + +/** + * Moth Cargo Station - The "sender" multiblock for the Cargo Moths system. + *

+ * Ships items and fluids to linked Moth Cargo Drop Off stations using moths. + * Does NOT require power - just moths! + *

+ * Features: + *

    + *
  • Cycle-based shipping (configurable via moth home tiers)
  • + *
  • Multiple distribution modes (1:1, 1:N fill first, 1:N round robin, N:1)
  • + *
  • Feeding bonuses from honey/oil
  • + *
  • Same-dimension only linking
  • + *
+ */ +public class MothCargoStationMachine extends LinkedWorkableMultiblockMachine { + + protected static final ManagedFieldHolder MANAGED_FIELD_HOLDER = new ManagedFieldHolder( + MothCargoStationMachine.class, + LinkedWorkableMultiblockMachine.MANAGED_FIELD_HOLDER); + + // ==================== Constants ==================== + + /** Base items per moth per cycle (1 stack) */ + public static final int BASE_ITEMS_PER_MOTH = 64; + /** Base fluid per moth per cycle (1000 mB) */ + public static final int BASE_FLUID_PER_MOTH = 1000; + + /** Cycle times in ticks per tier (T1=60s, T2=30s, T3=15s, T4=5s) */ + public static final int[] CYCLE_TICKS_BY_TIER = { 1200, 600, 300, 100 }; + /** Moths per home by tier (T1=1, T2=2, T3=4, T4=8) */ + public static final int[] MOTHS_PER_HOME_BY_TIER = { 1, 2, 4, 8 }; + /** Max moth homes per station */ + public static final int MAX_MOTH_HOMES = 5; + + /** Feeding multipliers */ + public static final int MULTIPLIER_REGULAR_HONEY = 2; + public static final int MULTIPLIER_LOFTY_HONEY = 4; + public static final int MULTIPLIER_PALE_OIL = 8; + + // ==================== Distribution Modes ==================== + + public enum DistributionMode { + /** Direct 1:1 transfer to single receiver */ + DIRECT, + /** Fill receivers in order until full, then move to next */ + FILL_FIRST, + /** Distribute equally across all receivers (round robin) */ + ROUND_ROBIN + } + + // ==================== State ==================== + + @Persisted + @DescSynced + @Getter + @Setter + private DistributionMode distributionMode = DistributionMode.FILL_FIRST; + + @Persisted + @Getter + private int mothHomeTier = 1; // 1-4 + + @Persisted + @Getter + private int mothHomeCount = 0; // 0-5 + + @Persisted + private int ticksSinceLastCycle = 0; + + @Persisted + private int roundRobinIndex = 0; + + @Persisted + @DescSynced + @Getter + private int currentFeedingMultiplier = 1; + + private TickableSubscription shippingSubscription; + + // ==================== Constructor ==================== + + public MothCargoStationMachine(IMachineBlockEntity holder, Object... args) { + super(holder, args); + } + + @Override + public ManagedFieldHolder getFieldHolder() { + return MANAGED_FIELD_HOLDER; + } + + // ==================== Linking Overrides ==================== + + @Override + public LinkRole getLinkRole() { + // Station is the CONTROLLER - it initiates transfers to Drop Offs + return LinkRole.CONTROLLER; + } + + @Override + public int getMaxPartners() { + // Can link to multiple drop-off points + return 16; + } + + @Override + public boolean canLinkTo(GlobalPos partner, ILinkedMultiblock partnerMachine) { + // Only link to Drop Off stations + if (!(partnerMachine instanceof MothCargoDropOffMachine)) { + return false; + } + + // Same dimension only + GlobalPos myPos = getGlobalPos(); + if (myPos == null) return false; + + return myPos.dimension().equals(partner.dimension()); + } + + // ==================== Lifecycle ==================== + + @Override + public void onStructureFormed() { + super.onStructureFormed(); + + // Scan for moth homes in structure + scanForMothHomes(); + + subscribeToShipping(); + } + + @Override + public void onStructureInvalid() { + super.onStructureInvalid(); + unsubscribeFromShipping(); + // Reset moth home stats + mothHomeTier = 0; + mothHomeCount = 0; + } + + @Override + public void onUnload() { + super.onUnload(); + unsubscribeFromShipping(); + } + + /** + * Scan the multiblock structure for Forestry beehive blocks (used as moth homes). + * Sets mothHomeTier and mothHomeCount based on what's found. + * All moth homes must be the same tier. + * + * Tier mapping: + * T1: forestry:beehive_forest + * T2: forestry:beehive_lush + * T3: forestry:beehive_desert + * T4: forestry:beehive_end + */ + private void scanForMothHomes() { + Level level = getLevel(); + if (level == null) { + mothHomeTier = 0; + mothHomeCount = 0; + return; + } + + BlockPos controllerPos = getPos(); + int foundTier = 0; + int foundCount = 0; + boolean mixedTiers = false; + + // Scan a 7x7x7 region around the controller (covers 5x5x5 structure plus margin) + int scanRadius = 3; + for (int x = -scanRadius; x <= scanRadius; x++) { + for (int y = -scanRadius; y <= scanRadius; y++) { + for (int z = -scanRadius; z <= scanRadius; z++) { + BlockPos checkPos = controllerPos.offset(x, y, z); + Block block = level.getBlockState(checkPos).getBlock(); + ResourceLocation blockId = BuiltInRegistries.BLOCK.getKey(block); + + int tier = getBeehiveTier(blockId); + if (tier > 0) { + if (foundCount == 0) { + // First moth home found - set the tier + foundTier = tier; + foundCount = 1; + } else if (tier == foundTier) { + // Same tier - count it + foundCount++; + } else { + // Mixed tiers detected + mixedTiers = true; + } + } + } + } + } + + // Enforce same-tier requirement + if (mixedTiers) { + CosmicCore.LOGGER.warn("Moth Cargo Station at {} has mixed tier moth homes - using lowest functionality", + controllerPos); + // Still use what we found, but warn + } + + // Cap at max moth homes + foundCount = Math.min(foundCount, MAX_MOTH_HOMES); + + mothHomeTier = foundTier; + mothHomeCount = foundCount; + + CosmicCore.LOGGER.debug("Moth Cargo Station at {} found {} T{} moth homes", + controllerPos, mothHomeCount, mothHomeTier); + } + + /** + * Get the tier of a beehive block by its registry name. + * + * @return tier (1-4) or 0 if not a valid beehive + */ + private int getBeehiveTier(ResourceLocation blockId) { + if (blockId.equals(MothCargoStation.BEEHIVE_FOREST)) return 1; + if (blockId.equals(MothCargoStation.BEEHIVE_LUSH)) return 2; + if (blockId.equals(MothCargoStation.BEEHIVE_DESERT)) return 3; + if (blockId.equals(MothCargoStation.BEEHIVE_END)) return 4; + return 0; + } + + private void subscribeToShipping() { + if (shippingSubscription == null) { + shippingSubscription = subscribeServerTick(this::onShippingTick); + } + } + + private void unsubscribeFromShipping() { + if (shippingSubscription != null) { + shippingSubscription.unsubscribe(); + shippingSubscription = null; + } + } + + // ==================== Shipping Logic ==================== + + private void onShippingTick() { + if (!isFormed() || getLevel() == null || getLevel().isClientSide()) { + return; + } + + // Check if we have moths + if (mothHomeCount <= 0) { + return; + } + + ticksSinceLastCycle++; + + int cycleTime = getCycleTimeTicks(); + if (ticksSinceLastCycle >= cycleTime) { + ticksSinceLastCycle = 0; + performShippingCycle(); + } + } + + /** + * Get the cycle time in ticks based on moth home tier. + */ + public int getCycleTimeTicks() { + int tierIndex = Math.max(0, Math.min(mothHomeTier - 1, CYCLE_TICKS_BY_TIER.length - 1)); + return CYCLE_TICKS_BY_TIER[tierIndex]; + } + + /** + * Get total moths available for shipping. + */ + public int getTotalMoths() { + int tierIndex = Math.max(0, Math.min(mothHomeTier - 1, MOTHS_PER_HOME_BY_TIER.length - 1)); + return mothHomeCount * MOTHS_PER_HOME_BY_TIER[tierIndex]; + } + + /** + * Get capacity per cycle (items or mB). + */ + public int getCapacityPerCycle(boolean isFluid) { + int baseCapacity = isFluid ? BASE_FLUID_PER_MOTH : BASE_ITEMS_PER_MOTH; + return getTotalMoths() * baseCapacity * currentFeedingMultiplier; + } + + /** + * Perform one shipping cycle - transfer items/fluids to linked drop-offs. + */ + private void performShippingCycle() { + Set partners = getLinkedPartners(); + if (partners.isEmpty()) { + return; + } + + // Get list of valid, formed drop-off partners + List dropOffs = getActiveDropOffs(partners); + if (dropOffs.isEmpty()) { + return; + } + + // Calculate capacity for this cycle + int itemCapacity = getCapacityPerCycle(false); + int fluidCapacity = getCapacityPerCycle(true); + + // Ship items + shipItems(dropOffs, itemCapacity); + + // Ship fluids + shipFluids(dropOffs, fluidCapacity); + + // Consume feeding materials (TODO) + consumeFeedingMaterials(); + } + + /** + * Get active (formed and loaded) drop-off machines from partner list. + */ + private List getActiveDropOffs(Set partners) { + List result = new ArrayList<>(); + + if (!(getLevel() instanceof ServerLevel serverLevel)) { + return result; + } + + for (GlobalPos partner : partners) { + MetaMachine machine = LinkedMultiblockHelper.getMachine(serverLevel.getServer(), partner); + if (machine instanceof MothCargoDropOffMachine dropOff && dropOff.isFormed()) { + result.add(dropOff); + } + } + + return result; + } + + /** + * Ship items to drop-offs based on distribution mode. + */ + private void shipItems(List dropOffs, int maxItems) { + // Get our input items + List inputHandlers = getItemInputHandlers(); + if (inputHandlers.isEmpty()) { + return; + } + + int remainingCapacity = maxItems; + + switch (distributionMode) { + case DIRECT -> { + // Ship to first drop-off only + if (!dropOffs.isEmpty()) { + remainingCapacity = shipItemsToDropOff(dropOffs.get(0), inputHandlers, remainingCapacity); + } + } + case FILL_FIRST -> { + // Fill each drop-off in order until capacity exhausted + for (MothCargoDropOffMachine dropOff : dropOffs) { + if (remainingCapacity <= 0) break; + remainingCapacity = shipItemsToDropOff(dropOff, inputHandlers, remainingCapacity); + } + } + case ROUND_ROBIN -> { + // Distribute items evenly starting from round robin index + int perDropOff = Math.max(1, remainingCapacity / dropOffs.size()); + for (int i = 0; i < dropOffs.size() && remainingCapacity > 0; i++) { + int index = (roundRobinIndex + i) % dropOffs.size(); + int toShip = Math.min(perDropOff, remainingCapacity); + int shipped = shipItemsToDropOff(dropOffs.get(index), inputHandlers, toShip); + remainingCapacity -= (toShip - shipped); + } + roundRobinIndex = (roundRobinIndex + 1) % dropOffs.size(); + } + } + } + + /** + * Ship items to a single drop-off, returns remaining capacity. + */ + private int shipItemsToDropOff(MothCargoDropOffMachine dropOff, List sources, int maxItems) { + List destHandlers = dropOff.getItemOutputHandlers(); + if (destHandlers.isEmpty()) { + return maxItems; + } + + int remaining = maxItems; + + for (IItemHandler source : sources) { + if (!(source instanceof NotifiableItemStackHandler sourceNotifiable)) { + continue; + } + + for (int slot = 0; slot < source.getSlots() && remaining > 0; slot++) { + // Use internal extract to bypass IO check + ItemStack stack = sourceNotifiable.extractItemInternal(slot, remaining, true); // Simulate + if (stack.isEmpty()) continue; + + // Try to insert into destination using internal method + ItemStack toInsert = stack.copy(); + int originalCount = toInsert.getCount(); + + for (IItemHandler dest : destHandlers) { + if (dest instanceof NotifiableItemStackHandler destNotifiable) { + // Use insertItemInternal which bypasses the IO check + for (int destSlot = 0; destSlot < dest.getSlots() && !toInsert.isEmpty(); destSlot++) { + toInsert = destNotifiable.insertItemInternal(destSlot, toInsert, false); + } + } else { + // Fallback to standard insertion + for (int destSlot = 0; destSlot < dest.getSlots() && !toInsert.isEmpty(); destSlot++) { + toInsert = dest.insertItem(destSlot, toInsert, false); + } + } + if (toInsert.isEmpty()) break; + } + + // Actually extract what we inserted + int inserted = originalCount - toInsert.getCount(); + if (inserted > 0) { + sourceNotifiable.extractItemInternal(slot, inserted, false); + remaining -= inserted; + } + } + } + + return remaining; + } + + /** + * Ship fluids to drop-offs based on distribution mode. + */ + private void shipFluids(List dropOffs, int maxFluid) { + List inputHandlers = getFluidInputHandlers(); + if (inputHandlers.isEmpty()) { + return; + } + + int remainingCapacity = maxFluid; + + switch (distributionMode) { + case DIRECT -> { + if (!dropOffs.isEmpty()) { + remainingCapacity = shipFluidsToDropOff(dropOffs.get(0), inputHandlers, remainingCapacity); + } + } + case FILL_FIRST -> { + for (MothCargoDropOffMachine dropOff : dropOffs) { + if (remainingCapacity <= 0) break; + remainingCapacity = shipFluidsToDropOff(dropOff, inputHandlers, remainingCapacity); + } + } + case ROUND_ROBIN -> { + int perDropOff = Math.max(1, remainingCapacity / dropOffs.size()); + for (int i = 0; i < dropOffs.size() && remainingCapacity > 0; i++) { + int index = (roundRobinIndex + i) % dropOffs.size(); + int toShip = Math.min(perDropOff, remainingCapacity); + int shipped = shipFluidsToDropOff(dropOffs.get(index), inputHandlers, toShip); + remainingCapacity -= (toShip - shipped); + } + } + } + } + + /** + * Ship fluids to a single drop-off, returns remaining capacity. + */ + private int shipFluidsToDropOff(MothCargoDropOffMachine dropOff, List sources, int maxFluid) { + List destHandlers = dropOff.getFluidOutputHandlers(); + if (destHandlers.isEmpty()) { + return maxFluid; + } + + int remaining = maxFluid; + + for (IFluidHandler source : sources) { + for (int tank = 0; tank < source.getTanks() && remaining > 0; tank++) { + FluidStack available = source.getFluidInTank(tank); + if (available.isEmpty()) continue; + + int toDrain = Math.min(available.getAmount(), remaining); + FluidStack drained = source.drain(new FluidStack(available, toDrain), + IFluidHandler.FluidAction.SIMULATE); + if (drained.isEmpty()) continue; + + // Try to insert into destination + int filled = 0; + for (IFluidHandler dest : destHandlers) { + int thisFill = dest.fill(drained.copy(), IFluidHandler.FluidAction.EXECUTE); + filled += thisFill; + drained.shrink(thisFill); + if (drained.isEmpty()) break; + } + + // Actually drain what we inserted + if (filled > 0) { + source.drain(new FluidStack(available, filled), IFluidHandler.FluidAction.EXECUTE); + remaining -= filled; + } + } + } + + return remaining; + } + + /** + * Consume feeding materials and update multiplier. + */ + private void consumeFeedingMaterials() { + // TODO: Check input bus for honey/oil and consume per cycle + // For now, default multiplier + currentFeedingMultiplier = 1; + } + + // ==================== Handler Access ==================== + + /** + * Get all item input handlers from the multiblock. + */ + private List getItemInputHandlers() { + List handlers = new ArrayList<>(); + + var itemCaps = getCapabilitiesFlat(IO.IN, ItemRecipeCapability.CAP); + if (itemCaps != null) { + for (var handler : itemCaps) { + if (handler instanceof NotifiableItemStackHandler itemHandler) { + handlers.add(itemHandler); + } + } + } + + return handlers; + } + + /** + * Get all fluid input handlers from the multiblock. + */ + private List getFluidInputHandlers() { + List handlers = new ArrayList<>(); + + var fluidCaps = getCapabilitiesFlat(IO.IN, FluidRecipeCapability.CAP); + if (fluidCaps != null) { + for (var handler : fluidCaps) { + if (handler instanceof NotifiableFluidTank fluidHandler) { + handlers.add(fluidHandler); + } + } + } + + return handlers; + } + + // ==================== UI ==================== + + @Override + public void addDisplayText(List textList) { + if (!isFormed()) { + textList.add(Component.literal("Structure not formed") + .setStyle(Style.EMPTY.withColor(ChatFormatting.RED))); + return; + } + + // Moth home info + if (mothHomeCount > 0) { + textList.add(Component.literal("Moth Homes: " + mothHomeCount + " (T" + mothHomeTier + ")") + .setStyle(Style.EMPTY.withColor(ChatFormatting.GREEN))); + textList.add(Component.literal("Total Moths: " + getTotalMoths()) + .setStyle(Style.EMPTY.withColor(ChatFormatting.AQUA))); + textList.add(Component.literal("Cycle Time: " + (getCycleTimeTicks() / 20) + "s") + .setStyle(Style.EMPTY.withColor(ChatFormatting.YELLOW))); + } else { + textList.add(Component.literal("No Moth Homes installed!") + .setStyle(Style.EMPTY.withColor(ChatFormatting.RED))); + } + + // Distribution mode + textList.add(Component.literal("Mode: " + distributionMode.name()) + .setStyle(Style.EMPTY.withColor(ChatFormatting.WHITE))); + + // Linked partners + int linkedCount = getLinkedPartners().size(); + if (linkedCount > 0) { + textList.add(Component.literal("Linked Drop-Offs: " + linkedCount) + .setStyle(Style.EMPTY.withColor(ChatFormatting.GREEN))); + } else { + textList.add(Component.literal("No Drop-Offs linked!") + .setStyle(Style.EMPTY.withColor(ChatFormatting.RED))); + } + + // Capacity info + if (mothHomeCount > 0) { + textList.add(Component + .literal("Capacity: " + getCapacityPerCycle(false) + " items / " + getCapacityPerCycle(true) + + " mB per cycle") + .setStyle(Style.EMPTY.withColor(ChatFormatting.GRAY))); + } + } + + @Override + protected InteractionResult onScrewdriverClick(Player playerIn, InteractionHand hand, Direction gridSide, + BlockHitResult hitResult) { + if (!isRemote()) { + // Cycle through distribution modes + DistributionMode[] modes = DistributionMode.values(); + int nextIndex = (distributionMode.ordinal() + 1) % modes.length; + distributionMode = modes[nextIndex]; + + playerIn.displayClientMessage( + Component.literal("Distribution Mode: " + distributionMode.name()) + .setStyle(Style.EMPTY.withColor(ChatFormatting.AQUA)), + true); + } + return InteractionResult.SUCCESS; + } +} diff --git a/src/main/java/com/ghostipedia/cosmiccore/common/machine/multiblock/multi/modular/MultiblockInit.java b/src/main/java/com/ghostipedia/cosmiccore/common/machine/multiblock/multi/modular/MultiblockInit.java index 105dcc8b8..5d347434b 100644 --- a/src/main/java/com/ghostipedia/cosmiccore/common/machine/multiblock/multi/modular/MultiblockInit.java +++ b/src/main/java/com/ghostipedia/cosmiccore/common/machine/multiblock/multi/modular/MultiblockInit.java @@ -64,7 +64,13 @@ public static void init() { VileFissionReactor.init(); VoidSaltReactor.init(); AtomicReconstructor.init(); + DivingBell.init(); + LinkTestStation.init(); StarLadder.init(); // KryosynCrackingChamber.init(); + + // Cargo Moths System + MothCargoStation.init(); + MothCargoDropOff.init(); } } diff --git a/src/main/java/com/ghostipedia/cosmiccore/common/recipe/condition/CosmicConditions.java b/src/main/java/com/ghostipedia/cosmiccore/common/recipe/condition/CosmicConditions.java index ff6af60f0..3e4de2610 100644 --- a/src/main/java/com/ghostipedia/cosmiccore/common/recipe/condition/CosmicConditions.java +++ b/src/main/java/com/ghostipedia/cosmiccore/common/recipe/condition/CosmicConditions.java @@ -4,5 +4,9 @@ public class CosmicConditions { public static void register() { TitanCondition.register(); + LinkedPartnerCondition.register(); + LinkedPartnerDimensionCondition.register(); + LinkedPartnerDimensionItemCondition.register(); + LinkedPartnerDimensionFluidCondition.register(); } } diff --git a/src/main/java/com/ghostipedia/cosmiccore/common/recipe/condition/LinkedPartnerCondition.java b/src/main/java/com/ghostipedia/cosmiccore/common/recipe/condition/LinkedPartnerCondition.java new file mode 100644 index 000000000..c48d48249 --- /dev/null +++ b/src/main/java/com/ghostipedia/cosmiccore/common/recipe/condition/LinkedPartnerCondition.java @@ -0,0 +1,116 @@ +package com.ghostipedia.cosmiccore.common.recipe.condition; + +import com.ghostipedia.cosmiccore.api.machine.multiblock.LinkedWorkableElectricMultiblockMachine; + +import com.gregtechceu.gtceu.api.machine.trait.RecipeLogic; +import com.gregtechceu.gtceu.api.recipe.GTRecipe; +import com.gregtechceu.gtceu.api.recipe.RecipeCondition; +import com.gregtechceu.gtceu.api.recipe.condition.RecipeConditionType; +import com.gregtechceu.gtceu.api.registry.GTRegistries; + +import net.minecraft.network.chat.Component; + +import com.mojang.serialization.Codec; +import com.mojang.serialization.codecs.RecordCodecBuilder; +import org.jetbrains.annotations.NotNull; + +/** + * Recipe condition that requires the machine to have linked partners. + *

+ * Can be configured to require: + * - A minimum number of linked partners + * - At least one partner to be formed (structure valid) + * - At least one partner to be actively working (running a recipe) + */ +public class LinkedPartnerCondition extends RecipeCondition { + + /** Minimum number of linked partners required */ + public int minPartners; + /** If true, at least one partner must have a valid structure */ + public boolean requireFormed; + /** If true, at least one partner must be actively running a recipe */ + public boolean requireWorking; + + public static final Codec CODEC = RecordCodecBuilder + .create(instance -> RecipeCondition.isReverse(instance) + .and(Codec.INT.optionalFieldOf("min_partners", 1).forGetter(val -> val.minPartners)) + .and(Codec.BOOL.optionalFieldOf("require_formed", false).forGetter(val -> val.requireFormed)) + .and(Codec.BOOL.optionalFieldOf("require_working", false).forGetter(val -> val.requireWorking)) + .apply(instance, LinkedPartnerCondition::new)); + + public static RecipeConditionType TYPE; + + public LinkedPartnerCondition(boolean isReverse, int minPartners, boolean requireFormed, boolean requireWorking) { + this.isReverse = isReverse; + this.minPartners = minPartners; + this.requireFormed = requireFormed; + this.requireWorking = requireWorking; + } + + public LinkedPartnerCondition(int minPartners, boolean requireFormed, boolean requireWorking) { + this(false, minPartners, requireFormed, requireWorking); + } + + public LinkedPartnerCondition(int minPartners) { + this(false, minPartners, false, false); + } + + public LinkedPartnerCondition() { + this(1); + } + + public static void register() { + TYPE = GTRegistries.RECIPE_CONDITIONS.register("linked_partner", + new RecipeConditionType<>(LinkedPartnerCondition::new, LinkedPartnerCondition.CODEC)); + } + + @Override + public RecipeConditionType getType() { + return TYPE; + } + + @Override + public Component getTooltips() { + if (requireWorking) { + return Component.translatable("cosmiccore.recipe.condition.linked_partner.working", minPartners); + } else if (requireFormed) { + return Component.translatable("cosmiccore.recipe.condition.linked_partner.formed", minPartners); + } else { + return Component.translatable("cosmiccore.recipe.condition.linked_partner.tooltip", minPartners); + } + } + + @Override + protected boolean testCondition(@NotNull GTRecipe recipe, @NotNull RecipeLogic recipeLogic) { + if (!(recipeLogic.getMachine() instanceof LinkedWorkableElectricMultiblockMachine linkedMachine)) { + return false; + } + + // Check minimum partner count + int partnerCount = linkedMachine.getLinkedPartners().size(); + if (partnerCount < minPartners) { + return false; + } + + // Check if any partner needs to be formed + if (requireFormed) { + if (linkedMachine.countFormedPartners() < 1) { + return false; + } + } + + // Check if any partner needs to be working + if (requireWorking) { + if (!linkedMachine.anyPartnerWorking()) { + return false; + } + } + + return true; + } + + @Override + public RecipeCondition createTemplate() { + return new LinkedPartnerCondition(); + } +} diff --git a/src/main/java/com/ghostipedia/cosmiccore/common/recipe/condition/LinkedPartnerDimensionCondition.java b/src/main/java/com/ghostipedia/cosmiccore/common/recipe/condition/LinkedPartnerDimensionCondition.java new file mode 100644 index 000000000..817bc53d4 --- /dev/null +++ b/src/main/java/com/ghostipedia/cosmiccore/common/recipe/condition/LinkedPartnerDimensionCondition.java @@ -0,0 +1,92 @@ +package com.ghostipedia.cosmiccore.common.recipe.condition; + +import com.ghostipedia.cosmiccore.api.machine.multiblock.LinkedWorkableElectricMultiblockMachine; + +import com.gregtechceu.gtceu.api.machine.trait.RecipeLogic; +import com.gregtechceu.gtceu.api.recipe.GTRecipe; +import com.gregtechceu.gtceu.api.recipe.RecipeCondition; +import com.gregtechceu.gtceu.api.recipe.condition.RecipeConditionType; +import com.gregtechceu.gtceu.api.registry.GTRegistries; + +import net.minecraft.core.GlobalPos; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.ResourceLocation; + +import com.mojang.serialization.Codec; +import com.mojang.serialization.codecs.RecordCodecBuilder; +import org.jetbrains.annotations.NotNull; + +/** + * Recipe condition that requires a linked partner to be in a specific dimension. + *

+ * Use cases: + * - "Requires partner in Sun Orbit" for solar plasma collection + * - "Requires partner in The Deep Below" for mining operations + * - "Requires partner in Moon" for low-gravity processing + */ +public class LinkedPartnerDimensionCondition extends RecipeCondition { + + /** The dimension the partner must be in */ + public ResourceLocation dimension; + + public static final Codec CODEC = RecordCodecBuilder + .create(instance -> RecipeCondition.isReverse(instance) + .and(ResourceLocation.CODEC.fieldOf("dimension").forGetter(val -> val.dimension)) + .apply(instance, LinkedPartnerDimensionCondition::new)); + + public static RecipeConditionType TYPE; + + public LinkedPartnerDimensionCondition(boolean isReverse, ResourceLocation dimension) { + this.isReverse = isReverse; + this.dimension = dimension; + } + + public LinkedPartnerDimensionCondition(ResourceLocation dimension) { + this(false, dimension); + } + + public LinkedPartnerDimensionCondition(String dimension) { + this(false, new ResourceLocation(dimension)); + } + + public LinkedPartnerDimensionCondition() { + this.dimension = new ResourceLocation("minecraft:overworld"); + } + + public static void register() { + TYPE = GTRegistries.RECIPE_CONDITIONS.register("linked_partner_dimension", + new RecipeConditionType<>(LinkedPartnerDimensionCondition::new, LinkedPartnerDimensionCondition.CODEC)); + } + + @Override + public RecipeConditionType getType() { + return TYPE; + } + + @Override + public Component getTooltips() { + return Component.translatable("cosmiccore.recipe.condition.linked_partner_dimension.tooltip", + dimension.toString()); + } + + @Override + protected boolean testCondition(@NotNull GTRecipe recipe, @NotNull RecipeLogic recipeLogic) { + if (!(recipeLogic.getMachine() instanceof LinkedWorkableElectricMultiblockMachine linkedMachine)) { + return false; + } + + // Check if any linked partner is in the required dimension + for (GlobalPos partner : linkedMachine.getLinkedPartners()) { + if (partner.dimension().location().equals(dimension)) { + return true; + } + } + + return false; + } + + @Override + public RecipeCondition createTemplate() { + return new LinkedPartnerDimensionCondition(); + } +} diff --git a/src/main/java/com/ghostipedia/cosmiccore/common/recipe/condition/LinkedPartnerDimensionFluidCondition.java b/src/main/java/com/ghostipedia/cosmiccore/common/recipe/condition/LinkedPartnerDimensionFluidCondition.java new file mode 100644 index 000000000..91af45cd8 --- /dev/null +++ b/src/main/java/com/ghostipedia/cosmiccore/common/recipe/condition/LinkedPartnerDimensionFluidCondition.java @@ -0,0 +1,136 @@ +package com.ghostipedia.cosmiccore.common.recipe.condition; + +import com.ghostipedia.cosmiccore.api.machine.multiblock.LinkedWorkableElectricMultiblockMachine; +import com.ghostipedia.cosmiccore.common.machine.multiblock.LinkedMultiblockHelper; + +import com.gregtechceu.gtceu.api.machine.trait.RecipeLogic; +import com.gregtechceu.gtceu.api.recipe.GTRecipe; +import com.gregtechceu.gtceu.api.recipe.RecipeCondition; +import com.gregtechceu.gtceu.api.recipe.condition.RecipeConditionType; +import com.gregtechceu.gtceu.api.registry.GTRegistries; + +import net.minecraft.core.GlobalPos; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.material.Fluid; +import net.minecraftforge.fluids.FluidStack; + +import com.mojang.serialization.Codec; +import com.mojang.serialization.codecs.RecordCodecBuilder; +import org.jetbrains.annotations.NotNull; + +import java.util.UUID; + +/** + * Recipe condition that requires a linked partner in a specific dimension + * to have a specific fluid in its input hatches. + *

+ * Use cases: + * - "Requires partner in Sun Orbit with Solar Plasma" + * - "Requires partner in Deep Below with Molten Core fluid" + */ +public class LinkedPartnerDimensionFluidCondition extends RecipeCondition { + + public ResourceLocation dimension; + public ResourceLocation fluidId; + public int minAmount; + + public static final Codec CODEC = RecordCodecBuilder + .create(instance -> RecipeCondition.isReverse(instance) + .and(ResourceLocation.CODEC.fieldOf("dimension").forGetter(val -> val.dimension)) + .and(ResourceLocation.CODEC.fieldOf("fluid").forGetter(val -> val.fluidId)) + .and(Codec.INT.optionalFieldOf("amount", 1000).forGetter(val -> val.minAmount)) + .apply(instance, LinkedPartnerDimensionFluidCondition::new)); + + public static RecipeConditionType TYPE; + + public LinkedPartnerDimensionFluidCondition(boolean isReverse, ResourceLocation dimension, ResourceLocation fluidId, + int minAmount) { + this.isReverse = isReverse; + this.dimension = dimension; + this.fluidId = fluidId; + this.minAmount = minAmount; + } + + public LinkedPartnerDimensionFluidCondition(ResourceLocation dimension, ResourceLocation fluidId, int minAmount) { + this(false, dimension, fluidId, minAmount); + } + + public LinkedPartnerDimensionFluidCondition(String dimension, Fluid fluid, int minAmount) { + this(false, new ResourceLocation(dimension), BuiltInRegistries.FLUID.getKey(fluid), minAmount); + } + + public LinkedPartnerDimensionFluidCondition(String dimension, Fluid fluid) { + this(dimension, fluid, 1000); + } + + public LinkedPartnerDimensionFluidCondition() { + this.dimension = new ResourceLocation("minecraft:overworld"); + this.fluidId = new ResourceLocation("minecraft:water"); + this.minAmount = 1000; + } + + public static void register() { + TYPE = GTRegistries.RECIPE_CONDITIONS.register("linked_partner_dimension_fluid", + new RecipeConditionType<>(LinkedPartnerDimensionFluidCondition::new, + LinkedPartnerDimensionFluidCondition.CODEC)); + } + + @Override + public RecipeConditionType getType() { + return TYPE; + } + + @Override + public Component getTooltips() { + Fluid fluid = BuiltInRegistries.FLUID.get(fluidId); + String fluidName = new FluidStack(fluid, 1000).getDisplayName().getString(); + return Component.translatable("cosmiccore.recipe.condition.linked_partner_dimension_fluid.tooltip", + minAmount, fluidName, dimension.toString()); + } + + @Override + protected boolean testCondition(@NotNull GTRecipe recipe, @NotNull RecipeLogic recipeLogic) { + if (!(recipeLogic.getMachine() instanceof LinkedWorkableElectricMultiblockMachine linkedMachine)) { + return false; + } + + if (!(linkedMachine.getLevel() instanceof ServerLevel serverLevel)) { + return false; + } + + UUID owner = linkedMachine.getTeamUUID(); + if (owner == null) return false; + + GlobalPos myPos = linkedMachine.getGlobalPos(); + Fluid targetFluid = BuiltInRegistries.FLUID.get(fluidId); + + // Check each partner in the required dimension + for (GlobalPos partner : linkedMachine.getLinkedPartners()) { + if (!partner.dimension().location().equals(dimension)) { + continue; + } + + // Check if this partner has the required fluid + boolean hasFluid = LinkedMultiblockHelper.partnerHasFluid( + serverLevel.getServer(), + owner, + myPos, + partner, + (FluidStack stack) -> stack.getFluid().isSame(targetFluid) && stack.getAmount() >= minAmount); + + if (hasFluid) { + return true; + } + } + + return false; + } + + @Override + public RecipeCondition createTemplate() { + return new LinkedPartnerDimensionFluidCondition(); + } +} diff --git a/src/main/java/com/ghostipedia/cosmiccore/common/recipe/condition/LinkedPartnerDimensionItemCondition.java b/src/main/java/com/ghostipedia/cosmiccore/common/recipe/condition/LinkedPartnerDimensionItemCondition.java new file mode 100644 index 000000000..6a3f88aea --- /dev/null +++ b/src/main/java/com/ghostipedia/cosmiccore/common/recipe/condition/LinkedPartnerDimensionItemCondition.java @@ -0,0 +1,136 @@ +package com.ghostipedia.cosmiccore.common.recipe.condition; + +import com.ghostipedia.cosmiccore.api.machine.multiblock.LinkedWorkableElectricMultiblockMachine; +import com.ghostipedia.cosmiccore.common.machine.multiblock.LinkedMultiblockHelper; + +import com.gregtechceu.gtceu.api.machine.trait.RecipeLogic; +import com.gregtechceu.gtceu.api.recipe.GTRecipe; +import com.gregtechceu.gtceu.api.recipe.RecipeCondition; +import com.gregtechceu.gtceu.api.recipe.condition.RecipeConditionType; +import com.gregtechceu.gtceu.api.registry.GTRegistries; + +import net.minecraft.core.GlobalPos; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; + +import com.mojang.serialization.Codec; +import com.mojang.serialization.codecs.RecordCodecBuilder; +import org.jetbrains.annotations.NotNull; + +import java.util.UUID; + +/** + * Recipe condition that requires a linked partner in a specific dimension + * to have a specific item in its input hatches. + *

+ * Use cases: + * - "Requires partner in Sun Orbit with Solar Collector item" + * - "Requires partner in Moon with Helium-3 canister" + */ +public class LinkedPartnerDimensionItemCondition extends RecipeCondition { + + public ResourceLocation dimension; + public ResourceLocation itemId; + public int minCount; + + public static final Codec CODEC = RecordCodecBuilder + .create(instance -> RecipeCondition.isReverse(instance) + .and(ResourceLocation.CODEC.fieldOf("dimension").forGetter(val -> val.dimension)) + .and(ResourceLocation.CODEC.fieldOf("item").forGetter(val -> val.itemId)) + .and(Codec.INT.optionalFieldOf("count", 1).forGetter(val -> val.minCount)) + .apply(instance, LinkedPartnerDimensionItemCondition::new)); + + public static RecipeConditionType TYPE; + + public LinkedPartnerDimensionItemCondition(boolean isReverse, ResourceLocation dimension, ResourceLocation itemId, + int minCount) { + this.isReverse = isReverse; + this.dimension = dimension; + this.itemId = itemId; + this.minCount = minCount; + } + + public LinkedPartnerDimensionItemCondition(ResourceLocation dimension, ResourceLocation itemId, int minCount) { + this(false, dimension, itemId, minCount); + } + + public LinkedPartnerDimensionItemCondition(String dimension, Item item, int minCount) { + this(false, new ResourceLocation(dimension), BuiltInRegistries.ITEM.getKey(item), minCount); + } + + public LinkedPartnerDimensionItemCondition(String dimension, Item item) { + this(dimension, item, 1); + } + + public LinkedPartnerDimensionItemCondition() { + this.dimension = new ResourceLocation("minecraft:overworld"); + this.itemId = new ResourceLocation("minecraft:stone"); + this.minCount = 1; + } + + public static void register() { + TYPE = GTRegistries.RECIPE_CONDITIONS.register("linked_partner_dimension_item", + new RecipeConditionType<>(LinkedPartnerDimensionItemCondition::new, + LinkedPartnerDimensionItemCondition.CODEC)); + } + + @Override + public RecipeConditionType getType() { + return TYPE; + } + + @Override + public Component getTooltips() { + Item item = BuiltInRegistries.ITEM.get(itemId); + String itemName = item.getDescription().getString(); + return Component.translatable("cosmiccore.recipe.condition.linked_partner_dimension_item.tooltip", + minCount, itemName, dimension.toString()); + } + + @Override + protected boolean testCondition(@NotNull GTRecipe recipe, @NotNull RecipeLogic recipeLogic) { + if (!(recipeLogic.getMachine() instanceof LinkedWorkableElectricMultiblockMachine linkedMachine)) { + return false; + } + + if (!(linkedMachine.getLevel() instanceof ServerLevel serverLevel)) { + return false; + } + + UUID owner = linkedMachine.getTeamUUID(); + if (owner == null) return false; + + GlobalPos myPos = linkedMachine.getGlobalPos(); + Item targetItem = BuiltInRegistries.ITEM.get(itemId); + + // Check each partner in the required dimension + for (GlobalPos partner : linkedMachine.getLinkedPartners()) { + if (!partner.dimension().location().equals(dimension)) { + continue; + } + + // Check if this partner has the required item + boolean hasItem = LinkedMultiblockHelper.partnerHasItem( + serverLevel.getServer(), + owner, + myPos, + partner, + (ItemStack stack) -> stack.is(targetItem) && stack.getCount() >= minCount); + + if (hasItem) { + return true; + } + } + + return false; + } + + @Override + public RecipeCondition createTemplate() { + return new LinkedPartnerDimensionItemCondition(); + } +} diff --git a/src/main/java/com/ghostipedia/cosmiccore/common/teleporter/LandingZoneHelper.java b/src/main/java/com/ghostipedia/cosmiccore/common/teleporter/LandingZoneHelper.java new file mode 100644 index 000000000..6514a4adf --- /dev/null +++ b/src/main/java/com/ghostipedia/cosmiccore/common/teleporter/LandingZoneHelper.java @@ -0,0 +1,74 @@ +package com.ghostipedia.cosmiccore.common.teleporter; + +import net.minecraft.core.BlockPos; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.Blocks; + +public class LandingZoneHelper { + + private static final int MIN_SEARCH_HEIGHT_BUFFER = 5; // Don't search below world limit + this buffer + private static final int CLEAR_AIR_HEIGHT = 3; // Clear this many blocks above platform for headroom + + public static class PlatformOptions { + + public final Block platformMaterial; + public final Block padBlock; + public final int platformRadius; + + // Pplatform options. + public PlatformOptions(Block platformMaterial, Block padBlock, int platformRadius) { + this.platformMaterial = platformMaterial; + this.padBlock = padBlock; + this.platformRadius = platformRadius; + } + } + + // Search downward from startY to find solid ground. + // Searches down to minBuildHeight + 5, then falls back to startY if no ground found. + public static BlockPos findSafeYLevel(ServerLevel level, int x, int z, int startY) { + for (int y = startY; y >= level.getMinBuildHeight() + MIN_SEARCH_HEIGHT_BUFFER; y--) { + BlockPos checkPos = new BlockPos(x, y, z); + if (level.getBlockState(checkPos.below()).isSolid()) { + // Found solid ground + return checkPos; + } + } + + return new BlockPos(x, startY, z); + } + + // Check if a pad block is intact at the given position. + public static boolean isPadIntact(ServerLevel level, BlockPos pos, Block expectedPad) { + return level.getBlockState(pos).is(expectedPad); + } + + // Build a landing platform with escape pad at center. + public static void buildPlatform(ServerLevel level, BlockPos center, PlatformOptions options) { + int radius = options.platformRadius; + + // Build platform 1 block below center (so players stand on it, pad is at center) + for (int x = -radius; x <= radius; x++) { + for (int z = -radius; z <= radius; z++) { + BlockPos platformPos = center.offset(x, -1, z); + level.setBlock(platformPos, options.platformMaterial.defaultBlockState(), 3); + } + } + + // Place pad at center + level.setBlock(center, options.padBlock.defaultBlockState(), 3); + + // Clear air above for headroom + for (int y = 0; y < CLEAR_AIR_HEIGHT; y++) { + for (int x = -radius; x <= radius; x++) { + for (int z = -radius; z <= radius; z++) { + BlockPos airPos = center.offset(x, y, z); + if (airPos.equals(center)) continue; // Don't clear the pad itself + if (level.getBlockState(airPos).isSolid()) { + level.setBlock(airPos, Blocks.AIR.defaultBlockState(), 3); + } + } + } + } + } +} diff --git a/src/main/java/com/ghostipedia/cosmiccore/common/teleporter/SafeTeleporter.java b/src/main/java/com/ghostipedia/cosmiccore/common/teleporter/SafeTeleporter.java new file mode 100644 index 000000000..6100221a5 --- /dev/null +++ b/src/main/java/com/ghostipedia/cosmiccore/common/teleporter/SafeTeleporter.java @@ -0,0 +1,68 @@ +package com.ghostipedia.cosmiccore.common.teleporter; + +import net.minecraft.core.BlockPos; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.level.portal.PortalInfo; +import net.minecraft.world.phys.Vec3; +import net.minecraftforge.common.util.ITeleporter; + +import org.jetbrains.annotations.Nullable; + +import java.util.function.Function; + +// Saftey first kids (reusable ITeleporter that ensures safe arrival after dimension changes) +public class SafeTeleporter implements ITeleporter { + + private final BlockPos targetPos; + private final boolean applySafetyBuffs; + private final int buffDuration; // ticks + + // Create a SafeTeleporter with default settings (buffs enabled, 100 tick duration). + public SafeTeleporter(BlockPos targetPos) { + this(targetPos, true, 100); + } + + // Create a SafeTeleporter with custom settings. + public SafeTeleporter(BlockPos targetPos, boolean applySafetyBuffs, int buffDuration) { + this.targetPos = targetPos; + this.applySafetyBuffs = applySafetyBuffs; + this.buffDuration = buffDuration; + } + + @Override + @Nullable + public PortalInfo getPortalInfo(Entity entity, ServerLevel destWorld, + Function defaultPortalInfo) { + // Place entity at center of block, slightly above the pad + Vec3 pos = new Vec3( + targetPos.getX() + 0.5, + targetPos.getY() + 0.1, // Slightly above to prevent clipping + targetPos.getZ() + 0.5); + + // Zero velocity to prevent fall damage + Vec3 velocity = Vec3.ZERO; + + // Preserve rotation + return new PortalInfo(pos, velocity, entity.getYRot(), entity.getXRot()); + } + + @Override + public Entity placeEntity(Entity entity, ServerLevel currentWorld, ServerLevel destWorld, + float yaw, Function repositionEntity) { + Entity result = repositionEntity.apply(false); + + // Apply safety effects + applySafetyEffects(result); + + return result; + } + + private void applySafetyEffects(Entity entity) { + // Clear fire + entity.clearFire(); + + // Reset fall distance to prevent fall damage + entity.fallDistance = 0; + } +} diff --git a/src/main/java/com/ghostipedia/cosmiccore/common/teleporter/TeleportOrigin.java b/src/main/java/com/ghostipedia/cosmiccore/common/teleporter/TeleportOrigin.java new file mode 100644 index 000000000..67c92c035 --- /dev/null +++ b/src/main/java/com/ghostipedia/cosmiccore/common/teleporter/TeleportOrigin.java @@ -0,0 +1,133 @@ +package com.ghostipedia.cosmiccore.common.teleporter; + +import com.ghostipedia.cosmiccore.api.capability.ITeleportOrigin; + +import net.minecraft.core.BlockPos; +import net.minecraft.core.registries.Registries; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.resources.ResourceKey; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.level.Level; +import net.minecraft.world.phys.Vec3; + +import org.jetbrains.annotations.Nullable; + +// Implementation of the teleport origin capability - Stores origin dimension, position, and rotation for teleport +// return trips. +public class TeleportOrigin implements ITeleportOrigin { + + @Nullable + private ResourceKey originDimension; + @Nullable + private Vec3 originPosition; + private float originYaw; + private float originPitch; + @Nullable + private BlockPos escapePadPosition; + + // spotless: off + @Override + public void setOriginDimension(ResourceKey dimension) { + this.originDimension = dimension; + } + + @Override + @Nullable + public ResourceKey getOriginDimension() { + return originDimension; + } + + @Override + public void setOriginPosition(Vec3 position) { + this.originPosition = position; + } + + @Override + @Nullable + public Vec3 getOriginPosition() { + return originPosition; + } + + @Override + public void setOriginRotation(float yaw, float pitch) { + this.originYaw = yaw; + this.originPitch = pitch; + } + + @Override + public float getOriginYaw() { + return originYaw; + } + + @Override + public float getOriginPitch() { + return originPitch; + } + + @Override + public boolean hasValidOrigin() { + return originDimension != null && originPosition != null; + } + + @Override + public void clearOriginData() { + originDimension = null; + originPosition = null; + originYaw = 0; + originPitch = 0; + escapePadPosition = null; + } + // spotless: on + + // Save capability data to NBT. + public CompoundTag save() { + CompoundTag tag = new CompoundTag(); + + if (originDimension != null) { + tag.putString("OriginDimension", originDimension.location().toString()); + } + + if (originPosition != null) { + tag.putDouble("OriginX", originPosition.x); + tag.putDouble("OriginY", originPosition.y); + tag.putDouble("OriginZ", originPosition.z); + } + + tag.putFloat("OriginYaw", originYaw); + tag.putFloat("OriginPitch", originPitch); + + if (escapePadPosition != null) { + tag.putLong("EscapePadPos", escapePadPosition.asLong()); + } + + return tag; + } + + // Load capability data from NBT. + public void load(CompoundTag tag) { + if (tag.contains("OriginDimension")) { + ResourceLocation dimLoc = new ResourceLocation(tag.getString("OriginDimension")); + this.originDimension = ResourceKey.create(Registries.DIMENSION, dimLoc); + } else { + this.originDimension = null; + } + + if (tag.contains("OriginX")) { + double x = tag.getDouble("OriginX"); + double y = tag.getDouble("OriginY"); + double z = tag.getDouble("OriginZ"); + this.originPosition = new Vec3(x, y, z); + } else { + this.originPosition = null; + } + + this.originYaw = tag.getFloat("OriginYaw"); + this.originPitch = tag.getFloat("OriginPitch"); + + if (tag.contains("EscapePadPos")) { + this.escapePadPosition = BlockPos.of(tag.getLong("EscapePadPos")); + } else { + this.escapePadPosition = null; + } + } +} diff --git a/src/main/java/com/ghostipedia/cosmiccore/common/teleporter/TeleportOriginCap.java b/src/main/java/com/ghostipedia/cosmiccore/common/teleporter/TeleportOriginCap.java new file mode 100644 index 000000000..9d1eb3097 --- /dev/null +++ b/src/main/java/com/ghostipedia/cosmiccore/common/teleporter/TeleportOriginCap.java @@ -0,0 +1,76 @@ +package com.ghostipedia.cosmiccore.common.teleporter; + +import com.ghostipedia.cosmiccore.CosmicCore; +import com.ghostipedia.cosmiccore.api.capability.ITeleportOrigin; + +import net.minecraft.core.Direction; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.player.Player; +import net.minecraftforge.common.capabilities.Capability; +import net.minecraftforge.common.capabilities.CapabilityManager; +import net.minecraftforge.common.capabilities.CapabilityToken; +import net.minecraftforge.common.capabilities.ICapabilityProvider; +import net.minecraftforge.common.capabilities.ICapabilitySerializable; +import net.minecraftforge.common.util.LazyOptional; +import net.minecraftforge.event.AttachCapabilitiesEvent; +import net.minecraftforge.event.entity.player.PlayerEvent; +import net.minecraftforge.eventbus.api.SubscribeEvent; +import net.minecraftforge.fml.common.Mod; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +// Capability provider for teleport origin data. +// Attaches to all players to track their teleportation origin for return trips. +@Mod.EventBusSubscriber(modid = CosmicCore.MOD_ID, bus = Mod.EventBusSubscriber.Bus.FORGE) +public class TeleportOriginCap { + + public static final ResourceLocation KEY = new ResourceLocation("cosmiccore", "teleport_origin"); + public static final Capability CAP = CapabilityManager.get(new CapabilityToken<>() {}); + + public static class Provider implements ICapabilityProvider, ICapabilitySerializable { + + private final TeleportOrigin impl = new TeleportOrigin(); + private final LazyOptional lazyOpt = LazyOptional.of(() -> impl); + + @Override + public @NotNull LazyOptional getCapability(@NotNull Capability capability, + @Nullable Direction direction) { + return capability == CAP ? lazyOpt.cast() : LazyOptional.empty(); + } + + @Override + public CompoundTag serializeNBT() { + return impl.save(); + } + + @Override + public void deserializeNBT(CompoundTag tag) { + impl.load(tag); + } + } + + // Attach capability to all players. + @SubscribeEvent + public static void attachCap(AttachCapabilitiesEvent event) { + if (event.getObject() instanceof Player) { + event.addCapability(KEY, new Provider()); + } + } + + // Clone capability data on player respawn/dimension change. + @SubscribeEvent + public static void cloneCap(PlayerEvent.Clone event) { + event.getOriginal().reviveCaps(); + event.getOriginal().getCapability(CAP).ifPresent(old -> { + event.getEntity().getCapability(CAP).ifPresent(now -> { + if (now instanceof TeleportOrigin originNow && old instanceof TeleportOrigin originOld) { + originNow.load(originOld.save()); + } + }); + }); + event.getOriginal().invalidateCaps(); + } +} diff --git a/src/main/java/com/ghostipedia/cosmiccore/common/teleporter/TeleportPadRegistry.java b/src/main/java/com/ghostipedia/cosmiccore/common/teleporter/TeleportPadRegistry.java new file mode 100644 index 000000000..91d8c7401 --- /dev/null +++ b/src/main/java/com/ghostipedia/cosmiccore/common/teleporter/TeleportPadRegistry.java @@ -0,0 +1,79 @@ +package com.ghostipedia.cosmiccore.common.teleporter; + +import net.minecraft.core.BlockPos; +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.HashSet; +import java.util.Set; + +// Tracks teleport pads that have been placed. Prevents duplicate platform spawning. +public class TeleportPadRegistry extends SavedData { + + private static final String DATA_NAME = "cosmiccore_teleport_pads"; + + private final Set pads = new HashSet<>(); + + public TeleportPadRegistry() { + super(); + } + + // Get the saved data instance for a specific dimension. + public static TeleportPadRegistry get(ServerLevel level) { + return level.getDataStorage().computeIfAbsent( + TeleportPadRegistry::load, + TeleportPadRegistry::new, + DATA_NAME); + } + + // Check if a pad exists at the given position. + public boolean hasPadAt(BlockPos pos) { + return pads.contains(pos); + } + + // Register a new pad at the given position. + public void registerPad(BlockPos pos) { + if (pads.add(pos)) { + setDirty(); + } + } + + // Remove a pad registration (like if it gets broken) + public void removePad(BlockPos pos) { + if (pads.remove(pos)) { + setDirty(); + } + } + + @Override + public @NotNull CompoundTag save(@NotNull CompoundTag tag) { + ListTag padsList = new ListTag(); + + for (BlockPos pos : pads) { + CompoundTag padTag = new CompoundTag(); + padTag.putLong("Pos", pos.asLong()); + padsList.add(padTag); + } + + tag.put("Pads", padsList); + return tag; + } + + public static TeleportPadRegistry load(CompoundTag tag) { + TeleportPadRegistry registry = new TeleportPadRegistry(); + + ListTag padsList = tag.getList("Pads", Tag.TAG_COMPOUND); + for (Tag padTagRaw : padsList) { + CompoundTag padTag = (CompoundTag) padTagRaw; + BlockPos pos = BlockPos.of(padTag.getLong("Pos")); + registry.pads.add(pos); + } + + return registry; + } +} diff --git a/src/main/java/com/ghostipedia/cosmiccore/gtbridge/CosmicCoreRecipes.java b/src/main/java/com/ghostipedia/cosmiccore/gtbridge/CosmicCoreRecipes.java index 137167033..fdbecb421 100644 --- a/src/main/java/com/ghostipedia/cosmiccore/gtbridge/CosmicCoreRecipes.java +++ b/src/main/java/com/ghostipedia/cosmiccore/gtbridge/CosmicCoreRecipes.java @@ -1,10 +1,16 @@ package com.ghostipedia.cosmiccore.gtbridge; import com.ghostipedia.cosmiccore.common.machine.multiblock.multi.logic.LarvaMachine; +import com.ghostipedia.cosmiccore.common.recipe.condition.LinkedPartnerCondition; +import com.ghostipedia.cosmiccore.common.recipe.condition.LinkedPartnerDimensionCondition; +import com.ghostipedia.cosmiccore.common.recipe.condition.LinkedPartnerDimensionFluidCondition; +import com.ghostipedia.cosmiccore.common.recipe.condition.LinkedPartnerDimensionItemCondition; import com.gregtechceu.gtceu.api.GTValues; import net.minecraft.data.recipes.FinishedRecipe; +import net.minecraft.world.item.Items; +import net.minecraft.world.level.material.Fluids; import java.util.function.Consumer; @@ -29,6 +35,70 @@ public static void init(Consumer provider) { .save(provider); LarvaMachine.generateTargettingChipRecipes(provider); + + // === Link Test Station Recipes === + // Basic recipe - no partner required (verifies machine works) + LINK_TEST_RECIPES.recipeBuilder("link_test_basic") + .inputItems(Items.IRON_INGOT) + .outputItems(Items.IRON_NUGGET, 9) + .duration(100) + .EUt(GTValues.VA[GTValues.LV]) + .save(provider); + + // Linked recipe - requires at least 1 linked partner + LINK_TEST_RECIPES.recipeBuilder("link_test_linked") + .inputItems(Items.GOLD_INGOT) + .outputItems(Items.DIAMOND) + .duration(200) + .EUt(GTValues.VA[GTValues.MV]) + .addCondition(new LinkedPartnerCondition(1)) + .save(provider); + + // Linked recipe - requires partner to be formed + LINK_TEST_RECIPES.recipeBuilder("link_test_formed_partner") + .inputItems(Items.EMERALD) + .outputItems(Items.NETHER_STAR) + .duration(400) + .EUt(GTValues.VA[GTValues.HV]) + .addCondition(new LinkedPartnerCondition(1, true, false)) + .save(provider); + + // Linked recipe - requires partner in Moon dimension + LINK_TEST_RECIPES.recipeBuilder("link_test_moon_partner") + .inputItems(Items.LAPIS_LAZULI, 4) + .outputItems(Items.ENDER_PEARL) + .duration(200) + .EUt(GTValues.VA[GTValues.MV]) + .addCondition(new LinkedPartnerDimensionCondition("ad_astra:moon")) + .save(provider); + + // Linked recipe - requires partner in Overworld (for testing from other dimensions) + LINK_TEST_RECIPES.recipeBuilder("link_test_overworld_partner") + .inputItems(Items.REDSTONE, 4) + .outputItems(Items.GLOWSTONE_DUST, 4) + .duration(200) + .EUt(GTValues.VA[GTValues.MV]) + .addCondition(new LinkedPartnerDimensionCondition("minecraft:overworld")) + .save(provider); + + // Linked recipe - requires partner in Overworld with diamonds in input + LINK_TEST_RECIPES.recipeBuilder("link_test_dimension_item") + .inputItems(Items.COAL, 8) + .outputItems(Items.DIAMOND) + .duration(400) + .EUt(GTValues.VA[GTValues.HV]) + .addCondition(new LinkedPartnerDimensionItemCondition("minecraft:overworld", Items.DIAMOND, 1)) + .save(provider); + + // Linked recipe - requires partner in Overworld with water in input + LINK_TEST_RECIPES.recipeBuilder("link_test_dimension_fluid") + .inputItems(Items.SPONGE) + .outputItems(Items.WET_SPONGE) + .duration(100) + .EUt(GTValues.VA[GTValues.LV]) + .addCondition(new LinkedPartnerDimensionFluidCondition("minecraft:overworld", Fluids.WATER, 1000)) + .save(provider); + /* * EMBER_TESTER_RECIPES.recipeBuilder("test") * .input(CosmicRecipeCapabilities.EMBER, 100d) diff --git a/src/main/java/com/ghostipedia/cosmiccore/gtbridge/CosmicRecipeTypes.java b/src/main/java/com/ghostipedia/cosmiccore/gtbridge/CosmicRecipeTypes.java index 3a6bc17a4..cd835bbfa 100644 --- a/src/main/java/com/ghostipedia/cosmiccore/gtbridge/CosmicRecipeTypes.java +++ b/src/main/java/com/ghostipedia/cosmiccore/gtbridge/CosmicRecipeTypes.java @@ -492,6 +492,13 @@ public class CosmicRecipeTypes { // .setSound(GTSoundEntries.CHEMICAL) // TODO - Sounds // .setProgressBar(GuiTextures.PROGRESS_BAR_ARROW_MULTIPLE, ProgressTexture.FillDirection.LEFT_TO_RIGHT); + // Link Test Station recipe type for testing cross-dimensional linking + public static final GTRecipeType LINK_TEST_RECIPES = GTRecipeTypes + .register("link_test", ELECTRIC) + .setMaxIOSize(2, 2, 0, 0) + .setSound(GTSoundEntries.ASSEMBLER) + .setProgressBar(GuiTextures.PROGRESS_BAR_ARROW, ProgressTexture.FillDirection.LEFT_TO_RIGHT); + public static void init() { LASER_ENGRAVER_RECIPES.setMaxIOSize(2, 2, 1, 1); // Oh my God