> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.trysulfur.net/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.trysulfur.net/_mcp/server.

# Recipes

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

| Builtin                | Signature                  | Purpose                                                                                                                                                                                                     |
| ---------------------- | -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `set_recipe_matcher`   | `set_recipe_matcher(fn)`   | Captures the crafting matcher. `fn(grid)` returns `{"id": int, "count": int}` or `None`.                                                                                                                    |
| `set_recipe_remaining` | `set_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.                                                     |
| `recipes`              | `recipes()`                | 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:

```python
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:

```python
{"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:

```python title="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)
```

```toml title="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.