# Cosmetics and Digger Forms

Cosmetics are the customization layer for the digger: skin, hair, beard, headwear, tunic, boots, an extra slot, a class-signature slot, and a full-body "form". `scripts/core/CosmeticManager.gd` owns ownership and the equipped loadout, every unlock routes through `unlock()`, and `data/cosmetics.json` holds the registry. **Ownership is account-wide; the equipped loadout is per-run** (save v4 for the loadout split, v5 for the `class` slot — see below).

## Ownership in config.json, loadout in the run save

`CosmeticManager` caches account-level config state in memory. On `_ready()` it pulls two structures from `SaveManager`, backed by `config.json`:

```gdscript
var owned:   Array      = []   # ids the account has unlocked (account-wide)
var loadout: Dictionary = {}   # the new-run TEMPLATE (slot -> item id + colour keys)
```

- `owned` — `SaveManager.get_cosmetics_owned()` / `set_cosmetics_owned()`, stored under `cosmetics_owned`. Account-wide, survives slot deletion: cosmetics are permanent progression.
- `loadout` — since `SAVE_VERSION = 4` this is only the **new-run template**: the look a fresh run inherits. The active run's truth is `current_run["cosmetics_loadout"]`, reached exclusively through the private `_run_loadout()` accessor, which lazily seeds it from the template on first touch (new runs; migrated saves are seeded by `_migrate()`'s v4 step; the title screen's default run dict is harmless — it is rebuilt on play).

Every read goes through the run: `get_loadout()`, `get_effect_mult()`, and `get_effect_chance()` all resolve against `_run_loadout()`, so gameplay traits and rendering always reflect *this slot's* digger. One consequence: ledger score rows (`save_score`) capture the look of the run that died, not whatever another slot equipped since.

After a factory reset, `reload_from_config()` re-pulls `owned` and the template from disk; without it, the stale in-memory `owned` array would resurrect every erased cosmetic on the next write.

`_ensure_defaults()` fills any missing template slot with that slot's default variant (via `DataRegistry.get_default_cosmetic_for_slot(slot)`), plus the companion colour keys `hair_color`, `beard_color`, and `tunic_dye`, so a fresh or newly extended config always renders a complete digger.

## Ownership rules

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

Free items are always owned. Everything else must be in `owned`.

## All unlocks route through unlock()

Every grant path funnels through one private helper, `_grant(item_id)`, which appends to `owned`, persists via `SaveManager.set_cosmetics_owned()`, and emits `EventBus.cosmetic_unlocked`. It returns `false` if already owned (so grants are idempotent). Three public entry points wrap it:

| Method | Toast | Used by |
|---|---|---|
| `unlock(item_id)` | Generic "New cosmetic unlocked: NAME" | Milestones, dig finds, scout, code redemption |
| `grant_reward(item_id)` | None (caller owns the celebration) | Credits reward |
| `_grant(item_id)` (internal) | None | Callers with a tailored toast (`_unlock_diadem`, `_unlock_dye`, mastery, Maw-Eaten, class prestige) |

Because `unlock()` is a no-op once owned, re-entering a redemption code or re-hitting a milestone can never double-grant. This is what makes back-filling a cosmetic onto an already-redeemed code farm-safe (see the codes article).

### Milestone hooks

`_connect_milestones()` wires the account-wide unlocks to `EventBus` signals — cosmetics are never connected node-to-node:

| Trigger signal | Unlock |
|---|---|
| `astrolabe_activated` (1st) | `head_crown` (Astrolabe Crown) |
| `astrolabe_activated` (3rd use) | `head_diadem` via `_unlock_diadem()` |
| `maw_repelled` (second repel; first arms a latch) | `head_horned` |
| `population_milestone` | `extra_mantle` |
| `new_day` with `days_survived >= 1` | `head_clothcap` |
| `ToolState.tool_unlocked == "iron_pickaxe"` | `head_ironhelm` |
| `skill_leveled` (node 7, champion) | that class's `class_*_prestige` via `_on_skill_leveled()` |

### Challenge mastery trophies

`_check_challenge_mastery()`, called from `_on_astrolabe_activated()`, grants a trophy for each Challenge active when the Ritual fires. The mapping is `CHALLENGE_TROPHY`:

| Challenge id | Trophy cosmetic |
|---|---|
| `ravenous_maw` | `head_ravenous` |
| `brittle_world` | `head_crackhelm` |
| `black_rot` | `head_plaguemask` |
| `eye_of_the_storm` | `tunic_oilskin` |
| `two_fronts` | `boots_warmarch` |
| `lone_villager` | `extra_sash` |

Each trophy carries a gameplay `effect` (see below) and a one-line trait blurb in `TROPHY_TRAIT_TEXT`, read by the mastery cutscene. Newly earned trophies are emitted together via `EventBus.challenge_mastered`.

The ultimate reward is `form_maweaten` (The Maw-Eaten), granted by `_check_maw_eaten()` on `EventBus.player_died` when the player dies having held all six Challenges and performed at least one Ritual.

## Class cosmetics

The `class` slot (last in `slot_order`, additive to `extra`) holds the champion-class signature look — it never competes with the gloves/amulet/mantle in `extra`. `CosmeticManager` maps class and skill to the relevant items:

```gdscript
const _CLASS_SIGNATURE := {  # class -> signature (free)
	"miner": "class_ropecoil", "hunter": "class_hunterkit", "smith": "class_apron",
	"mason": "class_toolbelt", "chieftain": "class_pauldrons", "merchant": "class_moneybag",
}
const _CLASS_PRESTIGE := {   # skill -> prestige variant (champion peak)
	"digging": "class_ropecoil_prestige", "foraging": "class_hunterkit_prestige",
	"crafting": "class_apron_prestige", "building": "class_toolbelt_prestige",
	"population": "class_pauldrons_prestige", "trading": "class_moneybag_prestige",
}
```

- **Signature accessory** — one per class, `source: free` (always equippable, removable in the customizer): Miner's Rope, Hunter's Kit, Smith's Apron, Mason's Tool-Belt, Chieftain's Pauldrons, Merchant's Purse. `_class_signature()` resolves the current calling's id (or the `class` default for a classless run). It is auto-seeded on new game (`reset_run_equipment`) and re-pointed on a carry-on calling switch (`reseed_class_signature`, called from `GameManager.switch_calling`).
- **Prestige variant** — `class_*_prestige` (gilded + glow, `hidden_until_owned`, `trigger: champion:<class>`), granted **account-wide at champion peak**. `_on_skill_leveled(skill, node)` fires the `_grant` when `node >= 7` and `SkillManager.is_champion(skill)` (node 7 is champion-only, so the node check alone nearly implies it), with a tailored "Champion peak!" toast.

See [Skill Tree and Classes](/docs/underroot/skill-tree-and-classes) for the class system itself.

## Digger forms

The `form` slot is a full-body reskin, distinct from the piecewise slots — while a form is equipped it overrides the hat/garment/dye layers visually. Defined forms:

| id | Name | Source |
|---|---|---|
| `form_none` | None | `free` (default) |
| `form_maweaten` | The Maw-Eaten | `milestone` (all-six-Challenge mastery) |
| `form_axel` | The Axel | `secret` (code) |
| `form_dave` | The Dave | `secret` (code) |
| `form_hugo` | The Hugo | `secret` (code) |

Secret forms are granted through code redemption, which calls `CosmeticManager.unlock()`.

## Cosmetic effects

Cosmetics can carry a generic `effect` bag, and `CosmeticManager` aggregates it across every equipped slot **of the active run's loadout**. Two readers cover the two effect shapes:

```gdscript
func get_effect_mult(key: String) -> float   # product across slots; absent -> 1.0
func get_effect_chance(key: String) -> float  # max across slots; absent -> 0.0
```

`get_effect_mult()` multiplies each equipped item's `effect[key]` (used for `dig_speed_mult`, `food_drain_mult`, `shelter_fuel_mult`, `wager_luck_mult`, etc.). `get_effect_chance()` takes the max (used for proc-style traits like `ward_bad_ingest_chance`). Slot competition — one item per slot — is the natural cap on how many trait multipliers stack. The class signature and prestige items carry no `effect` — they are cosmetic only; the class's gameplay bonus comes from `SkillManager`, not the accessory.

Two named wrappers delegate to `get_effect_mult()` for their call sites, both of them the Berry-picking Gloves' traits:

- `get_berry_speed_mult()` — divides `BerryBush.GATHER_TIME` in `GatherController`, captured once when a pick starts so swapping gloves mid-pick cannot retroactively speed it up.
- `get_berry_yield_mult()` — scales the food a bush harvest **grants**, in `ForestManager.harvest_bush()`. It deliberately does not scale the berries deducted from the bush: 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. Food is a float on the supply bar, so the multiplier applies with no rounding loss. This is the trait that matters at scale — bush stock and regrowth are the real ceiling on berries, not pick speed.

`describe_effect(def)` turns an effect bag into player-facing one-liners ("+60% berry picking"). Every surface that advertises a cosmetic reads it — the Your Digger customizer tiles (in the hover tooltip) and Wren's wares board (as a visible line under the item name, since the point of sale must explain itself and the web build has no hover). A new effect key without a line there is invisible to the player even when the hook site honours it.

## Equipping and per-run reset

`equip(key, value)` writes the **run** loadout (the per-slot truth, persisted by the normal autosave), mirrors the value into the account template (so the next new run inherits the look), persists the template, and emits `EventBus.cosmetic_changed`.

On a new game, `reset_run_equipment()` (called from `GameManager.reset_for_new_run()`, new game only — lineage successors keep their look) resets four slots **in that run's loadout only**: `headwear`, `extra`, and `form` go bare (to their defaults), and `class` is seeded to the current calling's signature via `_class_signature()` (the class is already set by this point). A fresh digger starts bare-headed, without extras, without a suit form — a carried-over form would paint over everything the intro customizer applies — but wearing its calling's signature accessory. The account template and every other slot's look are untouched; face and body slots persist.

## data/cosmetics.json structure

The registry has three top-level keys:

```json
{
  "slot_order": ["form","skin","headwear","hair","beard","tunic","boots","extra","class"],
  "items": { "...": { } },
  "dyes":  { "...": { } }
}
```

`slot_order` (read by `DataRegistry.get_cosmetic_slot_order()`) drives both the customizer layout and the effect-aggregation loops. Each entry in `items` looks like:

```json
"boots_furcuff": {
  "slot": "boots",
  "name": "Fur-cuffed Boots",
  "kind": "style",
  "source": "discovery",
  "effect": {"dig_speed_mult": 1.1}
}
```

| Field | Meaning |
|---|---|
| `slot` | Which slot the item occupies (must be in `slot_order`) |
| `name` | Display name |
| `kind` | `color`, `style`, or `style_color` (whether it carries a colour value) |
| `source` | `free`, `milestone`, `discovery`, `scout`, `wager`, or `secret` |
| `default` | `true` marks the humble starting variant for its slot |
| `trigger` | For milestone items, the milestone key (e.g. `astrolabe3`, `mastery:black_rot`, `champion:miner`) |
| `effect` | Optional gameplay-modifier bag |
| `hidden_until_owned` | Optional; hides the item in the customizer until the account owns it |
| `color` | For colour/style_color items, the default hex |

`dyes` are simpler `{name, color, source, default}` entries for the tunic dye picker, read via `DataRegistry.get_cosmetic_dye()`.

### hidden_until_owned

`hidden_until_owned: true` keeps an item out of the customizer grid until it is owned. It is used for the secret forms (`form_axel`, `form_dave`, `form_hugo`), for `head_headlamp_gold`, and for the six `class_*_prestige` variants, so unredeemed secret cosmetics and un-earned prestige looks do not hint at their existence in the UI. It does not affect ownership or effects — only visibility.

## Single-tile dig finds

A handful of cosmetics are seeded into the world as luck-based digs. `reseed_dig_finds()` (called on `_ready()` and on `EventBus.generation_started`) assigns each un-owned discovery item a single random tile within `SEED_X_MIN..SEED_X_MAX` / `SEED_Y_MIN..SEED_Y_MAX`. The seeds are re-rolled each run and never persisted. On `EventBus.tile_dug`, if the dug tile matches a seed, `unlock()` (or `_unlock_dye()` for dye seeds) fires. Current seeds: `boots_furcuff`, plus the `moss` and `royal` dyes.

## Key files

| File | Role |
|---|---|
| `scripts/core/CosmeticManager.gd` | Ownership, run/template loadouts, unlock routing, milestone hooks, mastery, class signature + prestige, dig finds, effect readers + `describe_effect()` |
| `data/cosmetics.json` | Item and dye registry; `slot_order` (incl. `class`) |
| `scripts/resources/DataRegistry.gd` | `get_cosmetic`, `get_cosmetic_dye`, `get_cosmetic_slot_order`, `get_default_cosmetic_for_slot` |
| `scripts/core/SaveManager.gd` | Persists `cosmetics_owned` + the loadout template in `config.json`; v4 migration seeds `run.cosmetics_loadout` |

## Related

- [Codes and Redemption](/docs/underroot/codes-and-redemption)
- [Challenges](/docs/underroot/challenges)
- [Skill Tree and Classes](/docs/underroot/skill-tree-and-classes)
- [Save System and Migration](/docs/underroot/save-system-and-migration)
- [DataRegistry and the Data Files](/docs/underroot/dataregistry-and-the-data-files)
- [Adding a Cosmetic](/docs/underroot/adding-a-cosmetic)
