# Adding Materials Tools and Recipes

A material, a tool, and the recipe that crafts it all go in JSON under `data/`, loaded at startup by `scripts/resources/DataRegistry.gd`. Standard content needs no GDScript. Here are the three edits, then validation.

## How the data is loaded

`DataRegistry._load_all_data()` reads each file into a dictionary keyed by id:

```gdscript
materials = _load_json("res://data/materials.json")
recipes   = _load_json("res://data/recipes.json")
tools     = _load_json("res://data/tools.json")
```

Every lookup returns `{}` on a miss, so callers always use `.get("key", default)`:

```gdscript
DataRegistry.get_material(mat_id)   # → Dictionary, {} if unknown
DataRegistry.get_tool(tool_id)      # → Dictionary
DataRegistry.get_recipe(recipe_id)  # → Dictionary
```

Each entry's top-level key must equal its own `id` field. The registry keys on the dictionary key, but validation and several call sites read the inner `id`. Keep them identical.

## Adding a material

One entry in `data/materials.json`, keyed by the material id. A terrain material (`coal`):

```json
"coal": {
  "id": "coal",
  "first_depth": 27,
  "display_name": "Coal",
  "source_type": "terrain",
  "dig_time": 4.0,
  "gather_time": 4.0,
  "terrain_hp": 30.0,
  "wall_hp": 0.0,
  "base_chew_resistance": 0.0,
  "is_wall_material": false,
  "is_crafting_material": false,
  "is_fuel": true,
  "fuel_seconds": 60.0,
  "work_rate_mult": 1.0,
  "trade_value": 3,
  "durability_cost": 1.0,
  "material_tier": 2,
  "yield_weights": [92, 6, 2]
}
```

Common fields:

| Field | Meaning |
|---|---|
| `id` | Must match the JSON key. |
| `display_name` | Player-facing name. |
| `source_type` | `terrain`, `forest`, etc. — where it comes from. |
| `terrain_hp` / `wall_hp` | HP as a dug tile / as a placed wall block. |
| `base_chew_resistance` | How hard the Maw finds it to eat. |
| `is_wall_material` | Eligible for wall building (raw materials, 1 unit per layer). |
| `is_crafting_material` | Usable as a recipe input. |
| `is_fuel` + `fuel_seconds` + `work_rate_mult` | Fuel value and speed multiplier for machines. |
| `trade_value` | Villager-economy anchor (iron ≈ 30). |
| `material_tier` | Tool-tier gating (see below). |
| `durability_cost` | Base tool-durability drain per dig. |
| `yield_weights` | Relative weights for the variable-yield roll (`TerrainDigging.roll_yield`). |

> Never add a `_comment` or any `_`-prefixed key to `materials.json`. `GameManager.compute_completion_pct` uses `materials.size()` as a denominator with no filtering, and the validator fails on meta keys here because they skew the completion percentage.

## Adding a tool

One entry in `data/tools.json`, keyed by the tool id (`pickaxe`):

```json
"pickaxe": {
  "id": "pickaxe",
  "family": "pickaxe",
  "tier": 1,
  "display_name": "Pickaxe",
  "description": "Standard miner. Breaks stone, coal, iron ore, and sulfur seams...",
  "dig_speed_multiplier": 2.0,
  "gather_speed_multiplier": 1.0,
  "allowed_materials": ["dirt", "clay", "stone", "coal", "iron_ore", "sulfur", "dense_stone"],
  "allowed_resource_nodes": [],
  "max_durability": 80,
  "recipe_id": "pickaxe",
  "max_material_tier": 3
}
```

Key fields:

| Field | Meaning |
|---|---|
| `family` / `tier` | Groups tiered tools (the Craft menu folds older tiers of a family). |
| `dig_speed_multiplier` / `gather_speed_multiplier` | Speed vs. bare hands. |
| `allowed_materials` | Material ids this tool may dig. Include exactly one tier of overreach. |
| `allowed_resource_nodes` | Surface node types (e.g. `"tree"`) it can harvest. |
| `max_durability` | Uses before it breaks. Use `-1` for infinite (only `hands`). |
| `recipe_id` | The recipe that crafts it (usually the same id). |
| `max_material_tier` | Highest normal material tier. Digging one tier above costs 4x durability (overreach); two tiers above is disallowed. |

Durability of `-1.0` means infinite — check `> 0` before drawing a durability bar.

## Adding the recipe

One entry in `data/recipes.json`, keyed by the recipe id. A tool recipe:

```json
"pickaxe": {
  "icon": "⛏️",
  "id": "pickaxe",
  "display_name": "Pickaxe",
  "category": "tools",
  "input_materials": {"wood": 3, "stone": 5},
  "output_id": "pickaxe",
  "output_amount": 1,
  "craft_time": 10.0,
  "required_layer": "stone",
  "unlocked_from_start": true
}
```

Fields:

| Field | Meaning |
|---|---|
| `category` | Which menu renders it (see the category rule below). |
| `input_materials` | `{material_id: amount}`; every id must exist, every amount `> 0`. |
| `output_id` | A material, tool, or machine id. |
| `output_amount` | Units produced per craft (`> 0`). |
| `craft_time` | Base seconds; effective time is divided by `balance.crafting.speed_multiplier`. |
| `required_layer` | Layer id the player must have reached (soft flavour gate). |
| `unlocked_from_start` | If `true`, unlocked immediately at run start. |

### The CraftMenu category rule

`scenes/ui/CraftMenu.gd` builds one collapsible group per category in `CATEGORY_ORDER` and **only** these four:

```gdscript
const CATEGORY_ORDER: Array = ["tools", "structures", "machines", "components"]
```

When it places a recipe it resolves the body by category and silently drops anything with no matching group:

```gdscript
var cat: String = recipe.get("category", "tools")
var body: VBoxContainer = _group_bodies.get(cat)
if body:
    _place_recipe(body, recipe_id, recipe)
```

So a recipe whose `category` is not one of those four **renders nowhere in the Craft menu**. Two more categories are valid but consumed elsewhere, not by CraftMenu:

| Category | Rendered by |
|---|---|
| `tools`, `structures`, `machines`, `components` | CraftMenu groups. |
| `processing` | Machine processing panels (run inside a machine, e.g. `glaze_stone`). |
| `village` | BuildMenu storage structures (e.g. `craft_well`, `craft_food_silo`). |

`tools/validate_data.gd` enforces exactly this set — any other `category` value is flagged as having "no consumer":

```gdscript
const VALID_RECIPE_CATEGORIES: Array[String] = [
    "tools", "structures", "machines", "components", "processing", "village",
]
```

### How recipes unlock

`scripts/core/CraftingUnlockManager.gd` gates visibility. A recipe unlocks when **every one of its `input_materials` has been seen at least once**. "Seen" means the material passed through inventory — `EventBus.inventory_changed` marks it via `_mark_seen()`, and `_check_unlocks()` then unlocks any recipe whose inputs are all seen:

```gdscript
var inputs: Dictionary = def.get("input_materials", {})
var all_seen := true
for mat_id: String in inputs.keys():
    if not seen_materials.has(mat_id):
        all_seen = false
        break
if all_seen and _astrolabe_gate_open(def):
    unlocked_recipes[recipe_id] = true
    EventBus.recipe_unlocked.emit(recipe_id)
```

Practical consequences when adding a recipe:

- If you want it available immediately, set `"unlocked_from_start": true` (handled by `_apply_start_unlocks()`).
- Otherwise the player must have obtained each input material at least once before the recipe appears.
- Two extra gates exist: `unlock_requires_machine` (unlocks when that machine type is placed, via `_on_machine_placed`) and `unlock_requires_astrolabe_uses` (a positive ritual-count gate). Do **not** combine `unlock_requires_astrolabe_uses` with `unlocked_from_start` — the start-unlock path never checks the ritual gate, so it would silently bypass it (the validator fails this combination).

## Validate

After editing any `data/*.json`, run the data validator (it checks recipe categories, that input/output ids resolve to real materials/tools/machines, positive amounts, and BOMs):

```text
<godot console exe> --headless --path . --script res://tools/validate_data.gd
```

It passes when the last line is `DATA OK`. For a new material/tool/recipe it will catch a mistyped input id, a missing `output_id`, a non-positive amount, or an unknown category.

Then run the smoke test to confirm the save round-trip and offline sim still hold over the real autoload stack:

```text
printf '\n[autoload]\n\nZZSmokeTest="*res://tools/smoke_test.gd"\n' >> project.godot
<godot console exe> --headless --path .        # expect SMOKE PASS
git checkout -- project.godot
```

> The parse-check (`tools\parse_check.ps1`) only compiles scripts, so it will not catch data errors — always run `validate_data.gd` after a JSON edit.

## Key files

| File | Role |
|---|---|
| `data/materials.json` | Material definitions. |
| `data/tools.json` | Tool definitions (durability, allowed materials, tiers). |
| `data/recipes.json` | Recipe definitions (inputs, output, category). |
| `scripts/resources/DataRegistry.gd` | Loads all data files; `get_material/tool/recipe`. |
| `scripts/core/CraftingUnlockManager.gd` | Unlocks recipes once all input materials are seen. |
| `scenes/ui/CraftMenu.gd` | Renders `tools/structures/machines/components` groups only. |
| `tools/validate_data.gd` | Data validator (categories, id references, amounts, BOMs). |

## Related

- [Adding a Machine](/docs/underroot/adding-a-machine)
- [DataRegistry and the Data Files](/docs/underroot/dataregistry-and-the-data-files)
- [Data Schemas Reference](/docs/underroot/data-schemas-reference)
- [Verification and CI](/docs/underroot/verification-and-ci)