aboutsummaryrefslogtreecommitdiff
path: root/skill_plans/thieving.md
diff options
context:
space:
mode:
Diffstat (limited to 'skill_plans/thieving.md')
-rw-r--r--skill_plans/thieving.md1391
1 files changed, 1391 insertions, 0 deletions
diff --git a/skill_plans/thieving.md b/skill_plans/thieving.md
new file mode 100644
index 0000000..90398fc
--- /dev/null
+++ b/skill_plans/thieving.md
@@ -0,0 +1,1391 @@
+# 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`