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