Tick Decisions
Use TickDecisionList when a plugin must choose actions in a fixed order once per game tick. Call it from RuneLite's onGameTick lifecycle. Do not put it inside TaskPipeline or another loop that polls faster than game ticks.
The layer has two responsibilities:
TickDecisionListguards against duplicate evaluation for the same tick and preserves declaration order.- Each
TickDecisiondecides whether later entries may run during that tick.
true reserves the remainder of the tick. false permits the next decision. A decision must return without sleeping or waiting and dispatch no more than one meaningful action. The next game tick must observe the action's postcondition.
Create a decision list
Keep the list as plugin-owned state and pass RuneLite's tick count from onGameTick:
private TickDecisionList combatDecisions;
@Override
protected void startUp() {
combatDecisions = TickDecisionList.of(
new PotionDecision()
.addPotion(superStrength, () ->
client.getBoostedSkillLevel(Skill.STRENGTH)
- client.getRealSkillLevel(Skill.STRENGTH) < 3),
new EatDecision(client),
attackDecision);
}
@Subscribe
public void onGameTick(GameTick event) {
combatDecisions.onTick(client.getTickCount());
}
In this order, a potion or food action remains non-blocking. attackDecision may return true after it dispatches an attack so no later decision can submit another incompatible action.
lastDecisionName() reports the decision that blocked the most recently evaluated tick. It returns null before the first evaluation and when every decision returned false.
Write a decision
Implement a leaf with a stable diagnostic name. Query current state inside evaluate() rather than caching a query builder across ticks.
final class AttackDecision implements TickDecision {
@Override
public String name() {
return "Attack target";
}
@Override
public boolean evaluate() {
if (!shouldAttack()) {
return false;
}
InteractionResult result = NPCActions.interact(targetName, "Attack");
return result.accepted();
}
}
Do not infer completion from InteractionResult.DISPATCHED. Re-read combat, inventory, animation, or skill state on a later tick.
Eating
EatDecision reads the current game tick and real and boosted hitpoints from the injected Client. Missing hitpoints equal real hitpoints minus boosted hitpoints. The default policy requires 20 missing hitpoints and includes these ccscripts-derived healing values:
| Food | Heal |
|---|---|
| Shark | 20 |
| Lobster | 14 |
| Swordfish | 14 |
| Jug of wine | 8 |
| Blighted manta ray | 22 |
| Blighted anglerfish | 10 |
| Blighted karambwan primary value | 45 |
| Cooked or blighted karambwan combo value | 18 |
The decision clears an active widget selection before eating. It records the cooldown only after InventoryActions.use(..., "Eat") accepts the dispatch. A normal eat blocks another eat for three game ticks.
The one-action contract prevents two inventory dispatches from one evaluate() call. When primary food and a combo food fit the current deficit, EatDecision dispatches the primary food and retains the combo identifier. It attempts that combo on the next evaluation, then starts the three-tick delay. If the combo has left the inventory, the decision drops the pending attempt.
Supply custom healing maps, a threshold, and a policy gate through the configurable constructor:
Map<Integer, Integer> food = new LinkedHashMap<>();
food.put(ItemID.MANTA_RAY, 22);
Map<Integer, Integer> combo = new LinkedHashMap<>();
combo.put(ItemID.COOKED_KARAMBWAN, 18);
TickDecision eat = new EatDecision(
client,
food,
combo,
18,
() -> !bankOpen && safeToEat());
Map iteration order does not select food. The decision scans current inventory order and chooses the first primary food whose heal does not exceed the missing hitpoints. It applies the same inventory-order rule to compatible combo food.
Potions and item variants
ItemVariant groups interchangeable item IDs in resolution order. Put the preferred dose first:
ItemVariant superStrength = new ItemVariant(
ItemID.SUPER_STRENGTH4,
ItemID.SUPER_STRENGTH3,
ItemID.SUPER_STRENGTH2,
ItemID.SUPER_STRENGTH1);
PotionDecision evaluates rules in the order you add them. A rule whose condition is false does nothing. When the condition is true, the decision resolves the first owned variant. If the player does not own that potion, evaluation continues to the next rule. The first owned match dispatches InventoryActions.use(id, "Drink") and ends potion evaluation for that tick.
PotionDecision potions = new PotionDecision()
.addPotion(superStrength, () ->
client.getBoostedSkillLevel(Skill.STRENGTH)
- client.getRealSkillLevel(Skill.STRENGTH) < 3)
.addPotion(prayerPotion, () ->
client.getBoostedSkillLevel(Skill.PRAYER) <= 20);
Conditions can read boost deltas, prayer points, run energy, poison state, or plugin configuration. Keep conditions read-only. Action APIs retain pacing and dispatch ownership.
Ordering guidelines
Place urgent setup and survival decisions before target actions. A typical combat list uses this order:
- Configure quick prayers.
- Move into the combat area.
- Drink potions.
- Flick or swap prayer.
- Eat.
- Loot.
- Set combat style.
- Attack.
Only include decisions the plugin needs. The SDK does not provide a generic combat branch or boss-cycle tracker until a concrete consumer establishes their runtime contracts.
Testing and live validation
Unit tests can prove evaluation order, same-tick suppression, short-circuit behavior, thresholds, cooldown arithmetic, and variant resolution. Use injected collaborators or package-local fakes rather than static mocking.
Static tests do not prove that RuneLite and the game accept an eat-plus-action sequence on the intended server ticks. Record that check as Live client verification: pending until a current client run observes the inventory, combat, and tick postconditions.