# The Artificer's Harrow

The Artificer's Harrow is Underroot's **design-your-own challenge** — the seventh Challenge card. Instead of picking a pre-built modifier, the player turns a set of severity dials and optionally folds in the built-in Challenges, producing a custom run configuration that can be shared as a code. It is implemented in `scripts/core/HarrowManager.gd`; its effect math is applied in `scripts/core/ChallengeManager.gd`.

> Relationship to the Astrolabe: the Harrow is *unlocked* through endgame progression (the Artificer is the fourth Astrolabe specialist), but mechanically it belongs to the Challenge system, not the ritual. See [The Astrolabe](/docs/underroot/the-astrolabe-and-harrow) for the ritual device and [Challenges](/docs/underroot/challenges) for the built-in modifiers it composes with.

## Unlocking the Harrow

The Harrow is account-wide and gated behind mastering every built-in Challenge — that is, owning all six Challenge mastery trophies (`CosmeticManager.CHALLENGE_TROPHY`):

```gdscript
func is_unlocked() -> bool:
	for id: String in CosmeticManager.CHALLENGE_TROPHY.keys():
		if not CosmeticManager.is_owned(str(CosmeticManager.CHALLENGE_TROPHY[id])):
			return false
	return true
```

Because the gate keys off cosmetic ownership, it survives run resets and save restores like any other account unlock. When the sixth trophy lands, `_maybe_announce_unseal()` (connected to `EventBus.challenge_mastered`) fires a one-time toast in the Artificer's orange accent, guarded by `SaveManager.harrow_announced()` so it never repeats.

## Anatomy of a Harrow config

A Harrow configuration is a plain dictionary:

```text
{harrow: true, version, name, author, includes: [...challenge_ids], dials: {dial_id: t}}
```

- `harrow: true` — payload self-declaration, so the config can be routed like any other shareable code.
- `name` / `author` — free text, each capped at `NAME_MAX_LEN` (24 chars).
- `includes` — ids of built-in Challenges (from `ChallengeManager.ORDER`) whose full effects stack on top of the dials.
- `dials` — `dial_id -> t`, where severity `t` is in `(0, 1]`. A dial at `t = 0` is baseline and is omitted entirely.

The active config for a run lives in `GameManager.current_run["harrow"]`. `HarrowManager` owns the dial catalog, the unlock gate, config hygiene, and the codes; **the effect math lives in `ChallengeManager`** — the Harrow only supplies severity values.

## The dial catalog

`DIALS` defines **twelve** dials, grouped into three clusters (`CLUSTERS`) that mirror the Harrow screen. Every dial is *harder-only by construction*: `t = 0` is always the baseline, and turning it toward `t = 1` only ever makes the run worse. Each dial has a `kind`:

- **`mult`** — `dial_value()` lerps `base → worst` by `t`; the result multiplies the matching Challenge lever.
- **`abs`** — an absolute value (a duration or a headcount) consumed via `lerp`/`min` at its effect site.
- **`composite`** — one named idea driving several knobs; consumers read `dial_t()` and apply their own lerps.

### Cluster 1 — The Maw and the World

| Dial | Name | Kind | Range (base → worst) | What it adjusts |
|---|---|---|---|---|
| `grace` | The Waking | abs | 360 s → 30 s | Grace period before the Maw first stirs (`maw_grace_period`). |
| `hunger` | The Hunger | mult | ×1.0 → ×4.0 | Long-term pressure escalation rate (`pressure_growth_mult`). At full turn it also lifts the pressure ceiling by up to +2.0 (`pressure_max_bonus`, ×5 → ×7). |
| `learning` | The Learning | mult | ×1.0 → ×3.0 | How fast the Maw grows familiar with materials it eats (`maw_learning_mult`). |
| `ritual_fury` | The Reckoning | mult | ×1.0 → ×2.5 | Permanent chew added per Astrolabe ritual (`ritual_chew_mult`). |
| `surges` | The Frenzy | composite | +0% → +100% | Surge frequency (`surge_interval_mult` ×1 → ×0.3) and surge strength (`surge_boost_mult` ×1 → ×1.667). |
| `sky` | The Sky Turns | composite | +0% → +100% | Storm-time Maw speed (`maw_storm_mult` ×1 → ×1.75), rain frequency (`rain_interval_mult` ×1 → ×0.1) and storm frequency (`storm_interval_mult` ×1 → ×0.2). |

### Cluster 2 — Your Works Fail You

| Dial | Name | Kind | Range (base → worst) | What it adjusts |
|---|---|---|---|---|
| `tools` | Green Iron | mult | ×1.0 → ×0.4 | Tool durability (`tool_durability_mult`, floored at `_FLOOR_DURABILITY`). |
| `walls` | Bad Mortar | mult | ×1.0 → ×0.4 | Wall HP (`wall_hp_mult`, floored at `_FLOOR_WALL_HP`). |

### Cluster 3 — The Village Is Fragile

| Dial | Name | Kind | Range (base → worst) | What it adjusts |
|---|---|---|---|---|
| `souls` | The Few | abs | 47 → 1 | Starting population; stores thin along with it (applied at run start). |
| `appetite` | The Appetite | mult | ×1.0 → ×2.0 | Food and water drain per villager (`food_drain_mult`, `water_drain_mult`). |
| `cold` | The Cold | mult | ×1.0 → ×2.0 | Shelter fuel burn rate (`shelter_fuel_mult`). |
| `meager` | Meager Earth | composite | +0% → +100% | Gathering-bell interval (`bell_interval_mult` ×1 → ×2), berries per pick (down to 1), and pond seep-back (`pond_regen_mult` ×1 → ×0.25). |

## How a dial becomes an effect

Every effect site in `ChallengeManager` folds its built-in Challenge value together with the matching Harrow dial, so a Harrow run and a Challenge run flow through exactly one code path. The pattern (from `ChallengeManager`):

```gdscript
func tool_durability_mult() -> float:
	var m := 0.5 if is_active("brittle_world") else 1.0
	return maxf(m * HarrowManager.dial_value("tools"), _FLOOR_DURABILITY)

func pressure_growth_mult() -> float:
	var m := 3.0 if is_active("ravenous_maw") else 1.0
	return minf(m * HarrowManager.dial_value("hunger"), _CAP_PRESSURE)
```

`dial_value(id)` returns `lerp(base, worst, t)` for `mult`/`abs` dials (or the untouched `base` when the dial is at zero, which makes it the identity). `composite` dials expose `dial_t(id)` instead, and each consumer lerps its own knob — that is how one dial (`meager`, `sky`, `surges`) drives several levers at once. The `souls` dial is special: it has no per-tick accessor and is applied imperatively in `apply_run_start()`, which runs before Black Rot's population-derived root buffer so the reduced headcount propagates correctly.

Because dials multiply on top of Challenge multipliers and every site clamps to a floor or cap (`_FLOOR_DURABILITY`, `_CAP_PRESSURE`, …), a maxed-out Harrow that also `includes` the matching Challenge stays punishing but never degenerates into divide-by-zero territory.

## Including built-in Challenges

Beyond the dials, a config's `includes` array names built-in Challenges whose full mechanical effects stack on the run. `sanitize()` filters these against `ChallengeManager.ORDER` and de-duplicates them, so only real Challenge ids survive. In effect a Harrow can start from, say, Two Fronts and then push individual dials past what that Challenge alone would do.

## Config hygiene and anti-cheat

`sanitize(cfg)` is the single funnel every external config passes through — redemption codes, the design library, and the Harrow UI all call it. It:

- rejects anything that is not a `harrow: true` payload (returns `{}`),
- clamps every severity into `(0, 1]` and drops non-numeric or sub-baseline (`t <= 0`) dials,
- filters and de-duplicates `includes` against `ChallengeManager.ORDER`,
- caps `name` / `author` at `NAME_MAX_LEN`, and
- returns `{}` when the result configures nothing (no dials and no includes).

> Because sanitization is harder-only, a hand-tampered code cannot sneak a dial *below* baseline to gain an advantage and still post scores — the clamp discards it.

## Sharing: Harrow codes

A config round-trips as a `UROOT1-` code (the same encoding as save codes: `JSON → UTF-8 → deflate → base64`, prefixed with `SaveManager.SAVE_CODE_PREFIX`):

```gdscript
func export_code(cfg: Dictionary) -> String:
	var clean := sanitize(cfg)
	if clean.is_empty():
		return ""
	var compressed := JSON.stringify(clean).to_utf8_buffer().compress(FileAccess.COMPRESSION_DEFLATE)
	return SaveManager.SAVE_CODE_PREFIX + Marshalls.raw_to_base64(compressed)
```

`decode_code(code)` reverses it and passes the result back through `sanitize()`, so a malformed or non-Harrow payload decodes to `{}`.

## Applying a Harrow to a run

The Harrow screen's **Begin** button stages the chosen config on `HarrowManager.pending_config`. `GameManager.reset_for_new_run()` reads it into `current_run["harrow"]` and then clears it — mirroring how `ChallengeManager.pending_selection` becomes `current_run["challenges"]`. From that point `is_harrow_run()`, `dial_t()`, and `dial_value()` all read the active run's config.

## Verification

`tools/harrow_check.gd` is a headless logic gate, run as a temporary autoload (`ZZHarrowCheck`). It exercises `sanitize` (tampered payloads dropped, over-range clamped, sub-baseline discarded), code round-tripping, the active-config queries, `ChallengeManager` effect stacking (dial × included Challenge, then floors/caps), and the run-start `souls` scaling. It prints `HARROW OK` / `HARROW FAIL` and quits with a matching exit code.

## Key files

| File | Role |
|---|---|
| `scripts/core/HarrowManager.gd` | Dial catalog (`DIALS`, `CLUSTERS`), unlock gate, `sanitize`/`export_code`/`decode_code`, `pending_config`. |
| `scripts/core/ChallengeManager.gd` | The effect sites that fold each dial into its lever. |
| `scripts/core/CosmeticManager.gd` | `CHALLENGE_TROPHY` ownership — the unlock gate. |
| `scenes/ui/HarrowScreen.gd` | The dial UI; stages `pending_config` on Begin. |
| `tools/harrow_check.gd` | Headless Harrow logic gate (`HARROW OK` / `HARROW FAIL`). |

## Related

- [Challenges](/docs/underroot/challenges)
- [The Astrolabe](/docs/underroot/the-astrolabe-and-harrow)
- [Cosmetics and Digger Forms](/docs/underroot/cosmetics-and-digger-forms)
- [The Maw](/docs/underroot/the-maw)
- [Adding a Challenge](/docs/underroot/adding-a-challenge)