Skip to navigation

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:

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

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:

{"type": "shaped", "w": ..., "h": ..., "ings": [set, ...], "icount": ..., "rid": ..., "rcount": ...}
{"type": "shapeless", "ings": [set, ...], "rid": ..., "rcount": ...}
{"type": "cooking", "ing": set, "rid": ..., "rcount": ...}
{"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)
TABLE = recipes() # the host-injected vanilla recipe table (the fallthrough source)
# Custom recipes: 1 dirt (id 55) -> 1 diamond (id 926), shapeless single-ingredient.
CUSTOM = [
{"kind": "shapeless1", "ing_id": 55, "rid": 926, "rcount": 1},
]
def _match_custom(cells, w, h):
tcells, tw, th, left, top, empty = _of_positioned(cells, w, h)
if empty:
return None
for rec in CUSTOM:
if rec["kind"] == "shapeless1":
if _ingredient_count(tcells) == 1 and tcells[0][0] == rec["ing_id"]:
return {"id": rec["rid"], "count": rec["rcount"]}
return None
def match(grid):
w = grid["w"]
h = grid["h"]
cells = grid["cells"]
# CUSTOM first, then the vanilla fallthrough over recipes().
out = _match_custom(cells, w, h)
if out != None:
return out
return _match_vanilla(cells, w, h) # the 1:1 vanilla algorithm over TABLE
def remaining(grid):
return None
set_recipe_matcher(match)
set_recipe_remaining(remaining)
plugins/customrecipe/plugin.toml
name = "customrecipe"
version = "0.1.0"
entrypoint = "main.star"
runtime = "starlark"
# Pure scalar math over the grid + recipe table: no capabilities needed.
capabilities = []

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.