aboutsummaryrefslogtreecommitdiff
path: root/skill_plans/science.md
diff options
context:
space:
mode:
Diffstat (limited to 'skill_plans/science.md')
-rw-r--r--skill_plans/science.md3115
1 files changed, 3115 insertions, 0 deletions
diff --git a/skill_plans/science.md b/skill_plans/science.md
new file mode 100644
index 0000000..b3c1cb7
--- /dev/null
+++ b/skill_plans/science.md
@@ -0,0 +1,3115 @@
+# Science Skill Implementation Plan
+
+## 1. Overview
+
+Science is the Magic equivalent in The House of Icarus. The terminology mapping is:
+
+| OSRS | House of Icarus |
+|------|-----------------|
+| Spells | Mods (modules) |
+| Casting | Triggering |
+| Runes | Junk |
+| Staves | Decks |
+| Rune Essence | Scrap (`scrap_metal`) |
+| Spellbook | Mod list |
+| Body/Mind runes | Not implemented (too low-level) |
+| Fire/Water/Earth/Air runes | Solarjunk/Hydrojunk/Ecojunk/Biojunk |
+| Chaos/Death/Blood/Law/Cosmic/Nature runes | Chaosjunk/Deathjunk/Bloodjunk/Lawjunk/Cosmicjunk/Naturejunk |
+
+**Core mechanic:** Every mod costs a combination of junk types plus 1 scrap. Wielding ANY deck (weapon_type: science) in main_hand removes the scrap requirement. Wielding a specific elemental deck ALSO provides an unlimited supply of that deck's base junk type (e.g., Solar Deck provides unlimited solarjunk).
+
+**Existing code:**
+- `Science SkillName = "science"` at `internal/player/player.go:14`
+- `SkillAbbr["science"] = "sci"` at `internal/player/player.go:46`
+- `WeaponScience WeaponType = "science"` at `internal/object/item.go:30`
+- `ScienceBonus int` on `ItemStats` at `internal/object/item.go:71` (currently unused)
+- `CombatLevel()` includes `+0.125 * Science` at `internal/player/player.go:325`
+- No magic/science system, combat formulas, autocast, or mod definitions exist
+
+**Dependencies:**
+- `scavenging.md` must be implemented first for base junk types (solarjunk, hydrojunk, ecojunk, biojunk) and scrap_metal
+- `combat.md` changes needed for science attack/defense bonuses on items and mobs (see Section 7)
+- The 6 higher-tier junk types (chaos, death, blood, law, cosmic, nature) require additional altars added to scavenging — see Section 11
+
+---
+
+## 2. Commands
+
+### `trigger` / `cast` (ClassActive)
+
+| Property | Value |
+|----------|-------|
+| Command | `trigger` |
+| Aliases | `cast` |
+| Class | `ClassActive` |
+| Handler | `g.doTrigger(sess, args)` |
+| File | `internal/game/cmd_trigger.go` |
+
+Always ClassActive. For combat mods, initiates science combat (autocasting loop). For utility mods, executes on the next tick (1-tick action).
+
+**Usage:**
+```
+trigger <mod_name> # utility mod or combat with default target
+trigger <mod_name> <target> # combat mod on specific mob
+cast solar bolt # alias, prefix matching
+trigger low process # utility mod
+trigger transport town # teleport
+trigger enchant 1 <jewelry> # enchant an inventory item
+trigger em grab <ground_item> # pick up ground item via science
+trigger superheat <ore> # smelt without furnace
+```
+
+### `autocast` / `auto` (ClassInstant)
+
+| Property | Value |
+|----------|-------|
+| Command | `autocast` |
+| Aliases | `auto` |
+| Class | `ClassInstant` |
+| Handler | `g.doAutocast(sess, args)` |
+| File | `internal/game/cmd_autocast.go` |
+
+Sets the autocast mod for science combat. When autocast is set and player uses `attack <mob>`, each combat tick triggers the autocast mod instead of a melee attack.
+
+**Usage:**
+```
+autocast solar bolt # set autocast to solar_bolt (prefix match)
+autocast off # disable autocast
+auto hydro surge # alias
+autocast # show current autocast
+```
+
+### `mods` / `modlist` (ClassInstant)
+
+| Property | Value |
+|----------|-------|
+| Command | `mods` |
+| Aliases | `modlist` |
+| Class | `ClassInstant` |
+| Handler | `g.doMods(sess)` |
+| File | `internal/game/cmd_mods.go` |
+
+Displays all mods the player has the Science level to use, organized by category, with junk costs.
+
+### Classification Changes
+
+**File: `internal/game/game.go`, `classifyCommand()` at line 136:**
+
+Add to `ClassInstant` case:
+```go
+case "say", "score", "sc", "inventory", "i", "inv",
+ "look", "l", "exits", "help",
+ "map", "option", "options", "alias", "unalias",
+ "description", "desc", "queued", "color", "colors",
+ "colortable", "prompt", "style",
+ "autocast", "auto", "mods", "modlist":
+ return ClassInstant
+```
+
+Add to `ClassActive` case:
+```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",
+ "trigger", "cast":
+ return ClassActive
+```
+
+### Dispatch Changes
+
+**File: `internal/game/game.go`, `executeCommand()` at line 249:**
+
+Add cases:
+```go
+case "autocast", "auto":
+ g.doAutocast(sess, strings.Join(args, " "))
+case "mods", "modlist":
+ g.doMods(sess)
+case "trigger", "cast":
+ g.doTrigger(sess, strings.Join(args, " "))
+ return
+```
+
+---
+
+## 3. Mod Definition Structure
+
+Mods are hardcoded in Go (not YAML-driven). Defined in `internal/game/science.go`.
+
+```go
+package game
+
+type ModCategory string
+
+const (
+ ModCombat ModCategory = "combat"
+ ModUtility ModCategory = "utility"
+ ModEnchant ModCategory = "enchant"
+ ModProcessing ModCategory = "processing"
+ ModTransport ModCategory = "transport"
+)
+
+type ModDef struct {
+ ID string
+ Name string
+ Level int
+ MaxHit int
+ BaseXP float64
+ JunkCost map[string]int
+ Category ModCategory
+ Element string
+ TargetType string // "mob", "inventory", "self", "ground_item"
+}
+
+var AllMods []*ModDef
+
+var modByID map[string]*ModDef
+
+func init() {
+ modByID = make(map[string]*ModDef, len(AllMods))
+ for _, m := range AllMods {
+ modByID[m.ID] = m
+ }
+}
+
+func GetMod(id string) *ModDef {
+ return modByID[id]
+}
+
+func FindMod(input string) *ModDef {
+ // Exact match first
+ if m, ok := modByID[input]; ok {
+ return m
+ }
+ // Prefix match on ID (underscores stripped for matching)
+ lower := strings.ToLower(strings.ReplaceAll(input, " ", "_"))
+ for _, m := range AllMods {
+ if strings.HasPrefix(m.ID, lower) {
+ return m
+ }
+ }
+ // Prefix match on Name
+ lowerSpace := strings.ToLower(input)
+ for _, m := range AllMods {
+ if strings.HasPrefix(strings.ToLower(m.Name), lowerSpace) {
+ return m
+ }
+ }
+ return nil
+}
+```
+
+---
+
+## 4. Combat Mods
+
+All combat mods have `Category: ModCombat`, `TargetType: "mob"`. The `Element` field determines elemental weakness bonuses.
+
+All junk costs below include `"scrap_metal": 1` which is removed if the player has ANY deck equipped.
+
+### Bio Strikes (Air spell equivalents — lowest level)
+
+| ID | Name | Level | Max Hit | Junk Cost | XP |
+|---|---|---|---|---|---|
+| `bio_strike` | Bio Strike | 1 | 4 | 2 biojunk, 1 scrap | 5.5 |
+| `bio_bolt` | Bio Bolt | 17 | 9 | 2 biojunk, 1 chaosjunk, 1 scrap | 13.5 |
+| `bio_blast` | Bio Blast | 41 | 13 | 3 biojunk, 1 chaosjunk, 1 deathjunk, 1 scrap | 25.5 |
+| `bio_wave` | Bio Wave | 62 | 17 | 5 biojunk, 1 deathjunk, 1 bloodjunk, 1 scrap | 36.0 |
+| `bio_surge` | Bio Surge | 81 | 21 | 7 biojunk, 1 bloodjunk, 1 scrap | 44.0 |
+
+```go
+{ID: "bio_strike", Name: "Bio Strike", Level: 1, MaxHit: 4, BaseXP: 5.5,
+ JunkCost: map[string]int{"biojunk": 2, "scrap_metal": 1},
+ Category: ModCombat, Element: "bio", TargetType: "mob"},
+{ID: "bio_bolt", Name: "Bio Bolt", Level: 17, MaxHit: 9, BaseXP: 13.5,
+ JunkCost: map[string]int{"biojunk": 2, "chaosjunk": 1, "scrap_metal": 1},
+ Category: ModCombat, Element: "bio", TargetType: "mob"},
+{ID: "bio_blast", Name: "Bio Blast", Level: 41, MaxHit: 13, BaseXP: 25.5,
+ JunkCost: map[string]int{"biojunk": 3, "chaosjunk": 1, "deathjunk": 1, "scrap_metal": 1},
+ Category: ModCombat, Element: "bio", TargetType: "mob"},
+{ID: "bio_wave", Name: "Bio Wave", Level: 62, MaxHit: 17, BaseXP: 36.0,
+ JunkCost: map[string]int{"biojunk": 5, "deathjunk": 1, "bloodjunk": 1, "scrap_metal": 1},
+ Category: ModCombat, Element: "bio", TargetType: "mob"},
+{ID: "bio_surge", Name: "Bio Surge", Level: 81, MaxHit: 21, BaseXP: 44.0,
+ JunkCost: map[string]int{"biojunk": 7, "bloodjunk": 1, "scrap_metal": 1},
+ Category: ModCombat, Element: "bio", TargetType: "mob"},
+```
+
+### Hydro Strikes (Water spell equivalents)
+
+| ID | Name | Level | Max Hit | Junk Cost | XP |
+|---|---|---|---|---|---|
+| `hydro_strike` | Hydro Strike | 5 | 6 | 3 hydrojunk, 1 ecojunk, 1 scrap | 7.5 |
+| `hydro_bolt` | Hydro Bolt | 23 | 10 | 3 hydrojunk, 2 ecojunk, 1 scrap | 16.5 |
+| `hydro_blast` | Hydro Blast | 47 | 14 | 5 hydrojunk, 3 ecojunk, 1 chaosjunk, 1 scrap | 28.5 |
+| `hydro_wave` | Hydro Wave | 65 | 18 | 7 hydrojunk, 5 ecojunk, 1 deathjunk, 1 scrap | 37.5 |
+| `hydro_surge` | Hydro Surge | 85 | 22 | 10 hydrojunk, 7 ecojunk, 1 bloodjunk, 1 scrap | 46.0 |
+
+```go
+{ID: "hydro_strike", Name: "Hydro Strike", Level: 5, MaxHit: 6, BaseXP: 7.5,
+ JunkCost: map[string]int{"hydrojunk": 3, "ecojunk": 1, "scrap_metal": 1},
+ Category: ModCombat, Element: "hydro", TargetType: "mob"},
+{ID: "hydro_bolt", Name: "Hydro Bolt", Level: 23, MaxHit: 10, BaseXP: 16.5,
+ JunkCost: map[string]int{"hydrojunk": 3, "ecojunk": 2, "scrap_metal": 1},
+ Category: ModCombat, Element: "hydro", TargetType: "mob"},
+{ID: "hydro_blast", Name: "Hydro Blast", Level: 47, MaxHit: 14, BaseXP: 28.5,
+ JunkCost: map[string]int{"hydrojunk": 5, "ecojunk": 3, "chaosjunk": 1, "scrap_metal": 1},
+ Category: ModCombat, Element: "hydro", TargetType: "mob"},
+{ID: "hydro_wave", Name: "Hydro Wave", Level: 65, MaxHit: 18, BaseXP: 37.5,
+ JunkCost: map[string]int{"hydrojunk": 7, "ecojunk": 5, "deathjunk": 1, "scrap_metal": 1},
+ Category: ModCombat, Element: "hydro", TargetType: "mob"},
+{ID: "hydro_surge", Name: "Hydro Surge", Level: 85, MaxHit: 22, BaseXP: 46.0,
+ JunkCost: map[string]int{"hydrojunk": 10, "ecojunk": 7, "bloodjunk": 1, "scrap_metal": 1},
+ Category: ModCombat, Element: "hydro", TargetType: "mob"},
+```
+
+### Eco Strikes (Earth spell equivalents)
+
+| ID | Name | Level | Max Hit | Junk Cost | XP |
+|---|---|---|---|---|---|
+| `eco_strike` | Eco Strike | 9 | 7 | 2 ecojunk, 2 biojunk, 1 scrap | 9.5 |
+| `eco_bolt` | Eco Bolt | 29 | 11 | 3 ecojunk, 2 biojunk, 1 scrap | 19.5 |
+| `eco_blast` | Eco Blast | 53 | 15 | 4 ecojunk, 3 biojunk, 1 chaosjunk, 1 scrap | 31.5 |
+| `eco_wave` | Eco Wave | 70 | 19 | 7 ecojunk, 5 biojunk, 1 deathjunk, 1 scrap | 40.0 |
+| `eco_surge` | Eco Surge | 90 | 23 | 10 ecojunk, 7 biojunk, 1 bloodjunk, 1 scrap | 48.5 |
+
+```go
+{ID: "eco_strike", Name: "Eco Strike", Level: 9, MaxHit: 7, BaseXP: 9.5,
+ JunkCost: map[string]int{"ecojunk": 2, "biojunk": 2, "scrap_metal": 1},
+ Category: ModCombat, Element: "eco", TargetType: "mob"},
+{ID: "eco_bolt", Name: "Eco Bolt", Level: 29, MaxHit: 11, BaseXP: 19.5,
+ JunkCost: map[string]int{"ecojunk": 3, "biojunk": 2, "scrap_metal": 1},
+ Category: ModCombat, Element: "eco", TargetType: "mob"},
+{ID: "eco_blast", Name: "Eco Blast", Level: 53, MaxHit: 15, BaseXP: 31.5,
+ JunkCost: map[string]int{"ecojunk": 4, "biojunk": 3, "chaosjunk": 1, "scrap_metal": 1},
+ Category: ModCombat, Element: "eco", TargetType: "mob"},
+{ID: "eco_wave", Name: "Eco Wave", Level: 70, MaxHit: 19, BaseXP: 40.0,
+ JunkCost: map[string]int{"ecojunk": 7, "biojunk": 5, "deathjunk": 1, "scrap_metal": 1},
+ Category: ModCombat, Element: "eco", TargetType: "mob"},
+{ID: "eco_surge", Name: "Eco Surge", Level: 90, MaxHit: 23, BaseXP: 48.5,
+ JunkCost: map[string]int{"ecojunk": 10, "biojunk": 7, "bloodjunk": 1, "scrap_metal": 1},
+ Category: ModCombat, Element: "eco", TargetType: "mob"},
+```
+
+### Solar Strikes (Fire spell equivalents — highest level)
+
+| ID | Name | Level | Max Hit | Junk Cost | XP |
+|---|---|---|---|---|---|
+| `solar_strike` | Solar Strike | 13 | 8 | 3 solarjunk, 2 ecojunk, 1 scrap | 11.5 |
+| `solar_bolt` | Solar Bolt | 35 | 12 | 4 solarjunk, 3 ecojunk, 1 scrap | 22.5 |
+| `solar_blast` | Solar Blast | 59 | 16 | 5 solarjunk, 4 ecojunk, 1 chaosjunk, 1 scrap | 34.5 |
+| `solar_wave` | Solar Wave | 75 | 20 | 7 solarjunk, 5 ecojunk, 1 deathjunk, 1 scrap | 42.5 |
+| `solar_surge` | Solar Surge | 95 | 24 | 10 solarjunk, 7 ecojunk, 1 bloodjunk, 1 scrap | 51.0 |
+
+```go
+{ID: "solar_strike", Name: "Solar Strike", Level: 13, MaxHit: 8, BaseXP: 11.5,
+ JunkCost: map[string]int{"solarjunk": 3, "ecojunk": 2, "scrap_metal": 1},
+ Category: ModCombat, Element: "solar", TargetType: "mob"},
+{ID: "solar_bolt", Name: "Solar Bolt", Level: 35, MaxHit: 12, BaseXP: 22.5,
+ JunkCost: map[string]int{"solarjunk": 4, "ecojunk": 3, "scrap_metal": 1},
+ Category: ModCombat, Element: "solar", TargetType: "mob"},
+{ID: "solar_blast", Name: "Solar Blast", Level: 59, MaxHit: 16, BaseXP: 34.5,
+ JunkCost: map[string]int{"solarjunk": 5, "ecojunk": 4, "chaosjunk": 1, "scrap_metal": 1},
+ Category: ModCombat, Element: "solar", TargetType: "mob"},
+{ID: "solar_wave", Name: "Solar Wave", Level: 75, MaxHit: 20, BaseXP: 42.5,
+ JunkCost: map[string]int{"solarjunk": 7, "ecojunk": 5, "deathjunk": 1, "scrap_metal": 1},
+ Category: ModCombat, Element: "solar", TargetType: "mob"},
+{ID: "solar_surge", Name: "Solar Surge", Level: 95, MaxHit: 24, BaseXP: 51.0,
+ JunkCost: map[string]int{"solarjunk": 10, "ecojunk": 7, "bloodjunk": 1, "scrap_metal": 1},
+ Category: ModCombat, Element: "solar", TargetType: "mob"},
+```
+
+---
+
+## 5. Utility Mods
+
+### Processing Mods (Alchemy equivalents)
+
+| ID | Name | Level | Junk Cost | XP | Effect |
+|---|---|---|---|---|---|
+| `low_process` | Low Level Processing | 21 | 3 naturejunk, 1 solarjunk, 1 scrap | 31.0 | Convert inventory item to credits at 50% of `value` |
+| `high_process` | High Level Processing | 55 | 5 naturejunk, 1 solarjunk, 1 scrap | 65.0 | Convert inventory item to credits at 100% of `value` |
+
+```go
+{ID: "low_process", Name: "Low Level Processing", Level: 21, MaxHit: 0, BaseXP: 31.0,
+ JunkCost: map[string]int{"naturejunk": 3, "solarjunk": 1, "scrap_metal": 1},
+ Category: ModProcessing, Element: "", TargetType: "inventory"},
+{ID: "high_process", Name: "High Level Processing", Level: 55, MaxHit: 0, BaseXP: 65.0,
+ JunkCost: map[string]int{"naturejunk": 5, "solarjunk": 1, "scrap_metal": 1},
+ Category: ModProcessing, Element: "", TargetType: "inventory"},
+```
+
+**Implementation:**
+1. Player types `trigger low_process <item>` or `trigger low process <item>`
+2. `doTrigger` resolves the mod via `FindMod`
+3. Finds the target item in player inventory via `findInventoryMatches`
+4. Checks junk cost (see Section 16)
+5. Creates a 1-tick action
+6. On advance: consume junk, remove 1 of the item, add credits (`item.Value / 2` for low, `item.Value` for high)
+7. Output: `"You process the <item>. You receive <N> credits."`
+8. Award XP to Science
+
+### Bones to Nutrients
+
+| ID | Name | Level | Junk Cost | XP | Effect |
+|---|---|---|---|---|---|
+| `bones_to_nutrients` | Bones to Nutrients | 15 | 2 naturejunk, 2 ecojunk, 1 scrap | 25.0 | Convert ALL `bones` in inventory to `nutrient_bar` |
+
+```go
+{ID: "bones_to_nutrients", Name: "Bones to Nutrients", Level: 15, MaxHit: 0, BaseXP: 25.0,
+ JunkCost: map[string]int{"naturejunk": 2, "ecojunk": 2, "scrap_metal": 1},
+ Category: ModUtility, Element: "", TargetType: "self"},
+```
+
+**Implementation:**
+1. `trigger bones to nutrients` (no target argument needed)
+2. Count all `bones` items in inventory
+3. If 0: `"You don't have any bones."`
+4. Consume junk cost (one-time cost, not per bone)
+5. Replace each `bones` inventory slot with `nutrient_bar` (new item, stackable, heal_value: 2)
+6. Output: `"You convert <N> bones into nutrient bars."`
+7. Award `25.0 * N` XP to Science (XP per bone converted)
+
+### Electromagnetic Grab (Telekinetic Grab equivalent)
+
+| ID | Name | Level | Junk Cost | XP | Effect |
+|---|---|---|---|---|---|
+| `em_grab` | Electromagnetic Grab | 33 | 1 lawjunk, 1 biojunk, 1 scrap | 43.0 | Pick up a ground item, bypassing reservation |
+
+```go
+{ID: "em_grab", Name: "Electromagnetic Grab", Level: 33, MaxHit: 0, BaseXP: 43.0,
+ JunkCost: map[string]int{"lawjunk": 1, "biojunk": 1, "scrap_metal": 1},
+ Category: ModUtility, Element: "", TargetType: "ground_item"},
+```
+
+**Implementation:**
+1. `trigger em grab <item>`
+2. Find matching ground item in room via existing `findGroundMatches` logic
+3. Consume junk cost
+4. Pick up item to inventory (bypass reservation — do NOT check `ReservedFor`)
+5. If inventory full: `"Your inventory is full."`
+6. Output: `"You magnetically pull the <item> toward you."`
+7. Award XP
+
+### Superheat Item
+
+| ID | Name | Level | Junk Cost | XP | Effect |
+|---|---|---|---|---|---|
+| `superheat` | Superheat Item | 43 | 4 naturejunk, 1 solarjunk, 1 scrap | 53.0 | Smelt ore into bar without furnace |
+
+```go
+{ID: "superheat", Name: "Superheat Item", Level: 43, MaxHit: 0, BaseXP: 53.0,
+ JunkCost: map[string]int{"naturejunk": 4, "solarjunk": 1, "scrap_metal": 1},
+ Category: ModUtility, Element: "", TargetType: "inventory"},
+```
+
+**Implementation:**
+1. `trigger superheat <ore>`
+2. Find matching item in inventory
+3. Look up the smelting recipe that uses this ore (search `RecipeStore` for type "smelt" recipes containing this item)
+4. If no recipe: `"You can't superheat that."`
+5. Check player has all required items for the recipe in inventory
+6. Check player meets the recipe's skill requirement
+7. Consume junk cost + recipe inputs
+8. Add recipe output to inventory
+9. Award `53.0` XP to Science + the recipe's smithing XP
+10. Output: `"You superheat the <ore> and produce a <bar>."`
+
+### Transport Mods (Teleport equivalents)
+
+| ID | Name | Level | Junk Cost | XP | Destination |
+|---|---|---|---|---|---|
+| `transport_town` | Transport: Town Square | 25 | 1 lawjunk, 1 solarjunk, 1 biojunk, 1 scrap | 27.0 | Room 1 (Town Square) |
+| `transport_forge` | Transport: Forge | 31 | 1 lawjunk, 1 ecojunk, 1 scrap | 35.0 | Room 12 (Forge) |
+| `transport_mine` | Transport: Mining Pit | 37 | 1 lawjunk, 1 ecojunk, 1 solarjunk, 1 scrap | 40.0 | Room 6 (Mining Pit) |
+| `transport_forest` | Transport: Forest | 45 | 1 lawjunk, 1 ecojunk, 1 biojunk, 1 scrap | 48.0 | Room 22 (Forest area) |
+| `transport_scavenge` | Transport: Scavenging Post | 51 | 1 lawjunk, 1 naturejunk, 1 scrap | 52.0 | Room 9 (Scavenging Post) |
+| `transport_deep_mine` | Transport: Deep Mine | 61 | 2 lawjunk, 1 ecojunk, 1 scrap | 60.0 | Room 7 (Deep mining area) |
+| `transport_fishing` | Transport: Fishing Dock | 55 | 1 lawjunk, 1 hydrojunk, 1 biojunk, 1 scrap | 56.0 | Room 10 (Fishing area) |
+
+```go
+{ID: "transport_town", Name: "Transport: Town Square", Level: 25, MaxHit: 0, BaseXP: 27.0,
+ JunkCost: map[string]int{"lawjunk": 1, "solarjunk": 1, "biojunk": 1, "scrap_metal": 1},
+ Category: ModTransport, Element: "", TargetType: "self"},
+{ID: "transport_forge", Name: "Transport: Forge", Level: 31, MaxHit: 0, BaseXP: 35.0,
+ JunkCost: map[string]int{"lawjunk": 1, "ecojunk": 1, "scrap_metal": 1},
+ Category: ModTransport, Element: "", TargetType: "self"},
+{ID: "transport_mine", Name: "Transport: Mining Pit", Level: 37, MaxHit: 0, BaseXP: 40.0,
+ JunkCost: map[string]int{"lawjunk": 1, "ecojunk": 1, "solarjunk": 1, "scrap_metal": 1},
+ Category: ModTransport, Element: "", TargetType: "self"},
+{ID: "transport_forest", Name: "Transport: Forest", Level: 45, MaxHit: 0, BaseXP: 48.0,
+ JunkCost: map[string]int{"lawjunk": 1, "ecojunk": 1, "biojunk": 1, "scrap_metal": 1},
+ Category: ModTransport, Element: "", TargetType: "self"},
+{ID: "transport_scavenge", Name: "Transport: Scavenging Post", Level: 51, MaxHit: 0, BaseXP: 52.0,
+ JunkCost: map[string]int{"lawjunk": 1, "naturejunk": 1, "scrap_metal": 1},
+ Category: ModTransport, Element: "", TargetType: "self"},
+{ID: "transport_deep_mine", Name: "Transport: Deep Mine", Level: 61, MaxHit: 0, BaseXP: 60.0,
+ JunkCost: map[string]int{"lawjunk": 2, "ecojunk": 1, "scrap_metal": 1},
+ Category: ModTransport, Element: "", TargetType: "self"},
+{ID: "transport_fishing", Name: "Transport: Fishing Dock", Level: 55, MaxHit: 0, BaseXP: 56.0,
+ JunkCost: map[string]int{"lawjunk": 1, "hydrojunk": 1, "biojunk": 1, "scrap_metal": 1},
+ Category: ModTransport, Element: "", TargetType: "self"},
+```
+
+**Transport Implementation:**
+1. `trigger transport town`
+2. Check level, check junk cost
+3. If player is in combat: `"You can't teleport during combat!"`
+4. Cancel any active action
+5. Create a 3-tick action (`ActionTriggering`)
+6. Tick 1: `"You begin activating the transport module..."`
+7. Tick 2: `"The world shimmers around you..."`
+8. Tick 3: Consume junk, teleport player, award XP
+9. If player takes damage (mob hit) during the cast, cancel: `"Your transport was interrupted!"`
+10. On completion: move player to destination room, `g.Hub.EnterRoom(sess, destRoom)`, `g.doLook(sess)`
+11. Output: `"You materialize at <room_name>."`
+
+The `ModDef` needs a `Destination int` field for transport mods. Add this to the struct:
+
+```go
+type ModDef struct {
+ ID string
+ Name string
+ Level int
+ MaxHit int
+ BaseXP float64
+ JunkCost map[string]int
+ Category ModCategory
+ Element string
+ TargetType string
+ Destination int // room ID for transport mods (0 = N/A)
+}
+```
+
+Set `Destination` on each transport mod:
+- `transport_town`: `Destination: 1`
+- `transport_forge`: `Destination: 12`
+- `transport_mine`: `Destination: 6`
+- `transport_forest`: `Destination: 22`
+- `transport_scavenge`: `Destination: 9`
+- `transport_deep_mine`: `Destination: 7`
+- `transport_fishing`: `Destination: 10`
+
+---
+
+## 6. Enchant Mods
+
+### Jewelry Enchantment
+
+| ID | Name | Level | Junk Cost | XP | Effect |
+|---|---|---|---|---|---|
+| `enchant_1` | Enchant Level 1 | 7 | 1 cosmicjunk, 1 hydrojunk, 1 scrap | 17.5 | Enchant sapphire jewelry |
+| `enchant_2` | Enchant Level 2 | 27 | 1 cosmicjunk, 3 biojunk, 1 scrap | 37.0 | Enchant emerald jewelry |
+| `enchant_3` | Enchant Level 3 | 49 | 1 cosmicjunk, 5 solarjunk, 1 scrap | 59.0 | Enchant ruby jewelry |
+| `enchant_4` | Enchant Level 4 | 57 | 1 cosmicjunk, 10 ecojunk, 1 scrap | 67.0 | Enchant diamond jewelry |
+
+```go
+{ID: "enchant_1", Name: "Enchant Level 1", Level: 7, MaxHit: 0, BaseXP: 17.5,
+ JunkCost: map[string]int{"cosmicjunk": 1, "hydrojunk": 1, "scrap_metal": 1},
+ Category: ModEnchant, Element: "", TargetType: "inventory"},
+{ID: "enchant_2", Name: "Enchant Level 2", Level: 27, MaxHit: 0, BaseXP: 37.0,
+ JunkCost: map[string]int{"cosmicjunk": 1, "biojunk": 3, "scrap_metal": 1},
+ Category: ModEnchant, Element: "", TargetType: "inventory"},
+{ID: "enchant_3", Name: "Enchant Level 3", Level: 49, MaxHit: 0, BaseXP: 59.0,
+ JunkCost: map[string]int{"cosmicjunk": 1, "solarjunk": 5, "scrap_metal": 1},
+ Category: ModEnchant, Element: "", TargetType: "inventory"},
+{ID: "enchant_4", Name: "Enchant Level 4", Level: 57, MaxHit: 0, BaseXP: 67.0,
+ JunkCost: map[string]int{"cosmicjunk": 1, "ecojunk": 10, "scrap_metal": 1},
+ Category: ModEnchant, Element: "", TargetType: "inventory"},
+```
+
+**Enchantment mapping** — hardcoded in `science.go`:
+
+```go
+var enchantMap = map[string]map[string]string{
+ "enchant_1": {
+ "sapphire_ring": "ring_of_recoil",
+ "sapphire_necklace": "necklace_of_passage",
+ "sapphire_bracelet": "bracelet_of_clay",
+ },
+ "enchant_2": {
+ "emerald_ring": "ring_of_dueling",
+ "emerald_necklace": "binding_necklace",
+ "emerald_bracelet": "bracelet_of_slaughter",
+ },
+ "enchant_3": {
+ "ruby_ring": "ring_of_forging",
+ "ruby_necklace": "digsite_pendant",
+ "ruby_bracelet": "inoculation_bracelet",
+ },
+ "enchant_4": {
+ "diamond_ring": "ring_of_life",
+ "diamond_necklace": "phoenix_necklace",
+ "diamond_bracelet": "abyssal_bracelet",
+ },
+}
+```
+
+**Implementation:**
+1. `trigger enchant 1 <jewelry item>`
+2. Find matching item in inventory
+3. Check the item is a valid input for the enchant level (look up `enchantMap[mod.ID][item.ID]`)
+4. If not valid: `"You can't enchant that with this mod."`
+5. Consume junk, remove unenchanted item, add enchanted item
+6. Output: `"You enchant the <item> and it becomes a <result>!"`
+7. Award XP
+
+### Bolt Chipping (Bolt Enchantment equivalents)
+
+| ID | Name | Level | Junk Cost | XP | Effect |
+|---|---|---|---|---|---|
+| `chip_sapphire` | Chip Sapphire Bolts | 4 | 1 cosmicjunk, 1 hydrojunk, 1 scrap | 9.0 | Enchant 10 sapphire bolts |
+| `chip_emerald` | Chip Emerald Bolts | 27 | 1 cosmicjunk, 3 biojunk, 1 scrap | 37.0 | Enchant 10 emerald bolts |
+| `chip_ruby` | Chip Ruby Bolts | 49 | 1 cosmicjunk, 5 solarjunk, 1 bloodjunk, 1 scrap | 59.0 | Enchant 10 ruby bolts |
+| `chip_diamond` | Chip Diamond Bolts | 57 | 1 cosmicjunk, 10 ecojunk, 1 scrap | 67.0 | Enchant 10 diamond bolts |
+
+```go
+{ID: "chip_sapphire", Name: "Chip Sapphire Bolts", Level: 4, MaxHit: 0, BaseXP: 9.0,
+ JunkCost: map[string]int{"cosmicjunk": 1, "hydrojunk": 1, "scrap_metal": 1},
+ Category: ModEnchant, Element: "", TargetType: "inventory"},
+{ID: "chip_emerald", Name: "Chip Emerald Bolts", Level: 27, MaxHit: 0, BaseXP: 37.0,
+ JunkCost: map[string]int{"cosmicjunk": 1, "biojunk": 3, "scrap_metal": 1},
+ Category: ModEnchant, Element: "", TargetType: "inventory"},
+{ID: "chip_ruby", Name: "Chip Ruby Bolts", Level: 49, MaxHit: 0, BaseXP: 59.0,
+ JunkCost: map[string]int{"cosmicjunk": 1, "solarjunk": 5, "bloodjunk": 1, "scrap_metal": 1},
+ Category: ModEnchant, Element: "", TargetType: "inventory"},
+{ID: "chip_diamond", Name: "Chip Diamond Bolts", Level: 57, MaxHit: 0, BaseXP: 67.0,
+ JunkCost: map[string]int{"cosmicjunk": 1, "ecojunk": 10, "scrap_metal": 1},
+ Category: ModEnchant, Element: "", TargetType: "inventory"},
+```
+
+**Bolt chip mapping** — hardcoded in `science.go`:
+
+```go
+var chipMap = map[string]struct {
+ Input string
+ Output string
+ Qty int
+}{
+ "chip_sapphire": {"sapphire_bolts", "sapphire_bolts_e", 10},
+ "chip_emerald": {"emerald_bolts", "emerald_bolts_e", 10},
+ "chip_ruby": {"ruby_bolts", "ruby_bolts_e", 10},
+ "chip_diamond": {"diamond_bolts", "diamond_bolts_e", 10},
+}
+```
+
+**Implementation:**
+1. `trigger chip sapphire` (no target needed — auto-finds bolts)
+2. Check player has at least 10 of the input bolt type
+3. Consume junk + 10 bolts, add 10 enchanted bolts
+4. If fewer than 10: `"You need at least 10 sapphire bolts."`
+5. Output: `"You chip 10 sapphire bolts with arcane circuitry."`
+6. Award XP
+
+---
+
+## 7. Science Combat Mechanic
+
+### Attack Flow
+
+When a player triggers a combat mod (via `trigger <mod> <mob>` or via autocast during combat):
+
+1. **Level check:** Player Science level >= mod.Level. If not: `"You need level <N> science to trigger <mod>."`
+2. **Junk cost check:** Call `hasJunkCost(p, mod)`. If not: `"You don't have enough junk to trigger <mod>."`
+3. **Consume junk:** Call `consumeJunkCost(p, mod)`. Removes junk from inventory (respecting `provides_junk` and deck scrap exemption).
+4. **Attack roll:** `ScienceAttackRoll = (scienceLevel + 8) * (equipScienceAttack + 64)`
+ - `scienceLevel` = `p.Level(player.Science)`
+ - `equipScienceAttack` = sum of `ScienceBonus` from ALL equipped items (existing field on `ItemStats`, currently unused)
+ - No style bonus for science (science doesn't use attack styles)
+5. **Defense roll:** `MobDefenseRoll = (mobDefLevel + 9) * (mobScienceDefense + 64)`
+ - `mobDefLevel` = `mob.Defense` (existing field)
+ - `mobScienceDefense` = new field on `MobDef` / `MobInstance` (see Section 7.1)
+6. **Elemental weakness:** If `mob.Weakness == mod.Element`, multiply `ScienceAttackRoll` by 1.3 (30% accuracy bonus)
+7. **Hit check:** `combat.HitCheck(scienceAttackRoll, mobDefenseRoll)`
+8. **Damage:** If hit, `dmg = 1 + rand.Intn(mod.MaxHit)`. Max hit comes from the mod definition, NOT equipment.
+9. **XP:** Award `mod.BaseXP` to Science, `mod.BaseXP * 0.33` to Hitpoints
+
+### Attack Speed
+
+Science combat attack speed is always **5 ticks** (same as OSRS magic). This is the mod trigger speed, regardless of the equipped deck's `speed` field. The deck's `speed` field is only used if the player melees with the deck (which would be unusual but allowed).
+
+### Autocast Attack Replacement
+
+When autocast is set and the player attacks a mob (via `attack <mob>`), the `startCombat` function detects `p.AutocastMod != ""` and uses the science combat path:
+
+1. The player attack subscriber (in `startCombat`, the first `Ticks.Subscribe`) checks `p.AutocastMod`
+2. If autocast is set: call `g.scienceAttack(sess, p, mob, autocastMod)` instead of `g.playerAttack(sess, p, mob)`
+3. If `scienceAttack` returns false (out of junk), disable autocast: `p.AutocastMod = ""`, output `"You've run out of junk. Switching to melee."`, then call `g.playerAttack(sess, p, mob)` for this tick and all future ticks
+4. Attack speed when autocasting: use 5 ticks (science speed), NOT the weapon's melee speed
+
+### Direct `trigger` Combat
+
+When the player types `trigger solar bolt <mob>`:
+
+1. If player is already in combat: switch to using this mod as the current autocast. Output: `"You switch to triggering <mod>."`
+2. If not in combat: start combat with the target mob using science combat (same as `doAttack` but with science path). Set `p.AutocastMod = mod.ID`.
+
+### New Combat Functions
+
+**File: `internal/game/cmd_trigger.go`**
+
+```go
+func (g *Game) scienceAttack(sess *net.Session, p *player.Player, mob *world.MobInstance, mod *ModDef) bool {
+ if p.Level(player.Science) < mod.Level {
+ sess.WriteLine(fmt.Sprintf("You need level %d science to trigger %s.", mod.Level, mod.Name))
+ return false
+ }
+ if !g.hasJunkCost(p, mod) {
+ return false // signal out of junk
+ }
+
+ if g.processConsumeQueue(p, sess) {
+ p.ActionState = &ActionState{Type: ActionEating, TargetName: "food"}
+ return true // ate food this tick, still have junk
+ }
+
+ g.consumeJunkCost(p, mod)
+
+ equipSciBonus := g.totalEquipScienceAttack(p)
+ attRoll := (p.Level(player.Science) + 8) * (equipSciBonus + 64)
+
+ mobSciDef := mob.ScienceDefense // new field
+ defRoll := (mob.Defense + 9) * (mobSciDef + 64)
+
+ if mob.Weakness == mod.Element {
+ attRoll = attRoll * 13 / 10 // +30% accuracy
+ }
+
+ if combat.HitCheck(attRoll, defRoll) {
+ dmg := combat.RollDamage(mod.MaxHit)
+ mob.HP -= dmg
+ if mob.HP < 0 {
+ mob.HP = 0
+ }
+ if mob.HP < mob.MaxHP && mob.HP > 0 {
+ mob.StartRegen()
+ }
+
+ sciXP := int(mod.BaseXP)
+ hpXP := int(mod.BaseXP * 0.33)
+ var gains []xpGain
+ var leveledUp []player.SkillName
+
+ if newLevel := p.AddSkillXP(player.Science, sciXP); newLevel > 0 {
+ leveledUp = append(leveledUp, player.Science)
+ }
+ gains = append(gains, xpGain{string(player.Science), sciXP})
+
+ if newLevel := p.AddSkillXP(player.Hitpoints, hpXP); newLevel > 0 {
+ leveledUp = append(leveledUp, player.Hitpoints)
+ }
+ gains = append(gains, xpGain{string(player.Hitpoints), hpXP})
+
+ g.AccountStore.SaveCharacter(p)
+
+ for _, skill := range leveledUp {
+ sess.WriteLine(g.colorize(sess, "level_up", fmt.Sprintf("*** You are now level %d %s! ***", p.Level(skill), skill)))
+ }
+
+ mobName := mobDisplayName(mob, true)
+ prefix := fmt.Sprintf(" %s hits %s for %s damage.",
+ g.colorize(sess, "science_mod", mod.Name),
+ g.colorize(sess, "mob_name", mobName),
+ g.colorize(sess, "damage", fmt.Sprint(dmg)))
+ hpPart := fmt.Sprintf("[%s/%dhp]", g.colorize(sess, "enemy_hp", fmt.Sprint(mob.HP)), mob.MaxHP)
+ line := prefix + " " + hpPart
+ if p.OptionBool("xp_drops") && len(gains) > 0 {
+ var parts []string
+ for _, gain := range gains {
+ parts = append(parts, fmt.Sprintf("+%dxp %s", gain.XP, player.SkillAbbr[player.SkillName(gain.Skill)]))
+ }
+ line += g.colorize(sess, "xp", " ("+strings.Join(parts, ", ")+")")
+ }
+ sess.WriteLine(line)
+ } else {
+ sess.WriteLine(g.colorize(sess, "miss", fmt.Sprintf(" %s fails to connect.", mod.Name)))
+ }
+ return true
+}
+
+func (g *Game) totalEquipScienceAttack(p *player.Player) int {
+ total := 0
+ for _, itemID := range p.Equipment {
+ def, err := g.ItemStore.Load(itemID)
+ if err == nil {
+ total += def.Stats.ScienceBonus
+ }
+ }
+ return total
+}
+```
+
+### 7.1 Mob Science Defense and Weakness Fields
+
+Add two new fields to `MobDef` and `MobInstance`:
+
+**File: `internal/world/mob.go`**
+
+In `MobDef` struct (after `Defense` field at line 29):
+```go
+ScienceDefense int `yaml:"science_defense"`
+Weakness string `yaml:"weakness"`
+```
+
+In `MobInstance` struct (after `Defense` field at line 48):
+```go
+ScienceDefense int
+Weakness string
+```
+
+In the mob instantiation logic (wherever `MobInstance` is created from `MobDef`), copy these fields:
+```go
+inst.ScienceDefense = def.ScienceDefense
+inst.Weakness = def.Weakness
+```
+
+Mob YAML example with weakness:
+```yaml
+id: fire_elemental
+name: fire elemental
+weakness: hydro # weak to hydro (water) mods — +30% accuracy
+science_defense: 20
+```
+
+---
+
+## 8. Autocast System
+
+### Player Field
+
+**File: `internal/player/player.go`**
+
+Add to `Player` struct (after `VisualTickCurrent` at line 174):
+```go
+AutocastMod string `yaml:"-"`
+```
+
+The `yaml:"-"` tag means autocast is NOT saved to character YAML. Autocast resets on logout.
+
+### `cmd_autocast.go`
+
+```go
+package game
+
+import (
+ "fmt"
+ "strings"
+
+ "thehouseoficarus/internal/net"
+ "thehouseoficarus/internal/player"
+)
+
+func (g *Game) doAutocast(sess *net.Session, input string) {
+ p := sess.Player.(*player.Player)
+ input = strings.TrimSpace(input)
+
+ if input == "" {
+ if p.AutocastMod == "" {
+ sess.WriteLine("No autocast mod set. Use 'autocast <mod>' to set one.")
+ } else {
+ mod := GetMod(p.AutocastMod)
+ if mod == nil {
+ sess.WriteLine("Autocast: none (invalid mod)")
+ p.AutocastMod = ""
+ } else {
+ sess.WriteLine(fmt.Sprintf("Autocast: %s (Lv%d)", mod.Name, mod.Level))
+ }
+ }
+ return
+ }
+
+ if strings.ToLower(input) == "off" {
+ p.AutocastMod = ""
+ sess.WriteLine("Autocast disabled.")
+ return
+ }
+
+ mod := FindMod(strings.ToLower(input))
+ if mod == nil {
+ sess.WriteLine("Unknown mod.")
+ return
+ }
+
+ if mod.Category != ModCombat {
+ sess.WriteLine("You can only autocast combat mods.")
+ return
+ }
+
+ if p.Level(player.Science) < mod.Level {
+ sess.WriteLine(fmt.Sprintf("You need level %d science to autocast %s.", mod.Level, mod.Name))
+ return
+ }
+
+ p.AutocastMod = mod.ID
+ sess.WriteLine(fmt.Sprintf("Autocast set to: %s", mod.Name))
+}
+```
+
+### Integration with `cmd_attack.go`
+
+**File: `internal/game/cmd_attack.go`**
+
+Modify `startCombat()` to detect autocast. The key change is in the player attack subscriber:
+
+Replace the player attack subscriber in `startCombat` (lines 138-154):
+
+```go
+playerSpeed := g.playerWeaponSpeed(p)
+autocastActive := p.AutocastMod != ""
+if autocastActive {
+ playerSpeed = 5.0 // science combat speed
+}
+
+// ... (existing style display code, but skip style display if autocasting)
+
+g.Ticks.Subscribe(engine.ToTicks(playerSpeed), func() bool {
+ cs := combat.GetCombat(p.Name)
+ if cs == nil || !cs.Active {
+ return false
+ }
+ currentMob := g.MobStore.GetInstance(cs.MobID)
+ if currentMob == nil || currentMob.HP <= 0 {
+ g.endCombat(sess, p, currentMob)
+ return false
+ }
+
+ if p.AutocastMod != "" {
+ mod := GetMod(p.AutocastMod)
+ if mod != nil {
+ if !g.scienceAttack(sess, p, currentMob, mod) {
+ p.AutocastMod = ""
+ sess.WriteLine("You've run out of junk. Switching to melee.")
+ g.playerAttack(sess, p, currentMob)
+ }
+ } else {
+ p.AutocastMod = ""
+ g.playerAttack(sess, p, currentMob)
+ }
+ } else {
+ g.playerAttack(sess, p, currentMob)
+ }
+
+ if currentMob.HP <= 0 {
+ g.endCombat(sess, p, currentMob)
+ return false
+ }
+ return true
+})
+```
+
+Also modify the initial combat message in `startCombat`:
+```go
+if autocastActive {
+ mod := GetMod(p.AutocastMod)
+ sess.WriteLine(fmt.Sprintf("\nYou attack %s with %s!",
+ g.colorize(sess, "mob_name", mobDisplayName(mob, true)),
+ g.colorize(sess, "science_mod", mod.Name)))
+} else {
+ // existing style display
+ sess.WriteLine(fmt.Sprintf("\nYou attack %s!%s", ...))
+}
+```
+
+### XP Distribution for Science Combat
+
+Science combat does NOT use attack styles. XP is always:
+- `mod.BaseXP` to Science
+- `mod.BaseXP * 0.33` to Hitpoints
+
+This replaces the melee XP distribution in `awardCombatXP`. The `scienceAttack` function handles XP directly (see Section 7 code).
+
+---
+
+## 9. Deck Items
+
+### `provides_junk` Field
+
+**File: `internal/object/item.go`**
+
+Add to `ItemDef` struct (after `Ticks` field at line 58):
+```go
+ProvidesJunk string `yaml:"provides_junk"`
+```
+
+### Deck YAML Definitions
+
+#### `data/items/basic_deck.yaml`
+
+```yaml
+id: basic_deck
+name: basic deck
+color: "245"
+description: "A simple programmable deck. Removes the scrap requirement for triggering mods, but provides no elemental junk."
+value: 500
+equip_slot: main_hand
+weapon_type: science
+speed: 5
+stats:
+ science_bonus: 5
+```
+
+#### `data/items/solar_deck.yaml`
+
+```yaml
+id: solar_deck
+name: solar deck
+color: "196"
+description: "A programmable deck pulsing with solar energy. Provides unlimited solarjunk and removes the scrap requirement."
+value: 1500
+equip_slot: main_hand
+weapon_type: science
+speed: 5
+stats:
+ science_bonus: 10
+provides_junk: solarjunk
+```
+
+#### `data/items/hydro_deck.yaml`
+
+```yaml
+id: hydro_deck
+name: hydro deck
+color: "39"
+description: "A programmable deck infused with hydro circuitry. Provides unlimited hydrojunk and removes the scrap requirement."
+value: 1500
+equip_slot: main_hand
+weapon_type: science
+speed: 5
+stats:
+ science_bonus: 10
+provides_junk: hydrojunk
+```
+
+#### `data/items/eco_deck.yaml`
+
+```yaml
+id: eco_deck
+name: eco deck
+color: "34"
+description: "A programmable deck threaded with eco-organic circuits. Provides unlimited ecojunk and removes the scrap requirement."
+value: 1500
+equip_slot: main_hand
+weapon_type: science
+speed: 5
+stats:
+ science_bonus: 10
+provides_junk: ecojunk
+```
+
+#### `data/items/bio_deck.yaml`
+
+```yaml
+id: bio_deck
+name: bio deck
+color: "208"
+description: "A programmable deck infused with bio-synthetic membranes. Provides unlimited biojunk and removes the scrap requirement."
+value: 1500
+equip_slot: main_hand
+weapon_type: science
+speed: 5
+stats:
+ science_bonus: 10
+provides_junk: biojunk
+```
+
+#### `data/items/advanced_solar_deck.yaml`
+
+```yaml
+id: advanced_solar_deck
+name: advanced solar deck
+color: "196 bold"
+description: "A high-powered solar deck with enhanced circuitry. Provides unlimited solarjunk and removes the scrap requirement."
+value: 15000
+equip_slot: main_hand
+weapon_type: science
+speed: 5
+stats:
+ science_bonus: 20
+provides_junk: solarjunk
+```
+
+#### `data/items/advanced_hydro_deck.yaml`
+
+```yaml
+id: advanced_hydro_deck
+name: advanced hydro deck
+color: "39 bold"
+description: "A high-powered hydro deck with enhanced circuitry. Provides unlimited hydrojunk and removes the scrap requirement."
+value: 15000
+equip_slot: main_hand
+weapon_type: science
+speed: 5
+stats:
+ science_bonus: 20
+provides_junk: hydrojunk
+```
+
+#### `data/items/advanced_eco_deck.yaml`
+
+```yaml
+id: advanced_eco_deck
+name: advanced eco deck
+color: "34 bold"
+description: "A high-powered eco deck with enhanced circuitry. Provides unlimited ecojunk and removes the scrap requirement."
+value: 15000
+equip_slot: main_hand
+weapon_type: science
+speed: 5
+stats:
+ science_bonus: 20
+provides_junk: ecojunk
+```
+
+#### `data/items/advanced_bio_deck.yaml`
+
+```yaml
+id: advanced_bio_deck
+name: advanced bio deck
+color: "208 bold"
+description: "A high-powered bio deck with enhanced circuitry. Provides unlimited biojunk and removes the scrap requirement."
+value: 15000
+equip_slot: main_hand
+weapon_type: science
+speed: 5
+stats:
+ science_bonus: 20
+provides_junk: biojunk
+```
+
+---
+
+## 10. New Junk Items (Higher-tier)
+
+The base 4 junk types (solarjunk, hydrojunk, ecojunk, biojunk) are already defined in `scavenging.md`. These are the 6 additional junk types needed for science.
+
+#### `data/items/chaosjunk.yaml`
+
+```yaml
+id: chaosjunk
+name: chaosjunk
+color: "198"
+description: "A volatile fragment of unstable circuitry that crackles with chaotic energy. Used for mid-level science mods."
+value: 75
+stackable: true
+```
+
+#### `data/items/deathjunk.yaml`
+
+```yaml
+id: deathjunk
+name: deathjunk
+color: "231"
+description: "A cold, pale fragment of dead circuitry that absorbs light. Used for high-level science mods."
+value: 150
+stackable: true
+```
+
+#### `data/items/bloodjunk.yaml`
+
+```yaml
+id: bloodjunk
+name: bloodjunk
+color: "124"
+description: "A dark crimson fragment of circuitry that pulses as if alive. Used for the most powerful science mods."
+value: 300
+stackable: true
+```
+
+#### `data/items/lawjunk.yaml`
+
+```yaml
+id: lawjunk
+name: lawjunk
+color: "33"
+description: "A precisely calibrated fragment of navigation circuitry. Used for transport mods."
+value: 200
+stackable: true
+```
+
+#### `data/items/cosmicjunk.yaml`
+
+```yaml
+id: cosmicjunk
+name: cosmicjunk
+color: "99"
+description: "A shimmering fragment of cosmic circuitry that bends light around it. Used for enchantment mods."
+value: 120
+stackable: true
+```
+
+#### `data/items/naturejunk.yaml`
+
+```yaml
+id: naturejunk
+name: naturejunk
+color: "76"
+description: "A fragment of bio-organic circuitry intertwined with living matter. Used for processing and conversion mods."
+value: 100
+stackable: true
+```
+
+---
+
+## 11. Junk Sources — Higher-tier Altars
+
+The 6 higher-tier junk types are produced at new altars via the Scavenging skill's `id` command (same mechanic as base junk). Each altar requires a corresponding identifier tool and a minimum Scavenging level.
+
+**This is a dependency on the scavenging.md plan.** The scavenging plan currently defines 4 altars (solar, hydro, eco, bio). 6 more must be added:
+
+| Junk Type | Identifier Tool | Altar Station | Scavenging Level | XP/scrap |
+|-----------|----------------|---------------|-------------------|----------|
+| Chaosjunk | Chaos Identifier | Chaos Altar | 35 | 20 |
+| Cosmicjunk | Cosmic Identifier | Cosmic Altar | 27 | 14 |
+| Naturejunk | Nature Identifier | Nature Altar | 44 | 22 |
+| Lawjunk | Law Identifier | Law Altar | 54 | 28 |
+| Deathjunk | Death Identifier | Death Altar | 65 | 35 |
+| Bloodjunk | Blood Identifier | Blood Altar | 77 | 45 |
+
+### New Identifier Items
+
+#### `data/items/chaos_identifier.yaml`
+
+```yaml
+id: chaos_identifier
+name: chaos identifier
+color: "198"
+description: "A handheld scanner calibrated to isolate chaos-frequency signatures in scrap metal. Required to produce chaosjunk at a chaos altar."
+value: 3000
+```
+
+#### `data/items/cosmic_identifier.yaml`
+
+```yaml
+id: cosmic_identifier
+name: cosmic identifier
+color: "99"
+description: "A handheld scanner calibrated to isolate cosmic-frequency signatures in scrap metal. Required to produce cosmicjunk at a cosmic altar."
+value: 2000
+```
+
+#### `data/items/nature_identifier.yaml`
+
+```yaml
+id: nature_identifier
+name: nature identifier
+color: "76"
+description: "A handheld scanner calibrated to isolate nature-frequency signatures in scrap metal. Required to produce naturejunk at a nature altar."
+value: 4000
+```
+
+#### `data/items/law_identifier.yaml`
+
+```yaml
+id: law_identifier
+name: law identifier
+color: "33"
+description: "A handheld scanner calibrated to isolate law-frequency signatures in scrap metal. Required to produce lawjunk at a law altar."
+value: 6000
+```
+
+#### `data/items/death_identifier.yaml`
+
+```yaml
+id: death_identifier
+name: death identifier
+color: "231"
+description: "A handheld scanner calibrated to isolate death-frequency signatures in scrap metal. Required to produce deathjunk at a death altar."
+value: 10000
+```
+
+#### `data/items/blood_identifier.yaml`
+
+```yaml
+id: blood_identifier
+name: blood identifier
+color: "124"
+description: "A handheld scanner calibrated to isolate blood-frequency signatures in scrap metal. Required to produce bloodjunk at a blood altar."
+value: 20000
+```
+
+### New Altar Objects
+
+#### `data/objects/chaos_altar.yaml`
+
+```yaml
+id: chaos_altar
+name: chaos altar
+color: "198"
+description: "A crackling altar of unstable circuitry. Sparks arc between exposed conductors. Place scrap metal here with a chaos identifier to produce chaosjunk. Type 'id' to begin."
+```
+
+#### `data/objects/cosmic_altar.yaml`
+
+```yaml
+id: cosmic_altar
+name: cosmic altar
+color: "99"
+description: "A shimmering altar that seems to bend the space around it. Place scrap metal here with a cosmic identifier to produce cosmicjunk. Type 'id' to begin."
+```
+
+#### `data/objects/nature_altar.yaml`
+
+```yaml
+id: nature_altar
+name: nature altar
+color: "76"
+description: "A living altar of intertwined organic circuitry and vines. Place scrap metal here with a nature identifier to produce naturejunk. Type 'id' to begin."
+```
+
+#### `data/objects/law_altar.yaml`
+
+```yaml
+id: law_altar
+name: law altar
+color: "33"
+description: "A precisely geometric altar with perfectly aligned conductors. Place scrap metal here with a law identifier to produce lawjunk. Type 'id' to begin."
+```
+
+#### `data/objects/death_altar.yaml`
+
+```yaml
+id: death_altar
+name: death altar
+color: "231"
+description: "A pale, lifeless altar that absorbs all warmth from the air. Place scrap metal here with a death identifier to produce deathjunk. Type 'id' to begin."
+```
+
+#### `data/objects/blood_altar.yaml`
+
+```yaml
+id: blood_altar
+name: blood altar
+color: "124"
+description: "A dark crimson altar with channels that pulse like veins. Place scrap metal here with a blood identifier to produce bloodjunk. Type 'id' to begin."
+```
+
+### New Altar Rooms
+
+These rooms should branch off from the scavenging area or be placed in harder-to-reach locations. Use next available room IDs. Example layouts:
+
+```yaml
+# Cosmic Altar Chamber
+id: <next_id>
+name: "Cosmic Altar Chamber"
+description: "The walls of this chamber shimmer with an otherworldly iridescence. A {99 bold}cosmic altar{/} hovers slightly above the ground at the center, its surface rippling like a mirage."
+exits:
+ south: 9
+objects:
+ - id: cosmic_altar
+```
+
+```yaml
+# Chaos Altar Chamber
+id: <next_id>
+name: "Chaos Altar Chamber"
+description: "Sparks arc unpredictably across the walls of this unstable chamber. A {198 bold}chaos altar{/} sits at the center, crackling with volatile energy."
+exits:
+ south: 9
+objects:
+ - id: chaos_altar
+```
+
+```yaml
+# Nature Altar Chamber
+id: <next_id>
+name: "Nature Altar Chamber"
+description: "Vines and moss cover every surface. The air is thick and humid. A {76 bold}nature altar{/} rises from the ground, pulsing with organic circuitry."
+exits:
+ south: 9
+objects:
+ - id: nature_altar
+```
+
+```yaml
+# Law Altar Chamber
+id: <next_id>
+name: "Law Altar Chamber"
+description: "This chamber is perfectly symmetrical. Every surface is polished to a mirror finish. A {33 bold}law altar{/} stands at the exact center, its geometric perfection almost unsettling."
+exits:
+ south: 9
+objects:
+ - id: law_altar
+```
+
+```yaml
+# Death Altar Chamber
+id: <next_id>
+name: "Death Altar Chamber"
+description: "The temperature drops sharply as you enter this pale, silent chamber. A {231}death altar{/} dominates the room, its surface cold to the touch and utterly devoid of light."
+exits:
+ south: 9
+objects:
+ - id: death_altar
+```
+
+```yaml
+# Blood Altar Chamber
+id: <next_id>
+name: "Blood Altar Chamber"
+description: "The walls seem to breathe in this unsettling chamber. A {124 bold}blood altar{/} pulses at the center, its surface covered in dark crimson channels that flow like living veins."
+exits:
+ south: 9
+objects:
+ - id: blood_altar
+```
+
+**Alternative junk sources (mob drops, future shops):** Higher-tier junk can also drop from mobs. Example mob drop entries:
+```yaml
+drops:
+ loot:
+ - item_id: chaosjunk
+ weight: 10
+ quantity: 3
+```
+
+---
+
+## 12. `provides_junk` and Deck Scrap Exemption
+
+### Junk Cost Checking Logic
+
+**File: `internal/game/science.go`**
+
+```go
+func (g *Game) hasDeckEquipped(p *player.Player) bool {
+ itemID, ok := p.Equipment[object.SlotMainHand]
+ if !ok {
+ return false
+ }
+ def, err := g.ItemStore.Load(itemID)
+ if err != nil {
+ return false
+ }
+ return def.WeaponType == object.WeaponScience
+}
+
+func (g *Game) equippedProvidesJunk(p *player.Player) string {
+ itemID, ok := p.Equipment[object.SlotMainHand]
+ if !ok {
+ return ""
+ }
+ def, err := g.ItemStore.Load(itemID)
+ if err != nil {
+ return ""
+ }
+ return def.ProvidesJunk
+}
+
+func (g *Game) effectiveJunkCost(p *player.Player, mod *ModDef) map[string]int {
+ cost := make(map[string]int)
+ for k, v := range mod.JunkCost {
+ cost[k] = v
+ }
+
+ hasDeck := g.hasDeckEquipped(p)
+
+ // Any deck removes scrap requirement
+ if hasDeck {
+ delete(cost, "scrap_metal")
+ }
+
+ // Specific deck provides unlimited elemental junk
+ providesJunk := g.equippedProvidesJunk(p)
+ if providesJunk != "" {
+ delete(cost, providesJunk)
+ }
+
+ return cost
+}
+
+func (g *Game) hasJunkCost(p *player.Player, mod *ModDef) bool {
+ cost := g.effectiveJunkCost(p, mod)
+ for itemID, qty := range cost {
+ if p.CountItem(itemID) < qty {
+ return false
+ }
+ }
+ return true
+}
+
+func (g *Game) consumeJunkCost(p *player.Player, mod *ModDef) bool {
+ cost := g.effectiveJunkCost(p, mod)
+ for itemID, qty := range cost {
+ if !p.RemoveItem(itemID, qty) {
+ return false
+ }
+ }
+ g.AccountStore.SaveCharacter(p)
+ return true
+}
+
+func (g *Game) junkCostString(p *player.Player, mod *ModDef) string {
+ cost := g.effectiveJunkCost(p, mod)
+ if len(cost) == 0 {
+ return "free"
+ }
+ var parts []string
+ for itemID, qty := range cost {
+ def, _ := g.ItemStore.Load(itemID)
+ name := itemID
+ if def != nil {
+ name = def.Name
+ }
+ if qty > 1 {
+ parts = append(parts, fmt.Sprintf("%d %s", qty, name))
+ } else {
+ parts = append(parts, name)
+ }
+ }
+ sort.Strings(parts)
+ return strings.Join(parts, ", ")
+}
+```
+
+---
+
+## 13. Utility Mod Implementation Details
+
+### `doTrigger` Handler
+
+**File: `internal/game/cmd_trigger.go`**
+
+```go
+package game
+
+import (
+ "fmt"
+ "strings"
+
+ "thehouseoficarus/internal/combat"
+ "thehouseoficarus/internal/net"
+ "thehouseoficarus/internal/player"
+)
+
+func (g *Game) doTrigger(sess *net.Session, input string) {
+ p := sess.Player.(*player.Player)
+ input = strings.TrimSpace(input)
+
+ if input == "" {
+ sess.WriteLine("Trigger what? Type 'mods' to see available mods.")
+ return
+ }
+
+ // Parse: "trigger <mod> [<target>]"
+ // Try longest prefix match for mod name, remainder is target
+ mod, targetArg := g.parseTriggerArgs(input)
+ if mod == nil {
+ sess.WriteLine("Unknown mod. Type 'mods' to see available mods.")
+ return
+ }
+
+ if p.Level(player.Science) < mod.Level {
+ sess.WriteLine(fmt.Sprintf("You need level %d science to trigger %s.", mod.Level, mod.Name))
+ return
+ }
+
+ if !g.hasJunkCost(p, mod) {
+ sess.WriteLine(fmt.Sprintf("You don't have enough junk to trigger %s.", mod.Name))
+ return
+ }
+
+ switch mod.Category {
+ case ModCombat:
+ g.triggerCombatMod(sess, p, mod, targetArg)
+ case ModTransport:
+ g.triggerTransport(sess, p, mod)
+ case ModProcessing:
+ g.triggerProcessing(sess, p, mod, targetArg)
+ case ModUtility:
+ g.triggerUtility(sess, p, mod, targetArg)
+ case ModEnchant:
+ g.triggerEnchant(sess, p, mod, targetArg)
+ }
+}
+
+func (g *Game) parseTriggerArgs(input string) (*ModDef, string) {
+ lower := strings.ToLower(input)
+
+ // Try matching progressively longer prefixes
+ words := strings.Fields(lower)
+ for i := len(words); i > 0; i-- {
+ candidate := strings.Join(words[:i], " ")
+ mod := FindMod(candidate)
+ if mod != nil {
+ target := strings.TrimSpace(strings.Join(words[i:], " "))
+ return mod, target
+ }
+ }
+ return nil, ""
+}
+
+func (g *Game) triggerCombatMod(sess *net.Session, p *player.Player, mod *ModDef, targetArg string) {
+ // If already in combat, switch autocast to this mod
+ if cs := combat.GetCombat(p.Name); cs != nil {
+ p.AutocastMod = mod.ID
+ sess.WriteLine(fmt.Sprintf("You switch to triggering %s.", mod.Name))
+ return
+ }
+
+ // Not in combat — need a target
+ if p.Action != nil {
+ g.CancelAction(p)
+ }
+
+ var mobTarget string
+ if targetArg == "" {
+ mobTarget = g.resolveDefaultMob(p.RoomID)
+ if mobTarget == "" {
+ sess.WriteLine("Trigger on what?")
+ return
+ }
+ } else {
+ mobTarget = targetArg
+ }
+
+ mob := g.findMob(sess, mobTarget, p.RoomID)
+ if mob == nil {
+ return
+ }
+
+ if mob.HP <= 0 {
+ sess.WriteLine("That is already dead.")
+ return
+ }
+
+ if mob.Protected {
+ sess.WriteLine(fmt.Sprintf("You can't attack %s!", mobDisplayName(mob, true)))
+ return
+ }
+
+ if combat.IsMobInCombat(mob.InstanceID) {
+ sess.WriteLine(fmt.Sprintf("%s is already engaged in combat!", mobDisplayName(mob, false)))
+ return
+ }
+
+ p.AutocastMod = mod.ID
+ g.startCombat(sess, p, mob)
+}
+
+func (g *Game) triggerTransport(sess *net.Session, p *player.Player, mod *ModDef) {
+ if combat.GetCombat(p.Name) != nil {
+ sess.WriteLine("You can't teleport during combat!")
+ return
+ }
+
+ if p.Action != nil {
+ g.CancelAction(p)
+ }
+
+ g.consumeJunkCost(p, mod)
+
+ sciXP := int(mod.BaseXP)
+ if newLevel := p.AddSkillXP(player.Science, sciXP); newLevel > 0 {
+ sess.WriteLine(g.colorize(sess, "level_up", fmt.Sprintf("*** You are now level %d science! ***", p.Level(player.Science))))
+ }
+ if p.OptionBool("xp_drops") {
+ sess.WriteLine(g.colorize(sess, "xp", fmt.Sprintf("+%dxp sci", sciXP)))
+ }
+ g.AccountStore.SaveCharacter(p)
+
+ // Broadcast departure
+ if g.Hub != nil {
+ for _, other := range g.Hub.PlayersInRoom(p.RoomID) {
+ if other != sess {
+ other.WriteLine(fmt.Sprintf("\n%s teleports away.", p.Name))
+ }
+ }
+ }
+
+ sess.WriteLine(fmt.Sprintf("\nYou activate %s...", mod.Name))
+
+ oldRoom := p.RoomID
+ p.RoomID = mod.Destination
+ if g.Hub != nil {
+ g.Hub.LeaveRoom(sess, oldRoom)
+ g.Hub.EnterRoom(sess, p.RoomID)
+ }
+
+ room, _ := g.World.LoadRoom(p.RoomID)
+ destName := fmt.Sprintf("room %d", p.RoomID)
+ if room != nil {
+ destName = room.Name
+ }
+ sess.WriteLine(fmt.Sprintf("You materialize at %s.", destName))
+ g.AccountStore.SaveCharacter(p)
+ g.doLook(sess)
+}
+
+func (g *Game) triggerProcessing(sess *net.Session, p *player.Player, mod *ModDef, targetArg string) {
+ if combat.GetCombat(p.Name) != nil {
+ sess.WriteLine("You can't do that during combat!")
+ return
+ }
+
+ if targetArg == "" {
+ sess.WriteLine(fmt.Sprintf("Usage: trigger %s <item>", strings.ReplaceAll(mod.ID, "_", " ")))
+ return
+ }
+
+ // Find item in inventory
+ slot, inv := g.findInventoryItem(p, targetArg)
+ if slot < 0 {
+ sess.WriteLine("You don't have that item.")
+ return
+ }
+
+ itemDef, err := g.ItemStore.Load(inv.ItemID)
+ if err != nil || itemDef.Value <= 0 {
+ sess.WriteLine("That item has no value.")
+ return
+ }
+
+ if p.Action != nil {
+ g.CancelAction(p)
+ }
+
+ g.consumeJunkCost(p, mod)
+
+ creditValue := itemDef.Value
+ if mod.ID == "low_process" {
+ creditValue = itemDef.Value / 2
+ if creditValue < 1 {
+ creditValue = 1
+ }
+ }
+
+ // Remove 1 of the item
+ if inv.Quantity > 1 {
+ inv.Quantity--
+ } else {
+ p.SetInvSlot(slot, nil)
+ }
+
+ p.Credits += creditValue
+
+ sciXP := int(mod.BaseXP)
+ var leveledUp []player.SkillName
+ if newLevel := p.AddSkillXP(player.Science, sciXP); newLevel > 0 {
+ leveledUp = append(leveledUp, player.Science)
+ }
+ g.AccountStore.SaveCharacter(p)
+
+ for _, skill := range leveledUp {
+ sess.WriteLine(g.colorize(sess, "level_up", fmt.Sprintf("*** You are now level %d %s! ***", p.Level(skill), skill)))
+ }
+
+ sess.WriteLine(fmt.Sprintf("You process the %s. You receive %s credits.",
+ itemDef.Name, g.colorize(sess, "credits_pickup", fmt.Sprint(creditValue))))
+
+ if p.OptionBool("xp_drops") {
+ sess.WriteLine(g.colorize(sess, "xp", fmt.Sprintf("+%dxp sci", sciXP)))
+ }
+}
+
+func (g *Game) triggerUtility(sess *net.Session, p *player.Player, mod *ModDef, targetArg string) {
+ switch mod.ID {
+ case "bones_to_nutrients":
+ g.triggerBonesToNutrients(sess, p, mod)
+ case "em_grab":
+ g.triggerEmGrab(sess, p, mod, targetArg)
+ case "superheat":
+ g.triggerSuperheat(sess, p, mod, targetArg)
+ }
+}
+
+func (g *Game) triggerBonesToNutrients(sess *net.Session, p *player.Player, mod *ModDef) {
+ if combat.GetCombat(p.Name) != nil {
+ sess.WriteLine("You can't do that during combat!")
+ return
+ }
+
+ boneCount := p.CountItem("bones")
+ if boneCount == 0 {
+ sess.WriteLine("You don't have any bones.")
+ return
+ }
+
+ if p.Action != nil {
+ g.CancelAction(p)
+ }
+
+ g.consumeJunkCost(p, mod)
+
+ // Replace all bones with nutrient_bar
+ for i := 0; i < 28; i++ {
+ slot := p.InvSlot(i)
+ if slot != nil && slot.ItemID == "bones" {
+ slot.ItemID = "nutrient_bar"
+ // Quantity stays the same (bones are non-stackable, qty=1 each)
+ }
+ }
+
+ sciXP := int(mod.BaseXP) * boneCount
+ if newLevel := p.AddSkillXP(player.Science, sciXP); newLevel > 0 {
+ sess.WriteLine(g.colorize(sess, "level_up", fmt.Sprintf("*** You are now level %d science! ***", p.Level(player.Science))))
+ }
+ g.AccountStore.SaveCharacter(p)
+
+ sess.WriteLine(fmt.Sprintf("You convert %d bones into nutrient bars.", boneCount))
+ if p.OptionBool("xp_drops") {
+ sess.WriteLine(g.colorize(sess, "xp", fmt.Sprintf("+%dxp sci", sciXP)))
+ }
+}
+
+func (g *Game) triggerEmGrab(sess *net.Session, p *player.Player, mod *ModDef, targetArg string) {
+ if targetArg == "" {
+ sess.WriteLine("Grab what? Usage: trigger em grab <item>")
+ return
+ }
+
+ if p.FirstFreeSlot() < 0 {
+ sess.WriteLine("Your inventory is full.")
+ return
+ }
+
+ // Find ground item (bypass reservation)
+ items := g.World.GroundItems(p.RoomID)
+ var matchIdx int = -1
+ for i, gi := range items {
+ def, _ := g.ItemStore.Load(gi.ItemID)
+ if def != nil && def.MatchesName(targetArg) {
+ matchIdx = i
+ break
+ }
+ // Also try raw ID match
+ if strings.HasPrefix(gi.ItemID, strings.ToLower(targetArg)) {
+ matchIdx = i
+ break
+ }
+ }
+
+ if matchIdx < 0 {
+ sess.WriteLine("You don't see that here.")
+ return
+ }
+
+ if p.Action != nil {
+ g.CancelAction(p)
+ }
+
+ g.consumeJunkCost(p, mod)
+
+ gi := items[matchIdx]
+ g.World.RemoveGroundItem(p.RoomID, matchIdx)
+ g.addToInventory(p, gi.ItemID, gi.Quantity)
+
+ def, _ := g.ItemStore.Load(gi.ItemID)
+ name := gi.ItemID
+ if def != nil {
+ name = def.Name
+ }
+
+ sciXP := int(mod.BaseXP)
+ if newLevel := p.AddSkillXP(player.Science, sciXP); newLevel > 0 {
+ sess.WriteLine(g.colorize(sess, "level_up", fmt.Sprintf("*** You are now level %d science! ***", p.Level(player.Science))))
+ }
+ g.AccountStore.SaveCharacter(p)
+
+ sess.WriteLine(fmt.Sprintf("You magnetically pull the %s toward you.", name))
+ if p.OptionBool("xp_drops") {
+ sess.WriteLine(g.colorize(sess, "xp", fmt.Sprintf("+%dxp sci", sciXP)))
+ }
+}
+
+func (g *Game) triggerSuperheat(sess *net.Session, p *player.Player, mod *ModDef, targetArg string) {
+ if combat.GetCombat(p.Name) != nil {
+ sess.WriteLine("You can't do that during combat!")
+ return
+ }
+
+ if targetArg == "" {
+ sess.WriteLine("Superheat what? Usage: trigger superheat <ore>")
+ return
+ }
+
+ // Find the ore in inventory
+ slot, inv := g.findInventoryItem(p, targetArg)
+ if slot < 0 {
+ sess.WriteLine("You don't have that item.")
+ return
+ }
+
+ // Find a smelting recipe that uses this item
+ recipe := g.RecipeStore.FindByInput("smelt", inv.ItemID)
+ if recipe == nil {
+ sess.WriteLine("You can't superheat that.")
+ return
+ }
+
+ // Check skill level for the recipe
+ if recipe.Level > 0 && p.Level(player.SkillName(recipe.Skill)) < recipe.Level {
+ sess.WriteLine(fmt.Sprintf("You need level %d %s to smelt that.", recipe.Level, recipe.Skill))
+ return
+ }
+
+ // Check all recipe inputs are available
+ for _, inputItem := range recipe.Inputs {
+ if p.CountItem(inputItem.ID) < inputItem.Qty {
+ def, _ := g.ItemStore.Load(inputItem.ID)
+ name := inputItem.ID
+ if def != nil {
+ name = def.Name
+ }
+ sess.WriteLine(fmt.Sprintf("You need %d %s.", inputItem.Qty, name))
+ return
+ }
+ }
+
+ if p.Action != nil {
+ g.CancelAction(p)
+ }
+
+ g.consumeJunkCost(p, mod)
+
+ // Consume recipe inputs
+ for _, inputItem := range recipe.Inputs {
+ p.RemoveItem(inputItem.ID, inputItem.Qty)
+ }
+
+ // Add recipe output
+ g.addToInventory(p, recipe.Output.ID, recipe.Output.Qty)
+
+ // Award Science XP + Smithing XP
+ sciXP := int(mod.BaseXP)
+ if newLevel := p.AddSkillXP(player.Science, sciXP); newLevel > 0 {
+ sess.WriteLine(g.colorize(sess, "level_up", fmt.Sprintf("*** You are now level %d science! ***", p.Level(player.Science))))
+ }
+ if recipe.XP > 0 {
+ if newLevel := p.AddSkillXP(player.SkillName(recipe.Skill), recipe.XP); newLevel > 0 {
+ sess.WriteLine(g.colorize(sess, "level_up", fmt.Sprintf("*** You are now level %d %s! ***", p.Level(player.SkillName(recipe.Skill)), recipe.Skill)))
+ }
+ }
+ g.AccountStore.SaveCharacter(p)
+
+ outputDef, _ := g.ItemStore.Load(recipe.Output.ID)
+ outputName := recipe.Output.ID
+ if outputDef != nil {
+ outputName = outputDef.Name
+ }
+ inputDef, _ := g.ItemStore.Load(inv.ItemID)
+ inputName := inv.ItemID
+ if inputDef != nil {
+ inputName = inputDef.Name
+ }
+
+ sess.WriteLine(fmt.Sprintf("You superheat the %s and produce a %s.", inputName, outputName))
+ if p.OptionBool("xp_drops") {
+ parts := []string{fmt.Sprintf("+%dxp sci", sciXP)}
+ if recipe.XP > 0 {
+ parts = append(parts, fmt.Sprintf("+%dxp %s", recipe.XP, player.SkillAbbr[player.SkillName(recipe.Skill)]))
+ }
+ sess.WriteLine(g.colorize(sess, "xp", "("+strings.Join(parts, ", ")+")"))
+ }
+}
+
+func (g *Game) triggerEnchant(sess *net.Session, p *player.Player, mod *ModDef, targetArg string) {
+ // Check for bolt chip mods first
+ if chipInfo, ok := chipMap[mod.ID]; ok {
+ g.triggerChipBolts(sess, p, mod, chipInfo)
+ return
+ }
+
+ // Jewelry enchantment
+ enchants, ok := enchantMap[mod.ID]
+ if !ok {
+ sess.WriteLine("That enchantment has no known recipes.")
+ return
+ }
+
+ if targetArg == "" {
+ // List valid targets
+ sess.WriteLine(fmt.Sprintf("Enchant what? Use: trigger %s <jewelry item>", strings.ReplaceAll(mod.ID, "_", " ")))
+ return
+ }
+
+ slot, inv := g.findInventoryItem(p, targetArg)
+ if slot < 0 {
+ sess.WriteLine("You don't have that item.")
+ return
+ }
+
+ outputID, ok := enchants[inv.ItemID]
+ if !ok {
+ sess.WriteLine("You can't enchant that with this mod.")
+ return
+ }
+
+ if p.Action != nil {
+ g.CancelAction(p)
+ }
+
+ g.consumeJunkCost(p, mod)
+
+ // Replace item
+ inv.ItemID = outputID
+
+ sciXP := int(mod.BaseXP)
+ if newLevel := p.AddSkillXP(player.Science, sciXP); newLevel > 0 {
+ sess.WriteLine(g.colorize(sess, "level_up", fmt.Sprintf("*** You are now level %d science! ***", p.Level(player.Science))))
+ }
+ g.AccountStore.SaveCharacter(p)
+
+ outputDef, _ := g.ItemStore.Load(outputID)
+ outputName := outputID
+ if outputDef != nil {
+ outputName = outputDef.Name
+ }
+ inputDef, _ := g.ItemStore.Load(targetArg)
+ inputName := targetArg
+ if inputDef != nil {
+ inputName = inputDef.Name
+ }
+
+ sess.WriteLine(fmt.Sprintf("You enchant the %s and it becomes a %s!", inputName, outputName))
+ if p.OptionBool("xp_drops") {
+ sess.WriteLine(g.colorize(sess, "xp", fmt.Sprintf("+%dxp sci", sciXP)))
+ }
+}
+
+func (g *Game) triggerChipBolts(sess *net.Session, p *player.Player, mod *ModDef, chip chipEntry) {
+ count := p.CountItem(chip.Input)
+ if count < chip.Qty {
+ inputDef, _ := g.ItemStore.Load(chip.Input)
+ name := chip.Input
+ if inputDef != nil {
+ name = inputDef.Name
+ }
+ sess.WriteLine(fmt.Sprintf("You need at least %d %s.", chip.Qty, name))
+ return
+ }
+
+ if p.Action != nil {
+ g.CancelAction(p)
+ }
+
+ g.consumeJunkCost(p, mod)
+
+ p.RemoveItem(chip.Input, chip.Qty)
+ g.addToInventory(p, chip.Output, chip.Qty)
+
+ sciXP := int(mod.BaseXP)
+ if newLevel := p.AddSkillXP(player.Science, sciXP); newLevel > 0 {
+ sess.WriteLine(g.colorize(sess, "level_up", fmt.Sprintf("*** You are now level %d science! ***", p.Level(player.Science))))
+ }
+ g.AccountStore.SaveCharacter(p)
+
+ inputDef, _ := g.ItemStore.Load(chip.Input)
+ name := chip.Input
+ if inputDef != nil {
+ name = inputDef.Name
+ }
+
+ sess.WriteLine(fmt.Sprintf("You chip %d %s with arcane circuitry.", chip.Qty, name))
+ if p.OptionBool("xp_drops") {
+ sess.WriteLine(g.colorize(sess, "xp", fmt.Sprintf("+%dxp sci", sciXP)))
+ }
+}
+
+// Helper: find an inventory item by name/prefix
+func (g *Game) findInventoryItem(p *player.Player, input string) (int, *player.InventorySlot) {
+ lower := strings.ToLower(strings.TrimSpace(input))
+ for i := 0; i < 28; i++ {
+ slot := p.InvSlot(i)
+ if slot == nil {
+ continue
+ }
+ def, err := g.ItemStore.Load(slot.ItemID)
+ if err != nil {
+ continue
+ }
+ if def.MatchesName(lower) {
+ return i, slot
+ }
+ }
+ return -1, nil
+}
+```
+
+### Helper types for chip map
+
+```go
+type chipEntry struct {
+ Input string
+ Output string
+ Qty int
+}
+
+var chipMap = map[string]chipEntry{
+ "chip_sapphire": {"sapphire_bolts", "sapphire_bolts_e", 10},
+ "chip_emerald": {"emerald_bolts", "emerald_bolts_e", 10},
+ "chip_ruby": {"ruby_bolts", "ruby_bolts_e", 10},
+ "chip_diamond": {"diamond_bolts", "diamond_bolts_e", 10},
+}
+```
+
+---
+
+## 14. Score Page / Mods List
+
+### `cmd_mods.go`
+
+**File: `internal/game/cmd_mods.go`**
+
+```go
+package game
+
+import (
+ "fmt"
+ "sort"
+ "strings"
+
+ "thehouseoficarus/internal/color"
+ "thehouseoficarus/internal/net"
+ "thehouseoficarus/internal/player"
+)
+
+func (g *Game) doMods(sess *net.Session) {
+ p := sess.Player.(*player.Player)
+ mode := g.colorMode(sess)
+ sciLevel := p.Level(player.Science)
+
+ categories := []struct {
+ Name string
+ Cat ModCategory
+ }{
+ {"Combat", ModCombat},
+ {"Processing", ModProcessing},
+ {"Utility", ModUtility},
+ {"Transport", ModTransport},
+ {"Enchantment", ModEnchant},
+ }
+
+ sess.WriteLine("")
+
+ anyMods := false
+ for _, cat := range categories {
+ var mods []*ModDef
+ for _, m := range AllMods {
+ if m.Category == cat.Cat && m.Level <= sciLevel {
+ mods = append(mods, m)
+ }
+ }
+ if len(mods) == 0 {
+ continue
+ }
+
+ sort.Slice(mods, func(i, j int) bool {
+ return mods[i].Level < mods[j].Level
+ })
+
+ t := &Table{Title: cat.Name + " Mods", Columns: []string{
+ color.Render(mode, color.Parse("75"), "Mod"),
+ color.Render(mode, color.Parse("230"), "Lv"),
+ color.Render(mode, color.Parse("245"), "Cost"),
+ color.Render(mode, color.Parse("222"), "XP"),
+ }}
+
+ for _, m := range mods {
+ costStr := g.modCostDisplay(p, m)
+ xpStr := fmt.Sprintf("%.1f", m.BaseXP)
+ if m.MaxHit > 0 {
+ xpStr += fmt.Sprintf(" (max %d)", m.MaxHit)
+ }
+ t.Rows = append(t.Rows, []string{
+ color.Render(mode, color.Parse("75"), m.Name),
+ color.Render(mode, color.Parse("230"), fmt.Sprint(m.Level)),
+ color.Render(mode, color.Parse("245"), costStr),
+ color.Render(mode, color.Parse("222"), xpStr),
+ })
+ }
+
+ for _, line := range t.Render(p.OptionBool("unicode")) {
+ sess.WriteLine(line)
+ }
+ anyMods = true
+ }
+
+ if !anyMods {
+ sess.WriteLine("You don't know any mods yet. Train Science to unlock mods.")
+ }
+
+ if p.AutocastMod != "" {
+ mod := GetMod(p.AutocastMod)
+ if mod != nil {
+ sess.WriteLine(fmt.Sprintf("\nAutocast: %s", g.colorize(sess, "science_mod", mod.Name)))
+ }
+ }
+}
+
+func (g *Game) modCostDisplay(p *player.Player, mod *ModDef) string {
+ cost := g.effectiveJunkCost(p, mod)
+ if len(cost) == 0 {
+ return "free"
+ }
+ var parts []string
+ // Sort keys for consistent display
+ keys := make([]string, 0, len(cost))
+ for k := range cost {
+ keys = append(keys, k)
+ }
+ sort.Strings(keys)
+ for _, itemID := range keys {
+ qty := cost[itemID]
+ def, _ := g.ItemStore.Load(itemID)
+ name := itemID
+ if def != nil {
+ name = def.Name
+ }
+ if qty > 1 {
+ parts = append(parts, fmt.Sprintf("%d %s", qty, name))
+ } else {
+ parts = append(parts, name)
+ }
+ }
+ return strings.Join(parts, ", ")
+}
+```
+
+**Display format example:**
+
+```
+Combat Mods
+┌───────────────┬────┬──────────────────────────────┬──────────────┐
+│ Mod │ Lv │ Cost │ XP │
+├───────────────┼────┼──────────────────────────────┼──────────────┤
+│ Bio Strike │ 1 │ 2 biojunk │ 5.5 (max 4) │
+│ Hydro Strike │ 5 │ 3 hydrojunk, 1 ecojunk │ 7.5 (max 6) │
+│ ... │ │ │ │
+└───────────────┴────┴──────────────────────────────┴──────────────┘
+
+Autocast: Solar Bolt
+```
+
+Note: the "Cost" column reflects the effective cost after deck bonuses. If the player has a solar deck equipped, solarjunk and scrap_metal are removed from cost display.
+
+---
+
+## 15. Code Changes — Complete File List
+
+### New Files
+
+| File | Purpose |
+|------|---------|
+| `internal/game/science.go` | `ModDef` struct, `AllMods` slice, `modByID` map, `FindMod`, `GetMod`, `enchantMap`, `chipMap`, `chipEntry`, junk cost helpers (`hasDeckEquipped`, `equippedProvidesJunk`, `effectiveJunkCost`, `hasJunkCost`, `consumeJunkCost`, `junkCostString`) |
+| `internal/game/cmd_trigger.go` | `doTrigger` handler, `parseTriggerArgs`, `triggerCombatMod`, `triggerTransport`, `triggerProcessing`, `triggerUtility`, `triggerEnchant`, `triggerBonesToNutrients`, `triggerEmGrab`, `triggerSuperheat`, `triggerChipBolts`, `scienceAttack`, `totalEquipScienceAttack`, `findInventoryItem` |
+| `internal/game/cmd_autocast.go` | `doAutocast` handler |
+| `internal/game/cmd_mods.go` | `doMods` handler, `modCostDisplay` |
+| `data/items/basic_deck.yaml` | Basic Deck item |
+| `data/items/solar_deck.yaml` | Solar Deck item |
+| `data/items/hydro_deck.yaml` | Hydro Deck item |
+| `data/items/eco_deck.yaml` | Eco Deck item |
+| `data/items/bio_deck.yaml` | Bio Deck item |
+| `data/items/advanced_solar_deck.yaml` | Advanced Solar Deck item |
+| `data/items/advanced_hydro_deck.yaml` | Advanced Hydro Deck item |
+| `data/items/advanced_eco_deck.yaml` | Advanced Eco Deck item |
+| `data/items/advanced_bio_deck.yaml` | Advanced Bio Deck item |
+| `data/items/chaosjunk.yaml` | Chaosjunk item |
+| `data/items/deathjunk.yaml` | Deathjunk item |
+| `data/items/bloodjunk.yaml` | Bloodjunk item |
+| `data/items/lawjunk.yaml` | Lawjunk item |
+| `data/items/cosmicjunk.yaml` | Cosmicjunk item |
+| `data/items/naturejunk.yaml` | Naturejunk item |
+| `data/items/nutrient_bar.yaml` | Nutrient Bar item (bones_to_nutrients output) |
+| `data/items/chaos_identifier.yaml` | Chaos Identifier tool |
+| `data/items/cosmic_identifier.yaml` | Cosmic Identifier tool |
+| `data/items/nature_identifier.yaml` | Nature Identifier tool |
+| `data/items/law_identifier.yaml` | Law Identifier tool |
+| `data/items/death_identifier.yaml` | Death Identifier tool |
+| `data/items/blood_identifier.yaml` | Blood Identifier tool |
+| `data/objects/chaos_altar.yaml` | Chaos Altar object |
+| `data/objects/cosmic_altar.yaml` | Cosmic Altar object |
+| `data/objects/nature_altar.yaml` | Nature Altar object |
+| `data/objects/law_altar.yaml` | Law Altar object |
+| `data/objects/death_altar.yaml` | Death Altar object |
+| `data/objects/blood_altar.yaml` | Blood Altar object |
+| `data/help/trigger.yaml` | Help for trigger command |
+| `data/help/autocast.yaml` | Help for autocast command |
+| `data/help/mods.yaml` | Help for mods command |
+| `data/help/science.yaml` | Help for Science skill |
+| New altar rooms (6 YAML files) | Rooms containing each new altar |
+
+### Modified Files
+
+| File | Changes |
+|------|---------|
+| `internal/object/item.go` | Add `ProvidesJunk string` field to `ItemDef` (after `Ticks` at line 58). Add `ScienceAttack int` and `ScienceDamage int` to `ItemStats` if needed for future granularity (currently `ScienceBonus` covers it). |
+| `internal/player/player.go` | Add `AutocastMod string` field to `Player` struct with `yaml:"-"` tag (after `VisualTickCurrent` at line 174). |
+| `internal/world/mob.go` | Add `ScienceDefense int` and `Weakness string` fields to `MobDef` (after `Defense` at line 29). Add same fields to `MobInstance` (after `Defense` at line 48). Copy fields in mob instantiation. |
+| `internal/game/game.go` | Add `"trigger"`, `"cast"` to `ClassActive` case in `classifyCommand()` (line 146). Add `"autocast"`, `"auto"`, `"mods"`, `"modlist"` to `ClassInstant` case (line 138). Add dispatch cases in `executeCommand()`: `case "trigger", "cast":` → `g.doTrigger(...)`, `case "autocast", "auto":` → `g.doAutocast(...)`, `case "mods", "modlist":` → `g.doMods(...)`. |
+| `internal/game/cmd_attack.go` | Modify `startCombat()` to check `p.AutocastMod`: if set, use 5-tick science speed and call `scienceAttack` instead of `playerAttack`. Handle junk depletion fallback to melee. Modify initial combat message for autocast. |
+| `internal/game/action_state.go` | Add `ActionTriggering ActionType = "triggering"` constant. Add case in `Description()`: `case ActionTriggering: return "triggering " + a.TargetName`. |
+| `internal/game/game.go` (`ProcessQueuedCommands`) | Add `ActionTriggering` to the persistent action types list in the switch at line 472 (if transport mods use multi-tick actions). |
+
+### Nutrient Bar Item
+
+#### `data/items/nutrient_bar.yaml`
+
+```yaml
+id: nutrient_bar
+name: nutrient bar
+color: "220"
+description: "A compressed bar of processed nutrients. Restores a small amount of health."
+value: 5
+heal_value: 2
+eat_message: "You eat the nutrient bar."
+```
+
+---
+
+## 16. Junk Checking Helper — Detailed Logic
+
+The junk cost system must handle 3 layers:
+1. **Base cost:** From `mod.JunkCost` (includes `scrap_metal: 1` on every mod)
+2. **Deck exemption:** If ANY deck equipped (weapon_type == "science"), remove `scrap_metal` from cost
+3. **Provides junk:** If equipped deck has `provides_junk: "solarjunk"`, remove `solarjunk` from cost
+
+### Step-by-step for `hasJunkCost`:
+
+```
+1. Copy mod.JunkCost into a new map
+2. Check if player has a science weapon in main_hand
+ - If yes: delete "scrap_metal" from cost map
+ - If yes AND weapon has provides_junk: delete that junk from cost map
+3. For each remaining (junk_id, qty) in cost map:
+ - If p.CountItem(junk_id) < qty: return false
+4. Return true
+```
+
+### Step-by-step for `consumeJunkCost`:
+
+```
+1. Compute effective cost (same as hasJunkCost)
+2. For each (junk_id, qty) in effective cost:
+ - p.RemoveItem(junk_id, qty)
+3. Save character
+```
+
+### Edge cases:
+
+- **Stacking:** Junk items are stackable, so `CountItem` returns the total across all inventory slots. `RemoveItem` handles removing across multiple slots.
+- **Multiple junk types from same deck:** A deck only provides ONE junk type. No deck provides multiple types.
+- **No deck, no scrap:** If player has no deck and no scrap in inventory, all mods fail with "You don't have enough junk."
+- **Zero-cost:** After deck reductions, if the effective cost map is empty, the mod is free to cast. This is intended (e.g., bio_strike with a bio deck costs nothing — same as OSRS air strike with staff of air + no rune essence needed).
+
+---
+
+## 17. Enchanted Items (Output definitions)
+
+These items are the output of enchantment mods. They need item YAML files.
+
+### Ring of Recoil (from sapphire ring)
+
+```yaml
+id: ring_of_recoil
+name: ring of recoil
+color: "39"
+description: "An enchanted sapphire ring that reflects a portion of melee damage back to the attacker."
+value: 500
+equip_slot: ring
+stats:
+ defense_bonus: 0
+```
+
+### Necklace of Passage (from sapphire necklace)
+
+```yaml
+id: necklace_of_passage
+name: necklace of passage
+color: "39"
+description: "An enchanted sapphire necklace that can teleport the wearer to various locations."
+value: 750
+equip_slot: neck
+```
+
+### Ring of Dueling (from emerald ring)
+
+```yaml
+id: ring_of_dueling
+name: ring of dueling
+color: "34"
+description: "An enchanted emerald ring used for teleporting to dueling arenas."
+value: 1000
+equip_slot: ring
+```
+
+### Ring of Forging (from ruby ring)
+
+```yaml
+id: ring_of_forging
+name: ring of forging
+color: "196"
+description: "An enchanted ruby ring that prevents ore from failing to smelt."
+value: 2000
+equip_slot: ring
+```
+
+### Ring of Life (from diamond ring)
+
+```yaml
+id: ring_of_life
+name: ring of life
+color: "231"
+description: "An enchanted diamond ring that teleports you to safety when your HP drops critically low."
+value: 5000
+equip_slot: ring
+```
+
+### Binding Necklace (from emerald necklace)
+
+```yaml
+id: binding_necklace
+name: binding necklace
+color: "34"
+description: "An enchanted emerald necklace. Provides a 100% success rate when identifying junk at altars."
+value: 1200
+equip_slot: neck
+```
+
+### Digsite Pendant (from ruby necklace)
+
+```yaml
+id: digsite_pendant
+name: digsite pendant
+color: "196"
+description: "An enchanted ruby necklace that can teleport you to dig sites."
+value: 2500
+equip_slot: neck
+```
+
+### Phoenix Necklace (from diamond necklace)
+
+```yaml
+id: phoenix_necklace
+name: phoenix necklace
+color: "231"
+description: "An enchanted diamond necklace that restores HP when you drop below 20% health."
+value: 5500
+equip_slot: neck
+```
+
+### Bracelets
+
+```yaml
+id: bracelet_of_clay
+name: bracelet of clay
+color: "39"
+description: "An enchanted sapphire bracelet. Softens clay for easier crafting."
+value: 500
+equip_slot: hands
+```
+
+```yaml
+id: bracelet_of_slaughter
+name: bracelet of slaughter
+color: "34"
+description: "An enchanted emerald bracelet that provides bonus XP on kills."
+value: 1200
+equip_slot: hands
+```
+
+```yaml
+id: inoculation_bracelet
+name: inoculation bracelet
+color: "196"
+description: "An enchanted ruby bracelet that provides resistance to poison."
+value: 2500
+equip_slot: hands
+```
+
+```yaml
+id: abyssal_bracelet
+name: abyssal bracelet
+color: "231"
+description: "An enchanted diamond bracelet that increases scavenging output."
+value: 6000
+equip_slot: hands
+```
+
+### Enchanted Bolts
+
+```yaml
+id: sapphire_bolts_e
+name: sapphire bolts (e)
+color: "39"
+description: "Enchanted sapphire-tipped bolts. Have a chance to drain the target's Science level."
+value: 30
+stackable: true
+equip_slot: ammo
+stats:
+ attack_bonus: 4
+```
+
+```yaml
+id: emerald_bolts_e
+name: emerald bolts (e)
+color: "34"
+description: "Enchanted emerald-tipped bolts. Have a chance to poison the target."
+value: 55
+stackable: true
+equip_slot: ammo
+stats:
+ attack_bonus: 6
+```
+
+```yaml
+id: ruby_bolts_e
+name: ruby bolts (e)
+color: "196"
+description: "Enchanted ruby-tipped bolts. Have a chance to deal extra damage based on the target's remaining HP."
+value: 100
+stackable: true
+equip_slot: ammo
+stats:
+ attack_bonus: 8
+```
+
+```yaml
+id: diamond_bolts_e
+name: diamond bolts (e)
+color: "231"
+description: "Enchanted diamond-tipped bolts. Have a chance to ignore the target's defense."
+value: 180
+stackable: true
+equip_slot: ammo
+stats:
+ attack_bonus: 10
+```
+
+### Unenchanted Jewelry (prerequisites — needed if not already in game)
+
+These items need to exist for the enchantment system to work. Create if they don't already exist:
+
+```yaml
+# data/items/sapphire_ring.yaml
+id: sapphire_ring
+name: sapphire ring
+color: "39"
+description: "A ring set with a sapphire. Can be enchanted."
+value: 200
+equip_slot: ring
+
+# data/items/sapphire_necklace.yaml
+id: sapphire_necklace
+name: sapphire necklace
+color: "39"
+description: "A necklace set with a sapphire. Can be enchanted."
+value: 250
+equip_slot: neck
+
+# data/items/sapphire_bracelet.yaml
+id: sapphire_bracelet
+name: sapphire bracelet
+color: "39"
+description: "A bracelet set with a sapphire. Can be enchanted."
+value: 200
+equip_slot: hands
+
+# data/items/emerald_ring.yaml
+id: emerald_ring
+name: emerald ring
+color: "34"
+description: "A ring set with an emerald. Can be enchanted."
+value: 400
+equip_slot: ring
+
+# data/items/emerald_necklace.yaml
+id: emerald_necklace
+name: emerald necklace
+color: "34"
+description: "A necklace set with an emerald. Can be enchanted."
+value: 450
+equip_slot: neck
+
+# data/items/emerald_bracelet.yaml
+id: emerald_bracelet
+name: emerald bracelet
+color: "34"
+description: "A bracelet set with an emerald. Can be enchanted."
+value: 400
+equip_slot: hands
+
+# data/items/ruby_ring.yaml
+id: ruby_ring
+name: ruby ring
+color: "196"
+description: "A ring set with a ruby. Can be enchanted."
+value: 800
+equip_slot: ring
+
+# data/items/ruby_necklace.yaml
+id: ruby_necklace
+name: ruby necklace
+color: "196"
+description: "A necklace set with a ruby. Can be enchanted."
+value: 850
+equip_slot: neck
+
+# data/items/ruby_bracelet.yaml
+id: ruby_bracelet
+name: ruby bracelet
+color: "196"
+description: "A bracelet set with a ruby. Can be enchanted."
+value: 800
+equip_slot: hands
+
+# data/items/diamond_ring.yaml
+id: diamond_ring
+name: diamond ring
+color: "231"
+description: "A ring set with a diamond. Can be enchanted."
+value: 1500
+equip_slot: ring
+
+# data/items/diamond_necklace.yaml
+id: diamond_necklace
+name: diamond necklace
+color: "231"
+description: "A necklace set with a diamond. Can be enchanted."
+value: 1600
+equip_slot: neck
+
+# data/items/diamond_bracelet.yaml
+id: diamond_bracelet
+name: diamond bracelet
+color: "231"
+description: "A bracelet set with a diamond. Can be enchanted."
+value: 1500
+equip_slot: hands
+```
+
+### Unenchanted Bolts (prerequisites)
+
+```yaml
+# data/items/sapphire_bolts.yaml
+id: sapphire_bolts
+name: sapphire bolts
+color: "39"
+description: "Bolts tipped with sapphire. Can be enchanted via science."
+value: 20
+stackable: true
+equip_slot: ammo
+stats:
+ attack_bonus: 3
+
+# data/items/emerald_bolts.yaml
+id: emerald_bolts
+name: emerald bolts
+color: "34"
+description: "Bolts tipped with emerald. Can be enchanted via science."
+value: 40
+stackable: true
+equip_slot: ammo
+stats:
+ attack_bonus: 5
+
+# data/items/ruby_bolts.yaml
+id: ruby_bolts
+name: ruby bolts
+color: "196"
+description: "Bolts tipped with ruby. Can be enchanted via science."
+value: 75
+stackable: true
+equip_slot: ammo
+stats:
+ attack_bonus: 7
+
+# data/items/diamond_bolts.yaml
+id: diamond_bolts
+name: diamond bolts
+color: "231"
+description: "Bolts tipped with diamond. Can be enchanted via science."
+value: 130
+stackable: true
+equip_slot: ammo
+stats:
+ attack_bonus: 9
+```
+
+---
+
+## 18. `RecipeStore.FindByInput` — New Method
+
+The `superheat` mod needs to find a smelting recipe given an input item. Add a helper method to `RecipeStore`:
+
+**File: `internal/action/recipe.go`** (or wherever RecipeStore is defined)
+
+```go
+func (s *RecipeStore) FindByInput(recipeType string, itemID string) *Recipe {
+ // Search all recipes of the given type for one that uses itemID as an input
+ recipes := s.LoadAll(recipeType)
+ for _, r := range recipes {
+ for _, input := range r.Inputs {
+ if input.ID == itemID {
+ return r
+ }
+ }
+ }
+ return nil
+}
+```
+
+If `RecipeStore` doesn't have a `LoadAll` method that filters by type, add one. The existing `RecipeStore` likely loads recipes from `data/recipes/` YAML files. Check the actual implementation to determine the exact approach.
+
+---
+
+## 19. Rooms — Deck and Junk Sources
+
+### Deck Spawn Locations
+
+Decks can be found as spawns, mob drops, or shop purchases. For initial implementation, place basic decks as ground spawns:
+
+**Update room 1 (Town Square) or a magic shop room:**
+
+```yaml
+spawns:
+ - item_id: basic_deck
+ quantity: 1
+ respawn_ticks: 120
+```
+
+Elemental decks should be rarer — place in harder areas or as mob drops. Advanced decks should only come from high-level content.
+
+### Higher-tier Junk Altar Rooms
+
+See Section 11 for full room definitions. Place them branching off from the scavenging area or in a dedicated "altar wing."
+
+---
+
+## 20. Help Files
+
+### `data/help/trigger.yaml`
+
+```yaml
+id: trigger
+title: "Trigger"
+aliases:
+ - cast
+body: |
+ Usage: trigger <mod> [target]
+ cast <mod> [target]
+
+ Trigger a science mod. Combat mods target a mob and initiate science-based combat.
+ Utility mods act on items in your inventory or on yourself.
+
+ Examples:
+ trigger bio strike goblin - Attack a goblin with Bio Strike
+ trigger low process iron ore - Convert iron ore to credits
+ trigger transport town - Teleport to Town Square
+ trigger enchant 1 sapphire ring - Enchant a sapphire ring
+ trigger superheat copper ore - Smelt copper ore without a furnace
+ trigger em grab bones - Pick up bones from the ground
+
+ All mods cost junk (and 1 scrap metal unless you have a deck equipped).
+ Type 'mods' to see your available mods and their costs.
+
+ See also: autocast, mods, science
+```
+
+### `data/help/autocast.yaml`
+
+```yaml
+id: autocast
+title: "Autocast"
+aliases:
+ - auto
+body: |
+ Usage: autocast <mod>
+ autocast off
+ autocast
+
+ Set a combat mod to automatically trigger each attack tick during combat.
+ When autocast is active, attacking a mob will use science combat instead
+ of melee, consuming junk each tick.
+
+ If you run out of junk, autocast disables and you switch to melee attacks.
+
+ autocast - Show current autocast setting
+ autocast solar bolt - Set autocast to Solar Bolt
+ autocast off - Disable autocast
+
+ See also: trigger, mods, science
+```
+
+### `data/help/mods.yaml`
+
+```yaml
+id: mods
+title: "Mods"
+aliases:
+ - modlist
+body: |
+ Usage: mods
+
+ Display all science mods you have the level to use, organized by
+ category (Combat, Processing, Utility, Transport, Enchantment).
+
+ Shows the junk cost for each mod (adjusted for your equipped deck).
+ Also shows your current autocast setting.
+
+ See also: trigger, autocast, science
+```
+
+### `data/help/science.yaml`
+
+```yaml
+id: science
+title: "Science"
+body: |
+ Science is the skill that powers mods — powerful modules that can be
+ triggered for combat, teleportation, item processing, and enchanting.
+
+ Key concepts:
+ - Mods are triggered with 'trigger <mod>' or 'cast <mod>'
+ - Every mod costs a combination of junk items
+ - All mods also cost 1 scrap metal, UNLESS you have a deck equipped
+ - Wielding a deck (science weapon) removes the scrap requirement
+ - Elemental decks also provide unlimited supply of their junk type
+
+ Junk types: solarjunk, hydrojunk, ecojunk, biojunk (from scavenging)
+ chaosjunk, deathjunk, bloodjunk (combat mod components)
+ lawjunk (transport), cosmicjunk (enchantment), naturejunk (processing)
+
+ Decks: basic deck, solar deck, hydro deck, eco deck, bio deck
+ Advanced versions of each elemental deck also exist.
+
+ Combat: Use 'trigger <mod> <mob>' or set 'autocast <mod>' then 'attack <mob>'
+
+ Type 'mods' to see all mods you can currently use.
+
+ See also: trigger, autocast, mods, scavenging
+```
+
+---
+
+## 21. Implementation Order — Step-by-step Checklist
+
+### Phase 1: Core Infrastructure
+
+- [ ] 1. Add `ProvidesJunk string` field to `ItemDef` in `internal/object/item.go`
+- [ ] 2. Add `AutocastMod string` field (yaml:"-") to `Player` in `internal/player/player.go`
+- [ ] 3. Add `ScienceDefense int` and `Weakness string` to `MobDef` and `MobInstance` in `internal/world/mob.go`
+- [ ] 4. Copy `ScienceDefense` and `Weakness` in mob instantiation logic
+- [ ] 5. Add `ActionTriggering ActionType = "triggering"` to `internal/game/action_state.go`
+- [ ] 6. Add `Description()` case for `ActionTriggering`
+- [ ] 7. Run `make vet` to verify no compile errors
+
+### Phase 2: Mod Definitions
+
+- [ ] 8. Create `internal/game/science.go` with `ModDef`, `AllMods`, `modByID`, `FindMod`, `GetMod`
+- [ ] 9. Define all 20 combat mods (bio/hydro/eco/solar × strike/bolt/blast/wave/surge)
+- [ ] 10. Define all 7 transport mods
+- [ ] 11. Define all 2 processing mods (low_process, high_process)
+- [ ] 12. Define 3 utility mods (bones_to_nutrients, em_grab, superheat)
+- [ ] 13. Define 4 enchant mods (enchant_1 through enchant_4)
+- [ ] 14. Define 4 chip mods (chip_sapphire through chip_diamond)
+- [ ] 15. Define `enchantMap` and `chipMap`
+- [ ] 16. Implement junk cost helpers (`hasDeckEquipped`, `equippedProvidesJunk`, `effectiveJunkCost`, `hasJunkCost`, `consumeJunkCost`)
+- [ ] 17. Run `make vet`
+
+### Phase 3: Commands
+
+- [ ] 18. Create `internal/game/cmd_mods.go` with `doMods`
+- [ ] 19. Create `internal/game/cmd_autocast.go` with `doAutocast`
+- [ ] 20. Create `internal/game/cmd_trigger.go` with `doTrigger`, `parseTriggerArgs`, all trigger sub-handlers, `scienceAttack`, `totalEquipScienceAttack`, `findInventoryItem`
+- [ ] 21. Update `classifyCommand()` in `game.go` — add trigger/cast to ClassActive, autocast/auto/mods/modlist to ClassInstant
+- [ ] 22. Update `executeCommand()` in `game.go` — add dispatch cases for all new commands
+- [ ] 23. Run `make vet`
+
+### Phase 4: Combat Integration
+
+- [ ] 24. Modify `startCombat()` in `cmd_attack.go` to detect `p.AutocastMod` and use science combat path
+- [ ] 25. Handle autocast speed (5 ticks for science) vs melee weapon speed
+- [ ] 26. Handle junk depletion → fallback to melee with message
+- [ ] 27. Update initial combat message for autocast mode
+- [ ] 28. Add `ActionTriggering` to persistent actions list in `ProcessQueuedCommands` if needed
+- [ ] 29. Run `make test`
+
+### Phase 5: Data Files — Junk Items
+
+- [ ] 30. Create `data/items/chaosjunk.yaml`
+- [ ] 31. Create `data/items/deathjunk.yaml`
+- [ ] 32. Create `data/items/bloodjunk.yaml`
+- [ ] 33. Create `data/items/lawjunk.yaml`
+- [ ] 34. Create `data/items/cosmicjunk.yaml`
+- [ ] 35. Create `data/items/naturejunk.yaml`
+- [ ] 36. Create `data/items/nutrient_bar.yaml`
+
+### Phase 6: Data Files — Deck Items
+
+- [ ] 37. Create `data/items/basic_deck.yaml`
+- [ ] 38. Create `data/items/solar_deck.yaml`
+- [ ] 39. Create `data/items/hydro_deck.yaml`
+- [ ] 40. Create `data/items/eco_deck.yaml`
+- [ ] 41. Create `data/items/bio_deck.yaml`
+- [ ] 42. Create `data/items/advanced_solar_deck.yaml`
+- [ ] 43. Create `data/items/advanced_hydro_deck.yaml`
+- [ ] 44. Create `data/items/advanced_eco_deck.yaml`
+- [ ] 45. Create `data/items/advanced_bio_deck.yaml`
+
+### Phase 7: Data Files — Enchanting Prerequisites
+
+- [ ] 46. Create all unenchanted jewelry items (12 files: sapphire/emerald/ruby/diamond × ring/necklace/bracelet)
+- [ ] 47. Create all enchanted jewelry items (12 files)
+- [ ] 48. Create all unenchanted bolt items (4 files)
+- [ ] 49. Create all enchanted bolt items (4 files)
+
+### Phase 8: Data Files — Higher-tier Altars (Scavenging extension)
+
+- [ ] 50. Create 6 new identifier items
+- [ ] 51. Create 6 new altar objects
+- [ ] 52. Create 6 new altar rooms (use next available room IDs)
+- [ ] 53. Update altar config in scavenging `doIdentify` logic to recognize new altars
+- [ ] 54. Place deck spawns in appropriate rooms
+
+### Phase 9: Data Files — Help
+
+- [ ] 55. Create `data/help/trigger.yaml`
+- [ ] 56. Create `data/help/autocast.yaml`
+- [ ] 57. Create `data/help/mods.yaml`
+- [ ] 58. Create `data/help/science.yaml`
+
+### Phase 10: Testing and Polish
+
+- [ ] 59. Run `make test` — all existing tests pass
+- [ ] 60. Run `make vet` — no warnings
+- [ ] 61. Manual test: trigger bio strike on a mob at level 1
+- [ ] 62. Manual test: autocast solar bolt, attack mob, verify science combat
+- [ ] 63. Manual test: run out of junk during autocast, verify melee fallback
+- [ ] 64. Manual test: equip solar deck, verify solarjunk and scrap removed from costs
+- [ ] 65. Manual test: trigger low process on an item, verify credits
+- [ ] 66. Manual test: trigger transport town, verify teleport
+- [ ] 67. Manual test: mods command displays correct costs
+- [ ] 68. Manual test: enchant a sapphire ring
+- [ ] 69. Manual test: superheat an ore
+- [ ] 70. Manual test: bones to nutrients
+- [ ] 71. Manual test: em grab a ground item
+
+---
+
+## 22. Dependencies
+
+| Dependency | Status | Required For |
+|------------|--------|-------------|
+| `scavenging.md` — base junk types (solarjunk, hydrojunk, ecojunk, biojunk, scrap_metal) | Must be implemented first | All mods use these as costs |
+| `scavenging.md` — `doIdentify` logic | Must be implemented first | Higher-tier altars reuse the same mechanic |
+| `scavenging.md` — needs update for 6 new altars | Update needed | Chaosjunk, deathjunk, bloodjunk, lawjunk, cosmicjunk, naturejunk production |
+| `ItemStats.ScienceBonus` field | Already exists (unused) | Science attack roll uses this |
+| `WeaponScience` weapon type | Already exists | Deck detection |
+| `combat.HitCheck`, `combat.RollDamage` | Already exist | Science combat reuses these |
+| `player.Science` skill constant | Already exists | Level checks, XP |
+| Crafting system (for jewelry) | May need implementation | Unenchanted jewelry creation |
+| Fletching system (for bolts) | May need implementation | Unenchanted bolt creation |
+
+### Circular dependency note
+
+The enchantment and bolt chipping mods require unenchanted jewelry and bolts to exist. These come from Crafting (jewelry) and Fletching (bolts). If those skills aren't implemented yet, the enchant/chip items can still be defined in YAML and placed as mob drops or ground spawns for testing. The enchantment system itself will work regardless — it just needs the input items to exist in inventory.
+
+---
+
+## 23. Complete `AllMods` Definition
+
+For reference, the complete `AllMods` slice in `internal/game/science.go`:
+
+```go
+var AllMods = []*ModDef{
+ // Bio Strikes (Air equivalents)
+ {ID: "bio_strike", Name: "Bio Strike", Level: 1, MaxHit: 4, BaseXP: 5.5,
+ JunkCost: map[string]int{"biojunk": 2, "scrap_metal": 1},
+ Category: ModCombat, Element: "bio", TargetType: "mob"},
+ {ID: "bio_bolt", Name: "Bio Bolt", Level: 17, MaxHit: 9, BaseXP: 13.5,
+ JunkCost: map[string]int{"biojunk": 2, "chaosjunk": 1, "scrap_metal": 1},
+ Category: ModCombat, Element: "bio", TargetType: "mob"},
+ {ID: "bio_blast", Name: "Bio Blast", Level: 41, MaxHit: 13, BaseXP: 25.5,
+ JunkCost: map[string]int{"biojunk": 3, "chaosjunk": 1, "deathjunk": 1, "scrap_metal": 1},
+ Category: ModCombat, Element: "bio", TargetType: "mob"},
+ {ID: "bio_wave", Name: "Bio Wave", Level: 62, MaxHit: 17, BaseXP: 36.0,
+ JunkCost: map[string]int{"biojunk": 5, "deathjunk": 1, "bloodjunk": 1, "scrap_metal": 1},
+ Category: ModCombat, Element: "bio", TargetType: "mob"},
+ {ID: "bio_surge", Name: "Bio Surge", Level: 81, MaxHit: 21, BaseXP: 44.0,
+ JunkCost: map[string]int{"biojunk": 7, "bloodjunk": 1, "scrap_metal": 1},
+ Category: ModCombat, Element: "bio", TargetType: "mob"},
+
+ // Hydro Strikes (Water equivalents)
+ {ID: "hydro_strike", Name: "Hydro Strike", Level: 5, MaxHit: 6, BaseXP: 7.5,
+ JunkCost: map[string]int{"hydrojunk": 3, "ecojunk": 1, "scrap_metal": 1},
+ Category: ModCombat, Element: "hydro", TargetType: "mob"},
+ {ID: "hydro_bolt", Name: "Hydro Bolt", Level: 23, MaxHit: 10, BaseXP: 16.5,
+ JunkCost: map[string]int{"hydrojunk": 3, "ecojunk": 2, "scrap_metal": 1},
+ Category: ModCombat, Element: "hydro", TargetType: "mob"},
+ {ID: "hydro_blast", Name: "Hydro Blast", Level: 47, MaxHit: 14, BaseXP: 28.5,
+ JunkCost: map[string]int{"hydrojunk": 5, "ecojunk": 3, "chaosjunk": 1, "scrap_metal": 1},
+ Category: ModCombat, Element: "hydro", TargetType: "mob"},
+ {ID: "hydro_wave", Name: "Hydro Wave", Level: 65, MaxHit: 18, BaseXP: 37.5,
+ JunkCost: map[string]int{"hydrojunk": 7, "ecojunk": 5, "deathjunk": 1, "scrap_metal": 1},
+ Category: ModCombat, Element: "hydro", TargetType: "mob"},
+ {ID: "hydro_surge", Name: "Hydro Surge", Level: 85, MaxHit: 22, BaseXP: 46.0,
+ JunkCost: map[string]int{"hydrojunk": 10, "ecojunk": 7, "bloodjunk": 1, "scrap_metal": 1},
+ Category: ModCombat, Element: "hydro", TargetType: "mob"},
+
+ // Eco Strikes (Earth equivalents)
+ {ID: "eco_strike", Name: "Eco Strike", Level: 9, MaxHit: 7, BaseXP: 9.5,
+ JunkCost: map[string]int{"ecojunk": 2, "biojunk": 2, "scrap_metal": 1},
+ Category: ModCombat, Element: "eco", TargetType: "mob"},
+ {ID: "eco_bolt", Name: "Eco Bolt", Level: 29, MaxHit: 11, BaseXP: 19.5,
+ JunkCost: map[string]int{"ecojunk": 3, "biojunk": 2, "scrap_metal": 1},
+ Category: ModCombat, Element: "eco", TargetType: "mob"},
+ {ID: "eco_blast", Name: "Eco Blast", Level: 53, MaxHit: 15, BaseXP: 31.5,
+ JunkCost: map[string]int{"ecojunk": 4, "biojunk": 3, "chaosjunk": 1, "scrap_metal": 1},
+ Category: ModCombat, Element: "eco", TargetType: "mob"},
+ {ID: "eco_wave", Name: "Eco Wave", Level: 70, MaxHit: 19, BaseXP: 40.0,
+ JunkCost: map[string]int{"ecojunk": 7, "biojunk": 5, "deathjunk": 1, "scrap_metal": 1},
+ Category: ModCombat, Element: "eco", TargetType: "mob"},
+ {ID: "eco_surge", Name: "Eco Surge", Level: 90, MaxHit: 23, BaseXP: 48.5,
+ JunkCost: map[string]int{"ecojunk": 10, "biojunk": 7, "bloodjunk": 1, "scrap_metal": 1},
+ Category: ModCombat, Element: "eco", TargetType: "mob"},
+
+ // Solar Strikes (Fire equivalents)
+ {ID: "solar_strike", Name: "Solar Strike", Level: 13, MaxHit: 8, BaseXP: 11.5,
+ JunkCost: map[string]int{"solarjunk": 3, "ecojunk": 2, "scrap_metal": 1},
+ Category: ModCombat, Element: "solar", TargetType: "mob"},
+ {ID: "solar_bolt", Name: "Solar Bolt", Level: 35, MaxHit: 12, BaseXP: 22.5,
+ JunkCost: map[string]int{"solarjunk": 4, "ecojunk": 3, "scrap_metal": 1},
+ Category: ModCombat, Element: "solar", TargetType: "mob"},
+ {ID: "solar_blast", Name: "Solar Blast", Level: 59, MaxHit: 16, BaseXP: 34.5,
+ JunkCost: map[string]int{"solarjunk": 5, "ecojunk": 4, "chaosjunk": 1, "scrap_metal": 1},
+ Category: ModCombat, Element: "solar", TargetType: "mob"},
+ {ID: "solar_wave", Name: "Solar Wave", Level: 75, MaxHit: 20, BaseXP: 42.5,
+ JunkCost: map[string]int{"solarjunk": 7, "ecojunk": 5, "deathjunk": 1, "scrap_metal": 1},
+ Category: ModCombat, Element: "solar", TargetType: "mob"},
+ {ID: "solar_surge", Name: "Solar Surge", Level: 95, MaxHit: 24, BaseXP: 51.0,
+ JunkCost: map[string]int{"solarjunk": 10, "ecojunk": 7, "bloodjunk": 1, "scrap_metal": 1},
+ Category: ModCombat, Element: "solar", TargetType: "mob"},
+
+ // Processing Mods
+ {ID: "low_process", Name: "Low Level Processing", Level: 21, MaxHit: 0, BaseXP: 31.0,
+ JunkCost: map[string]int{"naturejunk": 3, "solarjunk": 1, "scrap_metal": 1},
+ Category: ModProcessing, Element: "", TargetType: "inventory"},
+ {ID: "high_process", Name: "High Level Processing", Level: 55, MaxHit: 0, BaseXP: 65.0,
+ JunkCost: map[string]int{"naturejunk": 5, "solarjunk": 1, "scrap_metal": 1},
+ Category: ModProcessing, Element: "", TargetType: "inventory"},
+
+ // Utility Mods
+ {ID: "bones_to_nutrients", Name: "Bones to Nutrients", Level: 15, MaxHit: 0, BaseXP: 25.0,
+ JunkCost: map[string]int{"naturejunk": 2, "ecojunk": 2, "scrap_metal": 1},
+ Category: ModUtility, Element: "", TargetType: "self"},
+ {ID: "em_grab", Name: "Electromagnetic Grab", Level: 33, MaxHit: 0, BaseXP: 43.0,
+ JunkCost: map[string]int{"lawjunk": 1, "biojunk": 1, "scrap_metal": 1},
+ Category: ModUtility, Element: "", TargetType: "ground_item"},
+ {ID: "superheat", Name: "Superheat Item", Level: 43, MaxHit: 0, BaseXP: 53.0,
+ JunkCost: map[string]int{"naturejunk": 4, "solarjunk": 1, "scrap_metal": 1},
+ Category: ModUtility, Element: "", TargetType: "inventory"},
+
+ // Transport Mods
+ {ID: "transport_town", Name: "Transport: Town Square", Level: 25, MaxHit: 0, BaseXP: 27.0,
+ JunkCost: map[string]int{"lawjunk": 1, "solarjunk": 1, "biojunk": 1, "scrap_metal": 1},
+ Category: ModTransport, Element: "", TargetType: "self", Destination: 1},
+ {ID: "transport_forge", Name: "Transport: Forge", Level: 31, MaxHit: 0, BaseXP: 35.0,
+ JunkCost: map[string]int{"lawjunk": 1, "ecojunk": 1, "scrap_metal": 1},
+ Category: ModTransport, Element: "", TargetType: "self", Destination: 12},
+ {ID: "transport_mine", Name: "Transport: Mining Pit", Level: 37, MaxHit: 0, BaseXP: 40.0,
+ JunkCost: map[string]int{"lawjunk": 1, "ecojunk": 1, "solarjunk": 1, "scrap_metal": 1},
+ Category: ModTransport, Element: "", TargetType: "self", Destination: 6},
+ {ID: "transport_forest", Name: "Transport: Forest", Level: 45, MaxHit: 0, BaseXP: 48.0,
+ JunkCost: map[string]int{"lawjunk": 1, "ecojunk": 1, "biojunk": 1, "scrap_metal": 1},
+ Category: ModTransport, Element: "", TargetType: "self", Destination: 22},
+ {ID: "transport_scavenge", Name: "Transport: Scavenging Post", Level: 51, MaxHit: 0, BaseXP: 52.0,
+ JunkCost: map[string]int{"lawjunk": 1, "naturejunk": 1, "scrap_metal": 1},
+ Category: ModTransport, Element: "", TargetType: "self", Destination: 9},
+ {ID: "transport_deep_mine", Name: "Transport: Deep Mine", Level: 61, MaxHit: 0, BaseXP: 60.0,
+ JunkCost: map[string]int{"lawjunk": 2, "ecojunk": 1, "scrap_metal": 1},
+ Category: ModTransport, Element: "", TargetType: "self", Destination: 7},
+ {ID: "transport_fishing", Name: "Transport: Fishing Dock", Level: 55, MaxHit: 0, BaseXP: 56.0,
+ JunkCost: map[string]int{"lawjunk": 1, "hydrojunk": 1, "biojunk": 1, "scrap_metal": 1},
+ Category: ModTransport, Element: "", TargetType: "self", Destination: 10},
+
+ // Enchant Mods
+ {ID: "enchant_1", Name: "Enchant Level 1", Level: 7, MaxHit: 0, BaseXP: 17.5,
+ JunkCost: map[string]int{"cosmicjunk": 1, "hydrojunk": 1, "scrap_metal": 1},
+ Category: ModEnchant, Element: "", TargetType: "inventory"},
+ {ID: "enchant_2", Name: "Enchant Level 2", Level: 27, MaxHit: 0, BaseXP: 37.0,
+ JunkCost: map[string]int{"cosmicjunk": 1, "biojunk": 3, "scrap_metal": 1},
+ Category: ModEnchant, Element: "", TargetType: "inventory"},
+ {ID: "enchant_3", Name: "Enchant Level 3", Level: 49, MaxHit: 0, BaseXP: 59.0,
+ JunkCost: map[string]int{"cosmicjunk": 1, "solarjunk": 5, "scrap_metal": 1},
+ Category: ModEnchant, Element: "", TargetType: "inventory"},
+ {ID: "enchant_4", Name: "Enchant Level 4", Level: 57, MaxHit: 0, BaseXP: 67.0,
+ JunkCost: map[string]int{"cosmicjunk": 1, "ecojunk": 10, "scrap_metal": 1},
+ Category: ModEnchant, Element: "", TargetType: "inventory"},
+
+ // Chip Bolt Mods
+ {ID: "chip_sapphire", Name: "Chip Sapphire Bolts", Level: 4, MaxHit: 0, BaseXP: 9.0,
+ JunkCost: map[string]int{"cosmicjunk": 1, "hydrojunk": 1, "scrap_metal": 1},
+ Category: ModEnchant, Element: "", TargetType: "inventory"},
+ {ID: "chip_emerald", Name: "Chip Emerald Bolts", Level: 27, MaxHit: 0, BaseXP: 37.0,
+ JunkCost: map[string]int{"cosmicjunk": 1, "biojunk": 3, "scrap_metal": 1},
+ Category: ModEnchant, Element: "", TargetType: "inventory"},
+ {ID: "chip_ruby", Name: "Chip Ruby Bolts", Level: 49, MaxHit: 0, BaseXP: 59.0,
+ JunkCost: map[string]int{"cosmicjunk": 1, "solarjunk": 5, "bloodjunk": 1, "scrap_metal": 1},
+ Category: ModEnchant, Element: "", TargetType: "inventory"},
+ {ID: "chip_diamond", Name: "Chip Diamond Bolts", Level: 57, MaxHit: 0, BaseXP: 67.0,
+ JunkCost: map[string]int{"cosmicjunk": 1, "ecojunk": 10, "scrap_metal": 1},
+ Category: ModEnchant, Element: "", TargetType: "inventory"},
+}
+```
+
+Total mods: 20 combat + 2 processing + 3 utility + 7 transport + 4 enchant + 4 chip = **40 mods**.
+
+---
+
+## 24. `ScienceBonus` Usage Clarification
+
+The existing `ItemStats.ScienceBonus` field at `internal/object/item.go:71` is currently unused. With this implementation:
+
+- `ScienceBonus` on `ItemStats` is used as the **science attack bonus** for the attack roll calculation
+- It is analogous to `AttackBonus` for melee
+- All equipped items' `ScienceBonus` values are summed via `totalEquipScienceAttack()`
+- Decks have `science_bonus: 10` (basic) or `science_bonus: 20` (advanced) in their stats
+- Other equipment can also have `science_bonus` to boost science accuracy (e.g., mystic robes equivalent)
+
+**No new `ItemStats` fields are needed.** The existing `ScienceBonus` field covers the science attack roll. The max hit for science combat comes entirely from the mod definition, not from equipment (same as OSRS magic).
+
+---
+
+## 25. CombatLevel Update
+
+The existing `CombatLevel()` at `internal/player/player.go:314` already includes Science at `+0.125`. However, OSRS uses a dominant-style formula where magic competes with melee/ranged. For a more accurate OSRS formula:
+
+```go
+func (p *Player) CombatLevel() int {
+ base := 0.25 * float64(p.Level(Defense)+p.Level(Hitpoints)+p.Level(Technology))
+ att := float64(p.Level(Attack))
+ str := float64(p.Level(Strength))
+ melee := 0.325 * (att + str)
+ ranged := 0.325 * float64(p.Level(Ranged)) * 1.5
+ science := 0.325 * float64(p.Level(Science)) * 1.5
+
+ dominant := melee
+ if ranged > dominant {
+ dominant = ranged
+ }
+ if science > dominant {
+ dominant = science
+ }
+
+ return int(base + dominant)
+}
+```
+
+**This change is OPTIONAL.** The current formula works. Update only if the game design wants science to compete with melee/ranged for combat level dominance. If updated, remove the existing `+0.125 * Science` line and add science to the dominant-style calculation.
+
+---
+
+## 26. Color Target
+
+Add a new color target for science mod names:
+
+**File: `internal/config/colors.go`** (or wherever color targets are defined)
+
+Add `"science_mod"` as a configurable color target with a default of `"99"` (purple/cosmic).
+
+This allows players to customize the color of mod names in combat output via the `color` command:
+```
+color science_mod 39
+```