# Codes and Redemption

Redemption codes grant tools, materials, or cosmetics from a player-entered string. `scripts/core/CodeManager.gd` runs the redemption flow, `data/codes.json` is keyed by SHA-256 so no plaintext code ships in the exported pck, and one-time redemption is tracked in `config.json`.

## Redemption flow

`CodeManager.redeem(raw_code)` is the single entry point. It returns a result dict `{ "success": bool, "message": String, "color": Color }` and also emits a toast via `EventBus.show_toast` (visible briefly after the Settings panel closes; callers behind a modal should surface `message`/`color` inline instead).

The flow:

1. **Normalize** — `raw_code.strip_edges().to_upper()`. An empty code returns a warning immediately.
2. **Look up** — `DataRegistry.get_code(code)`. This is the datamine-safe step (see below). An empty result means "Unknown code."
3. **Check one-time status** — if the code is not `repeatable` and `SaveManager.is_code_redeemed(code)` is true, it has been redeemed before. Rather than reject outright, `CodeManager` back-fills only newly added, un-owned cosmetics via `_grant_new_cosmetics()` — tools and materials are never re-granted. If nothing was back-filled, it returns "Code already redeemed."
4. **Grant** — `_grant_rewards(def.get("rewards", {}))` applies the rewards block and returns a human-readable summary.
5. **Mark redeemed** — for non-repeatable codes, `SaveManager.mark_code_redeemed(code)`.

## The rewards block

`_grant_rewards()` handles three reward categories, each optional:

| Key | Shape | Applied via |
|---|---|---|
| `tools` | `{ tool_id: count }` | `ToolState.add_tool(tool_id)`, once per count |
| `materials` | `{ mat_id: count }` | `Inventory.add(mat_id, count)` |
| `cosmetics` | `[ cosmetic_id, ... ]` | `CosmeticManager.unlock(cosmetic_id)` |

Because `CosmeticManager.unlock()` is a no-op once an item is owned, a cosmetic already held is summarized as "NAME (already owned)" rather than double-granted.

### Farm safety

Tools and materials are farmable rewards; they must never be handed out twice. Cosmetics are account-unique, so re-granting one is harmless. That asymmetry is what allows step 3: a code can gain new cosmetic rewards after a player first redeemed it (say, a suit added to an existing code), and `_grant_new_cosmetics()` back-fills only the un-owned cosmetics from that block, ignoring tools and materials entirely. The code stays flagged redeemed throughout.

## data/codes.json is keyed by SHA-256

The catalog does not store plaintext codes. Each entry is keyed by the **SHA-256 hex digest of the uppercase code string**, so the plaintext cannot be recovered by datamining the shipped pck. The lookup hashes the player's input at read time:

```gdscript
func get_code(code_id: String) -> Dictionary:
	return codes.get(code_id.strip_edges().to_upper().sha256_text(), {})
```

So `CodeManager` passes the already-uppercased plaintext to `DataRegistry.get_code`, which normalizes again and hashes with `sha256_text()` before the dictionary lookup. A wrong code hashes to a key that is not present and returns `{}`.

A catalog entry (keyed by the hash) looks like:

```json
"<sha256-hex-of-uppercase-code>": {
  "repeatable": false,
  "rewards": {
    "tools":     { "steel_pickaxe": 1 },
    "materials": {},
    "cosmetics": ["form_dave"]
  }
}
```

## One-time tracking in config.json

Non-repeatable codes are recorded account-wide so they survive slot deletion and cannot be farmed:

```gdscript
func is_code_redeemed(code: String) -> bool:
	var redeemed: Array = _load_config().get("redeemed_codes", [])
	return code in redeemed
```

`redeemed_codes` lives in `config.json`. Note that this list stores the **plaintext the player typed** (uppercased), matching pre-hashing config entries — the hashing applies to the `codes.json` catalog, not to the redemption ledger. `mark_code_redeemed(code)` appends the uppercased plaintext if not already present.

> Do not confuse the two representations: `codes.json` is keyed by the SHA-256 hash (datamine-safe), while `config.json`'s `redeemed_codes` holds the plaintext code for one-time tracking. The catalog never contains plaintext; the ledger never contains hashes.

## Adding a new code

The plaintext-to-hash mapping is never committed to the shipped repo — it lives only in the private repo's git history (the pre-hashing `codes.json`) and the publisher's records. To add a code, hash it and add the hash-keyed entry:

1. Choose the code string and uppercase it.
2. Compute its SHA-256 hex digest. In Godot: `"YOURCODE".sha256_text()`. Outside Godot: `python -c "import hashlib;print(hashlib.sha256('YOURCODE'.encode()).hexdigest())"` (with the uppercased string).
3. Add a new key to `data/codes.json` using that hex digest, with `repeatable` and a `rewards` block.
4. Never add the plaintext code, a comment, or a hinting label to `codes.json` — the file's `_comment` documents this rule, and dev/cheat codes must be stripped before merging to main.
5. Run the data validator after any `data/*.json` edit.

## Key files

| File | Role |
|---|---|
| `scripts/core/CodeManager.gd` | `redeem()`, reward granting, back-fill, one-time enforcement |
| `data/codes.json` | Code catalog keyed by SHA-256 of the uppercase code |
| `scripts/resources/DataRegistry.gd` | `get_code()` — hashes input and looks up the catalog |
| `scripts/core/SaveManager.gd` | `is_code_redeemed()` / `mark_code_redeemed()`; `redeemed_codes` in `config.json` |

## Related

- [Cosmetics and Digger Forms](/docs/underroot/cosmetics-and-digger-forms)
- [DataRegistry and the Data Files](/docs/underroot/dataregistry-and-the-data-files)
- [Save System and Migration](/docs/underroot/save-system-and-migration)
- [Adding a Redemption Code](/docs/underroot/adding-a-redemption-code)