Skip to main content

Loadouts

com.n3plugins.sdk.loadouts declares what a character should carry and wear. A loadout is a target state, not an action - it is handed to the bank and production workflow builders, which reconcile the live game against it.

Types:

  • LoadoutItem - one immutable item requirement (id, amount, slot, flags).
  • InventoryLoadout - the 28-slot inventory target.
  • EquipmentLoadout - the worn-equipment target.
  • Loadout - shared base of both.
  • ItemDepletionListener - optional callback when a required item runs out.

Declare vs assert

Loadouts pair with InventoryPlan (see inventory-plan.md) but answer a different question:

TypeQuestionUsed by
InventoryPlanAssert - "does the inventory satisfy this right now?"a guard before a step
InventoryLoadoutDeclare - "inventory should contain this after a bank run"BankWorkflowBuilder.withdraw/reconcile, ProductionWorkflowBuilder.bankAndRestock
EquipmentLoadoutDeclare - "this should be worn after restocking"BankWorkflowBuilder.equip/reconcile

InventoryPlan reads; a loadout is a goal the builders drive toward.

Building items

LoadoutItem is immutable and built fluently. builder(int itemId) is the only entry point; every other field has a default.

LoadoutItem logs = LoadoutItem.builder(ItemID.YEW_LOGS)
.amount(27)
.build();

LoadoutItem natures = LoadoutItem.builder(ItemID.NATURE_RUNE)
.amount(100)
.stackable(true) // omit to let ItemMetadataResolver decide from client metadata
.build();

LoadoutItem staff = LoadoutItem.builder(ItemID.STAFF_OF_FIRE)
.slot(EquipmentInventorySlot.WEAPON) // required for equipment loadouts
.build();

Builder fields: amount(int) (default 1), optional(boolean), noted(boolean), stackable(boolean) (auto-resolved when unset), slot(EquipmentInventorySlot). slot comes from net.runelite.api.EquipmentInventorySlot.

Inventory loadout

InventoryLoadout inv = new InventoryLoadout();
inv.add(LoadoutItem.builder(ItemID.YEW_LOGS).amount(27).build());
inv.add(LoadoutItem.builder(ItemID.KNIFE).amount(1).build());

boolean done = inv.isFulfilled(); // live inventory matches the target

add(...) throws IllegalArgumentException if the declared items would exceed the 28-slot capacity. copy() returns a deep copy; fulfill(EquipmentLoadout) reconciles inventory against an equipment plan.

Equipment loadout

EquipmentLoadout eq = new EquipmentLoadout();
eq.add(LoadoutItem.builder(ItemID.STAFF_OF_FIRE)
.slot(EquipmentInventorySlot.WEAPON).build());
eq.add(LoadoutItem.builder(ItemID.AMULET_OF_GLORY)
.slot(EquipmentInventorySlot.AMULET).build());

List<LoadoutItem> missing = eq.getMissingItems(); // not present anywhere
List<LoadoutItem> toEquip = eq.getUnequippedItems(); // in inventory, not yet worn
LoadoutItem weapon = eq.get(EquipmentInventorySlot.WEAPON);

Equipment items must set slot; add(...) rejects a slotless item.

Shared Loadout surface

Both subclasses expose:

  • isFulfilled() - live state matches the declared target.
  • getRequiredItems() - the declared items.
  • getExcessItems() - held above the declared amount.
  • getForeignItemIds() - held but not part of the loadout (deposit candidates).
  • contains(int), get(int), desiredCount(int), desiredItemIds().
  • setItemDepletionListener(ItemDepletionListener) - fires onDeplete(LoadoutItem) when a required item is exhausted.

How the builders consume a loadout

You never reconcile a loadout by hand. Pass it to a workflow builder:

// Banking: see banking.md
BankWorkflowBuilder.create()
.reconcile(inv, eq) // deposit foreign -> withdraw missing -> equip unworn
.build();

// Production restock: see production-workflow.md
new ProductionWorkflowBuilder(services)
.waitForProductionComplete()
.bankAndRestock(inv)
.build();

The builder reads getForeignItemIds(), getMissingItems(), and getUnequippedItems() to decide what to deposit, withdraw, and equip - the loadout is purely declarative.

Testing

ItemMetadataResolver decides stackability from client metadata. In the test JVM the client is null, so set stackable(...) explicitly on stackable items, or inject a resolver via ItemMetadataResolver.setResolverForTesting(...) and reset it in @After.