# Adding a Machine

A placeable machine — processor, miner, or fixed structure — is defined in `data/machines.json` and given a build recipe. `scripts/machines/MachineManager.gd` registers its footprint and enforces its placement cap.

## The machine definition

One entry in `data/machines.json`, keyed by the machine type id. `DataRegistry.get_machine_def(type)` returns it (`{}` on miss). Two shapes — a fuel-burning miner and a recipe-driven processor:

```json
"drill": {
  "id": "drill",
  "family": "drill",
  "tier": 1,
  "display_name": "Drill",
  "description": "Drops onto a solid tile and burns fuel to dig downward...",
  "work_rate": 5.0,
  "max_fuel": 500.0,
  "footprint_w": 1,
  "footprint_h": 1,
  "is_processor": false,
  "durability_min": 50,
  "durability_max": 50,
  "max_placed": 3
},
"smelter": {
  "id": "smelter",
  "display_name": "Smelter",
  "description": "Smelts raw iron ore into refined iron bars.",
  "footprint_w": 3,
  "footprint_h": 3,
  "is_processor": true,
  "recipe_id": "smelt_iron",
  "work_rate": 0.0,
  "max_fuel": 0.0,
  "durability_min": 40,
  "durability_max": 60,
  "repair_cost": 25
}
```

Core fields:

| Field | Meaning |
|---|---|
| `id` | Must match the JSON key. |
| `display_name` / `description` | Player-facing text. |
| `footprint_w` / `footprint_h` | Tile footprint (drills are 1x1, processors 3x3). |
| `is_processor` | `true` = runs a recipe; consumes `recipe_id` inputs, emits `output_id`. |
| `recipe_id` | For processors: the recipe run each cycle (its `category` should be `processing`). |
| `work_rate` | For miners: tile-HP damage per second. |
| `max_fuel` | Fuel tank size in seconds; `0.0` for machines that carry no fuel. |
| `durability_min` / `durability_max` | Range for `_roll_break_at()`; both `0` means no durability (e.g. the drill's fixed life comes from its own count, structures have `0`). |
| `repair_cost` | Gold cost to repair when broken (`repair_machine`). |
| `max_placed` | Cap on simultaneous placed copies. See the fallback below. |

Optional/specialised fields seen in the data: `is_structure` (Astrolabe, binding stake), `is_pump` + `requires_well` + `lifespan_days` + `water_output` (pump), `is_lodge` + hunt tuning (hunting lodge), `is_explosive` (dynamite, mining charge), `dig_width` + `directional` (excavators), `process_section` and `forest_zone_max_x` (surface buildings), `selectable_recipes` (glazing kiln), and per-machine upgrade arrays (`sawmill_upgrades`, `lodge_upgrades`, `dose_levels`).

## The build recipe

A machine still needs a recipe to be craftable. Add it to `data/recipes.json` with `category: "machines"` (so CraftMenu renders it under MACHINES) and `output_id` set to the machine type:

```json
"craft_apothecary": {
  "display_name": "Build: Apothecary",
  "category": "machines",
  "input_materials": {"wood": 25, "stone": 15, "quartz": 4, "ancient_clay": 1},
  "output_id": "apothecary",
  "output_amount": 1,
  "craft_time": 30.0,
  "unlock_requires_astrolabe_uses": 2
}
```

The validator accepts a machine id as a recipe `output_id`, so `output_id` may point at your new `machines.json` entry.

## Footprint registration

`MachineManager` keeps a spatial index, `_occupied_tiles` (`Vector2i → machine_id`), in lockstep with the `machines` dict so placement checks are O(footprint) instead of scanning every machine. Every place, remove, relocate, and load path must keep them in sync — this is done for you by the manager's helpers:

```gdscript
func _register_machine(machine: MachineData) -> void:
    var fp := oriented_footprint(machine.machine_type, machine.dig_direction)
    for dy in fp.y:
        for dx in fp.x:
            _occupied_tiles[machine.position + Vector2i(dx, dy)] = machine.id
```

Placement clearance uses `is_footprint_clear()` (terrain empty and unoccupied). Directional machines (only `steam_excavator` today) rotate their footprint via `oriented_footprint()`. If your machine is a standard processor/miner/structure, the existing `place_machine()` / `place_static_machine()` / `relocate_machine()` / `load_from_save()` paths register it correctly with no code changes.

## Placement cap and the fallback

`max_placed` limits how many copies can exist at once. There are two enforcement layers, and they resolve the cap differently:

- `MachineManager.place_machine()` reads the raw value and only enforces a cap when it is `> 0`:

```gdscript
var max_placed: int = int(def0.get("max_placed", 0))
if max_placed > 0:
    ... # count active copies, reject if at cap
```

- The controller and craft UI apply a **default fallback** so a fixed installation that omits `max_placed` can never read as unlimited. `scenes/world/MachineController.gd` resolves it:

```gdscript
func _placement_cap(def: Dictionary) -> int:
    var mp: int = int(def.get("max_placed", 0))
    return mp if mp > 0 else GameConstants.MACHINE_DEFAULT_MAX_PLACED
```

`GameConstants.MACHINE_DEFAULT_MAX_PLACED` is `2`, and `CraftMenu.MACHINE_MAX_OWNED` resolves to the same constant so the craft gate and the placement guard agree.

> The fallback is applied at the controller/UI layer for processors, structures, and surface buildings — not inside `MachineManager.place_machine()` itself (which treats a missing `max_placed` as "no cap"). For a fixed installation, either set an explicit `max_placed`, or rely on the controller path that supplies the default of `2`. Miners like the drill set an explicit `max_placed` (3) and are placed through `place_machine()`.

## Validate

After editing `data/machines.json` (and the recipe), run the validator. It checks the recipe's category and that its inputs/`output_id` resolve to real ids; the apothecary def has dedicated checks (fuel materials, dose tiers) if you touch it:

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

Expect `DATA OK`. Then run the smoke test to confirm machines survive a save round-trip and the offline sim (`MachineManager.load_from_save` rebuilds `_occupied_tiles`, and `simulate_offline` ticks fuel machines):

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

Placement feel, footprint visuals, and processing panels still need a visual check in the editor.

## Key files

| File | Role |
|---|---|
| `data/machines.json` | Machine type definitions. |
| `scripts/machines/MachineManager.gd` | `machines` dict + `_occupied_tiles`; place/register/relocate/load/process. |
| `scripts/machines/MachineData.gd` | Per-instance runtime state (`create`, `to_dict`, `from_dict`). |
| `scenes/world/MachineController.gd` | Placement input, `_placement_cap()` fallback, panels. |
| `scripts/core/GameConstants.gd` | `MACHINE_DEFAULT_MAX_PLACED = 2`. |
| `tools/validate_data.gd` | Data validator. |

## Related

- [Adding Materials Tools and Recipes](/docs/underroot/adding-materials-tools-and-recipes)
- [Machines](/docs/underroot/machines)
- [DataRegistry and the Data Files](/docs/underroot/dataregistry-and-the-data-files)
- [Verification and CI](/docs/underroot/verification-and-ci)