# DataRegistry and the Data Files

`DataRegistry` loads every `data/*.json` file into memory at startup and exposes
typed lookup helpers. It's the single read path for tuning data — materials,
tools, recipes, machines, layers, discoveries, balance, trades, buildings, codes,
and cosmetics — so nothing else in the codebase opens a JSON file directly.

## What DataRegistry loads

`scripts/resources/DataRegistry.gd` (`extends Node`) reads each file in `_ready()`
via `_load_all_data()`. The results are held in plain dictionaries and arrays:

| Field | Source file | Shape |
|---|---|---|
| `materials` | `data/materials.json` | `id -> Dictionary` |
| `layers` | `data/layers.json` | `Array` (the file's `layers` array) |
| `recipes` | `data/recipes.json` | `id -> Dictionary` |
| `tools` | `data/tools.json` | `id -> Dictionary` |
| `machine_defs` | `data/machines.json` | `type -> Dictionary` |
| `discoveries` | `data/discoveries.json` | `id -> Dictionary` |
| `balance` | `data/balance.json` | section `-> Dictionary` |
| `trades` | `data/trades.json` | villager visit economy |
| `buildings` | `data/buildings.json` | bell building defs |
| `codes` | `data/codes.json` | SHA-256 hash `-> Dictionary` |
| `cosmetics` | `data/cosmetics.json` | `items`, `dyes`, `slot_order` |

`layers.json` is the one file whose top level is an object wrapping an array —
`DataRegistry` unwraps it: `layers = layer_data.get("layers", [])`. The
`_comment` meta key is stripped from `buildings`, `codes`, and `cosmetics` after
load so consumers never iterate a fake entry.

Loading is defensive. `_load_json()` returns `{}` (not a crash) if a file is
missing, cannot be opened, or fails to parse, pushing a warning or error instead:

```gdscript
func _load_json(path: String) -> Dictionary:
    if not FileAccess.file_exists(path):
        push_warning("[DataRegistry] File not found: %s" % path)
        return {}
    ...
    if err != OK:
        push_error("[DataRegistry] Parse error in %s: %s" % [path, json.get_error_message()])
        return {}
    return json.data
```

## The lookup API

Every getter returns a `Dictionary` (or the collection type), and every by-id
getter returns `{}` on a miss rather than null or an error:

```gdscript
DataRegistry.get_material(material_id)   # -> Dictionary  ({} on miss)
DataRegistry.get_tool(tool_id)           # -> Dictionary  ({} on miss)
DataRegistry.get_recipe(recipe_id)       # -> Dictionary  ({} on miss)
DataRegistry.get_machine_def(type)       # -> Dictionary  ({} on miss)
DataRegistry.get_discovery_def(id)       # -> Dictionary  ({} on miss)
```

Additional helpers layer computed or nested lookups on top:

| Method | Returns | Notes |
|---|---|---|
| `get_code(code_id)` | `Dictionary` | Hashes the trimmed, uppercased input to SHA-256 before lookup — plaintext codes never appear in `codes.json`. |
| `get_craft_duration(recipe_id)` | `float` | `craft_time` (default `5.0`) divided by `balance.crafting.speed_multiplier`. |
| `get_layer_at_depth(depth)` | `Dictionary` | Scans `layers` for the band whose `depth_start`/`depth_end` contains `depth`. |
| `get_layer_id_at_depth(depth)` | `String` | `id` of that layer, `""` if none. |
| `get_cosmetic(item_id)` | `Dictionary` | Reads from `cosmetics.items`. |
| `get_cosmetic_dye(dye_id)` | `Dictionary` | Reads from `cosmetics.dyes`. |
| `get_cosmetic_slot_order()` | `Array` | The `slot_order` array. |
| `get_cosmetics_for_slot(slot)` | `Array` | Items matching `slot`, each stamped with its `id`. |
| `get_default_cosmetic_for_slot(slot)` | `String` | The `id` of the slot's `default: true` item. |

## The "empty on miss" contract

Because getters return `{}` on a miss, callers must always read fields through
`.get("key", default)` — indexing a missing key on an empty dictionary would
error. This is the standard pattern across the codebase:

```gdscript
var mat := DataRegistry.get_material(mat_id)
var hp: float = float(mat.get("terrain_hp", 10.0))
var dn: String = str(mat.get("display_name", mat_id))
```

The typed wrapper classes follow the same rule internally. `MaterialDef`,
`ToolDef`, and `RecipeDef` (in `scripts/resources/`) each expose a
`static func from_dict(d: Dictionary)` that pulls fields with `.get()` and a
default, so a partial JSON row still produces a valid object:

```gdscript
static func from_dict(d: Dictionary) -> MaterialDef:
    var m := MaterialDef.new()
    m.id = d.get("id", "")
    m.dig_time = d.get("dig_time", 1.0)
    m.terrain_hp = d.get("terrain_hp", 10.0)
    ...
    return m
```

> These wrapper classes capture only a subset of each row's fields. Many live
> fields — `material_tier`, `trade_value`, `yield_weights`, a tool's
> `max_durability` and `max_material_tier`, a recipe's `category` and unlock
> gates — are read directly off the raw dictionary from `DataRegistry`, never
> through the `*Def` object. When in doubt, read the raw dictionary. See
> [Data Schemas Reference](/docs/underroot/data-schemas-reference).

## The Inventory API

`Inventory` (`scripts/resources/Inventory.gd`, `extends Node`) holds the run's
material counts in a single `resources: Dictionary` (`material_id -> int`) and is
the only place stock is mutated.

| Method | Signature | Behaviour |
|---|---|---|
| `get_amount(id)` | `(String) -> int` | Count of a material, `0` if absent. |
| `has_enough(id, n)` | `(String, int) -> bool` | `get_amount(id) >= n`. |
| `add(id, n, is_reclaim=false)` | `(String, int, bool) -> void` | Adds stock. Rejects negative amounts with an error. |
| `remove(id, n)` | `(String, int) -> bool` | Removes `n`; returns `false` and leaves stock untouched if insufficient. |
| `can_craft(recipe_id)` | `(String) -> bool` | True when every `input_materials` entry is satisfied. |

`add()` refuses negative amounts (a negative would bypass `remove()`'s
sufficiency check) and emits two signals: `EventBus.inventory_changed` always,
and `EventBus.inventory_added` only when `is_reclaim` is false — reclaimed
gravestone death-loot must not fire the "new gain" signal or lifetime gold stats
would double-count recovered stock. `remove()` short-circuits on
`has_enough()`:

```gdscript
func remove(material_id: String, amount: int) -> bool:
    if not has_enough(material_id, amount):
        return false
    resources[material_id] -= amount
    EventBus.inventory_changed.emit(material_id, resources[material_id])
    return true
```

`can_craft()` reads the recipe straight from `DataRegistry` and checks each
input:

```gdscript
func can_craft(recipe_id: String) -> bool:
    var recipe := DataRegistry.get_recipe(recipe_id)
    if recipe.is_empty():
        return false
    for material_id in recipe.input_materials:
        if not has_enough(material_id, recipe.input_materials[material_id]):
            return false
    return true
```

Save integration is direct: `get_save_data()` returns the `resources` dict, and
`load_save_data()` rebuilds it, emitting `inventory_changed` per material so
listeners repaint.

## Relationship to CraftingUnlockManager

`Inventory.can_craft()` answers "can I afford it right now"; whether a recipe is
*visible* at all is `CraftingUnlockManager`'s job
(`scripts/core/CraftingUnlockManager.gd`). It tracks `seen_materials` — every
material the run has ever received — and unlocks a recipe once **all** of its
`input_materials` have been seen at least once:

```gdscript
func _mark_seen(material_id: String) -> void:
    if seen_materials.has(material_id):
        return
    seen_materials[material_id] = true
    _check_unlocks()
```

It connects to `EventBus.inventory_changed` (so any stock touch marks a material
seen), `EventBus.machine_placed`, and `EventBus.astrolabe_activated`, then emits
`EventBus.recipe_unlocked`. Three recipe fields gate this beyond material sight:

- `unlocked_from_start: true` — granted unconditionally at run start via
  `_apply_start_unlocks()`.
- `unlock_requires_machine: "<machine_type>"` — unlocked when that machine is
  placed, not by materials.
- `unlock_requires_astrolabe_uses: N` — held back until the run's ritual count
  reaches `N` (checked via `AstrolabeManager.get_uses_count()`).

The flow is: `DataRegistry` supplies the recipe definitions,
`CraftingUnlockManager` decides which are unlocked, `Inventory.can_craft()`
decides which unlocked recipes are currently affordable.

## Key files

- `scripts/resources/DataRegistry.gd` — loads all `data/*.json`; typed lookup API.
- `scripts/resources/Inventory.gd` — run material counts; `add`/`remove`/`get_amount`/`can_craft`.
- `scripts/resources/MaterialDef.gd` — typed wrapper for a materials row (`from_dict`).
- `scripts/resources/ToolDef.gd` — typed wrapper for a tools row (`from_dict`, `can_dig`).
- `scripts/resources/RecipeDef.gd` — typed wrapper for a recipes row (`from_dict`).
- `scripts/core/CraftingUnlockManager.gd` — recipe visibility from seen materials, machines, and ritual count.

## Related

- [Data Schemas Reference](/docs/underroot/data-schemas-reference)
- [Adding Materials Tools and Recipes](/docs/underroot/adding-materials-tools-and-recipes)
- [Save System and Migration](/docs/underroot/save-system-and-migration)
- [EventBus and the Signal Convention](/docs/underroot/eventbus-and-the-signal-convention)
- [Verification and CI](/docs/underroot/verification-and-ci)