diff options
| author | historia <[not public]> | 2026-06-19 18:33:43 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-06-19 18:33:43 -0400 |
| commit | 2bec24859af8f03c80a0a58e3240519942450167 (patch) | |
| tree | dcb8baa5591fdeca51e179a63a08d46b014cb85c | |
| parent | 0ea4eec5dd2122c6704216971eb1249276297867 (diff) | |
| download | thehouseoficarus-2bec24859af8f03c80a0a58e3240519942450167.tar.gz | |
readme
| -rw-r--r-- | README.md | 31 | ||||
| -rw-r--r-- | TODO.md | 32 | ||||
| -rw-r--r-- | skill_plans/agility.md | 1814 | ||||
| -rw-r--r-- | skill_plans/assassin.md | 1895 | ||||
| -rw-r--r-- | skill_plans/combat.md | 3402 | ||||
| -rw-r--r-- | skill_plans/construction.md | 84 | ||||
| -rw-r--r-- | skill_plans/farming.md | 2123 | ||||
| -rw-r--r-- | skill_plans/hacking.md | 1391 | ||||
| -rw-r--r-- | skill_plans/pharmacy.md | 2421 | ||||
| -rw-r--r-- | skill_plans/scavenging.md | 950 | ||||
| -rw-r--r-- | skill_plans/science.md | 3115 | ||||
| -rw-r--r-- | skill_plans/technology.md | 1907 | ||||
| -rw-r--r-- | skill_plans/thieving.md | 1391 |
13 files changed, 8 insertions, 20548 deletions
@@ -104,29 +104,14 @@ Those who embraced AI control of society and tried to stop the Neoluddites throu **Craggers** Colonists who don't take a side in the conflict one way or the other. Everyone from criminals escaping their past to once well-paid Earth engineers overseeing terraforming operations. -# FAQ - -**Can I improve how fast the game responds?** - -THOI is designed to run with 600ms game ticks. If you want the game to "respond instantly" to commands, that's not possible and would break numerous things. Ticks are a game mechanic, not a limitation. You'll see that commands like `say`, `score`, or `style` take place immediately without game ticks. Ticks enables advanced techniques like tech flicking (enabling/disabling tech on the same tick to gain a buff without draining battery) and power skilling (restarting an action on certain ticks to avoid delays). - -**Why is movement so slow?** - -Distance between nodes is an important mechanic (e.g. mining node to bank to furnace). It completely determines the pace of the game. The 4-tick delay in movement simulates the real distance walking between areas and prevents the map from needing more filler areas. It also gives mobs 4-ticks to hit you and prevent you from escaping. There are *numerous* ways to improve movement: - -1. The `walk` command allows you to auto-walk to a certain room with one command like `walk 101` to walk to room #101. The number of rooms away you can walk increases with your Agility level. -2. The `aps` command allows you to auto-walk towards an APS node. You can use this even if the APS node is outside your normal walk range. -3. The Graceful Set reduces the delay by 0.25 ticks per piece, or 2 ticks if you have the full set. -4. The Cape of Agility at level 99 Agility lets you move one room per tick with no delay! -5. Higher technology levels allow you to fast-travel. -6. You can use `recall` once every 3000 ticks (30 minutes) to return to room #1. -7. Do stuff while walking! Train Fletching or Technology! Reprocess junk for credits! Talk to your fellow gamers! - -THOI is a game of slow progression and many aspects including movement are designed to be "semi-AFK", taking a while but not demanding your full attention. - -**Why can't I buy scrap/feathers/etc.?** - -THOI is a game about self-sufficiency on a forsaken asteroid. Supply ships aren't arriving to restock shops. More importantly, shops don't stock items produced by skills. A helmet shop removes the value of smithing your own helmet upgrade. A scrap shop reduces the value of scavenging your own scrap. Skills are not necessary chores to get back to combat, they are the game itself. Quality of life will have to be balanced, but the expected grinds should be between UIM and Ironman level to use a Runescape analogy. +# Todo + +- Implement "what/syntax" command to show un-hidden options in rooms. +- Fix and test all skills +- Make sure "use" works with everything as expected (e.g. id stations) +- Play around with Hacking. Needs interesting but semi-afk minigames. +- Prettify and make menus consistent +- Start worldbuilding with a tutorial island. # AI Disclosure diff --git a/TODO.md b/TODO.md deleted file mode 100644 index ed3da83..0000000 --- a/TODO.md +++ /dev/null @@ -1,32 +0,0 @@ -# TODO - -Upcoming work for The House of Icarus. See `AGENTS.md` for architecture and reference. -Detailed implementation plans for each skill/system are in `skill_plans/`. - ---- - -## Completed - -- [x] Cooking — fire/range, raw fish → cooked food, bread dough → bread, eat command -- [x] Smithing — furnace + anvil, ore → bars → weapons/armor -- [x] Fletching — logs → arrow shafts/bows, fletching as background action -- [x] Equipment — bronze/iron/steel/mithril/adamantite/rune weapons and armor, bows, arrows -- [x] Step 1: Pharmacy Rename — Alchemy → Pharmacy across codebase, SkillAbbr "pha", migration code in LoadCharacter -- [x] Step 2: Combat Overhaul — OSRS per-type attack/defense bonuses (stab/slash/crush/science/ranged), OSRS accuracy formula, AttackType on weapons, per-type mob defenses, 8 new mobs, ranged combat with ammo, stats command, showItemStats on look -- [x] Step 3: Combat Feel — Aggressive mobs (auto-attack on room enter, OSRS combat level rule), equipment skill requirements on all tiered gear -- [x] Step 4: Technology — 25 techs (attack/str/def/ranged/science boosts, 3 protection techs, utility techs), Battery drain, 1-tick flicking, tech command, TechTick, charging station, score/prompt integration -- [x] Step 5: Scavenging — mine_scrap behavior verified. Created 10 junk items (solar, hydro, cosmic, eco, chaos, bio, nature, law, death, blood — all stackable) with matching identifier tools and altar objects. `id`/`identify` command (Active): scans room for altar, checks for matching identifier in inventory, converts all scrap to junk on next tick with level-based multipliers, awards Scavenging XP. 10 altar rooms (150-159) branching from Scavenging Post (room 9). Help files created. -- [x] Step 6: Science — 40 mods (20 combat, 2 processing, 3 utility, 7 transport, 4 enchant, 4 chip), ModDef/AllMods in science.go. `trigger`/`cast` command (Active) routes to combat/transport/processing/utility/enchant handlers. `autocast`/`auto` command (Instant) sets science combat autocast. `mods`/`modlist` command (Instant) shows available mods with effective junk costs. Integrated autocast into `startCombat()` in cmd_attack.go with 5-tick science speed and melee fallback on junk depletion. `scienceAttack()` with Science level + science_attack roll, elemental weakness +30% accuracy, mod MaxHit damage. Junk cost system: deck-equipped removes scrap_metal, elemental deck provides unlimited junk type. Added `ProvidesJunk` to ItemDef, `AutocastMod` to Player, `FindByInput` to RecipeStore, `science_mod` color target. 9 deck items, 24 jewelry items, 8 bolt items, nutrient_bar item. Help files created. -- [x] Step 7: Shop System — Added `shop` talk node action that opens a buy/sell interface. `ShopConfig` with items + buy/sell prices defined in behavior YAML. `StateShop` session state with `buy`, `sell`, `browse`/`list`, `leave` commands. Items bought with credits, sold back at reduced prices. Created General Store clerk (Town Square) and Weapons Smith (Forge) NPCs. Created help files and worldbuilding guide docs. -- [x] Step 8: Thieving — `steal`/`thieve` command (Active) with continuous steal action. `sneak` command (Instant toggle) with guard-watching notifications. Success formula: 0.5 base + 0.03 per level, halved when guard watches. Mob stealing with aggro on failure. Object stealing with guard mechanic (SneakTick, watch/away cycles). Guard confrontation dialog (bribe 500cr, jail, fight). Created Market Stall object, Farmer + Bioengineer mobs, credit_stick item (searchable), 11 seed items, 5 new drop tables, 3 new rooms (160-162), 3 help files. Updated Man mob for stealability. -- [x] **Assassin System** — Added `task` command (Instant) to check current assignment. Created Client NPC with talk behavior: assigns random mob kill tasks, skip/extend via reputation, reputation shop. Task state stored in player flags (task_mob, task_count, task_remaining, tasks_completed, streak, reputation). In `endCombat()`, on-task kills award Assassin XP (mob.MaxHP * 2) + reputation. Streak bonuses at 10th/50th/100th/250th/1000th task. Created 4 slayer-only mobs (slug/drone/crawler/phantom) with assassin_level requirements, finishing blow items (slug+salt, crawler+acid_vial), and damage-without equipment (drone needs insulated_gloves, phantom needs spectral_visor). Added assassin equipment items (salt, acid_vial, insulated_gloves, spectral_visor) sold by Client. Added 5 new rooms (50-54: Assassin Den + Sewer Tunnels chain). Created help files (task, assassin). -- [x] **Pharmacy Skill** — Add `clean` command (Free, background action like fletching): auto-cleans grimy herbs in inventory one at a time, each clean awards Pharmacy XP based on herb level. Add `mix` command (Active, production system): no station required, shows recipe table, mixes potions. Create ~14 grimy herb items and ~14 clean herb items (guam through torstol). Create ~16 potion items (stim potion, fortify potion, restore serum, etc. — sci-fi names). Create vial_of_water and ~14 unfinished potion items. Create ~11 reagent items (eye_of_newt, limpwurt_root, etc.). Create ~30 recipes (14 clean-herb, 16 mix-potion). Add grimy herbs to mob drop tables. Potion effects are item-only for now (no buff system — just items in inventory for future use or sale). -- [x] **Farming Skill** — Add `plant`, `harvest`, `rake`, `water`, `cure`, `inspect` commands. Farming state tracked per-player via player flags (each plot stores seed, growth stage, watered, diseased status). Add `FarmTick()` that runs every ~500 ticks for online players: advances growth stages, rolls disease chance (watering prevents), diseased plants die if not cured. Create seed items (14 herb seeds, 7 allotment seeds). Create farming tools (rake, spade, watering_can, plant_cure). Create plot objects (herb_patch, allotment_patch, flower_patch). Create tool_shed object with talk behavior for store/retrieve interaction. Harvesting gives 3-15 herbs or 3-10 crops based on farming level. Create farming area rooms (150-152) south of room 15. -- [x] **Bank System** — Add bank storage to player (saved in character YAML, no slot limit or large slot limit). Create bank_booth object. `use bank` or `bank` command opens bank interface in a new session state. Commands: `deposit <item>`, `withdraw <item>`, `deposit all`, `list`/`browse`, `leave`. Bank is shared across all characters on the same account. Place bank booths in town and key areas. -- [x] **Construction Skill** — Create real estate broker NPC (talk behavior, sells house for 10 credits via `cost` + `set_player_flags`). Create Local Neighborhood room north of room 16 with Directory object. Directory: `look` shows all homeowners who have purchased a player house in this specific area (scan character files), `use` teleports to a house. Player houses are real YAML rooms in `data/rooms/player_housing/`. Each house has entrance + workshop. Workshop has workbench station object. Add `make`/`construct` command (Active, production system). Create plank items (regular through mahogany). Create construction recipes: logs → planks (very slow, ~20 ticks), planks → furniture items (shelf, table, chair, etc. — sellable items with credit value). All recipes use `type: "construction"`, `station: ["workbench"]`. Add sawmill operator NPC in lumberyard room (east of room 16): turns logs into planks for credits via talk node `sawmill` action. Add Plank Make science mod (Lv86 Utility): converts logs to planks at 70% sawmill cost. -- [x] **Agility Skill** — Create course definition system: `data/courses/<id>.yaml` defines a sequence of obstacle rooms with custom verbs (scramble, jump, swing, balance, climb, etc.), messages per phase, ticks per phase, and XP per obstacle. Add a single `doObstacle()` handler that all obstacle verbs route to. Course loader builds a `roomID → {courseID, obstacleIndex, verb}` map. Each obstacle is a 3-phase multi-tick action (approach → attempt → complete/fail). Fail chance based on agility vs required level; failure deals minor damage and teleports to course start. Track laps per course in player flags. Create 3 courses: Ventilation Shaft (Lv1, 5 obstacles), Rooftop (Lv20, 6 obstacles), Reactor (Lv50, 7 obstacles). Create ~18 obstacle rooms with descriptions. Big XP drop on course completion. -- [x] **Hacking Skill** — Add `StateHacking` session state. Add `jack`/`jackin` command (Active). Create `HackingMinigame` interface with `Init(level) string` and `HandleInput(input) (output, done, won)`. Terminal objects are registered in a Go map (not YAML behaviors). Implement 3 minigames: Hunt the Wumpus (Lv1, 20-node dodecahedron, probes, hazards), Mastermind/Code Breaker (Lv20, 4-digit cipher, 10 guesses, feedback), Liar's Dice/Signal Bluff (Lv40, hidden dice bidding/bluffing vs AI, wilds, exact calls). Create terminal objects and Netrunner NPC with tips dialog. Award XP on completion (more for winning, bonus for efficiency). Place terminals in rooms. - -- [x] More mobs — 18 new mobs filling Lv15-93 gaps (bandit, mutant_rat, scrap_bot, security_turret, ghoul, corrupted_scientist, war_drone, enforcer_bot, plasma_wraith, feral_cyborg, heavy_mech, elite_synth, veteran_merc, molten_elemental, titan_sentinel, psionic_entity, ancient_construct, void_reaper), 14 new dungeon rooms (55-68), iron_sentinel placed in room 64 -- [x] Player action broadcasts — gathering, combat, burning, stoking, searching, stealing, agility, production, use, resting, fletching, cleaning all broadcast to room. Follows existing toggle/arrive/say pattern. -- [x] Potion buff system — `drink` command (ClassFree), 3-tick cooldown. 16 potion YAMLs updated with `potion_effect`, `potion_bonus`, `potion_duration`. PotionBuff struct on Player. BuffTick decrements durations. buffLevelBonus() applied in combat (attack/strength/defense/ranged/science roll formulas). Buffs displayed in `score`. Supports stat-boost, heal, battery-restore, and all_combat potion types. diff --git a/skill_plans/agility.md b/skill_plans/agility.md deleted file mode 100644 index a7c9672..0000000 --- a/skill_plans/agility.md +++ /dev/null @@ -1,1814 +0,0 @@ -# Agility Skill Implementation Plan - -## 1. Overview - -Agility courses are sequences of special rooms where players type obstacle-specific commands (scramble, jump, swing, balance, climb, crawl, vault, leap, slide) to traverse obstacles for Agility XP. Each obstacle is a multi-tick action with sequenced flavor text messages. After the last obstacle in a course, the player receives a large course-completion XP bonus and is teleported back to the course hub. The game tracks per-character lap counts for each course using player flags. - -**Existing state:** -- `Agility` skill already defined in `internal/player/player.go` (line 27) with abbreviation `"agl"` (line 58) -- Agility level is already used by the `walk` command to cap multi-step movement distance (`cmd_walk.go:81`) -- Graceful equipment and Cape of Agility already reference agility for movement speed -- Room 17 ("Bonfire") is adjacent to the agility area and currently has description "This area is not yet accessible" -- Highest existing room ID is 149; agility rooms will use 200+ - -**Design goals:** -- Data-driven via YAML course definitions — add new courses without code changes -- Obstacle verbs are a fixed vocabulary (9 verbs) but which verb applies to which room is defined in course YAML -- All obstacle verbs share a single handler `doObstacle()` with a single advance function `advanceObstacle()` -- Fail mechanic: chance to fall based on level vs required level (take damage, teleport to course start) -- Lap tracking via player flags, displayed on course completion - -## 2. Architecture - -### Course System Overview - -Each agility course is a sequence of rooms. Each room contains one obstacle. The player types the obstacle's verb (e.g. `scramble`) to start a multi-tick action. On completion, the player is moved to the next room. After the final obstacle, the player receives course completion XP and is teleported back to the course hub room. - -``` -Course Hub (room 200) - ├── Vent Course Start (room 201) ──scramble──> room 202 ──balance──> room 203 ──jump──> room 204 ──crawl──> room 205 ──slide──> [completion: teleport to 200] - ├── Rooftop Course Start (room 210) ──climb──> ... ──leap──> [completion: teleport to 200] - └── Reactor Course Start (room 220) ──climb──> ... ──leap──> [completion: teleport to 200] -``` - -### Course Definition (YAML) - -Courses are defined in `data/courses/<id>.yaml`. A new `CourseStore` loads them on first access (same pattern as `action.Store`). On load, it builds two lookup maps: - -1. **`roomToObstacle`** — `map[int]*ObstacleInfo` — given a room ID, returns the obstacle info (course ID, obstacle index, verb, messages, ticks, XP, fail chance, next room) -2. **`obstacleVerbs`** — `map[string]bool` — the set of all verbs used across all courses (for `classifyCommand`) - -These maps are built once when the first course is loaded and rebuilt if courses are reloaded. - -### ObstacleInfo Struct - -```go -type ObstacleInfo struct { - CourseID string - CourseName string - ObstacleIndex int - TotalObstacles int - Verb string - Messages []string // sequenced messages, one per phase - TicksPerPhase float64 // ticks between messages - ObstacleXP int // XP awarded on obstacle completion - CompletionXP int // only set on last obstacle (course completion bonus) - FailChance float64 // base fail rate (0.0 - 1.0) before level adjustment - FailDamage [2]int // [min, max] damage on fail - NextRoom int // room to move to on success (0 on last obstacle = teleport to start) - StartRoom int // course hub room (for fail teleport and completion teleport) - RequiredLevel int // course required agility level -} -``` - -### Command Flow - -``` -Player types "scramble" - → classifyCommand("scramble") checks obstacleVerbs set → ClassActive - → queued as active command - → executeCommand routes to doObstacle() - → doObstacle() looks up roomToObstacle[player.RoomID] - → validates verb matches obstacle's verb - → validates agility level >= required - → checks not in combat - → calls startObstacle() - → sets Action with type "obstacle", phases in Data map - → AdvanceActions calls advanceObstacle() each tick - → phase 0: first message - → phase 1: second message + fail check - → phase 2: completion message, move to next room, award XP - → if last obstacle: award completion XP, increment lap counter, teleport to start -``` - -### State Tracking - -- **ActionType**: `ActionTraversing` (new constant = `"traversing"`) -- **Action.Type**: `"obstacle"` -- **Action.Data map keys**: - - `"course_id"` (string): course identifier - - `"phase"` (int): current message phase (0, 1, 2) - - `"obstacle_index"` (int): index in course sequence - - `"next_room"` (int): room to move to on completion - - `"start_room"` (int): course start for fail teleport - - `"obstacle_xp"` (int): XP for this obstacle - - `"completion_xp"` (int): bonus XP if last obstacle (0 otherwise) - - `"fail_chance"` (float64): adjusted fail probability - - `"fail_damage_min"` (int): min damage on fail - - `"fail_damage_max"` (int): max damage on fail - - `"messages"` ([]any): message strings for each phase - - `"ticks_per_phase"` (float64): ticks between phases - - `"total_obstacles"` (int): total obstacles in course - - `"required_level"` (int): course required level - -## 3. Commands - -### Obstacle Verbs (all Active) - -| Verb | Description | -|------|-------------| -| `scramble` | Scramble up a wall or surface | -| `jump` | Jump across a gap | -| `swing` | Swing on a cable, chain, or rope | -| `balance` | Walk across a narrow beam or pipe | -| `climb` | Climb a wall, ladder, or scaffolding | -| `crawl` | Crawl through a tight space | -| `vault` | Vault over a railing or barrier | -| `leap` | Make a running leap across a chasm | -| `slide` | Slide down a chute or surface | - -All verbs are classified as `ClassActive` and routed to `doObstacle()`. If the player's current room is not an obstacle room for that verb, the command is rejected with "You can't do that here." - -## 4. Course Definition System - -### YAML Format: `data/courses/<id>.yaml` - -```yaml -id: "vent_shaft" -name: "Ventilation Shaft Course" -required_level: 1 -start_room: 200 -completion_xp: 40 -obstacles: - - room_id: 201 - verb: scramble - ticks_per_phase: 2 - xp: 8 - fail_damage: [1, 2] - messages: - - "You approach the corroded ventilation wall..." - - "You find footholds in the rusted panels and begin to climb..." - - "You scramble up the wall and haul yourself onto the ledge!" - - room_id: 202 - verb: balance - ticks_per_phase: 2 - xp: 8 - fail_damage: [1, 2] - messages: - - "You step onto the narrow coolant pipe..." - - "Arms outstretched, you carefully place one foot in front of the other..." - - "You reach the other side of the pipe and step onto solid ground!" - # ... etc -``` - -### Go Struct: `CourseConfig` - -```go -type CourseConfig struct { - ID string `yaml:"id"` - Name string `yaml:"name"` - RequiredLevel int `yaml:"required_level"` - StartRoom int `yaml:"start_room"` - CompletionXP int `yaml:"completion_xp"` - Obstacles []ObstacleDef `yaml:"obstacles"` -} - -type ObstacleDef struct { - RoomID int `yaml:"room_id"` - Verb string `yaml:"verb"` - TicksPerPhase float64 `yaml:"ticks_per_phase"` - XP int `yaml:"xp"` - FailDamage [2]int `yaml:"fail_damage"` - Messages []string `yaml:"messages"` -} -``` - -### CourseStore - -```go -type CourseStore struct { - dataDir string - mu sync.Mutex - courses map[string]*CourseConfig - roomToObstacle map[int]*ObstacleInfo - obstacleVerbs map[string]bool - loaded bool -} -``` - -Located in `internal/game/course.go`. The store is initialized in `Game.New()` alongside the other stores. On first access (or on `loadAll()`), it reads all YAML files from `data/courses/`, parses them, and builds the lookup maps. - -`obstacleVerbs` is a package-level variable (not on the struct) so `classifyCommand()` can access it without a receiver: - -```go -var obstacleVerbs = map[string]bool{} - -func classifyCommand(cmd string) CommandClass { - // ... existing cases ... - if obstacleVerbs[cmd] { - return ClassActive - } - return ClassUnknown -} -``` - -The `CourseStore.loadAll()` method populates this package-level map after loading courses. - -## 5. New Files to Create - -### Go Files - -| File | Purpose | -|------|---------| -| `internal/game/course.go` | `CourseStore` struct, YAML loading, `CourseConfig`/`ObstacleDef` structs, `ObstacleInfo` struct, lookup map building, `obstacleVerbs` package var | -| `internal/game/cmd_agility.go` | `doObstacle()` command handler — validates room, verb, level; calls `startObstacle()` | -| `internal/game/action_agility.go` | `startObstacle()` and `advanceObstacle()` — multi-phase obstacle action lifecycle | - -### YAML Data Files - -| File | Purpose | -|------|---------| -| `data/courses/vent_shaft.yaml` | Ventilation Shaft Course definition (Level 1) | -| `data/courses/rooftop.yaml` | Rooftop Course definition (Level 20) | -| `data/courses/reactor.yaml` | Reactor Course definition (Level 50) | -| `data/rooms/200.yaml` | Agility Training Grounds (course hub) | -| `data/rooms/201.yaml` | Vent Shaft: Corroded Wall | -| `data/rooms/202.yaml` | Vent Shaft: Coolant Pipe Walkway | -| `data/rooms/203.yaml` | Vent Shaft: Shaft Gap | -| `data/rooms/204.yaml` | Vent Shaft: Narrow Vent | -| `data/rooms/205.yaml` | Vent Shaft: Emergency Chute | -| `data/rooms/210.yaml` | Rooftop: Hab Block Wall | -| `data/rooms/211.yaml` | Rooftop: Building Gap | -| `data/rooms/212.yaml` | Rooftop: Cable Array | -| `data/rooms/213.yaml` | Rooftop: Narrow Beam | -| `data/rooms/214.yaml` | Rooftop: Maintenance Railing | -| `data/rooms/215.yaml` | Rooftop: Rooftop Edge | -| `data/rooms/220.yaml` | Reactor: Scaffolding | -| `data/rooms/221.yaml` | Reactor: Coolant Chain | -| `data/rooms/222.yaml` | Reactor: Steam Pipe | -| `data/rooms/223.yaml` | Reactor: Platform Gap | -| `data/rooms/224.yaml` | Reactor: Service Conduit | -| `data/rooms/225.yaml` | Reactor: Radiation Barrier | -| `data/rooms/226.yaml` | Reactor: Reactor Chasm | -| `data/help/agility.yaml` | Help topic for Agility | - -## 6. Code Changes to Existing Files - -### `internal/game/game.go` - -**1. Add CourseStore to Game struct** (after `RecipeStore` field, line ~43): -```go -type Game struct { - // ... existing fields ... - RecipeStore *action.RecipeStore - CourseStore *CourseStore // NEW - // ... -} -``` - -**2. Initialize CourseStore in `New()`** (after `RecipeStore` init, line ~67): -```go -func New(dataDir string, colorConfig *config.ColorsConfig) *Game { - g := &Game{ - // ... existing ... - RecipeStore: action.NewRecipeStore(dataDir), - CourseStore: NewCourseStore(dataDir), // NEW - // ... - } - g.CourseStore.LoadAll() // populates obstacleVerbs package var - return g -} -``` - -**3. Update `classifyCommand()`** (at line ~155, before the `return ClassUnknown`): -```go -func classifyCommand(cmd string) CommandClass { - // ... existing switch ... - if _, ok := verbAliases[cmd]; ok { - return ClassActive - } - if obstacleVerbs[cmd] { // NEW - return ClassActive // NEW - } // NEW - return ClassUnknown -} -``` - -**4. Update `executeCommand()`** (add new case before `default`, around line ~427): -```go - case "walk": - g.doWalk(sess, args) - return - // NEW: obstacle verbs handled dynamically - default: - if obstacleVerbs[cmd] { - g.doObstacle(sess, cmd) - return - } - if a, ok := verbAliases[cmd]; ok { - // ... existing verbAliases handling ... -``` - -The obstacle verb check must come before the existing `verbAliases` check in the `default` case. Restructure the `default` block: - -```go - default: - if obstacleVerbs[cmd] { - g.doObstacle(sess, cmd) - return - } - if a, ok := verbAliases[cmd]; ok { - g.CancelAction(p) - switch a { - case "gather", "toggle": - // ... existing ... - case "talk": - // ... existing ... - } - } else { - sess.WriteLine("Unknown command.") - } -``` - -### `internal/game/action_state.go` - -**Add `ActionTraversing` constant** (after `ActionEating`, line ~24): -```go -const ( - // ... existing ... - ActionEating ActionType = "eating" - ActionTraversing ActionType = "traversing" // NEW -) -``` - -**Add description case in `Description()`** (after `ActionEating` case, line ~75): -```go - case ActionTraversing: - return a.Verb + " across " + a.TargetName -``` - -### `internal/game/action.go` - -**Add `"obstacle"` to `AdvanceActions()`** (in the switch at line ~225): -```go - switch p.Action.Type { - case "gather": - g.advanceGather(sess, p) - case "use": - g.advanceUse(sess, p) - case "burn": - g.advanceBurn(sess, p) - case "stoke": - g.advanceStoke(sess, p) - case "search": - g.advanceSearch(sess, p) - case "obstacle": // NEW - g.advanceObstacle(sess, p) // NEW - default: - if productionActionTypes[p.Action.Type] { - g.advanceProduction(sess, p) - } - } -``` - -**Add `ActionTraversing` to ProcessQueuedCommands stale-clear exclusion list** (line ~472): -```go - case ActionGathering, ActionCombating, ActionUsing, ActionTalking, - ActionToggling, ActionBurning, ActionStoking, ActionResting, ActionWalking, ActionProducing, - ActionTraversing: // NEW -``` - -### `data/rooms/17.yaml` - -Add a north exit to the agility training grounds: -```yaml -id: 17 -name: "Bonfire" -description: "A ring of stones surrounds a bed of ash. A few unlit logs wait nearby. A narrow passage leads north toward what sounds like echoing footsteps and clanging metal." -exits: - east: 18 - west: 16 - north: 200 -``` - -### `cmd/mud/main.go` - -No changes needed. The `AdvanceActions()` call already handles the new `"obstacle"` action type via the existing tick subscriber. - -## 7. Courses - -### Course 1: Ventilation Shaft Course (Level 1) - -**Theme:** Players crawl through the asteroid's ventilation infrastructure. Rusted panels, leaking coolant pipes, dark shafts. - -**`data/courses/vent_shaft.yaml`:** -```yaml -id: "vent_shaft" -name: "Ventilation Shaft Course" -required_level: 1 -start_room: 200 -completion_xp: 40 -obstacles: - - room_id: 201 - verb: scramble - ticks_per_phase: 2 - xp: 8 - fail_damage: [1, 2] - messages: - - "You approach the corroded ventilation wall..." - - "You find footholds in the rusted panels and begin to climb..." - - "You scramble up the wall and haul yourself onto the ledge!" - - room_id: 202 - verb: balance - ticks_per_phase: 2 - xp: 8 - fail_damage: [1, 2] - messages: - - "You step onto the narrow coolant pipe..." - - "Arms outstretched, you carefully place one foot in front of the other..." - - "You reach the other side of the pipe and step onto solid ground!" - - room_id: 203 - verb: jump - ticks_per_phase: 2 - xp: 7 - fail_damage: [1, 2] - messages: - - "You peer across the dark gap in the shaft floor..." - - "You take a few steps back, then sprint toward the edge..." - - "You leap across the gap and land safely on the other side!" - - room_id: 204 - verb: crawl - ticks_per_phase: 3 - xp: 7 - fail_damage: [1, 1] - messages: - - "You drop to your hands and knees at the narrow vent opening..." - - "You squeeze through the tight passage, metal scraping against your back..." - - "You emerge from the vent and stand up, brushing dust from your clothes!" - - room_id: 205 - verb: slide - ticks_per_phase: 2 - xp: 10 - fail_damage: [1, 2] - messages: - - "You sit at the top of the emergency chute..." - - "You push off and accelerate down the smooth metal surface..." - - "You shoot out the bottom of the chute and land on your feet!" -``` - -### Course 2: Rooftop Course (Level 20) - -**Theme:** Players traverse the rooftops of the hab blocks. Cables, narrow beams, gaps between buildings. - -**`data/courses/rooftop.yaml`:** -```yaml -id: "rooftop" -name: "Rooftop Course" -required_level: 20 -start_room: 200 -completion_xp: 120 -obstacles: - - room_id: 210 - verb: climb - ticks_per_phase: 2 - xp: 15 - fail_damage: [2, 4] - messages: - - "You grab the rough permacrete wall and begin to climb..." - - "Your fingers find cracks and ledges as you pull yourself higher..." - - "You heave yourself over the edge and onto the rooftop!" - - room_id: 211 - verb: jump - ticks_per_phase: 2 - xp: 22 - fail_damage: [2, 4] - messages: - - "You eye the gap between this building and the next..." - - "You sprint toward the edge, boots pounding on the rooftop..." - - "You launch yourself across and roll to a stop on the other roof!" - - room_id: 212 - verb: swing - ticks_per_phase: 3 - xp: 20 - fail_damage: [2, 5] - messages: - - "You grab the dangling power cable with both hands..." - - "You kick off the ledge and swing out over the street far below..." - - "You release at the peak of the arc and land on the opposite platform!" - - room_id: 213 - verb: balance - ticks_per_phase: 3 - xp: 18 - fail_damage: [2, 4] - messages: - - "You step onto the narrow structural beam spanning the alley..." - - "The beam sways slightly as you inch forward, arms out for balance..." - - "You reach the far side and step gratefully onto solid rooftop!" - - room_id: 214 - verb: vault - ticks_per_phase: 2 - xp: 15 - fail_damage: [2, 3] - messages: - - "You run toward the maintenance railing at full speed..." - - "You plant one hand on the rail and swing your legs over..." - - "You clear the railing and land in a crouch on the other side!" - - room_id: 215 - verb: leap - ticks_per_phase: 2 - xp: 20 - fail_damage: [3, 5] - messages: - - "You stare at the final gap — the widest yet..." - - "You take a deep breath, charge forward, and throw yourself into the air..." - - "You barely catch the far ledge, pull yourself up, and stand triumphant!" -``` - -### Course 3: Reactor Course (Level 50) - -**Theme:** Players navigate the hazardous environment around the asteroid's reactor core. Scaffolding, chains, steam, radiation barriers. - -**`data/courses/reactor.yaml`:** -```yaml -id: "reactor" -name: "Reactor Course" -required_level: 50 -start_room: 200 -completion_xp: 350 -obstacles: - - room_id: 220 - verb: climb - ticks_per_phase: 3 - xp: 40 - fail_damage: [3, 6] - messages: - - "You grip the reactor scaffolding and begin your ascent..." - - "The metal groans under your weight as you climb higher, heat radiating from below..." - - "You pull yourself onto the upper platform, the reactor humming beneath you!" - - room_id: 221 - verb: swing - ticks_per_phase: 3 - xp: 45 - fail_damage: [3, 7] - messages: - - "You seize the heavy coolant chain dangling above the reactor pit..." - - "You swing out over the glowing core, heat blasting your face..." - - "You release and land hard on the maintenance gantry, chain clanging behind you!" - - room_id: 222 - verb: balance - ticks_per_phase: 3 - xp: 50 - fail_damage: [4, 7] - messages: - - "You step onto the massive steam pipe spanning the reactor chamber..." - - "Steam jets hiss from valves on either side as you shuffle along the pipe..." - - "You reach the junction platform and hop off the pipe with relief!" - - room_id: 223 - verb: jump - ticks_per_phase: 2 - xp: 45 - fail_damage: [3, 6] - messages: - - "A section of the reactor platform is missing, leaving a gaping void..." - - "You back up, sprint, and leap with everything you've got..." - - "You slam into the far platform and roll to safety!" - - room_id: 224 - verb: crawl - ticks_per_phase: 3 - xp: 40 - fail_damage: [3, 5] - messages: - - "You squeeze into the narrow service conduit, radiation warnings plastered on every surface..." - - "You drag yourself through on your elbows, sparks showering from damaged wiring above..." - - "You tumble out the far end and gulp down clean air!" - - room_id: 225 - verb: vault - ticks_per_phase: 2 - xp: 50 - fail_damage: [4, 7] - messages: - - "A radiation containment barrier blocks the path, humming with energy..." - - "You time the pulse cycle, sprint at the barrier, and throw yourself over it..." - - "You clear the barrier and land on the other side, heart pounding!" - - room_id: 226 - verb: leap - ticks_per_phase: 3 - xp: 55 - fail_damage: [4, 8] - messages: - - "The final obstacle: a massive chasm over the reactor coolant pool..." - - "You sprint along the narrow runway, the abyss yawning below..." - - "You launch into the void, arms windmilling, and crash onto the far platform!" -``` - -## 8. Obstacle Action Lifecycle - -### `startObstacle()` in `internal/game/action_agility.go` - -```go -func (g *Game) startObstacle(sess *net.Session, p *player.Player, info *ObstacleInfo) { - failChance := g.calcFailChance(p, info) - - msgs := make([]any, len(info.Messages)) - for i, m := range info.Messages { - msgs[i] = m - } - - p.Action = &action.Action{ - Type: "obstacle", - TargetID: info.CourseID, - TargetName: info.CourseName, - WaitLeft: 0, - Data: map[string]any{ - "course_id": info.CourseID, - "phase": 0, - "obstacle_index": info.ObstacleIndex, - "total_obstacles": info.TotalObstacles, - "next_room": info.NextRoom, - "start_room": info.StartRoom, - "obstacle_xp": info.ObstacleXP, - "completion_xp": info.CompletionXP, - "fail_chance": failChance, - "fail_damage_min": info.FailDamage[0], - "fail_damage_max": info.FailDamage[1], - "messages": msgs, - "ticks_per_phase": info.TicksPerPhase, - "required_level": info.RequiredLevel, - }, - } - - p.ActionState = &ActionState{ - Type: ActionTraversing, - Verb: info.Verb + "ing", - TargetName: info.CourseName, - } -} -``` - -### `advanceObstacle()` in `internal/game/action_agility.go` - -```go -func (g *Game) advanceObstacle(sess *net.Session, p *player.Player) { - data := p.Action.Data - phase := data["phase"].(int) - messages := data["messages"].([]any) - ticksPerPhase := data["ticks_per_phase"].(float64) - courseID := data["course_id"].(string) - - if phase >= len(messages) { - g.CancelAction(p) - return - } - - msg := messages[phase].(string) - sess.WriteLine(g.colorize(sess, "agility", msg)) - - if phase == 1 { - failChance := data["fail_chance"].(float64) - if rand.Float64() < failChance { - g.obstacleFail(sess, p, data) - return - } - } - - nextPhase := phase + 1 - - if nextPhase >= len(messages) { - obstacleXP := data["obstacle_xp"].(int) - completionXP := data["completion_xp"].(int) - nextRoom := data["next_room"].(int) - startRoom := data["start_room"].(int) - obstacleIndex := data["obstacle_index"].(int) - totalObstacles := data["total_obstacles"].(int) - - if obstacleXP > 0 { - if newLevel := p.AddSkillXP(player.Agility, obstacleXP); newLevel > 0 { - sess.WriteLine(g.colorize(sess, "level_up", - fmt.Sprintf("*** You are now level %d agility! ***", newLevel))) - } - if p.OptionBool("xp_drops") { - sess.WriteLine(g.colorize(sess, "xp", - fmt.Sprintf("(+%dxp %s)", obstacleXP, player.SkillAbbr[player.Agility]))) - } - } - - isLastObstacle := obstacleIndex == totalObstacles-1 - - if isLastObstacle { - if completionXP > 0 { - if newLevel := p.AddSkillXP(player.Agility, completionXP); newLevel > 0 { - sess.WriteLine(g.colorize(sess, "level_up", - fmt.Sprintf("*** You are now level %d agility! ***", newLevel))) - } - if p.OptionBool("xp_drops") { - sess.WriteLine(g.colorize(sess, "xp", - fmt.Sprintf("(+%dxp %s course bonus)", completionXP, player.SkillAbbr[player.Agility]))) - } - } - - lapKey := "agility_laps_" + courseID - laps := g.getPlayerFlagInt(p, lapKey) + 1 - g.setPlayerFlag(p, lapKey, laps) - - courseName := p.Action.TargetName - sess.WriteLine(g.colorize(sess, "agility", - fmt.Sprintf("Course complete! %s lap %d finished.", courseName, laps))) - - g.CancelAction(p) - g.teleportPlayer(sess, p, startRoom) - } else { - g.CancelAction(p) - g.teleportPlayer(sess, p, nextRoom) - } - - g.AccountStore.SaveCharacter(p) - return - } - - data["phase"] = nextPhase - p.Action.WaitLeft = engine.ToTicks(ticksPerPhase) -} -``` - -### `teleportPlayer()` helper - -This function moves the player to a room without using normal movement mechanics (no movement delay, no flee check). It mirrors the structure of `completeMove()` but is instant: - -```go -func (g *Game) teleportPlayer(sess *net.Session, p *player.Player, targetRoomID int) { - oldRoom := p.RoomID - p.RoomID = targetRoomID - - g.World.SeedGroundItems(p.RoomID) - g.seedRoomMobs(p.RoomID) - g.seedRoomObjects(p.RoomID) - - if g.Hub != nil { - for _, other := range g.Hub.PlayersInRoom(oldRoom) { - if other != sess { - other.WriteLine(fmt.Sprintf("\n%s disappears.", p.Name)) - } - } - g.Hub.EnterRoom(sess, targetRoomID) - for _, other := range g.Hub.PlayersInRoom(targetRoomID) { - if other != sess { - other.WriteLine(fmt.Sprintf("\n%s arrives.", p.Name)) - } - } - } - - if p.OptionBool("description") { - g.doLook(sess) - } else { - targetRoom, _ := g.World.LoadRoom(targetRoomID) - if targetRoom != nil { - sess.WriteLine(g.colorize(sess, "room_name", targetRoom.Name)) - } - } - - g.RunEnterSteps(sess, targetRoomID) -} -``` - -**Note:** Check if a `teleportPlayer` helper already exists. The talk action system's `teleport` node action (in `action_talk.go` or similar) likely already implements this. If so, reuse it. If not, add it to `cmd_agility.go` or `action_agility.go`. - -### `obstacleFail()` helper - -```go -func (g *Game) obstacleFail(sess *net.Session, p *player.Player, data map[string]any) { - startRoom := data["start_room"].(int) - failMin := data["fail_damage_min"].(int) - failMax := data["fail_damage_max"].(int) - - damage := failMin - if failMax > failMin { - damage = failMin + rand.Intn(failMax-failMin+1) - } - - sess.WriteLine(g.colorize(sess, "damage", "You slip and fall!")) - - p.HP -= damage - if p.HP < 1 { - p.HP = 1 - } - sess.WriteLine(g.colorize(sess, "damage", fmt.Sprintf("You take %d damage. HP: %d/%d", damage, p.HP, p.MaxHP()))) - - g.AccountStore.SaveCharacter(p) - g.CancelAction(p) - g.teleportPlayer(sess, p, startRoom) -} -``` - -## 9. Fail Mechanics - -### Fail Chance Calculation - -`calcFailChance()` in `internal/game/action_agility.go`: - -```go -func (g *Game) calcFailChance(p *player.Player, info *ObstacleInfo) float64 { - level := p.Level(player.Agility) - required := info.RequiredLevel - chance := 0.30 - float64(level-required)*0.01 - if chance < 0.05 { - chance = 0.05 - } - if chance > 0.60 { - chance = 0.60 - } - return chance -} -``` - -**Formula:** `failChance = max(0.05, min(0.60, 0.30 - (level - required) * 0.01))` - -| Level vs Required | Fail Chance | -|--------------------|-------------| -| At required level | 30% | -| +5 levels | 25% | -| +10 levels | 20% | -| +20 levels | 10% | -| +25 levels | 5% (minimum) | -| Below required | Up to 60% (capped) | - -**On fail:** -- Player takes `fail_damage[0]` to `fail_damage[1]` HP damage (random in range, inclusive) -- HP cannot go below 1 (fail never kills) -- Player is teleported to the course `start_room` (hub room 200) -- Message: "You slip and fall!" -- Course progress resets (player must start from obstacle 1 again) -- No XP awarded for the failed obstacle - -**Fail check timing:** The fail check occurs when phase 1 completes (the second message). This means the player sees the first two messages, then either fails or proceeds to the completion message. - -## 10. Lap Tracking - -### Player Flags - -Lap counts are stored as player flags (per-character, saved to character YAML): - -- `agility_laps_vent_shaft` — number of completed Ventilation Shaft laps -- `agility_laps_rooftop` — number of completed Rooftop Course laps -- `agility_laps_reactor` — number of completed Reactor Course laps - -Flag key format: `agility_laps_<course_id>` - -### Helper Functions - -Player flags in this codebase use `map[string]any`. We need helpers to read/write integer flags: - -```go -func (g *Game) getPlayerFlagInt(p *player.Player, key string) int { - if p.Flags == nil { - return 0 - } - val, ok := p.Flags[key] - if !ok { - return 0 - } - switch v := val.(type) { - case int: - return v - case int64: - return int(v) - case float64: - return int(v) - } - return 0 -} - -func (g *Game) setPlayerFlag(p *player.Player, key string, val any) { - if p.Flags == nil { - p.Flags = make(map[string]any) - } - p.Flags[key] = val -} -``` - -**Note:** Check if similar helpers already exist in the codebase (e.g., in `action_talk.go` where `set_player_flags` is handled). Reuse them if so. - -### Completion Message - -On completing the last obstacle: -``` -You shoot out the bottom of the chute and land on your feet! -(+10xp agl) -(+40xp agl course bonus) -Course complete! Ventilation Shaft Course lap 47 finished. -``` - -## 11. Course Detection - -### How `doObstacle()` Works - -```go -func (g *Game) doObstacle(sess *net.Session, verb string) { - p := sess.Player.(*player.Player) - - if combat.GetCombat(p.Name) != nil { - sess.WriteLine("You can't do that during combat!") - return - } - - info := g.CourseStore.GetObstacle(p.RoomID) - if info == nil || info.Verb != verb { - sess.WriteLine("You can't do that here.") - return - } - - agilityLevel := p.Level(player.Agility) - if agilityLevel < info.RequiredLevel { - sess.WriteLine(fmt.Sprintf("You need level %d agility to attempt this course.", info.RequiredLevel)) - return - } - - if p.Action != nil { - g.CancelAction(p) - } - g.CancelBackgroundAction(p) - - g.startObstacle(sess, p, info) -} -``` - -### `CourseStore.GetObstacle()` - -```go -func (cs *CourseStore) GetObstacle(roomID int) *ObstacleInfo { - cs.mu.Lock() - defer cs.mu.Unlock() - if !cs.loaded { - cs.loadAllLocked() - } - return cs.roomToObstacle[roomID] -} -``` - -### Map Building in `loadAllLocked()` - -```go -func (cs *CourseStore) loadAllLocked() { - cs.loaded = true - cs.roomToObstacle = make(map[int]*ObstacleInfo) - localVerbs := make(map[string]bool) - - pattern := filepath.Join(cs.dataDir, "courses", "*.yaml") - files, err := filepath.Glob(pattern) - if err != nil { - return - } - - for _, f := range files { - data, err := os.ReadFile(f) - if err != nil { - continue - } - var cfg CourseConfig - if err := yaml.Unmarshal(data, &cfg); err != nil { - continue - } - cs.courses[cfg.ID] = &cfg - - totalObstacles := len(cfg.Obstacles) - for i, obs := range cfg.Obstacles { - nextRoom := 0 - if i < totalObstacles-1 { - nextRoom = cfg.Obstacles[i+1].RoomID - } - - completionXP := 0 - if i == totalObstacles-1 { - completionXP = cfg.CompletionXP - } - - info := &ObstacleInfo{ - CourseID: cfg.ID, - CourseName: cfg.Name, - ObstacleIndex: i, - TotalObstacles: totalObstacles, - Verb: obs.Verb, - Messages: obs.Messages, - TicksPerPhase: obs.TicksPerPhase, - ObstacleXP: obs.XP, - CompletionXP: completionXP, - FailDamage: obs.FailDamage, - NextRoom: nextRoom, - StartRoom: cfg.StartRoom, - RequiredLevel: cfg.RequiredLevel, - } - cs.roomToObstacle[obs.RoomID] = info - localVerbs[obs.Verb] = true - } - } - - obstacleVerbs = localVerbs -} -``` - -### Sequence Enforcement - -The course system does NOT enforce that players complete obstacles in order via explicit state tracking. Instead, it relies on room topology: the only way to reach room 202 is by completing the obstacle in room 201. If a player somehow ends up in room 203 without completing room 202's obstacle (e.g., via teleport or `walk`), they can still type the obstacle command and proceed. This is acceptable — the XP is balanced per-obstacle, and the completion bonus only fires on the last obstacle. - -Each obstacle room has a "down" exit back to the hub room (room 200) so players can bail out at any time. Walking backwards through the course is prevented by not having standard exits between obstacle rooms in the reverse direction. - -## 12. XP Table - -### Ventilation Shaft Course (Level 1) - -| # | Obstacle | Verb | XP | Ticks | -|---|----------|------|----|-------| -| 1 | Corroded Wall | scramble | 8 | 2/phase | -| 2 | Coolant Pipe Walkway | balance | 8 | 2/phase | -| 3 | Shaft Gap | jump | 7 | 2/phase | -| 4 | Narrow Vent | crawl | 7 | 3/phase | -| 5 | Emergency Chute | slide | 10 | 2/phase | -| | **Completion bonus** | | **40** | | -| | **Total per lap** | | **80** | | - -Time per lap: ~33 ticks (~20 seconds at 600ms ticks) - -### Rooftop Course (Level 20) - -| # | Obstacle | Verb | XP | Ticks | -|---|----------|------|----|-------| -| 1 | Hab Block Wall | climb | 15 | 2/phase | -| 2 | Building Gap | jump | 22 | 2/phase | -| 3 | Cable Array | swing | 20 | 3/phase | -| 4 | Narrow Beam | balance | 18 | 3/phase | -| 5 | Maintenance Railing | vault | 15 | 2/phase | -| 6 | Rooftop Edge | leap | 20 | 2/phase | -| | **Completion bonus** | | **120** | | -| | **Total per lap** | | **230** | | - -Time per lap: ~42 ticks (~25 seconds at 600ms ticks) - -### Reactor Course (Level 50) - -| # | Obstacle | Verb | XP | Ticks | -|---|----------|------|----|-------| -| 1 | Reactor Scaffolding | climb | 40 | 3/phase | -| 2 | Coolant Chain | swing | 45 | 3/phase | -| 3 | Steam Pipe | balance | 50 | 3/phase | -| 4 | Platform Gap | jump | 45 | 2/phase | -| 5 | Service Conduit | crawl | 40 | 3/phase | -| 6 | Radiation Barrier | vault | 50 | 2/phase | -| 7 | Reactor Chasm | leap | 55 | 3/phase | -| | **Completion bonus** | | **350** | | -| | **Total per lap** | | **675** | | - -Time per lap: ~57 ticks (~34 seconds at 600ms ticks) - -### XP/Hour Estimates (no fails) - -| Course | XP/Lap | Laps/Hr (est) | XP/Hr | -|--------|--------|---------------|-------| -| Ventilation Shaft | 80 | ~160 | ~12,800 | -| Rooftop | 230 | ~130 | ~29,900 | -| Reactor | 675 | ~95 | ~64,125 | - -## 13. Room YAML - -### Hub Room - -**`data/rooms/200.yaml`:** -```yaml -id: 200 -name: "Agility Training Grounds" -description: "A cavernous space beneath the asteroid's surface, repurposed as a training facility. Scaffolding, pipes, and platforms fill the chamber. Signs point to three courses of increasing difficulty: {33}Ventilation Shaft{/} (beginner), {214}Rooftop{/} (intermediate), and {196}Reactor{/} (advanced)." -exits: - south: 17 - north: 201 - east: 210 - west: 220 -``` - -### Ventilation Shaft Course Rooms - -**`data/rooms/201.yaml`:** -```yaml -id: 201 -name: "Ventilation Shaft - Corroded Wall" -description: "A towering wall of corroded ventilation panels rises before you. Rust-eaten handholds and buckled seams offer a treacherous path upward. The air smells of old metal and recycled atmosphere." -on_enter: - - message: "Type 'scramble' to climb the wall." -exits: - down: - room: 200 - blocked_message: "" -``` - -**`data/rooms/202.yaml`:** -```yaml -id: 202 -name: "Ventilation Shaft - Coolant Pipe Walkway" -description: "A narrow coolant pipe stretches across a dark chasm. Condensation drips from its surface, making it slick. Far below, you can hear the distant hum of machinery." -on_enter: - - message: "Type 'balance' to cross the pipe." -exits: - down: - room: 200 - blocked_message: "" -``` - -**`data/rooms/203.yaml`:** -```yaml -id: 203 -name: "Ventilation Shaft - Shaft Gap" -description: "The ventilation shaft floor is missing here — a jagged gap drops into darkness. The far side is just barely within jumping distance. Exposed wiring sparks intermittently below." -on_enter: - - message: "Type 'jump' to leap across the gap." -exits: - down: - room: 200 - blocked_message: "" -``` - -**`data/rooms/204.yaml`:** -```yaml -id: 204 -name: "Ventilation Shaft - Narrow Vent" -description: "The passage narrows dramatically here, becoming a tight rectangular vent barely wide enough to fit through. Scratches on the metal walls suggest others have squeezed through before you." -on_enter: - - message: "Type 'crawl' to squeeze through the vent." -exits: - down: - room: 200 - blocked_message: "" -``` - -**`data/rooms/205.yaml`:** -```yaml -id: 205 -name: "Ventilation Shaft - Emergency Chute" -description: "A smooth metal chute angles steeply downward, polished by countless slides. An old emergency evacuation sign hangs crookedly on the wall. This is the final obstacle — the chute leads back to the training grounds." -on_enter: - - message: "Type 'slide' to descend the chute." -exits: - down: - room: 200 - blocked_message: "" -``` - -### Rooftop Course Rooms - -**`data/rooms/210.yaml`:** -```yaml -id: 210 -name: "Rooftop Course - Hab Block Wall" -description: "The exterior wall of Hab Block 7 rises three stories above the street. Rough permacrete and maintenance handholds provide a challenging climb. The city spreads out below, neon signs flickering in the perpetual twilight." -on_enter: - - message: "Type 'climb' to scale the wall." -exits: - down: - room: 200 - blocked_message: "" -``` - -**`data/rooms/211.yaml`:** -```yaml -id: 211 -name: "Rooftop Course - Building Gap" -description: "You stand on the edge of Hab Block 7's roof. Across a three-meter gap, the roof of Hab Block 8 awaits. The street below is a dizzying drop. A few old bootprints mark the takeoff point." -on_enter: - - message: "Type 'jump' to leap to the next building." -exits: - down: - room: 200 - blocked_message: "" -``` - -**`data/rooms/212.yaml`:** -```yaml -id: 212 -name: "Rooftop Course - Cable Array" -description: "A tangle of power cables and data lines stretches between two antenna towers. One thick cable hangs low enough to grab. The gap below drops to a dark alleyway between hab blocks." -on_enter: - - message: "Type 'swing' to cross on the cable." -exits: - down: - room: 200 - blocked_message: "" -``` - -**`data/rooms/213.yaml`:** -```yaml -id: 213 -name: "Rooftop Course - Narrow Beam" -description: "A structural I-beam extends across the gap between two buildings, no wider than your foot. It sways slightly in the recycled air currents. Someone has scratched tally marks into the near end." -on_enter: - - message: "Type 'balance' to cross the beam." -exits: - down: - room: 200 - blocked_message: "" -``` - -**`data/rooms/214.yaml`:** -```yaml -id: 214 -name: "Rooftop Course - Maintenance Railing" -description: "A high maintenance railing blocks the path forward, topped with sensor equipment and warning labels. It's too high to step over but the right technique could clear it. Beyond the railing, the course continues." -on_enter: - - message: "Type 'vault' to clear the railing." -exits: - down: - room: 200 - blocked_message: "" -``` - -**`data/rooms/215.yaml`:** -```yaml -id: 215 -name: "Rooftop Course - Rooftop Edge" -description: "The final jump. The gap here is wider than any before — a full four meters of empty air between you and the landing platform. Far below, the streets of the asteroid colony pulse with dim light. This is the last obstacle." -on_enter: - - message: "Type 'leap' to make the final jump." -exits: - down: - room: 200 - blocked_message: "" -``` - -### Reactor Course Rooms - -**`data/rooms/220.yaml`:** -```yaml -id: 220 -name: "Reactor Course - Scaffolding" -description: "Massive metal scaffolding surrounds the outer reactor housing. The structure vibrates with the reactor's pulse. Heat radiates from every surface, and warning klaxons sound periodically in the distance." -on_enter: - - message: "Type 'climb' to ascend the scaffolding." -exits: - down: - room: 200 - blocked_message: "" -``` - -**`data/rooms/221.yaml`:** -```yaml -id: 221 -name: "Reactor Course - Coolant Chain" -description: "A heavy chain hangs from an overhead crane, suspended above the reactor cooling pit. The pit glows with an eerie blue-green light. The chain is your only way across — the gantry ahead is the landing zone." -on_enter: - - message: "Type 'swing' to cross on the chain." -exits: - down: - room: 200 - blocked_message: "" -``` - -**`data/rooms/222.yaml`:** -```yaml -id: 222 -name: "Reactor Course - Steam Pipe" -description: "An enormous steam pipe, two meters in diameter, stretches across the reactor chamber. Steam vents periodically blast from pressure valves along its length. The pipe's surface is warm but not scalding — yet." -on_enter: - - message: "Type 'balance' to traverse the pipe." -exits: - down: - room: 200 - blocked_message: "" -``` - -**`data/rooms/223.yaml`:** -```yaml -id: 223 -name: "Reactor Course - Platform Gap" -description: "A section of the reactor maintenance platform has collapsed into the void below. Emergency barriers block the edges, but someone has moved them aside here. The gap is intimidating but clearable." -on_enter: - - message: "Type 'jump' to clear the gap." -exits: - down: - room: 200 - blocked_message: "" -``` - -**`data/rooms/224.yaml`:** -```yaml -id: 224 -name: "Reactor Course - Service Conduit" -description: "A narrow service conduit leads through the reactor shielding. Radiation warning symbols are painted on every surface. Damaged wiring hangs from the ceiling, sparking occasionally. It's the only way forward." -on_enter: - - message: "Type 'crawl' to enter the conduit." -exits: - down: - room: 200 - blocked_message: "" -``` - -**`data/rooms/225.yaml`:** -```yaml -id: 225 -name: "Reactor Course - Radiation Barrier" -description: "A containment barrier hums with energy, its surface shimmering with a faint purple glow. It pulses on and off in a regular cycle. Beyond it, the final stretch of the course is visible." -on_enter: - - message: "Type 'vault' to clear the barrier." -exits: - down: - room: 200 - blocked_message: "" -``` - -**`data/rooms/226.yaml`:** -```yaml -id: 226 -name: "Reactor Course - Reactor Chasm" -description: "The final obstacle. A massive chasm separates you from the exit platform, the reactor's coolant pool churning far below in shades of luminous green. A narrow runway of grating leads to the edge. This is the longest leap on the course." -on_enter: - - message: "Type 'leap' to make the final jump." -exits: - down: - room: 200 - blocked_message: "" -``` - -## 14. Help File - -**`data/help/agility.yaml`:** -```yaml -id: agility -title: "Agility" -body: | - Agility is trained by completing obstacle courses. Each course is a sequence - of rooms with obstacles that you traverse using special commands. - - COMMANDS - scramble, jump, swing, balance, climb, crawl, vault, leap, slide - Each obstacle room tells you which command to use. Type it to begin - the obstacle. After a few ticks of sequenced messages, you'll either - succeed and move to the next obstacle, or slip and fall. - - COURSES - Ventilation Shaft Level 1 - 5 obstacles, 80 XP/lap - Rooftop Level 20 - 6 obstacles, 230 XP/lap - Reactor Level 50 - 7 obstacles, 675 XP/lap - - FAILING - Each obstacle has a chance to fail. If you fail, you take minor damage - and are teleported back to the Agility Training Grounds. Fail chance - decreases as your agility level increases above the course requirement. - - LAP TRACKING - The game tracks how many laps you've completed on each course. Your - lap count is displayed when you finish a course. - - TIPS - - The "down" exit in any obstacle room returns you to the Training - Grounds without penalty (but no XP either). - - Agility level also determines how many steps you can queue with the - "walk" command. - - Graceful equipment and Cape of Agility reduce movement speed. -related: - - skills - - walk -``` - -## 15. Full Go Implementation - -### `internal/game/course.go` - -```go -package game - -import ( - "os" - "path/filepath" - "sync" - - "gopkg.in/yaml.v3" -) - -type ObstacleDef struct { - RoomID int `yaml:"room_id"` - Verb string `yaml:"verb"` - TicksPerPhase float64 `yaml:"ticks_per_phase"` - XP int `yaml:"xp"` - FailDamage [2]int `yaml:"fail_damage"` - Messages []string `yaml:"messages"` -} - -type CourseConfig struct { - ID string `yaml:"id"` - Name string `yaml:"name"` - RequiredLevel int `yaml:"required_level"` - StartRoom int `yaml:"start_room"` - CompletionXP int `yaml:"completion_xp"` - Obstacles []ObstacleDef `yaml:"obstacles"` -} - -type ObstacleInfo struct { - CourseID string - CourseName string - ObstacleIndex int - TotalObstacles int - Verb string - Messages []string - TicksPerPhase float64 - ObstacleXP int - CompletionXP int - FailDamage [2]int - NextRoom int - StartRoom int - RequiredLevel int -} - -var obstacleVerbs = map[string]bool{} - -type CourseStore struct { - dataDir string - mu sync.Mutex - courses map[string]*CourseConfig - roomToObstacle map[int]*ObstacleInfo - loaded bool -} - -func NewCourseStore(dataDir string) *CourseStore { - return &CourseStore{ - dataDir: dataDir, - courses: make(map[string]*CourseConfig), - } -} - -func (cs *CourseStore) LoadAll() { - cs.mu.Lock() - defer cs.mu.Unlock() - cs.loadAllLocked() -} - -func (cs *CourseStore) GetObstacle(roomID int) *ObstacleInfo { - cs.mu.Lock() - defer cs.mu.Unlock() - if !cs.loaded { - cs.loadAllLocked() - } - return cs.roomToObstacle[roomID] -} - -func (cs *CourseStore) loadAllLocked() { - cs.loaded = true - cs.roomToObstacle = make(map[int]*ObstacleInfo) - localVerbs := make(map[string]bool) - - pattern := filepath.Join(cs.dataDir, "courses", "*.yaml") - files, _ := filepath.Glob(pattern) - - for _, f := range files { - data, err := os.ReadFile(f) - if err != nil { - continue - } - var cfg CourseConfig - if err := yaml.Unmarshal(data, &cfg); err != nil { - continue - } - cs.courses[cfg.ID] = &cfg - - totalObstacles := len(cfg.Obstacles) - for i, obs := range cfg.Obstacles { - nextRoom := 0 - if i < totalObstacles-1 { - nextRoom = cfg.Obstacles[i+1].RoomID - } - - completionXP := 0 - if i == totalObstacles-1 { - completionXP = cfg.CompletionXP - } - - info := &ObstacleInfo{ - CourseID: cfg.ID, - CourseName: cfg.Name, - ObstacleIndex: i, - TotalObstacles: totalObstacles, - Verb: obs.Verb, - Messages: obs.Messages, - TicksPerPhase: obs.TicksPerPhase, - ObstacleXP: obs.XP, - CompletionXP: completionXP, - FailDamage: obs.FailDamage, - NextRoom: nextRoom, - StartRoom: cfg.StartRoom, - RequiredLevel: cfg.RequiredLevel, - } - cs.roomToObstacle[obs.RoomID] = info - localVerbs[obs.Verb] = true - } - } - - obstacleVerbs = localVerbs -} -``` - -### `internal/game/cmd_agility.go` - -```go -package game - -import ( - "fmt" - - "thehouseoficarus/internal/combat" - "thehouseoficarus/internal/net" - "thehouseoficarus/internal/player" -) - -func (g *Game) doObstacle(sess *net.Session, verb string) { - p := sess.Player.(*player.Player) - - if combat.GetCombat(p.Name) != nil { - sess.WriteLine("You can't do that during combat!") - return - } - - info := g.CourseStore.GetObstacle(p.RoomID) - if info == nil || info.Verb != verb { - sess.WriteLine("You can't do that here.") - return - } - - agilityLevel := p.Level(player.Agility) - if agilityLevel < info.RequiredLevel { - sess.WriteLine(fmt.Sprintf("You need level %d agility to attempt this course.", info.RequiredLevel)) - return - } - - if p.Action != nil { - g.CancelAction(p) - } - g.CancelBackgroundAction(p) - - g.startObstacle(sess, p, info) -} -``` - -### `internal/game/action_agility.go` - -```go -package game - -import ( - "fmt" - "math/rand" - - "thehouseoficarus/internal/action" - "thehouseoficarus/internal/engine" - "thehouseoficarus/internal/net" - "thehouseoficarus/internal/player" -) - -func (g *Game) startObstacle(sess *net.Session, p *player.Player, info *ObstacleInfo) { - failChance := g.calcFailChance(p, info) - - msgs := make([]any, len(info.Messages)) - for i, m := range info.Messages { - msgs[i] = m - } - - p.Action = &action.Action{ - Type: "obstacle", - TargetID: info.CourseID, - TargetName: info.CourseName, - WaitLeft: 0, - Data: map[string]any{ - "course_id": info.CourseID, - "phase": 0, - "obstacle_index": info.ObstacleIndex, - "total_obstacles": info.TotalObstacles, - "next_room": info.NextRoom, - "start_room": info.StartRoom, - "obstacle_xp": info.ObstacleXP, - "completion_xp": info.CompletionXP, - "fail_chance": failChance, - "fail_damage_min": info.FailDamage[0], - "fail_damage_max": info.FailDamage[1], - "messages": msgs, - "ticks_per_phase": info.TicksPerPhase, - "required_level": info.RequiredLevel, - }, - } - - p.ActionState = &ActionState{ - Type: ActionTraversing, - Verb: info.Verb + "ing", - TargetName: info.CourseName, - } -} - -func (g *Game) advanceObstacle(sess *net.Session, p *player.Player) { - data := p.Action.Data - phase := data["phase"].(int) - messages := data["messages"].([]any) - ticksPerPhase := data["ticks_per_phase"].(float64) - - if phase >= len(messages) { - g.CancelAction(p) - return - } - - msg := messages[phase].(string) - sess.WriteLine(g.colorize(sess, "agility", msg)) - - if phase == 1 { - failChance := data["fail_chance"].(float64) - if rand.Float64() < failChance { - g.obstacleFail(sess, p, data) - return - } - } - - nextPhase := phase + 1 - - if nextPhase >= len(messages) { - obstacleXP := data["obstacle_xp"].(int) - completionXP := data["completion_xp"].(int) - nextRoom := data["next_room"].(int) - startRoom := data["start_room"].(int) - obstacleIndex := data["obstacle_index"].(int) - totalObstacles := data["total_obstacles"].(int) - courseID := data["course_id"].(string) - - if obstacleXP > 0 { - if newLevel := p.AddSkillXP(player.Agility, obstacleXP); newLevel > 0 { - sess.WriteLine(g.colorize(sess, "level_up", - fmt.Sprintf("*** You are now level %d agility! ***", newLevel))) - } - if p.OptionBool("xp_drops") { - sess.WriteLine(g.colorize(sess, "xp", - fmt.Sprintf("(+%dxp %s)", obstacleXP, player.SkillAbbr[player.Agility]))) - } - } - - isLastObstacle := obstacleIndex == totalObstacles-1 - - if isLastObstacle { - if completionXP > 0 { - if newLevel := p.AddSkillXP(player.Agility, completionXP); newLevel > 0 { - sess.WriteLine(g.colorize(sess, "level_up", - fmt.Sprintf("*** You are now level %d agility! ***", newLevel))) - } - if p.OptionBool("xp_drops") { - sess.WriteLine(g.colorize(sess, "xp", - fmt.Sprintf("(+%dxp %s course bonus)", completionXP, player.SkillAbbr[player.Agility]))) - } - } - - lapKey := "agility_laps_" + courseID - laps := g.getPlayerFlagInt(p, lapKey) + 1 - g.setPlayerFlag(p, lapKey, laps) - - courseName := p.Action.TargetName - sess.WriteLine(g.colorize(sess, "agility", - fmt.Sprintf("Course complete! %s lap %d finished.", courseName, laps))) - - g.CancelAction(p) - g.teleportPlayer(sess, p, startRoom) - } else { - g.CancelAction(p) - g.teleportPlayer(sess, p, nextRoom) - } - - g.AccountStore.SaveCharacter(p) - return - } - - data["phase"] = nextPhase - p.Action.WaitLeft = engine.ToTicks(ticksPerPhase) -} - -func (g *Game) obstacleFail(sess *net.Session, p *player.Player, data map[string]any) { - startRoom := data["start_room"].(int) - failMin := data["fail_damage_min"].(int) - failMax := data["fail_damage_max"].(int) - - damage := failMin - if failMax > failMin { - damage = failMin + rand.Intn(failMax-failMin+1) - } - - sess.WriteLine(g.colorize(sess, "damage", "You slip and fall!")) - - p.HP -= damage - if p.HP < 1 { - p.HP = 1 - } - sess.WriteLine(g.colorize(sess, "damage", - fmt.Sprintf("You take %d damage. HP: %d/%d", damage, p.HP, p.MaxHP()))) - - g.AccountStore.SaveCharacter(p) - g.CancelAction(p) - g.teleportPlayer(sess, p, startRoom) -} - -func (g *Game) calcFailChance(p *player.Player, info *ObstacleInfo) float64 { - level := p.Level(player.Agility) - required := info.RequiredLevel - chance := 0.30 - float64(level-required)*0.01 - if chance < 0.05 { - chance = 0.05 - } - if chance > 0.60 { - chance = 0.60 - } - return chance -} - -func (g *Game) getPlayerFlagInt(p *player.Player, key string) int { - if p.Flags == nil { - return 0 - } - val, ok := p.Flags[key] - if !ok { - return 0 - } - switch v := val.(type) { - case int: - return v - case int64: - return int(v) - case float64: - return int(v) - } - return 0 -} - -func (g *Game) setPlayerFlag(p *player.Player, key string, val any) { - if p.Flags == nil { - p.Flags = make(map[string]any) - } - p.Flags[key] = val -} - -func (g *Game) teleportPlayer(sess *net.Session, p *player.Player, targetRoomID int) { - oldRoom := p.RoomID - p.RoomID = targetRoomID - - g.World.SeedGroundItems(p.RoomID) - g.seedRoomMobs(p.RoomID) - g.seedRoomObjects(p.RoomID) - - if g.Hub != nil { - for _, other := range g.Hub.PlayersInRoom(oldRoom) { - if other != sess { - other.WriteLine(fmt.Sprintf("\n%s disappears.", p.Name)) - } - } - g.Hub.EnterRoom(sess, targetRoomID) - for _, other := range g.Hub.PlayersInRoom(targetRoomID) { - if other != sess { - other.WriteLine(fmt.Sprintf("\n%s arrives.", p.Name)) - } - } - } - - if p.OptionBool("description") { - g.doLook(sess) - } else { - targetRoom, _ := g.World.LoadRoom(targetRoomID) - if targetRoom != nil { - sess.WriteLine(g.colorize(sess, "room_name", targetRoom.Name)) - } - } - - g.RunEnterSteps(sess, targetRoomID) -} -``` - -## 16. Implementation Checklist - -1. [ ] Create `data/courses/` directory -2. [ ] Create `data/courses/vent_shaft.yaml` -3. [ ] Create `data/courses/rooftop.yaml` -4. [ ] Create `data/courses/reactor.yaml` -5. [ ] Create all room YAML files (200-226, 18 rooms total) -6. [ ] Update `data/rooms/17.yaml` to add north exit to 200 -7. [ ] Create `data/help/agility.yaml` -8. [ ] Create `internal/game/course.go` -9. [ ] Create `internal/game/cmd_agility.go` -10. [ ] Create `internal/game/action_agility.go` -11. [ ] Update `internal/game/game.go`: add `CourseStore` field, init in `New()`, update `classifyCommand()`, update `executeCommand()` default case -12. [ ] Update `internal/game/action_state.go`: add `ActionTraversing`, add `Description()` case -13. [ ] Update `internal/game/action.go`: add `"obstacle"` case in `AdvanceActions()`, add `ActionTraversing` to stale-clear exclusion -14. [ ] Check if `teleportPlayer` already exists (search for teleport in `action_talk.go`) — reuse or create -15. [ ] Check if `getPlayerFlagInt`/`setPlayerFlag` helpers already exist — reuse or create -16. [ ] Run `make vet` and `make test` -17. [ ] Test in-game: complete each course, verify XP, verify lap tracking, verify fail mechanic - -## 17. Edge Cases and Notes - -- **Player disconnects mid-obstacle:** Action is cleared on disconnect (standard behavior). Player stays in the obstacle room. On reconnect they can type the obstacle verb again or use "down" to leave. -- **Player types wrong verb:** "You can't do that here." — each room only accepts its specific verb. -- **Player types obstacle verb outside a course room:** "You can't do that here." -- **Player is in combat:** "You can't do that during combat!" — checked before anything else. -- **Player dies on fail:** HP cannot go below 1. Fail never kills. -- **Multiple players on same obstacle:** Each player's action is independent. No conflict resolution needed (unlike gathering/combat). -- **Walking to an obstacle room via `walk <room_number>`:** Works fine. The player can walk to any obstacle room and attempt it. This doesn't break anything — individual obstacle XP is small, and the completion bonus only fires on the last obstacle. -- **BFS pathfinding:** Obstacle rooms have a "down" exit to room 200, so BFS can find them. However, there are no forward exits between obstacle rooms (movement is done via teleport on obstacle completion), so BFS cannot path *through* the course. This is intentional. -- **Map display:** Obstacle rooms will appear on the map connected to room 200 via "down" exits. This is fine — they'll cluster around the hub. -- **Color target:** The plan uses `"agility"` as a color target for obstacle messages. If this target doesn't exist in the color config, it will fall back to default. Add it to the color config if desired, or use an existing target like `"broadcast"`. -- **`obstacleVerbs` race condition:** The package-level `obstacleVerbs` map is written once during `CourseStore.LoadAll()` (called from `Game.New()` before any sessions exist) and then only read. No mutex needed for reads. -- **`FailDamage` YAML parsing:** The `[2]int` type for `fail_damage` works with YAML arrays like `[1, 2]`. Go's `yaml.v3` handles this correctly for fixed-size arrays. -- **Verb conjugation for ActionState:** The `Verb` field is set to e.g. `"scrambling"` (verb + "ing"). This is a naive conjugation. For verbs like "slide" it produces "slideing" which is wrong. To handle this, add a small helper or hardcode the gerund forms: - ```go - var verbGerund = map[string]string{ - "scramble": "scrambling", - "jump": "jumping", - "swing": "swinging", - "balance": "balancing", - "climb": "climbing", - "crawl": "crawling", - "vault": "vaulting", - "leap": "leaping", - "slide": "sliding", - } - ``` - Use this in `startObstacle()` instead of naive `verb + "ing"`. diff --git a/skill_plans/assassin.md b/skill_plans/assassin.md deleted file mode 100644 index 31ea301..0000000 --- a/skill_plans/assassin.md +++ /dev/null @@ -1,1895 +0,0 @@ -# Assassin Skill Implementation Plan - -## 1. Overview - -Assassin is the Slayer analog for The House of Icarus. A special NPC called **The Client** assigns the player a task to kill a certain number of a specific mob type. Killing mobs "on task" awards Assassin XP in addition to normal combat XP. Some mobs require a minimum Assassin level to attack. Some mobs deal extra damage unless the player wears specific protective equipment. Some mobs cannot be killed below 1 HP without using a finishing blow item on them. - -Players earn **Reputation** (analogous to Slayer Points) on task completion, with streak bonuses at milestones. Reputation is spent in the Client's Reputation Shop to skip/extend tasks, unlock permanent perks, and buy auto-finishing blow unlocks. - -The Assassin skill already exists as a stub in `internal/player/player.go` (constant `Assassin SkillName = "assassin"`, abbreviation `"asm"`). XP is tracked but no behaviors, items, mobs, or commands exist yet. - ---- - -## 2. Task System Architecture - -### Player Flags for Task State - -All task state is stored in player flags (`p.Flags` map, persisted to character YAML). Using player flags means tasks are per-character, saved automatically, and require no schema changes to `Player`. - -| Flag Key | Type | Description | -|---|---|---| -| `assassin_task_mob` | `string` | Mob definition ID of current task (e.g. `"slug"`) | -| `assassin_task_total` | `int` | Total kills assigned for this task | -| `assassin_task_remaining` | `int` | Kills remaining on current task | -| `assassin_tasks_completed` | `int` | Lifetime tasks completed (never resets) | -| `assassin_streak` | `int` | Consecutive tasks completed without skipping | -| `assassin_reputation` | `int` | Current unspent reputation points | -| `assassin_unlocked_<perk>` | `bool` | Purchased permanent unlocks (e.g. `assassin_unlocked_auto_salt`) | - -### Task Lifecycle - -1. Player talks to The Client and selects "I need a job." -2. Client calls `assignAssassinTask(p)` which picks a mob from the task table based on `p.Level(player.Assassin)`. -3. Player flags are set: `assassin_task_mob`, `assassin_task_total`, `assassin_task_remaining`. -4. Player kills assigned mobs. Each on-task kill: - - Decrements `assassin_task_remaining` - - Awards Assassin XP = `mob.MaxHP * 2` - - Displays task progress message: `"Assassin task: 12 of 45 slugs remaining."` -5. When `assassin_task_remaining` reaches 0: - - Increments `assassin_tasks_completed` and `assassin_streak` - - Awards base reputation (1) + any streak bonus - - Clears `assassin_task_mob` - - Displays completion message with reputation earned -6. Player returns to Client for a new task. - -### How Flags Are Read/Written - -Player flags use `map[string]any`. Integer flags are stored as `int` but may unmarshal from YAML as `int`, `int64`, or `float64`. A helper function must handle type assertion: - -```go -// in internal/game/assassin.go -func getPlayerFlagInt(p *player.Player, key string) int { - if p.Flags == nil { - return 0 - } - val, ok := p.Flags[key] - if !ok { - return 0 - } - switch v := val.(type) { - case int: - return v - case int64: - return int(v) - case float64: - return int(v) - } - return 0 -} - -func getPlayerFlagString(p *player.Player, key string) string { - if p.Flags == nil { - return "" - } - val, ok := p.Flags[key] - if !ok { - return "" - } - s, _ := val.(string) - return s -} - -func setPlayerFlag(p *player.Player, key string, val any) { - if p.Flags == nil { - p.Flags = make(map[string]any) - } - p.Flags[key] = val -} -``` - ---- - -## 3. Commands - -### `task` (Instant Command) - -Displays the player's current Assassin task status. No arguments. - -**Output examples:** - -- Has task: `"Assassin task: Kill slugs. 12 of 45 remaining."` -- No task: `"You don't have an Assassin task. Talk to The Client to get one."` -- Task just completed: `"You have no active task. Talk to The Client for a new assignment."` - -Also shows streak and reputation: -``` -Assassin task: Kill slugs. 12 of 45 remaining. -Streak: 7 | Reputation: 42 -``` - -**Implementation:** Create `internal/game/cmd_task.go`: - -```go -package game - -import ( - "fmt" - - "thehouseoficarus/internal/net" - "thehouseoficarus/internal/player" -) - -func (g *Game) doTask(sess *net.Session) { - p := sess.Player.(*player.Player) - - mobID := getPlayerFlagString(p, "assassin_task_mob") - remaining := getPlayerFlagInt(p, "assassin_task_remaining") - total := getPlayerFlagInt(p, "assassin_task_total") - streak := getPlayerFlagInt(p, "assassin_streak") - rep := getPlayerFlagInt(p, "assassin_reputation") - - if mobID == "" || remaining <= 0 { - sess.WriteLine("You don't have an Assassin task. Talk to The Client to get one.") - } else { - def, err := g.MobStore.LoadDef(mobID) - name := mobID - if err == nil { - name = def.Name - } - sess.WriteLine(fmt.Sprintf("Assassin task: Kill %ss. %d of %d remaining.", name, remaining, total)) - } - sess.WriteLine(fmt.Sprintf("Streak: %d | Reputation: %d", streak, rep)) -} -``` - -### No New Combat Commands - -The existing `attack`/`kill` command is extended in `cmd_attack.go` to check assassin level requirements, apply finishing blow mechanics, and award on-task XP. No new verbs are needed. - ---- - -## 4. New Files to Create - -### Go Files - -| File | Purpose | -|---|---| -| `internal/game/cmd_task.go` | `doTask()` handler — displays current task info | -| `internal/game/assassin.go` | Core assassin logic: task table, `assignAssassinTask()`, `onAssassinKill()`, `assassinTaskTable`, flag helpers | - -### YAML Data Files - -| File | Purpose | -|---|---| -| `data/mobs/slug.yaml` | Slug mob (assassin level 1, finishing blow: salt) | -| `data/mobs/drone.yaml` | Drone mob (assassin level 15, damage_without: insulated_gloves) | -| `data/mobs/crawler.yaml` | Crawler mob (assassin level 30, finishing blow: acid_vial) | -| `data/mobs/phantom.yaml` | Phantom mob (assassin level 45, damage_without: spectral_visor) | -| `data/mobs/client.yaml` | The Client NPC (protected, unique, behavior: client_talk) | -| `data/items/salt.yaml` | Salt (stackable, finishing blow consumable) | -| `data/items/acid_vial.yaml` | Acid vial (stackable, finishing blow consumable) | -| `data/items/insulated_gloves.yaml` | Insulated gloves (hands slot, minimal stats) | -| `data/items/spectral_visor.yaml` | Spectral visor (head slot, minimal stats) | -| `data/behaviors/client_talk.yaml` | Client dialog tree (task assignment, reputation shop) | -| `data/help/task.yaml` | Help topic for `task` command | -| `data/help/assassin.yaml` | Help topic for assassin skill overview | -| `data/rooms/50.yaml` | Assassin Den room (Client's location) — or modify an existing room | - -### Rooms that need mobs added - -| Room | Mob(s) Added | -|---|---| -| New room (e.g. 50) | `client` | -| New room (e.g. 51) | `slug`, `slug` | -| New room (e.g. 52) | `drone`, `drone` | -| New room (e.g. 53) | `crawler` | -| New room (e.g. 54) | `phantom` | - ---- - -## 5. Code Changes to Existing Files - -### `internal/world/mob.go` — MobDef Extensions - -Add three new fields to `MobDef`: - -```go -type MobDef struct { - // ... existing fields ... - AssassinLevel int `yaml:"assassin_level"` - FinishingBlow string `yaml:"finishing_blow"` - DamageWithout string `yaml:"damage_without"` -} -``` - -Also add these fields to `MobInstance` so they're accessible at runtime: - -```go -type MobInstance struct { - // ... existing fields ... - AssassinLevel int - FinishingBlow string - DamageWithout string -} -``` - -In `SeedMobs()`, copy these fields from def to instance: - -```go -inst := &MobInstance{ - // ... existing fields ... - AssassinLevel: dw.def.AssassinLevel, - FinishingBlow: dw.def.FinishingBlow, - DamageWithout: dw.def.DamageWithout, -} -``` - -### `internal/game/cmd_attack.go` — Attack Modifications - -#### a) Assassin Level Check in `doAttack()` - -After the `mob.Protected` check (line 38-41) and before `startCombat()` (line 49), add: - -```go -if mob.AssassinLevel > 0 && p.Level(player.Assassin) < mob.AssassinLevel { - sess.WriteLine(fmt.Sprintf("You need Assassin level %d to attack %s.", mob.AssassinLevel, mobDisplayName(mob, true))) - return -} -``` - -#### b) Finishing Blow — HP Floor in `playerAttack()` - -After `mob.HP -= dmg` (line 199), add the finishing blow HP floor: - -```go -if mob.FinishingBlow != "" && mob.HP <= 0 { - mob.HP = 1 -} -``` - -This prevents the mob from dying via normal combat. The mob stays at 1 HP. The player must `use <item> on <mob>` to kill it. - -When the mob is at 1 HP and has a finishing blow requirement, display a hint on every attack: - -```go -if mob.FinishingBlow != "" && mob.HP == 1 { - def, _ := g.ItemStore.Load(mob.FinishingBlow) - itemName := mob.FinishingBlow - if def != nil { - itemName = def.Name - } - sess.WriteLine(g.colorize(sess, "warning", fmt.Sprintf(" %s is immune! Use %s on it to finish it off.", mobDisplayName(mob, false), itemName))) -} -``` - -#### c) Damage Increase in `mobAttack()` - -After calculating `dmg` in `mobAttack()` (line 254), before applying to `p.HP`, add: - -```go -if mob.DamageWithout != "" { - hasProtection := false - for _, itemID := range p.Equipment { - if itemID == mob.DamageWithout { - hasProtection = true - break - } - } - if !hasProtection { - dmg = dmg * 3 / 2 // 1.5x damage - } -} -``` - -#### d) On-Task Kill XP in `endCombat()` - -After the victory message (line 312) and before loot drops (line 324), add the assassin task processing: - -```go -if mob != nil && mob.HP <= 0 { - sess.WriteLine(g.colorize(sess, "victory", fmt.Sprintf("\nYou have defeated %s!", mobDisplayName(mob, true)))) - - // --- NEW: Assassin task processing --- - g.onAssassinKill(sess, p, mob) - // --- END NEW --- - - // ... existing broadcast, loot drops, respawn ... -} -``` - -### `internal/game/game.go` — Register `task` Command - -#### a) In `classifyCommand()` (line 136) - -Add `"task"` to the Instant list: - -```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", "task": - return ClassInstant -``` - -#### b) In `executeCommand()` (line 249) - -Add a case after `"sc", "score"`: - -```go -case "task": - g.doTask(sess) -``` - -### `internal/game/game.go` — Finishing Blow via `use` on Mob - -The `doUse()` function in `cmd_use.go` currently handles `use <item> on <target>` for objects. It needs a new path: if the target is a mob that the player is in combat with and the mob has `finishing_blow` set and is at 1 HP, consume the item and kill the mob. - -In the `doUse()` function, after failing to find an object target, attempt to find a mob target: - -```go -// After object lookup fails or before object lookup -// Check if target is a mob in combat with the player -cs := combat.GetCombat(p.Name) -if cs != nil { - mob := g.MobStore.GetInstance(cs.MobID) - if mob != nil && mob.HP == 1 && mob.FinishingBlow != "" { - if mob.MatchQuality(targetName) != world.MatchNone { - if strings.ToLower(itemName) == mob.FinishingBlow || itemMatchesDef(itemName, mob.FinishingBlow, g.ItemStore) { - if !p.HasItem(mob.FinishingBlow) { - sess.WriteLine(fmt.Sprintf("You don't have any %s.", mob.FinishingBlow)) - return - } - // Check for auto-finish unlock - autoKey := "assassin_unlocked_auto_" + mob.FinishingBlow - if getPlayerFlagBool(p, autoKey) { - // Don't consume - } else { - p.RemoveItem(mob.FinishingBlow, 1) - } - def, _ := g.ItemStore.Load(mob.FinishingBlow) - itemDisplayName := mob.FinishingBlow - if def != nil { - itemDisplayName = def.Name - } - sess.WriteLine(fmt.Sprintf("You use the %s on %s!", itemDisplayName, mobDisplayName(mob, true))) - mob.HP = 0 - g.endCombat(sess, p, mob) - return - } - } - } -} -``` - -A helper is needed to match an item input string to a finishing blow item ID: - -```go -func itemMatchesDef(input, itemID string, store *object.ItemStore) bool { - def, err := store.Load(itemID) - if err != nil { - return false - } - return def.MatchesName(input) -} -``` - -### `internal/game/action_state.go` — No Changes Needed - -The existing `ActionCombating` action type covers all combat states including assassin-related combat. No new action types are needed. - ---- - -## 6. The Client NPC - -### Mob Definition: `data/mobs/client.yaml` - -```yaml -id: client -name: The Client -description: "A shadowy figure in a dark coat. They speak in clipped, measured tones and seem to know everything about every creature in the sector." -unique: true -protected: true -behavior: client_talk -attack: 1 -strength: 1 -defense: 1 -hp: 100 -speed: 5 -aggressive: false -respawn_ticks: 30 -idle_descriptions: - - "reviews a holographic dossier" - - "marks a location on a worn star chart" - - "flips a credit chit between their fingers" - - "mutters coordinates under their breath" - - "glances at you appraisingly" - - "taps a data pad with a stylus" -``` - -### Talk Behavior: `data/behaviors/client_talk.yaml` - -The Client's dialog tree is complex. Since the talk system currently doesn't support dynamic logic (task assignment requires Go code), the approach is: - -**Option A (Recommended): Hybrid approach — use `set_player_flags` with a sentinel value, then intercept in `applyNodeAction`.** - -Add a new `NodeAction` field called `assign_task` (boolean) that triggers `assignAssassinTask()` from Go code. - -**Required change to `internal/action/behavior.go`:** - -```go -type NodeAction struct { - // ... existing fields ... - AssignTask bool `yaml:"assign_task"` - SkipTask bool `yaml:"skip_task"` - ExtendTask bool `yaml:"extend_task"` -} -``` - -**Required change to `internal/game/action_talk.go` in `applyNodeAction()`:** - -After existing action processing (line 218), add: - -```go -if na.AssignTask { - g.assignAssassinTask(sess, p) -} -if na.SkipTask { - g.skipAssassinTask(sess, p) -} -if na.ExtendTask { - g.extendAssassinTask(sess, p) -} -``` - -**Full behavior YAML:** - -```yaml -id: client_talk -type: talk -nodes: - start: - message: "The Client looks up from their data pad. \"What do you need?\"" - options: - - text: "\"I need a job.\"" - goto: assign_task - condition: - player_flag: assassin_task_mob - not: true - - text: "\"I've finished my task.\"" - goto: task_complete - condition: - all_of: - - player_flag: assassin_task_mob - not: true - value: "" - - text: "\"What's my current task?\"" - goto: current_task - condition: - player_flag: assassin_task_mob - not: true - - text: "\"I want to skip my task.\"" - goto: skip_confirm - condition: - player_flag: assassin_task_mob - not: true - - text: "\"I want to extend my task.\"" - goto: extend_confirm - condition: - player_flag: assassin_task_mob - not: true - - text: "\"I'd like to browse the Reputation Shop.\"" - goto: rep_shop - - text: "\"I need supplies.\"" - goto: supplies - - text: "\"Goodbye.\"" - end: true - - assign_task: - message: "\"Let me check what's available...\" The Client scrolls through their data pad." - action: - assign_task: true - options: - - text: "\"Understood.\"" - end: true - - text: "\"What else do you have?\"" - goto: start - - current_task: - message: "The Client checks their records." - options: - - text: "\"Okay.\"" - end: true - - skip_confirm: - message: "\"Skipping a task costs 30 Reputation and resets your streak. Are you sure?\"" - options: - - text: "\"Yes, skip it.\"" - goto: skip_done - - text: "\"Never mind.\"" - goto: start - - skip_done: - message: "\"Task cancelled. Your streak has been reset.\"" - action: - skip_task: true - options: - - text: "\"Give me a new one.\"" - goto: assign_task - - text: "\"Goodbye.\"" - end: true - - extend_confirm: - message: "\"Extending your task costs 30 Reputation and adds more kills. Want to proceed?\"" - options: - - text: "\"Yes, extend it.\"" - goto: extend_done - - text: "\"Never mind.\"" - goto: start - - extend_done: - message: "\"Done. I've added more targets to your contract.\"" - action: - extend_task: true - options: - - text: "\"Thanks.\"" - end: true - - rep_shop: - message: "\"Here's what I've got. All purchases are permanent.\"" - options: - - text: "\"Auto-finish: Salt (200 Rep) - Never consume salt on finishing blows.\"" - goto: buy_auto_salt - - text: "\"Auto-finish: Acid Vial (200 Rep) - Never consume acid vials on finishing blows.\"" - goto: buy_auto_acid - - text: "\"Unlock Superior Mobs (300 Rep) - Rare superior variants may spawn.\"" - goto: buy_superiors - - text: "\"Unlock Extended Tasks (100 Rep) - Allows extending tasks.\"" - goto: buy_extend_unlock - - text: "\"Back.\"" - goto: start - - buy_auto_salt: - message: "\"Auto-salt purchased. You'll no longer consume salt on finishing blows.\"" - action: - set_player_flags: - assassin_unlocked_auto_salt: true - options: - - text: "\"Thanks.\"" - goto: rep_shop - - buy_auto_acid: - message: "\"Auto-acid purchased. Acid vials will no longer be consumed.\"" - action: - set_player_flags: - assassin_unlocked_auto_acid_vial: true - options: - - text: "\"Thanks.\"" - goto: rep_shop - - buy_superiors: - message: "\"Superior encounters unlocked. Watch yourself out there.\"" - action: - set_player_flags: - assassin_unlocked_superiors: true - options: - - text: "\"Thanks.\"" - goto: rep_shop - - buy_extend_unlock: - message: "\"You can now extend tasks via our conversation.\"" - action: - set_player_flags: - assassin_unlocked_extend: true - options: - - text: "\"Thanks.\"" - goto: rep_shop - - supplies: - message: "\"I stock everything you need for the job. Cheap, too.\"" - options: - - text: "\"Buy salt (5 credits each).\"" - goto: buy_salt - condition: - min_credits: 5 - - text: "\"Buy acid vial (10 credits each).\"" - goto: buy_acid - condition: - min_credits: 10 - - text: "\"Buy insulated gloves (50 credits).\"" - goto: buy_gloves - condition: - min_credits: 50 - - text: "\"Buy spectral visor (75 credits).\"" - goto: buy_visor - condition: - min_credits: 75 - - text: "\"Back.\"" - goto: start - - buy_salt: - message: "\"Here you go.\" The Client slides a packet of salt across the table." - action: - give_item: salt - cost: 5 - options: - - text: "\"Buy more.\"" - goto: buy_salt - condition: - min_credits: 5 - - text: "\"Thanks.\"" - goto: supplies - - buy_acid: - message: "\"Handle with care.\" The Client passes you a vial of corrosive acid." - action: - give_item: acid_vial - cost: 10 - options: - - text: "\"Buy more.\"" - goto: buy_acid - condition: - min_credits: 10 - - text: "\"Thanks.\"" - goto: supplies - - buy_gloves: - message: "\"These'll keep the current from frying your hands.\" The Client tosses you a pair of thick rubber gloves." - action: - give_item: insulated_gloves - cost: 50 - options: - - text: "\"Thanks.\"" - goto: supplies - - buy_visor: - message: "\"Spectral frequency filter. Makes the invisible visible — and keeps their attacks from scrambling your brain.\"" - action: - give_item: spectral_visor - cost: 75 - options: - - text: "\"Thanks.\"" - goto: supplies -``` - -**Note on dynamic content in dialog:** The talk system currently renders static `message` strings. For the "current task" and "assign task" nodes, the message content depends on player state. Two approaches: - -**Approach 1 (Simple):** The `assign_task` action in `applyNodeAction` writes a message to the session directly. The node's `message` is generic ("Let me check what's available...") and the actual task details are output by the Go function. - -**Approach 2 (Placeholder):** Support a `{assassin_task}` placeholder in talk node messages that `showTalkNode` expands before display. This is more complex but cleaner. - -**Recommended: Approach 1.** The `assignAssassinTask()` function outputs the task details via `sess.WriteLine()` after the node message is shown. - ---- - -## 7. Task Assignment Logic - -### File: `internal/game/assassin.go` - -```go -package game - -import ( - "fmt" - "math/rand" - - "thehouseoficarus/internal/net" - "thehouseoficarus/internal/player" -) - -type assassinTaskEntry struct { - MobID string - MinLevel int - MaxLevel int - MinCount int - MaxCount int - Weight int -} - -var assassinTaskTable = []assassinTaskEntry{ - // Level 1-10: basic mobs - {"man", 1, 99, 10, 30, 10}, - {"cow", 1, 99, 10, 30, 10}, - - // Assassin-specific mobs - {"slug", 1, 99, 15, 45, 15}, - {"drone", 15, 99, 20, 50, 12}, - {"crawler", 30, 99, 15, 40, 10}, - {"phantom", 45, 99, 10, 30, 8}, -} - -func (g *Game) assignAssassinTask(sess *net.Session, p *player.Player) { - level := p.Level(player.Assassin) - - var eligible []assassinTaskEntry - totalWeight := 0 - for _, entry := range assassinTaskTable { - if level >= entry.MinLevel && level <= entry.MaxLevel { - eligible = append(eligible, entry) - totalWeight += entry.Weight - } - } - - if len(eligible) == 0 { - sess.WriteLine("The Client shakes their head. \"Nothing available for your level.\"") - return - } - - // Weighted random selection - roll := rand.Intn(totalWeight) - var chosen assassinTaskEntry - for _, entry := range eligible { - roll -= entry.Weight - if roll < 0 { - chosen = entry - break - } - } - - // Random count within range - count := chosen.MinCount + rand.Intn(chosen.MaxCount-chosen.MinCount+1) - - setPlayerFlag(p, "assassin_task_mob", chosen.MobID) - setPlayerFlag(p, "assassin_task_total", count) - setPlayerFlag(p, "assassin_task_remaining", count) - g.AccountStore.SaveCharacter(p) - - def, err := g.MobStore.LoadDef(chosen.MobID) - name := chosen.MobID - if err == nil { - name = def.Name - } - sess.WriteLine(g.colorize(sess, "assassin_task", fmt.Sprintf("\"Your target: %d %ss. Get to work.\"", count, name))) -} - -func (g *Game) onAssassinKill(sess *net.Session, p *player.Player, mob *world.MobInstance) { - taskMob := getPlayerFlagString(p, "assassin_task_mob") - if taskMob == "" || taskMob != mob.DefID { - return - } - - remaining := getPlayerFlagInt(p, "assassin_task_remaining") - if remaining <= 0 { - return - } - - // Award Assassin XP - xp := mob.MaxHP * 2 - newLevel := p.AddSkillXP(player.Assassin, xp) - if newLevel > 0 { - sess.WriteLine(g.colorize(sess, "level_up", fmt.Sprintf("*** You are now level %d Assassin! ***", newLevel))) - } - if p.OptionBool("xp_drops") { - sess.WriteLine(g.colorize(sess, "xp", fmt.Sprintf(" (+%dxp asm)", xp))) - } - - remaining-- - setPlayerFlag(p, "assassin_task_remaining", remaining) - - if remaining <= 0 { - // Task complete - completed := getPlayerFlagInt(p, "assassin_tasks_completed") + 1 - streak := getPlayerFlagInt(p, "assassin_streak") + 1 - - setPlayerFlag(p, "assassin_tasks_completed", completed) - setPlayerFlag(p, "assassin_streak", streak) - setPlayerFlag(p, "assassin_task_mob", "") - setPlayerFlag(p, "assassin_task_remaining", 0) - setPlayerFlag(p, "assassin_task_total", 0) - - // Calculate reputation - rep := 1 // base - bonus := streakBonus(streak) - rep += bonus - currentRep := getPlayerFlagInt(p, "assassin_reputation") - setPlayerFlag(p, "assassin_reputation", currentRep+rep) - - sess.WriteLine(g.colorize(sess, "assassin_task", fmt.Sprintf("\n*** Assassin task complete! ***"))) - if bonus > 0 { - sess.WriteLine(g.colorize(sess, "assassin_task", fmt.Sprintf(" Streak bonus! %d tasks in a row. +%d bonus reputation.", streak, bonus))) - } - sess.WriteLine(g.colorize(sess, "assassin_task", fmt.Sprintf(" Reputation earned: %d (total: %d)", rep, currentRep+rep))) - } else { - total := getPlayerFlagInt(p, "assassin_task_total") - def, _ := g.MobStore.LoadDef(taskMob) - name := taskMob - if def != nil { - name = def.Name - } - sess.WriteLine(g.colorize(sess, "assassin_task", fmt.Sprintf(" Assassin task: %d of %d %ss remaining.", remaining, total, name))) - } - - g.AccountStore.SaveCharacter(p) -} - -func (g *Game) skipAssassinTask(sess *net.Session, p *player.Player) { - rep := getPlayerFlagInt(p, "assassin_reputation") - if rep < 30 { - sess.WriteLine("You don't have enough Reputation to skip. (Need 30, have " + fmt.Sprint(rep) + ")") - return - } - setPlayerFlag(p, "assassin_reputation", rep-30) - setPlayerFlag(p, "assassin_task_mob", "") - setPlayerFlag(p, "assassin_task_remaining", 0) - setPlayerFlag(p, "assassin_task_total", 0) - setPlayerFlag(p, "assassin_streak", 0) // Reset streak - g.AccountStore.SaveCharacter(p) -} - -func (g *Game) extendAssassinTask(sess *net.Session, p *player.Player) { - rep := getPlayerFlagInt(p, "assassin_reputation") - if rep < 30 { - sess.WriteLine("You don't have enough Reputation to extend. (Need 30, have " + fmt.Sprint(rep) + ")") - return - } - taskMob := getPlayerFlagString(p, "assassin_task_mob") - if taskMob == "" { - sess.WriteLine("You don't have an active task to extend.") - return - } - setPlayerFlag(p, "assassin_reputation", rep-30) - // Add 50% more kills - total := getPlayerFlagInt(p, "assassin_task_total") - remaining := getPlayerFlagInt(p, "assassin_task_remaining") - extension := total / 2 - if extension < 5 { - extension = 5 - } - setPlayerFlag(p, "assassin_task_total", total+extension) - setPlayerFlag(p, "assassin_task_remaining", remaining+extension) - g.AccountStore.SaveCharacter(p) - - def, _ := g.MobStore.LoadDef(taskMob) - name := taskMob - if def != nil { - name = def.Name - } - sess.WriteLine(fmt.Sprintf("Task extended by %d. Kill %d more %ss (%d total).", extension, remaining+extension, name, total+extension)) -} - -func streakBonus(streak int) int { - bonus := 0 - if streak%1000 == 0 { - bonus += 50 - } else if streak%250 == 0 { - bonus += 35 - } else if streak%100 == 0 { - bonus += 25 - } else if streak%50 == 0 { - bonus += 15 - } else if streak%10 == 0 { - bonus += 5 - } - return bonus -} -``` - ---- - -## 8. Reputation System - -### Earning Reputation - -Reputation is earned on task completion: - -| Event | Reputation | -|---|---| -| Task completed (base) | +1 | -| Every 10th consecutive task | +5 bonus | -| Every 50th consecutive task | +15 bonus | -| Every 100th consecutive task | +25 bonus | -| Every 250th consecutive task | +35 bonus | -| Every 1000th consecutive task | +50 bonus | - -Milestone bonuses are checked via modulo and are **mutually exclusive** — only the highest applicable milestone bonus is awarded per completion. The `streakBonus()` function checks from highest to lowest: - -```go -func streakBonus(streak int) int { - if streak%1000 == 0 { - return 50 - } - if streak%250 == 0 { - return 35 - } - if streak%100 == 0 { - return 25 - } - if streak%50 == 0 { - return 15 - } - if streak%10 == 0 { - return 5 - } - return 0 -} -``` - -### Spending Reputation - -Reputation is spent via The Client's talk dialog. The `cost` field on `NodeAction` already handles credit deduction. For reputation, since it's stored as a player flag rather than `p.Credits`, we need one of: - -**Option A:** Add a `reputation_cost` field to `NodeAction`. In `applyNodeAction`, deduct from the player flag. - -**Option B:** Handle reputation costs inside `skipAssassinTask()` and `extendAssassinTask()` (the Go functions called by `skip_task`/`extend_task` actions). - -**Recommended: Option B** for skip/extend (since the Go function already exists), and **Option A** for shop purchases. - -Add to `NodeAction`: - -```go -type NodeAction struct { - // ... existing fields ... - AssignTask bool `yaml:"assign_task"` - SkipTask bool `yaml:"skip_task"` - ExtendTask bool `yaml:"extend_task"` - ReputationCost int `yaml:"reputation_cost"` -} -``` - -In `applyNodeAction()`: - -```go -if na.ReputationCost > 0 { - rep := getPlayerFlagInt(p, "assassin_reputation") - if rep < na.ReputationCost { - sess.WriteLine(fmt.Sprintf("You don't have enough Reputation. (Need %d, have %d)", na.ReputationCost, rep)) - return // Don't apply remaining actions - } - setPlayerFlag(p, "assassin_reputation", rep-na.ReputationCost) - g.AccountStore.SaveCharacter(p) -} -``` - -The reputation shop nodes in the behavior YAML then use: - -```yaml - buy_auto_salt: - message: "\"Auto-salt purchased. You'll no longer consume salt on finishing blows.\"" - action: - reputation_cost: 200 - set_player_flags: - assassin_unlocked_auto_salt: true - options: - - text: "\"Thanks.\"" - goto: rep_shop -``` - -Condition to show shop items only if not already purchased: - -```yaml - - text: "\"Auto-finish: Salt (200 Rep)\"" - goto: buy_auto_salt - condition: - all_of: - - player_flag: assassin_unlocked_auto_salt - not: true -``` - ---- - -## 9. Reputation Shop Items - -| Item | Cost | Flag Set | Effect | -|---|---|---|---| -| Skip task | 30 Rep | (handled by `skip_task` action) | Cancels current task, resets streak | -| Extend task | 30 Rep | (handled by `extend_task` action) | Adds 50% more kills to current task | -| Auto-finish: Salt | 200 Rep | `assassin_unlocked_auto_salt` | Salt is not consumed on finishing blows | -| Auto-finish: Acid Vial | 200 Rep | `assassin_unlocked_auto_acid_vial` | Acid vials are not consumed on finishing blows | -| Unlock Superior Mobs | 300 Rep | `assassin_unlocked_superiors` | Rare chance for stronger mob variant to spawn (future feature) | -| Unlock Extended Tasks | 100 Rep | `assassin_unlocked_extend` | Allows the "extend task" dialog option | -| Broader Task List | 150 Rep | `assassin_unlocked_broader` | Unlocks additional mobs in the task table (future feature) | - -### Updated Dialog YAML with Conditions - -The "extend" option should only appear if the player has purchased the unlock: - -```yaml - - text: "\"I want to extend my task.\"" - goto: extend_confirm - condition: - all_of: - - player_flag: assassin_task_mob - not: true - - player_flag: assassin_unlocked_extend - value: true -``` - ---- - -## 10. Slayer-Only Mobs - -### `data/mobs/slug.yaml` - -```yaml -id: slug -name: slug -description: "A bloated, translucent slug the size of a dog. Its skin glistens with toxic mucus. It cannot be killed by conventional means — only salt can destroy it." -assassin_level: 1 -finishing_blow: salt -attack: 3 -strength: 3 -defense: 1 -hp: 15 -speed: 6 -aggressive: false -respawn_ticks: 25 -idle_descriptions: - - "oozes along the floor leaving a slimy trail" - - "contracts and expands rhythmically" - - "extends its eyestalks toward you" - - "secretes a glob of toxic mucus" -combat_descriptions: - - "lunges slimily at %s" - - "sprays mucus toward %s" - - "writhes in combat with %s" -drops: - remains: slug_mucus - loot: - - item_id: credits - weight: 100 - quantity: 15 -``` - -### `data/mobs/drone.yaml` - -```yaml -id: drone -name: drone -description: "A malfunctioning security drone crackling with electrical discharge. Its attacks are especially dangerous to anyone not wearing insulated gloves." -assassin_level: 15 -damage_without: insulated_gloves -attack: 15 -strength: 14 -defense: 12 -hp: 45 -speed: 4 -aggressive: true -respawn_ticks: 35 -idle_descriptions: - - "hovers erratically, sparking" - - "emits a high-pitched whine" - - "scans the area with a flickering red beam" - - "rotates its weapon array with a mechanical click" -combat_descriptions: - - "fires an electrical bolt at %s" - - "charges its capacitors and zaps %s" - - "swoops down on %s with crackling energy" -drops: - remains: circuit_board - loot: - - item_id: credits - weight: 80 - quantity: 75 - - item_id: insulated_gloves - weight: 5 - - item_id: credits - weight: 15 - quantity: 200 -``` - -### `data/mobs/crawler.yaml` - -```yaml -id: crawler -name: crawler -description: "A heavily armored bio-mechanical creature with a chitinous exoskeleton. Its regenerative biology prevents death unless dissolved with acid." -assassin_level: 30 -finishing_blow: acid_vial -attack: 25 -strength: 22 -defense: 30 -hp: 80 -speed: 5 -aggressive: false -respawn_ticks: 45 -idle_descriptions: - - "scrapes its mandibles together menacingly" - - "clicks and chitters in an alien rhythm" - - "tests the air with feathered antennae" - - "coils its segmented body defensively" -combat_descriptions: - - "snaps its mandibles at %s" - - "lashes out with a barbed tail at %s" - - "charges headlong into %s" -drops: - remains: chitin_plate - loot: - - item_id: credits - weight: 70 - quantity: 250 - - item_id: acid_vial - weight: 10 - - item_id: credits - weight: 20 - quantity: 500 -``` - -### `data/mobs/phantom.yaml` - -```yaml -id: phantom -name: phantom -description: "A semi-transparent entity that phases in and out of visible light. Its psychic attacks are devastating to anyone without a spectral visor." -assassin_level: 45 -damage_without: spectral_visor -attack: 35 -strength: 30 -defense: 25 -hp: 100 -speed: 3 -aggressive: true -respawn_ticks: 50 -idle_descriptions: - - "flickers between visible and invisible" - - "emits a low, resonant hum" - - "drifts through a wall and back again" - - "stares at you with hollow, glowing eyes" -combat_descriptions: - - "blasts %s with a psychic wave" - - "phases through %s's defenses" - - "unleashes a spectral shriek at %s" -drops: - remains: ectoplasm - loot: - - item_id: credits - weight: 60 - quantity: 500 - - item_id: spectral_visor - weight: 3 - - item_id: credits - weight: 37 - quantity: 1000 -``` - -### Additional Item Drops (remain items) - -These are guaranteed drops ("remains") from the new mobs. Create simple item YAMLs: - -#### `data/items/slug_mucus.yaml` -```yaml -id: slug_mucus -name: slug mucus -color: "82" -description: "A glob of toxic slug mucus. Unpleasant." -value: 5 -stackable: false -``` - -#### `data/items/circuit_board.yaml` -```yaml -id: circuit_board -name: circuit board -color: "40" -description: "A scorched circuit board salvaged from a destroyed drone." -value: 25 -stackable: false -``` - -#### `data/items/chitin_plate.yaml` -```yaml -id: chitin_plate -name: chitin plate -color: "130" -description: "A thick plate of biological armor from a crawler's exoskeleton." -value: 50 -stackable: false -``` - -#### `data/items/ectoplasm.yaml` -```yaml -id: ectoplasm -name: ectoplasm -color: "159" -description: "A shimmering residue left behind by a destroyed phantom." -value: 75 -stackable: false -``` - ---- - -## 11. Assassin Equipment - -### `data/items/salt.yaml` - -```yaml -id: salt -name: salt -color: "255" -description: "A packet of coarse industrial salt. Used to destroy slugs." -value: 5 -stackable: true -``` - -### `data/items/acid_vial.yaml` - -```yaml -id: acid_vial -name: acid vial -color: "46" -description: "A small vial of concentrated acid. Used to dissolve crawlers." -value: 10 -stackable: true -``` - -### `data/items/insulated_gloves.yaml` - -```yaml -id: insulated_gloves -name: insulated gloves -color: "214" -description: "Heavy rubber gloves that protect against electrical attacks. Essential when fighting drones." -value: 50 -stackable: false -equip_slot: hands -stats: - defense_bonus: 1 -``` - -### `data/items/spectral_visor.yaml` - -```yaml -id: spectral_visor -name: spectral visor -color: "141" -description: "A visor fitted with spectral frequency filters. Dampens psychic attacks from phantoms." -value: 75 -stackable: false -equip_slot: head -stats: - defense_bonus: 2 -``` - ---- - -## 12. Finishing Blow Mechanic - -### Core Logic - -The finishing blow mechanic has two components: - -#### a) HP Floor (in `playerAttack`) - -In `internal/game/cmd_attack.go`, in the `playerAttack()` method, after `mob.HP -= dmg` (line 199): - -```go -mob.HP -= dmg -if mob.HP < 0 { - mob.HP = 0 -} - -// Finishing blow: mob cannot die from normal combat -if mob.FinishingBlow != "" && mob.HP <= 0 { - mob.HP = 1 -} -``` - -When mob is at 1 HP and has a finishing blow, display a message: - -```go -if mob.FinishingBlow != "" && mob.HP == 1 { - fbDef, _ := g.ItemStore.Load(mob.FinishingBlow) - fbName := mob.FinishingBlow - if fbDef != nil { - fbName = fbDef.Name - } - sess.WriteLine(g.colorize(sess, "warning", - fmt.Sprintf(" %s resists death! Use %s on it to finish it off.", - mobDisplayName(mob, false), fbName))) -} -``` - -#### b) `use <item> on <mob>` Kill (in `doUse` or new handler) - -The `doUse()` function in `internal/game/cmd_use.go` parses `use <item> on <target>`. Currently it only finds objects. The modification adds mob lookup: - -After the existing `use` parsing extracts `itemName` and `targetName`, before the object lookup: - -```go -// Check for finishing blow on mob in combat -cs := combat.GetCombat(p.Name) -if cs != nil { - mob := g.MobStore.GetInstance(cs.MobID) - if mob != nil && mob.FinishingBlow != "" && mob.HP == 1 { - // Check if target matches this mob - if mob.MatchQuality(targetName) != world.MatchNone { - g.doFinishingBlow(sess, p, mob, itemName) - return - } - } -} -``` - -The `doFinishingBlow` function: - -```go -func (g *Game) doFinishingBlow(sess *net.Session, p *player.Player, mob *world.MobInstance, itemInput string) { - // Verify the item matches the required finishing blow item - fbDef, err := g.ItemStore.Load(mob.FinishingBlow) - if err != nil { - sess.WriteLine("Something went wrong.") - return - } - - if !fbDef.MatchesName(itemInput) { - sess.WriteLine(fmt.Sprintf("That won't work on %s. You need %s.", mobDisplayName(mob, true), fbDef.Name)) - return - } - - if !p.HasItem(mob.FinishingBlow) { - sess.WriteLine(fmt.Sprintf("You don't have any %s.", fbDef.Name)) - return - } - - // Check for auto-finish unlock - autoKey := "assassin_unlocked_auto_" + mob.FinishingBlow - if p.Flags != nil { - if val, ok := p.Flags[autoKey]; ok { - if b, ok := val.(bool); ok && b { - // Don't consume - } else { - p.RemoveItem(mob.FinishingBlow, 1) - } - } else { - p.RemoveItem(mob.FinishingBlow, 1) - } - } else { - p.RemoveItem(mob.FinishingBlow, 1) - } - - sess.WriteLine(fmt.Sprintf("\nYou use the %s on %s!", g.itemColorize(sess, fbDef, fbDef.Name), g.colorize(sess, "mob_name", mobDisplayName(mob, true)))) - mob.HP = 0 - g.endCombat(sess, p, mob) -} -``` - -### Combat Continues While Mob is at 1 HP - -The mob still attacks the player while at 1 HP. The player's attacks continue hitting (and show damage) but the mob HP stays at 1. The player must type `use salt on slug` (or similar) during combat to kill it. This is handled because: - -1. The combat tick subscriber keeps running (mob HP > 0 since it's clamped to 1) -2. Player attacks deal damage but HP is floored at 1 -3. `use` command is classified as `ClassActive` — it replaces the current active action. However, this creates a conflict because the player is in combat. - -**Problem:** `StartAction()` checks `combat.GetCombat(p.Name) != nil` and returns "You can't do that during combat!" (line 50-53 of action.go). - -**Solution:** The finishing blow `use` should NOT route through `StartAction()`. Instead, handle it directly in `executeCommand()` under the `"use"` case, BEFORE calling `doUse()`: - -In `executeCommand()`, the `"use"` case (line 367-369): - -```go -case "use": - // Check for finishing blow first - if g.tryFinishingBlow(sess, strings.Join(args, " ")) { - return - } - g.doUse(sess, strings.Join(args, " ")) - return -``` - -The `tryFinishingBlow` function: - -```go -func (g *Game) tryFinishingBlow(sess *net.Session, input string) bool { - p := sess.Player.(*player.Player) - cs := combat.GetCombat(p.Name) - if cs == nil { - return false - } - mob := g.MobStore.GetInstance(cs.MobID) - if mob == nil || mob.FinishingBlow == "" || mob.HP != 1 { - return false - } - - // Parse "item on target" or "item on/with target" - lower := strings.ToLower(input) - var itemPart, targetPart string - for _, sep := range []string{" on ", " with "} { - if idx := strings.Index(lower, sep); idx > 0 { - itemPart = strings.TrimSpace(input[:idx]) - targetPart = strings.TrimSpace(input[idx+len(sep):]) - break - } - } - if targetPart == "" { - return false - } - - if mob.MatchQuality(targetPart) == world.MatchNone { - return false - } - - g.doFinishingBlow(sess, p, mob, itemPart) - return true -} -``` - ---- - -## 13. Damage Reduction Equipment - -### Mechanic - -Mobs with a `damage_without` field deal 1.5x damage when the player does NOT have the specified item equipped. This simulates the "protect yourself" mechanic from Slayer. - -### Implementation in `mobAttack()` - -In `internal/game/cmd_attack.go`, in the `mobAttack()` function, after computing `dmg` from `combat.RollDamage(maxHit)` (around line 254): - -```go -if combat.HitCheck(attRoll, defRoll) { - maxHit := combat.MaxHit(mob.Strength, 0, 0) - dmg := combat.RollDamage(maxHit) - - // Damage amplification if player lacks protective equipment - if mob.DamageWithout != "" { - hasProtection := false - for _, itemID := range p.Equipment { - if itemID == mob.DamageWithout { - hasProtection = true - break - } - } - if !hasProtection { - dmg = dmg * 3 / 2 - if dmg < 1 { - dmg = 1 - } - } - } - - p.HP -= dmg - // ... rest of existing code -} -``` - -### Player Feedback - -Optionally, the first time in a combat session the player takes amplified damage, display a warning: - -```go -if !hasProtection { - dmg = dmg * 3 / 2 - fbDef, _ := g.ItemStore.Load(mob.DamageWithout) - fbName := mob.DamageWithout - if fbDef != nil { - fbName = fbDef.Name - } - // Only warn once per combat (use a flag on combat state or session) - sess.WriteLine(g.colorize(sess, "warning", - fmt.Sprintf(" %s's attack is extra effective! Equip %s for protection.", - attacker, fbName))) -} -``` - -To avoid spamming this every hit, track whether the warning has been shown. The simplest approach: add a `DamageWarningShown` bool field to `combat.State`: - -```go -type State struct { - PlayerName string - MobID string - Active bool - DamageWarningShown bool -} -``` - -Then in `mobAttack()`: - -```go -if !hasProtection { - dmg = dmg * 3 / 2 - if !cs.DamageWarningShown { - cs.DamageWarningShown = true - // ... display warning - } -} -``` - ---- - -## 14. XP Calculation - -### Assassin XP Per Kill (On-Task Only) - -Formula: `assassinXP = mob.MaxHP * 2` - -This is awarded ONLY when the killed mob matches the player's current task (`assassin_task_mob` player flag). - -XP is awarded in `onAssassinKill()` which is called from `endCombat()` after the mob death is confirmed. - -### Examples - -| Mob | MaxHP | Assassin XP Per Kill | -|---|---|---| -| slug | 15 | 30 | -| drone | 45 | 90 | -| crawler | 80 | 160 | -| phantom | 100 | 200 | -| man | 7 | 14 | -| cow | 8 | 16 | - -### XP Table Reference - -The game uses the RSC XP table (defined in `internal/player/xp.go` or similar). Level 1 = 0 XP, Level 2 = 83 XP, etc. The same table applies to Assassin. - -### Normal Combat XP Still Awarded - -The existing `awardCombatXP()` function is not modified. Players receive both normal combat XP (Attack/Strength/Defense/Hitpoints based on style) AND Assassin XP when on-task. - ---- - -## 15. Mob YAML Extensions - -### New Fields on `MobDef` (in `internal/world/mob.go`) - -```go -type MobDef struct { - ID string `yaml:"id"` - Name string `yaml:"name"` - Description string `yaml:"description"` - BehaviorID string `yaml:"behavior"` - IdleDescriptions []string `yaml:"idle_descriptions"` - CombatDescriptions []string `yaml:"combat_descriptions"` - Attack int `yaml:"attack"` - Strength int `yaml:"strength"` - Defense int `yaml:"defense"` - HP int `yaml:"hp"` - Speed float64 `yaml:"speed"` - Aggressive bool `yaml:"aggressive"` - Protected bool `yaml:"protected"` - Unique bool `yaml:"unique"` - RespawnTicks float64 `yaml:"respawn_ticks"` - Drops DropTable `yaml:"drops"` - // NEW FIELDS: - AssassinLevel int `yaml:"assassin_level"` - FinishingBlow string `yaml:"finishing_blow"` - DamageWithout string `yaml:"damage_without"` -} -``` - -### New Fields on `MobInstance` - -```go -type MobInstance struct { - // ... existing fields ... - AssassinLevel int - FinishingBlow string - DamageWithout string -} -``` - -### Copy in `SeedMobs()` - -In the `SeedMobs` function (line 236-259 of `internal/world/mob.go`), add to the instance creation: - -```go -inst := &MobInstance{ - // ... existing fields ... - AssassinLevel: dw.def.AssassinLevel, - FinishingBlow: dw.def.FinishingBlow, - DamageWithout: dw.def.DamageWithout, -} -``` - -### Mobs Without These Fields - -Mobs that don't have these fields in their YAML will have zero-value defaults: -- `AssassinLevel: 0` — no requirement to attack -- `FinishingBlow: ""` — no finishing blow needed -- `DamageWithout: ""` — no extra damage - -This is fully backward compatible. - ---- - -## 16. Rooms - -### The Client's Location - -Create a new room for The Client. Since room 19 is currently a stub ("Agility Course"), use a different room number. Suggested: Room 50+ area for the "Assassin District." - -#### `data/rooms/50.yaml` — Assassin Den - -```yaml -id: 50 -name: "The Assassin's Den" -description: "A dimly lit basement accessible through a trapdoor. Tactical maps and bounty posters line the walls. The air smells of gun oil and burnt ozone. {141}The Client{/} sits behind a reinforced desk." -exits: - up: 1 -mobs: - - "client" -``` - -Connect from Town Square (room 1) by adding `down: 50` exit (or use a different connection point). Room 1 already has `down: 31`, so either: -- Add the den as a separate entrance from another room -- Create a chain: Room 1 → Room 31 → Room 50 - -Alternative: connect from an existing room that has a free exit direction. - -### Slayer-Only Mob Rooms - -#### `data/rooms/51.yaml` — Sewer Tunnels - -```yaml -id: 51 -name: "Sewer Tunnels" -description: "Dark, damp tunnels beneath the settlement. The floor is slick with moisture and something {82}slimy{/}. The smell is indescribable." -exits: - north: 50 - south: 52 -mobs: - - "slug" - - "slug" - - "slug" -``` - -#### `data/rooms/52.yaml` — Abandoned Sector - -```yaml -id: 52 -name: "Abandoned Sector" -description: "A decommissioned sector of the asteroid's infrastructure. Broken monitors flicker and exposed wiring {214}sparks{/} dangerously." -exits: - north: 51 - south: 53 -mobs: - - "drone" - - "drone" -``` - -#### `data/rooms/53.yaml` — Deep Tunnels - -```yaml -id: 53 -name: "Deep Tunnels" -description: "The tunnels descend deeper into the asteroid's core. Strange chitinous scraping echoes from the darkness. The walls are scarred with {130}claw marks{/}." -exits: - north: 52 - south: 54 -mobs: - - "crawler" - - "crawler" -``` - -#### `data/rooms/54.yaml` — The Void Chamber - -```yaml -id: 54 -name: "The Void Chamber" -description: "A vast cavern where reality seems to thin. The air shimmers with {141}spectral energy{/} and strange whispers fill your mind." -exits: - north: 53 -mobs: - - "phantom" - - "phantom" -``` - -### Connecting to Existing World - -Add an exit from the Assassin Den (room 50) back to an appropriate existing room. The den should connect to somewhere in the 1-30 range. Example: add `down: 50` to room 22 (west of town square) or create a new connection. - -Update the connecting room's YAML to add the exit: - -```yaml -# In the connecting room, add: -exits: - down: 50 # To Assassin Den -``` - ---- - -## 17. Help Files - -### `data/help/task.yaml` - -```yaml -name: "task" -category: "Assassin" -description: | - Check your current Assassin task status. - - Usage: task - - Displays your current task target, kills remaining, streak count, - and unspent Reputation points. If you have no active task, visit - The Client to receive a new assignment. - - The Client can be found in The Assassin's Den, accessible from - the lower levels of the settlement. -``` - -### `data/help/assassin.yaml` - -```yaml -name: "assassin" -category: "Skills" -description: | - The Assassin skill (Slayer equivalent). - - Talk to The Client in The Assassin's Den to receive tasks. Each task - assigns you a number of specific mobs to kill. Killing mobs on-task - awards Assassin XP (mob's max HP x 2) in addition to normal combat XP. - - Some mobs require a minimum Assassin level to attack: - Slug - Level 1 (needs salt to finish off) - Drone - Level 15 (extra damage without insulated gloves) - Crawler - Level 30 (needs acid vial to finish off) - Phantom - Level 45 (extra damage without spectral visor) - - Finishing Blow: Some mobs cannot be killed below 1 HP. Use the - required item on them during combat: "use salt on slug" - - Protection: Some mobs deal 1.5x damage unless you have the - required protective item equipped. - - Reputation is earned on task completion (1 per task + streak bonuses - at 10th, 50th, 100th, 250th, and 1000th consecutive tasks). - Spend Reputation at The Client's Reputation Shop for permanent - unlocks and task management options. - - Related: task, attack, use -``` - ---- - -## 18. Task Table - -The full task assignment table. Each entry specifies: -- `MobID`: The mob definition to assign -- `MinLevel` / `MaxLevel`: Player's Assassin level range for eligibility -- `MinCount` / `MaxCount`: Random kill count range -- `Weight`: Relative probability of being assigned - -### Starter Tasks (Assassin Level 1+) - -| MobID | Min Lvl | Max Lvl | Min Count | Max Count | Weight | Notes | -|---|---|---|---|---|---|---| -| `man` | 1 | 15 | 10 | 25 | 8 | Basic melee mob, 7 HP | -| `cow` | 1 | 15 | 10 | 25 | 8 | Basic melee mob, 8 HP | -| `slug` | 1 | 99 | 15 | 45 | 15 | Finishing blow: salt | - -### Mid-Level Tasks (Assassin Level 15+) - -| MobID | Min Lvl | Max Lvl | Min Count | Max Count | Weight | Notes | -|---|---|---|---|---|---|---| -| `drone` | 15 | 99 | 20 | 50 | 12 | Damage without: insulated_gloves | - -### High-Level Tasks (Assassin Level 30+) - -| MobID | Min Lvl | Max Lvl | Min Count | Max Count | Weight | Notes | -|---|---|---|---|---|---|---| -| `crawler` | 30 | 99 | 15 | 40 | 10 | Finishing blow: acid_vial | - -### Expert Tasks (Assassin Level 45+) - -| MobID | Min Lvl | Max Lvl | Min Count | Max Count | Weight | Notes | -|---|---|---|---|---|---|---| -| `phantom` | 45 | 99 | 10 | 30 | 8 | Damage without: spectral_visor | - -### Task Table in Go (Full) - -```go -var assassinTaskTable = []assassinTaskEntry{ - // Starter mobs — available early, phased out at mid levels - {"man", 1, 15, 10, 25, 8}, - {"cow", 1, 15, 10, 25, 8}, - - // Core assassin mobs — available from their assassin_level onward - {"slug", 1, 99, 15, 45, 15}, - {"drone", 15, 99, 20, 50, 12}, - {"crawler", 30, 99, 15, 40, 10}, - {"phantom", 45, 99, 10, 30, 8}, -} -``` - -### Expanding the Table - -As new mobs are added to the game, add entries to `assassinTaskTable`. The weighted random system ensures new mobs can be introduced without modifying existing entries. Future mobs behind the `assassin_unlocked_broader` perk can be filtered in `assignAssassinTask()`: - -```go -for _, entry := range assassinTaskTable { - if level >= entry.MinLevel && level <= entry.MaxLevel { - if entry.RequiresUnlock != "" { - if p.Flags == nil { - continue - } - if val, ok := p.Flags[entry.RequiresUnlock]; !ok || val != true { - continue - } - } - eligible = append(eligible, entry) - totalWeight += entry.Weight - } -} -``` - -Add `RequiresUnlock string` to `assassinTaskEntry` for this. - ---- - -## Summary of All File Changes - -### New Go Files -1. `internal/game/cmd_task.go` — `doTask()` command handler -2. `internal/game/assassin.go` — Task table, assignment, on-kill processing, flag helpers, finishing blow, skip/extend - -### Modified Go Files -1. `internal/world/mob.go` — Add `AssassinLevel`, `FinishingBlow`, `DamageWithout` to `MobDef` and `MobInstance`; copy in `SeedMobs()` -2. `internal/game/cmd_attack.go` — Assassin level check in `doAttack()`, HP floor in `playerAttack()`, damage amplification in `mobAttack()`, `onAssassinKill()` call in `endCombat()` -3. `internal/game/game.go` — Add `"task"` to `classifyCommand()` Instant list; add `"task"` case in `executeCommand()`; add finishing blow intercept before `doUse()` -4. `internal/action/behavior.go` — Add `AssignTask`, `SkipTask`, `ExtendTask`, `ReputationCost` to `NodeAction` -5. `internal/game/action_talk.go` — Handle new `NodeAction` fields in `applyNodeAction()` -6. `internal/combat/state.go` — Add `DamageWarningShown` to `State` (optional) - -### New YAML Files -1. `data/mobs/slug.yaml` -2. `data/mobs/drone.yaml` -3. `data/mobs/crawler.yaml` -4. `data/mobs/phantom.yaml` -5. `data/mobs/client.yaml` -6. `data/items/salt.yaml` -7. `data/items/acid_vial.yaml` -8. `data/items/insulated_gloves.yaml` -9. `data/items/spectral_visor.yaml` -10. `data/items/slug_mucus.yaml` -11. `data/items/circuit_board.yaml` -12. `data/items/chitin_plate.yaml` -13. `data/items/ectoplasm.yaml` -14. `data/behaviors/client_talk.yaml` -15. `data/help/task.yaml` -16. `data/help/assassin.yaml` -17. `data/rooms/50.yaml` -18. `data/rooms/51.yaml` -19. `data/rooms/52.yaml` -20. `data/rooms/53.yaml` -21. `data/rooms/54.yaml` - -### Modified YAML Files -1. An existing room YAML (e.g. room 22 or room 31) — add exit to room 50 - ---- - -## Implementation Order - -1. **MobDef extensions** (`internal/world/mob.go`) — Add fields, update `SeedMobs()` -2. **NodeAction extensions** (`internal/action/behavior.go`) — Add action fields -3. **Flag helpers** (`internal/game/assassin.go`) — `getPlayerFlagInt`, `getPlayerFlagString`, `setPlayerFlag` -4. **Task table and assignment** (`internal/game/assassin.go`) — `assassinTaskTable`, `assignAssassinTask()` -5. **On-kill processing** (`internal/game/assassin.go`) — `onAssassinKill()`, `streakBonus()` -6. **Skip/extend** (`internal/game/assassin.go`) — `skipAssassinTask()`, `extendAssassinTask()` -7. **Task command** (`internal/game/cmd_task.go`) — `doTask()` -8. **Register command** (`internal/game/game.go`) — `classifyCommand()`, `executeCommand()` -9. **Attack modifications** (`internal/game/cmd_attack.go`) — Level check, HP floor, damage amp, on-kill call -10. **Finishing blow via use** (`internal/game/cmd_attack.go` or `assassin.go`) — `tryFinishingBlow()`, `doFinishingBlow()` -11. **Talk action extensions** (`internal/game/action_talk.go`) — Handle `assign_task`, `skip_task`, `extend_task`, `reputation_cost` -12. **All YAML data files** — Mobs, items, behaviors, rooms, help -13. **Connect rooms** — Update existing room YAML to link to room 50 -14. **Test** — `make test`, `make vet`, manual play testing - ---- - -## Testing Checklist - -- [ ] `make build` succeeds -- [ ] `make vet` passes -- [ ] `make test` passes -- [ ] Can talk to The Client and receive a task -- [ ] `task` command shows current task info -- [ ] Killing on-task mobs awards Assassin XP and decrements remaining count -- [ ] Killing off-task mobs does NOT award Assassin XP -- [ ] Task completion awards reputation and increments streak -- [ ] Streak bonuses at 10th, 50th milestones -- [ ] Skip task costs 30 rep and resets streak -- [ ] Extend task costs 30 rep and adds kills -- [ ] Cannot attack mobs with `assassin_level` higher than player's level -- [ ] Slug stays at 1 HP from normal attacks -- [ ] `use salt on slug` kills the slug -- [ ] Salt is consumed on finishing blow (unless auto-finish unlocked) -- [ ] Drone deals 1.5x damage without insulated gloves equipped -- [ ] Drone deals normal damage with insulated gloves equipped -- [ ] Reputation shop purchases set correct player flags -- [ ] Auto-finish unlock prevents salt/acid consumption -- [ ] Items sold by Client via talk dialog work correctly -- [ ] Help topics display correctly -- [ ] New rooms are navigable and mobs spawn correctly -- [ ] Mob remains items drop correctly -- [ ] Backward compatibility — existing mobs with no assassin fields work normally diff --git a/skill_plans/combat.md b/skill_plans/combat.md deleted file mode 100644 index 67037d6..0000000 --- a/skill_plans/combat.md +++ /dev/null @@ -1,3402 +0,0 @@ -# Combat System Overhaul — OSRS-Style Attack Types & Per-Type Defense - -## 1. Overview - -The current combat system uses a single `attack_bonus` / `defense_bonus` per item, ignoring attack types entirely. OSRS uses per-type accuracy bonuses (stab/slash/crush/ranged/magic) and per-type defense bonuses. This overhaul brings the game's combat inline with OSRS mechanics while adapting the naming to the sci-fi theme (Science replaces Magic, Technology replaces Prayer). - -**What changes:** -- `ItemStats` struct: 5 attack bonuses, 5 defense bonuses, 4 damage bonuses (14 fields total) -- Weapons get an `attack_type` field (stab/slash/crush/ranged/science) -- Mobs get per-type defense bonuses and attack bonuses -- Combat formulas use the true OSRS accuracy formula (not the current binary hit/miss) -- `formulas.go` rewritten with proper OSRS accuracy curve -- `cmd_attack.go` updated to use per-type bonuses for both player and mob attacks -- Mobs get an `attack_type` field (default "crush") -- New `stats` command shows total equipment bonuses -- `look <item>` shows per-type combat stats -- All ~66 combat item YAMLs updated with per-type stats -- All 5 mob YAMLs updated with per-type defense/attack bonuses -- 8 new example mob YAMLs for varied combat encounters - -**What stays the same:** -- 4 attack styles (accurate/aggressive/defensive/balanced) — style determines level bonus -- XP distribution formula (dmg * 4, split by style) -- Death mechanics, flee mechanics, combat timing/tick system -- WeaponType enum (melee/ranged/science) — still used for equip text + XP routing -- Player combat level formula - ---- - -## 2. ItemStats Struct - -**File:** `internal/object/item.go:67-73` - -**Current:** -```go -type ItemStats struct { - AttackBonus int `yaml:"attack_bonus"` - StrengthBonus int `yaml:"strength_bonus"` - DefenseBonus int `yaml:"defense_bonus"` - ScienceBonus int `yaml:"science_bonus"` - TechnologyBonus int `yaml:"technology_bonus"` -} -``` - -**New:** -```go -type ItemStats struct { - // Offensive accuracy bonuses - StabAttack int `yaml:"stab_attack"` - SlashAttack int `yaml:"slash_attack"` - CrushAttack int `yaml:"crush_attack"` - ScienceAttack int `yaml:"science_attack"` - RangedAttack int `yaml:"ranged_attack"` - - // Defensive bonuses - StabDefense int `yaml:"stab_defense"` - SlashDefense int `yaml:"slash_defense"` - CrushDefense int `yaml:"crush_defense"` - ScienceDefense int `yaml:"science_defense"` - RangedDefense int `yaml:"ranged_defense"` - - // Damage bonuses - StrengthBonus int `yaml:"strength_bonus"` - RangedStrength int `yaml:"ranged_strength"` - ScienceDamage int `yaml:"science_damage"` - TechnologyBonus int `yaml:"technology_bonus"` -} -``` - -**Notes:** -- `AttackBonus` and `DefenseBonus` YAML tags are removed entirely. All item YAMLs will be updated to use the new per-type fields. -- `ScienceBonus` is removed. It was never used in combat. The new `ScienceDamage` field replaces it for future science combat. -- `TechnologyBonus` stays — it will be used in the Technology skill (Prayer equivalent) in the future. -- The old `attack` YAML tag (used by bows/arrows/bolts, silently ignored because the Go struct had `attack_bonus`) is also removed. Those items move to `ranged_attack` and `ranged_strength`. - -**Backward Compatibility:** None needed. All YAML files will be rewritten. The old YAML tags (`attack_bonus`, `defense_bonus`, `attack`) will no longer be recognized and will be silently ignored by `gopkg.in/yaml.v3` (which is the current behavior for unknown fields). No `UnmarshalYAML` shim needed. - ---- - -## 3. Weapon Attack Types - -**File:** `internal/object/item.go:33-59` — add field to `ItemDef` - -**Add this field to `ItemDef`:** -```go -AttackType string `yaml:"attack_type"` // "stab", "slash", "crush", "ranged", "science" -``` - -Insert it after `WeaponType` (line 41), before `Stats`: -```go -type ItemDef struct { - ID string `yaml:"id"` - Name string `yaml:"name"` - Color string `yaml:"color"` - Description string `yaml:"description"` - Value int `yaml:"value"` - Stackable bool `yaml:"stackable"` - EquipSlot EquipSlot `yaml:"equip_slot"` - WeaponType WeaponType `yaml:"weapon_type"` - AttackType string `yaml:"attack_type"` // NEW - Stats ItemStats `yaml:"stats"` - Speed float64 `yaml:"speed"` - // ... rest unchanged -} -``` - -**Default attack type mapping** (used when `attack_type` is empty): -- If `WeaponType == "ranged"` → `"ranged"` -- If `WeaponType == "science"` → `"science"` -- Unarmed (no weapon) → `"crush"` -- Otherwise → `"crush"` (safest default for melee) - -Weapons should always have `attack_type` set explicitly in YAML. The default is only a fallback. - -**Attack type assignments for existing weapons:** - -| Weapon Category | attack_type | Reasoning | -|-----------------|-------------|-----------| -| Swords (all tiers) | slash | Swords are slashing weapons | -| Daggers (all tiers) | stab | Daggers are stabbing weapons | -| Axes (all tiers) | slash | Axes are slashing weapons (also tools) | -| Pickaxes (all tiers) | stab | Pickaxes stab/pierce (also tools) | -| Bows (all types) | ranged | Ranged weapons | -| Unarmed | crush | Fists = crush | - ---- - -## 4. Combat Style Mapping - -The 4 existing styles remain. The style determines: -1. **Level bonus** (which combat level gets +3 or +1) -2. **XP distribution** (unchanged from current) - -The **weapon's `attack_type`** determines: -1. Which of the player's 5 attack bonus totals is used for the accuracy roll -2. Which of the mob's 5 defense bonus values is used for the defense roll - -**Style → Level Bonus (unchanged from current):** - -| Style | Attack Level | Strength Level | Defense Level | -|-------|-------------|----------------|---------------| -| Accurate | +3 | +0 | +0 | -| Aggressive | +0 | +3 | +0 | -| Defensive | +0 | +0 | +3 | -| Balanced | +1 | +1 | +1 | - -**Style → XP Distribution (unchanged from current):** - -| Style | Distribution | -|-------|-------------| -| Accurate | 75% Attack, 25% Hitpoints | -| Aggressive | 75% Strength, 25% Hitpoints | -| Defensive | 75% Defense, 25% Hitpoints | -| Balanced | 25% each Attack/Strength/Defense/Hitpoints | - -**Ranged style bonuses** (when weapon_type is "ranged"): - -| Style | Ranged Level | Defense Level | XP Distribution | -|-------|-------------|---------------|-----------------| -| Accurate | +3 | +0 | 75% Ranged, 25% Hitpoints | -| Aggressive | +3 | +0 | 75% Ranged, 25% Hitpoints | -| Defensive | +0 | +3 | 75% Ranged, 25% Hitpoints | -| Balanced | +1 | +1 | 75% Ranged, 25% Hitpoints | - -Note: For ranged, accurate and aggressive both give +3 Ranged (matching OSRS "accurate" and "rapid" which both boost ranged). The attack/strength bonuses from `AttackStyleBonus()` are NOT used — Ranged level is used instead. - ---- - -## 5. Updated Combat Formulas - -**File:** `internal/combat/formulas.go` — complete rewrite - -```go -package combat - -import "math/rand" - -// AttackRoll computes the maximum attack roll for accuracy. -// effectiveLevel = level + styleBonus + 8 -// equipBonus = total equipment bonus for the relevant attack type -func AttackRoll(level int, styleBonus int, equipBonus int) int { - effective := level + styleBonus + 8 - return effective * (equipBonus + 64) -} - -// DefenseRoll computes the maximum defense roll. -// effectiveLevel = level + styleBonus + 8 -// equipBonus = total equipment bonus for the relevant defense type -func DefenseRoll(level int, styleBonus int, equipBonus int) int { - effective := level + styleBonus + 8 - return effective * (equipBonus + 64) -} - -// HitChance returns the probability of hitting (0.0 to 1.0) using the -// true OSRS accuracy formula. -func HitChance(attackRoll, defenseRoll int) float64 { - a := float64(attackRoll) - d := float64(defenseRoll) - if a > d { - return 1.0 - (d+2.0)/(2.0*(a+1.0)) - } - return a / (2.0*(d+1.0)) -} - -// HitCheck uses the OSRS accuracy formula to determine if an attack hits. -// Returns true if the attack lands. -func HitCheck(attackRoll, defenseRoll int) bool { - chance := HitChance(attackRoll, defenseRoll) - return rand.Float64() < chance -} - -// MaxHit computes the maximum melee hit. -// level = Strength level (or Ranged level for ranged) -// styleBonus = strength style bonus (+3 aggressive, +1 balanced, 0 otherwise) -// equipBonus = total equipment strength bonus (or ranged_strength for ranged) -func MaxHit(level int, styleBonus int, equipBonus int) int { - effective := level + styleBonus + 8 - hit := (effective * (equipBonus + 64)) / 512 - if hit < 1 { - hit = 1 - } - return hit -} - -// RollDamage returns a random damage value from 0 to maxHit inclusive. -// In OSRS, 0 is a valid damage (a "hit 0" or splash). We keep the -// current behavior of 1..maxHit to match existing game feel, but this -// can be changed to 0..maxHit for true OSRS. -func RollDamage(maxHit int) int { - if maxHit <= 0 { - return 0 - } - return 1 + rand.Intn(maxHit) -} - -// AttackStyleBonus returns the level bonuses for melee combat styles. -// For ranged weapons, the caller should use RangedStyleBonus instead. -func AttackStyleBonus(style string) (attack, strength, defense int) { - switch style { - case "accurate": - return 3, 0, 0 - case "aggressive": - return 0, 3, 0 - case "defensive": - return 0, 0, 3 - case "balanced": - return 1, 1, 1 - default: - return 0, 0, 0 - } -} - -// RangedStyleBonus returns the level bonuses for ranged combat styles. -func RangedStyleBonus(style string) (ranged, defense int) { - switch style { - case "accurate": - return 3, 0 - case "aggressive": - return 3, 0 - case "defensive": - return 0, 3 - case "balanced": - return 1, 1 - default: - return 0, 0 - } -} - -// MobCombatLevel computes a mob's combat level. -// Uses the standard OSRS NPC formula: -// floor((attack + strength + defense + hp) / 4) -func MobCombatLevel(attack, strength, defense, hp int) int { - return (attack + strength + defense + hp) / 4 -} - -// EquipBonusForAttackType selects the correct attack bonus from a -// pre-summed equipment stats struct based on the weapon's attack type. -// attackType is one of: "stab", "slash", "crush", "ranged", "science" -// -// This function lives here to avoid circular imports. The caller passes -// the individual bonus values. -func SelectAttackBonus(attackType string, stab, slash, crush, science, ranged int) int { - switch attackType { - case "stab": - return stab - case "slash": - return slash - case "crush": - return crush - case "science": - return science - case "ranged": - return ranged - default: - return crush - } -} - -// SelectDefenseBonus picks the correct defense bonus to oppose a given -// attack type. -func SelectDefenseBonus(attackType string, stab, slash, crush, science, ranged int) int { - switch attackType { - case "stab": - return stab - case "slash": - return slash - case "crush": - return crush - case "science": - return science - case "ranged": - return ranged - default: - return crush - } -} -``` - -**Key differences from current formulas.go:** -1. `HitCheck` now uses the continuous OSRS accuracy formula instead of a binary comparison. Previously, if `attackRoll > defenseRoll` it was always a hit; if less, always a miss. Now it uses the proper probability curve. -2. `HitChance` is exposed as a separate function (useful for debugging/display). -3. `RangedStyleBonus` added for ranged weapons. -4. `MobCombatLevel` moved here from `utils.go` (optional, can stay in utils.go). -5. `SelectAttackBonus` and `SelectDefenseBonus` helper functions added. -6. `AttackRoll`, `DefenseRoll`, `MaxHit` formulas are actually unchanged in structure — they were already OSRS-correct. The change is in what values are passed to them. - ---- - -## 6. Code Changes to cmd_attack.go - -**File:** `internal/game/cmd_attack.go` - -### 6.1 playerAttack (lines 174-236) - -**Current flow:** -1. Gets style bonuses from `AttackStyleBonus` -2. Reads main hand weapon's `AttackBonus` and `StrengthBonus` -3. `attRoll = AttackRoll(Attack level, attBonus, equipAtt)` -4. `defRoll = DefenseRoll(mob.Defense, 0, 0)` — mob has NO equipment defense -5. Binary `HitCheck(attRoll, defRoll)` -6. `MaxHit(Strength level, strBonus, equipStr)` - -**New flow:** -```go -func (g *Game) playerAttack(sess *net.Session, p *player.Player, mob *world.MobInstance) { - if g.processConsumeQueue(p, sess) { - p.ActionState = &ActionState{Type: ActionEating, TargetName: "food"} - return - } - - // Determine weapon and attack type - attackType := "crush" // unarmed default - var weaponType object.WeaponType - if itemID, ok := p.Equipment[object.SlotMainHand]; ok { - if def, err := g.ItemStore.Load(itemID); err == nil { - if def.AttackType != "" { - attackType = def.AttackType - } else if def.WeaponType == object.WeaponRanged { - attackType = "ranged" - } else if def.WeaponType == object.WeaponScience { - attackType = "science" - } - weaponType = def.WeaponType - } - } - - // Sum all equipment bonuses - totals := g.playerEquipBonuses(p) - - if weaponType == object.WeaponRanged { - // --- RANGED ATTACK --- - rangedBonus, defBonus := combat.RangedStyleBonus(string(p.AttackStyle)) - - equipAttack := totals.RangedAttack - attRoll := combat.AttackRoll(p.Level(player.Ranged), rangedBonus, equipAttack) - - mobDefBonus := combat.SelectDefenseBonus(attackType, - mob.StabDefense, mob.SlashDefense, mob.CrushDefense, - mob.ScienceDefense, mob.RangedDefense) - defRoll := combat.DefenseRoll(mob.Defense, 0, mobDefBonus) - - if combat.HitCheck(attRoll, defRoll) { - maxHit := combat.MaxHit(p.Level(player.Ranged), rangedBonus, totals.RangedStrength) - dmg := combat.RollDamage(maxHit) - // ... apply damage, award XP (ranged style), consume ammo ... - _ = defBonus // used if needed for defense-related ranged style - } - } else { - // --- MELEE ATTACK --- - attBonus, strBonus, _ := combat.AttackStyleBonus(string(p.AttackStyle)) - - equipAttack := combat.SelectAttackBonus(attackType, - totals.StabAttack, totals.SlashAttack, totals.CrushAttack, - totals.ScienceAttack, totals.RangedAttack) - attRoll := combat.AttackRoll(p.Level(player.Attack), attBonus, equipAttack) - - mobDefBonus := combat.SelectDefenseBonus(attackType, - mob.StabDefense, mob.SlashDefense, mob.CrushDefense, - mob.ScienceDefense, mob.RangedDefense) - defRoll := combat.DefenseRoll(mob.Defense, 0, mobDefBonus) - - if combat.HitCheck(attRoll, defRoll) { - maxHit := combat.MaxHit(p.Level(player.Strength), strBonus, totals.StrengthBonus) - dmg := combat.RollDamage(maxHit) - // ... apply damage, award XP (current style) ... - } - } -} -``` - -**Detailed changes (line-by-line for melee path):** - -Replace lines 180-193: -```go -// OLD: -attBonus, strBonus, _ := combat.AttackStyleBonus(string(p.AttackStyle)) -equipAtt := 0 -equipStr := 0 -if itemID, ok := p.Equipment[object.SlotMainHand]; ok { - def, err := g.ItemStore.Load(itemID) - if err == nil { - equipAtt = def.Stats.AttackBonus - equipStr = def.Stats.StrengthBonus - } -} -attRoll := combat.AttackRoll(p.Level(player.Attack), attBonus, equipAtt) -defRoll := combat.DefenseRoll(mob.Defense, 0, 0) - -// NEW: -attBonus, strBonus, _ := combat.AttackStyleBonus(string(p.AttackStyle)) - -attackType := "crush" -if itemID, ok := p.Equipment[object.SlotMainHand]; ok { - if def, err := g.ItemStore.Load(itemID); err == nil { - if def.AttackType != "" { - attackType = def.AttackType - } - } -} - -totals := g.playerEquipBonuses(p) -equipAttack := combat.SelectAttackBonus(attackType, - totals.StabAttack, totals.SlashAttack, totals.CrushAttack, - totals.ScienceAttack, totals.RangedAttack) -attRoll := combat.AttackRoll(p.Level(player.Attack), attBonus, equipAttack) - -mobDefBonus := combat.SelectDefenseBonus(attackType, - mob.StabDefense, mob.SlashDefense, mob.CrushDefense, - mob.ScienceDefense, mob.RangedDefense) -defRoll := combat.DefenseRoll(mob.Defense, 0, mobDefBonus) -``` - -Replace line 196 (`MaxHit` call): -```go -// OLD: -maxHit := combat.MaxHit(p.Level(player.Strength), strBonus, equipStr) - -// NEW: -maxHit := combat.MaxHit(p.Level(player.Strength), strBonus, totals.StrengthBonus) -``` - -### 6.2 mobAttack (lines 238-289) - -**Current flow:** -1. Gets defense style bonus -2. Sums `DefenseBonus` from ALL equipped items -3. `attRoll = AttackRoll(mob.Attack, 0, 0)` — mob has NO attack bonus -4. `defRoll = DefenseRoll(Defense level, defBonus, equipDef)` - -**New flow:** -```go -func (g *Game) mobAttack(sess *net.Session, p *player.Player, mob *world.MobInstance) { - _, _, defStyleBonus := combat.AttackStyleBonus(string(p.AttackStyle)) - - // Determine mob's attack type - mobAttackType := mob.AttackType - if mobAttackType == "" { - mobAttackType = "crush" - } - - // Mob attack roll: uses mob's attack level + mob's attack bonus - attRoll := combat.AttackRoll(mob.Attack, 0, mob.AttackBonus) - - // Player defense roll: uses player's defense level + style bonus + - // equipment defense matching the mob's attack type - totals := g.playerEquipBonuses(p) - equipDef := combat.SelectDefenseBonus(mobAttackType, - totals.StabDefense, totals.SlashDefense, totals.CrushDefense, - totals.ScienceDefense, totals.RangedDefense) - defRoll := combat.DefenseRoll(p.Level(player.Defense), defStyleBonus, equipDef) - - if combat.HitCheck(attRoll, defRoll) { - maxHit := combat.MaxHit(mob.Strength, 0, mob.StrengthBonus) - dmg := combat.RollDamage(maxHit) - // ... rest unchanged (apply damage, save, flee check, etc.) - } -} -``` - -**Detailed changes (line-by-line):** - -Replace lines 239-250: -```go -// OLD: -_, _, defBonus := combat.AttackStyleBonus(string(p.AttackStyle)) -equipDef := 0 -for _, itemID := range p.Equipment { - def, err := g.ItemStore.Load(itemID) - if err == nil { - equipDef += def.Stats.DefenseBonus - } -} -attRoll := combat.AttackRoll(mob.Attack, 0, 0) -defRoll := combat.DefenseRoll(p.Level(player.Defense), defBonus, equipDef) - -// NEW: -_, _, defStyleBonus := combat.AttackStyleBonus(string(p.AttackStyle)) - -mobAttackType := mob.AttackType -if mobAttackType == "" { - mobAttackType = "crush" -} - -attRoll := combat.AttackRoll(mob.Attack, 0, mob.AttackBonus) - -totals := g.playerEquipBonuses(p) -equipDef := combat.SelectDefenseBonus(mobAttackType, - totals.StabDefense, totals.SlashDefense, totals.CrushDefense, - totals.ScienceDefense, totals.RangedDefense) -defRoll := combat.DefenseRoll(p.Level(player.Defense), defStyleBonus, equipDef) -``` - -Replace line 253 (`MaxHit` call): -```go -// OLD: -maxHit := combat.MaxHit(mob.Strength, 0, 0) - -// NEW: -maxHit := combat.MaxHit(mob.Strength, 0, mob.StrengthBonus) -``` - -### 6.3 awardCombatXP changes for Ranged - -**Current** (lines 378-406): Only awards to Attack/Strength/Defense/Hitpoints based on style. - -**New:** If the player's weapon_type is "ranged", XP goes to Ranged instead of melee skills. - -Add a new parameter to `awardCombatXP`: -```go -func (g *Game) awardCombatXP(p *player.Player, dmg int, isRanged bool) ([]xpGain, []player.SkillName) { - baseXP := dmg * 4 - var gains []xpGain - var leveledUp []player.SkillName - - if isRanged { - // All ranged styles: 75% ranged, 25% hitpoints - gains = []xpGain{ - {string(player.Ranged), baseXP * 3 / 4}, - {string(player.Hitpoints), baseXP / 4}, - } - } else { - switch p.AttackStyle { - case player.Accurate: - gains = []xpGain{{string(player.Attack), baseXP * 3 / 4}, {string(player.Hitpoints), baseXP / 4}} - case player.Aggressive: - gains = []xpGain{{string(player.Strength), baseXP * 3 / 4}, {string(player.Hitpoints), baseXP / 4}} - case player.Defensive: - gains = []xpGain{{string(player.Defense), baseXP * 3 / 4}, {string(player.Hitpoints), baseXP / 4}} - case player.Balanced: - quarter := baseXP / 4 - gains = []xpGain{ - {string(player.Attack), quarter}, - {string(player.Strength), quarter}, - {string(player.Defense), quarter}, - {string(player.Hitpoints), quarter}, - } - } - } - - for _, gain := range gains { - if newLevel := p.AddSkillXP(player.SkillName(gain.Skill), gain.XP); newLevel > 0 { - leveledUp = append(leveledUp, player.SkillName(gain.Skill)) - } - } - g.AccountStore.SaveCharacter(p) - return gains, leveledUp -} -``` - -Update call sites: -- `playerAttack` melee path: `g.awardCombatXP(p, dmg, false)` -- `playerAttack` ranged path: `g.awardCombatXP(p, dmg, true)` - -### 6.4 Ammo Consumption (Ranged) - -In `playerAttack`, after a successful ranged attack: -```go -if weaponType == object.WeaponRanged { - p.AmmoQty-- - if p.AmmoQty <= 0 { - delete(p.Equipment, object.SlotAmmo) - p.AmmoQty = 0 - sess.WriteLine("You've run out of ammo!") - combat.LeaveCombat(p.Name) - // End combat - } - g.AccountStore.SaveCharacter(p) -} -``` - -Before combat starts (in `startCombat` or `doAttack`), check for ammo: -```go -if weaponDef.WeaponType == object.WeaponRanged { - if _, hasAmmo := p.Equipment[object.SlotAmmo]; !hasAmmo || p.AmmoQty <= 0 { - sess.WriteLine("You don't have any ammo equipped.") - return - } -} -``` - -### 6.5 startCombat Style Display - -In `startCombat` (line 120), the style display should also show the attack type: -```go -attackType := "crush" -if itemID, ok := p.Equipment[object.SlotMainHand]; ok { - if def, err := g.ItemStore.Load(itemID); err == nil && def.AttackType != "" { - attackType = def.AttackType - } -} -styleStr := fmt.Sprintf(" Style: %s (%s, %s)", p.AttackStyle, strings.Join(styleParts, ", "), attackType) -``` - ---- - -## 7. Code Changes to MobDef and MobInstance - -**File:** `internal/world/mob.go` - -### 7.1 MobDef (lines 20-37) - -**Add these fields:** -```go -type MobDef struct { - ID string `yaml:"id"` - Name string `yaml:"name"` - Description string `yaml:"description"` - BehaviorID string `yaml:"behavior"` - IdleDescriptions []string `yaml:"idle_descriptions"` - CombatDescriptions []string `yaml:"combat_descriptions"` - Attack int `yaml:"attack"` - Strength int `yaml:"strength"` - Defense int `yaml:"defense"` - HP int `yaml:"hp"` - Ranged int `yaml:"ranged"` // NEW - Science int `yaml:"science"` // NEW - Speed float64 `yaml:"speed"` - Aggressive bool `yaml:"aggressive"` - Protected bool `yaml:"protected"` - Unique bool `yaml:"unique"` - RespawnTicks float64 `yaml:"respawn_ticks"` - Drops DropTable `yaml:"drops"` - - // NEW: Attack bonuses - AttackBonus int `yaml:"attack_bonus"` // equipment-equivalent attack bonus - StrengthBonus int `yaml:"strength_bonus"` // equipment-equivalent strength bonus - RangedBonus int `yaml:"ranged_bonus"` // ranged attack bonus - ScienceBonus int `yaml:"science_bonus"` // science attack bonus - AttackType string `yaml:"attack_type"` // "stab"/"slash"/"crush"/"ranged"/"science" (default "crush") - - // NEW: Defense bonuses - StabDefense int `yaml:"stab_defense"` - SlashDefense int `yaml:"slash_defense"` - CrushDefense int `yaml:"crush_defense"` - ScienceDefense int `yaml:"science_defense"` - RangedDefense int `yaml:"ranged_defense"` - - // NEW: Elemental weakness - Weakness string `yaml:"weakness"` // "solar"/"hydro"/"eco"/"bio"/"" -} -``` - -### 7.2 MobInstance (lines 39-62) - -**Add matching fields:** -```go -type MobInstance struct { - InstanceID string - DefID string - Name string - BehaviorID string - HP int - MaxHP int - Attack int - Strength int - Defense int - Ranged int // NEW - Science int // NEW - Speed float64 - Aggressive bool - Protected bool - Unique bool - RespawnTicks float64 - RoomID int - HomeRoomID int - Drops DropTable - IdleDescription string - WanderRooms []int - WanderInterval float64 - WanderTickCounter int - regenerateTick int - - // NEW: Attack bonuses - AttackBonus int - StrengthBonus int - RangedBonus int - ScienceBonus int - AttackType string - - // NEW: Defense bonuses - StabDefense int - SlashDefense int - CrushDefense int - ScienceDefense int - RangedDefense int - - // NEW: Elemental weakness - Weakness string -} -``` - -### 7.3 SeedMobs (lines 213-259) - -**Add field propagation** in the `inst := &MobInstance{...}` block (lines 236-258): - -After line 245 (`Defense: dw.def.Defense,`), add: -```go -Ranged: dw.def.Ranged, -Science: dw.def.Science, -``` - -After line 255 (`Drops: dw.def.Drops,`), add: -```go -AttackBonus: dw.def.AttackBonus, -StrengthBonus: dw.def.StrengthBonus, -RangedBonus: dw.def.RangedBonus, -ScienceBonus: dw.def.ScienceBonus, -AttackType: dw.def.AttackType, -StabDefense: dw.def.StabDefense, -SlashDefense: dw.def.SlashDefense, -CrushDefense: dw.def.CrushDefense, -ScienceDefense: dw.def.ScienceDefense, -RangedDefense: dw.def.RangedDefense, -Weakness: dw.def.Weakness, -``` - -### 7.4 mobCombatLevel Update - -**File:** `internal/game/utils.go:47-49` - -**Current:** -```go -func mobCombatLevel(m *world.MobInstance) int { - return int(0.25*float64(m.Attack+m.Strength+m.Defense+m.MaxHP) + 0.5) -} -``` - -**New** (unchanged — this formula is already correct for melee-only mobs): -```go -func mobCombatLevel(m *world.MobInstance) int { - base := float64(m.Defense+m.MaxHP) / 4.0 - melee := float64(m.Attack+m.Strength) / 4.0 - ranged := float64(m.Ranged) * 3.0 / 8.0 - science := float64(m.Science) * 3.0 / 8.0 - best := melee - if ranged > best { - best = ranged - } - if science > best { - best = science - } - return int(base + best + 0.5) -} -``` - -This uses the OSRS NPC combat level formula: -- `base = (def + hp) / 4` -- `offensive = max(melee=(atk+str)/4, ranged=rng*3/8, magic=sci*3/8)` -- `level = floor(base + offensive)` - -For existing mobs with no Ranged/Science, this produces the same results as the old formula. - ---- - -## 8. Equipment Bonus Aggregation - -**File:** `internal/game/cmd_attack.go` (or a new `internal/game/equip_stats.go`) - -Add a helper function that sums all equipped items' stats: - -```go -func (g *Game) playerEquipBonuses(p *player.Player) object.ItemStats { - var totals object.ItemStats - for _, itemID := range p.Equipment { - def, err := g.ItemStore.Load(itemID) - if err != nil { - continue - } - totals.StabAttack += def.Stats.StabAttack - totals.SlashAttack += def.Stats.SlashAttack - totals.CrushAttack += def.Stats.CrushAttack - totals.ScienceAttack += def.Stats.ScienceAttack - totals.RangedAttack += def.Stats.RangedAttack - totals.StabDefense += def.Stats.StabDefense - totals.SlashDefense += def.Stats.SlashDefense - totals.CrushDefense += def.Stats.CrushDefense - totals.ScienceDefense += def.Stats.ScienceDefense - totals.RangedDefense += def.Stats.RangedDefense - totals.StrengthBonus += def.Stats.StrengthBonus - totals.RangedStrength += def.Stats.RangedStrength - totals.ScienceDamage += def.Stats.ScienceDamage - totals.TechnologyBonus += def.Stats.TechnologyBonus - } - return totals -} -``` - -This function is called by `playerAttack`, `mobAttack`, the new `stats` command, and `doLookTarget` for comparing equipment. - ---- - -## 9. Equipment Stats Display - -**File:** `internal/game/cmd_look.go` - -### 9.1 Item Examination (doLookTarget) - -When looking at an item (inventory or ground), if the item has any non-zero combat stats, display them in a formatted block. - -**Location:** After displaying item name/description/value (lines 508-514 for ground items, lines 526-536 for inventory items). - -**Add this helper function:** -```go -func (g *Game) showItemStats(sess *net.Session, def *object.ItemDef) { - s := def.Stats - hasAttack := s.StabAttack != 0 || s.SlashAttack != 0 || s.CrushAttack != 0 || - s.ScienceAttack != 0 || s.RangedAttack != 0 - hasDefense := s.StabDefense != 0 || s.SlashDefense != 0 || s.CrushDefense != 0 || - s.ScienceDefense != 0 || s.RangedDefense != 0 - hasOther := s.StrengthBonus != 0 || s.RangedStrength != 0 || - s.ScienceDamage != 0 || s.TechnologyBonus != 0 - - if !hasAttack && !hasDefense && !hasOther { - return - } - - sess.WriteLine("") - if hasAttack || hasDefense { - sess.WriteLine(" Attack bonuses: Defense bonuses:") - sess.WriteLine(fmt.Sprintf(" Stab: %+4d Stab: %+4d", s.StabAttack, s.StabDefense)) - sess.WriteLine(fmt.Sprintf(" Slash: %+4d Slash: %+4d", s.SlashAttack, s.SlashDefense)) - sess.WriteLine(fmt.Sprintf(" Crush: %+4d Crush: %+4d", s.CrushAttack, s.CrushDefense)) - sess.WriteLine(fmt.Sprintf(" Science:%+4d Science:%+4d", s.ScienceAttack, s.ScienceDefense)) - sess.WriteLine(fmt.Sprintf(" Ranged: %+4d Ranged: %+4d", s.RangedAttack, s.RangedDefense)) - } - if hasOther { - sess.WriteLine("") - sess.WriteLine(" Other bonuses:") - if s.StrengthBonus != 0 { - sess.WriteLine(fmt.Sprintf(" Melee strength: %+d", s.StrengthBonus)) - } - if s.RangedStrength != 0 { - sess.WriteLine(fmt.Sprintf(" Ranged strength: %+d", s.RangedStrength)) - } - if s.ScienceDamage != 0 { - sess.WriteLine(fmt.Sprintf(" Science damage: %+d", s.ScienceDamage)) - } - if s.TechnologyBonus != 0 { - sess.WriteLine(fmt.Sprintf(" Technology: %+d", s.TechnologyBonus)) - } - } - if def.AttackType != "" { - sess.WriteLine(fmt.Sprintf(" Attack type: %s", def.AttackType)) - } - if def.Speed > 0 { - sess.WriteLine(fmt.Sprintf(" Speed: %.0f", def.Speed)) - } -} -``` - -**Call it** in the ground item branch (after line 513): -```go -g.showItemStats(sess, def) -``` - -**Call it** in the inventory item branch (after line 531): -```go -g.showItemStats(sess, def) -``` - -### 9.2 Mob Examination - -Update the mob examination section in `doLookTarget` (lines 451-457) to show per-type bonuses: - -```go -// Replace lines 451-457: -sess.WriteLines( - "", - fmt.Sprintf(" Attack: %d Strength: %d Defense: %d", best.Attack, best.Strength, best.Defense), - fmt.Sprintf(" HP: %d/%d", best.HP, best.MaxHP), -) -if best.StabDefense != 0 || best.SlashDefense != 0 || best.CrushDefense != 0 || - best.ScienceDefense != 0 || best.RangedDefense != 0 { - sess.WriteLines( - "", - " Defense bonuses:", - fmt.Sprintf(" Stab: %+d Slash: %+d Crush: %+d", best.StabDefense, best.SlashDefense, best.CrushDefense), - fmt.Sprintf(" Science: %+d Ranged: %+d", best.ScienceDefense, best.RangedDefense), - ) -} -if best.Weakness != "" { - sess.WriteLine(fmt.Sprintf(" Weakness: %s", best.Weakness)) -} -``` - ---- - -## 10. Equipment Totals Command — `stats` - -**New file:** `internal/game/cmd_stats.go` - -```go -package game - -import ( - "fmt" - "strings" - - "thehouseoficarus/internal/combat" - "thehouseoficarus/internal/net" - "thehouseoficarus/internal/object" - "thehouseoficarus/internal/player" -) - -func (g *Game) doStats(sess *net.Session) { - p := sess.Player.(*player.Player) - totals := g.playerEquipBonuses(p) - - attackType := "crush" - weaponName := "unarmed" - if itemID, ok := p.Equipment[object.SlotMainHand]; ok { - if def, err := g.ItemStore.Load(itemID); err == nil { - weaponName = def.Name - if def.AttackType != "" { - attackType = def.AttackType - } - } - } - - sess.WriteLines( - "", - fmt.Sprintf("Weapon: %s (attack type: %s)", weaponName, attackType), - "", - "Attack bonuses: Defense bonuses:", - fmt.Sprintf(" Stab: %+4d Stab: %+4d", totals.StabAttack, totals.StabDefense), - fmt.Sprintf(" Slash: %+4d Slash: %+4d", totals.SlashAttack, totals.SlashDefense), - fmt.Sprintf(" Crush: %+4d Crush: %+4d", totals.CrushAttack, totals.CrushDefense), - fmt.Sprintf(" Science:%+4d Science:%+4d", totals.ScienceAttack, totals.ScienceDefense), - fmt.Sprintf(" Ranged: %+4d Ranged: %+4d", totals.RangedAttack, totals.RangedDefense), - "", - "Other bonuses:", - fmt.Sprintf(" Melee strength: %+d", totals.StrengthBonus), - fmt.Sprintf(" Ranged strength: %+d", totals.RangedStrength), - fmt.Sprintf(" Science damage: %+d", totals.ScienceDamage), - fmt.Sprintf(" Technology: %+d", totals.TechnologyBonus), - ) - - // Show effective accuracy and max hit for current setup - attBonus, strBonus, _ := combat.AttackStyleBonus(string(p.AttackStyle)) - equipAtt := combat.SelectAttackBonus(attackType, - totals.StabAttack, totals.SlashAttack, totals.CrushAttack, - totals.ScienceAttack, totals.RangedAttack) - attRoll := combat.AttackRoll(p.Level(player.Attack), attBonus, equipAtt) - maxHit := combat.MaxHit(p.Level(player.Strength), strBonus, totals.StrengthBonus) - - sess.WriteLines( - "", - fmt.Sprintf("Style: %s Attack roll: %d Max hit: %d", - p.AttackStyle, attRoll, maxHit), - ) - - _ = strings.Join // avoid unused import (the real code won't need this) -} -``` - -**Register the command:** - -**File:** `internal/game/game.go` - -In `classifyCommand` (line 138), add `"stats"` to the `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", "stats": // ADD "stats" - return ClassInstant -``` - -In `executeCommand` (find the switch statement for instant commands), add: -```go -case "stats": - g.doStats(sess) -``` - ---- - -## 11. Item YAML Migration - -Every item with combat stats needs updated YAML. Below is the complete set. - -### 11.1 Melee Weapons — Swords (attack_type: slash) - -Stats are OSRS-proportional. Swords have high slash, moderate stab, no crush. - -**Bronze Sword** (`data/items/bronze_sword.yaml`): -```yaml -id: bronze_sword -name: bronze sword -color: "178" -description: "A basic bronze sword." -value: 12 -stackable: false -equip_slot: main_hand -weapon_type: melee -attack_type: slash -stats: - stab_attack: 4 - slash_attack: 10 - crush_attack: -2 - strength_bonus: 7 -speed: 4 -``` - -**Iron Sword** (`data/items/iron_sword.yaml`): -```yaml -id: iron_sword -name: iron sword -color: "250" -description: "An iron sword." -value: 30 -stackable: false -equip_slot: main_hand -weapon_type: melee -attack_type: slash -stats: - stab_attack: 7 - slash_attack: 15 - crush_attack: -2 - strength_bonus: 10 -speed: 4 -``` - -**Steel Sword** (`data/items/steel_sword.yaml`): -```yaml -id: steel_sword -name: steel sword -color: "253" -description: "A steel sword." -value: 100 -stackable: false -equip_slot: main_hand -weapon_type: melee -attack_type: slash -stats: - stab_attack: 10 - slash_attack: 22 - crush_attack: -2 - strength_bonus: 14 -speed: 4 -``` - -**Mithril Sword** (`data/items/mithril_sword.yaml`): -```yaml -id: mithril_sword -name: mithril sword -color: "75" -description: "A mithril sword." -value: 250 -stackable: false -equip_slot: main_hand -weapon_type: melee -attack_type: slash -stats: - stab_attack: 14 - slash_attack: 30 - crush_attack: -2 - strength_bonus: 20 -speed: 4 -``` - -**Adamant Sword** (`data/items/adamant_sword.yaml`): -```yaml -id: adamant_sword -name: adamant sword -color: "120" -description: "An adamant sword." -value: 700 -stackable: false -equip_slot: main_hand -weapon_type: melee -attack_type: slash -stats: - stab_attack: 20 - slash_attack: 40 - crush_attack: -2 - strength_bonus: 27 -speed: 4 -``` - -**Rune Sword** (`data/items/rune_sword.yaml`): -```yaml -id: rune_sword -name: rune sword -color: "87" -description: "A rune sword." -value: 10000 -stackable: false -equip_slot: main_hand -weapon_type: melee -attack_type: slash -stats: - stab_attack: 27 - slash_attack: 52 - crush_attack: -2 - strength_bonus: 36 -speed: 4 -``` - -### 11.2 Melee Weapons — Daggers (attack_type: stab) - -Daggers have high stab, moderate slash, negative crush. Fast speed. - -**Bronze Dagger** (`data/items/bronze_dagger.yaml`): -```yaml -id: bronze_dagger -name: bronze dagger -color: "178" -description: "A small bronze dagger." -value: 8 -stackable: false -equip_slot: main_hand -weapon_type: melee -attack_type: stab -stats: - stab_attack: 5 - slash_attack: 3 - crush_attack: -1 - strength_bonus: 4 -speed: 3 -``` - -**Iron Dagger** (`data/items/iron_dagger.yaml`): -```yaml -id: iron_dagger -name: iron dagger -color: "250" -description: "A small iron dagger." -value: 20 -stackable: false -equip_slot: main_hand -weapon_type: melee -attack_type: stab -stats: - stab_attack: 8 - slash_attack: 5 - crush_attack: -1 - strength_bonus: 7 -speed: 3 -``` - -**Steel Dagger** (`data/items/steel_dagger.yaml`): -```yaml -id: steel_dagger -name: steel dagger -color: "253" -description: "A small steel dagger." -value: 60 -stackable: false -equip_slot: main_hand -weapon_type: melee -attack_type: stab -stats: - stab_attack: 12 - slash_attack: 7 - crush_attack: -1 - strength_bonus: 10 -speed: 3 -``` - -**Mithril Dagger** (`data/items/mithril_dagger.yaml`): -```yaml -id: mithril_dagger -name: mithril dagger -color: "75" -description: "A small mithril dagger." -value: 150 -stackable: false -equip_slot: main_hand -weapon_type: melee -attack_type: stab -stats: - stab_attack: 17 - slash_attack: 10 - crush_attack: -1 - strength_bonus: 14 -speed: 3 -``` - -**Adamant Dagger** (`data/items/adamant_dagger.yaml`): -```yaml -id: adamant_dagger -name: adamant dagger -color: "120" -description: "A small adamant dagger." -value: 400 -stackable: false -equip_slot: main_hand -weapon_type: melee -attack_type: stab -stats: - stab_attack: 23 - slash_attack: 14 - crush_attack: -1 - strength_bonus: 19 -speed: 3 -``` - -**Rune Dagger** (`data/items/rune_dagger.yaml`): -```yaml -id: rune_dagger -name: rune dagger -color: "87" -description: "A small rune dagger." -value: 5000 -stackable: false -equip_slot: main_hand -weapon_type: melee -attack_type: stab -stats: - stab_attack: 30 - slash_attack: 18 - crush_attack: -1 - strength_bonus: 24 -speed: 3 -``` - -### 11.3 Melee Weapons — Axes (attack_type: slash) - -Axes are also tools. They have moderate slash, some crush, weak stab. Slower than swords. - -**Bronze Axe** (`data/items/bronze_axe.yaml`): -```yaml -id: bronze_axe -name: bronze axe -color: "178" -description: "A sturdy bronze axe, good for woodcutting." -value: 10 -stackable: false -equip_slot: main_hand -weapon_type: melee -attack_type: slash -stats: - stab_attack: -2 - slash_attack: 7 - crush_attack: 5 - strength_bonus: 5 -speed: 5 -tool_type: axe -tool_speed: 4 -``` - -**Iron Axe** (`data/items/iron_axe.yaml`): -```yaml -id: iron_axe -name: iron axe -color: "250" -description: "A sturdy iron axe, good for woodcutting." -value: 25 -stackable: false -equip_slot: main_hand -weapon_type: melee -attack_type: slash -stats: - stab_attack: -2 - slash_attack: 10 - crush_attack: 7 - strength_bonus: 8 -speed: 5 -tool_type: axe -tool_speed: 3.5 -``` - -**Steel Axe** (`data/items/steel_axe.yaml`): -```yaml -id: steel_axe -name: steel axe -color: "253" -description: "A sturdy steel axe, good for woodcutting." -value: 75 -stackable: false -equip_slot: main_hand -weapon_type: melee -attack_type: slash -stats: - stab_attack: -2 - slash_attack: 15 - crush_attack: 11 - strength_bonus: 12 -speed: 5 -tool_type: axe -tool_speed: 3 -``` - -**Black Axe** (`data/items/black_axe.yaml`): -```yaml -id: black_axe -name: black axe -color: "240" -description: "A black axe, good for woodcutting." -value: 150 -stackable: false -equip_slot: main_hand -weapon_type: melee -attack_type: slash -stats: - stab_attack: -2 - slash_attack: 18 - crush_attack: 14 - strength_bonus: 14 -speed: 5 -tool_type: axe -tool_speed: 2.5 -``` - -**Mithril Axe** (`data/items/mithril_axe.yaml`): -```yaml -id: mithril_axe -name: mithril axe -color: "75" -description: "A sturdy mithril axe, good for woodcutting." -value: 200 -stackable: false -equip_slot: main_hand -weapon_type: melee -attack_type: slash -stats: - stab_attack: -2 - slash_attack: 21 - crush_attack: 16 - strength_bonus: 17 -speed: 5 -tool_type: axe -tool_speed: 2.5 -``` - -**Adamant Axe** (`data/items/adamant_axe.yaml`): -```yaml -id: adamant_axe -name: adamant axe -color: "120" -description: "A sturdy adamant axe, good for woodcutting." -value: 500 -stackable: false -equip_slot: main_hand -weapon_type: melee -attack_type: slash -stats: - stab_attack: -2 - slash_attack: 29 - crush_attack: 22 - strength_bonus: 24 -speed: 5 -tool_type: axe -tool_speed: 2 -``` - -**Rune Axe** (`data/items/rune_axe.yaml`): -```yaml -id: rune_axe -name: rune axe -color: "87" -description: "A sturdy rune axe, good for woodcutting." -value: 5000 -stackable: false -equip_slot: main_hand -weapon_type: melee -attack_type: slash -stats: - stab_attack: -2 - slash_attack: 38 - crush_attack: 29 - strength_bonus: 32 -speed: 5 -tool_type: axe -tool_speed: 1.5 -``` - -**Dragon Axe** (`data/items/dragon_axe.yaml`): -```yaml -id: dragon_axe -name: dragon axe -color: "196" -description: "A powerful dragon axe." -value: 50000 -stackable: false -equip_slot: main_hand -weapon_type: melee -attack_type: slash -stats: - stab_attack: -2 - slash_attack: 43 - crush_attack: 32 - strength_bonus: 36 -speed: 5 -tool_type: axe -tool_speed: 1 -``` - -### 11.4 Melee Weapons — Pickaxes (attack_type: stab) - -Pickaxes stab/pierce. Tool items with moderate combat stats. - -**Bronze Pickaxe** (`data/items/bronze_pickaxe.yaml`): -```yaml -id: bronze_pickaxe -name: bronze pickaxe -color: "178" -description: "A sturdy bronze pickaxe, good for mining." -value: 10 -stackable: false -equip_slot: main_hand -weapon_type: melee -attack_type: stab -stats: - stab_attack: 4 - slash_attack: -2 - crush_attack: 2 - strength_bonus: 3 -speed: 5 -tool_type: pickaxe -tool_speed: 2 -``` - -**Iron Pickaxe** (`data/items/iron_pickaxe.yaml`): -```yaml -id: iron_pickaxe -name: iron pickaxe -color: "250" -description: "A sturdy iron pickaxe, good for mining." -value: 25 -stackable: false -equip_slot: main_hand -weapon_type: melee -attack_type: stab -stats: - stab_attack: 7 - slash_attack: -2 - crush_attack: 3 - strength_bonus: 5 -speed: 5 -tool_type: pickaxe -tool_speed: 1.5 -``` - -**Steel Pickaxe** (`data/items/steel_pickaxe.yaml`): -```yaml -id: steel_pickaxe -name: steel pickaxe -color: "253" -description: "A sturdy steel pickaxe, good for mining." -value: 75 -stackable: false -equip_slot: main_hand -weapon_type: melee -attack_type: stab -stats: - stab_attack: 10 - slash_attack: -2 - crush_attack: 4 - strength_bonus: 7 -speed: 5 -tool_type: pickaxe -tool_speed: 1.25 -``` - -**Black Pickaxe** (`data/items/black_pickaxe.yaml`): -```yaml -id: black_pickaxe -name: black pickaxe -color: "240" -description: "A black pickaxe, good for mining." -value: 150 -stackable: false -equip_slot: main_hand -weapon_type: melee -attack_type: stab -stats: - stab_attack: 14 - slash_attack: -2 - crush_attack: 6 - strength_bonus: 9 -speed: 5 -tool_type: pickaxe -tool_speed: 1 -``` - -**Mithril Pickaxe** (`data/items/mithril_pickaxe.yaml`): -```yaml -id: mithril_pickaxe -name: mithril pickaxe -color: "75" -description: "A sturdy mithril pickaxe, good for mining." -value: 200 -stackable: false -equip_slot: main_hand -weapon_type: melee -attack_type: stab -stats: - stab_attack: 17 - slash_attack: -2 - crush_attack: 8 - strength_bonus: 12 -speed: 5 -tool_type: pickaxe -tool_speed: 1 -``` - -**Adamant Pickaxe** (`data/items/adamant_pickaxe.yaml`): -```yaml -id: adamant_pickaxe -name: adamant pickaxe -color: "120" -description: "A sturdy adamant pickaxe, good for mining." -value: 500 -stackable: false -equip_slot: main_hand -weapon_type: melee -attack_type: stab -stats: - stab_attack: 23 - slash_attack: -2 - crush_attack: 10 - strength_bonus: 16 -speed: 5 -tool_type: pickaxe -tool_speed: 0.75 -``` - -**Rune Pickaxe** (`data/items/rune_pickaxe.yaml`): -```yaml -id: rune_pickaxe -name: rune pickaxe -color: "87" -description: "A sturdy rune pickaxe, good for mining." -value: 5000 -stackable: false -equip_slot: main_hand -weapon_type: melee -attack_type: stab -stats: - stab_attack: 30 - slash_attack: -2 - crush_attack: 14 - strength_bonus: 22 -speed: 5 -tool_type: pickaxe -tool_speed: 0.5 -``` - -**Dragon Pickaxe** (`data/items/dragon_pickaxe.yaml`): -```yaml -id: dragon_pickaxe -name: dragon pickaxe -color: "196" -description: "A powerful dragon pickaxe." -value: 50000 -stackable: false -equip_slot: main_hand -weapon_type: melee -attack_type: stab -stats: - stab_attack: 34 - slash_attack: -2 - crush_attack: 16 - strength_bonus: 26 -speed: 5 -tool_type: pickaxe -tool_speed: 0.25 -``` - -### 11.5 Armor — Med Helms - -Med helms have moderate defense, balanced across melee types. Slightly negative science defense (metal conducts energy), neutral ranged. - -**Bronze Med Helm** (`data/items/bronze_med_helm.yaml`): -```yaml -id: bronze_med_helm -name: bronze med helm -color: "178" -description: "A bronze medium helmet." -value: 8 -stackable: false -equip_slot: head -stats: - stab_defense: 3 - slash_defense: 4 - crush_defense: 2 - science_defense: -1 - ranged_defense: 3 -``` - -**Iron Med Helm** (`data/items/iron_med_helm.yaml`): -```yaml -id: iron_med_helm -name: iron med helm -color: "250" -description: "An iron medium helmet." -value: 20 -stackable: false -equip_slot: head -stats: - stab_defense: 5 - slash_defense: 6 - crush_defense: 3 - science_defense: -1 - ranged_defense: 5 -``` - -**Steel Med Helm** (`data/items/steel_med_helm.yaml`): -```yaml -id: steel_med_helm -name: steel med helm -color: "253" -description: "A steel medium helmet." -value: 60 -stackable: false -equip_slot: head -stats: - stab_defense: 7 - slash_defense: 9 - crush_defense: 5 - science_defense: -2 - ranged_defense: 7 -``` - -**Mithril Med Helm** (`data/items/mithril_med_helm.yaml`): -```yaml -id: mithril_med_helm -name: mithril med helm -color: "75" -description: "A mithril medium helmet." -value: 150 -stackable: false -equip_slot: head -stats: - stab_defense: 10 - slash_defense: 12 - crush_defense: 7 - science_defense: -3 - ranged_defense: 10 -``` - -**Adamant Med Helm** (`data/items/adamant_med_helm.yaml`): -```yaml -id: adamant_med_helm -name: adamant med helm -color: "120" -description: "An adamant medium helmet." -value: 400 -stackable: false -equip_slot: head -stats: - stab_defense: 14 - slash_defense: 16 - crush_defense: 10 - science_defense: -3 - ranged_defense: 14 -``` - -**Rune Med Helm** (`data/items/rune_med_helm.yaml`): -```yaml -id: rune_med_helm -name: rune med helm -color: "87" -description: "A rune medium helmet." -value: 5000 -stackable: false -equip_slot: head -stats: - stab_defense: 19 - slash_defense: 22 - crush_defense: 13 - science_defense: -4 - ranged_defense: 19 -``` - -### 11.6 Armor — Full Helms - -Full helms have higher defense than med helms. Larger science penalty. - -**Bronze Full Helm** (`data/items/bronze_full_helm.yaml`): -```yaml -id: bronze_full_helm -name: bronze full helm -color: "178" -description: "A bronze full helmet." -value: 20 -stackable: false -equip_slot: head -stats: - stab_defense: 5 - slash_defense: 6 - crush_defense: 4 - science_defense: -3 - ranged_defense: 5 -``` - -**Iron Full Helm** (`data/items/iron_full_helm.yaml`): -```yaml -id: iron_full_helm -name: iron full helm -color: "250" -description: "An iron full helmet." -value: 50 -stackable: false -equip_slot: head -stats: - stab_defense: 8 - slash_defense: 9 - crush_defense: 6 - science_defense: -3 - ranged_defense: 8 -``` - -**Steel Full Helm** (`data/items/steel_full_helm.yaml`): -```yaml -id: steel_full_helm -name: steel full helm -color: "253" -description: "A steel full helmet." -value: 150 -stackable: false -equip_slot: head -stats: - stab_defense: 12 - slash_defense: 13 - crush_defense: 9 - science_defense: -5 - ranged_defense: 12 -``` - -**Mithril Full Helm** (`data/items/mithril_full_helm.yaml`): -```yaml -id: mithril_full_helm -name: mithril full helm -color: "75" -description: "A mithril full helmet." -value: 400 -stackable: false -equip_slot: head -stats: - stab_defense: 16 - slash_defense: 18 - crush_defense: 12 - science_defense: -6 - ranged_defense: 16 -``` - -**Adamant Full Helm** (`data/items/adamant_full_helm.yaml`): -```yaml -id: adamant_full_helm -name: adamant full helm -color: "120" -description: "An adamant full helmet." -value: 1000 -stackable: false -equip_slot: head -stats: - stab_defense: 22 - slash_defense: 24 - crush_defense: 16 - science_defense: -8 - ranged_defense: 22 -``` - -**Rune Full Helm** (`data/items/rune_full_helm.yaml`): -```yaml -id: rune_full_helm -name: rune full helm -color: "87" -description: "A rune full helmet." -value: 12000 -stackable: false -equip_slot: head -stats: - stab_defense: 30 - slash_defense: 33 - crush_defense: 22 - science_defense: -11 - ranged_defense: 30 -``` - -### 11.7 Armor — Platebodies - -Platebodies are the highest-defense torso armor. Heavy science penalty, strong melee defense. - -**Bronze Platebody** (`data/items/bronze_platebody.yaml`): -```yaml -id: bronze_platebody -name: bronze platebody -color: "178" -description: "A bronze platebody." -value: 60 -stackable: false -equip_slot: torso -stats: - stab_defense: 12 - slash_defense: 15 - crush_defense: 10 - science_defense: -10 - ranged_defense: 12 -``` - -**Iron Platebody** (`data/items/iron_platebody.yaml`): -```yaml -id: iron_platebody -name: iron platebody -color: "250" -description: "An iron platebody." -value: 150 -stackable: false -equip_slot: torso -stats: - stab_defense: 18 - slash_defense: 22 - crush_defense: 14 - science_defense: -10 - ranged_defense: 18 -``` - -**Steel Platebody** (`data/items/steel_platebody.yaml`): -```yaml -id: steel_platebody -name: steel platebody -color: "253" -description: "A steel platebody." -value: 500 -stackable: false -equip_slot: torso -stats: - stab_defense: 27 - slash_defense: 32 - crush_defense: 22 - science_defense: -15 - ranged_defense: 27 -``` - -**Mithril Platebody** (`data/items/mithril_platebody.yaml`): -```yaml -id: mithril_platebody -name: mithril platebody -color: "75" -description: "A mithril platebody." -value: 1500 -stackable: false -equip_slot: torso -stats: - stab_defense: 36 - slash_defense: 42 - crush_defense: 30 - science_defense: -20 - ranged_defense: 36 -``` - -**Adamant Platebody** (`data/items/adamant_platebody.yaml`): -```yaml -id: adamant_platebody -name: adamant platebody -color: "120" -description: "An adamant platebody." -value: 5000 -stackable: false -equip_slot: torso -stats: - stab_defense: 49 - slash_defense: 55 - crush_defense: 40 - science_defense: -25 - ranged_defense: 49 -``` - -**Rune Platebody** (`data/items/rune_platebody.yaml`): -```yaml -id: rune_platebody -name: rune platebody -color: "87" -description: "A rune platebody." -value: 40000 -stackable: false -equip_slot: torso -stats: - stab_defense: 65 - slash_defense: 72 - crush_defense: 52 - science_defense: -30 - ranged_defense: 65 -``` - -### 11.8 Ranged Weapons — Bows - -Bows use the `ranged_attack` field. The bug where they used `attack:` (which was silently ignored since ItemStats has no `attack` YAML tag) is fixed. Longbows have higher accuracy than shortbows but are slower. - -**Shortbow** (`data/items/shortbow.yaml`): -```yaml -id: shortbow -name: shortbow -color: "137" -description: "A shortbow." -value: 10 -equip_slot: main_hand -weapon_type: ranged -attack_type: ranged -speed: 4 -stats: - ranged_attack: 8 -``` - -**Oak Shortbow** (`data/items/oak_shortbow.yaml`): -```yaml -id: oak_shortbow -name: oak shortbow -color: "136" -description: "An oak shortbow." -value: 40 -equip_slot: main_hand -weapon_type: ranged -attack_type: ranged -speed: 4 -stats: - ranged_attack: 14 -``` - -**Willow Shortbow** (`data/items/willow_shortbow.yaml`): -```yaml -id: willow_shortbow -name: willow shortbow -color: "107" -description: "A willow shortbow." -value: 160 -equip_slot: main_hand -weapon_type: ranged -attack_type: ranged -speed: 4 -stats: - ranged_attack: 20 -``` - -**Maple Shortbow** (`data/items/maple_shortbow.yaml`): -```yaml -id: maple_shortbow -name: maple shortbow -color: "172" -description: "A maple shortbow." -value: 640 -equip_slot: main_hand -weapon_type: ranged -attack_type: ranged -speed: 4 -stats: - ranged_attack: 29 -``` - -**Yew Shortbow** (`data/items/yew_shortbow.yaml`): -```yaml -id: yew_shortbow -name: yew shortbow -color: "94" -description: "A yew shortbow." -value: 1600 -equip_slot: main_hand -weapon_type: ranged -attack_type: ranged -speed: 4 -stats: - ranged_attack: 47 -``` - -**Magic Shortbow** (`data/items/magic_shortbow.yaml`): -```yaml -id: magic_shortbow -name: magic shortbow -color: "63" -description: "A magic shortbow." -value: 4000 -equip_slot: main_hand -weapon_type: ranged -attack_type: ranged -speed: 4 -stats: - ranged_attack: 69 -``` - -**Longbow** (`data/items/longbow.yaml`): -```yaml -id: longbow -name: longbow -color: "137" -description: "A longbow." -value: 20 -equip_slot: main_hand -weapon_type: ranged -attack_type: ranged -speed: 6 -stats: - ranged_attack: 8 -``` - -**Oak Longbow** (`data/items/oak_longbow.yaml`): -```yaml -id: oak_longbow -name: oak longbow -color: "136" -description: "An oak longbow." -value: 80 -equip_slot: main_hand -weapon_type: ranged -attack_type: ranged -speed: 6 -stats: - ranged_attack: 14 -``` - -**Willow Longbow** (`data/items/willow_longbow.yaml`): -```yaml -id: willow_longbow -name: willow longbow -color: "107" -description: "A willow longbow." -value: 320 -equip_slot: main_hand -weapon_type: ranged -attack_type: ranged -speed: 6 -stats: - ranged_attack: 20 -``` - -**Maple Longbow** (`data/items/maple_longbow.yaml`): -```yaml -id: maple_longbow -name: maple longbow -color: "172" -description: "A maple longbow." -value: 1280 -equip_slot: main_hand -weapon_type: ranged -attack_type: ranged -speed: 6 -stats: - ranged_attack: 29 -``` - -**Yew Longbow** (`data/items/yew_longbow.yaml`): -```yaml -id: yew_longbow -name: yew longbow -color: "94" -description: "A yew longbow." -value: 2000 -equip_slot: main_hand -weapon_type: ranged -attack_type: ranged -speed: 6 -stats: - ranged_attack: 47 -``` - -**Magic Longbow** (`data/items/magic_longbow.yaml`): -```yaml -id: magic_longbow -name: magic longbow -color: "63" -description: "A magic longbow." -value: 6000 -equip_slot: main_hand -weapon_type: ranged -attack_type: ranged -speed: 6 -stats: - ranged_attack: 69 -``` - -### 11.9 Ranged Ammo — Arrows - -Arrows now use `ranged_strength` instead of the broken `attack:` field. Arrow accuracy (ranged_attack) is 0 — all accuracy comes from the bow. Damage comes from the arrow's `ranged_strength`. - -**Bronze Arrow** (`data/items/bronze_arrow.yaml`): -```yaml -id: bronze_arrow -name: bronze arrow -color: "178" -description: "A bronze-tipped arrow." -value: 1 -stackable: true -equip_slot: ammo -stats: - ranged_strength: 7 -``` - -**Iron Arrow** (`data/items/iron_arrow.yaml`): -```yaml -id: iron_arrow -name: iron arrow -color: "250" -description: "An iron-tipped arrow." -value: 2 -stackable: true -equip_slot: ammo -stats: - ranged_strength: 10 -``` - -**Steel Arrow** (`data/items/steel_arrow.yaml`): -```yaml -id: steel_arrow -name: steel arrow -color: "253" -description: "A steel-tipped arrow." -value: 5 -stackable: true -equip_slot: ammo -stats: - ranged_strength: 16 -``` - -**Mithril Arrow** (`data/items/mithril_arrow.yaml`): -```yaml -id: mithril_arrow -name: mithril arrow -color: "75" -description: "A mithril-tipped arrow." -value: 10 -stackable: true -equip_slot: ammo -stats: - ranged_strength: 22 -``` - -**Adamant Arrow** (`data/items/adamant_arrow.yaml`): -```yaml -id: adamant_arrow -name: adamant arrow -color: "120" -description: "An adamant-tipped arrow." -value: 20 -stackable: true -equip_slot: ammo -stats: - ranged_strength: 31 -``` - -**Rune Arrow** (`data/items/rune_arrow.yaml`): -```yaml -id: rune_arrow -name: rune arrow -color: "87" -description: "A rune-tipped arrow." -value: 40 -stackable: true -equip_slot: ammo -stats: - ranged_strength: 49 -``` - -### 11.10 Ranged Ammo — Bolts - -Same pattern as arrows. `ranged_strength` for damage. - -**Bronze Bolts** (`data/items/bronze_bolts.yaml`): -```yaml -id: bronze_bolts -name: bronze bolts -color: "178" -description: "Bronze crossbow bolts." -value: 1 -stackable: true -equip_slot: ammo -stats: - ranged_strength: 10 -``` - -**Iron Bolts** (`data/items/iron_bolts.yaml`): -```yaml -id: iron_bolts -name: iron bolts -color: "250" -description: "Iron crossbow bolts." -value: 3 -stackable: true -equip_slot: ammo -stats: - ranged_strength: 17 -``` - -**Steel Bolts** (`data/items/steel_bolts.yaml`): -```yaml -id: steel_bolts -name: steel bolts -color: "253" -description: "Steel crossbow bolts." -value: 6 -stackable: true -equip_slot: ammo -stats: - ranged_strength: 25 -``` - -**Mithril Bolts** (`data/items/mithril_bolts.yaml`): -```yaml -id: mithril_bolts -name: mithril bolts -color: "75" -description: "Mithril crossbow bolts." -value: 12 -stackable: true -equip_slot: ammo -stats: - ranged_strength: 32 -``` - -**Adamant Bolts** (`data/items/adamant_bolts.yaml`): -```yaml -id: adamant_bolts -name: adamant bolts -color: "120" -description: "Adamant crossbow bolts." -value: 24 -stackable: true -equip_slot: ammo -stats: - ranged_strength: 43 -``` - -**Rune Bolts** (`data/items/rune_bolts.yaml`): -```yaml -id: rune_bolts -name: rune bolts -color: "87" -description: "Rune crossbow bolts." -value: 48 -stackable: true -equip_slot: ammo -stats: - ranged_strength: 55 -``` - -**Sapphire Bolts** (`data/items/sapphire_bolts.yaml`): -```yaml -id: sapphire_bolts -name: sapphire bolts -color: "69" -description: "Mithril bolts tipped with sapphire." -value: 40 -stackable: true -equip_slot: ammo -stats: - ranged_strength: 36 -``` - -**Emerald Bolts** (`data/items/emerald_bolts.yaml`): -```yaml -id: emerald_bolts -name: emerald bolts -color: "83" -description: "Mithril bolts tipped with emerald." -value: 65 -stackable: true -equip_slot: ammo -stats: - ranged_strength: 39 -``` - -**Ruby Bolts** (`data/items/ruby_bolts.yaml`): -```yaml -id: ruby_bolts -name: ruby bolts -color: "196" -description: "Adamant bolts tipped with ruby." -value: 130 -stackable: true -equip_slot: ammo -stats: - ranged_strength: 49 -``` - -**Diamond Bolts** (`data/items/diamond_bolts.yaml`): -```yaml -id: diamond_bolts -name: diamond bolts -color: "255" -description: "Adamant bolts tipped with diamond." -value: 230 -stackable: true -equip_slot: ammo -stats: - ranged_strength: 52 -``` - -### 11.11 Ranged Armor — Leather - -Leather armor provides ranged defense and slight melee defense. Positive ranged defense, negative science defense. - -**Leather Cowl** (`data/items/leather_cowl.yaml`): -```yaml -id: leather_cowl -name: leather cowl -color: "130" -description: "A leather cowl." -value: 15 -equip_slot: head -stats: - stab_defense: 1 - slash_defense: 2 - crush_defense: 1 - science_defense: 2 - ranged_defense: 4 -``` - -**Leather Body** (`data/items/leather_body.yaml`): -```yaml -id: leather_body -name: leather body -color: "130" -description: "A leather body armour." -value: 25 -equip_slot: torso -stats: - stab_defense: 4 - slash_defense: 7 - crush_defense: 5 - science_defense: 4 - ranged_defense: 10 -``` - -**Leather Chaps** (`data/items/leather_chaps.yaml`): -```yaml -id: leather_chaps -name: leather chaps -color: "130" -description: "A pair of leather chaps." -value: 20 -equip_slot: legs -stats: - stab_defense: 2 - slash_defense: 4 - crush_defense: 3 - science_defense: 3 - ranged_defense: 6 -``` - -**Leather Gloves** (`data/items/leather_gloves.yaml`): -```yaml -id: leather_gloves -name: leather gloves -color: "130" -description: "A pair of leather gloves." -value: 10 -equip_slot: hands -stats: - stab_defense: 1 - slash_defense: 1 - crush_defense: 1 - science_defense: 1 - ranged_defense: 2 -``` - -**Leather Vambraces** (`data/items/leather_vambraces.yaml`): -```yaml -id: leather_vambraces -name: leather vambraces -color: "130" -description: "A pair of leather vambraces." -value: 18 -equip_slot: hands -stats: - stab_defense: 2 - slash_defense: 2 - crush_defense: 2 - science_defense: 2 - ranged_defense: 4 - ranged_attack: 4 -``` - -**Leather Boots** (`data/items/leather_boots.yaml`): -```yaml -id: leather_boots -name: leather boots -color: "130" -description: "A pair of leather boots." -value: 12 -equip_slot: feet -stats: - stab_defense: 1 - slash_defense: 1 - crush_defense: 1 - science_defense: 1 - ranged_defense: 2 -``` - -**Coif** (`data/items/coif.yaml`): -```yaml -id: coif -name: coif -color: "94" -description: "A hard leather coif." -value: 45 -equip_slot: head -stats: - stab_defense: 2 - slash_defense: 4 - crush_defense: 2 - science_defense: 4 - ranged_defense: 6 - ranged_attack: 2 -``` - -**Hard Leather Body** (`data/items/hard_leather_body.yaml`): -```yaml -id: hard_leather_body -name: hard leather body -color: "94" -description: "A hardened leather body armour." -value: 60 -equip_slot: torso -stats: - stab_defense: 8 - slash_defense: 12 - crush_defense: 8 - science_defense: 6 - ranged_defense: 14 -``` - -**Hard Leather Shield** (`data/items/hard_leather_shield.yaml`): -```yaml -id: hard_leather_shield -name: hard leather shield -color: "94" -description: "A shield made of hardened leather." -value: 55 -equip_slot: off_hand -stats: - stab_defense: 5 - slash_defense: 7 - crush_defense: 5 - science_defense: 4 - ranged_defense: 8 -``` - -### 11.12 Non-Combat Equipment (no changes needed) - -These items have no combat stats and need no changes: -- Graceful set (hat, torso, legs, gloves, boots, cape) — movement only -- Cape of Agility — movement only -- Jewelry (rings, necklaces, amulets, bracelets) — currently no stats -- Tools without equip (needle, chisel, etc.) - -### 11.13 Unfinished Bow Items (no combat stats) - -These are fletching intermediates with no combat purpose: -- `shortbow_u`, `longbow_u`, `oak_shortbow_u`, `oak_longbow_u`, `willow_shortbow_u`, `willow_longbow_u`, `maple_shortbow_u`, `maple_longbow_u`, `yew_shortbow_u`, `yew_longbow_u`, `magic_shortbow_u`, `magic_longbow_u` -- `arrow_shaft`, `headless_arrow`, all `*_arrowtips`, all `*_bolts_unf`, all `*_bolt_tips` - -These have no equip slot or stats and need no changes. - ---- - -## 12. Mob YAML Updates - -### 12.1 Cow (`data/mobs/cow.yaml`) - -```yaml -id: cow -name: cow -description: "A placid dairy cow, chewing cud." -attack: 1 -strength: 1 -defense: 1 -hp: 8 -speed: 5 -aggressive: false -respawn_ticks: 30 -attack_type: crush -stab_defense: 1 -slash_defense: 1 -crush_defense: 1 -science_defense: 0 -ranged_defense: 0 -idle_descriptions: - - "moos softly" - - "chews on some grass" - - "swishes its tail lazily" - - "stares at you with big brown eyes" -drops: - remains: bones - loot: - - item_id: cowhide - weight: 100 - - item_id: raw_beef - weight: 100 -``` - -### 12.2 Man (`data/mobs/man.yaml`) - -```yaml -id: man -name: man -description: "A shabby-looking man loitering in the town square." -combat_descriptions: - - "is engaged in a fight to the death with %s" - - "is getting pummelled by %s" - - "is locked in combat with %s" - - "trades blows with %s" - - "circles warily around %s" -idle_descriptions: - - "scribbles something in a small notebook" - - "gazes skyward at the clouds" - - "leans against a wall, looking bored" - - "scratches his head thoughtfully" - - "stares off into the distance" - - "adjusts his tunic and stretches" -attack: 1 -strength: 1 -defense: 1 -hp: 7 -speed: 5 -aggressive: false -respawn_ticks: 30 -attack_type: crush -stab_defense: 0 -slash_defense: 0 -crush_defense: 0 -science_defense: 0 -ranged_defense: 0 -drops: - remains: "bones" - loot: - - item_id: "credits" - weight: 98 - quantity: 10 - - item_id: "credits" - weight: 2 - quantity: 150 -``` - -### 12.3 Guard (`data/mobs/guard.yaml`) - -```yaml -id: guard -name: Guard -description: "A stern-looking guard in weathered armor." -behavior: guard_talk -unique: true -protected: true -attack: 5 -strength: 5 -defense: 5 -hp: 30 -speed: 5 -aggressive: false -respawn_ticks: 60 -attack_type: slash -attack_bonus: 8 -strength_bonus: 6 -stab_defense: 12 -slash_defense: 15 -crush_defense: 10 -science_defense: -5 -ranged_defense: 12 -idle_descriptions: - - "scans the area with a watchful eye" - - "adjusts the grip on his weapon" - - "nods curtly at passersby" - - "leans against the gate, arms folded" -``` - -### 12.4 Newbie Trainer (`data/mobs/newbie_trainer.yaml`) - -```yaml -id: newbie_trainer -name: Newbie Trainer -description: "A friendly-looking instructor ready to help new adventurers." -unique: true -protected: true -attack: 1 -strength: 1 -defense: 1 -hp: 50 -speed: 5 -aggressive: false -respawn_ticks: 30 -attack_type: crush -stab_defense: 0 -slash_defense: 0 -crush_defense: 0 -science_defense: 0 -ranged_defense: 0 -idle_descriptions: - - "reviews a training manual" - - "adjusts a practice dummy" - - "demonstrates a sword stance to no one in particular" - - "offers a friendly nod to passersby" - - "polishes a wooden shield" - - "checks a stopwatch and jots something down" -``` - -### 12.5 Tanner (`data/mobs/tanner.yaml`) - -```yaml -id: tanner -name: Tanner -description: "A weathered craftsman with stained hands and a leather apron." -behavior: tanner_talk -unique: true -protected: true -hp: 50 -attack: 1 -strength: 1 -defense: 1 -speed: 5 -aggressive: false -respawn_ticks: 30 -attack_type: crush -stab_defense: 0 -slash_defense: 0 -crush_defense: 0 -science_defense: 0 -ranged_defense: 0 -idle_descriptions: - - "scrapes at a piece of hide" - - "examines a stack of leathers" - - "sharpens a tanning knife" - - "wipes sweat from his brow" -``` - ---- - -## 13. Example New Mobs - -These new mob YAMLs demonstrate the full stat spectrum. Place in `data/mobs/`. - -### 13.1 Rat (Level 1) - -```yaml -id: rat -name: rat -description: "A large, mangy rat with beady red eyes." -attack: 1 -strength: 1 -defense: 1 -hp: 2 -speed: 4 -aggressive: false -respawn_ticks: 20 -attack_type: crush -stab_defense: 0 -slash_defense: 0 -crush_defense: 0 -science_defense: 0 -ranged_defense: 0 -idle_descriptions: - - "sniffs around the ground" - - "nibbles on something" - - "scurries in circles" -drops: - remains: bones -``` - -### 13.2 Goblin (Level 5) - -Weak to crush — goblins wear thin leather that resists slashing poorly and does nothing against blunt force. - -```yaml -id: goblin -name: goblin -description: "A small green-skinned goblin with a rusty blade." -attack: 1 -strength: 3 -defense: 3 -hp: 12 -speed: 5 -aggressive: true -respawn_ticks: 25 -attack_type: slash -attack_bonus: 3 -strength_bonus: 2 -stab_defense: 4 -slash_defense: 3 -crush_defense: -2 -science_defense: 0 -ranged_defense: 3 -idle_descriptions: - - "mutters something unintelligible" - - "waves a rusty blade around" - - "picks its nose" - - "kicks a pebble" -drops: - remains: bones - loot: - - item_id: credits - weight: 80 - quantity: 5 - - item_id: credits - weight: 20 - quantity: 25 -``` - -### 13.3 Skeleton (Level 22) - -Skeletons have high slash defense (blade passes through gaps in bones), weak to crush (bones shatter). - -```yaml -id: skeleton -name: skeleton -description: "An animated pile of bones, held together by dark energy." -attack: 8 -strength: 6 -defense: 8 -hp: 22 -speed: 5 -aggressive: true -respawn_ticks: 30 -attack_type: stab -attack_bonus: 6 -strength_bonus: 4 -stab_defense: 10 -slash_defense: 18 -crush_defense: -5 -science_defense: -8 -ranged_defense: 10 -idle_descriptions: - - "rattles ominously" - - "clicks its jaw open and shut" - - "stares with empty eye sockets" -drops: - remains: bones - loot: - - item_id: credits - weight: 70 - quantity: 30 - - item_id: credits - weight: 30 - quantity: 100 -``` - -### 13.4 Zombie (Level 28) - -High slash defense (rotting flesh gives way), weak to crush and solar (sci-fi fire equivalent). - -```yaml -id: zombie -name: zombie -description: "A shambling corpse reanimated by residual asteroid radiation." -attack: 10 -strength: 8 -defense: 8 -hp: 30 -speed: 6 -aggressive: true -respawn_ticks: 35 -attack_type: crush -attack_bonus: 4 -strength_bonus: 6 -stab_defense: 8 -slash_defense: 20 -crush_defense: -8 -science_defense: -15 -ranged_defense: 8 -weakness: solar -idle_descriptions: - - "groans and shuffles forward" - - "reaches out with decaying arms" - - "stumbles and catches itself" - - "stares blankly ahead" -drops: - remains: bones - loot: - - item_id: credits - weight: 60 - quantity: 50 - - item_id: credits - weight: 40 - quantity: 200 -``` - -### 13.5 Moss Giant (Level 42) - -High HP, moderate defense across the board. Big and slow. - -```yaml -id: moss_giant -name: moss giant -description: "A towering creature covered in moss and vines, its rocky skin dripping with moisture." -attack: 12 -strength: 14 -defense: 10 -hp: 60 -speed: 7 -aggressive: true -respawn_ticks: 45 -attack_type: crush -attack_bonus: 10 -strength_bonus: 12 -stab_defense: 15 -slash_defense: 12 -crush_defense: 18 -science_defense: -5 -ranged_defense: 15 -idle_descriptions: - - "surveys its territory with dull eyes" - - "pulls a vine from its shoulder" - - "stamps the ground, sending tremors" - - "scratches its mossy hide" -drops: - remains: bones - loot: - - item_id: credits - weight: 50 - quantity: 120 - - item_id: credits - weight: 50 - quantity: 500 -``` - -### 13.6 Lesser Drone (Level 53) - -A science-based mob. High melee defense, weak to ranged. Uses science attacks. - -```yaml -id: lesser_drone -name: lesser drone -description: "A floating metallic drone with a pulsing energy core. Its surface crackles with electric discharge." -attack: 1 -strength: 1 -defense: 18 -hp: 55 -ranged: 0 -science: 20 -speed: 4 -aggressive: true -respawn_ticks: 50 -attack_type: science -attack_bonus: 0 -science_bonus: 22 -strength_bonus: 0 -stab_defense: 35 -slash_defense: 35 -crush_defense: 30 -science_defense: 20 -ranged_defense: -10 -idle_descriptions: - - "hovers silently, scanning the area" - - "emits a low electronic hum" - - "projects a thin beam of light across the floor" - - "rotates slowly in place" -drops: - loot: - - item_id: scrap_metal - weight: 60 - - item_id: credits - weight: 40 - quantity: 300 -``` - -### 13.7 Greater Drone (Level 83) - -Stronger version of the lesser drone. - -```yaml -id: greater_drone -name: greater drone -description: "A large, heavily armored drone with multiple energy emitters. Red warning lights pulse along its hull." -attack: 1 -strength: 1 -defense: 30 -hp: 120 -ranged: 0 -science: 40 -speed: 4 -aggressive: true -respawn_ticks: 80 -attack_type: science -attack_bonus: 0 -science_bonus: 45 -strength_bonus: 0 -stab_defense: 55 -slash_defense: 55 -crush_defense: 50 -science_defense: 40 -ranged_defense: -20 -idle_descriptions: - - "projects a defensive energy shield" - - "recalibrates its targeting array" - - "emits a warning klaxon" - - "deploys sensor probes" -drops: - loot: - - item_id: scrap_metal - weight: 40 - quantity: 3 - - item_id: credits - weight: 60 - quantity: 1500 -``` - -### 13.8 Iron Sentinel (Level 92) - -A heavily armored mechanical guardian. Very high melee defense, weak to science attacks. - -```yaml -id: iron_sentinel -name: iron sentinel -description: "A massive bipedal automaton forged from pre-regression alloys. Its joints grind with each deliberate step." -attack: 25 -strength: 30 -defense: 45 -hp: 180 -speed: 6 -aggressive: true -respawn_ticks: 100 -attack_type: crush -attack_bonus: 40 -strength_bonus: 35 -stab_defense: 80 -slash_defense: 80 -crush_defense: 70 -science_defense: -30 -ranged_defense: 50 -idle_descriptions: - - "stands motionless, servos whining softly" - - "turns its armored head with a mechanical grind" - - "vents steam from exhaust ports" - - "scans the area with a red optical sensor" -drops: - loot: - - item_id: scrap_metal - weight: 30 - quantity: 5 - - item_id: runite_bar - weight: 10 - - item_id: credits - weight: 60 - quantity: 5000 -``` - ---- - -## 14. Ranged Combat Integration - -### 14.1 Attack Flow - -When `weapon_type == "ranged"`: - -1. **Ammo check** (in `doAttack`, before `startCombat`): - ```go - if weaponDef.WeaponType == object.WeaponRanged { - if _, hasAmmo := p.Equipment[object.SlotAmmo]; !hasAmmo || p.AmmoQty <= 0 { - sess.WriteLine("You don't have any ammo equipped.") - return - } - } - ``` - -2. **Attack roll**: Uses `Ranged` level + `RangedStyleBonus` + total `ranged_attack` from all equipment (bow + ammo + any ranged-boosting armor). - -3. **Max hit**: Uses `Ranged` level + `RangedStyleBonus` (strength portion) + total `ranged_strength` from equipment (primarily the ammo). - -4. **Mob defense**: Uses mob's `ranged_defense` value. - -5. **Ammo consumption**: After each attack (hit or miss), decrement `p.AmmoQty` by 1. If ammo reaches 0, unequip it and end combat. - -6. **XP**: 75% Ranged, 25% Hitpoints (all ranged styles). - -### 14.2 RangedStyleBonus - -| Style | Ranged Level Bonus | Defense Level Bonus | XP | -|-------|-------------------|--------------------|----| -| Accurate | +3 | +0 | 75% rng, 25% hp | -| Aggressive | +3 | +0 | 75% rng, 25% hp | -| Defensive | +0 | +3 | 75% rng, 25% hp | -| Balanced | +1 | +1 | 75% rng, 25% hp | - -### 14.3 Ranged Max Hit Formula - -``` -effective_ranged = Ranged_level + style_bonus + 8 -max_hit = (effective_ranged * (total_ranged_strength + 64)) / 512 -``` - -The `ranged_strength` comes primarily from ammo (arrows/bolts). Some equipment might also add ranged_strength in the future. - ---- - -## 15. Science Combat Integration - -Science (magic) combat is covered in a separate plan (`science.md`). Key integration points: - -- `attack_type: "science"` — uses Science level + `science_attack` bonus for accuracy -- Max hit is determined by the equipped "mod" (spell/ability), NOT by equipment -- Mob defense against science uses `science_defense` -- XP: 75% Science, 25% Hitpoints -- Science-based mobs (like drones) use `science` level + `science_bonus` for their attacks - -For this combat overhaul, the struct fields (`ScienceAttack`, `ScienceDefense`, `ScienceDamage`) are added but the science combat flow implementation is deferred. - ---- - -## 16. Backward Compatibility - -### Approach: Full YAML rewrite (recommended) - -All item YAML files are updated to use the new field names. The old `attack_bonus`, `defense_bonus`, and `attack` YAML tags are simply removed from the Go struct. Since `gopkg.in/yaml.v3` silently ignores unknown fields during unmarshalling, any YAML files accidentally left with old tags will simply have those values ignored (zeroed). - -**No custom `UnmarshalYAML`** is needed because: -1. We control all the YAML files — they're in the repo under `data/` -2. Player data (accounts/characters) doesn't store `ItemStats` — equipment is stored as item IDs -3. There's no external API consuming these fields - -**Migration checklist:** -- [x] All `attack_bonus: N` → replaced by `stab_attack`/`slash_attack`/`crush_attack` per weapon type -- [x] All `defense_bonus: N` → replaced by per-type defense fields -- [x] All `attack: N` (bows/arrows/bolts) → replaced by `ranged_attack` or `ranged_strength` -- [x] All `strength_bonus: N` → kept as-is (same YAML tag) - -### What if old-format items are loaded? - -If someone has a custom item YAML with the old format, all combat stats will be 0. The item will have no combat effectiveness. This is acceptable — the items were already broken (bows used `attack:` which was silently ignored). - ---- - -## 17. Help Files - -### 17.1 Combat Help (`data/help/combat.yaml`) - -```yaml -id: combat -name: Combat -aliases: - - fighting - - fight -content: | - COMBAT - - Attack a mob with: attack <mob> - - Your accuracy depends on your attack type (stab, slash, crush, ranged, - or science) and your total equipment bonus for that type. Your weapon - determines the attack type. - - Mobs have per-type defense bonuses. A skeleton with high slash defense - but weak crush defense takes more hits from a mace than a sword. - - Use the 'style' command to change your combat style: - accurate - +3 attack level, XP to attack - aggressive - +3 strength level, XP to strength - defensive - +3 defense level, XP to defense - balanced - +1 to all, XP split evenly - - Use 'stats' to see your total equipment bonuses and current attack roll. - Use 'look <item>' to see an item's combat stats. -``` - -### 17.2 Stats Help (`data/help/stats.yaml`) - -```yaml -id: stats -name: Stats -aliases: - - bonuses - - equipment stats -content: | - STATS - - The 'stats' command shows your total equipment bonuses from all worn - items, your current weapon and attack type, and your effective attack - roll and max hit. - - Attack bonuses affect accuracy. The relevant bonus is determined by - your weapon's attack type (stab, slash, crush, ranged, or science). - - Defense bonuses reduce incoming damage. When a mob attacks you, the - game uses your defense bonus matching the mob's attack type. - - Strength bonus increases your max melee hit. - Ranged strength increases your max ranged hit. - Science damage increases your max science hit. - Technology bonus will affect technology abilities (future). -``` - -### 17.3 Style Help (`data/help/style.yaml`) - -If this file already exists, update it. If not, create it: - -```yaml -id: style -name: Style -aliases: - - combat style - - attack style -content: | - STYLE - - Usage: style <name> - - Changes your combat style. Prefix matching is supported. - - Styles: - accurate - +3 to attack level, XP goes to Attack - aggressive - +3 to strength level, XP goes to Strength - defensive - +3 to defense level, XP goes to Defense - balanced - +1 to all three, XP split evenly - - For ranged weapons: - accurate - +3 to ranged level - aggressive - +3 to ranged level (same as accurate for ranged) - defensive - +3 to defense level - balanced - +1 to ranged and defense - - Your style does NOT change your attack type. The attack type is - determined by your weapon (e.g., swords use slash, daggers use stab). - - Use 'stats' to see how your current style affects your attack roll. -``` - ---- - -## 18. Implementation Order - -Step-by-step checklist. Each step should compile and pass `make vet` before moving to the next. - -### Phase 1: Struct Changes (no behavior change yet) - -- [ ] **Step 1:** Update `ItemStats` struct in `internal/object/item.go:67-73` - - Replace the 5 fields with the 14 new fields - - Add `AttackType string` field to `ItemDef` after line 41 - -- [ ] **Step 2:** Update `MobDef` struct in `internal/world/mob.go:20-37` - - Add: `Ranged`, `Science`, `AttackBonus`, `StrengthBonus`, `RangedBonus`, `ScienceBonus`, `AttackType` - - Add: `StabDefense`, `SlashDefense`, `CrushDefense`, `ScienceDefense`, `RangedDefense` - - Add: `Weakness` - -- [ ] **Step 3:** Update `MobInstance` struct in `internal/world/mob.go:39-62` - - Mirror all new fields from MobDef - -- [ ] **Step 4:** Update `SeedMobs` in `internal/world/mob.go:213-259` - - Copy all new fields from def to instance - -- [ ] **Step 5:** Fix all compilation errors from removed `AttackBonus`/`DefenseBonus`/`ScienceBonus` fields - - `cmd_attack.go:187` — references `def.Stats.AttackBonus` → temporarily use `def.Stats.SlashAttack` - - `cmd_attack.go:188` — references `def.Stats.StrengthBonus` → OK, field still exists - - `cmd_attack.go:245` — references `def.Stats.DefenseBonus` → temporarily use sum of all defense types - - Any other references to removed fields - -- [ ] **Step 6:** Run `make vet` — should pass with no errors - -### Phase 2: Item YAML Updates - -- [ ] **Step 7:** Update all 6 sword YAMLs (Section 11.1) -- [ ] **Step 8:** Update all 6 dagger YAMLs (Section 11.2) -- [ ] **Step 9:** Update all 8 axe YAMLs (Section 11.3) -- [ ] **Step 10:** Update all 8 pickaxe YAMLs (Section 11.4) -- [ ] **Step 11:** Update all 6 med helm YAMLs (Section 11.5) -- [ ] **Step 12:** Update all 6 full helm YAMLs (Section 11.6) -- [ ] **Step 13:** Update all 6 platebody YAMLs (Section 11.7) -- [ ] **Step 14:** Update all 12 bow YAMLs (Section 11.8) -- [ ] **Step 15:** Update all 6 arrow YAMLs (Section 11.9) -- [ ] **Step 16:** Update all 10 bolt YAMLs (Section 11.10) -- [ ] **Step 17:** Update all 9 leather/ranged armor YAMLs (Section 11.11) - -### Phase 3: Mob YAML Updates - -- [ ] **Step 18:** Update all 5 existing mob YAMLs (Section 12) -- [ ] **Step 19:** Create 8 new mob YAMLs (Section 13) - -### Phase 4: Formula & Combat Logic - -- [ ] **Step 20:** Rewrite `internal/combat/formulas.go` (Section 5) - - New `HitChance` function - - Updated `HitCheck` to use probability curve - - Add `RangedStyleBonus` - - Add `SelectAttackBonus` and `SelectDefenseBonus` - -- [ ] **Step 21:** Add `playerEquipBonuses` helper (Section 8) - - New file `internal/game/equip_stats.go` or add to `cmd_attack.go` - -- [ ] **Step 22:** Update `playerAttack` in `cmd_attack.go` (Section 6.1) - - Determine attack type from weapon - - Use `playerEquipBonuses` for totals - - Use `SelectAttackBonus` for the right attack bonus - - Use `SelectDefenseBonus` for mob's matching defense - - Melee path: use Attack level + melee style bonus - - Ranged path: use Ranged level + ranged style bonus + ammo consumption - -- [ ] **Step 23:** Update `mobAttack` in `cmd_attack.go` (Section 6.2) - - Use mob's `AttackType` (default "crush") - - Use mob's `AttackBonus` in attack roll - - Use mob's `StrengthBonus` in max hit - - Use player's matching defense bonus for the mob's attack type - -- [ ] **Step 24:** Update `awardCombatXP` (Section 6.3) - - Add `isRanged` parameter - - Ranged path: 75% Ranged, 25% Hitpoints - -- [ ] **Step 25:** Update `mobCombatLevel` in `utils.go` (Section 7.4) - - New formula accounting for Ranged/Science levels - -- [ ] **Step 26:** Add ammo check in `doAttack` (Section 6.4) - -- [ ] **Step 27:** Run `make vet` and `make test` - -### Phase 5: Display Changes - -- [ ] **Step 28:** Add `showItemStats` helper to `cmd_look.go` (Section 9.1) -- [ ] **Step 29:** Call `showItemStats` in item examination paths in `doLookTarget` -- [ ] **Step 30:** Update mob examination in `doLookTarget` (Section 9.2) -- [ ] **Step 31:** Create `cmd_stats.go` with `doStats` command (Section 10) -- [ ] **Step 32:** Register `stats` command in `classifyCommand` and `executeCommand` in `game.go` -- [ ] **Step 33:** Create help YAML files (Section 17) - -### Phase 6: Final Verification - -- [ ] **Step 34:** Run `make build` -- [ ] **Step 35:** Run `make test` -- [ ] **Step 36:** Run `make vet` -- [ ] **Step 37:** Manual testing: equip weapons, check `stats`, `look <item>`, attack mobs, verify damage/accuracy feel reasonable -- [ ] **Step 38:** Verify ranged combat works with ammo consumption -- [ ] **Step 39:** Verify `look <mob>` shows defense bonuses - ---- - -## Appendix A: Complete Item Stats Reference Table - -### Melee Weapons - -| Item | Type | Stab | Slash | Crush | Str | Speed | -|------|------|------|-------|-------|-----|-------| -| bronze_dagger | stab | +5 | +3 | -1 | +4 | 3 | -| iron_dagger | stab | +8 | +5 | -1 | +7 | 3 | -| steel_dagger | stab | +12 | +7 | -1 | +10 | 3 | -| mithril_dagger | stab | +17 | +10 | -1 | +14 | 3 | -| adamant_dagger | stab | +23 | +14 | -1 | +19 | 3 | -| rune_dagger | stab | +30 | +18 | -1 | +24 | 3 | -| bronze_sword | slash | +4 | +10 | -2 | +7 | 4 | -| iron_sword | slash | +7 | +15 | -2 | +10 | 4 | -| steel_sword | slash | +10 | +22 | -2 | +14 | 4 | -| mithril_sword | slash | +14 | +30 | -2 | +20 | 4 | -| adamant_sword | slash | +20 | +40 | -2 | +27 | 4 | -| rune_sword | slash | +27 | +52 | -2 | +36 | 4 | -| bronze_axe | slash | -2 | +7 | +5 | +5 | 5 | -| iron_axe | slash | -2 | +10 | +7 | +8 | 5 | -| steel_axe | slash | -2 | +15 | +11 | +12 | 5 | -| black_axe | slash | -2 | +18 | +14 | +14 | 5 | -| mithril_axe | slash | -2 | +21 | +16 | +17 | 5 | -| adamant_axe | slash | -2 | +29 | +22 | +24 | 5 | -| rune_axe | slash | -2 | +38 | +29 | +32 | 5 | -| dragon_axe | slash | -2 | +43 | +32 | +36 | 5 | -| bronze_pickaxe | stab | +4 | -2 | +2 | +3 | 5 | -| iron_pickaxe | stab | +7 | -2 | +3 | +5 | 5 | -| steel_pickaxe | stab | +10 | -2 | +4 | +7 | 5 | -| black_pickaxe | stab | +14 | -2 | +6 | +9 | 5 | -| mithril_pickaxe | stab | +17 | -2 | +8 | +12 | 5 | -| adamant_pickaxe | stab | +23 | -2 | +10 | +16 | 5 | -| rune_pickaxe | stab | +30 | -2 | +14 | +22 | 5 | -| dragon_pickaxe | stab | +34 | -2 | +16 | +26 | 5 | - -### Metal Armor (Defense Bonuses) - -| Item | Slot | Stab | Slash | Crush | Science | Ranged | -|------|------|------|-------|-------|---------|--------| -| bronze_med_helm | head | +3 | +4 | +2 | -1 | +3 | -| iron_med_helm | head | +5 | +6 | +3 | -1 | +5 | -| steel_med_helm | head | +7 | +9 | +5 | -2 | +7 | -| mithril_med_helm | head | +10 | +12 | +7 | -3 | +10 | -| adamant_med_helm | head | +14 | +16 | +10 | -3 | +14 | -| rune_med_helm | head | +19 | +22 | +13 | -4 | +19 | -| bronze_full_helm | head | +5 | +6 | +4 | -3 | +5 | -| iron_full_helm | head | +8 | +9 | +6 | -3 | +8 | -| steel_full_helm | head | +12 | +13 | +9 | -5 | +12 | -| mithril_full_helm | head | +16 | +18 | +12 | -6 | +16 | -| adamant_full_helm | head | +22 | +24 | +16 | -8 | +22 | -| rune_full_helm | head | +30 | +33 | +22 | -11 | +30 | -| bronze_platebody | torso | +12 | +15 | +10 | -10 | +12 | -| iron_platebody | torso | +18 | +22 | +14 | -10 | +18 | -| steel_platebody | torso | +27 | +32 | +22 | -15 | +27 | -| mithril_platebody | torso | +36 | +42 | +30 | -20 | +36 | -| adamant_platebody | torso | +49 | +55 | +40 | -25 | +49 | -| rune_platebody | torso | +65 | +72 | +52 | -30 | +65 | - -### Leather/Ranged Armor (Defense Bonuses) - -| Item | Slot | Stab | Slash | Crush | Science | Ranged | Other | -|------|------|------|-------|-------|---------|--------|-------| -| leather_cowl | head | +1 | +2 | +1 | +2 | +4 | | -| leather_body | torso | +4 | +7 | +5 | +4 | +10 | | -| leather_chaps | legs | +2 | +4 | +3 | +3 | +6 | | -| leather_gloves | hands | +1 | +1 | +1 | +1 | +2 | | -| leather_vambraces | hands | +2 | +2 | +2 | +2 | +4 | ranged_attack: +4 | -| leather_boots | feet | +1 | +1 | +1 | +1 | +2 | | -| coif | head | +2 | +4 | +2 | +4 | +6 | ranged_attack: +2 | -| hard_leather_body | torso | +8 | +12 | +8 | +6 | +14 | | -| hard_leather_shield | off_hand | +5 | +7 | +5 | +4 | +8 | | - -### Ranged Weapons (Attack Bonuses) - -| Item | Ranged Attack | Ranged Str | Speed | -|------|--------------|------------|-------| -| shortbow | +8 | 0 | 4 | -| oak_shortbow | +14 | 0 | 4 | -| willow_shortbow | +20 | 0 | 4 | -| maple_shortbow | +29 | 0 | 4 | -| yew_shortbow | +47 | 0 | 4 | -| magic_shortbow | +69 | 0 | 4 | -| longbow | +8 | 0 | 6 | -| oak_longbow | +14 | 0 | 6 | -| willow_longbow | +20 | 0 | 6 | -| maple_longbow | +29 | 0 | 6 | -| yew_longbow | +47 | 0 | 6 | -| magic_longbow | +69 | 0 | 6 | - -### Ranged Ammo (Ranged Strength) - -| Item | Ranged Str | -|------|-----------| -| bronze_arrow | +7 | -| iron_arrow | +10 | -| steel_arrow | +16 | -| mithril_arrow | +22 | -| adamant_arrow | +31 | -| rune_arrow | +49 | -| bronze_bolts | +10 | -| iron_bolts | +17 | -| steel_bolts | +25 | -| mithril_bolts | +32 | -| adamant_bolts | +43 | -| rune_bolts | +55 | -| sapphire_bolts | +36 | -| emerald_bolts | +39 | -| ruby_bolts | +49 | -| diamond_bolts | +52 | - ---- - -## Appendix B: OSRS Accuracy Formula Explanation - -The true OSRS accuracy formula is: - -``` -if attack_roll > defense_roll: - hit_chance = 1 - (defense_roll + 2) / (2 * (attack_roll + 1)) -else: - hit_chance = attack_roll / (2 * (defense_roll + 1)) -``` - -This produces a smooth curve rather than the current binary hit/miss: -- Equal rolls → ~50% hit chance -- 2x attack vs defense → ~75% hit chance -- 0.5x attack vs defense → ~25% hit chance -- Very high attack → approaches ~100% but never reaches it -- Very low attack → approaches ~0% but never reaches it - -This is more balanced than the current system where `attackRoll > defenseRoll` is always a hit and `<` is always a miss. - ---- - -## Appendix C: Files Modified (Summary) - -| File | Changes | -|------|---------| -| `internal/object/item.go` | `ItemStats` struct rewrite (14 fields), `AttackType` field on `ItemDef` | -| `internal/world/mob.go` | New fields on `MobDef`, `MobInstance`, `SeedMobs` propagation | -| `internal/combat/formulas.go` | Full rewrite: `HitChance`, `HitCheck`, `RangedStyleBonus`, `SelectAttackBonus`, `SelectDefenseBonus` | -| `internal/game/cmd_attack.go` | `playerAttack` rewrite, `mobAttack` rewrite, `awardCombatXP` ranged support, ammo consumption | -| `internal/game/equip_stats.go` | New file: `playerEquipBonuses` helper | -| `internal/game/cmd_stats.go` | New file: `doStats` command | -| `internal/game/cmd_look.go` | `showItemStats` helper, item/mob examination updates | -| `internal/game/game.go` | Register `stats` in `classifyCommand` and `executeCommand` | -| `internal/game/utils.go` | Updated `mobCombatLevel` formula | -| `data/items/*.yaml` | ~66 item files updated with per-type stats | -| `data/mobs/*.yaml` | 5 existing mobs updated, 8 new mobs added | -| `data/help/combat.yaml` | New help file | -| `data/help/stats.yaml` | New help file | -| `data/help/style.yaml` | New/updated help file | diff --git a/skill_plans/construction.md b/skill_plans/construction.md deleted file mode 100644 index 17a1533..0000000 --- a/skill_plans/construction.md +++ /dev/null @@ -1,84 +0,0 @@ -# Construction Skill Plan - -## Overview - -Construction is a production skill centered around processing logs into planks and assembling furniture. Players gain XP through repetitive crafting at a workbench, with higher-tier logs providing more XP and more valuable outputs. - -## Core Loop - -1. **Gather logs** via Woodcutting -2. **Process logs into planks** at a workbench (with a saw) or at the sawmill (for credits) or via Plank Make (science mod) -3. **Assemble planks into furniture** at a workbench -4. **Sell furniture** for credits (furniture has higher value than input planks) - -## Architecture - -### Production System Integration - -Construction plugs into the existing unified production system. Recipes use `type: construction`, `station: [workbench]`, and optionally `tool: saw`. The `advanceProduction` function handles the full lifecycle without any construction-specific code. - -### Recipe Tiers - -**Plank recipes** (4 tiers): -- Regular (Lv1): logs → planks (20 ticks, 5xp) -- Oak (Lv15): oak_logs → oak_planks (20 ticks, 10xp) -- Teak (Lv35): teak_logs → teak_planks (20 ticks, 20xp) -- Mahogany (Lv50): mahogany_logs → mahogany_planks (20 ticks, 30xp) - -**Furniture recipes** (7 items across 4 tiers): -- Wooden shelf (Lv4), table (Lv6), chair (Lv8) -- Oak shelf (Lv18), table (Lv20) -- Teak table (Lv38) -- Mahogany table (Lv52) - -### Alternative Plank Sources - -1. **Sawmill operator** (room 171): Pays credits per log to get planks instantly. Fees: 5/10/20/40 credits. Uses the `sawmill` node action — a custom talk node action that process all logs at once. - -2. **Plank Make mod** (Science Lv86): Converts logs to planks at 70% of sawmill cost (3/7/14/28 credits) plus junk cost. Awards both Science XP and Construction XP. - -## Player Housing - -Houses serve as a private space with a workbench and bank booth. Future expansions will allow adding rooms, furniture placement, and multi-city ownership. - -### House Purchase -- Talk to the estate broker at the Construction Site (room 16) -- Cost: 10 credits -- Sets player flag: `owns_house_local_neighborhood: "local_neighborhood"` - -### House Access -- Walk north from Construction Site to Local Neighborhood (room 170) -- Use or talk to the Estate Directory -- Enter your house via the directory - -### House Rooms -- Room 200: Entrance Hall (charging station) -- Room 201: Workshop (workbench + bank booth) - -Rooms are real YAML files in `data/rooms/player_housing/`. The room loader checks this directory as fallback after the main rooms directory. - -### Future: Multi-House Support - -Each housing area gets a unique player flag: -- `owns_house_local_neighborhood: "local_neighborhood"` → rooms 200-201 -- `owns_house_industrial_district: "industrial_district"` → rooms 210-211 (future) -- etc. - -The `getOwnedHouseRoom` function maps flag values to room IDs. The directory scans for area-specific flags. - -## Files - -| File | Purpose | -|------|---------| -| `internal/game/action_production.go` | Added `"construction"` to productionTypes | -| `internal/game/cmd_construct.go` | `doConstruct` command handler | -| `internal/game/cmd_estate_directory.go` | Directory handling + teleport | -| `internal/game/action_talk.go` | Added sawmill node action + `processSawmill` | -| `internal/game/action.go` | Added directory interception in StartAction | -| `internal/game/cmd_look.go` | Added directory handling in doLookTarget | -| `internal/game/science.go` | Added Plank Make mod definition | -| `internal/game/cmd_trigger.go` | Added `triggerPlankMake` handler | -| `internal/game/game.go` | Added `construct`/`make` to command dispatch | -| `internal/action/behavior.go` | Added `Sawmill` field to NodeAction | -| `internal/world/world.go` | Extended LoadRoom to support player_housing dir | -| `internal/player/player.go` | Added `construct_all` option | diff --git a/skill_plans/farming.md b/skill_plans/farming.md deleted file mode 100644 index 689f9ec..0000000 --- a/skill_plans/farming.md +++ /dev/null @@ -1,2123 +0,0 @@ -# Farming Skill Implementation Plan - -## 1. Overview - -Farming allows players to plant seeds in farming patches, water them, wait for them to grow through multiple stages, and harvest the results. It is a long-cycle skill: seeds take minutes to grow, with periodic growth ticks advancing them through stages. Disease can strike at each growth stage; watering eliminates that risk. Dead plants must be cleared with a rake before replanting. - -The system is simpler than RuneScape: only three tools (rake, spade, watering can), no compost system, and growth only ticks for online players. - -**Patch types:** herb patches, allotment patches, flower patches, bush patches, tree patches. - -**Core loop:** rake weeds -> plant seed (requires spade) -> water (optional but prevents disease) -> wait for growth -> harvest (requires spade) -> repeat. - -**Sci-fi flavor:** Seeds are "bio-engineered seeds," patches are "hydroponic plots," the tool shed is a "supply locker," watering can is a "hydration unit." But mechanically they work identically to RS farming. - ---- - -## 2. Architecture - -### Per-Player Farming State via Player Flags - -Each farming patch is a world object (e.g., `herb_patch`) placed in a room. However, each player sees their **own** state for that patch. This is achieved using **player flags** (`p.Flags`), not world state. - -**Flag naming convention:** - -``` -farm_{patch_type}_{patch_index}_{field} -``` - -**Fields per patch:** - -| Flag Key | Type | Description | -|---|---|---| -| `farm_herb_1_seed` | `string` | Seed ID planted (e.g., `"guam_seed"`), empty if unplanted | -| `farm_herb_1_stage` | `int` | Current growth stage (0 = just planted, N = fully grown) | -| `farm_herb_1_watered` | `bool` | Whether current stage has been watered | -| `farm_herb_1_diseased` | `bool` | Whether plant is currently diseased | -| `farm_herb_1_dead` | `bool` | Whether plant has died (must rake) | -| `farm_herb_1_weeds` | `bool` | Whether patch has weeds (must rake before planting) | -| `farm_herb_1_ready` | `bool` | Whether crop is fully grown and ready to harvest | - -**Patch index mapping:** Each physical patch object in a room corresponds to a unique patch index. The index is determined by the object's position in the room's `objects` list combined with a patch type prefix. For example, room 150 might have: - -```yaml -objects: - - id: herb_patch # farm_herb_1 - - id: herb_patch # farm_herb_2 - - id: allotment_patch # farm_allot_1 -``` - -The mapping from object instance to flag prefix is derived at runtime: -- Object def ID `herb_patch` with index 0 in room -> flag prefix `farm_herb_1` -- Object def ID `herb_patch` with index 1 in room -> flag prefix `farm_herb_2` -- Object def ID `allotment_patch` with index 0 in room -> flag prefix `farm_allot_1` - -**Helper function** `farmFlagPrefix(defID string, index int) string`: -```go -func farmFlagPrefix(defID string, index int) string { - switch defID { - case "herb_patch": - return fmt.Sprintf("farm_herb_%d", index+1) - case "allotment_patch": - return fmt.Sprintf("farm_allot_%d", index+1) - case "flower_patch": - return fmt.Sprintf("farm_flower_%d", index+1) - case "bush_patch": - return fmt.Sprintf("farm_bush_%d", index+1) - case "tree_patch": - return fmt.Sprintf("farm_tree_%d", index+1) - } - return "" -} -``` - -**Why player flags?** -- World flags are shared by all players. Farming patches must be per-player. -- Player flags are already persisted to character YAML automatically via `AccountStore.SaveCharacter()`. -- No new data structures or serialization code needed. -- The `p.Flags` map is `map[string]any` and supports string, int, bool, and float values natively via YAML serialization. - -### Patch State Initialization - -When a player first interacts with a farming patch (via `inspect`, `plant`, `rake`, etc.), if no flags exist for that patch, initialize it with weeds: - -```go -func (g *Game) ensureFarmState(p *player.Player, prefix string) { - if p.Flags == nil { - p.Flags = make(map[string]any) - } - if _, exists := p.Flags[prefix+"_seed"]; !exists { - p.Flags[prefix+"_weeds"] = true - p.Flags[prefix+"_seed"] = "" - p.Flags[prefix+"_stage"] = 0 - p.Flags[prefix+"_watered"] = false - p.Flags[prefix+"_diseased"] = false - p.Flags[prefix+"_dead"] = false - p.Flags[prefix+"_ready"] = false - } -} -``` - -### Seed Data Lookup - -Each seed item has farming-specific fields. Since `ItemDef` in `internal/object/item.go` is the canonical item definition, we add new fields to `ItemDef`: - -```go -// New fields added to ItemDef struct in internal/object/item.go -FarmPatchType string `yaml:"farm_patch_type"` // "herb", "allotment", "flower", "bush", "tree" -FarmLevel int `yaml:"farm_level"` // Required farming level to plant -FarmPlantXP int `yaml:"farm_plant_xp"` // XP for planting -FarmHarvestXP int `yaml:"farm_harvest_xp"` // XP per harvest action -FarmStages int `yaml:"farm_stages"` // Number of growth stages -FarmProduct string `yaml:"farm_product"` // Item ID produced on harvest -FarmMinYield int `yaml:"farm_min_yield"` // Minimum harvest quantity -FarmMaxYield int `yaml:"farm_max_yield"` // Maximum harvest quantity -``` - -This keeps the data-driven pattern: seed behavior is defined in YAML, not hardcoded. - ---- - -## 3. Growth Tick - -### `FarmTick()` in `internal/game/tick.go` - -A new tick function that runs on a **counter-based schedule** rather than every tick. Growth is checked every 500 ticks (approximately 5 minutes at 600ms tick rate). - -**Implementation:** - -Add a counter field to `Game`: - -```go -// In Game struct (game.go) -farmTickCounter int -``` - -Add `FarmTick()` to `tick.go`: - -```go -const FarmTickInterval = 500 // ticks between farm growth checks (~5 minutes) - -func (g *Game) FarmTick() { - g.farmTickCounter++ - if g.farmTickCounter < FarmTickInterval { - return - } - g.farmTickCounter = 0 - - if g.Hub == nil { - return - } - - for _, sess := range g.Hub.AllSessions() { - p, ok := sess.Player.(*player.Player) - if !ok || p == nil || p.Flags == nil { - continue - } - g.advanceFarmGrowth(sess, p) - } -} -``` - -### `advanceFarmGrowth()` in `internal/game/action_farm.go` - -```go -func (g *Game) advanceFarmGrowth(sess *net.Session, p *player.Player) { - // Scan all farming flag prefixes in player flags - prefixes := g.findActiveFarmPrefixes(p) - for _, prefix := range prefixes { - seedID, _ := p.Flags[prefix+"_seed"].(string) - if seedID == "" { - continue - } - dead, _ := p.Flags[prefix+"_dead"].(bool) - if dead { - continue - } - diseased, _ := p.Flags[prefix+"_diseased"].(bool) - ready, _ := p.Flags[prefix+"_ready"].(bool) - if ready { - continue - } - - // If diseased and not cured, plant dies - if diseased { - p.Flags[prefix+"_dead"] = true - p.Flags[prefix+"_diseased"] = false - sess.WriteLine(g.colorize(sess, "farm_disease", - fmt.Sprintf("\nYour %s has died from disease!", seedDisplayName(g, seedID)))) - g.AccountStore.SaveCharacter(p) - continue - } - - // Advance growth stage - stage, _ := p.Flags[prefix+"_stage"].(int) - watered, _ := p.Flags[prefix+"_watered"].(bool) - - seedDef, err := g.ItemStore.Load(seedID) - if err != nil { - continue - } - - maxStages := seedDef.FarmStages - if maxStages <= 0 { - maxStages = 4 - } - - stage++ - - if stage >= maxStages { - // Fully grown! - p.Flags[prefix+"_stage"] = stage - p.Flags[prefix+"_ready"] = true - p.Flags[prefix+"_watered"] = false - sess.WriteLine(g.colorize(sess, "farm_grow", - fmt.Sprintf("\nYour %s is fully grown and ready to harvest!", - seedDisplayName(g, seedID)))) - } else { - // Disease check (10% chance if not watered) - if !watered && rand.Float64() < 0.10 { - p.Flags[prefix+"_stage"] = stage - p.Flags[prefix+"_diseased"] = true - p.Flags[prefix+"_watered"] = false - sess.WriteLine(g.colorize(sess, "farm_disease", - fmt.Sprintf("\nYour %s has become diseased!", - seedDisplayName(g, seedID)))) - } else { - p.Flags[prefix+"_stage"] = stage - p.Flags[prefix+"_watered"] = false // Reset watered for next stage - sess.WriteLine(g.colorize(sess, "farm_grow", - fmt.Sprintf("\nYour %s has grown to stage %d/%d.", - seedDisplayName(g, seedID), stage, maxStages))) - } - } - g.AccountStore.SaveCharacter(p) - } -} -``` - -### `findActiveFarmPrefixes()` - -Scans `p.Flags` to find all unique farm prefixes that have a planted seed: - -```go -func (g *Game) findActiveFarmPrefixes(p *player.Player) []string { - seen := make(map[string]bool) - var prefixes []string - for key := range p.Flags { - if !strings.HasPrefix(key, "farm_") { - continue - } - if !strings.HasSuffix(key, "_seed") { - continue - } - prefix := strings.TrimSuffix(key, "_seed") - if !seen[prefix] { - seen[prefix] = true - if seedID, ok := p.Flags[key].(string); ok && seedID != "" { - prefixes = append(prefixes, prefix) - } - } - } - sort.Strings(prefixes) - return prefixes -} -``` - -### Subscribe in main.go - -Add `g.FarmTick()` to the tick subscription in `cmd/mud/main.go`: - -```go -g.Ticks.Subscribe(1, func() bool { - g.MoveTick() - g.ProcessQueuedCommands() - g.World.Tick() - g.MobStore.Tick() - g.RegenTick() - g.DisconnectTick() - g.WanderTick() - g.SharedDepletionTick() - g.FireTick() - g.AdvanceActions() - g.ConsumeTick() - g.BroadcastRespawns() - g.VisualTick() - g.FarmTick() // <-- ADD THIS - return true -}) -``` - -### Design Decision: Online-Only Growth - -Growth only advances for online players. When a player logs off, their crops freeze in place. This is intentional: -- Keeps implementation simple (no background timers) -- Players don't return to find everything dead -- Matches the "live state" philosophy of the codebase - ---- - -## 4. Commands - -### Command Summary - -| Command | Class | Description | -|---|---|---| -| `plant <seed>` | Active | Plant a seed in the appropriate patch in the current room | -| `harvest [patch]` | Active | Harvest a fully grown crop from a patch | -| `rake [patch]` | Active | Clear weeds or dead plants from a patch | -| `water [patch]` | Active | Water a patch with a watering can | -| `cure [patch]` | Active | Use plant cure on a diseased patch | -| `inspect [patch]` | Instant | Check the status of farming patches in the room | - -### 4.1 `plant <seed>` (Active) - -**Classification:** Add `"plant"` to the Active case in `classifyCommand()`. - -**Dispatch:** Add case in `executeCommand()`: -```go -case "plant": - g.CancelAction(p) - if len(args) == 0 { - sess.WriteLine("Plant what?") - } else { - g.doPlant(sess, strings.Join(args, " ")) - } - return -``` - -**Handler: `doPlant()`** in `cmd_farm.go`: - -1. Find the seed item in player inventory by name match (`findInventoryMatches`). -2. Load the seed's `ItemDef`. Check `FarmPatchType` is set — if not, "You can't plant that." -3. Check farming level: `p.Level(player.Farming) >= seedDef.FarmLevel` — if not, "You need level N farming to plant that." -4. Check the player has a spade: scan inventory and equipment for `tool_type: "spade"`. If not found, "You need a spade to plant seeds." -5. Find a matching patch object in the room: scan `g.World.FindObjInstances(p.RoomID, seedDef.FarmPatchType+"_patch")`. -6. If no matching patch in room, "There's no suitable patch here to plant that." -7. If multiple patches, find the first one that is clear (no weeds, not planted, not dead) using the player's flags for each patch. -8. If no clear patch, "All patches here have something in them. Rake them first." or "All patches are occupied." -9. Determine the flag prefix via `farmFlagPrefix(patchDefID, patchIndex)`. -10. Ensure farm state is initialized. -11. Check weeds: if `prefix_weeds == true`, "You need to rake the weeds first." -12. Check already planted: if `prefix_seed != ""`, "Something is already planted here." -13. Remove 1 seed from inventory. -14. Set flags: `prefix_seed = seedID`, `prefix_stage = 0`, `prefix_watered = false`, `prefix_diseased = false`, `prefix_dead = false`, `prefix_ready = false`. -15. Award planting XP: `p.AddSkillXP(player.Farming, seedDef.FarmPlantXP)`. -16. Save character. -17. Output: "You plant a guam seed in the herb patch." -18. Set ActionState: `&ActionState{Type: ActionPlanting, TargetName: seedDef.Name}`. -19. Set Action with WaitLeft of 3 ticks (planting takes a moment). - -**Alternative simpler approach:** Since planting is conceptually instant (just set flags and remove seed), it can be implemented as a direct command handler without a multi-tick action. This matches how `burn` phase 0 works. However, to match the spec request for Active classification, use a 2-tick action: - -```go -p.Action = &action.Action{ - Type: "plant", - TargetID: seedID, - TargetName: seedDef.Name, - WaitLeft: 2, - Data: map[string]any{ - "seed_id": seedID, - "prefix": prefix, - "xp": seedDef.FarmPlantXP, - }, -} -p.ActionState = &ActionState{Type: ActionPlanting, TargetName: seedDef.Name} -``` - -Then in `advancePlant()`, do the actual flag-setting and item removal. - -### 4.2 `harvest [patch]` (Active) - -**Classification:** Add `"harvest"` to Active case in `classifyCommand()`. - -**Dispatch:** Add case in `executeCommand()`: -```go -case "harvest": - g.CancelAction(p) - if len(args) == 0 { - g.doHarvest(sess, "") - } else { - g.doHarvest(sess, strings.Join(args, " ")) - } - return -``` - -**Handler: `doHarvest()`** in `cmd_farm.go`: - -1. Find farming patches in the current room. -2. If `input != ""`, match against patch names (e.g., "herb", "allotment"). Support numbered targeting: `1.herb`. -3. If `input == ""`, find the first patch that is ready to harvest (smart default). -4. For the chosen patch, get flag prefix and check `prefix_ready == true`. -5. If not ready: "There's nothing ready to harvest here." -6. Check player has spade: "You need a spade to harvest." -7. Check inventory space: need at least 1 free slot. -8. Load seed def to get `FarmProduct`, `FarmMinYield`, `FarmMaxYield`, `FarmHarvestXP`. -9. Start harvest action (3-tick duration): - -```go -p.Action = &action.Action{ - Type: "harvest", - TargetID: prefix, - TargetName: productName, - WaitLeft: 3, - Data: map[string]any{ - "prefix": prefix, - "seed_id": seedID, - "product": seedDef.FarmProduct, - "min_yield": seedDef.FarmMinYield, - "max_yield": seedDef.FarmMaxYield, - "xp": seedDef.FarmHarvestXP, - }, -} -p.ActionState = &ActionState{Type: ActionHarvesting, TargetName: "crops"} -``` - -10. In `advanceHarvest()`: - - Calculate yield: `minYield + rand.Intn(maxYield - minYield + 1)`. Bonus: `yield += farmingLevel / 20` (higher farming = slightly better yields). - - Cap yield by available inventory slots. - - Add items to inventory (stackable items merge, non-stackable use 1 slot each). - - Award XP: `harvestXP * yield`. - - Clear patch flags: set `prefix_seed = ""`, `prefix_stage = 0`, `prefix_ready = false`, `prefix_weeds = true` (weeds return after harvest). - - Save character. - - Output: "You harvest 7 guam leaves from the herb patch." with XP drop. - -### 4.3 `rake [patch]` (Active) - -**Classification:** Add `"rake"` to Active case in `classifyCommand()`. - -**Dispatch:** -```go -case "rake": - g.CancelAction(p) - if len(args) == 0 { - g.doRake(sess, "") - } else { - g.doRake(sess, strings.Join(args, " ")) - } - return -``` - -**Handler: `doRake()`** in `cmd_farm.go`: - -1. Check player has a rake (tool_type "rake") in inventory or equipment. -2. Find farming patches in room. If input given, match; otherwise find first patch with weeds or dead plants. -3. Get flag prefix. Check `prefix_weeds == true` or `prefix_dead == true`. -4. If neither: "The patch doesn't need raking." -5. Start rake action (4-tick duration): - -```go -p.Action = &action.Action{ - Type: "rake", - TargetID: prefix, - TargetName: patchName, - WaitLeft: 4, - Data: map[string]any{ - "prefix": prefix, - }, -} -p.ActionState = &ActionState{Type: ActionRaking, TargetName: patchName} -``` - -6. In `advanceRake()`: - - If was dead: clear all flags (`prefix_seed = ""`, `prefix_dead = false`, `prefix_stage = 0`, `prefix_weeds = true`). Then set weeds false (raking clears both dead AND weeds in one go). - - Actually: set `prefix_weeds = false`, `prefix_dead = false`, `prefix_seed = ""`, `prefix_stage = 0`, `prefix_ready = false`, `prefix_diseased = false`. - - Raking gives **no farming XP** (as specified). - - Save character. - - Output: "You rake the patch clean." - -### 4.4 `water [patch]` (Active) - -**Classification:** Add `"water"` to Active case in `classifyCommand()`. - -**Dispatch:** -```go -case "water": - g.CancelAction(p) - if len(args) == 0 { - g.doWater(sess, "") - } else { - g.doWater(sess, strings.Join(args, " ")) - } - return -``` - -**Handler: `doWater()`** in `cmd_farm.go`: - -1. Check player has a watering can (tool_type "watering_can") in inventory or equipment. -2. Find farming patches in room. Match input or find first unwatered planted patch. -3. Get flag prefix. Check there is a seed planted and it's not dead/ready. -4. If `prefix_watered == true`: "The patch is already watered." -5. If `prefix_ready == true`: "The crop is already fully grown." -6. If no seed: "There's nothing planted here to water." -7. Start water action (2-tick duration, fast): - -```go -p.Action = &action.Action{ - Type: "water", - TargetID: prefix, - TargetName: patchName, - WaitLeft: 2, - Data: map[string]any{ - "prefix": prefix, - }, -} -p.ActionState = &ActionState{Type: ActionWatering, TargetName: patchName} -``` - -8. In `advanceWater()`: - - Set `prefix_watered = true`. - - Save character. - - Output: "You water the herb patch." - -### 4.5 `cure [patch]` (Active) - -**Classification:** Add `"cure"` to Active case in `classifyCommand()`. - -**Dispatch:** -```go -case "cure": - g.CancelAction(p) - if len(args) == 0 { - g.doCure(sess, "") - } else { - g.doCure(sess, strings.Join(args, " ")) - } - return -``` - -**Handler: `doCure()`** in `cmd_farm.go`: - -1. Check player has `plant_cure` item in inventory. -2. Find farming patches in room. Match input or find first diseased patch. -3. Get flag prefix. Check `prefix_diseased == true`. -4. If not diseased: "The patch isn't diseased." -5. Start cure action (2-tick duration): - -```go -p.Action = &action.Action{ - Type: "cure", - TargetID: prefix, - TargetName: patchName, - WaitLeft: 2, - Data: map[string]any{ - "prefix": prefix, - }, -} -p.ActionState = &ActionState{Type: ActionCuring, TargetName: patchName} -``` - -6. In `advanceCure()`: - - Remove 1 `plant_cure` from inventory. - - Set `prefix_diseased = false`. - - Save character. - - Output: "You apply the plant cure. The patch looks healthy again." - -### 4.6 `inspect [patch]` (Instant) - -**Classification:** Add `"inspect"` to Instant case in `classifyCommand()`. - -**Dispatch:** -```go -case "inspect": - if len(args) == 0 { - g.doInspect(sess, "") - } else { - g.doInspect(sess, strings.Join(args, " ")) - } -``` - -**Handler: `doInspect()`** in `cmd_farm.go`: - -1. Find all farming patch objects in the current room. -2. If none: "There are no farming patches here." -3. If input is given, filter to matching patches. -4. For each patch, load the player's flags and display: - -``` -=== Herb Patch 1 === - Status: Growing (stage 2/4) - Planted: Guam seed - Watered: Yes - Diseased: No - -=== Herb Patch 2 === - Status: Weeds - (Rake to clear before planting) - -=== Allotment Patch 1 === - Status: Ready to harvest! - Planted: Potato seed -``` - -Possible statuses: -- `Weeds` — needs raking -- `Empty` — ready to plant -- `Growing (stage N/M)` — in progress -- `Watered` — growing and watered this stage -- `Diseased!` — needs curing -- `Dead` — needs raking -- `Ready to harvest!` — fully grown - ---- - -## 5. New Files to Create - -### Go Files - -| File | Purpose | -|---|---| -| `internal/game/cmd_farm.go` | Command handlers: `doPlant()`, `doHarvest()`, `doRake()`, `doWater()`, `doCure()`, `doInspect()` | -| `internal/game/action_farm.go` | Action lifecycle: `advancePlant()`, `advanceHarvest()`, `advanceRake()`, `advanceWater()`, `advanceCure()`, `advanceFarmGrowth()`, `findActiveFarmPrefixes()`, `farmFlagPrefix()`, `ensureFarmState()`, `FarmTick()`, helper functions | - -### YAML Files - -**Items (seeds):** -- `data/items/guam_seed.yaml` -- `data/items/marrentill_seed.yaml` -- `data/items/tarromin_seed.yaml` -- `data/items/harralander_seed.yaml` -- `data/items/ranarr_seed.yaml` -- `data/items/toadflax_seed.yaml` -- `data/items/irit_seed.yaml` -- `data/items/avantoe_seed.yaml` -- `data/items/kwuarm_seed.yaml` -- `data/items/snapdragon_seed.yaml` -- `data/items/cadantine_seed.yaml` -- `data/items/lantadyme_seed.yaml` -- `data/items/dwarf_weed_seed.yaml` -- `data/items/torstol_seed.yaml` -- `data/items/potato_seed.yaml` -- `data/items/onion_seed.yaml` -- `data/items/cabbage_seed.yaml` -- `data/items/tomato_seed.yaml` -- `data/items/sweetcorn_seed.yaml` -- `data/items/strawberry_seed.yaml` -- `data/items/watermelon_seed.yaml` - -**Items (products — only if they don't already exist):** -- `data/items/guam_leaf.yaml` -- `data/items/marrentill.yaml` -- `data/items/tarromin.yaml` -- `data/items/harralander.yaml` -- `data/items/ranarr_weed.yaml` -- `data/items/toadflax.yaml` -- `data/items/irit_leaf.yaml` -- `data/items/avantoe.yaml` -- `data/items/kwuarm.yaml` -- `data/items/snapdragon.yaml` -- `data/items/cadantine.yaml` -- `data/items/lantadyme.yaml` -- `data/items/dwarf_weed.yaml` -- `data/items/torstol.yaml` -- `data/items/potato.yaml` -- `data/items/onion.yaml` -- `data/items/cabbage.yaml` -- `data/items/tomato.yaml` -- `data/items/sweetcorn.yaml` (ear_of_sweetcorn) -- `data/items/strawberry.yaml` -- `data/items/watermelon.yaml` - -**Items (tools):** -- `data/items/rake.yaml` -- `data/items/spade.yaml` -- `data/items/watering_can.yaml` -- `data/items/plant_cure.yaml` - -**Objects:** -- `data/objects/herb_patch.yaml` -- `data/objects/allotment_patch.yaml` -- `data/objects/flower_patch.yaml` -- `data/objects/tool_shed.yaml` - -**Rooms:** -- `data/rooms/150.yaml` — Farming Hub (repurpose room 15 description or create new room) -- `data/rooms/151.yaml` — Herb Garden -- `data/rooms/152.yaml` — Allotment Field - -**Help Files:** -- `data/help/plant.yaml` -- `data/help/harvest.yaml` -- `data/help/rake.yaml` -- `data/help/water.yaml` -- `data/help/cure.yaml` -- `data/help/inspect.yaml` -- `data/help/farming.yaml` - ---- - -## 6. Code Changes to Existing Files - -### `internal/object/item.go` - -Add new fields to `ItemDef` struct: - -```go -FarmPatchType string `yaml:"farm_patch_type"` -FarmLevel int `yaml:"farm_level"` -FarmPlantXP int `yaml:"farm_plant_xp"` -FarmHarvestXP int `yaml:"farm_harvest_xp"` -FarmStages int `yaml:"farm_stages"` -FarmProduct string `yaml:"farm_product"` -FarmMinYield int `yaml:"farm_min_yield"` -FarmMaxYield int `yaml:"farm_max_yield"` -``` - -### `internal/game/game.go` - -**In `classifyCommand()`:** - -Add to Instant 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", "inspect": // <-- ADD inspect -``` - -Add to Active 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", - "plant", "harvest", "rake", "water", "cure": // <-- ADD THESE -``` - -**In `executeCommand()`:** - -Add cases for each farming command: -```go -case "plant": - g.CancelAction(p) - if len(args) == 0 { - sess.WriteLine("Plant what?") - } else { - g.doPlant(sess, strings.Join(args, " ")) - } - return -case "harvest": - g.CancelAction(p) - if len(args) == 0 { - g.doHarvest(sess, "") - } else { - g.doHarvest(sess, strings.Join(args, " ")) - } - return -case "rake": - g.CancelAction(p) - if len(args) == 0 { - g.doRake(sess, "") - } else { - g.doRake(sess, strings.Join(args, " ")) - } - return -case "water": - g.CancelAction(p) - if len(args) == 0 { - g.doWater(sess, "") - } else { - g.doWater(sess, strings.Join(args, " ")) - } - return -case "cure": - g.CancelAction(p) - if len(args) == 0 { - g.doCure(sess, "") - } else { - g.doCure(sess, strings.Join(args, " ")) - } - return -case "inspect": - if len(args) == 0 { - g.doInspect(sess, "") - } else { - g.doInspect(sess, strings.Join(args, " ")) - } -``` - -**In `Game` struct:** - -Add field: -```go -farmTickCounter int -``` - -### `internal/game/action_state.go` - -Add new ActionType constants: - -```go -ActionPlanting ActionType = "planting" -ActionHarvesting ActionType = "harvesting_crop" -ActionRaking ActionType = "raking" -ActionWatering ActionType = "watering" -ActionCuring ActionType = "curing" -``` - -Add cases in `Description()`: - -```go -case ActionPlanting: - return "planting " + a.TargetName -case ActionHarvesting: - return "harvesting " + a.TargetName -case ActionRaking: - return "raking a " + a.TargetName -case ActionWatering: - return "watering a " + a.TargetName -case ActionCuring: - return "curing a " + a.TargetName -``` - -### `internal/game/action.go` - -**In `AdvanceActions()`**, add cases for farming action types: - -```go -case "plant": - g.advancePlant(sess, p) -case "harvest": - g.advanceHarvest(sess, p) -case "rake": - g.advanceRake(sess, p) -case "water": - g.advanceWater(sess, p) -case "cure": - g.advanceCure(sess, p) -``` - -**In `ProcessQueuedCommands()`**, add farming ActionTypes to the persistent list that should NOT be cleared after one tick: - -```go -case ActionGathering, ActionCombating, ActionUsing, ActionTalking, - ActionToggling, ActionBurning, ActionStoking, ActionResting, ActionWalking, ActionProducing, - ActionPlanting, ActionHarvesting, ActionRaking, ActionWatering, ActionCuring: - // keep these -``` - -### `cmd/mud/main.go` - -Add `g.FarmTick()` to the tick subscription (after `g.VisualTick()`). - ---- - -## 7. Seeds - -### Herb Seeds - -All herb seeds: `stackable: true`, `farm_patch_type: "herb"`, `farm_stages: 4`. - -```yaml -# data/items/guam_seed.yaml -id: guam_seed -name: guam seed -color: "34" -description: "A guam seed for planting in a herb patch." -value: 1 -stackable: true -farm_patch_type: herb -farm_level: 9 -farm_plant_xp: 11 -farm_harvest_xp: 13 -farm_stages: 4 -farm_product: guam_leaf -farm_min_yield: 3 -farm_max_yield: 12 -``` - -```yaml -# data/items/marrentill_seed.yaml -id: marrentill_seed -name: marrentill seed -color: "34" -description: "A marrentill seed for planting in a herb patch." -value: 2 -stackable: true -farm_patch_type: herb -farm_level: 14 -farm_plant_xp: 14 -farm_harvest_xp: 15 -farm_stages: 4 -farm_product: marrentill -farm_min_yield: 3 -farm_max_yield: 12 -``` - -```yaml -# data/items/tarromin_seed.yaml -id: tarromin_seed -name: tarromin seed -color: "34" -description: "A tarromin seed for planting in a herb patch." -value: 3 -stackable: true -farm_patch_type: herb -farm_level: 19 -farm_plant_xp: 18 -farm_harvest_xp: 18 -farm_stages: 4 -farm_product: tarromin -farm_min_yield: 3 -farm_max_yield: 12 -``` - -```yaml -# data/items/harralander_seed.yaml -id: harralander_seed -name: harralander seed -color: "70" -description: "A harralander seed for planting in a herb patch." -value: 5 -stackable: true -farm_patch_type: herb -farm_level: 26 -farm_plant_xp: 22 -farm_harvest_xp: 24 -farm_stages: 4 -farm_product: harralander -farm_min_yield: 3 -farm_max_yield: 12 -``` - -```yaml -# data/items/ranarr_seed.yaml -id: ranarr_seed -name: ranarr seed -color: "28" -description: "A ranarr seed for planting in a herb patch. Highly valued." -value: 100 -stackable: true -farm_patch_type: herb -farm_level: 32 -farm_plant_xp: 27 -farm_harvest_xp: 31 -farm_stages: 4 -farm_product: ranarr_weed -farm_min_yield: 3 -farm_max_yield: 10 -``` - -```yaml -# data/items/toadflax_seed.yaml -id: toadflax_seed -name: toadflax seed -color: "106" -description: "A toadflax seed for planting in a herb patch." -value: 50 -stackable: true -farm_patch_type: herb -farm_level: 38 -farm_plant_xp: 34 -farm_harvest_xp: 39 -farm_stages: 4 -farm_product: toadflax -farm_min_yield: 3 -farm_max_yield: 10 -``` - -```yaml -# data/items/irit_seed.yaml -id: irit_seed -name: irit seed -color: "114" -description: "An irit seed for planting in a herb patch." -value: 40 -stackable: true -farm_patch_type: herb -farm_level: 44 -farm_plant_xp: 43 -farm_harvest_xp: 49 -farm_stages: 4 -farm_product: irit_leaf -farm_min_yield: 3 -farm_max_yield: 10 -``` - -```yaml -# data/items/avantoe_seed.yaml -id: avantoe_seed -name: avantoe seed -color: "34" -description: "An avantoe seed for planting in a herb patch." -value: 60 -stackable: true -farm_patch_type: herb -farm_level: 50 -farm_plant_xp: 55 -farm_harvest_xp: 62 -farm_stages: 4 -farm_product: avantoe -farm_min_yield: 3 -farm_max_yield: 10 -``` - -```yaml -# data/items/kwuarm_seed.yaml -id: kwuarm_seed -name: kwuarm seed -color: "178" -description: "A kwuarm seed for planting in a herb patch." -value: 80 -stackable: true -farm_patch_type: herb -farm_level: 56 -farm_plant_xp: 69 -farm_harvest_xp: 78 -farm_stages: 4 -farm_product: kwuarm -farm_min_yield: 3 -farm_max_yield: 9 -``` - -```yaml -# data/items/snapdragon_seed.yaml -id: snapdragon_seed -name: snapdragon seed -color: "161" -description: "A snapdragon seed for planting in a herb patch." -value: 150 -stackable: true -farm_patch_type: herb -farm_level: 62 -farm_plant_xp: 82 -farm_harvest_xp: 99 -farm_stages: 4 -farm_product: snapdragon -farm_min_yield: 3 -farm_max_yield: 9 -``` - -```yaml -# data/items/cadantine_seed.yaml -id: cadantine_seed -name: cadantine seed -color: "30" -description: "A cadantine seed for planting in a herb patch." -value: 120 -stackable: true -farm_patch_type: herb -farm_level: 67 -farm_plant_xp: 97 -farm_harvest_xp: 120 -farm_stages: 4 -farm_product: cadantine -farm_min_yield: 3 -farm_max_yield: 9 -``` - -```yaml -# data/items/lantadyme_seed.yaml -id: lantadyme_seed -name: lantadyme seed -color: "36" -description: "A lantadyme seed for planting in a herb patch." -value: 130 -stackable: true -farm_patch_type: herb -farm_level: 73 -farm_plant_xp: 105 -farm_harvest_xp: 135 -farm_stages: 4 -farm_product: lantadyme -farm_min_yield: 3 -farm_max_yield: 8 -``` - -```yaml -# data/items/dwarf_weed_seed.yaml -id: dwarf_weed_seed -name: dwarf weed seed -color: "100" -description: "A dwarf weed seed for planting in a herb patch." -value: 140 -stackable: true -farm_patch_type: herb -farm_level: 79 -farm_plant_xp: 120 -farm_harvest_xp: 150 -farm_stages: 4 -farm_product: dwarf_weed -farm_min_yield: 3 -farm_max_yield: 8 -``` - -```yaml -# data/items/torstol_seed.yaml -id: torstol_seed -name: torstol seed -color: "220" -description: "A torstol seed for planting in a herb patch. Extremely rare and valuable." -value: 500 -stackable: true -farm_patch_type: herb -farm_level: 85 -farm_plant_xp: 142 -farm_harvest_xp: 200 -farm_stages: 4 -farm_product: torstol -farm_min_yield: 3 -farm_max_yield: 8 -``` - -### Allotment Seeds - -All allotment seeds: `stackable: true`, `farm_patch_type: "allotment"`, `farm_stages: 4`. - -```yaml -# data/items/potato_seed.yaml -id: potato_seed -name: potato seed -color: "180" -description: "A potato seed for planting in an allotment patch." -value: 1 -stackable: true -farm_patch_type: allotment -farm_level: 1 -farm_plant_xp: 8 -farm_harvest_xp: 9 -farm_stages: 4 -farm_product: potato -farm_min_yield: 3 -farm_max_yield: 10 -``` - -```yaml -# data/items/onion_seed.yaml -id: onion_seed -name: onion seed -color: "229" -description: "An onion seed for planting in an allotment patch." -value: 1 -stackable: true -farm_patch_type: allotment -farm_level: 5 -farm_plant_xp: 10 -farm_harvest_xp: 11 -farm_stages: 4 -farm_product: onion -farm_min_yield: 3 -farm_max_yield: 10 -``` - -```yaml -# data/items/cabbage_seed.yaml -id: cabbage_seed -name: cabbage seed -color: "71" -description: "A cabbage seed for planting in an allotment patch." -value: 1 -stackable: true -farm_patch_type: allotment -farm_level: 7 -farm_plant_xp: 10 -farm_harvest_xp: 12 -farm_stages: 4 -farm_product: cabbage -farm_min_yield: 3 -farm_max_yield: 10 -``` - -```yaml -# data/items/tomato_seed.yaml -id: tomato_seed -name: tomato seed -color: "196" -description: "A tomato seed for planting in an allotment patch." -value: 2 -stackable: true -farm_patch_type: allotment -farm_level: 12 -farm_plant_xp: 13 -farm_harvest_xp: 14 -farm_stages: 4 -farm_product: tomato -farm_min_yield: 3 -farm_max_yield: 10 -``` - -```yaml -# data/items/sweetcorn_seed.yaml -id: sweetcorn_seed -name: sweetcorn seed -color: "220" -description: "A sweetcorn seed for planting in an allotment patch." -value: 5 -stackable: true -farm_patch_type: allotment -farm_level: 20 -farm_plant_xp: 17 -farm_harvest_xp: 19 -farm_stages: 4 -farm_product: sweetcorn -farm_min_yield: 3 -farm_max_yield: 10 -``` - -```yaml -# data/items/strawberry_seed.yaml -id: strawberry_seed -name: strawberry seed -color: "197" -description: "A strawberry seed for planting in an allotment patch." -value: 8 -stackable: true -farm_patch_type: allotment -farm_level: 31 -farm_plant_xp: 26 -farm_harvest_xp: 29 -farm_stages: 4 -farm_product: strawberry -farm_min_yield: 3 -farm_max_yield: 10 -``` - -```yaml -# data/items/watermelon_seed.yaml -id: watermelon_seed -name: watermelon seed -color: "34" -description: "A watermelon seed for planting in an allotment patch." -value: 15 -stackable: true -farm_patch_type: allotment -farm_level: 47 -farm_plant_xp: 49 -farm_harvest_xp: 55 -farm_stages: 4 -farm_product: watermelon -farm_min_yield: 3 -farm_max_yield: 10 -``` - ---- - -## 8. Tools - -### Rake - -```yaml -# data/items/rake.yaml -id: rake -name: rake -color: "94" -description: "A sturdy rake for clearing weeds and dead plants from farming patches." -value: 8 -stackable: false -tool_type: rake -``` - -### Spade - -```yaml -# data/items/spade.yaml -id: spade -name: spade -color: "241" -description: "A metal spade used for planting seeds and harvesting crops." -value: 8 -stackable: false -tool_type: spade -``` - -### Watering Can - -```yaml -# data/items/watering_can.yaml -id: watering_can -name: watering can -color: "39" -description: "A watering can for hydrating farming patches. Watering eliminates disease risk." -value: 12 -stackable: false -tool_type: watering_can -``` - -### Plant Cure - -```yaml -# data/items/plant_cure.yaml -id: plant_cure -name: plant cure -color: "120" -description: "A bio-engineered solution that cures diseased plants." -value: 25 -stackable: true -``` - -### Tool Shed Object - -```yaml -# data/objects/tool_shed.yaml -id: tool_shed -name: tool shed -color: "94" -behavior: "" -hidden: false -inroom_description: "A weathered tool shed stands against the wall." -description: "A small shed for storing farming tools. Use 'use tool_shed' to store or retrieve tools." -use_interactions: - - item: rake - message: "You store the rake in the tool shed." - action: - take_item: rake - set_player_flags: - tool_shed_rake: true - - item: spade - message: "You store the spade in the tool shed." - action: - take_item: spade - set_player_flags: - tool_shed_spade: true - - item: watering_can - message: "You store the watering can in the tool shed." - action: - take_item: watering_can - set_player_flags: - tool_shed_watering_can: true -``` - -**Retrieval:** For retrieving tools, the tool shed should also have interactions that are available when the player does NOT have the item but HAS the player flag. This requires adding conditional `use_interactions` on the object. Since the existing `UseInteraction` struct supports `Condition`, add retrieve entries: - -```yaml -# Additional use_interactions on tool_shed.yaml - - item: "" - condition: - player_flag: tool_shed_rake - message: "You retrieve the rake from the tool shed." - action: - give_item: rake - set_player_flags: - tool_shed_rake: false - - item: "" - condition: - player_flag: tool_shed_spade - message: "You retrieve the spade from the tool shed." - action: - give_item: spade - set_player_flags: - tool_shed_spade: false - - item: "" - condition: - player_flag: tool_shed_watering_can - message: "You retrieve the watering can from the tool shed." - action: - give_item: watering_can - set_player_flags: - tool_shed_watering_can: false -``` - -**Note:** The current `UseInteraction` system requires an `item` field (the item being used on the object). For retrieval (no item needed), this may require either: -1. A new `talk`-style behavior on the tool shed with dialog options, OR -2. Adding a `talk` behavior that lists stored tools and lets the player choose, OR -3. A new command `retrieve <tool> from shed`, OR -4. Simply using `use shed` with no item to trigger a menu of stored tools. - -**Recommended approach:** Give the tool shed a `talk` behavior. When the player does `talk shed` or `use shed`, they get a dialog: - -```yaml -# data/behaviors/tool_shed_talk.yaml -id: tool_shed_talk -type: talk -nodes: - start: - message: "The tool shed is open. What would you like to do?" - options: - - text: "Store rake" - goto: store_rake - condition: - has_item: rake - - text: "Store spade" - goto: store_spade - condition: - has_item: spade - - text: "Store watering can" - goto: store_watering_can - condition: - has_item: watering_can - - text: "Retrieve rake" - goto: retrieve_rake - condition: - player_flag: tool_shed_rake - - text: "Retrieve spade" - goto: retrieve_spade - condition: - player_flag: tool_shed_spade - - text: "Retrieve watering can" - goto: retrieve_watering_can - condition: - player_flag: tool_shed_watering_can - - text: "Never mind" - end: true - store_rake: - message: "You store the rake in the tool shed." - action: - take_item: rake - set_player_flags: - tool_shed_rake: true - options: - - text: "Continue" - goto: start - store_spade: - message: "You store the spade in the tool shed." - action: - take_item: spade - set_player_flags: - tool_shed_spade: true - options: - - text: "Continue" - goto: start - store_watering_can: - message: "You store the watering can in the tool shed." - action: - take_item: watering_can - set_player_flags: - tool_shed_watering_can: true - options: - - text: "Continue" - goto: start - retrieve_rake: - message: "You retrieve the rake from the tool shed." - action: - give_item: rake - set_player_flags: - tool_shed_rake: false - options: - - text: "Continue" - goto: start - retrieve_spade: - message: "You retrieve the spade from the tool shed." - action: - give_item: spade - set_player_flags: - tool_shed_spade: false - options: - - text: "Continue" - goto: start - retrieve_watering_can: - message: "You retrieve the watering can from the tool shed." - action: - give_item: watering_can - set_player_flags: - tool_shed_watering_can: false - options: - - text: "Continue" - goto: start -``` - -Update `data/objects/tool_shed.yaml` to reference this behavior: - -```yaml -id: tool_shed -name: tool shed -color: "94" -behavior: tool_shed_talk -hidden: false -inroom_description: "A weathered tool shed stands against the wall." -description: "A small shed for storing farming tools. Talk to it to store or retrieve tools." -``` - ---- - -## 9. Plot Objects - -### Herb Patch - -```yaml -# data/objects/herb_patch.yaml -id: herb_patch -name: herb patch -color: "28" -behavior: "" -hidden: false -inroom_description: "A prepared herb patch sits in the soil." -description: "A small patch of tilled soil suitable for growing herbs. Use 'inspect' to check its status." -``` - -Note: `behavior: ""` because farming patches don't use the standard behavior system. They are interacted with via the dedicated farming commands (`plant`, `harvest`, `rake`, `water`, `cure`, `inspect`). - -### Allotment Patch - -```yaml -# data/objects/allotment_patch.yaml -id: allotment_patch -name: allotment patch -color: "94" -behavior: "" -hidden: false -inroom_description: "An allotment patch is marked out in the ground." -description: "A large patch of soil for growing vegetables. Use 'inspect' to check its status." -``` - -### Flower Patch - -```yaml -# data/objects/flower_patch.yaml -id: flower_patch -name: flower patch -color: "213" -behavior: "" -hidden: false -inroom_description: "A flower patch is outlined with small stones." -description: "A small decorative patch for growing flowers. Use 'inspect' to check its status." -``` - -### How `look` Shows Patch State - -The `doLook` / `doLookTarget` functions need modification. When a player looks at a farming patch object, the system should append the player's personal patch state to the object description. - -**In `doLookTarget()` (likely in `cmd_look.go` or the look handler):** - -When the target matches a farming patch object, after displaying the base description, append: - -```go -func (g *Game) farmPatchLookSuffix(p *player.Player, defID string, index int) string { - prefix := farmFlagPrefix(defID, index) - if prefix == "" { - return "" - } - g.ensureFarmState(p, prefix) - - weeds, _ := p.Flags[prefix+"_weeds"].(bool) - if weeds { - return "\nIt is overgrown with weeds." - } - - seedID, _ := p.Flags[prefix+"_seed"].(string) - if seedID == "" { - return "\nThe patch is empty and ready for planting." - } - - dead, _ := p.Flags[prefix+"_dead"].(bool) - if dead { - return "\nThe plant has died. You need to rake it clean." - } - - diseased, _ := p.Flags[prefix+"_diseased"].(bool) - if diseased { - return "\nThe plant looks diseased! Use plant cure to save it." - } - - ready, _ := p.Flags[prefix+"_ready"].(bool) - if ready { - seedDef, _ := g.ItemStore.Load(seedID) - name := seedID - if seedDef != nil { - name = seedDef.Name - } - return fmt.Sprintf("\nA fully grown %s is ready to harvest!", name) - } - - stage, _ := p.Flags[prefix+"_stage"].(int) - seedDef, _ := g.ItemStore.Load(seedID) - maxStages := 4 - if seedDef != nil && seedDef.FarmStages > 0 { - maxStages = seedDef.FarmStages - } - name := seedID - if seedDef != nil { - name = seedDef.Name - } - watered, _ := p.Flags[prefix+"_watered"].(bool) - suffix := fmt.Sprintf("\nA %s is growing (stage %d/%d).", name, stage, maxStages) - if watered { - suffix += " It has been watered." - } - return suffix -} -``` - -The room `look` listing should also reflect patch state. In the objects section of `doLook`, for each farming patch object, replace the generic `inroom_description` with a state-aware version: - -- Weeds: "A herb patch sits here, overgrown with weeds." -- Empty: "An empty herb patch is ready for planting." -- Growing: "A herb patch has a small plant growing in it." -- Diseased: "A herb patch has a {196}sickly-looking{/} plant in it." -- Dead: "A herb patch contains a dead, withered plant." -- Ready: "A herb patch has a {34 bold}fully grown crop{/} ready to harvest!" - ---- - -## 10. Rooms - -### Room Layout - -Room 15 currently is "Alchemy Lab" with exits east:16, west:14. We will repurpose it as the farming hub entrance, or more practically, create new rooms at the end of the room list (rooms 150-152) and connect them. - -**Connection point:** Add a south exit from an existing hub room to room 150. Room 1 (Town Square) or another central room should connect to the farming area. - -Alternatively, give room 15 (Alchemy Lab) a south exit to room 150. - -### Room 150: Farming Hub - -```yaml -# data/rooms/150.yaml -id: 150 -name: "Hydroponics Bay" -description: "A large enclosed area with climate-controlled growing stations. {34}Lush green patches{/} of soil are laid out in neat rows under artificial UV lamps. A {94}tool shed{/} stands near the entrance." -map_symbol: "H" -exits: - north: 15 - east: 151 - south: 152 -objects: - - id: tool_shed -spawns: - - item_id: rake - quantity: 1 - respawn_ticks: 120 - - item_id: spade - quantity: 1 - respawn_ticks: 120 - - item_id: watering_can - quantity: 1 - respawn_ticks: 120 - - item_id: plant_cure - quantity: 3 - respawn_ticks: 200 - - item_id: guam_seed - quantity: 5 - respawn_ticks: 300 - - item_id: potato_seed - quantity: 10 - respawn_ticks: 300 - - item_id: onion_seed - quantity: 10 - respawn_ticks: 300 -``` - -### Room 151: Herb Garden - -```yaml -# data/rooms/151.yaml -id: 151 -name: "Herb Garden" -description: "A dedicated section of the hydroponics bay for growing herbs. {28}Herb patches{/} are arranged in two rows under specialized grow lights." -map_symbol: "G" -exits: - west: 150 -objects: - - id: herb_patch - - id: herb_patch - - id: herb_patch - - id: herb_patch -``` - -### Room 152: Allotment Field - -```yaml -# data/rooms/152.yaml -id: 152 -name: "Allotment Field" -description: "A section with larger soil beds for growing vegetables. {94}Allotment patches{/} stretch across the floor under warm overhead lamps." -map_symbol: "A" -exits: - north: 150 -objects: - - id: allotment_patch - - id: allotment_patch - - id: allotment_patch - - id: flower_patch -``` - -### Connect Room 15 to Room 150 - -Update `data/rooms/15.yaml` to add a south exit: - -```yaml -id: 15 -name: "Alchemy Lab" -description: "Shelves lined with glass vials, dried herbs, and bubbling cauldrons. This area is not yet accessible." -exits: - east: 16 - west: 14 - south: 150 -``` - ---- - -## 11. Growth Stages - -### Stage Definitions - -Each crop type has a fixed number of growth stages. Growth advances by 1 stage per farm tick (every 500 game ticks ≈ 5 minutes). - -| Crop Type | Stages | Total Grow Time (online) | -|---|---|---| -| Herb | 4 | ~20 minutes | -| Allotment | 4 | ~20 minutes | -| Flower | 3 | ~15 minutes | -| Bush | 5 | ~25 minutes | -| Tree | 6 | ~30 minutes | - -All stages are equal duration (1 farm tick each). The `farm_stages` field on the seed item controls this per-seed. - -### Stage Progression - -``` -Stage 0: Just planted (seedling) -Stage 1: Small sprout -Stage 2: Growing plant -Stage 3: Maturing plant -Stage 4: Fully grown (for 4-stage crops) -``` - -At each stage transition, the disease check occurs (unless watered). Watering is reset each stage — you must water each stage individually if you want full protection. - -### Watering Reset Mechanic - -When growth advances a stage: -1. The `_watered` flag is set to `false`. -2. Disease check runs (10% chance if not watered, 0% if watered). -3. Player must water again for the next stage. - -This means a 4-stage herb crop with full watering protection requires 4 watering actions spread across 4 farm ticks. - ---- - -## 12. Disease & Death - -### Disease Chance - -- **Base disease chance per growth stage:** 10% (0.10) -- **If watered:** 0% (eliminated entirely) -- **Not affected by farming level** (keep it simple for Phase 1) - -### Disease Flow - -``` -[Growth tick fires] - | - +-- Is plant diseased? - | | - | +-- YES: Plant DIES. Set _dead = true, _diseased = false. - | | Notify player: "Your <seed> has died from disease!" - | | - | +-- NO: Continue to growth check. - | - +-- Advance stage by 1. - | - +-- Is plant now fully grown? - | | - | +-- YES: Set _ready = true. Notify: "Your <seed> is fully grown!" - | | - | +-- NO: Roll disease check. - | | - | +-- Watered? No disease. - | | - | +-- Not watered? 10% chance of disease. - | | - | +-- Disease! Set _diseased = true. Notify: "Your <seed> has become diseased!" - | | - | +-- No disease. Continue growing. - | - +-- Reset _watered to false. -``` - -### Curing - -- Use `cure` command or `use plant_cure on herb patch`. -- Consumes 1 `plant_cure` from inventory. -- Sets `_diseased = false`. -- Must be done **before the next farm tick** or the plant dies. - -### Dead Plants - -- Dead plants block the patch. Nothing can be planted. -- Must be cleared with `rake` command. -- Raking dead plants awards **no XP**. -- After raking, the patch has weeds (must be raked again to clear before planting). -- **Actually, simplify:** Raking a dead plant fully clears the patch (no double-rake). Set `_weeds = false`, `_dead = false`, `_seed = ""`, etc. The patch is immediately ready for replanting. - -### Raking Weeds - -- New patches start with weeds. -- After harvesting, the patch returns to having weeds. -- Raking weeds also awards **no XP** and takes 4 ticks. -- After raking weeds, patch is empty and ready for planting. - ---- - -## 13. Harvesting - -### Harvest Mechanics - -1. Player must be in a room with a farming patch that has `_ready == true`. -2. Player must have a spade (tool_type "spade") in inventory or equipment. -3. Player must have at least 1 free inventory slot. -4. Harvest action takes 3 ticks. - -### Yield Calculation - -```go -func farmYield(minYield, maxYield, farmingLevel int) int { - baseYield := minYield + rand.Intn(maxYield-minYield+1) - bonus := farmingLevel / 20 - return baseYield + bonus -} -``` - -- Herbs: base 3-12, +1 per 20 farming levels → at level 99: 3-17 -- Allotments: base 3-10, +1 per 20 farming levels → at level 99: 3-14 - -### Yield Capping - -Yield is capped by available inventory space. If the product is stackable (herbs could be), all yield goes into one slot. If not stackable, yield = min(yield, freeSlots). - -For simplicity, **all farm products are stackable** (herb leaves, vegetables). - -### Harvest XP - -XP is awarded per harvest action (not per item). The total XP is `farm_harvest_xp` from the seed def, awarded once when harvesting completes. - -### Post-Harvest - -After harvesting: -- `_seed = ""`, `_stage = 0`, `_ready = false`, `_watered = false` -- `_weeds = true` — weeds grow back after harvest (must rake before replanting) - -### Output Messages - -``` -"You harvest 7 guam leaves from the herb patch." -" (+13xp frm)" // if xp_drops enabled -``` - ---- - -## 14. XP Table - -### Herb Seeds - -| Seed | Level | Plant XP | Harvest XP | Total XP (min harvest) | -|---|---|---|---|---| -| Guam | 9 | 11 | 13 | 24 | -| Marrentill | 14 | 14 | 15 | 29 | -| Tarromin | 19 | 18 | 18 | 36 | -| Harralander | 26 | 22 | 24 | 46 | -| Ranarr | 32 | 27 | 31 | 58 | -| Toadflax | 38 | 34 | 39 | 73 | -| Irit | 44 | 43 | 49 | 92 | -| Avantoe | 50 | 55 | 62 | 117 | -| Kwuarm | 56 | 69 | 78 | 147 | -| Snapdragon | 62 | 82 | 99 | 181 | -| Cadantine | 67 | 97 | 120 | 217 | -| Lantadyme | 73 | 105 | 135 | 240 | -| Dwarf Weed | 79 | 120 | 150 | 270 | -| Torstol | 85 | 142 | 200 | 342 | - -### Allotment Seeds - -| Seed | Level | Plant XP | Harvest XP | Total XP | -|---|---|---|---|---| -| Potato | 1 | 8 | 9 | 17 | -| Onion | 5 | 10 | 11 | 21 | -| Cabbage | 7 | 10 | 12 | 22 | -| Tomato | 12 | 13 | 14 | 27 | -| Sweetcorn | 20 | 17 | 19 | 36 | -| Strawberry | 31 | 26 | 29 | 55 | -| Watermelon | 47 | 49 | 55 | 104 | - -### XP Comparison to Other Skills - -These values are intentionally lower per-action than gathering skills because farming is passive — the player plants, waters, and waits. The time-gated nature means farming XP comes slowly but with minimal active effort. - ---- - -## 15. Tool Shed - -### Overview - -The tool shed is an object in the Farming Hub room (150). Players can interact with it to store and retrieve farming tools, freeing up inventory space while farming. - -### Implementation - -The tool shed uses the existing **talk behavior** system. When a player does `talk shed` or `talk tool_shed`, they enter a dialog that shows available storage/retrieval options based on their inventory and player flags. - -**Player flags for tool shed:** -- `tool_shed_rake: true/false` -- `tool_shed_spade: true/false` -- `tool_shed_watering_can: true/false` - -**Behavior YAML:** See Section 8 above (`data/behaviors/tool_shed_talk.yaml`). - -**Object YAML:** See Section 8 above (`data/objects/tool_shed.yaml`). - -### Tool Shed in `look` - -When the player looks at the tool shed, the description should mention what's stored: - -```go -func (g *Game) toolShedLookSuffix(p *player.Player) string { - var stored []string - if v, _ := p.Flags["tool_shed_rake"].(bool); v { - stored = append(stored, "a rake") - } - if v, _ := p.Flags["tool_shed_spade"].(bool); v { - stored = append(stored, "a spade") - } - if v, _ := p.Flags["tool_shed_watering_can"].(bool); v { - stored = append(stored, "a watering can") - } - if len(stored) == 0 { - return "\nThe shed is empty." - } - return fmt.Sprintf("\nInside: %s.", strings.Join(stored, ", ")) -} -``` - -This could be appended when the player does `look tool shed`. Since the description field supports `{quality}` placeholder for fire objects, a similar approach could be used, but it's simpler to handle this in the Go code for the `doLookTarget` handler. - ---- - -## 16. Help Files - -### `data/help/plant.yaml` - -```yaml -name: "plant" -category: "Skills" -description: | - Plant a seed in a farming patch. - - Usage: plant <seed> - - Requires a spade in your inventory. The seed must match the - patch type in the current room (herb seeds go in herb patches, - vegetable seeds go in allotment patches). - - The patch must be clear of weeds (use 'rake' first) and empty. - - Planting awards a small amount of farming XP. - - See also: help farming, help harvest, help rake, help water -``` - -### `data/help/harvest.yaml` - -```yaml -name: "harvest" -category: "Skills" -description: | - Harvest a fully grown crop from a farming patch. - - Usage: harvest [patch] - - Requires a spade in your inventory. The crop must be fully - grown (check with 'inspect'). Yields a random amount of the - crop based on your farming level. - - If no patch is specified, the first harvestable patch in the - room is targeted. - - After harvesting, the patch grows weeds and must be raked - before replanting. - - See also: help farming, help plant, help inspect -``` - -### `data/help/rake.yaml` - -```yaml -name: "rake" -category: "Skills" -description: | - Clear weeds or dead plants from a farming patch. - - Usage: rake [patch] - - Requires a rake in your inventory. Patches start with weeds - and regrow weeds after harvesting. Dead plants from disease - also need to be raked before replanting. - - Raking awards no farming XP. - - If no patch is specified, the first patch needing raking - in the room is targeted. - - See also: help farming, help plant, help water -``` - -### `data/help/water.yaml` - -```yaml -name: "water" -category: "Skills" -description: | - Water a farming patch to prevent disease. - - Usage: water [patch] - - Requires a watering can in your inventory. Watering a patch - eliminates the disease chance for the current growth stage. - You must water each stage separately as the watering resets - when the plant grows. - - If no patch is specified, the first unwatered patch with a - growing plant is targeted. - - See also: help farming, help plant, help cure -``` - -### `data/help/cure.yaml` - -```yaml -name: "cure" -category: "Skills" -description: | - Cure a diseased farming patch. - - Usage: cure [patch] - - Requires a plant cure item in your inventory (consumed on use). - Diseased plants must be cured before the next growth tick or - they will die. - - If no patch is specified, the first diseased patch in the - room is targeted. - - See also: help farming, help water, help inspect -``` - -### `data/help/inspect.yaml` - -```yaml -name: "inspect" -category: "Skills" -description: | - Check the status of farming patches in the current room. - - Usage: inspect [patch] - - Shows the current state of each farming patch including: - - What is planted - - Growth stage - - Whether it has been watered - - Disease status - - Whether it is ready to harvest - - If no patch is specified, all patches in the room are shown. - - See also: help farming, help plant, help harvest -``` - -### `data/help/farming.yaml` - -```yaml -name: "farming" -category: "Skills" -description: | - Farming lets you grow herbs and vegetables in patches. - - The basic cycle: - 1. Rake the patch to clear weeds (requires rake) - 2. Plant a seed (requires spade, seed in inventory) - 3. Water the patch to prevent disease (requires watering can) - 4. Wait for the plant to grow through stages - 5. Water again at each growth stage for protection - 6. Harvest the fully grown crop (requires spade) - - Growth happens every ~5 minutes while you are online. - Crops do not grow while you are logged off. - - Disease: Each growth stage has a 10% chance of disease unless - the patch was watered. Diseased plants can be cured with plant - cure. If not cured before the next growth tick, the plant dies - and must be raked away. - - Tool shed: Store farming tools to free inventory space. - Use 'talk shed' to store or retrieve tools. - - Patches: Herb patches grow herb seeds. Allotment patches grow - vegetable seeds. Check 'inspect' for patch status. - - Commands: plant, harvest, rake, water, cure, inspect - - See also: help plant, help harvest, help rake, help water, - help cure, help inspect -``` - ---- - -## Implementation Order - -Recommended order for implementing this feature: - -1. **Add `ItemDef` fields** (`internal/object/item.go`) — the 8 new `farm_*` fields. Run `make vet`. -2. **Add `ActionType` constants** (`internal/game/action_state.go`) — 5 new types + `Description()` cases. -3. **Create `action_farm.go`** — farm state helpers, `ensureFarmState()`, `farmFlagPrefix()`, `findActiveFarmPrefixes()`, `FarmTick()`, `advanceFarmGrowth()`, and all `advanceXxx()` functions. -4. **Create `cmd_farm.go`** — all command handlers: `doPlant()`, `doHarvest()`, `doRake()`, `doWater()`, `doCure()`, `doInspect()`, and look suffix helpers. -5. **Update `game.go`** — `classifyCommand()`, `executeCommand()`, `farmTickCounter` field on `Game`. -6. **Update `action.go`** — add farming cases to `AdvanceActions()` and `ProcessQueuedCommands()`. -7. **Update `main.go`** — add `g.FarmTick()` to tick subscription. -8. **Create tool YAML files** — rake, spade, watering_can, plant_cure in `data/items/`. -9. **Create seed YAML files** — all 21 seeds in `data/items/`. -10. **Create product YAML files** — all harvest products that don't already exist. -11. **Create object YAML files** — herb_patch, allotment_patch, flower_patch, tool_shed in `data/objects/`. -12. **Create behavior YAML** — `tool_shed_talk` in `data/behaviors/`. -13. **Create room YAML files** — rooms 150, 151, 152 in `data/rooms/`. -14. **Update room 15** — add south exit to room 150. -15. **Create help YAML files** — all 7 help files. -16. **Integrate patch state into look** — modify `doLookTarget()` and room look to show per-player patch state. -17. **Test** — `make build && make test && make vet`. - ---- - -## Edge Cases & Notes - -- **Inventory full when harvesting:** Cap yield at available slots. If no slots available, "Your inventory is too full!" -- **No patches in room:** "There are no farming patches here." -- **Wrong seed for patch:** "That seed can't be planted in this type of patch." -- **Already planted:** "Something is already growing in this patch." -- **No tool:** "You need a rake/spade/watering can to do that." -- **No plant cure:** "You don't have any plant cure." -- **Multi-patch rooms:** Support numbered targeting like `plant guam seed 2.patch` or implicit "first available" logic. -- **Cancellation:** All farming actions (plant, harvest, rake, water, cure) are cancelled by movement, combat, or other active commands (standard `CancelAction` behavior). -- **Offline growth:** Intentionally disabled. Crops freeze when player logs off. This means farming is most effective when the player stays online and periodically waters/harvests. -- **Multiple players:** Since state is per-player (player flags), two players can farm the same patches independently. Each sees their own state. -- **Save frequency:** Character is saved after every state change (planting, watering, curing, harvesting, raking, growth tick). This matches the existing pattern of `g.AccountStore.SaveCharacter(p)` after mutations. -- **Color targets:** Add `farm_grow` and `farm_disease` to the color system (in `config/colors.go` or wherever color targets are registered). Suggested defaults: `farm_grow: "34"` (green), `farm_disease: "196"` (red). -- **Player flag cleanup:** Dead characters or deleted characters will have farming flags in their YAML files. This is harmless — the flags are simply ignored if the character is deleted. No cleanup needed. -- **Flag type safety:** When reading flags, always use type assertions with default values: `stage, _ := p.Flags[prefix+"_stage"].(int)`. YAML deserialization may store ints as `int` or `float64` depending on the value — handle both like `OptionInt()` does. diff --git a/skill_plans/hacking.md b/skill_plans/hacking.md deleted file mode 100644 index 4559209..0000000 --- a/skill_plans/hacking.md +++ /dev/null @@ -1,1391 +0,0 @@ -# Hacking Skill — Implementation Plan - -## 1. Overview - -Hacking is the Hunter analog for The House of Icarus. Instead of setting traps and catching creatures in the wilderness, players "jack in" to terminal objects scattered around the game world and play text-based minigames to train their Hacking skill. - -The core loop: -1. Player finds a terminal object in a room (e.g., `terminal_basic` in the Hacking Lab). -2. Player types `jack` or `jack <terminal>` to jack into it. -3. The session enters `StateHacking` — all further input is routed to the active minigame handler instead of normal command dispatch. -4. Player plays the minigame (Hunt the Wumpus, Mastermind, Liar's Dice, etc.). -5. On completion (win or lose), Hacking XP is awarded and the player returns to `StateGame`. -6. Player can type `jack out` or `quit` at any time to abort (small consolation XP for attempt if progress was made). - -Different terminals require different Hacking levels and offer different minigames. Higher-level terminals offer harder minigames with more XP. - -## 2. Architecture - -### Session State - -Add `StateHacking` to the `SessionState` enum in `internal/net/server.go`: - -```go -// In the const block, after StateColorChoice: -StateHacking -``` - -When `sess.State == net.StateHacking`, `HandleSession` in `internal/game/game.go` routes all input to `g.handleHackingInput(sess, input)`: - -```go -// In HandleSession switch: -case net.StateHacking: - g.handleHackingInput(sess, input) -``` - -### Hacking State Storage - -Add a `hackingStates` map to the `Game` struct: - -```go -type Game struct { - // ... existing fields ... - hackingStates map[string]*HackingSession // keyed by player name -} -``` - -Initialize it in `New()`: - -```go -hackingStates: make(map[string]*HackingSession), -``` - -The `HackingSession` struct: - -```go -type HackingSession struct { - Minigame HackingMinigame - TerminalID string // object def ID (e.g., "terminal_basic") - Level int // player's hacking level at start - ReqLevel int // terminal's required level - Started bool // true once first input received (for attempt XP) -} -``` - -### ActionState - -Add `ActionHacking` to `action_state.go`: - -```go -ActionHacking ActionType = "hacking" -``` - -Add a case in `Description()`: - -```go -case ActionHacking: - return "jacked into a " + a.TargetName -``` - -### Cleanup - -In `Game.SetHub`'s `OnRemove` callback (or wherever disconnect cleanup happens), delete the hacking state: - -```go -delete(g.hackingStates, p.Name) -``` - -Also in `CancelAction`, if the player is in StateHacking, clean up: - -```go -if _, ok := g.hackingStates[p.Name]; ok { - delete(g.hackingStates, p.Name) - sess.State = net.StateGame -} -``` - -## 3. Commands - -### `jack` / `jackin` (Active command) - -Add to `classifyCommand()` in `game.go`: - -```go -case "jack", "jackin": - return ClassActive -``` - -Add to `executeCommand()` in `game.go`: - -```go -case "jack", "jackin": - g.CancelAction(p) - g.doJack(sess, strings.Join(args, " ")) - return -``` - -### In-Minigame Input - -While in `StateHacking`, ALL input goes to `handleHackingInput()`. The minigame's `HandleInput()` method processes it. Special pre-check: - -- If input is `jack out`, `quit`, or `disconnect`: exit the minigame, award attempt XP if `Started == true`, restore `StateGame`, write prompt. -- Otherwise: pass to `Minigame.HandleInput(input)`. - -### No Queuing While Hacking - -While `sess.State == net.StateHacking`, the player cannot queue commands. Input does not go through `handleGameCommand` at all. This is the same pattern as `StateTalk`, `StateRecipeChoice`, etc. - -## 4. New Files to Create - -### Go Files (all in `internal/game/`) - -| File | Purpose | -|---|---| -| `cmd_jack.go` | `doJack()` command handler — find terminal, check level, start minigame | -| `hacking.go` | `HackingMinigame` interface, `HackingSession` struct, `handleHackingInput()`, `startHacking()`, `endHacking()`, XP calculation | -| `hacking_wumpus.go` | Hunt the Wumpus minigame implementation | -| `hacking_mastermind.go` | Mastermind / Code Breaker minigame implementation | -| `hacking_liars_dice.go` | Liar's Dice / Signal Bluff minigame implementation | - -### YAML Data Files - -| File | Purpose | -|---|---| -| `data/objects/terminal_basic.yaml` | Level 1 terminal (Wumpus) | -| `data/objects/terminal_advanced.yaml` | Level 20 terminal (Mastermind) | -| `data/objects/terminal_secure.yaml` | Level 40 terminal (Liar's Dice) | -| `data/behaviors/netrunner_talk.yaml` | Netrunner NPC talk behavior | -| `data/objects/netrunner.yaml` | Netrunner NPC object | -| `data/help/jack.yaml` | Help for the jack command | -| `data/help/hacking.yaml` | Help for the hacking skill | -| `data/rooms/8.yaml` | Updated Hacking Lab with terminal + NPC | - -## 5. Code Changes to Existing Files - -### `internal/net/server.go` - -Add `StateHacking` to the `SessionState` const block: - -```go -const ( - // ... existing states ... - StateColorChoice - StateHacking // <-- add after StateColorChoice -) -``` - -No changes to the `Session` struct needed — hacking state is stored on `Game.hackingStates`, not on the session. - -### `internal/game/game.go` - -**HandleSession** — add case: - -```go -case net.StateHacking: - g.handleHackingInput(sess, input) -``` - -This goes after the `StateColorChoice` case, before the closing `}`. - -**classifyCommand** — add: - -```go -case "jack", "jackin": - return ClassActive -``` - -Add `"jack", "jackin"` to the active commands list in the switch (the line with `"get", "take", "grab", "pick", "drop", ...`). - -**executeCommand** — add case: - -```go -case "jack", "jackin": - g.CancelAction(p) - g.doJack(sess, strings.Join(args, " ")) - return -``` - -**New()** — add initialization: - -```go -hackingStates: make(map[string]*HackingSession), -``` - -**Game struct** — add field: - -```go -hackingStates map[string]*HackingSession -``` - -### `internal/game/action_state.go` - -Add constant: - -```go -ActionHacking ActionType = "hacking" -``` - -Add Description case: - -```go -case ActionHacking: - return "jacked into a " + a.TargetName -``` - -## 6. Terminal Objects - -### `data/objects/terminal_basic.yaml` - -```yaml -id: terminal_basic -name: basic terminal -color: "40" -description: "A battered terminal with a cracked screen. Faded text scrolls across the display — it looks like it's running some kind of legacy intrusion detection system. {40}[Level 1 Hacking]{/}" -inroom_description: "A {40}basic terminal{/} hums quietly against the wall." -``` - -No `behavior` field — the `jack` command handles terminal interaction directly by checking object ID prefix `terminal_`. - -### `data/objects/terminal_advanced.yaml` - -```yaml -id: terminal_advanced -name: advanced terminal -color: "33" -description: "A sleek terminal with a holographic display. Encrypted data streams flow across the screen in complex patterns. {33}[Level 20 Hacking]{/}" -inroom_description: "An {33}advanced terminal{/} projects a holographic interface." -``` - -### `data/objects/terminal_secure.yaml` - -```yaml -id: terminal_secure -name: secure terminal -color: "196" -description: "A heavily reinforced terminal embedded in the wall. Warning glyphs pulse across its surface. A bluffing protocol runs on loop — it's probing you as much as you're probing it. {196}[Level 40 Hacking]{/}" -inroom_description: "A {196}secure terminal{/} glows with warning indicators." -``` - -### Terminal Registry (in `hacking.go`) - -Rather than using YAML behaviors, terminals are registered in a Go map. This keeps minigame logic in Go (minigames are complex stateful Go code, not YAML-driven): - -```go -type TerminalDef struct { - ObjectID string - Level int - Minigame func() HackingMinigame - BaseXPWin int - BaseXPLose int - Name string -} - -var terminalDefs = map[string]TerminalDef{ - "terminal_basic": { - ObjectID: "terminal_basic", - Level: 1, - Minigame: func() HackingMinigame { return NewWumpusGame() }, - BaseXPWin: 50, - BaseXPLose: 10, - Name: "Hunt the Wumpus", - }, - "terminal_advanced": { - ObjectID: "terminal_advanced", - Level: 20, - Minigame: func() HackingMinigame { return NewMastermindGame() }, - BaseXPWin: 150, - BaseXPLose: 30, - Name: "Code Breaker", - }, - "terminal_secure": { - ObjectID: "terminal_secure", - Level: 40, - Minigame: func() HackingMinigame { return NewLiarsDiceGame() }, - BaseXPWin: 300, - BaseXPLose: 50, - Name: "Signal Bluff", - }, -} -``` - -## 7. Minigame Interface - -Defined in `internal/game/hacking.go`: - -```go -type HackingMinigame interface { - Init(level int) string - HandleInput(input string) (output string, done bool, won bool) - Name() string -} -``` - -### `Init(level int) string` - -Called once when the minigame starts. `level` is the player's current Hacking level (may influence difficulty scaling in future). Returns the initial display string — the welcome message, instructions, and initial game state. - -### `HandleInput(input string) (output string, done bool, won bool)` - -Called for every line of player input while in the minigame. - -- `output`: Text to display to the player (game response, updated state, etc.) -- `done`: If `true`, the minigame is over. `won` indicates win/lose. -- `won`: Only meaningful when `done == true`. `true` = player completed the objective. - -### `Name() string` - -Returns the display name of the minigame (e.g., "Hunt the Wumpus"). - -## 8. Hunt the Wumpus (Level 1) - -File: `internal/game/hacking_wumpus.go` - -### Theme Reskin - -| Classic | Sci-Fi | -|---|---| -| Cave | Network node | -| Wumpus | Rogue AI | -| Pit | Data trap (firewall) | -| Super bats | ICE (Intrusion Countermeasures Electronics) | -| Arrows | Probes | -| Shoot | Launch probe | - -### Struct - -```go -type WumpusGame struct { - rooms [20][3]int // adjacency list (dodecahedron) - player int // current room (0-19) - wumpus int // wumpus room - pits [2]int // pit rooms - ice [2]int // ICE rooms - probes int // remaining probes (starts at 5) - gameOver bool - won bool -} -``` - -### Dodecahedron Topology - -The classic 20-room dodecahedron. Each room connects to exactly 3 others. Hard-coded adjacency table (same as the original 1973 Hunt the Wumpus): - -```go -var dodecahedron = [20][3]int{ - {1, 4, 7}, // room 0 - {0, 2, 9}, // room 1 - {1, 3, 11}, // room 2 - {2, 4, 13}, // room 3 - {0, 3, 5}, // room 4 - {4, 6, 14}, // room 5 - {5, 7, 16}, // room 6 - {0, 6, 8}, // room 7 - {7, 9, 17}, // room 8 - {1, 8, 10}, // room 9 - {9, 11, 18}, // room 10 - {2, 10, 12}, // room 11 - {11, 13, 19}, // room 12 - {3, 12, 14}, // room 13 - {5, 13, 15}, // room 14 - {14, 16, 19}, // room 15 - {6, 15, 17}, // room 16 - {8, 16, 18}, // room 17 - {10, 17, 19}, // room 18 - {12, 15, 18}, // room 19 -} -``` - -### Initialization (`Init`) - -1. Randomly place: player, wumpus, 2 pits, 2 ICE — all in distinct rooms. -2. Set `probes = 5`. -3. Return welcome text + initial room description. - -Welcome text: -``` -=== HUNT THE ROGUE AI === - -You've jacked into an abandoned network. Somewhere in this maze of -20 nodes, a rogue AI lurks. You have 5 probes to find and neutralize it. - -Hazards: - - Rogue AI: Moves to an adjacent node if you enter its node. Kills you. - - Data Traps: Fall in and you're fried. 2 in the network. - - ICE: Grabs you and dumps you in a random node. 2 in the network. - -Commands: - move <node> - Move to an adjacent node (1-20) - shoot <node> - Launch a probe into an adjacent node (1-20) - status - Show your current status - map - Show network map - jack out - Disconnect (forfeit) -``` - -### Room Description - -On entering a room (or at start), display: - -``` ---- Node 7 --- -Tunnels lead to: 1, 7, 9 -``` - -Then hazard warnings for adjacent rooms: -- Wumpus adjacent: `"You detect corrupted data nearby..."` -- Pit adjacent: `"You sense a void in the network..."` -- ICE adjacent: `"You hear static crackling..."` - -### Commands - -**`move <N>`** (or just `<N>` as shorthand for move): - -1. Validate N is 1-20 and adjacent to current room (display uses 1-indexed, internal is 0-indexed). -2. Move player to room N-1. -3. Check hazards in new room: - - **Wumpus room**: Wumpus wakes up and moves to a random adjacent room (75% chance) or stays and kills player (25% chance). If it moves, player survives and sees "The rogue AI stirs and relocates..." + new warnings. - - **Pit room**: Player dies. "You've fallen into a data trap! Your connection is severed." `done=true, won=false`. - - **ICE room**: Player teleported to a random room. "ICE detected! You're relocated to a random node..." Then check the new room for hazards recursively (can chain into pit or wumpus). -4. Show new room description. - -**`shoot <N>`** (or `probe <N>`, `launch <N>`): - -1. Validate N is 1-20 and adjacent to current room. -2. Decrement probes. -3. If wumpus is in room N-1: "Your probe hits the rogue AI! It destabilizes and crashes." `done=true, won=true`. -4. If miss: "Your probe finds nothing in node N." Wumpus is startled and moves to a random adjacent room (75% chance). "You hear something shift in the network..." -5. If probes == 0: "You're out of probes. The rogue AI detects your presence and terminates your connection." `done=true, won=false`. - -**`status`**: -``` -Node: 7 | Probes: 4/5 -``` - -**`map`**: - -Display the full 20-node dodecahedron as a text map. Mark the player's current position with `[*]` and visited nodes with `[.]`. Unknown nodes shown as `[ ]`. - -Simple list format (since drawing a dodecahedron in ASCII is complex): -``` -=== Network Map === - [*] Node 7 -> 1, 7, 9 - [ ] Node 1 -> 1, 5, 8 - ... -``` - -Only show nodes the player has visited plus their current adjacencies. Unvisited non-adjacent nodes shown as `[ ] Node N -> ?, ?, ?`. - -### Win/Lose - -- **Win** (shot the wumpus): Award `BaseXPWin` (50 XP). -- **Lose** (fell in pit, eaten by wumpus, out of probes): Award `BaseXPLose` (10 XP). -- **Quit** (jack out before completion): Award `BaseXPLose` only if `Started == true` (player made at least one move/shot). - -### Constructor - -```go -func NewWumpusGame() *WumpusGame { - return &WumpusGame{} -} -``` - -The `Init` method does all setup using `math/rand`. - -## 9. Mastermind / Code Breaker (Level 20) - -File: `internal/game/hacking_mastermind.go` - -### Theme Reskin - -| Classic | Sci-Fi | -|---|---| -| Colored pegs | Cipher digits (1-6) | -| Code maker | Encryption module | -| Black pegs (exact) | Exact matches (locked) | -| White pegs (color match) | Partial matches (found) | - -### Struct - -```go -type MastermindGame struct { - code [4]int // secret code, digits 1-6, no repeats - guesses []MastermindGuess - maxGuess int // 10 - gameOver bool - won bool -} - -type MastermindGuess struct { - Digits [4]int - Exact int // digits in correct position - Partial int // correct digit, wrong position -} -``` - -### Initialization (`Init`) - -1. Generate a random 4-digit code using digits 1-6, no repeats. -2. Set `maxGuess = 10`. -3. Return welcome text. - -Welcome text: -``` -=== CODE BREAKER === - -The encryption module is running a 4-digit cipher using symbols 1-6. -No symbol repeats. You have 10 attempts to crack the code. - -After each guess, you'll see: - [X] = correct symbol in correct position - [O] = correct symbol in wrong position - [ ] = symbol not in code - -Commands: - <4 digits> - Guess the code (e.g., 1234) - status - Show previous guesses - jack out - Disconnect (forfeit) -``` - -### Commands - -**`<4 digits>`** (e.g., `1234`, `5621`): - -1. Validate: exactly 4 characters, each 1-6, no repeats. If invalid, show error and don't consume a guess. -2. Calculate exact matches and partial matches: - - Exact: digit is correct and in correct position. - - Partial: digit is in the code but wrong position. -3. Display the guess with feedback: - ``` - Attempt 3/10: 1 2 3 4 -> [X][ ][O][ ] (1 locked, 1 found) - ``` -4. If exact == 4: "Code cracked! The encryption module yields." `done=true, won=true`. -5. If guesses exhausted: "Out of attempts. The code was: 5 2 6 1. Connection terminated." `done=true, won=false`. - -**`status`**: - -Display all previous guesses with their feedback: -``` -=== Attempts === - 1: 1 2 3 4 -> [X][ ][O][ ] (1 locked, 1 found) - 2: 1 5 6 3 -> [X][O][ ][ ] (1 locked, 1 found) -Remaining: 8/10 -``` - -### Win/Lose + Bonus - -- **Win**: `BaseXPWin` (150 XP) + bonus for fewer guesses: `bonusXP = (maxGuess - guessesUsed) * 15`. Solving in 1 guess = 150 + 135 = 285 XP. Solving in 10 = 150 + 0 = 150 XP. -- **Lose**: `BaseXPLose` (30 XP). -- **Quit**: `BaseXPLose` if at least 1 guess was made. - -### Constructor - -```go -func NewMastermindGame() *MastermindGame { - return &MastermindGame{} -} -``` - -## 10. Liar's Dice / Signal Bluff (Level 40) - -File: `internal/game/hacking_liars_dice.go` - -### Theme Reskin - -| Classic | Sci-Fi | -|---|---| -| Dice | Signal fragments | -| Bid | Broadcast | -| Call (liar) | Intercept | -| Spot on | Exact match | -| Round | Cycle | - -### Concept - -Liar's Dice is a bluffing/deduction game. You and the AI each roll hidden dice. Players take turns making increasingly bold claims about the total dice showing a certain face across ALL dice (yours and the AI's combined). You can raise the bid or call the opponent a liar. If you call correctly, they lose a die. If you're wrong, you lose a die. Last player with dice wins. - -This works perfectly over telnet — just a few lines of text per turn. - -### Struct - -```go -type LiarsDiceGame struct { - playerDice []int // player's current dice (face values 1-6) - aiDice []int // AI's current dice (face values 1-6) - currentBid LiarsBid - playerTurn bool - round int - gameOver bool - won bool - lastAction string // for display context -} - -type LiarsBid struct { - Quantity int // "three" - Face int // "fours" (i.e., 3 dice showing 4) -} -``` - -### Rules - -- Both players start with 5 dice each (10 total). -- At the start of each round, all dice are re-rolled. You see only your own. -- The starting player makes a bid: "I claim there are at least N dice showing face F across all dice." -- The next player must either: - - **Raise**: Increase the quantity, OR keep the same quantity but increase the face value, OR increase both. - - **Call ("intercept")**: Challenge the bid. All dice are revealed. If the actual count >= the bid, the caller loses a die. If the actual count < the bid, the bidder loses a die. - - **Exact ("exact match")**: Claim the bid is EXACTLY right. If correct, the caller GAINS a die (up to max 5). If wrong, they lose a die. High risk, high reward. -- **Ones (1s) are wild** — they count as ANY face when tallying. This is the standard Liar's Dice rule and adds strategic depth. -- When a player loses all dice, they lose the game. -- Player who starts each round alternates. - -### Initialization (`Init`) - -1. Set `playerDice = make([]int, 5)`, `aiDice = make([]int, 5)`. -2. Roll all dice. -3. Player goes first in round 1. -4. Return welcome text + first round display. - -Welcome text: -``` -=== SIGNAL BLUFF === - -You've connected to a secure channel running a bluffing protocol. -You and the system each have 5 signal fragments (dice). Each round, -fragments are scrambled. You see only yours. Take turns making claims -about the total count of a specific signal across ALL fragments. - -1s are wild — they count as any signal. - -Commands: - bid <qty> <face> - Claim at least N fragments show face F - (e.g., 'bid 3 4' = "at least three 4s") - call - Challenge the last bid (reveal all) - exact - Claim the bid is exactly right - status - Show your fragments and current bid - jack out - Disconnect (forfeit) -``` - -### Round Start Display - -``` ---- Cycle 1 --- -Your fragments: [3] [1] [5] [3] [6] (You: 5, System: 5) -You go first. Make a bid. -``` - -### Commands - -**`bid <qty> <face>`** (or `b <qty> <face>`, or just `<qty> <face>`): - -1. Validate: qty >= 1, face 1-6. -2. Validate the bid is higher than the current bid: - - Higher quantity with any face, OR - - Same quantity with higher face. - - Exception: bids on 1s follow special rules (since 1s are wild, bidding on 1s requires half the quantity, rounded up — standard Liar's Dice convention). **Simplification: skip the special 1s bidding rule. 1s are wild for counting but bidding on 1s works like any other face.** This keeps it simpler. -3. Set `currentBid = {qty, face}`. -4. AI takes its turn (see AI section). - -**`call`** (or `c`, `intercept`, `liar`): - -1. Can only be used when there's an active bid from the AI. -2. Reveal all dice. -3. Count total dice showing `currentBid.Face` across both players' dice, plus all 1s (wild). -4. Display: - ``` - You intercept! - Your fragments: [3] [1] [5] [3] [6] - System fragments: [2] [4] [4] [1] [4] - - Bid was: 4 fours - Actual count: 4 (three 4s + one wild 1 from system) - - The bid holds! You lose a fragment. - ``` - Or: - ``` - Actual count: 2 (one 4 + one wild 1) - - Bluff called! System loses a fragment. - ``` -5. Loser removes one die. Check for game over. -6. Start new round. - -**`exact`** (or `e`, `exact match`): - -1. Can only be used when there's an active bid from the AI. -2. Reveal all dice. Count as above. -3. If count == bid quantity exactly: Caller gains one die (up to 5). "Exact match! You recover a fragment." -4. If count != bid quantity: Caller loses one die. "Not an exact match. You lose a fragment." -5. Start new round. - -**`status`**: -``` ---- Cycle 3 --- -Your fragments: [3] [1] [5] (You: 3, System: 4) -Current bid: 3 fives (by System) -Your turn: bid higher, call, or exact. -``` - -### AI Strategy - -The AI should be competent but beatable. Implement a simple probabilistic strategy: - -```go -func (g *LiarsDiceGame) aiTurn() string { - totalDice := len(g.playerDice) + len(g.aiDice) - - // Count how many of the current bid face the AI has (including wilds) - aiCount := countFace(g.aiDice, g.currentBid.Face) - - // Estimate total: AI's known count + expected from unknown dice - // Expected per unknown die: 1/3 chance (1/6 for the face + 1/6 for wild 1) - unknownDice := len(g.playerDice) - estimated := float64(aiCount) + float64(unknownDice) / 3.0 - - // If current bid seems unreasonable, call - if float64(g.currentBid.Quantity) > estimated * 1.5 { - // Call - return g.resolveCall(false) // false = AI is calling - } - - // Otherwise, raise the bid - // Try to bid on a face the AI has many of - bestFace, bestCount := g.aiBestFace() - newQty := g.currentBid.Quantity - newFace := g.currentBid.Face - - if bestFace > newFace { - newFace = bestFace - } else { - newQty++ - newFace = bestFace - } - - // Sanity check: don't bid impossibly high - if newQty > totalDice { - return g.resolveCall(false) // forced to call - } - - g.currentBid = LiarsBid{newQty, newFace} - g.playerTurn = true - return fmt.Sprintf("System broadcasts: %d %ss.\nYour turn: bid higher, call, or exact.", - newQty, faceName(newFace)) -} -``` - -Add some randomness to the AI's bluffing — occasionally it bluffs high, occasionally it calls when it shouldn't. Scale the AI's skill with the player's hacking level for replayability. - -```go -func (g *LiarsDiceGame) aiBestFace() (face int, count int) { - counts := make(map[int]int) - for _, d := range g.aiDice { - if d == 1 { // wilds count for everything, skip - continue - } - counts[d]++ - } - wilds := 0 - for _, d := range g.aiDice { - if d == 1 { - wilds++ - } - } - best := 2 // never bid on 1s voluntarily - bestN := counts[2] + wilds - for f := 3; f <= 6; f++ { - n := counts[f] + wilds - if n > bestN || (n == bestN && f > best) { - best = f - bestN = n - } - } - return best, bestN -} -``` - -### Face Names Helper - -```go -func faceName(f int) string { - names := []string{"", "one", "two", "three", "four", "five", "six"} - return names[f] -} - -func faceNamePlural(f int) string { - names := []string{"", "ones", "twos", "threes", "fours", "fives", "sixes"} - return names[f] -} -``` - -### Win/Lose - -- **Win** (AI runs out of dice): `BaseXPWin` (300 XP). -- **Lose** (player runs out of dice): `BaseXPLose` (50 XP). -- **Quit**: `BaseXPLose` if at least one round was completed. -- **Bonus**: If the player wins without ever losing a die (perfect game), award 1.5x XP. Implement via `XPBonuser` interface: - -```go -func (g *LiarsDiceGame) BonusXP() int { - if len(g.playerDice) == 5 { - return 150 // 50% bonus for perfect game - } - return 0 -} -``` - -### Constructor - -```go -func NewLiarsDiceGame() *LiarsDiceGame { - return &LiarsDiceGame{} -} -``` - -## 11. XP Scaling - -XP is calculated in `endHacking()` in `hacking.go`: - -```go -func hackingXP(baseXP int, playerLevel int, reqLevel int) int { - multiplier := 1.0 + 0.01*float64(playerLevel-reqLevel) - if multiplier < 1.0 { - multiplier = 1.0 - } - return int(float64(baseXP) * multiplier) -} -``` - -- A level 1 player on the basic terminal (req 1): `50 * 1.0 = 50 XP` -- A level 30 player on the basic terminal (req 1): `50 * 1.29 = 64 XP` -- A level 20 player on the advanced terminal (req 20): `150 * 1.0 = 150 XP` -- A level 50 player on the advanced terminal (req 20): `150 * 1.30 = 195 XP` - -The scaling is mild — it mainly incentivizes unlocking harder terminals rather than grinding low-level ones forever. - -XP is awarded using the same pattern as other skills: - -```go -p.GainXP(player.Hacking, xp) -``` - -And level-up messages use the same `xpGain` pattern: - -```go -g.awardXP(sess, []xpGain{{Skill: string(player.Hacking), XP: xp}}) -``` - -Find the existing `awardXP` helper (used by gathering, combat, production) and call it the same way. - -## 12. Hacking Rumors NPC — "Netrunner" - -### `data/objects/netrunner.yaml` - -```yaml -id: netrunner -name: Netrunner -color: "39" -behavior: netrunner_talk -description: "A wiry figure hunched over a custom rig, fingers dancing across holographic keys. Cables snake from the back of their neck into the terminal. They glance up at you with augmented eyes." -inroom_description: "A {39}Netrunner{/} sits cross-legged on the floor, wired into the network." -``` - -### `data/behaviors/netrunner_talk.yaml` - -```yaml -id: netrunner_talk -type: talk -nodes: - start: - message: "The Netrunner looks up. \"You want in on the network? I can give you some pointers.\"" - options: - - text: "\"What is hacking?\"" - goto: what_is - - text: "\"Where can I find terminals?\"" - goto: terminals - - text: "\"Any tips for the basic terminal?\"" - goto: tips_basic - - text: "\"What about the advanced terminal?\"" - goto: tips_advanced - condition: - player_flag: hacking_asked_basic - value: true - - text: "\"Tell me about the secure terminal.\"" - goto: tips_secure - condition: - player_flag: hacking_asked_advanced - value: true - - text: "\"Goodbye.\"" - end: true - what_is: - message: "\"Hacking's about jacking into terminals and running their gauntlets. Each terminal runs a different challenge — puzzles, hunts, pattern games. Crack 'em and you get better at it. Simple as that.\"" - options: - - text: "\"Where can I find terminals?\"" - goto: terminals - - text: "\"Goodbye.\"" - end: true - terminals: - message: "\"There's a basic terminal right here in this lab — good for beginners. The station down east has an advanced one if you've got the chops. And if you're really good, there's a secure terminal deep in the compound. You'll need the skills to match though.\"" - options: - - text: "\"Any tips?\"" - goto: tips_basic - - text: "\"Thanks.\"" - end: true - tips_basic: - message: "\"The basic terminal runs an old rogue AI hunt. Twenty nodes in a network. You've got probes to flush it out. Listen for the warnings — corrupted data means it's close, voids mean a trap, static means ICE. Shoot into adjacent nodes, don't walk into danger.\"" - action: - set_player_flags: - hacking_asked_basic: true - options: - - text: "\"What about the advanced terminal?\"" - goto: tips_advanced - - text: "\"Thanks.\"" - end: true - tips_advanced: - message: "\"Advanced terminal runs a cipher crack. Four symbols, no repeats. You get feedback after each guess — locked means right spot, found means right symbol wrong spot. Process of elimination. Ten tries.\"" - action: - set_player_flags: - hacking_asked_advanced: true - options: - - text: "\"Tell me about the secure terminal.\"" - goto: tips_secure - - text: "\"Thanks.\"" - end: true - tips_secure: - message: "\"Secure terminal? Signal Bluff. It's a bluffing game — you and the system each roll hidden dice and take turns making claims about the total. Ones are wild. Call the bluff or raise the stakes. Read the probabilities, play the odds, and don't let it psych you out.\"" - options: - - text: "\"Thanks.\"" - end: true -``` - -## 13. Rooms - -### Updated `data/rooms/8.yaml` - -```yaml -id: 8 -name: "Hacking Lab" -description: "A dimly lit room filled with old terminals and flickering screens. Cables dangle from the ceiling, some still sparking. The air smells of ozone and burnt circuitry. A {40}basic terminal{/} against the far wall still has power." -exits: - east: 9 - west: 6 -objects: - - id: terminal_basic - - id: netrunner -``` - -### Terminal Placements in Other Rooms - -Place the advanced and secure terminals in existing rooms. Exact room IDs depend on the world layout, but suggested placements: - -- `terminal_advanced` — place in a room that feels like a more secure/advanced area. Add to a room's `objects:` list. Example: a room in the production district or deeper in the compound. -- `terminal_secure` — place in a high-level area or behind a condition-gated exit. - -For now, if no appropriate rooms exist yet, create placeholder rooms or add them to existing higher-level areas. Minimum viable: all three terminals can be in room 8 for testing, then moved later: - -```yaml -# For testing, room 8 can have all three: -objects: - - id: terminal_basic - - id: terminal_advanced - - id: terminal_secure - - id: netrunner -``` - -## 14. Display - -All output uses `sess.WriteLine()` for lines and `sess.Write()` for prompts without newlines. - -### Hacking Prompt - -While in StateHacking, after each minigame output, display a custom prompt: - -```go -sess.Write("\nhack> ") -``` - -This replaces the normal game prompt while jacked in. - -### Colorization - -Use the existing `colorize()` helper with color targets. Suggested color usage: - -- Terminal name: use the object's color field -- Minigame title/headers: `{39}` (cyan) or `{40}` (green) -- Warnings in Wumpus: `{196}` (red) for wumpus, `{208}` (orange) for pits, `{226}` (yellow) for ICE -- Mastermind exact match `[X]`: `{40}` (green) -- Mastermind partial match `[O]`: `{226}` (yellow) -- Mastermind miss `[ ]`: default -- Liar's Dice bid announcement: `{33}` (cyan) -- Liar's Dice call result (win): `{40}` (green) -- Liar's Dice call result (lose): `{196}` (red) -- Liar's Dice dice display `[N]`: `{230}` (yellow-white) - -Use `g.colorize(sess, ...)` or raw inline color tags in output strings. Since minigames return plain strings, either: -1. Return strings with inline `{N}text{/}` tags and have `handleHackingInput` pass them through the color system before writing, OR -2. Minigames return raw strings and the handler colorizes key parts. - -Option 1 is simpler. The `sess.WriteLine()` path should already support inline tags if the existing `colorize`/`colorTag` system is used. Check how room descriptions handle inline tags — the same mechanism applies. - -### Wumpus Map Display - -For the `map` command in Wumpus, display a simple node list: - -``` -=== Network Map === - Node 1: -> 2, 5, 8 - Node 2: -> 1, 3, 10 - ... - Node 20: -> 13, 16, 19 - -You are at Node 7. -``` - -Mark the player's node with `[*]`. Optionally track visited nodes and mark them differently, but this is not required for v1. - -### Mastermind Guess Display - -``` -=== Code Breaker === -Attempt 1/10: 1 2 3 4 -> {40}[X]{/} [ ] {226}[O]{/} [ ] (1 locked, 1 found) -Attempt 2/10: 5 6 1 2 -> [ ] {226}[O]{/} [ ] {226}[O]{/} (0 locked, 2 found) -``` - -### Liar's Dice Display - -All output is simple text lines — no grid needed: - -``` ---- Cycle 2 --- -Your fragments: [3] [1] [5] [3] (You: 4, System: 5) -System broadcasts: 3 fours. -Your turn: bid higher, call, or exact. -``` - -On a call: -``` -You intercept! -Your fragments: [3] [1] [5] [3] -System fragments: [2] [4] [4] [1] [4] - -Bid was: 3 fours -Actual count: 4 (three 4s + one wild 1) - -The bid holds! You lose a fragment. -``` - -## 15. State Management - -### Full Flow - -``` -1. Player types: jack terminal -2. handleGameCommand -> classifyCommand("jack") -> ClassActive -> queued -3. ProcessQueuedCommands -> executeCommand -> doJack() -4. doJack(): - a. Find terminal object in room (use World.FindObjInstances or check objects list) - b. Look up TerminalDef by object ID - c. Check hacking level >= required level - d. Check player not in combat - e. Cancel any active action - f. Create HackingSession, store in hackingStates[p.Name] - g. Set p.ActionState = &ActionState{Type: ActionHacking, TargetName: terminalDef.Name} - h. Set sess.State = net.StateHacking - i. Call minigame.Init(level), write output to session - j. Write "hack> " prompt -5. Player input now routes to handleHackingInput() -6. handleHackingInput(): - a. Check for "jack out" / "quit" -> endHacking(sess, false, false) - b. Otherwise: call minigame.HandleInput(input) - c. Write output - d. If done: endHacking(sess, true, won) - e. Else: write "hack> " prompt -7. endHacking(): - a. Calculate XP (win or lose) - b. Award XP via awardXP() - c. Clear hackingStates[p.Name] - d. Clear p.ActionState - e. Set sess.State = net.StateGame - f. Write summary message - g. Write normal game prompt -``` - -### `cmd_jack.go` — Full Implementation Spec - -```go -func (g *Game) doJack(sess *net.Session, target string) { - p := sess.Player.(*player.Player) - - if combat.GetCombat(p.Name) != nil { - sess.WriteLine("You can't do that during combat!") - return - } - - // If target is empty, auto-detect: find any terminal_* object in the room - if target == "" { - target = g.findTerminalInRoom(p.RoomID) - if target == "" { - sess.WriteLine("There's no terminal here to jack into.") - return - } - } - - // Find matching object in room - instances := g.World.FindObjInstances(p.RoomID, strings.ToLower(target)) - if len(instances) == 0 { - sess.WriteLine("You don't see that here.") - return - } - - // Check if it's a terminal - defID := instances[0].DefID - tDef, ok := terminalDefs[defID] - if !ok { - sess.WriteLine("You can't jack into that.") - return - } - - // Check hacking level - hackLevel := p.SkillLevel(player.Hacking) - if hackLevel < tDef.Level { - sess.WriteLine(fmt.Sprintf("You need level %d Hacking to use this terminal.", tDef.Level)) - return - } - - // Start minigame - minigame := tDef.Minigame() - display := minigame.Init(hackLevel) - - g.hackingStates[p.Name] = &HackingSession{ - Minigame: minigame, - TerminalID: defID, - Level: hackLevel, - ReqLevel: tDef.Level, - Started: false, - } - - p.ActionState = &ActionState{Type: ActionHacking, TargetName: tDef.Name} - sess.State = net.StateHacking - - // Broadcast to room - g.broadcast(sess, p.RoomID, fmt.Sprintf("%s jacks into a %s.", p.Name, tDef.Name)) - - sess.WriteLine(display) - sess.Write("\nhack> ") -} -``` - -### `hacking.go` — `handleHackingInput` Implementation Spec - -```go -func (g *Game) handleHackingInput(sess *net.Session, input string) { - p, ok := sess.Player.(*player.Player) - if !ok { - sess.State = net.StateGame - return - } - - hs, ok := g.hackingStates[p.Name] - if !ok { - sess.State = net.StateGame - g.writePrompt(sess) - return - } - - input = strings.TrimSpace(input) - lower := strings.ToLower(input) - - if lower == "jack out" || lower == "quit" || lower == "disconnect" { - g.endHacking(sess, p, false, false) - return - } - - hs.Started = true - output, done, won := hs.Minigame.HandleInput(input) - - sess.WriteLine(output) - - if done { - g.endHacking(sess, p, true, won) - return - } - - sess.Write("\nhack> ") -} - -func (g *Game) endHacking(sess *net.Session, p *player.Player, completed bool, won bool) { - hs, ok := g.hackingStates[p.Name] - if !ok { - sess.State = net.StateGame - g.writePrompt(sess) - return - } - - tDef := terminalDefs[hs.TerminalID] - var xp int - - if completed && won { - xp = hackingXP(tDef.BaseXPWin, hs.Level, hs.ReqLevel) - sess.WriteLine(fmt.Sprintf("\nConnection terminated. Contract complete.")) - } else if completed { - xp = hackingXP(tDef.BaseXPLose, hs.Level, hs.ReqLevel) - sess.WriteLine(fmt.Sprintf("\nConnection lost.")) - } else if hs.Started { - xp = hackingXP(tDef.BaseXPLose, hs.Level, hs.ReqLevel) - sess.WriteLine("\nYou jack out of the terminal.") - } else { - sess.WriteLine("\nYou jack out of the terminal.") - } - - // Handle bonus XP for Mastermind (fewer guesses) - // The minigame can embed bonus info — but simpler to just use baseXP. - // For Mastermind, override: the HandleInput returns the bonus data via naming convention - // or we add a BonusXP() method to the interface. See Alternative below. - - if xp > 0 { - g.awardXP(sess, []xpGain{{Skill: string(player.Hacking), XP: xp}}) - } - - // Broadcast to room - g.broadcast(sess, p.RoomID, fmt.Sprintf("%s disconnects from the terminal.", p.Name)) - - delete(g.hackingStates, p.Name) - p.ActionState = nil - sess.State = net.StateGame - g.writePrompt(sess) -} -``` - -### Bonus XP for Mastermind - -Add an optional method to the interface (or use a concrete type assertion): - -```go -type XPBonuser interface { - BonusXP() int -} -``` - -In `endHacking`, after calculating base XP: - -```go -if won { - if b, ok := hs.Minigame.(XPBonuser); ok { - xp += hackingXP(b.BonusXP(), hs.Level, hs.ReqLevel) - } -} -``` - -Mastermind implements `BonusXP()`: - -```go -func (m *MastermindGame) BonusXP() int { - return (m.maxGuess - len(m.guesses)) * 15 -} -``` - -### Disconnect Cleanup - -In `Game.SetHub`'s `OnRemove` callback, after the existing player cleanup: - -```go -if p, ok := sess.Player.(*player.Player); ok { - delete(g.hackingStates, p.Name) -} -``` - -### Combat Interruption - -If a mob wanders into the room and attacks the player while jacked in, the hacking session should be forcibly ended. In the mob attack initiation code, check if the player is in `StateHacking` and call `endHacking` first. Alternatively, since mobs only attack via combat initiation and the player isn't in `StateGame`, mobs should not be able to initiate combat with a jacked-in player. This is the simpler approach — **mobs do not attack jacked-in players** (the terminal provides safety, lore-wise the player's physical body is in a protected alcove). - -## 16. Help Files - -### `data/help/jack.yaml` - -```yaml -name: "jack" -category: "Commands" -description: | - Jack into a terminal to start a hacking minigame. - - Usage: jack [terminal] - - Connects to a hacking terminal in the room and starts its - minigame. Each terminal type offers a different challenge - and requires a minimum Hacking level. - - If there's only one terminal in the room, you can just type 'jack'. - - While jacked in, all input goes to the minigame. Type 'jack out' - or 'quit' to disconnect early. - - Terminal types: - Basic terminal - Level 1 - Hunt the Rogue AI - Advanced terminal - Level 20 - Code Breaker - Secure terminal - Level 40 - Signal Bluff - - See also: help hacking -``` - -### `data/help/hacking.yaml` - -```yaml -name: "hacking" -category: "Skills" -description: | - Hacking is trained by jacking into terminals and completing - their minigames. Different terminals offer different challenges - at different difficulty levels. - - Find terminals scattered around the world. Use the 'jack' command - to connect. Each terminal runs a unique puzzle — solve it for XP. - Winning gives full XP; losing or quitting early gives a small - consolation amount. - - Talk to the Netrunner in the Hacking Lab for tips on each - terminal's challenge. - - XP scales slightly with your Hacking level above the terminal's - requirement, but the main progression is unlocking harder terminals - with bigger rewards. - - See also: help jack -``` - -## 17. Implementation Order - -Recommended order for an implementing agent: - -1. **`internal/net/server.go`** — Add `StateHacking` constant. -2. **`internal/game/action_state.go`** — Add `ActionHacking` constant and `Description()` case. -3. **`internal/game/hacking.go`** — Create file with `HackingMinigame` interface, `HackingSession` struct, `terminalDefs` map, `handleHackingInput()`, `endHacking()`, `hackingXP()`, `XPBonuser` interface. -4. **`internal/game/hacking_wumpus.go`** — Implement `WumpusGame`. -5. **`internal/game/hacking_mastermind.go`** — Implement `MastermindGame`. -6. **`internal/game/hacking_liars_dice.go`** — Implement `LiarsDiceGame`. -7. **`internal/game/cmd_jack.go`** — Implement `doJack()` and `findTerminalInRoom()`. -8. **`internal/game/game.go`** — Add `hackingStates` field, init in `New()`, add `StateHacking` case in `HandleSession`, add `jack`/`jackin` to `classifyCommand` and `executeCommand`, add disconnect cleanup in `SetHub`. -9. **Data files** — Create all YAML files (objects, behaviors, help, update room 8). -10. **Test** — `make build && make vet` to verify compilation. Manual testing via telnet. - -## 18. Testing Checklist - -- [ ] `make build` compiles without errors -- [ ] `make vet` passes -- [ ] `make test` passes (no existing tests break) -- [ ] Player can `jack` into basic terminal in room 8 -- [ ] Wumpus game: move, shoot, status, map commands work -- [ ] Wumpus game: winning awards full XP -- [ ] Wumpus game: losing awards consolation XP -- [ ] `jack out` exits minigame cleanly -- [ ] Player returns to normal `StateGame` after minigame -- [ ] Level check prevents low-level players from using advanced terminals -- [ ] Player's ActionState shows "jacked into a Hunt the Wumpus" in `look` -- [ ] Disconnect while jacked in cleans up state -- [ ] Mastermind: guessing, feedback, win/lose, bonus XP -- [ ] Liar's Dice: bidding, calling, exact, AI turns, win/lose -- [ ] Netrunner NPC responds to `talk netrunner` -- [ ] `help jack` and `help hacking` display correctly -- [ ] XP scaling formula applies correctly - -## 19. Future Expansion - -Additional minigames can be added by: -1. Creating a new `hacking_<name>.go` file implementing `HackingMinigame` -2. Creating a new `terminal_<name>.yaml` object -3. Adding an entry to `terminalDefs` - -Potential future minigames: -- **Blackjack / Data Assembly** (Level 60): Assemble data packets to exactly 21 without busting -- **Logic Gates** (Level 70): Solve a boolean circuit puzzle -- **Stock Trading / Market Exploit** (Level 50): Buy/sell commodities over N rounds, maximize profit -- **Number Maze** (Level 80): Navigate a grid where each cell tells you how far you can jump - -The interface is simple enough that adding new minigames is self-contained — no changes to the core hacking infrastructure needed. diff --git a/skill_plans/pharmacy.md b/skill_plans/pharmacy.md deleted file mode 100644 index 5e0b7af..0000000 --- a/skill_plans/pharmacy.md +++ /dev/null @@ -1,2421 +0,0 @@ -# Pharmacy Skill Implementation Plan - -## 1. Overview - -Pharmacy replaces the Alchemy skill everywhere in the codebase. It is a production skill centered on creating potions from herbs and reagents. - -**Production chain:** -1. **Obtain grimy herbs** -- mob drops, farming (future), or ground spawns -2. **Clean herbs** (`clean` command) -- background action, awards Pharmacy XP, level-gated per herb -3. **Create unfinished potions** (`use` command) -- combine clean herb + vial of water via MadeFrom (no XP, no level req) -4. **Finish potions** (`mix` command) -- combine unfinished potion + reagent via recipe system, awards Pharmacy XP, level-gated - -**Potion effects are NOT implemented yet.** Potions are items only. A future update will add consumable effects (stat boosts, healing, curing). For now, potions have `description` text describing their intended effect and a `value` for trading. - ---- - -## 2. Skill Rename (Alchemy -> Pharmacy) - -### Files to change - -**`internal/player/player.go`** -- 3 locations: -- Line 24: `Alchemy SkillName = "alchemy"` -> `Pharmacy SkillName = "pharmacy"` -- Line 37: `Alchemy, Thieving,` -> `Pharmacy, Thieving,` -- Line 56: `Alchemy: "alc",` -> `Pharmacy: "pha",` - -**`data/help/` YAML files** -- if any reference "alchemy" in text, update to "pharmacy". Currently none exist for alchemy, so no changes needed. New help files will be created. - -**`AGENTS.md`** -- Update the Skills table: -- Change `Alchemy` to `Pharmacy` in the Production skills row -- Update the `SkillAbbr` reference from `"alc"` to `"pha"` - -**No other Go files reference "alchemy" or "Alchemy" directly** (confirmed by grep). The skill constant `Alchemy` is only used in `player.go` in 3 places. - -**Existing player save files** -- Any character YAML files with `alchemy: <xp>` in their skills map will need the key renamed to `pharmacy`. Add a migration note: on load, if `skills` map contains key `"alchemy"`, copy its value to `"pharmacy"` and delete the old key. Add this to `player.go` or to the account loading logic. - -### Migration code (in `internal/player/` account loader or `New()`) - -After unmarshaling a Player from YAML, add: -```go -if xp, ok := p.Skills["alchemy"]; ok { - p.Skills[Pharmacy] = xp - delete(p.Skills, "alchemy") -} -``` - -Place this in the `AccountStore.LoadCharacter()` function (or wherever character YAML is deserialized) right after the unmarshal call. - ---- - -## 3. Commands - -### `clean` -- Free command (background action) - -| Property | Value | -|---|---| -| Classification | `ClassFree` | -| Verb | `clean` | -| Action type | Background (like `fletch`) | -| ActionType const | `ActionCleaning` | -| Station required | No | -| Tick interval | 2 ticks per herb | -| Cancels active action | No | -| Cancels background action | Yes (replaces fletching etc.) | - -**Usage:** -- `clean` -- Start cleaning all grimy herbs in inventory (auto-selects, processes one every 2 ticks) -- `clean <herb>` -- Clean only grimy herbs matching `<herb>` (e.g., `clean guam` cleans only grimy guam) - -**Behavior:** The player types `clean`. The command scans inventory for any item that has a `clean` type recipe. It starts a background action that cleans one herb every 2 ticks. After each herb is cleaned, it scans for the next. It stops when no more grimy herbs remain (or no more that match the filter). Outputs a message per herb cleaned. Can be done while walking, fighting, etc. (background action). - -### `mix` -- Active command (production) - -| Property | Value | -|---|---| -| Classification | `ClassActive` | -| Verb | `mix` | -| Action type | Active (uses standard production) | -| ActionType key | `"mix"` in productionTypes | -| Station required | No | -| Tick interval | Per recipe `wait` field (default 4) | - -**Usage:** -- `mix` -- Show table of available pharmacy recipes, select by number/name -- `mix <potion>` -- Mix a specific potion -- `mix 10 <potion>` -- Mix a specific quantity - -**Behavior:** Works like `cook` but with no station requirement. Shows a production table of all `pharmacy` type recipes the player can make. Uses standard production cycle (start message, timed loop, success check, XP, output). - -### Command dispatch additions - -**`classifyCommand()` in `game.go`:** -```go -case "eat", "fletch", "clean": - return ClassFree -``` -And: -```go -case "quit", "use", "burn", "stoke", "search", "walk", "cook", "smelt", "smith", "craft", "mix": - return ClassActive -``` - -**`executeCommand()` in `game.go`:** -Add two new cases: -```go -case "clean": - g.doClean(sess, strings.Join(args, " ")) - return -case "mix": - g.doMix(sess, strings.Join(args, " ")) - return -``` - -### Option additions - -Add to `OptionDefs` in `player.go`: -```go -{"mix_all", OptBool, false, nil, "Auto-start mixing when only one product is possible"}, -``` - ---- - -## 4. New Files to Create - -### Go files (in `internal/game/`) - -| File | Purpose | -|---|---| -| `cmd_clean.go` | `doClean()` handler -- find grimy herbs, start background clean action | -| `cmd_mix.go` | `doMix()` handler -- find pharmacy recipes, show table or start production | -| `action_clean.go` | `startCleanAction()`, `advanceClean()` -- background action lifecycle | - -### YAML data files - -| Path | Count | Description | -|---|---|---| -| `data/items/grimy_*.yaml` | 14 | Grimy herb items | -| `data/items/<herb>.yaml` | 14 | Clean herb items (guam, marrentill, etc.) | -| `data/items/vial_of_water.yaml` | 1 | Vial of water | -| `data/items/vial.yaml` | 1 | Empty vial | -| `data/items/*_unf.yaml` | 14 | Unfinished potions | -| `data/items/<potion>.yaml` | 16 | Finished potions | -| `data/items/<reagent>.yaml` | ~11 | Secondary ingredients | -| `data/recipes/clean_*.yaml` | 14 | Clean recipes (type: clean) | -| `data/recipes/mix_*.yaml` | 16 | Mix recipes (type: pharmacy) | -| `data/help/clean.yaml` | 1 | Help for clean command | -| `data/help/mix.yaml` | 1 | Help for mix command | -| `data/help/pharmacy.yaml` | 1 | Help for pharmacy skill | - -**Total: ~102 new YAML files, 3 new Go files.** - ---- - -## 5. Code Changes to Existing Files - -### `internal/player/player.go` - -1. **Rename constant** (line 24): - ``` - OLD: Alchemy SkillName = "alchemy" - NEW: Pharmacy SkillName = "pharmacy" - ``` - -2. **Update AllSkills** (line 37): - ``` - OLD: Alchemy, Thieving, Agility, Construction, Scavenging, Hacking, Assassin, Farming, - NEW: Pharmacy, Thieving, Agility, Construction, Scavenging, Hacking, Assassin, Farming, - ``` - -3. **Update SkillAbbr** (line 56): - ``` - OLD: Alchemy: "alc", - NEW: Pharmacy: "pha", - ``` - -4. **Add option** to `OptionDefs` slice (after `smelt_all`): - ```go - {"mix_all", OptBool, false, nil, "Auto-start mixing when only one product is possible"}, - ``` - -5. **Add migration** in the character loading path. In `internal/player/account.go` (or wherever `LoadCharacter` unmarshals), after unmarshal add: - ```go - if xp, ok := p.Skills["alchemy"]; ok { - p.Skills[Pharmacy] = xp - delete(p.Skills, "alchemy") - } - ``` - -### `internal/game/game.go` - -1. **`classifyCommand()`** -- Add `"clean"` to the `ClassFree` case and `"mix"` to the `ClassActive` case: - ```go - case "eat", "fletch", "clean": - return ClassFree - ``` - ```go - case "quit", "use", "burn", "stoke", "search", "walk", "cook", "smelt", "smith", "craft", "mix": - return ClassActive - ``` - -2. **`executeCommand()`** -- Add two cases before the `default:` block: - ```go - case "clean": - g.doClean(sess, strings.Join(args, " ")) - return - case "mix": - g.doMix(sess, strings.Join(args, " ")) - return - ``` - -### `internal/game/action_state.go` - -1. **Add ActionType constant:** - ```go - ActionCleaning ActionType = "cleaning" - ActionMixing ActionType = "mixing" - ``` - -2. **Add Description() cases:** - ```go - case ActionCleaning: - return "cleaning herbs" - case ActionMixing: - return "mixing " + a.TargetName - ``` - -### `internal/game/action.go` - -1. **Add `"clean"` to the background action advance switch** (in `AdvanceActions`, around line 254): - ```go - switch p.BackgroundAction.Type { - case "fletch": - g.advanceFletch(sess, p) - case "clean": - g.advanceClean(sess, p) - } - ``` - -### `internal/game/action_production.go` - -1. **Add pharmacy to `productionTypes` map:** - ```go - var productionTypes = map[string]productionTypeInfo{ - "cooking": {"cook", "cooking"}, - "smelting": {"smelt", "smelting"}, - "smithing": {"smith", "smithing"}, - "crafting": {"craft", "crafting"}, - "combine": {"combine", "combining"}, - "fletching": {"fletch", "fletching"}, - "pharmacy": {"mix", "mixing"}, - } - ``` - -### `internal/object/item.go` - -No changes needed. Grimy herbs and clean herbs are normal items. The `clean_into` relationship is encoded in the recipe system, not on ItemDef. - -### `internal/net/server.go` - -No new session states needed. The `mix` command uses existing `StateProductChoice` and `StateHowMany`. The `clean` command doesn't prompt -- it just starts. - -### `AGENTS.md` - -Update all references from "Alchemy" to "Pharmacy" and "alc" to "pha". Update the Skills table, the Adding a New Production Skill section, the Commands table (add `clean` and `mix`), and the Action Types table (add `cleaning` and `mixing`). - ---- - -## 6. Herbs -- Full Item YAML - -All herbs share a common pattern. Grimy herbs are uncleaned; clean herbs are the output. - -### Grimy Herbs - -Each grimy herb follows this template: - -```yaml -id: grimy_<name> -name: "grimy <display_name>" -color: "65" -description: "A grimy <display_name> leaf. It needs to be cleaned before it can be used." -value: <value> -stackable: false -``` - -#### `data/items/grimy_guam.yaml` -```yaml -id: grimy_guam -name: "grimy guam" -color: "65" -description: "A grimy guam leaf. It needs to be cleaned before it can be used." -value: 2 -stackable: false -``` - -#### `data/items/grimy_marrentill.yaml` -```yaml -id: grimy_marrentill -name: "grimy marrentill" -color: "65" -description: "A grimy marrentill leaf. It needs to be cleaned before it can be used." -value: 4 -stackable: false -``` - -#### `data/items/grimy_tarromin.yaml` -```yaml -id: grimy_tarromin -name: "grimy tarromin" -color: "65" -description: "A grimy tarromin leaf. It needs to be cleaned before it can be used." -value: 7 -stackable: false -``` - -#### `data/items/grimy_harralander.yaml` -```yaml -id: grimy_harralander -name: "grimy harralander" -color: "65" -description: "A grimy harralander leaf. It needs to be cleaned before it can be used." -value: 12 -stackable: false -``` - -#### `data/items/grimy_ranarr.yaml` -```yaml -id: grimy_ranarr -name: "grimy ranarr" -color: "65" -description: "A grimy ranarr leaf. It needs to be cleaned before it can be used." -value: 25 -stackable: false -``` - -#### `data/items/grimy_toadflax.yaml` -```yaml -id: grimy_toadflax -name: "grimy toadflax" -color: "65" -description: "A grimy toadflax leaf. It needs to be cleaned before it can be used." -value: 18 -stackable: false -``` - -#### `data/items/grimy_irit.yaml` -```yaml -id: grimy_irit -name: "grimy irit" -color: "65" -description: "A grimy irit leaf. It needs to be cleaned before it can be used." -value: 30 -stackable: false -``` - -#### `data/items/grimy_avantoe.yaml` -```yaml -id: grimy_avantoe -name: "grimy avantoe" -color: "65" -description: "A grimy avantoe leaf. It needs to be cleaned before it can be used." -value: 35 -stackable: false -``` - -#### `data/items/grimy_kwuarm.yaml` -```yaml -id: grimy_kwuarm -name: "grimy kwuarm" -color: "65" -description: "A grimy kwuarm leaf. It needs to be cleaned before it can be used." -value: 40 -stackable: false -``` - -#### `data/items/grimy_snapdragon.yaml` -```yaml -id: grimy_snapdragon -name: "grimy snapdragon" -color: "65" -description: "A grimy snapdragon leaf. It needs to be cleaned before it can be used." -value: 50 -stackable: false -``` - -#### `data/items/grimy_cadantine.yaml` -```yaml -id: grimy_cadantine -name: "grimy cadantine" -color: "65" -description: "A grimy cadantine leaf. It needs to be cleaned before it can be used." -value: 55 -stackable: false -``` - -#### `data/items/grimy_lantadyme.yaml` -```yaml -id: grimy_lantadyme -name: "grimy lantadyme" -color: "65" -description: "A grimy lantadyme leaf. It needs to be cleaned before it can be used." -value: 60 -stackable: false -``` - -#### `data/items/grimy_dwarf_weed.yaml` -```yaml -id: grimy_dwarf_weed -name: "grimy dwarf weed" -color: "65" -description: "A grimy dwarf weed leaf. It needs to be cleaned before it can be used." -value: 65 -stackable: false -``` - -#### `data/items/grimy_torstol.yaml` -```yaml -id: grimy_torstol -name: "grimy torstol" -color: "65" -description: "A grimy torstol leaf. It needs to be cleaned before it can be used." -value: 75 -stackable: false -``` - -### Clean Herbs - -Each clean herb follows this template: - -```yaml -id: <name> -name: "<display_name>" -color: "48" -description: "A clean <display_name> leaf, ready for use in pharmacy." -value: <value> -stackable: false -``` - -#### `data/items/guam.yaml` -```yaml -id: guam -name: "guam" -color: "48" -description: "A clean guam leaf, ready for use in pharmacy." -value: 3 -stackable: false -``` - -#### `data/items/marrentill.yaml` -```yaml -id: marrentill -name: "marrentill" -color: "48" -description: "A clean marrentill leaf, ready for use in pharmacy." -value: 6 -stackable: false -``` - -#### `data/items/tarromin.yaml` -```yaml -id: tarromin -name: "tarromin" -color: "48" -description: "A clean tarromin leaf, ready for use in pharmacy." -value: 10 -stackable: false -``` - -#### `data/items/harralander.yaml` -```yaml -id: harralander -name: "harralander" -color: "48" -description: "A clean harralander leaf, ready for use in pharmacy." -value: 16 -stackable: false -``` - -#### `data/items/ranarr.yaml` -```yaml -id: ranarr -name: "ranarr" -color: "48" -description: "A clean ranarr leaf, ready for use in pharmacy." -value: 35 -stackable: false -``` - -#### `data/items/toadflax.yaml` -```yaml -id: toadflax -name: "toadflax" -color: "48" -description: "A clean toadflax leaf, ready for use in pharmacy." -value: 25 -stackable: false -``` - -#### `data/items/irit.yaml` -```yaml -id: irit -name: "irit" -color: "48" -description: "A clean irit leaf, ready for use in pharmacy." -value: 40 -stackable: false -``` - -#### `data/items/avantoe.yaml` -```yaml -id: avantoe -name: "avantoe" -color: "48" -description: "A clean avantoe leaf, ready for use in pharmacy." -value: 48 -stackable: false -``` - -#### `data/items/kwuarm.yaml` -```yaml -id: kwuarm -name: "kwuarm" -color: "48" -description: "A clean kwuarm leaf, ready for use in pharmacy." -value: 54 -stackable: false -``` - -#### `data/items/snapdragon.yaml` -```yaml -id: snapdragon -name: "snapdragon" -color: "48" -description: "A clean snapdragon leaf, ready for use in pharmacy." -value: 65 -stackable: false -``` - -#### `data/items/cadantine.yaml` -```yaml -id: cadantine -name: "cadantine" -color: "48" -description: "A clean cadantine leaf, ready for use in pharmacy." -value: 70 -stackable: false -``` - -#### `data/items/lantadyme.yaml` -```yaml -id: lantadyme -name: "lantadyme" -color: "48" -description: "A clean lantadyme leaf, ready for use in pharmacy." -value: 78 -stackable: false -``` - -#### `data/items/dwarf_weed.yaml` -```yaml -id: dwarf_weed -name: "dwarf weed" -color: "48" -description: "A clean dwarf weed leaf, ready for use in pharmacy." -value: 85 -stackable: false -``` - -#### `data/items/torstol.yaml` -```yaml -id: torstol -name: "torstol" -color: "48" -description: "A clean torstol leaf, ready for use in pharmacy." -value: 100 -stackable: false -``` - ---- - -## 7. Reagents -- Full Item YAML - -Secondary ingredients used to finish potions. Some are sci-fi renamed. - -#### `data/items/eye_of_newt.yaml` -```yaml -id: eye_of_newt -name: "eye of newt" -color: "215" -description: "A preserved eye of newt, suspended in synthetic fluid. Used in pharmacy." -value: 3 -stackable: false -``` - -#### `data/items/limpwurt_root.yaml` -```yaml -id: limpwurt_root -name: "limpwurt root" -color: "130" -description: "A thick, gnarled root with natural stimulant properties." -value: 8 -stackable: false -``` - -#### `data/items/arachnid_enzyme.yaml` -```yaml -id: arachnid_enzyme -name: "arachnid enzyme" -color: "196" -description: "A viscous red enzyme harvested from spider egg sacs. Potent catalytic agent." -value: 12 -stackable: false -``` - -#### `data/items/antitoxin_powder.yaml` -```yaml -id: antitoxin_powder -name: "antitoxin powder" -color: "255" -description: "A fine white powder derived from synthetic horn material. Neutralizes biological toxins." -value: 15 -stackable: false -``` - -#### `data/items/white_berries.yaml` -```yaml -id: white_berries -name: "white berries" -color: "255" -description: "Pale berries from a hardy bush. They have defensive biochemical properties." -value: 10 -stackable: false -``` - -#### `data/items/snape_grass.yaml` -```yaml -id: snape_grass -name: "snape grass" -color: "120" -description: "A long blade of snape grass. It crackles with faint static charge." -value: 10 -stackable: false -``` - -#### `data/items/chocolate_dust.yaml` -```yaml -id: chocolate_dust -name: "chocolate dust" -color: "94" -description: "Finely ground chocolate. A surprisingly effective pharmaceutical binding agent." -value: 5 -stackable: false -``` - -#### `data/items/blight_spore.yaml` -```yaml -id: blight_spore -name: "blight spore" -color: "58" -description: "A desiccated fungal spore from the deadlands. Pulsing with residual bio-energy." -value: 20 -stackable: false -``` - -#### `data/items/potato_cactus.yaml` -```yaml -id: potato_cactus -name: "potato cactus" -color: "106" -description: "A bulbous cactus with medicinal properties. Thrives in the arid zones." -value: 18 -stackable: false -``` - -#### `data/items/catalyst_wine.yaml` -```yaml -id: catalyst_wine -name: "catalyst wine" -color: "124" -description: "A volatile crimson wine infused with unstable compounds. Handle with care." -value: 30 -stackable: false -``` - -#### `data/items/crushed_nest.yaml` -```yaml -id: crushed_nest -name: "crushed nest" -color: "94" -description: "Finely crushed bird's nest material. Contains trace minerals used in advanced pharmacy." -value: 25 -stackable: false -``` - -#### `data/items/vial_of_water.yaml` -```yaml -id: vial_of_water -name: "vial of water" -color: "39" -description: "A small glass vial filled with purified water. The base for all potions." -value: 2 -stackable: false -``` - -#### `data/items/vial.yaml` -```yaml -id: vial -name: "vial" -color: "253" -description: "An empty glass vial." -value: 1 -stackable: false -``` - ---- - -## 8. Potions -- Full Item YAML - -### Unfinished Potions - -Each unfinished potion is created by using a clean herb on a vial of water. They use `made_from` for item-on-item combining via the `use` command. No level requirement. No XP. Instant (ticks: 0 means instant combine in the `use` system). - -Template: -```yaml -id: <name>_unf -name: "unfinished <display_name>" -color: "<color>" -description: "An unfinished potion with <herb> floating in water. Needs a secondary ingredient." -value: <value> -stackable: false -made_from: - - items: [<herb_id>] - qty: 1 - - items: [vial_of_water] - qty: 1 -ticks: 0 -``` - -#### `data/items/stim_potion_unf.yaml` -```yaml -id: stim_potion_unf -name: "unfinished stim potion" -color: "48" -description: "An unfinished potion with guam floating in water." -value: 5 -stackable: false -made_from: - - items: [guam] - qty: 1 - - items: [vial_of_water] - qty: 1 -ticks: 0 -``` - -#### `data/items/bio_serum_unf.yaml` -```yaml -id: bio_serum_unf -name: "unfinished bio serum" -color: "48" -description: "An unfinished potion with marrentill floating in water." -value: 8 -stackable: false -made_from: - - items: [marrentill] - qty: 1 - - items: [vial_of_water] - qty: 1 -ticks: 0 -``` - -#### `data/items/amp_potion_unf.yaml` -```yaml -id: amp_potion_unf -name: "unfinished amp potion" -color: "48" -description: "An unfinished potion with tarromin floating in water." -value: 12 -stackable: false -made_from: - - items: [tarromin] - qty: 1 - - items: [vial_of_water] - qty: 1 -ticks: 0 -``` - -#### `data/items/nano_restore_unf.yaml` -```yaml -id: nano_restore_unf -name: "unfinished nano restore" -color: "48" -description: "An unfinished potion with harralander floating in water." -value: 18 -stackable: false -made_from: - - items: [harralander] - qty: 1 - - items: [vial_of_water] - qty: 1 -ticks: 0 -``` - -#### `data/items/stim_cell_unf.yaml` -```yaml -id: stim_cell_unf -name: "unfinished stim cell" -color: "48" -description: "An unfinished potion with harralander floating in water." -value: 18 -stackable: false -made_from: - - items: [harralander] - qty: 1 - - items: [vial_of_water] - qty: 1 -ticks: 0 -``` - -**Note:** Nano Restore and Stim Cell share the same herb (harralander). The unfinished potions for harralander-based recipes are the same item. Simplify: use a single `harralander_unf` item instead, and both finished potions use it as input. - -**Revised approach for shared herbs:** Use a single unfinished potion per herb (named by herb, not by final potion): - -#### `data/items/guam_potion_unf.yaml` -```yaml -id: guam_potion_unf -name: "guam potion (unf)" -color: "48" -description: "An unfinished potion with guam floating in water." -value: 5 -stackable: false -made_from: - - items: [guam] - qty: 1 - - items: [vial_of_water] - qty: 1 -ticks: 0 -``` - -#### `data/items/marrentill_potion_unf.yaml` -```yaml -id: marrentill_potion_unf -name: "marrentill potion (unf)" -color: "48" -description: "An unfinished potion with marrentill floating in water." -value: 8 -stackable: false -made_from: - - items: [marrentill] - qty: 1 - - items: [vial_of_water] - qty: 1 -ticks: 0 -``` - -#### `data/items/tarromin_potion_unf.yaml` -```yaml -id: tarromin_potion_unf -name: "tarromin potion (unf)" -color: "48" -description: "An unfinished potion with tarromin floating in water." -value: 12 -stackable: false -made_from: - - items: [tarromin] - qty: 1 - - items: [vial_of_water] - qty: 1 -ticks: 0 -``` - -#### `data/items/harralander_potion_unf.yaml` -```yaml -id: harralander_potion_unf -name: "harralander potion (unf)" -color: "48" -description: "An unfinished potion with harralander floating in water." -value: 18 -stackable: false -made_from: - - items: [harralander] - qty: 1 - - items: [vial_of_water] - qty: 1 -ticks: 0 -``` - -#### `data/items/ranarr_potion_unf.yaml` -```yaml -id: ranarr_potion_unf -name: "ranarr potion (unf)" -color: "48" -description: "An unfinished potion with ranarr floating in water." -value: 38 -stackable: false -made_from: - - items: [ranarr] - qty: 1 - - items: [vial_of_water] - qty: 1 -ticks: 0 -``` - -#### `data/items/toadflax_potion_unf.yaml` -```yaml -id: toadflax_potion_unf -name: "toadflax potion (unf)" -color: "48" -description: "An unfinished potion with toadflax floating in water." -value: 28 -stackable: false -made_from: - - items: [toadflax] - qty: 1 - - items: [vial_of_water] - qty: 1 -ticks: 0 -``` - -#### `data/items/irit_potion_unf.yaml` -```yaml -id: irit_potion_unf -name: "irit potion (unf)" -color: "48" -description: "An unfinished potion with irit floating in water." -value: 42 -stackable: false -made_from: - - items: [irit] - qty: 1 - - items: [vial_of_water] - qty: 1 -ticks: 0 -``` - -#### `data/items/avantoe_potion_unf.yaml` -```yaml -id: avantoe_potion_unf -name: "avantoe potion (unf)" -color: "48" -description: "An unfinished potion with avantoe floating in water." -value: 50 -stackable: false -made_from: - - items: [avantoe] - qty: 1 - - items: [vial_of_water] - qty: 1 -ticks: 0 -``` - -#### `data/items/kwuarm_potion_unf.yaml` -```yaml -id: kwuarm_potion_unf -name: "kwuarm potion (unf)" -color: "48" -description: "An unfinished potion with kwuarm floating in water." -value: 56 -stackable: false -made_from: - - items: [kwuarm] - qty: 1 - - items: [vial_of_water] - qty: 1 -ticks: 0 -``` - -#### `data/items/snapdragon_potion_unf.yaml` -```yaml -id: snapdragon_potion_unf -name: "snapdragon potion (unf)" -color: "48" -description: "An unfinished potion with snapdragon floating in water." -value: 68 -stackable: false -made_from: - - items: [snapdragon] - qty: 1 - - items: [vial_of_water] - qty: 1 -ticks: 0 -``` - -#### `data/items/cadantine_potion_unf.yaml` -```yaml -id: cadantine_potion_unf -name: "cadantine potion (unf)" -color: "48" -description: "An unfinished potion with cadantine floating in water." -value: 72 -stackable: false -made_from: - - items: [cadantine] - qty: 1 - - items: [vial_of_water] - qty: 1 -ticks: 0 -``` - -#### `data/items/lantadyme_potion_unf.yaml` -```yaml -id: lantadyme_potion_unf -name: "lantadyme potion (unf)" -color: "48" -description: "An unfinished potion with lantadyme floating in water." -value: 80 -stackable: false -made_from: - - items: [lantadyme] - qty: 1 - - items: [vial_of_water] - qty: 1 -ticks: 0 -``` - -#### `data/items/dwarf_weed_potion_unf.yaml` -```yaml -id: dwarf_weed_potion_unf -name: "dwarf weed potion (unf)" -color: "48" -description: "An unfinished potion with dwarf weed floating in water." -value: 88 -stackable: false -made_from: - - items: [dwarf_weed] - qty: 1 - - items: [vial_of_water] - qty: 1 -ticks: 0 -``` - -#### `data/items/torstol_potion_unf.yaml` -```yaml -id: torstol_potion_unf -name: "torstol potion (unf)" -color: "48" -description: "An unfinished potion with torstol floating in water." -value: 102 -stackable: false -made_from: - - items: [torstol] - qty: 1 - - items: [vial_of_water] - qty: 1 -ticks: 0 -``` - -### Finished Potions - -All potions are non-stackable. Effects are NOT implemented yet -- just items with descriptions. - -| Potion (sci-fi name) | RS equivalent | Herb | Reagent | Level | XP | -|---|---|---|---|---|---| -| Stim Potion | Attack potion | guam | eye_of_newt | 3 | 25 | -| Bio Serum | Antipoison | marrentill | antitoxin_powder | 5 | 38 | -| Amp Potion | Strength potion | tarromin | limpwurt_root | 12 | 50 | -| Nano Restore | Stat restore | harralander | arachnid_enzyme | 22 | 63 | -| Stim Cell | Energy potion | harralander | chocolate_dust | 26 | 68 | -| Shield Potion | Defence potion | ranarr | white_berries | 30 | 75 | -| Tech Serum | Prayer potion | ranarr | snape_grass | 38 | 88 | -| Super Stim | Super attack | irit | eye_of_newt | 45 | 100 | -| Super Bio Serum | Super antipoison | irit | antitoxin_powder | 48 | 106 | -| Super Stim Cell | Super energy | avantoe | blight_spore | 52 | 118 | -| Super Amp | Super strength | kwuarm | limpwurt_root | 55 | 125 | -| Full Restore | Super restore | snapdragon | arachnid_enzyme | 63 | 143 | -| Super Shield | Super defence | cadantine | white_berries | 66 | 150 | -| Targeting Serum | Ranging potion | dwarf_weed | catalyst_wine | 72 | 163 | -| Science Serum | Magic potion | lantadyme | potato_cactus | 76 | 173 | -| Restoration Compound | Saradomin brew | toadflax | crushed_nest | 81 | 180 | - -#### `data/items/stim_potion.yaml` -```yaml -id: stim_potion -name: "stim potion" -color: "196" -description: "A bubbling red potion that temporarily boosts attack capability. [Effect not yet implemented]" -value: 30 -stackable: false -``` - -#### `data/items/bio_serum.yaml` -```yaml -id: bio_serum -name: "bio serum" -color: "48" -description: "A clear green serum that neutralizes biological toxins. [Effect not yet implemented]" -value: 45 -stackable: false -``` - -#### `data/items/amp_potion.yaml` -```yaml -id: amp_potion -name: "amp potion" -color: "255" -description: "A milky white potion that temporarily amplifies physical strength. [Effect not yet implemented]" -value: 55 -stackable: false -``` - -#### `data/items/nano_restore.yaml` -```yaml -id: nano_restore -name: "nano restore" -color: "208" -description: "An orange potion containing nanobots that restore diminished attributes. [Effect not yet implemented]" -value: 70 -stackable: false -``` - -#### `data/items/stim_cell.yaml` -```yaml -id: stim_cell -name: "stim cell" -color: "226" -description: "A bright yellow energy supplement that restores stamina. [Effect not yet implemented]" -value: 65 -stackable: false -``` - -#### `data/items/shield_potion.yaml` -```yaml -id: shield_potion -name: "shield potion" -color: "39" -description: "A luminous blue potion that temporarily reinforces defensive capability. [Effect not yet implemented]" -value: 85 -stackable: false -``` - -#### `data/items/tech_serum.yaml` -```yaml -id: tech_serum -name: "tech serum" -color: "51" -description: "A cyan serum that temporarily enhances technology interface capability. [Effect not yet implemented]" -value: 100 -stackable: false -``` - -#### `data/items/super_stim.yaml` -```yaml -id: super_stim -name: "super stim" -color: "160" -description: "A potent dark-red stimulant. Dramatically boosts attack capability. [Effect not yet implemented]" -value: 120 -stackable: false -``` - -#### `data/items/super_bio_serum.yaml` -```yaml -id: super_bio_serum -name: "super bio serum" -color: "34" -description: "A concentrated antitoxin. Provides extended poison immunity. [Effect not yet implemented]" -value: 130 -stackable: false -``` - -#### `data/items/super_stim_cell.yaml` -```yaml -id: super_stim_cell -name: "super stim cell" -color: "220" -description: "An advanced energy compound that fully restores stamina. [Effect not yet implemented]" -value: 140 -stackable: false -``` - -#### `data/items/super_amp.yaml` -```yaml -id: super_amp -name: "super amp" -color: "231" -description: "A dangerously concentrated strength amplifier. Handle carefully. [Effect not yet implemented]" -value: 155 -stackable: false -``` - -#### `data/items/full_restore.yaml` -```yaml -id: full_restore -name: "full restore" -color: "207" -description: "A shimmering pink potion. Restores all diminished stats simultaneously. [Effect not yet implemented]" -value: 200 -stackable: false -``` - -#### `data/items/super_shield.yaml` -```yaml -id: super_shield -name: "super shield" -color: "27" -description: "A deep indigo potion that provides significant defensive enhancement. [Effect not yet implemented]" -value: 175 -stackable: false -``` - -#### `data/items/targeting_serum.yaml` -```yaml -id: targeting_serum -name: "targeting serum" -color: "70" -description: "A dark green serum that sharpens ranged targeting systems. [Effect not yet implemented]" -value: 190 -stackable: false -``` - -#### `data/items/science_serum.yaml` -```yaml -id: science_serum -name: "science serum" -color: "93" -description: "A violet serum that heightens scientific cognition and casting ability. [Effect not yet implemented]" -value: 200 -stackable: false -``` - -#### `data/items/restoration_compound.yaml` -```yaml -id: restoration_compound -name: "restoration compound" -color: "214" -description: "A golden compound that restores hitpoints and boosts defense, but drains other stats. [Effect not yet implemented]" -value: 250 -stackable: false -``` - ---- - -## 9. Recipes -- Full YAML - -### Clean Recipes (type: clean) - -These are used by the `clean` background action. Each has `type: clean` and `skill: pharmacy`. - -#### `data/recipes/clean_guam.yaml` -```yaml -id: clean_guam -type: clean -skill: pharmacy -level: 3 -xp: 3 -wait: 2 -consume: - - items: [grimy_guam] - qty: 1 -output: guam -message: "You clean the grimy guam leaf." -``` - -#### `data/recipes/clean_marrentill.yaml` -```yaml -id: clean_marrentill -type: clean -skill: pharmacy -level: 5 -xp: 4 -wait: 2 -consume: - - items: [grimy_marrentill] - qty: 1 -output: marrentill -message: "You clean the grimy marrentill leaf." -``` - -#### `data/recipes/clean_tarromin.yaml` -```yaml -id: clean_tarromin -type: clean -skill: pharmacy -level: 11 -xp: 5 -wait: 2 -consume: - - items: [grimy_tarromin] - qty: 1 -output: tarromin -message: "You clean the grimy tarromin leaf." -``` - -#### `data/recipes/clean_harralander.yaml` -```yaml -id: clean_harralander -type: clean -skill: pharmacy -level: 20 -xp: 6 -wait: 2 -consume: - - items: [grimy_harralander] - qty: 1 -output: harralander -message: "You clean the grimy harralander leaf." -``` - -#### `data/recipes/clean_ranarr.yaml` -```yaml -id: clean_ranarr -type: clean -skill: pharmacy -level: 25 -xp: 8 -wait: 2 -consume: - - items: [grimy_ranarr] - qty: 1 -output: ranarr -message: "You clean the grimy ranarr leaf." -``` - -#### `data/recipes/clean_toadflax.yaml` -```yaml -id: clean_toadflax -type: clean -skill: pharmacy -level: 30 -xp: 8 -wait: 2 -consume: - - items: [grimy_toadflax] - qty: 1 -output: toadflax -message: "You clean the grimy toadflax leaf." -``` - -#### `data/recipes/clean_irit.yaml` -```yaml -id: clean_irit -type: clean -skill: pharmacy -level: 40 -xp: 9 -wait: 2 -consume: - - items: [grimy_irit] - qty: 1 -output: irit -message: "You clean the grimy irit leaf." -``` - -#### `data/recipes/clean_avantoe.yaml` -```yaml -id: clean_avantoe -type: clean -skill: pharmacy -level: 48 -xp: 10 -wait: 2 -consume: - - items: [grimy_avantoe] - qty: 1 -output: avantoe -message: "You clean the grimy avantoe leaf." -``` - -#### `data/recipes/clean_kwuarm.yaml` -```yaml -id: clean_kwuarm -type: clean -skill: pharmacy -level: 54 -xp: 11 -wait: 2 -consume: - - items: [grimy_kwuarm] - qty: 1 -output: kwuarm -message: "You clean the grimy kwuarm leaf." -``` - -#### `data/recipes/clean_snapdragon.yaml` -```yaml -id: clean_snapdragon -type: clean -skill: pharmacy -level: 59 -xp: 12 -wait: 2 -consume: - - items: [grimy_snapdragon] - qty: 1 -output: snapdragon -message: "You clean the grimy snapdragon leaf." -``` - -#### `data/recipes/clean_cadantine.yaml` -```yaml -id: clean_cadantine -type: clean -skill: pharmacy -level: 65 -xp: 13 -wait: 2 -consume: - - items: [grimy_cadantine] - qty: 1 -output: cadantine -message: "You clean the grimy cadantine leaf." -``` - -#### `data/recipes/clean_lantadyme.yaml` -```yaml -id: clean_lantadyme -type: clean -skill: pharmacy -level: 67 -xp: 13 -wait: 2 -consume: - - items: [grimy_lantadyme] - qty: 1 -output: lantadyme -message: "You clean the grimy lantadyme leaf." -``` - -#### `data/recipes/clean_dwarf_weed.yaml` -```yaml -id: clean_dwarf_weed -type: clean -skill: pharmacy -level: 70 -xp: 14 -wait: 2 -consume: - - items: [grimy_dwarf_weed] - qty: 1 -output: dwarf_weed -message: "You clean the grimy dwarf weed leaf." -``` - -#### `data/recipes/clean_torstol.yaml` -```yaml -id: clean_torstol -type: clean -skill: pharmacy -level: 75 -xp: 15 -wait: 2 -consume: - - items: [grimy_torstol] - qty: 1 -output: torstol -message: "You clean the grimy torstol leaf." -``` - -### Mix Recipes (type: pharmacy) - -These are used by the `mix` command via the standard production system. No station required (station field omitted or empty). - -#### `data/recipes/mix_stim_potion.yaml` -```yaml -id: mix_stim_potion -type: pharmacy -skill: pharmacy -level: 3 -xp: 25 -wait: 4 -consume: - - items: [guam_potion_unf] - qty: 1 - - items: [eye_of_newt] - qty: 1 -output: stim_potion -message: "You mix a stim potion." -``` - -#### `data/recipes/mix_bio_serum.yaml` -```yaml -id: mix_bio_serum -type: pharmacy -skill: pharmacy -level: 5 -xp: 38 -wait: 4 -consume: - - items: [marrentill_potion_unf] - qty: 1 - - items: [antitoxin_powder] - qty: 1 -output: bio_serum -message: "You mix a bio serum." -``` - -#### `data/recipes/mix_amp_potion.yaml` -```yaml -id: mix_amp_potion -type: pharmacy -skill: pharmacy -level: 12 -xp: 50 -wait: 4 -consume: - - items: [tarromin_potion_unf] - qty: 1 - - items: [limpwurt_root] - qty: 1 -output: amp_potion -message: "You mix an amp potion." -``` - -#### `data/recipes/mix_nano_restore.yaml` -```yaml -id: mix_nano_restore -type: pharmacy -skill: pharmacy -level: 22 -xp: 63 -wait: 4 -consume: - - items: [harralander_potion_unf] - qty: 1 - - items: [arachnid_enzyme] - qty: 1 -output: nano_restore -message: "You mix a nano restore." -``` - -#### `data/recipes/mix_stim_cell.yaml` -```yaml -id: mix_stim_cell -type: pharmacy -skill: pharmacy -level: 26 -xp: 68 -wait: 4 -consume: - - items: [harralander_potion_unf] - qty: 1 - - items: [chocolate_dust] - qty: 1 -output: stim_cell -message: "You mix a stim cell." -``` - -#### `data/recipes/mix_shield_potion.yaml` -```yaml -id: mix_shield_potion -type: pharmacy -skill: pharmacy -level: 30 -xp: 75 -wait: 4 -consume: - - items: [ranarr_potion_unf] - qty: 1 - - items: [white_berries] - qty: 1 -output: shield_potion -message: "You mix a shield potion." -``` - -#### `data/recipes/mix_tech_serum.yaml` -```yaml -id: mix_tech_serum -type: pharmacy -skill: pharmacy -level: 38 -xp: 88 -wait: 4 -consume: - - items: [ranarr_potion_unf] - qty: 1 - - items: [snape_grass] - qty: 1 -output: tech_serum -message: "You mix a tech serum." -``` - -#### `data/recipes/mix_super_stim.yaml` -```yaml -id: mix_super_stim -type: pharmacy -skill: pharmacy -level: 45 -xp: 100 -wait: 4 -consume: - - items: [irit_potion_unf] - qty: 1 - - items: [eye_of_newt] - qty: 1 -output: super_stim -message: "You mix a super stim." -``` - -#### `data/recipes/mix_super_bio_serum.yaml` -```yaml -id: mix_super_bio_serum -type: pharmacy -skill: pharmacy -level: 48 -xp: 106 -wait: 4 -consume: - - items: [irit_potion_unf] - qty: 1 - - items: [antitoxin_powder] - qty: 1 -output: super_bio_serum -message: "You mix a super bio serum." -``` - -#### `data/recipes/mix_super_stim_cell.yaml` -```yaml -id: mix_super_stim_cell -type: pharmacy -skill: pharmacy -level: 52 -xp: 118 -wait: 4 -consume: - - items: [avantoe_potion_unf] - qty: 1 - - items: [blight_spore] - qty: 1 -output: super_stim_cell -message: "You mix a super stim cell." -``` - -#### `data/recipes/mix_super_amp.yaml` -```yaml -id: mix_super_amp -type: pharmacy -skill: pharmacy -level: 55 -xp: 125 -wait: 4 -consume: - - items: [kwuarm_potion_unf] - qty: 1 - - items: [limpwurt_root] - qty: 1 -output: super_amp -message: "You mix a super amp." -``` - -#### `data/recipes/mix_full_restore.yaml` -```yaml -id: mix_full_restore -type: pharmacy -skill: pharmacy -level: 63 -xp: 143 -wait: 4 -consume: - - items: [snapdragon_potion_unf] - qty: 1 - - items: [arachnid_enzyme] - qty: 1 -output: full_restore -message: "You mix a full restore." -``` - -#### `data/recipes/mix_super_shield.yaml` -```yaml -id: mix_super_shield -type: pharmacy -skill: pharmacy -level: 66 -xp: 150 -wait: 4 -consume: - - items: [cadantine_potion_unf] - qty: 1 - - items: [white_berries] - qty: 1 -output: super_shield -message: "You mix a super shield." -``` - -#### `data/recipes/mix_targeting_serum.yaml` -```yaml -id: mix_targeting_serum -type: pharmacy -skill: pharmacy -level: 72 -xp: 163 -wait: 4 -consume: - - items: [dwarf_weed_potion_unf] - qty: 1 - - items: [catalyst_wine] - qty: 1 -output: targeting_serum -message: "You mix a targeting serum." -``` - -#### `data/recipes/mix_science_serum.yaml` -```yaml -id: mix_science_serum -type: pharmacy -skill: pharmacy -level: 76 -xp: 173 -wait: 4 -consume: - - items: [lantadyme_potion_unf] - qty: 1 - - items: [potato_cactus] - qty: 1 -output: science_serum -message: "You mix a science serum." -``` - -#### `data/recipes/mix_restoration_compound.yaml` -```yaml -id: mix_restoration_compound -type: pharmacy -skill: pharmacy -level: 81 -xp: 180 -wait: 4 -consume: - - items: [toadflax_potion_unf] - qty: 1 - - items: [crushed_nest] - qty: 1 -output: restoration_compound -message: "You mix a restoration compound." -``` - ---- - -## 10. Clean Mechanic - -### Overview - -`clean` is a **Free** command that starts a **background action** (like fletching). It auto-scans the player's inventory for grimy herbs and cleans them one at a time. - -### Flow - -1. Player types `clean` (or `clean <herb_name>`) -2. `doClean()` in `cmd_clean.go` is called -3. It loads all recipes with `type: "clean"` from the recipe store -4. If a filter was provided (e.g., `clean guam`), filter recipes to those whose consume item matches `grimy_<filter>` -5. Find the first recipe the player has materials AND level for -6. If none found, output "You don't have any herbs to clean." and return -7. Cancel any existing background action -8. Start a background action with type `"clean"` and initial `wait: 1` (phase 0) -9. Set `BackgroundActionState` to `&ActionState{Type: ActionCleaning}` -10. Output "You begin cleaning herbs." - -### Advance logic (`advanceClean`) - -Called every tick when `p.BackgroundAction.Type == "clean"`: - -1. **Phase 0** (first tick): Set phase to 1, set WaitLeft to `engine.ToTicks(2)` (2 ticks). Return. -2. **Phase 1** (production tick): - - Load all `type: "clean"` recipes - - If a filter was stored in `Data["filter"]`, restrict to matching recipes - - Sort recipes by level ascending - - Find the first recipe the player has items AND level for - - If none found: output "You've run out of herbs to clean.", cancel background action, return - - Consume the grimy herb, place the clean herb in the same inventory slot - - Award XP via `p.AddSkillXP(player.Pharmacy, recipe.XP)` - - Check for level-up, output message - - Output the recipe's message (e.g., "You clean the grimy guam leaf.") with XP drop - - Save character - - Check if there are more herbs to clean (scan again) - - If yes: set `WaitLeft = engine.ToTicks(2)`, continue - - If no: output "You've finished cleaning herbs.", cancel background action - -### In-slot replacement - -When cleaning a herb, the clean herb should replace the grimy herb in the **same inventory slot** rather than consuming from one slot and adding to a free slot. This keeps inventory tidy. Implementation: - -```go -for i := 0; i < 28; i++ { - slot := p.InvSlot(i) - if slot != nil && slot.ItemID == recipe.Consume[0].Items[0] { - slot.ItemID = recipe.Output - break - } -} -``` - -This avoids the consume+place pattern and preserves slot position. - -### Filter behavior - -- `clean` with no args: cleans any grimy herb, cycling through all types -- `clean guam`: only cleans `grimy_guam`. Stored as `Data["filter"] = "guam"`. Recipe matching checks if the consume item ID contains the filter string. - -### Interaction with other actions - -- `clean` does NOT cancel the player's active action (gathering, combat, movement) -- `clean` DOES cancel any existing background action (fletching) -- Starting a new active action does NOT cancel cleaning (background actions are independent) -- `get`, `drop`, `quit` cancel the active action but NOT background actions (existing behavior) - ---- - -## 11. Mix Mechanic - -### Overview - -`mix` is an **Active** command that uses the standard production system. No station required. Works anywhere. - -### Flow - -1. Player types `mix` (or `mix <potion>` or `mix 10 <potion>`) -2. `doMix()` in `cmd_mix.go` is called -3. It loads all recipes with `type: "pharmacy"` from the recipe store -4. If no args: show production table of all pharmacy recipes (like cook does) -5. If args provided: match by name, start production if unambiguous - -### Implementation (`cmd_mix.go`) - -Follow the `cmd_cook.go` pattern closely: - -```go -package game - -import ( - "fmt" - "strings" - - "thehouseoficarus/internal/action" - "thehouseoficarus/internal/net" - "thehouseoficarus/internal/player" -) - -func (g *Game) doMix(sess *net.Session, input string) { - p := sess.Player.(*player.Player) - g.CancelAction(p) - - allRecipes, err := g.RecipeStore.LoadAll() - if err != nil { - sess.WriteLine("Error loading recipes.") - return - } - - if input == "" { - g.showMixMenu(sess, p, allRecipes) - return - } - - qty, itemName := parseQty(input) - _ = qty - - // Find matching pharmacy recipes by output name - var matched []action.RecipeDef - for _, r := range allRecipes { - if r.Type != "pharmacy" { - continue - } - outDef, _ := g.ItemStore.Load(r.Output) - name := r.Output - if outDef != nil { - name = outDef.Name - } - if world.WordPrefixMatch(itemName, name) { - matched = append(matched, r) - } - } - - if len(matched) == 0 { - sess.WriteLine("You can't mix that.") - return - } - - // Filter to available - var available []action.RecipeDef - for _, r := range matched { - if r.HasAllItems(p.HasItem) && p.Level(player.Pharmacy) >= r.Level { - available = append(available, r) - } - } - - if len(available) == 0 { - sess.WriteLine("You don't have the materials for that.") - return - } - - if len(available) == 1 { - g.promptHowMany(sess, available[0].ID) - return - } - - // Ambiguous -- show menu - sess.State = net.StateRecipeChoice - var names []string - for _, r := range available { - names = append(names, g.recipeName(sess, &r)) - } - g.showMenuTable(sess, "What would you like to mix?", names) - sess.PendingMenu = recipeMenuData(available) -} - -func (g *Game) showMixMenu(sess *net.Session, p *player.Player, allRecipes []action.RecipeDef) { - seen := make(map[string]bool) - var entries []recipeEntry - - for _, r := range allRecipes { - if r.Type != "pharmacy" { - continue - } - if seen[r.ID] { - continue - } - if !r.HasAllItems(p.HasItem) { - continue - } - seen[r.ID] = true - entries = append(entries, recipeEntry{g.recipeName(sess, &r), r}) - } - - if len(entries) == 0 { - sess.WriteLine("You don't have anything you can mix.") - return - } - - if len(entries) == 1 { - if p.OptionBool("mix_all") { - g.startProductionFromRecipe(sess, p, &entries[0].Recipe, 0) - return - } - g.promptHowMany(sess, entries[0].Recipe.ID) - return - } - - sess.State = net.StateRecipeChoice - var names []string - for _, e := range entries { - names = append(names, e.ItemName) - } - g.showMenuTable(sess, "What would you like to mix?", names) - sess.PendingMenu = entryMenuData(entries) -} -``` - -### `use` command integration - -The `use` command already handles MadeFrom combines. When a player does `use guam on vial of water`, it will find the `guam_potion_unf` item's MadeFrom definition and create the combine recipe automatically. No extra code needed. - -When a player does `use eye_of_newt on guam potion`, the `use` command should also check pharmacy recipes via `RecipeStore.FindByItems()`. This already works because `FindByItems` searches all recipe types. The production system will handle it. - ---- - -## 12. Action Lifecycle - -### Clean Action Lifecycle - -**Files:** `cmd_clean.go`, `action_clean.go` - -#### `cmd_clean.go` -- `doClean(sess, input)` - -1. Get player -2. Check not in combat (if so, "You can't do that during combat!") -3. Load all recipes with `type == "clean"` -4. If `input` provided, filter recipes to those whose consume items match the filter -5. Find any recipe the player can do (has items + has level) -6. If none: "You don't have any herbs to clean." return -7. Cancel existing background action -8. Set `p.BackgroundAction`: - ```go - p.BackgroundAction = &action.Action{ - Type: "clean", - TargetID: "clean_herbs", - Data: map[string]any{ - "phase": 0, - "filter": input, // "" for all herbs - }, - WaitLeft: engine.ToTicks(1), - } - p.BackgroundActionState = &ActionState{Type: ActionCleaning} - ``` -9. Output: `"\nYou begin cleaning herbs."` - -#### `action_clean.go` -- `advanceClean(sess, p)` - -```go -func (g *Game) advanceClean(sess *net.Session, p *player.Player) { - phase, _ := p.BackgroundAction.Data["phase"].(int) - filter, _ := p.BackgroundAction.Data["filter"].(string) - - if phase == 0 { - p.BackgroundAction.Data["phase"] = 1 - p.BackgroundAction.WaitLeft = engine.ToTicks(2) - return - } - - allRecipes, err := g.RecipeStore.LoadAll() - if err != nil { - g.CancelBackgroundAction(p) - return - } - - // Find first cleanable herb - var recipe *action.RecipeDef - for _, r := range allRecipes { - if r.Type != "clean" { - continue - } - if filter != "" { - // Check if this recipe's output or consume matches the filter - if !strings.Contains(r.Consume[0].Items[0], filter) && - !strings.Contains(r.Output, filter) { - continue - } - } - if p.Level(player.Pharmacy) < r.Level { - continue - } - if !r.HasAllItems(p.HasItem) { - continue - } - recipe = &r - break - } - - if recipe == nil { - sess.WriteLine("\nYou've finished cleaning herbs.") - g.CancelBackgroundAction(p) - return - } - - // In-slot replacement - consumeID := recipe.Consume[0].Items[0] - for i := 0; i < 28; i++ { - slot := p.InvSlot(i) - if slot != nil && slot.ItemID == consumeID { - slot.ItemID = recipe.Output - break - } - } - - // Award XP - if recipe.XP > 0 { - if newLevel := p.AddSkillXP(player.Pharmacy, recipe.XP); newLevel > 0 { - sess.WriteLine(g.colorize(sess, "level_up", - fmt.Sprintf("*** You are now level %d pharmacy! ***", newLevel))) - } - } - g.AccountStore.SaveCharacter(p) - - // Output message - msg := recipe.Message - if msg == "" { - outDef, _ := g.ItemStore.Load(recipe.Output) - outputName := recipe.Output - if outDef != nil { - outputName = outDef.Name - } - msg = fmt.Sprintf("You clean a %s.", outputName) - } - if p.OptionBool("xp_drops") && recipe.XP > 0 { - abbr := player.SkillAbbr[player.Pharmacy] - msg += g.colorize(sess, "xp", fmt.Sprintf(" (+%dxp %s)", recipe.XP, abbr)) - } - sess.WriteLine(msg) - - // Check for more herbs - hasMore := false - for _, r := range allRecipes { - if r.Type != "clean" { - continue - } - if filter != "" && !strings.Contains(r.Consume[0].Items[0], filter) && - !strings.Contains(r.Output, filter) { - continue - } - if p.Level(player.Pharmacy) >= r.Level && r.HasAllItems(p.HasItem) { - hasMore = true - break - } - } - - if !hasMore { - sess.WriteLine("\nYou've finished cleaning herbs.") - g.CancelBackgroundAction(p) - return - } - - p.BackgroundAction.WaitLeft = engine.ToTicks(2) -} -``` - -### Mix Action Lifecycle - -Mix uses the standard production system entirely. No custom advance function needed. The `advanceProduction()` function handles it because `"mix"` is registered in `productionActionTypes` via `productionTypes`. - -Flow: -1. `doMix()` -> user selects recipe -> `promptHowMany()` -> `handleHowMany()` -> `startProductionFromRecipe()` -2. `startProductionFromRecipe()` creates `p.Action` with type `"mix"`, actionType from `productionTypes["pharmacy"]` -3. Each tick, `AdvanceActions()` calls `advanceProduction()` for `"mix"` type -4. Production cycle: start message -> wait -> skill check -> consume + output -> XP -> repeat or end - ---- - -## 13. XP Table - -### Herb Cleaning XP - -| Herb | Pharmacy Level | Clean XP | -|---|---|---| -| Guam | 3 | 3 | -| Marrentill | 5 | 4 | -| Tarromin | 11 | 5 | -| Harralander | 20 | 6 | -| Ranarr | 25 | 8 | -| Toadflax | 30 | 8 | -| Irit | 40 | 9 | -| Avantoe | 48 | 10 | -| Kwuarm | 54 | 11 | -| Snapdragon | 59 | 12 | -| Cadantine | 65 | 13 | -| Lantadyme | 67 | 13 | -| Dwarf Weed | 70 | 14 | -| Torstol | 75 | 15 | - -### Potion Mixing XP - -| Potion | Pharmacy Level | Mix XP | Herb | Reagent | -|---|---|---|---|---| -| Stim Potion | 3 | 25 | Guam | Eye of Newt | -| Bio Serum | 5 | 38 | Marrentill | Antitoxin Powder | -| Amp Potion | 12 | 50 | Tarromin | Limpwurt Root | -| Nano Restore | 22 | 63 | Harralander | Arachnid Enzyme | -| Stim Cell | 26 | 68 | Harralander | Chocolate Dust | -| Shield Potion | 30 | 75 | Ranarr | White Berries | -| Tech Serum | 38 | 88 | Ranarr | Snape Grass | -| Super Stim | 45 | 100 | Irit | Eye of Newt | -| Super Bio Serum | 48 | 106 | Irit | Antitoxin Powder | -| Super Stim Cell | 52 | 118 | Avantoe | Blight Spore | -| Super Amp | 55 | 125 | Kwuarm | Limpwurt Root | -| Full Restore | 63 | 143 | Snapdragon | Arachnid Enzyme | -| Super Shield | 66 | 150 | Cadantine | White Berries | -| Targeting Serum | 72 | 163 | Dwarf Weed | Catalyst Wine | -| Science Serum | 76 | 173 | Lantadyme | Potato Cactus | -| Restoration Compound | 81 | 180 | Toadflax | Crushed Nest | - -### Unfinished Potions - -Adding a clean herb to a vial of water gives **0 XP** and has **no level requirement**. This is an instant combine via MadeFrom, not a recipe. - -### Total XP per potion (clean + mix) - -Example: Stim Potion = 3 (clean guam) + 25 (mix) = 28 total pharmacy XP per potion. - ---- - -## 14. Mob Drops - -Grimy herbs should be added to existing mob drop tables. Higher-level mobs drop rarer herbs. - -### Existing mobs to modify - -#### `data/mobs/man.yaml` -- Add to loot table: -```yaml - - item_id: "grimy_guam" - weight: 8 - - item_id: "grimy_marrentill" - weight: 4 - - item_id: "grimy_tarromin" - weight: 2 -``` - -#### `data/mobs/guard.yaml` -- Add to loot table: -```yaml - - item_id: "grimy_guam" - weight: 6 - - item_id: "grimy_marrentill" - weight: 5 - - item_id: "grimy_tarromin" - weight: 4 - - item_id: "grimy_harralander" - weight: 3 - - item_id: "grimy_ranarr" - weight: 1 -``` - -### Suggested new mobs (future) - -Higher-tier mobs (when added) should drop higher-tier herbs. Guidelines: - -| Mob Combat Level | Herb Drops | -|---|---| -| 1-10 | Guam, Marrentill | -| 10-25 | Tarromin, Harralander | -| 25-50 | Ranarr, Toadflax, Irit | -| 50-75 | Avantoe, Kwuarm, Snapdragon | -| 75-100 | Cadantine, Lantadyme | -| 100+ | Dwarf Weed, Torstol | - -### Shared drop table (optional) - -Create `data/drops/herb_table_low.yaml` etc. for reusable herb drop tables: - -#### `data/drops/herb_table_low.yaml` -```yaml -id: herb_table_low -entries: - - item_id: grimy_guam - weight: 10 - - item_id: grimy_marrentill - weight: 7 - - item_id: grimy_tarromin - weight: 4 - - item_id: grimy_harralander - weight: 2 -``` - -#### `data/drops/herb_table_mid.yaml` -```yaml -id: herb_table_mid -entries: - - item_id: grimy_harralander - weight: 8 - - item_id: grimy_ranarr - weight: 6 - - item_id: grimy_toadflax - weight: 5 - - item_id: grimy_irit - weight: 4 - - item_id: grimy_avantoe - weight: 2 -``` - -#### `data/drops/herb_table_high.yaml` -```yaml -id: herb_table_high -entries: - - item_id: grimy_kwuarm - weight: 7 - - item_id: grimy_snapdragon - weight: 5 - - item_id: grimy_cadantine - weight: 4 - - item_id: grimy_lantadyme - weight: 3 - - item_id: grimy_dwarf_weed - weight: 2 - - item_id: grimy_torstol - weight: 1 -``` - -Mobs can reference these via `table: herb_table_low` in their loot entries (existing drop table system). - -### Reagent sources - -Reagents are obtained from: -- **Shops** (future shop system): Eye of Newt, Vial of Water, Vial, Chocolate Dust -- **Mob drops**: Limpwurt Root, Arachnid Enzyme, White Berries, Snape Grass, Antitoxin Powder -- **Gathering** (future): Blight Spore, Potato Cactus -- **Other skills**: Crushed Nest (from search on bird's nest), Catalyst Wine (future quest/gathering) - -For initial implementation, consider adding ground spawns or shop items for basic reagents (eye_of_newt, vial_of_water) so players can train pharmacy from level 1. - ---- - -## 15. Help Files - -#### `data/help/clean.yaml` -```yaml -name: "clean" -category: "Commands" -description: | - Clean grimy herbs from your inventory. - - Usage: clean Clean all grimy herbs - clean <herb> Clean only a specific herb type - - Cleaning is a background action that runs alongside other - activities. You can clean herbs while walking, gathering, or - even during combat. - - Each herb type requires a minimum Pharmacy level to clean. - You earn Pharmacy XP for each herb cleaned. - - The command processes one herb every 2 ticks and automatically - moves on to the next grimy herb in your inventory. It stops - when you have no more grimy herbs (or no more matching the - filter). - - Grimy herbs are obtained as drops from mobs or through farming. - Clean herbs are used to create unfinished potions by combining - them with a vial of water. - - See also: mix, pharmacy, use -``` - -#### `data/help/mix.yaml` -```yaml -name: "mix" -category: "Commands" -description: | - Mix potions from unfinished potions and secondary reagents. - - Usage: mix Show all potions you can mix right now - mix <potion> Mix a specific potion - mix 10 <potion> Mix a specific quantity - - Mixing does not require a station and can be done anywhere. - - Before mixing starts, you are prompted "How many?" -- press - return for all, type a number, or type anything else to cancel. - - The Pharmacy skill check determines success or failure. Higher - levels yield higher success rates. - - You can also mix by using items directly: - use eye of newt on guam potion - - To create unfinished potions, use a clean herb on a vial - of water: - use guam on vial of water - - See also: clean, pharmacy, use -``` - -#### `data/help/pharmacy.yaml` -```yaml -name: "pharmacy" -category: "Skills" -description: | - Pharmacy is the skill of creating potions from herbs and - reagents. It replaces the old Alchemy discipline. - - The production chain: - 1. Obtain grimy herbs (mob drops, farming) - 2. Clean herbs with the "clean" command (Pharmacy XP) - 3. Use clean herbs on vials of water to create unfinished - potions (no XP) - 4. Mix unfinished potions with reagents using the "mix" - command (Pharmacy XP) - - Potions provide temporary stat boosts, healing, and other - effects when consumed. Higher-level potions require rarer - herbs and reagents. - - Herbs (ordered by level): - Guam (3), Marrentill (5), Tarromin (11), Harralander (20), - Ranarr (25), Toadflax (30), Irit (40), Avantoe (48), - Kwuarm (54), Snapdragon (59), Cadantine (65), - Lantadyme (67), Dwarf Weed (70), Torstol (75) - - See also: clean, mix, score -``` - ---- - -## Implementation Order - -Recommended sequence for implementation: - -1. **Skill rename** -- Change `Alchemy` to `Pharmacy` in `player.go` (3 lines). Add migration code. Update AGENTS.md. Run `make vet` and `make test`. - -2. **Add ActionType constants** -- Add `ActionCleaning` and `ActionMixing` to `action_state.go` with `Description()` cases. - -3. **Add productionTypes entry** -- Add `"pharmacy"` to `productionTypes` map in `action_production.go`. - -4. **Add option** -- Add `mix_all` to `OptionDefs` in `player.go`. - -5. **Create all item YAML files** -- All herbs (grimy + clean), reagents, vials, unfinished potions, finished potions. Total ~57 items. - -6. **Create all recipe YAML files** -- 14 clean recipes + 16 mix recipes. Total 30 recipes. - -7. **Create `cmd_mix.go`** -- Follow `cmd_cook.go` pattern. Register in `classifyCommand()` and `executeCommand()`. - -8. **Create `cmd_clean.go`** -- The doClean handler. Register in `classifyCommand()` and `executeCommand()`. - -9. **Create `action_clean.go`** -- The `advanceClean()` background action. Register in `AdvanceActions()` switch. - -10. **Update mob drop tables** -- Add grimy herbs to existing mobs. Create shared herb drop tables. - -11. **Create help YAML files** -- `clean.yaml`, `mix.yaml`, `pharmacy.yaml`. - -12. **Test** -- Run `make build`, `make vet`, `make test`. Connect via telnet and verify: - - `score` shows Pharmacy (not Alchemy) - - Grimy herbs can be obtained (add to inventory manually or kill mobs) - - `clean` works as background action - - `use guam on vial of water` creates unfinished potion - - `mix` shows available recipes and produces potions - - XP is awarded correctly - - Level-up messages display correctly - ---- - -## File Checklist - -### New Go files (3) -- [ ] `internal/game/cmd_clean.go` -- [ ] `internal/game/cmd_mix.go` -- [ ] `internal/game/action_clean.go` - -### Modified Go files (5) -- [ ] `internal/player/player.go` (rename Alchemy->Pharmacy, add option) -- [ ] `internal/game/game.go` (classifyCommand, executeCommand) -- [ ] `internal/game/action_state.go` (ActionCleaning, ActionMixing) -- [ ] `internal/game/action.go` (AdvanceActions background switch) -- [ ] `internal/game/action_production.go` (productionTypes map) - -### New YAML item files (57) -- [ ] 14 grimy herbs: `grimy_guam.yaml` through `grimy_torstol.yaml` -- [ ] 14 clean herbs: `guam.yaml` through `torstol.yaml` -- [ ] 14 unfinished potions: `guam_potion_unf.yaml` through `torstol_potion_unf.yaml` -- [ ] 16 finished potions (see Section 8) -- [ ] 11 reagents (see Section 7) -- [ ] 2 vials: `vial.yaml`, `vial_of_water.yaml` - -### New YAML recipe files (30) -- [ ] 14 clean recipes: `clean_guam.yaml` through `clean_torstol.yaml` -- [ ] 16 mix recipes: `mix_stim_potion.yaml` through `mix_restoration_compound.yaml` - -### New YAML help files (3) -- [ ] `data/help/clean.yaml` -- [ ] `data/help/mix.yaml` -- [ ] `data/help/pharmacy.yaml` - -### New YAML drop tables (3, optional) -- [ ] `data/drops/herb_table_low.yaml` -- [ ] `data/drops/herb_table_mid.yaml` -- [ ] `data/drops/herb_table_high.yaml` - -### Modified YAML files (2+) -- [ ] `data/mobs/man.yaml` (add herb drops) -- [ ] `data/mobs/guard.yaml` (add herb drops) - -### Documentation -- [ ] `AGENTS.md` (update skill name, add commands, add action types) diff --git a/skill_plans/scavenging.md b/skill_plans/scavenging.md deleted file mode 100644 index 5dc7d39..0000000 --- a/skill_plans/scavenging.md +++ /dev/null @@ -1,950 +0,0 @@ -# Scavenging Skill Implementation Plan - -## 1. Overview - -Scavenging is the Runecrafting analog for The House of Icarus. The skill has two phases: - -1. **Mining scrap** — Gather `scrap_metal` from scrap pile objects using a pickaxe (Mining skill). This already works via `data/behaviors/mine_scrap.yaml` and `data/objects/scrap_pile.yaml`. The item `data/items/scrap_metal.yaml` exists and is non-stackable. -2. **Identifying junk** — Take scrap to an altar station. With the correct identifier tool in inventory, type `id` (or `use <altar>`). On the next tick, ALL scrap in the player's inventory is converted into the corresponding junk type. Output quantity is `scrap_count × multiplier`, where multiplier increases with Scavenging level (like OSRS double runes). - -There are 4 junk types, each requiring a specific identifier tool and altar: - -| Junk Type | Identifier Tool | Altar Station | Level Req | XP/scrap | -|-----------|----------------|---------------|-----------|----------| -| Solarjunk | Solar Identifier | Solar Altar | 1 | 5 | -| Hydrojunk | Hydro Identifier | Hydro Altar | 14 | 8 | -| Ecojunk | Eco Identifier | Eco Altar | 28 | 12 | -| Biojunk | Bio Identifier | Bio Altar | 44 | 16 | - -The Scavenging skill constant already exists in `internal/player/player.go:28` as `Scavenging SkillName = "scavenging"` with abbreviation `"scv"`. - ---- - -## 2. Commands - -### New Command: `id` - -| Property | Value | -|----------|-------| -| Command | `id` | -| Aliases | `identify` | -| Class | `ClassActive` | -| Handler | `g.doIdentify(sess, args)` | -| Action Type | `"identify"` | -| ActionState | `ActionIdentifying` | -| Ticks | 1 (instant conversion on next tick) | - -**Behavior:** When the player types `id`: -1. Cancel any active action. -2. Scan the room for an altar object (`solar_altar`, `hydro_altar`, `eco_altar`, `bio_altar`). -3. If no altar found: `"There is no altar here."` -4. Determine the junk type from the altar type. -5. Check the player has the matching identifier tool in inventory or equipment. -6. If no identifier: `"You need a <type> identifier to use this altar."` -7. Check the player has at least one `scrap_metal` in inventory. -8. If no scrap: `"You don't have any scrap metal."` -9. Check player's Scavenging level meets the junk's level requirement. -10. If too low: `"You need level <N> scavenging to identify <junk type>."` -11. Create a 1-tick `"identify"` action. -12. On advance: count all `scrap_metal` slots, remove them, calculate multiplier, add junk, award XP. - -**`use <altar>` path:** The `id` command is the primary interface. Additionally, `use` on an altar without specifying an item should route to the same `doIdentify` logic. This can be handled by giving each altar object a `behavior` that points to a behavior YAML with `type: use`, OR by special-casing altar IDs in the `use` command handler. The recommended approach is to add `use_interactions` on each altar object YAML that triggers the identify logic (see Section 6). - -### Classification and Dispatch Changes - -**File: `internal/game/game.go`** - -In `classifyCommand()` at line 136, add `"id"` and `"identify"` to the `ClassActive` list: - -```go -case "get", "take", "grab", "pick", "drop", - "attack", "kill", - "north", "n", "south", "s", "east", "e", - "west", "w", "up", "u", "down", "d", - "quit", "use", "burn", "stoke", "search", "walk", "cook", "smelt", "smith", "craft", - "id", "identify": - return ClassActive -``` - -In `executeCommand()` (around line 249), add a case: - -```go -case "id", "identify": - g.doIdentify(sess, strings.Join(args, " ")) - return -``` - ---- - -## 3. New Files to Create - -### Go Files - -| File | Purpose | -|------|---------| -| `internal/game/cmd_id.go` | `doIdentify()` handler — validate altar, identifier, scrap; create action | -| `internal/game/action_identify.go` | `startIdentify()` and `advanceIdentify()` — the conversion logic | - -### YAML Files — Items - -| File | Description | -|------|-------------| -| `data/items/solar_identifier.yaml` | Solar Identifier tool | -| `data/items/hydro_identifier.yaml` | Hydro Identifier tool | -| `data/items/eco_identifier.yaml` | Eco Identifier tool | -| `data/items/bio_identifier.yaml` | Bio Identifier tool | -| `data/items/solarjunk.yaml` | Solarjunk (stackable output) | -| `data/items/hydrojunk.yaml` | Hydrojunk (stackable output) | -| `data/items/ecojunk.yaml` | Ecojunk (stackable output) | -| `data/items/biojunk.yaml` | Biojunk (stackable output) | - -### YAML Files — Objects - -| File | Description | -|------|-------------| -| `data/objects/solar_altar.yaml` | Solar Altar station | -| `data/objects/hydro_altar.yaml` | Hydro Altar station | -| `data/objects/eco_altar.yaml` | Eco Altar station | -| `data/objects/bio_altar.yaml` | Bio Altar station | - -### YAML Files — Help - -| File | Description | -|------|-------------| -| `data/help/id.yaml` | Help for the `id` command | -| `data/help/scavenging.yaml` | Help for the Scavenging skill | - ---- - -## 4. Code Changes to Existing Files - -### `internal/game/game.go` - -1. **`classifyCommand()`** (line 136): Add `"id"`, `"identify"` to `ClassActive` case. -2. **`executeCommand()`** (line 249): Add `case "id", "identify":` dispatching to `g.doIdentify(sess, strings.Join(args, " "))`. - -### `internal/game/action_state.go` - -Add the new ActionType constant: - -```go -ActionIdentifying ActionType = "identifying" -``` - -Add a case in `Description()`: - -```go -case ActionIdentifying: - return "identifying scrap at " + a.TargetName -``` - -### `internal/game/action.go` - -In `AdvanceActions()` (line 225), add a case in the switch: - -```go -case "identify": - g.advanceIdentify(sess, p) -``` - -### `internal/game/game.go` — `ProcessQueuedCommands()` - -In the `ActionState` clearing logic (line 472), the `ActionIdentifying` type is a 1-tick action like `ActionPickingUp`. It does NOT need to persist in the list of long-running actions. After the advance tick fires and completes, the action is nil'd and the state clears naturally. No changes needed here — the `default` branch at line 474 already clears non-persistent action states. - ---- - -## 5. Items — Full YAML Definitions - -### `data/items/scrap_metal.yaml` (ALREADY EXISTS — verify) - -```yaml -id: scrap_metal -name: scrap metal -color: "243" -description: "A chunk of salvaged scrap metal." -value: 2 -stackable: false -``` - -This already exists at `data/items/scrap_metal.yaml`. No changes needed. Scrap metal is intentionally non-stackable (like rune essence in OSRS — each piece takes an inventory slot, limiting how many you can carry per trip to 28). - -### `data/items/solar_identifier.yaml` - -```yaml -id: solar_identifier -name: solar identifier -color: "220" -description: "A handheld scanner calibrated to isolate solar-frequency signatures in scrap metal. Required to produce solarjunk at a solar altar." -value: 500 -``` - -### `data/items/hydro_identifier.yaml` - -```yaml -id: hydro_identifier -name: hydro identifier -color: "39" -description: "A handheld scanner calibrated to isolate hydro-frequency signatures in scrap metal. Required to produce hydrojunk at a hydro altar." -value: 1000 -``` - -### `data/items/eco_identifier.yaml` - -```yaml -id: eco_identifier -name: eco identifier -color: "34" -description: "A handheld scanner calibrated to isolate eco-frequency signatures in scrap metal. Required to produce ecojunk at an eco altar." -value: 2500 -``` - -### `data/items/bio_identifier.yaml` - -```yaml -id: bio_identifier -name: bio identifier -color: "196" -description: "A handheld scanner calibrated to isolate bio-frequency signatures in scrap metal. Required to produce biojunk at a bio altar." -value: 5000 -``` - -### `data/items/solarjunk.yaml` - -```yaml -id: solarjunk -name: solarjunk -color: "220" -description: "A fragment of reclaimed solar circuitry. Used as a universal energy medium." -value: 10 -stackable: true -``` - -### `data/items/hydrojunk.yaml` - -```yaml -id: hydrojunk -name: hydrojunk -color: "39" -description: "A fragment of reclaimed hydro circuitry. Used as a universal energy medium." -value: 18 -stackable: true -``` - -### `data/items/ecojunk.yaml` - -```yaml -id: ecojunk -name: ecojunk -color: "34" -description: "A fragment of reclaimed eco circuitry. Used as a universal energy medium." -value: 30 -stackable: true -``` - -### `data/items/biojunk.yaml` - -```yaml -id: biojunk -name: biojunk -color: "196" -description: "A fragment of reclaimed bio circuitry. Used as a universal energy medium." -value: 50 -stackable: true -``` - ---- - -## 6. Objects — Full YAML Definitions - -### `data/objects/solar_altar.yaml` - -```yaml -id: solar_altar -name: solar altar -color: "220" -description: "A humming altar of golden circuitry. Place scrap metal here with a solar identifier to produce solarjunk. Type 'id' to begin." -``` - -### `data/objects/hydro_altar.yaml` - -```yaml -id: hydro_altar -name: hydro altar -color: "39" -description: "A pulsing altar of blue crystalline conduits. Place scrap metal here with a hydro identifier to produce hydrojunk. Type 'id' to begin." -``` - -### `data/objects/eco_altar.yaml` - -```yaml -id: eco_altar -name: eco altar -color: "34" -description: "A thrumming altar of green organic circuits. Place scrap metal here with an eco identifier to produce ecojunk. Type 'id' to begin." -``` - -### `data/objects/bio_altar.yaml` - -```yaml -id: bio_altar -name: bio altar -color: "196" -description: "A sinister altar of red biological interfaces. Place scrap metal here with a bio identifier to produce biojunk. Type 'id' to begin." -``` - ---- - -## 7. Rooms - -### Existing Room: Room 9 — Scavenging Post - -Currently at `data/rooms/9.yaml`: - -```yaml -id: 9 -name: "Scavenging Post" -description: "A cluttered yard full of scrap metal and discarded technology. This area is not yet accessible." -exits: - east: 10 - west: 8 -``` - -**Update room 9** to add scrap pile objects and a useful description: - -```yaml -id: 9 -name: "Scavenging Post" -description: "A cluttered yard full of {243}scrap metal{/} and discarded technology. Piles of salvageable debris are scattered across the ground. The air smells of ozone and rust." -exits: - east: 10 - west: 8 -objects: - - id: scrap_pile - - id: scrap_pile - - id: scrap_pile -``` - -### New Altar Rooms - -Create 4 new rooms for the altars. These should branch off from or near the Scavenging Post area. The exact room IDs depend on the next available IDs in the codebase. Check `data/rooms/` for the highest existing ID and use the next sequential numbers. - -**Suggested room layout:** -- Room 9 (Scavenging Post) — hub with scrap piles -- New rooms branching from room 9 or nearby rooms for each altar - -Example new room files (IDs are placeholders — use next available): - -#### `data/rooms/<next_id>.yaml` — Solar Altar Chamber - -```yaml -id: <next_id> -name: "Solar Altar Chamber" -description: "A circular chamber bathed in warm {220}golden light{/}. At its center stands a humming {220 bold}solar altar{/}, its surface etched with fractal circuit patterns that glow softly." -exits: - south: 9 -objects: - - id: solar_altar -``` - -#### `data/rooms/<next_id+1>.yaml` — Hydro Altar Chamber - -```yaml -id: <next_id+1> -name: "Hydro Altar Chamber" -description: "Condensation drips from the ceiling of this cool, blue-lit chamber. A {39 bold}hydro altar{/} dominates the room, its crystalline surface rippling with patterns like flowing water." -exits: - south: 9 -objects: - - id: hydro_altar -``` - -#### `data/rooms/<next_id+2>.yaml` — Eco Altar Chamber - -```yaml -id: <next_id+2> -name: "Eco Altar Chamber" -description: "Bioluminescent moss covers the walls of this overgrown chamber. An {34 bold}eco altar{/} rises from the floor, entwined with living circuitry that pulses with green light." -exits: - south: 9 -objects: - - id: eco_altar -``` - -#### `data/rooms/<next_id+3>.yaml` — Bio Altar Chamber - -```yaml -id: <next_id+3> -name: "Bio Altar Chamber" -description: "The air here is thick and warm. A {196 bold}bio altar{/} throbs at the center of the room, its surface covered in red organic membranes threaded with copper wire." -exits: - south: 9 -objects: - - id: bio_altar -``` - -**Update room 9 exits** to add north exits (or other directions) to the altar rooms. The exact exit directions depend on the room layout. Add exits from room 9 to each altar room, e.g.: - -```yaml -exits: - east: 10 - west: 8 - north: <solar_altar_room_id> -``` - -Or create an intermediate hub room that branches to all 4 altars. - -**To determine actual room IDs:** Run `ls data/rooms/ | sort -n | tail -5` to find the highest existing room ID, then use sequential IDs after that. - ---- - -## 8. Mechanics — The `id` Command - -### Flow Diagram - -``` -Player types "id" (or "identify") - │ - ├── CancelAction(p) - │ - ├── Scan room for altar objects - │ └── Check: solar_altar, hydro_altar, eco_altar, bio_altar - │ └── If none found: "There is no altar here." → return - │ - ├── Determine altar type → junk mapping - │ ├── solar_altar → solarjunk (req: solar_identifier, level 1, 5 XP) - │ ├── hydro_altar → hydrojunk (req: hydro_identifier, level 14, 8 XP) - │ ├── eco_altar → ecojunk (req: eco_identifier, level 28, 12 XP) - │ └── bio_altar → biojunk (req: bio_identifier, level 44, 16 XP) - │ - ├── Check identifier tool in inventory or equipment - │ └── If missing: "You need a <name> to use this altar." → return - │ - ├── Check player has scrap_metal in inventory - │ └── If none: "You don't have any scrap metal." → return - │ - ├── Check Scavenging level ≥ required level - │ └── If too low: "You need level <N> scavenging to do that." → return - │ - └── Create action: - p.Action = &action.Action{ - Type: "identify", - TargetName: <altar_name>, - Data: { - "altar_type": <altar_def_id>, - "junk_id": <junk_item_id>, - "xp_per": <xp_per_scrap>, - "level_req": <level_req>, - }, - WaitLeft: 1, - } - p.ActionState = &ActionState{Type: ActionIdentifying, TargetName: <altar_name>} -``` - -### Advance Logic (next tick) - -``` -advanceIdentify(sess, p): - │ - ├── Read action data (junk_id, xp_per, level_req) - │ - ├── Count all scrap_metal in inventory - │ └── Iterate slots 0-27, sum quantities where ItemID == "scrap_metal" - │ - ├── Calculate multiplier from Scavenging level (see Section 11) - │ └── totalJunk = scrapCount * multiplier - │ - ├── Check inventory space for junk (junk is stackable, so only need 1 free slot - │ if player doesn't already have that junk type) - │ └── If no existing stack AND no free slot: - │ "Your inventory is too full!" → CancelAction → return - │ - ├── Remove all scrap_metal from inventory - │ └── Iterate slots 0-27, nil any slot with ItemID == "scrap_metal" - │ - ├── Add junk to inventory - │ └── Find existing stack of junk_id → add totalJunk to quantity - │ └── OR find free slot → set to {ItemID: junk_id, Quantity: totalJunk} - │ - ├── Award XP: totalXP = scrapCount * xp_per - │ └── p.AddSkillXP(player.Scavenging, totalXP) - │ └── Check for level-up, output message if so - │ - ├── Output message: - │ "The altar hums. You identify <scrapCount> scrap metal into <totalJunk> <junk_name>." - │ └── If xp_drops enabled: " (+<totalXP>xp scv)" - │ - ├── SaveCharacter(p) - │ - └── CancelAction(p) -``` - ---- - -## 9. Action Lifecycle — Implementation Details - -### `internal/game/cmd_id.go` - -```go -package game - -import ( - "fmt" - - "thehouseoficarus/internal/action" - "thehouseoficarus/internal/net" - "thehouseoficarus/internal/player" -) - -type altarConfig struct { - AltarID string - IdentifierID string - JunkID string - LevelReq int - XPPer int -} - -var altarConfigs = []altarConfig{ - {"solar_altar", "solar_identifier", "solarjunk", 1, 5}, - {"hydro_altar", "hydro_identifier", "hydrojunk", 14, 8}, - {"eco_altar", "eco_identifier", "ecojunk", 28, 12}, - {"bio_altar", "bio_identifier", "biojunk", 44, 16}, -} - -func (g *Game) doIdentify(sess *net.Session, input string) { - p := sess.Player.(*player.Player) - g.CancelAction(p) - - var found *altarConfig - for _, ac := range altarConfigs { - if defID, _ := g.findStation(p.RoomID, []string{ac.AltarID}); defID != "" { - found = &ac - break - } - } - if found == nil { - sess.WriteLine("There is no altar here.") - return - } - - identifierName := found.IdentifierID - if def, err := g.ItemStore.Load(found.IdentifierID); err == nil { - identifierName = def.Name - } - if !p.HasItem(found.IdentifierID) { - sess.WriteLine(fmt.Sprintf("You need a %s to use this altar.", identifierName)) - return - } - - scrapCount := g.countItem(p, "scrap_metal") - if scrapCount == 0 { - sess.WriteLine("You don't have any scrap metal.") - return - } - - scavLevel := p.Level(player.Scavenging) - if scavLevel < found.LevelReq { - junkName := found.JunkID - if def, err := g.ItemStore.Load(found.JunkID); err == nil { - junkName = def.Name - } - sess.WriteLine(fmt.Sprintf("You need level %d scavenging to identify %s.", found.LevelReq, junkName)) - return - } - - altarName := found.AltarID - if def, err := g.ObjectStore.Load(found.AltarID); err == nil { - altarName = def.Name - } - - p.Action = &action.Action{ - Type: "identify", - TargetID: found.AltarID, - TargetName: altarName, - WaitLeft: 1, - Data: map[string]any{ - "junk_id": found.JunkID, - "xp_per": found.XPPer, - "level_req": found.LevelReq, - }, - } - p.ActionState = &ActionState{Type: ActionIdentifying, TargetName: altarName} -} - -// countItem counts total quantity of an item across all inventory slots. -// For non-stackable items, each slot with that item contributes 1. -func (g *Game) countItem(p *player.Player, itemID string) int { - total := 0 - for i := 0; i < 28; i++ { - slot := p.InvSlot(i) - if slot != nil && slot.ItemID == itemID { - total += slot.Quantity - } - } - return total -} -``` - -**Note on `countItem`:** Check if `player.Player` already has a `CountItem` method. Looking at the codebase, `p.CountItem` is used in production code (`recipe.HasAllItemsQty(p.CountItem)`). Use that if it already returns a count for a given item ID. If `CountItem(id string) int` exists, use `p.CountItem("scrap_metal")` instead of a custom helper. - -### `internal/game/action_identify.go` - -```go -package game - -import ( - "fmt" - - "thehouseoficarus/internal/net" - "thehouseoficarus/internal/player" -) - -func (g *Game) advanceIdentify(sess *net.Session, p *player.Player) { - junkID := p.Action.Data["junk_id"].(string) - xpPer := p.Action.Data["xp_per"].(int) - - scrapCount := 0 - for i := 0; i < 28; i++ { - slot := p.InvSlot(i) - if slot != nil && slot.ItemID == "scrap_metal" { - scrapCount += slot.Quantity - } - } - - if scrapCount == 0 { - sess.WriteLine("You don't have any scrap metal.") - g.CancelAction(p) - return - } - - scavLevel := p.Level(player.Scavenging) - multiplier := junkMultiplier(junkID, scavLevel) - totalJunk := scrapCount * multiplier - - hasExistingStack := false - for i := 0; i < 28; i++ { - slot := p.InvSlot(i) - if slot != nil && slot.ItemID == junkID { - hasExistingStack = true - break - } - } - if !hasExistingStack && p.FirstFreeSlot() == -1 { - sess.WriteLine("Your inventory is too full!") - g.CancelAction(p) - return - } - - for i := 0; i < 28; i++ { - slot := p.InvSlot(i) - if slot != nil && slot.ItemID == "scrap_metal" { - p.SetInvSlot(i, nil) - } - } - - placed := false - for i := 0; i < 28; i++ { - slot := p.InvSlot(i) - if slot != nil && slot.ItemID == junkID { - slot.Quantity += totalJunk - placed = true - break - } - } - if !placed { - freeSlot := p.FirstFreeSlot() - p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: junkID, Quantity: totalJunk}) - } - - totalXP := scrapCount * xpPer - junkName := junkID - if def, err := g.ItemStore.Load(junkID); err == nil { - junkName = def.Name - } - - msg := fmt.Sprintf("The altar hums. You identify %d scrap metal into %d %s.", scrapCount, totalJunk, junkName) - - if newLevel := p.AddSkillXP(player.Scavenging, totalXP); newLevel > 0 { - sess.WriteLine(g.colorize(sess, "level_up", - fmt.Sprintf("*** You are now level %d scavenging! ***", newLevel))) - } - - if p.OptionBool("xp_drops") && totalXP > 0 { - msg += g.colorize(sess, "xp", fmt.Sprintf(" (+%dxp scv)", totalXP)) - } - sess.WriteLine(msg) - - g.AccountStore.SaveCharacter(p) - g.CancelAction(p) -} -``` - -**Note on `xpPer` type:** When data is stored via `map[string]any` and read back after JSON/YAML round-tripping through the action system, integer values may come back as `float64` (if the action data is ever serialized). Check how other action handlers read integers from `Data`. Looking at `action_search.go:42`, it uses `data["slot_idx"].(int)` — so direct int assertion is fine since action data is never serialized to disk. However, `action_production.go:310` reads `wait` as `float64` — this is because `map[string]any` stores numbers from YAML as float64. Since we're setting these values directly in Go code (not loading from YAML), `int` assertion is correct. But to be safe, consider storing as `float64` and reading as `float64`, then converting: - -```go -xpPer := int(p.Action.Data["xp_per"].(float64)) -``` - -This is safer. Check the `advanceSearch` pattern — it reads `int` directly because it was set as `int` in `startSearch`. Since `doIdentify` also sets `int` values directly, the `.(int)` assertion should work. But be aware: if the game ever serializes/deserializes actions, this could break. Follow whichever pattern the codebase uses consistently. - ---- - -## 10. XP Table - -XP is awarded per scrap converted, not per junk produced. This means multipliers give more output but not more XP per scrap. - -| Junk Type | XP per scrap | Level Req | -|-----------|-------------|-----------| -| Solarjunk | 5 | 1 | -| Hydrojunk | 8 | 14 | -| Ecojunk | 12 | 28 | -| Biojunk | 16 | 44 | - -**XP examples (28 inventory slots = 28 scrap per trip):** - -| Junk Type | XP per trip (28 scrap) | -|-----------|----------------------| -| Solarjunk | 140 | -| Hydrojunk | 224 | -| Ecojunk | 336 | -| Biojunk | 448 | - -For reference, level 99 requires 13,034,431 XP (RSC table). At 448 XP per trip (biojunk), that's ~29,094 trips — comparable to OSRS Runecrafting grind. - ---- - -## 11. Level Multipliers - -At higher Scavenging levels, each scrap metal produces multiple junk (like OSRS double/triple runes). - -### Multiplier Table - -| Scavenging Level | Solarjunk | Hydrojunk | Ecojunk | Biojunk | -|-----------------|-----------|-----------|---------|---------| -| 1 | 1x | — | — | — | -| 14 | 1x | 1x | — | — | -| 22 | 2x | 1x | — | — | -| 28 | 2x | 1x | 1x | — | -| 36 | 2x | 2x | 1x | — | -| 44 | 3x | 2x | 1x | 1x | -| 56 | 3x | 2x | 2x | 1x | -| 66 | 4x | 3x | 2x | 1x | -| 78 | 4x | 3x | 3x | 2x | -| 88 | 5x | 4x | 3x | 2x | -| 99 | 6x | 4x | 3x | 3x | - -### Implementation - -```go -type junkMultiplierEntry struct { - Level int - Multiplier int -} - -var junkMultipliers = map[string][]junkMultiplierEntry{ - "solarjunk": { - {1, 1}, {22, 2}, {44, 3}, {66, 4}, {88, 5}, {99, 6}, - }, - "hydrojunk": { - {14, 1}, {36, 2}, {66, 3}, {88, 4}, - }, - "ecojunk": { - {28, 1}, {56, 2}, {78, 3}, - }, - "biojunk": { - {44, 1}, {78, 2}, {99, 3}, - }, -} - -func junkMultiplier(junkID string, level int) int { - entries, ok := junkMultipliers[junkID] - if !ok { - return 1 - } - mult := 1 - for _, e := range entries { - if level >= e.Level { - mult = e.Multiplier - } - } - return mult -} -``` - -Place this in `cmd_id.go` or `action_identify.go` — whichever file contains the `advanceIdentify` function. - ---- - -## 12. Mining Scrap — Verification - -The existing setup: - -- **Behavior:** `data/behaviors/mine_scrap.yaml` — skill: mining, level 1, XP 5, base_wait 10, requires pickaxe, 55% base success + 1% per level (cap 95%), drops `scrap_metal` with depletion, 50-tick respawn. -- **Object:** `data/objects/scrap_pile.yaml` — name "scrap pile", behavior `mine_scrap`. -- **Item:** `data/items/scrap_metal.yaml` — non-stackable, value 2. - -**Verify these work by:** -1. Ensure room 9 has `scrap_pile` objects listed. -2. Test: `mine scrap` or `mine pile` should start gathering. -3. Test: Successful gather produces `scrap_metal` in inventory. -4. Test: Depletion and respawn work correctly. - -**Potential adjustment:** The `base_wait: 10` (10 ticks = 6 seconds at 600ms tick) is reasonable for scrap mining. The success rate starting at 55% and capping at 95% is fine. Consider whether the behavior should use `skill: scavenging` instead of `skill: mining`. The TODO says "Mine 'scrap' in scrap pile objects" which implies Mining skill is correct (Scavenging is for the identification phase). However, this is a design decision — if scrap mining should train Scavenging, change the behavior to `skill: scavenging`. - -**Recommendation:** Keep `skill: mining` for scrap mining (consistent with the mining skill being used for all pick-based gathering). The Scavenging skill is trained exclusively through identification at altars. This parallels OSRS where Mining trains Mining and Runecrafting trains Runecrafting. - ---- - -## 13. Help Files - -### `data/help/id.yaml` - -```yaml -name: "id" -category: "Commands" -description: | - Identify scrap metal at an altar to produce junk. - - Usage: id - identify - - Stand at an altar (solar, hydro, eco, or bio) with the matching - identifier tool in your inventory and scrap metal. Type 'id' to - convert all your scrap metal into junk on the next game tick. - - Each altar type requires a specific identifier: - Solar Altar → solar identifier → solarjunk (level 1) - Hydro Altar → hydro identifier → hydrojunk (level 14) - Eco Altar → eco identifier → ecojunk (level 28) - Bio Altar → bio identifier → biojunk (level 44) - - At higher scavenging levels, each scrap produces multiple junk. - See 'help scavenging' for the full multiplier table. -``` - -### `data/help/scavenging.yaml` - -```yaml -name: "scavenging" -category: "Skills" -description: | - Scavenging is the art of reclaiming useful circuitry from salvaged scrap. - - Step 1: Mine scrap metal from scrap piles (requires a pickaxe, uses - the Mining skill). - Step 2: Take scrap metal to an altar with the matching identifier tool. - Step 3: Type 'id' to convert all scrap metal into junk. - - Junk types and requirements: - Solarjunk — level 1, solar identifier, solar altar (5 XP/scrap) - Hydrojunk — level 14, hydro identifier, hydro altar (8 XP/scrap) - Ecojunk — level 28, eco identifier, eco altar (12 XP/scrap) - Biojunk — level 44, bio identifier, bio altar (16 XP/scrap) - - Higher scavenging levels produce more junk per scrap: - Solarjunk: 2x at 22, 3x at 44, 4x at 66, 5x at 88, 6x at 99 - Hydrojunk: 2x at 36, 3x at 66, 4x at 88 - Ecojunk: 2x at 56, 3x at 78 - Biojunk: 2x at 78, 3x at 99 - - Scrap metal is not stackable — you can carry 28 per trip (one per - inventory slot). Each trip converts all scrap in one tick. -``` - ---- - -## 14. Implementation Checklist - -### Phase 1: Core Files (minimal working feature) - -- [ ] Create `internal/game/cmd_id.go` with `doIdentify()`, `altarConfigs`, `junkMultiplier()` -- [ ] Create `internal/game/action_identify.go` with `advanceIdentify()` -- [ ] Edit `internal/game/action_state.go`: add `ActionIdentifying` constant and `Description()` case -- [ ] Edit `internal/game/game.go` → `classifyCommand()`: add `"id"`, `"identify"` to `ClassActive` -- [ ] Edit `internal/game/game.go` → `executeCommand()`: add `case "id", "identify":` -- [ ] Edit `internal/game/action.go` → `AdvanceActions()`: add `case "identify":` - -### Phase 2: YAML Data - -- [ ] Create `data/items/solar_identifier.yaml` -- [ ] Create `data/items/hydro_identifier.yaml` -- [ ] Create `data/items/eco_identifier.yaml` -- [ ] Create `data/items/bio_identifier.yaml` -- [ ] Create `data/items/solarjunk.yaml` -- [ ] Create `data/items/hydrojunk.yaml` -- [ ] Create `data/items/ecojunk.yaml` -- [ ] Create `data/items/biojunk.yaml` -- [ ] Create `data/objects/solar_altar.yaml` -- [ ] Create `data/objects/hydro_altar.yaml` -- [ ] Create `data/objects/eco_altar.yaml` -- [ ] Create `data/objects/bio_altar.yaml` - -### Phase 3: Rooms - -- [ ] Update `data/rooms/9.yaml` — add scrap pile objects, update description, add exits to altar rooms -- [ ] Create altar room YAML files (4 rooms) — determine next available room IDs -- [ ] Ensure exits are bidirectional (altar rooms exit back to room 9 or a hub) - -### Phase 4: Help - -- [ ] Create `data/help/id.yaml` -- [ ] Create `data/help/scavenging.yaml` - -### Phase 5: Verify - -- [ ] `make build` — compiles without errors -- [ ] `make vet` — no vet warnings -- [ ] `make test` — all tests pass -- [ ] Manual test: mine scrap at room 9, walk to altar, `id`, verify conversion -- [ ] Test: identifier not in inventory → correct error message -- [ ] Test: no scrap in inventory → correct error message -- [ ] Test: no altar in room → correct error message -- [ ] Test: level too low → correct error message -- [ ] Test: inventory full (no room for junk) → correct error message -- [ ] Test: multiplier works at higher levels -- [ ] Test: XP is awarded correctly -- [ ] Test: level-up message displays -- [ ] Test: `help id` and `help scavenging` display correctly - ---- - -## 15. Design Decisions & Edge Cases - -### Why not use the recipe/production system? - -The production system (`action_production.go`) processes items one at a time in a loop with "How many?" prompts and per-cycle timing. Scavenging converts ALL scrap in a single tick with level-based multipliers. The mechanics are fundamentally different: -- Production: consume 1 input → wait N ticks → produce 1 output → repeat -- Identify: consume ALL scrap → wait 1 tick → produce (scrap × multiplier) junk - -A custom action type (`identify`) is cleaner than bending the production system. - -### Scrap is non-stackable (intentional) - -Like rune essence in OSRS, scrap metal fills one inventory slot per piece. This limits trips to 28 scrap maximum, creating a meaningful gameplay loop of mine → walk → identify → repeat. - -### Identifier tools are NOT consumed - -Identifiers are reusable tools (like a talisman in OSRS). Players buy/find them once and keep them. They take 1 inventory slot, reducing max scrap per trip to 27. - -### What if the player has multiple types of identifiers? - -The `id` command scans for altars in the room, not identifiers. It finds which altar is present, then checks for the matching identifier. Only one altar type per room. No ambiguity. - -### What if multiple altars are in the same room? - -The first matching altar in `altarConfigs` order wins (solar → hydro → eco → bio). In practice, each room should have exactly one altar. - -### Junk items are stackable - -Unlike scrap, junk stacks in a single inventory slot. A player can accumulate unlimited junk in one slot. This is intentional — junk is the "rune" equivalent and will be consumed by future skills/spells. - -### XP is per scrap, not per junk - -If a player converts 28 scrap at 3x multiplier, they get 84 junk but only 28 × (xp_per) XP. The multiplier rewards efficiency (more output per trip) without inflating XP rates. - -### The `countItem` helper - -Before implementing, check if `player.Player` already has a `CountItem(id string) int` method. The production system uses `p.CountItem` — see `action_production.go:221` where `recipe.HasAllItemsQty(p.CountItem)` passes it as a function. If `CountItem` exists, use it directly instead of writing a new helper. If it returns a count, `p.CountItem("scrap_metal")` gives the total. - -### Action cancellation - -The `identify` action is a 1-tick action. If the player moves or starts another action before it fires, the action is cancelled naturally (CancelAction is called). The scrap is NOT consumed until `advanceIdentify` runs, so cancellation is safe — no items lost. diff --git a/skill_plans/science.md b/skill_plans/science.md deleted file mode 100644 index b3c1cb7..0000000 --- a/skill_plans/science.md +++ /dev/null @@ -1,3115 +0,0 @@ -# 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 -``` diff --git a/skill_plans/technology.md b/skill_plans/technology.md deleted file mode 100644 index c51e5dc..0000000 --- a/skill_plans/technology.md +++ /dev/null @@ -1,1907 +0,0 @@ -# Technology Skill Implementation Plan - -## 1. Overview - -Technology is the Prayer equivalent in The House of Icarus. - -| OSRS Concept | THOI Equivalent | -|---|---| -| Prayer | Technology | -| Prayer points | Battery | -| Prayers | Techs | -| Prayer bonus (equipment) | Technology bonus (equipment, `ItemStats.TechnologyBonus`) | -| Altar | Charging Station / Power Conduit | -| Prayer level | Technology level (already exists: `player.Technology`) | - -Battery is a `float64` that drains while techs are active. Max battery = Technology level. Battery is saved to character YAML. Active techs are NOT saved (deactivate on logout/death). `TechnologyBonus` on equipment (`ItemStats.TechnologyBonus` at `internal/object/item.go:72`) already exists but is currently unused. - ---- - -## 2. Player State Changes - -### File: `internal/player/player.go` - -Add these fields to the `Player` struct (after line 174, near `VisualTickCurrent`): - -```go -Battery float64 `yaml:"battery"` -ActiveTechs map[string]bool `yaml:"-"` -QuickTech string `yaml:"quick_tech,omitempty"` -TechActivatedAt map[string]int64 `yaml:"-"` // tick number when each tech was last activated -``` - -**Field details:** - -| Field | YAML | Description | -|---|---|---| -| `Battery` | `yaml:"battery"` | Current battery level. Saved to character YAML. Float64 for drain precision. | -| `ActiveTechs` | `yaml:"-"` | Map of currently active tech IDs. NOT saved. Cleared on logout/death. | -| `QuickTech` | `yaml:"quick_tech,omitempty"` | ID of the quick-toggle tech. Saved to character YAML. | -| `TechActivatedAt` | `yaml:"-"` | Maps tech ID to the tick number it was last activated. Used for 1-tick flicking grace period. | - -### New methods on `*Player`: - -```go -func (p *Player) MaxBattery() float64 { - return float64(p.Level(Technology)) -} - -func (p *Player) HasActiveTech(techID string) bool { - if p.ActiveTechs == nil { - return false - } - return p.ActiveTechs[techID] -} - -func (p *Player) ActivateTech(techID string, tickNum int64) { - if p.ActiveTechs == nil { - p.ActiveTechs = make(map[string]bool) - } - if p.TechActivatedAt == nil { - p.TechActivatedAt = make(map[string]int64) - } - p.ActiveTechs[techID] = true - p.TechActivatedAt[techID] = tickNum -} - -func (p *Player) DeactivateTech(techID string) { - if p.ActiveTechs != nil { - delete(p.ActiveTechs, techID) - } -} - -func (p *Player) DeactivateAllTechs() { - p.ActiveTechs = nil - p.TechActivatedAt = nil -} - -func (p *Player) ActiveTechList() []string { - var result []string - for id := range p.ActiveTechs { - result = append(result, id) - } - sort.Strings(result) - return result -} -``` - -### Initialization in `New()`: - -Add to `New()` function at `internal/player/player.go:276`: - -```go -// After p.HP = p.MaxHP() (line 289): -p.Battery = p.MaxBattery() -``` - -New characters start with full battery. - -### Add tick counter to Game struct - -The `Game` struct at `internal/game/game.go:36` needs a tick counter for tracking activation times: - -```go -type Game struct { - // ... existing fields ... - tickCount int64 -} -``` - -Increment `tickCount` at the start of each tick cycle. See Section 7 for where this happens. - ---- - -## 3. Tech Definitions - -### New file: `internal/game/tech.go` - -Hardcoded in Go (not YAML-driven). All tech definitions, mutual exclusivity, and helper functions live here. - -```go -package game - -import "sort" - -type TechEffects struct { - AttackPercent int - StrengthPercent int - DefensePercent int - RangedPercent int - SciencePercent int - ProtectMelee bool - ProtectRanged bool - ProtectScience bool - DamageReduction float64 - HPRegenMulti float64 // multiplier on HP regen rate (2.0 = 2x faster) - PreserveDrain float64 // reduces other tech drain by this fraction (0.2 = 20% reduction) - RetributionPct float64 // on death, deal this fraction of max HP as damage -} - -type TechDef struct { - ID string - Name string - Level int - DrainRate float64 - Category string - Group string // mutual exclusivity group - Effects TechEffects -} - -var AllTechs []TechDef - -var techByID map[string]*TechDef - -func init() { - AllTechs = []TechDef{ - // ── Attack Techs ── - { - ID: "clarity_1", Name: "Clarity", Level: 4, DrainRate: 0.05, - Category: "attack", Group: "attack_tier", - Effects: TechEffects{AttackPercent: 5}, - }, - { - ID: "clarity_2", Name: "Enhanced Clarity", Level: 16, DrainRate: 0.10, - Category: "attack", Group: "attack_tier", - Effects: TechEffects{AttackPercent: 10}, - }, - { - ID: "clarity_3", Name: "Superior Clarity", Level: 44, DrainRate: 0.15, - Category: "attack", Group: "attack_tier", - Effects: TechEffects{AttackPercent: 15}, - }, - - // ── Strength Techs ── - { - ID: "amplifier_1", Name: "Power Amplifier", Level: 7, DrainRate: 0.05, - Category: "strength", Group: "strength_tier", - Effects: TechEffects{StrengthPercent: 5}, - }, - { - ID: "amplifier_2", Name: "Enhanced Amplifier", Level: 23, DrainRate: 0.10, - Category: "strength", Group: "strength_tier", - Effects: TechEffects{StrengthPercent: 10}, - }, - { - ID: "amplifier_3", Name: "Superior Amplifier", Level: 49, DrainRate: 0.15, - Category: "strength", Group: "strength_tier", - Effects: TechEffects{StrengthPercent: 15}, - }, - - // ── Defense Techs ── - { - ID: "shield_1", Name: "Energy Shield", Level: 10, DrainRate: 0.05, - Category: "defense", Group: "defense_tier", - Effects: TechEffects{DefensePercent: 5}, - }, - { - ID: "shield_2", Name: "Enhanced Shield", Level: 28, DrainRate: 0.10, - Category: "defense", Group: "defense_tier", - Effects: TechEffects{DefensePercent: 10}, - }, - { - ID: "shield_3", Name: "Superior Shield", Level: 52, DrainRate: 0.15, - Category: "defense", Group: "defense_tier", - Effects: TechEffects{DefensePercent: 15}, - }, - - // ── Ranged Techs ── - { - ID: "targeting_1", Name: "Targeting System", Level: 8, DrainRate: 0.05, - Category: "ranged", Group: "ranged_tier", - Effects: TechEffects{RangedPercent: 5}, - }, - { - ID: "targeting_2", Name: "Enhanced Targeting", Level: 22, DrainRate: 0.10, - Category: "ranged", Group: "ranged_tier", - Effects: TechEffects{RangedPercent: 10}, - }, - { - ID: "targeting_3", Name: "Superior Targeting", Level: 46, DrainRate: 0.15, - Category: "ranged", Group: "ranged_tier", - Effects: TechEffects{RangedPercent: 15}, - }, - - // ── Science Techs ── - { - ID: "focus_1", Name: "Neural Focus", Level: 9, DrainRate: 0.05, - Category: "science", Group: "science_tier", - Effects: TechEffects{SciencePercent: 5}, - }, - { - ID: "focus_2", Name: "Enhanced Focus", Level: 27, DrainRate: 0.10, - Category: "science", Group: "science_tier", - Effects: TechEffects{SciencePercent: 10}, - }, - { - ID: "focus_3", Name: "Superior Focus", Level: 55, DrainRate: 0.15, - Category: "science", Group: "science_tier", - Effects: TechEffects{SciencePercent: 15}, - }, - - // ── Protection Techs ── - { - ID: "protect_melee", Name: "Kinetic Barrier", Level: 37, DrainRate: 0.20, - Category: "protection", Group: "protection", - Effects: TechEffects{ProtectMelee: true, DamageReduction: 0.4}, - }, - { - ID: "protect_ranged", Name: "Projectile Screen", Level: 40, DrainRate: 0.20, - Category: "protection", Group: "protection", - Effects: TechEffects{ProtectRanged: true, DamageReduction: 0.4}, - }, - { - ID: "protect_science", Name: "Neural Firewall", Level: 43, DrainRate: 0.20, - Category: "protection", Group: "protection", - Effects: TechEffects{ProtectScience: true, DamageReduction: 0.4}, - }, - - // ── Utility Techs ── - { - ID: "regen", Name: "Nano Repair", Level: 22, DrainRate: 0.10, - Category: "utility", Group: "regen_tier", - Effects: TechEffects{HPRegenMulti: 2.0}, - }, - { - ID: "rapid_heal", Name: "Rapid Repair", Level: 31, DrainRate: 0.15, - Category: "utility", Group: "regen_tier", - Effects: TechEffects{HPRegenMulti: 4.0}, - }, - { - ID: "preserve", Name: "Power Saver", Level: 55, DrainRate: 0.05, - Category: "utility", Group: "", - Effects: TechEffects{PreserveDrain: 0.2}, - }, - { - ID: "retribution", Name: "Dead Man's Switch", Level: 46, DrainRate: 0.10, - Category: "utility", Group: "", - Effects: TechEffects{RetributionPct: 0.25}, - }, - - // ── Combo Techs (high level, multi-stat) ── - { - ID: "overclock", Name: "Overclock", Level: 60, DrainRate: 0.25, - Category: "combo", Group: "attack_tier", - Effects: TechEffects{AttackPercent: 15, StrengthPercent: 15}, - }, - { - ID: "fortify", Name: "Fortify", Level: 65, DrainRate: 0.25, - Category: "combo", Group: "defense_tier", - Effects: TechEffects{DefensePercent: 15, HPRegenMulti: 2.0}, - }, - } - - techByID = make(map[string]*TechDef, len(AllTechs)) - for i := range AllTechs { - techByID[AllTechs[i].ID] = &AllTechs[i] - } -} - -func GetTechDef(id string) *TechDef { - return techByID[id] -} - -func TechsForLevel(level int) []*TechDef { - var result []*TechDef - for i := range AllTechs { - if AllTechs[i].Level <= level { - result = append(result, &AllTechs[i]) - } - } - return result -} - -func TechByPrefixMatch(input string) []*TechDef { - input = strings.ToLower(input) - var exact []*TechDef - var prefix []*TechDef - for i := range AllTechs { - lower := strings.ToLower(AllTechs[i].Name) - lowerID := strings.ToLower(AllTechs[i].ID) - if lower == input || lowerID == input { - exact = append(exact, &AllTechs[i]) - } else if strings.HasPrefix(lower, input) || strings.HasPrefix(lowerID, input) { - prefix = append(prefix, &AllTechs[i]) - } - } - if len(exact) > 0 { - return exact - } - return prefix -} -``` - -**Total: 25 techs.** - ---- - -## 4. Full Tech List Reference - -| # | ID | Name | Level | Drain/tick | Category | Group | Effect | -|---|---|---|---|---|---|---|---| -| 1 | `clarity_1` | Clarity | 4 | 0.05 | attack | attack_tier | +5% Attack level | -| 2 | `clarity_2` | Enhanced Clarity | 16 | 0.10 | attack | attack_tier | +10% Attack level | -| 3 | `clarity_3` | Superior Clarity | 44 | 0.15 | attack | attack_tier | +15% Attack level | -| 4 | `amplifier_1` | Power Amplifier | 7 | 0.05 | strength | strength_tier | +5% Strength level | -| 5 | `amplifier_2` | Enhanced Amplifier | 23 | 0.10 | strength | strength_tier | +10% Strength level | -| 6 | `amplifier_3` | Superior Amplifier | 49 | 0.15 | strength | strength_tier | +15% Strength level | -| 7 | `shield_1` | Energy Shield | 10 | 0.05 | defense | defense_tier | +5% Defense level | -| 8 | `shield_2` | Enhanced Shield | 28 | 0.10 | defense | defense_tier | +10% Defense level | -| 9 | `shield_3` | Superior Shield | 52 | 0.15 | defense | defense_tier | +15% Defense level | -| 10 | `targeting_1` | Targeting System | 8 | 0.05 | ranged | ranged_tier | +5% Ranged level | -| 11 | `targeting_2` | Enhanced Targeting | 22 | 0.10 | ranged | ranged_tier | +10% Ranged level | -| 12 | `targeting_3` | Superior Targeting | 46 | 0.15 | ranged | ranged_tier | +15% Ranged level | -| 13 | `focus_1` | Neural Focus | 9 | 0.05 | science | science_tier | +5% Science level | -| 14 | `focus_2` | Enhanced Focus | 27 | 0.10 | science | science_tier | +10% Science level | -| 15 | `focus_3` | Superior Focus | 55 | 0.15 | science | science_tier | +15% Science level | -| 16 | `protect_melee` | Kinetic Barrier | 37 | 0.20 | protection | protection | 40% melee damage reduction | -| 17 | `protect_ranged` | Projectile Screen | 40 | 0.20 | protection | protection | 40% ranged damage reduction | -| 18 | `protect_science` | Neural Firewall | 43 | 0.20 | protection | protection | 40% science damage reduction | -| 19 | `regen` | Nano Repair | 22 | 0.10 | utility | regen_tier | 2x HP regen rate | -| 20 | `rapid_heal` | Rapid Repair | 31 | 0.15 | utility | regen_tier | 4x HP regen rate | -| 21 | `preserve` | Power Saver | 55 | 0.05 | utility | (none) | 20% drain reduction on other techs | -| 22 | `retribution` | Dead Man's Switch | 46 | 0.10 | utility | (none) | 25% max HP damage to mob on death | -| 23 | `overclock` | Overclock | 60 | 0.25 | combo | attack_tier | +15% Attack, +15% Strength | -| 24 | `fortify` | Fortify | 65 | 0.25 | combo | defense_tier | +15% Defense, 2x HP regen | - ---- - -## 5. Mutual Exclusivity Rules - -Techs with the same non-empty `Group` value are mutually exclusive. Activating one deactivates all others in the same group. - -| Group | Techs | Rule | -|---|---|---| -| `attack_tier` | clarity_1, clarity_2, clarity_3, overclock | Only one at a time | -| `strength_tier` | amplifier_1, amplifier_2, amplifier_3 | Only one at a time | -| `defense_tier` | shield_1, shield_2, shield_3, fortify | Only one at a time | -| `ranged_tier` | targeting_1, targeting_2, targeting_3 | Only one at a time | -| `science_tier` | focus_1, focus_2, focus_3 | Only one at a time | -| `protection` | protect_melee, protect_ranged, protect_science | Only one at a time | -| `regen_tier` | regen, rapid_heal | Only one at a time | -| (empty) | preserve, retribution | No exclusivity, can stack with anything | - -**Cross-category stacking IS allowed.** Example: `clarity_3 + amplifier_3 + protect_melee + preserve` is valid (4 active techs from 4 different groups). - -Implementation in `toggleTech()`: - -```go -func (g *Game) deactivateGroup(p *player.Player, group string) []string { - if group == "" { - return nil - } - var deactivated []string - for id := range p.ActiveTechs { - def := GetTechDef(id) - if def != nil && def.Group == group { - p.DeactivateTech(id) - deactivated = append(deactivated, def.Name) - } - } - return deactivated -} -``` - ---- - -## 6. Commands - -### New file: `internal/game/cmd_tech.go` - -```go -package game - -import ( - "fmt" - "strings" - - "thehouseoficarus/internal/color" - "thehouseoficarus/internal/net" - "thehouseoficarus/internal/player" -) - -func (g *Game) doTech(sess *net.Session, input string) { - p := sess.Player.(*player.Player) - techLevel := p.Level(player.Technology) - - if techLevel < 1 { - sess.WriteLine("\nYou need at least level 1 Technology to use tech.") - return - } - - input = strings.TrimSpace(input) - - if input == "" { - // No args: toggle quick tech, or show list if no quick tech set - if p.QuickTech == "" { - g.doTechList(sess) - return - } - g.toggleTech(sess, p, p.QuickTech) - return - } - - lower := strings.ToLower(input) - - if lower == "list" { - g.doTechList(sess) - return - } - - if strings.HasPrefix(lower, "quick ") { - name := strings.TrimSpace(input[6:]) - g.doTechQuick(sess, p, name) - return - } - - // Toggle a specific tech by name/id prefix match - matches := TechByPrefixMatch(lower) - if len(matches) == 0 { - sess.WriteLine(fmt.Sprintf("\nUnknown tech: %s", input)) - return - } - if len(matches) > 1 { - var names []string - for _, m := range matches { - names = append(names, m.Name) - } - sess.WriteLine(fmt.Sprintf("\nAmbiguous tech: %s. Matches: %s", input, strings.Join(names, ", "))) - return - } - - g.toggleTech(sess, p, matches[0].ID) -} - -func (g *Game) toggleTech(sess *net.Session, p *player.Player, techID string) { - def := GetTechDef(techID) - if def == nil { - sess.WriteLine("\nUnknown tech.") - return - } - - techLevel := p.Level(player.Technology) - - // If already active, deactivate - if p.HasActiveTech(techID) { - p.DeactivateTech(techID) - sess.WriteLine(fmt.Sprintf("\n%s deactivated.", def.Name)) - return - } - - // Check level requirement - if techLevel < def.Level { - sess.WriteLine(fmt.Sprintf("\nYou need level %d Technology to use %s.", def.Level, def.Name)) - return - } - - // Check battery - if p.Battery <= 0 { - sess.WriteLine("\nYour battery is depleted!") - return - } - - // Deactivate conflicting techs in same group - deactivated := g.deactivateGroup(p, def.Group) - for _, name := range deactivated { - sess.WriteLine(fmt.Sprintf("\n%s deactivated.", name)) - } - - // Activate - p.ActivateTech(techID, g.tickCount) - sess.WriteLine(fmt.Sprintf("\n%s activated.", def.Name)) -} - -func (g *Game) deactivateGroup(p *player.Player, group string) []string { - if group == "" { - return nil - } - var deactivated []string - for id := range p.ActiveTechs { - def := GetTechDef(id) - if def != nil && def.Group == group { - p.DeactivateTech(id) - deactivated = append(deactivated, def.Name) - } - } - return deactivated -} - -func (g *Game) doTechList(sess *net.Session) { - p := sess.Player.(*player.Player) - mode := g.colorMode(sess) - techLevel := p.Level(player.Technology) - - sess.WriteLines( - "", - fmt.Sprintf("Battery: %s/%s", - g.colorize(sess, "battery", fmt.Sprintf("%.1f", p.Battery)), - color.Render(mode, color.Parse("230"), fmt.Sprintf("%.0f", p.MaxBattery())), - ), - ) - - t := &Table{Title: "Technology", Columns: []string{ - color.Render(mode, color.Parse("75"), "Tech"), - color.Render(mode, color.Parse("245"), "Level"), - color.Render(mode, color.Parse("222"), "Drain/tick"), - color.Render(mode, color.Parse("179"), "Effect"), - color.Render(mode, color.Parse("230"), "Status"), - }} - - for _, tech := range AllTechs { - status := "" - nameColor := "245" - if tech.Level <= techLevel { - nameColor = "75" - if p.HasActiveTech(tech.ID) { - status = color.Render(mode, color.Parse("82"), "ON") - } else { - status = color.Render(mode, color.Parse("240"), "off") - } - } else { - status = color.Render(mode, color.Parse("160"), "locked") - } - - effect := techEffectString(tech) - - t.Rows = append(t.Rows, []string{ - color.Render(mode, color.Parse(nameColor), tech.Name), - color.Render(mode, color.Parse("230"), fmt.Sprint(tech.Level)), - color.Render(mode, color.Parse("222"), fmt.Sprintf("%.2f", tech.DrainRate)), - color.Render(mode, color.Parse("179"), effect), - status, - }) - } - - for _, line := range t.Render(p.OptionBool("unicode")) { - sess.WriteLine(line) - } - - if p.QuickTech != "" { - qDef := GetTechDef(p.QuickTech) - if qDef != nil { - sess.WriteLine(fmt.Sprintf("\nQuick tech: %s", qDef.Name)) - } - } -} - -func (g *Game) doTechQuick(sess *net.Session, p *player.Player, input string) { - if input == "" { - if p.QuickTech == "" { - sess.WriteLine("\nNo quick tech set. Usage: tech quick <name>") - } else { - def := GetTechDef(p.QuickTech) - name := p.QuickTech - if def != nil { - name = def.Name - } - sess.WriteLine(fmt.Sprintf("\nQuick tech: %s", name)) - } - return - } - - matches := TechByPrefixMatch(strings.ToLower(input)) - if len(matches) == 0 { - sess.WriteLine(fmt.Sprintf("\nUnknown tech: %s", input)) - return - } - if len(matches) > 1 { - var names []string - for _, m := range matches { - names = append(names, m.Name) - } - sess.WriteLine(fmt.Sprintf("\nAmbiguous tech: %s. Matches: %s", input, strings.Join(names, ", "))) - return - } - - p.QuickTech = matches[0].ID - g.AccountStore.SaveCharacter(p) - sess.WriteLine(fmt.Sprintf("\nQuick tech set to %s.", matches[0].Name)) -} - -func techEffectString(tech TechDef) string { - var parts []string - e := tech.Effects - if e.AttackPercent > 0 { - parts = append(parts, fmt.Sprintf("+%d%% Attack", e.AttackPercent)) - } - if e.StrengthPercent > 0 { - parts = append(parts, fmt.Sprintf("+%d%% Strength", e.StrengthPercent)) - } - if e.DefensePercent > 0 { - parts = append(parts, fmt.Sprintf("+%d%% Defense", e.DefensePercent)) - } - if e.RangedPercent > 0 { - parts = append(parts, fmt.Sprintf("+%d%% Ranged", e.RangedPercent)) - } - if e.SciencePercent > 0 { - parts = append(parts, fmt.Sprintf("+%d%% Science", e.SciencePercent)) - } - if e.ProtectMelee { - parts = append(parts, fmt.Sprintf("%.0f%% melee protection", e.DamageReduction*100)) - } - if e.ProtectRanged { - parts = append(parts, fmt.Sprintf("%.0f%% ranged protection", e.DamageReduction*100)) - } - if e.ProtectScience { - parts = append(parts, fmt.Sprintf("%.0f%% science protection", e.DamageReduction*100)) - } - if e.HPRegenMulti > 0 { - parts = append(parts, fmt.Sprintf("%.0fx HP regen", e.HPRegenMulti)) - } - if e.PreserveDrain > 0 { - parts = append(parts, fmt.Sprintf("%.0f%% drain reduction", e.PreserveDrain*100)) - } - if e.RetributionPct > 0 { - parts = append(parts, fmt.Sprintf("%.0f%% retribution", e.RetributionPct*100)) - } - return strings.Join(parts, ", ") -} -``` - -### Command Classification - -**File: `internal/game/game.go`** - -In `classifyCommand()` at line 136, add `"tech"` and `"t"` to the `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", - "tech", "t": // <-- ADD THESE - return ClassInstant -``` - -### Command Execution - -In `executeCommand()` at line 249, add a case for `"tech"` and `"t"`: - -```go -case "tech", "t": - g.doTech(sess, strings.Join(args, " ")) -``` - -Insert after the `"style"` case (around line 294). - ---- - -## 7. Drain Mechanics - `TechTick()` - -### File: `internal/game/tick.go` - -Add `TechTick()` method. This runs once per game tick. - -```go -func (g *Game) TechTick() { - if g.Hub == nil { - return - } - for _, sess := range g.Hub.AllSessions() { - p, ok := sess.Player.(*player.Player) - if !ok || p == nil || len(p.ActiveTechs) == 0 { - continue - } - - // Calculate total drain - totalDrain := 0.0 - hasPreserve := false - preserveReduction := 0.0 - - for id := range p.ActiveTechs { - def := GetTechDef(id) - if def == nil { - continue - } - - // 1-tick flicking: skip drain for techs activated THIS tick - if p.TechActivatedAt != nil { - if activatedAt, ok := p.TechActivatedAt[id]; ok && activatedAt == g.tickCount { - continue - } - } - - if def.Effects.PreserveDrain > 0 { - hasPreserve = true - preserveReduction = def.Effects.PreserveDrain - } - } - - for id := range p.ActiveTechs { - def := GetTechDef(id) - if def == nil { - continue - } - - // 1-tick flicking: skip drain for techs activated THIS tick - if p.TechActivatedAt != nil { - if activatedAt, ok := p.TechActivatedAt[id]; ok && activatedAt == g.tickCount { - continue - } - } - - drain := def.DrainRate - if hasPreserve && def.Effects.PreserveDrain == 0 { - drain *= (1.0 - preserveReduction) - } - totalDrain += drain - } - - // Apply equipment TechnologyBonus reduction - equipTechBonus := g.totalTechBonus(p) - if equipTechBonus > 0 { - reduction := float64(equipTechBonus) * 0.01 - if reduction > 0.5 { - reduction = 0.5 - } - totalDrain *= (1.0 - reduction) - } - - if totalDrain <= 0 { - continue - } - - p.Battery -= totalDrain - if p.Battery <= 0 { - p.Battery = 0 - p.DeactivateAllTechs() - sess.WriteLine(g.colorize(sess, "tech_depleted", - "\nYour battery is depleted! All tech has been disabled.")) - g.writePrompt(sess) - } - } -} -``` - -### Equipment Technology Bonus Helper - -Add to `internal/game/tech.go`: - -```go -func (g *Game) totalTechBonus(p *player.Player) int { - total := 0 - for _, itemID := range p.Equipment { - def, err := g.ItemStore.Load(itemID) - if err == nil { - total += def.Stats.TechnologyBonus - } - } - return total -} -``` - -### Drain Formula - -``` -drainPerTick = sum of each active tech's DrainRate - * (1.0 - preserveReduction) // if Power Saver is active, 0.8x on other techs - * (1.0 - equipTechBonus * 0.01) // equipment reduction, capped at 50% - -Battery -= drainPerTick -if Battery <= 0: deactivate all techs -``` - -**Example:** Player has `clarity_3` (0.15) + `amplifier_3` (0.15) + `preserve` (0.05) active. Equipment tech bonus = 10. - -``` -preserve drain = 0.05 (not reduced by itself) -clarity_3 drain = 0.15 * 0.8 = 0.12 -amplifier_3 drain = 0.15 * 0.8 = 0.12 -raw total = 0.05 + 0.12 + 0.12 = 0.29 -after equipment: 0.29 * (1.0 - 0.10) = 0.261 per tick -``` - -At 600ms ticks: 100 ticks/minute. Battery 55 would last ~210 ticks = ~126 seconds = ~2 minutes. - -### Register TechTick in Main Tick Loop - -**File: `cmd/mud/main.go`** - -Add `g.TechTick()` to the tick subscriber, AFTER `g.AdvanceActions()` (combat damage has already been calculated by this point in the tick cycle, so protection was already applied): - -```go -g.Ticks.Subscribe(1, func() bool { - g.tickCount++ // <-- ADD: increment tick counter - g.MoveTick() - g.ProcessQueuedCommands() - g.World.Tick() - g.MobStore.Tick() - g.RegenTick() - g.DisconnectTick() - g.WanderTick() - g.SharedDepletionTick() - g.FireTick() - g.AdvanceActions() - g.TechTick() // <-- ADD: drain battery after combat/actions - g.ConsumeTick() - g.BroadcastRespawns() - g.VisualTick() - return true -}) -``` - -The `g.tickCount++` MUST be the first line so that `TechActivatedAt` comparisons use the current tick number. `TechTick` runs AFTER `AdvanceActions` (which includes combat subscriptions) so protection has already been applied before drain is calculated. - ---- - -## 8. 1-Tick Flicking Mechanic - -### How It Works - -The flicking mechanic uses `TechActivatedAt` to grant a 1-tick grace period on activation: - -1. `ActivateTech(techID, tickNum)` records the current `tickCount` in `TechActivatedAt[techID]` -2. `TechTick()` skips drain for any tech where `TechActivatedAt[techID] == g.tickCount` -3. This means: a tech activated during the current tick is NOT drained on that tick. Drain starts the NEXT tick. - -### Flicking Sequence - -**Continuous 1-tick flicking (no drain):** - -``` -Tick N: TechTick runs. Tech is OFF. No drain. - Player types "tech kinetic" → tech activates, TechActivatedAt = N+1 (next tick hasn't started) - -Wait... let me reconsider. The tick counter increments at the START of each tick cycle. So: - -Tick N starts: tickCount = N - - ProcessQueuedCommands (no tech-related queue, tech is instant) - - Combat: mob attacks, Kinetic Barrier is ON → damage reduced 40% - - TechTick: check clarity. TechActivatedAt == N → skip drain! No drain. - -Between tick N and tick N+1: - - Player types "tech kinetic" → deactivates tech - -Tick N+1 starts: tickCount = N+1 - - Combat: mob attacks, Kinetic Barrier is OFF → full damage - - TechTick: no active techs, no drain. - -Between tick N+1 and tick N+2: - - Player types "tech kinetic" → activates tech, TechActivatedAt = N+1 - -Tick N+2 starts: tickCount = N+2 - - Combat: Kinetic Barrier is ON → damage reduced - - TechTick: TechActivatedAt = N+1, tickCount = N+2 → N+1 != N+2 → DRAIN occurs -``` - -**Correction:** The above shows drain on tick N+2. For true 0-drain flicking, the player must: - -1. Have tech active at the start of tick N (activated between N-1 and N, so `TechActivatedAt = N-1`) -2. Wait: TechTick on tick N would drain because `N-1 != N`. So the player needs to toggle OFF before tick N's TechTick, then back ON. - -**Revised flicking sequence for zero drain:** - -The key insight: `tech` is an **Instant** command. It executes immediately when typed, NOT during tick processing. Ticks run in a goroutine. The player's input is processed on the session goroutine. But since all game state is accessed from the tick goroutine via the hub, there's a concurrency concern. - -**Actually**, re-reading `HandleSession` at `game.go:92`: input handling happens synchronously when the session receives input. The tick loop is a separate goroutine. Both access `p.ActiveTechs`. For thread safety, we need to ensure that tech toggling is safe. - -**Simpler approach that guarantees flicking works:** Instead of relying on timing between ticks, use the activation-tick grace: - -``` -Between tick N-1 and tick N: - Player types "t" → deactivate tech - Player types "t" → reactivate tech, TechActivatedAt = N (tickCount hasn't incremented yet, still N-1... wait) -``` - -**The issue:** `tickCount` is only updated inside the tick goroutine. Between ticks, it's the OLD value. So `TechActivatedAt` would be set to the old tick count, and TechTick on the NEXT tick (count+1) would see a mismatch and drain. - -**Fix:** Compare `TechActivatedAt >= g.tickCount` instead of `==`. No, that breaks things differently. - -**Better fix:** Use a **"tech toggled off and back on this period" flag**. Here's the definitive approach: - -### Definitive 1-Tick Flicking Implementation - -Add to `Player`: - -```go -TechFlickWindow map[string]bool `yaml:"-"` // techs that were deactivated and reactivated between ticks -``` - -When `DeactivateTech` is called, if the tech was active, mark it in a "recently deactivated" set. When `ActivateTech` is called, if the tech was recently deactivated, set `TechFlickWindow[id] = true`. - -At the start of each tick in `TechTick`, clear `TechFlickWindow` for techs that were NOT toggled this period. - -**Actually, let me simplify even further.** The cleanest approach matching OSRS: - -### Final Flicking Approach: Drain-Tick Snapshot - -At the **start** of `TechTick()`, snapshot which techs are active. Only drain techs in the snapshot. Then clear the snapshot. - -If a player deactivates and reactivates between ticks, the snapshot at the start of the NEXT tick will show the tech as active, but we add a check: if the tech was toggled (deactivated then reactivated) since the last snapshot, skip it. - -**Implementation:** - -Add to `Player`: - -```go -TechToggleCount map[string]int `yaml:"-"` // incremented on each toggle (activate or deactivate) -``` - -In `TechTick`: - -```go -func (g *Game) TechTick() { - if g.Hub == nil { - return - } - for _, sess := range g.Hub.AllSessions() { - p, ok := sess.Player.(*player.Player) - if !ok || p == nil || len(p.ActiveTechs) == 0 { - continue - } - - totalDrain := 0.0 - hasPreserve := false - preserveReduction := 0.0 - - // First pass: check for preserve - for id := range p.ActiveTechs { - def := GetTechDef(id) - if def == nil { - continue - } - if def.Effects.PreserveDrain > 0 { - hasPreserve = true - preserveReduction = def.Effects.PreserveDrain - } - } - - // Second pass: calculate drain - for id := range p.ActiveTechs { - def := GetTechDef(id) - if def == nil { - continue - } - - // 1-tick flicking: skip drain if tech was activated on the current tick - if p.TechActivatedAt != nil { - if activatedAt, ok := p.TechActivatedAt[id]; ok && activatedAt == g.tickCount { - continue - } - } - - drain := def.DrainRate - if hasPreserve && def.Effects.PreserveDrain == 0 { - drain *= (1.0 - preserveReduction) - } - totalDrain += drain - } - - // Equipment tech bonus reduction - equipTechBonus := g.totalTechBonus(p) - if equipTechBonus > 0 { - reduction := float64(equipTechBonus) * 0.01 - if reduction > 0.5 { - reduction = 0.5 - } - totalDrain *= (1.0 - reduction) - } - - if totalDrain <= 0 { - continue - } - - p.Battery -= totalDrain - if p.Battery <= 0 { - p.Battery = 0 - p.DeactivateAllTechs() - sess.WriteLine(g.colorize(sess, "tech_depleted", - "\nYour battery is depleted! All tech has been disabled.")) - g.writePrompt(sess) - } - } -} -``` - -### Flicking Works Because: - -1. `g.tickCount` increments at the very start of the tick cycle (first line of the tick subscriber) -2. `tech` is Instant, so it executes on the session goroutine BETWEEN ticks -3. When a player activates a tech between ticks, `ActivateTech(id, g.tickCount)` stores the CURRENT tick count (which hasn't been incremented yet for the next tick) -4. When the next tick starts, `g.tickCount++` runs, making `g.tickCount = oldCount + 1` -5. `TechTick` compares `TechActivatedAt[id] (== oldCount)` with `g.tickCount (== oldCount + 1)` → NOT equal → drain happens - -**For flicking to produce zero drain:** - -1. Tech is active (activated on tick N-1, so `TechActivatedAt = N-1`) -2. Between tick N-1 and tick N, player types "t" (deactivate), then "t" (reactivate) -3. Reactivation sets `TechActivatedAt = N-1` (tickCount hasn't changed yet) -4. Tick N starts: `tickCount = N`. `TechActivatedAt = N-1`. `N-1 != N` → **DRAIN HAPPENS** - -This doesn't give zero-drain flicking. We need a different mechanism. - -### Correct Implementation: Grace Period from Re-Activation - -The correct approach: **compare with `g.tickCount - 1`** (activation happened this tick period = between the last tick and the current one). - -**Or better:** Set `TechActivatedAt` to `g.tickCount + 1` when activating between ticks, so it matches the NEXT tick count. - -**Or simplest:** Don't use tick counts at all. Use a boolean flag: - -```go -TechActivatedSinceTick map[string]bool `yaml:"-"` -``` - -- When a tech is activated, set `TechActivatedSinceTick[id] = true` -- At the START of `TechTick`, before calculating drain: for any tech in `TechActivatedSinceTick`, skip drain. Then CLEAR `TechActivatedSinceTick`. -- This means: any tech activated between the previous `TechTick` and the current `TechTick` gets 1 free tick of no drain. - -**Flicking with this approach:** - -1. TechTick runs (tick N). Tech was active, drain applied. TechActivatedSinceTick is empty. -2. Player types "t" → deactivates tech. -3. Player types "t" → reactivates tech. `TechActivatedSinceTick[id] = true`. -4. TechTick runs (tick N+1). Tech is active. `TechActivatedSinceTick[id]` is true → **skip drain**. Clear flag. -5. Player types "t" → deactivates. -6. Player types "t" → reactivates. Flag set again. -7. TechTick runs (tick N+2). Flag is true → skip drain. Clear. -8. ...repeat forever: **zero drain** as long as player toggles off+on between each tick. - -**This is the correct OSRS-like 1-tick flicking behavior.** - -### Final Player Fields for Flicking - -Replace the earlier `TechActivatedAt` with: - -```go -TechActivatedSinceTick map[string]bool `yaml:"-"` -``` - -Update `ActivateTech`: - -```go -func (p *Player) ActivateTech(techID string) { - if p.ActiveTechs == nil { - p.ActiveTechs = make(map[string]bool) - } - if p.TechActivatedSinceTick == nil { - p.TechActivatedSinceTick = make(map[string]bool) - } - p.ActiveTechs[techID] = true - p.TechActivatedSinceTick[techID] = true -} -``` - -Remove the `tickNum` parameter from `ActivateTech`. Remove `TechActivatedAt` from the plan entirely. Remove `tickCount` from `Game` struct. - -Update `TechTick`: - -```go -func (g *Game) TechTick() { - if g.Hub == nil { - return - } - for _, sess := range g.Hub.AllSessions() { - p, ok := sess.Player.(*player.Player) - if !ok || p == nil || len(p.ActiveTechs) == 0 { - continue - } - - totalDrain := 0.0 - hasPreserve := false - preserveReduction := 0.0 - - for id := range p.ActiveTechs { - def := GetTechDef(id) - if def != nil && def.Effects.PreserveDrain > 0 { - hasPreserve = true - preserveReduction = def.Effects.PreserveDrain - } - } - - for id := range p.ActiveTechs { - def := GetTechDef(id) - if def == nil { - continue - } - - // 1-tick flicking grace: skip drain for freshly activated techs - if p.TechActivatedSinceTick[id] { - continue - } - - drain := def.DrainRate - if hasPreserve && def.Effects.PreserveDrain == 0 { - drain *= (1.0 - preserveReduction) - } - totalDrain += drain - } - - // Clear the activation flags AFTER drain calculation - p.TechActivatedSinceTick = nil - - equipTechBonus := g.totalTechBonus(p) - if equipTechBonus > 0 { - reduction := float64(equipTechBonus) * 0.01 - if reduction > 0.5 { - reduction = 0.5 - } - totalDrain *= (1.0 - reduction) - } - - if totalDrain <= 0 { - continue - } - - p.Battery -= totalDrain - if p.Battery <= 0 { - p.Battery = 0 - p.DeactivateAllTechs() - sess.WriteLine(g.colorize(sess, "tech_depleted", - "\nYour battery is depleted! All tech has been disabled.")) - g.writePrompt(sess) - } - } -} -``` - -### Protection with Flicking - -Protection techs work independently of drain. `mobAttack()` checks `p.HasActiveTech("protect_melee")` at the moment damage is calculated. If the tech is currently active (toggled on between ticks, even if it will be toggled off before next TechTick), protection applies. - -**1-tick protection sequence:** -1. Player types "tech kinetic" → Kinetic Barrier activates, `TechActivatedSinceTick["protect_melee"] = true` -2. Tick fires: mob attacks → `p.HasActiveTech("protect_melee")` is true → 40% damage reduction -3. TechTick: `TechActivatedSinceTick` has protect_melee → skip drain. Clear flags. **Zero drain, full protection for this tick.** -4. Player types "tech kinetic" → deactivates. Next tick: no protection, no drain. - -**For sustained protection with minimal drain (1 tick of drain per 2 ticks):** -1. Tech activated. TechActivatedSinceTick set. -2. Tick: protection ON, drain skipped (grace). -3. Player does NOT toggle. Tech stays active. -4. Next tick: protection ON, drain APPLIED (grace expired). TechActivatedSinceTick cleared already. -5. Player toggles off, then on. Grace set again. -6. Next tick: protection ON, drain skipped. -7. ...alternating pattern. - ---- - -## 9. Protection in Combat - -### File: `internal/game/cmd_attack.go` - -Modify `mobAttack()` (line 238). After damage is calculated (line 254: `dmg := combat.RollDamage(maxHit)`), add protection check: - -```go -func (g *Game) mobAttack(sess *net.Session, p *player.Player, mob *world.MobInstance) { - _, _, defBonus := combat.AttackStyleBonus(string(p.AttackStyle)) - - equipDef := 0 - for _, itemID := range p.Equipment { - def, err := g.ItemStore.Load(itemID) - if err == nil { - equipDef += def.Stats.DefenseBonus - } - } - - attRoll := combat.AttackRoll(mob.Attack, 0, 0) - defRoll := combat.DefenseRoll(p.Level(player.Defense), defBonus, equipDef) - - if combat.HitCheck(attRoll, defRoll) { - maxHit := combat.MaxHit(mob.Strength, 0, 0) - dmg := combat.RollDamage(maxHit) - - // ── TECH PROTECTION ── - dmg = g.applyTechProtection(p, mob, dmg) - - p.HP -= dmg - // ... rest of existing code unchanged ... -``` - -### New helper function in `internal/game/tech.go`: - -```go -func (g *Game) applyTechProtection(p *player.Player, mob *world.MobInstance, dmg int) int { - if len(p.ActiveTechs) == 0 { - return dmg - } - - weaponType := mob.WeaponType // need to add WeaponType to MobInstance or MobDef - // Default: melee if not specified - if weaponType == "" { - weaponType = "melee" - } - - var protectTechID string - switch weaponType { - case "melee": - protectTechID = "protect_melee" - case "ranged": - protectTechID = "protect_ranged" - case "science": - protectTechID = "protect_science" - } - - if protectTechID != "" && p.HasActiveTech(protectTechID) { - def := GetTechDef(protectTechID) - if def != nil { - reduced := int(float64(dmg) * (1.0 - def.Effects.DamageReduction)) - if reduced < 0 { - reduced = 0 - } - return reduced - } - } - return dmg -} -``` - -### Mob WeaponType - -**File: `internal/world/mobs.go`** (or wherever `MobDef` is defined) - -Add `WeaponType` field to `MobDef`: - -```go -type MobDef struct { - // ... existing fields ... - WeaponType string `yaml:"weapon_type"` // "melee" (default), "ranged", "science" -} -``` - -Propagate to `MobInstance`: - -```go -type MobInstance struct { - // ... existing fields ... - WeaponType string -} -``` - -When spawning mob instances, copy `WeaponType` from def. Default to `"melee"` if empty. - ---- - -## 10. Tech Bonuses in Combat - -### File: `internal/game/cmd_attack.go` - -Modify `playerAttack()` to add tech-boosted levels. - -Current code (line 192): -```go -attRoll := combat.AttackRoll(p.Level(player.Attack), attBonus, equipAtt) -``` - -Change to: -```go -effectiveAttack := p.Level(player.Attack) + g.techLevelBonus(p, "attack") -effectiveStrength := p.Level(player.Strength) + g.techLevelBonus(p, "strength") - -attRoll := combat.AttackRoll(effectiveAttack, attBonus, equipAtt) -``` - -And for the maxHit calculation (line 196): -```go -maxHit := combat.MaxHit(effectiveStrength, strBonus, equipStr) -``` - -For defense in `mobAttack()` (line 250): -```go -effectiveDefense := p.Level(player.Defense) + g.techLevelBonus(p, "defense") -defRoll := combat.DefenseRoll(effectiveDefense, defBonus, equipDef) -``` - -### Tech Level Bonus Helper - -Add to `internal/game/tech.go`: - -```go -func (g *Game) techLevelBonus(p *player.Player, stat string) int { - if len(p.ActiveTechs) == 0 { - return 0 - } - - totalPercent := 0 - for id := range p.ActiveTechs { - def := GetTechDef(id) - if def == nil { - continue - } - switch stat { - case "attack": - totalPercent += def.Effects.AttackPercent - case "strength": - totalPercent += def.Effects.StrengthPercent - case "defense": - totalPercent += def.Effects.DefensePercent - case "ranged": - totalPercent += def.Effects.RangedPercent - case "science": - totalPercent += def.Effects.SciencePercent - } - } - - if totalPercent == 0 { - return 0 - } - - var baseLevel int - switch stat { - case "attack": - baseLevel = p.Level(player.Attack) - case "strength": - baseLevel = p.Level(player.Strength) - case "defense": - baseLevel = p.Level(player.Defense) - case "ranged": - baseLevel = p.Level(player.Ranged) - case "science": - baseLevel = p.Level(player.Science) - } - - return baseLevel * totalPercent / 100 -} -``` - -**Example:** Player has Attack level 80 and `clarity_3` active (+15%): -``` -techLevelBonus = 80 * 15 / 100 = 12 -effectiveAttack = 80 + 12 = 92 -``` - -This matches OSRS where prayers provide a percentage boost to the effective level. - ---- - -## 11. HP Regen with Tech - -### File: `internal/game/tick.go` - -Modify `RegenTick()` to check for regen techs. Currently (line 49-50): - -```go -p.RegenerateTick-- -if p.RegenerateTick <= 0 { - p.HP++ -``` - -Change the decrement to account for regen multiplier: - -```go -regenSpeed := 1 -if p.ActiveTechs != nil { - for id := range p.ActiveTechs { - def := GetTechDef(id) - if def != nil && def.Effects.HPRegenMulti > 0 { - multi := int(def.Effects.HPRegenMulti) - if multi > regenSpeed { - regenSpeed = multi - } - } - } -} -p.RegenerateTick -= regenSpeed -if p.RegenerateTick <= 0 { - p.HP++ -``` - -With `Nano Repair` (2x), regen tick decrements by 2 instead of 1, halving the time to heal. With `Rapid Repair` (4x), decrements by 4. - ---- - -## 12. Retribution (Dead Man's Switch) - -### File: `internal/game/cmd_attack.go` - -In `endCombat()`, when the player dies (line 296-308), before dropping items and teleporting, check for retribution: - -```go -if p.HP <= 0 { - // ── RETRIBUTION CHECK ── - if p.HasActiveTech("retribution") { - retDef := GetTechDef("retribution") - if retDef != nil && mob != nil && mob.HP > 0 { - retDmg := int(float64(p.MaxHP()) * retDef.Effects.RetributionPct) - if retDmg > 0 { - mob.HP -= retDmg - if mob.HP < 0 { - mob.HP = 0 - } - sess.WriteLine(fmt.Sprintf("\nDead Man's Switch activates! %s takes %d damage!", - mobDisplayName(mob, true), retDmg)) - } - } - } - - // Deactivate all techs on death - p.DeactivateAllTechs() - - sess.WriteLine(g.colorize(sess, "death", "\nOh dear, you are dead!")) - // ... rest of existing death code ... -``` - ---- - -## 13. Battery Recharge - -### Charging Station Object - -**File: `data/objects/charging_station.yaml`** - -```yaml -id: charging_station -name: Charging Station -description: "A humming terminal with exposed capacitor banks. Its indicator light pulses with stored energy. You could recharge your battery here." -inroom_description: "A charging station hums quietly against the wall." -behavior: recharge -``` - -**File: `data/behaviors/recharge.yaml`** - -```yaml -id: recharge -type: use -actions: - - message: "You connect to the charging station. Energy flows into your systems..." - set_player_flags: {} -``` - -**Actually**, recharging is simpler as a `use_interaction` on the object, or even simpler as a hardcoded handler. Since the recharge is instant (like an OSRS altar), the cleanest approach is a **use_interaction** on the object definition. - -### Alternative: Hardcoded `use` handler for charging stations - -Add to the object's `use_interactions` in `data/objects/charging_station.yaml`: - -```yaml -id: charging_station -name: Charging Station -description: "A humming terminal with exposed capacitor banks. You could recharge your battery here." -inroom_description: "A charging station hums quietly against the wall." -use_interactions: - - verb: "use" - message: "You connect to the charging station. Your battery is fully recharged." - action: "recharge_battery" -``` - -### Handling in code - -**File: `internal/game/action_use.go`** (or wherever `use_interactions` are processed) - -In the handler for `use_interactions`, add a check for the `recharge_battery` action: - -```go -if interaction.Action == "recharge_battery" { - p.Battery = p.MaxBattery() - g.AccountStore.SaveCharacter(p) -} -``` - -**However**, looking at the existing `use_interactions` system, it might use a different mechanism. If `use_interactions` don't support custom action strings, implement recharging as a simple check in the object's interaction handler. - -### Simplest approach: Check object ID directly - -In whatever function handles `use <target>` on objects, add a special case: - -```go -if obj.ID == "charging_station" || obj.ID == "power_conduit" { - if p.Battery >= p.MaxBattery() { - sess.WriteLine("\nYour battery is already full.") - } else { - p.Battery = p.MaxBattery() - g.AccountStore.SaveCharacter(p) - sess.WriteLine(g.colorize(sess, "battery_recharge", - "\nYou connect to the charging station. Your battery is fully recharged.")) - } - return -} -``` - -Add this check in `doUse()` in `internal/game/cmd_use.go` (or wherever `use` commands targeting objects are routed), BEFORE the normal behavior lookup. - -### Charging Station Room Placement - -Add to a starter room, e.g., `data/rooms/1.yaml`: - -```yaml -objects: - - id: charging_station -``` - -Or create a dedicated room. Place in any room where the player should be able to recharge. - ---- - -## 14. Score Page Changes - -### File: `internal/game/cmd_score.go` - -Modify `doScore()` to show battery and active techs. - -After the HP line (line 19), add battery: - -```go -sess.WriteLines( - "", - fmt.Sprintf("Name: %s", g.colorize(sess, "player_name", p.Name)), - fmt.Sprintf("Combat Level: %s", color.Render(mode, color.Parse("230"), fmt.Sprint(p.CombatLevel()))), - fmt.Sprintf("HP: %s/%s", g.colorize(sess, "character_hp", fmt.Sprint(p.HP)), color.Render(mode, color.Parse("230"), fmt.Sprint(p.MaxHP()))), - fmt.Sprintf("Battery: %s/%s", g.colorize(sess, "battery", fmt.Sprintf("%.1f", p.Battery)), color.Render(mode, color.Parse("230"), fmt.Sprintf("%.0f", p.MaxBattery()))), - fmt.Sprintf("Credits: %s", g.colorize(sess, "credits_pickup", fmt.Sprint(p.Credits))), -) -``` - -After the skills table, add active techs section: - -```go -// After the skills table render loop - -if len(p.ActiveTechs) > 0 { - sess.WriteLine("") - sess.WriteLine(g.colorize(sess, "tech_header", "Active Tech:")) - for _, id := range p.ActiveTechList() { - def := GetTechDef(id) - if def != nil { - sess.WriteLine(fmt.Sprintf(" %s %s", - color.Render(mode, color.Parse("82"), "[ON]"), - color.Render(mode, color.Parse("75"), def.Name), - )) - } - } -} -``` - ---- - -## 15. Prompt Variables - -### File: `internal/game/prompt.go` - -Add battery prompt variables in `expandPromptVars()` after the HP variables (around line 38): - -```go -text = strings.ReplaceAll(text, "%b", fmt.Sprintf("%.1f", p.Battery)) -text = strings.ReplaceAll(text, "%B", fmt.Sprintf("%.0f", p.MaxBattery())) -``` - -This lets players set prompts like: `prompt %h/%Hhp %b/%Bbat > ` - ---- - -## 16. Logout & Death Tech Deactivation - -### Logout - -**File: `internal/game/game.go`** - -In `SetHub()` (line 81), the `OnRemove` callback handles session removal. Add tech deactivation: - -```go -hub.OnRemove(func(sess *net.Session) { - if p, ok := sess.Player.(*player.Player); ok { - p.DeactivateAllTechs() // <-- ADD - g.charsMu.Lock() - delete(g.loggedInChars, p.Name) - g.charsMu.Unlock() - } -}) -``` - -### Death - -Already covered in Section 12 (`endCombat` death handler). Add `p.DeactivateAllTechs()` before the death message. - -### Quit - -**File: `internal/game/cmd_quit.go`** (or wherever `doQuit` is) - -When the player quits (rest timer expires, session closes), techs are already deactivated by `OnRemove`. No additional code needed. - ---- - -## 17. New Items with Technology Bonus - -### Equipment YAML files - -The `ItemStats.TechnologyBonus` field already exists at `internal/object/item.go:72`. Just create items that use it. - -**File: `data/items/neural_headband.yaml`** - -```yaml -id: neural_headband -name: Neural Headband -description: "A thin band of circuitry that amplifies neural signals, reducing battery drain." -color: "39" -value: 500 -equip_slot: head -stats: - defense_bonus: 1 - technology_bonus: 4 -``` - -**File: `data/items/power_amulet.yaml`** - -```yaml -id: power_amulet -name: Power Amulet -description: "A pendant containing a miniature power cell that augments tech efficiency." -color: "220" -value: 800 -equip_slot: neck -stats: - technology_bonus: 3 -``` - -**File: `data/items/capacitor_gloves.yaml`** - -```yaml -id: capacitor_gloves -name: Capacitor Gloves -description: "Gloves woven with superconducting filaments." -color: "250" -value: 300 -equip_slot: hands -stats: - defense_bonus: 2 - technology_bonus: 2 -``` - -**File: `data/items/holy_wrench_equiv.yaml`** (equivalent to OSRS holy wrench) - -```yaml -id: power_regulator -name: Power Regulator -description: "A small device that optimizes battery recharge efficiency. Keep it in your inventory when recharging." -color: "45" -value: 1000 -``` - -Optional: If the player has a `power_regulator` in inventory when recharging at a station, they get +2 bonus battery above max. (Stretch goal, not required for initial implementation.) - ---- - -## 18. Color Targets - -### File: `internal/config/colors.go` (or wherever `DefaultColors()` is defined) - -Add new color targets: - -```go -"battery": "45", // cyan for battery display -"battery_recharge": "82", // green for recharge message -"tech_header": "75", // blue for "Active Tech:" header -"tech_depleted": "196", // red for battery depleted warning -"tech_activated": "82", // green for activation messages -"tech_deactivated": "240", // gray for deactivation messages -``` - ---- - -## 19. Help Files - -### File: `data/help/tech.yaml` - -```yaml -id: tech -title: Technology & Tech -aliases: - - technology - - battery - - techs -body: | - Technology is a skill that allows you to activate powerful tech modules - that enhance your combat abilities. Each tech drains battery while active. - - Battery is your tech power supply. Max battery equals your Technology level. - Battery does not regenerate naturally -- recharge at a Charging Station. - - Commands: - tech Toggle your quick tech (or show list if none set) - tech <name> Toggle a specific tech on/off - tech list Show all available techs - tech quick <name> Set your quick tech - t Shortcut for 'tech' (toggle quick tech) - - Tips: - - Multiple techs can be active simultaneously (from different groups) - - Equipment with Technology bonus reduces drain rate - - Toggling tech off and back on between ticks avoids drain ("flicking") - - Protection techs reduce incoming damage by 40% -``` - ---- - -## 20. Concurrency Safety - -Tech toggling happens on the session goroutine (instant command). Tech drain happens on the tick goroutine. Both access `p.ActiveTechs`. - -**Options:** - -1. **Accept the race.** In OSRS, prayer toggling and drain are both on the game thread. In this MUD, instant commands execute on the session goroutine while ticks run on the engine goroutine. However, looking at the existing codebase, ALL commands (including instant ones like `style`, `say`, `score`) access player state without locks. The same race already exists for `AttackStyle`, `HP`, `Skills`, etc. So this is an accepted pattern in the codebase. - -2. **If races must be fixed:** Add a `sync.Mutex` to `Player` for tech state. But this would be inconsistent with the rest of the codebase. Only do this if the existing code uses mutexes for player state (it doesn't). - -**Recommendation:** Follow the existing pattern. Don't add mutexes. The worst case is a tech being drained for one extra tick or one fewer tick due to a race, which is acceptable for a MUD. - ---- - -## 21. Edge Cases - -| Edge Case | Behavior | -|---|---| -| Battery is 0, player tries to activate tech | "Your battery is depleted!" — blocked | -| Battery drains to 0 mid-tick | All techs deactivated, message sent | -| Player logs out | All techs deactivated (not saved) | -| Player dies | All techs deactivated, battery stays at current value | -| Player levels Technology | Max battery increases, current battery unchanged (no auto-refill) | -| Player levels Technology from 0 to 1 | Can now use techs. Battery was initialized to 0 from `New()`. Need to handle: set `Battery = MaxBattery()` when Technology XP is first gained, OR let player recharge at station. **Recommendation:** Battery starts at MaxBattery() for new characters (already handled in `New()`). For existing characters loaded from YAML with `Battery: 0`, they just need to visit a charging station. | -| Quick tech is set to a tech the player can't use (level too low) | "You need level X Technology to use Y." — same as manual toggle | -| Quick tech ID is invalid (tech was removed from code) | "Unknown tech." — handle gracefully | -| All techs deactivated by battery depletion during combat | Combat continues normally, just no tech bonuses/protection | -| Player with 0 Technology level types "tech" | "You need at least level 1 Technology to use tech." | -| Multiple protection techs (mutual exclusivity) | Only one protection tech at a time (group: "protection"). Activating one deactivates the other. | -| `tech list` with no Technology levels unlocked | Shows all techs with "locked" status | - ---- - -## 22. Complete File Change List - -| File | Change Type | Description | -|---|---|---| -| `internal/player/player.go` | MODIFY | Add `Battery`, `ActiveTechs`, `QuickTech`, `TechActivatedSinceTick` fields to `Player`. Add `MaxBattery()`, `HasActiveTech()`, `ActivateTech()`, `DeactivateTech()`, `DeactivateAllTechs()`, `ActiveTechList()` methods. Set `Battery` in `New()`. | -| `internal/game/tech.go` | NEW | `TechDef`, `TechEffects` structs. All 25 tech definitions. `GetTechDef()`, `TechsForLevel()`, `TechByPrefixMatch()`, `techEffectString()`, `totalTechBonus()`, `techLevelBonus()`, `applyTechProtection()`, `deactivateGroup()` functions. | -| `internal/game/cmd_tech.go` | NEW | `doTech()`, `toggleTech()`, `doTechList()`, `doTechQuick()` handlers. | -| `internal/game/game.go` | MODIFY | Add `"tech"`, `"t"` to `classifyCommand()` ClassInstant. Add `case "tech", "t"` to `executeCommand()`. | -| `internal/game/tick.go` | MODIFY | Add `TechTick()` function. Modify `RegenTick()` for tech regen multiplier. | -| `cmd/mud/main.go` | MODIFY | Add `g.TechTick()` call to tick subscriber (after `g.AdvanceActions()`). | -| `internal/game/cmd_attack.go` | MODIFY | Add `applyTechProtection()` call in `mobAttack()`. Add `techLevelBonus()` calls in `playerAttack()` and `mobAttack()` defense. Add retribution check in `endCombat()` death handler. Add `p.DeactivateAllTechs()` on death. | -| `internal/game/cmd_score.go` | MODIFY | Add battery line after HP. Add active techs section after skills table. | -| `internal/game/prompt.go` | MODIFY | Add `%b` (battery) and `%B` (max battery) prompt variables. | -| `internal/game/game.go` (SetHub) | MODIFY | Add `p.DeactivateAllTechs()` in `OnRemove` callback. | -| `internal/game/color.go` | NO CHANGE | Color helpers already support arbitrary targets via `resolveColor`. | -| `internal/config/colors.go` | MODIFY | Add default color entries for `battery`, `tech_depleted`, etc. | -| `internal/object/item.go` | NO CHANGE | `TechnologyBonus` field already exists. | -| `internal/world/mobs.go` | MODIFY | Add `WeaponType` field to `MobDef` and `MobInstance`. | -| `data/objects/charging_station.yaml` | NEW | Charging station object definition. | -| `data/items/neural_headband.yaml` | NEW | Tech-bonus headband item. | -| `data/items/power_amulet.yaml` | NEW | Tech-bonus amulet item. | -| `data/items/capacitor_gloves.yaml` | NEW | Tech-bonus gloves item. | -| `data/help/tech.yaml` | NEW | Help file for technology/tech commands. | - ---- - -## 23. Implementation Order - -### Phase 1: Core Infrastructure -- [ ] 1. Add `Battery`, `ActiveTechs`, `QuickTech`, `TechActivatedSinceTick` fields to `Player` struct in `internal/player/player.go` -- [ ] 2. Add `MaxBattery()`, `HasActiveTech()`, `ActivateTech()`, `DeactivateTech()`, `DeactivateAllTechs()`, `ActiveTechList()` methods to `Player` -- [ ] 3. Set `p.Battery = p.MaxBattery()` in `New()` -- [ ] 4. Create `internal/game/tech.go` with all `TechDef` definitions, `GetTechDef()`, `TechsForLevel()`, `TechByPrefixMatch()`, `techEffectString()` - -### Phase 2: Commands -- [ ] 5. Create `internal/game/cmd_tech.go` with `doTech()`, `toggleTech()`, `doTechList()`, `doTechQuick()` -- [ ] 6. Add `"tech"`, `"t"` to `classifyCommand()` as `ClassInstant` in `internal/game/game.go` -- [ ] 7. Add `case "tech", "t"` to `executeCommand()` in `internal/game/game.go` -- [ ] 8. **Test:** Verify `tech list`, `tech <name>`, `tech quick <name>`, `t` commands work. Verify mutual exclusivity. Verify level checks. - -### Phase 3: Battery Drain -- [ ] 9. Add `totalTechBonus()` helper to `internal/game/tech.go` -- [ ] 10. Add `TechTick()` to `internal/game/tick.go` -- [ ] 11. Add `g.TechTick()` to tick subscriber in `cmd/mud/main.go` (after `g.AdvanceActions()`) -- [ ] 12. **Test:** Verify drain reduces battery. Verify battery depletion deactivates all techs. Verify equipment tech bonus reduces drain. Verify Power Saver reduces drain on other techs. - -### Phase 4: 1-Tick Flicking -- [ ] 13. Verify `TechActivatedSinceTick` flag is set on `ActivateTech()` and checked in `TechTick()` -- [ ] 14. **Test:** Activate tech, verify drain on next tick. Deactivate and reactivate between ticks, verify NO drain on next tick. Repeat to confirm sustained zero-drain flicking. - -### Phase 5: Combat Integration -- [ ] 15. Add `techLevelBonus()` to `internal/game/tech.go` -- [ ] 16. Modify `playerAttack()` in `cmd_attack.go` to use `techLevelBonus` for attack and strength -- [ ] 17. Modify `mobAttack()` in `cmd_attack.go` to use `techLevelBonus` for defense -- [ ] 18. Add `WeaponType` to `MobDef`/`MobInstance` in `internal/world/` -- [ ] 19. Add `applyTechProtection()` to `internal/game/tech.go` -- [ ] 20. Call `applyTechProtection()` in `mobAttack()` after damage calculation -- [ ] 21. **Test:** Verify attack/strength/defense bonuses affect combat rolls. Verify protection reduces damage by 40%. - -### Phase 6: Retribution & Regen -- [ ] 22. Add retribution check in `endCombat()` death handler -- [ ] 23. Add `p.DeactivateAllTechs()` on death in `endCombat()` -- [ ] 24. Modify `RegenTick()` to check regen tech multiplier -- [ ] 25. **Test:** Verify retribution deals damage on death. Verify Nano Repair doubles regen speed. - -### Phase 7: Recharge & UI -- [ ] 26. Create `data/objects/charging_station.yaml` -- [ ] 27. Add recharge handler (in `doUse` or `use_interactions` handler) -- [ ] 28. Place charging station in at least one room -- [ ] 29. Modify `doScore()` in `cmd_score.go` to show battery and active techs -- [ ] 30. Add `%b` and `%B` prompt variables in `prompt.go` -- [ ] 31. Add `p.DeactivateAllTechs()` in `OnRemove` callback in `game.go` - -### Phase 8: Polish -- [ ] 32. Add default color targets for `battery`, `tech_depleted`, etc. -- [ ] 33. Create tech-bonus equipment items (neural_headband, power_amulet, capacitor_gloves) -- [ ] 34. Create `data/help/tech.yaml` -- [ ] 35. Run `make vet` and `make test` -- [ ] 36. Manual testing: full tech lifecycle (activate, drain, flick, protect, recharge, death, logout) - ---- - -## 24. Test Cases - -### Unit tests to add (in `internal/game/` or `internal/player/`): - -```go -func TestMaxBattery(t *testing.T) { - p := player.New("test") - p.Skills[player.Technology] = player.XPForLevel(43) - if p.MaxBattery() != 43.0 { - t.Errorf("expected MaxBattery 43, got %f", p.MaxBattery()) - } -} - -func TestTechMutualExclusivity(t *testing.T) { - p := player.New("test") - p.ActivateTech("clarity_1") - p.ActivateTech("clarity_2") // should NOT auto-deactivate clarity_1 (that's Game's job) - // The Game.toggleTech function handles deactivation via deactivateGroup -} - -func TestTechLevelBonus(t *testing.T) { - // Player with Attack 80, clarity_3 active (+15%) - // Expected bonus: 80 * 15 / 100 = 12 - p := player.New("test") - p.Skills[player.Attack] = player.XPForLevel(80) - p.ActivateTech("clarity_3") - // g.techLevelBonus(p, "attack") should return 12 -} - -func TestDrainWithPreserve(t *testing.T) { - // clarity_3 (0.15) + preserve (0.05) - // clarity drain = 0.15 * 0.8 = 0.12 - // preserve drain = 0.05 - // total = 0.17 -} - -func TestFlickingNoDrain(t *testing.T) { - // Activate tech, set TechActivatedSinceTick - // TechTick should skip drain for that tech - // Clear flag - // Next TechTick should drain -} -``` - ---- - -## 25. Summary of All New Functions - -| Function | File | Purpose | -|---|---|---| -| `Player.MaxBattery()` | `player.go` | Returns Technology level as float64 | -| `Player.HasActiveTech(id)` | `player.go` | Checks if tech is currently active | -| `Player.ActivateTech(id)` | `player.go` | Activates tech, sets flick flag | -| `Player.DeactivateTech(id)` | `player.go` | Deactivates single tech | -| `Player.DeactivateAllTechs()` | `player.go` | Clears all active techs | -| `Player.ActiveTechList()` | `player.go` | Returns sorted list of active tech IDs | -| `GetTechDef(id)` | `tech.go` | Looks up tech by ID | -| `TechsForLevel(level)` | `tech.go` | Returns techs available at given level | -| `TechByPrefixMatch(input)` | `tech.go` | Prefix-matches tech name or ID | -| `techEffectString(tech)` | `tech.go` | Human-readable effect description | -| `Game.totalTechBonus(p)` | `tech.go` | Sums equipment TechnologyBonus | -| `Game.techLevelBonus(p, stat)` | `tech.go` | Calculates percentage level bonus from active techs | -| `Game.applyTechProtection(p, mob, dmg)` | `tech.go` | Applies protection damage reduction | -| `Game.deactivateGroup(p, group)` | `tech.go` | Deactivates all techs in a mutual-exclusivity group | -| `Game.doTech(sess, input)` | `cmd_tech.go` | Main tech command handler | -| `Game.toggleTech(sess, p, techID)` | `cmd_tech.go` | Toggle specific tech on/off | -| `Game.doTechList(sess)` | `cmd_tech.go` | Display tech list table | -| `Game.doTechQuick(sess, p, input)` | `cmd_tech.go` | Set quick tech | -| `Game.TechTick()` | `tick.go` | Per-tick battery drain | diff --git a/skill_plans/thieving.md b/skill_plans/thieving.md deleted file mode 100644 index 90398fc..0000000 --- a/skill_plans/thieving.md +++ /dev/null @@ -1,1391 +0,0 @@ -# Thieving Skill Implementation Plan - -## 1. Overview - -Thieving is a gathering-category skill that allows players to steal from mobs and objects. Unlike mining/fishing/woodcutting which use the behavior-YAML-driven `gather` system, thieving is a **hardcoded action type** (like `burn` and `search`) because it has unique mechanics: - -- Mob aggro on failure (mobs turn hostile) -- Guard watching cycles on objects (tick-based state machine) -- Guard spawning on watched-object failures -- Sneak mode toggle with real-time guard awareness notifications - -The skill uses the existing `Thieving` constant already defined in `internal/player/player.go` (line 25, abbreviation `"thv"` at line 57). - -Commands: `steal` (active), `sneak` (instant toggle). Aliases: `thieve` maps to `steal`. - ---- - -## 2. Commands - -### `steal` / `thieve` - -- **Classification:** `ClassActive` -- **Aliases:** `thieve` → `steal` (added to `verbAliases` in `action.go`) -- **Syntax:** - - `steal` — auto-resolves if only one stealable target (mob or object) in room - - `steal <target>` — steal from a specific mob or object by name - - `steal 2.man` — steal from the 2nd man (numbered targeting) -- **Behavior:** Searches mobs first (reversed from `StartAction` which searches objects first), then objects. This is because stealing from mobs is the primary use case. - -### `sneak` - -- **Classification:** `ClassInstant` -- **Syntax:** `sneak` — toggles sneak mode on/off -- **Behavior:** Sets `p.Sneaking` (new bool field on Player, transient/not saved). While sneaking, `SneakTick()` sends guard-watching notifications each tick. - ---- - -## 3. New Files to Create - -### Go Files - -| File | Purpose | -|---|---| -| `internal/game/action_steal.go` | `doSteal()`, `startSteal()`, `advanceSteal()`, `resolveStealTarget()`, mob aggro logic, guard alert logic | -| `internal/game/cmd_sneak.go` | `doSneak()` toggle handler, `SneakTick()` for guard-watching notifications | - -### YAML Data Files - -| File | Purpose | -|---|---| -| `data/items/credit_stick.yaml` | Credit stick item (searchable) | -| `data/items/potato_seed.yaml` | Low-value seed | -| `data/items/onion_seed.yaml` | Low-value seed | -| `data/items/cabbage_seed.yaml` | Low-value seed | -| `data/items/tomato_seed.yaml` | Low-mid value seed | -| `data/items/sweetcorn_seed.yaml` | Mid value seed | -| `data/items/strawberry_seed.yaml` | Mid value seed | -| `data/items/watermelon_seed.yaml` | Mid-high value seed | -| `data/items/ranarr_seed.yaml` | High value seed | -| `data/items/snapdragon_seed.yaml` | High value seed | -| `data/items/torstol_seed.yaml` | Very high value seed | -| `data/items/bread.yaml` | Low-value food from stall | -| `data/items/apple.yaml` | Low-value food from stall | -| `data/items/cheese.yaml` | Low-value food from stall | -| `data/objects/market_stall.yaml` | Market stall object (stealable) | -| `data/mobs/farmer.yaml` | Farmer mob (stealable, low-mid seeds) | -| `data/mobs/bioengineer.yaml` | Bioengineer mob (stealable, mid-high seeds) | -| `data/drops/credit_stick_drop.yaml` | Drop table for credit stick search | -| `data/drops/man_steal.yaml` | Drop table for stealing from man | -| `data/drops/farmer_steal.yaml` | Drop table for stealing from farmer | -| `data/drops/bioengineer_steal.yaml` | Drop table for stealing from bioengineer | -| `data/drops/market_stall_steal.yaml` | Drop table for stealing from market stall | -| `data/behaviors/stall_guard_talk.yaml` | Talk behavior for the guard who catches you | -| `data/rooms/150.yaml` | Market Square (stall + guard + men) | -| `data/rooms/151.yaml` | Farm Outpost (farmer + bioengineer) | -| `data/rooms/152.yaml` | Detention Cell (jail room) | -| `data/help/steal.yaml` | Help topic for steal | -| `data/help/sneak.yaml` | Help topic for sneak | -| `data/help/thieving.yaml` | Help topic for thieving skill | - ---- - -## 4. Code Changes to Existing Files - -### `internal/player/player.go` - -Add a transient `Sneaking` field to the `Player` struct: - -```go -// In the Player struct, after the existing transient fields: -Sneaking bool `yaml:"-"` -``` - -Add after line 174 (`VisualTickCurrent int`): -```go -Sneaking bool `yaml:"-"` -``` - -### `internal/game/action_state.go` - -Add the new `ActionType` constant. After `ActionEating` (line 24): - -```go -ActionStealing ActionType = "stealing" -``` - -Add a `Description()` case inside the switch (after the `ActionEating` case, around line 75): - -```go -case ActionStealing: - return "stealing from " + a.TargetName -``` - -### `internal/game/action.go` - -Add `steal` aliases to `verbAliases` map (after `"push": "toggle"` on line 27): - -```go -"steal": "steal", -"thieve": "steal", -``` - -Add to `verbSkill` map (after `"shear": "crafting"` on line 36): - -```go -"steal": "thieving", -"thieve": "thieving", -``` - -Add `"steal"` case to `AdvanceActions()` switch (after `case "search":` block, around line 235): - -```go -case "steal": - g.advanceSteal(sess, p) -``` - -### `internal/game/game.go` - -#### `classifyCommand()` — line 136 - -Add `"sneak"` to the `ClassInstant` list (line 138-142): - -```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", "sneak": - return ClassInstant -``` - -Add `"steal", "thieve"` to the `ClassActive` list (line 146-151): - -```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", - "steal", "thieve": - return ClassActive -``` - -#### `executeCommand()` — line 249 - -Add `"sneak"` case in the instant section (after the `"colortable"` case, around line 329): - -```go -case "sneak": - g.doSneak(sess) -``` - -Add `"steal", "thieve"` case in the active section (after the `"search"` block, around line 417): - -```go -case "steal", "thieve": - g.CancelAction(p) - if len(args) == 0 { - g.doSteal(sess, "") - } else { - g.doSteal(sess, strings.Join(args, " ")) - } - return -``` - -#### `ProcessQueuedCommands()` — line 457 - -Add `ActionStealing` to the list of persistent action states that don't get cleared (line 472): - -```go -case ActionGathering, ActionCombating, ActionUsing, ActionTalking, - ActionToggling, ActionBurning, ActionStoking, ActionResting, ActionWalking, ActionProducing, - ActionStealing: -``` - -### `cmd/mud/main.go` - -Add `SneakTick()` to the tick subscriber (after `g.VisualTick()` on line 55): - -```go -g.SneakTick() -``` - -### `internal/world/mob.go` - -Add steal-related fields to `MobDef` struct (after `Drops` field, line 36): - -```go -StealTable string `yaml:"steal_table"` -StealLevel int `yaml:"steal_level"` -StealXP int `yaml:"steal_xp"` -StealSpeed float64 `yaml:"steal_speed"` -``` - -Add corresponding runtime fields to `MobInstance` struct (after `regenerateTick` field, line 61): - -```go -StealTable string -StealLevel int -StealXP int -StealSpeed float64 -``` - -In the mob instantiation function (wherever `MobInstance` is created from `MobDef`, in `mob.go`), copy the steal fields: - -```go -inst.StealTable = def.StealTable -inst.StealLevel = def.StealLevel -inst.StealXP = def.StealXP -inst.StealSpeed = def.StealSpeed -``` - -Search for `func (s *MobStore) spawnMob` or equivalent — it's the function that creates `MobInstance` from `MobDef`. The steal fields must be copied there. Around line 160-200 in `mob.go`, find where instances are built and add the four field copies. - -### `internal/object/object.go` - -Add steal-related fields to `ObjectDef` struct: - -```go -StealTable string `yaml:"steal_table"` -StealLevel int `yaml:"steal_level"` -StealXP int `yaml:"steal_xp"` -StealSpeed float64 `yaml:"steal_speed"` -GuardMob string `yaml:"guard_mob"` -``` - -- `steal_table`: Drop table ID for loot when stealing from this object -- `steal_level`: Required thieving level -- `steal_xp`: XP awarded per successful steal -- `steal_speed`: Ticks per steal attempt (base wait) -- `guard_mob`: Mob def ID that guards this object (watches it) - ---- - -## 5. Items - -### `data/items/credit_stick.yaml` - -```yaml -id: credit_stick -name: credit stick -color: "220" -description: "A small electronic stick loaded with credits. You can search it to extract the credits." -value: 10 -stackable: false -search_table: credit_stick_drop -search_ticks: 2 -search_message: "cracking open the credit stick" -``` - -### `data/items/bread.yaml` - -```yaml -id: bread -name: bread -color: "179" -description: "A crusty loaf of bread." -value: 5 -stackable: false -heal_value: 3 -eat_message: "You eat the bread. Not bad." -``` - -### `data/items/apple.yaml` - -```yaml -id: apple -name: apple -color: "196" -description: "A bright red apple." -value: 3 -stackable: false -heal_value: 2 -eat_message: "You eat the apple. Refreshing." -``` - -### `data/items/cheese.yaml` - -```yaml -id: cheese -name: cheese -color: "226" -description: "A wedge of sharp cheese." -value: 4 -stackable: false -heal_value: 2 -eat_message: "You eat the cheese. Tasty." -``` - -### `data/items/potato_seed.yaml` - -```yaml -id: potato_seed -name: potato seed -color: "94" -description: "A seed for growing potatoes." -value: 2 -stackable: true -``` - -### `data/items/onion_seed.yaml` - -```yaml -id: onion_seed -name: onion seed -color: "229" -description: "A seed for growing onions." -value: 3 -stackable: true -``` - -### `data/items/cabbage_seed.yaml` - -```yaml -id: cabbage_seed -name: cabbage seed -color: "34" -description: "A seed for growing cabbages." -value: 4 -stackable: true -``` - -### `data/items/tomato_seed.yaml` - -```yaml -id: tomato_seed -name: tomato seed -color: "196" -description: "A seed for growing tomatoes." -value: 8 -stackable: true -``` - -### `data/items/sweetcorn_seed.yaml` - -```yaml -id: sweetcorn_seed -name: sweetcorn seed -color: "226" -description: "A seed for growing sweetcorn." -value: 25 -stackable: true -``` - -### `data/items/strawberry_seed.yaml` - -```yaml -id: strawberry_seed -name: strawberry seed -color: "197" -description: "A seed for growing strawberries." -value: 40 -stackable: true -``` - -### `data/items/watermelon_seed.yaml` - -```yaml -id: watermelon_seed -name: watermelon seed -color: "34" -description: "A seed for growing watermelons." -value: 80 -stackable: true -``` - -### `data/items/ranarr_seed.yaml` - -```yaml -id: ranarr_seed -name: ranarr seed -color: "28" -description: "A rare herb seed with potent alchemical properties." -value: 500 -stackable: true -``` - -### `data/items/snapdragon_seed.yaml` - -```yaml -id: snapdragon_seed -name: snapdragon seed -color: "92" -description: "An extremely rare herb seed. Highly valued by alchemists." -value: 1500 -stackable: true -``` - -### `data/items/torstol_seed.yaml` - -```yaml -id: torstol_seed -name: torstol seed -color: "46" -description: "The rarest of herb seeds. Worth a small fortune." -value: 5000 -stackable: true -``` - ---- - -## 6. Mobs - -### `data/mobs/man.yaml` (UPDATE existing file) - -Add `steal_table`, `steal_level`, `steal_xp`, and `steal_speed` fields: - -```yaml -id: man -name: man -description: "A shabby-looking man loitering in the town square." -combat_descriptions: - - "is engaged in a fight to the death with %s" - - "is getting pummelled by %s" - - "is locked in combat with %s" - - "trades blows with %s" - - "circles warily around %s" -idle_descriptions: - - "scribbles something in a small notebook" - - "gazes skyward at the clouds" - - "leans against a wall, looking bored" - - "scratches his head thoughtfully" - - "stares off into the distance" - - "adjusts his tunic and stretches" -attack: 1 -strength: 1 -defense: 1 -hp: 7 -speed: 5 -aggressive: false -respawn_ticks: 30 -steal_table: man_steal -steal_level: 1 -steal_xp: 8 -steal_speed: 4 -drops: - remains: "bones" - loot: - - item_id: "credits" - weight: 98 - quantity: 10 - - item_id: "credits" - weight: 2 - quantity: 150 -``` - -### `data/mobs/farmer.yaml` - -```yaml -id: farmer -name: Farmer -description: "A weathered farmer in muddy overalls, pockets bulging with seeds." -combat_descriptions: - - "swings a shovel at %s" - - "is getting beaten by %s" - - "grapples with %s" -idle_descriptions: - - "examines a handful of seeds" - - "wipes dirt from his hands" - - "mutters about the growing season" - - "adjusts his wide-brimmed hat" -attack: 3 -strength: 3 -defense: 3 -hp: 15 -speed: 5 -aggressive: false -respawn_ticks: 40 -steal_table: farmer_steal -steal_level: 10 -steal_xp: 15 -steal_speed: 4 -drops: - remains: "bones" - loot: - - item_id: "potato_seed" - weight: 40 - quantity: 3 - - item_id: "onion_seed" - weight: 30 - quantity: 2 - - item_id: "cabbage_seed" - weight: 20 - quantity: 2 - - item_id: "tomato_seed" - weight: 10 - quantity: 1 -``` - -### `data/mobs/bioengineer.yaml` - -```yaml -id: bioengineer -name: Bioengineer -description: "A lab-coated scientist carrying a satchel of genetically modified seeds. Her pockets are stuffed with rare specimens." -combat_descriptions: - - "jabs a syringe at %s" - - "is being overpowered by %s" - - "fights desperately against %s" -idle_descriptions: - - "scribbles notes on a clipboard" - - "carefully inspects a vial of green liquid" - - "adjusts her safety goggles" - - "mutters about gene splicing yields" -attack: 6 -strength: 4 -defense: 5 -hp: 25 -speed: 5 -aggressive: false -respawn_ticks: 50 -steal_table: bioengineer_steal -steal_level: 38 -steal_xp: 45 -steal_speed: 4 -drops: - remains: "bones" - loot: - - item_id: "sweetcorn_seed" - weight: 30 - quantity: 2 - - item_id: "strawberry_seed" - weight: 25 - quantity: 1 - - item_id: "watermelon_seed" - weight: 15 - quantity: 1 - - item_id: "ranarr_seed" - weight: 5 - quantity: 1 -``` - ---- - -## 7. Objects - -### `data/objects/market_stall.yaml` - -```yaml -id: market_stall -name: Market Stall -description: "A wooden stall piled with food and sundries. The vendor doesn't seem particularly attentive." -inroom_description: "A bustling {179}market stall{/} is set up here." -hidden: false -steal_table: market_stall_steal -steal_level: 5 -steal_xp: 12 -steal_speed: 5 -guard_mob: guard -``` - -Note: `guard_mob: guard` means a mob with def ID `guard` in the same room is watching this stall. The `guard_mob` field is a reference — the actual mob must be placed in the room YAML via the `mobs:` list. If the guard mob is present in the room at the time of the steal, the watching mechanic activates. - ---- - -## 8. Rooms - -### `data/rooms/150.yaml` — Market Square - -```yaml -id: 150 -name: "Market Square" -description: "A noisy open-air market wedged between crumbling hab-blocks. Vendors hawk salvaged tech and reconstituted food from makeshift stalls. A {220 bold}Guard{/} watches over the area with a stern expression." -map_symbol: "M" -exits: - south: 100 -objects: - - id: market_stall -mobs: - - id: man - wander_interval: 15 - - id: man - wander_interval: 18 - - id: man - wander_interval: 20 - - id: guard -``` - -### `data/rooms/151.yaml` — Farm Outpost - -```yaml -id: 151 -name: "Farm Outpost" -description: "A cluster of hydroponic grow-pods on the asteroid's surface, shielded by a flickering atmospheric dome. Rows of bio-luminescent crops stretch into the distance." -map_symbol: "F" -exits: - west: 150 -mobs: - - id: farmer - wander_rooms: [151] - - id: farmer - wander_rooms: [151] - - id: bioengineer - wander_rooms: [151] -``` - -### `data/rooms/152.yaml` — Detention Cell - -```yaml -id: 152 -name: "Detention Cell" -description: "A small, grimy holding cell. The walls are scratched with tally marks from previous occupants. A heavy door bars the only exit." -map_symbol: "J" -exits: - south: 100 -``` - -### Room connectivity - -Add an exit from room 100 (Grand Concourse) to room 150: - -In `data/rooms/100.yaml`, add `north: 110` already exists. Add `south: 150`: - -```yaml -id: 100 -name: "Grand Concourse" -description: "The expansive white platform of Station X1's main thoroughfare. Neon strips pulse along the ceiling, reflecting off polished permacrete floors. Citizens and synthetics stream past in a constant dance of commerce and purpose." -map_symbol: "+" -exits: - east: 101 - north: 110 - south: 150 -``` - -Add an exit from room 150 to 151: - -Already handled: room 150 has `south: 100`, and room 151 has `west: 150`. Add `east: 151` to room 150's exits. - -Updated room 150 exits: -```yaml -exits: - south: 100 - east: 151 -``` - ---- - -## 9. Mechanics - -### Steal Success Formula - -Uses the existing `SuccessChance` pattern from `internal/action/store.go`: - -``` -chance = base + (thievingLevel - requiredLevel) * perLevel -clamped to [0, cap] -``` - -Constants (hardcoded in `action_steal.go`): - -```go -var stealSuccess = action.SuccessFormula{ - Base: 0.5, - PerLevel: 0.03, - Cap: 0.95, -} -``` - -When a guard mob is watching (see Guard Watching Cycle), the chance is halved: - -```go -if guardWatching { - chance *= 0.5 -} -``` - -Minimum chance is 0.05 (5%) even when halved. - -### Steal from Mob — Failure Consequence - -On a failed steal against a mob: -1. The mob turns aggressive **toward the player only** — initiates combat. -2. Message: `"The <mob name> notices you! They attack!"` -3. Combat starts via the existing `g.startCombat(sess, p, mob)` call. -4. The mob must not already be in combat (`combat.IsMobInCombat`). If it is, output `"The <mob name> is busy."` and cancel. - -### Steal from Object — Failure Consequence - -On a failed steal against a guarded object: -- **If the guard mob is watching:** The guard calls for backup. A `stall_guard` mob instance is spawned in the room (or the existing guard initiates a talk dialog). The player enters `StateTalk` with the `stall_guard_talk` behavior offering: bribe, jail, or fight. -- **If the guard mob is NOT watching:** Simple failure message. `"You fail to steal anything."` No consequences. -- **If no guard mob exists in the room:** Simple failure, no consequences. - -### Sneak Mode Toggle - -- `sneak` toggles `p.Sneaking` bool. -- When enabled: `"You begin sneaking."` — the player is now in sneak mode. -- When disabled: `"You stop sneaking."` — normal mode. -- Sneak mode is **transient** (not saved to YAML). Lost on disconnect. -- Sneak mode does NOT affect movement or other actions — it only enables guard-watching notifications and improves steal chance on guarded objects. - -### Guard Watching Cycle - -Each tick, `SneakTick()` runs for all sneaking players. For each sneaking player: - -1. Find all objects in the room with a `guard_mob` field. -2. For each such object, check if a living mob with that def ID is in the room. -3. If a guard is present, use a tick-based watching cycle: - - The guard watches the object for `watchDuration` ticks (8 ticks), then looks away for `lookAwayDuration` ticks (4 ticks), cycling. - - Tracked via world-level state: `guardWatchTimers map[string]int` on the `Game` struct, keyed by `"roomID:objDefID"`. - - Each tick the counter increments. If `counter % (watchDuration + lookAwayDuration) < watchDuration`, the guard is watching. -4. Send a message to the sneaking player: - - Watching: `"The Guard is watching the Market Stall."` - - Not watching: `"The Guard looks away from the Market Stall."` - - Only send on state **transitions** (watching→not watching, not watching→watching), not every tick. Track last-known state per player per object. - -To track per-player notification state, add a transient field to Player: - -```go -SneakNotified map[string]bool `yaml:"-"` // key: "roomID:objDefID", value: last known watching state -``` - -### Guard Watching — Implementation on Game struct - -Add to `Game` struct: - -```go -guardWatchTimers map[string]int // key: "roomID:objDefID", value: tick counter -``` - -Initialize in `New()`: - -```go -guardWatchTimers: make(map[string]int), -``` - ---- - -## 10. Action Lifecycle - -### File: `internal/game/action_steal.go` - -```go -package game - -import ( - "fmt" - "math/rand" - "sort" - "strconv" - "strings" - - "thehouseoficarus/internal/action" - "thehouseoficarus/internal/combat" - "thehouseoficarus/internal/engine" - "thehouseoficarus/internal/net" - "thehouseoficarus/internal/object" - "thehouseoficarus/internal/player" - "thehouseoficarus/internal/world" -) - -var stealSuccess = action.SuccessFormula{ - Base: 0.5, - PerLevel: 0.03, - Cap: 0.95, -} -``` - -### `doSteal(sess *net.Session, input string)` - -Entry point called from `executeCommand`. Signature: - -```go -func (g *Game) doSteal(sess *net.Session, input string) -``` - -Logic: -1. `p := sess.Player.(*player.Player)` -2. Check `combat.GetCombat(p.Name) != nil` → `"You can't do that during combat!"` -3. `g.CancelAction(p)` -4. Call `g.resolveStealTarget(sess, p, input)` → returns `(targetType string, mob *world.MobInstance, obj *object.ObjectDef, err string)` -5. If err != "" → `sess.WriteLine(err); return` -6. Call `g.startSteal(sess, p, targetType, mob, obj)` - -### `resolveStealTarget(sess *net.Session, p *player.Player, input string) (targetType string, mob *world.MobInstance, obj *object.ObjectDef, errMsg string)` - -Logic: -1. Parse `input` for numbered targeting (`N.name` → `instanceIdx`, `name`). -2. If `input == ""` (no target specified): - a. Collect all stealable mobs in room (those with `StealTable != ""`). - b. Collect all stealable objects in room (those with `StealTable != ""`). - c. Combined count: if 0 → return `"", nil, nil, "There's nothing here to steal from."` - d. If all stealable targets are the same mob def → auto-select first mob. Return `"mob", mob, nil, ""`. - e. If exactly 1 stealable object and 0 stealable mobs → auto-select object. Return `"object", nil, obj, ""`. - f. If multiple different types → return `"", nil, nil, "Steal from what?"`. -3. If `input != ""`: - a. Search mobs in room with `StealTable != ""` matching input (using `mob.MatchQuality(name)`). - b. If multiple mobs found with different def IDs → `"Which one?"`. - c. If mobs found → apply `instanceIdx`, return `"mob", selectedMob, nil, ""`. - d. If no mob found, search objects in room with `StealTable != ""` matching input (using `world.WordPrefixMatch`). - e. If object found → return `"object", nil, objDef, ""`. - f. If nothing → return `"", nil, nil, "There's nothing here to steal from."`. - -### `startSteal(sess *net.Session, p *player.Player, targetType string, mob *world.MobInstance, obj *object.ObjectDef)` - -Logic: -1. Determine `stealTable`, `stealLevel`, `stealXP`, `stealSpeed`, `targetName`, `targetID`: - - If `targetType == "mob"`: from `mob.StealTable`, `mob.StealLevel`, `mob.StealXP`, `mob.StealSpeed`, `mob.Name`, `mob.InstanceID` - - If `targetType == "object"`: from `obj.StealTable`, `obj.StealLevel`, `obj.StealXP`, `obj.StealSpeed`, `obj.Name`, `obj.ID` -2. Check `stealTable == ""` → `"You can't steal from the <name>."` -3. Check level requirement: `p.Level(player.Thieving) < stealLevel` → `"You need level <N> thieving to steal from the <name>."` -4. Check inventory space: `p.FirstFreeSlot() == -1` → `"Your inventory is too full!"` -5. If mob target, check `mob.HP <= 0` → `"That is already dead."`. Check `combat.IsMobInCombat(mob.InstanceID)` → `"The <name> is busy."` -6. Determine `guardWatching` (only for object targets with `obj.GuardMob != ""`): - - Check if a mob with DefID == `obj.GuardMob` exists in room and is alive. - - If so, check the guard watch timer cycle to determine if watching. -7. Create action: - -```go -sess.WriteLine(fmt.Sprintf("You attempt to steal from the %s...", targetName)) - -p.ActionState = &ActionState{Type: ActionStealing, TargetName: targetName} - -p.Action = &action.Action{ - Type: "steal", - TargetID: targetID, - TargetName: targetName, - WaitLeft: engine.ToTicks(stealSpeed), - Data: map[string]any{ - "target_type": targetType, - "steal_table": stealTable, - "steal_level": stealLevel, - "steal_xp": stealXP, - "target_name": targetName, - "mob_instance_id": mobInstanceID, // "" if object - "obj_def_id": objDefID, // "" if mob - "guard_mob": guardMob, // "" if no guard - "guard_watching": guardWatching, - }, -} -``` - -### `advanceSteal(sess *net.Session, p *player.Player)` - -Called from `AdvanceActions()` when `p.Action.Type == "steal"` and timer reaches 0. - -Logic: -1. Extract all data fields from `p.Action.Data`. -2. Validate target still exists: - - If mob: check `g.MobStore.GetInstance(mobInstanceID)` is non-nil, still alive, still in same room. - - If object: check object still exists in room (via `g.World.FindObjInstances`). - - If gone: `"Your target is gone."` → `g.CancelAction(p); return` -3. Calculate success chance: - ```go - level := p.Level(player.Thieving) - chance := action.SuccessChance(stealSuccess, level, stealLevel) - if guardWatching { - chance *= 0.5 - if chance < 0.05 { - chance = 0.05 - } - } - ``` -4. Roll: `rand.Float64() < chance` -5. **On success:** - a. Load drop table: `g.BehaviorStore.LoadDropTable(stealTable)` - b. Resolve drop: `g.BehaviorStore.ResolveDrop(dt.Drops)` - c. If drop is nil or empty → `"You steal nothing of value."` (edge case) - d. Give item to player using same pattern as `giveSearchLoot` (check free slot, handle credits specially, drop to ground if full). - e. Award XP: - ```go - if stealXP > 0 { - if newLevel := p.AddSkillXP(player.Thieving, stealXP); newLevel > 0 { - sess.WriteLine(g.colorize(sess, "level_up", fmt.Sprintf("*** You are now level %d thieving! ***", newLevel))) - } - } - ``` - f. XP drop message (if `xp_drops` option on): - ```go - if stealXP > 0 && p.OptionBool("xp_drops") { - msg += g.colorize(sess, "xp", fmt.Sprintf(" (+%dxp %s)", stealXP, player.SkillAbbr[player.Thieving])) - } - ``` - g. Save character: `g.AccountStore.SaveCharacter(p)` - h. Restart action for continuous stealing (like gather loops): - ```go - p.Action.WaitLeft = engine.ToTicks(stealSpeed) - ``` - Check inventory space before restarting. If full → cancel action. -6. **On failure (mob target):** - a. `sess.WriteLine(fmt.Sprintf("The %s notices you! They attack!", targetName))` - b. Initiate combat: `g.startCombat(sess, p, mob)` — reuse existing combat start. - c. Cancel steal action: `g.CancelAction(p)` -7. **On failure (object target):** - a. If `guardMob != ""` and guard is alive in room and `guardWatching`: - - `sess.WriteLine("You fumble and the Guard spots you!")` - - Start the guard talk interaction: - ```go - guardMob := g.findGuardInRoom(p.RoomID, data["guard_mob"].(string)) - if guardMob != nil { - g.CancelAction(p) - g.startMobTalk(sess, p, guardMob) - return - } - ``` - - This requires the guard mob to have `behavior: stall_guard_talk` set in its YAML def. BUT: the existing `guard` mob already has `behavior: guard_talk`. We need a **separate** behavior for the caught-stealing scenario. Options: - - Create a new talk behavior `stall_guard_talk` and **temporarily** override the guard's behavior when the steal fails. Since `startMobTalk` uses `mob.BehaviorID`, we can set `guardMob.BehaviorID = "stall_guard_talk"` before calling it, then restore after. This is hacky. - - Better: Use `g.startTalkFromBehavior(sess, p, "stall_guard_talk", guardMob.Name)` — create a small helper that starts a talk without requiring the mob's own behavior field. This is cleaner. - - Cleanest approach: Add a helper `startStealGuardTalk(sess, p, guardMob)` that loads `stall_guard_talk` behavior directly and initiates the talk state, bypassing the mob's own behavior ID. - b. If guard not watching or no guard: - - `sess.WriteLine("You fail to steal anything.")` - - Restart action for retry: `p.Action.WaitLeft = engine.ToTicks(stealSpeed)` - -### Helper: `findGuardInRoom(roomID int, guardDefID string) *world.MobInstance` - -```go -func (g *Game) findGuardInRoom(roomID int, guardDefID string) *world.MobInstance { - mobs := g.MobStore.MobsInRoom(roomID) - for _, m := range mobs { - if m.DefID == guardDefID && m.HP > 0 { - return m - } - } - return nil -} -``` - -### Helper: `isGuardWatching(roomID int, objDefID string) bool` - -```go -const guardWatchDuration = 8 -const guardLookAwayDuration = 4 -const guardCycleLength = guardWatchDuration + guardLookAwayDuration // 12 - -func (g *Game) isGuardWatching(roomID int, objDefID string) bool { - key := fmt.Sprintf("%d:%s", roomID, objDefID) - counter := g.guardWatchTimers[key] - return counter % guardCycleLength < guardWatchDuration -} -``` - -### Helper: `startStealGuardTalk(sess *net.Session, p *player.Player, guardMob *world.MobInstance)` - -```go -func (g *Game) startStealGuardTalk(sess *net.Session, p *player.Player, guardMob *world.MobInstance) { - cfg, err := g.BehaviorStore.LoadTalk("stall_guard_talk") - if err != nil { - sess.WriteLine("The Guard glares at you but says nothing.") - return - } - - p.ActionState = &ActionState{Type: ActionTalking, TargetName: guardMob.Name} - - startNode := cfg.Nodes["start"] - sess.WriteLine(fmt.Sprintf("\n%s says: \"%s\"", g.colorize(sess, "mob_name", guardMob.Name), startNode.Message)) - - // Show options (reuse existing talk option display pattern) - g.showTalkOptions(sess, p, cfg, "start") - - sess.State = net.StateTalk - sess.TalkData = &net.TalkData{ - BehaviorID: "stall_guard_talk", - NodeID: "start", - TargetName: guardMob.Name, - } -} -``` - -Note: The above uses `sess.TalkData` — check how the existing talk system stores state. Look at `internal/net/server.go` for `TalkData`. The existing talk system stores talk state in the session. The `startStealGuardTalk` function must follow the exact same pattern as `startMobTalk` / `startTalk` in `action_talk.go` — read that file to match precisely. The key point is that the talk behavior `stall_guard_talk` is loaded by ID rather than from the mob's own `BehaviorID`. - ---- - -## 11. Sneak Mode - -### File: `internal/game/cmd_sneak.go` - -```go -package game - -import ( - "fmt" - - "thehouseoficarus/internal/net" - "thehouseoficarus/internal/player" -) - -func (g *Game) doSneak(sess *net.Session) { - p := sess.Player.(*player.Player) - p.Sneaking = !p.Sneaking - if p.Sneaking { - p.SneakNotified = make(map[string]bool) - sess.WriteLine("You begin sneaking.") - } else { - p.SneakNotified = nil - sess.WriteLine("You stop sneaking.") - } -} - -func (g *Game) SneakTick() { - if g.Hub == nil { - return - } - - // Advance all guard watch timers - for key := range g.guardWatchTimers { - g.guardWatchTimers[key]++ - } - - for _, sess := range g.Hub.AllSessions() { - p, ok := sess.Player.(*player.Player) - if !ok || p == nil || !p.Sneaking { - continue - } - - objs := g.World.AllObjInstances(p.RoomID) - for _, st := range objs { - objDef, err := g.ObjectStore.Load(st.DefID) - if err != nil || objDef.GuardMob == "" { - continue - } - - guard := g.findGuardInRoom(p.RoomID, objDef.GuardMob) - if guard == nil { - continue - } - - // Ensure timer exists - timerKey := fmt.Sprintf("%d:%s", p.RoomID, st.DefID) - if _, exists := g.guardWatchTimers[timerKey]; !exists { - g.guardWatchTimers[timerKey] = 0 - } - - watching := g.isGuardWatching(p.RoomID, st.DefID) - notifyKey := timerKey - - if p.SneakNotified == nil { - p.SneakNotified = make(map[string]bool) - } - - lastState, known := p.SneakNotified[notifyKey] - if !known || lastState != watching { - if watching { - sess.WriteLine(g.colorize(sess, "warning", fmt.Sprintf("The %s is watching the %s.", guard.Name, objDef.Name))) - } else { - sess.WriteLine(g.colorize(sess, "success", fmt.Sprintf("The %s looks away from the %s.", guard.Name, objDef.Name))) - } - p.SneakNotified[notifyKey] = watching - } - } - } -} -``` - -### Player struct additions (in `internal/player/player.go`) - -After `Sneaking bool`: - -```go -SneakNotified map[string]bool `yaml:"-"` -``` - ---- - -## 12. Guard Interaction - -### Talk Behavior: `data/behaviors/stall_guard_talk.yaml` - -```yaml -id: stall_guard_talk -type: talk -nodes: - start: - message: "Caught you red-handed! You have three options, thief." - options: - - text: "I'll pay a fine. (500 credits)" - goto: bribe - condition: - min_credits: 500 - - text: "Take me to jail." - goto: jail - - text: "You'll have to catch me first!" - goto: fight - - text: "I can't afford that..." - goto: jail - condition: - min_credits: 500 - not: true - bribe: - message: "Smart choice. Hand over 500 credits and we'll forget this happened." - action: - cost: 500 - set_player_flags: - bribed_guard: true - options: - - text: "Fine, take it." - end: true - jail: - message: "Off to the detention cell with you!" - action: - teleport: 152 - set_player_flags: - been_to_jail: true - options: - - text: "(You are dragged away)" - end: true - fight: - message: "Then defend yourself!" - action: - set_flags: - guard_hostile: true - options: - - text: "(The guard attacks!)" - end: true -``` - -**Fight option handling:** When the `fight` node ends and `guard_hostile` flag is set, the guard should attack the player. This is handled in a post-talk hook. After the talk ends (when the player selects the end option for the `fight` node), check the world flag `guard_hostile`. If set: - -1. Clear the flag immediately: `delete(g.WorldFlags, "guard_hostile")` -2. Find the guard mob in the room -3. If guard exists and is not protected for combat purposes: temporarily set `guard.Protected = false`, start combat via `g.startCombat(sess, p, guardMob)`, then restore `guard.Protected = true` afterward (or just leave it false during this combat). - -**Implementation note:** The existing talk system processes `NodeAction` fields automatically via `executeTalkAction`. The `teleport` action already works. The `cost` action deducts credits. The only custom behavior needed is the "fight" trigger. Since the talk system already handles `set_flags`, we need a post-talk check in `handleTalkInput` (in `internal/game/action_talk.go`) or in the talk end handler: - -Add to the end of talk processing (where `end: true` is handled), after executing the node action: - -```go -if g.WorldFlags["guard_hostile"] != nil { - delete(g.WorldFlags, "guard_hostile") - guardMob := g.findGuardInRoom(p.RoomID, "guard") - if guardMob != nil { - guardMob.Protected = false - g.startCombat(sess, p, guardMob) - } -} -``` - -This check goes in the talk-end code path in `action_talk.go` (or `game.go` where `handleTalkInput` processes choices). - ---- - -## 13. XP Table - -| Target | Thieving Level Required | XP per Steal | Steal Speed (ticks) | -|---|---|---|---| -| Man | 1 | 8 | 4 | -| Market Stall | 5 | 12 | 5 | -| Farmer | 10 | 15 | 4 | -| Bioengineer | 38 | 45 | 4 | - -These values are set in the mob/object YAML files via `steal_level`, `steal_xp`, and `steal_speed` fields. - -**XP progression reference (RSC table):** -- Level 1: 0 XP -- Level 10: 1,154 XP (~144 man steals) -- Level 38: 31,191 XP (~668 farmer steals from level 10) -- Level 50: 101,333 XP (~1,559 bioengineer steals from level 38) -- Level 99: 13,034,431 XP - ---- - -## 14. Drop Tables - -### `data/drops/credit_stick_drop.yaml` - -```yaml -id: credit_stick_drop -drops: - - item_id: credits - weight: 40 - quantity: 15 - - item_id: credits - weight: 30 - quantity: 30 - - item_id: credits - weight: 20 - quantity: 50 - - item_id: credits - weight: 8 - quantity: 100 - - item_id: credits - weight: 2 - quantity: 250 -``` - -### `data/drops/man_steal.yaml` - -```yaml -id: man_steal -drops: - - item_id: credit_stick - weight: 80 - quantity: 1 - - item_id: credits - weight: 20 - quantity: 5 -``` - -### `data/drops/farmer_steal.yaml` - -```yaml -id: farmer_steal -drops: - - item_id: potato_seed - weight: 30 - quantity: 1 - - item_id: onion_seed - weight: 25 - quantity: 1 - - item_id: cabbage_seed - weight: 20 - quantity: 1 - - item_id: tomato_seed - weight: 15 - quantity: 1 - - item_id: sweetcorn_seed - weight: 8 - quantity: 1 - - item_id: strawberry_seed - weight: 2 - quantity: 1 -``` - -### `data/drops/bioengineer_steal.yaml` - -```yaml -id: bioengineer_steal -drops: - - item_id: sweetcorn_seed - weight: 25 - quantity: 1 - - item_id: strawberry_seed - weight: 20 - quantity: 1 - - item_id: watermelon_seed - weight: 20 - quantity: 1 - - item_id: ranarr_seed - weight: 15 - quantity: 1 - - item_id: snapdragon_seed - weight: 12 - quantity: 1 - - item_id: torstol_seed - weight: 8 - quantity: 1 -``` - -### `data/drops/market_stall_steal.yaml` - -```yaml -id: market_stall_steal -drops: - - item_id: bread - weight: 40 - quantity: 1 - - item_id: apple - weight: 35 - quantity: 1 - - item_id: cheese - weight: 25 - quantity: 1 -``` - ---- - -## 15. Help Files - -### `data/help/steal.yaml` - -```yaml -name: "steal" -category: "Skills" -description: | - Steal from mobs or objects. - - Usage: steal [target] - - Attempts to pickpocket a mob or shoplift from an object. Requires - a minimum thieving level depending on the target. - - If there is only one stealable target in the room, you can type - just "steal". If there are multiple different targets, you must - specify: "steal man", "steal stall", "steal 2.man". - - On success, you receive a random item from the target's loot table - and gain thieving XP. The action repeats automatically until you - run out of inventory space or are interrupted. - - On failure against a mob, the mob turns hostile and attacks you. - - On failure against a guarded object (while the guard is watching), - the guard confronts you with options to pay a bribe, go to jail, - or fight. - - Use "sneak" to see when guards are watching or looking away. - - Aliases: thieve - - See also: help sneak, help thieving -``` - -### `data/help/sneak.yaml` - -```yaml -name: "sneak" -category: "Skills" -description: | - Toggle sneak mode on and off. - - Usage: sneak - - While sneaking, you receive messages telling you when guards are - watching or looking away from objects they protect. Use this - information to time your steals for when the guard is distracted. - - Stealing from a guarded object while the guard is looking away - has no penalty on failure. Stealing while the guard is watching - halves your success chance, and a failure causes the guard to - confront you. - - Sneak mode is lost when you disconnect. - - See also: help steal, help thieving -``` - -### `data/help/thieving.yaml` - -```yaml -name: "thieving" -category: "Skills" -description: | - Thieving lets you steal from mobs and objects for loot and XP. - - Targets: - Man - Level 1, 8 XP - Credit sticks - Market Stall - Level 5, 12 XP - Food items (guarded) - Farmer - Level 10, 15 XP - Low/mid seeds - Bioengineer - Level 38, 45 XP - Mid/high seeds - - Success chance increases with your thieving level relative to - the target's requirement. Failing against a mob starts combat. - Failing against a guarded object while the guard watches triggers - a confrontation (bribe, jail, or fight). - - Credit sticks obtained from stealing can be searched for credits. - - Commands: steal, sneak - - See also: help steal, help sneak -``` - ---- - -## Summary of All Changes - -### New Go files (2): -1. `internal/game/action_steal.go` — `doSteal`, `resolveStealTarget`, `startSteal`, `advanceSteal`, `findGuardInRoom`, `isGuardWatching`, `startStealGuardTalk`, helpers -2. `internal/game/cmd_sneak.go` — `doSneak`, `SneakTick` - -### Modified Go files (6): -1. `internal/player/player.go` — Add `Sneaking bool` and `SneakNotified map[string]bool` to Player struct -2. `internal/game/action_state.go` — Add `ActionStealing` constant and `Description()` case -3. `internal/game/action.go` — Add `"steal"/"thieve"` to `verbAliases` and `verbSkill`, add `"steal"` case to `AdvanceActions()` -4. `internal/game/game.go` — Add `"sneak"` to ClassInstant, `"steal"/"thieve"` to ClassActive, add cases to `executeCommand()`, add `ActionStealing` to persistent states in `ProcessQueuedCommands()`, add `guardWatchTimers` to Game struct -5. `internal/world/mob.go` — Add `StealTable`, `StealLevel`, `StealXP`, `StealSpeed` to `MobDef` and `MobInstance`, copy in spawn function -6. `internal/object/object.go` — Add `StealTable`, `StealLevel`, `StealXP`, `StealSpeed`, `GuardMob` to `ObjectDef` -7. `cmd/mud/main.go` — Add `g.SneakTick()` to tick subscriber -8. `internal/game/action_talk.go` — Add post-talk `guard_hostile` flag check for fight option - -### Modified YAML files (2): -1. `data/mobs/man.yaml` — Add steal fields -2. `data/rooms/100.yaml` — Add `south: 150` exit - -### New YAML files (24): -- 13 items: `credit_stick`, `bread`, `apple`, `cheese`, `potato_seed`, `onion_seed`, `cabbage_seed`, `tomato_seed`, `sweetcorn_seed`, `strawberry_seed`, `watermelon_seed`, `ranarr_seed`, `snapdragon_seed`, `torstol_seed` -- 2 mobs: `farmer`, `bioengineer` -- 1 object: `market_stall` -- 1 behavior: `stall_guard_talk` -- 4 drop tables: `credit_stick_drop`, `man_steal`, `farmer_steal`, `bioengineer_steal`, `market_stall_steal` -- 3 rooms: `150`, `151`, `152` -- 3 help files: `steal`, `sneak`, `thieving` |
