# Adding a Cosmetic

Cosmetics are account-wide, purely visual (or lightly gameplay-modifying) items for the player's digger. They live in `data/cosmetics.json`, are owned via `scripts/core/CosmeticManager.gd`, and persist in `config.json` so they survive slot deletion. Adding one covers the item entry, its unlock source, digger forms, hiding unearned secrets, and tying it to a redemption code.

## The cosmetics file

`data/cosmetics.json` has three sections: `slot_order`, `items`, and `dyes`. A cosmetic item is one entry in `items`, keyed by its id. Examples:

```json
"head_ironhelm": {"slot": "headwear", "name": "Iron Helm", "kind": "style",
                  "source": "milestone", "trigger": "iron_pickaxe"},
"boots_furcuff": {"slot": "boots", "name": "Fur-cuffed Boots", "kind": "style",
                  "source": "discovery", "effect": {"dig_speed_mult": 1.1}},
"form_dave":     {"slot": "form", "name": "The Dave", "kind": "style",
                  "source": "secret", "hidden_until_owned": true,
                  "effect": {"food_drain_mult": 0.6, "water_drain_mult": 0.6}}
```

Item fields:

| Field | Meaning |
|---|---|
| `slot` | One of `slot_order`: `form`, `skin`, `headwear`, `hair`, `beard`, `tunic`, `boots`, `extra`. |
| `name` | Player-facing name. Name the *kind* of thing, not just the adjective — the shop row and the customizer tile both print this string raw, so "Berry-picking Gloves" reads where a bare "Gloves" did not. |
| `kind` | `color`, `style`, or `style_color` (a style that also carries a `color`). |
| `source` | How it is obtained: `free`, `milestone`, `discovery`, `scout`, `wager`, or `secret`. |
| `default` | `true` marks the humble starting variant for its slot (one per slot). |
| `trigger` | For `milestone` items: the milestone id that unlocks it (e.g. `iron_pickaxe`, `astrolabe`, `mastery:black_rot`). |
| `color` | Hex colour for `color` / `style_color` kinds. |
| `effect` | Optional gameplay-modifier bag (see below). |
| `hidden_until_owned` | If `true`, the customizer hides the item until it is owned. |

`free` items are always considered owned. Every other source must be granted through `CosmeticManager` before `is_owned()` returns true:

```gdscript
func is_owned(item_id: String) -> bool:
    var def := DataRegistry.get_cosmetic(item_id)
    if str(def.get("source", "")) == "free":
        return true
    return item_id in owned
```

## The effect bag

A cosmetic may carry gameplay modifiers in a generic `effect` dictionary. Worn items contribute multiplicatively per key via `get_effect_mult()`, and proc-style chances via `get_effect_chance()`:

```gdscript
func get_effect_mult(key: String) -> float:
    var product := 1.0
    for slot: String in DataRegistry.get_cosmetic_slot_order():
        var item_id: String = str(loadout.get(slot, ""))
        if item_id.is_empty():
            continue
        var eff: Dictionary = DataRegistry.get_cosmetic(item_id).get("effect", {})
        product *= float(eff.get(key, 1.0))
    return product
```

Keys in use include `dig_speed_mult`, `food_drain_mult`, `water_drain_mult`, `wager_luck_mult`, `berry_speed_mult`, `berry_yield_mult`, `shelter_fuel_mult`, `maw_learn_mult`, `chest_gold_mult`, and the proc key `ward_bad_ingest_chance`. One item per slot is the natural cap on stacking.

A new key needs **two** wirings, and it is silently inert without either:

1. **A hook site that reads it.** Reuse an existing key so an existing site already consults it, or add a `CosmeticManager.get_effect_mult("your_key")` read at the value's source.
2. **A line in `CosmeticManager.describe_effect()`.** That function turns the effect bag into the player-facing one-liners ("+60% berry picking"), and every surface that advertises a cosmetic — the Your Digger tiles, Wren's wares board — words the trait from it. A key with no line there works but is invisible to the player, which for a paid ware means buying blind.

**Watch what a multiplier actually multiplies.** `berry_yield_mult` scales the food *granted* by a bush harvest and deliberately leaves the berries *deducted* alone: a bush is a 20-berry stock that then rests a full day, so scaling the deduction would strip bushes faster for the same total and the trait would be worth nothing. Where a resource has a stock and a regrow timer, a yield bonus must land on the payout side.

## Granting ownership

All grants route through `unlock()`, which is idempotent (a no-op once owned) and fires a toast:

```gdscript
func unlock(item_id: String) -> bool:
    if is_owned(item_id):
        return false
    if not _grant(item_id):
        return false
    var display_name := str(DataRegistry.get_cosmetic(item_id).get("name", item_id))
    EventBus.show_toast.emit("New cosmetic unlocked: %s" % display_name, Color(0.95, 0.82, 0.30), 4.0)
    return true
```

`_grant()` appends to `owned`, persists via `SaveManager.set_cosmetics_owned()`, and emits `EventBus.cosmetic_unlocked`. Use `grant_reward()` instead of `unlock()` when the caller wants to own the celebration (no generic toast).

Wire your unlock to a trigger:

- **Milestone** — add a hook in `_connect_milestones()` or one of the milestone handlers. For example, the iron helm unlocks when the iron pickaxe is crafted:

```gdscript
ToolState.tool_unlocked.connect(func(tool_id: String) -> void:
    if tool_id == "iron_pickaxe":
        unlock("head_ironhelm"))
```

- **Discovery (dig find)** — add the item id to `reseed_dig_finds()`; it is hidden on a random tile each run and unlocked when that tile is dug:

```gdscript
for id: String in ["boots_furcuff"]:   # discovery items in the items table
    if not is_owned(id):
        _dig_seeds[id] = _random_dig_tile()
```

- **Challenge mastery** — map the Challenge id to your cosmetic in `CHALLENGE_TROPHY`; performing the Ritual with that Challenge active grants it.

- **Scout purchase** — add the id to `trades.json → cosmetic_offers` with a `gold_cost`. The wares row prints the item's `name` and, beneath it, the `describe_effect` lines, so a bought cosmetic explains itself at the point of sale. See [The Villager Economy](/docs/underroot/the-villager-economy).

## Digger forms

The `form` slot is a full-body reskin (`The Maw-Eaten`, `The Axel`, `The Dave`, `The Hugo`). A form is just an `items` entry with `"slot": "form"`. `form_none` is the `default`. Forms commonly carry an `effect` bag (e.g. `form_hugo` grants `dig_speed_mult: 1.4`). Secret forms use `"source": "secret"` and `"hidden_until_owned": true` so they never show in the customizer until redeemed.

> A fresh digger starts bare-headed with no extra, even if the account owns such gear — `reset_run_equipment()` resets only the `headwear` and `extra` slots to their defaults on a new game (lineage successors keep their look). Face/body slots and the equipped `form` persist.

## Tying a cosmetic to a code

A redemption code can grant a cosmetic. In `data/codes.json` the reward block's `cosmetics` array lists item ids, and `CodeManager` routes each through `CosmeticManager.unlock()`:

```gdscript
var cosmetic_rewards: Array = rewards.get("cosmetics", [])
for cosmetic_id: String in cosmetic_rewards:
    var cname := str(DataRegistry.get_cosmetic(cosmetic_id).get("name", cosmetic_id))
    if CosmeticManager.unlock(cosmetic_id):
        summary.append(cname)
```

Because cosmetics are account-unique and `unlock()` is idempotent, a code that gains a new cosmetic later can be re-entered to back-fill only the un-owned one (`_grant_new_cosmetics`), even after it was already redeemed — without re-granting its farmable tools/materials. See the redemption-code guide for the full flow. The validator checks that every cosmetic id in a code reward exists in `cosmetics.json → items`.

## Validate

After editing `data/cosmetics.json`, run the data validator (it checks code rewards reference real cosmetic ids, among other things):

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

Expect `DATA OK`. If you added an unlock hook in `CosmeticManager.gd`, also run the parse-check:

```text
powershell -ExecutionPolicy Bypass -File tools\parse_check.ps1
```

Parse-check only fully analyses autoloads. A cosmetic touching a scene script (the customizer, the wares board) needs the temp-autoload compile harness — add the script to `tools/tmp_compile_check.gd` and run it headless.

Then the smoke test for the config round-trip:

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

**Restore `project.godot` by copying the backup back, never with `git checkout --`.** That file carries uncommitted local tweaks, and a checkout discards them. Restore it immediately after the run either way: a temp autoload left in place gets baked into an export and quits the game at boot.

The digger render (how the cosmetic actually looks) needs an in-editor pass.

## Key files

| File | Role |
|---|---|
| `data/cosmetics.json` | `slot_order`, `items`, `dyes`. |
| `scripts/core/CosmeticManager.gd` | Ownership, loadout, `unlock()`, effect aggregation, `describe_effect()` wording, dig-find seeding, milestone hooks. |
| `scripts/resources/DataRegistry.gd` | `get_cosmetic`, `get_cosmetic_dye`, `get_cosmetic_slot_order`. |
| `scripts/core/CodeManager.gd` | Grants cosmetics from redemption codes. |
| `tools/validate_data.gd` | Verifies code cosmetic ids exist. |

## Related

- [Cosmetics and Digger Forms](/docs/underroot/cosmetics-and-digger-forms)
- [The Villager Economy](/docs/underroot/the-villager-economy)
- [Adding a Redemption Code](/docs/underroot/adding-a-redemption-code)
- [Adding a Challenge](/docs/underroot/adding-a-challenge)
- [Save System and Migration](/docs/underroot/save-system-and-migration)
