diff options
Diffstat (limited to 'skill_plans/science.md')
| -rw-r--r-- | skill_plans/science.md | 3115 |
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 +``` |
