aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--AGENTS.md7
-rw-r--r--TODO.md33
-rw-r--r--cmd/mud/main.go1
-rw-r--r--data/behaviors/client_talk.yaml217
-rw-r--r--data/behaviors/general_store.yaml49
-rw-r--r--data/behaviors/stall_guard_talk.yaml36
-rw-r--r--data/behaviors/weapons_shop.yaml43
-rw-r--r--data/drops/bioengineer_steal.yaml20
-rw-r--r--data/drops/credit_stick_drop.yaml17
-rw-r--r--data/drops/farmer_steal.yaml20
-rw-r--r--data/drops/man_steal.yaml8
-rw-r--r--data/drops/market_stall_steal.yaml11
-rw-r--r--data/help/assassin.yaml27
-rw-r--r--data/help/shop.yaml20
-rw-r--r--data/help/sneak.yaml19
-rw-r--r--data/help/steal.yaml29
-rw-r--r--data/help/task.yaml13
-rw-r--r--data/help/thieving.yaml21
-rw-r--r--data/items/acid_vial.yaml6
-rw-r--r--data/items/apple.yaml8
-rw-r--r--data/items/cabbage_seed.yaml6
-rw-r--r--data/items/cheese.yaml8
-rw-r--r--data/items/chitin_plate.yaml6
-rw-r--r--data/items/circuit_board.yaml6
-rw-r--r--data/items/credit_stick.yaml9
-rw-r--r--data/items/ectoplasm.yaml6
-rw-r--r--data/items/insulated_gloves.yaml9
-rw-r--r--data/items/onion_seed.yaml6
-rw-r--r--data/items/potato_seed.yaml6
-rw-r--r--data/items/ranarr_seed.yaml6
-rw-r--r--data/items/salt.yaml6
-rw-r--r--data/items/slug_mucus.yaml6
-rw-r--r--data/items/snapdragon_seed.yaml6
-rw-r--r--data/items/spectral_visor.yaml9
-rw-r--r--data/items/strawberry_seed.yaml6
-rw-r--r--data/items/sweetcorn_seed.yaml6
-rw-r--r--data/items/tomato_seed.yaml6
-rw-r--r--data/items/torstol_seed.yaml6
-rw-r--r--data/items/watermelon_seed.yaml6
-rw-r--r--data/mobs/bioengineer.yaml44
-rw-r--r--data/mobs/client.yaml20
-rw-r--r--data/mobs/crawler.yaml32
-rw-r--r--data/mobs/drone.yaml32
-rw-r--r--data/mobs/farmer.yaml44
-rw-r--r--data/mobs/general_store_clerk.yaml24
-rw-r--r--data/mobs/man.yaml4
-rw-r--r--data/mobs/phantom.yaml32
-rw-r--r--data/mobs/slug.yaml27
-rw-r--r--data/mobs/weapons_smith.yaml24
-rw-r--r--data/objects/market_stall.yaml11
-rw-r--r--data/rooms/1.yaml1
-rw-r--r--data/rooms/100.yaml7
-rw-r--r--data/rooms/101.yaml8
-rw-r--r--data/rooms/102.yaml8
-rw-r--r--data/rooms/103.yaml8
-rw-r--r--data/rooms/104.yaml7
-rw-r--r--data/rooms/105.yaml8
-rw-r--r--data/rooms/106.yaml7
-rw-r--r--data/rooms/107.yaml8
-rw-r--r--data/rooms/108.yaml7
-rw-r--r--data/rooms/109.yaml7
-rw-r--r--data/rooms/110.yaml8
-rw-r--r--data/rooms/111.yaml9
-rw-r--r--data/rooms/112.yaml9
-rw-r--r--data/rooms/113.yaml7
-rw-r--r--data/rooms/115.yaml7
-rw-r--r--data/rooms/116.yaml8
-rw-r--r--data/rooms/117.yaml9
-rw-r--r--data/rooms/118.yaml8
-rw-r--r--data/rooms/119.yaml8
-rw-r--r--data/rooms/12.yaml2
-rw-r--r--data/rooms/120.yaml8
-rw-r--r--data/rooms/121.yaml9
-rw-r--r--data/rooms/122.yaml7
-rw-r--r--data/rooms/126.yaml8
-rw-r--r--data/rooms/127.yaml9
-rw-r--r--data/rooms/128.yaml9
-rw-r--r--data/rooms/129.yaml7
-rw-r--r--data/rooms/130.yaml8
-rw-r--r--data/rooms/131.yaml8
-rw-r--r--data/rooms/136.yaml8
-rw-r--r--data/rooms/137.yaml9
-rw-r--r--data/rooms/138.yaml9
-rw-r--r--data/rooms/139.yaml6
-rw-r--r--data/rooms/140.yaml7
-rw-r--r--data/rooms/141.yaml8
-rw-r--r--data/rooms/142.yaml7
-rw-r--r--data/rooms/143.yaml6
-rw-r--r--data/rooms/145.yaml6
-rw-r--r--data/rooms/146.yaml8
-rw-r--r--data/rooms/147.yaml8
-rw-r--r--data/rooms/148.yaml8
-rw-r--r--data/rooms/149.yaml8
-rw-r--r--data/rooms/150.yaml8
-rw-r--r--data/rooms/151.yaml8
-rw-r--r--data/rooms/152.yaml8
-rw-r--r--data/rooms/153.yaml8
-rw-r--r--data/rooms/154.yaml7
-rw-r--r--data/rooms/155.yaml7
-rw-r--r--data/rooms/156.yaml7
-rw-r--r--data/rooms/157.yaml7
-rw-r--r--data/rooms/158.yaml7
-rw-r--r--data/rooms/159.yaml7
-rw-r--r--data/rooms/160.yaml17
-rw-r--r--data/rooms/161.yaml13
-rw-r--r--data/rooms/162.yaml6
-rw-r--r--data/rooms/31.yaml1
-rw-r--r--data/rooms/50.yaml7
-rw-r--r--data/rooms/51.yaml10
-rw-r--r--data/rooms/52.yaml9
-rw-r--r--data/rooms/53.yaml9
-rw-r--r--data/rooms/54.yaml8
-rw-r--r--internal/action/behavior.go16
-rw-r--r--internal/combat/state.go7
-rw-r--r--internal/config/config.go2
-rw-r--r--internal/game/action.go6
-rw-r--r--internal/game/action_state.go3
-rw-r--r--internal/game/action_steal.go449
-rw-r--r--internal/game/action_talk.go47
-rw-r--r--internal/game/assassin.go284
-rw-r--r--internal/game/cmd_attack.go54
-rw-r--r--internal/game/cmd_shop.go295
-rw-r--r--internal/game/cmd_sneak.go71
-rw-r--r--internal/game/cmd_task.go34
-rw-r--r--internal/game/game.go30
-rw-r--r--internal/net/server.go2
-rw-r--r--internal/object/object.go5
-rw-r--r--internal/player/player.go2
-rw-r--r--internal/world/mob.go27
-rw-r--r--worldbuilding_guide/behaviors.md67
-rw-r--r--worldbuilding_guide/conditions.md4
-rw-r--r--worldbuilding_guide/mobs.md64
-rw-r--r--worldbuilding_guide/objects.md31
133 files changed, 2568 insertions, 432 deletions
diff --git a/AGENTS.md b/AGENTS.md
index 44b0556..a047bdc 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -67,6 +67,9 @@ internal/
| `cmd_stats.go` | doStats — equipment bonus totals, attack roll, max hit display |
| `cmd_clean.go` | doClean — background herb cleaning (Pharmacy) |
| `cmd_mix.go` | doMix — potion mixing via production system (Pharmacy) |
+| `cmd_shop.go` | handleShopInput — buy/sell/browse/leave in shop interface |
+| `action_steal.go` | doSteal, startSteal, advanceSteal — thieving action lifecycle |
+| `cmd_sneak.go` | doSneak, SneakTick — sneak toggle + guard watch notifications |
| `action_clean.go` | advanceClean — background clean action lifecycle |
| `equip_stats.go` | playerEquipBonuses — sums all equipped items into one ItemStats |
| `aggro.go` | checkAggro — aggressive mob attack on room enter |
@@ -276,6 +279,7 @@ The `verbSkill` map maps verb → skill name for default target resolution:
| cook | `action_production.go` | Recipe-driven cooking on fire/range stations |
| smelt | `action_production.go` | Recipe-driven smelting of ore into bars at furnace |
| smith | `action_production.go` | Recipe-driven smithing of bars into items at anvil (requires hammer) |
+| steal | `action_steal.go` | Thieving: pickpocket mobs, steal from objects, guard watching, sneak mode |
### GatherConfig Fields
@@ -316,6 +320,8 @@ clamped to [0, cfg.Cap]
| `take_item` | Removes an item from inventory |
| `teleport` | Moves player to a room ID |
| `heal` | Restores hitpoints |
+| `cost` | Deducts credits from player |
+| `shop` | Opens a buy/sell interface (see Shop section) |
### Condition System
@@ -520,6 +526,7 @@ From `internal/net/server.go`:
| `StateRecipeChoice` | Recipe/product selection table menu (number or name input, enter cancels) |
| `StateHowMany` | "How many?" production count prompt |
| `StateSmithProduct` | Smith product selection with numbered table (#, name, or partial name input) |
+| `StateShop` | Buy/sell/browse in shop (buy, sell, browse, leave commands) |
| `StateColorChoice` | New account color preference |
## Adding a New Skill
diff --git a/TODO.md b/TODO.md
index 67c77f1..61d2738 100644
--- a/TODO.md
+++ b/TODO.md
@@ -16,39 +16,12 @@ Detailed implementation plans for each skill/system are in `skill_plans/`.
- [x] Step 3: Combat Feel — Aggressive mobs (auto-attack on room enter, OSRS combat level rule), equipment skill requirements on all tiered gear
- [x] Step 4: Technology — 25 techs (attack/str/def/ranged/science boosts, 3 protection techs, utility techs), Battery drain, 1-tick flicking, tech command, TechTick, charging station, score/prompt integration
- [x] Step 5: Scavenging — mine_scrap behavior verified. Created 10 junk items (solar, hydro, cosmic, eco, chaos, bio, nature, law, death, blood — all stackable) with matching identifier tools and altar objects. `id`/`identify` command (Active): scans room for altar, checks for matching identifier in inventory, converts all scrap to junk on next tick with level-based multipliers, awards Scavenging XP. 10 altar rooms (150-159) branching from Scavenging Post (room 9). Help files created.
-- [x] Step 6: Science — 40 mods (20 combat, 2 processing, 3 utility, 7 transport, 4 enchant, 4 chip), ModDef/AllMods in science.go. `trigger`/`cast` command (Active). `autocast`/`auto` (Instant). `mods`/`modlist` (Instant). Autocast in startCombat with 5-tick science speed and melee fallback. `scienceAttack()` with elemental weakness +30%. Junk cost system with deck exemptions. `ProvidesJunk` on ItemDef, `AutocastMod` on Player, `FindByInput` on RecipeStore, `science_mod` color target. 9 deck items, 24 jewelry items, 8 bolt items, nutrient_bar. Help files created.
-
----
-
-## Step 5: Scavenging
-
-See `skill_plans/scavenging.md`. The simplest new skill — mine scrap (already exists), identify it at altars to produce junk. Must be implemented before Science since Science consumes junk.
-
-- [x] Step 5: Scavenging — mine_scrap behavior verified. Created 10 junk items (solar, hydro, cosmic, eco, chaos, bio, nature, law, death, blood — all stackable) with matching identifier tools and altar objects. `id`/`identify` command (Active): scans room for altar, checks for matching identifier in inventory, converts all scrap to junk on next tick with level-based multipliers, awards Scavenging XP. 10 altar rooms (150-159) branching from Scavenging Post (room 9). Help files created.
-
----
-
-## Step 6: Science (Magic System)
-
-See `skill_plans/science.md`. Players "trigger mods" (cast spells) powered by junk (runes). Decks (staves) provide unlimited supply of one junk type and remove scrap requirement. Depends on combat overhaul + scavenging.
-
- [x] Step 6: Science — 40 mods (20 combat, 2 processing, 3 utility, 7 transport, 4 enchant, 4 chip), ModDef/AllMods in science.go. `trigger`/`cast` command (Active) routes to combat/transport/processing/utility/enchant handlers. `autocast`/`auto` command (Instant) sets science combat autocast. `mods`/`modlist` command (Instant) shows available mods with effective junk costs. Integrated autocast into `startCombat()` in cmd_attack.go with 5-tick science speed and melee fallback on junk depletion. `scienceAttack()` with Science level + science_attack roll, elemental weakness +30% accuracy, mod MaxHit damage. Junk cost system: deck-equipped removes scrap_metal, elemental deck provides unlimited junk type. Added `ProvidesJunk` to ItemDef, `AutocastMod` to Player, `FindByInput` to RecipeStore, `science_mod` color target. 9 deck items, 24 jewelry items, 8 bolt items, nutrient_bar item. Help files created.
+- [x] Step 7: Shop System — Added `shop` talk node action that opens a buy/sell interface. `ShopConfig` with items + buy/sell prices defined in behavior YAML. `StateShop` session state with `buy`, `sell`, `browse`/`list`, `leave` commands. Items bought with credits, sold back at reduced prices. Created General Store clerk (Town Square) and Weapons Smith (Forge) NPCs. Created help files and worldbuilding guide docs.
---
-## Step 7: Shop System
-
-Originally Phase 2, but needed before Assassin and generally useful. Add a buy/sell interface via talk node actions.
-
-- [ ] **Shop System** — Add a `shop` talk node action that opens a buy/sell interface. The shop inventory is defined in the behavior YAML as a list of items with buy/sell prices. Player enters `StateShop`. Commands: `buy <item>`, `sell <item>`, `browse`/`list`, `leave`. Items are bought with credits, sold back at a percentage. Add a General Store NPC and a Weapons Shop NPC. Place in appropriate rooms.
-
----
-
-## Step 8: Thieving
-
-See `skill_plans/thieving.md`. Steal from mobs and objects. Sneak mode, guard mechanics.
-
-- [ ] **Thieving** — Add `steal`/`thieve` command (Active) and `sneak` command (Instant toggle). Stealing from mobs: roll success based on Thieving level vs mob's thieving level requirement. On fail, mob turns hostile (starts combat). Stealing from objects: market stall gives random low-value food. Guard watching mechanic: mobs can guard objects, `sneak` mode shows guard attention cycle, lower success when watched, guard called on watched failure. Create credit_stick item (non-stackable, searchable for credits). Create Farmer and Bioengineer mobs (steal seeds from them). Add guard confrontation dialog (bribe/jail/fight). Create jail room. Update Man mob to be stealable.
+- [x] Step 8: Thieving — `steal`/`thieve` command (Active) with continuous steal action. `sneak` command (Instant toggle) with guard-watching notifications. Success formula: 0.5 base + 0.03 per level, halved when guard watches. Mob stealing with aggro on failure. Object stealing with guard mechanic (SneakTick, watch/away cycles). Guard confrontation dialog (bribe 500cr, jail, fight). Created Market Stall object, Farmer + Bioengineer mobs, credit_stick item (searchable), 11 seed items, 5 new drop tables, 3 new rooms (160-162), 3 help files. Updated Man mob for stealability.
---
@@ -56,7 +29,7 @@ See `skill_plans/thieving.md`. Steal from mobs and objects. Sneak mode, guard me
See `skill_plans/assassin.md`. Task system, reputation, slayer-only mobs. Depends on combat being solid and having enough mobs to assign as tasks.
-- [ ] **Assassin System** — Add `task` command (Instant) to check current assignment. Create Client NPC with talk behavior: assigns random mob kill tasks, skip/extend via reputation, reputation shop. Store task state in player flags (task_mob, task_count, task_remaining, tasks_completed, streak, reputation). In `endCombat()`, check if killed mob is on-task and award Assassin XP + reputation. Streak bonuses at 10th/50th/100th/250th/1000th task. Create 4+ slayer-only mobs with assassin_level requirements, finishing blow items (slug+salt, crawler+acid_vial), and damage-without equipment (drone needs insulated_gloves, phantom needs spectral_visor). Add assassin equipment items (cheap, sold by Client). Place slayer mobs in appropriate rooms.
+- [x] **Assassin System** — Added `task` command (Instant) to check current assignment. Created Client NPC with talk behavior: assigns random mob kill tasks, skip/extend via reputation, reputation shop. Task state stored in player flags (task_mob, task_count, task_remaining, tasks_completed, streak, reputation). In `endCombat()`, on-task kills award Assassin XP (mob.MaxHP * 2) + reputation. Streak bonuses at 10th/50th/100th/250th/1000th task. Created 4 slayer-only mobs (slug/drone/crawler/phantom) with assassin_level requirements, finishing blow items (slug+salt, crawler+acid_vial), and damage-without equipment (drone needs insulated_gloves, phantom needs spectral_visor). Added assassin equipment items (salt, acid_vial, insulated_gloves, spectral_visor) sold by Client. Added 5 new rooms (50-54: Assassin Den + Sewer Tunnels chain). Created help files (task, assassin).
---
diff --git a/cmd/mud/main.go b/cmd/mud/main.go
index 55804d3..82427ba 100644
--- a/cmd/mud/main.go
+++ b/cmd/mud/main.go
@@ -54,6 +54,7 @@ func main() {
g.ConsumeTick()
g.BroadcastRespawns()
g.VisualTick()
+ g.SneakTick()
return true
})
diff --git a/data/behaviors/client_talk.yaml b/data/behaviors/client_talk.yaml
new file mode 100644
index 0000000..84e4f6a
--- /dev/null
+++ b/data/behaviors/client_talk.yaml
@@ -0,0 +1,217 @@
+id: client_talk
+type: talk
+nodes:
+ start:
+ message: "The Client looks up from their data pad. \"What do you need?\""
+ options:
+ - text: "\"I need a job.\""
+ goto: assign_task
+ condition:
+ player_flag: assassin_task_mob
+ not: true
+ - text: "\"What's my current task?\""
+ goto: current_task
+ condition:
+ player_flag: assassin_task_mob
+ not: false
+ - text: "\"I want to skip my task.\""
+ goto: skip_confirm
+ condition:
+ player_flag: assassin_task_mob
+ not: false
+ - text: "\"I want to extend my task.\""
+ goto: extend_confirm
+ condition:
+ all_of:
+ - player_flag: assassin_task_mob
+ not: false
+ - player_flag: assassin_unlocked_extend
+ value: true
+ - text: "\"I'd like to browse the Reputation Shop.\""
+ goto: rep_shop
+ - text: "\"I need supplies.\""
+ goto: supplies
+ - text: "\"Goodbye.\""
+ end: true
+
+ assign_task:
+ message: "\"Let me check what's available...\" The Client scrolls through their data pad."
+ action:
+ assign_task: true
+ options:
+ - text: "\"Understood.\""
+ end: true
+ - text: "\"What else do you have?\""
+ goto: start
+
+ current_task:
+ message: "\"Let me check your file.\" The Client pulls up your record."
+ options:
+ - text: "\"Okay.\""
+ end: true
+
+ skip_confirm:
+ message: "\"Skipping a task costs 30 Reputation and resets your streak. Are you sure?\""
+ options:
+ - text: "\"Yes, skip it.\""
+ goto: skip_done
+ - text: "\"Never mind.\""
+ goto: start
+
+ skip_done:
+ message: "\"Task cancelled. Your streak has been reset.\""
+ action:
+ skip_task: true
+ options:
+ - text: "\"Give me a new one.\""
+ goto: assign_task
+ - text: "\"Goodbye.\""
+ end: true
+
+ extend_confirm:
+ message: "\"Extending your task costs 30 Reputation and adds more kills. Want to proceed?\""
+ options:
+ - text: "\"Yes, extend it.\""
+ goto: extend_done
+ - text: "\"Never mind.\""
+ goto: start
+
+ extend_done:
+ message: "\"Done. I've added more targets to your contract.\""
+ action:
+ extend_task: true
+ options:
+ - text: "\"Thanks.\""
+ end: true
+
+ rep_shop:
+ message: "\"Here's what I've got. All purchases are permanent.\""
+ options:
+ - text: "\"Auto-finish: Salt (200 Rep) - Never consume salt on finishing blows.\""
+ goto: buy_auto_salt
+ condition:
+ player_flag: assassin_unlocked_auto_salt
+ not: true
+ - text: "\"Auto-finish: Acid Vial (200 Rep) - Never consume acid vials on finishing blows.\""
+ goto: buy_auto_acid
+ condition:
+ player_flag: assassin_unlocked_auto_acid_vial
+ not: true
+ - text: "\"Unlock Superior Mobs (300 Rep) - Rare superior variants may spawn.\""
+ goto: buy_superiors
+ condition:
+ player_flag: assassin_unlocked_superiors
+ not: true
+ - text: "\"Unlock Extended Tasks (100 Rep) - Allows extending tasks.\""
+ goto: buy_extend_unlock
+ condition:
+ player_flag: assassin_unlocked_extend
+ not: true
+ - text: "\"Back.\""
+ goto: start
+
+ buy_auto_salt:
+ message: "\"Auto-salt purchased. You'll no longer consume salt on finishing blows.\""
+ action:
+ reputation_cost: 200
+ set_player_flags:
+ assassin_unlocked_auto_salt: true
+ options:
+ - text: "\"Thanks.\""
+ goto: rep_shop
+
+ buy_auto_acid:
+ message: "\"Auto-acid purchased. Acid vials will no longer be consumed.\""
+ action:
+ reputation_cost: 200
+ set_player_flags:
+ assassin_unlocked_auto_acid_vial: true
+ options:
+ - text: "\"Thanks.\""
+ goto: rep_shop
+
+ buy_superiors:
+ message: "\"Superior encounters unlocked. Watch yourself out there.\""
+ action:
+ reputation_cost: 300
+ set_player_flags:
+ assassin_unlocked_superiors: true
+ options:
+ - text: "\"Thanks.\""
+ goto: rep_shop
+
+ buy_extend_unlock:
+ message: "\"You can now extend tasks via our conversation.\""
+ action:
+ reputation_cost: 100
+ set_player_flags:
+ assassin_unlocked_extend: true
+ options:
+ - text: "\"Thanks.\""
+ goto: rep_shop
+
+ supplies:
+ message: "\"I stock everything you need for the job. Cheap, too.\""
+ options:
+ - text: "\"Buy salt (5 credits each).\""
+ goto: buy_salt
+ condition:
+ min_credits: 5
+ - text: "\"Buy acid vial (10 credits each).\""
+ goto: buy_acid
+ condition:
+ min_credits: 10
+ - text: "\"Buy insulated gloves (50 credits).\""
+ goto: buy_gloves
+ condition:
+ min_credits: 50
+ - text: "\"Buy spectral visor (75 credits).\""
+ goto: buy_visor
+ condition:
+ min_credits: 75
+ - text: "\"Back.\""
+ goto: start
+
+ buy_salt:
+ message: "\"Here you go.\" The Client slides a packet of salt across the table."
+ action:
+ give_item: salt
+ cost: 5
+ options:
+ - text: "\"Buy more.\""
+ goto: buy_salt
+ condition:
+ min_credits: 5
+ - text: "\"Thanks.\""
+ goto: supplies
+
+ buy_acid:
+ message: "\"Handle with care.\" The Client passes you a vial of corrosive acid."
+ action:
+ give_item: acid_vial
+ cost: 10
+ options:
+ - text: "\"Buy more.\""
+ goto: buy_acid
+ condition:
+ min_credits: 10
+ - text: "\"Thanks.\""
+ goto: supplies
+
+ buy_gloves:
+ message: "\"These'll keep the current from frying your hands.\" The Client tosses you a pair of thick rubber gloves."
+ action:
+ give_item: insulated_gloves
+ cost: 50
+ options:
+ - text: "\"Thanks.\""
+ goto: supplies
+
+ buy_visor:
+ message: "\"Spectral frequency filter. Makes the invisible visible — and keeps their attacks from scrambling your brain.\""
+ action:
+ give_item: spectral_visor
+ cost: 75
+ options:
+ - text: "\"Thanks.\""
+ goto: supplies
diff --git a/data/behaviors/general_store.yaml b/data/behaviors/general_store.yaml
new file mode 100644
index 0000000..657e842
--- /dev/null
+++ b/data/behaviors/general_store.yaml
@@ -0,0 +1,49 @@
+id: general_store
+type: talk
+nodes:
+ start:
+ message: "\"Welcome to the General Store! Need any supplies?\""
+ options:
+ - text: "\"I'd like to browse.\""
+ goto: shop
+ - text: "\"Goodbye.\""
+ end: true
+ shop:
+ message: "\"Take your time. What can I get you?\""
+ action:
+ shop:
+ message: "What would you like to buy or sell?"
+ items:
+ - item_id: fishing_rod
+ buy_price: 10
+ sell_price: 2
+ - item_id: fishing_bait
+ buy_price: 2
+ sell_price: 0
+ - item_id: matches
+ buy_price: 10
+ sell_price: 2
+ - item_id: firesteel
+ buy_price: 40
+ sell_price: 10
+ - item_id: lighter
+ buy_price: 30
+ sell_price: 7
+ - item_id: hammer
+ buy_price: 20
+ sell_price: 5
+ - item_id: knife
+ buy_price: 10
+ sell_price: 2
+ - item_id: chisel
+ buy_price: 10
+ sell_price: 2
+ - item_id: bread
+ buy_price: 10
+ sell_price: 2
+ - item_id: bowstring
+ buy_price: 20
+ sell_price: 5
+ options:
+ - text: "\"I'm done.\""
+ goto: start
diff --git a/data/behaviors/stall_guard_talk.yaml b/data/behaviors/stall_guard_talk.yaml
new file mode 100644
index 0000000..c9434af
--- /dev/null
+++ b/data/behaviors/stall_guard_talk.yaml
@@ -0,0 +1,36 @@
+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
+ bribe:
+ message: "Smart choice. Hand over 500 credits and we'll forget this happened."
+ action:
+ cost: 500
+ options:
+ - text: "\"Fine, take it.\""
+ end: true
+ jail:
+ message: "Off to the detention cell with you!"
+ action:
+ teleport: 162
+ 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 \ No newline at end of file
diff --git a/data/behaviors/weapons_shop.yaml b/data/behaviors/weapons_shop.yaml
new file mode 100644
index 0000000..d12a4e6
--- /dev/null
+++ b/data/behaviors/weapons_shop.yaml
@@ -0,0 +1,43 @@
+id: weapons_shop
+type: talk
+nodes:
+ start:
+ message: "\"Welcome to the Smithy! Finest weapons and armor on the asteroid.\""
+ options:
+ - text: "\"Show me what you've got.\""
+ goto: shop
+ - text: "\"Nothing right now.\""
+ end: true
+ shop:
+ message: "\"Here's the current inventory. All quality guaranteed.\""
+ action:
+ shop:
+ message: "The Smithy — buy or sell weapons and armor."
+ items:
+ - item_id: bronze_sword
+ buy_price: 25
+ sell_price: 6
+ - item_id: iron_sword
+ buy_price: 60
+ sell_price: 15
+ - item_id: bronze_axe
+ buy_price: 20
+ sell_price: 5
+ - item_id: iron_axe
+ buy_price: 50
+ sell_price: 12
+ - item_id: bronze_pickaxe
+ buy_price: 20
+ sell_price: 5
+ - item_id: iron_pickaxe
+ buy_price: 50
+ sell_price: 12
+ - item_id: bronze_med_helm
+ buy_price: 16
+ sell_price: 4
+ - item_id: iron_med_helm
+ buy_price: 40
+ sell_price: 10
+ options:
+ - text: "\"That's all for now.\""
+ goto: start \ No newline at end of file
diff --git a/data/drops/bioengineer_steal.yaml b/data/drops/bioengineer_steal.yaml
new file mode 100644
index 0000000..3e5e7ce
--- /dev/null
+++ b/data/drops/bioengineer_steal.yaml
@@ -0,0 +1,20 @@
+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 \ No newline at end of file
diff --git a/data/drops/credit_stick_drop.yaml b/data/drops/credit_stick_drop.yaml
new file mode 100644
index 0000000..d78dc57
--- /dev/null
+++ b/data/drops/credit_stick_drop.yaml
@@ -0,0 +1,17 @@
+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 \ No newline at end of file
diff --git a/data/drops/farmer_steal.yaml b/data/drops/farmer_steal.yaml
new file mode 100644
index 0000000..e64f245
--- /dev/null
+++ b/data/drops/farmer_steal.yaml
@@ -0,0 +1,20 @@
+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 \ No newline at end of file
diff --git a/data/drops/man_steal.yaml b/data/drops/man_steal.yaml
new file mode 100644
index 0000000..d0ec45f
--- /dev/null
+++ b/data/drops/man_steal.yaml
@@ -0,0 +1,8 @@
+id: man_steal
+drops:
+ - item_id: credit_stick
+ weight: 80
+ quantity: 1
+ - item_id: credits
+ weight: 20
+ quantity: 5 \ No newline at end of file
diff --git a/data/drops/market_stall_steal.yaml b/data/drops/market_stall_steal.yaml
new file mode 100644
index 0000000..e45c878
--- /dev/null
+++ b/data/drops/market_stall_steal.yaml
@@ -0,0 +1,11 @@
+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 \ No newline at end of file
diff --git a/data/help/assassin.yaml b/data/help/assassin.yaml
new file mode 100644
index 0000000..60508e0
--- /dev/null
+++ b/data/help/assassin.yaml
@@ -0,0 +1,27 @@
+name: "assassin"
+category: "Skills"
+description: |
+ The Assassin skill (Slayer equivalent).
+
+ Talk to The Client in The Assassin's Den to receive tasks. Each task
+ assigns you a number of specific mobs to kill. Killing mobs on-task
+ awards Assassin XP (mob's max HP x 2) in addition to normal combat XP.
+
+ Some mobs require a minimum Assassin level to attack:
+ Slug - Level 1 (needs salt to finish off)
+ Drone - Level 15 (extra damage without insulated gloves)
+ Crawler - Level 30 (needs acid vial to finish off)
+ Phantom - Level 45 (extra damage without spectral visor)
+
+ Finishing Blow: Some mobs cannot be killed below 1 HP. Use the
+ required item on them during combat: "use salt on slug"
+
+ Protection: Some mobs deal 1.5x damage unless you have the
+ required protective item equipped.
+
+ Reputation is earned on task completion (1 per task + streak bonuses
+ at 10th, 50th, 100th, 250th, and 1000th consecutive tasks).
+ Spend Reputation at The Client's Reputation Shop for permanent
+ unlocks and task management options.
+
+ Related: task, attack, use
diff --git a/data/help/shop.yaml b/data/help/shop.yaml
new file mode 100644
index 0000000..a0fd187
--- /dev/null
+++ b/data/help/shop.yaml
@@ -0,0 +1,20 @@
+name: "shop"
+category: "Interaction"
+description: |
+ Buy and sell items at shops.
+
+ Find a shopkeeper NPC and talk to them to open their shop. While
+ browsing a shop, these commands are available:
+
+ browse List all items for sale with buy/sell prices
+ list Same as browse
+ buy <item> Purchase an item with credits (use # to buy by number)
+ sell <item> Sell an item from your inventory for credits
+ leave Exit the shop and return to conversation
+
+ Shops buy items at a lower price than they sell them. Some items
+ cannot be sold back.
+
+ Shopkeepers can be found throughout the world:
+ - General Store: Town Square (sells tools and supplies)
+ - Smithy: Forge (sells weapons, armor, and tools) \ No newline at end of file
diff --git a/data/help/sneak.yaml b/data/help/sneak.yaml
new file mode 100644
index 0000000..b10866c
--- /dev/null
+++ b/data/help/sneak.yaml
@@ -0,0 +1,19 @@
+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 \ No newline at end of file
diff --git a/data/help/steal.yaml b/data/help/steal.yaml
new file mode 100644
index 0000000..dc12a2f
--- /dev/null
+++ b/data/help/steal.yaml
@@ -0,0 +1,29 @@
+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 \ No newline at end of file
diff --git a/data/help/task.yaml b/data/help/task.yaml
new file mode 100644
index 0000000..2b38e04
--- /dev/null
+++ b/data/help/task.yaml
@@ -0,0 +1,13 @@
+name: "task"
+category: "Assassin"
+description: |
+ Check your current Assassin task status.
+
+ Usage: task
+
+ Displays your current task target, kills remaining, streak count,
+ and unspent Reputation points. If you have no active task, visit
+ The Client to receive a new assignment.
+
+ The Client can be found in The Assassin's Den, accessible from
+ the lower levels of the settlement.
diff --git a/data/help/thieving.yaml b/data/help/thieving.yaml
new file mode 100644
index 0000000..e5aea10
--- /dev/null
+++ b/data/help/thieving.yaml
@@ -0,0 +1,21 @@
+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 \ No newline at end of file
diff --git a/data/items/acid_vial.yaml b/data/items/acid_vial.yaml
new file mode 100644
index 0000000..7a49e2d
--- /dev/null
+++ b/data/items/acid_vial.yaml
@@ -0,0 +1,6 @@
+id: acid_vial
+name: acid vial
+color: "46"
+description: "A small vial of concentrated acid. Used to dissolve crawlers."
+value: 10
+stackable: true
diff --git a/data/items/apple.yaml b/data/items/apple.yaml
new file mode 100644
index 0000000..10c09c1
--- /dev/null
+++ b/data/items/apple.yaml
@@ -0,0 +1,8 @@
+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." \ No newline at end of file
diff --git a/data/items/cabbage_seed.yaml b/data/items/cabbage_seed.yaml
new file mode 100644
index 0000000..35ebee3
--- /dev/null
+++ b/data/items/cabbage_seed.yaml
@@ -0,0 +1,6 @@
+id: cabbage_seed
+name: cabbage seed
+color: "34"
+description: "A seed for growing cabbages."
+value: 4
+stackable: true \ No newline at end of file
diff --git a/data/items/cheese.yaml b/data/items/cheese.yaml
new file mode 100644
index 0000000..79d9fe8
--- /dev/null
+++ b/data/items/cheese.yaml
@@ -0,0 +1,8 @@
+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." \ No newline at end of file
diff --git a/data/items/chitin_plate.yaml b/data/items/chitin_plate.yaml
new file mode 100644
index 0000000..4148a2a
--- /dev/null
+++ b/data/items/chitin_plate.yaml
@@ -0,0 +1,6 @@
+id: chitin_plate
+name: chitin plate
+color: "130"
+description: "A thick plate of biological armor from a crawler's exoskeleton."
+value: 50
+stackable: false
diff --git a/data/items/circuit_board.yaml b/data/items/circuit_board.yaml
new file mode 100644
index 0000000..5b4db83
--- /dev/null
+++ b/data/items/circuit_board.yaml
@@ -0,0 +1,6 @@
+id: circuit_board
+name: circuit board
+color: "40"
+description: "A scorched circuit board salvaged from a destroyed drone."
+value: 25
+stackable: false
diff --git a/data/items/credit_stick.yaml b/data/items/credit_stick.yaml
new file mode 100644
index 0000000..46b3050
--- /dev/null
+++ b/data/items/credit_stick.yaml
@@ -0,0 +1,9 @@
+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" \ No newline at end of file
diff --git a/data/items/ectoplasm.yaml b/data/items/ectoplasm.yaml
new file mode 100644
index 0000000..8417e2d
--- /dev/null
+++ b/data/items/ectoplasm.yaml
@@ -0,0 +1,6 @@
+id: ectoplasm
+name: ectoplasm
+color: "159"
+description: "A shimmering residue left behind by a destroyed phantom."
+value: 75
+stackable: false
diff --git a/data/items/insulated_gloves.yaml b/data/items/insulated_gloves.yaml
new file mode 100644
index 0000000..3e53614
--- /dev/null
+++ b/data/items/insulated_gloves.yaml
@@ -0,0 +1,9 @@
+id: insulated_gloves
+name: insulated gloves
+color: "214"
+description: "Heavy rubber gloves that protect against electrical attacks. Essential when fighting drones."
+value: 50
+stackable: false
+equip_slot: hands
+stats:
+ defense_bonus: 1
diff --git a/data/items/onion_seed.yaml b/data/items/onion_seed.yaml
new file mode 100644
index 0000000..fbf6a8a
--- /dev/null
+++ b/data/items/onion_seed.yaml
@@ -0,0 +1,6 @@
+id: onion_seed
+name: onion seed
+color: "229"
+description: "A seed for growing onions."
+value: 3
+stackable: true \ No newline at end of file
diff --git a/data/items/potato_seed.yaml b/data/items/potato_seed.yaml
new file mode 100644
index 0000000..8cc4669
--- /dev/null
+++ b/data/items/potato_seed.yaml
@@ -0,0 +1,6 @@
+id: potato_seed
+name: potato seed
+color: "94"
+description: "A seed for growing potatoes."
+value: 2
+stackable: true \ No newline at end of file
diff --git a/data/items/ranarr_seed.yaml b/data/items/ranarr_seed.yaml
new file mode 100644
index 0000000..af464ec
--- /dev/null
+++ b/data/items/ranarr_seed.yaml
@@ -0,0 +1,6 @@
+id: ranarr_seed
+name: ranarr seed
+color: "28"
+description: "A rare herb seed with potent alchemical properties."
+value: 500
+stackable: true \ No newline at end of file
diff --git a/data/items/salt.yaml b/data/items/salt.yaml
new file mode 100644
index 0000000..d9c1b2a
--- /dev/null
+++ b/data/items/salt.yaml
@@ -0,0 +1,6 @@
+id: salt
+name: salt
+color: "255"
+description: "A packet of coarse industrial salt. Used to destroy slugs."
+value: 5
+stackable: true
diff --git a/data/items/slug_mucus.yaml b/data/items/slug_mucus.yaml
new file mode 100644
index 0000000..620038a
--- /dev/null
+++ b/data/items/slug_mucus.yaml
@@ -0,0 +1,6 @@
+id: slug_mucus
+name: slug mucus
+color: "82"
+description: "A glob of toxic slug mucus. Unpleasant."
+value: 5
+stackable: false
diff --git a/data/items/snapdragon_seed.yaml b/data/items/snapdragon_seed.yaml
new file mode 100644
index 0000000..3ec440d
--- /dev/null
+++ b/data/items/snapdragon_seed.yaml
@@ -0,0 +1,6 @@
+id: snapdragon_seed
+name: snapdragon seed
+color: "92"
+description: "An extremely rare herb seed. Highly valued by alchemists."
+value: 1500
+stackable: true \ No newline at end of file
diff --git a/data/items/spectral_visor.yaml b/data/items/spectral_visor.yaml
new file mode 100644
index 0000000..851a929
--- /dev/null
+++ b/data/items/spectral_visor.yaml
@@ -0,0 +1,9 @@
+id: spectral_visor
+name: spectral visor
+color: "141"
+description: "A visor fitted with spectral frequency filters. Dampens psychic attacks from phantoms."
+value: 75
+stackable: false
+equip_slot: head
+stats:
+ defense_bonus: 2
diff --git a/data/items/strawberry_seed.yaml b/data/items/strawberry_seed.yaml
new file mode 100644
index 0000000..bcf8ddc
--- /dev/null
+++ b/data/items/strawberry_seed.yaml
@@ -0,0 +1,6 @@
+id: strawberry_seed
+name: strawberry seed
+color: "197"
+description: "A seed for growing strawberries."
+value: 40
+stackable: true \ No newline at end of file
diff --git a/data/items/sweetcorn_seed.yaml b/data/items/sweetcorn_seed.yaml
new file mode 100644
index 0000000..56bc587
--- /dev/null
+++ b/data/items/sweetcorn_seed.yaml
@@ -0,0 +1,6 @@
+id: sweetcorn_seed
+name: sweetcorn seed
+color: "226"
+description: "A seed for growing sweetcorn."
+value: 25
+stackable: true \ No newline at end of file
diff --git a/data/items/tomato_seed.yaml b/data/items/tomato_seed.yaml
new file mode 100644
index 0000000..622db9b
--- /dev/null
+++ b/data/items/tomato_seed.yaml
@@ -0,0 +1,6 @@
+id: tomato_seed
+name: tomato seed
+color: "196"
+description: "A seed for growing tomatoes."
+value: 8
+stackable: true \ No newline at end of file
diff --git a/data/items/torstol_seed.yaml b/data/items/torstol_seed.yaml
new file mode 100644
index 0000000..5db6f9d
--- /dev/null
+++ b/data/items/torstol_seed.yaml
@@ -0,0 +1,6 @@
+id: torstol_seed
+name: torstol seed
+color: "46"
+description: "The rarest of herb seeds. Worth a small fortune."
+value: 5000
+stackable: true \ No newline at end of file
diff --git a/data/items/watermelon_seed.yaml b/data/items/watermelon_seed.yaml
new file mode 100644
index 0000000..adfecc2
--- /dev/null
+++ b/data/items/watermelon_seed.yaml
@@ -0,0 +1,6 @@
+id: watermelon_seed
+name: watermelon seed
+color: "34"
+description: "A seed for growing watermelons."
+value: 80
+stackable: true \ No newline at end of file
diff --git a/data/mobs/bioengineer.yaml b/data/mobs/bioengineer.yaml
new file mode 100644
index 0000000..a6f2823
--- /dev/null
+++ b/data/mobs/bioengineer.yaml
@@ -0,0 +1,44 @@
+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
+attack_type: crush
+stab_defense: 0
+slash_defense: 0
+crush_defense: 0
+science_defense: 0
+ranged_defense: 0
+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 \ No newline at end of file
diff --git a/data/mobs/client.yaml b/data/mobs/client.yaml
new file mode 100644
index 0000000..9bcae4e
--- /dev/null
+++ b/data/mobs/client.yaml
@@ -0,0 +1,20 @@
+id: client
+name: The Client
+description: "A shadowy figure in a dark coat. They speak in clipped, measured tones and seem to know everything about every creature in the sector."
+unique: true
+protected: true
+behavior: client_talk
+attack: 1
+strength: 1
+defense: 1
+hp: 100
+speed: 5
+aggressive: false
+respawn_ticks: 30
+idle_descriptions:
+ - "reviews a holographic dossier"
+ - "marks a location on a worn star chart"
+ - "flips a credit chit between their fingers"
+ - "mutters coordinates under their breath"
+ - "glances at you appraisingly"
+ - "taps a data pad with a stylus"
diff --git a/data/mobs/crawler.yaml b/data/mobs/crawler.yaml
new file mode 100644
index 0000000..ace034b
--- /dev/null
+++ b/data/mobs/crawler.yaml
@@ -0,0 +1,32 @@
+id: crawler
+name: crawler
+description: "A heavily armored bio-mechanical creature with a chitinous exoskeleton. Its regenerative biology prevents death unless dissolved with acid."
+assassin_level: 30
+finishing_blow: acid_vial
+attack: 25
+strength: 22
+defense: 30
+hp: 80
+speed: 5
+aggressive: false
+respawn_ticks: 45
+idle_descriptions:
+ - "scrapes its mandibles together menacingly"
+ - "clicks and chitters in an alien rhythm"
+ - "tests the air with feathered antennae"
+ - "coils its segmented body defensively"
+combat_descriptions:
+ - "snaps its mandibles at %s"
+ - "lashes out with a barbed tail at %s"
+ - "charges headlong into %s"
+drops:
+ remains: chitin_plate
+ loot:
+ - item_id: credits
+ weight: 70
+ quantity: 250
+ - item_id: acid_vial
+ weight: 10
+ - item_id: credits
+ weight: 20
+ quantity: 500
diff --git a/data/mobs/drone.yaml b/data/mobs/drone.yaml
new file mode 100644
index 0000000..4ceafc0
--- /dev/null
+++ b/data/mobs/drone.yaml
@@ -0,0 +1,32 @@
+id: drone
+name: drone
+description: "A malfunctioning security drone crackling with electrical discharge. Its attacks are especially dangerous to anyone not wearing insulated gloves."
+assassin_level: 15
+damage_without: insulated_gloves
+attack: 15
+strength: 14
+defense: 12
+hp: 45
+speed: 4
+aggressive: true
+respawn_ticks: 35
+idle_descriptions:
+ - "hovers erratically, sparking"
+ - "emits a high-pitched whine"
+ - "scans the area with a flickering red beam"
+ - "rotates its weapon array with a mechanical click"
+combat_descriptions:
+ - "fires an electrical bolt at %s"
+ - "charges its capacitors and zaps %s"
+ - "swoops down on %s with crackling energy"
+drops:
+ remains: circuit_board
+ loot:
+ - item_id: credits
+ weight: 80
+ quantity: 75
+ - item_id: insulated_gloves
+ weight: 5
+ - item_id: credits
+ weight: 15
+ quantity: 200
diff --git a/data/mobs/farmer.yaml b/data/mobs/farmer.yaml
new file mode 100644
index 0000000..7b12b9f
--- /dev/null
+++ b/data/mobs/farmer.yaml
@@ -0,0 +1,44 @@
+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
+attack_type: crush
+stab_defense: 0
+slash_defense: 0
+crush_defense: 0
+science_defense: 0
+ranged_defense: 0
+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 \ No newline at end of file
diff --git a/data/mobs/general_store_clerk.yaml b/data/mobs/general_store_clerk.yaml
new file mode 100644
index 0000000..842a98b
--- /dev/null
+++ b/data/mobs/general_store_clerk.yaml
@@ -0,0 +1,24 @@
+id: general_store_clerk
+name: Shopkeeper
+description: "A friendly merchant with a tidy apron, standing behind a well-stocked counter."
+behavior: general_store
+unique: true
+protected: true
+hp: 50
+attack: 1
+strength: 1
+defense: 1
+speed: 5
+aggressive: false
+respawn_ticks: 30
+attack_type: crush
+stab_defense: 0
+slash_defense: 0
+crush_defense: 0
+science_defense: 0
+ranged_defense: 0
+idle_descriptions:
+ - "arranges items on the shelves"
+ - "polishes the counter"
+ - "counts credits in the register"
+ - "greets a passerby with a nod" \ No newline at end of file
diff --git a/data/mobs/man.yaml b/data/mobs/man.yaml
index fe60cbd..c65f85c 100644
--- a/data/mobs/man.yaml
+++ b/data/mobs/man.yaml
@@ -21,6 +21,10 @@ hp: 7
speed: 5
aggressive: false
respawn_ticks: 30
+steal_table: man_steal
+steal_level: 1
+steal_xp: 8
+steal_speed: 4
attack_type: crush
stab_defense: 0
slash_defense: 0
diff --git a/data/mobs/phantom.yaml b/data/mobs/phantom.yaml
new file mode 100644
index 0000000..3fb1893
--- /dev/null
+++ b/data/mobs/phantom.yaml
@@ -0,0 +1,32 @@
+id: phantom
+name: phantom
+description: "A semi-transparent entity that phases in and out of visible light. Its psychic attacks are devastating to anyone without a spectral visor."
+assassin_level: 45
+damage_without: spectral_visor
+attack: 35
+strength: 30
+defense: 25
+hp: 100
+speed: 3
+aggressive: true
+respawn_ticks: 50
+idle_descriptions:
+ - "flickers between visible and invisible"
+ - "emits a low, resonant hum"
+ - "drifts through a wall and back again"
+ - "stares at you with hollow, glowing eyes"
+combat_descriptions:
+ - "blasts %s with a psychic wave"
+ - "phases through %s's defenses"
+ - "unleashes a spectral shriek at %s"
+drops:
+ remains: ectoplasm
+ loot:
+ - item_id: credits
+ weight: 60
+ quantity: 500
+ - item_id: spectral_visor
+ weight: 3
+ - item_id: credits
+ weight: 37
+ quantity: 1000
diff --git a/data/mobs/slug.yaml b/data/mobs/slug.yaml
new file mode 100644
index 0000000..2275ca7
--- /dev/null
+++ b/data/mobs/slug.yaml
@@ -0,0 +1,27 @@
+id: slug
+name: slug
+description: "A bloated, translucent slug the size of a dog. Its skin glistens with toxic mucus. It cannot be killed by conventional means — only salt can destroy it."
+assassin_level: 1
+finishing_blow: salt
+attack: 3
+strength: 3
+defense: 1
+hp: 15
+speed: 6
+aggressive: false
+respawn_ticks: 25
+idle_descriptions:
+ - "oozes along the floor leaving a slimy trail"
+ - "contracts and expands rhythmically"
+ - "extends its eyestalks toward you"
+ - "secretes a glob of toxic mucus"
+combat_descriptions:
+ - "lunges slimily at %s"
+ - "sprays mucus toward %s"
+ - "writhes in combat with %s"
+drops:
+ remains: slug_mucus
+ loot:
+ - item_id: credits
+ weight: 100
+ quantity: 15
diff --git a/data/mobs/weapons_smith.yaml b/data/mobs/weapons_smith.yaml
new file mode 100644
index 0000000..da6ef3b
--- /dev/null
+++ b/data/mobs/weapons_smith.yaml
@@ -0,0 +1,24 @@
+id: weapons_smith
+name: Weaponsmith
+description: "A burly smith with soot-stained hands and a heavy leather apron, standing proudly by the forge."
+behavior: weapons_shop
+unique: true
+protected: true
+hp: 80
+attack: 1
+strength: 1
+defense: 1
+speed: 5
+aggressive: false
+respawn_ticks: 30
+attack_type: crush
+stab_defense: 0
+slash_defense: 0
+crush_defense: 0
+science_defense: 0
+ranged_defense: 0
+idle_descriptions:
+ - "hammers at a glowing blade on the anvil"
+ - "wipes soot from his brow"
+ - "inspects a finished sword"
+ - "tends to the furnace fire" \ No newline at end of file
diff --git a/data/objects/market_stall.yaml b/data/objects/market_stall.yaml
new file mode 100644
index 0000000..a94d5cb
--- /dev/null
+++ b/data/objects/market_stall.yaml
@@ -0,0 +1,11 @@
+id: market_stall
+name: Market Stall
+description: "A wooden stall piled with food and sundries. The vendor doesn't seem particularly attentive."
+color: "179"
+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 \ No newline at end of file
diff --git a/data/rooms/1.yaml b/data/rooms/1.yaml
index 6fe6518..fa3fb9b 100644
--- a/data/rooms/1.yaml
+++ b/data/rooms/1.yaml
@@ -12,6 +12,7 @@ objects:
- id: charging_station
mobs:
- "newbie_trainer"
+ - "general_store_clerk"
spawns:
- item_id: cape_of_agility
quantity: 1
diff --git a/data/rooms/100.yaml b/data/rooms/100.yaml
deleted file mode 100644
index b8b0f1e..0000000
--- a/data/rooms/100.yaml
+++ /dev/null
@@ -1,7 +0,0 @@
-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
diff --git a/data/rooms/101.yaml b/data/rooms/101.yaml
deleted file mode 100644
index fa5af97..0000000
--- a/data/rooms/101.yaml
+++ /dev/null
@@ -1,8 +0,0 @@
-id: 101
-name: "Customs & Immigration"
-description: "A long hall lined with automated kiosks. Holographic scanners bathe travelers in cool blue light as bio-signatures are verified. The air smells of ozone and recycled stationery."
-map_symbol: "#"
-exits:
- west: 100
- east: 102
- north: 111
diff --git a/data/rooms/102.yaml b/data/rooms/102.yaml
deleted file mode 100644
index 1bc8573..0000000
--- a/data/rooms/102.yaml
+++ /dev/null
@@ -1,8 +0,0 @@
-id: 102
-name: "Port Authority Office"
-description: "Cluttered desks float in low gravity, terminals displaying shipping manifests and docking schedules. A large viewscreen shows the constant traffic of orbital freighters beyond the station."
-map_symbol: "P"
-exits:
- west: 101
- east: 103
- north: 112
diff --git a/data/rooms/103.yaml b/data/rooms/103.yaml
deleted file mode 100644
index f216fc4..0000000
--- a/data/rooms/103.yaml
+++ /dev/null
@@ -1,8 +0,0 @@
-id: 103
-name: "Interstellar Cargo Bay 3"
-description: "Massive crates stamped with corporate logos are stacked to the ceiling. Loading clamps hum with magnetic tension. Echoes of distant announcements reverberate through the spacious bay."
-map_symbol: "C"
-exits:
- west: 102
- east: 104
- north: 113
diff --git a/data/rooms/104.yaml b/data/rooms/104.yaml
deleted file mode 100644
index c7821c1..0000000
--- a/data/rooms/104.yaml
+++ /dev/null
@@ -1,7 +0,0 @@
-id: 104
-name: "Freight Elevator Lobby"
-description: "A utilitarian chamber where cargo lifters descend into the station's underbelly. Indicator lights blink in sequence as massive elevator doors cycle open and closed with hydraulic sighs."
-map_symbol: "E"
-exits:
- west: 103
- east: 105
diff --git a/data/rooms/105.yaml b/data/rooms/105.yaml
deleted file mode 100644
index f07c8aa..0000000
--- a/data/rooms/105.yaml
+++ /dev/null
@@ -1,8 +0,0 @@
-id: 105
-name: "Hydroponic Distribution Hub"
-description: "Towering racks of fresh produce await sorting. Leafy greens spill from aeroponic towers as workers in sterile suits package the station's food supply for distribution."
-map_symbol: "H"
-exits:
- west: 104
- east: 106
- north: 115
diff --git a/data/rooms/106.yaml b/data/rooms/106.yaml
deleted file mode 100644
index a264b01..0000000
--- a/data/rooms/106.yaml
+++ /dev/null
@@ -1,7 +0,0 @@
-id: 106
-name: "Recycled Water Processing"
-description: "The air is humid and warm. Pipes of all sizes crisscross the ceiling, carrying reclaimed water through filtration stages. The low rumble of pumps is a constant companion."
-map_symbol: "W"
-exits:
- west: 105
- east: 107
diff --git a/data/rooms/107.yaml b/data/rooms/107.yaml
deleted file mode 100644
index 34401bc..0000000
--- a/data/rooms/107.yaml
+++ /dev/null
@@ -1,8 +0,0 @@
-id: 107
-name: "Atmospheric Scrubber Array"
-description: "Wall-mounted scrubbers cycle station air through banks of moss and activated carbon. Green LED strips indicate optimal CO2 absorption. The room breathes like a mechanical lung."
-map_symbol: "S"
-exits:
- west: 106
- east: 108
- north: 117
diff --git a/data/rooms/108.yaml b/data/rooms/108.yaml
deleted file mode 100644
index 3f6914a..0000000
--- a/data/rooms/108.yaml
+++ /dev/null
@@ -1,7 +0,0 @@
-id: 108
-name: "Power Relay Station Gamma"
-description: "Crackling conduits channel raw solar energy from the exterior arrays. Warning signs in three languages adorn every surface. The hum is powerful enough to feel in your chest."
-map_symbol: "G"
-exits:
- west: 107
- east: 109
diff --git a/data/rooms/109.yaml b/data/rooms/109.yaml
deleted file mode 100644
index c22b9d0..0000000
--- a/data/rooms/109.yaml
+++ /dev/null
@@ -1,7 +0,0 @@
-id: 109
-name: "Solar Array Access Corridor"
-description: "A narrow passage leading to the station's exterior skin. Small viewports reveal the blindingly bright solar panels stretching into the void, harvesting the star's energy."
-map_symbol: "A"
-exits:
- west: 108
- north: 119
diff --git a/data/rooms/110.yaml b/data/rooms/110.yaml
deleted file mode 100644
index fd9c061..0000000
--- a/data/rooms/110.yaml
+++ /dev/null
@@ -1,8 +0,0 @@
-id: 110
-name: "Cryo-Sleep Recovery Ward"
-description: "Pale blue light fills this sterile room. Recovery pods line the walls, their occupants slowly reacclimating to standard gravity after decades in suspended animation."
-map_symbol: "R"
-exits:
- south: 100
- east: 111
- north: 120
diff --git a/data/rooms/111.yaml b/data/rooms/111.yaml
deleted file mode 100644
index d3a5a2a..0000000
--- a/data/rooms/111.yaml
+++ /dev/null
@@ -1,9 +0,0 @@
-id: 111
-name: "Medical Bay Alpha"
-description: "State-of-the-art diagnostic bays with hovering holographic displays. Automated medbots glide silently between stations. The scent of antiseptic gel lingers in the recycled air."
-map_symbol: "+"
-exits:
- south: 101
- west: 110
- east: 112
- north: 121
diff --git a/data/rooms/112.yaml b/data/rooms/112.yaml
deleted file mode 100644
index d5d46c0..0000000
--- a/data/rooms/112.yaml
+++ /dev/null
@@ -1,9 +0,0 @@
-id: 112
-name: "Pharmacy & Genetek"
-description: "Racks of color-coded pharmaceuticals glow under UV-protected lighting. A geneprinter in the corner hums as it synthesizes custom therapies and genetic patches on demand."
-map_symbol: "G"
-exits:
- south: 102
- west: 111
- east: 113
- north: 122
diff --git a/data/rooms/113.yaml b/data/rooms/113.yaml
deleted file mode 100644
index 4a3333e..0000000
--- a/data/rooms/113.yaml
+++ /dev/null
@@ -1,7 +0,0 @@
-id: 113
-name: "Café Synthesizer"
-description: "A cozy establishment where food printers whir and steam. Patrons sit at floating tables sipping replicated coffee that is surprisingly close to the real thing."
-map_symbol: "c"
-exits:
- west: 112
- south: 103
diff --git a/data/rooms/115.yaml b/data/rooms/115.yaml
deleted file mode 100644
index b0c9a4f..0000000
--- a/data/rooms/115.yaml
+++ /dev/null
@@ -1,7 +0,0 @@
-id: 115
-name: "Community Garden Atrium"
-description: "Sunlight-mimicking lamps nourish a lush indoor forest of ferns, flowering vines, and small fruit trees. A path of stepping stones winds through the greenery. Birds chirp from hidden perches."
-map_symbol: "G"
-exits:
- south: 105
- east: 116
diff --git a/data/rooms/116.yaml b/data/rooms/116.yaml
deleted file mode 100644
index 9c6c258..0000000
--- a/data/rooms/116.yaml
+++ /dev/null
@@ -1,8 +0,0 @@
-id: 116
-name: "School of Zero-G Arts"
-description: "Students float in a vast open chamber, practicing zero-gravity dance and sculpture. Paint droplets hang suspended mid-air, forming impromptu art installations that drift lazily."
-map_symbol: "Z"
-exits:
- west: 115
- east: 117
- north: 126
diff --git a/data/rooms/117.yaml b/data/rooms/117.yaml
deleted file mode 100644
index 5a1ecf0..0000000
--- a/data/rooms/117.yaml
+++ /dev/null
@@ -1,9 +0,0 @@
-id: 117
-name: "Library of Forgotten Code"
-description: "Data crystals line the walls from floor to ceiling. Vintage terminals allow access to archived knowledge from before the Collapse. The soft click of crystal readers fills the reverent silence."
-map_symbol: "L"
-exits:
- west: 116
- east: 118
- south: 107
- north: 127
diff --git a/data/rooms/118.yaml b/data/rooms/118.yaml
deleted file mode 100644
index 95a4a1f..0000000
--- a/data/rooms/118.yaml
+++ /dev/null
@@ -1,8 +0,0 @@
-id: 118
-name: "Quiet Meditation Spire"
-description: "A narrow tower lined with sound-dampening panels. Cushions arranged in concentric circles face a central fountain that flows upward into a recycling grate. No words are spoken here."
-map_symbol: "M"
-exits:
- west: 117
- east: 119
- north: 128
diff --git a/data/rooms/119.yaml b/data/rooms/119.yaml
deleted file mode 100644
index dbe9402..0000000
--- a/data/rooms/119.yaml
+++ /dev/null
@@ -1,8 +0,0 @@
-id: 119
-name: "Sleep Pod Cluster 7"
-description: "Hundreds of stacked sleep pods line the corridor, their occupants visible through frosted transparencies. A few are open, revealing neatly made bunks with personal hologram projections."
-map_symbol: "p"
-exits:
- west: 118
- south: 109
- north: 129
diff --git a/data/rooms/12.yaml b/data/rooms/12.yaml
index d8850eb..0c4443b 100644
--- a/data/rooms/12.yaml
+++ b/data/rooms/12.yaml
@@ -4,6 +4,8 @@ description: "A blazing forge and anvil dominate this smoky workshop. The heat f
exits:
east: 13
west: 11
+mobs:
+ - "weapons_smith"
objects:
- id: furnace
- id: anvil
diff --git a/data/rooms/120.yaml b/data/rooms/120.yaml
deleted file mode 100644
index a03d2f2..0000000
--- a/data/rooms/120.yaml
+++ /dev/null
@@ -1,8 +0,0 @@
-id: 120
-name: "The Neon Drake Tavern"
-description: "Neon dragons coil across the ceiling in animated loops. The bar serves synthesized spirits that pack a surprising punch. A jukebox plays synthwave classics from the pre-Collapse era."
-map_symbol: "T"
-exits:
- south: 110
- east: 121
- north: 130
diff --git a/data/rooms/121.yaml b/data/rooms/121.yaml
deleted file mode 100644
index 8adf8c3..0000000
--- a/data/rooms/121.yaml
+++ /dev/null
@@ -1,9 +0,0 @@
-id: 121
-name: "Dance Floor Theta-9"
-description: "The floor pulses with reactive LED tiles that shift color with every step. A spherical DJ booth hovers in the center, surrounded by a crowd of moving silhouettes lost in the beat."
-map_symbol: "D"
-exits:
- west: 120
- east: 122
- south: 111
- north: 131
diff --git a/data/rooms/122.yaml b/data/rooms/122.yaml
deleted file mode 100644
index d4ec067..0000000
--- a/data/rooms/122.yaml
+++ /dev/null
@@ -1,7 +0,0 @@
-id: 122
-name: "VR Combat Arena"
-description: "Rows of neural-interface pods line the walls. Spectators watch live feeds of digital battles where players fight with light and code in simulated zero-G deathmatches."
-map_symbol: "V"
-exits:
- west: 121
- south: 112
diff --git a/data/rooms/126.yaml b/data/rooms/126.yaml
deleted file mode 100644
index a76b7d1..0000000
--- a/data/rooms/126.yaml
+++ /dev/null
@@ -1,8 +0,0 @@
-id: 126
-name: "Holographic Tattoo Parlor"
-description: "Clients float in zero-g chairs while artists etch animated holograms onto their skin. A woman displays a sleeve of swimming koi that ripple and dart as she moves her arm."
-map_symbol: "t"
-exits:
- south: 116
- east: 127
- north: 136
diff --git a/data/rooms/127.yaml b/data/rooms/127.yaml
deleted file mode 100644
index f7565c8..0000000
--- a/data/rooms/127.yaml
+++ /dev/null
@@ -1,9 +0,0 @@
-id: 127
-name: "Underground Fight Club"
-description: "In a repurposed cargo container, bare-knuckle brawls draw crowds of gamblers. The ring is marked by glow tape. A hulking enforcer named Jax watches the crowd with narrowed eyes."
-map_symbol: "F"
-exits:
- west: 126
- east: 128
- south: 117
- north: 137
diff --git a/data/rooms/128.yaml b/data/rooms/128.yaml
deleted file mode 100644
index 93abe6c..0000000
--- a/data/rooms/128.yaml
+++ /dev/null
@@ -1,9 +0,0 @@
-id: 128
-name: "Ration Exchange Trade Post"
-description: "Bulletin boards display trading offers for ration cards, spare parts, and favors. A grumpy quartermaster oversees the exchange from behind a counter of reinforced polyglass."
-map_symbol: "X"
-exits:
- west: 127
- east: 129
- south: 118
- north: 138
diff --git a/data/rooms/129.yaml b/data/rooms/129.yaml
deleted file mode 100644
index 35a5087..0000000
--- a/data/rooms/129.yaml
+++ /dev/null
@@ -1,7 +0,0 @@
-id: 129
-name: "Viewing Platform Nebula Vista"
-description: "A panoramic window overlooks the Crab Nebula in all its colorful glory. Telescopes mounted on tracks allow close-up viewing of distant stars and planets. Benches face the void."
-map_symbol: "V"
-exits:
- west: 128
- south: 119
diff --git a/data/rooms/130.yaml b/data/rooms/130.yaml
deleted file mode 100644
index 35c0b35..0000000
--- a/data/rooms/130.yaml
+++ /dev/null
@@ -1,8 +0,0 @@
-id: 130
-name: "Reactor Core Access"
-description: "Warning lights pulse a deep amber. The massive blast door is reinforced with thermal shielding. A placard reads 'Authorized Personnel Only — Class 3 Radiation Zone.'"
-map_symbol: "R"
-exits:
- south: 120
- east: 131
- north: 140
diff --git a/data/rooms/131.yaml b/data/rooms/131.yaml
deleted file mode 100644
index 2825c15..0000000
--- a/data/rooms/131.yaml
+++ /dev/null
@@ -1,8 +0,0 @@
-id: 131
-name: "Engineering Bay 4"
-description: "Tools hang on magnetic strips along every wall. Schematic blueprints float in holographic displays. Engineers in grease-stained jumpsuits argumentatively debate power allocation."
-map_symbol: "E"
-exits:
- west: 130
- south: 121
- north: 141
diff --git a/data/rooms/136.yaml b/data/rooms/136.yaml
deleted file mode 100644
index a5a87ae..0000000
--- a/data/rooms/136.yaml
+++ /dev/null
@@ -1,8 +0,0 @@
-id: 136
-name: "Research Lab Xenobotany"
-description: "Terrariums contain alien flora from a dozen star systems. A pulsating purple fungus emits a low-frequency hum. The lead researcher takes meticulous notes on bioluminescent patterns."
-map_symbol: "L"
-exits:
- south: 126
- east: 137
- north: 146
diff --git a/data/rooms/137.yaml b/data/rooms/137.yaml
deleted file mode 100644
index a14651b..0000000
--- a/data/rooms/137.yaml
+++ /dev/null
@@ -1,9 +0,0 @@
-id: 137
-name: "Zero-G Forge"
-description: "Molten metal floats in perfect spheres as magnetic fields shape it into precision components. A welder in a full environmental suit guides the process with practiced hand gestures."
-map_symbol: "F"
-exits:
- west: 136
- east: 138
- south: 127
- north: 147
diff --git a/data/rooms/138.yaml b/data/rooms/138.yaml
deleted file mode 100644
index c00d30d..0000000
--- a/data/rooms/138.yaml
+++ /dev/null
@@ -1,9 +0,0 @@
-id: 138
-name: "Tool Locker Fabrication"
-description: "Walls covered with every tool imaginable, from micro-screwdrivers to plasma cutters. A 3D fabricator in the corner hums quietly, ready to print custom tool heads on demand."
-map_symbol: "T"
-exits:
- west: 137
- east: 139
- south: 128
- north: 148
diff --git a/data/rooms/139.yaml b/data/rooms/139.yaml
deleted file mode 100644
index a8226fa..0000000
--- a/data/rooms/139.yaml
+++ /dev/null
@@ -1,6 +0,0 @@
-id: 139
-name: "Maintenance Tunnel 9"
-description: "A narrow, cramped tunnel lined with exposed conduit and crawling with maintenance drones. The flickering fluorescent light makes the shadows dance along the curved walls."
-map_symbol: "M"
-exits:
- west: 138
diff --git a/data/rooms/140.yaml b/data/rooms/140.yaml
deleted file mode 100644
index c4d7db3..0000000
--- a/data/rooms/140.yaml
+++ /dev/null
@@ -1,7 +0,0 @@
-id: 140
-name: "Captain's Quarter Balcony"
-description: "A private balcony overlooking the grand concourse below. Potted orchids thrive in the artificial light. The captain's telescope is aimed at a distant binary star system."
-map_symbol: "K"
-exits:
- south: 130
- east: 141
diff --git a/data/rooms/141.yaml b/data/rooms/141.yaml
deleted file mode 100644
index d1ba9bf..0000000
--- a/data/rooms/141.yaml
+++ /dev/null
@@ -1,8 +0,0 @@
-id: 141
-name: "Executive Lounge Nebula"
-description: "Chrome and glass furniture floats in arranged clusters. A fully stocked bar offers genuine Earth spirits at exorbitant prices. The view of the nebula through the window is breathtaking."
-map_symbol: "E"
-exits:
- west: 140
- east: 142
- south: 131
diff --git a/data/rooms/142.yaml b/data/rooms/142.yaml
deleted file mode 100644
index e903d24..0000000
--- a/data/rooms/142.yaml
+++ /dev/null
@@ -1,7 +0,0 @@
-id: 142
-name: "Board Room Round Table"
-description: "A circular table dominates the room, its surface a holographic display of the sector map. Chairs hover at precise intervals. Decisions that affect thousands are made around this table."
-map_symbol: "B"
-exits:
- west: 141
- east: 143
diff --git a/data/rooms/143.yaml b/data/rooms/143.yaml
deleted file mode 100644
index 7e16905..0000000
--- a/data/rooms/143.yaml
+++ /dev/null
@@ -1,6 +0,0 @@
-id: 143
-name: "Observation Dome"
-description: "The entire ceiling is a transparent dome offering an unobstructed view of deep space. Stars wheel slowly overhead as the station rotates. A gentle chime marks each passing hour."
-map_symbol: "O"
-exits:
- west: 142
diff --git a/data/rooms/145.yaml b/data/rooms/145.yaml
deleted file mode 100644
index fc8948e..0000000
--- a/data/rooms/145.yaml
+++ /dev/null
@@ -1,6 +0,0 @@
-id: 145
-name: "AI Core Chamber"
-description: "A massive crystalline structure pulses with internal light. The station's artificial intelligence, AURA, processes billions of operations per second. A respectful silence is observed here."
-map_symbol: "A"
-exits:
- east: 146
diff --git a/data/rooms/146.yaml b/data/rooms/146.yaml
deleted file mode 100644
index b3512b4..0000000
--- a/data/rooms/146.yaml
+++ /dev/null
@@ -1,8 +0,0 @@
-id: 146
-name: "Master Timekeeper's Office"
-description: "Clocks of every conceivable design cover the walls — analog, digital, atomic, celestial. The Master Timekeeper ensures all station systems are synchronized to the galactic standard."
-map_symbol: "T"
-exits:
- west: 145
- east: 147
- south: 136
diff --git a/data/rooms/147.yaml b/data/rooms/147.yaml
deleted file mode 100644
index 4c89429..0000000
--- a/data/rooms/147.yaml
+++ /dev/null
@@ -1,8 +0,0 @@
-id: 147
-name: "Archives of Station Law"
-description: "A solemn chamber where the station's founding charter is displayed in a sealed crys-glass case. Legal precedents and arbitration records line the walls in data-slate form."
-map_symbol: "A"
-exits:
- west: 146
- east: 148
- south: 137
diff --git a/data/rooms/148.yaml b/data/rooms/148.yaml
deleted file mode 100644
index 603536d..0000000
--- a/data/rooms/148.yaml
+++ /dev/null
@@ -1,8 +0,0 @@
-id: 148
-name: "Comm Spire Antenna Array"
-description: "A vertical shaft rises into a transparent dome bristling with antennae and dishes. Signal boosters amplify transmissions across the sector. The air crackles with silent communication."
-map_symbol: "C"
-exits:
- west: 147
- east: 149
- south: 138
diff --git a/data/rooms/149.yaml b/data/rooms/149.yaml
deleted file mode 100644
index a5bb1d9..0000000
--- a/data/rooms/149.yaml
+++ /dev/null
@@ -1,8 +0,0 @@
-id: 149
-name: "Escape Pod Bay 0"
-description: "A circular chamber lined with escape pods, their hatches open for inspection. A sign reminds all personnel to familiarize themselves with emergency procedures before departure."
-map_symbol: "E"
-exits:
- west: 148
-mobs:
- - id: iron_sentinel
diff --git a/data/rooms/150.yaml b/data/rooms/150.yaml
deleted file mode 100644
index ba13c81..0000000
--- a/data/rooms/150.yaml
+++ /dev/null
@@ -1,8 +0,0 @@
-id: 150
-name: "Solar Altar Chamber"
-description: "A circular chamber bathed in warm {220}golden light{/}. At its center stands a humming {220 bold}solar altar{/}, its surface etched with fractal circuit patterns that glow softly."
-exits:
- south: 9
- east: 156
-objects:
- - id: solar_altar
diff --git a/data/rooms/151.yaml b/data/rooms/151.yaml
deleted file mode 100644
index 8d79273..0000000
--- a/data/rooms/151.yaml
+++ /dev/null
@@ -1,8 +0,0 @@
-id: 151
-name: "Hydro Altar Chamber"
-description: "Condensation drips from the ceiling of this cool, blue-lit chamber. A {39 bold}hydro altar{/} dominates the room, its crystalline surface rippling with patterns like flowing water."
-exits:
- north: 9
- east: 157
-objects:
- - id: hydro_altar
diff --git a/data/rooms/152.yaml b/data/rooms/152.yaml
deleted file mode 100644
index 26523a4..0000000
--- a/data/rooms/152.yaml
+++ /dev/null
@@ -1,8 +0,0 @@
-id: 152
-name: "Eco Altar Chamber"
-description: "Bioluminescent moss covers the walls of this overgrown chamber. An {34 bold}eco altar{/} rises from the floor, entwined with living circuitry that pulses with green light."
-exits:
- southwest: 9
- north: 158
-objects:
- - id: eco_altar
diff --git a/data/rooms/153.yaml b/data/rooms/153.yaml
deleted file mode 100644
index 36ef05c..0000000
--- a/data/rooms/153.yaml
+++ /dev/null
@@ -1,8 +0,0 @@
-id: 153
-name: "Bio Altar Chamber"
-description: "The air here is thick and warm. A {196 bold}bio altar{/} throbs at the center of the room, its surface covered in red organic membranes threaded with copper wire."
-exits:
- northwest: 9
- north: 159
-objects:
- - id: bio_altar
diff --git a/data/rooms/154.yaml b/data/rooms/154.yaml
deleted file mode 100644
index b86be53..0000000
--- a/data/rooms/154.yaml
+++ /dev/null
@@ -1,7 +0,0 @@
-id: 154
-name: "Chaos Altar Chamber"
-description: "The air crackles with {226 bold}unstable energy{/} in this chamber. A {226 bold}chaos altar{/} of twisting metal and sparking wires stands at the center, humming with discordant frequencies."
-exits:
- southeast: 9
-objects:
- - id: chaos_altar
diff --git a/data/rooms/155.yaml b/data/rooms/155.yaml
deleted file mode 100644
index c333836..0000000
--- a/data/rooms/155.yaml
+++ /dev/null
@@ -1,7 +0,0 @@
-id: 155
-name: "Death Altar Chamber"
-description: "Shadows pool in the corners of this cold, silent chamber. A {52 bold}death altar{/} of polished obsidian dominates the room, absorbing all light that touches it."
-exits:
- northeast: 9
-objects:
- - id: death_altar
diff --git a/data/rooms/156.yaml b/data/rooms/156.yaml
deleted file mode 100644
index d77f1e5..0000000
--- a/data/rooms/156.yaml
+++ /dev/null
@@ -1,7 +0,0 @@
-id: 156
-name: "Blood Altar Chamber"
-description: "The walls of this chamber are veined with dark crimson lines that pulse rhythmically. A {124 bold}blood altar{/} of deep red crystal stands at the center, radiating gentle warmth."
-exits:
- west: 150
-objects:
- - id: blood_altar
diff --git a/data/rooms/157.yaml b/data/rooms/157.yaml
deleted file mode 100644
index 40f6b5b..0000000
--- a/data/rooms/157.yaml
+++ /dev/null
@@ -1,7 +0,0 @@
-id: 157
-name: "Law Altar Chamber"
-description: "Orderly lines of blue light trace geometric patterns across every surface of this pristine chamber. A {27 bold}law altar{/} of perfect blue stone rises from the floor, its surface engraved with precise mathematical formulas."
-exits:
- west: 151
-objects:
- - id: law_altar
diff --git a/data/rooms/158.yaml b/data/rooms/158.yaml
deleted file mode 100644
index ec0f760..0000000
--- a/data/rooms/158.yaml
+++ /dev/null
@@ -1,7 +0,0 @@
-id: 158
-name: "Cosmic Altar Chamber"
-description: "Darkness envelops this chamber save for thousands of {201}twinkling lights{/} that float like stars. A {201 bold}cosmic altar{/} of translucent pink crystal hovers above the floor, surrounded by orbiting motes of light."
-exits:
- south: 152
-objects:
- - id: cosmic_altar
diff --git a/data/rooms/159.yaml b/data/rooms/159.yaml
deleted file mode 100644
index ac65867..0000000
--- a/data/rooms/159.yaml
+++ /dev/null
@@ -1,7 +0,0 @@
-id: 159
-name: "Nature Altar Chamber"
-description: "Vines and flowering moss carpet every surface of this verdant chamber. A {46 bold}nature altar{/} of living green stone rises from the floor, entwined with blooming plants that turn toward you as you enter."
-exits:
- south: 153
-objects:
- - id: nature_altar
diff --git a/data/rooms/160.yaml b/data/rooms/160.yaml
new file mode 100644
index 0000000..dea9d00
--- /dev/null
+++ b/data/rooms/160.yaml
@@ -0,0 +1,17 @@
+id: 160
+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:
+ north: 1
+ east: 161
+objects:
+ - id: market_stall
+mobs:
+ - id: man
+ wander_interval: 15
+ - id: man
+ wander_interval: 18
+ - id: man
+ wander_interval: 20
+ - id: guard \ No newline at end of file
diff --git a/data/rooms/161.yaml b/data/rooms/161.yaml
new file mode 100644
index 0000000..c887347
--- /dev/null
+++ b/data/rooms/161.yaml
@@ -0,0 +1,13 @@
+id: 161
+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: 160
+mobs:
+ - id: farmer
+ wander_rooms: [161]
+ - id: farmer
+ wander_rooms: [161]
+ - id: bioengineer
+ wander_rooms: [161] \ No newline at end of file
diff --git a/data/rooms/162.yaml b/data/rooms/162.yaml
new file mode 100644
index 0000000..a8333d8
--- /dev/null
+++ b/data/rooms/162.yaml
@@ -0,0 +1,6 @@
+id: 162
+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:
+ north: 1 \ No newline at end of file
diff --git a/data/rooms/31.yaml b/data/rooms/31.yaml
index fc57b2d..3d010df 100644
--- a/data/rooms/31.yaml
+++ b/data/rooms/31.yaml
@@ -4,3 +4,4 @@ description: "The central square of Lumbridge. A fountain gurgles peacefully in
map_symbol: "b"
exits:
up: 1
+ down: 50
diff --git a/data/rooms/50.yaml b/data/rooms/50.yaml
new file mode 100644
index 0000000..0c9ebf2
--- /dev/null
+++ b/data/rooms/50.yaml
@@ -0,0 +1,7 @@
+id: 50
+name: "The Assassin's Den"
+description: "A dimly lit basement accessible through a trapdoor. Tactical maps and bounty posters line the walls. The air smells of gun oil and burnt ozone. {141}The Client{/} sits behind a reinforced desk."
+exits:
+ up: 31
+mobs:
+ - "client"
diff --git a/data/rooms/51.yaml b/data/rooms/51.yaml
new file mode 100644
index 0000000..74a97ef
--- /dev/null
+++ b/data/rooms/51.yaml
@@ -0,0 +1,10 @@
+id: 51
+name: "Sewer Tunnels"
+description: "Dark, damp tunnels beneath the settlement. The floor is slick with moisture and something {82}slimy{/}. The smell is indescribable."
+exits:
+ north: 50
+ south: 52
+mobs:
+ - "slug"
+ - "slug"
+ - "slug"
diff --git a/data/rooms/52.yaml b/data/rooms/52.yaml
new file mode 100644
index 0000000..2f90abd
--- /dev/null
+++ b/data/rooms/52.yaml
@@ -0,0 +1,9 @@
+id: 52
+name: "Abandoned Sector"
+description: "A decommissioned sector of the asteroid's infrastructure. Broken monitors flicker and exposed wiring {214}sparks{/} dangerously."
+exits:
+ north: 51
+ south: 53
+mobs:
+ - "drone"
+ - "drone"
diff --git a/data/rooms/53.yaml b/data/rooms/53.yaml
new file mode 100644
index 0000000..126719b
--- /dev/null
+++ b/data/rooms/53.yaml
@@ -0,0 +1,9 @@
+id: 53
+name: "Deep Tunnels"
+description: "The tunnels descend deeper into the asteroid's core. Strange chitinous scraping echoes from the darkness. The walls are scarred with {130}claw marks{/}."
+exits:
+ north: 52
+ south: 54
+mobs:
+ - "crawler"
+ - "crawler"
diff --git a/data/rooms/54.yaml b/data/rooms/54.yaml
new file mode 100644
index 0000000..5bb6a1d
--- /dev/null
+++ b/data/rooms/54.yaml
@@ -0,0 +1,8 @@
+id: 54
+name: "The Void Chamber"
+description: "A vast cavern where reality seems to thin. The air shimmers with {141}spectral energy{/} and strange whispers fill your mind."
+exits:
+ north: 53
+mobs:
+ - "phantom"
+ - "phantom"
diff --git a/internal/action/behavior.go b/internal/action/behavior.go
index ccd95bf..236ab81 100644
--- a/internal/action/behavior.go
+++ b/internal/action/behavior.go
@@ -50,6 +50,22 @@ type NodeAction struct {
Teleport int `yaml:"teleport"`
Heal int `yaml:"heal"`
Cost int `yaml:"cost"`
+ Shop *ShopConfig `yaml:"shop"`
+ AssignTask bool `yaml:"assign_task"`
+ SkipTask bool `yaml:"skip_task"`
+ ExtendTask bool `yaml:"extend_task"`
+ ReputationCost int `yaml:"reputation_cost"`
+}
+
+type ShopConfig struct {
+ Message string `yaml:"message"`
+ Items []ShopItem `yaml:"items"`
+}
+
+type ShopItem struct {
+ ItemID string `yaml:"item_id"`
+ BuyPrice int `yaml:"buy_price"`
+ SellPrice int `yaml:"sell_price"`
}
type Condition struct {
diff --git a/internal/combat/state.go b/internal/combat/state.go
index 9c8d346..7a4f24c 100644
--- a/internal/combat/state.go
+++ b/internal/combat/state.go
@@ -3,9 +3,10 @@ package combat
import "sync"
type State struct {
- PlayerName string
- MobID string
- Active bool
+ PlayerName string
+ MobID string
+ Active bool
+ DamageWarningShown bool
}
var (
diff --git a/internal/config/config.go b/internal/config/config.go
index 932af4a..ad90103 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -51,6 +51,8 @@ func DefaultColors() ColorsConfig {
"battery": "45",
"tech_depleted": "196",
"science_mod": "99",
+ "assassin_task": "219",
+ "warning": "208",
}
}
diff --git a/internal/game/action.go b/internal/game/action.go
index 2d30010..3cc51c6 100644
--- a/internal/game/action.go
+++ b/internal/game/action.go
@@ -25,6 +25,8 @@ var verbAliases = map[string]string{
"ask": "talk",
"pull": "toggle",
"push": "toggle",
+ "steal": "steal",
+ "thieve": "steal",
}
var verbSkill = map[string]string{
@@ -33,6 +35,8 @@ var verbSkill = map[string]string{
"cut": "woodcutting",
"fish": "fishing",
"shear": "crafting",
+ "steal": "thieving",
+ "thieve": "thieving",
}
func normalizeVerb(v string) string {
@@ -235,6 +239,8 @@ func (g *Game) AdvanceActions() {
g.advanceSearch(sess, p)
case "identify":
g.advanceIdentify(sess, p)
+ case "steal":
+ g.advanceSteal(sess, p)
default:
if productionActionTypes[p.Action.Type] {
g.advanceProduction(sess, p)
diff --git a/internal/game/action_state.go b/internal/game/action_state.go
index 596eb4e..2dc42ae 100644
--- a/internal/game/action_state.go
+++ b/internal/game/action_state.go
@@ -26,6 +26,7 @@ const (
ActionMixing ActionType = "mixing"
ActionIdentifying ActionType = "identifying"
ActionTriggering ActionType = "triggering"
+ ActionStealing ActionType = "stealing"
)
type ActionState struct {
@@ -85,6 +86,8 @@ func (a *ActionState) Description() string {
return "identifying scrap at " + a.TargetName
case ActionTriggering:
return "triggering " + a.TargetName
+ case ActionStealing:
+ return "stealing from " + a.TargetName
}
return ""
}
diff --git a/internal/game/action_steal.go b/internal/game/action_steal.go
new file mode 100644
index 0000000..dac90f2
--- /dev/null
+++ b/internal/game/action_steal.go
@@ -0,0 +1,449 @@
+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,
+}
+
+func (g *Game) doSteal(sess *net.Session, input string) {
+ p := sess.Player.(*player.Player)
+
+ if combat.GetCombat(p.Name) != nil {
+ sess.WriteLine("You can't do that during combat!")
+ return
+ }
+
+ g.CancelAction(p)
+
+ targetType, mob, objDef, errMsg := g.resolveStealTarget(sess, p, input)
+ if errMsg != "" {
+ sess.WriteLine(errMsg)
+ return
+ }
+
+ g.startSteal(sess, p, targetType, mob, objDef)
+}
+
+func (g *Game) resolveStealTarget(sess *net.Session, p *player.Player, input string) (targetType string, mob *world.MobInstance, objDef *object.ObjectDef, errMsg string) {
+ input = strings.ToLower(strings.TrimSpace(input))
+ instanceIdx := -1
+ searchName := input
+
+ if dotPos := strings.Index(input, "."); dotPos > 0 {
+ if n, err := strconv.Atoi(input[:dotPos]); err == nil && n > 0 {
+ instanceIdx = n - 1
+ searchName = input[dotPos+1:]
+ }
+ }
+
+ roomMobs := g.MobStore.MobsInRoom(p.RoomID)
+ var stealMobs []*world.MobInstance
+ for _, m := range roomMobs {
+ if m.StealTable != "" {
+ stealMobs = append(stealMobs, m)
+ }
+ }
+
+ roomObjs := g.World.AllObjInstances(p.RoomID)
+ var stealObjs []*object.ObjectDef
+ for _, st := range roomObjs {
+ def, err := g.ObjectStore.Load(st.DefID)
+ if err != nil || def.StealTable == "" {
+ continue
+ }
+ stealObjs = append(stealObjs, def)
+ }
+
+ if input == "" {
+ if len(stealMobs) == 0 && len(stealObjs) == 0 {
+ return "", nil, nil, "There's nothing here to steal from."
+ }
+
+ uniqueMobDefs := make(map[string]bool)
+ for _, m := range stealMobs {
+ uniqueMobDefs[m.DefID] = true
+ }
+
+ if len(stealMobs) > 0 && len(stealObjs) == 0 {
+ if len(uniqueMobDefs) <= 1 {
+ return "mob", stealMobs[0], nil, ""
+ }
+ }
+ if len(stealObjs) > 0 && len(stealMobs) == 0 {
+ uniqueObjDefs := make(map[string]bool)
+ for _, o := range stealObjs {
+ uniqueObjDefs[o.ID] = true
+ }
+ if len(uniqueObjDefs) <= 1 {
+ return "object", nil, stealObjs[0], ""
+ }
+ }
+ return "", nil, nil, "Steal from what?"
+ }
+
+ var matchingMobs []*world.MobInstance
+ for _, m := range stealMobs {
+ if m.MatchQuality(searchName) >= world.MatchPrefix {
+ matchingMobs = append(matchingMobs, m)
+ }
+ }
+ sort.Slice(matchingMobs, func(i, j int) bool {
+ return matchingMobs[i].InstanceID < matchingMobs[j].InstanceID
+ })
+
+ if len(matchingMobs) > 0 {
+ uniqueDefs := make(map[string]bool)
+ for _, m := range matchingMobs {
+ uniqueDefs[m.DefID] = true
+ }
+ if len(uniqueDefs) > 1 && instanceIdx < 0 {
+ return "", nil, nil, "Which one?"
+ }
+ if instanceIdx >= 0 && instanceIdx < len(matchingMobs) {
+ return "mob", matchingMobs[instanceIdx], nil, ""
+ }
+ if len(matchingMobs) == 1 {
+ return "mob", matchingMobs[0], nil, ""
+ }
+ for i := range matchingMobs {
+ if instanceIdx == i || instanceIdx < 0 {
+ return "mob", matchingMobs[i], nil, ""
+ }
+ }
+ }
+
+ var matchingObjs []*object.ObjectDef
+ for _, o := range stealObjs {
+ if world.WordPrefixMatch(searchName, o.Name) {
+ matchingObjs = append(matchingObjs, o)
+ }
+ }
+ if len(matchingObjs) > 1 {
+ return "", nil, nil, "Which one?"
+ }
+ if len(matchingObjs) == 1 {
+ return "object", nil, matchingObjs[0], ""
+ }
+
+ return "", nil, nil, "There's nothing here to steal from."
+}
+
+func (g *Game) startSteal(sess *net.Session, p *player.Player, targetType string, mob *world.MobInstance, objDef *object.ObjectDef) {
+ var stealTable string
+ var stealLevel int
+ var stealXP int
+ var stealSpeed float64
+ var targetName string
+ var targetID string
+ var mobInstanceID string
+ var objDefID string
+ var guardMob string
+ guardWatching := false
+
+ if targetType == "mob" {
+ stealTable = mob.StealTable
+ stealLevel = mob.StealLevel
+ stealXP = mob.StealXP
+ stealSpeed = mob.StealSpeed
+ targetName = mob.Name
+ targetID = mob.InstanceID
+ mobInstanceID = mob.InstanceID
+
+ if stealTable == "" {
+ sess.WriteLine(fmt.Sprintf("You can't steal from the %s.", targetName))
+ return
+ }
+ if thievingLevel := p.Level(player.Thieving); stealLevel > 0 && thievingLevel < stealLevel {
+ sess.WriteLine(fmt.Sprintf("You need level %d thieving to steal from the %s.", stealLevel, targetName))
+ return
+ }
+ if mob.HP <= 0 {
+ sess.WriteLine("That is already dead.")
+ return
+ }
+ if combat.IsMobInCombat(mob.InstanceID) {
+ sess.WriteLine(fmt.Sprintf("The %s is busy.", targetName))
+ return
+ }
+ } else {
+ stealTable = objDef.StealTable
+ stealLevel = objDef.StealLevel
+ stealXP = objDef.StealXP
+ stealSpeed = objDef.StealSpeed
+ targetName = objDef.Name
+ targetID = objDef.ID
+ objDefID = objDef.ID
+ guardMob = objDef.GuardMob
+
+ if stealTable == "" {
+ sess.WriteLine(fmt.Sprintf("You can't steal from the %s.", targetName))
+ return
+ }
+ if thievingLevel := p.Level(player.Thieving); stealLevel > 0 && thievingLevel < stealLevel {
+ sess.WriteLine(fmt.Sprintf("You need level %d thieving to steal from the %s.", stealLevel, targetName))
+ return
+ }
+
+ if guardMob != "" {
+ guard := g.findGuardInRoom(p.RoomID, guardMob)
+ if guard != nil {
+ timerKey := fmt.Sprintf("%d:%s", p.RoomID, objDefID)
+ if _, exists := g.guardWatchTimers[timerKey]; !exists {
+ g.guardWatchTimers[timerKey] = 0
+ }
+ guardWatching = g.isGuardWatching(p.RoomID, objDefID)
+ }
+ }
+ }
+
+ if p.FirstFreeSlot() == -1 {
+ sess.WriteLine("Your inventory is too full!")
+ return
+ }
+
+ 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,
+ "steal_speed": stealSpeed,
+ "target_name": targetName,
+ "mob_instance_id": mobInstanceID,
+ "obj_def_id": objDefID,
+ "guard_mob": guardMob,
+ "guard_watching": guardWatching,
+ },
+ }
+}
+
+func (g *Game) advanceSteal(sess *net.Session, p *player.Player) {
+ data := p.Action.Data
+ targetType := data["target_type"].(string)
+ stealTable := data["steal_table"].(string)
+ stealLevel := data["steal_level"].(int)
+ stealXP := data["steal_xp"].(int)
+ stealSpeed := data["steal_speed"].(float64)
+ targetName := data["target_name"].(string)
+ guardMob := data["guard_mob"].(string)
+ guardWatching := data["guard_watching"].(bool)
+
+ if targetType == "mob" {
+ mobInstanceID := data["mob_instance_id"].(string)
+ mob := g.MobStore.GetInstance(mobInstanceID)
+ if mob == nil || mob.HP <= 0 || mob.RoomID != p.RoomID {
+ sess.WriteLine("Your target is gone.")
+ g.CancelAction(p)
+ return
+ }
+ if combat.IsMobInCombat(mob.InstanceID) {
+ sess.WriteLine(fmt.Sprintf("The %s is busy.", targetName))
+ g.CancelAction(p)
+ return
+ }
+ data["steal_level"] = mob.StealLevel
+ data["steal_xp"] = mob.StealXP
+ data["steal_speed"] = mob.StealSpeed
+ stealLevel = mob.StealLevel
+ stealXP = mob.StealXP
+ stealSpeed = mob.StealSpeed
+ } else {
+ objDefID := data["obj_def_id"].(string)
+ found := false
+ for _, st := range g.World.AllObjInstances(p.RoomID) {
+ if st.DefID == objDefID && !st.Depleted {
+ found = true
+ break
+ }
+ }
+ if !found {
+ sess.WriteLine("Your target is gone.")
+ g.CancelAction(p)
+ return
+ }
+ objDef, err := g.ObjectStore.Load(objDefID)
+ if err == nil {
+ data["steal_level"] = objDef.StealLevel
+ data["steal_xp"] = objDef.StealXP
+ data["steal_speed"] = objDef.StealSpeed
+ stealLevel = objDef.StealLevel
+ stealXP = objDef.StealXP
+ stealSpeed = objDef.StealSpeed
+ data["guard_mob"] = objDef.GuardMob
+ guardMob = objDef.GuardMob
+
+ if guardMob != "" {
+ guard := g.findGuardInRoom(p.RoomID, guardMob)
+ if guard != nil {
+ timerKey := fmt.Sprintf("%d:%s", p.RoomID, objDefID)
+ if _, exists := g.guardWatchTimers[timerKey]; !exists {
+ g.guardWatchTimers[timerKey] = 0
+ }
+ guardWatching = g.isGuardWatching(p.RoomID, objDefID)
+ data["guard_watching"] = guardWatching
+ }
+ }
+ }
+ }
+
+ level := p.Level(player.Thieving)
+ chance := action.SuccessChance(stealSuccess, level, stealLevel)
+ if guardWatching {
+ chance *= 0.5
+ if chance < 0.05 {
+ chance = 0.05
+ }
+ }
+
+ if rand.Float64() < chance {
+ dt, err := g.BehaviorStore.LoadDropTable(stealTable)
+ if err != nil || len(dt.Drops) == 0 {
+ sess.WriteLine("You steal nothing of value.")
+ } else {
+ drop := g.BehaviorStore.ResolveDrop(dt.Drops)
+ if drop == nil || drop.ItemID == "" {
+ sess.WriteLine("You steal nothing of value.")
+ } else {
+ g.giveStealLoot(sess, p, drop)
+ }
+ }
+
+ if stealXP > 0 {
+ prevLevel := p.Level(player.Thieving)
+ p.AddSkillXP(player.Thieving, stealXP)
+ newLevel := p.Level(player.Thieving)
+ if newLevel > prevLevel {
+ sess.WriteLine(g.colorize(sess, "level_up", fmt.Sprintf("*** You are now level %d thieving! ***", newLevel)))
+ }
+ }
+
+ g.AccountStore.SaveCharacter(p)
+
+ if p.FirstFreeSlot() == -1 {
+ sess.WriteLine("Your inventory is full.")
+ g.CancelAction(p)
+ return
+ }
+
+ p.Action.WaitLeft = engine.ToTicks(stealSpeed)
+ } else {
+ if targetType == "mob" {
+ mobInstanceID := data["mob_instance_id"].(string)
+ mob := g.MobStore.GetInstance(mobInstanceID)
+ sess.WriteLine(fmt.Sprintf("The %s notices you! They attack!", targetName))
+ g.CancelAction(p)
+ if mob != nil && mob.HP > 0 {
+ g.startCombat(sess, p, mob)
+ }
+ } else {
+ if guardMob != "" && guardWatching {
+ guard := g.findGuardInRoom(p.RoomID, guardMob)
+ if guard != nil {
+ sess.WriteLine("You fumble and the Guard spots you!")
+ g.CancelAction(p)
+ g.startStealGuardTalk(sess, p, guard)
+ return
+ }
+ }
+ sess.WriteLine("You fail to steal anything.")
+ p.Action.WaitLeft = engine.ToTicks(stealSpeed)
+ }
+ }
+}
+
+func (g *Game) giveStealLoot(sess *net.Session, p *player.Player, drop *action.DropEntry) {
+ qty := drop.Quantity
+ if qty <= 0 {
+ qty = 1
+ }
+
+ if drop.ItemID == "credits" {
+ p.Credits += qty
+ sess.WriteLine(g.colorize(sess, "credits_pickup", fmt.Sprintf("You steal %d credits.", qty)))
+ return
+ }
+
+ name := drop.ItemID
+ lootDef, _ := g.ItemStore.Load(drop.ItemID)
+ if lootDef != nil {
+ name = lootDef.Name
+ }
+
+ freeSlot := p.FirstFreeSlot()
+ if freeSlot == -1 {
+ g.World.AddGroundItem(p.RoomID, drop.ItemID, qty)
+ sess.WriteLine(fmt.Sprintf("You steal %s but your inventory is full. It falls to the ground.", g.itemColorize(sess, lootDef, name)))
+ return
+ }
+
+ p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: drop.ItemID, Quantity: qty})
+ sess.WriteLine(fmt.Sprintf("You steal %s.", g.itemColorize(sess, lootDef, name)))
+}
+
+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
+}
+
+const guardWatchDuration = 8
+const guardLookAwayDuration = 4
+const guardCycleLength = guardWatchDuration + guardLookAwayDuration
+
+func (g *Game) isGuardWatching(roomID int, objDefID string) bool {
+ key := fmt.Sprintf("%d:%s", roomID, objDefID)
+ counter := g.guardWatchTimers[key]
+ return counter%guardCycleLength < guardWatchDuration
+}
+
+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
+ }
+
+ node, ok := cfg.Nodes["start"]
+ if !ok {
+ return
+ }
+
+ p.ActionState = &ActionState{Type: ActionTalking, TargetName: guardMob.Name}
+
+ p.Action = &action.Action{
+ Type: "talk",
+ TargetID: guardMob.DefID,
+ TargetName: guardMob.Name,
+ Data: map[string]any{"node": "start", "behavior_id": "stall_guard_talk", "steal_guard": true},
+ }
+
+ g.showTalkNode(sess, node)
+}
diff --git a/internal/game/action_talk.go b/internal/game/action_talk.go
index 2d4a814..1a21e7f 100644
--- a/internal/game/action_talk.go
+++ b/internal/game/action_talk.go
@@ -57,7 +57,27 @@ func (g *Game) showTalkNode(sess *net.Session, node action.TalkNode) {
sess.WriteLine(fmt.Sprintf("\n%s", g.colorize(sess, "dialog", node.Message)))
if node.Action != nil {
+ if node.Action.Shop != nil {
+ g.applyNodeAction(sess, node.Action)
+ g.enterShop(sess, node.Action.Shop)
+ return
+ }
g.applyNodeAction(sess, node.Action)
+
+ if g.WorldFlags["guard_hostile"] != nil {
+ delete(g.WorldFlags, "guard_hostile")
+ p, _ := sess.Player.(*player.Player)
+ if p != nil && p.Action != nil && p.Action.Data["steal_guard"] == true {
+ guardMob := g.findGuardInRoom(p.RoomID, "guard")
+ if guardMob != nil {
+ sess.State = net.StateGame
+ g.CancelAction(p)
+ guardMob.Protected = false
+ g.startCombat(sess, p, guardMob)
+ }
+ return
+ }
+ }
}
if len(node.Options) == 0 {
@@ -162,11 +182,38 @@ func (g *Game) handleTalkInput(sess *net.Session, input string) {
g.writePrompt(sess)
}
+func (g *Game) enterShop(sess *net.Session, cfg *action.ShopConfig) {
+ sess.Shop = cfg
+ sess.State = net.StateShop
+ g.showShopBrowse(sess)
+ g.writeShopPrompt(sess)
+}
+
func (g *Game) applyNodeAction(sess *net.Session, na *action.NodeAction) {
p, ok := sess.Player.(*player.Player)
if !ok {
return
}
+
+ if na.AssignTask {
+ g.assignAssassinTask(sess, p)
+ }
+ if na.SkipTask {
+ g.skipAssassinTask(sess, p)
+ }
+ if na.ExtendTask {
+ g.extendAssassinTask(sess, p)
+ }
+ if na.ReputationCost > 0 {
+ rep := getPlayerFlagInt(p, "assassin_reputation")
+ if rep < na.ReputationCost {
+ sess.WriteLine(fmt.Sprintf("You don't have enough Reputation. (Need %d, have %d)", na.ReputationCost, rep))
+ return
+ }
+ setPlayerFlag(p, "assassin_reputation", rep-na.ReputationCost)
+ g.AccountStore.SaveCharacter(p)
+ }
+
for k, v := range na.SetFlags {
g.WorldFlags[k] = v
}
diff --git a/internal/game/assassin.go b/internal/game/assassin.go
new file mode 100644
index 0000000..d7af1a4
--- /dev/null
+++ b/internal/game/assassin.go
@@ -0,0 +1,284 @@
+package game
+
+import (
+ "fmt"
+ "math/rand"
+ "strings"
+
+ "thehouseoficarus/internal/combat"
+ "thehouseoficarus/internal/net"
+ "thehouseoficarus/internal/player"
+ "thehouseoficarus/internal/world"
+)
+
+func getPlayerFlagInt(p *player.Player, key string) int {
+ if p.Flags == nil {
+ return 0
+ }
+ val, ok := p.Flags[key]
+ if !ok {
+ return 0
+ }
+ switch v := val.(type) {
+ case int:
+ return v
+ case int64:
+ return int(v)
+ case float64:
+ return int(v)
+ }
+ return 0
+}
+
+func getPlayerFlagString(p *player.Player, key string) string {
+ if p.Flags == nil {
+ return ""
+ }
+ val, ok := p.Flags[key]
+ if !ok {
+ return ""
+ }
+ s, _ := val.(string)
+ return s
+}
+
+func setPlayerFlag(p *player.Player, key string, val any) {
+ if p.Flags == nil {
+ p.Flags = make(map[string]any)
+ }
+ p.Flags[key] = val
+}
+
+type assassinTaskEntry struct {
+ MobID string
+ MinLevel int
+ MaxLevel int
+ MinCount int
+ MaxCount int
+ Weight int
+}
+
+var assassinTaskTable = []assassinTaskEntry{
+ {"man", 1, 15, 10, 25, 8},
+ {"cow", 1, 15, 10, 25, 8},
+ {"slug", 1, 99, 15, 45, 15},
+ {"drone", 15, 99, 20, 50, 12},
+ {"crawler", 30, 99, 15, 40, 10},
+ {"phantom", 45, 99, 10, 30, 8},
+}
+
+func (g *Game) assignAssassinTask(sess *net.Session, p *player.Player) {
+ level := p.Level(player.Assassin)
+ var eligible []assassinTaskEntry
+ totalWeight := 0
+ for _, entry := range assassinTaskTable {
+ if level >= entry.MinLevel && level <= entry.MaxLevel {
+ eligible = append(eligible, entry)
+ totalWeight += entry.Weight
+ }
+ }
+ if len(eligible) == 0 {
+ sess.WriteLine("The Client shakes their head. \"Nothing available for your level.\"")
+ return
+ }
+ roll := rand.Intn(totalWeight)
+ var chosen assassinTaskEntry
+ for _, entry := range eligible {
+ roll -= entry.Weight
+ if roll < 0 {
+ chosen = entry
+ break
+ }
+ }
+ count := chosen.MinCount + rand.Intn(chosen.MaxCount-chosen.MinCount+1)
+ setPlayerFlag(p, "assassin_task_mob", chosen.MobID)
+ setPlayerFlag(p, "assassin_task_total", count)
+ setPlayerFlag(p, "assassin_task_remaining", count)
+ g.AccountStore.SaveCharacter(p)
+
+ def, err := g.MobStore.LoadDef(chosen.MobID)
+ name := chosen.MobID
+ if err == nil {
+ name = def.Name
+ }
+ sess.WriteLine(g.colorize(sess, "assassin_task", fmt.Sprintf("\"Your target: %d %ss. Get to work.\"", count, name)))
+}
+
+func (g *Game) onAssassinKill(sess *net.Session, p *player.Player, mob *world.MobInstance) {
+ taskMob := getPlayerFlagString(p, "assassin_task_mob")
+ if taskMob == "" || taskMob != mob.DefID {
+ return
+ }
+ remaining := getPlayerFlagInt(p, "assassin_task_remaining")
+ if remaining <= 0 {
+ return
+ }
+ xp := mob.MaxHP * 2
+ newLevel := p.AddSkillXP(player.Assassin, xp)
+ if newLevel > 0 {
+ sess.WriteLine(g.colorize(sess, "level_up", fmt.Sprintf("*** You are now level %d Assassin! ***", newLevel)))
+ }
+ if p.OptionBool("xp_drops") {
+ sess.WriteLine(g.colorize(sess, "xp", fmt.Sprintf(" (+%dxp asm)", xp)))
+ }
+ remaining--
+ setPlayerFlag(p, "assassin_task_remaining", remaining)
+ if remaining <= 0 {
+ completed := getPlayerFlagInt(p, "assassin_tasks_completed") + 1
+ streak := getPlayerFlagInt(p, "assassin_streak") + 1
+ setPlayerFlag(p, "assassin_tasks_completed", completed)
+ setPlayerFlag(p, "assassin_streak", streak)
+ delete(p.Flags, "assassin_task_mob")
+ setPlayerFlag(p, "assassin_task_remaining", 0)
+ setPlayerFlag(p, "assassin_task_total", 0)
+ rep := 1
+ bonus := streakBonus(streak)
+ rep += bonus
+ currentRep := getPlayerFlagInt(p, "assassin_reputation")
+ setPlayerFlag(p, "assassin_reputation", currentRep+rep)
+ sess.WriteLine(g.colorize(sess, "assassin_task", fmt.Sprintf("\n*** Assassin task complete! ***")))
+ if bonus > 0 {
+ sess.WriteLine(g.colorize(sess, "assassin_task", fmt.Sprintf(" Streak bonus! %d tasks in a row. +%d bonus reputation.", streak, bonus)))
+ }
+ sess.WriteLine(g.colorize(sess, "assassin_task", fmt.Sprintf(" Reputation earned: %d (total: %d)", rep, currentRep+rep)))
+ } else {
+ total := getPlayerFlagInt(p, "assassin_task_total")
+ def, _ := g.MobStore.LoadDef(taskMob)
+ name := taskMob
+ if def != nil {
+ name = def.Name
+ }
+ sess.WriteLine(g.colorize(sess, "assassin_task", fmt.Sprintf(" Assassin task: %d of %d %ss remaining.", remaining, total, name)))
+ }
+ g.AccountStore.SaveCharacter(p)
+}
+
+func (g *Game) skipAssassinTask(sess *net.Session, p *player.Player) {
+ rep := getPlayerFlagInt(p, "assassin_reputation")
+ if rep < 30 {
+ sess.WriteLine("You don't have enough Reputation to skip. (Need 30, have " + fmt.Sprint(rep) + ")")
+ return
+ }
+ setPlayerFlag(p, "assassin_reputation", rep-30)
+ delete(p.Flags, "assassin_task_mob")
+ setPlayerFlag(p, "assassin_task_remaining", 0)
+ setPlayerFlag(p, "assassin_task_total", 0)
+ setPlayerFlag(p, "assassin_streak", 0)
+ g.AccountStore.SaveCharacter(p)
+}
+
+func (g *Game) extendAssassinTask(sess *net.Session, p *player.Player) {
+ rep := getPlayerFlagInt(p, "assassin_reputation")
+ if rep < 30 {
+ sess.WriteLine("You don't have enough Reputation to extend. (Need 30, have " + fmt.Sprint(rep) + ")")
+ return
+ }
+ taskMob := getPlayerFlagString(p, "assassin_task_mob")
+ if taskMob == "" {
+ sess.WriteLine("You don't have an active task to extend.")
+ return
+ }
+ setPlayerFlag(p, "assassin_reputation", rep-30)
+ total := getPlayerFlagInt(p, "assassin_task_total")
+ remaining := getPlayerFlagInt(p, "assassin_task_remaining")
+ extension := total / 2
+ if extension < 5 {
+ extension = 5
+ }
+ setPlayerFlag(p, "assassin_task_total", total+extension)
+ setPlayerFlag(p, "assassin_task_remaining", remaining+extension)
+ g.AccountStore.SaveCharacter(p)
+
+ def, _ := g.MobStore.LoadDef(taskMob)
+ name := taskMob
+ if def != nil {
+ name = def.Name
+ }
+ sess.WriteLine(fmt.Sprintf("Task extended by %d. Kill %d more %ss (%d total).", extension, remaining+extension, name, total+extension))
+}
+
+func streakBonus(streak int) int {
+ if streak%1000 == 0 {
+ return 50
+ }
+ if streak%250 == 0 {
+ return 35
+ }
+ if streak%100 == 0 {
+ return 25
+ }
+ if streak%50 == 0 {
+ return 15
+ }
+ if streak%10 == 0 {
+ return 5
+ }
+ return 0
+}
+
+func (g *Game) tryFinishingBlow(sess *net.Session, input string) bool {
+ p := sess.Player.(*player.Player)
+ cs := combat.GetCombat(p.Name)
+ if cs == nil {
+ return false
+ }
+ mob := g.MobStore.GetInstance(cs.MobID)
+ if mob == nil || mob.FinishingBlow == "" || mob.HP != 1 {
+ return false
+ }
+ lower := strings.ToLower(input)
+ var itemPart, targetPart string
+ for _, sep := range []string{" on ", " with "} {
+ if idx := strings.Index(lower, sep); idx > 0 {
+ itemPart = strings.TrimSpace(input[:idx])
+ targetPart = strings.TrimSpace(input[idx+len(sep):])
+ break
+ }
+ }
+ if targetPart == "" {
+ itemPart = strings.TrimSpace(input)
+ if mob.MatchQuality(itemPart) != world.MatchNone {
+ targetPart = itemPart
+ itemPart = strings.TrimSpace(mob.FinishingBlow)
+ }
+ }
+ if targetPart == "" {
+ return false
+ }
+ if mob.MatchQuality(targetPart) == world.MatchNone {
+ return false
+ }
+ g.doFinishingBlow(sess, p, mob, itemPart)
+ return true
+}
+
+func (g *Game) doFinishingBlow(sess *net.Session, p *player.Player, mob *world.MobInstance, itemInput string) {
+ fbDef, err := g.ItemStore.Load(mob.FinishingBlow)
+ if err != nil {
+ sess.WriteLine("Something went wrong.")
+ return
+ }
+ if !fbDef.MatchesName(itemInput) && !strings.EqualFold(itemInput, fbDef.ID) {
+ sess.WriteLine(fmt.Sprintf("That won't work on %s. You need %s.", mobDisplayName(mob, true), fbDef.Name))
+ return
+ }
+ if !p.HasItem(mob.FinishingBlow) {
+ sess.WriteLine(fmt.Sprintf("You don't have any %s.", fbDef.Name))
+ return
+ }
+ autoKey := "assassin_unlocked_auto_" + mob.FinishingBlow
+ consumed := true
+ if p.Flags != nil {
+ if val, ok := p.Flags[autoKey]; ok {
+ if b, ok := val.(bool); ok && b {
+ consumed = false
+ }
+ }
+ }
+ if consumed {
+ p.RemoveItem(mob.FinishingBlow, 1)
+ }
+ sess.WriteLine(fmt.Sprintf("\nYou use the %s on %s!", g.itemColorize(sess, fbDef, fbDef.Name), g.colorize(sess, "mob_name", mobDisplayName(mob, true))))
+ mob.HP = 0
+ g.endCombat(sess, p, mob)
+}
diff --git a/internal/game/cmd_attack.go b/internal/game/cmd_attack.go
index 29bff30..a8dc5d3 100644
--- a/internal/game/cmd_attack.go
+++ b/internal/game/cmd_attack.go
@@ -41,6 +41,11 @@ func (g *Game) doAttack(sess *net.Session, input string) {
return
}
+ if mob.AssassinLevel > 0 && p.Level(player.Assassin) < mob.AssassinLevel {
+ sess.WriteLine(fmt.Sprintf("You need Assassin level %d to attack %s.", mob.AssassinLevel, mobDisplayName(mob, true)))
+ return
+ }
+
if combat.IsMobInCombat(mob.InstanceID) {
sess.WriteLine(fmt.Sprintf("%s is already engaged in combat!", mobDisplayName(mob, false)))
return
@@ -268,9 +273,23 @@ func (g *Game) playerAttack(sess *net.Session, p *player.Player, mob *world.MobI
if mob.HP < 0 {
mob.HP = 0
}
+ if mob.FinishingBlow != "" && mob.HP <= 0 {
+ mob.HP = 1
+ }
if mob.HP < mob.MaxHP && mob.HP > 0 {
mob.StartRegen()
}
+
+ if mob.FinishingBlow != "" && mob.HP == 1 {
+ fbDef, _ := g.ItemStore.Load(mob.FinishingBlow)
+ fbName := mob.FinishingBlow
+ if fbDef != nil {
+ fbName = fbDef.Name
+ }
+ sess.WriteLine(g.colorize(sess, "warning",
+ fmt.Sprintf(" %s resists death! Use %s on it to finish it off.",
+ mobDisplayName(mob, false), fbName)))
+ }
gains, leveled := g.awardCombatXP(p, dmg, isRanged)
if isRanged {
@@ -329,6 +348,7 @@ func (g *Game) playerAttack(sess *net.Session, p *player.Player, mob *world.MobI
}
func (g *Game) mobAttack(sess *net.Session, p *player.Player, mob *world.MobInstance) {
+ cs := combat.GetCombat(p.Name)
_, _, defStyleBonus := combat.AttackStyleBonus(string(p.AttackStyle))
mobAttackType := mob.AttackType
@@ -349,6 +369,37 @@ func (g *Game) mobAttack(sess *net.Session, p *player.Player, mob *world.MobInst
dmg := combat.RollDamage(maxHit)
dmg = g.applyTechProtection(p, mob, dmg)
+ if mob.DamageWithout != "" {
+ hasProtection := false
+ for _, itemID := range p.Equipment {
+ if itemID == mob.DamageWithout {
+ hasProtection = true
+ break
+ }
+ }
+ if !hasProtection {
+ dmg = dmg * 3 / 2
+ if dmg < 1 {
+ dmg = 1
+ }
+ if cs != nil && !cs.DamageWarningShown {
+ cs.DamageWarningShown = true
+ fbDef, _ := g.ItemStore.Load(mob.DamageWithout)
+ fbName := mob.DamageWithout
+ if fbDef != nil {
+ fbName = fbDef.Name
+ }
+ attacker := mob.Name
+ if !mob.Unique {
+ attacker = "The " + mob.Name
+ }
+ sess.WriteLine(g.colorize(sess, "warning",
+ fmt.Sprintf(" %s's attack is extra effective! Equip %s for protection.",
+ attacker, fbName)))
+ }
+ }
+ }
+
p.HP -= dmg
if p.HP < 0 {
p.HP = 0
@@ -421,6 +472,9 @@ func (g *Game) endCombat(sess *net.Session, p *player.Player, mob *world.MobInst
if mob != nil && mob.HP <= 0 {
sess.WriteLine(g.colorize(sess, "victory", fmt.Sprintf("\nYou have defeated %s!", mobDisplayName(mob, true))))
+
+ g.onAssassinKill(sess, p, mob)
+
if g.Hub != nil {
for _, other := range g.Hub.PlayersInRoom(p.RoomID) {
if other != sess && other.Player != nil {
diff --git a/internal/game/cmd_shop.go b/internal/game/cmd_shop.go
new file mode 100644
index 0000000..a3f7613
--- /dev/null
+++ b/internal/game/cmd_shop.go
@@ -0,0 +1,295 @@
+package game
+
+import (
+ "fmt"
+ "strings"
+
+ "thehouseoficarus/internal/action"
+ "thehouseoficarus/internal/net"
+ "thehouseoficarus/internal/player"
+)
+
+func (g *Game) handleShopInput(sess *net.Session, input string) {
+ if sess.Player == nil {
+ sess.State = net.StateGame
+ g.writePrompt(sess)
+ return
+ }
+
+ input = strings.TrimSpace(input)
+ parts := strings.Fields(strings.ToLower(input))
+ if len(parts) == 0 {
+ g.showShopBrowse(sess)
+ return
+ }
+
+ cmd := parts[0]
+
+ switch cmd {
+ case "buy":
+ if len(parts) < 2 {
+ sess.WriteLine("Buy what? Use 'browse' to see available items.")
+ } else {
+ g.doShopBuy(sess, strings.Join(parts[1:], " "))
+ }
+ case "sell":
+ if len(parts) < 2 {
+ sess.WriteLine("Sell what?")
+ } else {
+ g.doShopSell(sess, strings.Join(parts[1:], " "))
+ }
+ case "browse", "list":
+ g.showShopBrowse(sess)
+ case "leave", "bye", "exit", "quit":
+ g.leaveShop(sess)
+ return
+ default:
+ sess.WriteLine("Commands: buy <item>, sell <item>, browse, leave")
+ }
+ g.writeShopPrompt(sess)
+}
+
+func (g *Game) writeShopPrompt(sess *net.Session) {
+ sess.Write(fmt.Sprintf("\n%s", g.colorize(sess, "dialog", "> ")))
+}
+
+func (g *Game) shopConfig(sess *net.Session) *action.ShopConfig {
+ cfg, _ := sess.Shop.(*action.ShopConfig)
+ return cfg
+}
+
+func (g *Game) showShopBrowse(sess *net.Session) {
+ cfg := g.shopConfig(sess)
+ if cfg == nil {
+ return
+ }
+
+ if cfg.Message != "" {
+ sess.WriteLine(fmt.Sprintf("\n%s", g.colorize(sess, "dialog", cfg.Message)))
+ }
+
+ if len(cfg.Items) == 0 {
+ sess.WriteLine("This shop has nothing for sale.")
+ return
+ }
+
+ unicode := true
+ if p, ok := sess.Player.(*player.Player); ok {
+ unicode = p.OptionBool("unicode")
+ }
+
+ t := Table{
+ Title: "Shop Inventory",
+ Columns: []string{"#", "Item", "Buy Price", "Sell Price"},
+ }
+
+ for i, si := range cfg.Items {
+ def, _ := g.ItemStore.Load(si.ItemID)
+ itemName := si.ItemID
+ if def != nil {
+ itemName = def.Name
+ }
+ buyStr := fmt.Sprintf("%d cr", si.BuyPrice)
+ sellStr := "\u2014"
+ if si.SellPrice > 0 {
+ sellStr = fmt.Sprintf("%d cr", si.SellPrice)
+ }
+ t.Rows = append(t.Rows, []string{
+ fmt.Sprintf("%d", i+1),
+ g.colorize(sess, "item", itemName),
+ g.colorize(sess, "credits_pickup", buyStr),
+ g.colorize(sess, "credits_pickup", sellStr),
+ })
+ }
+
+ for _, line := range t.Render(unicode) {
+ sess.WriteLine(line)
+ }
+}
+
+func (g *Game) doShopBuy(sess *net.Session, input string) {
+ cfg := g.shopConfig(sess)
+ if cfg == nil {
+ return
+ }
+
+ idx, err := parseChoiceIndex(input)
+ if err == nil && idx > 0 && idx <= len(cfg.Items) {
+ si := cfg.Items[idx-1]
+ g.buyShopItem(sess, si)
+ return
+ }
+
+ var match *action.ShopItem
+ for i := range cfg.Items {
+ si := &cfg.Items[i]
+ def, _ := g.ItemStore.Load(si.ItemID)
+ itemName := si.ItemID
+ if def != nil {
+ itemName = def.Name
+ }
+
+ invMatches := g.collectMatches(input, func(yield func(string, int) bool) {
+ yield(itemName, -1)
+ })
+
+ if len(invMatches) > 0 {
+ if match != nil {
+ sess.WriteLine(fmt.Sprintf("That's ambiguous, which one? (use #, e.g. buy %s 1)", si.ItemID))
+ return
+ }
+ match = si
+ }
+ }
+
+ if match != nil {
+ g.buyShopItem(sess, *match)
+ return
+ }
+
+ sess.WriteLine("That item isn't for sale here. Use 'browse' to see shop inventory.")
+}
+
+func (g *Game) buyShopItem(sess *net.Session, si action.ShopItem) {
+ p, _ := sess.Player.(*player.Player)
+
+ if p.Credits < si.BuyPrice {
+ sess.WriteLine(fmt.Sprintf("You need %d credits to buy that (you have %d).", si.BuyPrice, p.Credits))
+ return
+ }
+
+ def, _ := g.ItemStore.Load(si.ItemID)
+ displayName := si.ItemID
+ if def != nil {
+ displayName = def.Name
+ }
+
+ if def != nil && def.Stackable {
+ for i := 0; i < 28; i++ {
+ slot := p.InvSlot(i)
+ if slot != nil && slot.ItemID == si.ItemID {
+ p.Credits -= si.BuyPrice
+ slot.Quantity++
+ g.AccountStore.SaveCharacter(p)
+ itemColor := g.itemColorize(sess, def, displayName)
+ sess.WriteLine(fmt.Sprintf("You buy a %s for %s credits.", itemColor, g.colorize(sess, "credits_pickup", fmt.Sprintf("%d", si.BuyPrice))))
+ return
+ }
+ }
+ }
+
+ freeSlot := p.FirstFreeSlot()
+ if freeSlot == -1 {
+ sess.WriteLine("Your inventory is full.")
+ return
+ }
+
+ p.Credits -= si.BuyPrice
+ p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: si.ItemID, Quantity: 1})
+ g.AccountStore.SaveCharacter(p)
+
+ itemColor := g.itemColorize(sess, def, displayName)
+ sess.WriteLine(fmt.Sprintf("You buy a %s for %s credits.", itemColor, g.colorize(sess, "credits_pickup", fmt.Sprintf("%d", si.BuyPrice))))
+}
+
+func (g *Game) doShopSell(sess *net.Session, input string) {
+ cfg := g.shopConfig(sess)
+ if cfg == nil {
+ return
+ }
+
+ p, _ := sess.Player.(*player.Player)
+
+ matches := g.findInventoryMatches(input, p)
+ if len(matches) == 0 {
+ sess.WriteLine("You don't have that item.")
+ return
+ }
+
+ if len(matches) > 1 {
+ sess.WriteLine("That's ambiguous, which one?")
+ return
+ }
+
+ match := matches[0]
+
+ var shopItem *action.ShopItem
+ for i := range cfg.Items {
+ si := &cfg.Items[i]
+ if si.ItemID == match.ID && si.SellPrice > 0 {
+ shopItem = si
+ break
+ }
+ }
+
+ if shopItem == nil {
+ def, _ := g.ItemStore.Load(match.ID)
+ displayName := match.ID
+ if def != nil {
+ displayName = def.Name
+ }
+ sess.WriteLine(fmt.Sprintf("The shop doesn't want to buy %s.", g.colorize(sess, "item", displayName)))
+ return
+ }
+
+ p.RemoveItem(match.ID, 1)
+ p.Credits += shopItem.SellPrice
+ g.AccountStore.SaveCharacter(p)
+
+ def, _ := g.ItemStore.Load(match.ID)
+ displayName := match.ID
+ if def != nil {
+ displayName = def.Name
+ }
+ itemColor := g.itemColorize(sess, def, displayName)
+ sess.WriteLine(fmt.Sprintf("You sell a %s for %s credits.", itemColor, g.colorize(sess, "credits_pickup", fmt.Sprintf("%d", shopItem.SellPrice))))
+}
+
+func (g *Game) leaveShop(sess *net.Session) {
+ sess.Shop = nil
+ p, ok := sess.Player.(*player.Player)
+ if !ok || p == nil || p.Action == nil || p.Action.Type != "talk" {
+ sess.State = net.StateGame
+ if p != nil {
+ g.CancelAction(p)
+ }
+ g.writePrompt(sess)
+ return
+ }
+
+ behaviorID, _ := p.Action.Data["behavior_id"].(string)
+ nodeKey, _ := p.Action.Data["node"].(string)
+
+ cfg, err := g.BehaviorStore.LoadTalk(behaviorID)
+ if err != nil {
+ sess.State = net.StateGame
+ g.CancelAction(p)
+ g.writePrompt(sess)
+ return
+ }
+
+ node, ok := cfg.Nodes[nodeKey]
+ if !ok {
+ sess.State = net.StateGame
+ g.CancelAction(p)
+ g.writePrompt(sess)
+ return
+ }
+
+ sess.State = net.StateTalk
+ visible := 0
+ for _, opt := range node.Options {
+ if opt.Condition != nil && !g.checkCondition(sess, opt.Condition) {
+ continue
+ }
+ visible++
+ sess.WriteLine(fmt.Sprintf(" %d. %s", visible, opt.Text))
+ }
+ if visible == 0 {
+ sess.State = net.StateGame
+ g.CancelAction(p)
+ g.writePrompt(sess)
+ return
+ }
+ sess.Write("\nChoice: ")
+}
diff --git a/internal/game/cmd_sneak.go b/internal/game/cmd_sneak.go
new file mode 100644
index 0000000..97aab8a
--- /dev/null
+++ b/internal/game/cmd_sneak.go
@@ -0,0 +1,71 @@
+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
+ }
+
+ 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
+ }
+
+ 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)
+
+ if p.SneakNotified == nil {
+ p.SneakNotified = make(map[string]bool)
+ }
+
+ lastState, known := p.SneakNotified[timerKey]
+ if !known || lastState != watching {
+ if watching {
+ sess.WriteLine(fmt.Sprintf("The %s is watching the %s.", guard.Name, objDef.Name))
+ } else {
+ sess.WriteLine(fmt.Sprintf("The %s looks away from the %s.", guard.Name, objDef.Name))
+ }
+ p.SneakNotified[timerKey] = watching
+ }
+ }
+ }
+}
diff --git a/internal/game/cmd_task.go b/internal/game/cmd_task.go
new file mode 100644
index 0000000..2dcf3f4
--- /dev/null
+++ b/internal/game/cmd_task.go
@@ -0,0 +1,34 @@
+package game
+
+import (
+ "fmt"
+
+ "thehouseoficarus/internal/net"
+ "thehouseoficarus/internal/player"
+)
+
+func (g *Game) doTask(sess *net.Session) {
+ p := sess.Player.(*player.Player)
+
+ mobID := getPlayerFlagString(p, "assassin_task_mob")
+ remaining := getPlayerFlagInt(p, "assassin_task_remaining")
+ total := getPlayerFlagInt(p, "assassin_task_total")
+ streak := getPlayerFlagInt(p, "assassin_streak")
+ rep := getPlayerFlagInt(p, "assassin_reputation")
+
+ if mobID == "" || remaining <= 0 {
+ if getPlayerFlagInt(p, "assassin_tasks_completed") > 0 {
+ sess.WriteLine("You have no active task. Talk to The Client for a new assignment.")
+ } else {
+ sess.WriteLine("You don't have an Assassin task. Talk to The Client to get one.")
+ }
+ } else {
+ def, err := g.MobStore.LoadDef(mobID)
+ name := mobID
+ if err == nil {
+ name = def.Name
+ }
+ sess.WriteLine(fmt.Sprintf("Assassin task: Kill %ss. %d of %d remaining.", name, remaining, total))
+ }
+ sess.WriteLine(fmt.Sprintf("Streak: %d | Reputation: %d", streak, rep))
+}
diff --git a/internal/game/game.go b/internal/game/game.go
index 9d8a2c9..8b99731 100644
--- a/internal/game/game.go
+++ b/internal/game/game.go
@@ -52,8 +52,9 @@ type Game struct {
combatPadWidth int
freeQueue map[string][]QueuedCommand
activeQueue map[string]*QueuedCommand
- consumeQueue map[string]*QueuedCommand
+ consumeQueue map[string]*QueuedCommand
pendingDepletions []pendingDepletion
+ guardWatchTimers map[string]int
}
func New(dataDir string, colorConfig *config.ColorsConfig) *Game {
@@ -75,6 +76,7 @@ func New(dataDir string, colorConfig *config.ColorsConfig) *Game {
activeQueue: make(map[string]*QueuedCommand),
consumeQueue: make(map[string]*QueuedCommand),
pendingDepletions: nil,
+ guardWatchTimers: make(map[string]int),
}
}
@@ -121,6 +123,8 @@ func (g *Game) HandleSession(sess *net.Session, input string) {
g.handleDescriptionChange(sess, input)
case net.StateTalk:
g.handleTalkInput(sess, input)
+ case net.StateShop:
+ g.handleShopInput(sess, input)
case net.StateDropAllConfirm:
g.handleDropAllConfirm(sess, input)
case net.StateRecipeChoice:
@@ -141,8 +145,9 @@ func classifyCommand(cmd string) CommandClass {
"map", "option", "options", "alias", "unalias",
"description", "desc", "queued", "color", "colors",
"colortable", "prompt", "style", "stats",
- "tech", "t",
- "autocast", "auto", "mods", "modlist":
+ "tech", "t", "sneak",
+ "autocast", "auto", "mods", "modlist",
+ "task":
return ClassInstant
case "equip", "eq", "equipment", "wear", "wield", "remove", "unwear", "unwield":
return ClassFree
@@ -152,6 +157,7 @@ func classifyCommand(cmd string) CommandClass {
"west", "w", "up", "u", "down", "d",
"quit", "use", "burn", "stoke", "search", "walk", "cook", "smelt", "smith", "craft", "mix",
"id", "identify",
+ "steal", "thieve",
"trigger", "cast":
return ClassActive
case "eat", "fletch", "clean":
@@ -319,6 +325,8 @@ func (g *Game) executeCommand(sess *net.Session, cmd string, args []string, rawI
}
case "sc", "score":
g.doScore(sess)
+ case "task":
+ g.doTask(sess)
case "stats":
g.doStats(sess)
case "tech", "t":
@@ -374,6 +382,9 @@ func (g *Game) executeCommand(sess *net.Session, cmd string, args []string, rawI
return
}
case "use":
+ if g.tryFinishingBlow(sess, strings.Join(args, " ")) {
+ return
+ }
g.doUse(sess, strings.Join(args, " "))
return
case "cook":
@@ -407,6 +418,8 @@ func (g *Game) executeCommand(sess *net.Session, cmd string, args []string, rawI
g.doAutocast(sess, strings.Join(args, " "))
case "mods", "modlist":
g.doMods(sess)
+ case "sneak":
+ g.doSneak(sess)
case "trigger", "cast":
g.doTrigger(sess, strings.Join(args, " "))
return
@@ -440,6 +453,14 @@ func (g *Game) executeCommand(sess *net.Session, cmd string, args []string, rawI
g.doSearch(sess, strings.Join(args, " "))
}
return
+ case "steal", "thieve":
+ g.CancelAction(p)
+ if len(args) == 0 {
+ g.doSteal(sess, "")
+ } else {
+ g.doSteal(sess, strings.Join(args, " "))
+ }
+ return
case "walk":
g.doWalk(sess, args)
return
@@ -495,7 +516,8 @@ func (g *Game) ProcessQueuedCommands() {
}
switch as.Type {
case ActionGathering, ActionCombating, ActionUsing, ActionTalking,
- ActionToggling, ActionBurning, ActionStoking, ActionResting, ActionWalking, ActionProducing:
+ ActionToggling, ActionBurning, ActionStoking, ActionResting, ActionWalking, ActionProducing,
+ ActionStealing:
default:
if as.Type != ActionMoving || p.MoveTicks <= 0 {
p.ActionState = nil
diff --git a/internal/net/server.go b/internal/net/server.go
index f6776fc..7c1b79a 100644
--- a/internal/net/server.go
+++ b/internal/net/server.go
@@ -38,6 +38,7 @@ const (
StateCraftProduct
StateProductChoice
StateColorChoice
+ StateShop
)
type Session struct {
@@ -54,6 +55,7 @@ type Session struct {
PendingBackground bool
Disconnecting bool
DisconnectTicks int
+ Shop interface{}
}
type AccountEntry struct {
diff --git a/internal/object/object.go b/internal/object/object.go
index fe55865..8c4c752 100644
--- a/internal/object/object.go
+++ b/internal/object/object.go
@@ -19,4 +19,9 @@ type ObjectDef struct {
RemovalItem string `yaml:"removal_item"`
Description string `yaml:"description"`
UseInteractions []UseInteraction `yaml:"use_interactions"`
+ 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"`
}
diff --git a/internal/player/player.go b/internal/player/player.go
index dbbd4da..a3ca4ce 100644
--- a/internal/player/player.go
+++ b/internal/player/player.go
@@ -182,6 +182,8 @@ type Player struct {
ActiveTechs map[string]bool `yaml:"-"`
QuickTech string `yaml:"quick_tech,omitempty"`
TechActivatedSinceTick map[string]bool `yaml:"-"`
+ Sneaking bool `yaml:"-"`
+ SneakNotified map[string]bool `yaml:"-"`
}
func (p *Player) ClearMoveState() {
diff --git a/internal/world/mob.go b/internal/world/mob.go
index daf73dc..ce9eddd 100644
--- a/internal/world/mob.go
+++ b/internal/world/mob.go
@@ -47,7 +47,15 @@ type MobDef struct {
ScienceDefense int `yaml:"science_defense"`
RangedDefense int `yaml:"ranged_defense"`
- Weakness string `yaml:"weakness"`
+ Weakness string `yaml:"weakness"`
+ StealTable string `yaml:"steal_table"`
+ StealLevel int `yaml:"steal_level"`
+ StealXP int `yaml:"steal_xp"`
+ StealSpeed float64 `yaml:"steal_speed"`
+
+ AssassinLevel int `yaml:"assassin_level"`
+ FinishingBlow string `yaml:"finishing_blow"`
+ DamageWithout string `yaml:"damage_without"`
}
type MobInstance struct {
@@ -86,7 +94,15 @@ type MobInstance struct {
ScienceDefense int
RangedDefense int
- Weakness string
+ Weakness string
+ StealTable string
+ StealLevel int
+ StealXP int
+ StealSpeed float64
+
+ AssassinLevel int
+ FinishingBlow string
+ DamageWithout string
}
const (
@@ -292,6 +308,13 @@ func (s *MobStore) SeedMobs(roomID int, entries []RoomMob) {
ScienceDefense: dw.def.ScienceDefense,
RangedDefense: dw.def.RangedDefense,
Weakness: dw.def.Weakness,
+ StealTable: dw.def.StealTable,
+ StealLevel: dw.def.StealLevel,
+ StealXP: dw.def.StealXP,
+ StealSpeed: dw.def.StealSpeed,
+ AssassinLevel: dw.def.AssassinLevel,
+ FinishingBlow: dw.def.FinishingBlow,
+ DamageWithout: dw.def.DamageWithout,
}
inst.IdleDescription = pickIdleDescription(dw.def.IdleDescriptions)
s.instances[instID] = inst
diff --git a/worldbuilding_guide/behaviors.md b/worldbuilding_guide/behaviors.md
index 8e47883..aa46073 100644
--- a/worldbuilding_guide/behaviors.md
+++ b/worldbuilding_guide/behaviors.md
@@ -197,6 +197,12 @@ nodes:
| `take_item` | Removes an item from the player's inventory |
| `teleport` | Moves the player to a room ID |
| `heal` | Restores that many hitpoints |
+| `cost` | Credits charged for the action |
+| `shop` | Opens a buy/sell shop interface (see Shop section below) |
+| `assign_task` | Assigns a random assassin task to the player based on their assassin level |
+| `skip_task` | Cancels current assassin task, costs 30 reputation, resets streak |
+| `extend_task` | Adds 50% more kills to current task, costs 30 reputation |
+| `reputation_cost` | Deducts reputation from the player's `assassin_reputation` flag (fails node if insufficient) |
All fields in a single action are processed together — you can give an item, take an item, set flags, and heal all in one node.
@@ -212,6 +218,67 @@ action:
teleport: 1 # return to town
```
+#### Shop (buy/sell interface)
+
+The `shop` node action opens a dedicated buy/sell interface. The player can browse
+items, buy with credits, and sell items back. Define a shop on a talk node:
+
+```yaml
+action:
+ shop:
+ message: "What would you like to buy or sell?"
+ items:
+ - item_id: fishing_rod
+ buy_price: 10
+ sell_price: 2
+ - item_id: fishing_bait
+ buy_price: 2
+ sell_price: 0
+```
+
+| Field | Description |
+|---|---|
+| `shop.message` | Greeting shown when entering the shop |
+| `shop.items` | List of items for sale |
+| `item_id` | Item definition ID |
+| `buy_price` | Credits to buy from shop |
+| `sell_price` | Credits shop pays (0 = won't buy) |
+
+Typical shop flow: a talk node with a "Browse" option leads to a `shop` node.
+When the player leaves the shop, they return to the talk node's options.
+
+Full example — General Store:
+```yaml
+id: general_store
+type: talk
+nodes:
+ start:
+ message: "\"Welcome to the General Store!\""
+ options:
+ - text: "\"I'd like to browse.\""
+ goto: shop
+ - text: "\"Goodbye.\""
+ end: true
+ shop:
+ message: "\"Take your time.\""
+ action:
+ shop:
+ message: "What would you like to buy or sell?"
+ items:
+ - item_id: fishing_rod
+ buy_price: 10
+ sell_price: 2
+ - item_id: hammer
+ buy_price: 20
+ sell_price: 5
+ options:
+ - text: "\"I'm done.\""
+ goto: start
+```
+
+Node options on the shop node are shown when the player leaves the shop.
+Use `goto: start` to loop back to the main greeting.
+
### Toggle (levers, switches, gates)
Simple toggle that sets a world flag:
diff --git a/worldbuilding_guide/conditions.md b/worldbuilding_guide/conditions.md
index ce9f74c..773230b 100644
--- a/worldbuilding_guide/conditions.md
+++ b/worldbuilding_guide/conditions.md
@@ -28,6 +28,10 @@ condition:
condition:
has_item: bronze_key
not: true
+
+# Check if player has enough credits
+condition:
+ min_credits: 50
```
### Compound conditions
diff --git a/worldbuilding_guide/mobs.md b/worldbuilding_guide/mobs.md
index 55c41b0..3826dda 100644
--- a/worldbuilding_guide/mobs.md
+++ b/worldbuilding_guide/mobs.md
@@ -104,5 +104,69 @@ idle_descriptions:
Note: `wander_rooms` and `wander_interval` are NOT set on the mob definition. Wander config
is per-instance in the room YAML (see Rooms > Mobs section above).
+### Stealable Mobs
+
+Mobs can be stealable via the `steal` command. On failure, the mob turns hostile and attacks.
+
+```yaml
+id: farmer
+name: Farmer
+steal_table: farmer_steal # Drop table for steal loot
+steal_level: 10 # Required thieving level
+steal_xp: 15 # XP per successful steal
+steal_speed: 4 # Ticks per steal attempt
+```
+
+| Field | Description |
+|---|---|
+| `steal_table` | Drop table ID for loot when stealing |
+| `steal_level` | Required thieving level |
+| `steal_xp` | XP awarded per successful steal |
+| `steal_speed` | Ticks per steal attempt (base wait) |
+
+### Assassin Mobs
+
+Assassin (Slayer) mobs restrict combat by assassin level and introduce finishing blows and protective equipment:
+
+```yaml
+id: slug
+name: slug
+assassin_level: 1 # required assassin level to attack
+finishing_blow: salt # item required to kill (mob stays at 1 HP otherwise)
+attack: 3
+strength: 3
+defense: 1
+hp: 15
+speed: 6
+drops:
+ remains: slug_mucus
+ loot:
+ - item_id: credits
+ weight: 100
+ quantity: 15
+```
+
+Damage-without mobs deal 1.5x damage unless the player has the specified item equipped:
+```yaml
+id: drone
+name: drone
+assassin_level: 15
+damage_without: insulated_gloves # 1.5x damage without this item equipped
+attack: 15
+strength: 14
+defense: 12
+hp: 45
+speed: 4
+aggressive: true
+```
+
+| Field | Description |
+|---|---|
+| `assassin_level` | Required assassin skill level to attack this mob |
+| `finishing_blow` | Item ID required to kill this mob (mob stays at 1 HP until used via `use <item> on <target>`) |
+| `damage_without` | Item ID for protection — mob deals 1.5x damage if player doesn't have it equipped |
+
+Mobs without these fields work normally (assassin_level defaults to 0, finishing_blow and damage_without default to empty).
+
---
diff --git a/worldbuilding_guide/objects.md b/worldbuilding_guide/objects.md
index 3644a18..1c1aebb 100644
--- a/worldbuilding_guide/objects.md
+++ b/worldbuilding_guide/objects.md
@@ -119,3 +119,34 @@ use_interactions:
---
+## Stealable Objects
+
+Objects can be stealable via the `steal` command. These use the same drop table system as mobs and searches.
+
+```yaml
+id: market_stall
+name: Market Stall
+description: "A wooden stall piled with food and sundries."
+steal_table: market_stall_steal
+steal_level: 5
+steal_xp: 12
+steal_speed: 5
+guard_mob: guard
+```
+
+| Field | Description |
+| ------------- | ------------------------------------------------ |
+| `steal_table` | Drop table ID for loot when stealing |
+| `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) |
+
+When `guard_mob` is set, a mob with that def ID in the same room watches the object
+on a tick-based cycle (8 ticks watching, 4 ticks looking away). While the guard is
+watching, steal success chance is halved and failures trigger a confrontation dialog.
+
+Use the `sneak` command to see guard watch state changes in real time.
+
+---
+