diff options
Diffstat (limited to 'skill_plans/thieving.md')
| -rw-r--r-- | skill_plans/thieving.md | 1391 |
1 files changed, 0 insertions, 1391 deletions
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` |
