Recipes

Drive crafting resolution from a plugin — the value-returning matcher seam

View as Markdown

Crafting in Sulfur is resolved through the plugin API. The event bus is fire-and-forget dispatch; the recipe seam is its value-returning twin: the host calls a plugin callable and reads the result back into Go.

Even a default server crafts through this seam — the bundled crafting plugin is a literal port of the 26.2 jar’s matching algorithms (CraftingInput.ofPositioned, ShapedRecipePattern.matches, ShapelessRecipe.matches, SingleItemRecipe.matches) registered via set_recipe_matcher.

The builtins

BuiltinSignaturePurpose
set_recipe_matcherset_recipe_matcher(fn)Captures the crafting matcher. fn(grid) returns {"id": int, "count": int} or None.
set_recipe_remainingset_recipe_remaining(fn)Captures the remaining-items callable (buckets-back etc.). Returning None means “no leftover items” — the vanilla default for the current recipe set.
recipesrecipes()Returns the Go-parsed vanilla recipe table the server injects, or None before injection. Starlark has no JSON/file builtins by design, so the parse stays in Go and only the match lives in the plugin.

There is a single matcher slot: set_recipe_matcher captures one callable, and the last-loaded plugin wins (plugins load in directory order). A custom-recipe plugin should therefore check its own recipes first and fall through to the vanilla algorithm over recipes() — see the pattern below. Unloading the owning plugin clears the matcher.

The grid payload and result

The matcher receives one argument, a frozen dict:

1grid = {
2 "w": 3, # grid width
3 "h": 3, # grid height
4 "cells": [(id, count), ...] # row-major (w*h) item cells; id <= 0 or count <= 0 == empty
5}

It returns either None (no match) or a dict {"id": result_item_id, "count": result_count}. The host validates the result: a non-dict, missing keys, or id <= 0 / count <= 0 is treated as no-match — a plugin cannot forge a bogus stack.

The recipe table returned by recipes() is a list of dicts:

1{"type": "shaped", "w": ..., "h": ..., "ings": [set, ...], "icount": ..., "rid": ..., "rcount": ...}
2{"type": "shapeless", "ings": [set, ...], "rid": ..., "rcount": ...}
3{"type": "cooking", "ing": set, "rid": ..., "rcount": ...}
4{"type": "stonecutting", "ing": set, "rid": ..., "rcount": ...}

where each ingredient set is a dict used for membership (id in ing); an empty dict is the empty-optional pattern cell.

Isolation

Matcher calls follow the same isolation rules as event hooks: each Match runs on a fresh step-budgeted thread inside a recover. A matcher that errors, panics, or exceeds the step budget logs and returns “no match” — the tick never hangs on crafting.

Example: adding a custom recipe

The bundled customrecipe operator plugin adds a non-vanilla recipe (1 dirt → 1 diamond) while keeping every vanilla recipe working, via the custom-first + vanilla-fallthrough pattern:

plugins/customrecipe/main.star (abridged)
1TABLE = recipes() # the host-injected vanilla recipe table (the fallthrough source)
2
3# Custom recipes: 1 dirt (id 55) -> 1 diamond (id 926), shapeless single-ingredient.
4CUSTOM = [
5 {"kind": "shapeless1", "ing_id": 55, "rid": 926, "rcount": 1},
6]
7
8def _match_custom(cells, w, h):
9 tcells, tw, th, left, top, empty = _of_positioned(cells, w, h)
10 if empty:
11 return None
12 for rec in CUSTOM:
13 if rec["kind"] == "shapeless1":
14 if _ingredient_count(tcells) == 1 and tcells[0][0] == rec["ing_id"]:
15 return {"id": rec["rid"], "count": rec["rcount"]}
16 return None
17
18def match(grid):
19 w = grid["w"]
20 h = grid["h"]
21 cells = grid["cells"]
22 # CUSTOM first, then the vanilla fallthrough over recipes().
23 out = _match_custom(cells, w, h)
24 if out != None:
25 return out
26 return _match_vanilla(cells, w, h) # the 1:1 vanilla algorithm over TABLE
27
28def remaining(grid):
29 return None
30
31set_recipe_matcher(match)
32set_recipe_remaining(remaining)
plugins/customrecipe/plugin.toml
1name = "customrecipe"
2version = "0.1.0"
3entrypoint = "main.star"
4runtime = "starlark"
5# Pure scalar math over the grid + recipe table: no capabilities needed.
6capabilities = []

The full vanilla match helpers (_of_positioned, _matches_shaped, _matches_shapeless, _matches_single) are in plugins/crafting/main.star — Starlark plugins cannot import one another, so a custom matcher includes its own copy of the fallthrough.