diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d7a96c61..a5b4ea76 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -44,7 +44,7 @@ teamreborn-energy = "3.0.0" tomstorage-fabric = "1.20-1.6.1-fabric" techreborn = "4582415" # TechReborn-5.8.1.jar reborncore = "4582416" # RebornCore-5.8.1.jar -ae2-fabric = "fabric-15.0.4-beta" +ae2-fabric = "15.0.7-beta" natures-compass-fabric = "4591880" # NaturesCompass-1.20.1-2.2.1-fabric.jar additional-lanterns-fabric = "1.0.5-fabric-mc1.20" supermartijn642s-core-lib-fabric = "1.1.10-fabric-mc1.20" diff --git a/openspec/changes/add-ae2-configurable-objects/.openspec.yaml b/openspec/changes/add-ae2-configurable-objects/.openspec.yaml new file mode 100644 index 00000000..e08b5f89 --- /dev/null +++ b/openspec/changes/add-ae2-configurable-objects/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-03 diff --git a/openspec/changes/add-ae2-configurable-objects/design.md b/openspec/changes/add-ae2-configurable-objects/design.md new file mode 100644 index 00000000..8fafdca9 --- /dev/null +++ b/openspec/changes/add-ae2-configurable-objects/design.md @@ -0,0 +1,100 @@ +## Context + +The existing AE2 integration adds network-level methods to `AENetworkBlockEntity` instances. It does not expose the local configuration of an ME Interface or any multipart device mounted in a `CableBusBlockEntity`. AE2 represents these local controls through several distinct APIs: stock inventories with amounts, type-only filter inventories, upgrade inventories, settings managers, priorities, numeric thresholds, and real encoded-pattern inventories. + +The public typed contract for this change is `projects/typed-peripheral-unlimitedperipheralworks/integrations/ae2Objects.ts`. The implementation supports Forge AE2 15.2.13 and Fabric AE2 15.0.7-beta without leaking loader-specific fluid units. + +## Goals / Non-Goals + +**Goals:** + +- Give full-block machines direct methods for their one logical device. +- Let a cable peripheral return a side-bound Lua object for each supported multipart device. +- Expose semantic operations such as stock targets, filters, thresholds, and pattern slots rather than a generic `setConfig` method. +- Reuse AE2 mutation APIs so saves, stock replanning, watchers, crafting-provider updates, and storage remounts occur normally. +- Transfer real upgrade cards and patterns through inventories visible to the calling computer. +- Keep the TypeScript contract and runtime method surface aligned. + +**Non-Goals:** + +- Arbitrary addon-defined `AEKeyType` serialization. +- Reconstructing exact NBT- or capability-sensitive keys from an item ID or NBT hash. +- Pattern authoring through a Pattern Encoding Terminal. +- Editing a storage cell through a Cell Workbench. +- Adding configuration to an Annihilation Plane, which has no corresponding AE2 configuration surface. +- Exposing terminal display preferences or every upgrade-only production machine in the first implementation. + +## Decisions + +### Full blocks expose devices directly; cable blocks return side objects + +An ME Interface block and Pattern Provider block each represent one logical device and receive their device methods directly as peripheral plugins. A cable host receives `getSide(direction)`, which returns the concrete API object for the part mounted on that side. + +This avoids a redundant device argument for full blocks and avoids flattening several cable parts into one ambiguous method namespace. The alternative of adding a device ID to every method was rejected because callers already have a natural Minecraft direction for multipart parts. + +Pattern Provider push direction uses a separate direction type that includes `all`, matching AE2's default state without allowing `all` in cable-side lookup. + +### Side objects store locators, not part instances + +A returned object stores the server level, cable position, side, expected part kind, and originating computer access. Each call resolves the current part again. Replacing a part with the same kind keeps the object usable; removing it or changing its kind produces an operational error. + +Holding the original `IPart` was rejected because Lua can retain an object after the part has been removed. Re-resolving also keeps world access on the server thread. + +### Returned objects capture the originating computer + +CC:Tweaked can expose methods on arbitrary returned objects through `@LuaFunction`, but those methods do not automatically receive `IComputerAccess`. `getSide` therefore captures the calling computer access for later upgrade and pattern transfers. The object uses it only to resolve peripheral names visible to that computer. + +### Device APIs use semantic capabilities + +The implementation may share internal helpers, but the Lua surface follows the meaning of each AE2 device: + +| Device | Public concept | +| --- | --- | +| Interface | Desired stock and current local contents | +| Import/Export Bus | Import or export filter | +| Storage Bus | External-storage partition filter | +| Formation Plane | World-placement filter | +| Storage Level Emitter | Monitored resource and threshold | +| Energy Level Emitter | Energy threshold | +| Pattern Provider | Real encoded-pattern inventory | + +A generic `setConfig` was rejected because AE2 `CONFIG_STACKS` and `CONFIG_TYPES` inventories have incompatible amount semantics. + +### Public resources initially support items and fluids + +The API accepts `{ type = "item" | "fluid", name = registryId }`. Stock targets add `count`. Item counts remain item units; fluid counts are millibuckets. Fabric amounts are converted through the existing platform fluid divider. + +Addon key types and exact tagged variants are deferred until UPW has a round-trippable representation. An ID-only setter always describes the default untagged variant. + +### Active filter slots follow installed Capacity Cards + +Import buses, export buses, storage buses, and formation planes expose `18 + 9 * capacityCards`, capped at 63. Reads and writes validate against the active count, not the 63-slot backing inventory. + +Removing a Capacity Card clears filters that become inactive before completing the transfer. This matches AE2 menu cleanup and prevents hidden configuration from unexpectedly returning later. Failing card removal instead was considered safer for preservation but would differ from normal AE2 interaction. + +### Mutations use AE2-owned inventories and managers + +Configuration changes call `ConfigInventory.setStack`, settings use `IConfigManager`, priorities use `IPriorityHost`, and cards use `IUpgradeInventory`. Direct NBT mutation and GUI packet emulation are prohibited. This preserves AE2 listeners and loader behavior. + +### The typed contract remains a separate additive source file + +`integrations/ae2Objects.ts` is the source of the proposed TypeScript surface. It contains only interfaces and aliases, has no provider for returned objects, and does not modify the existing network API in `integrations/ae2.ts`. Generated `.d.ts` and `.lua` files remain build output. + +## Risks / Trade-offs + +- [Fabric export-bus crafting tracker has only nine entries] -> Verify whether the Fabric dependency can be upgraded; otherwise add a narrowly scoped compatibility correction and a regression test before enabling crafting-card scenarios beyond slot nine. +- [Returned objects retain computer access] -> Invalidate transfer operations after detach and never expose the captured object outside its originating Lua value. +- [Capacity-card removal clears ghost filters] -> Document and test the destructive configuration effect before moving the real card. +- [An unconfigured storage emitter sums heterogeneous raw AE amounts] -> Report its threshold unit as `ae_internal`; normalize only when an item or fluid key is selected. +- [A TypeScript union cannot automatically narrow from a method result] -> Keep `getDeviceType()` for Lua inspection and allow consumers to narrow or assert the concrete returned interface. +- [Fabric and Forge integrations are duplicated] -> Keep behavior and tests equivalent while retaining loader-specific AE2 imports and fluid conversion. + +## Migration Plan + +This is additive. Existing AE2 network methods and peripheral types remain unchanged. Add the new plugins and returned objects behind AE2 integration registration, add both-loader GameTests, then publish generated typed-peripheral declarations from `ae2Objects.ts`. Rollback consists of removing the new providers and typed module; no persisted data migration is needed because all state remains AE2-owned. + +## Open Questions + +- Whether Pattern Providers belong in the first implementation or a follow-up focused on real slot inventories. +- Whether exact tagged resources should later be copied from an inventory slot, resolved from an existing ME network key, or support both paths. +- Whether Drive and Chest priority-only plugins provide enough value for a follow-up. diff --git a/openspec/changes/add-ae2-configurable-objects/proposal.md b/openspec/changes/add-ae2-configurable-objects/proposal.md new file mode 100644 index 00000000..0789ff21 --- /dev/null +++ b/openspec/changes/add-ae2-configurable-objects/proposal.md @@ -0,0 +1,30 @@ +## Why + +UPW exposes an AE2 network as a peripheral but does not expose the configuration, upgrade cards, priorities, or real pattern slots of individual AE2 devices. Multipart cable blocks also need a way to address a specific mounted part without flattening every part into one ambiguous peripheral API. + +## What Changes + +- Add direct configuration APIs for full-block ME Interfaces and Pattern Providers. +- Add a cable-host `getSide(side)` method that returns a Lua object bound to a supported AE2 part. +- Add semantic stock, filter, threshold, settings, priority, upgrade-card transfer, and encoded-pattern transfer methods appropriate to each supported device. +- Normalize public fluid amounts to millibuckets across Fabric and Forge. +- Add the proposed typed contract in `projects/typed-peripheral-unlimitedperipheralworks/integrations/ae2Objects.ts`. +- Exclude devices without a meaningful automation configuration surface and defer pattern authoring and inserted-cell editing. + +## Capabilities + +### New Capabilities + +- `ae2-configurable-objects`: Configurable full-block AE2 peripherals and side-bound multipart Lua objects, including their device-specific state and inventory transfers. + +### Modified Capabilities + +None. + +## Impact + +- AE2 integrations in the Fabric and Forge projects. +- Peripheral plugin discovery and multipart cable-side resolution. +- Lua object conversion through CC:Tweaked `@LuaFunction` methods. +- Typed API contract at `projects/typed-peripheral-unlimitedperipheralworks/integrations/ae2Objects.ts`. +- GameTests for both loaders, including card transfers, device replacement, amount normalization, and AE2 mutation callbacks. diff --git a/openspec/changes/add-ae2-configurable-objects/specs/ae2-configurable-objects/spec.md b/openspec/changes/add-ae2-configurable-objects/specs/ae2-configurable-objects/spec.md new file mode 100644 index 00000000..095f7438 --- /dev/null +++ b/openspec/changes/add-ae2-configurable-objects/specs/ae2-configurable-objects/spec.md @@ -0,0 +1,177 @@ +## ADDED Requirements + +### Requirement: Full-block device API +The system SHALL expose the device-specific API directly on a supported full-block ME Interface or Pattern Provider without requiring a device or side argument. The method surface SHALL match the corresponding multipart object except for controls that only exist on the full block. + +#### Scenario: Configure a full-block interface +- **WHEN** a computer calls `setStock` on an attached ME Interface block +- **THEN** the interface updates that stock target without requiring a device selector + +#### Scenario: Configure a full-block pattern provider direction +- **WHEN** a computer calls `setPushDirection` on an attached Pattern Provider block +- **THEN** the block updates its output direction through AE2's normal state mutation + +### Requirement: Multipart side lookup +The system SHALL expose `getSide(side)` on an AE2 cable peripheral for the six Minecraft directions. It SHALL return a Lua object for a supported part or `nil, error` for an empty or unsupported side. + +#### Scenario: Resolve an export bus +- **WHEN** a cable has an Export Bus on its north side and a computer calls `getSide("north")` +- **THEN** the call returns an object exposing the Export Bus API + +#### Scenario: Resolve an empty side +- **WHEN** a cable has no part on its south side and a computer calls `getSide("south")` +- **THEN** the call returns `nil` and a descriptive error + +#### Scenario: Reject an invalid direction +- **WHEN** a computer calls `getSide` with a value outside north, south, east, west, up, and down +- **THEN** the call raises a Lua argument error + +### Requirement: Side-object lifecycle +A returned side object SHALL re-resolve the cable part by level, block position, and side for every world operation. It SHALL operate on a replacement part of the same kind and SHALL fail if the part is removed or replaced by another kind. + +#### Scenario: Same-kind replacement +- **WHEN** an Export Bus is replaced by another Export Bus after its side object was obtained +- **THEN** subsequent object calls operate on the replacement Export Bus + +#### Scenario: Different-kind replacement +- **WHEN** an Export Bus is replaced by an Import Bus after its side object was obtained +- **THEN** subsequent Export Bus object calls fail without mutating the Import Bus + +### Requirement: Resource representation +The system SHALL accept built-in item and fluid resources using a type and registry name. Item amounts SHALL use item counts and fluid amounts SHALL use millibuckets on both loaders. Unsupported key types, unknown IDs, and invalid amounts SHALL raise Lua errors. + +#### Scenario: Configure fluid stock on Fabric +- **WHEN** a computer configures 1000 mB of water on a Fabric ME Interface +- **THEN** the system stores the loader-correct AE2 amount representing one bucket + +#### Scenario: Reject an addon key type +- **WHEN** a computer supplies a resource type other than item or fluid +- **THEN** the operation fails without changing the AE2 configuration + +### Requirement: Upgrade inventory access +Every supported device with a non-empty AE2 upgrade inventory SHALL expose its physical slot count, sparse slot contents, slot detail, and `pullUpgrade` and `pushUpgrade` transfers. Transfers SHALL move real items, honor AE2 card and slot limits, use one-based Lua slots, and resolve inventories through the originating computer. + +#### Scenario: Insert a valid card +- **WHEN** `pullUpgrade` targets an empty compatible upgrade slot and the source inventory contains a valid card +- **THEN** the card moves into the AE2 upgrade inventory and the method returns the moved count + +#### Scenario: Reject an invalid card atomically +- **WHEN** `pullUpgrade` supplies a card that the target device does not accept +- **THEN** the method returns zero or raises the established transfer error and leaves the source inventory unchanged + +#### Scenario: Extract through a returned object +- **WHEN** `pushUpgrade` is called on a side object with a destination peripheral visible to the originating computer +- **THEN** the card moves to that destination using the captured computer access + +### Requirement: Capacity-controlled filters +Import Buses, Export Buses, Storage Buses, and Formation Planes SHALL expose 18 active filter slots plus nine per installed Capacity Card, capped at 63. Filter methods SHALL address only active one-based slots and SHALL represent filters without amounts. + +#### Scenario: Capacity Card expands filters +- **WHEN** a device has two installed Capacity Cards +- **THEN** `getFilterSlotCount` returns 36 and slot 36 is configurable + +#### Scenario: Reject an amount on a filter +- **WHEN** a caller supplies an amount to a type-only filter operation +- **THEN** the operation fails rather than silently discarding the amount + +#### Scenario: Remove a Capacity Card +- **WHEN** removing a Capacity Card reduces the active filter range +- **THEN** filters in newly inactive slots are cleared before the card transfer completes + +### Requirement: ME Interface stock control +An ME Interface block or part SHALL expose nine stock rows. Each configured row SHALL contain a resource and positive target amount, and read methods SHALL report both desired target and current local stored contents when present. + +#### Scenario: Increase an item stock target +- **WHEN** a computer sets an iron-ingot target of 32 on row one +- **THEN** AE2 saves the configuration and replans local stock toward 32 items + +#### Scenario: Clear a stock target +- **WHEN** a computer calls `clearStock` for a configured row +- **THEN** AE2 clears the target and replans remaining local contents back into network storage + +#### Scenario: Reject an excessive target +- **WHEN** a target exceeds the resource capacity accepted by the interface row +- **THEN** the operation fails or reports the normalized accepted value instead of silently presenting the requested value as stored + +### Requirement: Import Bus control +An Import Bus object SHALL expose its import filter, fuzzy mode, redstone mode, and upgrade inventory. An installed Inverter Card SHALL invert filter behavior according to AE2 rules. + +#### Scenario: Set an import filter +- **WHEN** a computer configures water in an active Import Bus filter slot +- **THEN** AE2 rebuilds the import partition filter and wakes or sleeps the bus according to its redstone state + +### Requirement: Export Bus control +An Export Bus object SHALL expose its export filter, fuzzy mode, redstone mode, craft-only setting, scheduling mode, and upgrade inventory. Filter entries SHALL select resource types but SHALL NOT specify an export quantity. + +#### Scenario: Set round-robin export +- **WHEN** a computer sets scheduling mode to `round_robin` +- **THEN** AE2 uses its round-robin configured-slot behavior + +#### Scenario: Enable craft-only mode +- **WHEN** a computer enables craft-only mode and a Crafting Card is installed +- **THEN** the Export Bus requests configured resources through AE2 crafting rather than extracting stored resources + +### Requirement: Storage Bus control +A Storage Bus object SHALL expose its partition filter, fuzzy mode, access mode, storage-filter mode, filter-on-extract setting, priority, and upgrade inventory. Mutations SHALL request the AE2 storage remount needed to apply them. + +#### Scenario: Change storage access +- **WHEN** a computer changes a Storage Bus from `read_write` to `read` +- **THEN** AE2 remounts the external storage with extraction-only access + +#### Scenario: Change priority +- **WHEN** a computer changes the Storage Bus priority +- **THEN** AE2 saves the priority and requests a storage remount + +### Requirement: Formation Plane control +A Formation Plane object SHALL expose its placement filter, fuzzy mode, block-placement setting, priority, and upgrade inventory. Filter and upgrade changes SHALL rebuild its partition behavior through AE2. + +#### Scenario: Configure dropped-item behavior +- **WHEN** a computer disables block placement +- **THEN** the Formation Plane uses AE2's dropped-item behavior for placeable resources + +### Requirement: Storage Level Emitter control +A Storage Level Emitter object SHALL expose one optional monitored resource, threshold, threshold unit, fuzzy mode, craft-via-redstone setting, emitter mode, output state, and upgrade inventory. Item thresholds SHALL use item counts, configured fluid thresholds SHALL use millibuckets, and an unconfigured heterogeneous threshold SHALL report `ae_internal`. + +#### Scenario: Monitor a fluid threshold +- **WHEN** a computer selects water and sets a threshold of 4000 +- **THEN** the emitter compares against four buckets using loader-correct internal units + +#### Scenario: Use a Crafting Card +- **WHEN** a Crafting Card is installed +- **THEN** emitter output reflects whether AE2 is requesting the selected resource, or any resource when none is selected + +### Requirement: Energy Level Emitter control +An Energy Level Emitter object SHALL expose its AE energy threshold, emitter mode, and current redstone output. It SHALL NOT expose upgrade methods because it has no physical upgrade slots. + +#### Scenario: Change energy threshold +- **WHEN** a computer changes the energy threshold +- **THEN** AE2 reinstalls or updates its energy watcher and refreshes emitter output + +### Requirement: Pattern Provider control +A Pattern Provider block or part SHALL expose its nine real encoded-pattern slots, priority, blocking setting, Pattern Access Terminal visibility, and crafting lock mode. Pattern transfers SHALL move real validated encoded-pattern items. A full block SHALL report and accept its six directional push states plus AE2's default `all` state. + +#### Scenario: Insert an encoded pattern +- **WHEN** `pullPattern` selects a valid encoded pattern from another inventory +- **THEN** the item moves into the requested provider slot and AE2 refreshes advertised patterns + +#### Scenario: Reject a non-pattern item +- **WHEN** `pullPattern` selects an item that AE2 cannot decode as a pattern +- **THEN** the item remains in the source inventory and the provider does not advertise it + +### Requirement: AE2-owned mutation callbacks +All mutations SHALL execute on the server thread through AE2 inventories, configuration managers, priority hosts, and block-state APIs. The implementation SHALL NOT mutate AE2 NBT directly or emulate GUI packets. + +#### Scenario: Interface callback execution +- **WHEN** a stock target changes through Lua +- **THEN** AE2 saves the host, recalculates its stock plan, and notifies neighbors through its normal callback + +#### Scenario: Storage callback execution +- **WHEN** a Storage Bus filter changes through Lua +- **THEN** AE2 invalidates the old partition and remounts storage through its normal callback path + +### Requirement: Typed API contract +The runtime method names, arguments, returned structures, and enum values SHALL match `projects/typed-peripheral-unlimitedperipheralworks/integrations/ae2Objects.ts`. Returned object interfaces SHALL be `@noSelf` types and SHALL NOT declare independent peripheral providers. + +#### Scenario: Compile typed contract +- **WHEN** the typed-peripheral project is compiled with TypeScriptToLua +- **THEN** `ae2Objects.ts` compiles without type errors or runtime provider declarations diff --git a/openspec/changes/add-ae2-configurable-objects/tasks.md b/openspec/changes/add-ae2-configurable-objects/tasks.md new file mode 100644 index 00000000..20137607 --- /dev/null +++ b/openspec/changes/add-ae2-configurable-objects/tasks.md @@ -0,0 +1,32 @@ +## 1. Shared API Model + +- [x] 1.1 Add shared resource parsing and representation for item counts and millibucket fluid amounts +- [x] 1.2 Add locator-based returned Lua objects that re-resolve cable parts and validate their expected device kind +- [x] 1.3 Add common priority, settings, filter, and upgrade-inventory operations used by concrete device objects +- [x] 1.4 Capture and invalidate originating computer access for side-object inventory transfers + +## 2. Device Plugins + +- [x] 2.1 Add direct full-block and side-object ME Interface stock APIs +- [x] 2.2 Add Import Bus and Export Bus filter, setting, and upgrade APIs +- [x] 2.3 Add Storage Bus filter, storage setting, priority, and upgrade APIs +- [x] 2.4 Add Formation Plane filter, placement setting, priority, and upgrade APIs +- [x] 2.5 Add Storage and Energy Level Emitter threshold and output APIs +- [x] 2.6 Add full-block and side-object Pattern Provider pattern, setting, priority, and direction APIs +- [x] 2.7 Register equivalent AE2 providers and cable-side dispatch on Fabric and Forge + +## 3. Compatibility and Validation + +- [x] 3.1 Resolve the Fabric 15.0.4-beta Export Bus crafting-tracker defect through an AE2 update or targeted compatibility correction +- [x] 3.2 Validate active filter slots and clear newly inactive filters during Capacity Card extraction +- [x] 3.3 Reject unknown resources, unsupported key types, invalid enum values, inactive slots, and invalid stock amounts without partial mutation +- [x] 3.4 Keep runtime signatures synchronized with `projects/typed-peripheral-unlimitedperipheralworks/integrations/ae2Objects.ts` + +## 4. Tests + +- [x] 4.1 Add shared GameTests for full-block direct APIs and cable `getSide` lookup, replacement, and removal behavior +- [x] 4.2 Add shared GameTests for stock targets, filters, settings, priorities, and AE2 mutation callbacks +- [x] 4.3 Add shared GameTests for atomic upgrade and pattern transfers, card limits, and Capacity Card slot changes +- [x] 4.4 Add loader assertions for millibucket normalization and Export Bus crafting beyond slot nine +- [x] 4.5 Compile the typed-peripheral project and TypeScript fixtures +- [x] 4.6 Run the complete Fabric and Forge GameTest suite and timed multi-loader build diff --git a/openspec/changes/add-ae2-wireless-terminal-turtle-upgrade/.openspec.yaml b/openspec/changes/add-ae2-wireless-terminal-turtle-upgrade/.openspec.yaml new file mode 100644 index 00000000..1b062d3a --- /dev/null +++ b/openspec/changes/add-ae2-wireless-terminal-turtle-upgrade/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-04 diff --git a/openspec/changes/add-ae2-wireless-terminal-turtle-upgrade/design.md b/openspec/changes/add-ae2-wireless-terminal-turtle-upgrade/design.md new file mode 100644 index 00000000..39033d89 --- /dev/null +++ b/openspec/changes/add-ae2-wireless-terminal-turtle-upgrade/design.md @@ -0,0 +1,118 @@ +## Context + +UPW already adapts storage from an `AENetworkBlockEntity` into an `AEItemStorage`, and Tweakium's regular `item_storage` plugin provides item listing and named-peripheral transfers. It also exposes a turtle's inventory through `TurtlePeripheralOwner.storage` and provides `FuelBoon` through `TurtlePeripheralOwner.attachFuel()`. + +The wireless terminal use case differs in two ways. Its AE2 storage is discovered from item NBT and the turtle's current position rather than a stationary block entity, and its transfer endpoint is always the same turtle rather than a peripheral name. A normal `ItemStoragePlugin` therefore cannot be reused unchanged because it retains one storage object and requires named peripherals. + +AE2 15's standard Wireless Terminal stores its linked access-point position in item NBT. Vanilla resolves that block entity to discover the grid, scans the grid's access points for an active in-range access point, and normally drains terminal charge while a menu remains open. CC:Tweaked stores configurable turtle upgrade state separately from the upgrade's constant crafting item, so default upgrade behavior would discard the terminal's link, charge, cards, and other NBT when unequipped. + +## Goals / Non-Goals + +**Goals:** + +- Equip a linked standard AE2 Wireless Terminal directly, without introducing another item or recipe. +- Preserve the exact terminal item across equip, turtle persistence, and unequip. +- Expose network item listing and implicit transfers to and from the turtle inventory. +- Revalidate the linked grid and wireless range on every call without chunk loading. +- Charge turtle fuel rather than terminal AE power for mutation calls. +- Attribute AE2 storage mutations to the turtle owner. +- Publish a TypeScript contract aligned with the runtime API. +- Keep Fabric and Forge behavior and tests equivalent. + +**Non-Goals:** + +- Wireless Crafting Terminal or addon-terminal support. +- Fluid or addon `AEKeyType` access. +- AE2 crafting requests or terminal UI configuration. +- Cross-dimensional, infinite-range, or chunk-loading behavior. +- Terminal battery drain or charging while installed. +- A general redesign of Tweakium's item-storage plugins. + +## Decisions + +### The linked Wireless Terminal is the upgrade item + +Register an AE2-specific turtle upgrade whose custom crafting item is the standard Wireless Terminal. Require link NBT during suitability checks. Do not add a UPW bridge item or recipe. + +The upgrade must override the CC:Tweaked item/data round trip: copy the equipped stack into upgrade data and reconstruct it from that data in `getUpgradeItem`. The runtime peripheral must read and update the terminal state through the turtle side's authoritative upgrade data rather than retaining the serializer's default crafting stack. + +This keeps pairing in AE2's existing Wireless Access Point workflow and guarantees that unequipping returns the player's original terminal. A separate adapter item was rejected because it would duplicate link persistence and add a recipe without improving behavior. + +### Every call resolves a fresh wireless session + +Resolve the linked position from the stored terminal stack on every method call. Require the linked block entity to be already loaded and ticking, obtain its current grid, and search that grid for the nearest active access point in the turtle's current level whose range contains the turtle position. + +Do not cache `IGrid`, `MEStorage`, access-point, or block-entity instances. Turtles move, grids split, access points lose channels, and Lua can retain peripherals across all of those changes. A cached storage reference was rejected because it would allow stale or out-of-range access. + +Use AE2's public terminal/link and access-point APIs where possible, but perform the range calculation against the turtle position rather than constructing a menu host. Match vanilla's strict same-level and active-access-point checks and never call APIs that force a chunk load. + +### The turtle owner is the AE2 action source + +Wrap storage simulation and mutation in `TurtlePeripheralOwner.withPlayer(..., skipInventory = true)` and create a player `IActionSource` from that fake player. This preserves the turtle's owning profile when available and follows the project's existing fake-player conventions. + +Using `IActionSource.empty()` was rejected because it loses ownership attribution. Treating the linked access point as the acting machine was rejected because the access point is only the connection anchor, not the initiator. + +### A dedicated peripheral exposes item-storage-like methods + +Expose peripheral type `ae2_wireless_terminal` with: + +```text +items(detailed?, filter?) +pullItem(itemQuery?, limit?, toSlot?) +pushItem(fromSlotOrItemQuery?, limit?) +``` + +The method names use the turtle caller's perspective: `pullItem` moves AE2 to turtle and `pushItem` moves turtle to AE2. `pullItem` accepts an optional one-based destination slot. `pushItem` accepts either a one-based source slot or an item query as its first argument. + +Use Tweakium's existing item representation and item-query conversion. Reuse `PeripheralWorksConfig.itemStorageTransferLimit`. Implement transfer methods directly or through a narrowly scoped shared transfer primitive; do not add a generalized plugin abstraction solely for this peripheral. + +### A separate crafting monitor owns crafting jobs + +Register a second turtle upgrade using AE2's linked Wireless Crafting Terminal and peripheral type `ae2_crafting_monitor`. It exposes `scheduleCrafting`, `getCraftingJob`, `getCraftingJobs`, and `cancelCrafting`; the regular Wireless Terminal remains storage-only. Both upgrades preserve their complete equipped terminal stack and use the same fresh link/range resolver. + +Use one shared crafting-job peripheral plugin for stationary AE2 blocks and crafting monitor turtles, injecting only connection resolution and action-source handling. Return the submitted `ICraftingLink` UUID after the existing leading success value. Track links in a process-wide weak-key map scoped by weak crafting-service identity, so the cache does not keep completed jobs or grids alive. + +An unknown ID, an ID from another network, or a link already reclaimed by garbage collection is reported uniformly as a missing job. Job tracking intentionally does not survive server restart because AE2 has no public lookup-by-UUID API; using AE2 implementation internals to reconstruct links was rejected. + +### Resolve AE storage with a caller-supplied action source + +The existing `AEItemStorage` couples `MEStorage` operations to `IActionSource.ofMachine(entity)` and `entity.setChanged()`. The wireless peripheral needs the same item conversion and storage behavior with a player action source and no stationary entity. + +Minimally generalize the adapter to receive its action source and optional change callback, preserving the current block integration behavior. If the loader APIs prevent an identical change, keep equivalent narrow implementations in each loader. A second full AE item-storage implementation was rejected because it would duplicate key conversion and transfer semantics. + +### Fuel is charged once per validated transfer call + +Attach a `FuelBoon` with a maximum consumption rate consistent with existing turtle upgrades. After link, range, arguments, and slots are validated, require and consume one base fuel before executing either mutation. A valid operation that moves zero items still costs fuel; rejected calls and `items()` do not. The fuel consumption rate multiplier remains available through inherited Fuel API methods, and CC:Tweaked's fuel-disabled mode remains free. + +Per-item and distance-based charging were rejected by product decision. Terminal AE charge is retained but not consumed, avoiding an installed peripheral that must be repeatedly removed for charging. + +### The typed contract is a new additive source module + +Create `projects/typed-peripheral-unlimitedperipheralworks/integrations/ae2WirelessTerminal.ts`. It should import `ItemQuery` from `@siredvin/typed-peripheral-api/item_storage`, item detail types and `IPeripheralProvider` from `@siredvin/typed-peripheral-base`, and `FuelApi` from `@siredvin/typed-peripheral-api/fuel`. + +Define listing overloads equivalent to `ItemStorageAPI`, redefine `pushItem` and `pullItem` with implicit turtle endpoints and optional slots, extend `FuelApi`, and export an `IPeripheralProvider` for `ae2_wireless_terminal`. Add `ae2CraftingMonitor.ts` for the separate crafting peripheral and update `integrations/ae2.ts` with the same job tracking surface for stationary AE blocks. Generated `.d.ts` and `.lua` files remain untracked build output. + +### Registration remains inside the optional AE2 integration + +Register the serializer, generated turtle upgrade data, client model, language entry, and peripheral only when AE2 integration loading runs. Use the existing integration hooks demonstrated by Nature's Compass and ProjectE. Render the terminal item slightly scaled down and facing upward as the turtle upgrade model rather than adding a custom model asset. + +Keep implementation and GameTests in the Fabric and Forge AE2 integration source sets because core must remain free of hard AE2 references. Preserve equivalent behavior despite the project's supported AE2 version difference. + +## Risks / Trade-offs + +- [Default CC:Tweaked upgrade serialization returns a constant crafting item] -> Explicitly test complete terminal NBT through equip, save/load, and unequip. +- [A turtle can move or the AE2 grid can change between calls] -> Resolve the linked grid, access point, range, storage, and action source for every call. +- [The linked access-point chunk may be unloaded even when another network access point is nearby] -> Match vanilla terminal anchoring and fail without loading the chunk. +- [Fuel can be consumed when a valid transfer moves zero items] -> Document and test the product-selected per-operation semantics. +- [Fabric and Forge AE2 source is duplicated] -> Keep public behavior and GameTests equivalent and avoid loader-specific behavior in the typed contract. +- [Transfer simulation and mutation could use different action sources] -> Build one owner-derived action source per call and use it for both phases. +- [Terminal Energy Cards no longer affect runtime cost] -> Preserve them for exact item round trips but document that turtle fuel replaces terminal charge while installed. + +## Migration Plan + +This is additive and introduces no world migration. Register the optional upgrade and generated data when AE2 is present, add the typed source, and verify both loaders. Removing the feature unregisters the upgrade; players should unequip terminals before rollback so no equipped upgrade data becomes inaccessible. + +## Open Questions + +- Whether future support for Wireless Crafting Terminals should use a second upgrade ID with the same peripheral contract. +- Whether future AE2 versions expose a public wireless-session helper that can replace the small vanilla-equivalent range resolver. diff --git a/openspec/changes/add-ae2-wireless-terminal-turtle-upgrade/proposal.md b/openspec/changes/add-ae2-wireless-terminal-turtle-upgrade/proposal.md new file mode 100644 index 00000000..923f9bd7 --- /dev/null +++ b/openspec/changes/add-ae2-wireless-terminal-turtle-upgrade/proposal.md @@ -0,0 +1,32 @@ +## Why + +Turtles cannot directly use a linked AE2 wireless terminal to inspect network items or exchange items with their own inventory. Existing item-storage methods require a separately named source or target peripheral, which is awkward for a turtle whose local 16-slot inventory is the intended endpoint. + +## What Changes + +- Allow a linked standard AE2 Wireless Terminal to be equipped directly as a turtle peripheral upgrade while preserving the terminal's complete item state. +- Add an AE2 wireless terminal peripheral that lists network items and transfers items between the AE2 network and the owning turtle's inventory without a peripheral-name argument. +- Apply vanilla AE2 link, loaded-network, active-access-point, dimension, and wireless-range constraints on every peripheral call without loading chunks. +- Charge one turtle fuel for each valid item-transfer operation through Tweakium's fuel boon while leaving the terminal's AE charge unchanged. +- Add the proposed typed contract at `projects/typed-peripheral-unlimitedperipheralworks/integrations/ae2WirelessTerminal.ts` and keep generated declarations as build output. +- Add a separate Crafting Monitor turtle upgrade backed by AE2's linked Wireless Crafting Terminal for crafting requests, weak job tracking, and cancellation. +- Keep item storage on the standard Wireless Terminal and crafting control on the Wireless Crafting Terminal; defer fluid storage and cross-dimensional access. + +## Capabilities + +### New Capabilities + +- `ae2-wireless-terminal-turtle-upgrade`: Direct terminal equipping, state preservation, wireless connection validation, network item inspection, turtle-local transfers, fuel charging, a separate Wireless Crafting Terminal monitor upgrade, weak crafting-job tracking, and typed Lua contracts. + +### Modified Capabilities + +None. + +## Impact + +- AE2 integrations in the Fabric and Forge projects. +- Turtle upgrade registration, model registration, language data, and generated upgrade data. +- Persistent CC:Tweaked turtle upgrade NBT containing the equipped terminal state. +- Tweakium turtle ownership, inventory storage, item-query, and fuel-boon integration. +- Typed peripheral sources for the wireless terminal and crafting monitor. +- Fabric and Forge GameTests covering connection constraints, transfers, fuel, and item-state round trips. diff --git a/openspec/changes/add-ae2-wireless-terminal-turtle-upgrade/specs/ae2-wireless-terminal-turtle-upgrade/spec.md b/openspec/changes/add-ae2-wireless-terminal-turtle-upgrade/specs/ae2-wireless-terminal-turtle-upgrade/spec.md new file mode 100644 index 00000000..70a216ae --- /dev/null +++ b/openspec/changes/add-ae2-wireless-terminal-turtle-upgrade/specs/ae2-wireless-terminal-turtle-upgrade/spec.md @@ -0,0 +1,150 @@ +## ADDED Requirements + +### Requirement: Equip a linked AE2 Wireless Terminal directly +The system SHALL register the standard AE2 Wireless Terminal as the crafting item for a turtle peripheral upgrade with peripheral type `ae2_wireless_terminal`. The system SHALL accept only a terminal containing a valid AE2 access-point link and SHALL NOT require a separate UPW item or crafting recipe. + +#### Scenario: Equip a linked terminal +- **WHEN** a turtle equips a standard AE2 Wireless Terminal containing an access-point link +- **THEN** the turtle gains an `ae2_wireless_terminal` peripheral on that side + +#### Scenario: Reject an unlinked terminal +- **WHEN** a turtle attempts to equip a standard AE2 Wireless Terminal without an access-point link +- **THEN** the item is not accepted as the wireless terminal upgrade + +#### Scenario: Do not accept other terminal variants +- **WHEN** a turtle attempts to equip an AE2 Wireless Crafting Terminal or another terminal variant +- **THEN** the item is not accepted as this upgrade + +### Requirement: Preserve the complete terminal item state +The system SHALL copy the equipped terminal's complete item state into persistent turtle upgrade data and SHALL reconstruct that state when the upgrade is unequipped. Peripheral use SHALL NOT consume or modify the terminal's AE charge. + +#### Scenario: Terminal survives an equip round trip +- **WHEN** a linked, charged, named terminal with installed Energy Cards is equipped, saved, loaded, and unequipped +- **THEN** the returned terminal retains its link, charge, name, upgrades, and all other item NBT + +#### Scenario: Peripheral calls leave terminal charge unchanged +- **WHEN** the turtle lists or transfers items through the upgrade +- **THEN** the terminal's stored AE charge remains unchanged + +### Requirement: Resolve a live vanilla wireless connection for every call +The system SHALL resolve the terminal's linked access point and AE2 grid on every peripheral call, then require an active wireless access point from that grid in the turtle's current dimension and within that access point's range. Resolution SHALL NOT load chunks and SHALL NOT retain a grid or storage reference across calls. + +#### Scenario: Use an active access point in range +- **WHEN** the linked network is loaded and has an active access point whose range contains the turtle +- **THEN** the peripheral call operates on that network + +#### Scenario: Linked access point is unavailable +- **WHEN** the terminal is unlinked, the linked access point is missing or unloaded, or its grid is unavailable +- **THEN** the call fails with an operational error and does not load the access point's chunk + +#### Scenario: Turtle is outside wireless coverage +- **WHEN** no active access point from the linked grid is in the turtle's dimension and range +- **THEN** the call fails with an out-of-range operational error + +#### Scenario: Turtle moves between calls +- **WHEN** the turtle moves out of range after a successful call +- **THEN** the next call revalidates its position and fails rather than using a cached connection + +### Requirement: List network items with item-storage query semantics +The peripheral SHALL expose `items(detailed?, filter?)` with the same detailed/base representations and item-query matching used by the regular `item_storage` API. Listing SHALL include only item keys and SHALL NOT include AE2 fluids or addon key types. + +#### Scenario: List detailed items +- **WHEN** Lua calls `items()` or `items(true)` while connected +- **THEN** the peripheral returns the matching network items using detailed item representations + +#### Scenario: List filtered base items +- **WHEN** Lua calls `items(false, filter)` while connected +- **THEN** the peripheral returns only matching network items using base item representations + +### Requirement: Push network items into the turtle inventory +The peripheral SHALL expose `pullItem(itemQuery?, limit?, toSlot?)`, which moves matching items from the connected AE2 network into the owning turtle's 16-slot inventory. `toSlot`, when present, SHALL use one-based turtle slot numbering. The operation SHALL respect the configured item-storage transfer limit. + +#### Scenario: Pull into any turtle slot +- **WHEN** Lua calls `pullItem(query, limit)` with matching network items and available turtle capacity +- **THEN** up to the effective limit is extracted from AE2 and inserted into the turtle inventory and the moved count is returned + +#### Scenario: Pull into a selected turtle slot +- **WHEN** Lua calls `pullItem(query, limit, toSlot)` with a valid compatible destination slot +- **THEN** items are inserted only into that turtle slot and the moved count is returned + +#### Scenario: Reject an invalid destination slot +- **WHEN** `toSlot` is outside the inclusive range 1 through 16 +- **THEN** the call fails without moving items or consuming fuel + +### Requirement: Pull turtle items into the network +The peripheral SHALL expose `pushItem(fromSlotOrItemQuery?, limit?)`, which moves items from the owning turtle's inventory into the connected AE2 network. A numeric first argument SHALL select a one-based turtle source slot; a string or table SHALL use the regular item-query semantics across the turtle inventory. The operation SHALL respect the configured item-storage transfer limit. + +#### Scenario: Push from any turtle slot +- **WHEN** Lua calls `pushItem(query, limit)` with matching turtle items and available AE2 capacity +- **THEN** up to the effective limit is removed from the turtle inventory, inserted into AE2, and the moved count is returned + +#### Scenario: Push from a selected turtle slot +- **WHEN** Lua calls `pushItem(fromSlot, limit)` with a valid source slot +- **THEN** only items from that turtle slot are offered to AE2 and the moved count is returned + +#### Scenario: Reject an invalid source slot +- **WHEN** `fromSlot` is outside the inclusive range 1 through 16 +- **THEN** the call fails without moving items or consuming fuel + +### Requirement: Charge turtle fuel per valid transfer call +The peripheral SHALL use a Tweakium `FuelBoon` and consume one base turtle fuel for each connected, validated `pushItem` or `pullItem` operation. Fuel SHALL be charged per call regardless of the number of items moved, including when zero items move after validation. Read-only calls and calls rejected before transfer execution SHALL consume no fuel. Turtles configured with fuel disabled SHALL execute without fuel consumption. + +#### Scenario: Successful transfer consumes fuel +- **WHEN** a connected turtle with fuel calls `pushItem` or `pullItem` +- **THEN** one base fuel, adjusted by the configured fuel consumption rate, is consumed + +#### Scenario: Valid no-op transfer consumes fuel +- **WHEN** a connected turtle calls a transfer method but no item matches or the destination accepts nothing +- **THEN** the method returns zero and consumes one base fuel + +#### Scenario: Insufficient fuel prevents transfer +- **WHEN** a fuel-enabled turtle lacks the required fuel for a transfer operation +- **THEN** the call fails and no items move + +#### Scenario: Connection failure does not consume fuel +- **WHEN** a transfer call fails link, grid, dimension, range, or slot validation +- **THEN** no turtle fuel is consumed + +### Requirement: Attribute AE2 mutations to the turtle owner +The system SHALL perform AE2 insertion and extraction with a player action source derived from the turtle's owning-player fake player, following the existing Tweakium turtle-owner mechanism. + +#### Scenario: Transfer uses turtle owner context +- **WHEN** a turtle transfers items with AE2 +- **THEN** both simulated and committed AE2 storage operations use the turtle owner's action source + +### Requirement: Publish a typed peripheral contract +The typed project SHALL define the storage contract in `integrations/ae2WirelessTerminal.ts` and the crafting contract in `integrations/ae2CraftingMonitor.ts`. The storage contract SHALL declare the `ae2_wireless_terminal` provider, item listing overloads, implicit-turtle transfer signatures, and inherited fuel methods. The crafting contract SHALL declare the `ae2_crafting_monitor` provider and job methods. The stationary AE2 contract SHALL expose the same job type, IDs, lookup, listing, and cancellation methods. Generated `.d.ts` and `.lua` files SHALL remain build output. + +#### Scenario: Compile the typed contract +- **WHEN** the typed-peripheral project is built +- **THEN** the new source compiles and produces declarations matching the runtime Lua surface + +#### Scenario: Resolve the peripheral provider +- **WHEN** a TypeScript consumer uses the exported wireless terminal provider +- **THEN** it resolves peripherals whose runtime type is `ae2_wireless_terminal` + +### Requirement: Request and weakly track AE2 crafting jobs +The system SHALL register an `ae2_crafting_monitor` turtle upgrade using an exact linked AE2 Wireless Crafting Terminal and preserve that stack unchanged. A shared crafting-job peripheral plugin SHALL serve stationary AE2 peripherals and crafting monitor upgrades with injected connection and action-source behavior. Successful `scheduleCrafting` calls SHALL retain the existing leading `true` result and additionally return the submitted AE2 crafting-link UUID. The server process SHALL weakly track submitted links per AE2 crafting service without retaining links or grids solely for tracking. The regular `ae2_wireless_terminal` peripheral SHALL remain storage-only. + +#### Scenario: Request a tracked crafting job +- **WHEN** AE2 accepts a crafting request +- **THEN** the call returns `true` and a job ID that can be queried or canceled while the weak link remains available + +#### Scenario: Query a tracked crafting job +- **WHEN** Lua queries a known job ID through a stationary AE2 peripheral or in-range crafting monitor on the same network +- **THEN** it receives the ID, target, requested amount, and `running`, `done`, or `canceled` state + +#### Scenario: Cancel a running job +- **WHEN** Lua cancels a known running job +- **THEN** the AE2 crafting link is canceled and the call returns true + +#### Scenario: Handle a missing job +- **WHEN** an ID is unknown, belongs to another network, or its weakly cached link has been reclaimed +- **THEN** lookup and cancellation return `nil` and a not-found error without throwing + +### Requirement: Keep item storage local-dimensional +The first version's storage API SHALL NOT expose fluids, terminal user-interface settings, cross-dimensional access, or Wireless Crafting Terminal support. Crafting requests MAY target AE2 item or fluid patterns through the existing mode parameter. + +#### Scenario: Network contains non-item keys +- **WHEN** the connected AE2 network contains fluids or addon-defined keys +- **THEN** `items()` omits those keys and transfer methods operate only on items diff --git a/openspec/changes/add-ae2-wireless-terminal-turtle-upgrade/tasks.md b/openspec/changes/add-ae2-wireless-terminal-turtle-upgrade/tasks.md new file mode 100644 index 00000000..704371d6 --- /dev/null +++ b/openspec/changes/add-ae2-wireless-terminal-turtle-upgrade/tasks.md @@ -0,0 +1,45 @@ +## 1. AE2 Storage And Wireless Resolution + +- [x] 1.1 Generalize the Fabric and Forge `AEItemStorage` adapters to accept a caller-supplied `IActionSource` and change callback while preserving stationary ME block behavior. +- [x] 1.2 Add equivalent Fabric and Forge wireless resolvers that reconstruct the stored terminal, resolve its loaded linked access point and current grid, and select an active same-level access point containing the turtle in range without loading chunks. +- [x] 1.3 Return distinct operational errors for invalid stored state, unavailable linked network, and out-of-range access, and ensure each peripheral method resolves a fresh session. + +## 2. Turtle Upgrade State And Registration + +- [x] 2.1 Implement the AE2 wireless terminal turtle upgrade and peripheral in both loader integration source sets using a `TurtlePeripheralOwner` with an attached `FuelBoon`. +- [x] 2.2 Persist the complete equipped Wireless Terminal stack in authoritative side upgrade data and reconstruct it unchanged on unequip, while rejecting unlinked terminals and non-standard terminal variants. +- [x] 2.3 Register the optional `ae2_wireless_terminal` serializer, generated turtle upgrade data, scaled upward-facing terminal model, and English and Ukrainian language entries through the existing AE2 integration hooks. + +## 3. Lua Item API + +- [x] 3.1 Implement `items(detailed?, filter?)` with the regular item-storage representations and query semantics while excluding fluids and addon keys. +- [x] 3.2 Implement `pullItem(itemQuery?, limit?, toSlot?)` from AE2 into the turtle inventory with one-based slot validation and the configured item-storage transfer limit. +- [x] 3.3 Implement `pushItem(fromSlotOrItemQuery?, limit?)` from the turtle inventory into AE2 with one-based slot validation and the configured item-storage transfer limit. +- [x] 3.4 Use one owner-derived player `IActionSource` for transfer simulation and mutation, consume one base fuel after connection and argument validation for every transfer call including valid zero-move calls, and leave terminal AE charge unchanged. + +## 4. Typed Peripheral Contract + +- [x] 4.1 Create `projects/typed-peripheral-unlimitedperipheralworks/integrations/ae2WirelessTerminal.ts` with item listing overloads, implicit-turtle transfer signatures, optional one-based slot parameters, inherited `FuelApi`, and an `ae2_wireless_terminal` provider. +- [x] 4.2 Add a TypeScript fixture that resolves the provider and type-checks detailed/base listing plus push, pull, slot, and fuel calls. +- [x] 4.3 Run `./gradlew :typescript-tests:compileTestLua --no-daemon` with the required timeout and log capture, and verify generated declarations remain build output. + +## 5. Multi-Loader GameTests + +- [x] 5.1 Add equivalent Fabric and Forge GameTests that equip a linked terminal and verify link, charge, Energy Cards, name, and arbitrary NBT survive save/load and unequip. +- [x] 5.2 Test in-range listing, filtered representations, both transfer directions, explicit turtle slots, transfer limits, and owner-attributed AE2 simulation and mutation. +- [x] 5.3 Test one-fuel-per-call behavior for successful and valid zero-move transfers, insufficient and disabled fuel, free read calls, and no fuel loss on connection or slot validation errors. +- [x] 5.4 Test missing or unloaded linked access points, unavailable grids, inactive and out-of-range access points, turtle movement between calls, same-dimension enforcement, no chunk loading, and unchanged terminal AE charge. + +## 6. Verification + +- [x] 6.1 Run formatting and targeted compile checks for both loaders and fix all failures. +- [x] 6.2 Run the root `gameTest` task for Fabric and Forge under `xvfb-run` with the required timeout and log capture, and verify all existing and new GameTests pass. +- [x] 6.3 Run the timed root `build --no-daemon` with complete log capture and verify the multi-loader build and typed-peripheral outputs pass. + +## 7. Crafting Job Tracking + +- [x] 7.1 Return AE2 crafting-link UUIDs from successful stationary and crafting-monitor `scheduleCrafting` calls while preserving existing response ordering and errors. +- [x] 7.2 Extract one shared stationary/turtle crafting-job peripheral plugin with injected context, weakly cached links, lookup, listing, cancellation, terminal-state, and explicit missing-job responses. +- [x] 7.3 Register a separate `ae2_crafting_monitor` upgrade using a linked Wireless Crafting Terminal and remove crafting methods from the regular wireless terminal. +- [x] 7.4 Publish separate crafting-monitor typings and exercise runtime missing-job responses through the crafting monitor. +- [x] 7.5 Run datagen, strict OpenSpec validation, the minimal multi-loader GameTests, and the timed root build. diff --git a/projects/core/src/main/kotlin/site/siredvin/peripheralworks/client/turtle/ScaledItemModeller.kt b/projects/core/src/main/kotlin/site/siredvin/peripheralworks/client/turtle/ScaledItemModeller.kt index 0af79cef..25a6a70f 100644 --- a/projects/core/src/main/kotlin/site/siredvin/peripheralworks/client/turtle/ScaledItemModeller.kt +++ b/projects/core/src/main/kotlin/site/siredvin/peripheralworks/client/turtle/ScaledItemModeller.kt @@ -9,13 +9,14 @@ import dan200.computercraft.api.turtle.ITurtleUpgrade import dan200.computercraft.api.turtle.TurtleSide import org.joml.Quaternionf -class ScaledItemModeller(scaleFactor: Float, modelPixelSize: Int = 16) : TurtleUpgradeModeller { +class ScaledItemModeller(scaleFactor: Float, modelPixelSize: Int = 16, xRotationDegrees: Float = 0f, heightShift: Float = 0f) : TurtleUpgradeModeller { companion object { - fun buildMatrix(side: TurtleSide, scaleFactor: Float, modelPixelSize: Int): Transformation { + fun buildMatrix(side: TurtleSide, scaleFactor: Float, modelPixelSize: Int, xRotationDegrees: Float = 0f, heightShift: Float = 0f): Transformation { val shiftFactor = (1 - scaleFactor) / (2 * scaleFactor) val stack = PoseStack() - stack.translate(0.5f, 0.5f, 0.5f) + stack.translate(0.5f, 0.5f + heightShift, 0.5f) + stack.mulPose(Quaternionf().rotateLocalX(xRotationDegrees * 0.017453292f)) stack.mulPose(Quaternionf().rotateLocalY(90f * 0.017453292f)) stack.translate(-0.5f, -0.5f, -0.5f) stack.pushPose() @@ -37,8 +38,8 @@ class ScaledItemModeller(scaleFactor: Float, modelPixelSize: } } - private val leftTransformation = buildMatrix(TurtleSide.LEFT, scaleFactor, modelPixelSize) - private val rightTransformation = buildMatrix(TurtleSide.RIGHT, scaleFactor, modelPixelSize) + private val leftTransformation = buildMatrix(TurtleSide.LEFT, scaleFactor, modelPixelSize, xRotationDegrees, heightShift) + private val rightTransformation = buildMatrix(TurtleSide.RIGHT, scaleFactor, modelPixelSize, xRotationDegrees, heightShift) override fun getModel( upgrade: T, diff --git a/projects/core/src/testMod/resources/gameteststructures/peripheralworksgametests.ae2_configurable_objects.snbt b/projects/core/src/testMod/resources/gameteststructures/peripheralworksgametests.ae2_configurable_objects.snbt new file mode 100644 index 00000000..d6a79906 --- /dev/null +++ b/projects/core/src/testMod/resources/gameteststructures/peripheralworksgametests.ae2_configurable_objects.snbt @@ -0,0 +1 @@ +{size:[5,4,5],entities:[],data:[{pos:[2,0,1],state:"minecraft:polished_andesite"},{pos:[2,0,2],state:"minecraft:polished_andesite"},{pos:[2,0,3],state:"minecraft:polished_andesite"},{pos:[2,2,2],state:"computercraft:computer_normal{facing:north,state:off}",nbt:{ComputerId:1,Label:"peripheralworksgametests.ae2_configurable_objects",On:0b,id:"computercraft:computer_normal"}},{pos:[1,2,2],state:"minecraft:chest{facing:north,type:single,waterlogged:false}",nbt:{id:"minecraft:chest"}}],palette:["minecraft:polished_andesite","computercraft:computer_normal{facing:north,state:off}","minecraft:chest{facing:north,type:single,waterlogged:false}"],DataVersion:3465} diff --git a/projects/core/src/testMod/resources/gameteststructures/peripheralworksgametests.ae2_wireless_terminal.snbt b/projects/core/src/testMod/resources/gameteststructures/peripheralworksgametests.ae2_wireless_terminal.snbt new file mode 100644 index 00000000..ad1e4e5b --- /dev/null +++ b/projects/core/src/testMod/resources/gameteststructures/peripheralworksgametests.ae2_wireless_terminal.snbt @@ -0,0 +1 @@ +{size:[7,4,7],entities:[],data:[{pos:[3,0,2],state:"minecraft:polished_andesite"},{pos:[3,1,2],state:"computercraft:turtle_normal{facing:south,waterlogged:false}",nbt:{ComputerId:1,Fuel:10,Items:[{Count:8b,Slot:15b,id:"minecraft:gold_ingot"}],Label:"peripheralworksgametests.ae2_wireless_terminal",On:0b,Owner:{LowerId:-6876936588741668278L,Name:"Dev",UpperId:4039158846114182220L},Slot:0,id:"computercraft:turtle_normal"}}],palette:["minecraft:polished_andesite","computercraft:turtle_normal{facing:south,waterlogged:false}"],DataVersion:3465} diff --git a/projects/fabric/build.gradle.kts b/projects/fabric/build.gradle.kts index 79564349..bac1b263 100644 --- a/projects/fabric/build.gradle.kts +++ b/projects/fabric/build.gradle.kts @@ -35,8 +35,11 @@ fabricShaking { } if (minimalTestEnvironment) { - sourceSets.main { kotlin.exclude("site/siredvin/peripheralworks/integrations/**") } - tasks.named("compileKotlin") { exclude("**/integrations/**") } + val excludedIntegrations = file("src/main/kotlin/site/siredvin/peripheralworks/integrations").listFiles()!! + .filter { it.isDirectory && it.name != "ae2" } + .map { "**/integrations/${it.name}/**" } + sourceSets.main { kotlin.exclude(excludedIntegrations) } + tasks.named("compileKotlin") { exclude(excludedIntegrations) } } val testMod = sourceSets.create("testMod") { @@ -112,10 +115,8 @@ repositories { } dependencies { - if (!minimalTestEnvironment) { - modApi(libs.bundles.externalMods.fabric.integrations.api) { - exclude("net.fabricmc.fabric-api") - } + modApi(libs.bundles.externalMods.fabric.integrations.api) { + exclude("net.fabricmc.fabric-api") } modImplementation(libs.bundles.fabric.core) @@ -135,7 +136,9 @@ dependencies { exclude("net.fabricmc", "fabric-loader") } - if (!minimalTestEnvironment) { + if (minimalTestEnvironment) { + modImplementation(libs.ae2.fabric) + } else { libs.bundles.externalMods.fabric.integrations.full.get().map { modCompileOnly(it) } libs.bundles.externalMods.fabric.integrations.active.get().map { modRuntimeOnly(it) } libs.bundles.externalMods.fabric.integrations.activedep.get().map { modRuntimeOnly(it) } diff --git a/projects/fabric/src/generated/resources/.cache/02059454eca80d9e0c9e9e11fd54b2d59e706782 b/projects/fabric/src/generated/resources/.cache/02059454eca80d9e0c9e9e11fd54b2d59e706782 index d08372e6..3e0235d6 100644 --- a/projects/fabric/src/generated/resources/.cache/02059454eca80d9e0c9e9e11fd54b2d59e706782 +++ b/projects/fabric/src/generated/resources/.cache/02059454eca80d9e0c9e9e11fd54b2d59e706782 @@ -1,2 +1,2 @@ -// 1.20.1 2026-07-26T10:53:36.338538167 Unlimited Peripheral Works/ComputerLanguageuk_ua -fff76a15a1370138c05a5a2a960863839e43eb78 assets/peripheralworks/lang/uk_ua.json +// 1.20.1 2026-08-04T16:34:08.472242312 Unlimited Peripheral Works/ComputerLanguageuk_ua +c8d94a3ecb2aff5520d03b37fc5b860dae444a99 assets/peripheralworks/lang/uk_ua.json diff --git a/projects/fabric/src/generated/resources/.cache/0bc303fc01858bc5bb03e4fc1dd179f5647fa6e9 b/projects/fabric/src/generated/resources/.cache/0bc303fc01858bc5bb03e4fc1dd179f5647fa6e9 index fa8d7928..59f5c056 100644 --- a/projects/fabric/src/generated/resources/.cache/0bc303fc01858bc5bb03e4fc1dd179f5647fa6e9 +++ b/projects/fabric/src/generated/resources/.cache/0bc303fc01858bc5bb03e4fc1dd179f5647fa6e9 @@ -1,2 +1,2 @@ -// 1.20.1 2026-07-26T10:53:36.337098203 Unlimited Peripheral Works/ComputerLanguageen_us -da2584953c12d8da1c2136f644791cf4b53e98e7 assets/peripheralworks/lang/en_us.json +// 1.20.1 2026-08-04T16:34:08.470505558 Unlimited Peripheral Works/ComputerLanguageen_us +cd4e92fe3d59cdde19bfb8dc20fb68a284246336 assets/peripheralworks/lang/en_us.json diff --git a/projects/fabric/src/generated/resources/.cache/531a261a2f38c8bf4ccd118f4d3ba50c31382e4c b/projects/fabric/src/generated/resources/.cache/531a261a2f38c8bf4ccd118f4d3ba50c31382e4c index bf7ce213..236403ab 100644 --- a/projects/fabric/src/generated/resources/.cache/531a261a2f38c8bf4ccd118f4d3ba50c31382e4c +++ b/projects/fabric/src/generated/resources/.cache/531a261a2f38c8bf4ccd118f4d3ba50c31382e4c @@ -1,4 +1,4 @@ -// 1.20.1 2026-07-26T10:53:36.338088049 Unlimited Peripheral Works/Recipes +// 1.20.1 2026-08-04T16:34:08.471901553 Unlimited Peripheral Works/Recipes 379f6f63779c41d1be5a1de6c1315cdc0e712956 data/peripheralworks/recipes/display_pedestal.json a2696e406e9032af7d01eb6c0b534b706f6d9f5f data/minecraft/recipes/card_clean.json 09177d9534a5723cf4e8af294cb05de7b5b398e2 data/minecraft/recipes/statue_cloning.json diff --git a/projects/fabric/src/generated/resources/.cache/607a26b838614ef5b743d5bbf3ae3d2c499369d8 b/projects/fabric/src/generated/resources/.cache/607a26b838614ef5b743d5bbf3ae3d2c499369d8 index 8d86d20d..9223751e 100644 --- a/projects/fabric/src/generated/resources/.cache/607a26b838614ef5b743d5bbf3ae3d2c499369d8 +++ b/projects/fabric/src/generated/resources/.cache/607a26b838614ef5b743d5bbf3ae3d2c499369d8 @@ -1,4 +1,4 @@ -// 1.20.1 2026-07-26T10:53:36.334653245 Unlimited Peripheral Works/Pocket Computer Upgrades +// 1.20.1 2026-08-04T16:34:08.469052612 Unlimited Peripheral Works/Pocket Computer Upgrades 2d73257c0a967655f5a7acfa66bf9c39308db3f1 data/peripheralworks/computercraft/pocket_upgrades/ultimate_sensor.json 911a593d4aafc165c692b127978a82c7f08b960e data/peripheralworks/computercraft/pocket_upgrades/hologram_projector.json c6585d2541a807aca0f82eb85fd2e9aa81dd677c data/peripheralworks/computercraft/pocket_upgrades/universal_scanner.json diff --git a/projects/fabric/src/generated/resources/.cache/6395424bffbf73e09f2569a090a0c7904acfe74e b/projects/fabric/src/generated/resources/.cache/6395424bffbf73e09f2569a090a0c7904acfe74e index 790bc1f0..ce87ace6 100644 --- a/projects/fabric/src/generated/resources/.cache/6395424bffbf73e09f2569a090a0c7904acfe74e +++ b/projects/fabric/src/generated/resources/.cache/6395424bffbf73e09f2569a090a0c7904acfe74e @@ -1,2 +1,2 @@ -// 1.20.1 2026-07-26T10:53:36.336781249 Unlimited Peripheral Works/Tags for minecraft:entity_type +// 1.20.1 2026-08-04T16:34:08.470360861 Unlimited Peripheral Works/Tags for minecraft:entity_type fd653137ea41bed52ba37a69079d9c9c67d5c0c5 data/peripheralworks/tags/entity_types/link_blocklist.json diff --git a/projects/fabric/src/generated/resources/.cache/71c885c52f014a226c863b3b638bfcbefe557470 b/projects/fabric/src/generated/resources/.cache/71c885c52f014a226c863b3b638bfcbefe557470 index 824a5473..7e64f3f9 100644 --- a/projects/fabric/src/generated/resources/.cache/71c885c52f014a226c863b3b638bfcbefe557470 +++ b/projects/fabric/src/generated/resources/.cache/71c885c52f014a226c863b3b638bfcbefe557470 @@ -1,7 +1,7 @@ -// 1.20.1 2026-07-26T10:53:36.337291381 Unlimited Peripheral Works/minecraft:block Loot Table +// 1.20.1 2026-08-04T16:34:08.4706229 Unlimited Peripheral Works/minecraft:block Loot Table c41e20b1f8780d66f6a04d3be5bd08af1a4f0067 data/peripheralworks/loot_tables/blocks/display_pedestal.json -fbbfca4d72a5799857020a3d05f2541ca11c4d71 data/peripheralworks/loot_tables/blocks/item_pedestal.json 49ceaf9817f5ac29e3613656fc964267d13e4ec4 data/peripheralworks/loot_tables/blocks/reality_forger.json +fbbfca4d72a5799857020a3d05f2541ca11c4d71 data/peripheralworks/loot_tables/blocks/item_pedestal.json 320af2c7b0ae9a8a6323b415204098004eeceb0c data/peripheralworks/loot_tables/blocks/statue_workbench.json d687230cc9ef8c2e199ff471525d3244bd321197 data/peripheralworks/loot_tables/blocks/map_pedestal.json a6c257852185d4ed159fd5d346ab4b2c7fa5238e data/peripheralworks/loot_tables/blocks/remote_observer.json diff --git a/projects/fabric/src/generated/resources/.cache/767876eefc79819d4fe92ae5cc7876886174715a b/projects/fabric/src/generated/resources/.cache/767876eefc79819d4fe92ae5cc7876886174715a index cbb90ecf..f7e01d1a 100644 --- a/projects/fabric/src/generated/resources/.cache/767876eefc79819d4fe92ae5cc7876886174715a +++ b/projects/fabric/src/generated/resources/.cache/767876eefc79819d4fe92ae5cc7876886174715a @@ -1,4 +1,4 @@ -// 1.20.1 2026-07-26T10:53:36.335494128 Unlimited Peripheral Works/Block State Definitions +// 1.20.1 2026-08-04T16:34:08.46966642 Unlimited Peripheral Works/Block State Definitions 36c244142cb28bcce875cbcb68f6102141bf78d5 assets/peripheralworks/blockstates/peripheral_casing.json efd5ba47bc8f73def3c977d8887af54ef4ad1370 assets/peripheralworks/models/block/map_pedestal.json c8fb84d1128c18b2958d371b6b86c1b3ce6ef08e assets/peripheralworks/blockstates/ultimate_sensor.json @@ -10,8 +10,8 @@ fa09940646b26b32e317d46436ba6e5708942a3d assets/peripheralworks/models/block/sta 767287600ba5632ed8a38b3d2675cc8ff924a609 assets/peripheralworks/models/item/flexible_reality_anchor.json c23515b1dd22a9ab692eb1d023862f1e952c93ff assets/peripheralworks/blockstates/map_pedestal.json 778759f975277f38234eec1b10e5b25082f838e1 assets/peripheralworks/models/item/peripheralium_hub.json -3f9d98dbf30a19bef2f380a22aa02a21e5c2a49d assets/peripheralworks/blockstates/universal_scanner.json d51f30f284b2cf682dcc04372764ce53718de77c assets/peripheralworks/blockstates/informative_registry.json +3f9d98dbf30a19bef2f380a22aa02a21e5c2a49d assets/peripheralworks/blockstates/universal_scanner.json c584ae8cb969815c79c844b4063142c7b18acc5a assets/peripheralworks/models/turtle/hologram_projector_left.json 8d77c1f92bd14f27cd16d72a9ea98032581def5c assets/peripheralworks/models/item/netherite_peripheralium_hub.json a83df0770a4d517d4f4d3bb447d0eda00bcfc4a1 assets/peripheralworks/blockstates/statue_workbench.json diff --git a/projects/fabric/src/generated/resources/.cache/82679a7db4466355c130a976d950498c822528b1 b/projects/fabric/src/generated/resources/.cache/82679a7db4466355c130a976d950498c822528b1 index e13d22ce..b8b6da49 100644 --- a/projects/fabric/src/generated/resources/.cache/82679a7db4466355c130a976d950498c822528b1 +++ b/projects/fabric/src/generated/resources/.cache/82679a7db4466355c130a976d950498c822528b1 @@ -1,6 +1,8 @@ -// 1.20.1 2026-07-26T10:53:36.337809388 Unlimited Peripheral Works/Turtle Upgrades +// 1.20.1 2026-08-04T16:34:08.470975171 Unlimited Peripheral Works/Turtle Upgrades c6585d2541a807aca0f82eb85fd2e9aa81dd677c data/peripheralworks/computercraft/turtle_upgrades/universal_scanner.json +938c77ddc4b7e3d1f4b7a7a1adcf4c610c64f1d7 data/peripheralworks/computercraft/turtle_upgrades/ae2_crafting_monitor.json 2246ea1d5805e9afda36c4f1265f520598315dc9 data/peripheralworks/computercraft/turtle_upgrades/netherite_peripheralium_hub.json +25be7c06a318cf62fa70a4f814e8e0370076cd20 data/peripheralworks/computercraft/turtle_upgrades/ae2_wireless_terminal.json 2d73257c0a967655f5a7acfa66bf9c39308db3f1 data/peripheralworks/computercraft/turtle_upgrades/ultimate_sensor.json 911a593d4aafc165c692b127978a82c7f08b960e data/peripheralworks/computercraft/turtle_upgrades/hologram_projector.json d718e1fc9579bfe9b1ed23e3c237508eaab815a9 data/peripheralworks/computercraft/turtle_upgrades/natures_compass.json diff --git a/projects/fabric/src/generated/resources/.cache/fc9938a5ed47eba4f6288c566e1715997233e407 b/projects/fabric/src/generated/resources/.cache/fc9938a5ed47eba4f6288c566e1715997233e407 index e1279b16..d4c9c247 100644 --- a/projects/fabric/src/generated/resources/.cache/fc9938a5ed47eba4f6288c566e1715997233e407 +++ b/projects/fabric/src/generated/resources/.cache/fc9938a5ed47eba4f6288c566e1715997233e407 @@ -1,4 +1,4 @@ -// 1.20.1 2026-07-26T10:53:36.337583137 Unlimited Peripheral Works/Tags for minecraft:block +// 1.20.1 2026-08-04T16:34:08.470845684 Unlimited Peripheral Works/Tags for minecraft:block b5037659257ff31abadd72db3bd95e96e1a4af16 data/peripheralworks/tags/blocks/peripheral_proxy_forbidden.json 4e7a5645e6b3b9adc27b24377cbd15d93f6587bd data/computercraft/tags/blocks/peripheral_hub_ignore.json acf9379ee065125e563d0d56d0e3ea5db91e9815 data/peripheralworks/tags/blocks/reality_forger_forbidden.json diff --git a/projects/fabric/src/generated/resources/.cache/ff035bb697732aad9f15f284633c652c364b9404 b/projects/fabric/src/generated/resources/.cache/ff035bb697732aad9f15f284633c652c364b9404 index 275cb0cb..48afa092 100644 --- a/projects/fabric/src/generated/resources/.cache/ff035bb697732aad9f15f284633c652c364b9404 +++ b/projects/fabric/src/generated/resources/.cache/ff035bb697732aad9f15f284633c652c364b9404 @@ -1,3 +1,3 @@ -// 1.20.1 2026-07-26T10:53:36.338807729 Unlimited Peripheral Works/Tags for minecraft:item +// 1.20.1 2026-08-04T16:34:08.472438595 Unlimited Peripheral Works/Tags for minecraft:item 31939d3b804f748a932a97bb3d1ca071c930070b data/peripheralworks/tags/items/peripheral_proxy_forbidden.json d419b420963d6122db39dd885d326a0aa4bfc8eb data/peripheralworks/tags/items/reality_forger_forbidden.json diff --git a/projects/fabric/src/generated/resources/assets/peripheralworks/lang/en_us.json b/projects/fabric/src/generated/resources/assets/peripheralworks/lang/en_us.json index 809a2943..155ca3fb 100644 --- a/projects/fabric/src/generated/resources/assets/peripheralworks/lang/en_us.json +++ b/projects/fabric/src/generated/resources/assets/peripheralworks/lang/en_us.json @@ -157,6 +157,8 @@ "tooltip.peripheralworks.remote_observer_range": " §6Max range of observed block: %s", "tooltip.peripheralworks.universal_scanner_free_range": " §6Cost-free scan range: %s", "tooltip.peripheralworks.universal_scanner_max_range": " §6Max scan range: %s", + "turtle.peripheralworks.ae2_crafting_monitor": "AE crafting monitor", + "turtle.peripheralworks.ae2_wireless_terminal": "AE terminal", "turtle.peripheralworks.hologram_projector": "Projecting", "turtle.peripheralworks.natures_compass": "Nature Compassing", "turtle.peripheralworks.netherite_peripheralium_hub": "Netherite Hub", diff --git a/projects/fabric/src/generated/resources/assets/peripheralworks/lang/uk_ua.json b/projects/fabric/src/generated/resources/assets/peripheralworks/lang/uk_ua.json index dd05d965..2d1fe21b 100644 --- a/projects/fabric/src/generated/resources/assets/peripheralworks/lang/uk_ua.json +++ b/projects/fabric/src/generated/resources/assets/peripheralworks/lang/uk_ua.json @@ -157,6 +157,8 @@ "tooltip.peripheralworks.remote_observer_range": " §6Максимальна дальність стостерігання: %s", "tooltip.peripheralworks.universal_scanner_free_range": " §6Бескоштовний радіус сканування: %s", "tooltip.peripheralworks.universal_scanner_max_range": " §6Максимальний радіус сканування: %s", + "turtle.peripheralworks.ae2_crafting_monitor": "AE монітор крафтингу", + "turtle.peripheralworks.ae2_wireless_terminal": "AE термінальна", "turtle.peripheralworks.hologram_projector": "Проекуюча", "turtle.peripheralworks.natures_compass": "Природновідчуваюча", "turtle.peripheralworks.netherite_peripheralium_hub": "З вбудованим незеритовим осередком", diff --git a/projects/fabric/src/generated/resources/data/peripheralworks/computercraft/turtle_upgrades/ae2_crafting_monitor.json b/projects/fabric/src/generated/resources/data/peripheralworks/computercraft/turtle_upgrades/ae2_crafting_monitor.json new file mode 100644 index 00000000..f9e8b972 --- /dev/null +++ b/projects/fabric/src/generated/resources/data/peripheralworks/computercraft/turtle_upgrades/ae2_crafting_monitor.json @@ -0,0 +1,12 @@ +{ + "fabric:load_conditions": [ + { + "condition": "fabric:all_mods_loaded", + "values": [ + "ae2" + ] + } + ], + "type": "peripheralworks:ae2_crafting_monitor", + "item": "ae2:wireless_crafting_terminal" +} \ No newline at end of file diff --git a/projects/fabric/src/generated/resources/data/peripheralworks/computercraft/turtle_upgrades/ae2_wireless_terminal.json b/projects/fabric/src/generated/resources/data/peripheralworks/computercraft/turtle_upgrades/ae2_wireless_terminal.json new file mode 100644 index 00000000..f18faf2c --- /dev/null +++ b/projects/fabric/src/generated/resources/data/peripheralworks/computercraft/turtle_upgrades/ae2_wireless_terminal.json @@ -0,0 +1,12 @@ +{ + "fabric:load_conditions": [ + { + "condition": "fabric:all_mods_loaded", + "values": [ + "ae2" + ] + } + ], + "type": "peripheralworks:ae2_wireless_terminal", + "item": "ae2:wireless_terminal" +} \ No newline at end of file diff --git a/projects/fabric/src/main/kotlin/site/siredvin/peripheralworks/integrations/ae2/AE2ConfigurableObjects.kt b/projects/fabric/src/main/kotlin/site/siredvin/peripheralworks/integrations/ae2/AE2ConfigurableObjects.kt new file mode 100644 index 00000000..ea22d0e6 --- /dev/null +++ b/projects/fabric/src/main/kotlin/site/siredvin/peripheralworks/integrations/ae2/AE2ConfigurableObjects.kt @@ -0,0 +1,777 @@ +package site.siredvin.peripheralworks.integrations.ae2 + +import appeng.api.config.* +import appeng.api.crafting.PatternDetailsHelper +import appeng.api.inventories.InternalInventory +import appeng.api.stacks.AEFluidKey +import appeng.api.upgrades.IUpgradeInventory +import appeng.block.crafting.PatternProviderBlock +import appeng.block.crafting.PushDirection +import appeng.blockentity.crafting.PatternProviderBlockEntity +import appeng.blockentity.misc.InterfaceBlockEntity +import appeng.blockentity.networking.CableBusBlockEntity +import appeng.core.definitions.AEItems +import appeng.helpers.IPriorityHost +import appeng.helpers.InterfaceLogicHost +import appeng.helpers.externalstorage.GenericStackInv +import appeng.helpers.patternprovider.PatternProviderLogicHost +import appeng.parts.automation.* +import appeng.parts.crafting.PatternProviderPart +import appeng.parts.misc.InterfacePart +import appeng.parts.storagebus.StorageBusPart +import appeng.util.ConfigInventory +import dan200.computercraft.api.lua.LuaException +import dan200.computercraft.api.lua.LuaFunction +import dan200.computercraft.api.lua.MethodResult +import dan200.computercraft.api.peripheral.IComputerAccess +import dan200.computercraft.api.peripheral.IPeripheral +import net.minecraft.core.BlockPos +import net.minecraft.core.Direction +import net.minecraft.world.item.ItemStack +import net.minecraft.world.level.Level +import site.siredvin.broccolium.modules.platform.PlatformToolkit +import site.siredvin.broccolium.modules.storage.base.api.SlottedAgnosticSink +import site.siredvin.broccolium.modules.storage.base.api.SlottedAgnosticStorage +import site.siredvin.broccolium.modules.storage.item.AgnosticItemSinkLookup +import site.siredvin.broccolium.modules.storage.item.AgnosticItemStorageLookup +import site.siredvin.broccolium.modules.storage.item.ContainerWrapper +import site.siredvin.broccolium.modules.storage.item.ItemStorageUtils +import site.siredvin.peripheralworks.api.PeripheralPluginProvider +import site.siredvin.tweakium.modules.peripheral.api.IExpandedPeripheral +import site.siredvin.tweakium.modules.peripheral.api.IPeripheralPlugin +import site.siredvin.tweakium.modules.peripheral.api.ISidedPeripheral +import site.siredvin.tweakium.modules.peripheral.representation.LuaRepresentation +import site.siredvin.tweakium.modules.peripheral.util.assertBetween +import java.util.* +import kotlin.math.min + +private fun itemDetails(stack: ItemStack): Map = LuaRepresentation.forItemStack(stack) + +private fun parseDirection(value: String): Direction = Direction.byName(value) + ?: throw LuaException("Direction must be north, south, east, west, up, or down") + +private fun parseLimit(limit: Optional): Int = limit.orElse(Int.MAX_VALUE).also { + if (it < 0) throw LuaException("Limit must be non-negative") +} + +private fun requireAttached(access: IComputerAccess, attached: (() -> Boolean)?) { + if (attached != null && !attached()) throw LuaException("The originating computer is no longer attached") +} + +private fun peripheral(access: IComputerAccess, name: String, source: Boolean): IPeripheral = access.getAvailablePeripheral(name) + ?: throw LuaException("${if (source) "Source" else "Target"} '$name' does not exist") + +private fun pullItem( + level: Level, + access: IComputerAccess, + inventory: InternalInventory, + fromName: String, + fromSlot: Int, + limit: Optional, + toSlot: Optional, + predicate: (ItemStack) -> Boolean, +): Int { + val location = peripheral(access, fromName, true) + val direction = (location as? ISidedPeripheral)?.side + val source = AgnosticItemStorageLookup.extractFromUnknown(level, location.target, direction) + ?: throw LuaException("Source '$fromName' is not an inventory") + if (source !is SlottedAgnosticStorage) throw LuaException("Source '$fromName' is not slotted storage") + assertBetween(fromSlot, 1, source.size, "fromSlot") + if (toSlot.isPresent) assertBetween(toSlot.get(), 1, inventory.size(), "toSlot") + val actualLimit = parseLimit(limit) + if (actualLimit == 0 || !predicate(source.get(fromSlot - 1))) return 0 + val moved = ContainerWrapper(inventory.toContainer()).moveFrom( + source, + actualLimit, + toSlot.orElse(0) - 1, + fromSlot - 1, + predicate, + ) + if (moved > 0) for (slot in 0 until inventory.size()) inventory.sendChangeNotification(slot) + return moved +} + +private fun pushItem( + level: Level, + access: IComputerAccess, + inventory: InternalInventory, + toName: String, + fromSlot: Int, + limit: Optional, + toSlot: Optional, +): Pair { + assertBetween(fromSlot, 1, inventory.size(), "fromSlot") + val location = peripheral(access, toName, false) + val direction = (location as? ISidedPeripheral)?.side + val target = AgnosticItemSinkLookup.extractFromUnknown(level, location.target, direction) + ?: throw LuaException("Target '$toName' is not an inventory") + if (toSlot.isPresent) { + if (target !is SlottedAgnosticSink) throw LuaException("Target '$toName' is not slotted storage") + assertBetween(toSlot.get(), 1, target.size, "toSlot") + } + val actualLimit = parseLimit(limit) + if (actualLimit == 0) return 0 to ItemStack.EMPTY + val stack = inventory.getStackInSlot(fromSlot - 1).copy() + val moved = ContainerWrapper(inventory.toContainer()).moveTo( + target, + actualLimit, + fromSlot - 1, + toSlot.orElse(0) - 1, + ItemStorageUtils.ALWAYS, + ) + if (moved > 0) inventory.sendChangeNotification(fromSlot - 1) + return moved to stack +} + +private fun clearInactiveFilters(upgrades: IUpgradeInventory, config: ConfigInventory) { + val active = min(18 + upgrades.getInstalledUpgrades(AEItems.CAPACITY_CARD) * 9, config.size()) + for (slot in active until config.size()) config.setStack(slot, null) +} + +internal abstract class DeviceObject( + private val deviceType: String, + protected val level: Level, + private val resolveDevice: () -> T, +) { + protected fun device(): T = resolveDevice() + + @LuaFunction(mainThread = true) + fun getDeviceType(): String { + device() + return deviceType + } +} + +internal abstract class UpgradeableDeviceObject( + deviceType: String, + level: Level, + resolveDevice: () -> T, + private val upgrades: (T) -> IUpgradeInventory, + private val config: ((T) -> ConfigInventory)? = null, +) : DeviceObject(deviceType, level, resolveDevice) { + protected fun upgradeInventory(): IUpgradeInventory = upgrades(device()) + + protected fun pullUpgrade( + access: IComputerAccess, + attached: (() -> Boolean)?, + fromName: String, + fromSlot: Int, + limit: Optional, + toSlot: Optional, + ): Int { + requireAttached(access, attached) + return pullItem(level, access, upgradeInventory(), fromName, fromSlot, limit, toSlot) { true } + } + + protected fun pushUpgrade( + access: IComputerAccess, + attached: (() -> Boolean)?, + toName: String, + fromSlot: Int, + limit: Optional, + toSlot: Optional, + ): Int { + requireAttached(access, attached) + val target = device() + val inventory = upgrades(target) + val (moved, stack) = pushItem(level, access, inventory, toName, fromSlot, limit, toSlot) + if (moved > 0 && stack.`is`(AEItems.CAPACITY_CARD.asItem())) { + config?.invoke(target)?.let { + clearInactiveFilters(inventory, it) + } + } + return moved + } + + @LuaFunction(mainThread = true) + fun getUpgradeSlotCount(): Int = upgradeInventory().size() + + @LuaFunction(mainThread = true) + fun listUpgrades(): Map> = buildMap { + val inventory = upgradeInventory() + for (slot in 0 until inventory.size()) { + val stack = inventory.getStackInSlot(slot) + if (!stack.isEmpty) put(slot + 1, itemDetails(stack)) + } + } + + @LuaFunction(mainThread = true) + fun getUpgrade(slot: Int): Map? { + val inventory = upgradeInventory() + assertBetween(slot, 1, inventory.size(), "slot") + return inventory.getStackInSlot(slot - 1).takeUnless(ItemStack::isEmpty)?.let(::itemDetails) + } +} + +internal abstract class FilterDeviceObject( + deviceType: String, + level: Level, + resolveDevice: () -> T, + private val filterUpgrades: (T) -> IUpgradeInventory, + private val filter: (T) -> ConfigInventory, +) : UpgradeableDeviceObject(deviceType, level, resolveDevice, filterUpgrades, filter) { + private fun activeSlots(target: T): Int = min( + 18 + filterUpgrades(target).getInstalledUpgrades(AEItems.CAPACITY_CARD) * 9, + filter(target).size(), + ) + + private fun checkedFilter(slot: Int): Pair { + val target = device() + val size = activeSlots(target) + assertBetween(slot, 1, size, "slot") + return filter(target) to size + } + + @LuaFunction(mainThread = true) + fun getFilterSlotCount(): Int { + val target = device() + return activeSlots(target) + } + + @LuaFunction(mainThread = true) + fun listFilters(): Map> { + val target = device() + val inventory = filter(target) + return buildMap { + for (slot in 0 until activeSlots(target)) { + inventory.getKey(slot)?.let { + put(slot + 1, AE2Helper.keyToMap(it)) + } + } + } + } + + @LuaFunction(mainThread = true) + fun getFilter(slot: Int): Map? = checkedFilter(slot).first.getKey(slot - 1)?.let(AE2Helper::keyToMap) + + @LuaFunction(mainThread = true) + fun setFilter(slot: Int, resource: Map<*, *>) { + val inventory = checkedFilter(slot).first + val stack = AE2Helper.parseResource(resource, false) + if (!inventory.isAllowed(stack.what())) throw LuaException("Resource is not supported by this device") + inventory.setStack(slot - 1, stack) + } + + @LuaFunction(mainThread = true) + fun clearFilter(slot: Int) { + checkedFilter(slot).first.setStack(slot - 1, null) + } +} + +internal open class InterfaceObject(level: Level, resolve: () -> InterfaceLogicHost) : + UpgradeableDeviceObject("interface", level, resolve, InterfaceLogicHost::getUpgrades), + IPeripheralPlugin { + private val host: InterfaceLogicHost get() = device() + + @LuaFunction(mainThread = true) + fun getPriority(): Int = host.priority + + @LuaFunction(mainThread = true) + fun setPriority(priority: Int) { + host.priority = priority + } + + @LuaFunction(mainThread = true) + fun getFuzzyMode(): String = host.configManager.getSetting(Settings.FUZZY_MODE).name.lowercase(Locale.ROOT) + + @LuaFunction(mainThread = true) + fun setFuzzyMode(mode: String) { + host.configManager.putSetting( + Settings.FUZZY_MODE, + FuzzyMode.entries.firstOrNull { it.name.lowercase(Locale.ROOT) == mode } + ?: throw LuaException("Invalid fuzzy mode '$mode'"), + ) + } + + private fun stock(slot: Int): Pair { + assertBetween(slot, 1, 9, "slot") + return host.interfaceLogic.config to host.interfaceLogic.storage + } + + private fun stockRow(slot: Int): Map>? { + val (config, storage) = stock(slot) + val target = config.getStack(slot - 1) + val stored = storage.getStack(slot - 1) + if (target == null && stored == null) return null + return buildMap { + target?.let { put("target", AE2Helper.stackToMap(it)) } + stored?.let { put("stored", AE2Helper.stackToMap(it)) } + } + } + + @LuaFunction(mainThread = true) + fun listStock(): Map>> = buildMap { + for (slot in 1..9) stockRow(slot)?.let { put(slot, it) } + } + + @LuaFunction(mainThread = true) + fun getStock(slot: Int): Map>? = stockRow(slot) + + @LuaFunction(mainThread = true) + fun setStock(slot: Int, target: Map<*, *>) { + val config = stock(slot).first + val stack = AE2Helper.parseResource(target, true) + if (!config.isAllowed(stack.what())) throw LuaException("Resource is not supported by this interface") + if (stack.amount() > config.getMaxAmount(stack.what())) throw LuaException("Stock amount is too large for this resource") + config.setStack(slot - 1, stack) + } + + @LuaFunction(mainThread = true) + fun clearStock(slot: Int) { + stock(slot).first.setStack(slot - 1, null) + } +} + +internal class DirectInterfaceObject(level: Level, entity: InterfaceBlockEntity) : InterfaceObject(level, { entity }) { + @LuaFunction(mainThread = true) + fun pullUpgrade(access: IComputerAccess, fromName: String, fromSlot: Int, limit: Optional, toSlot: Optional): Int = pullUpgrade(access, null, fromName, fromSlot, limit, toSlot) + + @LuaFunction(mainThread = true) + fun pushUpgrade(access: IComputerAccess, toName: String, fromSlot: Int, limit: Optional, toSlot: Optional): Int = pushUpgrade(access, null, toName, fromSlot, limit, toSlot) +} + +internal class SideInterfaceObject( + level: Level, + resolve: () -> InterfacePart, + private val access: IComputerAccess, + private val attached: () -> Boolean, +) : InterfaceObject(level, resolve) { + @LuaFunction(mainThread = true) + fun pullUpgrade(fromName: String, fromSlot: Int, limit: Optional, toSlot: Optional): Int = pullUpgrade(access, attached, fromName, fromSlot, limit, toSlot) + + @LuaFunction(mainThread = true) + fun pushUpgrade(toName: String, fromSlot: Int, limit: Optional, toSlot: Optional): Int = pushUpgrade(access, attached, toName, fromSlot, limit, toSlot) +} + +internal abstract class BusObject( + deviceType: String, + level: Level, + resolve: () -> T, +) : FilterDeviceObject(deviceType, level, resolve, IOBusPart::getUpgrades, IOBusPart::getConfig) { + @LuaFunction(mainThread = true) + fun getFuzzyMode(): String = device().configManager.getSetting(Settings.FUZZY_MODE).name.lowercase(Locale.ROOT) + + @LuaFunction(mainThread = true) + fun setFuzzyMode(mode: String) = device().configManager.putSetting( + Settings.FUZZY_MODE, + FuzzyMode.entries.firstOrNull { it.name.lowercase(Locale.ROOT) == mode } + ?: throw LuaException("Invalid fuzzy mode '$mode'"), + ) + + @LuaFunction(mainThread = true) + fun getRedstoneMode(): String = device().configManager.getSetting(Settings.REDSTONE_CONTROLLED).name.lowercase(Locale.ROOT) + + @LuaFunction(mainThread = true) + fun setRedstoneMode(mode: String) = device().configManager.putSetting( + Settings.REDSTONE_CONTROLLED, + RedstoneMode.entries.firstOrNull { it.name.lowercase(Locale.ROOT) == mode } + ?: throw LuaException("Invalid redstone mode '$mode'"), + ) +} + +internal class ImportBusObject( + level: Level, + resolve: () -> ImportBusPart, + private val access: IComputerAccess, + private val attached: () -> Boolean, +) : BusObject("import_bus", level, resolve) { + @LuaFunction(mainThread = true) + fun pullUpgrade(fromName: String, fromSlot: Int, limit: Optional, toSlot: Optional): Int = pullUpgrade(access, attached, fromName, fromSlot, limit, toSlot) + + @LuaFunction(mainThread = true) + fun pushUpgrade(toName: String, fromSlot: Int, limit: Optional, toSlot: Optional): Int = pushUpgrade(access, attached, toName, fromSlot, limit, toSlot) +} + +internal class ExportBusObject( + level: Level, + resolve: () -> ExportBusPart, + private val access: IComputerAccess, + private val attached: () -> Boolean, +) : BusObject("export_bus", level, resolve) { + @LuaFunction(mainThread = true) + fun pullUpgrade(fromName: String, fromSlot: Int, limit: Optional, toSlot: Optional): Int = pullUpgrade(access, attached, fromName, fromSlot, limit, toSlot) + + @LuaFunction(mainThread = true) + fun pushUpgrade(toName: String, fromSlot: Int, limit: Optional, toSlot: Optional): Int = pushUpgrade(access, attached, toName, fromSlot, limit, toSlot) + + @LuaFunction(mainThread = true) + fun isCraftOnly(): Boolean = device().configManager.getSetting(Settings.CRAFT_ONLY) == YesNo.YES + + @LuaFunction(mainThread = true) + fun setCraftOnly(craftOnly: Boolean) = device().configManager.putSetting(Settings.CRAFT_ONLY, if (craftOnly) YesNo.YES else YesNo.NO) + + @LuaFunction(mainThread = true) + fun getSchedulingMode(): String = when (device().configManager.getSetting(Settings.SCHEDULING_MODE)) { + SchedulingMode.DEFAULT -> "default" + SchedulingMode.ROUNDROBIN -> "round_robin" + SchedulingMode.RANDOM -> "random" + } + + @LuaFunction(mainThread = true) + fun setSchedulingMode(mode: String) = device().configManager.putSetting( + Settings.SCHEDULING_MODE, + when (mode) { + "default" -> SchedulingMode.DEFAULT + "round_robin" -> SchedulingMode.ROUNDROBIN + "random" -> SchedulingMode.RANDOM + else -> throw LuaException("Invalid scheduling mode '$mode'") + }, + ) +} + +internal abstract class PriorityFilterObject( + deviceType: String, + level: Level, + resolve: () -> T, + filter: (T) -> ConfigInventory, +) : FilterDeviceObject(deviceType, level, resolve, UpgradeablePart::getUpgrades, filter) { + @LuaFunction(mainThread = true) + fun getPriority(): Int = (device() as IPriorityHost).priority + + @LuaFunction(mainThread = true) + fun setPriority(priority: Int) { + (device() as IPriorityHost).priority = priority + } + + @LuaFunction(mainThread = true) + fun getFuzzyMode(): String = device().configManager.getSetting(Settings.FUZZY_MODE).name.lowercase(Locale.ROOT) + + @LuaFunction(mainThread = true) + fun setFuzzyMode(mode: String) = device().configManager.putSetting( + Settings.FUZZY_MODE, + FuzzyMode.entries.firstOrNull { it.name.lowercase(Locale.ROOT) == mode } + ?: throw LuaException("Invalid fuzzy mode '$mode'"), + ) +} + +internal class StorageBusObject( + level: Level, + resolve: () -> StorageBusPart, + private val access: IComputerAccess, + private val attached: () -> Boolean, +) : PriorityFilterObject("storage_bus", level, resolve, StorageBusPart::getConfig) { + @LuaFunction(mainThread = true) + fun pullUpgrade(fromName: String, fromSlot: Int, limit: Optional, toSlot: Optional): Int = pullUpgrade(access, attached, fromName, fromSlot, limit, toSlot) + + @LuaFunction(mainThread = true) + fun pushUpgrade(toName: String, fromSlot: Int, limit: Optional, toSlot: Optional): Int = pushUpgrade(access, attached, toName, fromSlot, limit, toSlot) + + @LuaFunction(mainThread = true) + fun getAccessMode(): String = device().configManager.getSetting(Settings.ACCESS).name.lowercase(Locale.ROOT) + + @LuaFunction(mainThread = true) + fun setAccessMode(mode: String) = device().configManager.putSetting( + Settings.ACCESS, + AccessRestriction.entries.firstOrNull { it.name.lowercase(Locale.ROOT) == mode } + ?: throw LuaException("Invalid access mode '$mode'"), + ) + + @LuaFunction(mainThread = true) + fun getStorageFilterMode(): String = device().configManager.getSetting(Settings.STORAGE_FILTER).name.lowercase(Locale.ROOT) + + @LuaFunction(mainThread = true) + fun setStorageFilterMode(mode: String) = device().configManager.putSetting( + Settings.STORAGE_FILTER, + StorageFilter.entries.firstOrNull { it.name.lowercase(Locale.ROOT) == mode } + ?: throw LuaException("Invalid storage filter mode '$mode'"), + ) + + @LuaFunction(mainThread = true) + fun shouldFilterOnExtract(): Boolean = device().configManager.getSetting(Settings.FILTER_ON_EXTRACT) == YesNo.YES + + @LuaFunction(mainThread = true) + fun setFilterOnExtract(filterOnExtract: Boolean) = device().configManager.putSetting(Settings.FILTER_ON_EXTRACT, if (filterOnExtract) YesNo.YES else YesNo.NO) +} + +internal class FormationPlaneObject( + level: Level, + resolve: () -> FormationPlanePart, + private val access: IComputerAccess, + private val attached: () -> Boolean, +) : PriorityFilterObject("formation_plane", level, resolve, FormationPlanePart::getConfig) { + @LuaFunction(mainThread = true) + fun pullUpgrade(fromName: String, fromSlot: Int, limit: Optional, toSlot: Optional): Int = pullUpgrade(access, attached, fromName, fromSlot, limit, toSlot) + + @LuaFunction(mainThread = true) + fun pushUpgrade(toName: String, fromSlot: Int, limit: Optional, toSlot: Optional): Int = pushUpgrade(access, attached, toName, fromSlot, limit, toSlot) + + @LuaFunction(mainThread = true) + fun shouldPlaceBlocks(): Boolean = device().configManager.getSetting(Settings.PLACE_BLOCK) == YesNo.YES + + @LuaFunction(mainThread = true) + fun setPlaceBlocks(placeBlocks: Boolean) = device().configManager.putSetting(Settings.PLACE_BLOCK, if (placeBlocks) YesNo.YES else YesNo.NO) +} + +internal abstract class LevelEmitterObject( + deviceType: String, + level: Level, + resolve: () -> T, +) : DeviceObject(deviceType, level, resolve) { + @LuaFunction(mainThread = true) + fun getEmitterMode(): String = device().configManager.getSetting(Settings.REDSTONE_EMITTER).name.lowercase(Locale.ROOT) + + @LuaFunction(mainThread = true) + fun setEmitterMode(mode: String) = device().configManager.putSetting( + Settings.REDSTONE_EMITTER, + when (mode) { + "low_signal" -> RedstoneMode.LOW_SIGNAL + "high_signal" -> RedstoneMode.HIGH_SIGNAL + else -> throw LuaException("Invalid emitter mode '$mode'") + }, + ) + + @LuaFunction(mainThread = true) + fun isEmitting(): Boolean = device().isProvidingWeakPower > 0 +} + +internal class StorageLevelEmitterObject( + level: Level, + resolve: () -> StorageLevelEmitterPart, + private val access: IComputerAccess, + private val attached: () -> Boolean, +) : LevelEmitterObject("storage_level_emitter", level, resolve) { + private fun internalThreshold(threshold: Long): Long { + if (threshold < 0) throw LuaException("Threshold must be a non-negative integer") + val key = device().config.getKey(0) + if (key !is AEFluidKey) return threshold + val divider = PlatformToolkit.get().fluidCompactDivider.toLong() + if (threshold > Long.MAX_VALUE / divider) throw LuaException("Threshold is too large") + return threshold * divider + } + + @LuaFunction(mainThread = true) + fun getUpgradeSlotCount(): Int = device().upgrades.size() + + @LuaFunction(mainThread = true) + fun listUpgrades(): Map> = buildMap { + val inventory = device().upgrades + for (slot in 0 until inventory.size()) { + inventory.getStackInSlot(slot).takeUnless(ItemStack::isEmpty)?.let { + put(slot + 1, itemDetails(it)) + } + } + } + + @LuaFunction(mainThread = true) + fun getUpgrade(slot: Int): Map? { + val inventory = device().upgrades + assertBetween(slot, 1, inventory.size(), "slot") + return inventory.getStackInSlot(slot - 1).takeUnless(ItemStack::isEmpty)?.let(::itemDetails) + } + + @LuaFunction(mainThread = true) + fun pullUpgrade(fromName: String, fromSlot: Int, limit: Optional, toSlot: Optional): Int { + requireAttached(access, attached) + return pullItem(level, access, device().upgrades, fromName, fromSlot, limit, toSlot) { true } + } + + @LuaFunction(mainThread = true) + fun pushUpgrade(toName: String, fromSlot: Int, limit: Optional, toSlot: Optional): Int { + requireAttached(access, attached) + return pushItem(level, access, device().upgrades, toName, fromSlot, limit, toSlot).first + } + + @LuaFunction(mainThread = true) + fun getFuzzyMode(): String = device().configManager.getSetting(Settings.FUZZY_MODE).name.lowercase(Locale.ROOT) + + @LuaFunction(mainThread = true) + fun setFuzzyMode(mode: String) = device().configManager.putSetting( + Settings.FUZZY_MODE, + FuzzyMode.entries.firstOrNull { it.name.lowercase(Locale.ROOT) == mode } + ?: throw LuaException("Invalid fuzzy mode '$mode'"), + ) + + @LuaFunction(mainThread = true) + fun getMonitoredResource(): Map? = device().config.getKey(0)?.let(AE2Helper::keyToMap) + + @LuaFunction(mainThread = true) + fun setMonitoredResource(resource: Map<*, *>) { + val target = device() + val stack = AE2Helper.parseResource(resource, false) + if (!target.config.isAllowed(stack.what())) throw LuaException("Resource is not supported by this emitter") + target.config.setStack(0, stack) + } + + @LuaFunction(mainThread = true) + fun clearMonitoredResource() = device().config.setStack(0, null) + + @LuaFunction(mainThread = true) + fun getThreshold(): Long { + val target = device() + return AE2Helper.publicAmount(target.config.getKey(0) ?: return target.reportingValue, target.reportingValue) + } + + @LuaFunction(mainThread = true) + fun setThreshold(threshold: Long) = device().setReportingValue(internalThreshold(threshold)) + + @LuaFunction(mainThread = true) + fun getThresholdUnit(): String = when (device().config.getKey(0)) { + null -> "ae_internal" + is AEFluidKey -> "millibucket" + else -> "item" + } + + @LuaFunction(mainThread = true) + fun shouldCraftViaRedstone(): Boolean = device().configManager.getSetting(Settings.CRAFT_VIA_REDSTONE) == YesNo.YES + + @LuaFunction(mainThread = true) + fun setCraftViaRedstone(craftViaRedstone: Boolean) = device().configManager.putSetting(Settings.CRAFT_VIA_REDSTONE, if (craftViaRedstone) YesNo.YES else YesNo.NO) +} + +internal class EnergyLevelEmitterObject(level: Level, resolve: () -> EnergyLevelEmitterPart) : LevelEmitterObject("energy_level_emitter", level, resolve) { + @LuaFunction(mainThread = true) + fun getThreshold(): Long = device().reportingValue + + @LuaFunction(mainThread = true) + fun setThreshold(threshold: Long) { + if (threshold < 0) throw LuaException("Threshold must be a non-negative integer") + device().reportingValue = threshold + } +} + +internal open class PatternProviderObject(level: Level, resolve: () -> PatternProviderLogicHost) : + DeviceObject("pattern_provider", level, resolve), + IPeripheralPlugin { + protected fun patternInventory(): InternalInventory = device().logic.patternInv + + protected fun pullPattern(access: IComputerAccess, attached: (() -> Boolean)?, fromName: String, fromSlot: Int, limit: Optional, toSlot: Optional): Int { + requireAttached(access, attached) + return pullItem(level, access, patternInventory(), fromName, fromSlot, limit, toSlot) { + PatternDetailsHelper.decodePattern(it, level) != null + } + } + + protected fun pushPattern(access: IComputerAccess, attached: (() -> Boolean)?, toName: String, fromSlot: Int, limit: Optional, toSlot: Optional): Int { + requireAttached(access, attached) + return pushItem(level, access, patternInventory(), toName, fromSlot, limit, toSlot).first + } + + @LuaFunction(mainThread = true) + fun listPatterns(): Map> = buildMap { + val inventory = patternInventory() + for (slot in 0 until inventory.size()) { + inventory.getStackInSlot(slot).takeUnless(ItemStack::isEmpty)?.let { + put(slot + 1, itemDetails(it)) + } + } + } + + @LuaFunction(mainThread = true) + fun getPattern(slot: Int): Map? { + val inventory = patternInventory() + assertBetween(slot, 1, inventory.size(), "slot") + return inventory.getStackInSlot(slot - 1).takeUnless(ItemStack::isEmpty)?.let(::itemDetails) + } + + @LuaFunction(mainThread = true) + fun getPriority(): Int = device().priority + + @LuaFunction(mainThread = true) + fun setPriority(priority: Int) { + device().priority = priority + } + + @LuaFunction(mainThread = true) + fun isBlocking(): Boolean = device().configManager.getSetting(Settings.BLOCKING_MODE) == YesNo.YES + + @LuaFunction(mainThread = true) + fun setBlocking(blocking: Boolean) = device().configManager.putSetting(Settings.BLOCKING_MODE, if (blocking) YesNo.YES else YesNo.NO) + + @LuaFunction(mainThread = true) + fun isVisibleInPatternAccessTerminal(): Boolean = device().configManager.getSetting(Settings.PATTERN_ACCESS_TERMINAL) == YesNo.YES + + @LuaFunction(mainThread = true) + fun setVisibleInPatternAccessTerminal(visible: Boolean) = device().configManager.putSetting(Settings.PATTERN_ACCESS_TERMINAL, if (visible) YesNo.YES else YesNo.NO) + + @LuaFunction(mainThread = true) + fun getPatternLockMode(): String = device().configManager.getSetting(Settings.LOCK_CRAFTING_MODE).name.lowercase(Locale.ROOT) + + @LuaFunction(mainThread = true) + fun setPatternLockMode(mode: String) = device().configManager.putSetting( + Settings.LOCK_CRAFTING_MODE, + LockCraftingMode.entries.firstOrNull { it.name.lowercase(Locale.ROOT) == mode } + ?: throw LuaException("Invalid pattern lock mode '$mode'"), + ) +} + +internal class DirectPatternProviderObject( + level: Level, + private val entity: PatternProviderBlockEntity, +) : PatternProviderObject(level, { entity }) { + @LuaFunction(mainThread = true) + fun pullPattern(access: IComputerAccess, fromName: String, fromSlot: Int, limit: Optional, toSlot: Optional): Int = pullPattern(access, null, fromName, fromSlot, limit, toSlot) + + @LuaFunction(mainThread = true) + fun pushPattern(access: IComputerAccess, toName: String, fromSlot: Int, limit: Optional, toSlot: Optional): Int = pushPattern(access, null, toName, fromSlot, limit, toSlot) + + @LuaFunction(mainThread = true) + fun getPushDirection(): String = entity.blockState.getValue(PatternProviderBlock.PUSH_DIRECTION).serializedName + + @LuaFunction(mainThread = true) + fun setPushDirection(direction: String) { + val value = PushDirection.entries.firstOrNull { it.serializedName == direction } + ?: throw LuaException("Invalid push direction '$direction'") + level.setBlockAndUpdate(entity.blockPos, entity.blockState.setValue(PatternProviderBlock.PUSH_DIRECTION, value)) + } +} + +internal class SidePatternProviderObject( + level: Level, + resolve: () -> PatternProviderPart, + private val access: IComputerAccess, + private val attached: () -> Boolean, +) : PatternProviderObject(level, resolve) { + @LuaFunction(mainThread = true) + fun pullPattern(fromName: String, fromSlot: Int, limit: Optional, toSlot: Optional): Int = pullPattern(access, attached, fromName, fromSlot, limit, toSlot) + + @LuaFunction(mainThread = true) + fun pushPattern(toName: String, fromSlot: Int, limit: Optional, toSlot: Optional): Int = pushPattern(access, attached, toName, fromSlot, limit, toSlot) +} + +internal class CableConfigurableObject(private val level: Level, private val pos: BlockPos) : IPeripheralPlugin { + override var connectedPeripheral: IExpandedPeripheral? = null + + private fun resolve(side: Direction, expected: Class): T { + val cable = level.getBlockEntity(pos) as? CableBusBlockEntity + ?: throw LuaException("AE2 cable is no longer present") + val part = cable.getPart(side) + if (part == null || part.javaClass != expected) throw LuaException("AE2 ${side.serializedName} part is no longer the expected device") + return expected.cast(part) + } + + @LuaFunction(mainThread = true) + fun getSide(access: IComputerAccess, side: String): MethodResult { + val direction = parseDirection(side) + val cable = level.getBlockEntity(pos) as? CableBusBlockEntity + ?: return MethodResult.of(null, "AE2 cable is no longer present") + val part = cable.getPart(direction) ?: return MethodResult.of(null, "No AE2 part on side '$side'") + val attached = { connectedPeripheral?.isComputerPresent(access.id) == true } + val result: Any = when (part.javaClass) { + InterfacePart::class.java -> SideInterfaceObject(level, { resolve(direction, InterfacePart::class.java) }, access, attached) + ImportBusPart::class.java -> ImportBusObject(level, { resolve(direction, ImportBusPart::class.java) }, access, attached) + ExportBusPart::class.java -> ExportBusObject(level, { resolve(direction, ExportBusPart::class.java) }, access, attached) + StorageBusPart::class.java -> StorageBusObject(level, { resolve(direction, StorageBusPart::class.java) }, access, attached) + FormationPlanePart::class.java -> FormationPlaneObject(level, { resolve(direction, FormationPlanePart::class.java) }, access, attached) + StorageLevelEmitterPart::class.java -> StorageLevelEmitterObject(level, { resolve(direction, StorageLevelEmitterPart::class.java) }, access, attached) + EnergyLevelEmitterPart::class.java -> EnergyLevelEmitterObject(level, { resolve(direction, EnergyLevelEmitterPart::class.java) }) + PatternProviderPart::class.java -> SidePatternProviderObject(level, { resolve(direction, PatternProviderPart::class.java) }, access, attached) + else -> return MethodResult.of(null, "Unsupported AE2 part on side '$side'") + } + return MethodResult.of(result) + } +} + +object AE2CableObjectProvider : PeripheralPluginProvider { + override val pluginType = "ae2_cable_objects" + override fun provide(level: Level, pos: BlockPos, side: Direction): IPeripheralPlugin? = if (level.getBlockEntity(pos) is CableBusBlockEntity) CableConfigurableObject(level, pos) else null +} + +object AE2InterfaceObjectProvider : PeripheralPluginProvider { + override val pluginType = "ae2_interface_object" + override fun provide(level: Level, pos: BlockPos, side: Direction): IPeripheralPlugin? = (level.getBlockEntity(pos) as? InterfaceBlockEntity)?.let { DirectInterfaceObject(level, it) } +} + +object AE2PatternProviderObjectProvider : PeripheralPluginProvider { + override val pluginType = "ae2_pattern_provider_object" + override fun provide(level: Level, pos: BlockPos, side: Direction): IPeripheralPlugin? = (level.getBlockEntity(pos) as? PatternProviderBlockEntity)?.let { DirectPatternProviderObject(level, it) } +} diff --git a/projects/fabric/src/main/kotlin/site/siredvin/peripheralworks/integrations/ae2/AE2CraftingJobs.kt b/projects/fabric/src/main/kotlin/site/siredvin/peripheralworks/integrations/ae2/AE2CraftingJobs.kt new file mode 100644 index 00000000..80c62987 --- /dev/null +++ b/projects/fabric/src/main/kotlin/site/siredvin/peripheralworks/integrations/ae2/AE2CraftingJobs.kt @@ -0,0 +1,159 @@ +package site.siredvin.peripheralworks.integrations.ae2 + +import appeng.api.networking.crafting.CalculationStrategy +import appeng.api.networking.crafting.ICraftingLink +import appeng.api.networking.crafting.ICraftingService +import appeng.api.networking.security.IActionSource +import appeng.blockentity.grid.AENetworkBlockEntity +import appeng.core.definitions.AEItems +import dan200.computercraft.api.lua.LuaFunction +import dan200.computercraft.api.lua.MethodResult +import net.minecraft.core.BlockPos +import net.minecraft.core.Direction +import net.minecraft.world.level.Level +import site.siredvin.broccolium.modules.platform.PlatformToolkit +import site.siredvin.peripheralworks.api.PeripheralPluginProvider +import site.siredvin.peripheralworks.integrations.ae2.AE2Helper.buildKey +import site.siredvin.peripheralworks.integrations.ae2.AE2Helper.keyCounterToLua +import site.siredvin.peripheralworks.integrations.ae2.AE2Helper.stackToMap +import site.siredvin.tweakium.modules.peripheral.api.IPeripheralPlugin +import site.siredvin.tweakium.modules.peripheral.owner.TurtlePeripheralOwner +import java.lang.ref.WeakReference +import java.util.Collections +import java.util.Locale +import java.util.Optional +import java.util.WeakHashMap + +object AE2CraftingJobs { + private data class Job(val service: WeakReference, val target: Map, val amount: Long) + + // ponytail: job counts are tiny; scan weak keys until UUID lookup is proven necessary. + private val jobs = Collections.synchronizedMap(WeakHashMap()) + + fun schedule( + level: Level, + service: ICraftingService, + source: IActionSource, + mode: String, + id: String, + amount: Optional, + targetCPU: Optional, + ): MethodResult { + val publicAmount = amount.orElse(if (mode == "item") 1 else 1000) + if (publicAmount <= 0) return MethodResult.of(null, "Amount must be positive") + val key = buildKey(mode, id) + val realAmount = try { + if (mode == "fluid") Math.multiplyExact(publicAmount, PlatformToolkit.get().fluidCompactDivider.toLong()) else publicAmount + } catch (_: ArithmeticException) { + return MethodResult.of(null, "Amount is too large") + } + val plan = service.beginCraftingCalculation(level, { source }, key, realAmount, CalculationStrategy.REPORT_MISSING_ITEMS).get() + if (!plan.missingItems().isEmpty) return MethodResult.of(false, "Missing items", keyCounterToLua(plan.missingItems())) + val cpu = if (targetCPU.isPresent) { + service.cpus.firstOrNull { it.name?.string == targetCPU.get() } + ?: return MethodResult.of(null, "Cannot find target CPU") + } else { + null + } + val submitted = service.submitJob(plan, null, cpu, false, source) + if (!submitted.successful()) { + return MethodResult.of(null, "Cannot submit crafting job: ${submitted.errorCode()?.name?.lowercase(Locale.ROOT) ?: "unknown error"}") + } + val link = submitted.link() ?: return MethodResult.of(null, "AE2 did not return a crafting job") + val jobID = link.craftingID.toString() + jobs[link] = Job(WeakReference(service), stackToMap(plan.finalOutput()), publicAmount) + return MethodResult.of(true, jobID) + } + + fun get(service: ICraftingService, jobID: String): MethodResult { + val job = find(service, jobID) ?: return missing(jobID) + return MethodResult.of(toMap(job.first, job.second)) + } + + fun getAll(service: ICraftingService): List> = synchronized(jobs) { + jobs.mapNotNull { (link, job) -> if (job.service.get() === service) toMap(link, job) else null } + } + + fun cancel(service: ICraftingService, jobID: String): MethodResult { + val (link) = find(service, jobID) ?: return missing(jobID) + if (link.isCanceled) return MethodResult.of(false, "Crafting job '$jobID' is already canceled") + if (link.isDone) return MethodResult.of(false, "Crafting job '$jobID' is already done") + link.cancel() + return MethodResult.of(true) + } + + private fun find(service: ICraftingService, jobID: String): Pair? = synchronized(jobs) { + jobs.entries.firstOrNull { (link, job) -> link.craftingID.toString() == jobID && job.service.get() === service } + ?.let { it.key to it.value } + } + + private fun toMap(link: ICraftingLink, job: Job): Map = mapOf( + "id" to link.craftingID.toString(), + "state" to when { + link.isCanceled -> "canceled" + link.isDone -> "done" + else -> "running" + }, + "target" to job.target, + "amount" to job.amount, + ) + + private fun missing(jobID: String): MethodResult = MethodResult.of(null, "Crafting job '$jobID' was not found") +} + +class AE2CraftingJobsPlugin private constructor( + private val resolve: () -> Context?, + private val withActionSource: (((IActionSource) -> MethodResult) -> MethodResult), + private val unavailableMessage: String, +) : IPeripheralPlugin { + private data class Context(val level: Level, val service: ICraftingService) + + @LuaFunction(mainThread = false) + fun scheduleCrafting(mode: String, id: String, amount: Optional, targetCPU: Optional): MethodResult { + val context = resolve() ?: return unavailable() + return withActionSource { source -> AE2CraftingJobs.schedule(context.level, context.service, source, mode, id, amount, targetCPU) } + } + + @LuaFunction(mainThread = true) + fun getCraftingJob(jobID: String): MethodResult { + val context = resolve() ?: return unavailable() + return AE2CraftingJobs.get(context.service, jobID) + } + + @LuaFunction(mainThread = true) + fun getCraftingJobs(): List> = resolve()?.let { AE2CraftingJobs.getAll(it.service) } ?: emptyList() + + @LuaFunction(mainThread = true) + fun cancelCrafting(jobID: String): MethodResult { + val context = resolve() ?: return unavailable() + return AE2CraftingJobs.cancel(context.service, jobID) + } + + private fun unavailable(): MethodResult = MethodResult.of(null, unavailableMessage) + + companion object { + fun forMachine(level: Level, entity: AENetworkBlockEntity) = AE2CraftingJobsPlugin( + resolve = { entity.mainNode.grid?.craftingService?.let { Context(level, it) } }, + withActionSource = { callback -> callback(IActionSource.ofMachine(entity)) }, + unavailableMessage = "AE2 network is not connected", + ) + + fun forTurtle(owner: TurtlePeripheralOwner) = AE2CraftingJobsPlugin( + resolve = { + owner.level?.let { level -> Context(level, resolveWirelessSession(owner, AEItems.WIRELESS_CRAFTING_TERMINAL.asItem()).craftingService) } + }, + withActionSource = { callback -> owner.withPlayer({ callback(IActionSource.ofPlayer(it.fakePlayer)) }, skipInventory = true) }, + unavailableMessage = "Linked AE2 network is unavailable", + ) + } +} + +object AE2CraftingJobsPluginProvider : PeripheralPluginProvider { + override val pluginType = "ae2_crafting_jobs" + + override fun provide(level: Level, pos: BlockPos, side: Direction): IPeripheralPlugin? { + if (!Configuration.enableMEInterface) return null + val entity = level.getBlockEntity(pos) as? AENetworkBlockEntity ?: return null + return AE2CraftingJobsPlugin.forMachine(level, entity) + } +} diff --git a/projects/fabric/src/main/kotlin/site/siredvin/peripheralworks/integrations/ae2/AE2CraftingMonitor.kt b/projects/fabric/src/main/kotlin/site/siredvin/peripheralworks/integrations/ae2/AE2CraftingMonitor.kt new file mode 100644 index 00000000..c3ea8767 --- /dev/null +++ b/projects/fabric/src/main/kotlin/site/siredvin/peripheralworks/integrations/ae2/AE2CraftingMonitor.kt @@ -0,0 +1,48 @@ +package site.siredvin.peripheralworks.integrations.ae2 + +import appeng.core.definitions.AEItems +import dan200.computercraft.api.turtle.ITurtleAccess +import dan200.computercraft.api.turtle.TurtleSide +import net.minecraft.nbt.CompoundTag +import net.minecraft.nbt.Tag +import net.minecraft.resources.ResourceLocation +import net.minecraft.world.item.ItemStack +import site.siredvin.peripheralworks.PeripheralWorksCore +import site.siredvin.tweakium.modules.peripheral.OwnedPeripheral +import site.siredvin.tweakium.modules.peripheral.owner.TurtlePeripheralOwner +import site.siredvin.tweakium.modules.turtle.PeripheralTurtleUpgrade + +class AE2CraftingMonitorUpgrade(stack: ItemStack) : PeripheralTurtleUpgrade(UPGRADE_ID, stack) { + override fun buildPeripheral(turtle: ITurtleAccess, side: TurtleSide): AE2CraftingMonitorPeripheral = AE2CraftingMonitorPeripheral.create(turtle, side) + + override fun getUpgradeData(stack: ItemStack): CompoundTag = CompoundTag().apply { + put(AE2_TERMINAL_TAG, stack.save(CompoundTag())) + } + + override fun getUpgradeItem(upgradeData: CompoundTag): ItemStack = if (upgradeData.contains(AE2_TERMINAL_TAG, Tag.TAG_COMPOUND.toInt())) { + ItemStack.of(upgradeData.getCompound(AE2_TERMINAL_TAG)) + } else { + craftingItem + } + + override fun isItemSuitable(stack: ItemStack): Boolean = AEItems.WIRELESS_CRAFTING_TERMINAL.isSameAs(stack) && + AEItems.WIRELESS_CRAFTING_TERMINAL.asItem().getLinkedPosition(stack) != null + + companion object { + val UPGRADE_ID = ResourceLocation(PeripheralWorksCore.MOD_ID, AE2CraftingMonitorPeripheral.TYPE) + } +} + +class AE2CraftingMonitorPeripheral private constructor(owner: TurtlePeripheralOwner) : OwnedPeripheral(TYPE, owner) { + override val isEnabled = true + + init { + addPlugin(AE2CraftingJobsPlugin.forTurtle(owner)) + } + + companion object { + const val TYPE = "ae2_crafting_monitor" + + fun create(turtle: ITurtleAccess, side: TurtleSide): AE2CraftingMonitorPeripheral = AE2CraftingMonitorPeripheral(TurtlePeripheralOwner(turtle, side)) + } +} diff --git a/projects/fabric/src/main/kotlin/site/siredvin/peripheralworks/integrations/ae2/AE2Helper.kt b/projects/fabric/src/main/kotlin/site/siredvin/peripheralworks/integrations/ae2/AE2Helper.kt index 313f66ee..95b4ef16 100644 --- a/projects/fabric/src/main/kotlin/site/siredvin/peripheralworks/integrations/ae2/AE2Helper.kt +++ b/projects/fabric/src/main/kotlin/site/siredvin/peripheralworks/integrations/ae2/AE2Helper.kt @@ -25,6 +25,52 @@ object AE2Helper { return base } + fun keyToMap(key: AEKey): Map = when (key) { + is AEItemKey -> mapOf("type" to "item", "name" to PlatformRegistries.ITEMS.getKey(key.item).toString()) + is AEFluidKey -> mapOf("type" to "fluid", "name" to PlatformRegistries.FLUIDS.getKey(key.fluid).toString()) + else -> throw LuaException("Unsupported AE2 resource type") + } + + fun stackToMap(stack: GenericStack): Map = keyToMap(stack.what) + ("count" to publicAmount(stack.what, stack.amount)) + + fun parseResource(resource: Map<*, *>, requireCount: Boolean): GenericStack { + val type = resource["type"] as? String ?: throw LuaException("Resource type must be 'item' or 'fluid'") + val name = resource["name"] as? String ?: throw LuaException("Resource name must be a registry ID") + val id = ResourceLocation.tryParse(name) ?: throw LuaException("Invalid resource ID '$name'") + val key = when (type) { + "item" -> { + if (id !in PlatformRegistries.ITEMS.keySet()) throw LuaException("Unknown item '$name'") + val item = PlatformRegistries.ITEMS.get(id) + AEItemKey.of(item) + } + "fluid" -> { + if (id !in PlatformRegistries.FLUIDS.keySet()) throw LuaException("Unknown fluid '$name'") + val fluid = PlatformRegistries.FLUIDS.get(id) + AEFluidKey.of(fluid) + } + else -> throw LuaException("Resource type must be 'item' or 'fluid'") + } + if (!requireCount) { + if (resource.containsKey("count")) throw LuaException("Filter resources must not include a count") + return GenericStack(key, 0) + } + val count = (resource["count"] as? Number)?.toDouble() ?: throw LuaException("Resource count must be a positive integer") + if (!count.isFinite() || count <= 0 || count % 1.0 != 0.0) throw LuaException("Resource count must be a positive integer") + if (count >= Long.MAX_VALUE.toDouble()) throw LuaException("Resource count is too large") + val amount = try { + if (key is AEFluidKey) Math.multiplyExact(count.toLong(), PlatformToolkit.get().fluidCompactDivider.toLong()) else count.toLong() + } catch (_: ArithmeticException) { + throw LuaException("Resource count is too large") + } + return GenericStack(key, amount) + } + + fun publicAmount(key: AEKey, amount: Long): Long = if (key is AEFluidKey) { + amount / PlatformToolkit.get().fluidCompactDivider.toLong() + } else { + amount + } + fun keyCounterToLua(counter: KeyCounter, predicate: Predicate = ALWAYS, displayType: Boolean = false): List> = counter .mapNotNull { entry -> val aeKey = entry.key diff --git a/projects/fabric/src/main/kotlin/site/siredvin/peripheralworks/integrations/ae2/AE2WirelessTerminal.kt b/projects/fabric/src/main/kotlin/site/siredvin/peripheralworks/integrations/ae2/AE2WirelessTerminal.kt new file mode 100644 index 00000000..af5633b5 --- /dev/null +++ b/projects/fabric/src/main/kotlin/site/siredvin/peripheralworks/integrations/ae2/AE2WirelessTerminal.kt @@ -0,0 +1,153 @@ +package site.siredvin.peripheralworks.integrations.ae2 + +import appeng.api.implementations.blockentities.IWirelessAccessPoint +import appeng.api.networking.crafting.ICraftingService +import appeng.api.networking.security.IActionSource +import appeng.api.storage.MEStorage +import appeng.blockentity.networking.WirelessAccessPointBlockEntity +import appeng.core.definitions.AEItems +import appeng.items.tools.powered.WirelessTerminalItem +import dan200.computercraft.api.lua.IArguments +import dan200.computercraft.api.lua.LuaException +import dan200.computercraft.api.lua.LuaFunction +import dan200.computercraft.api.turtle.ITurtleAccess +import dan200.computercraft.api.turtle.TurtleSide +import net.minecraft.nbt.CompoundTag +import net.minecraft.nbt.Tag +import net.minecraft.resources.ResourceLocation +import net.minecraft.world.item.ItemStack +import site.siredvin.peripheralworks.PeripheralWorksCore +import site.siredvin.peripheralworks.common.configuration.PeripheralWorksConfig +import site.siredvin.tweakium.modules.peripheral.OwnedPeripheral +import site.siredvin.tweakium.modules.peripheral.api.IPeripheralPlugin +import site.siredvin.tweakium.modules.peripheral.boon.PeripheralOwnerBoonKey +import site.siredvin.tweakium.modules.peripheral.owner.TurtlePeripheralOwner +import site.siredvin.tweakium.modules.peripheral.representation.LuaRepresentation +import site.siredvin.tweakium.modules.peripheral.representation.RepresentationMode +import site.siredvin.tweakium.modules.plugins.PeripheralPluginUtils +import site.siredvin.tweakium.modules.turtle.PeripheralTurtleUpgrade +import java.util.Optional +import java.util.function.Predicate +import kotlin.math.min + +internal const val AE2_TERMINAL_TAG = "terminal" + +internal data class AE2WirelessSession(val storage: MEStorage, val craftingService: ICraftingService) + +internal fun resolveWirelessSession(owner: TurtlePeripheralOwner, terminal: WirelessTerminalItem): AE2WirelessSession { + val data = owner.turtle.getUpgradeNBTData(owner.side) + if (!data.contains(AE2_TERMINAL_TAG, Tag.TAG_COMPOUND.toInt())) throw LuaException("Invalid stored wireless terminal") + val stack = ItemStack.of(data.getCompound(AE2_TERMINAL_TAG)) + if (stack.item !== terminal || terminal.getLinkedPosition(stack) == null) throw LuaException("Invalid stored wireless terminal") + val level = owner.level ?: throw LuaException("Linked AE2 network is unavailable") + val grid = terminal.getLinkedGrid(stack, level, null) ?: throw LuaException("Linked AE2 network is unavailable") + val inRange = grid.getMachines(WirelessAccessPointBlockEntity::class.java).any { accessPoint -> + isInWirelessRange(accessPoint, level, owner.pos) + } + if (!inRange) throw LuaException("Turtle is outside wireless range") + return AE2WirelessSession(grid.storageService.inventory, grid.craftingService) +} + +private fun isInWirelessRange(accessPoint: IWirelessAccessPoint, level: net.minecraft.world.level.Level, pos: net.minecraft.core.BlockPos): Boolean = accessPoint.isActive && accessPoint.location.level === level && accessPoint.location.pos.distSqr(pos) < accessPoint.range * accessPoint.range + +class AE2WirelessTerminalUpgrade(stack: ItemStack) : PeripheralTurtleUpgrade(UPGRADE_ID, stack) { + override fun buildPeripheral(turtle: ITurtleAccess, side: TurtleSide): AE2WirelessTerminalPeripheral = AE2WirelessTerminalPeripheral.create(turtle, side) + + override fun getUpgradeData(stack: ItemStack): CompoundTag = CompoundTag().apply { + put(AE2_TERMINAL_TAG, stack.save(CompoundTag())) + } + + override fun getUpgradeItem(upgradeData: CompoundTag): ItemStack = if (upgradeData.contains(AE2_TERMINAL_TAG, Tag.TAG_COMPOUND.toInt())) { + ItemStack.of(upgradeData.getCompound(AE2_TERMINAL_TAG)) + } else { + craftingItem + } + + override fun isItemSuitable(stack: ItemStack): Boolean = AEItems.WIRELESS_TERMINAL.isSameAs(stack) && + AEItems.WIRELESS_TERMINAL.asItem().getLinkedPosition(stack) != null + + companion object { + val UPGRADE_ID = ResourceLocation(PeripheralWorksCore.MOD_ID, AE2WirelessTerminalPeripheral.TYPE) + } +} + +class AE2WirelessTerminalPeripheral private constructor(owner: TurtlePeripheralOwner) : OwnedPeripheral(TYPE, owner) { + override val isEnabled = true + + init { + addPlugin(AE2WirelessTerminalPlugin(owner)) + } + + companion object { + const val TYPE = "ae2_wireless_terminal" + + fun create(turtle: ITurtleAccess, side: TurtleSide): AE2WirelessTerminalPeripheral { + val owner = TurtlePeripheralOwner(turtle, side).attachFuel() + return AE2WirelessTerminalPeripheral(owner) + } + } +} + +private class AE2WirelessTerminalPlugin(private val owner: TurtlePeripheralOwner) : IPeripheralPlugin { + private fun resolve(): AE2WirelessSession = resolveWirelessSession(owner, AEItems.WIRELESS_TERMINAL.asItem()) + + private fun validateTransfer(itemQuery: Any?, limit: Optional, slot: Optional): Pair, Pair> { + val predicate = PeripheralPluginUtils.itemQueryToPredicate(itemQuery) + val transferLimit = min(PeripheralWorksConfig.itemStorageTransferLimit, limit.orElse(Int.MAX_VALUE)) + if (transferLimit < 0) throw LuaException("Limit must be non-negative") + val inventorySize = owner.storage!!.size + val storageSlot = slot.map { it - 1 }.orElse(-1) + if (storageSlot !in -1 until inventorySize) throw LuaException("Slot must be between 1 and $inventorySize") + return predicate to (transferLimit to storageSlot) + } + + private fun validatePush(fromSlotOrItemQuery: Any?, limit: Optional): Pair, Pair> { + if (fromSlotOrItemQuery !is Number) return validateTransfer(fromSlotOrItemQuery, limit, Optional.empty()) + val fromSlot = fromSlotOrItemQuery.toInt() + if (fromSlotOrItemQuery.toDouble() != fromSlot.toDouble()) throw LuaException("Slot must be an integer") + return validateTransfer(null, limit, Optional.of(fromSlot)) + } + + private fun consumeFuel() { + val fuel = owner.getBoon(PeripheralOwnerBoonKey.FUEL)!! + if (!fuel.consumeFuel(1, false)) throw LuaException("Not enough fuel") + } + + @LuaFunction(mainThread = true) + fun getFuelMaxLevel(): Int = owner.getBoon(PeripheralOwnerBoonKey.FUEL)!!.maxFuelLevel + + @LuaFunction(mainThread = true) + fun items(arguments: IArguments): List> { + val storage = AEItemStorage(resolve().storage, IActionSource.empty()) {} + val mode = if (arguments.optBoolean(0, true)) RepresentationMode.DETAILED else RepresentationMode.BASE + val predicate = PeripheralPluginUtils.itemQueryToPredicate(arguments.get(1)) + return storage.getContent().asSequence().filter(predicate::test).map { LuaRepresentation.forItemStack(it, mode) }.toList() + } + + @LuaFunction(mainThread = true) + fun pullItem(itemQuery: Any?, limit: Optional, toSlot: Optional): Int { + val session = resolve() + val (predicate, transfer) = validateTransfer(itemQuery, limit, toSlot) + consumeFuel() + return owner.withPlayer({ player -> + AEItemStorage(session.storage, IActionSource.ofPlayer(player.fakePlayer)) {} + .moveTo(owner.storage!!, transfer.first, transfer.second, predicate) + }, skipInventory = true) + } + + @LuaFunction(mainThread = true) + fun pushItem(fromSlotOrItemQuery: Any?, limit: Optional): Int { + val session = resolve() + val (predicate, transfer) = validatePush(fromSlotOrItemQuery, limit) + consumeFuel() + return owner.withPlayer({ player -> + owner.storage!!.moveTo( + AEItemStorage(session.storage, IActionSource.ofPlayer(player.fakePlayer)) {}, + transfer.first, + transfer.second, + -1, + predicate, + ) + }, skipInventory = true) + } +} diff --git a/projects/fabric/src/main/kotlin/site/siredvin/peripheralworks/integrations/ae2/AEItemStorage.kt b/projects/fabric/src/main/kotlin/site/siredvin/peripheralworks/integrations/ae2/AEItemStorage.kt index 5366982a..559cad3b 100644 --- a/projects/fabric/src/main/kotlin/site/siredvin/peripheralworks/integrations/ae2/AEItemStorage.kt +++ b/projects/fabric/src/main/kotlin/site/siredvin/peripheralworks/integrations/ae2/AEItemStorage.kt @@ -11,7 +11,13 @@ import site.siredvin.broccolium.modules.storage.base.api.SomethingOperator import site.siredvin.broccolium.modules.storage.item.ItemStorageUtils import java.util.function.Predicate -class AEItemStorage(private val storage: MEStorage, private val entity: AENetworkBlockEntity) : AgnosticStorage { +class AEItemStorage( + private val storage: MEStorage, + private val actionSource: IActionSource, + private val changeCallback: () -> Unit, +) : AgnosticStorage { + constructor(storage: MEStorage, entity: AENetworkBlockEntity) : this(storage, IActionSource.ofMachine(entity), entity::setChanged) + override fun getContent(): Iterator { return storage.availableStacks.mapNotNull { if (it.key !is AEItemKey) return@mapNotNull null @@ -20,7 +26,7 @@ class AEItemStorage(private val storage: MEStorage, private val entity: AENetwor } override fun setChanged() { - entity.setChanged() + changeCallback() } override val maxStackSize: Int @@ -29,7 +35,7 @@ class AEItemStorage(private val storage: MEStorage, private val entity: AENetwor get() = ItemStorageUtils override fun store(stack: ItemStack, simulate: Boolean): ItemStack { - val insertedAmount = storage.insert(AEItemKey.of(stack), stack.count.toLong(), if (simulate) Actionable.SIMULATE else Actionable.MODULATE, IActionSource.ofMachine(entity)) + val insertedAmount = storage.insert(AEItemKey.of(stack), stack.count.toLong(), if (simulate) Actionable.SIMULATE else Actionable.MODULATE, actionSource) if (insertedAmount == 0L) return stack stack.shrink(insertedAmount.toInt()) return stack @@ -43,7 +49,7 @@ class AEItemStorage(private val storage: MEStorage, private val entity: AENetwor } return@find predicate.test(aeKey.toStack(it.longValue.toInt())) } ?: return ItemStack.EMPTY - val extractedAmount = storage.extract(itemToTransfer.key, minOf(limit.toLong(), itemToTransfer.longValue), if (simulate) Actionable.SIMULATE else Actionable.MODULATE, IActionSource.ofMachine(entity)) + val extractedAmount = storage.extract(itemToTransfer.key, minOf(limit.toLong(), itemToTransfer.longValue), if (simulate) Actionable.SIMULATE else Actionable.MODULATE, actionSource) if (extractedAmount == 0L) return ItemStack.EMPTY return (itemToTransfer.key as AEItemKey).toStack(extractedAmount.toInt()) } diff --git a/projects/fabric/src/main/kotlin/site/siredvin/peripheralworks/integrations/ae2/Integration.kt b/projects/fabric/src/main/kotlin/site/siredvin/peripheralworks/integrations/ae2/Integration.kt index c77ad06f..a205e751 100644 --- a/projects/fabric/src/main/kotlin/site/siredvin/peripheralworks/integrations/ae2/Integration.kt +++ b/projects/fabric/src/main/kotlin/site/siredvin/peripheralworks/integrations/ae2/Integration.kt @@ -1,6 +1,11 @@ package site.siredvin.peripheralworks.integrations.ae2 import appeng.blockentity.grid.AENetworkBlockEntity +import appeng.core.definitions.AEBlockEntities +import appeng.core.definitions.AEItems +import dan200.computercraft.api.peripheral.PeripheralLookup +import dan200.computercraft.api.turtle.ITurtleUpgrade +import dan200.computercraft.api.turtle.TurtleUpgradeSerialiser import net.minecraft.core.BlockPos import net.minecraft.core.Direction import net.minecraft.world.item.ItemStack @@ -12,7 +17,13 @@ import site.siredvin.broccolium.modules.storage.energy.api.AgnosticEnergyStorage import site.siredvin.broccolium.modules.storage.fluid.AgnosticFluidStorageLookup import site.siredvin.broccolium.modules.storage.fluid.api.AgnosticFluidStorage import site.siredvin.broccolium.modules.storage.item.AgnosticItemStorageLookup +import site.siredvin.peripheralworks.PeripheralWorksClientCore +import site.siredvin.peripheralworks.client.turtle.ScaledItemModeller import site.siredvin.peripheralworks.computercraft.ComputerCraftProxy +import site.siredvin.peripheralworks.data.ModEnLanguageProvider +import site.siredvin.peripheralworks.data.ModTurtleUpgradeDataProvider +import site.siredvin.peripheralworks.data.ModUaLanguageProvider +import site.siredvin.peripheralworks.xplat.ModPlatform class Integration : Runnable { @@ -40,6 +51,34 @@ class Integration : Runnable { } override fun run() { + val wirelessTerminalUpgrade = ModPlatform.registerTurtleUpgrade( + AE2WirelessTerminalUpgrade.UPGRADE_ID, + TurtleUpgradeSerialiser.simpleWithCustomItem { _, stack -> AE2WirelessTerminalUpgrade(stack) }, + ) + ModTurtleUpgradeDataProvider.hookUpgrade { + it.simpleWithCustomItem(AE2WirelessTerminalUpgrade.UPGRADE_ID, wirelessTerminalUpgrade.get(), AEItems.WIRELESS_TERMINAL.asItem()).requireMod("ae2") + } + PeripheralWorksClientCore.EXTRA_TURTLE_MODEL_PROVIDERS.add { + @Suppress("UNCHECKED_CAST") + Pair(wirelessTerminalUpgrade.get() as TurtleUpgradeSerialiser, ScaledItemModeller(0.75f, heightShift = 0.15f)) + } + ModEnLanguageProvider.addHook { it.addTurtle(AE2WirelessTerminalUpgrade.UPGRADE_ID, "AE terminal") } + ModUaLanguageProvider.addHook { it.addTurtle(AE2WirelessTerminalUpgrade.UPGRADE_ID, "AE термінальна") } + + val craftingMonitorUpgrade = ModPlatform.registerTurtleUpgrade( + AE2CraftingMonitorUpgrade.UPGRADE_ID, + TurtleUpgradeSerialiser.simpleWithCustomItem { _, stack -> AE2CraftingMonitorUpgrade(stack) }, + ) + ModTurtleUpgradeDataProvider.hookUpgrade { + it.simpleWithCustomItem(AE2CraftingMonitorUpgrade.UPGRADE_ID, craftingMonitorUpgrade.get(), AEItems.WIRELESS_CRAFTING_TERMINAL.asItem()).requireMod("ae2") + } + PeripheralWorksClientCore.EXTRA_TURTLE_MODEL_PROVIDERS.add { + @Suppress("UNCHECKED_CAST") + Pair(craftingMonitorUpgrade.get() as TurtleUpgradeSerialiser, ScaledItemModeller(0.75f, heightShift = 0.15f)) + } + ModEnLanguageProvider.addHook { it.addTurtle(AE2CraftingMonitorUpgrade.UPGRADE_ID, "AE crafting monitor") } + ModUaLanguageProvider.addHook { it.addTurtle(AE2CraftingMonitorUpgrade.UPGRADE_ID, "AE монітор крафтингу") } + if (Configuration.enableStorageIntegrations) { AgnosticItemStorageLookup.addBlockLookup(::extractItemStorage) AgnosticFluidStorageLookup.addBlockLookup(::extractFluidStorage) @@ -47,6 +86,14 @@ class Integration : Runnable { } if (Configuration.enableMEInterface) { ComputerCraftProxy.addProvider(MENetworkBlockPlugin.Provider) + ComputerCraftProxy.addProvider(AE2CraftingJobsPluginProvider) + ComputerCraftProxy.addProvider(AE2CableObjectProvider) + ComputerCraftProxy.addProvider(AE2InterfaceObjectProvider) + ComputerCraftProxy.addProvider(AE2PatternProviderObjectProvider) + PeripheralLookup.get().registerForBlockEntity( + { entity, side -> ComputerCraftProxy.peripheralProvider(entity.level!!, entity.blockPos, entity.blockState, entity, side) }, + AEBlockEntities.CABLE_BUS, + ) } } } diff --git a/projects/fabric/src/main/kotlin/site/siredvin/peripheralworks/integrations/ae2/MENetworkBlockPlugin.kt b/projects/fabric/src/main/kotlin/site/siredvin/peripheralworks/integrations/ae2/MENetworkBlockPlugin.kt index eb12e95f..9163a582 100644 --- a/projects/fabric/src/main/kotlin/site/siredvin/peripheralworks/integrations/ae2/MENetworkBlockPlugin.kt +++ b/projects/fabric/src/main/kotlin/site/siredvin/peripheralworks/integrations/ae2/MENetworkBlockPlugin.kt @@ -1,7 +1,5 @@ package site.siredvin.peripheralworks.integrations.ae2 -import appeng.api.networking.crafting.CalculationStrategy -import appeng.api.networking.security.IActionSource import appeng.api.stacks.AEFluidKey import appeng.api.stacks.AEItemKey import appeng.api.stacks.AEKey @@ -12,17 +10,13 @@ import net.minecraft.core.BlockPos import net.minecraft.core.Direction import net.minecraft.world.level.Level import site.siredvin.broccolium.modules.platform.PlatformRegistries -import site.siredvin.broccolium.modules.platform.PlatformToolkit import site.siredvin.peripheralworks.api.PeripheralPluginProvider import site.siredvin.peripheralworks.integrations.ae2.AE2Helper.buildKey import site.siredvin.peripheralworks.integrations.ae2.AE2Helper.genericStackToMap -import site.siredvin.peripheralworks.integrations.ae2.AE2Helper.keyCounterToLua import site.siredvin.tweakium.modules.peripheral.api.IPeripheralPlugin import site.siredvin.tweakium.modules.peripheral.representation.LuaRepresentation -import java.util.* -import kotlin.NoSuchElementException -class MENetworkBlockPlugin(private val level: Level, private val entity: AENetworkBlockEntity) : IPeripheralPlugin { +class MENetworkBlockPlugin(private val entity: AENetworkBlockEntity) : IPeripheralPlugin { companion object { const val PLUGIN_TYPE = "ae2" } @@ -39,7 +33,7 @@ class MENetworkBlockPlugin(private val level: Level, private val entity: AENetwo if (entity !is AENetworkBlockEntity) { return null } - return MENetworkBlockPlugin(level, entity) + return MENetworkBlockPlugin(entity) } } @@ -167,42 +161,4 @@ class MENetworkBlockPlugin(private val level: Level, private val entity: AENetwo } return MethodResult.of(craftingList) } - - @LuaFunction(mainThread = false) - fun scheduleCrafting(mode: String, id_key: String, amount: Optional, targetCPU: Optional): MethodResult { - val craftingService = entity.mainNode.grid?.craftingService ?: return MethodResult.of(null, "AE2 network is not connected") - - val key = buildKey(mode, id_key) - val source = IActionSource.ofMachine(entity) - val realAmount = if (mode == "item") { - amount.orElse(1) - } else { - amount.orElse(1000) * PlatformToolkit.get().fluidCompactDivider - }.toLong() - val future = craftingService.beginCraftingCalculation( - level, - { source }, - key, - realAmount, - CalculationStrategy.REPORT_MISSING_ITEMS, - ) - val plan = future.get() - - if (!plan.missingItems().isEmpty) { - return MethodResult.of(false, "Missing items", keyCounterToLua(plan.missingItems())) - } - val realTargetCPU = if (targetCPU.isPresent) { - try { - craftingService.cpus.first { - it.name != null && it.name!!.string.equals(targetCPU.get()) - } - } catch (e: NoSuchElementException) { - return MethodResult.of(null, "Cannot find target CPU") - } - } else { - null - } - craftingService.submitJob(plan, null, realTargetCPU, false, source) - return MethodResult.of(true) - } } diff --git a/projects/fabric/src/testMod/kotlin/site/siredvin/peripheralworks/testmod/AE2ConfigurableObjectsGameTests.kt b/projects/fabric/src/testMod/kotlin/site/siredvin/peripheralworks/testmod/AE2ConfigurableObjectsGameTests.kt new file mode 100644 index 00000000..523d250f --- /dev/null +++ b/projects/fabric/src/testMod/kotlin/site/siredvin/peripheralworks/testmod/AE2ConfigurableObjectsGameTests.kt @@ -0,0 +1,133 @@ +package site.siredvin.peripheralworks.testmod + +import appeng.api.crafting.PatternDetailsHelper +import appeng.api.networking.crafting.ICraftingService +import appeng.api.networking.security.IActionSource +import appeng.api.stacks.AEItemKey +import appeng.api.stacks.GenericStack +import appeng.blockentity.networking.CableBusBlockEntity +import appeng.core.definitions.AEBlocks +import appeng.core.definitions.AEItems +import appeng.core.definitions.AEParts +import appeng.helpers.MultiCraftingTracker +import appeng.parts.automation.ExportBusPart +import dan200.computercraft.shared.computer.blocks.ComputerBlockEntity +import net.minecraft.core.BlockPos +import net.minecraft.core.Direction +import net.minecraft.gametest.framework.GameTest +import net.minecraft.gametest.framework.GameTestAssertException +import net.minecraft.gametest.framework.GameTestHelper +import net.minecraft.world.item.ItemStack +import net.minecraft.world.item.Items +import net.minecraft.world.level.block.entity.ChestBlockEntity +import site.siredvin.peripheralworks.computercraft.ComputerCraftProxy +import site.siredvin.testiarium.api.TestGroup +import site.siredvin.testiarium.api.thenExecuteFailFast +import site.siredvin.testiarium.cct.CctComputerState +import java.lang.reflect.Proxy +import java.util.concurrent.CompletableFuture + +@TestGroup("peripheralworks") +class AE2ConfigurableObjectsGameTests { + @GameTest(template = FIXTURE, batch = FIXTURE, timeoutTicks = 2400) + fun configurableObjects(helper: GameTestHelper) { + val computer = findComputer(helper) + val interfacePos = computer.blockPos.relative(Direction.NORTH) + val cablePos = computer.blockPos.relative(Direction.SOUTH) + val chestPos = computer.blockPos.relative(Direction.WEST) + val patternProviderPos = computer.blockPos.relative(Direction.EAST) + helper.level.setBlockAndUpdate(interfacePos, AEBlocks.INTERFACE.block().defaultBlockState()) + helper.level.setBlockAndUpdate(patternProviderPos, AEBlocks.PATTERN_PROVIDER.block().defaultBlockState()) + helper.level.setBlockAndUpdate(cablePos, AEBlocks.CABLE_BUS.block().defaultBlockState()) + helper.level.setBlockAndUpdate(chestPos, net.minecraft.world.level.block.Blocks.CHEST.defaultBlockState()) + val cable = helper.level.getBlockEntity(cablePos) as CableBusBlockEntity + val exportBus = cable.addPart(AEParts.EXPORT_BUS.asItem(), Direction.SOUTH, null)!! + assertCraftingSlotTen(exportBus, helper) + cable.addPart(AEParts.STORAGE_BUS.asItem(), Direction.EAST, null) + cable.addPart(AEParts.FORMATION_PLANE.asItem(), Direction.WEST, null) + cable.addPart(AEParts.LEVEL_EMITTER.asItem(), Direction.UP, null) + cable.addPart(AEParts.ENERGY_LEVEL_EMITTER.asItem(), Direction.DOWN, null) + check(ComputerCraftProxy.collectPlugins(helper.level, cablePos, Direction.NORTH).containsKey("ae2_cable_objects")) { + "Cable provider was not registered" + } + check(ComputerCraftProxy.collectPlugins(helper.level, interfacePos, Direction.SOUTH).containsKey("ae2_interface_object")) { + "Interface provider was not registered" + } + check(ComputerCraftProxy.collectPlugins(helper.level, patternProviderPos, Direction.WEST).containsKey("ae2_pattern_provider_object")) { + "Pattern Provider provider was not registered" + } + + (helper.level.getBlockEntity(chestPos) as ChestBlockEntity).apply { + setItem(0, AEItems.CAPACITY_CARD.stack()) + setItem(2, AEItems.CRAFTING_CARD.stack()) + setItem(3, AEItems.FUZZY_CARD.stack()) + setItem(4, ItemStack(Items.STONE)) + setItem( + 5, + PatternDetailsHelper.encodeProcessingPattern( + arrayOf(GenericStack(AEItemKey.of(Items.COBBLESTONE), 1)), + arrayOf(GenericStack(AEItemKey.of(Items.STONE), 1)), + ), + ) + } + helper.startSequence() + .thenIdle(5) + .thenExecute { computer.createServerComputer().turnOn() } + .thenWaitUntil { await("same-kind") } + .thenExecuteFailFast { + state().check("same-kind") + cable.removePartFromSide(Direction.SOUTH) + check(cable.addPart(AEParts.EXPORT_BUS.asItem(), Direction.SOUTH, null) != null) + } + .thenWaitUntil { await("different-kind") } + .thenExecuteFailFast { + state().check("different-kind") + cable.removePartFromSide(Direction.SOUTH) + check(cable.addPart(AEParts.IMPORT_BUS.asItem(), Direction.SOUTH, null) != null) + } + .thenWaitUntil { await("removed") } + .thenExecuteFailFast { + state().check("removed") + cable.removePartFromSide(Direction.SOUTH) + } + .thenWaitUntil { await(CctComputerState.DONE) } + .thenExecuteFailFast { state().check(CctComputerState.DONE) } + .thenSucceed() + } + + private fun state() = CctComputerState.get(FIXTURE) ?: throw GameTestAssertException("Computer '$FIXTURE' has not started") + + private fun await(marker: String) { + val state = CctComputerState.get(FIXTURE) ?: throw GameTestAssertException("Computer '$FIXTURE' has not started") + if (state.isDone(CctComputerState.DONE)) state.check(CctComputerState.DONE) + if (!state.isDone(marker)) throw GameTestAssertException("Computer '$FIXTURE' has not reached $marker") + } + + private fun findComputer(helper: GameTestHelper): ComputerBlockEntity { + for (x in 0 until 5) { + for (y in 0 until 4) { + for (z in 0 until 5) { + val entity = helper.getBlockEntity(BlockPos(x, y, z)) + if (entity is ComputerBlockEntity) return entity + } + } + } + throw GameTestAssertException("Fixture computer is missing") + } + + private fun assertCraftingSlotTen(exportBus: ExportBusPart, helper: GameTestHelper) { + val field = ExportBusPart::class.java.getDeclaredField("craftingTracker").apply { isAccessible = true } + val tracker = field.get(exportBus) as MultiCraftingTracker + val craftingService = Proxy.newProxyInstance( + ICraftingService::class.java.classLoader, + arrayOf(ICraftingService::class.java), + ) { _, method, _ -> + if (method.name == "beginCraftingCalculation") CompletableFuture.completedFuture(null) else error("Unexpected ${method.name}") + } as ICraftingService + tracker.handleCrafting(9, AEItemKey.of(Items.STONE), 1, helper.level, craftingService, IActionSource.empty()) + } + + companion object { + private const val FIXTURE = "peripheralworksgametests.ae2_configurable_objects" + } +} diff --git a/projects/fabric/src/testMod/kotlin/site/siredvin/peripheralworks/testmod/AE2WirelessTerminalGameTests.kt b/projects/fabric/src/testMod/kotlin/site/siredvin/peripheralworks/testmod/AE2WirelessTerminalGameTests.kt new file mode 100644 index 00000000..49bbcc0d --- /dev/null +++ b/projects/fabric/src/testMod/kotlin/site/siredvin/peripheralworks/testmod/AE2WirelessTerminalGameTests.kt @@ -0,0 +1,156 @@ +package site.siredvin.peripheralworks.testmod + +import appeng.api.config.Actionable +import appeng.api.networking.security.IActionSource +import appeng.api.stacks.AEItemKey +import appeng.blockentity.networking.WirelessAccessPointBlockEntity +import appeng.blockentity.storage.ChestBlockEntity +import appeng.core.definitions.AEBlocks +import appeng.core.definitions.AEItems +import appeng.items.tools.powered.WirelessTerminalItem +import dan200.computercraft.api.turtle.TurtleSide +import dan200.computercraft.api.upgrades.UpgradeData +import dan200.computercraft.shared.config.Config +import dan200.computercraft.shared.turtle.blocks.TurtleBlockEntity +import net.minecraft.core.BlockPos +import net.minecraft.core.GlobalPos +import net.minecraft.gametest.framework.GameTest +import net.minecraft.gametest.framework.GameTestAssertException +import net.minecraft.gametest.framework.GameTestHelper +import net.minecraft.network.chat.Component +import net.minecraft.world.item.ItemStack +import net.minecraft.world.item.Items +import site.siredvin.peripheralworks.integrations.ae2.AE2CraftingMonitorUpgrade +import site.siredvin.peripheralworks.integrations.ae2.AE2WirelessTerminalUpgrade +import site.siredvin.testiarium.api.TestGroup +import site.siredvin.testiarium.api.thenExecuteFailFast +import site.siredvin.testiarium.cct.CctComputerState + +@TestGroup("peripheralworks") +class AE2WirelessTerminalGameTests { + @GameTest(template = FIXTURE, batch = FIXTURE, timeoutTicks = 12000) + fun wirelessTerminal(helper: GameTestHelper) { + val turtle = findTurtle(helper) + val accessPointPos = turtle.blockPos.offset(0, 0, 1) + val energyPos = accessPointPos.offset(0, 0, 1) + val chestPos = energyPos.offset(1, 0, 0) + helper.level.setBlockAndUpdate(accessPointPos, AEBlocks.WIRELESS_ACCESS_POINT.block().defaultBlockState()) + helper.level.setBlockAndUpdate(energyPos, AEBlocks.CREATIVE_ENERGY_CELL.block().defaultBlockState()) + helper.level.setBlockAndUpdate(chestPos, AEBlocks.CHEST.block().defaultBlockState()) + (helper.level.getBlockEntity(chestPos) as ChestBlockEntity).setCell(AEItems.ITEM_CELL_1K.stack()) + + val terminalItem = AEItems.WIRELESS_TERMINAL.asItem() + val terminal = AEItems.WIRELESS_TERMINAL.stack().apply { + hoverName = Component.literal("Test terminal") + orCreateTag.putString("upw_test", "preserved") + } + WirelessTerminalItem.LINKABLE_HANDLER.link(terminal, GlobalPos.of(helper.level.dimension(), accessPointPos)) + terminalItem.injectAEPower(terminal, 400.0, Actionable.MODULATE) + terminalItem.getUpgrades(terminal).setItemDirect(0, AEItems.ENERGY_CARD.stack()) + val initialCharge = terminalItem.getAECurrentPower(terminal) + val upgrade = AE2WirelessTerminalUpgrade(AEItems.WIRELESS_TERMINAL.stack()) + check(ItemStack.matches(terminal, upgrade.getUpgradeItem(upgrade.getUpgradeData(terminal)))) + turtle.access.setUpgradeWithData(TurtleSide.LEFT, UpgradeData.of(upgrade, upgrade.getUpgradeData(terminal))) + val craftingTerminal = AEItems.WIRELESS_CRAFTING_TERMINAL.stack() + WirelessTerminalItem.LINKABLE_HANDLER.link(craftingTerminal, GlobalPos.of(helper.level.dimension(), accessPointPos)) + val craftingMonitor = AE2CraftingMonitorUpgrade(AEItems.WIRELESS_CRAFTING_TERMINAL.stack()) + check(ItemStack.matches(craftingTerminal, craftingMonitor.getUpgradeItem(craftingMonitor.getUpgradeData(craftingTerminal)))) + turtle.access.setUpgradeWithData(TurtleSide.RIGHT, UpgradeData.of(craftingMonitor, craftingMonitor.getUpgradeData(craftingTerminal))) + + helper.startSequence() + .thenIdle(10) + .thenExecuteFailFast { + val accessPoint = helper.level.getBlockEntity(accessPointPos) as WirelessAccessPointBlockEntity + check(accessPoint.isActive) { "Wireless access point did not become active" } + val inserted = accessPoint.grid!!.storageService.inventory.insert(AEItemKey.of(Items.STONE), 64, Actionable.MODULATE, IActionSource.empty()) + check(inserted == 64L) { "Failed to seed AE2 item storage" } + turtle.createServerComputer().turnOn() + } + .thenWaitUntil { await("initial") } + .thenExecuteFailFast { + state().check("initial") + check(turtle.access.fuelLevel == 7) { "Expected three fuel-consuming calls, got ${turtle.access.fuelLevel}" } + check(turtle.contents[0].count == 5 && turtle.contents[0].`is`(Items.STONE)) + check(turtle.contents[15].count == 5 && turtle.contents[15].`is`(Items.GOLD_INGOT)) + turtle.access.fuelLevel = 0 + } + .thenWaitUntil { await("empty-fuel") } + .thenExecuteFailFast { + state().check("empty-fuel") + Config.turtlesNeedFuel = false + turtle.access.fuelLevel = 1 + } + .thenWaitUntil { await("disabled") } + .thenExecuteFailFast { + state().check("disabled") + Config.turtlesNeedFuel = true + turtle.access.fuelLevel = 2 + } + .thenWaitUntil { await("restored") } + .thenExecuteFailFast { + state().check("restored") + check(turtle.access.teleportTo(helper.level, turtle.blockPos.offset(18, 0, 0))) + } + .thenWaitUntil { await("out-of-range") } + .thenExecuteFailFast { + state().check("out-of-range") + check(turtle.access.teleportTo(helper.level, accessPointPos.offset(0, 0, -1))) + } + .thenWaitUntil { await("returned") } + .thenExecuteFailFast { + state().check("returned") + helper.level.removeBlock(energyPos, false) + } + .thenWaitUntil { await("inactive") } + .thenExecuteFailFast { + state().check("inactive") + helper.level.setBlockAndUpdate(energyPos, AEBlocks.CREATIVE_ENERGY_CELL.block().defaultBlockState()) + } + .thenWaitUntil { await("reactivated") } + .thenExecuteFailFast { + state().check("reactivated") + val data = turtle.access.getUpgradeNBTData(TurtleSide.LEFT) + val stored = ItemStack.of(data.getCompound("terminal")) + WirelessTerminalItem.LINKABLE_HANDLER.link(stored, GlobalPos.of(helper.level.dimension(), UNLOADED_POS)) + data.put("terminal", stored.save(net.minecraft.nbt.CompoundTag())) + turtle.access.updateUpgradeNBTData(TurtleSide.LEFT) + check(helper.level.chunkSource.getChunkNow(UNLOADED_POS.x shr 4, UNLOADED_POS.z shr 4) == null) + } + .thenWaitUntil { await(CctComputerState.DONE) } + .thenExecuteFailFast { + state().check(CctComputerState.DONE) + check(helper.level.chunkSource.getChunkNow(UNLOADED_POS.x shr 4, UNLOADED_POS.z shr 4) == null) { "Wireless resolution loaded the linked chunk" } + val stored = turtle.access.getUpgradeWithData(TurtleSide.LEFT)!!.upgradeItem + check(stored.hoverName.string == "Test terminal") + check(stored.tag?.getString("upw_test") == "preserved") + check(terminalItem.getUpgrades(stored).getInstalledUpgrades(AEItems.ENERGY_CARD) == 1) + check(terminalItem.getAECurrentPower(stored) == initialCharge) { "Peripheral use changed terminal charge" } + } + .thenSucceed() + } + + private fun state() = CctComputerState.get(FIXTURE) ?: throw GameTestAssertException("Computer '$FIXTURE' has not started") + + private fun findTurtle(helper: GameTestHelper): TurtleBlockEntity { + for (x in 0 until 7) { + for (y in 0 until 4) { + for (z in 0 until 7) { + val entity = helper.getBlockEntity(BlockPos(x, y, z)) + if (entity is TurtleBlockEntity) return entity + } + } + } + throw GameTestAssertException("Fixture turtle is missing") + } + + private fun await(marker: String) { + val state = state() + if (state.isDone(CctComputerState.DONE)) state.check(CctComputerState.DONE) + if (!state.isDone(marker)) throw GameTestAssertException("Computer '$FIXTURE' has not reached $marker") + } + + companion object { + private const val FIXTURE = "peripheralworksgametests.ae2_wireless_terminal" + private val UNLOADED_POS = BlockPos(1_000_000, 64, 1_000_000) + } +} diff --git a/projects/fabric/src/testMod/kotlin/site/siredvin/peripheralworks/testmod/FabricPeripheralWorksTestMod.kt b/projects/fabric/src/testMod/kotlin/site/siredvin/peripheralworks/testmod/FabricPeripheralWorksTestMod.kt index c5bd19fc..915b4694 100644 --- a/projects/fabric/src/testMod/kotlin/site/siredvin/peripheralworks/testmod/FabricPeripheralWorksTestMod.kt +++ b/projects/fabric/src/testMod/kotlin/site/siredvin/peripheralworks/testmod/FabricPeripheralWorksTestMod.kt @@ -17,6 +17,8 @@ object FabricPeripheralWorksTestMod : ModInitializer { CctFixtureCommands.importFiles(it) } Testiarium.register(PeripheralWorksGameTests::class.java) + Testiarium.register(AE2ConfigurableObjectsGameTests::class.java) + Testiarium.register(AE2WirelessTerminalGameTests::class.java) if (FabricLoader.getInstance().environmentType == EnvType.CLIENT) { Testiarium.register(Class.forName("site.siredvin.peripheralworks.testmod.NetworkManagerClientGameTests")) } diff --git a/projects/forge/build.gradle.kts b/projects/forge/build.gradle.kts index fe507fc9..7330d9df 100644 --- a/projects/forge/build.gradle.kts +++ b/projects/forge/build.gradle.kts @@ -35,8 +35,11 @@ forgeShaking { } if (minimalTestEnvironment) { - sourceSets.main { kotlin.exclude("site/siredvin/peripheralworks/integrations/**") } - tasks.named("compileKotlin") { exclude("**/integrations/**") } + val excludedIntegrations = file("src/main/kotlin/site/siredvin/peripheralworks/integrations").listFiles()!! + .filter { it.isDirectory && it.name != "ae2" } + .map { "**/integrations/${it.name}/**" } + sourceSets.main { kotlin.exclude(excludedIntegrations) } + tasks.named("compileKotlin") { exclude(excludedIntegrations) } } val testMod = sourceSets.create("testMod") { @@ -74,7 +77,9 @@ dependencies { // runtimeOnly(fg.deobf("com.simibubi.create:create-1.20.1:6.0.0-84:all")) compileOnly(fg.deobf("net.createmod.ponder:Ponder-Forge-1.20.1:1.0.51")) - if (!minimalTestEnvironment) { + if (minimalTestEnvironment) { + implementation(fg.deobf(libs.ae2.forge.get())) + } else { libs.bundles.externalMods.forge.integrations.full.get().map { compileOnly(fg.deobf(it)) } libs.bundles.externalMods.forge.integrations.raw.full.get().map { compileOnly(it) } libs.bundles.externalMods.forge.integrations.active.get().map { runtimeOnly(fg.deobf(it)) } diff --git a/projects/forge/src/generated/resources/.cache/332c3938fc847411e7f47874e3456ed3599bef6a b/projects/forge/src/generated/resources/.cache/332c3938fc847411e7f47874e3456ed3599bef6a index 7b8cde38..b1757680 100644 --- a/projects/forge/src/generated/resources/.cache/332c3938fc847411e7f47874e3456ed3599bef6a +++ b/projects/forge/src/generated/resources/.cache/332c3938fc847411e7f47874e3456ed3599bef6a @@ -1,2 +1,2 @@ -// 1.20.1 2026-07-25T21:13:37.742801759 vanilla/ComputerLanguageen_us -3f6387e39a99555b8f87230e64cceff8e6c70935 assets/peripheralworks/lang/en_us.json +// 1.20.1 2026-08-04T16:34:49.330806371 vanilla/ComputerLanguageen_us +7a102403804ca9dec57c9e80f1bd1e44d5b1db50 assets/peripheralworks/lang/en_us.json diff --git a/projects/forge/src/generated/resources/.cache/42bea422736702abc417b05c462ae52120b042de b/projects/forge/src/generated/resources/.cache/42bea422736702abc417b05c462ae52120b042de index a537db37..22db064b 100644 --- a/projects/forge/src/generated/resources/.cache/42bea422736702abc417b05c462ae52120b042de +++ b/projects/forge/src/generated/resources/.cache/42bea422736702abc417b05c462ae52120b042de @@ -1,4 +1,6 @@ -// 1.20.1 2025-11-08T18:25:34.6607103 vanilla/Turtle Upgrades +// 1.20.1 2026-08-04T16:34:49.329673094 vanilla/Turtle Upgrades +d240fa0f18606d9d4e6781d8797dd80ffc38e237 data/peripheralworks/computercraft/turtle_upgrades/ae2_crafting_monitor.json +3853625a634297d38a4ed83cc9b58f38bce9211c data/peripheralworks/computercraft/turtle_upgrades/ae2_wireless_terminal.json 911a593d4aafc165c692b127978a82c7f08b960e data/peripheralworks/computercraft/turtle_upgrades/hologram_projector.json d205e7849ba7d32f6e008c44a81fb296064072dd data/peripheralworks/computercraft/turtle_upgrades/natures_compass.json 2246ea1d5805e9afda36c4f1265f520598315dc9 data/peripheralworks/computercraft/turtle_upgrades/netherite_peripheralium_hub.json diff --git a/projects/forge/src/generated/resources/.cache/442eea4c4e28c24d3ee14e17147972c3af1159eb b/projects/forge/src/generated/resources/.cache/442eea4c4e28c24d3ee14e17147972c3af1159eb index db87304b..00d1c4e2 100644 --- a/projects/forge/src/generated/resources/.cache/442eea4c4e28c24d3ee14e17147972c3af1159eb +++ b/projects/forge/src/generated/resources/.cache/442eea4c4e28c24d3ee14e17147972c3af1159eb @@ -1,2 +1,2 @@ -// 1.20.1 2026-07-25T21:13:37.743030755 vanilla/ComputerLanguageuk_ua -a11c85b7543def12c6a94487874731a025496b0d assets/peripheralworks/lang/uk_ua.json +// 1.20.1 2026-08-04T16:34:49.331013825 vanilla/ComputerLanguageuk_ua +c0023a66893302d84e0e97d0383f9a9701a75f6a assets/peripheralworks/lang/uk_ua.json diff --git a/projects/forge/src/generated/resources/assets/peripheralworks/lang/en_us.json b/projects/forge/src/generated/resources/assets/peripheralworks/lang/en_us.json index f9cde705..5f7d8760 100644 --- a/projects/forge/src/generated/resources/assets/peripheralworks/lang/en_us.json +++ b/projects/forge/src/generated/resources/assets/peripheralworks/lang/en_us.json @@ -165,6 +165,8 @@ "tooltip.peripheralworks.remote_observer_range": " §6Max range of observed block: %s", "tooltip.peripheralworks.universal_scanner_free_range": " §6Cost-free scan range: %s", "tooltip.peripheralworks.universal_scanner_max_range": " §6Max scan range: %s", + "turtle.peripheralworks.ae2_crafting_monitor": "AE crafting monitor", + "turtle.peripheralworks.ae2_wireless_terminal": "AE terminal", "turtle.peripheralworks.hologram_projector": "Projecting", "turtle.peripheralworks.natures_compass": "Nature Compassing", "turtle.peripheralworks.netherite_peripheralium_hub": "Netherite Hub", diff --git a/projects/forge/src/generated/resources/assets/peripheralworks/lang/uk_ua.json b/projects/forge/src/generated/resources/assets/peripheralworks/lang/uk_ua.json index 55210b4a..ee788785 100644 --- a/projects/forge/src/generated/resources/assets/peripheralworks/lang/uk_ua.json +++ b/projects/forge/src/generated/resources/assets/peripheralworks/lang/uk_ua.json @@ -165,6 +165,8 @@ "tooltip.peripheralworks.remote_observer_range": " §6Максимальна дальність стостерігання: %s", "tooltip.peripheralworks.universal_scanner_free_range": " §6Бескоштовний радіус сканування: %s", "tooltip.peripheralworks.universal_scanner_max_range": " §6Максимальний радіус сканування: %s", + "turtle.peripheralworks.ae2_crafting_monitor": "AE монітор крафтингу", + "turtle.peripheralworks.ae2_wireless_terminal": "AE термінальна", "turtle.peripheralworks.hologram_projector": "Проекуюча", "turtle.peripheralworks.natures_compass": "Природновідчуваюча", "turtle.peripheralworks.netherite_peripheralium_hub": "З вбудованим незеритовим осередком", diff --git a/projects/forge/src/generated/resources/data/peripheralworks/computercraft/turtle_upgrades/ae2_crafting_monitor.json b/projects/forge/src/generated/resources/data/peripheralworks/computercraft/turtle_upgrades/ae2_crafting_monitor.json new file mode 100644 index 00000000..dfd7f4d7 --- /dev/null +++ b/projects/forge/src/generated/resources/data/peripheralworks/computercraft/turtle_upgrades/ae2_crafting_monitor.json @@ -0,0 +1,10 @@ +{ + "type": "peripheralworks:ae2_crafting_monitor", + "forge:conditions": [ + { + "type": "forge:mod_loaded", + "modid": "ae2" + } + ], + "item": "ae2:wireless_crafting_terminal" +} \ No newline at end of file diff --git a/projects/forge/src/generated/resources/data/peripheralworks/computercraft/turtle_upgrades/ae2_wireless_terminal.json b/projects/forge/src/generated/resources/data/peripheralworks/computercraft/turtle_upgrades/ae2_wireless_terminal.json new file mode 100644 index 00000000..348f7cca --- /dev/null +++ b/projects/forge/src/generated/resources/data/peripheralworks/computercraft/turtle_upgrades/ae2_wireless_terminal.json @@ -0,0 +1,10 @@ +{ + "type": "peripheralworks:ae2_wireless_terminal", + "forge:conditions": [ + { + "type": "forge:mod_loaded", + "modid": "ae2" + } + ], + "item": "ae2:wireless_terminal" +} \ No newline at end of file diff --git a/projects/forge/src/main/kotlin/site/siredvin/peripheralworks/ForgePeripheralWorks.kt b/projects/forge/src/main/kotlin/site/siredvin/peripheralworks/ForgePeripheralWorks.kt index 3679eb90..2aee35ef 100644 --- a/projects/forge/src/main/kotlin/site/siredvin/peripheralworks/ForgePeripheralWorks.kt +++ b/projects/forge/src/main/kotlin/site/siredvin/peripheralworks/ForgePeripheralWorks.kt @@ -93,7 +93,6 @@ object ForgePeripheralWorks { loader.maybeLoadIntegration("occultism").ifPresent { (it as Runnable).run() } loader.maybeLoadIntegration("easy_villagers").ifPresent { (it as Runnable).run() } loader.maybeLoadIntegration("toms_storage").ifPresent { (it as Runnable).run() } - loader.maybeLoadIntegration("ae2").ifPresent { (it as Runnable).run() } loader.maybeLoadIntegration("mna").ifPresent { (it as Runnable).run() } loader.maybeLoadIntegration("deepresonance").ifPresent { (it as Runnable).run() } loader.maybeLoadIntegration("powah").ifPresent { (it as Runnable).run() } @@ -126,6 +125,7 @@ object ForgePeripheralWorks { loader.maybeLoadIntegration("naturescompass").ifPresent { (it as Runnable).run() } loader.maybeLoadIntegration("ars_nouveau").ifPresent { (it as Runnable).run() } loader.maybeLoadIntegration("projecte").ifPresent { (it as Runnable).run() } + loader.maybeLoadIntegration("ae2").ifPresent { (it as Runnable).run() } } @Suppress("MemberVisibilityCanBePrivate") diff --git a/projects/forge/src/main/kotlin/site/siredvin/peripheralworks/integrations/ae2/AE2ConfigurableObjects.kt b/projects/forge/src/main/kotlin/site/siredvin/peripheralworks/integrations/ae2/AE2ConfigurableObjects.kt new file mode 100644 index 00000000..ea22d0e6 --- /dev/null +++ b/projects/forge/src/main/kotlin/site/siredvin/peripheralworks/integrations/ae2/AE2ConfigurableObjects.kt @@ -0,0 +1,777 @@ +package site.siredvin.peripheralworks.integrations.ae2 + +import appeng.api.config.* +import appeng.api.crafting.PatternDetailsHelper +import appeng.api.inventories.InternalInventory +import appeng.api.stacks.AEFluidKey +import appeng.api.upgrades.IUpgradeInventory +import appeng.block.crafting.PatternProviderBlock +import appeng.block.crafting.PushDirection +import appeng.blockentity.crafting.PatternProviderBlockEntity +import appeng.blockentity.misc.InterfaceBlockEntity +import appeng.blockentity.networking.CableBusBlockEntity +import appeng.core.definitions.AEItems +import appeng.helpers.IPriorityHost +import appeng.helpers.InterfaceLogicHost +import appeng.helpers.externalstorage.GenericStackInv +import appeng.helpers.patternprovider.PatternProviderLogicHost +import appeng.parts.automation.* +import appeng.parts.crafting.PatternProviderPart +import appeng.parts.misc.InterfacePart +import appeng.parts.storagebus.StorageBusPart +import appeng.util.ConfigInventory +import dan200.computercraft.api.lua.LuaException +import dan200.computercraft.api.lua.LuaFunction +import dan200.computercraft.api.lua.MethodResult +import dan200.computercraft.api.peripheral.IComputerAccess +import dan200.computercraft.api.peripheral.IPeripheral +import net.minecraft.core.BlockPos +import net.minecraft.core.Direction +import net.minecraft.world.item.ItemStack +import net.minecraft.world.level.Level +import site.siredvin.broccolium.modules.platform.PlatformToolkit +import site.siredvin.broccolium.modules.storage.base.api.SlottedAgnosticSink +import site.siredvin.broccolium.modules.storage.base.api.SlottedAgnosticStorage +import site.siredvin.broccolium.modules.storage.item.AgnosticItemSinkLookup +import site.siredvin.broccolium.modules.storage.item.AgnosticItemStorageLookup +import site.siredvin.broccolium.modules.storage.item.ContainerWrapper +import site.siredvin.broccolium.modules.storage.item.ItemStorageUtils +import site.siredvin.peripheralworks.api.PeripheralPluginProvider +import site.siredvin.tweakium.modules.peripheral.api.IExpandedPeripheral +import site.siredvin.tweakium.modules.peripheral.api.IPeripheralPlugin +import site.siredvin.tweakium.modules.peripheral.api.ISidedPeripheral +import site.siredvin.tweakium.modules.peripheral.representation.LuaRepresentation +import site.siredvin.tweakium.modules.peripheral.util.assertBetween +import java.util.* +import kotlin.math.min + +private fun itemDetails(stack: ItemStack): Map = LuaRepresentation.forItemStack(stack) + +private fun parseDirection(value: String): Direction = Direction.byName(value) + ?: throw LuaException("Direction must be north, south, east, west, up, or down") + +private fun parseLimit(limit: Optional): Int = limit.orElse(Int.MAX_VALUE).also { + if (it < 0) throw LuaException("Limit must be non-negative") +} + +private fun requireAttached(access: IComputerAccess, attached: (() -> Boolean)?) { + if (attached != null && !attached()) throw LuaException("The originating computer is no longer attached") +} + +private fun peripheral(access: IComputerAccess, name: String, source: Boolean): IPeripheral = access.getAvailablePeripheral(name) + ?: throw LuaException("${if (source) "Source" else "Target"} '$name' does not exist") + +private fun pullItem( + level: Level, + access: IComputerAccess, + inventory: InternalInventory, + fromName: String, + fromSlot: Int, + limit: Optional, + toSlot: Optional, + predicate: (ItemStack) -> Boolean, +): Int { + val location = peripheral(access, fromName, true) + val direction = (location as? ISidedPeripheral)?.side + val source = AgnosticItemStorageLookup.extractFromUnknown(level, location.target, direction) + ?: throw LuaException("Source '$fromName' is not an inventory") + if (source !is SlottedAgnosticStorage) throw LuaException("Source '$fromName' is not slotted storage") + assertBetween(fromSlot, 1, source.size, "fromSlot") + if (toSlot.isPresent) assertBetween(toSlot.get(), 1, inventory.size(), "toSlot") + val actualLimit = parseLimit(limit) + if (actualLimit == 0 || !predicate(source.get(fromSlot - 1))) return 0 + val moved = ContainerWrapper(inventory.toContainer()).moveFrom( + source, + actualLimit, + toSlot.orElse(0) - 1, + fromSlot - 1, + predicate, + ) + if (moved > 0) for (slot in 0 until inventory.size()) inventory.sendChangeNotification(slot) + return moved +} + +private fun pushItem( + level: Level, + access: IComputerAccess, + inventory: InternalInventory, + toName: String, + fromSlot: Int, + limit: Optional, + toSlot: Optional, +): Pair { + assertBetween(fromSlot, 1, inventory.size(), "fromSlot") + val location = peripheral(access, toName, false) + val direction = (location as? ISidedPeripheral)?.side + val target = AgnosticItemSinkLookup.extractFromUnknown(level, location.target, direction) + ?: throw LuaException("Target '$toName' is not an inventory") + if (toSlot.isPresent) { + if (target !is SlottedAgnosticSink) throw LuaException("Target '$toName' is not slotted storage") + assertBetween(toSlot.get(), 1, target.size, "toSlot") + } + val actualLimit = parseLimit(limit) + if (actualLimit == 0) return 0 to ItemStack.EMPTY + val stack = inventory.getStackInSlot(fromSlot - 1).copy() + val moved = ContainerWrapper(inventory.toContainer()).moveTo( + target, + actualLimit, + fromSlot - 1, + toSlot.orElse(0) - 1, + ItemStorageUtils.ALWAYS, + ) + if (moved > 0) inventory.sendChangeNotification(fromSlot - 1) + return moved to stack +} + +private fun clearInactiveFilters(upgrades: IUpgradeInventory, config: ConfigInventory) { + val active = min(18 + upgrades.getInstalledUpgrades(AEItems.CAPACITY_CARD) * 9, config.size()) + for (slot in active until config.size()) config.setStack(slot, null) +} + +internal abstract class DeviceObject( + private val deviceType: String, + protected val level: Level, + private val resolveDevice: () -> T, +) { + protected fun device(): T = resolveDevice() + + @LuaFunction(mainThread = true) + fun getDeviceType(): String { + device() + return deviceType + } +} + +internal abstract class UpgradeableDeviceObject( + deviceType: String, + level: Level, + resolveDevice: () -> T, + private val upgrades: (T) -> IUpgradeInventory, + private val config: ((T) -> ConfigInventory)? = null, +) : DeviceObject(deviceType, level, resolveDevice) { + protected fun upgradeInventory(): IUpgradeInventory = upgrades(device()) + + protected fun pullUpgrade( + access: IComputerAccess, + attached: (() -> Boolean)?, + fromName: String, + fromSlot: Int, + limit: Optional, + toSlot: Optional, + ): Int { + requireAttached(access, attached) + return pullItem(level, access, upgradeInventory(), fromName, fromSlot, limit, toSlot) { true } + } + + protected fun pushUpgrade( + access: IComputerAccess, + attached: (() -> Boolean)?, + toName: String, + fromSlot: Int, + limit: Optional, + toSlot: Optional, + ): Int { + requireAttached(access, attached) + val target = device() + val inventory = upgrades(target) + val (moved, stack) = pushItem(level, access, inventory, toName, fromSlot, limit, toSlot) + if (moved > 0 && stack.`is`(AEItems.CAPACITY_CARD.asItem())) { + config?.invoke(target)?.let { + clearInactiveFilters(inventory, it) + } + } + return moved + } + + @LuaFunction(mainThread = true) + fun getUpgradeSlotCount(): Int = upgradeInventory().size() + + @LuaFunction(mainThread = true) + fun listUpgrades(): Map> = buildMap { + val inventory = upgradeInventory() + for (slot in 0 until inventory.size()) { + val stack = inventory.getStackInSlot(slot) + if (!stack.isEmpty) put(slot + 1, itemDetails(stack)) + } + } + + @LuaFunction(mainThread = true) + fun getUpgrade(slot: Int): Map? { + val inventory = upgradeInventory() + assertBetween(slot, 1, inventory.size(), "slot") + return inventory.getStackInSlot(slot - 1).takeUnless(ItemStack::isEmpty)?.let(::itemDetails) + } +} + +internal abstract class FilterDeviceObject( + deviceType: String, + level: Level, + resolveDevice: () -> T, + private val filterUpgrades: (T) -> IUpgradeInventory, + private val filter: (T) -> ConfigInventory, +) : UpgradeableDeviceObject(deviceType, level, resolveDevice, filterUpgrades, filter) { + private fun activeSlots(target: T): Int = min( + 18 + filterUpgrades(target).getInstalledUpgrades(AEItems.CAPACITY_CARD) * 9, + filter(target).size(), + ) + + private fun checkedFilter(slot: Int): Pair { + val target = device() + val size = activeSlots(target) + assertBetween(slot, 1, size, "slot") + return filter(target) to size + } + + @LuaFunction(mainThread = true) + fun getFilterSlotCount(): Int { + val target = device() + return activeSlots(target) + } + + @LuaFunction(mainThread = true) + fun listFilters(): Map> { + val target = device() + val inventory = filter(target) + return buildMap { + for (slot in 0 until activeSlots(target)) { + inventory.getKey(slot)?.let { + put(slot + 1, AE2Helper.keyToMap(it)) + } + } + } + } + + @LuaFunction(mainThread = true) + fun getFilter(slot: Int): Map? = checkedFilter(slot).first.getKey(slot - 1)?.let(AE2Helper::keyToMap) + + @LuaFunction(mainThread = true) + fun setFilter(slot: Int, resource: Map<*, *>) { + val inventory = checkedFilter(slot).first + val stack = AE2Helper.parseResource(resource, false) + if (!inventory.isAllowed(stack.what())) throw LuaException("Resource is not supported by this device") + inventory.setStack(slot - 1, stack) + } + + @LuaFunction(mainThread = true) + fun clearFilter(slot: Int) { + checkedFilter(slot).first.setStack(slot - 1, null) + } +} + +internal open class InterfaceObject(level: Level, resolve: () -> InterfaceLogicHost) : + UpgradeableDeviceObject("interface", level, resolve, InterfaceLogicHost::getUpgrades), + IPeripheralPlugin { + private val host: InterfaceLogicHost get() = device() + + @LuaFunction(mainThread = true) + fun getPriority(): Int = host.priority + + @LuaFunction(mainThread = true) + fun setPriority(priority: Int) { + host.priority = priority + } + + @LuaFunction(mainThread = true) + fun getFuzzyMode(): String = host.configManager.getSetting(Settings.FUZZY_MODE).name.lowercase(Locale.ROOT) + + @LuaFunction(mainThread = true) + fun setFuzzyMode(mode: String) { + host.configManager.putSetting( + Settings.FUZZY_MODE, + FuzzyMode.entries.firstOrNull { it.name.lowercase(Locale.ROOT) == mode } + ?: throw LuaException("Invalid fuzzy mode '$mode'"), + ) + } + + private fun stock(slot: Int): Pair { + assertBetween(slot, 1, 9, "slot") + return host.interfaceLogic.config to host.interfaceLogic.storage + } + + private fun stockRow(slot: Int): Map>? { + val (config, storage) = stock(slot) + val target = config.getStack(slot - 1) + val stored = storage.getStack(slot - 1) + if (target == null && stored == null) return null + return buildMap { + target?.let { put("target", AE2Helper.stackToMap(it)) } + stored?.let { put("stored", AE2Helper.stackToMap(it)) } + } + } + + @LuaFunction(mainThread = true) + fun listStock(): Map>> = buildMap { + for (slot in 1..9) stockRow(slot)?.let { put(slot, it) } + } + + @LuaFunction(mainThread = true) + fun getStock(slot: Int): Map>? = stockRow(slot) + + @LuaFunction(mainThread = true) + fun setStock(slot: Int, target: Map<*, *>) { + val config = stock(slot).first + val stack = AE2Helper.parseResource(target, true) + if (!config.isAllowed(stack.what())) throw LuaException("Resource is not supported by this interface") + if (stack.amount() > config.getMaxAmount(stack.what())) throw LuaException("Stock amount is too large for this resource") + config.setStack(slot - 1, stack) + } + + @LuaFunction(mainThread = true) + fun clearStock(slot: Int) { + stock(slot).first.setStack(slot - 1, null) + } +} + +internal class DirectInterfaceObject(level: Level, entity: InterfaceBlockEntity) : InterfaceObject(level, { entity }) { + @LuaFunction(mainThread = true) + fun pullUpgrade(access: IComputerAccess, fromName: String, fromSlot: Int, limit: Optional, toSlot: Optional): Int = pullUpgrade(access, null, fromName, fromSlot, limit, toSlot) + + @LuaFunction(mainThread = true) + fun pushUpgrade(access: IComputerAccess, toName: String, fromSlot: Int, limit: Optional, toSlot: Optional): Int = pushUpgrade(access, null, toName, fromSlot, limit, toSlot) +} + +internal class SideInterfaceObject( + level: Level, + resolve: () -> InterfacePart, + private val access: IComputerAccess, + private val attached: () -> Boolean, +) : InterfaceObject(level, resolve) { + @LuaFunction(mainThread = true) + fun pullUpgrade(fromName: String, fromSlot: Int, limit: Optional, toSlot: Optional): Int = pullUpgrade(access, attached, fromName, fromSlot, limit, toSlot) + + @LuaFunction(mainThread = true) + fun pushUpgrade(toName: String, fromSlot: Int, limit: Optional, toSlot: Optional): Int = pushUpgrade(access, attached, toName, fromSlot, limit, toSlot) +} + +internal abstract class BusObject( + deviceType: String, + level: Level, + resolve: () -> T, +) : FilterDeviceObject(deviceType, level, resolve, IOBusPart::getUpgrades, IOBusPart::getConfig) { + @LuaFunction(mainThread = true) + fun getFuzzyMode(): String = device().configManager.getSetting(Settings.FUZZY_MODE).name.lowercase(Locale.ROOT) + + @LuaFunction(mainThread = true) + fun setFuzzyMode(mode: String) = device().configManager.putSetting( + Settings.FUZZY_MODE, + FuzzyMode.entries.firstOrNull { it.name.lowercase(Locale.ROOT) == mode } + ?: throw LuaException("Invalid fuzzy mode '$mode'"), + ) + + @LuaFunction(mainThread = true) + fun getRedstoneMode(): String = device().configManager.getSetting(Settings.REDSTONE_CONTROLLED).name.lowercase(Locale.ROOT) + + @LuaFunction(mainThread = true) + fun setRedstoneMode(mode: String) = device().configManager.putSetting( + Settings.REDSTONE_CONTROLLED, + RedstoneMode.entries.firstOrNull { it.name.lowercase(Locale.ROOT) == mode } + ?: throw LuaException("Invalid redstone mode '$mode'"), + ) +} + +internal class ImportBusObject( + level: Level, + resolve: () -> ImportBusPart, + private val access: IComputerAccess, + private val attached: () -> Boolean, +) : BusObject("import_bus", level, resolve) { + @LuaFunction(mainThread = true) + fun pullUpgrade(fromName: String, fromSlot: Int, limit: Optional, toSlot: Optional): Int = pullUpgrade(access, attached, fromName, fromSlot, limit, toSlot) + + @LuaFunction(mainThread = true) + fun pushUpgrade(toName: String, fromSlot: Int, limit: Optional, toSlot: Optional): Int = pushUpgrade(access, attached, toName, fromSlot, limit, toSlot) +} + +internal class ExportBusObject( + level: Level, + resolve: () -> ExportBusPart, + private val access: IComputerAccess, + private val attached: () -> Boolean, +) : BusObject("export_bus", level, resolve) { + @LuaFunction(mainThread = true) + fun pullUpgrade(fromName: String, fromSlot: Int, limit: Optional, toSlot: Optional): Int = pullUpgrade(access, attached, fromName, fromSlot, limit, toSlot) + + @LuaFunction(mainThread = true) + fun pushUpgrade(toName: String, fromSlot: Int, limit: Optional, toSlot: Optional): Int = pushUpgrade(access, attached, toName, fromSlot, limit, toSlot) + + @LuaFunction(mainThread = true) + fun isCraftOnly(): Boolean = device().configManager.getSetting(Settings.CRAFT_ONLY) == YesNo.YES + + @LuaFunction(mainThread = true) + fun setCraftOnly(craftOnly: Boolean) = device().configManager.putSetting(Settings.CRAFT_ONLY, if (craftOnly) YesNo.YES else YesNo.NO) + + @LuaFunction(mainThread = true) + fun getSchedulingMode(): String = when (device().configManager.getSetting(Settings.SCHEDULING_MODE)) { + SchedulingMode.DEFAULT -> "default" + SchedulingMode.ROUNDROBIN -> "round_robin" + SchedulingMode.RANDOM -> "random" + } + + @LuaFunction(mainThread = true) + fun setSchedulingMode(mode: String) = device().configManager.putSetting( + Settings.SCHEDULING_MODE, + when (mode) { + "default" -> SchedulingMode.DEFAULT + "round_robin" -> SchedulingMode.ROUNDROBIN + "random" -> SchedulingMode.RANDOM + else -> throw LuaException("Invalid scheduling mode '$mode'") + }, + ) +} + +internal abstract class PriorityFilterObject( + deviceType: String, + level: Level, + resolve: () -> T, + filter: (T) -> ConfigInventory, +) : FilterDeviceObject(deviceType, level, resolve, UpgradeablePart::getUpgrades, filter) { + @LuaFunction(mainThread = true) + fun getPriority(): Int = (device() as IPriorityHost).priority + + @LuaFunction(mainThread = true) + fun setPriority(priority: Int) { + (device() as IPriorityHost).priority = priority + } + + @LuaFunction(mainThread = true) + fun getFuzzyMode(): String = device().configManager.getSetting(Settings.FUZZY_MODE).name.lowercase(Locale.ROOT) + + @LuaFunction(mainThread = true) + fun setFuzzyMode(mode: String) = device().configManager.putSetting( + Settings.FUZZY_MODE, + FuzzyMode.entries.firstOrNull { it.name.lowercase(Locale.ROOT) == mode } + ?: throw LuaException("Invalid fuzzy mode '$mode'"), + ) +} + +internal class StorageBusObject( + level: Level, + resolve: () -> StorageBusPart, + private val access: IComputerAccess, + private val attached: () -> Boolean, +) : PriorityFilterObject("storage_bus", level, resolve, StorageBusPart::getConfig) { + @LuaFunction(mainThread = true) + fun pullUpgrade(fromName: String, fromSlot: Int, limit: Optional, toSlot: Optional): Int = pullUpgrade(access, attached, fromName, fromSlot, limit, toSlot) + + @LuaFunction(mainThread = true) + fun pushUpgrade(toName: String, fromSlot: Int, limit: Optional, toSlot: Optional): Int = pushUpgrade(access, attached, toName, fromSlot, limit, toSlot) + + @LuaFunction(mainThread = true) + fun getAccessMode(): String = device().configManager.getSetting(Settings.ACCESS).name.lowercase(Locale.ROOT) + + @LuaFunction(mainThread = true) + fun setAccessMode(mode: String) = device().configManager.putSetting( + Settings.ACCESS, + AccessRestriction.entries.firstOrNull { it.name.lowercase(Locale.ROOT) == mode } + ?: throw LuaException("Invalid access mode '$mode'"), + ) + + @LuaFunction(mainThread = true) + fun getStorageFilterMode(): String = device().configManager.getSetting(Settings.STORAGE_FILTER).name.lowercase(Locale.ROOT) + + @LuaFunction(mainThread = true) + fun setStorageFilterMode(mode: String) = device().configManager.putSetting( + Settings.STORAGE_FILTER, + StorageFilter.entries.firstOrNull { it.name.lowercase(Locale.ROOT) == mode } + ?: throw LuaException("Invalid storage filter mode '$mode'"), + ) + + @LuaFunction(mainThread = true) + fun shouldFilterOnExtract(): Boolean = device().configManager.getSetting(Settings.FILTER_ON_EXTRACT) == YesNo.YES + + @LuaFunction(mainThread = true) + fun setFilterOnExtract(filterOnExtract: Boolean) = device().configManager.putSetting(Settings.FILTER_ON_EXTRACT, if (filterOnExtract) YesNo.YES else YesNo.NO) +} + +internal class FormationPlaneObject( + level: Level, + resolve: () -> FormationPlanePart, + private val access: IComputerAccess, + private val attached: () -> Boolean, +) : PriorityFilterObject("formation_plane", level, resolve, FormationPlanePart::getConfig) { + @LuaFunction(mainThread = true) + fun pullUpgrade(fromName: String, fromSlot: Int, limit: Optional, toSlot: Optional): Int = pullUpgrade(access, attached, fromName, fromSlot, limit, toSlot) + + @LuaFunction(mainThread = true) + fun pushUpgrade(toName: String, fromSlot: Int, limit: Optional, toSlot: Optional): Int = pushUpgrade(access, attached, toName, fromSlot, limit, toSlot) + + @LuaFunction(mainThread = true) + fun shouldPlaceBlocks(): Boolean = device().configManager.getSetting(Settings.PLACE_BLOCK) == YesNo.YES + + @LuaFunction(mainThread = true) + fun setPlaceBlocks(placeBlocks: Boolean) = device().configManager.putSetting(Settings.PLACE_BLOCK, if (placeBlocks) YesNo.YES else YesNo.NO) +} + +internal abstract class LevelEmitterObject( + deviceType: String, + level: Level, + resolve: () -> T, +) : DeviceObject(deviceType, level, resolve) { + @LuaFunction(mainThread = true) + fun getEmitterMode(): String = device().configManager.getSetting(Settings.REDSTONE_EMITTER).name.lowercase(Locale.ROOT) + + @LuaFunction(mainThread = true) + fun setEmitterMode(mode: String) = device().configManager.putSetting( + Settings.REDSTONE_EMITTER, + when (mode) { + "low_signal" -> RedstoneMode.LOW_SIGNAL + "high_signal" -> RedstoneMode.HIGH_SIGNAL + else -> throw LuaException("Invalid emitter mode '$mode'") + }, + ) + + @LuaFunction(mainThread = true) + fun isEmitting(): Boolean = device().isProvidingWeakPower > 0 +} + +internal class StorageLevelEmitterObject( + level: Level, + resolve: () -> StorageLevelEmitterPart, + private val access: IComputerAccess, + private val attached: () -> Boolean, +) : LevelEmitterObject("storage_level_emitter", level, resolve) { + private fun internalThreshold(threshold: Long): Long { + if (threshold < 0) throw LuaException("Threshold must be a non-negative integer") + val key = device().config.getKey(0) + if (key !is AEFluidKey) return threshold + val divider = PlatformToolkit.get().fluidCompactDivider.toLong() + if (threshold > Long.MAX_VALUE / divider) throw LuaException("Threshold is too large") + return threshold * divider + } + + @LuaFunction(mainThread = true) + fun getUpgradeSlotCount(): Int = device().upgrades.size() + + @LuaFunction(mainThread = true) + fun listUpgrades(): Map> = buildMap { + val inventory = device().upgrades + for (slot in 0 until inventory.size()) { + inventory.getStackInSlot(slot).takeUnless(ItemStack::isEmpty)?.let { + put(slot + 1, itemDetails(it)) + } + } + } + + @LuaFunction(mainThread = true) + fun getUpgrade(slot: Int): Map? { + val inventory = device().upgrades + assertBetween(slot, 1, inventory.size(), "slot") + return inventory.getStackInSlot(slot - 1).takeUnless(ItemStack::isEmpty)?.let(::itemDetails) + } + + @LuaFunction(mainThread = true) + fun pullUpgrade(fromName: String, fromSlot: Int, limit: Optional, toSlot: Optional): Int { + requireAttached(access, attached) + return pullItem(level, access, device().upgrades, fromName, fromSlot, limit, toSlot) { true } + } + + @LuaFunction(mainThread = true) + fun pushUpgrade(toName: String, fromSlot: Int, limit: Optional, toSlot: Optional): Int { + requireAttached(access, attached) + return pushItem(level, access, device().upgrades, toName, fromSlot, limit, toSlot).first + } + + @LuaFunction(mainThread = true) + fun getFuzzyMode(): String = device().configManager.getSetting(Settings.FUZZY_MODE).name.lowercase(Locale.ROOT) + + @LuaFunction(mainThread = true) + fun setFuzzyMode(mode: String) = device().configManager.putSetting( + Settings.FUZZY_MODE, + FuzzyMode.entries.firstOrNull { it.name.lowercase(Locale.ROOT) == mode } + ?: throw LuaException("Invalid fuzzy mode '$mode'"), + ) + + @LuaFunction(mainThread = true) + fun getMonitoredResource(): Map? = device().config.getKey(0)?.let(AE2Helper::keyToMap) + + @LuaFunction(mainThread = true) + fun setMonitoredResource(resource: Map<*, *>) { + val target = device() + val stack = AE2Helper.parseResource(resource, false) + if (!target.config.isAllowed(stack.what())) throw LuaException("Resource is not supported by this emitter") + target.config.setStack(0, stack) + } + + @LuaFunction(mainThread = true) + fun clearMonitoredResource() = device().config.setStack(0, null) + + @LuaFunction(mainThread = true) + fun getThreshold(): Long { + val target = device() + return AE2Helper.publicAmount(target.config.getKey(0) ?: return target.reportingValue, target.reportingValue) + } + + @LuaFunction(mainThread = true) + fun setThreshold(threshold: Long) = device().setReportingValue(internalThreshold(threshold)) + + @LuaFunction(mainThread = true) + fun getThresholdUnit(): String = when (device().config.getKey(0)) { + null -> "ae_internal" + is AEFluidKey -> "millibucket" + else -> "item" + } + + @LuaFunction(mainThread = true) + fun shouldCraftViaRedstone(): Boolean = device().configManager.getSetting(Settings.CRAFT_VIA_REDSTONE) == YesNo.YES + + @LuaFunction(mainThread = true) + fun setCraftViaRedstone(craftViaRedstone: Boolean) = device().configManager.putSetting(Settings.CRAFT_VIA_REDSTONE, if (craftViaRedstone) YesNo.YES else YesNo.NO) +} + +internal class EnergyLevelEmitterObject(level: Level, resolve: () -> EnergyLevelEmitterPart) : LevelEmitterObject("energy_level_emitter", level, resolve) { + @LuaFunction(mainThread = true) + fun getThreshold(): Long = device().reportingValue + + @LuaFunction(mainThread = true) + fun setThreshold(threshold: Long) { + if (threshold < 0) throw LuaException("Threshold must be a non-negative integer") + device().reportingValue = threshold + } +} + +internal open class PatternProviderObject(level: Level, resolve: () -> PatternProviderLogicHost) : + DeviceObject("pattern_provider", level, resolve), + IPeripheralPlugin { + protected fun patternInventory(): InternalInventory = device().logic.patternInv + + protected fun pullPattern(access: IComputerAccess, attached: (() -> Boolean)?, fromName: String, fromSlot: Int, limit: Optional, toSlot: Optional): Int { + requireAttached(access, attached) + return pullItem(level, access, patternInventory(), fromName, fromSlot, limit, toSlot) { + PatternDetailsHelper.decodePattern(it, level) != null + } + } + + protected fun pushPattern(access: IComputerAccess, attached: (() -> Boolean)?, toName: String, fromSlot: Int, limit: Optional, toSlot: Optional): Int { + requireAttached(access, attached) + return pushItem(level, access, patternInventory(), toName, fromSlot, limit, toSlot).first + } + + @LuaFunction(mainThread = true) + fun listPatterns(): Map> = buildMap { + val inventory = patternInventory() + for (slot in 0 until inventory.size()) { + inventory.getStackInSlot(slot).takeUnless(ItemStack::isEmpty)?.let { + put(slot + 1, itemDetails(it)) + } + } + } + + @LuaFunction(mainThread = true) + fun getPattern(slot: Int): Map? { + val inventory = patternInventory() + assertBetween(slot, 1, inventory.size(), "slot") + return inventory.getStackInSlot(slot - 1).takeUnless(ItemStack::isEmpty)?.let(::itemDetails) + } + + @LuaFunction(mainThread = true) + fun getPriority(): Int = device().priority + + @LuaFunction(mainThread = true) + fun setPriority(priority: Int) { + device().priority = priority + } + + @LuaFunction(mainThread = true) + fun isBlocking(): Boolean = device().configManager.getSetting(Settings.BLOCKING_MODE) == YesNo.YES + + @LuaFunction(mainThread = true) + fun setBlocking(blocking: Boolean) = device().configManager.putSetting(Settings.BLOCKING_MODE, if (blocking) YesNo.YES else YesNo.NO) + + @LuaFunction(mainThread = true) + fun isVisibleInPatternAccessTerminal(): Boolean = device().configManager.getSetting(Settings.PATTERN_ACCESS_TERMINAL) == YesNo.YES + + @LuaFunction(mainThread = true) + fun setVisibleInPatternAccessTerminal(visible: Boolean) = device().configManager.putSetting(Settings.PATTERN_ACCESS_TERMINAL, if (visible) YesNo.YES else YesNo.NO) + + @LuaFunction(mainThread = true) + fun getPatternLockMode(): String = device().configManager.getSetting(Settings.LOCK_CRAFTING_MODE).name.lowercase(Locale.ROOT) + + @LuaFunction(mainThread = true) + fun setPatternLockMode(mode: String) = device().configManager.putSetting( + Settings.LOCK_CRAFTING_MODE, + LockCraftingMode.entries.firstOrNull { it.name.lowercase(Locale.ROOT) == mode } + ?: throw LuaException("Invalid pattern lock mode '$mode'"), + ) +} + +internal class DirectPatternProviderObject( + level: Level, + private val entity: PatternProviderBlockEntity, +) : PatternProviderObject(level, { entity }) { + @LuaFunction(mainThread = true) + fun pullPattern(access: IComputerAccess, fromName: String, fromSlot: Int, limit: Optional, toSlot: Optional): Int = pullPattern(access, null, fromName, fromSlot, limit, toSlot) + + @LuaFunction(mainThread = true) + fun pushPattern(access: IComputerAccess, toName: String, fromSlot: Int, limit: Optional, toSlot: Optional): Int = pushPattern(access, null, toName, fromSlot, limit, toSlot) + + @LuaFunction(mainThread = true) + fun getPushDirection(): String = entity.blockState.getValue(PatternProviderBlock.PUSH_DIRECTION).serializedName + + @LuaFunction(mainThread = true) + fun setPushDirection(direction: String) { + val value = PushDirection.entries.firstOrNull { it.serializedName == direction } + ?: throw LuaException("Invalid push direction '$direction'") + level.setBlockAndUpdate(entity.blockPos, entity.blockState.setValue(PatternProviderBlock.PUSH_DIRECTION, value)) + } +} + +internal class SidePatternProviderObject( + level: Level, + resolve: () -> PatternProviderPart, + private val access: IComputerAccess, + private val attached: () -> Boolean, +) : PatternProviderObject(level, resolve) { + @LuaFunction(mainThread = true) + fun pullPattern(fromName: String, fromSlot: Int, limit: Optional, toSlot: Optional): Int = pullPattern(access, attached, fromName, fromSlot, limit, toSlot) + + @LuaFunction(mainThread = true) + fun pushPattern(toName: String, fromSlot: Int, limit: Optional, toSlot: Optional): Int = pushPattern(access, attached, toName, fromSlot, limit, toSlot) +} + +internal class CableConfigurableObject(private val level: Level, private val pos: BlockPos) : IPeripheralPlugin { + override var connectedPeripheral: IExpandedPeripheral? = null + + private fun resolve(side: Direction, expected: Class): T { + val cable = level.getBlockEntity(pos) as? CableBusBlockEntity + ?: throw LuaException("AE2 cable is no longer present") + val part = cable.getPart(side) + if (part == null || part.javaClass != expected) throw LuaException("AE2 ${side.serializedName} part is no longer the expected device") + return expected.cast(part) + } + + @LuaFunction(mainThread = true) + fun getSide(access: IComputerAccess, side: String): MethodResult { + val direction = parseDirection(side) + val cable = level.getBlockEntity(pos) as? CableBusBlockEntity + ?: return MethodResult.of(null, "AE2 cable is no longer present") + val part = cable.getPart(direction) ?: return MethodResult.of(null, "No AE2 part on side '$side'") + val attached = { connectedPeripheral?.isComputerPresent(access.id) == true } + val result: Any = when (part.javaClass) { + InterfacePart::class.java -> SideInterfaceObject(level, { resolve(direction, InterfacePart::class.java) }, access, attached) + ImportBusPart::class.java -> ImportBusObject(level, { resolve(direction, ImportBusPart::class.java) }, access, attached) + ExportBusPart::class.java -> ExportBusObject(level, { resolve(direction, ExportBusPart::class.java) }, access, attached) + StorageBusPart::class.java -> StorageBusObject(level, { resolve(direction, StorageBusPart::class.java) }, access, attached) + FormationPlanePart::class.java -> FormationPlaneObject(level, { resolve(direction, FormationPlanePart::class.java) }, access, attached) + StorageLevelEmitterPart::class.java -> StorageLevelEmitterObject(level, { resolve(direction, StorageLevelEmitterPart::class.java) }, access, attached) + EnergyLevelEmitterPart::class.java -> EnergyLevelEmitterObject(level, { resolve(direction, EnergyLevelEmitterPart::class.java) }) + PatternProviderPart::class.java -> SidePatternProviderObject(level, { resolve(direction, PatternProviderPart::class.java) }, access, attached) + else -> return MethodResult.of(null, "Unsupported AE2 part on side '$side'") + } + return MethodResult.of(result) + } +} + +object AE2CableObjectProvider : PeripheralPluginProvider { + override val pluginType = "ae2_cable_objects" + override fun provide(level: Level, pos: BlockPos, side: Direction): IPeripheralPlugin? = if (level.getBlockEntity(pos) is CableBusBlockEntity) CableConfigurableObject(level, pos) else null +} + +object AE2InterfaceObjectProvider : PeripheralPluginProvider { + override val pluginType = "ae2_interface_object" + override fun provide(level: Level, pos: BlockPos, side: Direction): IPeripheralPlugin? = (level.getBlockEntity(pos) as? InterfaceBlockEntity)?.let { DirectInterfaceObject(level, it) } +} + +object AE2PatternProviderObjectProvider : PeripheralPluginProvider { + override val pluginType = "ae2_pattern_provider_object" + override fun provide(level: Level, pos: BlockPos, side: Direction): IPeripheralPlugin? = (level.getBlockEntity(pos) as? PatternProviderBlockEntity)?.let { DirectPatternProviderObject(level, it) } +} diff --git a/projects/forge/src/main/kotlin/site/siredvin/peripheralworks/integrations/ae2/AE2CraftingJobs.kt b/projects/forge/src/main/kotlin/site/siredvin/peripheralworks/integrations/ae2/AE2CraftingJobs.kt new file mode 100644 index 00000000..80c62987 --- /dev/null +++ b/projects/forge/src/main/kotlin/site/siredvin/peripheralworks/integrations/ae2/AE2CraftingJobs.kt @@ -0,0 +1,159 @@ +package site.siredvin.peripheralworks.integrations.ae2 + +import appeng.api.networking.crafting.CalculationStrategy +import appeng.api.networking.crafting.ICraftingLink +import appeng.api.networking.crafting.ICraftingService +import appeng.api.networking.security.IActionSource +import appeng.blockentity.grid.AENetworkBlockEntity +import appeng.core.definitions.AEItems +import dan200.computercraft.api.lua.LuaFunction +import dan200.computercraft.api.lua.MethodResult +import net.minecraft.core.BlockPos +import net.minecraft.core.Direction +import net.minecraft.world.level.Level +import site.siredvin.broccolium.modules.platform.PlatformToolkit +import site.siredvin.peripheralworks.api.PeripheralPluginProvider +import site.siredvin.peripheralworks.integrations.ae2.AE2Helper.buildKey +import site.siredvin.peripheralworks.integrations.ae2.AE2Helper.keyCounterToLua +import site.siredvin.peripheralworks.integrations.ae2.AE2Helper.stackToMap +import site.siredvin.tweakium.modules.peripheral.api.IPeripheralPlugin +import site.siredvin.tweakium.modules.peripheral.owner.TurtlePeripheralOwner +import java.lang.ref.WeakReference +import java.util.Collections +import java.util.Locale +import java.util.Optional +import java.util.WeakHashMap + +object AE2CraftingJobs { + private data class Job(val service: WeakReference, val target: Map, val amount: Long) + + // ponytail: job counts are tiny; scan weak keys until UUID lookup is proven necessary. + private val jobs = Collections.synchronizedMap(WeakHashMap()) + + fun schedule( + level: Level, + service: ICraftingService, + source: IActionSource, + mode: String, + id: String, + amount: Optional, + targetCPU: Optional, + ): MethodResult { + val publicAmount = amount.orElse(if (mode == "item") 1 else 1000) + if (publicAmount <= 0) return MethodResult.of(null, "Amount must be positive") + val key = buildKey(mode, id) + val realAmount = try { + if (mode == "fluid") Math.multiplyExact(publicAmount, PlatformToolkit.get().fluidCompactDivider.toLong()) else publicAmount + } catch (_: ArithmeticException) { + return MethodResult.of(null, "Amount is too large") + } + val plan = service.beginCraftingCalculation(level, { source }, key, realAmount, CalculationStrategy.REPORT_MISSING_ITEMS).get() + if (!plan.missingItems().isEmpty) return MethodResult.of(false, "Missing items", keyCounterToLua(plan.missingItems())) + val cpu = if (targetCPU.isPresent) { + service.cpus.firstOrNull { it.name?.string == targetCPU.get() } + ?: return MethodResult.of(null, "Cannot find target CPU") + } else { + null + } + val submitted = service.submitJob(plan, null, cpu, false, source) + if (!submitted.successful()) { + return MethodResult.of(null, "Cannot submit crafting job: ${submitted.errorCode()?.name?.lowercase(Locale.ROOT) ?: "unknown error"}") + } + val link = submitted.link() ?: return MethodResult.of(null, "AE2 did not return a crafting job") + val jobID = link.craftingID.toString() + jobs[link] = Job(WeakReference(service), stackToMap(plan.finalOutput()), publicAmount) + return MethodResult.of(true, jobID) + } + + fun get(service: ICraftingService, jobID: String): MethodResult { + val job = find(service, jobID) ?: return missing(jobID) + return MethodResult.of(toMap(job.first, job.second)) + } + + fun getAll(service: ICraftingService): List> = synchronized(jobs) { + jobs.mapNotNull { (link, job) -> if (job.service.get() === service) toMap(link, job) else null } + } + + fun cancel(service: ICraftingService, jobID: String): MethodResult { + val (link) = find(service, jobID) ?: return missing(jobID) + if (link.isCanceled) return MethodResult.of(false, "Crafting job '$jobID' is already canceled") + if (link.isDone) return MethodResult.of(false, "Crafting job '$jobID' is already done") + link.cancel() + return MethodResult.of(true) + } + + private fun find(service: ICraftingService, jobID: String): Pair? = synchronized(jobs) { + jobs.entries.firstOrNull { (link, job) -> link.craftingID.toString() == jobID && job.service.get() === service } + ?.let { it.key to it.value } + } + + private fun toMap(link: ICraftingLink, job: Job): Map = mapOf( + "id" to link.craftingID.toString(), + "state" to when { + link.isCanceled -> "canceled" + link.isDone -> "done" + else -> "running" + }, + "target" to job.target, + "amount" to job.amount, + ) + + private fun missing(jobID: String): MethodResult = MethodResult.of(null, "Crafting job '$jobID' was not found") +} + +class AE2CraftingJobsPlugin private constructor( + private val resolve: () -> Context?, + private val withActionSource: (((IActionSource) -> MethodResult) -> MethodResult), + private val unavailableMessage: String, +) : IPeripheralPlugin { + private data class Context(val level: Level, val service: ICraftingService) + + @LuaFunction(mainThread = false) + fun scheduleCrafting(mode: String, id: String, amount: Optional, targetCPU: Optional): MethodResult { + val context = resolve() ?: return unavailable() + return withActionSource { source -> AE2CraftingJobs.schedule(context.level, context.service, source, mode, id, amount, targetCPU) } + } + + @LuaFunction(mainThread = true) + fun getCraftingJob(jobID: String): MethodResult { + val context = resolve() ?: return unavailable() + return AE2CraftingJobs.get(context.service, jobID) + } + + @LuaFunction(mainThread = true) + fun getCraftingJobs(): List> = resolve()?.let { AE2CraftingJobs.getAll(it.service) } ?: emptyList() + + @LuaFunction(mainThread = true) + fun cancelCrafting(jobID: String): MethodResult { + val context = resolve() ?: return unavailable() + return AE2CraftingJobs.cancel(context.service, jobID) + } + + private fun unavailable(): MethodResult = MethodResult.of(null, unavailableMessage) + + companion object { + fun forMachine(level: Level, entity: AENetworkBlockEntity) = AE2CraftingJobsPlugin( + resolve = { entity.mainNode.grid?.craftingService?.let { Context(level, it) } }, + withActionSource = { callback -> callback(IActionSource.ofMachine(entity)) }, + unavailableMessage = "AE2 network is not connected", + ) + + fun forTurtle(owner: TurtlePeripheralOwner) = AE2CraftingJobsPlugin( + resolve = { + owner.level?.let { level -> Context(level, resolveWirelessSession(owner, AEItems.WIRELESS_CRAFTING_TERMINAL.asItem()).craftingService) } + }, + withActionSource = { callback -> owner.withPlayer({ callback(IActionSource.ofPlayer(it.fakePlayer)) }, skipInventory = true) }, + unavailableMessage = "Linked AE2 network is unavailable", + ) + } +} + +object AE2CraftingJobsPluginProvider : PeripheralPluginProvider { + override val pluginType = "ae2_crafting_jobs" + + override fun provide(level: Level, pos: BlockPos, side: Direction): IPeripheralPlugin? { + if (!Configuration.enableMEInterface) return null + val entity = level.getBlockEntity(pos) as? AENetworkBlockEntity ?: return null + return AE2CraftingJobsPlugin.forMachine(level, entity) + } +} diff --git a/projects/forge/src/main/kotlin/site/siredvin/peripheralworks/integrations/ae2/AE2CraftingMonitor.kt b/projects/forge/src/main/kotlin/site/siredvin/peripheralworks/integrations/ae2/AE2CraftingMonitor.kt new file mode 100644 index 00000000..e9e944ea --- /dev/null +++ b/projects/forge/src/main/kotlin/site/siredvin/peripheralworks/integrations/ae2/AE2CraftingMonitor.kt @@ -0,0 +1,48 @@ +package site.siredvin.peripheralworks.integrations.ae2 + +import appeng.core.definitions.AEItems +import dan200.computercraft.api.turtle.ITurtleAccess +import dan200.computercraft.api.turtle.TurtleSide +import net.minecraft.nbt.CompoundTag +import net.minecraft.nbt.Tag +import net.minecraft.resources.ResourceLocation +import net.minecraft.world.item.ItemStack +import site.siredvin.peripheralworks.PeripheralWorksCore +import site.siredvin.tweakium.modules.peripheral.OwnedPeripheral +import site.siredvin.tweakium.modules.peripheral.owner.TurtlePeripheralOwner +import site.siredvin.tweakium.modules.turtle.PeripheralTurtleUpgrade + +class AE2CraftingMonitorUpgrade(stack: ItemStack) : PeripheralTurtleUpgrade(UPGRADE_ID, stack) { + override fun buildPeripheral(turtle: ITurtleAccess, side: TurtleSide): AE2CraftingMonitorPeripheral = AE2CraftingMonitorPeripheral.create(turtle, side) + + override fun getUpgradeData(stack: ItemStack): CompoundTag = CompoundTag().apply { + put(AE2_TERMINAL_TAG, stack.save(CompoundTag())) + } + + override fun getUpgradeItem(upgradeData: CompoundTag): ItemStack = if (upgradeData.contains(AE2_TERMINAL_TAG, Tag.TAG_COMPOUND.toInt())) { + ItemStack.of(upgradeData.getCompound(AE2_TERMINAL_TAG)) + } else { + craftingItem + } + + override fun isItemSuitable(stack: ItemStack): Boolean = AEItems.WIRELESS_CRAFTING_TERMINAL.isSameAs(stack) && + AEItems.WIRELESS_CRAFTING_TERMINAL.asItem().getLinkedPosition(stack) != null + + companion object { + val UPGRADE_ID = ResourceLocation.fromNamespaceAndPath(PeripheralWorksCore.MOD_ID, AE2CraftingMonitorPeripheral.TYPE) + } +} + +class AE2CraftingMonitorPeripheral private constructor(owner: TurtlePeripheralOwner) : OwnedPeripheral(TYPE, owner) { + override val isEnabled = true + + init { + addPlugin(AE2CraftingJobsPlugin.forTurtle(owner)) + } + + companion object { + const val TYPE = "ae2_crafting_monitor" + + fun create(turtle: ITurtleAccess, side: TurtleSide): AE2CraftingMonitorPeripheral = AE2CraftingMonitorPeripheral(TurtlePeripheralOwner(turtle, side)) + } +} diff --git a/projects/forge/src/main/kotlin/site/siredvin/peripheralworks/integrations/ae2/AE2Helper.kt b/projects/forge/src/main/kotlin/site/siredvin/peripheralworks/integrations/ae2/AE2Helper.kt index 54c9bea3..37067c6b 100644 --- a/projects/forge/src/main/kotlin/site/siredvin/peripheralworks/integrations/ae2/AE2Helper.kt +++ b/projects/forge/src/main/kotlin/site/siredvin/peripheralworks/integrations/ae2/AE2Helper.kt @@ -25,6 +25,52 @@ object AE2Helper { return base } + fun keyToMap(key: AEKey): Map = when (key) { + is AEItemKey -> mapOf("type" to "item", "name" to PlatformRegistries.ITEMS.getKey(key.item).toString()) + is AEFluidKey -> mapOf("type" to "fluid", "name" to PlatformRegistries.FLUIDS.getKey(key.fluid).toString()) + else -> throw LuaException("Unsupported AE2 resource type") + } + + fun stackToMap(stack: GenericStack): Map = keyToMap(stack.what) + ("count" to publicAmount(stack.what, stack.amount)) + + fun parseResource(resource: Map<*, *>, requireCount: Boolean): GenericStack { + val type = resource["type"] as? String ?: throw LuaException("Resource type must be 'item' or 'fluid'") + val name = resource["name"] as? String ?: throw LuaException("Resource name must be a registry ID") + val id = ResourceLocation.tryParse(name) ?: throw LuaException("Invalid resource ID '$name'") + val key = when (type) { + "item" -> { + if (id !in PlatformRegistries.ITEMS.keySet()) throw LuaException("Unknown item '$name'") + val item = PlatformRegistries.ITEMS.get(id) + AEItemKey.of(item) + } + "fluid" -> { + if (id !in PlatformRegistries.FLUIDS.keySet()) throw LuaException("Unknown fluid '$name'") + val fluid = PlatformRegistries.FLUIDS.get(id) + AEFluidKey.of(fluid) + } + else -> throw LuaException("Resource type must be 'item' or 'fluid'") + } + if (!requireCount) { + if (resource.containsKey("count")) throw LuaException("Filter resources must not include a count") + return GenericStack(key, 0) + } + val count = (resource["count"] as? Number)?.toDouble() ?: throw LuaException("Resource count must be a positive integer") + if (!count.isFinite() || count <= 0 || count % 1.0 != 0.0) throw LuaException("Resource count must be a positive integer") + if (count >= Long.MAX_VALUE.toDouble()) throw LuaException("Resource count is too large") + val amount = try { + if (key is AEFluidKey) Math.multiplyExact(count.toLong(), PlatformToolkit.get().fluidCompactDivider.toLong()) else count.toLong() + } catch (_: ArithmeticException) { + throw LuaException("Resource count is too large") + } + return GenericStack(key, amount) + } + + fun publicAmount(key: AEKey, amount: Long): Long = if (key is AEFluidKey) { + amount / PlatformToolkit.get().fluidCompactDivider.toLong() + } else { + amount + } + fun keyCounterToLua(counter: KeyCounter, predicate: Predicate = ALWAYS, displayType: Boolean = false): List> = counter .mapNotNull { entry -> val aeKey = entry.key diff --git a/projects/forge/src/main/kotlin/site/siredvin/peripheralworks/integrations/ae2/AE2WirelessTerminal.kt b/projects/forge/src/main/kotlin/site/siredvin/peripheralworks/integrations/ae2/AE2WirelessTerminal.kt new file mode 100644 index 00000000..4b1d9b59 --- /dev/null +++ b/projects/forge/src/main/kotlin/site/siredvin/peripheralworks/integrations/ae2/AE2WirelessTerminal.kt @@ -0,0 +1,153 @@ +package site.siredvin.peripheralworks.integrations.ae2 + +import appeng.api.implementations.blockentities.IWirelessAccessPoint +import appeng.api.networking.crafting.ICraftingService +import appeng.api.networking.security.IActionSource +import appeng.api.storage.MEStorage +import appeng.blockentity.networking.WirelessAccessPointBlockEntity +import appeng.core.definitions.AEItems +import appeng.items.tools.powered.WirelessTerminalItem +import dan200.computercraft.api.lua.IArguments +import dan200.computercraft.api.lua.LuaException +import dan200.computercraft.api.lua.LuaFunction +import dan200.computercraft.api.turtle.ITurtleAccess +import dan200.computercraft.api.turtle.TurtleSide +import net.minecraft.nbt.CompoundTag +import net.minecraft.nbt.Tag +import net.minecraft.resources.ResourceLocation +import net.minecraft.world.item.ItemStack +import site.siredvin.peripheralworks.PeripheralWorksCore +import site.siredvin.peripheralworks.common.configuration.PeripheralWorksConfig +import site.siredvin.tweakium.modules.peripheral.OwnedPeripheral +import site.siredvin.tweakium.modules.peripheral.api.IPeripheralPlugin +import site.siredvin.tweakium.modules.peripheral.boon.PeripheralOwnerBoonKey +import site.siredvin.tweakium.modules.peripheral.owner.TurtlePeripheralOwner +import site.siredvin.tweakium.modules.peripheral.representation.LuaRepresentation +import site.siredvin.tweakium.modules.peripheral.representation.RepresentationMode +import site.siredvin.tweakium.modules.plugins.PeripheralPluginUtils +import site.siredvin.tweakium.modules.turtle.PeripheralTurtleUpgrade +import java.util.Optional +import java.util.function.Predicate +import kotlin.math.min + +internal const val AE2_TERMINAL_TAG = "terminal" + +internal data class AE2WirelessSession(val storage: MEStorage, val craftingService: ICraftingService) + +internal fun resolveWirelessSession(owner: TurtlePeripheralOwner, terminal: WirelessTerminalItem): AE2WirelessSession { + val data = owner.turtle.getUpgradeNBTData(owner.side) + if (!data.contains(AE2_TERMINAL_TAG, Tag.TAG_COMPOUND.toInt())) throw LuaException("Invalid stored wireless terminal") + val stack = ItemStack.of(data.getCompound(AE2_TERMINAL_TAG)) + if (stack.item !== terminal || terminal.getLinkedPosition(stack) == null) throw LuaException("Invalid stored wireless terminal") + val level = owner.level ?: throw LuaException("Linked AE2 network is unavailable") + val grid = terminal.getLinkedGrid(stack, level, null) ?: throw LuaException("Linked AE2 network is unavailable") + val inRange = grid.getMachines(WirelessAccessPointBlockEntity::class.java).any { accessPoint -> + isInWirelessRange(accessPoint, level, owner.pos) + } + if (!inRange) throw LuaException("Turtle is outside wireless range") + return AE2WirelessSession(grid.storageService.inventory, grid.craftingService) +} + +private fun isInWirelessRange(accessPoint: IWirelessAccessPoint, level: net.minecraft.world.level.Level, pos: net.minecraft.core.BlockPos): Boolean = accessPoint.isActive && accessPoint.location.level === level && accessPoint.location.pos.distSqr(pos) < accessPoint.range * accessPoint.range + +class AE2WirelessTerminalUpgrade(stack: ItemStack) : PeripheralTurtleUpgrade(UPGRADE_ID, stack) { + override fun buildPeripheral(turtle: ITurtleAccess, side: TurtleSide): AE2WirelessTerminalPeripheral = AE2WirelessTerminalPeripheral.create(turtle, side) + + override fun getUpgradeData(stack: ItemStack): CompoundTag = CompoundTag().apply { + put(AE2_TERMINAL_TAG, stack.save(CompoundTag())) + } + + override fun getUpgradeItem(upgradeData: CompoundTag): ItemStack = if (upgradeData.contains(AE2_TERMINAL_TAG, Tag.TAG_COMPOUND.toInt())) { + ItemStack.of(upgradeData.getCompound(AE2_TERMINAL_TAG)) + } else { + craftingItem + } + + override fun isItemSuitable(stack: ItemStack): Boolean = AEItems.WIRELESS_TERMINAL.isSameAs(stack) && + AEItems.WIRELESS_TERMINAL.asItem().getLinkedPosition(stack) != null + + companion object { + val UPGRADE_ID = ResourceLocation.fromNamespaceAndPath(PeripheralWorksCore.MOD_ID, AE2WirelessTerminalPeripheral.TYPE) + } +} + +class AE2WirelessTerminalPeripheral private constructor(owner: TurtlePeripheralOwner) : OwnedPeripheral(TYPE, owner) { + override val isEnabled = true + + init { + addPlugin(AE2WirelessTerminalPlugin(owner)) + } + + companion object { + const val TYPE = "ae2_wireless_terminal" + + fun create(turtle: ITurtleAccess, side: TurtleSide): AE2WirelessTerminalPeripheral { + val owner = TurtlePeripheralOwner(turtle, side).attachFuel() + return AE2WirelessTerminalPeripheral(owner) + } + } +} + +private class AE2WirelessTerminalPlugin(private val owner: TurtlePeripheralOwner) : IPeripheralPlugin { + private fun resolve(): AE2WirelessSession = resolveWirelessSession(owner, AEItems.WIRELESS_TERMINAL.asItem()) + + private fun validateTransfer(itemQuery: Any?, limit: Optional, slot: Optional): Pair, Pair> { + val predicate = PeripheralPluginUtils.itemQueryToPredicate(itemQuery) + val transferLimit = min(PeripheralWorksConfig.itemStorageTransferLimit, limit.orElse(Int.MAX_VALUE)) + if (transferLimit < 0) throw LuaException("Limit must be non-negative") + val inventorySize = owner.storage!!.size + val storageSlot = slot.map { it - 1 }.orElse(-1) + if (storageSlot !in -1 until inventorySize) throw LuaException("Slot must be between 1 and $inventorySize") + return predicate to (transferLimit to storageSlot) + } + + private fun validatePush(fromSlotOrItemQuery: Any?, limit: Optional): Pair, Pair> { + if (fromSlotOrItemQuery !is Number) return validateTransfer(fromSlotOrItemQuery, limit, Optional.empty()) + val fromSlot = fromSlotOrItemQuery.toInt() + if (fromSlotOrItemQuery.toDouble() != fromSlot.toDouble()) throw LuaException("Slot must be an integer") + return validateTransfer(null, limit, Optional.of(fromSlot)) + } + + private fun consumeFuel() { + val fuel = owner.getBoon(PeripheralOwnerBoonKey.FUEL)!! + if (!fuel.consumeFuel(1, false)) throw LuaException("Not enough fuel") + } + + @LuaFunction(mainThread = true) + fun getFuelMaxLevel(): Int = owner.getBoon(PeripheralOwnerBoonKey.FUEL)!!.maxFuelLevel + + @LuaFunction(mainThread = true) + fun items(arguments: IArguments): List> { + val storage = AEItemStorage(resolve().storage, IActionSource.empty()) {} + val mode = if (arguments.optBoolean(0, true)) RepresentationMode.DETAILED else RepresentationMode.BASE + val predicate = PeripheralPluginUtils.itemQueryToPredicate(arguments.get(1)) + return storage.getContent().asSequence().filter(predicate::test).map { LuaRepresentation.forItemStack(it, mode) }.toList() + } + + @LuaFunction(mainThread = true) + fun pullItem(itemQuery: Any?, limit: Optional, toSlot: Optional): Int { + val session = resolve() + val (predicate, transfer) = validateTransfer(itemQuery, limit, toSlot) + consumeFuel() + return owner.withPlayer({ player -> + AEItemStorage(session.storage, IActionSource.ofPlayer(player.fakePlayer)) {} + .moveTo(owner.storage!!, transfer.first, transfer.second, predicate) + }, skipInventory = true) + } + + @LuaFunction(mainThread = true) + fun pushItem(fromSlotOrItemQuery: Any?, limit: Optional): Int { + val session = resolve() + val (predicate, transfer) = validatePush(fromSlotOrItemQuery, limit) + consumeFuel() + return owner.withPlayer({ player -> + owner.storage!!.moveTo( + AEItemStorage(session.storage, IActionSource.ofPlayer(player.fakePlayer)) {}, + transfer.first, + transfer.second, + -1, + predicate, + ) + }, skipInventory = true) + } +} diff --git a/projects/forge/src/main/kotlin/site/siredvin/peripheralworks/integrations/ae2/AEItemStorage.kt b/projects/forge/src/main/kotlin/site/siredvin/peripheralworks/integrations/ae2/AEItemStorage.kt index 6cd14154..514a8aa3 100644 --- a/projects/forge/src/main/kotlin/site/siredvin/peripheralworks/integrations/ae2/AEItemStorage.kt +++ b/projects/forge/src/main/kotlin/site/siredvin/peripheralworks/integrations/ae2/AEItemStorage.kt @@ -11,7 +11,13 @@ import site.siredvin.broccolium.modules.storage.base.api.SomethingOperator import site.siredvin.broccolium.modules.storage.item.ItemStorageUtils import java.util.function.Predicate -class AEItemStorage(private val storage: MEStorage, private val entity: AENetworkBlockEntity) : AgnosticStorage { +class AEItemStorage( + private val storage: MEStorage, + private val actionSource: IActionSource, + private val changeCallback: () -> Unit, +) : AgnosticStorage { + constructor(storage: MEStorage, entity: AENetworkBlockEntity) : this(storage, IActionSource.ofMachine(entity), entity::setChanged) + override fun getContent(): Iterator { return storage.availableStacks.mapNotNull { if (it.key !is AEItemKey) return@mapNotNull null @@ -25,11 +31,11 @@ class AEItemStorage(private val storage: MEStorage, private val entity: AENetwor get() = ItemStorageUtils override fun setChanged() { - entity.setChanged() + changeCallback() } override fun store(stack: ItemStack, simulate: Boolean): ItemStack { - val insertedAmount = storage.insert(AEItemKey.of(stack), stack.count.toLong(), if (simulate) Actionable.SIMULATE else Actionable.MODULATE, IActionSource.ofMachine(entity)) + val insertedAmount = storage.insert(AEItemKey.of(stack), stack.count.toLong(), if (simulate) Actionable.SIMULATE else Actionable.MODULATE, actionSource) if (insertedAmount == 0L) return stack stack.shrink(insertedAmount.toInt()) return stack @@ -43,7 +49,7 @@ class AEItemStorage(private val storage: MEStorage, private val entity: AENetwor } return@find predicate.test(aeKey.toStack(it.longValue.toInt())) } ?: return ItemStack.EMPTY - val extractedAmount = storage.extract(itemToTransfer.key, minOf(limit.toLong(), itemToTransfer.longValue), if (simulate) Actionable.SIMULATE else Actionable.MODULATE, IActionSource.ofMachine(entity)) + val extractedAmount = storage.extract(itemToTransfer.key, minOf(limit.toLong(), itemToTransfer.longValue), if (simulate) Actionable.SIMULATE else Actionable.MODULATE, actionSource) if (extractedAmount == 0L) return ItemStack.EMPTY return (itemToTransfer.key as AEItemKey).toStack(extractedAmount.toInt()) } diff --git a/projects/forge/src/main/kotlin/site/siredvin/peripheralworks/integrations/ae2/Integration.kt b/projects/forge/src/main/kotlin/site/siredvin/peripheralworks/integrations/ae2/Integration.kt index c77ad06f..67b28205 100644 --- a/projects/forge/src/main/kotlin/site/siredvin/peripheralworks/integrations/ae2/Integration.kt +++ b/projects/forge/src/main/kotlin/site/siredvin/peripheralworks/integrations/ae2/Integration.kt @@ -1,6 +1,9 @@ package site.siredvin.peripheralworks.integrations.ae2 import appeng.blockentity.grid.AENetworkBlockEntity +import appeng.core.definitions.AEItems +import dan200.computercraft.api.turtle.ITurtleUpgrade +import dan200.computercraft.api.turtle.TurtleUpgradeSerialiser import net.minecraft.core.BlockPos import net.minecraft.core.Direction import net.minecraft.world.item.ItemStack @@ -12,7 +15,13 @@ import site.siredvin.broccolium.modules.storage.energy.api.AgnosticEnergyStorage import site.siredvin.broccolium.modules.storage.fluid.AgnosticFluidStorageLookup import site.siredvin.broccolium.modules.storage.fluid.api.AgnosticFluidStorage import site.siredvin.broccolium.modules.storage.item.AgnosticItemStorageLookup +import site.siredvin.peripheralworks.PeripheralWorksClientCore +import site.siredvin.peripheralworks.client.turtle.ScaledItemModeller import site.siredvin.peripheralworks.computercraft.ComputerCraftProxy +import site.siredvin.peripheralworks.data.ModEnLanguageProvider +import site.siredvin.peripheralworks.data.ModTurtleUpgradeDataProvider +import site.siredvin.peripheralworks.data.ModUaLanguageProvider +import site.siredvin.peripheralworks.xplat.ModPlatform class Integration : Runnable { @@ -40,6 +49,34 @@ class Integration : Runnable { } override fun run() { + val wirelessTerminalUpgrade = ModPlatform.registerTurtleUpgrade( + AE2WirelessTerminalUpgrade.UPGRADE_ID, + TurtleUpgradeSerialiser.simpleWithCustomItem { _, stack -> AE2WirelessTerminalUpgrade(stack) }, + ) + ModTurtleUpgradeDataProvider.hookUpgrade { + it.simpleWithCustomItem(AE2WirelessTerminalUpgrade.UPGRADE_ID, wirelessTerminalUpgrade.get(), AEItems.WIRELESS_TERMINAL.asItem()).requireMod("ae2") + } + PeripheralWorksClientCore.EXTRA_TURTLE_MODEL_PROVIDERS.add { + @Suppress("UNCHECKED_CAST") + Pair(wirelessTerminalUpgrade.get() as TurtleUpgradeSerialiser, ScaledItemModeller(0.75f, heightShift = 0.15f)) + } + ModEnLanguageProvider.addHook { it.addTurtle(AE2WirelessTerminalUpgrade.UPGRADE_ID, "AE terminal") } + ModUaLanguageProvider.addHook { it.addTurtle(AE2WirelessTerminalUpgrade.UPGRADE_ID, "AE термінальна") } + + val craftingMonitorUpgrade = ModPlatform.registerTurtleUpgrade( + AE2CraftingMonitorUpgrade.UPGRADE_ID, + TurtleUpgradeSerialiser.simpleWithCustomItem { _, stack -> AE2CraftingMonitorUpgrade(stack) }, + ) + ModTurtleUpgradeDataProvider.hookUpgrade { + it.simpleWithCustomItem(AE2CraftingMonitorUpgrade.UPGRADE_ID, craftingMonitorUpgrade.get(), AEItems.WIRELESS_CRAFTING_TERMINAL.asItem()).requireMod("ae2") + } + PeripheralWorksClientCore.EXTRA_TURTLE_MODEL_PROVIDERS.add { + @Suppress("UNCHECKED_CAST") + Pair(craftingMonitorUpgrade.get() as TurtleUpgradeSerialiser, ScaledItemModeller(0.75f, heightShift = 0.15f)) + } + ModEnLanguageProvider.addHook { it.addTurtle(AE2CraftingMonitorUpgrade.UPGRADE_ID, "AE crafting monitor") } + ModUaLanguageProvider.addHook { it.addTurtle(AE2CraftingMonitorUpgrade.UPGRADE_ID, "AE монітор крафтингу") } + if (Configuration.enableStorageIntegrations) { AgnosticItemStorageLookup.addBlockLookup(::extractItemStorage) AgnosticFluidStorageLookup.addBlockLookup(::extractFluidStorage) @@ -47,6 +84,10 @@ class Integration : Runnable { } if (Configuration.enableMEInterface) { ComputerCraftProxy.addProvider(MENetworkBlockPlugin.Provider) + ComputerCraftProxy.addProvider(AE2CraftingJobsPluginProvider) + ComputerCraftProxy.addProvider(AE2CableObjectProvider) + ComputerCraftProxy.addProvider(AE2InterfaceObjectProvider) + ComputerCraftProxy.addProvider(AE2PatternProviderObjectProvider) } } } diff --git a/projects/forge/src/main/kotlin/site/siredvin/peripheralworks/integrations/ae2/MENetworkBlockPlugin.kt b/projects/forge/src/main/kotlin/site/siredvin/peripheralworks/integrations/ae2/MENetworkBlockPlugin.kt index eb12e95f..9163a582 100644 --- a/projects/forge/src/main/kotlin/site/siredvin/peripheralworks/integrations/ae2/MENetworkBlockPlugin.kt +++ b/projects/forge/src/main/kotlin/site/siredvin/peripheralworks/integrations/ae2/MENetworkBlockPlugin.kt @@ -1,7 +1,5 @@ package site.siredvin.peripheralworks.integrations.ae2 -import appeng.api.networking.crafting.CalculationStrategy -import appeng.api.networking.security.IActionSource import appeng.api.stacks.AEFluidKey import appeng.api.stacks.AEItemKey import appeng.api.stacks.AEKey @@ -12,17 +10,13 @@ import net.minecraft.core.BlockPos import net.minecraft.core.Direction import net.minecraft.world.level.Level import site.siredvin.broccolium.modules.platform.PlatformRegistries -import site.siredvin.broccolium.modules.platform.PlatformToolkit import site.siredvin.peripheralworks.api.PeripheralPluginProvider import site.siredvin.peripheralworks.integrations.ae2.AE2Helper.buildKey import site.siredvin.peripheralworks.integrations.ae2.AE2Helper.genericStackToMap -import site.siredvin.peripheralworks.integrations.ae2.AE2Helper.keyCounterToLua import site.siredvin.tweakium.modules.peripheral.api.IPeripheralPlugin import site.siredvin.tweakium.modules.peripheral.representation.LuaRepresentation -import java.util.* -import kotlin.NoSuchElementException -class MENetworkBlockPlugin(private val level: Level, private val entity: AENetworkBlockEntity) : IPeripheralPlugin { +class MENetworkBlockPlugin(private val entity: AENetworkBlockEntity) : IPeripheralPlugin { companion object { const val PLUGIN_TYPE = "ae2" } @@ -39,7 +33,7 @@ class MENetworkBlockPlugin(private val level: Level, private val entity: AENetwo if (entity !is AENetworkBlockEntity) { return null } - return MENetworkBlockPlugin(level, entity) + return MENetworkBlockPlugin(entity) } } @@ -167,42 +161,4 @@ class MENetworkBlockPlugin(private val level: Level, private val entity: AENetwo } return MethodResult.of(craftingList) } - - @LuaFunction(mainThread = false) - fun scheduleCrafting(mode: String, id_key: String, amount: Optional, targetCPU: Optional): MethodResult { - val craftingService = entity.mainNode.grid?.craftingService ?: return MethodResult.of(null, "AE2 network is not connected") - - val key = buildKey(mode, id_key) - val source = IActionSource.ofMachine(entity) - val realAmount = if (mode == "item") { - amount.orElse(1) - } else { - amount.orElse(1000) * PlatformToolkit.get().fluidCompactDivider - }.toLong() - val future = craftingService.beginCraftingCalculation( - level, - { source }, - key, - realAmount, - CalculationStrategy.REPORT_MISSING_ITEMS, - ) - val plan = future.get() - - if (!plan.missingItems().isEmpty) { - return MethodResult.of(false, "Missing items", keyCounterToLua(plan.missingItems())) - } - val realTargetCPU = if (targetCPU.isPresent) { - try { - craftingService.cpus.first { - it.name != null && it.name!!.string.equals(targetCPU.get()) - } - } catch (e: NoSuchElementException) { - return MethodResult.of(null, "Cannot find target CPU") - } - } else { - null - } - craftingService.submitJob(plan, null, realTargetCPU, false, source) - return MethodResult.of(true) - } } diff --git a/projects/forge/src/testMod/java/site/siredvin/peripheralworks/testmod/ForgePeripheralWorksTestMod.java b/projects/forge/src/testMod/java/site/siredvin/peripheralworks/testmod/ForgePeripheralWorksTestMod.java index 8fd23b99..c945db07 100644 --- a/projects/forge/src/testMod/java/site/siredvin/peripheralworks/testmod/ForgePeripheralWorksTestMod.java +++ b/projects/forge/src/testMod/java/site/siredvin/peripheralworks/testmod/ForgePeripheralWorksTestMod.java @@ -24,6 +24,8 @@ public ForgePeripheralWorksTestMod() { CctFixtureCommands.INSTANCE.importFiles(event.getServer()); }); Testiarium.register(PeripheralWorksGameTests.class); + Testiarium.register(AE2ConfigurableObjectsGameTests.class); + Testiarium.register(AE2WirelessTerminalGameTests.class); DistExecutor.unsafeRunWhenOn(Dist.CLIENT, () -> ClientTests::register); ForgeTestiarium.registerTests(); } diff --git a/projects/forge/src/testMod/kotlin/site/siredvin/peripheralworks/testmod/AE2ConfigurableObjectsGameTests.kt b/projects/forge/src/testMod/kotlin/site/siredvin/peripheralworks/testmod/AE2ConfigurableObjectsGameTests.kt new file mode 100644 index 00000000..523d250f --- /dev/null +++ b/projects/forge/src/testMod/kotlin/site/siredvin/peripheralworks/testmod/AE2ConfigurableObjectsGameTests.kt @@ -0,0 +1,133 @@ +package site.siredvin.peripheralworks.testmod + +import appeng.api.crafting.PatternDetailsHelper +import appeng.api.networking.crafting.ICraftingService +import appeng.api.networking.security.IActionSource +import appeng.api.stacks.AEItemKey +import appeng.api.stacks.GenericStack +import appeng.blockentity.networking.CableBusBlockEntity +import appeng.core.definitions.AEBlocks +import appeng.core.definitions.AEItems +import appeng.core.definitions.AEParts +import appeng.helpers.MultiCraftingTracker +import appeng.parts.automation.ExportBusPart +import dan200.computercraft.shared.computer.blocks.ComputerBlockEntity +import net.minecraft.core.BlockPos +import net.minecraft.core.Direction +import net.minecraft.gametest.framework.GameTest +import net.minecraft.gametest.framework.GameTestAssertException +import net.minecraft.gametest.framework.GameTestHelper +import net.minecraft.world.item.ItemStack +import net.minecraft.world.item.Items +import net.minecraft.world.level.block.entity.ChestBlockEntity +import site.siredvin.peripheralworks.computercraft.ComputerCraftProxy +import site.siredvin.testiarium.api.TestGroup +import site.siredvin.testiarium.api.thenExecuteFailFast +import site.siredvin.testiarium.cct.CctComputerState +import java.lang.reflect.Proxy +import java.util.concurrent.CompletableFuture + +@TestGroup("peripheralworks") +class AE2ConfigurableObjectsGameTests { + @GameTest(template = FIXTURE, batch = FIXTURE, timeoutTicks = 2400) + fun configurableObjects(helper: GameTestHelper) { + val computer = findComputer(helper) + val interfacePos = computer.blockPos.relative(Direction.NORTH) + val cablePos = computer.blockPos.relative(Direction.SOUTH) + val chestPos = computer.blockPos.relative(Direction.WEST) + val patternProviderPos = computer.blockPos.relative(Direction.EAST) + helper.level.setBlockAndUpdate(interfacePos, AEBlocks.INTERFACE.block().defaultBlockState()) + helper.level.setBlockAndUpdate(patternProviderPos, AEBlocks.PATTERN_PROVIDER.block().defaultBlockState()) + helper.level.setBlockAndUpdate(cablePos, AEBlocks.CABLE_BUS.block().defaultBlockState()) + helper.level.setBlockAndUpdate(chestPos, net.minecraft.world.level.block.Blocks.CHEST.defaultBlockState()) + val cable = helper.level.getBlockEntity(cablePos) as CableBusBlockEntity + val exportBus = cable.addPart(AEParts.EXPORT_BUS.asItem(), Direction.SOUTH, null)!! + assertCraftingSlotTen(exportBus, helper) + cable.addPart(AEParts.STORAGE_BUS.asItem(), Direction.EAST, null) + cable.addPart(AEParts.FORMATION_PLANE.asItem(), Direction.WEST, null) + cable.addPart(AEParts.LEVEL_EMITTER.asItem(), Direction.UP, null) + cable.addPart(AEParts.ENERGY_LEVEL_EMITTER.asItem(), Direction.DOWN, null) + check(ComputerCraftProxy.collectPlugins(helper.level, cablePos, Direction.NORTH).containsKey("ae2_cable_objects")) { + "Cable provider was not registered" + } + check(ComputerCraftProxy.collectPlugins(helper.level, interfacePos, Direction.SOUTH).containsKey("ae2_interface_object")) { + "Interface provider was not registered" + } + check(ComputerCraftProxy.collectPlugins(helper.level, patternProviderPos, Direction.WEST).containsKey("ae2_pattern_provider_object")) { + "Pattern Provider provider was not registered" + } + + (helper.level.getBlockEntity(chestPos) as ChestBlockEntity).apply { + setItem(0, AEItems.CAPACITY_CARD.stack()) + setItem(2, AEItems.CRAFTING_CARD.stack()) + setItem(3, AEItems.FUZZY_CARD.stack()) + setItem(4, ItemStack(Items.STONE)) + setItem( + 5, + PatternDetailsHelper.encodeProcessingPattern( + arrayOf(GenericStack(AEItemKey.of(Items.COBBLESTONE), 1)), + arrayOf(GenericStack(AEItemKey.of(Items.STONE), 1)), + ), + ) + } + helper.startSequence() + .thenIdle(5) + .thenExecute { computer.createServerComputer().turnOn() } + .thenWaitUntil { await("same-kind") } + .thenExecuteFailFast { + state().check("same-kind") + cable.removePartFromSide(Direction.SOUTH) + check(cable.addPart(AEParts.EXPORT_BUS.asItem(), Direction.SOUTH, null) != null) + } + .thenWaitUntil { await("different-kind") } + .thenExecuteFailFast { + state().check("different-kind") + cable.removePartFromSide(Direction.SOUTH) + check(cable.addPart(AEParts.IMPORT_BUS.asItem(), Direction.SOUTH, null) != null) + } + .thenWaitUntil { await("removed") } + .thenExecuteFailFast { + state().check("removed") + cable.removePartFromSide(Direction.SOUTH) + } + .thenWaitUntil { await(CctComputerState.DONE) } + .thenExecuteFailFast { state().check(CctComputerState.DONE) } + .thenSucceed() + } + + private fun state() = CctComputerState.get(FIXTURE) ?: throw GameTestAssertException("Computer '$FIXTURE' has not started") + + private fun await(marker: String) { + val state = CctComputerState.get(FIXTURE) ?: throw GameTestAssertException("Computer '$FIXTURE' has not started") + if (state.isDone(CctComputerState.DONE)) state.check(CctComputerState.DONE) + if (!state.isDone(marker)) throw GameTestAssertException("Computer '$FIXTURE' has not reached $marker") + } + + private fun findComputer(helper: GameTestHelper): ComputerBlockEntity { + for (x in 0 until 5) { + for (y in 0 until 4) { + for (z in 0 until 5) { + val entity = helper.getBlockEntity(BlockPos(x, y, z)) + if (entity is ComputerBlockEntity) return entity + } + } + } + throw GameTestAssertException("Fixture computer is missing") + } + + private fun assertCraftingSlotTen(exportBus: ExportBusPart, helper: GameTestHelper) { + val field = ExportBusPart::class.java.getDeclaredField("craftingTracker").apply { isAccessible = true } + val tracker = field.get(exportBus) as MultiCraftingTracker + val craftingService = Proxy.newProxyInstance( + ICraftingService::class.java.classLoader, + arrayOf(ICraftingService::class.java), + ) { _, method, _ -> + if (method.name == "beginCraftingCalculation") CompletableFuture.completedFuture(null) else error("Unexpected ${method.name}") + } as ICraftingService + tracker.handleCrafting(9, AEItemKey.of(Items.STONE), 1, helper.level, craftingService, IActionSource.empty()) + } + + companion object { + private const val FIXTURE = "peripheralworksgametests.ae2_configurable_objects" + } +} diff --git a/projects/forge/src/testMod/kotlin/site/siredvin/peripheralworks/testmod/AE2WirelessTerminalGameTests.kt b/projects/forge/src/testMod/kotlin/site/siredvin/peripheralworks/testmod/AE2WirelessTerminalGameTests.kt new file mode 100644 index 00000000..49bbcc0d --- /dev/null +++ b/projects/forge/src/testMod/kotlin/site/siredvin/peripheralworks/testmod/AE2WirelessTerminalGameTests.kt @@ -0,0 +1,156 @@ +package site.siredvin.peripheralworks.testmod + +import appeng.api.config.Actionable +import appeng.api.networking.security.IActionSource +import appeng.api.stacks.AEItemKey +import appeng.blockentity.networking.WirelessAccessPointBlockEntity +import appeng.blockentity.storage.ChestBlockEntity +import appeng.core.definitions.AEBlocks +import appeng.core.definitions.AEItems +import appeng.items.tools.powered.WirelessTerminalItem +import dan200.computercraft.api.turtle.TurtleSide +import dan200.computercraft.api.upgrades.UpgradeData +import dan200.computercraft.shared.config.Config +import dan200.computercraft.shared.turtle.blocks.TurtleBlockEntity +import net.minecraft.core.BlockPos +import net.minecraft.core.GlobalPos +import net.minecraft.gametest.framework.GameTest +import net.minecraft.gametest.framework.GameTestAssertException +import net.minecraft.gametest.framework.GameTestHelper +import net.minecraft.network.chat.Component +import net.minecraft.world.item.ItemStack +import net.minecraft.world.item.Items +import site.siredvin.peripheralworks.integrations.ae2.AE2CraftingMonitorUpgrade +import site.siredvin.peripheralworks.integrations.ae2.AE2WirelessTerminalUpgrade +import site.siredvin.testiarium.api.TestGroup +import site.siredvin.testiarium.api.thenExecuteFailFast +import site.siredvin.testiarium.cct.CctComputerState + +@TestGroup("peripheralworks") +class AE2WirelessTerminalGameTests { + @GameTest(template = FIXTURE, batch = FIXTURE, timeoutTicks = 12000) + fun wirelessTerminal(helper: GameTestHelper) { + val turtle = findTurtle(helper) + val accessPointPos = turtle.blockPos.offset(0, 0, 1) + val energyPos = accessPointPos.offset(0, 0, 1) + val chestPos = energyPos.offset(1, 0, 0) + helper.level.setBlockAndUpdate(accessPointPos, AEBlocks.WIRELESS_ACCESS_POINT.block().defaultBlockState()) + helper.level.setBlockAndUpdate(energyPos, AEBlocks.CREATIVE_ENERGY_CELL.block().defaultBlockState()) + helper.level.setBlockAndUpdate(chestPos, AEBlocks.CHEST.block().defaultBlockState()) + (helper.level.getBlockEntity(chestPos) as ChestBlockEntity).setCell(AEItems.ITEM_CELL_1K.stack()) + + val terminalItem = AEItems.WIRELESS_TERMINAL.asItem() + val terminal = AEItems.WIRELESS_TERMINAL.stack().apply { + hoverName = Component.literal("Test terminal") + orCreateTag.putString("upw_test", "preserved") + } + WirelessTerminalItem.LINKABLE_HANDLER.link(terminal, GlobalPos.of(helper.level.dimension(), accessPointPos)) + terminalItem.injectAEPower(terminal, 400.0, Actionable.MODULATE) + terminalItem.getUpgrades(terminal).setItemDirect(0, AEItems.ENERGY_CARD.stack()) + val initialCharge = terminalItem.getAECurrentPower(terminal) + val upgrade = AE2WirelessTerminalUpgrade(AEItems.WIRELESS_TERMINAL.stack()) + check(ItemStack.matches(terminal, upgrade.getUpgradeItem(upgrade.getUpgradeData(terminal)))) + turtle.access.setUpgradeWithData(TurtleSide.LEFT, UpgradeData.of(upgrade, upgrade.getUpgradeData(terminal))) + val craftingTerminal = AEItems.WIRELESS_CRAFTING_TERMINAL.stack() + WirelessTerminalItem.LINKABLE_HANDLER.link(craftingTerminal, GlobalPos.of(helper.level.dimension(), accessPointPos)) + val craftingMonitor = AE2CraftingMonitorUpgrade(AEItems.WIRELESS_CRAFTING_TERMINAL.stack()) + check(ItemStack.matches(craftingTerminal, craftingMonitor.getUpgradeItem(craftingMonitor.getUpgradeData(craftingTerminal)))) + turtle.access.setUpgradeWithData(TurtleSide.RIGHT, UpgradeData.of(craftingMonitor, craftingMonitor.getUpgradeData(craftingTerminal))) + + helper.startSequence() + .thenIdle(10) + .thenExecuteFailFast { + val accessPoint = helper.level.getBlockEntity(accessPointPos) as WirelessAccessPointBlockEntity + check(accessPoint.isActive) { "Wireless access point did not become active" } + val inserted = accessPoint.grid!!.storageService.inventory.insert(AEItemKey.of(Items.STONE), 64, Actionable.MODULATE, IActionSource.empty()) + check(inserted == 64L) { "Failed to seed AE2 item storage" } + turtle.createServerComputer().turnOn() + } + .thenWaitUntil { await("initial") } + .thenExecuteFailFast { + state().check("initial") + check(turtle.access.fuelLevel == 7) { "Expected three fuel-consuming calls, got ${turtle.access.fuelLevel}" } + check(turtle.contents[0].count == 5 && turtle.contents[0].`is`(Items.STONE)) + check(turtle.contents[15].count == 5 && turtle.contents[15].`is`(Items.GOLD_INGOT)) + turtle.access.fuelLevel = 0 + } + .thenWaitUntil { await("empty-fuel") } + .thenExecuteFailFast { + state().check("empty-fuel") + Config.turtlesNeedFuel = false + turtle.access.fuelLevel = 1 + } + .thenWaitUntil { await("disabled") } + .thenExecuteFailFast { + state().check("disabled") + Config.turtlesNeedFuel = true + turtle.access.fuelLevel = 2 + } + .thenWaitUntil { await("restored") } + .thenExecuteFailFast { + state().check("restored") + check(turtle.access.teleportTo(helper.level, turtle.blockPos.offset(18, 0, 0))) + } + .thenWaitUntil { await("out-of-range") } + .thenExecuteFailFast { + state().check("out-of-range") + check(turtle.access.teleportTo(helper.level, accessPointPos.offset(0, 0, -1))) + } + .thenWaitUntil { await("returned") } + .thenExecuteFailFast { + state().check("returned") + helper.level.removeBlock(energyPos, false) + } + .thenWaitUntil { await("inactive") } + .thenExecuteFailFast { + state().check("inactive") + helper.level.setBlockAndUpdate(energyPos, AEBlocks.CREATIVE_ENERGY_CELL.block().defaultBlockState()) + } + .thenWaitUntil { await("reactivated") } + .thenExecuteFailFast { + state().check("reactivated") + val data = turtle.access.getUpgradeNBTData(TurtleSide.LEFT) + val stored = ItemStack.of(data.getCompound("terminal")) + WirelessTerminalItem.LINKABLE_HANDLER.link(stored, GlobalPos.of(helper.level.dimension(), UNLOADED_POS)) + data.put("terminal", stored.save(net.minecraft.nbt.CompoundTag())) + turtle.access.updateUpgradeNBTData(TurtleSide.LEFT) + check(helper.level.chunkSource.getChunkNow(UNLOADED_POS.x shr 4, UNLOADED_POS.z shr 4) == null) + } + .thenWaitUntil { await(CctComputerState.DONE) } + .thenExecuteFailFast { + state().check(CctComputerState.DONE) + check(helper.level.chunkSource.getChunkNow(UNLOADED_POS.x shr 4, UNLOADED_POS.z shr 4) == null) { "Wireless resolution loaded the linked chunk" } + val stored = turtle.access.getUpgradeWithData(TurtleSide.LEFT)!!.upgradeItem + check(stored.hoverName.string == "Test terminal") + check(stored.tag?.getString("upw_test") == "preserved") + check(terminalItem.getUpgrades(stored).getInstalledUpgrades(AEItems.ENERGY_CARD) == 1) + check(terminalItem.getAECurrentPower(stored) == initialCharge) { "Peripheral use changed terminal charge" } + } + .thenSucceed() + } + + private fun state() = CctComputerState.get(FIXTURE) ?: throw GameTestAssertException("Computer '$FIXTURE' has not started") + + private fun findTurtle(helper: GameTestHelper): TurtleBlockEntity { + for (x in 0 until 7) { + for (y in 0 until 4) { + for (z in 0 until 7) { + val entity = helper.getBlockEntity(BlockPos(x, y, z)) + if (entity is TurtleBlockEntity) return entity + } + } + } + throw GameTestAssertException("Fixture turtle is missing") + } + + private fun await(marker: String) { + val state = state() + if (state.isDone(CctComputerState.DONE)) state.check(CctComputerState.DONE) + if (!state.isDone(marker)) throw GameTestAssertException("Computer '$FIXTURE' has not reached $marker") + } + + companion object { + private const val FIXTURE = "peripheralworksgametests.ae2_wireless_terminal" + private val UNLOADED_POS = BlockPos(1_000_000, 64, 1_000_000) + } +} diff --git a/projects/typed-peripheral-unlimitedperipheralworks/integrations/ae2.ts b/projects/typed-peripheral-unlimitedperipheralworks/integrations/ae2.ts index d587c5a8..ea369ae9 100644 --- a/projects/typed-peripheral-unlimitedperipheralworks/integrations/ae2.ts +++ b/projects/typed-peripheral-unlimitedperipheralworks/integrations/ae2.ts @@ -13,6 +13,12 @@ export type AE2Crafting = { progress: number; CPU?: string; }; +export type AE2CraftingJob = { + id: string; + state: "running" | "done" | "canceled"; + target: object; + amount: number; +}; /** @noSelf **/ export interface AE2NetworkAPI extends IPeripheral { @@ -25,13 +31,16 @@ export interface AE2NetworkAPI extends IPeripheral { getCraftableFluids(): { name: string }[]; getPatternsFor(mode: "item" | "fluid", id: string): AE2Pattern[]; getActiveCraftings(): Fallible; + getCraftingJob(jobId: string): Fallible; + getCraftingJobs(): AE2CraftingJob[]; + cancelCrafting(jobId: string): Fallible; scheduleCrafting( mode: "item" | "fluid", id: string, amount?: number, targetCPU?: string ): LuaMultiReturn< - | [true] + | [true, string] | [null, string] | [false, string, LuaTable] >; diff --git a/projects/typed-peripheral-unlimitedperipheralworks/integrations/ae2CraftingMonitor.ts b/projects/typed-peripheral-unlimitedperipheralworks/integrations/ae2CraftingMonitor.ts new file mode 100644 index 00000000..5ee600af --- /dev/null +++ b/projects/typed-peripheral-unlimitedperipheralworks/integrations/ae2CraftingMonitor.ts @@ -0,0 +1,23 @@ +import { Fallible } from "../types"; +import { AE2CraftingJob } from "./ae2"; +import { IPeripheralProvider } from "@siredvin/typed-peripheral-base"; + +/** @noSelf **/ +export interface AE2CraftingMonitorAPI extends IPeripheral { + scheduleCrafting( + mode: "item" | "fluid", + id: string, + amount?: number, + targetCPU?: string + ): LuaMultiReturn< + | [true, string] + | [null, string] + | [false, string, LuaTable] + >; + getCraftingJob(jobId: string): Fallible; + getCraftingJobs(): AE2CraftingJob[]; + cancelCrafting(jobId: string): Fallible; +} + +export const ae2CraftingMonitorProvider = + new IPeripheralProvider("ae2_crafting_monitor"); diff --git a/projects/typed-peripheral-unlimitedperipheralworks/integrations/ae2Objects.ts b/projects/typed-peripheral-unlimitedperipheralworks/integrations/ae2Objects.ts new file mode 100644 index 00000000..0761f0f7 --- /dev/null +++ b/projects/typed-peripheral-unlimitedperipheralworks/integrations/ae2Objects.ts @@ -0,0 +1,215 @@ +import { AE2NetworkAPI } from "./ae2"; +import { Fallible } from "../types"; + +export type AE2Direction = "north" | "south" | "east" | "west" | "up" | "down"; +export type AE2PushDirection = AE2Direction | "all"; + +export type AE2DeviceType = + | "interface" + | "import_bus" + | "export_bus" + | "storage_bus" + | "formation_plane" + | "storage_level_emitter" + | "energy_level_emitter" + | "pattern_provider"; + +export type AE2Resource = { + type: "item" | "fluid"; + name: string; +}; + +export type AE2Stack = AE2Resource & { + /** Item count or fluid amount in millibuckets. */ + count: number; +}; + +export type AE2StockRow = { + target?: AE2Stack; + stored?: AE2Stack; +}; + +export type AE2FuzzyMode = + | "ignore_all" + | "percent_99" + | "percent_75" + | "percent_50" + | "percent_25"; + +export type AE2RedstoneMode = "ignore" | "low_signal" | "high_signal" | "signal_pulse"; +export type AE2EmitterMode = "low_signal" | "high_signal"; +export type AE2SchedulingMode = "default" | "round_robin" | "random"; +export type AE2AccessMode = "no_access" | "read" | "write" | "read_write"; +export type AE2StorageFilterMode = "none" | "extractable_only"; +export type AE2PatternLockMode = + | "none" + | "lock_until_pulse" + | "lock_while_high" + | "lock_while_low" + | "lock_until_result"; + +/** @noSelf **/ +export interface AE2DeviceObject { + getDeviceType(): AE2DeviceType; +} + +/** @noSelf **/ +export interface AE2UpgradeableObject { + getUpgradeSlotCount(): number; + listUpgrades(): LuaTable; + getUpgrade(slot: number): ItemDetail | null; + pullUpgrade(fromName: string, fromSlot: number, limit?: number, toSlot?: number): number; + pushUpgrade(toName: string, fromSlot: number, limit?: number, toSlot?: number): number; +} + +/** @noSelf **/ +export interface AE2PriorityObject { + getPriority(): number; + setPriority(priority: number): void; +} + +/** @noSelf **/ +export interface AE2FuzzyObject { + getFuzzyMode(): AE2FuzzyMode; + setFuzzyMode(mode: AE2FuzzyMode): void; +} + +/** @noSelf **/ +export interface AE2RedstoneControlledObject { + getRedstoneMode(): AE2RedstoneMode; + setRedstoneMode(mode: AE2RedstoneMode): void; +} + +/** @noSelf **/ +export interface AE2FilterObject { + getFilterSlotCount(): number; + listFilters(): LuaTable; + getFilter(slot: number): AE2Resource | null; + setFilter(slot: number, resource: AE2Resource): void; + clearFilter(slot: number): void; +} + +/** @noSelf **/ +export interface AE2InterfaceObject + extends AE2DeviceObject, + AE2UpgradeableObject, + AE2PriorityObject, + AE2FuzzyObject { + listStock(): LuaTable; + getStock(slot: number): AE2StockRow | null; + setStock(slot: number, target: AE2Stack): void; + clearStock(slot: number): void; +} + +/** @noSelf **/ +export interface AE2ImportBusObject + extends AE2DeviceObject, + AE2UpgradeableObject, + AE2FilterObject, + AE2FuzzyObject, + AE2RedstoneControlledObject {} + +/** @noSelf **/ +export interface AE2ExportBusObject + extends AE2DeviceObject, + AE2UpgradeableObject, + AE2FilterObject, + AE2FuzzyObject, + AE2RedstoneControlledObject { + isCraftOnly(): boolean; + setCraftOnly(craftOnly: boolean): void; + getSchedulingMode(): AE2SchedulingMode; + setSchedulingMode(mode: AE2SchedulingMode): void; +} + +/** @noSelf **/ +export interface AE2StorageBusObject + extends AE2DeviceObject, + AE2UpgradeableObject, + AE2FilterObject, + AE2PriorityObject, + AE2FuzzyObject { + getAccessMode(): AE2AccessMode; + setAccessMode(mode: AE2AccessMode): void; + getStorageFilterMode(): AE2StorageFilterMode; + setStorageFilterMode(mode: AE2StorageFilterMode): void; + shouldFilterOnExtract(): boolean; + setFilterOnExtract(filterOnExtract: boolean): void; +} + +/** @noSelf **/ +export interface AE2FormationPlaneObject + extends AE2DeviceObject, + AE2UpgradeableObject, + AE2FilterObject, + AE2PriorityObject, + AE2FuzzyObject { + shouldPlaceBlocks(): boolean; + setPlaceBlocks(placeBlocks: boolean): void; +} + +/** @noSelf **/ +export interface AE2LevelEmitterObject extends AE2DeviceObject { + getEmitterMode(): AE2EmitterMode; + setEmitterMode(mode: AE2EmitterMode): void; + isEmitting(): boolean; +} + +/** @noSelf **/ +export interface AE2StorageLevelEmitterObject + extends AE2LevelEmitterObject, + AE2UpgradeableObject, + AE2FuzzyObject { + getMonitoredResource(): AE2Resource | null; + setMonitoredResource(resource: AE2Resource): void; + clearMonitoredResource(): void; + getThreshold(): number; + setThreshold(threshold: number): void; + getThresholdUnit(): "item" | "millibucket" | "ae_internal"; + shouldCraftViaRedstone(): boolean; + setCraftViaRedstone(craftViaRedstone: boolean): void; +} + +/** @noSelf **/ +export interface AE2EnergyLevelEmitterObject extends AE2LevelEmitterObject { + getThreshold(): number; + setThreshold(threshold: number): void; +} + +/** @noSelf **/ +export interface AE2PatternProviderObject extends AE2DeviceObject, AE2PriorityObject { + listPatterns(): LuaTable; + getPattern(slot: number): ItemDetail | null; + pullPattern(fromName: string, fromSlot: number, limit?: number, toSlot?: number): number; + pushPattern(toName: string, fromSlot: number, limit?: number, toSlot?: number): number; + isBlocking(): boolean; + setBlocking(blocking: boolean): void; + isVisibleInPatternAccessTerminal(): boolean; + setVisibleInPatternAccessTerminal(visible: boolean): void; + getPatternLockMode(): AE2PatternLockMode; + setPatternLockMode(mode: AE2PatternLockMode): void; +} + +export type AE2CableDevice = + | AE2InterfaceObject + | AE2ImportBusObject + | AE2ExportBusObject + | AE2StorageBusObject + | AE2FormationPlaneObject + | AE2StorageLevelEmitterObject + | AE2EnergyLevelEmitterObject + | AE2PatternProviderObject; + +/** @noSelf **/ +export interface AE2CableAPI extends IPeripheral { + getSide(side: AE2Direction): Fallible; +} + +/** @noSelf **/ +export interface AE2InterfacePeripheral extends AE2NetworkAPI, AE2InterfaceObject {} + +/** @noSelf **/ +export interface AE2PatternProviderPeripheral extends AE2NetworkAPI, AE2PatternProviderObject { + getPushDirection(): AE2PushDirection; + setPushDirection(direction: AE2PushDirection): void; +} diff --git a/projects/typed-peripheral-unlimitedperipheralworks/integrations/ae2WirelessTerminal.ts b/projects/typed-peripheral-unlimitedperipheralworks/integrations/ae2WirelessTerminal.ts new file mode 100644 index 00000000..49fc8776 --- /dev/null +++ b/projects/typed-peripheral-unlimitedperipheralworks/integrations/ae2WirelessTerminal.ts @@ -0,0 +1,25 @@ +import { FuelApi } from "@siredvin/typed-peripheral-api/fuel"; +import { ItemQuery } from "@siredvin/typed-peripheral-api/item_storage"; +import { + ExtendedItemDetail, + IPeripheralProvider, + ShortItemDetail, +} from "@siredvin/typed-peripheral-base"; + +/** @noSelf **/ +export interface AE2WirelessTerminalAPI extends FuelApi { + items(): LuaTable; + items( + detailed: true, + filter?: ItemQuery + ): LuaTable; + items( + detailed: false, + filter?: ItemQuery + ): LuaTable; + pullItem(itemQuery?: ItemQuery, limit?: number, toSlot?: number): number; + pushItem(fromSlotOrItemQuery?: number | ItemQuery, limit?: number): number; +} + +export const ae2WirelessTerminalProvider = + new IPeripheralProvider("ae2_wireless_terminal"); diff --git a/projects/typescript-tests/build.mjs b/projects/typescript-tests/build.mjs index 4afd5cd5..ef2221a8 100644 --- a/projects/typescript-tests/build.mjs +++ b/projects/typescript-tests/build.mjs @@ -16,7 +16,7 @@ for (const peripheral of [ "universal_scanner", "ultimate_sensor", "item_pedestal", "map_pedestal", "display_pedestal", "remote_observer", "peripheral_proxy", "reality_forger", "recipe_registry", "informative_registry", "statue_workbench", "entity_link", "network_manager", - "hologram_projector", + "hologram_projector", "ae2_configurable_objects", "ae2_wireless_terminal", ]) { const result = spawnSync( resolve("node_modules/.bin/tstl"), diff --git a/projects/typescript-tests/src/ae2_configurable_objects.ts b/projects/typescript-tests/src/ae2_configurable_objects.ts new file mode 100644 index 00000000..4c7c8949 --- /dev/null +++ b/projects/typescript-tests/src/ae2_configurable_objects.ts @@ -0,0 +1,191 @@ +import type { + AE2CableAPI, + AE2EnergyLevelEmitterObject, + AE2ExportBusObject, + AE2FormationPlaneObject, + AE2ImportBusObject, + AE2InterfacePeripheral, + AE2PatternProviderPeripheral, + AE2StorageBusObject, + AE2StorageLevelEmitterObject, +} from "@siredvin/typed-peripheral-unlimitedperipheralworks/integrations/ae2Objects"; +import type { InventoryViewAPI } from "@siredvin/typed-peripheral-api/inventory_view"; + +/** @noSelf **/ +interface TestApi { + ok(marker?: string): void; +} +declare const test: TestApi; + +const check = (value: unknown, message: string): void => { + if (!value) throw message; +}; +const fails = (action: (this: void) => void, message: string): void => { + const [ok] = pcall(action); + check(!ok, message); +}; +const item = (name: string) => ({ type: "item" as const, name }); +const fluid = (name: string) => ({ type: "fluid" as const, name }); +const sides = ["top", "bottom", "left", "right", "front", "back"]; +let inventory: InventoryViewAPI | undefined; +let inventoryName = ""; +let cable: AE2CableAPI | undefined; +let directInterface: AE2InterfacePeripheral | undefined; +let directProvider: AE2PatternProviderPeripheral | undefined; +let seen = ""; +for (let attempt = 0; attempt < 100; attempt++) { + seen = ""; + for (const side of sides) { + const wrapped = peripheral.wrap(side) as any; + if (!wrapped) continue; + seen += `${side}:${peripheral.getType(side)} `; + if (wrapped.getSide) cable = wrapped; + else if (wrapped.listStock) directInterface = wrapped; + else if (wrapped.listPatterns) directProvider = wrapped; + else if (wrapped.list) { + inventory = wrapped as InventoryViewAPI; + inventoryName = side; + } + } + if (inventory && cable && directInterface && directProvider) break; + sleep(0.05); +} +const requirePeripheral = (value: unknown, name: string): void => { + if (!value) throw `Fixture ${name} did not become available`; +}; +requirePeripheral(inventory, "inventory"); +requirePeripheral(cable, `cable (${seen})`); +requirePeripheral(directInterface, "Interface"); +requirePeripheral(directProvider, "Pattern Provider"); + +const aeCable = cable as AE2CableAPI; +const aeInterface = directInterface as AE2InterfacePeripheral; +const aeProvider = directProvider as AE2PatternProviderPeripheral; +const itemInventory = inventory as InventoryViewAPI; + +aeInterface.setStock(1, { ...item("minecraft:iron_ingot"), count: 32 }); +const initialStock = aeInterface.getStock(1); +check(initialStock?.target?.count === 32, `direct interface stock callback returned ${textutils.serialize(initialStock)}`); +aeInterface.setStock(2, { ...fluid("minecraft:water"), count: 1000 }); +check(aeInterface.getStock(2)?.target?.count === 1000, "fluid stock was not exposed as 1000 mB"); +aeInterface.setPriority(12); +aeInterface.setFuzzyMode("percent_75"); +check(aeInterface.getPriority() === 12 && aeInterface.getFuzzyMode() === "percent_75", "interface settings did not apply"); +fails( + () => aeInterface.setStock(1, { type: "item", name: "minecraft:not_a_real_item", count: 1 }), + "unknown stock resource was accepted", +); +check(aeInterface.getStock(1)?.target?.count === 32, "invalid stock resource partially mutated the interface"); +aeInterface.clearStock(1); +check(aeInterface.getStock(1) === null, "direct interface stock did not clear"); + +aeProvider.setPriority(9); +aeProvider.setBlocking(true); +aeProvider.setVisibleInPatternAccessTerminal(false); +aeProvider.setPatternLockMode("lock_while_high"); +aeProvider.setPushDirection("east"); +check( + aeProvider.getPriority() === 9 && aeProvider.isBlocking() && + !aeProvider.isVisibleInPatternAccessTerminal() && + aeProvider.getPatternLockMode() === "lock_while_high" && aeProvider.getPushDirection() === "east", + "direct pattern provider settings did not apply", +); +check(aeProvider.pullPattern(inventoryName, 6, 1, 1) === 1, "encoded pattern did not transfer into the provider"); +check(aeProvider.getPattern(1) !== null, "encoded pattern slot was not updated"); +check(aeProvider.pullPattern(inventoryName, 5, 1, 2) === 0, "non-pattern item transferred into the provider"); +check(itemInventory.list()[5]?.name === "minecraft:stone", "rejected pattern mutated its source slot"); +check(aeProvider.pushPattern(inventoryName, 1, 1, 12) === 1, "encoded pattern did not transfer out of the provider"); + +const [south, southError] = aeCable.getSide("south"); +check(south && !southError, "south Export Bus was not resolved"); +const exportBus = south as AE2ExportBusObject; +const [empty, emptyError] = aeCable.getSide("north"); +check(empty === null && !!emptyError, "empty cable side did not return nil and an error"); +fails(() => (aeCable as any).getSide("sideways"), "invalid cable direction was accepted"); + +exportBus.setFilter(1, item("minecraft:iron_ingot")); +exportBus.setFuzzyMode("percent_50"); +exportBus.setRedstoneMode("high_signal"); +exportBus.setSchedulingMode("round_robin"); +check( + exportBus.getFilter(1)?.name === "minecraft:iron_ingot" && exportBus.getFuzzyMode() === "percent_50" && + exportBus.getRedstoneMode() === "high_signal" && exportBus.getSchedulingMode() === "round_robin", + "Export Bus callbacks did not apply", +); +fails( + () => exportBus.setFilter(1, { ...item("minecraft:gold_ingot"), count: 1 } as any), + "amount-bearing filter was accepted", +); +check(exportBus.getFilter(1)?.name === "minecraft:iron_ingot", "invalid filter partially mutated the Export Bus"); +check(exportBus.pullUpgrade(inventoryName, 1, 1, 1) === 1, "Capacity Card did not transfer through the side object"); +const expandedSlots = exportBus.getFilterSlotCount(); +check(expandedSlots === 27, `Capacity Card produced ${expandedSlots} slots with ${textutils.serialize(exportBus.listUpgrades())}`); +exportBus.setFilter(27, item("minecraft:diamond")); +check(exportBus.pullUpgrade(inventoryName, 3, 1, 2) === 1, "Crafting Card did not transfer into the Export Bus"); +exportBus.setCraftOnly(true); +exportBus.setFilter(10, item("minecraft:gold_ingot")); +check(exportBus.isCraftOnly() && exportBus.getFilter(10)?.name === "minecraft:gold_ingot", "craft-only slot 10 configuration failed"); +check(exportBus.pullUpgrade(inventoryName, 5, 1, 3) === 0, "invalid upgrade card was accepted"); +check(itemInventory.list()[5]?.name === "minecraft:stone", "rejected upgrade mutated its source slot"); +check(exportBus.pushUpgrade(inventoryName, 1, 1, 10) === 1, "Capacity Card did not transfer out through originating computer access"); +check(exportBus.getFilterSlotCount() === 18, "Capacity Card removal did not shrink active filters"); +check(exportBus.pullUpgrade(inventoryName, 10, 1, 1) === 1, "Capacity Card could not be restored"); +check(exportBus.getFilterSlotCount() === 27 && exportBus.getFilter(27) === null, "inactive filter was not cleared on shrink"); + +const [east] = aeCable.getSide("east"); +const storageBus = east as AE2StorageBusObject; +storageBus.setFilter(1, item("minecraft:cobblestone")); +storageBus.setPriority(7); +storageBus.setAccessMode("read"); +storageBus.setStorageFilterMode("extractable_only"); +storageBus.setFilterOnExtract(true); +check( + storageBus.getFilter(1)?.name === "minecraft:cobblestone" && storageBus.getPriority() === 7 && + storageBus.getAccessMode() === "read" && storageBus.getStorageFilterMode() === "extractable_only" && + storageBus.shouldFilterOnExtract(), + "Storage Bus callbacks did not apply", +); + +const [west] = aeCable.getSide("west"); +const formationPlane = west as AE2FormationPlaneObject; +formationPlane.setFilter(1, item("minecraft:stone")); +formationPlane.setPriority(4); +formationPlane.setPlaceBlocks(false); +check(formationPlane.getPriority() === 4 && !formationPlane.shouldPlaceBlocks(), "Formation Plane callbacks did not apply"); + +const [up] = aeCable.getSide("up"); +const storageEmitter = up as AE2StorageLevelEmitterObject; +storageEmitter.setMonitoredResource(fluid("minecraft:water")); +storageEmitter.setThreshold(4000); +storageEmitter.setEmitterMode("high_signal"); +storageEmitter.setCraftViaRedstone(true); +check( + storageEmitter.getThreshold() === 4000 && storageEmitter.getThresholdUnit() === "millibucket" && + storageEmitter.getEmitterMode() === "high_signal" && storageEmitter.shouldCraftViaRedstone(), + "storage emitter fluid normalization or callbacks failed", +); +check(exportBus.pushUpgrade(inventoryName, 2, 1, 11) === 1, "Crafting Card could not be staged for the emitter"); +check(storageEmitter.pullUpgrade(inventoryName, 11, 1, 1) === 1, "emitter upgrade transfer failed"); +check(storageEmitter.pullUpgrade(inventoryName, 4, 1, 1) === 0, "emitter accepted a card beyond its one-slot limit"); +check(itemInventory.list()[4]?.name === "ae2:fuzzy_card", "card-limit rejection mutated the source inventory"); + +const [down] = aeCable.getSide("down"); +const energyEmitter = down as AE2EnergyLevelEmitterObject; +energyEmitter.setThreshold(250); +energyEmitter.setEmitterMode("high_signal"); +check(energyEmitter.getThreshold() === 250 && energyEmitter.getEmitterMode() === "high_signal", "energy emitter callbacks did not apply"); + +test.ok("same-kind"); +while (exportBus.getFilter(1) !== null) sleep(0.05); +exportBus.setFilter(1, item("minecraft:gold_ingot")); +check(exportBus.getFilter(1)?.name === "minecraft:gold_ingot", "side object did not operate on same-kind replacement"); + +test.ok("different-kind"); +while (pcall(() => exportBus.getDeviceType())[0]) sleep(0.05); +const [replacement] = aeCable.getSide("south"); +const importBus = replacement as AE2ImportBusObject; +check(importBus.getDeviceType() === "import_bus", "different-kind replacement was not visible through getSide"); + +test.ok("removed"); +while (pcall(() => importBus.getDeviceType())[0]) sleep(0.05); +test.ok(); diff --git a/projects/typescript-tests/src/ae2_wireless_terminal.ts b/projects/typescript-tests/src/ae2_wireless_terminal.ts new file mode 100644 index 00000000..605d32d2 --- /dev/null +++ b/projects/typescript-tests/src/ae2_wireless_terminal.ts @@ -0,0 +1,79 @@ +import { ae2CraftingMonitorProvider } from "@siredvin/typed-peripheral-unlimitedperipheralworks/integrations/ae2CraftingMonitor"; +import { ae2WirelessTerminalProvider } from "@siredvin/typed-peripheral-unlimitedperipheralworks/integrations/ae2WirelessTerminal"; + +/** @noSelf **/ +interface TestApi { + ok(marker?: string): void; +} +declare const test: TestApi; + +const check = (value: unknown, message: string): void => { + if (!value) throw message; +}; + +const terminal = ae2WirelessTerminalProvider.findOrThrow(); +const craftingMonitor = ae2CraftingMonitorProvider.findOrThrow(); +const [missingJob, missingJobError] = craftingMonitor.getCraftingJob("missing"); +check(missingJob === null && typeof missingJobError === "string" && missingJobError.includes("not found"), "Missing crafting job was not reported"); +const [missingCancel, missingCancelError] = craftingMonitor.cancelCrafting("missing"); +check(missingCancel === null && typeof missingCancelError === "string" && missingCancelError.includes("not found"), "Missing crafting job cancellation was not reported"); +craftingMonitor.getCraftingJobs(); +const typecheckCraftingRequest = (): void => { + const [scheduled, jobId] = craftingMonitor.scheduleCrafting("item", "minecraft:stone", 1); + if (scheduled) craftingMonitor.getCraftingJob(jobId); +}; +const detailed = terminal.items(); +terminal.items(true); +const filtered = terminal.items(false, { name: "minecraft:stone" }); +check(detailed[1]?.name === "minecraft:stone" && filtered[1]?.name === "minecraft:stone", "Stone was not listed"); +const initialFuel = terminal.getFuelLevel(); +check(terminal.pullItem("minecraft:stone", 5, 1) === 5, "Stone was not pulled into slot 1"); +check(terminal.pullItem("minecraft:dirt") === 0, "Missing dirt unexpectedly moved"); +check(terminal.pushItem(16, 3) === 3, "Gold was not pushed from slot 16"); +check(terminal.getFuelLevel() === initialFuel - 3, "Transfers did not consume one fuel each"); +const fuelAfterTransfers = terminal.getFuelLevel(); +check(!pcall(() => terminal.pushItem(17))[0], "Invalid slot was accepted"); +check(terminal.getFuelLevel() === fuelAfterTransfers, "Invalid slot consumed fuel"); +terminal.items(); +check(terminal.getFuelLevel() === fuelAfterTransfers, "Item listing consumed fuel"); +check(terminal.getFuelMaxLevel() >= terminal.getFuelLevel(), "Fuel exceeded its maximum"); +check(terminal.getFuelConsumptionRate() === 1, "Unexpected fuel consumption rate"); +terminal.setFuelConsumptionRate(1); +test.ok("initial"); +while (terminal.getFuelLevel() !== 0) sleep(0.05); +check(!pcall(() => terminal.pushItem("minecraft:dirt"))[0], "Transfer succeeded without fuel"); +check(terminal.getFuelLevel() === 0, "Failed transfer changed fuel"); +test.ok("empty-fuel"); +while (terminal.getFuelLevel() === 0) sleep(0.05); +check(terminal.pushItem("minecraft:dirt") === 0, "Valid zero-move transfer returned items"); +check(terminal.getFuelLevel() === 1, "Fuel-disabled transfer consumed fuel"); +test.ok("disabled"); +while (terminal.getFuelLevel() === 1) sleep(0.05); +check(terminal.pushItem("minecraft:dirt") === 0, "Valid zero-move transfer returned items"); +check(terminal.getFuelLevel() === 1, "Valid zero-move transfer did not consume fuel"); +test.ok("restored"); +let failure = ""; +while (!failure.includes("outside wireless range")) { + const [ok, error] = pcall(() => terminal.items()); + failure = ok ? "" : `${error}`; + if (!failure) sleep(0.05); +} +test.ok("out-of-range"); +while (!pcall(() => terminal.items())[0]) sleep(0.05); +test.ok("returned"); +failure = ""; +while (!failure.includes("outside wireless range")) { + const [ok, error] = pcall(() => terminal.items()); + failure = ok ? "" : `${error}`; + if (!failure) sleep(0.05); +} +test.ok("inactive"); +while (!pcall(() => terminal.items())[0]) sleep(0.05); +test.ok("reactivated"); +failure = ""; +while (!failure.includes("unavailable")) { + const [ok, error] = pcall(() => terminal.items()); + failure = ok ? "" : `${error}`; + if (!failure) sleep(0.05); +} +test.ok();