aboutsummaryrefslogtreecommitdiff
path: root/skill_plans/scavenging.md
diff options
context:
space:
mode:
Diffstat (limited to 'skill_plans/scavenging.md')
-rw-r--r--skill_plans/scavenging.md950
1 files changed, 950 insertions, 0 deletions
diff --git a/skill_plans/scavenging.md b/skill_plans/scavenging.md
new file mode 100644
index 0000000..5dc7d39
--- /dev/null
+++ b/skill_plans/scavenging.md
@@ -0,0 +1,950 @@
+# Scavenging Skill Implementation Plan
+
+## 1. Overview
+
+Scavenging is the Runecrafting analog for The House of Icarus. The skill has two phases:
+
+1. **Mining scrap** — Gather `scrap_metal` from scrap pile objects using a pickaxe (Mining skill). This already works via `data/behaviors/mine_scrap.yaml` and `data/objects/scrap_pile.yaml`. The item `data/items/scrap_metal.yaml` exists and is non-stackable.
+2. **Identifying junk** — Take scrap to an altar station. With the correct identifier tool in inventory, type `id` (or `use <altar>`). On the next tick, ALL scrap in the player's inventory is converted into the corresponding junk type. Output quantity is `scrap_count × multiplier`, where multiplier increases with Scavenging level (like OSRS double runes).
+
+There are 4 junk types, each requiring a specific identifier tool and altar:
+
+| Junk Type | Identifier Tool | Altar Station | Level Req | XP/scrap |
+|-----------|----------------|---------------|-----------|----------|
+| Solarjunk | Solar Identifier | Solar Altar | 1 | 5 |
+| Hydrojunk | Hydro Identifier | Hydro Altar | 14 | 8 |
+| Ecojunk | Eco Identifier | Eco Altar | 28 | 12 |
+| Biojunk | Bio Identifier | Bio Altar | 44 | 16 |
+
+The Scavenging skill constant already exists in `internal/player/player.go:28` as `Scavenging SkillName = "scavenging"` with abbreviation `"scv"`.
+
+---
+
+## 2. Commands
+
+### New Command: `id`
+
+| Property | Value |
+|----------|-------|
+| Command | `id` |
+| Aliases | `identify` |
+| Class | `ClassActive` |
+| Handler | `g.doIdentify(sess, args)` |
+| Action Type | `"identify"` |
+| ActionState | `ActionIdentifying` |
+| Ticks | 1 (instant conversion on next tick) |
+
+**Behavior:** When the player types `id`:
+1. Cancel any active action.
+2. Scan the room for an altar object (`solar_altar`, `hydro_altar`, `eco_altar`, `bio_altar`).
+3. If no altar found: `"There is no altar here."`
+4. Determine the junk type from the altar type.
+5. Check the player has the matching identifier tool in inventory or equipment.
+6. If no identifier: `"You need a <type> identifier to use this altar."`
+7. Check the player has at least one `scrap_metal` in inventory.
+8. If no scrap: `"You don't have any scrap metal."`
+9. Check player's Scavenging level meets the junk's level requirement.
+10. If too low: `"You need level <N> scavenging to identify <junk type>."`
+11. Create a 1-tick `"identify"` action.
+12. On advance: count all `scrap_metal` slots, remove them, calculate multiplier, add junk, award XP.
+
+**`use <altar>` path:** The `id` command is the primary interface. Additionally, `use` on an altar without specifying an item should route to the same `doIdentify` logic. This can be handled by giving each altar object a `behavior` that points to a behavior YAML with `type: use`, OR by special-casing altar IDs in the `use` command handler. The recommended approach is to add `use_interactions` on each altar object YAML that triggers the identify logic (see Section 6).
+
+### Classification and Dispatch Changes
+
+**File: `internal/game/game.go`**
+
+In `classifyCommand()` at line 136, add `"id"` and `"identify"` to the `ClassActive` list:
+
+```go
+case "get", "take", "grab", "pick", "drop",
+ "attack", "kill",
+ "north", "n", "south", "s", "east", "e",
+ "west", "w", "up", "u", "down", "d",
+ "quit", "use", "burn", "stoke", "search", "walk", "cook", "smelt", "smith", "craft",
+ "id", "identify":
+ return ClassActive
+```
+
+In `executeCommand()` (around line 249), add a case:
+
+```go
+case "id", "identify":
+ g.doIdentify(sess, strings.Join(args, " "))
+ return
+```
+
+---
+
+## 3. New Files to Create
+
+### Go Files
+
+| File | Purpose |
+|------|---------|
+| `internal/game/cmd_id.go` | `doIdentify()` handler — validate altar, identifier, scrap; create action |
+| `internal/game/action_identify.go` | `startIdentify()` and `advanceIdentify()` — the conversion logic |
+
+### YAML Files — Items
+
+| File | Description |
+|------|-------------|
+| `data/items/solar_identifier.yaml` | Solar Identifier tool |
+| `data/items/hydro_identifier.yaml` | Hydro Identifier tool |
+| `data/items/eco_identifier.yaml` | Eco Identifier tool |
+| `data/items/bio_identifier.yaml` | Bio Identifier tool |
+| `data/items/solarjunk.yaml` | Solarjunk (stackable output) |
+| `data/items/hydrojunk.yaml` | Hydrojunk (stackable output) |
+| `data/items/ecojunk.yaml` | Ecojunk (stackable output) |
+| `data/items/biojunk.yaml` | Biojunk (stackable output) |
+
+### YAML Files — Objects
+
+| File | Description |
+|------|-------------|
+| `data/objects/solar_altar.yaml` | Solar Altar station |
+| `data/objects/hydro_altar.yaml` | Hydro Altar station |
+| `data/objects/eco_altar.yaml` | Eco Altar station |
+| `data/objects/bio_altar.yaml` | Bio Altar station |
+
+### YAML Files — Help
+
+| File | Description |
+|------|-------------|
+| `data/help/id.yaml` | Help for the `id` command |
+| `data/help/scavenging.yaml` | Help for the Scavenging skill |
+
+---
+
+## 4. Code Changes to Existing Files
+
+### `internal/game/game.go`
+
+1. **`classifyCommand()`** (line 136): Add `"id"`, `"identify"` to `ClassActive` case.
+2. **`executeCommand()`** (line 249): Add `case "id", "identify":` dispatching to `g.doIdentify(sess, strings.Join(args, " "))`.
+
+### `internal/game/action_state.go`
+
+Add the new ActionType constant:
+
+```go
+ActionIdentifying ActionType = "identifying"
+```
+
+Add a case in `Description()`:
+
+```go
+case ActionIdentifying:
+ return "identifying scrap at " + a.TargetName
+```
+
+### `internal/game/action.go`
+
+In `AdvanceActions()` (line 225), add a case in the switch:
+
+```go
+case "identify":
+ g.advanceIdentify(sess, p)
+```
+
+### `internal/game/game.go` — `ProcessQueuedCommands()`
+
+In the `ActionState` clearing logic (line 472), the `ActionIdentifying` type is a 1-tick action like `ActionPickingUp`. It does NOT need to persist in the list of long-running actions. After the advance tick fires and completes, the action is nil'd and the state clears naturally. No changes needed here — the `default` branch at line 474 already clears non-persistent action states.
+
+---
+
+## 5. Items — Full YAML Definitions
+
+### `data/items/scrap_metal.yaml` (ALREADY EXISTS — verify)
+
+```yaml
+id: scrap_metal
+name: scrap metal
+color: "243"
+description: "A chunk of salvaged scrap metal."
+value: 2
+stackable: false
+```
+
+This already exists at `data/items/scrap_metal.yaml`. No changes needed. Scrap metal is intentionally non-stackable (like rune essence in OSRS — each piece takes an inventory slot, limiting how many you can carry per trip to 28).
+
+### `data/items/solar_identifier.yaml`
+
+```yaml
+id: solar_identifier
+name: solar identifier
+color: "220"
+description: "A handheld scanner calibrated to isolate solar-frequency signatures in scrap metal. Required to produce solarjunk at a solar altar."
+value: 500
+```
+
+### `data/items/hydro_identifier.yaml`
+
+```yaml
+id: hydro_identifier
+name: hydro identifier
+color: "39"
+description: "A handheld scanner calibrated to isolate hydro-frequency signatures in scrap metal. Required to produce hydrojunk at a hydro altar."
+value: 1000
+```
+
+### `data/items/eco_identifier.yaml`
+
+```yaml
+id: eco_identifier
+name: eco identifier
+color: "34"
+description: "A handheld scanner calibrated to isolate eco-frequency signatures in scrap metal. Required to produce ecojunk at an eco altar."
+value: 2500
+```
+
+### `data/items/bio_identifier.yaml`
+
+```yaml
+id: bio_identifier
+name: bio identifier
+color: "196"
+description: "A handheld scanner calibrated to isolate bio-frequency signatures in scrap metal. Required to produce biojunk at a bio altar."
+value: 5000
+```
+
+### `data/items/solarjunk.yaml`
+
+```yaml
+id: solarjunk
+name: solarjunk
+color: "220"
+description: "A fragment of reclaimed solar circuitry. Used as a universal energy medium."
+value: 10
+stackable: true
+```
+
+### `data/items/hydrojunk.yaml`
+
+```yaml
+id: hydrojunk
+name: hydrojunk
+color: "39"
+description: "A fragment of reclaimed hydro circuitry. Used as a universal energy medium."
+value: 18
+stackable: true
+```
+
+### `data/items/ecojunk.yaml`
+
+```yaml
+id: ecojunk
+name: ecojunk
+color: "34"
+description: "A fragment of reclaimed eco circuitry. Used as a universal energy medium."
+value: 30
+stackable: true
+```
+
+### `data/items/biojunk.yaml`
+
+```yaml
+id: biojunk
+name: biojunk
+color: "196"
+description: "A fragment of reclaimed bio circuitry. Used as a universal energy medium."
+value: 50
+stackable: true
+```
+
+---
+
+## 6. Objects — Full YAML Definitions
+
+### `data/objects/solar_altar.yaml`
+
+```yaml
+id: solar_altar
+name: solar altar
+color: "220"
+description: "A humming altar of golden circuitry. Place scrap metal here with a solar identifier to produce solarjunk. Type 'id' to begin."
+```
+
+### `data/objects/hydro_altar.yaml`
+
+```yaml
+id: hydro_altar
+name: hydro altar
+color: "39"
+description: "A pulsing altar of blue crystalline conduits. Place scrap metal here with a hydro identifier to produce hydrojunk. Type 'id' to begin."
+```
+
+### `data/objects/eco_altar.yaml`
+
+```yaml
+id: eco_altar
+name: eco altar
+color: "34"
+description: "A thrumming altar of green organic circuits. Place scrap metal here with an eco identifier to produce ecojunk. Type 'id' to begin."
+```
+
+### `data/objects/bio_altar.yaml`
+
+```yaml
+id: bio_altar
+name: bio altar
+color: "196"
+description: "A sinister altar of red biological interfaces. Place scrap metal here with a bio identifier to produce biojunk. Type 'id' to begin."
+```
+
+---
+
+## 7. Rooms
+
+### Existing Room: Room 9 — Scavenging Post
+
+Currently at `data/rooms/9.yaml`:
+
+```yaml
+id: 9
+name: "Scavenging Post"
+description: "A cluttered yard full of scrap metal and discarded technology. This area is not yet accessible."
+exits:
+ east: 10
+ west: 8
+```
+
+**Update room 9** to add scrap pile objects and a useful description:
+
+```yaml
+id: 9
+name: "Scavenging Post"
+description: "A cluttered yard full of {243}scrap metal{/} and discarded technology. Piles of salvageable debris are scattered across the ground. The air smells of ozone and rust."
+exits:
+ east: 10
+ west: 8
+objects:
+ - id: scrap_pile
+ - id: scrap_pile
+ - id: scrap_pile
+```
+
+### New Altar Rooms
+
+Create 4 new rooms for the altars. These should branch off from or near the Scavenging Post area. The exact room IDs depend on the next available IDs in the codebase. Check `data/rooms/` for the highest existing ID and use the next sequential numbers.
+
+**Suggested room layout:**
+- Room 9 (Scavenging Post) — hub with scrap piles
+- New rooms branching from room 9 or nearby rooms for each altar
+
+Example new room files (IDs are placeholders — use next available):
+
+#### `data/rooms/<next_id>.yaml` — Solar Altar Chamber
+
+```yaml
+id: <next_id>
+name: "Solar Altar Chamber"
+description: "A circular chamber bathed in warm {220}golden light{/}. At its center stands a humming {220 bold}solar altar{/}, its surface etched with fractal circuit patterns that glow softly."
+exits:
+ south: 9
+objects:
+ - id: solar_altar
+```
+
+#### `data/rooms/<next_id+1>.yaml` — Hydro Altar Chamber
+
+```yaml
+id: <next_id+1>
+name: "Hydro Altar Chamber"
+description: "Condensation drips from the ceiling of this cool, blue-lit chamber. A {39 bold}hydro altar{/} dominates the room, its crystalline surface rippling with patterns like flowing water."
+exits:
+ south: 9
+objects:
+ - id: hydro_altar
+```
+
+#### `data/rooms/<next_id+2>.yaml` — Eco Altar Chamber
+
+```yaml
+id: <next_id+2>
+name: "Eco Altar Chamber"
+description: "Bioluminescent moss covers the walls of this overgrown chamber. An {34 bold}eco altar{/} rises from the floor, entwined with living circuitry that pulses with green light."
+exits:
+ south: 9
+objects:
+ - id: eco_altar
+```
+
+#### `data/rooms/<next_id+3>.yaml` — Bio Altar Chamber
+
+```yaml
+id: <next_id+3>
+name: "Bio Altar Chamber"
+description: "The air here is thick and warm. A {196 bold}bio altar{/} throbs at the center of the room, its surface covered in red organic membranes threaded with copper wire."
+exits:
+ south: 9
+objects:
+ - id: bio_altar
+```
+
+**Update room 9 exits** to add north exits (or other directions) to the altar rooms. The exact exit directions depend on the room layout. Add exits from room 9 to each altar room, e.g.:
+
+```yaml
+exits:
+ east: 10
+ west: 8
+ north: <solar_altar_room_id>
+```
+
+Or create an intermediate hub room that branches to all 4 altars.
+
+**To determine actual room IDs:** Run `ls data/rooms/ | sort -n | tail -5` to find the highest existing room ID, then use sequential IDs after that.
+
+---
+
+## 8. Mechanics — The `id` Command
+
+### Flow Diagram
+
+```
+Player types "id" (or "identify")
+ │
+ ├── CancelAction(p)
+ │
+ ├── Scan room for altar objects
+ │ └── Check: solar_altar, hydro_altar, eco_altar, bio_altar
+ │ └── If none found: "There is no altar here." → return
+ │
+ ├── Determine altar type → junk mapping
+ │ ├── solar_altar → solarjunk (req: solar_identifier, level 1, 5 XP)
+ │ ├── hydro_altar → hydrojunk (req: hydro_identifier, level 14, 8 XP)
+ │ ├── eco_altar → ecojunk (req: eco_identifier, level 28, 12 XP)
+ │ └── bio_altar → biojunk (req: bio_identifier, level 44, 16 XP)
+ │
+ ├── Check identifier tool in inventory or equipment
+ │ └── If missing: "You need a <name> to use this altar." → return
+ │
+ ├── Check player has scrap_metal in inventory
+ │ └── If none: "You don't have any scrap metal." → return
+ │
+ ├── Check Scavenging level ≥ required level
+ │ └── If too low: "You need level <N> scavenging to do that." → return
+ │
+ └── Create action:
+ p.Action = &action.Action{
+ Type: "identify",
+ TargetName: <altar_name>,
+ Data: {
+ "altar_type": <altar_def_id>,
+ "junk_id": <junk_item_id>,
+ "xp_per": <xp_per_scrap>,
+ "level_req": <level_req>,
+ },
+ WaitLeft: 1,
+ }
+ p.ActionState = &ActionState{Type: ActionIdentifying, TargetName: <altar_name>}
+```
+
+### Advance Logic (next tick)
+
+```
+advanceIdentify(sess, p):
+ │
+ ├── Read action data (junk_id, xp_per, level_req)
+ │
+ ├── Count all scrap_metal in inventory
+ │ └── Iterate slots 0-27, sum quantities where ItemID == "scrap_metal"
+ │
+ ├── Calculate multiplier from Scavenging level (see Section 11)
+ │ └── totalJunk = scrapCount * multiplier
+ │
+ ├── Check inventory space for junk (junk is stackable, so only need 1 free slot
+ │ if player doesn't already have that junk type)
+ │ └── If no existing stack AND no free slot:
+ │ "Your inventory is too full!" → CancelAction → return
+ │
+ ├── Remove all scrap_metal from inventory
+ │ └── Iterate slots 0-27, nil any slot with ItemID == "scrap_metal"
+ │
+ ├── Add junk to inventory
+ │ └── Find existing stack of junk_id → add totalJunk to quantity
+ │ └── OR find free slot → set to {ItemID: junk_id, Quantity: totalJunk}
+ │
+ ├── Award XP: totalXP = scrapCount * xp_per
+ │ └── p.AddSkillXP(player.Scavenging, totalXP)
+ │ └── Check for level-up, output message if so
+ │
+ ├── Output message:
+ │ "The altar hums. You identify <scrapCount> scrap metal into <totalJunk> <junk_name>."
+ │ └── If xp_drops enabled: " (+<totalXP>xp scv)"
+ │
+ ├── SaveCharacter(p)
+ │
+ └── CancelAction(p)
+```
+
+---
+
+## 9. Action Lifecycle — Implementation Details
+
+### `internal/game/cmd_id.go`
+
+```go
+package game
+
+import (
+ "fmt"
+
+ "thehouseoficarus/internal/action"
+ "thehouseoficarus/internal/net"
+ "thehouseoficarus/internal/player"
+)
+
+type altarConfig struct {
+ AltarID string
+ IdentifierID string
+ JunkID string
+ LevelReq int
+ XPPer int
+}
+
+var altarConfigs = []altarConfig{
+ {"solar_altar", "solar_identifier", "solarjunk", 1, 5},
+ {"hydro_altar", "hydro_identifier", "hydrojunk", 14, 8},
+ {"eco_altar", "eco_identifier", "ecojunk", 28, 12},
+ {"bio_altar", "bio_identifier", "biojunk", 44, 16},
+}
+
+func (g *Game) doIdentify(sess *net.Session, input string) {
+ p := sess.Player.(*player.Player)
+ g.CancelAction(p)
+
+ var found *altarConfig
+ for _, ac := range altarConfigs {
+ if defID, _ := g.findStation(p.RoomID, []string{ac.AltarID}); defID != "" {
+ found = &ac
+ break
+ }
+ }
+ if found == nil {
+ sess.WriteLine("There is no altar here.")
+ return
+ }
+
+ identifierName := found.IdentifierID
+ if def, err := g.ItemStore.Load(found.IdentifierID); err == nil {
+ identifierName = def.Name
+ }
+ if !p.HasItem(found.IdentifierID) {
+ sess.WriteLine(fmt.Sprintf("You need a %s to use this altar.", identifierName))
+ return
+ }
+
+ scrapCount := g.countItem(p, "scrap_metal")
+ if scrapCount == 0 {
+ sess.WriteLine("You don't have any scrap metal.")
+ return
+ }
+
+ scavLevel := p.Level(player.Scavenging)
+ if scavLevel < found.LevelReq {
+ junkName := found.JunkID
+ if def, err := g.ItemStore.Load(found.JunkID); err == nil {
+ junkName = def.Name
+ }
+ sess.WriteLine(fmt.Sprintf("You need level %d scavenging to identify %s.", found.LevelReq, junkName))
+ return
+ }
+
+ altarName := found.AltarID
+ if def, err := g.ObjectStore.Load(found.AltarID); err == nil {
+ altarName = def.Name
+ }
+
+ p.Action = &action.Action{
+ Type: "identify",
+ TargetID: found.AltarID,
+ TargetName: altarName,
+ WaitLeft: 1,
+ Data: map[string]any{
+ "junk_id": found.JunkID,
+ "xp_per": found.XPPer,
+ "level_req": found.LevelReq,
+ },
+ }
+ p.ActionState = &ActionState{Type: ActionIdentifying, TargetName: altarName}
+}
+
+// countItem counts total quantity of an item across all inventory slots.
+// For non-stackable items, each slot with that item contributes 1.
+func (g *Game) countItem(p *player.Player, itemID string) int {
+ total := 0
+ for i := 0; i < 28; i++ {
+ slot := p.InvSlot(i)
+ if slot != nil && slot.ItemID == itemID {
+ total += slot.Quantity
+ }
+ }
+ return total
+}
+```
+
+**Note on `countItem`:** Check if `player.Player` already has a `CountItem` method. Looking at the codebase, `p.CountItem` is used in production code (`recipe.HasAllItemsQty(p.CountItem)`). Use that if it already returns a count for a given item ID. If `CountItem(id string) int` exists, use `p.CountItem("scrap_metal")` instead of a custom helper.
+
+### `internal/game/action_identify.go`
+
+```go
+package game
+
+import (
+ "fmt"
+
+ "thehouseoficarus/internal/net"
+ "thehouseoficarus/internal/player"
+)
+
+func (g *Game) advanceIdentify(sess *net.Session, p *player.Player) {
+ junkID := p.Action.Data["junk_id"].(string)
+ xpPer := p.Action.Data["xp_per"].(int)
+
+ scrapCount := 0
+ for i := 0; i < 28; i++ {
+ slot := p.InvSlot(i)
+ if slot != nil && slot.ItemID == "scrap_metal" {
+ scrapCount += slot.Quantity
+ }
+ }
+
+ if scrapCount == 0 {
+ sess.WriteLine("You don't have any scrap metal.")
+ g.CancelAction(p)
+ return
+ }
+
+ scavLevel := p.Level(player.Scavenging)
+ multiplier := junkMultiplier(junkID, scavLevel)
+ totalJunk := scrapCount * multiplier
+
+ hasExistingStack := false
+ for i := 0; i < 28; i++ {
+ slot := p.InvSlot(i)
+ if slot != nil && slot.ItemID == junkID {
+ hasExistingStack = true
+ break
+ }
+ }
+ if !hasExistingStack && p.FirstFreeSlot() == -1 {
+ sess.WriteLine("Your inventory is too full!")
+ g.CancelAction(p)
+ return
+ }
+
+ for i := 0; i < 28; i++ {
+ slot := p.InvSlot(i)
+ if slot != nil && slot.ItemID == "scrap_metal" {
+ p.SetInvSlot(i, nil)
+ }
+ }
+
+ placed := false
+ for i := 0; i < 28; i++ {
+ slot := p.InvSlot(i)
+ if slot != nil && slot.ItemID == junkID {
+ slot.Quantity += totalJunk
+ placed = true
+ break
+ }
+ }
+ if !placed {
+ freeSlot := p.FirstFreeSlot()
+ p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: junkID, Quantity: totalJunk})
+ }
+
+ totalXP := scrapCount * xpPer
+ junkName := junkID
+ if def, err := g.ItemStore.Load(junkID); err == nil {
+ junkName = def.Name
+ }
+
+ msg := fmt.Sprintf("The altar hums. You identify %d scrap metal into %d %s.", scrapCount, totalJunk, junkName)
+
+ if newLevel := p.AddSkillXP(player.Scavenging, totalXP); newLevel > 0 {
+ sess.WriteLine(g.colorize(sess, "level_up",
+ fmt.Sprintf("*** You are now level %d scavenging! ***", newLevel)))
+ }
+
+ if p.OptionBool("xp_drops") && totalXP > 0 {
+ msg += g.colorize(sess, "xp", fmt.Sprintf(" (+%dxp scv)", totalXP))
+ }
+ sess.WriteLine(msg)
+
+ g.AccountStore.SaveCharacter(p)
+ g.CancelAction(p)
+}
+```
+
+**Note on `xpPer` type:** When data is stored via `map[string]any` and read back after JSON/YAML round-tripping through the action system, integer values may come back as `float64` (if the action data is ever serialized). Check how other action handlers read integers from `Data`. Looking at `action_search.go:42`, it uses `data["slot_idx"].(int)` — so direct int assertion is fine since action data is never serialized to disk. However, `action_production.go:310` reads `wait` as `float64` — this is because `map[string]any` stores numbers from YAML as float64. Since we're setting these values directly in Go code (not loading from YAML), `int` assertion is correct. But to be safe, consider storing as `float64` and reading as `float64`, then converting:
+
+```go
+xpPer := int(p.Action.Data["xp_per"].(float64))
+```
+
+This is safer. Check the `advanceSearch` pattern — it reads `int` directly because it was set as `int` in `startSearch`. Since `doIdentify` also sets `int` values directly, the `.(int)` assertion should work. But be aware: if the game ever serializes/deserializes actions, this could break. Follow whichever pattern the codebase uses consistently.
+
+---
+
+## 10. XP Table
+
+XP is awarded per scrap converted, not per junk produced. This means multipliers give more output but not more XP per scrap.
+
+| Junk Type | XP per scrap | Level Req |
+|-----------|-------------|-----------|
+| Solarjunk | 5 | 1 |
+| Hydrojunk | 8 | 14 |
+| Ecojunk | 12 | 28 |
+| Biojunk | 16 | 44 |
+
+**XP examples (28 inventory slots = 28 scrap per trip):**
+
+| Junk Type | XP per trip (28 scrap) |
+|-----------|----------------------|
+| Solarjunk | 140 |
+| Hydrojunk | 224 |
+| Ecojunk | 336 |
+| Biojunk | 448 |
+
+For reference, level 99 requires 13,034,431 XP (RSC table). At 448 XP per trip (biojunk), that's ~29,094 trips — comparable to OSRS Runecrafting grind.
+
+---
+
+## 11. Level Multipliers
+
+At higher Scavenging levels, each scrap metal produces multiple junk (like OSRS double/triple runes).
+
+### Multiplier Table
+
+| Scavenging Level | Solarjunk | Hydrojunk | Ecojunk | Biojunk |
+|-----------------|-----------|-----------|---------|---------|
+| 1 | 1x | — | — | — |
+| 14 | 1x | 1x | — | — |
+| 22 | 2x | 1x | — | — |
+| 28 | 2x | 1x | 1x | — |
+| 36 | 2x | 2x | 1x | — |
+| 44 | 3x | 2x | 1x | 1x |
+| 56 | 3x | 2x | 2x | 1x |
+| 66 | 4x | 3x | 2x | 1x |
+| 78 | 4x | 3x | 3x | 2x |
+| 88 | 5x | 4x | 3x | 2x |
+| 99 | 6x | 4x | 3x | 3x |
+
+### Implementation
+
+```go
+type junkMultiplierEntry struct {
+ Level int
+ Multiplier int
+}
+
+var junkMultipliers = map[string][]junkMultiplierEntry{
+ "solarjunk": {
+ {1, 1}, {22, 2}, {44, 3}, {66, 4}, {88, 5}, {99, 6},
+ },
+ "hydrojunk": {
+ {14, 1}, {36, 2}, {66, 3}, {88, 4},
+ },
+ "ecojunk": {
+ {28, 1}, {56, 2}, {78, 3},
+ },
+ "biojunk": {
+ {44, 1}, {78, 2}, {99, 3},
+ },
+}
+
+func junkMultiplier(junkID string, level int) int {
+ entries, ok := junkMultipliers[junkID]
+ if !ok {
+ return 1
+ }
+ mult := 1
+ for _, e := range entries {
+ if level >= e.Level {
+ mult = e.Multiplier
+ }
+ }
+ return mult
+}
+```
+
+Place this in `cmd_id.go` or `action_identify.go` — whichever file contains the `advanceIdentify` function.
+
+---
+
+## 12. Mining Scrap — Verification
+
+The existing setup:
+
+- **Behavior:** `data/behaviors/mine_scrap.yaml` — skill: mining, level 1, XP 5, base_wait 10, requires pickaxe, 55% base success + 1% per level (cap 95%), drops `scrap_metal` with depletion, 50-tick respawn.
+- **Object:** `data/objects/scrap_pile.yaml` — name "scrap pile", behavior `mine_scrap`.
+- **Item:** `data/items/scrap_metal.yaml` — non-stackable, value 2.
+
+**Verify these work by:**
+1. Ensure room 9 has `scrap_pile` objects listed.
+2. Test: `mine scrap` or `mine pile` should start gathering.
+3. Test: Successful gather produces `scrap_metal` in inventory.
+4. Test: Depletion and respawn work correctly.
+
+**Potential adjustment:** The `base_wait: 10` (10 ticks = 6 seconds at 600ms tick) is reasonable for scrap mining. The success rate starting at 55% and capping at 95% is fine. Consider whether the behavior should use `skill: scavenging` instead of `skill: mining`. The TODO says "Mine 'scrap' in scrap pile objects" which implies Mining skill is correct (Scavenging is for the identification phase). However, this is a design decision — if scrap mining should train Scavenging, change the behavior to `skill: scavenging`.
+
+**Recommendation:** Keep `skill: mining` for scrap mining (consistent with the mining skill being used for all pick-based gathering). The Scavenging skill is trained exclusively through identification at altars. This parallels OSRS where Mining trains Mining and Runecrafting trains Runecrafting.
+
+---
+
+## 13. Help Files
+
+### `data/help/id.yaml`
+
+```yaml
+name: "id"
+category: "Commands"
+description: |
+ Identify scrap metal at an altar to produce junk.
+
+ Usage: id
+ identify
+
+ Stand at an altar (solar, hydro, eco, or bio) with the matching
+ identifier tool in your inventory and scrap metal. Type 'id' to
+ convert all your scrap metal into junk on the next game tick.
+
+ Each altar type requires a specific identifier:
+ Solar Altar → solar identifier → solarjunk (level 1)
+ Hydro Altar → hydro identifier → hydrojunk (level 14)
+ Eco Altar → eco identifier → ecojunk (level 28)
+ Bio Altar → bio identifier → biojunk (level 44)
+
+ At higher scavenging levels, each scrap produces multiple junk.
+ See 'help scavenging' for the full multiplier table.
+```
+
+### `data/help/scavenging.yaml`
+
+```yaml
+name: "scavenging"
+category: "Skills"
+description: |
+ Scavenging is the art of reclaiming useful circuitry from salvaged scrap.
+
+ Step 1: Mine scrap metal from scrap piles (requires a pickaxe, uses
+ the Mining skill).
+ Step 2: Take scrap metal to an altar with the matching identifier tool.
+ Step 3: Type 'id' to convert all scrap metal into junk.
+
+ Junk types and requirements:
+ Solarjunk — level 1, solar identifier, solar altar (5 XP/scrap)
+ Hydrojunk — level 14, hydro identifier, hydro altar (8 XP/scrap)
+ Ecojunk — level 28, eco identifier, eco altar (12 XP/scrap)
+ Biojunk — level 44, bio identifier, bio altar (16 XP/scrap)
+
+ Higher scavenging levels produce more junk per scrap:
+ Solarjunk: 2x at 22, 3x at 44, 4x at 66, 5x at 88, 6x at 99
+ Hydrojunk: 2x at 36, 3x at 66, 4x at 88
+ Ecojunk: 2x at 56, 3x at 78
+ Biojunk: 2x at 78, 3x at 99
+
+ Scrap metal is not stackable — you can carry 28 per trip (one per
+ inventory slot). Each trip converts all scrap in one tick.
+```
+
+---
+
+## 14. Implementation Checklist
+
+### Phase 1: Core Files (minimal working feature)
+
+- [ ] Create `internal/game/cmd_id.go` with `doIdentify()`, `altarConfigs`, `junkMultiplier()`
+- [ ] Create `internal/game/action_identify.go` with `advanceIdentify()`
+- [ ] Edit `internal/game/action_state.go`: add `ActionIdentifying` constant and `Description()` case
+- [ ] Edit `internal/game/game.go` → `classifyCommand()`: add `"id"`, `"identify"` to `ClassActive`
+- [ ] Edit `internal/game/game.go` → `executeCommand()`: add `case "id", "identify":`
+- [ ] Edit `internal/game/action.go` → `AdvanceActions()`: add `case "identify":`
+
+### Phase 2: YAML Data
+
+- [ ] Create `data/items/solar_identifier.yaml`
+- [ ] Create `data/items/hydro_identifier.yaml`
+- [ ] Create `data/items/eco_identifier.yaml`
+- [ ] Create `data/items/bio_identifier.yaml`
+- [ ] Create `data/items/solarjunk.yaml`
+- [ ] Create `data/items/hydrojunk.yaml`
+- [ ] Create `data/items/ecojunk.yaml`
+- [ ] Create `data/items/biojunk.yaml`
+- [ ] Create `data/objects/solar_altar.yaml`
+- [ ] Create `data/objects/hydro_altar.yaml`
+- [ ] Create `data/objects/eco_altar.yaml`
+- [ ] Create `data/objects/bio_altar.yaml`
+
+### Phase 3: Rooms
+
+- [ ] Update `data/rooms/9.yaml` — add scrap pile objects, update description, add exits to altar rooms
+- [ ] Create altar room YAML files (4 rooms) — determine next available room IDs
+- [ ] Ensure exits are bidirectional (altar rooms exit back to room 9 or a hub)
+
+### Phase 4: Help
+
+- [ ] Create `data/help/id.yaml`
+- [ ] Create `data/help/scavenging.yaml`
+
+### Phase 5: Verify
+
+- [ ] `make build` — compiles without errors
+- [ ] `make vet` — no vet warnings
+- [ ] `make test` — all tests pass
+- [ ] Manual test: mine scrap at room 9, walk to altar, `id`, verify conversion
+- [ ] Test: identifier not in inventory → correct error message
+- [ ] Test: no scrap in inventory → correct error message
+- [ ] Test: no altar in room → correct error message
+- [ ] Test: level too low → correct error message
+- [ ] Test: inventory full (no room for junk) → correct error message
+- [ ] Test: multiplier works at higher levels
+- [ ] Test: XP is awarded correctly
+- [ ] Test: level-up message displays
+- [ ] Test: `help id` and `help scavenging` display correctly
+
+---
+
+## 15. Design Decisions & Edge Cases
+
+### Why not use the recipe/production system?
+
+The production system (`action_production.go`) processes items one at a time in a loop with "How many?" prompts and per-cycle timing. Scavenging converts ALL scrap in a single tick with level-based multipliers. The mechanics are fundamentally different:
+- Production: consume 1 input → wait N ticks → produce 1 output → repeat
+- Identify: consume ALL scrap → wait 1 tick → produce (scrap × multiplier) junk
+
+A custom action type (`identify`) is cleaner than bending the production system.
+
+### Scrap is non-stackable (intentional)
+
+Like rune essence in OSRS, scrap metal fills one inventory slot per piece. This limits trips to 28 scrap maximum, creating a meaningful gameplay loop of mine → walk → identify → repeat.
+
+### Identifier tools are NOT consumed
+
+Identifiers are reusable tools (like a talisman in OSRS). Players buy/find them once and keep them. They take 1 inventory slot, reducing max scrap per trip to 27.
+
+### What if the player has multiple types of identifiers?
+
+The `id` command scans for altars in the room, not identifiers. It finds which altar is present, then checks for the matching identifier. Only one altar type per room. No ambiguity.
+
+### What if multiple altars are in the same room?
+
+The first matching altar in `altarConfigs` order wins (solar → hydro → eco → bio). In practice, each room should have exactly one altar.
+
+### Junk items are stackable
+
+Unlike scrap, junk stacks in a single inventory slot. A player can accumulate unlimited junk in one slot. This is intentional — junk is the "rune" equivalent and will be consumed by future skills/spells.
+
+### XP is per scrap, not per junk
+
+If a player converts 28 scrap at 3x multiplier, they get 84 junk but only 28 × (xp_per) XP. The multiplier rewards efficiency (more output per trip) without inflating XP rates.
+
+### The `countItem` helper
+
+Before implementing, check if `player.Player` already has a `CountItem(id string) int` method. The production system uses `p.CountItem` — see `action_production.go:221` where `recipe.HasAllItemsQty(p.CountItem)` passes it as a function. If `CountItem` exists, use it directly instead of writing a new helper. If it returns a count, `p.CountItem("scrap_metal")` gives the total.
+
+### Action cancellation
+
+The `identify` action is a 1-tick action. If the player moves or starts another action before it fires, the action is cancelled naturally (CancelAction is called). The scrap is NOT consumed until `advanceIdentify` runs, so cancellation is safe — no items lost.