aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore5
-rw-r--r--AGENTS.md79
-rw-r--r--README.md49
-rw-r--r--TODO.md58
-rw-r--r--WORLDBUILDING.md196
-rw-r--r--cmd/mud/main.go32
-rw-r--r--config.yaml13
-rw-r--r--data/behaviors/chop_magic.yaml23
-rw-r--r--data/behaviors/chop_mahogany.yaml23
-rw-r--r--data/behaviors/chop_maple.yaml23
-rw-r--r--data/behaviors/chop_oak.yaml23
-rw-r--r--data/behaviors/chop_redwood.yaml23
-rw-r--r--data/behaviors/chop_teak.yaml23
-rw-r--r--data/behaviors/chop_tree.yaml21
-rw-r--r--data/behaviors/chop_willow.yaml23
-rw-r--r--data/behaviors/chop_yew.yaml23
-rw-r--r--data/behaviors/fish_angler.yaml19
-rw-r--r--data/behaviors/fish_bait.yaml29
-rw-r--r--data/behaviors/fish_cage.yaml18
-rw-r--r--data/behaviors/fish_fly.yaml29
-rw-r--r--data/behaviors/fish_harpoon.yaml28
-rw-r--r--data/behaviors/fish_net.yaml38
-rw-r--r--data/behaviors/fish_sacred_eel.yaml19
-rw-r--r--data/behaviors/fish_swamp.yaml24
-rw-r--r--data/behaviors/fish_trout.yaml17
-rw-r--r--data/behaviors/mine_adamantite.yaml21
-rw-r--r--data/behaviors/mine_clay.yaml21
-rw-r--r--data/behaviors/mine_coal.yaml21
-rw-r--r--data/behaviors/mine_copper.yaml7
-rw-r--r--data/behaviors/mine_gold.yaml21
-rw-r--r--data/behaviors/mine_iron.yaml21
-rw-r--r--data/behaviors/mine_mithril.yaml21
-rw-r--r--data/behaviors/mine_runite.yaml21
-rw-r--r--data/behaviors/mine_scrap.yaml21
-rw-r--r--data/behaviors/mine_silver.yaml21
-rw-r--r--data/behaviors/mine_tin.yaml21
-rw-r--r--data/drops/birds_nest_drop.yaml17
-rw-r--r--data/help/alias.yaml15
-rw-r--r--data/help/attack.yaml4
-rw-r--r--data/help/drop.yaml6
-rw-r--r--data/help/equipment.yaml2
-rw-r--r--data/help/fishing.yaml22
-rw-r--r--data/help/gathering.yaml24
-rw-r--r--data/help/get.yaml2
-rw-r--r--data/help/look.yaml11
-rw-r--r--data/help/mining.yaml8
-rw-r--r--data/help/movement.yaml4
-rw-r--r--data/help/quit.yaml7
-rw-r--r--data/help/remove.yaml11
-rw-r--r--data/help/search.yaml12
-rw-r--r--data/help/style.yaml3
-rw-r--r--data/help/toggle.yaml3
-rw-r--r--data/help/wear.yaml16
-rw-r--r--data/help/woodcutting.yaml22
-rw-r--r--data/items/adamant_axe.yaml15
-rw-r--r--data/items/adamant_pickaxe.yaml15
-rw-r--r--data/items/adamantite_ore.yaml6
-rw-r--r--data/items/big_fishing_net.yaml15
-rw-r--r--data/items/birds_nest.yaml6
-rw-r--r--data/items/black_axe.yaml15
-rw-r--r--data/items/black_pickaxe.yaml15
-rw-r--r--data/items/bronze_axe.yaml15
-rw-r--r--data/items/bronze_pickaxe.yaml1
-rw-r--r--data/items/clay.yaml6
-rw-r--r--data/items/coal.yaml6
-rw-r--r--data/items/copper_ore.yaml2
-rw-r--r--data/items/dragon_axe.yaml15
-rw-r--r--data/items/dragon_pickaxe.yaml15
-rw-r--r--data/items/feather.yaml6
-rw-r--r--data/items/fishing_bait.yaml6
-rw-r--r--data/items/fishing_rod.yaml10
-rw-r--r--data/items/fly_fishing_rod.yaml15
-rw-r--r--data/items/gold_ore.yaml6
-rw-r--r--data/items/harpoon.yaml15
-rw-r--r--data/items/iron_axe.yaml15
-rw-r--r--data/items/iron_ore.yaml6
-rw-r--r--data/items/iron_pickaxe.yaml15
-rw-r--r--data/items/lobster_pot.yaml15
-rw-r--r--data/items/logs.yaml6
-rw-r--r--data/items/magic_logs.yaml6
-rw-r--r--data/items/mahogany_logs.yaml6
-rw-r--r--data/items/maple_logs.yaml6
-rw-r--r--data/items/mithril_axe.yaml15
-rw-r--r--data/items/mithril_ore.yaml6
-rw-r--r--data/items/mithril_pickaxe.yaml15
-rw-r--r--data/items/raw_anchovies.yaml6
-rw-r--r--data/items/raw_anglerfish.yaml6
-rw-r--r--data/items/raw_bass.yaml6
-rw-r--r--data/items/raw_cave_eel.yaml6
-rw-r--r--data/items/raw_cod.yaml6
-rw-r--r--data/items/raw_herring.yaml6
-rw-r--r--data/items/raw_lobster.yaml6
-rw-r--r--data/items/raw_mackerel.yaml6
-rw-r--r--data/items/raw_pike.yaml6
-rw-r--r--data/items/raw_rainbow_fish.yaml6
-rw-r--r--data/items/raw_salmon.yaml6
-rw-r--r--data/items/raw_sardine.yaml6
-rw-r--r--data/items/raw_shark.yaml6
-rw-r--r--data/items/raw_shrimps.yaml6
-rw-r--r--data/items/raw_slimy_eel.yaml6
-rw-r--r--data/items/raw_swordfish.yaml6
-rw-r--r--data/items/raw_trout.yaml2
-rw-r--r--data/items/raw_tuna.yaml6
-rw-r--r--data/items/redwood_logs.yaml6
-rw-r--r--data/items/rune_axe.yaml15
-rw-r--r--data/items/rune_pickaxe.yaml15
-rw-r--r--data/items/runite_ore.yaml6
-rw-r--r--data/items/sacred_eel.yaml6
-rw-r--r--data/items/scrap_metal.yaml6
-rw-r--r--data/items/silver_ore.yaml6
-rw-r--r--data/items/small_fishing_net.yaml15
-rw-r--r--data/items/steel_axe.yaml15
-rw-r--r--data/items/steel_pickaxe.yaml15
-rw-r--r--data/items/teak_logs.yaml6
-rw-r--r--data/items/tin_ore.yaml8
-rw-r--r--data/items/uncut_diamond.yaml2
-rw-r--r--data/items/uncut_emerald.yaml2
-rw-r--r--data/items/uncut_ruby.yaml2
-rw-r--r--data/items/uncut_sapphire.yaml2
-rw-r--r--data/items/willow_logs.yaml6
-rw-r--r--data/items/yew_logs.yaml6
-rw-r--r--data/mobs/man.yaml2
-rw-r--r--data/objects/adamantite_rock.yaml3
-rw-r--r--data/objects/anglerfish_spot.yaml5
-rw-r--r--data/objects/bait_fishing_spot.yaml5
-rw-r--r--data/objects/cage_fishing_spot.yaml5
-rw-r--r--data/objects/clay_rock.yaml3
-rw-r--r--data/objects/coal_rock.yaml3
-rw-r--r--data/objects/fishing_spot.yaml3
-rw-r--r--data/objects/fly_fishing_spot.yaml5
-rw-r--r--data/objects/gold_rock.yaml3
-rw-r--r--data/objects/harpoon_fishing_spot.yaml5
-rw-r--r--data/objects/iron_rock.yaml3
-rw-r--r--data/objects/magic_tree.yaml3
-rw-r--r--data/objects/mahogany_tree.yaml3
-rw-r--r--data/objects/maple_tree.yaml3
-rw-r--r--data/objects/mithril_rock.yaml3
-rw-r--r--data/objects/net_fishing_spot.yaml5
-rw-r--r--data/objects/oak_tree.yaml3
-rw-r--r--data/objects/redwood_tree.yaml3
-rw-r--r--data/objects/runite_rock.yaml3
-rw-r--r--data/objects/sacred_eel_spot.yaml5
-rw-r--r--data/objects/scrap_pile.yaml3
-rw-r--r--data/objects/silver_rock.yaml3
-rw-r--r--data/objects/swamp_fishing_spot.yaml5
-rw-r--r--data/objects/teak_tree.yaml3
-rw-r--r--data/objects/tin_rock.yaml3
-rw-r--r--data/objects/tree.yaml3
-rw-r--r--data/objects/willow_tree.yaml3
-rw-r--r--data/objects/yew_tree.yaml3
-rw-r--r--data/rooms/1.yaml14
-rw-r--r--data/rooms/10.yaml22
-rw-r--r--data/rooms/11.yaml19
-rw-r--r--data/rooms/12.yaml6
-rw-r--r--data/rooms/13.yaml6
-rw-r--r--data/rooms/14.yaml6
-rw-r--r--data/rooms/15.yaml6
-rw-r--r--data/rooms/16.yaml6
-rw-r--r--data/rooms/17.yaml6
-rw-r--r--data/rooms/18.yaml6
-rw-r--r--data/rooms/19.yaml6
-rw-r--r--data/rooms/2.yaml19
-rw-r--r--data/rooms/20.yaml6
-rw-r--r--data/rooms/21.yaml13
-rw-r--r--data/rooms/3.yaml16
-rw-r--r--data/rooms/4.yaml15
-rw-r--r--data/rooms/5.yaml12
-rw-r--r--data/rooms/6.yaml16
-rw-r--r--data/rooms/7.yaml7
-rw-r--r--data/rooms/8.yaml14
-rw-r--r--data/rooms/9.yaml5
-rw-r--r--go.mod5
-rw-r--r--go.sum3
-rw-r--r--internal/action/action.go2
-rw-r--r--internal/action/behavior.go21
-rw-r--r--internal/config/config.go59
-rw-r--r--internal/game/action.go593
-rw-r--r--internal/game/action_gather.go355
-rw-r--r--internal/game/action_room.go47
-rw-r--r--internal/game/action_talk.go211
-rw-r--r--internal/game/action_toggle.go46
-rw-r--r--internal/game/action_use.go120
-rw-r--r--internal/game/cmd_drop.go163
-rw-r--r--internal/game/cmd_equipment.go28
-rw-r--r--internal/game/cmd_get.go222
-rw-r--r--internal/game/cmd_inventory.go36
-rw-r--r--internal/game/cmd_look.go119
-rw-r--r--internal/game/cmd_misc.go125
-rw-r--r--internal/game/cmd_move.go6
-rw-r--r--internal/game/cmd_quit.go1
-rw-r--r--internal/game/cmd_remove.go67
-rw-r--r--internal/game/cmd_say.go21
-rw-r--r--internal/game/cmd_score.go27
-rw-r--r--internal/game/cmd_search.go87
-rw-r--r--internal/game/cmd_style.go45
-rw-r--r--internal/game/cmd_ticktest.go21
-rw-r--r--internal/game/cmd_toggle.go3
-rw-r--r--internal/game/cmd_wear.go146
-rw-r--r--internal/game/game.go31
-rw-r--r--internal/game/login.go (renamed from internal/game/session.go)83
-rw-r--r--internal/game/tick.go92
-rw-r--r--internal/game/utils.go12
-rw-r--r--internal/net/conn.go101
-rw-r--r--internal/net/server.go153
-rw-r--r--internal/net/terminal.html164
-rw-r--r--internal/net/web.go72
-rw-r--r--internal/net/wsconn.go34
-rw-r--r--internal/object/item.go39
-rw-r--r--internal/player/player.go14
-rw-r--r--internal/player/validate.go37
-rw-r--r--internal/world/mob.go46
-rw-r--r--internal/world/room.go21
-rw-r--r--internal/world/world.go107
213 files changed, 4552 insertions, 1184 deletions
diff --git a/.gitignore b/.gitignore
index 9a5ecb5..6a21779 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,3 +3,8 @@
/data/players/characters/*
!/data/players/characters/.gitkeep
tc
+.cache/
+.npm/
+.local/
+.config/
+node_modules/
diff --git a/AGENTS.md b/AGENTS.md
index 6834676..5de818b 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -2,6 +2,8 @@
Third Collapse — a Runescape-like sci-fi MUD written in Go. Data-driven via YAML files. No database, no ORM.
+See `TODO.md` for the full project overview, implemented features, and roadmap.
+
## Build & Run
```bash
@@ -11,23 +13,26 @@ make test # go test ./...
make vet # go vet ./...
```
-Single dependency: `gopkg.in/yaml.v3`.
+Binary accepts `--config <path>` (defaults to `config.yaml`). If no config found, starts telnet on :4000.
+
+Dependencies: `gopkg.in/yaml.v3`, `github.com/gorilla/websocket`.
## Package Map
```
-cmd/mud/main.go Entry point — wires server, game, tick engine
-internal/net/ Telnet server, TCP session state machine, room hub
-internal/game/ Session handler, login, command dispatch, all game logic
+cmd/mud/main.go Entry point — config loading, multi-listener, tick engine
+internal/config/ YAML config (telnet, http, https listeners)
+internal/net/ Conn interface (tcp + websocket), session state, hub, IAC echo
+internal/game/ Login flow, command dispatch, input sanitization, all game logic
internal/action/ Behavior structs (GatherConfig, TalkConfig, etc.), Action, drop tables
-internal/player/ Player struct, skills, inventory, XP, Account YAML persistence
+internal/player/ Player struct, skills, inventory, XP, name/password validation
internal/world/ Room, MobDef, MobInstance, ObjState, ground items, wandering
internal/combat/ Combat state tracking, OSRS-style combat formulas
internal/object/ ItemDef, ObjectDef, item/object YAML loaders
internal/engine/ Tick scheduler — 600ms interval, subscriber pattern
```
-`internal/game/` is the largest package and contains most command implementations as `cmd_*.go` files.
+`internal/game/` is the largest package and contains most command implementations as `cmd_*.go` and `action_*.go` files.
## Key Conventions
@@ -37,7 +42,11 @@ internal/engine/ Tick scheduler — 600ms interval, subscriber pattern
**No database.** Everything is flat YAML files. Account passwords use sha256 + 16-byte random salt.
-**Tick-driven.** The world runs on a 600ms tick. Combat, gathering, regen, wandering, respawns all advance per tick. See `cmd/mud/main.go` for the subscription loop.
+**Transport-agnostic.** The `Conn` interface (`internal/net/conn.go`) abstracts the transport layer. `tcpConn` wraps a raw `net.Conn` for telnet. `wsConn` wraps `gorilla/websocket.Conn` for the web client. Game code works with `*Session` regardless of transport.
+
+**Input sanitization.** `HandleSession` strips Unicode control characters (ANSI escapes, nulls, etc.) from every input line before dispatch. Account/character names validated to `[A-Za-z0-9 ]{1,30}`. Input truncated to 1024 bytes. Password echo suppressed via IAC ECHO (telnet) and OSC sequences (web). WebSocket origin checked against host header.
+
+**Tick-driven.** The world runs on a 600ms tick. Combat, gathering, regen, wandering, respawns, shared depletion, woodcutting timers all advance per tick. See `cmd/mud/main.go` for the subscription loop.
## Two State Systems
@@ -52,12 +61,13 @@ internal/engine/ Tick scheduler — 600ms interval, subscriber pattern
3. Switch on command word
4. Most commands call into `cmd_*.go` files
-Commands that need an object/mob interaction route through `StartAction()` in `action.go`, which:
-1. Normalizes verb (mine/chop/fish → gather, talk/speak/ask → talk, pull/push → toggle)
-2. Searches object instances in room
-3. Falls back to mob instances if no object matches
-4. Loads the behavior from YAML
-5. Routes to type-specific handler (startGather, startTalk, startUse, startToggle)
+Actions that interact with objects/mobs route through `StartAction()` in `action.go`, which:
+1. Normalizes verb (mine/chop/fish/cut → gather, talk/speak/ask → talk, pull/push → toggle)
+2. Checks for ambiguous matches (multiple object types)
+3. Searches object instances in room
+4. Falls back to mob instances if no object matches
+5. Loads the behavior from YAML
+6. Routes to type-specific handler (startGather, startTalk, startUse, startToggle)
## Adding a New Skill
@@ -68,7 +78,7 @@ Commands that need an object/mob interaction route through `StartAction()` in `a
5. Place the object in a room YAML in `data/rooms/`
6. Create any resource items in `data/items/`
-Woodcutting is the next planned gathering skill — same pattern as mining (see `data/behaviors/mine_copper.yaml`).
+Set `xp` on the behavior to award XP on successful gathers. Set `shared_deplete` for tree-style depletion, or use per-drop `depletes: true` for rock-style depletion.
## Adding a New Production Skill
@@ -80,37 +90,38 @@ Same as gathering but use `type: use` behavior (see `UseConfig` in `internal/act
2. Add a case in `handleGameCommand()` in `internal/game/game.go`
3. Update `data/help/` YAML files
+## Two Depletion Mechanics
+
+**Per-drop depletion** (mining, regular trees): Set `depletes: true` on a drop entry. The resource depletes on first successful gather of that drop. Rocks don't show despawn timers when not depleted.
+
+**Shared depletion** (higher-tier trees): Set `shared_deplete: <ticks>` on the behavior. A shared timer counts down while anyone is chopping; when it hits 0, the next successful gather depletes the tree for all players. The timer regenerates back to max when no one is chopping. `WoodcuttingTick` manages this in `internal/game/tick.go`.
+
## Data Files
```
data/rooms/<id>.yaml Room definitions (exits, objects, mobs, spawns, on_enter)
data/items/<id>.yaml Item definitions (stats, equip slot, tool type, speed)
-data/mobs/<id>.yaml Mob definitions (combat stats, drops, behavior, wandering)
-data/objects/<id>.yaml Object definitions (name, behavior, hidden)
-data/behaviors/<id>.yaml Behaviors (gather, talk, use, toggle configs)
-data/drops/<id>.yaml Shared drop tables (weighted item lists)
-data/help/<id>.yaml Help topics
-data/players/accounts/ Account YAML (gitignored)
-data/players/characters/ Character YAML (gitignored)
+data/mobs/<id>.yaml Mob definitions (combat stats, drops, behavior — NO wander config)
+data/objects/<id>.yaml Object definitions (name, behavior, hidden)
+data/behaviors/<id>.yaml Behaviors (gather, talk, use, toggle configs)
+data/drops/<id>.yaml Shared drop tables (weighted item lists)
+data/help/<id>.yaml Help topics
+data/players/accounts/ Account YAML (gitignored)
+data/players/characters/ Character YAML (gitignored)
```
-See `WORLDBUILDING.md` for full YAML format reference with examples.
+Wander config (`wander_rooms`, `wander_interval`) is set per-instance in room YAML — mob defs are generic. Mobs wander via legal (unconditioned) room exits; objects teleport between rooms in their list.
-## Current State
-
-- 21 skills defined, 2 implemented (Mining, Fishing)
-- 4 behavior types: gather, use, talk, toggle
-- Combat system complete (melee only — Ranged not implemented yet)
-- 11 rooms with conditional exits and on-enter scripts
-- Talk system: dialog trees with conditions, item give/take, flag setting, teleport, heal
-- Account-wide alias system
-- No shops, banks, or quest system yet (though talk + flags already supports quest logic)
+See `WORLDBUILDING.md` for full YAML format reference with examples.
## Places to Be Careful
- `StartAction` searches objects first, then mobs. A mob must have `behavior:` set on its YAML def to be interactable.
-- Exits changed from `map[ExitDir]int` to `map[ExitDir]ExitDef` — old `north: 2` still works via custom `UnmarshalYAML`.
-- `CheckExitCondition` was unified into `checkCondition` — there's only one condition evaluator now.
+- Exits changed from `map[ExitDir]int` to `map[ExitDir]ExitDef` — old `north: 2` still works via custom `UnmarshalYAML`. Same pattern used for `RoomMob` (supports both `"man"` and `{id: man, ...}`).
+- `checkCondition` is the single condition evaluator — used by exits, talk options, on-enter scripts, and toggle checks.
- Object `Hidden: true` means the object doesn't appear in room listings but is still interactable.
- Alias expansion happens before command dispatch. An alias can shadow a built-in command.
-- `say` preserves case in the message (alias expansion preserves case, and the dispatch extracts the original input text for say).
+- `say` preserves case (alias expansion preserves case, and the dispatch extracts the original input text for say).
+- `get`, `drop`, and `quit` cancel the player's active action (gathering, use, etc.).
+- `findGroundMatches` and `findInventoryMatches` support bidirectional prefix matching — `"iron ax"` matches `"iron axe"`.
+- Mob wander config is per-instance in room YAML via `RoomMob`, not on `MobDef`. Mobs without `wander_interval` in the room stay still.
diff --git a/README.md b/README.md
index 971bbf7..4c3caeb 100644
--- a/README.md
+++ b/README.md
@@ -18,30 +18,31 @@ There is not much here yet.
## Skills
-| Skill | Description |
-|---|---|
-| Attack | How accurate you are with melee weapons. |
-| Strength | How hard you hit with melee weapons. |
-| Defense | With higher defense you are less likely to get hit. |
-| Hit Points | Your HP is your maximum amount of health. |
-| Ranged | Accuracy and effectiveness with using bows, guns, and thrown weapons. |
-| Technology | Accuracy and effectiveness using scrap for utility and combat. Almost magical! |
-| Science | Temporary togglable buffs that slowly drain this resource. Recharges at terminals. |
-| Fishing | Relax, AFK, and catch some fish for easy food. |
-| Mining | Relax, AFK, and mine some ore. Or do sweaty 3-tick mining to mine a lot of ore. |
-| Woodcutting | The best skill. No firemaking. You're already an expert at using a lighter! |
-| Assassin | Take on jobs to slay large numbers of creatures and rivals for big rewards. |
-| Hacking | Follow rumors using illegal computer skills for personal gain. |
-| Thieving | Steal limitless credits and farming seeds from the rich NPCs of Gaia 04. |
-| Scavenging | Pilfer junkyard scrap, then turn it into components for Technology. |
-| Farming | Grow your own food and reagents. You terraformed Gaia 04 after all! |
-| Cooking | Cook food for HP and other minor buffs with your fish and crops. |
-| Smithing | Turn ore into bars. Turn bars into useful equipment and ammo! Modify weapons! |
-| Crafting | Create jewelry, clothes, glass, pottery, and fine silver items. |
-| Fletching | Create cheap low-tech bows, crossbows, darts, arrows, and bolts. |
-| Alchemy | Clean herbs and brew potions for temporary buffs. |
-| Construction | Build a player-owned house for fast travel or to show off rare items. |
-| Agility | Higher agility means shorter wait times when AFK travelling with APS. |
+| Skill | Description |
+| ------------ | ---------------------------------------------------------------------------------- |
+| Attack | How accurate you are with melee weapons. |
+| Strength | How hard you hit with melee weapons. |
+| Defense | With higher defense you are less likely to get hit. |
+| Hit Points | Your HP is your maximum amount of health. |
+| Ranged | Accuracy and effectiveness with using bows, guns, and thrown weapons. |
+| Technology | Accuracy and effectiveness using scrap for utility and combat. Almost magical! |
+| Science | Temporary togglable buffs that slowly drain this resource. Recharges at terminals. |
+| Fishing | Relax, AFK, and catch some fish for easy food. |
+| Mining | Relax, AFK, and mine some ore. Or do sweaty 3-tick mining to mine a lot of ore. |
+| Woodcutting | The best skill. Chop trees to get wood. |
+| Firemaking | A skilling skill with little real purpose. Grind it to show off your pyromania? |
+| Assassin | Take on jobs to slay large numbers of creatures and rivals for big rewards. |
+| Hacking | Follow rumors using illegal computer skills for personal gain. |
+| Thieving | Steal limitless credits and farming seeds from the rich NPCs of Gaia 04. |
+| Scavenging | Pilfer junkyard scrap, then turn it into components for Technology. |
+| Farming | Grow your own food and reagents. You terraformed Gaia 04 after all! |
+| Cooking | Cook food for HP and other minor buffs with your fish and crops. |
+| Smithing | Turn ore into bars. Turn bars into useful equipment and ammo! Modify weapons! |
+| Crafting | Create jewelry, clothes, glass, pottery, and fine silver items. |
+| Fletching | Create cheap low-tech bows, crossbows, darts, arrows, and bolts. |
+| Alchemy | Clean herbs and brew potions for temporary buffs. |
+| Construction | Build a player-owned house for fast travel or to show off rare items. |
+| Agility | Higher agility means shorter wait times when AFK travelling with APS. |
# Building
diff --git a/TODO.md b/TODO.md
index 833f6ed..244fdf4 100644
--- a/TODO.md
+++ b/TODO.md
@@ -6,28 +6,29 @@ A low-tech sci-fi MUD set on a terraformed asteroid. Runescape-style combat and
Not generic fantasy. Takes place on a terraformed asteroid. Technology has regressed — people scrap and scavenge remnants of the old world. "Science" replaces Prayer, "Technology" replaces Magic. Scavenging replaces Runecrafting.
-## Skills (22 total, all go from 1-99)
+## Skills (23 total, all go from 1-99)
```
Combat: Attack, Strength, Defense, Hitpoints, Ranged, Science, Technology
Gathering: Fishing, Woodcutting, Mining, Hacking, Scavenging, Farming
-Production: Cooking, Smithing, Crafting, Fletching, Alchemy, Construction
+Production: Cooking, Smithing, Crafting, Fletching, Alchemy, Construction, Firemaking
Utility: Thieving, Agility, Assassin
```
## Architecture
```
-cmd/mud/ Entry point
+cmd/mud/ Entry point (--config flag, multi-listener)
internal/
+ config/ YAML config loading (telnet, http, https listeners)
action/ Behavior system: gather/talk/use/toggle types, drop tables
engine/ Tick scheduler (600ms)
world/ Rooms, exits, ground items, mobs, object instance state
- player/ Character sheet, skills, XP, inventory, equipment
+ player/ Character sheet, skills, XP, inventory, equipment, validation
combat/ Combat formulas, combat state tracking
object/ ItemDef, ObjectDef, item/object stores
- net/ Telnet server, sessions, room-based player hub
- game/ Session handler, login flow, command dispatch, action tick
+ net/ Telnet + HTTPS/WebSocket server, sessions, Conn interface, echo control
+ game/ Session handler, login flow, command dispatch, action tick, input sanitization
data/
behaviors/ Behavior YAML files (gather, talk, use, toggle)
drops/ Shared drop table YAML files
@@ -47,13 +48,23 @@ Objects and mobs in rooms can have behaviors. Four types exist:
| Type | Verbs | Use |
|------|-------|-----|
-| `gather` | mine, chop, fish | Resource gathering with skill checks, tool requirements, depletion/respawn |
+| `gather` | mine, chop, cut, fish | Resource gathering with skill checks, tool requirements, depletion/respawn |
| `use` | use | Crafting stations — consume items, produce output, optional skill checks |
| `talk` | talk, speak, ask | NPC conversation trees with choices, conditions, actions |
| `toggle` | pull, push | Levers, switches, gates — set world flags, conditional on existing values |
Behaviors are defined in `data/behaviors/<id>.yaml`. Objects reference them with `behavior: <id>`. Mobs can also carry `behavior: <id>` — StartAction falls back to mob lookup when no object matches.
+### Two Depletion Mechanics
+
+**Per-drop depletion** (mining, regular trees): Set `depletes: true` on a drop entry. The resource depletes on first successful gather of that drop.
+
+**Shared depletion** (higher-tier trees): Set `shared_deplete: <ticks>` on the behavior. A shared timer counts down while anyone is chopping; when it hits 0, the next successful gather depletes the tree for all players. The timer regenerates back to max when no one is chopping.
+
+### Bird's Nests
+
+Set `nest_chance: 256` on a gather behavior to give a 1/256 independent chance to find a bird's nest alongside the normal drop. Bird's nests are searched with the `search` command, rolling on the `birds_nest_drop` drop table.
+
### Condition System
Used by exits, talk options, on-enter scripts, and toggle checks:
@@ -98,15 +109,28 @@ Aliases resolve before built-in commands and support argument passthrough (`alia
- Exits support conditions (world flags, player flags, items, compound)
- Rooms support `on_enter` scripts with per-step conditions
- Objects can be `hidden: true` (interactable but not listed in room)
+- Mobs wander via legal room exits (unconditioned), not hardcoded room lists
+- Mob wander config (`wander_rooms`, `wander_interval`) set per-instance in room YAML
+- Object wander config same format — objects teleport between listed rooms
## Implemented
### Core Systems
- [x] Telnet server, account creation, hashed passwords, account/character management
+- [x] HTTPS/WebSocket server with embedded web terminal client
+- [x] Multi-listener config (telnet + http + https, any combination)
+- [x] YAML config file (`config.yaml`) with `--config` flag and runtime defaults
- [x] 600ms tick engine driving all world simulation
- [x] Room-based player hub with enter/exit/action notifications
- [x] Single-login enforcement, disconnect timer
- [x] Player YAML persistence on every state change
+- [x] Transport-agnostic Conn interface (tcpConn + wsConn)
+- [x] Password echo suppression (telnet IAC ECHO, web OSC sequences)
+- [x] Input sanitization (control character stripping at HandleSession)
+- [x] Name validation (alphanumeric+spaces, 3-30 chars)
+- [x] Max input line length (1024 bytes)
+- [x] WebSocket origin checking (same-origin + loopback)
+- [x] Ground item spawn stacking fix (Tick + SeedGroundItems)
### Commands
- [x] `look / l` — room rendering (mobs, objects, ground items, exits, players)
@@ -117,13 +141,14 @@ Aliases resolve before built-in commands and support argument passthrough (`alia
- [x] `score / inventory / equipment / exits` — character and world info
- [x] `style` — combat style selection (prefix matching)
- [x] `attack / kill <mob>` — combat with numbered targeting
-- [x] `mine / chop / fish <object>` — gathering with numbered targeting
+- [x] `mine / chop / cut / fish <object>` — gathering with numbered targeting
- [x] `use <object>` — crafting station interaction
- [x] `talk / speak / ask <target>` — NPC conversations (objects or mobs)
- [x] `pull / push <object>` — toggle interactions (levers, gates)
- [x] `alias <name> <cmd> / unalias <name>` — account-wide command aliases
-- [x] `toggle [name]` — 8 character settings
+- [x] `toggle [name]` — 9 character settings
- [x] `description / desc / help [topic] / quit`
+- [x] `search <item>` — search bird's nests for loot
### Combat
- [x] RSC-based formulas (attack/defense rolls, max hit)
@@ -131,27 +156,32 @@ Aliases resolve before built-in commands and support argument passthrough (`alia
- [x] Equipment bonuses, HP tracking, death mechanics
- [x] Combat lock timer, movement interrupts combat
- [x] Mob/player regen (1 HP per 100 ticks)
-- [x] Mob wandering, enter/leave/spawn notifications
+- [x] Mob exit-based wandering, enter/leave/spawn notifications
- [x] Drop reservation system (100-tick owner lock on loot)
### Skills & Gathering
- [x] Mining (copper rocks, pickaxe, depletion/respawn, gem sub-table)
- [x] Fishing (wandering spots, non-depleting, fishing rod)
+- [x] Woodcutting (9 tree types, axes, shared depletion, bird's nests)
- [x] Tool system (tool_type + tool_speed on items, tool requirement on behaviors)
- [x] Shared drop tables (referenced by multiple behaviors)
-- [x] 21 skill definitions with RSC XP table
+- [x] XP drops for all skill actions (combat, gathering, use)
+- [x] 23 skill definitions with RSC XP table (3 implemented: Mining, Fishing, Woodcutting)
### World & Data
- [x] Room, item, object, mob, behavior, drop table YAML loaders
- [x] Object instance tracking (depletion, wandering, respawn, broadcast)
-- [x] 11 playable rooms with mobs, objects, spawns, and conditional exits
+- [x] 21 structured rooms (west gathering wing, east production/utility/combat wing)
- [x] Conditional exits (world flags, player flags, items, compound conditions)
- [x] Room on_enter scripts with conditions
- [x] Hidden objects (interactable but not listed in room output)
- [x] Player-local flags vs world flags for multiplayer state separation
- [x] Compound conditions: all_of / any_of
- [x] Aligned ground item reservation display
-- [x] Hidden object support (referenced in room descriptions, not auto-listed)
+- [x] Mob wander config per-instance in room YAML (not on MobDef)
+- [x] Object depletion timer display (depletion toggle)
+- [x] Bidirectional prefix matching for get/drop/attack/gather commands
+- [x] Ambiguity detection when multiple object types match a command
### Documentation
- [x] WORLDBUILDING.md — comprehensive guide with examples
@@ -164,7 +194,7 @@ See WORLDBUILDING.md for full examples of every data type with explanations.
## Action Plan
### Phase 1: Core Skill Chains (breadth over depth)
-- [ ] Woodcutting — tree objects + axes, same pattern as mining
+- [x] Woodcutting — tree objects + axes, shared depletion, bird's nests
- [ ] Cooking — fire/range object (`use` type), raw fish/meat → cooked food
- [ ] Smithing — furnace + anvil objects (`use` type), ore → bars → weapons/armor
- [ ] Fletching — fletching table object (`use` type), logs → arrow shafts/bows
diff --git a/WORLDBUILDING.md b/WORLDBUILDING.md
index a0ca168..9585c32 100644
--- a/WORLDBUILDING.md
+++ b/WORLDBUILDING.md
@@ -92,13 +92,28 @@ spawns:
### Mobs — NPCs placed in the room
+Simple string (no wandering):
```yaml
mobs:
- - "man" # spawns a "man" mob
- - "man" # second "man" — duplicate IDs create multiple instances
- - "guard" # unique named mob
+ - "newbie_trainer"
+ - "man"
```
+With wander config per-instance:
+```yaml
+mobs:
+ - id: man
+ wander_interval: 10 # attempts to wander every 10 ticks
+ - id: man
+ wander_interval: 15
+ wander_rooms: [1, 4, 5] # optional — only exit to these rooms
+```
+
+Mob wander config lives in the room YAML, not in the mob definition. This keeps mobs generic
+so the same `man` can wander differently depending on where it's placed. Mobs wander through
+legal (unconditioned) room exits. If no legal exits exist, the mob stays still. Mobs with
+no `wander_interval` never wander.
+
### Objects — interactive fixtures
```yaml
@@ -106,7 +121,7 @@ objects:
- id: copper_rock # simple placement
- id: copper_rock # second instance
- id: fishing_spot
- wander_rooms: [7, 8, 9] # moves between these rooms
+ wander_rooms: [7, 8, 9] # teleports between these rooms
wander_interval: 12 # every 12 ticks
- id: iron_gate # hidden object (see below)
```
@@ -143,7 +158,6 @@ stats: # optional — combat bonuses
attack_bonus: 2
strength_bonus: 1
speed: 5 # ticks between attacks
-toolbelt: true # usable from toolbelt (doesn't need to be in inventory)
tool_type: pickaxe # used by gather behaviors that require "tool: pickaxe"
tool_speed: 2 # reduces gather wait time
```
@@ -173,8 +187,6 @@ hp: 7
speed: 5
aggressive: false
respawn_ticks: 30
-wander_rooms: [1, 2, 3]
-wander_interval: 10
drops:
remains: "bones" # always dropped on death
loot:
@@ -210,6 +222,9 @@ idle_descriptions:
- "adjusts the grip on his weapon"
```
+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).
+
---
## Objects
@@ -221,6 +236,13 @@ name: copper rock
behavior: mine_copper
```
+Tree object:
+```yaml
+id: oak_tree
+name: oak tree
+behavior: chop_oak
+```
+
Hidden object — doesn't appear in room's object list, only discoverable via description or experimentation:
```yaml
id: iron_gate
@@ -246,11 +268,13 @@ props:
### Gather (mining, fishing, woodcutting)
+Mining — per-drop depletion:
```yaml
id: mine_copper
type: gather
skill: mining
level: 1
+xp: 17 # XP awarded per successful gather
base_wait: 8 # ticks between attempts
tool: pickaxe # requires item with tool_type: pickaxe
success:
@@ -279,6 +303,7 @@ id: fish_trout
type: gather
skill: fishing
level: 1
+xp: 10
base_wait: 4
tool: fishing_rod
success:
@@ -294,6 +319,80 @@ drops:
message: "You catch a trout!"
```
+Woodcutting with shared depletion and bird's nests:
+```yaml
+id: chop_oak
+type: gather
+skill: woodcutting
+level: 15
+xp: 37
+base_wait: 6
+tool: axe
+success:
+ base: 0.40
+ per_level: 0.01
+ cap: 0.90
+gather_message: "You swing your axe at the oak tree..."
+fail_message: "You swing but get no logs."
+drops:
+ - item_id: oak_logs
+ weight: 100
+ depletes: false # depletion is timer-based (shared_deplete)
+ message: "You get some oak logs."
+deplete_delay: 14 # ticks until tree respawns after being cut down
+shared_deplete: 45 # max ticks before next gather depletes (counts down while chopping)
+nest_chance: 256 # 1/256 chance for a bird's nest on each successful gather
+respawn_message: "A new oak sapling grows in its place."
+respawn_broadcast: "An {name} grows back."
+```
+
+Regular tree — always depletes on first gather, no shared timer, no nests:
+```yaml
+id: chop_tree
+type: gather
+skill: woodcutting
+level: 1
+xp: 25
+base_wait: 4
+tool: axe
+success:
+ base: 0.50
+ per_level: 0.01
+ cap: 0.95
+gather_message: "You swing your axe at the tree..."
+fail_message: "You swing but get no logs."
+drops:
+ - item_id: logs
+ weight: 100
+ depletes: true # regular tree depletes on first successful gather
+ message: "You get some logs."
+deplete_delay: 80
+respawn_message: "A new tree grows in its place."
+respawn_broadcast: "A {name} grows back."
+```
+
+#### Shared depletion explained
+
+When `shared_deplete > 0`, the tree has a shared despawn timer:
+- The timer starts at `shared_deplete` max when the first player begins chopping.
+- Each tick, if anyone is chopping, the timer counts down.
+- When the timer reaches 0, the NEXT successful gather depletes the tree.
+- If no one is chopping and the tree isn't depleted, the timer ticks back UP.
+- All players chopping the same tree are interrupted when it depletes.
+
+Use `shared_deplete` for trees. Use `depletes: true` on individual drops for rocks.
+
+#### Bird's nests
+
+When `nest_chance > 0`, each successful gather has a 1/N independent chance to also drop
+a bird's nest. The nest goes to inventory (or to the ground if inventory is full).
+Use the `search` command to open nests — they roll on the `birds_nest_drop` table.
+
+#### XP drops
+
+When `xp > 0`, the gather awards XP on each successful drop. If the player's `xpdrops`
+toggle is on, the output includes the XP gain: `(+37xp wct)`.
+
### Talk (dialog trees)
Full conversation with conditions, actions, and player flag tracking:
@@ -424,6 +523,7 @@ success:
cap: 0.95
skill: smithing
level: 1
+xp: 15 # XP awarded per successful craft
```
---
@@ -511,6 +611,28 @@ drops:
weight: 1
```
+Bird's nest drop table:
+```yaml
+# data/drops/birds_nest_drop.yaml
+id: birds_nest_drop
+drops:
+ - item_id: credits
+ weight: 50
+ quantity: 200
+ - item_id: credits
+ weight: 30
+ quantity: 500
+ - item_id: credits
+ weight: 15
+ quantity: 1000
+ - item_id: credits
+ weight: 4
+ quantity: 3000
+ - item_id: credits
+ weight: 1
+ quantity: 10000
+```
+
Referenced from a gather behavior:
```yaml
drops:
@@ -744,26 +866,68 @@ behavior: fish_trout
```
```yaml
-# In rooms 7, 8, and 9:
+# In a room:
objects:
- id: fishing_spot
wander_rooms: [7, 8, 9]
wander_interval: 12
```
-Players gathering from a wandering object are silently interrupted when it moves.
+Objects teleport between rooms in their `wander_rooms` list. Players gathering from a
+wandering object are silently interrupted when it moves.
---
## Wandering Mobs
+Mob wandering is configured per-instance in the room YAML, not on the mob definition:
```yaml
-# mobs that roam between rooms:
-wander_rooms: [1, 2, 3, 4]
-wander_interval: 10 # moves every 10 ticks
+# In a room:
+mobs:
+ - id: man
+ wander_interval: 10 # attempt to wander every 10 ticks
+ - id: man
+ wander_interval: 15
+ wander_rooms: [5, 6, 7] # optional — restrict which rooms via exits
```
-Mobs stop wandering while in combat. Dead mobs respawn at their home room after `respawn_ticks`.
+Mobs wander through legal (unconditioned) room exits. When the wander interval expires,
+the mob picks a random exit with no conditions and moves through it. If `wander_rooms`
+is set, only exits leading to those room IDs are legal. Mobs without `wander_interval`
+never wander. Mobs stop wandering while in combat. Dead mobs respawn at their home room.
+
+---
+
+## Player Toggles
+
+Players can toggle personal settings with the `toggle` command:
+
+| Toggle | Effect |
+|---|---|
+| `description` | Show full room description when moving |
+| `tinymap` | Mini-map display (not yet implemented) |
+| `xpdrops` | Show XP gained in gather/craft/combat messages |
+| `exits` | Show exit destinations inline in look output |
+| `mobenter` | Notify when a mob enters the room |
+| `mobleave` | Notify when a mob leaves the room |
+| `mobspawn` | Notify when a mob spawns in the area |
+| `reserve` | Show full reserved item details in look |
+| `depletion` | Show depletion and despawn timers on objects |
+
+---
+
+## The `search` Command
+
+Used to open searchable items in your inventory (bird's nests, etc.):
+```
+Usage: search <item>
+
+Example: search nest
+ search birds nest
+```
+
+Searches your inventory for the named item. If found, removes it and rolls on a drop table.
+Currently only bird's nests are searchable.
---
@@ -802,6 +966,10 @@ description: "A dusty corridor. One of the wall stones looks slightly out of pla
6. **The `behavior` field on mobs** lets you talk to them directly — `talk guard` finds the guard mob, no duplicate object entry needed.
-7. **Exits accept both `int` and `map` formats.** `north: 2` is shorthand for `north: {room: 2}`. Add `condition` and `blocked_message` only when needed.
+7. **Exits accept both `int` and `map` formats.** `north: 2` is shorthand for `north: {room: 2}`. Add `condition` and `blocked_message` only when needed. Mob room entries accept both `"man"` and `{id: man, ...}`.
8. **Live editing works.** Room, item, mob, object, and behavior YAML files are read from disk on each access. Change a room description or dialog and it takes effect immediately — no restart needed.
+
+9. **Shared depletion vs per-drop depletion.** Use `shared_deplete` for trees — the timer counts down while being chopped and regens when left alone. Use `depletes: true` on individual drops for rocks — they deplete on first successful gather.
+
+10. **Mob wander config goes in the room YAML**, not the mob definition. This lets the same `man` wander differently in different rooms.
diff --git a/cmd/mud/main.go b/cmd/mud/main.go
index d1e4ef5..e3e3d32 100644
--- a/cmd/mud/main.go
+++ b/cmd/mud/main.go
@@ -1,30 +1,35 @@
package main
import (
+ "flag"
"fmt"
"log"
"os"
"os/signal"
- "strconv"
"syscall"
"thirdcollapse/internal/combat"
+ "thirdcollapse/internal/config"
"thirdcollapse/internal/game"
"thirdcollapse/internal/net"
)
func main() {
+ configPath := flag.String("config", "config.yaml", "path to config file")
+ flag.Parse()
+
dataDir := os.Getenv("DATA_DIR")
if dataDir == "" {
dataDir = "data"
}
- port := 4000
- if len(os.Args) > 1 {
- if p, err := strconv.Atoi(os.Args[1]); err == nil && p >= 1 && p <= 65535 {
- port = p
+ cfg, err := config.Load(*configPath)
+ if err != nil {
+ if os.IsNotExist(err) {
+ log.Printf("config file %s not found, using defaults (telnet :4000)", *configPath)
+ cfg = config.Default()
} else {
- log.Fatalf("invalid port: %s (must be 1-65535)", os.Args[1])
+ log.Fatalf("failed to load config: %v", err)
}
}
@@ -39,19 +44,28 @@ func main() {
g.RegenTick()
g.DisconnectTick()
g.WanderTick()
+ g.WoodcuttingTick()
g.AdvanceActions()
g.BroadcastRespawns()
return true
})
- addr := fmt.Sprintf(":%d", port)
- server, err := net.NewServer(addr)
+ server, err := net.NewServer(cfg)
if err != nil {
log.Fatalf("failed to start server: %v", err)
}
g.SetHub(server.Hub())
- log.Printf("Third Collapse listening on :%d", port)
+ if cfg.Telnet.Enabled {
+ log.Printf("Telnet listening on :%d", cfg.Telnet.Port)
+ }
+ if cfg.HTTP.Enabled {
+ log.Printf("HTTP listening on :%d", cfg.HTTP.Port)
+ }
+ if cfg.HTTPS.Enabled {
+ log.Printf("HTTPS listening on :%d", cfg.HTTPS.Port)
+ }
+ fmt.Println() // blank line after log prefixes
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
diff --git a/config.yaml b/config.yaml
new file mode 100644
index 0000000..e980438
--- /dev/null
+++ b/config.yaml
@@ -0,0 +1,13 @@
+telnet:
+ enabled: true
+ port: 4000
+
+http:
+ enabled: true
+ port: 8888
+
+https:
+ enabled: false
+ port: 8443
+ cert_file: ""
+ key_file: ""
diff --git a/data/behaviors/chop_magic.yaml b/data/behaviors/chop_magic.yaml
new file mode 100644
index 0000000..762d979
--- /dev/null
+++ b/data/behaviors/chop_magic.yaml
@@ -0,0 +1,23 @@
+id: chop_magic
+type: gather
+skill: woodcutting
+level: 75
+xp: 250
+base_wait: 12
+tools: [axe]
+success:
+ base: 0.15
+ per_level: 0.01
+ cap: 0.65
+gather_message: "You swing your axe at the magic tree..."
+fail_message: "You swing but get no logs."
+drops:
+ - item_id: magic_logs
+ weight: 100
+ depletes: false
+ message: "You get some magic logs."
+deplete_delay: 199
+shared_deplete: 390
+nest_chance: 256
+respawn_message: "A new magic sapling grows in its place."
+respawn_broadcast: "A {name} grows back."
diff --git a/data/behaviors/chop_mahogany.yaml b/data/behaviors/chop_mahogany.yaml
new file mode 100644
index 0000000..61ad6af
--- /dev/null
+++ b/data/behaviors/chop_mahogany.yaml
@@ -0,0 +1,23 @@
+id: chop_mahogany
+type: gather
+skill: woodcutting
+level: 50
+xp: 125
+base_wait: 8
+tools: [axe]
+success:
+ base: 0.25
+ per_level: 0.01
+ cap: 0.75
+gather_message: "You swing your axe at the mahogany tree..."
+fail_message: "You swing but get no logs."
+drops:
+ - item_id: mahogany_logs
+ weight: 100
+ depletes: false
+ message: "You get some mahogany logs."
+deplete_delay: 14
+shared_deplete: 100
+nest_chance: 256
+respawn_message: "A new mahogany sapling grows in its place."
+respawn_broadcast: "A {name} grows back."
diff --git a/data/behaviors/chop_maple.yaml b/data/behaviors/chop_maple.yaml
new file mode 100644
index 0000000..aa4ebea
--- /dev/null
+++ b/data/behaviors/chop_maple.yaml
@@ -0,0 +1,23 @@
+id: chop_maple
+type: gather
+skill: woodcutting
+level: 45
+xp: 100
+base_wait: 8
+tools: [axe]
+success:
+ base: 0.28
+ per_level: 0.01
+ cap: 0.78
+gather_message: "You swing your axe at the maple tree..."
+fail_message: "You swing but get no logs."
+drops:
+ - item_id: maple_logs
+ weight: 100
+ depletes: false
+ message: "You get some maple logs."
+deplete_delay: 59
+shared_deplete: 100
+nest_chance: 256
+respawn_message: "A new maple sapling grows in its place."
+respawn_broadcast: "A {name} grows back."
diff --git a/data/behaviors/chop_oak.yaml b/data/behaviors/chop_oak.yaml
new file mode 100644
index 0000000..6731d24
--- /dev/null
+++ b/data/behaviors/chop_oak.yaml
@@ -0,0 +1,23 @@
+id: chop_oak
+type: gather
+skill: woodcutting
+level: 15
+xp: 37
+base_wait: 6
+tools: [axe]
+success:
+ base: 0.40
+ per_level: 0.01
+ cap: 0.90
+gather_message: "You swing your axe at the oak tree..."
+fail_message: "You swing but get no logs."
+drops:
+ - item_id: oak_logs
+ weight: 100
+ depletes: false
+ message: "You get some oak logs."
+deplete_delay: 14
+shared_deplete: 45
+nest_chance: 256
+respawn_message: "A new oak sapling grows in its place."
+respawn_broadcast: "An {name} grows back."
diff --git a/data/behaviors/chop_redwood.yaml b/data/behaviors/chop_redwood.yaml
new file mode 100644
index 0000000..e3def4f
--- /dev/null
+++ b/data/behaviors/chop_redwood.yaml
@@ -0,0 +1,23 @@
+id: chop_redwood
+type: gather
+skill: woodcutting
+level: 90
+xp: 380
+base_wait: 14
+tools: [axe]
+success:
+ base: 0.10
+ per_level: 0.01
+ cap: 0.60
+gather_message: "You swing your axe at the redwood tree..."
+fail_message: "You swing but get no logs."
+drops:
+ - item_id: redwood_logs
+ weight: 100
+ depletes: false
+ message: "You get some redwood logs."
+deplete_delay: 199
+shared_deplete: 440
+nest_chance: 256
+respawn_message: "A new redwood sapling grows in its place."
+respawn_broadcast: "A {name} grows back."
diff --git a/data/behaviors/chop_teak.yaml b/data/behaviors/chop_teak.yaml
new file mode 100644
index 0000000..ed2f60d
--- /dev/null
+++ b/data/behaviors/chop_teak.yaml
@@ -0,0 +1,23 @@
+id: chop_teak
+type: gather
+skill: woodcutting
+level: 35
+xp: 85
+base_wait: 6
+tools: [axe]
+success:
+ base: 0.32
+ per_level: 0.01
+ cap: 0.82
+gather_message: "You swing your axe at the teak tree..."
+fail_message: "You swing but get no logs."
+drops:
+ - item_id: teak_logs
+ weight: 100
+ depletes: false
+ message: "You get some teak logs."
+deplete_delay: 15
+shared_deplete: 50
+nest_chance: 256
+respawn_message: "A new teak sapling grows in its place."
+respawn_broadcast: "A {name} grows back."
diff --git a/data/behaviors/chop_tree.yaml b/data/behaviors/chop_tree.yaml
new file mode 100644
index 0000000..445047d
--- /dev/null
+++ b/data/behaviors/chop_tree.yaml
@@ -0,0 +1,21 @@
+id: chop_tree
+type: gather
+skill: woodcutting
+level: 1
+xp: 25
+base_wait: 4
+tools: [axe]
+success:
+ base: 0.50
+ per_level: 0.01
+ cap: 0.95
+gather_message: "You swing your axe at the tree..."
+fail_message: "You swing but get no logs."
+drops:
+ - item_id: logs
+ weight: 100
+ depletes: true
+ message: "You get some logs."
+deplete_delay: 80
+respawn_message: "A new tree grows in its place."
+respawn_broadcast: "A {name} grows back."
diff --git a/data/behaviors/chop_willow.yaml b/data/behaviors/chop_willow.yaml
new file mode 100644
index 0000000..f972aa5
--- /dev/null
+++ b/data/behaviors/chop_willow.yaml
@@ -0,0 +1,23 @@
+id: chop_willow
+type: gather
+skill: woodcutting
+level: 30
+xp: 67
+base_wait: 6
+tools: [axe]
+success:
+ base: 0.35
+ per_level: 0.01
+ cap: 0.85
+gather_message: "You swing your axe at the willow tree..."
+fail_message: "You swing but get no logs."
+drops:
+ - item_id: willow_logs
+ weight: 100
+ depletes: false
+ message: "You get some willow logs."
+deplete_delay: 14
+shared_deplete: 50
+nest_chance: 256
+respawn_message: "A new willow sapling grows in its place."
+respawn_broadcast: "A {name} grows back."
diff --git a/data/behaviors/chop_yew.yaml b/data/behaviors/chop_yew.yaml
new file mode 100644
index 0000000..48f387d
--- /dev/null
+++ b/data/behaviors/chop_yew.yaml
@@ -0,0 +1,23 @@
+id: chop_yew
+type: gather
+skill: woodcutting
+level: 60
+xp: 175
+base_wait: 10
+tools: [axe]
+success:
+ base: 0.20
+ per_level: 0.01
+ cap: 0.70
+gather_message: "You swing your axe at the yew tree..."
+fail_message: "You swing but get no logs."
+drops:
+ - item_id: yew_logs
+ weight: 100
+ depletes: false
+ message: "You get some yew logs."
+deplete_delay: 99
+shared_deplete: 190
+nest_chance: 256
+respawn_message: "A new yew sapling grows in its place."
+respawn_broadcast: "A {name} grows back."
diff --git a/data/behaviors/fish_angler.yaml b/data/behaviors/fish_angler.yaml
new file mode 100644
index 0000000..107e9b5
--- /dev/null
+++ b/data/behaviors/fish_angler.yaml
@@ -0,0 +1,19 @@
+id: fish_angler
+type: gather
+skill: fishing
+level: 1
+base_wait: 6
+tools: [fishing_rod]
+bait: fishing_bait
+success:
+ base: 0.30
+ per_level: 0.005
+ cap: 0.80
+gather_message: "You cast your line into the deep water..."
+fail_message: "Nothing seems to bite."
+drops:
+ - item_id: raw_anglerfish
+ level: 82
+ xp: 120
+ weight: 100
+ message: "You catch a raw anglerfish."
diff --git a/data/behaviors/fish_bait.yaml b/data/behaviors/fish_bait.yaml
new file mode 100644
index 0000000..ee13e95
--- /dev/null
+++ b/data/behaviors/fish_bait.yaml
@@ -0,0 +1,29 @@
+id: fish_bait
+type: gather
+skill: fishing
+level: 1
+base_wait: 5
+tools: [fishing_rod]
+bait: fishing_bait
+success:
+ base: 0.50
+ per_level: 0.005
+ cap: 0.90
+gather_message: "You cast your line into the water..."
+fail_message: "Nothing seems to bite."
+drops:
+ - item_id: raw_sardine
+ level: 5
+ xp: 20
+ weight: 60
+ message: "You catch a raw sardine."
+ - item_id: raw_herring
+ level: 10
+ xp: 30
+ weight: 40
+ message: "You catch a raw herring."
+ - item_id: raw_pike
+ level: 25
+ xp: 60
+ weight: 30
+ message: "You catch a raw pike."
diff --git a/data/behaviors/fish_cage.yaml b/data/behaviors/fish_cage.yaml
new file mode 100644
index 0000000..1fc92e4
--- /dev/null
+++ b/data/behaviors/fish_cage.yaml
@@ -0,0 +1,18 @@
+id: fish_cage
+type: gather
+skill: fishing
+level: 1
+base_wait: 6
+tools: [lobster_pot]
+success:
+ base: 0.45
+ per_level: 0.005
+ cap: 0.85
+gather_message: "You lower your lobster pot into the water..."
+fail_message: "The pot comes up empty."
+drops:
+ - item_id: raw_lobster
+ level: 40
+ xp: 90
+ weight: 100
+ message: "You catch a raw lobster."
diff --git a/data/behaviors/fish_fly.yaml b/data/behaviors/fish_fly.yaml
new file mode 100644
index 0000000..af80076
--- /dev/null
+++ b/data/behaviors/fish_fly.yaml
@@ -0,0 +1,29 @@
+id: fish_fly
+type: gather
+skill: fishing
+level: 1
+base_wait: 5
+tools: [fly_rod]
+bait: feather
+success:
+ base: 0.50
+ per_level: 0.005
+ cap: 0.90
+gather_message: "You cast your fly into the water..."
+fail_message: "Nothing seems to bite."
+drops:
+ - item_id: raw_trout
+ level: 20
+ xp: 50
+ weight: 50
+ message: "You catch a raw trout."
+ - item_id: raw_salmon
+ level: 30
+ xp: 70
+ weight: 50
+ message: "You catch a raw salmon."
+ - item_id: raw_rainbow_fish
+ level: 38
+ xp: 80
+ weight: 30
+ message: "You catch a raw rainbow fish."
diff --git a/data/behaviors/fish_harpoon.yaml b/data/behaviors/fish_harpoon.yaml
new file mode 100644
index 0000000..8d94a48
--- /dev/null
+++ b/data/behaviors/fish_harpoon.yaml
@@ -0,0 +1,28 @@
+id: fish_harpoon
+type: gather
+skill: fishing
+level: 1
+base_wait: 6
+tools: [harpoon]
+success:
+ base: 0.40
+ per_level: 0.005
+ cap: 0.80
+gather_message: "You ready your harpoon..."
+fail_message: "You fail to spear anything."
+drops:
+ - item_id: raw_tuna
+ level: 35
+ xp: 80
+ weight: 60
+ message: "You harpoon a raw tuna."
+ - item_id: raw_swordfish
+ level: 50
+ xp: 100
+ weight: 50
+ message: "You harpoon a raw swordfish."
+ - item_id: raw_shark
+ level: 76
+ xp: 110
+ weight: 30
+ message: "You harpoon a raw shark."
diff --git a/data/behaviors/fish_net.yaml b/data/behaviors/fish_net.yaml
new file mode 100644
index 0000000..b971710
--- /dev/null
+++ b/data/behaviors/fish_net.yaml
@@ -0,0 +1,38 @@
+id: fish_net
+type: gather
+skill: fishing
+level: 1
+base_wait: 5
+tools: [small_net, big_net]
+success:
+ base: 0.50
+ per_level: 0.005
+ cap: 0.90
+gather_message: "You cast your net into the water..."
+fail_message: "You catch nothing."
+drops:
+ - item_id: raw_shrimps
+ level: 1
+ xp: 10
+ weight: 50
+ message: "You catch some raw shrimps."
+ - item_id: raw_anchovies
+ level: 1
+ xp: 10
+ weight: 50
+ message: "You catch some raw anchovies."
+ - item_id: raw_mackerel
+ level: 16
+ xp: 20
+ weight: 50
+ message: "You catch a raw mackerel."
+ - item_id: raw_cod
+ level: 23
+ xp: 30
+ weight: 50
+ message: "You catch a raw cod."
+ - item_id: raw_bass
+ level: 46
+ xp: 50
+ weight: 50
+ message: "You catch a raw bass."
diff --git a/data/behaviors/fish_sacred_eel.yaml b/data/behaviors/fish_sacred_eel.yaml
new file mode 100644
index 0000000..2e0235f
--- /dev/null
+++ b/data/behaviors/fish_sacred_eel.yaml
@@ -0,0 +1,19 @@
+id: fish_sacred_eel
+type: gather
+skill: fishing
+level: 1
+base_wait: 6
+tools: [fishing_rod]
+bait: fishing_bait
+success:
+ base: 0.25
+ per_level: 0.005
+ cap: 0.75
+gather_message: "You cast your line near the sacred waters..."
+fail_message: "Nothing seems to bite."
+drops:
+ - item_id: sacred_eel
+ level: 87
+ xp: 125
+ weight: 100
+ message: "You catch a sacred eel."
diff --git a/data/behaviors/fish_swamp.yaml b/data/behaviors/fish_swamp.yaml
new file mode 100644
index 0000000..ee1e5f3
--- /dev/null
+++ b/data/behaviors/fish_swamp.yaml
@@ -0,0 +1,24 @@
+id: fish_swamp
+type: gather
+skill: fishing
+level: 1
+base_wait: 5
+tools: [fishing_rod]
+bait: fishing_bait
+success:
+ base: 0.45
+ per_level: 0.005
+ cap: 0.85
+gather_message: "You cast your line into the murky swamp..."
+fail_message: "Nothing seems to bite."
+drops:
+ - item_id: raw_slimy_eel
+ level: 28
+ xp: 80
+ weight: 60
+ message: "You catch a raw slimy eel."
+ - item_id: raw_cave_eel
+ level: 38
+ xp: 80
+ weight: 40
+ message: "You catch a raw cave eel."
diff --git a/data/behaviors/fish_trout.yaml b/data/behaviors/fish_trout.yaml
deleted file mode 100644
index fd0e37a..0000000
--- a/data/behaviors/fish_trout.yaml
+++ /dev/null
@@ -1,17 +0,0 @@
-id: fish_trout
-type: gather
-skill: fishing
-level: 1
-base_wait: 4
-tool: fishing_rod
-success:
- base: 0.30
- per_level: 0.01
- cap: 0.90
-gather_message: "You cast your line into the water..."
-fail_message: "Nothing seems to bite."
-drops:
- - item_id: raw_trout
- weight: 100
- depletes: false
- message: "You catch a trout!"
diff --git a/data/behaviors/mine_adamantite.yaml b/data/behaviors/mine_adamantite.yaml
new file mode 100644
index 0000000..d4a3cda
--- /dev/null
+++ b/data/behaviors/mine_adamantite.yaml
@@ -0,0 +1,21 @@
+id: mine_adamantite
+type: gather
+skill: mining
+level: 70
+xp: 95
+base_wait: 10
+tools: [pickaxe]
+success:
+ base: 0.15
+ per_level: 0.01
+ cap: 0.85
+gather_message: "You swing your pickaxe at the adamantite rock..."
+fail_message: "You chip away but get nothing useful."
+drops:
+ - item_id: adamantite_ore
+ weight: 100
+ depletes: true
+ message: "You manage to mine some adamantite ore."
+deplete_delay: 140
+respawn_message: "You see more ore in the rock."
+respawn_broadcast: "A green glint of adamantite catches your eye from some {name}."
diff --git a/data/behaviors/mine_clay.yaml b/data/behaviors/mine_clay.yaml
new file mode 100644
index 0000000..8798671
--- /dev/null
+++ b/data/behaviors/mine_clay.yaml
@@ -0,0 +1,21 @@
+id: mine_clay
+type: gather
+skill: mining
+level: 1
+xp: 5
+base_wait: 10
+tools: [pickaxe]
+success:
+ base: 0.55
+ per_level: 0.01
+ cap: 0.95
+gather_message: "You dig at the soft clay..."
+fail_message: "You fail to get any clay."
+drops:
+ - item_id: clay
+ weight: 100
+ depletes: true
+ message: "You manage to get some clay."
+deplete_delay: 50
+respawn_message: "The clay deposit is ready to dig again."
+respawn_broadcast: "A deposit of clay softens."
diff --git a/data/behaviors/mine_coal.yaml b/data/behaviors/mine_coal.yaml
new file mode 100644
index 0000000..036a009
--- /dev/null
+++ b/data/behaviors/mine_coal.yaml
@@ -0,0 +1,21 @@
+id: mine_coal
+type: gather
+skill: mining
+level: 30
+xp: 50
+base_wait: 10
+tools: [pickaxe]
+success:
+ base: 0.30
+ per_level: 0.01
+ cap: 0.95
+gather_message: "You swing your pickaxe at the coal rock..."
+fail_message: "You chip away but get nothing useful."
+drops:
+ - item_id: coal
+ weight: 100
+ depletes: true
+ message: "You manage to mine some coal."
+deplete_delay: 80
+respawn_message: "You see more coal in the rock."
+respawn_broadcast: "A dark vein of coal is visible in some {name}."
diff --git a/data/behaviors/mine_copper.yaml b/data/behaviors/mine_copper.yaml
index 1f2f884..d4b8fbf 100644
--- a/data/behaviors/mine_copper.yaml
+++ b/data/behaviors/mine_copper.yaml
@@ -2,10 +2,11 @@ id: mine_copper
type: gather
skill: mining
level: 1
-base_wait: 8
-tool: pickaxe
+xp: 17
+base_wait: 10
+tools: [pickaxe]
success:
- base: 0.40
+ base: 0.50
per_level: 0.01
cap: 0.95
gather_message: "You swing your pickaxe at the rock..."
diff --git a/data/behaviors/mine_gold.yaml b/data/behaviors/mine_gold.yaml
new file mode 100644
index 0000000..731dbec
--- /dev/null
+++ b/data/behaviors/mine_gold.yaml
@@ -0,0 +1,21 @@
+id: mine_gold
+type: gather
+skill: mining
+level: 40
+xp: 65
+base_wait: 10
+tools: [pickaxe]
+success:
+ base: 0.25
+ per_level: 0.01
+ cap: 0.95
+gather_message: "You swing your pickaxe at the gold rock..."
+fail_message: "You chip away but get nothing useful."
+drops:
+ - item_id: gold_ore
+ weight: 100
+ depletes: true
+ message: "You manage to mine some gold ore."
+deplete_delay: 90
+respawn_message: "You see more ore in the rock."
+respawn_broadcast: "A golden glint catches your eye from some {name}."
diff --git a/data/behaviors/mine_iron.yaml b/data/behaviors/mine_iron.yaml
new file mode 100644
index 0000000..fbd5ac1
--- /dev/null
+++ b/data/behaviors/mine_iron.yaml
@@ -0,0 +1,21 @@
+id: mine_iron
+type: gather
+skill: mining
+level: 15
+xp: 35
+base_wait: 10
+tools: [pickaxe]
+success:
+ base: 0.40
+ per_level: 0.01
+ cap: 0.95
+gather_message: "You swing your pickaxe at the iron rock..."
+fail_message: "You chip away but get nothing useful."
+drops:
+ - item_id: iron_ore
+ weight: 100
+ depletes: true
+ message: "You manage to mine some iron ore."
+deplete_delay: 60
+respawn_message: "You see more ore in the rock."
+respawn_broadcast: "A glint of iron catches your eye from some {name}."
diff --git a/data/behaviors/mine_mithril.yaml b/data/behaviors/mine_mithril.yaml
new file mode 100644
index 0000000..53ffe7f
--- /dev/null
+++ b/data/behaviors/mine_mithril.yaml
@@ -0,0 +1,21 @@
+id: mine_mithril
+type: gather
+skill: mining
+level: 55
+xp: 80
+base_wait: 10
+tools: [pickaxe]
+success:
+ base: 0.20
+ per_level: 0.01
+ cap: 0.90
+gather_message: "You swing your pickaxe at the mithril rock..."
+fail_message: "You chip away but get nothing useful."
+drops:
+ - item_id: mithril_ore
+ weight: 100
+ depletes: true
+ message: "You manage to mine some mithril ore."
+deplete_delay: 110
+respawn_message: "You see more ore in the rock."
+respawn_broadcast: "A blue glint of mithril catches your eye from some {name}."
diff --git a/data/behaviors/mine_runite.yaml b/data/behaviors/mine_runite.yaml
new file mode 100644
index 0000000..4fbd547
--- /dev/null
+++ b/data/behaviors/mine_runite.yaml
@@ -0,0 +1,21 @@
+id: mine_runite
+type: gather
+skill: mining
+level: 85
+xp: 125
+base_wait: 10
+tools: [pickaxe]
+success:
+ base: 0.08
+ per_level: 0.01
+ cap: 0.80
+gather_message: "You swing your pickaxe at the runite rock..."
+fail_message: "You chip away but get nothing useful."
+drops:
+ - item_id: runite_ore
+ weight: 100
+ depletes: true
+ message: "You manage to mine some runite ore."
+deplete_delay: 180
+respawn_message: "You see more ore in the rock."
+respawn_broadcast: "A pale blue glint of runite catches your eye from some {name}."
diff --git a/data/behaviors/mine_scrap.yaml b/data/behaviors/mine_scrap.yaml
new file mode 100644
index 0000000..57e0362
--- /dev/null
+++ b/data/behaviors/mine_scrap.yaml
@@ -0,0 +1,21 @@
+id: mine_scrap
+type: gather
+skill: mining
+level: 1
+xp: 5
+base_wait: 10
+tools: [pickaxe]
+success:
+ base: 0.55
+ per_level: 0.01
+ cap: 0.95
+gather_message: "You swing your pickaxe at the scrap pile..."
+fail_message: "You find nothing useful."
+drops:
+ - item_id: scrap_metal
+ weight: 100
+ depletes: true
+ message: "You salvage some scrap metal."
+deplete_delay: 50
+respawn_message: "More scrap is revealed in the pile."
+respawn_broadcast: "The scrap pile looks worth mining again."
diff --git a/data/behaviors/mine_silver.yaml b/data/behaviors/mine_silver.yaml
new file mode 100644
index 0000000..e6652cb
--- /dev/null
+++ b/data/behaviors/mine_silver.yaml
@@ -0,0 +1,21 @@
+id: mine_silver
+type: gather
+skill: mining
+level: 20
+xp: 40
+base_wait: 10
+tools: [pickaxe]
+success:
+ base: 0.35
+ per_level: 0.01
+ cap: 0.95
+gather_message: "You swing your pickaxe at the silver rock..."
+fail_message: "You chip away but get nothing useful."
+drops:
+ - item_id: silver_ore
+ weight: 100
+ depletes: true
+ message: "You manage to mine some silver ore."
+deplete_delay: 70
+respawn_message: "You see more ore in the rock."
+respawn_broadcast: "A silvery glint catches your eye from some {name}."
diff --git a/data/behaviors/mine_tin.yaml b/data/behaviors/mine_tin.yaml
new file mode 100644
index 0000000..b771a44
--- /dev/null
+++ b/data/behaviors/mine_tin.yaml
@@ -0,0 +1,21 @@
+id: mine_tin
+type: gather
+skill: mining
+level: 1
+xp: 17
+base_wait: 10
+tools: [pickaxe]
+success:
+ base: 0.50
+ per_level: 0.01
+ cap: 0.95
+gather_message: "You swing your pickaxe at the rock..."
+fail_message: "You chip away but get nothing useful."
+drops:
+ - item_id: tin_ore
+ weight: 100
+ depletes: true
+ message: "You manage to mine some tin ore."
+deplete_delay: 50
+respawn_message: "You see more ore in the rock."
+respawn_broadcast: "A glint of tin catches your eye from some {name}."
diff --git a/data/drops/birds_nest_drop.yaml b/data/drops/birds_nest_drop.yaml
new file mode 100644
index 0000000..d573d66
--- /dev/null
+++ b/data/drops/birds_nest_drop.yaml
@@ -0,0 +1,17 @@
+id: birds_nest_drop
+drops:
+ - item_id: credits
+ weight: 50
+ quantity: 200
+ - item_id: credits
+ weight: 30
+ quantity: 500
+ - item_id: credits
+ weight: 15
+ quantity: 1000
+ - item_id: credits
+ weight: 4
+ quantity: 3000
+ - item_id: credits
+ weight: 1
+ quantity: 10000
diff --git a/data/help/alias.yaml b/data/help/alias.yaml
new file mode 100644
index 0000000..4be2f98
--- /dev/null
+++ b/data/help/alias.yaml
@@ -0,0 +1,15 @@
+name: "alias"
+category: "General"
+description: |
+ Create and manage command aliases.
+
+ Usage: alias - List all aliases
+ alias <name> <cmd> - Create an alias (e.g. alias k kill)
+ unalias <name> - Remove an alias
+
+ Aliases are account-wide and shared by all your characters. They
+ support argument passthrough: "alias k kill" means "k man" expands
+ to "kill man".
+
+ Aliases resolve before built-in commands. An alias can shadow an
+ existing command name.
diff --git a/data/help/attack.yaml b/data/help/attack.yaml
index 8c0bec3..96a4bcd 100644
--- a/data/help/attack.yaml
+++ b/data/help/attack.yaml
@@ -3,7 +3,7 @@ category: "Combat"
description: |
Attack a mob in the current room.
- Usage: attack <mob>, kill <mob>, fight <mob>
+ Usage: attack <mob>, kill <mob>
attack 1.man, kill 2.man
Enter combat with the named mob. If multiple mobs with different names
@@ -12,4 +12,4 @@ description: |
name (e.g. "attack 1.man"). Plain "attack man" targets the first.
Attacks occur at weapon speed on the 600ms tick. Use "style" to change
- combat style. You are locked in place for 15 ticks when combat starts.
+ combat style. You are locked in place when combat starts.
diff --git a/data/help/drop.yaml b/data/help/drop.yaml
index b4e1a95..a4f2d5c 100644
--- a/data/help/drop.yaml
+++ b/data/help/drop.yaml
@@ -4,6 +4,10 @@ description: |
Drop an item from your inventory.
Usage: drop <item>
+ drop all
Drops one unit of the named item onto the ground in your current room.
- Other players can then pick it up.
+ Other players can then pick it up. "drop all" drops your entire
+ inventory after confirmation.
+
+ Dropping an item cancels any active gathering or crafting action.
diff --git a/data/help/equipment.yaml b/data/help/equipment.yaml
index b2224a4..1a4b229 100644
--- a/data/help/equipment.yaml
+++ b/data/help/equipment.yaml
@@ -6,3 +6,5 @@ description: |
Usage: equipment, eq
Shows what you have equipped in each slot (main hand, head, torso, etc.).
+ Use "wear <item>" or "wield <item>" to equip items from your inventory.
+ Use "remove <item>" to unequip.
diff --git a/data/help/fishing.yaml b/data/help/fishing.yaml
index 60f2d6a..cf2ea95 100644
--- a/data/help/fishing.yaml
+++ b/data/help/fishing.yaml
@@ -1,14 +1,22 @@
name: "fishing"
category: "Skills"
description: |
- Fish from fishing spots.
+ Fish from fishing spots at the water's edge.
Usage: fish <spot>, fish <N.spot>
- Fishing requires a tool matching the spot's tool type (e.g. fishing_rod).
- Better rods have higher tool_speed, reducing the wait between casts.
+ Fishing requires the correct tool for the spot you're using. Different
+ spots call for different equipment — nets, rods, harpoons, or pots.
+ Some spots also require bait (fishing bait or feathers), which is
+ consumed each cast whether you catch anything or not.
- Each cast has a chance to catch fish based on your fishing level.
- Fishing spots never deplete — you fish until your inventory fills or
- the spot wanders away. Fishing spots move between predefined rooms,
- simulating wandering fish schools.
+ If you run out of bait, fishing stops. Better tools have higher
+ tool_speed, reducing wait time. A wielded tool takes priority over
+ inventory tools.
+
+ Each spot type can yield different fish depending on your fishing
+ level. Higher level unlocks more valuable catches.
+
+ Look at a spot to see what equipment it needs.
+
+ Enable the "xpdrops" toggle to see XP gains with each catch.
diff --git a/data/help/gathering.yaml b/data/help/gathering.yaml
index 57c317e..876f92f 100644
--- a/data/help/gathering.yaml
+++ b/data/help/gathering.yaml
@@ -5,18 +5,32 @@ description: |
Usage: mine <object> - Mine ore from rocks (requires pickaxe)
chop <object> - Chop logs from trees (requires axe)
- fish <object> - Fish from fishing spots (requires rod/net)
+ cut <object> - Same as chop
+ fish <object> - Fish from fishing spots (requires correct tool)
Gathering skills use a tick-based action system:
1. You begin the action (e.g. "You swing your pickaxe...")
2. After a wait (reduced by tool speed), the game rolls for success
3. Success picks a weighted random drop from the object's drop table
- 4. Depleting drops remove the resource temporarily; rare drops don't
- Tools: Items with tool_type and tool_speed. The best matching tool in
- your inventory or toolbelt is used automatically. Higher tool_speed
- means faster gathering.
+ Tools: Items with tool_type and tool_speed. A wielded tool always takes
+ priority. Otherwise the first matching tool in your inventory is used.
+ Higher tool_speed means faster gathering.
+
+ Bait: Some fishing spots consume bait each attempt. If you run out,
+ the action stops.
+
+ XP: All successful gathers award XP. Enable the "xpdrops" toggle to
+ see XP gains with each drop (e.g. "+37xp wct"). Fishing catches award
+ XP based on the specific fish caught.
Numbered targeting: "mine 1.rock" targets a specific instance when
multiple objects of the same name are present. "look" shows instance
indices and depletion status.
+
+ Two depletion mechanics:
+ - Per-drop (rocks): Depletes on first successful gather. Respawns
+ after a fixed delay.
+ - Shared (trees): A despawn timer counts down while being chopped.
+ When it hits 0, the next successful gather depletes it for everyone.
+ If no one is chopping, the timer regenerates back up.
diff --git a/data/help/get.yaml b/data/help/get.yaml
index abd416e..777541b 100644
--- a/data/help/get.yaml
+++ b/data/help/get.yaml
@@ -8,3 +8,5 @@ description: |
"get all" picks up everything on the ground. Credits are automatically
added to your credit balance and never take inventory space.
+
+ Picking up an item cancels any active gathering or crafting action.
diff --git a/data/help/look.yaml b/data/help/look.yaml
index b1d7a6d..2be3d30 100644
--- a/data/help/look.yaml
+++ b/data/help/look.yaml
@@ -8,16 +8,19 @@ description: |
Without a target, shows: room name, description, objects (with instance
indices and depletion status), mobs (with HP and combat level), ground
- items (with reservation info), exits, and other players.
+ items (alphabetically sorted, with reservation info), exits, and other
+ players.
Players are shown with their current action (e.g. "Bob is here, mining
a copper rock [1]") or combat status ("Bob is here (fighting a man [2])").
- Instance indices appear when multiple objects or mobs of the same type
- are present.
+ Instance indices appear only when multiple objects or mobs of the same
+ type are present and differ in state.
With a target, shows details about:
- Mobs: stats and combat level
- - Objects: description and depletion timers (per instance)
+ - Objects: description and, with the depletion toggle on, timers
- Items (ground or inventory): description and value
- Players: stats, equipment, and description
- Directions: shows the adjacent room as if you were there
+
+ Use "toggle depletion" to show depletion and despawn timers on objects.
diff --git a/data/help/mining.yaml b/data/help/mining.yaml
index b883465..231027f 100644
--- a/data/help/mining.yaml
+++ b/data/help/mining.yaml
@@ -4,9 +4,9 @@ description: |
Mine ore from rocks.
Usage: mine <rock>, mine <N.rock>
- mine 1.rock, mine 2.rock
+ mine 1.rock, mine copper
- Mining requires a pickaxe in your inventory or toolbelt. Better pickaxes
+ Mining requires a pickaxe, either wielded or in your inventory. Better pickaxes
have higher tool_speed, which reduces the wait between swings.
Each swing has a chance to yield ore based on your mining level. If you
@@ -14,8 +14,8 @@ description: |
mining the same rock are interrupted when it depletes.
If you get a gem or other rare drop, the rock does not deplete and you
- continue mining. Type "mine rock" again during a wait to restart the
- swing cycle immediately.
+ continue mining. Enable the "xpdrops" toggle to see XP gains.
Use numbered targeting (e.g. "mine 2.rock") when multiple rocks of the
same type are present. "look rock" shows which rocks are depleted.
+ Use "toggle depletion" to see exact respawn timers.
diff --git a/data/help/movement.yaml b/data/help/movement.yaml
index 239220f..91aefa6 100644
--- a/data/help/movement.yaml
+++ b/data/help/movement.yaml
@@ -6,8 +6,8 @@ description: |
Usage: north, south, east, west, up, down
Aliases: n, s, e, w, u, d
- You cannot move for 15 ticks after combat starts. Moving interrupts
- combat and any active gathering or crafting action.
+ You cannot move during the combat lock period after combat starts.
+ Moving interrupts combat and any active gathering or crafting action.
With "description" toggled on, moving shows the full room description
instead of just the room name. Use "toggle" to change this setting.
diff --git a/data/help/quit.yaml b/data/help/quit.yaml
index c41771b..ce45c34 100644
--- a/data/help/quit.yaml
+++ b/data/help/quit.yaml
@@ -5,11 +5,12 @@ description: |
Usage: quit
- Cannot be used during combat. Starts a 10-tick rest sequence:
+ Stops your current action (gathering, crafting, etc.). Cannot be used
+ during combat. Starts a 10-tick rest sequence:
10 ticks: "You sit down to rest..."
6 ticks: "You catch your breath..."
3 ticks: "You close your eyes..."
0 ticks: Returns to account menu
- The rest is cancelled if you take any action or if combat starts.
- Saves your character when the timer completes.
+ The rest is cancelled if you take any action. Saves your character
+ when the timer completes.
diff --git a/data/help/remove.yaml b/data/help/remove.yaml
new file mode 100644
index 0000000..757bb27
--- /dev/null
+++ b/data/help/remove.yaml
@@ -0,0 +1,11 @@
+name: "remove"
+category: "Items"
+description: |
+ Unequip an item and return it to your inventory.
+
+ Usage: remove <item>, unwear <item>, unwield <item>
+
+ Removes the named item (or the item in the named slot) and places it
+ in your inventory. If your inventory is full, you cannot remove it.
+
+ Examples: remove axe, remove main_hand
diff --git a/data/help/search.yaml b/data/help/search.yaml
new file mode 100644
index 0000000..42eec52
--- /dev/null
+++ b/data/help/search.yaml
@@ -0,0 +1,12 @@
+name: "search"
+category: "Items"
+description: |
+ Search an item in your inventory.
+
+ Usage: search <item>
+
+ Example: search nest, search birds nest
+
+ Opens a searchable item (currently only bird's nests) and rolls on its
+ drop table for loot. The item is consumed in the process. Items found
+ go to inventory or fall to the ground if full.
diff --git a/data/help/style.yaml b/data/help/style.yaml
index 3016013..50b4a38 100644
--- a/data/help/style.yaml
+++ b/data/help/style.yaml
@@ -10,4 +10,5 @@ description: |
Defensive - +3 defense bonus, trains Defense (75%) and Hitpoints (25%)
Balanced - +1 all bonuses, trains all combat skills evenly (25% each)
- XP is awarded per hit: 4 XP per point of damage.
+ XP is awarded per hit: 4 XP per point of damage. Enable "xpdrops" to
+ see XP gains in combat messages.
diff --git a/data/help/toggle.yaml b/data/help/toggle.yaml
index 44a2b33..27080be 100644
--- a/data/help/toggle.yaml
+++ b/data/help/toggle.yaml
@@ -9,9 +9,10 @@ description: |
Toggles:
description - Long room descriptions when moving between rooms
tinymap - Mini-map display (not yet implemented)
- xpdrops - Show XP gained in combat hit messages
+ xpdrops - Show XP gained in gather, crafting, and combat messages
exits - Show exit destinations in look output
mobenter - Notify when a mob enters the room
mobleave - Notify when a mob leaves the room
mobspawn - Notify when a mob spawns in the area
reserve - Show full reserved item details in look
+ depletion - Show depletion and despawn timers on objects
diff --git a/data/help/wear.yaml b/data/help/wear.yaml
new file mode 100644
index 0000000..d0b665d
--- /dev/null
+++ b/data/help/wear.yaml
@@ -0,0 +1,16 @@
+name: "wear"
+category: "Items"
+description: |
+ Equip weapons and tools from your inventory.
+
+ Usage: wear <item>, wield <item>
+ wear all
+
+ Equips the named item into its appropriate equipment slot. If something
+ is already in that slot, they are swapped. "wear all" equips the
+ highest-value item for each slot from your inventory.
+
+ Equipped tools (pickaxes, axes, fishing rods) are used for gathering
+ actions and always take priority over inventory tools.
+
+ Worn items appear on the equipment/eq screen.
diff --git a/data/help/woodcutting.yaml b/data/help/woodcutting.yaml
new file mode 100644
index 0000000..80179a9
--- /dev/null
+++ b/data/help/woodcutting.yaml
@@ -0,0 +1,22 @@
+name: "woodcutting"
+category: "Skills"
+description: |
+ Chop logs from trees.
+
+ Usage: chop <tree>, cut <tree>
+ chop 1.oak, chop oak tree
+
+ Woodcutting requires an axe, either wielded or in your inventory. Better axes
+ have higher tool_speed, reducing the wait between swings. Axes can
+ also be equipped as melee weapons.
+
+ Regular trees deplete on first successful chop and respawn after a
+ delay. Higher-tier trees (oak and above) use shared depletion: a
+ despawn timer counts down while anyone chops. When it reaches 0, the
+ next successful gather depletes the tree for all choppers. If no one
+ is chopping, the timer regenerates back toward its maximum.
+
+ All trees except regular trees have a 1/256 chance to drop a bird's
+ nest alongside the logs. Use "search nest" to open a nest for loot.
+
+ Use the "depletion" toggle to see timers on trees and rocks.
diff --git a/data/items/adamant_axe.yaml b/data/items/adamant_axe.yaml
new file mode 100644
index 0000000..389e21e
--- /dev/null
+++ b/data/items/adamant_axe.yaml
@@ -0,0 +1,15 @@
+id: adamant_axe
+name: adamant axe
+aliases: ["axe", "hatchet"]
+description: "A powerful adamant axe."
+value: 1250
+stackable: false
+equip_slot: main_hand
+weapon_type: melee
+stats:
+ attack_bonus: 12
+ strength_bonus: 7
+ defense_bonus: 0
+speed: 5
+tool_type: axe
+tool_speed: 8
diff --git a/data/items/adamant_pickaxe.yaml b/data/items/adamant_pickaxe.yaml
new file mode 100644
index 0000000..953dec0
--- /dev/null
+++ b/data/items/adamant_pickaxe.yaml
@@ -0,0 +1,15 @@
+id: adamant_pickaxe
+name: adamant pickaxe
+aliases: ["pick", "pickaxe"]
+description: "A powerful adamant pickaxe."
+value: 1250
+stackable: false
+equip_slot: main_hand
+weapon_type: melee
+stats:
+ attack_bonus: 12
+ strength_bonus: 7
+ defense_bonus: 0
+speed: 5
+tool_type: pickaxe
+tool_speed: 6
diff --git a/data/items/adamantite_ore.yaml b/data/items/adamantite_ore.yaml
new file mode 100644
index 0000000..078deea
--- /dev/null
+++ b/data/items/adamantite_ore.yaml
@@ -0,0 +1,6 @@
+id: adamantite_ore
+name: adamantite ore
+aliases: ["adamantite", "adamant", "ore"]
+description: "A chunk of dark green adamantite ore."
+value: 100
+stackable: false
diff --git a/data/items/big_fishing_net.yaml b/data/items/big_fishing_net.yaml
new file mode 100644
index 0000000..da8a321
--- /dev/null
+++ b/data/items/big_fishing_net.yaml
@@ -0,0 +1,15 @@
+id: big_fishing_net
+name: big fishing net
+aliases: ["net", "big net"]
+description: "A larger fishing net for catching bigger fish."
+value: 20
+stackable: false
+equip_slot: main_hand
+weapon_type: melee
+stats:
+ attack_bonus: 0
+ strength_bonus: 0
+ defense_bonus: 0
+speed: 5
+tool_type: big_net
+tool_speed: 1
diff --git a/data/items/birds_nest.yaml b/data/items/birds_nest.yaml
new file mode 100644
index 0000000..674cdaa
--- /dev/null
+++ b/data/items/birds_nest.yaml
@@ -0,0 +1,6 @@
+id: birds_nest
+name: bird's nest
+aliases: ["nest", "birds", "bird"]
+description: "A bird's nest. You can search it for treasure."
+value: 5
+stackable: false
diff --git a/data/items/black_axe.yaml b/data/items/black_axe.yaml
new file mode 100644
index 0000000..d7ec254
--- /dev/null
+++ b/data/items/black_axe.yaml
@@ -0,0 +1,15 @@
+id: black_axe
+name: black axe
+aliases: ["axe", "hatchet"]
+description: "A sleek black axe, as fast as steel."
+value: 500
+stackable: false
+equip_slot: main_hand
+weapon_type: melee
+stats:
+ attack_bonus: 7
+ strength_bonus: 4
+ defense_bonus: 0
+speed: 5
+tool_type: axe
+tool_speed: 6
diff --git a/data/items/black_pickaxe.yaml b/data/items/black_pickaxe.yaml
new file mode 100644
index 0000000..f29c05c
--- /dev/null
+++ b/data/items/black_pickaxe.yaml
@@ -0,0 +1,15 @@
+id: black_pickaxe
+name: black pickaxe
+aliases: ["pick", "pickaxe"]
+description: "A sleek black pickaxe."
+value: 500
+stackable: false
+equip_slot: main_hand
+weapon_type: melee
+stats:
+ attack_bonus: 7
+ strength_bonus: 4
+ defense_bonus: 0
+speed: 5
+tool_type: pickaxe
+tool_speed: 5
diff --git a/data/items/bronze_axe.yaml b/data/items/bronze_axe.yaml
new file mode 100644
index 0000000..802a9f7
--- /dev/null
+++ b/data/items/bronze_axe.yaml
@@ -0,0 +1,15 @@
+id: bronze_axe
+name: bronze axe
+aliases: ["axe", "hatchet"]
+description: "A sturdy bronze axe, good for woodcutting."
+value: 10
+stackable: false
+equip_slot: main_hand
+weapon_type: melee
+stats:
+ attack_bonus: 2
+ strength_bonus: 1
+ defense_bonus: 0
+speed: 5
+tool_type: axe
+tool_speed: 4
diff --git a/data/items/bronze_pickaxe.yaml b/data/items/bronze_pickaxe.yaml
index 260652b..c451887 100644
--- a/data/items/bronze_pickaxe.yaml
+++ b/data/items/bronze_pickaxe.yaml
@@ -11,6 +11,5 @@ stats:
strength_bonus: 1
defense_bonus: 0
speed: 5
-toolbelt: true
tool_type: pickaxe
tool_speed: 2
diff --git a/data/items/clay.yaml b/data/items/clay.yaml
new file mode 100644
index 0000000..2a26d51
--- /dev/null
+++ b/data/items/clay.yaml
@@ -0,0 +1,6 @@
+id: clay
+name: clay
+aliases: ["clay"]
+description: "Some soft clay, useful for crafting and pottery."
+value: 1
+stackable: false
diff --git a/data/items/coal.yaml b/data/items/coal.yaml
new file mode 100644
index 0000000..7e88fda
--- /dev/null
+++ b/data/items/coal.yaml
@@ -0,0 +1,6 @@
+id: coal
+name: coal
+aliases: ["coal"]
+description: "A lump of coal, useful for smelting."
+value: 5
+stackable: false
diff --git a/data/items/copper_ore.yaml b/data/items/copper_ore.yaml
index 7192b92..215ece8 100644
--- a/data/items/copper_ore.yaml
+++ b/data/items/copper_ore.yaml
@@ -3,4 +3,4 @@ name: copper ore
aliases: ["ore", "copper"]
description: "A chunk of copper-bearing rock. Can be smelted into a bronze bar."
value: 1
-stackable: true
+stackable: false
diff --git a/data/items/dragon_axe.yaml b/data/items/dragon_axe.yaml
new file mode 100644
index 0000000..613f7c6
--- /dev/null
+++ b/data/items/dragon_axe.yaml
@@ -0,0 +1,15 @@
+id: dragon_axe
+name: dragon axe
+aliases: ["axe", "hatchet"]
+description: "A legendary dragon axe that bites through wood like butter."
+value: 100000
+stackable: false
+equip_slot: main_hand
+weapon_type: melee
+stats:
+ attack_bonus: 22
+ strength_bonus: 14
+ defense_bonus: 0
+speed: 5
+tool_type: axe
+tool_speed: 10
diff --git a/data/items/dragon_pickaxe.yaml b/data/items/dragon_pickaxe.yaml
new file mode 100644
index 0000000..bb6211d
--- /dev/null
+++ b/data/items/dragon_pickaxe.yaml
@@ -0,0 +1,15 @@
+id: dragon_pickaxe
+name: dragon pickaxe
+aliases: ["pick", "pickaxe"]
+description: "A legendary dragon pickaxe that pulverizes rock."
+value: 100000
+stackable: false
+equip_slot: main_hand
+weapon_type: melee
+stats:
+ attack_bonus: 22
+ strength_bonus: 14
+ defense_bonus: 0
+speed: 5
+tool_type: pickaxe
+tool_speed: 8
diff --git a/data/items/feather.yaml b/data/items/feather.yaml
new file mode 100644
index 0000000..d9216f8
--- /dev/null
+++ b/data/items/feather.yaml
@@ -0,0 +1,6 @@
+id: feather
+name: feather
+aliases: ["feathers"]
+description: "A colorful feather. Used as fly fishing bait."
+value: 1
+stackable: true
diff --git a/data/items/fishing_bait.yaml b/data/items/fishing_bait.yaml
new file mode 100644
index 0000000..004a61d
--- /dev/null
+++ b/data/items/fishing_bait.yaml
@@ -0,0 +1,6 @@
+id: fishing_bait
+name: fishing bait
+aliases: ["bait"]
+description: "A handful of wriggling bait. Used with a fishing rod."
+value: 1
+stackable: true
diff --git a/data/items/fishing_rod.yaml b/data/items/fishing_rod.yaml
index 5441ba6..24c8ce9 100644
--- a/data/items/fishing_rod.yaml
+++ b/data/items/fishing_rod.yaml
@@ -1,9 +1,15 @@
id: fishing_rod
name: fishing rod
aliases: ["rod", "fishing"]
-description: "A simple fishing rod with a hook and line."
+description: "A simple fishing rod. Use with fishing bait."
value: 5
stackable: false
-toolbelt: true
+equip_slot: main_hand
+weapon_type: melee
+stats:
+ attack_bonus: 0
+ strength_bonus: 0
+ defense_bonus: 0
+speed: 5
tool_type: fishing_rod
tool_speed: 0
diff --git a/data/items/fly_fishing_rod.yaml b/data/items/fly_fishing_rod.yaml
new file mode 100644
index 0000000..a2571f8
--- /dev/null
+++ b/data/items/fly_fishing_rod.yaml
@@ -0,0 +1,15 @@
+id: fly_fishing_rod
+name: fly fishing rod
+aliases: ["rod", "fly rod", "fly fishing"]
+description: "A flexible rod for fly fishing with artificial flies."
+value: 20
+stackable: false
+equip_slot: main_hand
+weapon_type: melee
+stats:
+ attack_bonus: 0
+ strength_bonus: 0
+ defense_bonus: 0
+speed: 5
+tool_type: fly_rod
+tool_speed: 1
diff --git a/data/items/gold_ore.yaml b/data/items/gold_ore.yaml
new file mode 100644
index 0000000..28f3352
--- /dev/null
+++ b/data/items/gold_ore.yaml
@@ -0,0 +1,6 @@
+id: gold_ore
+name: gold ore
+aliases: ["gold", "ore"]
+description: "A glittering chunk of gold ore."
+value: 25
+stackable: false
diff --git a/data/items/harpoon.yaml b/data/items/harpoon.yaml
new file mode 100644
index 0000000..d69cc41
--- /dev/null
+++ b/data/items/harpoon.yaml
@@ -0,0 +1,15 @@
+id: harpoon
+name: harpoon
+aliases: ["harpoon", "spear"]
+description: "A barbed harpoon for spearing large fish."
+value: 30
+stackable: false
+equip_slot: main_hand
+weapon_type: melee
+stats:
+ attack_bonus: 0
+ strength_bonus: 0
+ defense_bonus: 0
+speed: 5
+tool_type: harpoon
+tool_speed: 0
diff --git a/data/items/iron_axe.yaml b/data/items/iron_axe.yaml
new file mode 100644
index 0000000..d236a5d
--- /dev/null
+++ b/data/items/iron_axe.yaml
@@ -0,0 +1,15 @@
+id: iron_axe
+name: iron axe
+aliases: ["axe", "hatchet"]
+description: "A solid iron axe, better than bronze."
+value: 50
+stackable: false
+equip_slot: main_hand
+weapon_type: melee
+stats:
+ attack_bonus: 3
+ strength_bonus: 2
+ defense_bonus: 0
+speed: 5
+tool_type: axe
+tool_speed: 5
diff --git a/data/items/iron_ore.yaml b/data/items/iron_ore.yaml
new file mode 100644
index 0000000..c89d789
--- /dev/null
+++ b/data/items/iron_ore.yaml
@@ -0,0 +1,6 @@
+id: iron_ore
+name: iron ore
+aliases: ["iron", "ore"]
+description: "A chunk of iron ore, ready for smelting."
+value: 5
+stackable: false
diff --git a/data/items/iron_pickaxe.yaml b/data/items/iron_pickaxe.yaml
new file mode 100644
index 0000000..83a436a
--- /dev/null
+++ b/data/items/iron_pickaxe.yaml
@@ -0,0 +1,15 @@
+id: iron_pickaxe
+name: iron pickaxe
+aliases: ["pick", "pickaxe"]
+description: "A solid iron pickaxe, better than bronze."
+value: 50
+stackable: false
+equip_slot: main_hand
+weapon_type: melee
+stats:
+ attack_bonus: 3
+ strength_bonus: 2
+ defense_bonus: 0
+speed: 5
+tool_type: pickaxe
+tool_speed: 3
diff --git a/data/items/lobster_pot.yaml b/data/items/lobster_pot.yaml
new file mode 100644
index 0000000..52fe908
--- /dev/null
+++ b/data/items/lobster_pot.yaml
@@ -0,0 +1,15 @@
+id: lobster_pot
+name: lobster pot
+aliases: ["pot", "lobster", "cage"]
+description: "A wire cage for trapping lobsters."
+value: 20
+stackable: false
+equip_slot: main_hand
+weapon_type: melee
+stats:
+ attack_bonus: 0
+ strength_bonus: 0
+ defense_bonus: 0
+speed: 5
+tool_type: lobster_pot
+tool_speed: 0
diff --git a/data/items/logs.yaml b/data/items/logs.yaml
new file mode 100644
index 0000000..9b148ae
--- /dev/null
+++ b/data/items/logs.yaml
@@ -0,0 +1,6 @@
+id: logs
+name: logs
+aliases: ["log", "wood"]
+description: "Some regular logs, good for firemaking and fletching."
+value: 2
+stackable: false
diff --git a/data/items/magic_logs.yaml b/data/items/magic_logs.yaml
new file mode 100644
index 0000000..baac96b
--- /dev/null
+++ b/data/items/magic_logs.yaml
@@ -0,0 +1,6 @@
+id: magic_logs
+name: magic logs
+aliases: ["logs", "magic", "log"]
+description: "Some mystical magic logs."
+value: 80
+stackable: false
diff --git a/data/items/mahogany_logs.yaml b/data/items/mahogany_logs.yaml
new file mode 100644
index 0000000..87ea061
--- /dev/null
+++ b/data/items/mahogany_logs.yaml
@@ -0,0 +1,6 @@
+id: mahogany_logs
+name: mahogany logs
+aliases: ["logs", "mahogany", "log"]
+description: "Some beautiful mahogany logs."
+value: 24
+stackable: false
diff --git a/data/items/maple_logs.yaml b/data/items/maple_logs.yaml
new file mode 100644
index 0000000..11f7dcd
--- /dev/null
+++ b/data/items/maple_logs.yaml
@@ -0,0 +1,6 @@
+id: maple_logs
+name: maple logs
+aliases: ["logs", "maple", "log"]
+description: "Some sturdy maple logs."
+value: 16
+stackable: false
diff --git a/data/items/mithril_axe.yaml b/data/items/mithril_axe.yaml
new file mode 100644
index 0000000..642f6d5
--- /dev/null
+++ b/data/items/mithril_axe.yaml
@@ -0,0 +1,15 @@
+id: mithril_axe
+name: mithril axe
+aliases: ["axe", "hatchet"]
+description: "A lightweight mithril axe."
+value: 500
+stackable: false
+equip_slot: main_hand
+weapon_type: melee
+stats:
+ attack_bonus: 9
+ strength_bonus: 5
+ defense_bonus: 0
+speed: 5
+tool_type: axe
+tool_speed: 7
diff --git a/data/items/mithril_ore.yaml b/data/items/mithril_ore.yaml
new file mode 100644
index 0000000..c0b9481
--- /dev/null
+++ b/data/items/mithril_ore.yaml
@@ -0,0 +1,6 @@
+id: mithril_ore
+name: mithril ore
+aliases: ["mithril", "ore"]
+description: "A piece of light blue mithril ore."
+value: 50
+stackable: false
diff --git a/data/items/mithril_pickaxe.yaml b/data/items/mithril_pickaxe.yaml
new file mode 100644
index 0000000..8eef9e2
--- /dev/null
+++ b/data/items/mithril_pickaxe.yaml
@@ -0,0 +1,15 @@
+id: mithril_pickaxe
+name: mithril pickaxe
+aliases: ["pick", "pickaxe"]
+description: "A lightweight mithril pickaxe."
+value: 500
+stackable: false
+equip_slot: main_hand
+weapon_type: melee
+stats:
+ attack_bonus: 9
+ strength_bonus: 5
+ defense_bonus: 0
+speed: 5
+tool_type: pickaxe
+tool_speed: 5
diff --git a/data/items/raw_anchovies.yaml b/data/items/raw_anchovies.yaml
new file mode 100644
index 0000000..f833026
--- /dev/null
+++ b/data/items/raw_anchovies.yaml
@@ -0,0 +1,6 @@
+id: raw_anchovies
+name: raw anchovies
+aliases: ["anchovies", "anchovy"]
+description: "Some tiny raw anchovies."
+value: 2
+stackable: false
diff --git a/data/items/raw_anglerfish.yaml b/data/items/raw_anglerfish.yaml
new file mode 100644
index 0000000..42cd9b9
--- /dev/null
+++ b/data/items/raw_anglerfish.yaml
@@ -0,0 +1,6 @@
+id: raw_anglerfish
+name: raw anglerfish
+aliases: ["anglerfish", "angler"]
+description: "An ugly but delicious raw anglerfish."
+value: 20
+stackable: false
diff --git a/data/items/raw_bass.yaml b/data/items/raw_bass.yaml
new file mode 100644
index 0000000..07d373e
--- /dev/null
+++ b/data/items/raw_bass.yaml
@@ -0,0 +1,6 @@
+id: raw_bass
+name: raw bass
+aliases: ["bass"]
+description: "A freshly caught raw bass."
+value: 10
+stackable: false
diff --git a/data/items/raw_cave_eel.yaml b/data/items/raw_cave_eel.yaml
new file mode 100644
index 0000000..66048c1
--- /dev/null
+++ b/data/items/raw_cave_eel.yaml
@@ -0,0 +1,6 @@
+id: raw_cave_eel
+name: raw cave eel
+aliases: ["cave eel", "eel"]
+description: "A pale eel from deep underground waters."
+value: 12
+stackable: false
diff --git a/data/items/raw_cod.yaml b/data/items/raw_cod.yaml
new file mode 100644
index 0000000..cdc4fb3
--- /dev/null
+++ b/data/items/raw_cod.yaml
@@ -0,0 +1,6 @@
+id: raw_cod
+name: raw cod
+aliases: ["cod"]
+description: "A freshly caught raw cod."
+value: 6
+stackable: false
diff --git a/data/items/raw_herring.yaml b/data/items/raw_herring.yaml
new file mode 100644
index 0000000..d6fce43
--- /dev/null
+++ b/data/items/raw_herring.yaml
@@ -0,0 +1,6 @@
+id: raw_herring
+name: raw herring
+aliases: ["herring"]
+description: "A freshly caught raw herring."
+value: 5
+stackable: false
diff --git a/data/items/raw_lobster.yaml b/data/items/raw_lobster.yaml
new file mode 100644
index 0000000..d30907e
--- /dev/null
+++ b/data/items/raw_lobster.yaml
@@ -0,0 +1,6 @@
+id: raw_lobster
+name: raw lobster
+aliases: ["lobster"]
+description: "A freshly caught raw lobster."
+value: 15
+stackable: false
diff --git a/data/items/raw_mackerel.yaml b/data/items/raw_mackerel.yaml
new file mode 100644
index 0000000..5cd81e8
--- /dev/null
+++ b/data/items/raw_mackerel.yaml
@@ -0,0 +1,6 @@
+id: raw_mackerel
+name: raw mackerel
+aliases: ["mackerel"]
+description: "A freshly caught raw mackerel."
+value: 4
+stackable: false
diff --git a/data/items/raw_pike.yaml b/data/items/raw_pike.yaml
new file mode 100644
index 0000000..6313798
--- /dev/null
+++ b/data/items/raw_pike.yaml
@@ -0,0 +1,6 @@
+id: raw_pike
+name: raw pike
+aliases: ["pike"]
+description: "A freshly caught raw pike."
+value: 8
+stackable: false
diff --git a/data/items/raw_rainbow_fish.yaml b/data/items/raw_rainbow_fish.yaml
new file mode 100644
index 0000000..72f20b2
--- /dev/null
+++ b/data/items/raw_rainbow_fish.yaml
@@ -0,0 +1,6 @@
+id: raw_rainbow_fish
+name: raw rainbow fish
+aliases: ["rainbow fish", "rainbow"]
+description: "A shimmering raw rainbow fish."
+value: 10
+stackable: false
diff --git a/data/items/raw_salmon.yaml b/data/items/raw_salmon.yaml
new file mode 100644
index 0000000..e90b8a5
--- /dev/null
+++ b/data/items/raw_salmon.yaml
@@ -0,0 +1,6 @@
+id: raw_salmon
+name: raw salmon
+aliases: ["salmon"]
+description: "A freshly caught raw salmon."
+value: 8
+stackable: false
diff --git a/data/items/raw_sardine.yaml b/data/items/raw_sardine.yaml
new file mode 100644
index 0000000..262d30e
--- /dev/null
+++ b/data/items/raw_sardine.yaml
@@ -0,0 +1,6 @@
+id: raw_sardine
+name: raw sardine
+aliases: ["sardine"]
+description: "A freshly caught raw sardine."
+value: 3
+stackable: false
diff --git a/data/items/raw_shark.yaml b/data/items/raw_shark.yaml
new file mode 100644
index 0000000..05a4e72
--- /dev/null
+++ b/data/items/raw_shark.yaml
@@ -0,0 +1,6 @@
+id: raw_shark
+name: raw shark
+aliases: ["shark"]
+description: "A freshly caught raw shark."
+value: 30
+stackable: false
diff --git a/data/items/raw_shrimps.yaml b/data/items/raw_shrimps.yaml
new file mode 100644
index 0000000..0696f90
--- /dev/null
+++ b/data/items/raw_shrimps.yaml
@@ -0,0 +1,6 @@
+id: raw_shrimps
+name: raw shrimps
+aliases: ["shrimps", "shrimp"]
+description: "Some small raw shrimps."
+value: 2
+stackable: false
diff --git a/data/items/raw_slimy_eel.yaml b/data/items/raw_slimy_eel.yaml
new file mode 100644
index 0000000..535bf4d
--- /dev/null
+++ b/data/items/raw_slimy_eel.yaml
@@ -0,0 +1,6 @@
+id: raw_slimy_eel
+name: raw slimy eel
+aliases: ["slimy eel", "eel"]
+description: "A slippery raw eel covered in slime."
+value: 10
+stackable: false
diff --git a/data/items/raw_swordfish.yaml b/data/items/raw_swordfish.yaml
new file mode 100644
index 0000000..745fdb1
--- /dev/null
+++ b/data/items/raw_swordfish.yaml
@@ -0,0 +1,6 @@
+id: raw_swordfish
+name: raw swordfish
+aliases: ["swordfish", "sword"]
+description: "A freshly caught raw swordfish."
+value: 18
+stackable: false
diff --git a/data/items/raw_trout.yaml b/data/items/raw_trout.yaml
index 2a44c72..0e96ba3 100644
--- a/data/items/raw_trout.yaml
+++ b/data/items/raw_trout.yaml
@@ -3,4 +3,4 @@ name: raw trout
aliases: ["trout", "fish", "raw"]
description: "A freshly caught trout. Needs cooking."
value: 3
-stackable: true
+stackable: false
diff --git a/data/items/raw_tuna.yaml b/data/items/raw_tuna.yaml
new file mode 100644
index 0000000..747ef14
--- /dev/null
+++ b/data/items/raw_tuna.yaml
@@ -0,0 +1,6 @@
+id: raw_tuna
+name: raw tuna
+aliases: ["tuna"]
+description: "A freshly caught raw tuna."
+value: 12
+stackable: false
diff --git a/data/items/redwood_logs.yaml b/data/items/redwood_logs.yaml
new file mode 100644
index 0000000..7a2c66b
--- /dev/null
+++ b/data/items/redwood_logs.yaml
@@ -0,0 +1,6 @@
+id: redwood_logs
+name: redwood logs
+aliases: ["logs", "redwood", "log"]
+description: "Some massive redwood logs."
+value: 120
+stackable: false
diff --git a/data/items/rune_axe.yaml b/data/items/rune_axe.yaml
new file mode 100644
index 0000000..8e145f1
--- /dev/null
+++ b/data/items/rune_axe.yaml
@@ -0,0 +1,15 @@
+id: rune_axe
+name: rune axe
+aliases: ["axe", "hatchet"]
+description: "An extremely sharp rune axe."
+value: 40000
+stackable: false
+equip_slot: main_hand
+weapon_type: melee
+stats:
+ attack_bonus: 16
+ strength_bonus: 10
+ defense_bonus: 0
+speed: 5
+tool_type: axe
+tool_speed: 9
diff --git a/data/items/rune_pickaxe.yaml b/data/items/rune_pickaxe.yaml
new file mode 100644
index 0000000..9f0ca32
--- /dev/null
+++ b/data/items/rune_pickaxe.yaml
@@ -0,0 +1,15 @@
+id: rune_pickaxe
+name: rune pickaxe
+aliases: ["pick", "pickaxe"]
+description: "An extremely durable rune pickaxe."
+value: 40000
+stackable: false
+equip_slot: main_hand
+weapon_type: melee
+stats:
+ attack_bonus: 16
+ strength_bonus: 10
+ defense_bonus: 0
+speed: 5
+tool_type: pickaxe
+tool_speed: 7
diff --git a/data/items/runite_ore.yaml b/data/items/runite_ore.yaml
new file mode 100644
index 0000000..330ebe2
--- /dev/null
+++ b/data/items/runite_ore.yaml
@@ -0,0 +1,6 @@
+id: runite_ore
+name: runite ore
+aliases: ["runite", "rune", "ore"]
+description: "A rare piece of light blue runite ore."
+value: 500
+stackable: false
diff --git a/data/items/sacred_eel.yaml b/data/items/sacred_eel.yaml
new file mode 100644
index 0000000..18b7991
--- /dev/null
+++ b/data/items/sacred_eel.yaml
@@ -0,0 +1,6 @@
+id: sacred_eel
+name: sacred eel
+aliases: ["sacred eel", "eel"]
+description: "A glowing eel imbued with sacred energy."
+value: 25
+stackable: false
diff --git a/data/items/scrap_metal.yaml b/data/items/scrap_metal.yaml
new file mode 100644
index 0000000..ce4b137
--- /dev/null
+++ b/data/items/scrap_metal.yaml
@@ -0,0 +1,6 @@
+id: scrap_metal
+name: scrap metal
+aliases: ["scrap", "metal"]
+description: "A chunk of salvaged scrap metal."
+value: 2
+stackable: false
diff --git a/data/items/silver_ore.yaml b/data/items/silver_ore.yaml
new file mode 100644
index 0000000..0f2c7f2
--- /dev/null
+++ b/data/items/silver_ore.yaml
@@ -0,0 +1,6 @@
+id: silver_ore
+name: silver ore
+aliases: ["silver", "ore"]
+description: "A piece of silver ore with a faint metallic sheen."
+value: 10
+stackable: false
diff --git a/data/items/small_fishing_net.yaml b/data/items/small_fishing_net.yaml
new file mode 100644
index 0000000..01dafd2
--- /dev/null
+++ b/data/items/small_fishing_net.yaml
@@ -0,0 +1,15 @@
+id: small_fishing_net
+name: small fishing net
+aliases: ["net", "small net"]
+description: "A small hand-held fishing net."
+value: 5
+stackable: false
+equip_slot: main_hand
+weapon_type: melee
+stats:
+ attack_bonus: 0
+ strength_bonus: 0
+ defense_bonus: 0
+speed: 5
+tool_type: small_net
+tool_speed: 0
diff --git a/data/items/steel_axe.yaml b/data/items/steel_axe.yaml
new file mode 100644
index 0000000..ba14b16
--- /dev/null
+++ b/data/items/steel_axe.yaml
@@ -0,0 +1,15 @@
+id: steel_axe
+name: steel axe
+aliases: ["axe", "hatchet"]
+description: "A sharp steel axe for serious woodcutting."
+value: 200
+stackable: false
+equip_slot: main_hand
+weapon_type: melee
+stats:
+ attack_bonus: 5
+ strength_bonus: 3
+ defense_bonus: 0
+speed: 5
+tool_type: axe
+tool_speed: 6
diff --git a/data/items/steel_pickaxe.yaml b/data/items/steel_pickaxe.yaml
new file mode 100644
index 0000000..d2b24d4
--- /dev/null
+++ b/data/items/steel_pickaxe.yaml
@@ -0,0 +1,15 @@
+id: steel_pickaxe
+name: steel pickaxe
+aliases: ["pick", "pickaxe"]
+description: "A sharp steel pickaxe."
+value: 200
+stackable: false
+equip_slot: main_hand
+weapon_type: melee
+stats:
+ attack_bonus: 5
+ strength_bonus: 3
+ defense_bonus: 0
+speed: 5
+tool_type: pickaxe
+tool_speed: 4
diff --git a/data/items/teak_logs.yaml b/data/items/teak_logs.yaml
new file mode 100644
index 0000000..ac9b736
--- /dev/null
+++ b/data/items/teak_logs.yaml
@@ -0,0 +1,6 @@
+id: teak_logs
+name: teak logs
+aliases: ["logs", "teak", "log"]
+description: "Some fine teak logs."
+value: 12
+stackable: false
diff --git a/data/items/tin_ore.yaml b/data/items/tin_ore.yaml
index 62a55b4..97d0adb 100644
--- a/data/items/tin_ore.yaml
+++ b/data/items/tin_ore.yaml
@@ -1,6 +1,6 @@
-id: "tin_ore"
-name: "tin ore"
+id: tin_ore
+name: tin ore
aliases: ["tin", "ore"]
-description: "A chunk of tin ore."
-value: 2
+description: "A lump of tin ore, ready for smelting."
+value: 3
stackable: false
diff --git a/data/items/uncut_diamond.yaml b/data/items/uncut_diamond.yaml
index 3dcea70..843b2a4 100644
--- a/data/items/uncut_diamond.yaml
+++ b/data/items/uncut_diamond.yaml
@@ -3,4 +3,4 @@ name: uncut diamond
aliases: ["diamond", "gem", "uncut"]
description: "An uncut diamond. Can be cut with crafting."
value: 200
-stackable: true
+stackable: false
diff --git a/data/items/uncut_emerald.yaml b/data/items/uncut_emerald.yaml
index a9f3a66..e584286 100644
--- a/data/items/uncut_emerald.yaml
+++ b/data/items/uncut_emerald.yaml
@@ -3,4 +3,4 @@ name: uncut emerald
aliases: ["emerald", "gem", "uncut"]
description: "An uncut emerald. Can be cut with crafting."
value: 50
-stackable: true
+stackable: false
diff --git a/data/items/uncut_ruby.yaml b/data/items/uncut_ruby.yaml
index 0e9d36d..560bf08 100644
--- a/data/items/uncut_ruby.yaml
+++ b/data/items/uncut_ruby.yaml
@@ -3,4 +3,4 @@ name: uncut ruby
aliases: ["ruby", "gem", "uncut"]
description: "An uncut ruby. Can be cut with crafting."
value: 100
-stackable: true
+stackable: false
diff --git a/data/items/uncut_sapphire.yaml b/data/items/uncut_sapphire.yaml
index 70039d8..2f5c93e 100644
--- a/data/items/uncut_sapphire.yaml
+++ b/data/items/uncut_sapphire.yaml
@@ -3,4 +3,4 @@ name: uncut sapphire
aliases: ["sapphire", "gem", "uncut"]
description: "An uncut sapphire. Can be cut with crafting."
value: 25
-stackable: true
+stackable: false
diff --git a/data/items/willow_logs.yaml b/data/items/willow_logs.yaml
new file mode 100644
index 0000000..1669e66
--- /dev/null
+++ b/data/items/willow_logs.yaml
@@ -0,0 +1,6 @@
+id: willow_logs
+name: willow logs
+aliases: ["logs", "willow", "log"]
+description: "Some weeping willow logs."
+value: 8
+stackable: false
diff --git a/data/items/yew_logs.yaml b/data/items/yew_logs.yaml
new file mode 100644
index 0000000..5f1ed40
--- /dev/null
+++ b/data/items/yew_logs.yaml
@@ -0,0 +1,6 @@
+id: yew_logs
+name: yew logs
+aliases: ["logs", "yew", "log"]
+description: "Some high-quality yew logs."
+value: 40
+stackable: false
diff --git a/data/mobs/man.yaml b/data/mobs/man.yaml
index 30a3105..5fa9625 100644
--- a/data/mobs/man.yaml
+++ b/data/mobs/man.yaml
@@ -21,8 +21,6 @@ hp: 7
speed: 5
aggressive: false
respawn_ticks: 30
-wander_rooms: [1, 2, 3]
-wander_interval: 10
drops:
remains: "bones"
loot:
diff --git a/data/objects/adamantite_rock.yaml b/data/objects/adamantite_rock.yaml
new file mode 100644
index 0000000..603f358
--- /dev/null
+++ b/data/objects/adamantite_rock.yaml
@@ -0,0 +1,3 @@
+id: adamantite_rock
+name: adamantite rock
+behavior: mine_adamantite
diff --git a/data/objects/anglerfish_spot.yaml b/data/objects/anglerfish_spot.yaml
new file mode 100644
index 0000000..8d8658f
--- /dev/null
+++ b/data/objects/anglerfish_spot.yaml
@@ -0,0 +1,5 @@
+id: anglerfish_spot
+name: anglerfish spot
+behavior: fish_angler
+props:
+ description: "A faint glow emanates from the depths. Use a fishing rod with bait here."
diff --git a/data/objects/bait_fishing_spot.yaml b/data/objects/bait_fishing_spot.yaml
new file mode 100644
index 0000000..60971a6
--- /dev/null
+++ b/data/objects/bait_fishing_spot.yaml
@@ -0,0 +1,5 @@
+id: bait_fishing_spot
+name: bait fishing spot
+behavior: fish_bait
+props:
+ description: "Small fish ripple the surface. Use a fishing rod with bait here."
diff --git a/data/objects/cage_fishing_spot.yaml b/data/objects/cage_fishing_spot.yaml
new file mode 100644
index 0000000..1a7d848
--- /dev/null
+++ b/data/objects/cage_fishing_spot.yaml
@@ -0,0 +1,5 @@
+id: cage_fishing_spot
+name: cage fishing spot
+behavior: fish_cage
+props:
+ description: "A rocky underwater shelf. Use a lobster pot here."
diff --git a/data/objects/clay_rock.yaml b/data/objects/clay_rock.yaml
new file mode 100644
index 0000000..368373f
--- /dev/null
+++ b/data/objects/clay_rock.yaml
@@ -0,0 +1,3 @@
+id: clay_rock
+name: clay deposit
+behavior: mine_clay
diff --git a/data/objects/coal_rock.yaml b/data/objects/coal_rock.yaml
new file mode 100644
index 0000000..1f44461
--- /dev/null
+++ b/data/objects/coal_rock.yaml
@@ -0,0 +1,3 @@
+id: coal_rock
+name: coal rock
+behavior: mine_coal
diff --git a/data/objects/fishing_spot.yaml b/data/objects/fishing_spot.yaml
deleted file mode 100644
index 73ad3a2..0000000
--- a/data/objects/fishing_spot.yaml
+++ /dev/null
@@ -1,3 +0,0 @@
-id: fishing_spot
-name: fishing spot
-behavior: fish_trout
diff --git a/data/objects/fly_fishing_spot.yaml b/data/objects/fly_fishing_spot.yaml
new file mode 100644
index 0000000..b9545c9
--- /dev/null
+++ b/data/objects/fly_fishing_spot.yaml
@@ -0,0 +1,5 @@
+id: fly_fishing_spot
+name: fly fishing spot
+behavior: fish_fly
+props:
+ description: "Insects dance above the water. Use a fly fishing rod with feathers here."
diff --git a/data/objects/gold_rock.yaml b/data/objects/gold_rock.yaml
new file mode 100644
index 0000000..d701817
--- /dev/null
+++ b/data/objects/gold_rock.yaml
@@ -0,0 +1,3 @@
+id: gold_rock
+name: gold rock
+behavior: mine_gold
diff --git a/data/objects/harpoon_fishing_spot.yaml b/data/objects/harpoon_fishing_spot.yaml
new file mode 100644
index 0000000..c7e7f89
--- /dev/null
+++ b/data/objects/harpoon_fishing_spot.yaml
@@ -0,0 +1,5 @@
+id: harpoon_fishing_spot
+name: harpoon fishing spot
+behavior: fish_harpoon
+props:
+ description: "Large shadows move beneath the waves. Use a harpoon here."
diff --git a/data/objects/iron_rock.yaml b/data/objects/iron_rock.yaml
new file mode 100644
index 0000000..448ddc3
--- /dev/null
+++ b/data/objects/iron_rock.yaml
@@ -0,0 +1,3 @@
+id: iron_rock
+name: iron rock
+behavior: mine_iron
diff --git a/data/objects/magic_tree.yaml b/data/objects/magic_tree.yaml
new file mode 100644
index 0000000..5e82407
--- /dev/null
+++ b/data/objects/magic_tree.yaml
@@ -0,0 +1,3 @@
+id: magic_tree
+name: magic tree
+behavior: chop_magic
diff --git a/data/objects/mahogany_tree.yaml b/data/objects/mahogany_tree.yaml
new file mode 100644
index 0000000..33bc35e
--- /dev/null
+++ b/data/objects/mahogany_tree.yaml
@@ -0,0 +1,3 @@
+id: mahogany_tree
+name: mahogany tree
+behavior: chop_mahogany
diff --git a/data/objects/maple_tree.yaml b/data/objects/maple_tree.yaml
new file mode 100644
index 0000000..8d145c0
--- /dev/null
+++ b/data/objects/maple_tree.yaml
@@ -0,0 +1,3 @@
+id: maple_tree
+name: maple tree
+behavior: chop_maple
diff --git a/data/objects/mithril_rock.yaml b/data/objects/mithril_rock.yaml
new file mode 100644
index 0000000..afe106b
--- /dev/null
+++ b/data/objects/mithril_rock.yaml
@@ -0,0 +1,3 @@
+id: mithril_rock
+name: mithril rock
+behavior: mine_mithril
diff --git a/data/objects/net_fishing_spot.yaml b/data/objects/net_fishing_spot.yaml
new file mode 100644
index 0000000..0f1cd96
--- /dev/null
+++ b/data/objects/net_fishing_spot.yaml
@@ -0,0 +1,5 @@
+id: net_fishing_spot
+name: net fishing spot
+behavior: fish_net
+props:
+ description: "Bubbles break the surface. Use a small or big fishing net here."
diff --git a/data/objects/oak_tree.yaml b/data/objects/oak_tree.yaml
new file mode 100644
index 0000000..d3a8e74
--- /dev/null
+++ b/data/objects/oak_tree.yaml
@@ -0,0 +1,3 @@
+id: oak_tree
+name: oak tree
+behavior: chop_oak
diff --git a/data/objects/redwood_tree.yaml b/data/objects/redwood_tree.yaml
new file mode 100644
index 0000000..345f037
--- /dev/null
+++ b/data/objects/redwood_tree.yaml
@@ -0,0 +1,3 @@
+id: redwood_tree
+name: redwood tree
+behavior: chop_redwood
diff --git a/data/objects/runite_rock.yaml b/data/objects/runite_rock.yaml
new file mode 100644
index 0000000..df40aa3
--- /dev/null
+++ b/data/objects/runite_rock.yaml
@@ -0,0 +1,3 @@
+id: runite_rock
+name: runite rock
+behavior: mine_runite
diff --git a/data/objects/sacred_eel_spot.yaml b/data/objects/sacred_eel_spot.yaml
new file mode 100644
index 0000000..9925727
--- /dev/null
+++ b/data/objects/sacred_eel_spot.yaml
@@ -0,0 +1,5 @@
+id: sacred_eel_spot
+name: sacred eel spot
+behavior: fish_sacred_eel
+props:
+ description: "Waters shimmer with an otherworldly light. Use a fishing rod with bait here."
diff --git a/data/objects/scrap_pile.yaml b/data/objects/scrap_pile.yaml
new file mode 100644
index 0000000..d67ef11
--- /dev/null
+++ b/data/objects/scrap_pile.yaml
@@ -0,0 +1,3 @@
+id: scrap_pile
+name: scrap pile
+behavior: mine_scrap
diff --git a/data/objects/silver_rock.yaml b/data/objects/silver_rock.yaml
new file mode 100644
index 0000000..c5a9658
--- /dev/null
+++ b/data/objects/silver_rock.yaml
@@ -0,0 +1,3 @@
+id: silver_rock
+name: silver rock
+behavior: mine_silver
diff --git a/data/objects/swamp_fishing_spot.yaml b/data/objects/swamp_fishing_spot.yaml
new file mode 100644
index 0000000..8fc83b7
--- /dev/null
+++ b/data/objects/swamp_fishing_spot.yaml
@@ -0,0 +1,5 @@
+id: swamp_fishing_spot
+name: swamp fishing spot
+behavior: fish_swamp
+props:
+ description: "Murky water bubbles ominously. Use a fishing rod with bait here."
diff --git a/data/objects/teak_tree.yaml b/data/objects/teak_tree.yaml
new file mode 100644
index 0000000..628ec8a
--- /dev/null
+++ b/data/objects/teak_tree.yaml
@@ -0,0 +1,3 @@
+id: teak_tree
+name: teak tree
+behavior: chop_teak
diff --git a/data/objects/tin_rock.yaml b/data/objects/tin_rock.yaml
new file mode 100644
index 0000000..96c524d
--- /dev/null
+++ b/data/objects/tin_rock.yaml
@@ -0,0 +1,3 @@
+id: tin_rock
+name: tin rock
+behavior: mine_tin
diff --git a/data/objects/tree.yaml b/data/objects/tree.yaml
new file mode 100644
index 0000000..cfcbe02
--- /dev/null
+++ b/data/objects/tree.yaml
@@ -0,0 +1,3 @@
+id: tree
+name: tree
+behavior: chop_tree
diff --git a/data/objects/willow_tree.yaml b/data/objects/willow_tree.yaml
new file mode 100644
index 0000000..b5426ff
--- /dev/null
+++ b/data/objects/willow_tree.yaml
@@ -0,0 +1,3 @@
+id: willow_tree
+name: willow tree
+behavior: chop_willow
diff --git a/data/objects/yew_tree.yaml b/data/objects/yew_tree.yaml
new file mode 100644
index 0000000..3e784f7
--- /dev/null
+++ b/data/objects/yew_tree.yaml
@@ -0,0 +1,3 @@
+id: yew_tree
+name: yew tree
+behavior: chop_yew
diff --git a/data/rooms/1.yaml b/data/rooms/1.yaml
index 2f30f7e..77c49a0 100644
--- a/data/rooms/1.yaml
+++ b/data/rooms/1.yaml
@@ -1,18 +1,10 @@
id: 1
-name: "Lumbridge Town Square"
-description: "A bustling town square in the heart of Lumbridge. Cobblestone paths lead in all directions. A fountain gurgles peacefully in the center."
+name: "Town Square"
+description: "The central square of Lumbridge. A fountain gurgles peacefully in the center. Paths lead west toward the gathering grounds and east toward the production district."
map_symbol: "S"
exits:
- north: 2
- west: 7
- east: 3
+ east: 2
objects:
- id: lumby_fountain
-spawns:
- - item_id: "copper_ore"
- quantity: 1
- respawn_ticks: 10
mobs:
- "newbie_trainer"
- - "man"
- - "man"
diff --git a/data/rooms/10.yaml b/data/rooms/10.yaml
index 98fba43..63d9fc7 100644
--- a/data/rooms/10.yaml
+++ b/data/rooms/10.yaml
@@ -1,20 +1,6 @@
id: 10
-name: "Guard Post"
-description: "A checkpoint at the edge of the mine. A guard stands watch beside a heavy iron gate set into the north wall."
+name: "Farming Plot"
+description: "A small patch of tilled soil behind a weathered fence. This area is not yet accessible."
exits:
- west: 2
- north:
- room: 11
- condition:
- flag: gate_open
- value: true
- blocked_message: "A heavy iron gate blocks the way north. Maybe the guard can help."
-objects:
- - id: iron_gate
-mobs:
- - "guard"
-on_enter:
- - message: "The Guard barks: \"State your business!\""
- condition:
- player_flag: talked_to_guard
- not: true
+ west: 9
+ east: 11
diff --git a/data/rooms/11.yaml b/data/rooms/11.yaml
index dcc3b05..bf26577 100644
--- a/data/rooms/11.yaml
+++ b/data/rooms/11.yaml
@@ -1,17 +1,6 @@
id: 11
-name: "Supply Room"
-description: "A dusty storage room filled with crates and barrels. This area is normally off-limits."
+name: "Kitchen"
+description: "A warm kitchen with a stone hearth and wooden counters. This area is not yet accessible."
exits:
- south:
- room: 10
- condition:
- flag: gate_open
- value: true
- blocked_message: "The iron gate is closed."
-spawns:
- - item_id: uncut_sapphire
- quantity: 1
- respawn_ticks: 100
- - item_id: credits
- quantity: 50
- respawn_ticks: 200
+ east: 12
+ west: 10
diff --git a/data/rooms/12.yaml b/data/rooms/12.yaml
new file mode 100644
index 0000000..c9ab894
--- /dev/null
+++ b/data/rooms/12.yaml
@@ -0,0 +1,6 @@
+id: 12
+name: "Forge"
+description: "A blazing forge and anvil dominate this smoky workshop. This area is not yet accessible."
+exits:
+ east: 13
+ west: 11
diff --git a/data/rooms/13.yaml b/data/rooms/13.yaml
new file mode 100644
index 0000000..3b394af
--- /dev/null
+++ b/data/rooms/13.yaml
@@ -0,0 +1,6 @@
+id: 13
+name: "Workshop"
+description: "A well-equipped workshop with crafting tables and tool racks. This area is not yet accessible."
+exits:
+ east: 14
+ west: 12
diff --git a/data/rooms/14.yaml b/data/rooms/14.yaml
new file mode 100644
index 0000000..2e90539
--- /dev/null
+++ b/data/rooms/14.yaml
@@ -0,0 +1,6 @@
+id: 14
+name: "Fletching Bench"
+description: "A workbench covered in wood shavings, feathers, and arrowheads. This area is not yet accessible."
+exits:
+ east: 15
+ west: 13
diff --git a/data/rooms/15.yaml b/data/rooms/15.yaml
new file mode 100644
index 0000000..de48e37
--- /dev/null
+++ b/data/rooms/15.yaml
@@ -0,0 +1,6 @@
+id: 15
+name: "Alchemy Lab"
+description: "Shelves lined with glass vials, dried herbs, and bubbling cauldrons. This area is not yet accessible."
+exits:
+ east: 16
+ west: 14
diff --git a/data/rooms/16.yaml b/data/rooms/16.yaml
new file mode 100644
index 0000000..51ee392
--- /dev/null
+++ b/data/rooms/16.yaml
@@ -0,0 +1,6 @@
+id: 16
+name: "Construction Site"
+description: "A half-built structure surrounded by planks, nails, and blueprints. This area is not yet accessible."
+exits:
+ east: 17
+ west: 15
diff --git a/data/rooms/17.yaml b/data/rooms/17.yaml
new file mode 100644
index 0000000..80b1dfc
--- /dev/null
+++ b/data/rooms/17.yaml
@@ -0,0 +1,6 @@
+id: 17
+name: "Bonfire"
+description: "A ring of stones surrounds a bed of ash. A few unlit logs wait nearby. This area is not yet accessible."
+exits:
+ east: 18
+ west: 16
diff --git a/data/rooms/18.yaml b/data/rooms/18.yaml
new file mode 100644
index 0000000..9dfc446
--- /dev/null
+++ b/data/rooms/18.yaml
@@ -0,0 +1,6 @@
+id: 18
+name: "Thieves' Den"
+description: "A shadowy hideout with lockpicks, dark cloaks, and wanted posters on the walls. This area is not yet accessible."
+exits:
+ east: 19
+ west: 17
diff --git a/data/rooms/19.yaml b/data/rooms/19.yaml
new file mode 100644
index 0000000..35800a0
--- /dev/null
+++ b/data/rooms/19.yaml
@@ -0,0 +1,6 @@
+id: 19
+name: "Agility Course"
+description: "A training ground with balance beams, monkey bars, and climbing nets. This area is not yet accessible."
+exits:
+ east: 20
+ west: 18
diff --git a/data/rooms/2.yaml b/data/rooms/2.yaml
index 89913f7..39fcb9a 100644
--- a/data/rooms/2.yaml
+++ b/data/rooms/2.yaml
@@ -1,14 +1,17 @@
id: 2
-name: "Copper Mine"
-description: "A small mining pit. The walls glint with copper veins."
+name: "Fishing Spot"
+description: "A quiet spot along the riverbank. The water is clear and fish can be seen swimming beneath the surface. You can cast a line here with a fishing rod and bait."
+map_symbol: "F"
exits:
- south: 1
- east: 10
+ east: 4
+ north: 3
+ west: 1
objects:
- - id: copper_rock
- - id: copper_rock
- - id: copper_rock
+ - id: bait_fishing_spot
spawns:
- - item_id: bronze_pickaxe
+ - item_id: fishing_rod
quantity: 1
respawn_ticks: 30
+ - item_id: fishing_bait
+ quantity: 20
+ respawn_ticks: 60
diff --git a/data/rooms/20.yaml b/data/rooms/20.yaml
new file mode 100644
index 0000000..379322b
--- /dev/null
+++ b/data/rooms/20.yaml
@@ -0,0 +1,6 @@
+id: 20
+name: "Assassin's Bureau"
+description: "A discreet office with job postings pinned to a corkboard. This area is not yet accessible."
+exits:
+ east: 21
+ west: 19
diff --git a/data/rooms/21.yaml b/data/rooms/21.yaml
new file mode 100644
index 0000000..987f5ea
--- /dev/null
+++ b/data/rooms/21.yaml
@@ -0,0 +1,13 @@
+id: 21
+name: "Combat Arena"
+description: "A sandy training ground with practice dummies and sparring rings. Several men wander here looking for a fight."
+map_symbol: "C"
+exits:
+ west: 20
+mobs:
+ - id: man
+ wander_interval: 10
+ - id: man
+ wander_interval: 12
+ - id: man
+ wander_interval: 15
diff --git a/data/rooms/3.yaml b/data/rooms/3.yaml
index 57a7cb1..b54546f 100644
--- a/data/rooms/3.yaml
+++ b/data/rooms/3.yaml
@@ -1,6 +1,14 @@
id: 3
-name: "Lumbridge General Store"
-description: "A small but well-stocked general store. Shelves line the walls with various goods."
-map_symbol: "G"
+name: "Fly Fishing Pool"
+description: "A calm pool where insects hover over the water. Perfect for fly fishing."
exits:
- west: 1
+ south: 2
+objects:
+ - id: fly_fishing_spot
+spawns:
+ - item_id: fly_fishing_rod
+ quantity: 1
+ respawn_ticks: 30
+ - item_id: feather
+ quantity: 15
+ respawn_ticks: 60
diff --git a/data/rooms/4.yaml b/data/rooms/4.yaml
index 49280fe..f1d7d08 100644
--- a/data/rooms/4.yaml
+++ b/data/rooms/4.yaml
@@ -1,7 +1,14 @@
id: 4
-name: "Lumbridge Forest Edge"
-description: "The road ends here at the edge of a dark forest. Towering oak trees loom ahead to the north."
-map_symbol: "F"
+name: "Woodcutting Glade"
+description: "A sunny glade at the edge of the forest. A sturdy tree stands here, perfect for practicing woodcutting."
+map_symbol: "W"
exits:
- south: 2
+ east: 6
north: 5
+ west: 2
+objects:
+ - id: tree
+spawns:
+ - item_id: bronze_axe
+ quantity: 1
+ respawn_ticks: 60
diff --git a/data/rooms/5.yaml b/data/rooms/5.yaml
index 3c16a41..195c098 100644
--- a/data/rooms/5.yaml
+++ b/data/rooms/5.yaml
@@ -1,6 +1,12 @@
id: 5
-name: "Deep Forest"
-description: "Thick trees surround you on all sides. Shafts of light pierce through the canopy above."
-map_symbol: "T"
+name: "Oak Grove"
+description: "A stand of young oak trees. The wood here is tougher but more valuable."
+map_symbol: "O"
exits:
south: 4
+objects:
+ - id: oak_tree
+spawns:
+ - item_id: iron_axe
+ quantity: 1
+ respawn_ticks: 60
diff --git a/data/rooms/6.yaml b/data/rooms/6.yaml
index b4bae5b..66d8d85 100644
--- a/data/rooms/6.yaml
+++ b/data/rooms/6.yaml
@@ -1,6 +1,16 @@
id: 6
-name: "Abandoned Mine"
-description: "A dark, dusty mine shaft. Veins of copper and tin run through the walls. A single lantern flickers overhead."
+name: "Mining Pit"
+description: "A small mining pit. The rocky walls glint with veins of copper ore."
map_symbol: "M"
exits:
- west: 2
+ east: 8
+ north: 7
+ west: 4
+objects:
+ - id: copper_rock
+ - id: copper_rock
+ - id: copper_rock
+spawns:
+ - item_id: bronze_pickaxe
+ quantity: 1
+ respawn_ticks: 30
diff --git a/data/rooms/7.yaml b/data/rooms/7.yaml
index f9168ec..c153d68 100644
--- a/data/rooms/7.yaml
+++ b/data/rooms/7.yaml
@@ -1,6 +1,5 @@
id: 7
-name: "Lake West Shore"
-description: "The western edge of a placid lake. Reeds rustle in the breeze."
+name: "Deep Mine"
+description: "A deeper mine shaft. Higher quality ores can be found here. This area is not yet accessible."
exits:
- east: 8
- south: 1
+ south: 6
diff --git a/data/rooms/8.yaml b/data/rooms/8.yaml
index 46e29f3..4e36af4 100644
--- a/data/rooms/8.yaml
+++ b/data/rooms/8.yaml
@@ -1,14 +1,6 @@
id: 8
-name: "Lake Center Shore"
-description: "The center of the lake's southern shore. A good spot to cast a line."
+name: "Hacking Lab"
+description: "A dimly lit room filled with old terminals and flickering screens. This area is not yet accessible."
exits:
- west: 7
east: 9
-objects:
- - id: fishing_spot
- wander_rooms: [7, 8, 9]
- wander_interval: 12
-spawns:
- - item_id: fishing_rod
- quantity: 1
- respawn_ticks: 30
+ west: 6
diff --git a/data/rooms/9.yaml b/data/rooms/9.yaml
index b5ea278..aca7ec2 100644
--- a/data/rooms/9.yaml
+++ b/data/rooms/9.yaml
@@ -1,5 +1,6 @@
id: 9
-name: "Lake East Shore"
-description: "The eastern shore of the lake. A fallen log offers a comfortable seat."
+name: "Scavenging Post"
+description: "A cluttered yard full of scrap metal and discarded technology. This area is not yet accessible."
exits:
+ east: 10
west: 8
diff --git a/go.mod b/go.mod
index 7bf3b11..efc3c82 100644
--- a/go.mod
+++ b/go.mod
@@ -2,4 +2,7 @@ module thirdcollapse
go 1.25.0
-require gopkg.in/yaml.v3 v3.0.1 // indirect
+require (
+ github.com/gorilla/websocket v1.5.3
+ gopkg.in/yaml.v3 v3.0.1
+)
diff --git a/go.sum b/go.sum
index 4bc0337..6a2c68d 100644
--- a/go.sum
+++ b/go.sum
@@ -1,3 +1,6 @@
+github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
+github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/internal/action/action.go b/internal/action/action.go
index 9cd4c0f..44f27f8 100644
--- a/internal/action/action.go
+++ b/internal/action/action.go
@@ -24,6 +24,8 @@ type DropEntry struct {
Quantity int `yaml:"quantity"`
Depletes bool `yaml:"depletes"`
Message string `yaml:"message"`
+ Level int `yaml:"level"`
+ XP int `yaml:"xp"`
}
type DropTableDef struct {
diff --git a/internal/action/behavior.go b/internal/action/behavior.go
index d8eb909..a3c8802 100644
--- a/internal/action/behavior.go
+++ b/internal/action/behavior.go
@@ -3,8 +3,10 @@ package action
type GatherConfig struct {
Skill string `yaml:"skill"`
Level int `yaml:"level"`
+ XP int `yaml:"xp"`
BaseWait int `yaml:"base_wait"`
- Tool string `yaml:"tool"`
+ Tools []string `yaml:"tools"`
+ Bait string `yaml:"bait"`
Success SuccessFormula `yaml:"success"`
GatherMsg string `yaml:"gather_message"`
FailMsg string `yaml:"fail_message"`
@@ -12,6 +14,8 @@ type GatherConfig struct {
DepleteDelay int `yaml:"deplete_delay"`
RespawnMsg string `yaml:"respawn_message"`
RespawnBroadcast string `yaml:"respawn_broadcast"`
+ SharedDeplete int `yaml:"shared_deplete"`
+ NestChance int `yaml:"nest_chance"`
}
type SuccessFormula struct {
@@ -57,14 +61,15 @@ type Condition struct {
}
type UseConfig struct {
- Message string `yaml:"message"`
- Wait int `yaml:"wait"`
- Consume map[string]int `yaml:"consume"`
- Reward DropEntry `yaml:"reward"`
- FailMsg string `yaml:"fail_message"`
+ Message string `yaml:"message"`
+ Wait int `yaml:"wait"`
+ Consume map[string]int `yaml:"consume"`
+ Reward DropEntry `yaml:"reward"`
+ FailMsg string `yaml:"fail_message"`
Success *SuccessFormula `yaml:"success"`
- Skill string `yaml:"skill"`
- Level int `yaml:"level"`
+ Skill string `yaml:"skill"`
+ Level int `yaml:"level"`
+ XP int `yaml:"xp"`
}
type ToggleConfig struct {
diff --git a/internal/config/config.go b/internal/config/config.go
new file mode 100644
index 0000000..8ff5740
--- /dev/null
+++ b/internal/config/config.go
@@ -0,0 +1,59 @@
+package config
+
+import (
+ "os"
+
+ "gopkg.in/yaml.v3"
+)
+
+type Config struct {
+ Telnet TelnetConfig `yaml:"telnet"`
+ HTTP HTTPConfig `yaml:"http"`
+ HTTPS HTTPSConfig `yaml:"https"`
+}
+
+type TelnetConfig struct {
+ Enabled bool `yaml:"enabled"`
+ Port int `yaml:"port"`
+}
+
+type HTTPConfig struct {
+ Enabled bool `yaml:"enabled"`
+ Port int `yaml:"port"`
+}
+
+type HTTPSConfig struct {
+ Enabled bool `yaml:"enabled"`
+ Port int `yaml:"port"`
+ CertFile string `yaml:"cert_file"`
+ KeyFile string `yaml:"key_file"`
+}
+
+func Default() *Config {
+ return &Config{
+ Telnet: TelnetConfig{
+ Enabled: true,
+ Port: 4000,
+ },
+ HTTP: HTTPConfig{
+ Enabled: false,
+ Port: 8080,
+ },
+ HTTPS: HTTPSConfig{
+ Enabled: false,
+ Port: 8443,
+ },
+ }
+}
+
+func Load(path string) (*Config, error) {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return nil, err
+ }
+ cfg := Default()
+ if err := yaml.Unmarshal(data, cfg); err != nil {
+ return nil, err
+ }
+ return cfg, nil
+}
diff --git a/internal/game/action.go b/internal/game/action.go
index 62cdf62..018c4fb 100644
--- a/internal/game/action.go
+++ b/internal/game/action.go
@@ -2,7 +2,6 @@ package game
import (
"fmt"
- "math/rand"
"sort"
"strconv"
"strings"
@@ -48,6 +47,15 @@ func (g *Game) StartAction(sess *net.Session, verb, target string) {
var behaviorID string
if len(instances) > 0 {
+ uniqueDefs := make(map[string]bool)
+ for _, ist := range instances {
+ uniqueDefs[ist.DefID] = true
+ }
+ if len(uniqueDefs) > 1 {
+ sess.WriteLine("That's ambiguous, which one?")
+ return
+ }
+
var chosen *world.ObjState
if instanceIdx >= 0 && instanceIdx < len(instances) {
chosen = &instances[instanceIdx]
@@ -127,7 +135,6 @@ func (g *Game) StartAction(sess *net.Session, verb, target string) {
sess.WriteLine(fmt.Sprintf("You can't %s that.", verb))
return
}
- // Find the actual ObjState for the chosen object
chosenObj := g.World.FindObjInstances(p.RoomID, obj.ID)
sort.Slice(chosenObj, func(i, j int) bool {
return chosenObj[i].Index < chosenObj[j].Index
@@ -187,389 +194,16 @@ func (g *Game) AdvanceActions() {
}
}
-func (g *Game) startGather(sess *net.Session, p *player.Player, obj *object.ObjectDef, st *world.ObjState) {
- cfg, err := g.BehaviorStore.LoadGather(obj.BehaviorID)
- if err != nil {
- sess.WriteLine("Something is wrong with this object.")
- return
- }
-
- skillLevel := p.Level(player.SkillName(cfg.Skill))
- if cfg.Level > 0 && skillLevel < cfg.Level {
- sess.WriteLine(fmt.Sprintf("You need level %d %s to do that.", cfg.Level, cfg.Skill))
- return
- }
-
- if st.Depleted {
- sess.WriteLine(fmt.Sprintf("The %s is depleted for %d more ticks.", obj.Name, st.DepleteTimer))
- return
- }
-
- wait := cfg.BaseWait
-
- if cfg.Tool != "" {
- bestSpeed := -1
- for i := 0; i < 28; i++ {
- slot := p.InvSlot(i)
- if slot == nil {
- continue
- }
- def, err := g.ItemStore.Load(slot.ItemID)
- if err == nil && def.ToolType == cfg.Tool && def.ToolSpeed > bestSpeed {
- bestSpeed = def.ToolSpeed
- }
- }
- for _, itemID := range p.Toolbelt {
- def, err := g.ItemStore.Load(itemID)
- if err == nil && def.ToolType == cfg.Tool && def.ToolSpeed > bestSpeed {
- bestSpeed = def.ToolSpeed
- }
- }
- if bestSpeed < 0 {
- sess.WriteLine(fmt.Sprintf("You need a %s to mine the %s.", cfg.Tool, obj.Name))
- return
- }
- wait = cfg.BaseWait - bestSpeed
- if wait < 1 {
- wait = 1
- }
- }
-
- if p.FirstFreeSlot() == -1 {
- sess.WriteLine("Your inventory is too full!")
- return
- }
-
- p.Action = &action.Action{
- Type: "gather",
- TargetID: st.DefID,
- TargetName: obj.Name,
- WaitLeft: 0,
- Data: map[string]any{
- "behavior_id": obj.BehaviorID,
- "instance_key": g.World.ObjStateKey(st.RoomID, st.DefID, st.Index),
- "instance_idx": st.Index + 1,
- "effective_wait": wait,
- "step": 0,
- },
- }
-}
-
-func (g *Game) advanceGather(sess *net.Session, p *player.Player) {
- behaviorID := p.Action.Data["behavior_id"].(string)
- cfg, err := g.BehaviorStore.LoadGather(behaviorID)
- if err != nil {
- g.CancelAction(p)
- return
- }
-
- step := p.Action.Data["step"].(int)
- wait := p.Action.Data["effective_wait"].(int)
- instanceKey := p.Action.Data["instance_key"].(string)
-
- if step == 0 {
- sess.WriteLine(fmt.Sprintf("\n%s", cfg.GatherMsg))
- p.Action.Data["step"] = 1
- p.Action.WaitLeft = wait
- return
- }
-
- if step == 2 {
- st := g.World.GetObjStateByKey(instanceKey)
- if st != nil && st.Depleted {
- p.Action.WaitLeft = 3
- return
- }
- if cfg.RespawnMsg != "" {
- sess.WriteLine(fmt.Sprintf("\n%s", cfg.RespawnMsg))
- }
- p.Action.Data["step"] = 0
- p.Action.WaitLeft = wait
- return
- }
-
- skillLevel := p.Level(player.SkillName(cfg.Skill))
- chance := action.SuccessChance(cfg.Success, skillLevel, cfg.Level)
-
- if rand.Float64() < chance {
- drop := g.BehaviorStore.ResolveDrop(cfg.Drops)
- if drop != nil {
- freeSlot := p.FirstFreeSlot()
- if freeSlot == -1 {
- sess.WriteLine("Your inventory is too full!")
- g.CancelAction(p)
- return
- }
-
- qty := drop.Quantity
- if qty <= 0 {
- qty = 1
- }
-
- itemName := drop.ItemID
- if def, err := g.ItemStore.Load(drop.ItemID); err == nil {
- itemName = def.Name
- }
-
- p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: drop.ItemID, Quantity: qty})
-
- msg := drop.Message
- if msg == "" {
- msg = fmt.Sprintf("You manage to get some %s.", itemName)
- }
- sess.WriteLine(msg)
-
- g.AccountStore.SaveCharacter(p)
-
- if drop.Depletes {
- delay := cfg.DepleteDelay
- if delay <= 0 {
- delay = 10
- }
- st := g.World.GetObjStateByKey(instanceKey)
- if st != nil {
- st.Depleted = true
- st.DepleteTimer = delay
- }
-
- for _, other := range g.Hub.PlayersInRoom(p.RoomID) {
- if other == sess {
- continue
- }
- op, ok := other.Player.(*player.Player)
- if !ok || op.Action == nil || op.Action.Type != "gather" {
- continue
- }
- if op.Action.Data["instance_key"] == instanceKey {
- other.WriteLine(fmt.Sprintf("\nThe %s was depleted by %s!", p.Action.TargetName, p.Name))
- g.CancelAction(op)
- }
- }
-
- p.Action.Data["step"] = 2
- p.Action.WaitLeft = 1
- return
- }
-
- // Non-depleting drop: loop back. Check inventory before looping.
- if p.FirstFreeSlot() == -1 {
- sess.WriteLine("Your inventory is too full!")
- g.CancelAction(p)
- return
- }
- p.Action.Data["step"] = 0
- p.Action.WaitLeft = wait
- return
- }
- }
-
- // Failed: show fail message, loop back
- sess.WriteLine(cfg.FailMsg)
- if p.FirstFreeSlot() == -1 {
- sess.WriteLine("Your inventory is too full!")
- g.CancelAction(p)
- return
- }
- p.Action.Data["step"] = 0
- p.Action.WaitLeft = wait
-}
-
-func (g *Game) startMobTalk(sess *net.Session, p *player.Player, mob *world.MobInstance) {
- cfg, err := g.BehaviorStore.LoadTalk(mob.BehaviorID)
- if err != nil {
- sess.WriteLine("This person has nothing to say.")
- return
- }
-
- if node, ok := cfg.Nodes["start"]; ok {
- p.Action = &action.Action{
- Type: "talk",
- TargetID: mob.DefID,
- TargetName: mob.Name,
- Data: map[string]any{"node": "start", "behavior_id": mob.BehaviorID},
- }
- g.showTalkNode(sess, node)
- } else {
- sess.WriteLine("This person has nothing to say.")
- }
-}
-
-func (g *Game) startTalk(sess *net.Session, p *player.Player, obj *object.ObjectDef) {
- cfg, err := g.BehaviorStore.LoadTalk(obj.BehaviorID)
- if err != nil {
- sess.WriteLine("This person has nothing to say.")
- return
- }
-
- if node, ok := cfg.Nodes["start"]; ok {
- p.Action = &action.Action{
- Type: "talk",
- TargetID: obj.ID,
- TargetName: obj.Name,
- Data: map[string]any{"node": "start", "behavior_id": obj.BehaviorID},
- }
- g.showTalkNode(sess, node)
- } else {
- sess.WriteLine("This person has nothing to say.")
- }
-}
-
-func (g *Game) showTalkNode(sess *net.Session, node action.TalkNode) {
- sess.WriteLine(fmt.Sprintf("\n%s", node.Message))
-
- if node.Action != nil {
- g.applyNodeAction(sess, node.Action)
- }
-
- if len(node.Options) == 0 {
- sess.State = net.StateGame
- sess.Write("\r\n> ")
- return
- }
-
- 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))
- }
- sess.State = net.StateTalk
- sess.Write("\nChoice: ")
-}
-
-func (g *Game) handleTalkInput(sess *net.Session, input string) {
- p, ok := sess.Player.(*player.Player)
- if !ok || p.Action == nil || p.Action.Type != "talk" {
- sess.State = net.StateGame
- sess.Write("\r\n> ")
- return
- }
-
- input = strings.TrimSpace(input)
- lower := strings.ToLower(input)
- if lower == "bye" || lower == "exit" || lower == "end" || lower == "quit" {
- g.CancelAction(p)
- sess.State = net.StateGame
- sess.Write("\r\n> ")
- return
- }
-
- behaviorID := p.Action.Data["behavior_id"].(string)
- cfg, err := g.BehaviorStore.LoadTalk(behaviorID)
- if err != nil {
- g.CancelAction(p)
- sess.State = net.StateGame
- sess.Write("\r\n> ")
- return
- }
-
- nodeKey := p.Action.Data["node"].(string)
- node, ok := cfg.Nodes[nodeKey]
- if !ok {
- g.CancelAction(p)
- sess.State = net.StateGame
- sess.Write("\r\n> ")
- return
- }
-
- idx, err := parseIndex(input)
- if err != nil || idx <= 0 {
- g.CancelAction(p)
- sess.State = net.StateGame
- sess.Write("\r\n> ")
- return
- }
-
- validIdx := 0
- var chosen *action.TalkOption
- for i := range node.Options {
- opt := &node.Options[i]
- if opt.Condition != nil && !g.checkCondition(sess, opt.Condition) { continue
- }
- validIdx++
- if validIdx == idx {
- chosen = opt
- break
- }
- }
-
- if chosen == nil {
- g.CancelAction(p)
- sess.State = net.StateGame
- sess.Write("\r\n> ")
- return
- }
-
- if chosen.End {
- g.CancelAction(p)
- sess.State = net.StateGame
- sess.Write("\r\n> ")
- return
- }
-
- if chosen.Goto != "" {
- if nextNode, ok := cfg.Nodes[chosen.Goto]; ok {
- p.Action.Data["node"] = chosen.Goto
- g.showTalkNode(sess, nextNode)
- return
- }
- }
-
- g.CancelAction(p)
- sess.State = net.StateGame
- sess.Write("\r\n> ")
-}
-
-func (g *Game) applyNodeAction(sess *net.Session, na *action.NodeAction) {
- p, ok := sess.Player.(*player.Player)
- if !ok {
- return
- }
- for k, v := range na.SetFlags {
- g.WorldFlags[k] = v
- }
- for k, v := range na.SetPlayerFlags {
- if p.Flags == nil {
- p.Flags = make(map[string]any)
- }
- p.Flags[k] = v
- }
- if na.TakeItem != "" {
- if p.HasItem(na.TakeItem) {
- p.RemoveItem(na.TakeItem, 1)
- }
- }
- if na.GiveItem != "" {
- slot := p.FirstFreeSlot()
- if slot == -1 {
- sess.WriteLine("Your inventory is too full to receive that.")
- } else {
- p.SetInvSlot(slot, &player.InventorySlot{ItemID: na.GiveItem, Quantity: 1})
- g.AccountStore.SaveCharacter(p)
- }
- }
- if na.Teleport > 0 {
- p.RoomID = na.Teleport
- g.AccountStore.SaveCharacter(p)
- g.World.SeedGroundItems(p.RoomID)
- g.seedRoomMobs(p.RoomID)
- g.ensureRoomObjects(p.RoomID)
- if g.Hub != nil {
- g.Hub.EnterRoom(sess, p.RoomID)
- }
- g.doLook(sess)
- g.RunEnterSteps(sess, p.RoomID)
- }
- if na.Heal > 0 {
- p.HP += na.Heal
- if maxHP := p.MaxHP(); p.HP > maxHP {
- p.HP = maxHP
- }
- g.AccountStore.SaveCharacter(p)
- sess.WriteLine(fmt.Sprintf("You regain %d hitpoints.", na.Heal))
+func normalizeVerb(v string) string {
+ switch v {
+ case "mine", "chop", "fish", "cut":
+ return "gather"
+ case "talk", "speak", "ask":
+ return "talk"
+ case "pull", "push":
+ return "toggle"
}
+ return v
}
func (g *Game) checkCondition(sess *net.Session, c *action.Condition) bool {
@@ -619,194 +253,3 @@ func (g *Game) checkCondition(sess *net.Session, c *action.Condition) bool {
}
return true
}
-
-func (g *Game) startUse(sess *net.Session, p *player.Player, obj *object.ObjectDef) {
- cfg, err := g.BehaviorStore.LoadUse(obj.BehaviorID)
- if err != nil {
- sess.WriteLine("You can't use this.")
- return
- }
-
- for itemID, qty := range cfg.Consume {
- if !p.HasItem(itemID) {
- defName := itemID
- if def, err := g.ItemStore.Load(itemID); err == nil {
- defName = def.Name
- }
- sess.WriteLine(fmt.Sprintf("You need %s to use this.", defName))
- return
- }
- _ = qty
- }
-
- if cfg.Success == nil && p.FirstFreeSlot() == -1 {
- sess.WriteLine("Your inventory is full.")
- return
- }
-
- p.Action = &action.Action{
- Type: "use",
- TargetID: obj.ID,
- TargetName: obj.Name,
- WaitLeft: 1,
- Data: map[string]any{"step": 0, "behavior_id": obj.BehaviorID},
- }
-
- sess.WriteLine(fmt.Sprintf("\n%s", cfg.Message))
-}
-
-func (g *Game) advanceUse(sess *net.Session, p *player.Player) {
- behaviorID := p.Action.Data["behavior_id"].(string)
- cfg, err := g.BehaviorStore.LoadUse(behaviorID)
- if err != nil {
- g.CancelAction(p)
- return
- }
-
- for itemID, qty := range cfg.Consume {
- if !p.HasItem(itemID) {
- defName := itemID
- if def, err := g.ItemStore.Load(itemID); err == nil {
- defName = def.Name
- }
- sess.WriteLine(fmt.Sprintf("You've run out of %s.", defName))
- g.CancelAction(p)
- return
- }
- if !p.RemoveItem(itemID, qty) {
- sess.WriteLine(fmt.Sprintf("You need %d of %s.", qty, itemID))
- g.CancelAction(p)
- return
- }
- }
-
- if cfg.Success != nil {
- skillLevel := p.Level(player.SkillName(cfg.Skill))
- chance := action.SuccessChance(*cfg.Success, skillLevel, cfg.Level)
- if rand.Float64() >= chance {
- if cfg.FailMsg != "" {
- sess.WriteLine(cfg.FailMsg)
- }
- p.Action.WaitLeft = cfg.Wait
- return
- }
- }
-
- freeSlot := p.FirstFreeSlot()
- if freeSlot == -1 {
- sess.WriteLine("Your inventory is full.")
- g.CancelAction(p)
- return
- }
-
- qty := cfg.Reward.Quantity
- if qty <= 0 {
- qty = 1
- }
-
- p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: cfg.Reward.ItemID, Quantity: qty})
-
- g.AccountStore.SaveCharacter(p)
-
- itemName := cfg.Reward.ItemID
- if def, err := g.ItemStore.Load(cfg.Reward.ItemID); err == nil {
- itemName = def.Name
- }
- sess.WriteLine(fmt.Sprintf("You make a %s.", itemName))
-
- if p.FirstFreeSlot() == -1 {
- sess.WriteLine("Your inventory is full.")
- g.CancelAction(p)
- return
- }
-
- p.Action.WaitLeft = cfg.Wait
-}
-
-func (g *Game) startToggle(sess *net.Session, p *player.Player, obj *object.ObjectDef) {
- cfg, err := g.BehaviorStore.LoadToggle(obj.BehaviorID)
- if err != nil {
- sess.WriteLine("Nothing happens.")
- return
- }
-
- if cfg.Check != nil {
- val, ok := g.WorldFlags[cfg.Check.Flag]
- if cfg.Check.Not {
- if ok && val == cfg.Check.Value {
- sess.WriteLine("Nothing happens.")
- return
- }
- } else {
- if !ok || val != cfg.Check.Value {
- sess.WriteLine("Nothing happens.")
- return
- }
- }
- }
-
- for flag, val := range cfg.SetFlags {
- g.WorldFlags[flag] = val
- }
-
- sess.WriteLine(fmt.Sprintf("\n%s", cfg.Message))
-
- if g.Hub != nil {
- for _, other := range g.Hub.PlayersInRoom(p.RoomID) {
- if other != sess && other.Player != nil {
- other.WriteLine(fmt.Sprintf("\n%s uses the %s.", p.Name, obj.Name))
- }
- }
- }
-}
-
-func normalizeVerb(v string) string {
- switch v {
- case "mine", "chop", "fish":
- return "gather"
- case "talk", "speak", "ask":
- return "talk"
- case "pull", "push":
- return "toggle"
- }
- return v
-}
-
-func (g *Game) RunEnterSteps(sess *net.Session, roomID int) {
- room, err := g.World.LoadRoom(roomID)
- if err != nil || len(room.OnEnter) == 0 {
- return
- }
- for _, step := range room.OnEnter {
- if step.Condition != nil && !g.checkCondition(sess, step.Condition) {
- continue
- }
- if step.Message != "" {
- sess.WriteLine(fmt.Sprintf("\n%s", step.Message))
- }
- }
-}
-
-func (g *Game) BroadcastRespawns() {
- respawns := g.World.FlushObjRespawns()
- for _, st := range respawns {
- def, err := g.ObjectStore.Load(st.DefID)
- if err != nil || def.BehaviorID == "" {
- continue
- }
- cfg, err := g.BehaviorStore.LoadGather(def.BehaviorID)
- if err != nil || cfg.RespawnBroadcast == "" {
- continue
- }
- name := def.Name
- if idx := st.Index + 1; len(g.World.FindObjInstances(st.RoomID, st.DefID)) > 1 {
- name += fmt.Sprintf(" [%d]", idx)
- }
- msg := strings.ReplaceAll(cfg.RespawnBroadcast, "{name}", name)
- if g.Hub != nil {
- for _, sess := range g.Hub.PlayersInRoom(st.RoomID) {
- sess.WriteLine(fmt.Sprintf("\n%s", msg))
- }
- }
- }
-}
diff --git a/internal/game/action_gather.go b/internal/game/action_gather.go
new file mode 100644
index 0000000..e1e724e
--- /dev/null
+++ b/internal/game/action_gather.go
@@ -0,0 +1,355 @@
+package game
+
+import (
+ "fmt"
+ "math/rand"
+ "strings"
+
+ "thirdcollapse/internal/action"
+ "thirdcollapse/internal/net"
+ "thirdcollapse/internal/object"
+ "thirdcollapse/internal/player"
+ "thirdcollapse/internal/world"
+)
+
+func (g *Game) startGather(sess *net.Session, p *player.Player, obj *object.ObjectDef, st *world.ObjState) {
+ cfg, err := g.BehaviorStore.LoadGather(obj.BehaviorID)
+ if err != nil {
+ sess.WriteLine("Something is wrong with this object.")
+ return
+ }
+
+ skillLevel := p.Level(player.SkillName(cfg.Skill))
+ if cfg.Level > 0 && skillLevel < cfg.Level {
+ sess.WriteLine(fmt.Sprintf("You need level %d %s to do that.", cfg.Level, cfg.Skill))
+ return
+ }
+
+ if st.Depleted {
+ if cfg.SharedDeplete > 0 {
+ sess.WriteLine(fmt.Sprintf("The %s has been cut down for %d more ticks.", obj.Name, st.DepleteTimer))
+ } else {
+ sess.WriteLine(fmt.Sprintf("The %s is depleted for %d more ticks.", obj.Name, st.DepleteTimer))
+ }
+ return
+ }
+
+ wait := cfg.BaseWait
+
+ if len(cfg.Tools) > 0 {
+ toolSpeed := -1
+ if itemID, ok := p.Equipment[object.SlotMainHand]; ok {
+ def, err := g.ItemStore.Load(itemID)
+ if err == nil {
+ for _, t := range cfg.Tools {
+ if def.ToolType == t {
+ toolSpeed = def.ToolSpeed
+ break
+ }
+ }
+ }
+ }
+ if toolSpeed < 0 {
+ for i := 0; i < 28; i++ {
+ slot := p.InvSlot(i)
+ if slot == nil {
+ continue
+ }
+ def, err := g.ItemStore.Load(slot.ItemID)
+ if err != nil {
+ continue
+ }
+ for _, t := range cfg.Tools {
+ if def.ToolType == t {
+ toolSpeed = def.ToolSpeed
+ break
+ }
+ }
+ if toolSpeed >= 0 {
+ break
+ }
+ }
+ }
+ if toolSpeed < 0 {
+ names := make([]string, len(cfg.Tools))
+ for i, t := range cfg.Tools {
+ names[i] = strings.ReplaceAll(t, "_", " ")
+ }
+ toolList := strings.Join(names, " or ")
+ sess.WriteLine(fmt.Sprintf("You need a %s to do that.", toolList))
+ return
+ }
+ wait = cfg.BaseWait - toolSpeed
+ if wait < 1 {
+ wait = 1
+ }
+ }
+
+ if cfg.Bait != "" {
+ if !p.HasItem(cfg.Bait) {
+ baitName := cfg.Bait
+ if def, err := g.ItemStore.Load(cfg.Bait); err == nil {
+ baitName = def.Name
+ }
+ sess.WriteLine(fmt.Sprintf("You need %s to fish here.", baitName))
+ return
+ }
+ }
+
+ if p.FirstFreeSlot() == -1 {
+ sess.WriteLine("Your inventory is too full!")
+ return
+ }
+
+ data := map[string]any{
+ "behavior_id": obj.BehaviorID,
+ "instance_key": g.World.ObjStateKey(st.RoomID, st.DefID, st.Index),
+ "instance_idx": st.Index + 1,
+ "effective_wait": wait,
+ "step": 0,
+ }
+ if cfg.SharedDeplete > 0 {
+ data["shared_deplete"] = true
+ }
+ p.Action = &action.Action{
+ Type: "gather",
+ TargetID: st.DefID,
+ TargetName: obj.Name,
+ WaitLeft: 0,
+ Data: data,
+ }
+}
+
+func (g *Game) advanceGather(sess *net.Session, p *player.Player) {
+ behaviorID := p.Action.Data["behavior_id"].(string)
+ cfg, err := g.BehaviorStore.LoadGather(behaviorID)
+ if err != nil {
+ g.CancelAction(p)
+ return
+ }
+
+ step := p.Action.Data["step"].(int)
+ wait := p.Action.Data["effective_wait"].(int)
+ instanceKey := p.Action.Data["instance_key"].(string)
+ _, shared := p.Action.Data["shared_deplete"]
+
+ if step == 0 {
+ if cfg.Bait != "" {
+ if !p.HasItem(cfg.Bait) {
+ baitName := cfg.Bait
+ if def, err := g.ItemStore.Load(cfg.Bait); err == nil {
+ baitName = def.Name
+ }
+ sess.WriteLine(fmt.Sprintf("You've run out of %s.", baitName))
+ g.CancelAction(p)
+ return
+ }
+ }
+ sess.WriteLine(fmt.Sprintf("\n%s", cfg.GatherMsg))
+ p.Action.Data["step"] = 1
+ p.Action.WaitLeft = wait
+ return
+ }
+
+ if step == 2 {
+ st := g.World.GetObjStateByKey(instanceKey)
+ if st != nil && st.Depleted {
+ p.Action.WaitLeft = 3
+ return
+ }
+ if cfg.RespawnMsg != "" {
+ sess.WriteLine(fmt.Sprintf("\n%s", cfg.RespawnMsg))
+ }
+ p.Action.Data["step"] = 0
+ p.Action.WaitLeft = wait
+ return
+ }
+
+ if cfg.Bait != "" {
+ if !p.HasItem(cfg.Bait) {
+ baitName := cfg.Bait
+ if def, err := g.ItemStore.Load(cfg.Bait); err == nil {
+ baitName = def.Name
+ }
+ sess.WriteLine(fmt.Sprintf("You've run out of %s.", baitName))
+ g.CancelAction(p)
+ return
+ }
+ p.RemoveItem(cfg.Bait, 1)
+ }
+
+ skillLevel := p.Level(player.SkillName(cfg.Skill))
+ chance := action.SuccessChance(cfg.Success, skillLevel, cfg.Level)
+
+ if rand.Float64() < chance {
+ eligible := filterDropsByLevel(cfg.Drops, skillLevel)
+ drop := g.BehaviorStore.ResolveDrop(eligible)
+ if drop != nil {
+ freeSlot := p.FirstFreeSlot()
+ if freeSlot == -1 {
+ sess.WriteLine("Your inventory is too full!")
+ g.CancelAction(p)
+ return
+ }
+
+ qty := drop.Quantity
+ if qty <= 0 {
+ qty = 1
+ }
+
+ itemName := drop.ItemID
+ if def, err := g.ItemStore.Load(drop.ItemID); err == nil {
+ itemName = def.Name
+ }
+
+ p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: drop.ItemID, Quantity: qty})
+ xp := drop.XP
+ if xp <= 0 {
+ xp = cfg.XP
+ }
+ if xp > 0 {
+ p.AddXP(player.SkillName(cfg.Skill), xp)
+ }
+ g.AccountStore.SaveCharacter(p)
+
+ msg := drop.Message
+ if msg == "" {
+ msg = fmt.Sprintf("You manage to get some %s.", itemName)
+ }
+ if xp > 0 && p.Toggles["xpdrops"] {
+ msg += fmt.Sprintf(" (+%dxp %s)", xp, player.SkillAbbr[player.SkillName(cfg.Skill)])
+ }
+ sess.WriteLine(msg)
+
+ g.checkBirdNest(sess, p, cfg, freeSlot)
+
+ if shared {
+ st := g.World.GetObjStateByKey(instanceKey)
+ if st != nil && st.SharedTimer <= 0 {
+ g.depleteSharedTree(sess, p, st, cfg.DepleteDelay, instanceKey)
+ return
+ }
+ }
+
+ if !shared && drop.Depletes {
+ delay := cfg.DepleteDelay
+ if delay <= 0 {
+ delay = 10
+ }
+ st := g.World.GetObjStateByKey(instanceKey)
+ if st != nil {
+ st.Depleted = true
+ st.DepleteTimer = delay
+ }
+
+ for _, other := range g.Hub.PlayersInRoom(p.RoomID) {
+ if other == sess {
+ continue
+ }
+ op, ok := other.Player.(*player.Player)
+ if !ok || op.Action == nil || op.Action.Type != "gather" {
+ continue
+ }
+ if op.Action.Data["instance_key"] == instanceKey {
+ other.WriteLine(fmt.Sprintf("\nThe %s was depleted by %s!", p.Action.TargetName, p.Name))
+ g.CancelAction(op)
+ }
+ }
+
+ p.Action.Data["step"] = 2
+ p.Action.WaitLeft = 1
+ return
+ }
+
+ if p.FirstFreeSlot() == -1 {
+ sess.WriteLine("Your inventory is too full!")
+ g.CancelAction(p)
+ return
+ }
+ p.Action.Data["step"] = 0
+ p.Action.WaitLeft = wait
+ return
+ }
+ }
+
+ sess.WriteLine(cfg.FailMsg)
+ if p.FirstFreeSlot() == -1 {
+ sess.WriteLine("Your inventory is too full!")
+ g.CancelAction(p)
+ return
+ }
+ p.Action.Data["step"] = 0
+ p.Action.WaitLeft = wait
+}
+
+func filterDropsByLevel(drops []action.DropEntry, level int) []action.DropEntry {
+ var eligible []action.DropEntry
+ for _, d := range drops {
+ if level >= d.Level {
+ eligible = append(eligible, d)
+ }
+ }
+ if len(eligible) == 0 {
+ return drops
+ }
+ return eligible
+}
+
+func (g *Game) depleteSharedTree(sess *net.Session, p *player.Player, st *world.ObjState, respawnTicks int, instanceKey string) {
+ st.Depleted = true
+ st.DepleteTimer = respawnTicks
+ st.SharedTimer = 0
+
+ sess.WriteLine(fmt.Sprintf("\nThe %s falls to the ground!", p.Action.TargetName))
+
+ for _, other := range g.Hub.PlayersInRoom(p.RoomID) {
+ if other == sess {
+ continue
+ }
+ op, ok := other.Player.(*player.Player)
+ if !ok || op.Action == nil || op.Action.Type != "gather" {
+ continue
+ }
+ if op.Action.Data["instance_key"] == instanceKey {
+ other.WriteLine(fmt.Sprintf("\nThe %s falls to the ground!", p.Action.TargetName))
+ g.CancelAction(op)
+ }
+ }
+
+ p.Action.Data["step"] = 2
+ p.Action.WaitLeft = 1
+}
+
+func (g *Game) checkBirdNest(sess *net.Session, p *player.Player, cfg *action.GatherConfig, currentDropSlot int) {
+ if cfg.NestChance <= 0 {
+ return
+ }
+ if rand.Intn(cfg.NestChance) != 0 {
+ return
+ }
+
+ nestName := "bird's nest"
+ if def, err := g.ItemStore.Load("birds_nest"); err == nil {
+ nestName = def.Name
+ }
+
+ freeSlot := -1
+ for i := 0; i < 28; i++ {
+ if i == currentDropSlot {
+ continue
+ }
+ if p.InvSlot(i) == nil {
+ freeSlot = i
+ break
+ }
+ }
+ if freeSlot == -1 {
+ g.World.AddGroundItem(p.RoomID, "birds_nest", 1)
+ sess.WriteLine(fmt.Sprintf("A %s falls to the ground.", nestName))
+ return
+ }
+
+ p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: "birds_nest", Quantity: 1})
+ g.AccountStore.SaveCharacter(p)
+ sess.WriteLine(fmt.Sprintf("A %s falls out of the tree!", nestName))
+}
diff --git a/internal/game/action_room.go b/internal/game/action_room.go
new file mode 100644
index 0000000..426b512
--- /dev/null
+++ b/internal/game/action_room.go
@@ -0,0 +1,47 @@
+package game
+
+import (
+ "fmt"
+ "strings"
+
+ "thirdcollapse/internal/net"
+)
+
+func (g *Game) RunEnterSteps(sess *net.Session, roomID int) {
+ room, err := g.World.LoadRoom(roomID)
+ if err != nil || len(room.OnEnter) == 0 {
+ return
+ }
+ for _, step := range room.OnEnter {
+ if step.Condition != nil && !g.checkCondition(sess, step.Condition) {
+ continue
+ }
+ if step.Message != "" {
+ sess.WriteLine(fmt.Sprintf("\n%s", step.Message))
+ }
+ }
+}
+
+func (g *Game) BroadcastRespawns() {
+ respawns := g.World.FlushObjRespawns()
+ for _, st := range respawns {
+ def, err := g.ObjectStore.Load(st.DefID)
+ if err != nil || def.BehaviorID == "" {
+ continue
+ }
+ cfg, err := g.BehaviorStore.LoadGather(def.BehaviorID)
+ if err != nil || cfg.RespawnBroadcast == "" {
+ continue
+ }
+ name := def.Name
+ if idx := st.Index + 1; len(g.World.FindObjInstances(st.RoomID, st.DefID)) > 1 {
+ name += fmt.Sprintf(" [%d]", idx)
+ }
+ msg := strings.ReplaceAll(cfg.RespawnBroadcast, "{name}", name)
+ if g.Hub != nil {
+ for _, sess := range g.Hub.PlayersInRoom(st.RoomID) {
+ sess.WriteLine(fmt.Sprintf("\n%s", msg))
+ }
+ }
+ }
+}
diff --git a/internal/game/action_talk.go b/internal/game/action_talk.go
new file mode 100644
index 0000000..f4929e9
--- /dev/null
+++ b/internal/game/action_talk.go
@@ -0,0 +1,211 @@
+package game
+
+import (
+ "fmt"
+ "strings"
+
+ "thirdcollapse/internal/action"
+ "thirdcollapse/internal/net"
+ "thirdcollapse/internal/object"
+ "thirdcollapse/internal/player"
+ "thirdcollapse/internal/world"
+)
+
+func (g *Game) startMobTalk(sess *net.Session, p *player.Player, mob *world.MobInstance) {
+ cfg, err := g.BehaviorStore.LoadTalk(mob.BehaviorID)
+ if err != nil {
+ sess.WriteLine("This person has nothing to say.")
+ return
+ }
+
+ if node, ok := cfg.Nodes["start"]; ok {
+ p.Action = &action.Action{
+ Type: "talk",
+ TargetID: mob.DefID,
+ TargetName: mob.Name,
+ Data: map[string]any{"node": "start", "behavior_id": mob.BehaviorID},
+ }
+ g.showTalkNode(sess, node)
+ } else {
+ sess.WriteLine("This person has nothing to say.")
+ }
+}
+
+func (g *Game) startTalk(sess *net.Session, p *player.Player, obj *object.ObjectDef) {
+ cfg, err := g.BehaviorStore.LoadTalk(obj.BehaviorID)
+ if err != nil {
+ sess.WriteLine("This person has nothing to say.")
+ return
+ }
+
+ if node, ok := cfg.Nodes["start"]; ok {
+ p.Action = &action.Action{
+ Type: "talk",
+ TargetID: obj.ID,
+ TargetName: obj.Name,
+ Data: map[string]any{"node": "start", "behavior_id": obj.BehaviorID},
+ }
+ g.showTalkNode(sess, node)
+ } else {
+ sess.WriteLine("This person has nothing to say.")
+ }
+}
+
+func (g *Game) showTalkNode(sess *net.Session, node action.TalkNode) {
+ sess.WriteLine(fmt.Sprintf("\n%s", node.Message))
+
+ if node.Action != nil {
+ g.applyNodeAction(sess, node.Action)
+ }
+
+ if len(node.Options) == 0 {
+ sess.State = net.StateGame
+ sess.Write("\r\n> ")
+ return
+ }
+
+ 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))
+ }
+ sess.State = net.StateTalk
+ sess.Write("\nChoice: ")
+}
+
+func (g *Game) handleTalkInput(sess *net.Session, input string) {
+ p, ok := sess.Player.(*player.Player)
+ if !ok || p.Action == nil || p.Action.Type != "talk" {
+ sess.State = net.StateGame
+ sess.Write("\r\n> ")
+ return
+ }
+
+ input = strings.TrimSpace(input)
+ lower := strings.ToLower(input)
+ if lower == "bye" || lower == "exit" || lower == "end" || lower == "quit" {
+ g.CancelAction(p)
+ sess.State = net.StateGame
+ sess.Write("\r\n> ")
+ return
+ }
+
+ behaviorID := p.Action.Data["behavior_id"].(string)
+ cfg, err := g.BehaviorStore.LoadTalk(behaviorID)
+ if err != nil {
+ g.CancelAction(p)
+ sess.State = net.StateGame
+ sess.Write("\r\n> ")
+ return
+ }
+
+ nodeKey := p.Action.Data["node"].(string)
+ node, ok := cfg.Nodes[nodeKey]
+ if !ok {
+ g.CancelAction(p)
+ sess.State = net.StateGame
+ sess.Write("\r\n> ")
+ return
+ }
+
+ idx, err := parseIndex(input)
+ if err != nil || idx <= 0 {
+ g.CancelAction(p)
+ sess.State = net.StateGame
+ sess.Write("\r\n> ")
+ return
+ }
+
+ validIdx := 0
+ var chosen *action.TalkOption
+ for i := range node.Options {
+ opt := &node.Options[i]
+ if opt.Condition != nil && !g.checkCondition(sess, opt.Condition) {
+ continue
+ }
+ validIdx++
+ if validIdx == idx {
+ chosen = opt
+ break
+ }
+ }
+
+ if chosen == nil {
+ g.CancelAction(p)
+ sess.State = net.StateGame
+ sess.Write("\r\n> ")
+ return
+ }
+
+ if chosen.End {
+ g.CancelAction(p)
+ sess.State = net.StateGame
+ sess.Write("\r\n> ")
+ return
+ }
+
+ if chosen.Goto != "" {
+ if nextNode, ok := cfg.Nodes[chosen.Goto]; ok {
+ p.Action.Data["node"] = chosen.Goto
+ g.showTalkNode(sess, nextNode)
+ return
+ }
+ }
+
+ g.CancelAction(p)
+ sess.State = net.StateGame
+ sess.Write("\r\n> ")
+}
+
+func (g *Game) applyNodeAction(sess *net.Session, na *action.NodeAction) {
+ p, ok := sess.Player.(*player.Player)
+ if !ok {
+ return
+ }
+ for k, v := range na.SetFlags {
+ g.WorldFlags[k] = v
+ }
+ for k, v := range na.SetPlayerFlags {
+ if p.Flags == nil {
+ p.Flags = make(map[string]any)
+ }
+ p.Flags[k] = v
+ }
+ if na.TakeItem != "" {
+ if p.HasItem(na.TakeItem) {
+ p.RemoveItem(na.TakeItem, 1)
+ }
+ }
+ if na.GiveItem != "" {
+ slot := p.FirstFreeSlot()
+ if slot == -1 {
+ sess.WriteLine("Your inventory is too full to receive that.")
+ } else {
+ p.SetInvSlot(slot, &player.InventorySlot{ItemID: na.GiveItem, Quantity: 1})
+ g.AccountStore.SaveCharacter(p)
+ }
+ }
+ if na.Teleport > 0 {
+ p.RoomID = na.Teleport
+ g.AccountStore.SaveCharacter(p)
+ g.World.SeedGroundItems(p.RoomID)
+ g.seedRoomMobs(p.RoomID)
+ g.ensureRoomObjects(p.RoomID)
+ if g.Hub != nil {
+ g.Hub.EnterRoom(sess, p.RoomID)
+ }
+ g.doLook(sess)
+ g.RunEnterSteps(sess, p.RoomID)
+ }
+ if na.Heal > 0 {
+ p.HP += na.Heal
+ if maxHP := p.MaxHP(); p.HP > maxHP {
+ p.HP = maxHP
+ }
+ g.AccountStore.SaveCharacter(p)
+ sess.WriteLine(fmt.Sprintf("You regain %d hitpoints.", na.Heal))
+ }
+}
diff --git a/internal/game/action_toggle.go b/internal/game/action_toggle.go
new file mode 100644
index 0000000..3818d0d
--- /dev/null
+++ b/internal/game/action_toggle.go
@@ -0,0 +1,46 @@
+package game
+
+import (
+ "fmt"
+
+ "thirdcollapse/internal/net"
+ "thirdcollapse/internal/object"
+ "thirdcollapse/internal/player"
+)
+
+func (g *Game) startToggle(sess *net.Session, p *player.Player, obj *object.ObjectDef) {
+ cfg, err := g.BehaviorStore.LoadToggle(obj.BehaviorID)
+ if err != nil {
+ sess.WriteLine("Nothing happens.")
+ return
+ }
+
+ if cfg.Check != nil {
+ val, ok := g.WorldFlags[cfg.Check.Flag]
+ if cfg.Check.Not {
+ if ok && val == cfg.Check.Value {
+ sess.WriteLine("Nothing happens.")
+ return
+ }
+ } else {
+ if !ok || val != cfg.Check.Value {
+ sess.WriteLine("Nothing happens.")
+ return
+ }
+ }
+ }
+
+ for flag, val := range cfg.SetFlags {
+ g.WorldFlags[flag] = val
+ }
+
+ sess.WriteLine(fmt.Sprintf("\n%s", cfg.Message))
+
+ if g.Hub != nil {
+ for _, other := range g.Hub.PlayersInRoom(p.RoomID) {
+ if other != sess && other.Player != nil {
+ other.WriteLine(fmt.Sprintf("\n%s uses the %s.", p.Name, obj.Name))
+ }
+ }
+ }
+}
diff --git a/internal/game/action_use.go b/internal/game/action_use.go
new file mode 100644
index 0000000..c1a07ed
--- /dev/null
+++ b/internal/game/action_use.go
@@ -0,0 +1,120 @@
+package game
+
+import (
+ "fmt"
+ "math/rand"
+
+ "thirdcollapse/internal/action"
+ "thirdcollapse/internal/net"
+ "thirdcollapse/internal/object"
+ "thirdcollapse/internal/player"
+)
+
+func (g *Game) startUse(sess *net.Session, p *player.Player, obj *object.ObjectDef) {
+ cfg, err := g.BehaviorStore.LoadUse(obj.BehaviorID)
+ if err != nil {
+ sess.WriteLine("You can't use this.")
+ return
+ }
+
+ for itemID, qty := range cfg.Consume {
+ if !p.HasItem(itemID) {
+ defName := itemID
+ if def, err := g.ItemStore.Load(itemID); err == nil {
+ defName = def.Name
+ }
+ sess.WriteLine(fmt.Sprintf("You need %s to use this.", defName))
+ return
+ }
+ _ = qty
+ }
+
+ if cfg.Success == nil && p.FirstFreeSlot() == -1 {
+ sess.WriteLine("Your inventory is full.")
+ return
+ }
+
+ p.Action = &action.Action{
+ Type: "use",
+ TargetID: obj.ID,
+ TargetName: obj.Name,
+ WaitLeft: 1,
+ Data: map[string]any{"step": 0, "behavior_id": obj.BehaviorID},
+ }
+
+ sess.WriteLine(fmt.Sprintf("\n%s", cfg.Message))
+}
+
+func (g *Game) advanceUse(sess *net.Session, p *player.Player) {
+ behaviorID := p.Action.Data["behavior_id"].(string)
+ cfg, err := g.BehaviorStore.LoadUse(behaviorID)
+ if err != nil {
+ g.CancelAction(p)
+ return
+ }
+
+ for itemID, qty := range cfg.Consume {
+ if !p.HasItem(itemID) {
+ defName := itemID
+ if def, err := g.ItemStore.Load(itemID); err == nil {
+ defName = def.Name
+ }
+ sess.WriteLine(fmt.Sprintf("You've run out of %s.", defName))
+ g.CancelAction(p)
+ return
+ }
+ if !p.RemoveItem(itemID, qty) {
+ sess.WriteLine(fmt.Sprintf("You need %d of %s.", qty, itemID))
+ g.CancelAction(p)
+ return
+ }
+ }
+
+ if cfg.Success != nil {
+ skillLevel := p.Level(player.SkillName(cfg.Skill))
+ chance := action.SuccessChance(*cfg.Success, skillLevel, cfg.Level)
+ if rand.Float64() >= chance {
+ if cfg.FailMsg != "" {
+ sess.WriteLine(cfg.FailMsg)
+ }
+ p.Action.WaitLeft = cfg.Wait
+ return
+ }
+ }
+
+ freeSlot := p.FirstFreeSlot()
+ if freeSlot == -1 {
+ sess.WriteLine("Your inventory is full.")
+ g.CancelAction(p)
+ return
+ }
+
+ qty := cfg.Reward.Quantity
+ if qty <= 0 {
+ qty = 1
+ }
+
+ p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: cfg.Reward.ItemID, Quantity: qty})
+ if cfg.XP > 0 {
+ p.AddXP(player.SkillName(cfg.Skill), cfg.XP)
+ }
+ g.AccountStore.SaveCharacter(p)
+
+ itemName := cfg.Reward.ItemID
+ if def, err := g.ItemStore.Load(cfg.Reward.ItemID); err == nil {
+ itemName = def.Name
+ }
+ line := fmt.Sprintf("You make a %s.", itemName)
+ if cfg.XP > 0 && p.Toggles["xpdrops"] {
+ line += fmt.Sprintf(" (+%dxp %s)", cfg.XP, player.SkillAbbr[player.SkillName(cfg.Skill)])
+ }
+ sess.WriteLine(line)
+
+ if p.FirstFreeSlot() == -1 {
+ sess.WriteLine("Your inventory is full.")
+ g.CancelAction(p)
+ return
+ }
+
+ p.Action.WaitLeft = cfg.Wait
+}
diff --git a/internal/game/cmd_drop.go b/internal/game/cmd_drop.go
index 2f79a2f..cbe232d 100644
--- a/internal/game/cmd_drop.go
+++ b/internal/game/cmd_drop.go
@@ -8,24 +8,27 @@ import (
"thirdcollapse/internal/player"
)
-func (g *Game) doDrop(sess *net.Session, input string) {
+func (g *Game) doDropAll(sess *net.Session) {
p := sess.Player.(*player.Player)
+ g.CancelAction(p)
- if input == "all" {
- count := 0
- for _, slot := range p.Inventory {
- if slot != nil && slot.Quantity > 0 {
- count++
- }
+ count := 0
+ for _, slot := range p.Inventory {
+ if slot != nil && slot.Quantity > 0 {
+ count++
}
- if count == 0 {
- sess.WriteLine("You have nothing to drop.")
- return
- }
- sess.State = net.StateDropAllConfirm
- sess.WriteLine(fmt.Sprintf("\nDrop all %d items? [y/N]", count))
+ }
+ if count == 0 {
+ sess.WriteLine("You have nothing to drop.")
return
}
+ sess.State = net.StateDropAllConfirm
+ sess.WriteLine(fmt.Sprintf("\nDrop all %d items? [y/N]", count))
+}
+
+func (g *Game) doDropAllNamed(sess *net.Session, input string) {
+ p := sess.Player.(*player.Player)
+ g.CancelAction(p)
matches := g.findInventoryMatches(input, p)
if len(matches) == 0 {
@@ -43,25 +46,141 @@ func (g *Game) doDrop(sess *net.Session, input string) {
}
itemID := matches[0].ID
- slotIdx := matches[0].Slot
- slot := p.InvSlot(slotIdx)
def, _ := g.ItemStore.Load(itemID)
- qty := 1
name := itemID
if def != nil {
name = def.Name
}
- if slot.Quantity > qty {
- slot.Quantity -= qty
+ if def != nil && def.Stackable {
+ slot := p.InvSlot(matches[0].Slot)
+ qty := slot.Quantity
+ p.SetInvSlot(matches[0].Slot, nil)
+ g.World.AddGroundItem(p.RoomID, itemID, qty)
+ g.AccountStore.SaveCharacter(p)
+ if qty == 1 {
+ sess.WriteLine(fmt.Sprintf("You drop a %s.", name))
+ } else {
+ sess.WriteLine(fmt.Sprintf("You drop %d x %s.", qty, name))
+ }
+ return
+ }
+
+ total := 0
+ for _, match := range matches {
+ slot := p.InvSlot(match.Slot)
+ if slot == nil || slot.ItemID != itemID || slot.Quantity <= 0 {
+ continue
+ }
+ total += slot.Quantity
+ p.SetInvSlot(match.Slot, nil)
+ g.World.AddGroundItem(p.RoomID, itemID, slot.Quantity)
+ }
+ g.AccountStore.SaveCharacter(p)
+ if total == 1 {
+ sess.WriteLine(fmt.Sprintf("You drop a %s.", name))
} else {
- p.SetInvSlot(slotIdx, nil)
+ sess.WriteLine(fmt.Sprintf("You drop %d x %s.", total, name))
+ }
+}
+
+func (g *Game) doDrop(sess *net.Session, input string) {
+ p := sess.Player.(*player.Player)
+ g.CancelAction(p)
+
+ qty, itemName := parseQty(input)
+
+ matches := g.findInventoryMatches(itemName, p)
+ if len(matches) == 0 {
+ sess.WriteLine(fmt.Sprintf("You don't have any '%s'.", itemName))
+ return
+ }
+
+ unique := uniqueItemNames(matches)
+ if len(unique) > 1 {
+ sess.WriteLine("Which one?")
+ for _, name := range unique {
+ sess.WriteLine(fmt.Sprintf(" - %s", name))
+ }
+ return
+ }
+
+ itemID := matches[0].ID
+ def, _ := g.ItemStore.Load(itemID)
+
+ name := itemID
+ if def != nil {
+ name = def.Name
+ }
+
+ if qty == 0 {
+ if def != nil && def.Stackable {
+ for i := 0; i < 28; i++ {
+ slot := p.InvSlot(i)
+ if slot != nil && slot.ItemID == itemID && slot.Quantity > 0 {
+ qty = slot.Quantity
+ break
+ }
+ }
+ } else {
+ qty = 1
+ }
}
- g.World.AddGroundItem(p.RoomID, itemID, qty)
+ if qty <= 0 {
+ sess.WriteLine(fmt.Sprintf("You don't have any %s.", name))
+ return
+ }
+
+ if def != nil && def.Stackable {
+ slot := p.InvSlot(matches[0].Slot)
+ if qty > slot.Quantity {
+ qty = slot.Quantity
+ }
+ if slot.Quantity > qty {
+ slot.Quantity -= qty
+ } else {
+ p.SetInvSlot(matches[0].Slot, nil)
+ }
+ g.World.AddGroundItem(p.RoomID, itemID, qty)
+ g.AccountStore.SaveCharacter(p)
+ if qty == 1 {
+ sess.WriteLine(fmt.Sprintf("You drop a %s.", name))
+ } else {
+ sess.WriteLine(fmt.Sprintf("You drop %d x %s.", qty, name))
+ }
+ return
+ }
+
+ remaining := qty
+ for _, match := range matches {
+ if remaining <= 0 {
+ break
+ }
+ slot := p.InvSlot(match.Slot)
+ if slot == nil || slot.ItemID != itemID || slot.Quantity <= 0 {
+ continue
+ }
+ take := 1
+ if slot.Quantity > take {
+ slot.Quantity -= take
+ } else {
+ p.SetInvSlot(match.Slot, nil)
+ }
+ g.World.AddGroundItem(p.RoomID, itemID, take)
+ remaining -= take
+ }
+
+ dropped := qty - remaining
g.AccountStore.SaveCharacter(p)
- sess.WriteLine(fmt.Sprintf("You drop a %s.", name))
+ if dropped == 0 {
+ sess.WriteLine(fmt.Sprintf("You don't have any %s.", name))
+ } else if dropped == 1 {
+ sess.WriteLine(fmt.Sprintf("You drop a %s.", name))
+ } else {
+ sess.WriteLine(fmt.Sprintf("You drop %d x %s.", dropped, name))
+ }
}
func (g *Game) handleDropAllConfirm(sess *net.Session, input string) {
@@ -71,6 +190,7 @@ func (g *Game) handleDropAllConfirm(sess *net.Session, input string) {
sess.Write("\r\n> ")
return
}
+ g.CancelAction(p)
input = strings.TrimSpace(strings.ToLower(input))
if input != "y" && input != "yes" {
@@ -103,7 +223,6 @@ func (g *Game) handleDropAllConfirm(sess *net.Session, input string) {
} else {
sess.Write(fmt.Sprintf("\nYou drop your "))
innerPickupReport(sess, dropped)
- sess.WriteLine(".")
}
sess.Write("\r\n> ")
}
diff --git a/internal/game/cmd_equipment.go b/internal/game/cmd_equipment.go
new file mode 100644
index 0000000..cda39b1
--- /dev/null
+++ b/internal/game/cmd_equipment.go
@@ -0,0 +1,28 @@
+package game
+
+import (
+ "fmt"
+
+ "thirdcollapse/internal/net"
+ "thirdcollapse/internal/player"
+)
+
+func (g *Game) doEquipment(sess *net.Session) {
+ p := sess.Player.(*player.Player)
+ sess.WriteLine("")
+ sess.WriteLine("Equipment:")
+
+ for _, slot := range EquipSlots {
+ itemID, ok := p.Equipment[slot]
+ if !ok {
+ sess.WriteLine(fmt.Sprintf(" %-12s (empty)", slot))
+ continue
+ }
+ def, err := g.ItemStore.Load(itemID)
+ name := itemID
+ if err == nil {
+ name = def.Name
+ }
+ sess.WriteLine(fmt.Sprintf(" %-12s %s", slot, name))
+ }
+}
diff --git a/internal/game/cmd_get.go b/internal/game/cmd_get.go
index bce8ddc..70b458e 100644
--- a/internal/game/cmd_get.go
+++ b/internal/game/cmd_get.go
@@ -9,15 +9,18 @@ import (
func (g *Game) doGet(sess *net.Session, input string) {
p := sess.Player.(*player.Player)
+ g.CancelAction(p)
ground := g.World.GroundItems(p.RoomID)
if len(ground) == 0 {
sess.WriteLine("There's nothing on the ground to pick up.")
return
}
- matches := g.findGroundMatches(input, p.RoomID)
+ qty, itemName := parseQty(input)
+
+ matches := g.findGroundMatches(itemName, p.RoomID)
if len(matches) == 0 {
- sess.WriteLine(fmt.Sprintf("There's no '%s' here.", input))
+ sess.WriteLine(fmt.Sprintf("There's no '%s' here.", itemName))
return
}
@@ -41,18 +44,29 @@ func (g *Game) doGet(sess *net.Session, input string) {
def, _ := g.ItemStore.Load(itemID)
- freeSlot := p.FirstFreeSlot()
- if freeSlot == -1 {
- sess.WriteLine("Your inventory is full.")
+ available := 0
+ if gqty, ok := ground[itemID]; ok {
+ available = gqty
+ }
+ if available <= 0 {
+ sess.WriteLine("There's none of that here.")
return
}
- qty := 1
- if def != nil && def.Stackable {
- ground := g.World.GroundItems(p.RoomID)
- if gqty, ok := ground[itemID]; ok && gqty > 0 {
- qty = gqty
+ if qty == 0 {
+ if def != nil && def.Stackable {
+ qty = available
+ } else {
+ qty = 1
}
+ } else if qty > available {
+ qty = available
+ }
+
+ freeSlot := p.FirstFreeSlot()
+ if freeSlot == -1 {
+ sess.WriteLine("Your inventory is full.")
+ return
}
if def != nil && def.Stackable {
@@ -67,29 +81,191 @@ func (g *Game) doGet(sess *net.Session, input string) {
_ = removed
slot.Quantity += qty
g.AccountStore.SaveCharacter(p)
- sess.WriteLine(fmt.Sprintf("You pick up a %s. (now %d)", def.Name, slot.Quantity))
+ sess.WriteLine(fmt.Sprintf("You pick up %d x %s. (now %d)", qty, def.Name, slot.Quantity))
return
}
}
+ freeSlot = p.FirstFreeSlot()
+ if freeSlot == -1 {
+ sess.WriteLine("Your inventory is full.")
+ return
+ }
+ removed, ok := g.World.RemoveReservedGroundItem(p.RoomID, itemID, qty, p.Name)
+ if !ok {
+ sess.WriteLine("That's not yours!")
+ return
+ }
+ _ = removed
+ p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: itemID, Quantity: qty})
+ g.AccountStore.SaveCharacter(p)
+ if qty == 1 {
+ sess.WriteLine(fmt.Sprintf("You pick up a %s.", def.Name))
+ } else {
+ sess.WriteLine(fmt.Sprintf("You pick up %d x %s.", qty, def.Name))
+ }
+ return
}
- removed, ok := g.World.RemoveReservedGroundItem(p.RoomID, itemID, qty, p.Name)
- if !ok {
- sess.WriteLine("That's not yours!")
- return
+ picked := 0
+ for picked < qty {
+ fs := p.FirstFreeSlot()
+ if fs == -1 {
+ break
+ }
+ removed, ok := g.World.RemoveReservedGroundItem(p.RoomID, itemID, 1, p.Name)
+ if !ok {
+ break
+ }
+ _ = removed
+ p.SetInvSlot(fs, &player.InventorySlot{ItemID: itemID, Quantity: 1})
+ picked++
}
- _ = removed
- p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: itemID, Quantity: qty})
+
g.AccountStore.SaveCharacter(p)
+ if picked == 0 {
+ sess.WriteLine("Your inventory is full.")
+ } else if picked == 1 {
+ name := itemID
+ if def != nil {
+ name = def.Name
+ }
+ sess.WriteLine(fmt.Sprintf("You pick up a %s.", name))
+ } else {
+ name := itemID
+ if def != nil {
+ name = def.Name
+ }
+ sess.WriteLine(fmt.Sprintf("You pick up %d x %s.", picked, name))
+ }
+}
+
+func (g *Game) doGetAllNamed(sess *net.Session, input string) {
+ p := sess.Player.(*player.Player)
+ g.CancelAction(p)
+ ground := g.World.GroundItems(p.RoomID)
+ if len(ground) == 0 {
+ sess.WriteLine("There's nothing on the ground to pick up.")
+ return
+ }
+
+ matches := g.findGroundMatches(input, p.RoomID)
+ if len(matches) == 0 {
+ sess.WriteLine(fmt.Sprintf("There's no '%s' here.", input))
+ return
+ }
+
+ if len(matches) > 1 {
+ unique := uniqueItemNames(matches)
+ if len(unique) > 1 {
+ sess.WriteLine("Which one?")
+ for _, name := range unique {
+ sess.WriteLine(fmt.Sprintf(" - %s", name))
+ }
+ return
+ }
+ }
+
+ itemID := matches[0].ID
+
+ if itemID == "credits" {
+ g.pickupCredits(sess, p, itemID)
+ return
+ }
+
+ def, _ := g.ItemStore.Load(itemID)
+
+ available := 0
+ if gqty, ok := ground[itemID]; ok {
+ available = gqty
+ }
+ if available <= 0 {
+ sess.WriteLine("There's none of that here.")
+ return
+ }
+
+ wanted := available
+ if def != nil && !def.Stackable {
+ free := p.FreeSlots()
+ if wanted > free {
+ wanted = free
+ }
+ }
+
+ if wanted <= 0 {
+ sess.WriteLine("Your inventory is full.")
+ return
+ }
+
name := itemID
if def != nil {
name = def.Name
}
- sess.WriteLine(fmt.Sprintf("You pick up a %s.", name))
+
+ if def != nil && def.Stackable {
+ for i := 0; i < 28; i++ {
+ slot := p.InvSlot(i)
+ if slot != nil && slot.ItemID == itemID {
+ removed, ok := g.World.RemoveReservedGroundItem(p.RoomID, itemID, wanted, p.Name)
+ if !ok {
+ sess.WriteLine("That's not yours!")
+ return
+ }
+ slot.Quantity += removed
+ g.AccountStore.SaveCharacter(p)
+ sess.WriteLine(fmt.Sprintf("You pick up %d x %s. (now %d)", removed, name, slot.Quantity))
+ return
+ }
+ }
+ freeSlot := p.FirstFreeSlot()
+ if freeSlot == -1 {
+ sess.WriteLine("Your inventory is full.")
+ return
+ }
+ removed, ok := g.World.RemoveReservedGroundItem(p.RoomID, itemID, wanted, p.Name)
+ if !ok {
+ sess.WriteLine("That's not yours!")
+ return
+ }
+ p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: itemID, Quantity: removed})
+ g.AccountStore.SaveCharacter(p)
+ if removed == 1 {
+ sess.WriteLine(fmt.Sprintf("You pick up a %s.", name))
+ } else {
+ sess.WriteLine(fmt.Sprintf("You pick up %d x %s.", removed, name))
+ }
+ return
+ }
+
+ picked := 0
+ for picked < wanted {
+ fs := p.FirstFreeSlot()
+ if fs == -1 {
+ break
+ }
+ _, ok := g.World.RemoveReservedGroundItem(p.RoomID, itemID, 1, p.Name)
+ if !ok {
+ break
+ }
+ p.SetInvSlot(fs, &player.InventorySlot{ItemID: itemID, Quantity: 1})
+ picked++
+ }
+
+ g.AccountStore.SaveCharacter(p)
+ if picked == 0 {
+ sess.WriteLine("Your inventory is full.")
+ } else if picked == 1 {
+ sess.WriteLine(fmt.Sprintf("You pick up a %s.", name))
+ } else {
+ sess.WriteLine(fmt.Sprintf("You pick up %d x %s.", picked, name))
+ }
+ if picked < available {
+ sess.WriteLine(fmt.Sprintf("You're only able to take %d x %s!", picked, name))
+ }
}
func (g *Game) doGetAll(sess *net.Session) {
p := sess.Player.(*player.Player)
+ g.CancelAction(p)
ground := g.World.GroundItems(p.RoomID)
if len(ground) == 0 {
sess.WriteLine("There's nothing on the ground to pick up.")
@@ -141,10 +317,10 @@ func (g *Game) doGetAll(sess *net.Session) {
freeSlot := p.FirstFreeSlot()
if freeSlot == -1 {
if len(picked) > 0 {
- sess.WriteLine("Inventory full. Picked up so far:")
+ sess.WriteLine("You can't hold everything but you picked up:")
innerPickupReport(sess, picked)
} else {
- sess.WriteLine("Your inventory is full.")
+ sess.WriteLine("Your inventory is full!")
}
g.AccountStore.SaveCharacter(p)
return
@@ -165,10 +341,10 @@ func (g *Game) doGetAll(sess *net.Session) {
freeSlot := p.FirstFreeSlot()
if freeSlot == -1 {
if len(picked) > 0 {
- sess.WriteLine("Inventory full. Picked up so far:")
+ sess.WriteLine("You can't hold everything but you picked up:")
innerPickupReport(sess, picked)
} else {
- sess.WriteLine("Your inventory is full.")
+ sess.WriteLine("Your inventory is full!")
}
g.AccountStore.SaveCharacter(p)
return
@@ -194,7 +370,7 @@ func (g *Game) doGetAll(sess *net.Session) {
if len(picked) == 0 {
sess.WriteLine("Your inventory is full.")
} else {
- sess.Write("You pick up: ")
+ sess.Write("You picked up: ")
innerPickupReport(sess, picked)
}
}
diff --git a/internal/game/cmd_inventory.go b/internal/game/cmd_inventory.go
new file mode 100644
index 0000000..9cc4ac6
--- /dev/null
+++ b/internal/game/cmd_inventory.go
@@ -0,0 +1,36 @@
+package game
+
+import (
+ "fmt"
+
+ "thirdcollapse/internal/net"
+ "thirdcollapse/internal/player"
+)
+
+func (g *Game) doInventory(sess *net.Session) {
+ p := sess.Player.(*player.Player)
+ sess.WriteLine("")
+ sess.WriteLine("Inventory (28 slots):")
+
+ empty := 0
+ num := 0
+ for i := 0; i < 28; i++ {
+ slot := p.InvSlot(i)
+ if slot == nil {
+ empty++
+ continue
+ }
+ num++
+ def, err := g.ItemStore.Load(slot.ItemID)
+ name := slot.ItemID
+ if err == nil {
+ name = def.Name
+ }
+ if slot.Quantity > 1 {
+ sess.WriteLine(fmt.Sprintf(" %2d) %d x %s", num, slot.Quantity, name))
+ } else {
+ sess.WriteLine(fmt.Sprintf(" %2d) %s", num, name))
+ }
+ }
+ sess.WriteLine(fmt.Sprintf(" (%d empty slots)", empty))
+}
diff --git a/internal/game/cmd_look.go b/internal/game/cmd_look.go
index e7d30d6..48b7daf 100644
--- a/internal/game/cmd_look.go
+++ b/internal/game/cmd_look.go
@@ -79,41 +79,94 @@ func (g *Game) doLook(sess *net.Session) {
continue
}
- var activeIdxs, depletedIdxs []int
+ type instInfo struct {
+ idx int
+ depleted bool
+ sharedMax int
+ sharedCur int
+ respawnIn int
+ }
+ var instances []instInfo
for i := 0; i < count; i++ {
st := g.World.GetObjState(p.RoomID, objID, i)
- if st != nil && st.Depleted {
- depletedIdxs = append(depletedIdxs, i+1)
- } else {
- activeIdxs = append(activeIdxs, i+1)
+ if st == nil {
+ continue
}
+ instances = append(instances, instInfo{
+ idx: i + 1,
+ depleted: st.Depleted,
+ sharedMax: st.SharedMax,
+ sharedCur: st.SharedTimer,
+ respawnIn: st.DepleteTimer,
+ })
}
+ multi := len(instances) > 1
+ showTimers := p.Toggles["depletion"]
- if len(activeIdxs) > 0 {
- if len(activeIdxs) == 1 {
- sess.Write(fmt.Sprintf(" A %s is here.", def.Name))
+ var freshIdxs []int
+ var timed, depleted []instInfo
+ for _, ins := range instances {
+ if ins.depleted {
+ depleted = append(depleted, ins)
+ } else if ins.sharedMax > 0 && ins.sharedCur < ins.sharedMax {
+ timed = append(timed, ins)
} else {
- sess.Write(fmt.Sprintf(" %d %ss are here.", len(activeIdxs), def.Name))
+ freshIdxs = append(freshIdxs, ins.idx)
+ }
+ }
+
+ if len(freshIdxs) > 0 {
+ var suffix string
+ if multi && (len(timed) > 0 || len(depleted) > 0) {
+ suffix = fmt.Sprintf(" [%s]", intsJoin(freshIdxs))
}
- if count > 1 {
- sess.WriteLine(fmt.Sprintf(" [%s]", intsJoin(activeIdxs)))
+ if len(freshIdxs) == 1 {
+ sess.WriteLine(fmt.Sprintf(" A %s is here.%s", def.Name, suffix))
} else {
- sess.WriteLine("")
+ sess.WriteLine(fmt.Sprintf(" %d %ss are here.%s", len(freshIdxs), def.Name, suffix))
}
}
- if len(depletedIdxs) > 0 {
- if len(depletedIdxs) == 1 {
- sess.Write(fmt.Sprintf(" 1 depleted %s is here.", def.Name))
- } else {
- sess.Write(fmt.Sprintf(" %d depleted %ss are here.", len(depletedIdxs), def.Name))
+
+ for _, ins := range timed {
+ tag := ""
+ if multi {
+ tag = fmt.Sprintf(" [%d]", ins.idx)
+ }
+ timer := ""
+ if showTimers {
+ timer = fmt.Sprintf(" (despawn: %d/%d)", ins.sharedCur, ins.sharedMax)
}
- sess.WriteLine(fmt.Sprintf(" [%s]", intsJoin(depletedIdxs)))
+ sess.WriteLine(fmt.Sprintf(" A %s is here.%s%s", def.Name, tag, timer))
+ }
+
+ for _, ins := range depleted {
+ tag := ""
+ if multi {
+ tag = fmt.Sprintf(" [%d]", ins.idx)
+ }
+ timer := ""
+ if showTimers {
+ timer = fmt.Sprintf(" (respawns in %d ticks)", ins.respawnIn)
+ }
+ sess.WriteLine(fmt.Sprintf(" 1 depleted %s is here.%s%s", def.Name, tag, timer))
}
}
}
ground := g.World.GroundItemsDetailed(p.RoomID)
if len(ground) > 0 {
+ sort.Slice(ground, func(i, j int) bool {
+ ni := ground[i].ItemID
+ if def, err := g.ItemStore.Load(ground[i].ItemID); err == nil {
+ ni = def.Name
+ }
+ nj := ground[j].ItemID
+ if def, err := g.ItemStore.Load(ground[j].ItemID); err == nil {
+ nj = def.Name
+ }
+ return strings.ToLower(ni) < strings.ToLower(nj)
+ })
+
sess.WriteLine("")
sess.WriteLine("On the ground:")
@@ -281,12 +334,20 @@ func (g *Game) doLookTarget(sess *net.Session, input string) {
sess.WriteLine(fmt.Sprintf(" %s", desc))
}
- for _, ist := range instances {
- if ist.Depleted {
- if len(instances) > 1 {
- sess.WriteLine(fmt.Sprintf(" %s %d: depleted for %d more ticks.", def.Name, ist.Index+1, ist.DepleteTimer))
- } else {
- sess.WriteLine(fmt.Sprintf(" Depleted for %d more ticks.", ist.DepleteTimer))
+ if p.Toggles["depletion"] {
+ for _, ist := range instances {
+ if ist.Depleted {
+ if len(instances) > 1 {
+ sess.WriteLine(fmt.Sprintf(" %s %d: depleted, respawns in %d ticks.", def.Name, ist.Index+1, ist.DepleteTimer))
+ } else {
+ sess.WriteLine(fmt.Sprintf(" Depleted, respawns in %d ticks.", ist.DepleteTimer))
+ }
+ } else if ist.SharedMax > 0 && ist.SharedTimer < ist.SharedMax {
+ if len(instances) > 1 {
+ sess.WriteLine(fmt.Sprintf(" %s %d: despawn timer %d/%d.", def.Name, ist.Index+1, ist.SharedTimer, ist.SharedMax))
+ } else {
+ sess.WriteLine(fmt.Sprintf(" Despawn timer: %d/%d ticks.", ist.SharedTimer, ist.SharedMax))
+ }
}
}
}
@@ -335,14 +396,14 @@ func (g *Game) doLookTarget(sess *net.Session, input string) {
if strings.ToLower(op.Name) != lower {
continue
}
- showPlayerInfo(sess, op)
+ showPlayerInfo(g, sess, op)
return
}
sess.WriteLine(fmt.Sprintf("There's no '%s' here.", input))
}
-func showPlayerInfo(sess *net.Session, p *player.Player) {
+func showPlayerInfo(g *Game, sess *net.Session, p *player.Player) {
sess.WriteLines(
"",
p.Name,
@@ -363,7 +424,11 @@ func showPlayerInfo(sess *net.Session, p *player.Player) {
if !ok {
continue
}
- sess.WriteLine(fmt.Sprintf(" %-12s %s", slot, itemID))
+ name := itemID
+ if def, err := g.ItemStore.Load(itemID); err == nil {
+ name = def.Name
+ }
+ sess.WriteLine(fmt.Sprintf(" %-12s %s", slot, name))
}
if p.Description != "" {
diff --git a/internal/game/cmd_misc.go b/internal/game/cmd_misc.go
deleted file mode 100644
index c02bd35..0000000
--- a/internal/game/cmd_misc.go
+++ /dev/null
@@ -1,125 +0,0 @@
-package game
-
-import (
- "fmt"
- "strings"
-
- "thirdcollapse/internal/net"
- "thirdcollapse/internal/player"
-)
-
-func (g *Game) doSay(sess *net.Session, msg string) {
- p := sess.Player.(*player.Player)
- roomID := p.RoomID
-
- for _, other := range g.Hub.PlayersInRoom(roomID) {
- if other == sess {
- other.WriteLine(fmt.Sprintf("\nYou say: %s", msg))
- } else {
- other.WriteLine(fmt.Sprintf("\n%s says: %s", p.Name, msg))
- }
- }
-}
-
-func (g *Game) doScore(sess *net.Session) {
- p := sess.Player.(*player.Player)
- sess.WriteLines(
- "",
- fmt.Sprintf("Name: %s", p.Name),
- fmt.Sprintf("Combat Level: %d", p.CombatLevel()),
- fmt.Sprintf("HP: %d/%d", p.HP, p.MaxHP()),
- fmt.Sprintf("Credits: %d", p.Credits),
- "",
- "Skills:",
- )
- for _, s := range player.AllSkills {
- level := p.Level(s)
- xp := p.Skills[s]
- next := player.XPForNextLevel(xp)
- sess.WriteLine(fmt.Sprintf(" %-12s Level: %2d XP: %d / %d", s, level, xp, xp+next))
- }
-}
-
-func (g *Game) doInventory(sess *net.Session) {
- p := sess.Player.(*player.Player)
- sess.WriteLine("")
- sess.WriteLine("Inventory (28 slots):")
-
- empty := 0
- num := 0
- for i := 0; i < 28; i++ {
- slot := p.InvSlot(i)
- if slot == nil {
- empty++
- continue
- }
- num++
- def, err := g.ItemStore.Load(slot.ItemID)
- name := slot.ItemID
- if err == nil {
- name = def.Name
- }
- if slot.Quantity > 1 {
- sess.WriteLine(fmt.Sprintf(" %2d) %d x %s", num, slot.Quantity, name))
- } else {
- sess.WriteLine(fmt.Sprintf(" %2d) %s", num, name))
- }
- }
- sess.WriteLine(fmt.Sprintf(" (%d empty slots)", empty))
-}
-
-func (g *Game) doEquipment(sess *net.Session) {
- p := sess.Player.(*player.Player)
- sess.WriteLine("")
- sess.WriteLine("Equipment:")
-
- for _, slot := range EquipSlots {
- itemID, ok := p.Equipment[slot]
- if !ok {
- sess.WriteLine(fmt.Sprintf(" %-12s (empty)", slot))
- continue
- }
- def, err := g.ItemStore.Load(itemID)
- name := itemID
- if err == nil {
- name = def.Name
- }
- sess.WriteLine(fmt.Sprintf(" %-12s %s", slot, name))
- }
-}
-
-func (g *Game) doStyle(sess *net.Session, input string) {
- p := sess.Player.(*player.Player)
-
- styles := []string{"accurate", "aggressive", "defensive", "balanced"}
- if input == "" {
- sess.WriteLine("\nCombat styles:")
- for _, s := range styles {
- marker := " "
- if string(p.AttackStyle) == s {
- marker = "*"
- }
- sess.WriteLine(fmt.Sprintf(" %s %s", marker, s))
- }
- return
- }
-
- input = strings.ToLower(input)
- var matches []string
- for _, s := range styles {
- if strings.HasPrefix(s, input) {
- matches = append(matches, s)
- }
- }
- if len(matches) == 1 {
- p.AttackStyle = player.AttackStyle(matches[0])
- g.AccountStore.SaveCharacter(p)
- sess.WriteLine(fmt.Sprintf("\nCombat style set to %s.", matches[0]))
- return
- }
- if len(matches) > 1 {
- sess.WriteLine(fmt.Sprintf("\nAmbiguous style: %s. Choices: %s", input, strings.Join(matches, ", ")))
- return
- }
- sess.WriteLine(fmt.Sprintf("\nUnknown style: %s. Choices: accurate, aggressive, defensive, balanced", input))
-}
diff --git a/internal/game/cmd_move.go b/internal/game/cmd_move.go
index e288dad..79ca45e 100644
--- a/internal/game/cmd_move.go
+++ b/internal/game/cmd_move.go
@@ -118,5 +118,11 @@ func (g *Game) ensureRoomObjects(roomID int) {
if len(obj.WanderRooms) > 0 {
g.World.SetObjWander(roomID, obj.ID, obj.WanderRooms, obj.WanderInterval)
}
+ if def.BehaviorID != "" {
+ cfg, err := g.BehaviorStore.LoadGather(def.BehaviorID)
+ if err == nil && cfg.SharedDeplete > 0 {
+ g.World.SetObjSharedDeplete(roomID, obj.ID, cfg.SharedDeplete)
+ }
+ }
}
}
diff --git a/internal/game/cmd_quit.go b/internal/game/cmd_quit.go
index a720bff..51e1fee 100644
--- a/internal/game/cmd_quit.go
+++ b/internal/game/cmd_quit.go
@@ -14,6 +14,7 @@ func (g *Game) doQuit(sess *net.Session) {
return
}
+ g.CancelAction(p)
g.cancelRest(p.Name)
ticksLeft := 10
diff --git a/internal/game/cmd_remove.go b/internal/game/cmd_remove.go
new file mode 100644
index 0000000..9f0cbe8
--- /dev/null
+++ b/internal/game/cmd_remove.go
@@ -0,0 +1,67 @@
+package game
+
+import (
+ "fmt"
+ "strings"
+
+ "thirdcollapse/internal/net"
+ "thirdcollapse/internal/object"
+ "thirdcollapse/internal/player"
+)
+
+func (g *Game) doRemove(sess *net.Session, input string) {
+ p := sess.Player.(*player.Player)
+
+ lower := strings.ToLower(strings.TrimSpace(input))
+ if lower == "" {
+ sess.WriteLine("Remove what?")
+ return
+ }
+
+ g.CancelAction(p)
+
+ var foundSlot object.EquipSlot
+ var foundItemID string
+
+ for _, slot := range EquipSlots {
+ itemID, ok := p.Equipment[slot]
+ if !ok {
+ continue
+ }
+ if strings.ToLower(string(slot)) == lower {
+ foundSlot = slot
+ foundItemID = itemID
+ break
+ }
+ def, err := g.ItemStore.Load(itemID)
+ if err != nil {
+ continue
+ }
+ if def.MatchesName(input) {
+ foundSlot = slot
+ foundItemID = itemID
+ break
+ }
+ }
+
+ if foundSlot == "" {
+ sess.WriteLine(fmt.Sprintf("You aren't wearing any '%s'.", input))
+ return
+ }
+
+ freeSlot := p.FirstFreeSlot()
+ if freeSlot == -1 {
+ sess.WriteLine("Nowhere to put that!")
+ return
+ }
+
+ delete(p.Equipment, foundSlot)
+ p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: foundItemID, Quantity: 1})
+ g.AccountStore.SaveCharacter(p)
+
+ name := foundItemID
+ if def, err := g.ItemStore.Load(foundItemID); err == nil {
+ name = def.Name
+ }
+ sess.WriteLine(fmt.Sprintf("You remove %s.", name))
+}
diff --git a/internal/game/cmd_say.go b/internal/game/cmd_say.go
new file mode 100644
index 0000000..35ec5fa
--- /dev/null
+++ b/internal/game/cmd_say.go
@@ -0,0 +1,21 @@
+package game
+
+import (
+ "fmt"
+
+ "thirdcollapse/internal/net"
+ "thirdcollapse/internal/player"
+)
+
+func (g *Game) doSay(sess *net.Session, msg string) {
+ p := sess.Player.(*player.Player)
+ roomID := p.RoomID
+
+ for _, other := range g.Hub.PlayersInRoom(roomID) {
+ if other == sess {
+ other.WriteLine(fmt.Sprintf("You say: %s", msg))
+ } else {
+ other.WriteLine(fmt.Sprintf("\n%s says: %s", p.Name, msg))
+ }
+ }
+}
diff --git a/internal/game/cmd_score.go b/internal/game/cmd_score.go
new file mode 100644
index 0000000..9b3904a
--- /dev/null
+++ b/internal/game/cmd_score.go
@@ -0,0 +1,27 @@
+package game
+
+import (
+ "fmt"
+
+ "thirdcollapse/internal/net"
+ "thirdcollapse/internal/player"
+)
+
+func (g *Game) doScore(sess *net.Session) {
+ p := sess.Player.(*player.Player)
+ sess.WriteLines(
+ "",
+ fmt.Sprintf("Name: %s", p.Name),
+ fmt.Sprintf("Combat Level: %d", p.CombatLevel()),
+ fmt.Sprintf("HP: %d/%d", p.HP, p.MaxHP()),
+ fmt.Sprintf("Credits: %d", p.Credits),
+ "",
+ "Skills:",
+ )
+ for _, s := range player.AllSkills {
+ level := p.Level(s)
+ xp := p.Skills[s]
+ next := player.XPForNextLevel(xp)
+ sess.WriteLine(fmt.Sprintf(" %-12s Level: %2d XP: %d / %d", s, level, xp, xp+next))
+ }
+}
diff --git a/internal/game/cmd_search.go b/internal/game/cmd_search.go
new file mode 100644
index 0000000..341685e
--- /dev/null
+++ b/internal/game/cmd_search.go
@@ -0,0 +1,87 @@
+package game
+
+import (
+ "fmt"
+ "strings"
+
+ "thirdcollapse/internal/net"
+ "thirdcollapse/internal/player"
+)
+
+func (g *Game) doSearch(sess *net.Session, input string) {
+ p := sess.Player.(*player.Player)
+
+ lower := strings.ToLower(strings.TrimSpace(input))
+ if lower == "" {
+ sess.WriteLine("Search what?")
+ return
+ }
+
+ matches := g.findInventoryMatches(lower, p)
+ if len(matches) == 0 {
+ sess.WriteLine(fmt.Sprintf("You don't have any '%s' to search.", input))
+ return
+ }
+
+ itemID := matches[0].ID
+ slotIdx := matches[0].Slot
+
+ if itemID != "birds_nest" {
+ sess.WriteLine(fmt.Sprintf("You can't search %s.", matches[0].Name))
+ return
+ }
+
+ slot := p.InvSlot(slotIdx)
+ if slot == nil || slot.ItemID != "birds_nest" {
+ return
+ }
+
+ if slot.Quantity > 1 {
+ slot.Quantity--
+ } else {
+ p.SetInvSlot(slotIdx, nil)
+ }
+
+ dt, err := g.BehaviorStore.LoadDropTable("birds_nest_drop")
+ if err != nil || len(dt.Drops) == 0 {
+ sess.WriteLine("You search the bird's nest but find nothing.")
+ g.AccountStore.SaveCharacter(p)
+ return
+ }
+
+ drop := g.BehaviorStore.ResolveDrop(dt.Drops)
+ if drop == nil {
+ sess.WriteLine("You search the bird's nest but find nothing.")
+ g.AccountStore.SaveCharacter(p)
+ return
+ }
+
+ qty := drop.Quantity
+ if qty <= 0 {
+ qty = 1
+ }
+
+ if drop.ItemID == "credits" {
+ p.Credits += qty
+ sess.WriteLine(fmt.Sprintf("You search the bird's nest and find %d credits.", qty))
+ } else {
+ freeSlot := p.FirstFreeSlot()
+ if freeSlot == -1 {
+ g.World.AddGroundItem(p.RoomID, drop.ItemID, qty)
+ name := drop.ItemID
+ if def, err := g.ItemStore.Load(drop.ItemID); err == nil {
+ name = def.Name
+ }
+ sess.WriteLine(fmt.Sprintf("You search the bird's nest and find %s. It falls to the ground.", name))
+ } else {
+ p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: drop.ItemID, Quantity: qty})
+ name := drop.ItemID
+ if def, err := g.ItemStore.Load(drop.ItemID); err == nil {
+ name = def.Name
+ }
+ sess.WriteLine(fmt.Sprintf("You search the bird's nest and find %s.", name))
+ }
+ }
+
+ g.AccountStore.SaveCharacter(p)
+}
diff --git a/internal/game/cmd_style.go b/internal/game/cmd_style.go
new file mode 100644
index 0000000..d86920c
--- /dev/null
+++ b/internal/game/cmd_style.go
@@ -0,0 +1,45 @@
+package game
+
+import (
+ "fmt"
+ "strings"
+
+ "thirdcollapse/internal/net"
+ "thirdcollapse/internal/player"
+)
+
+func (g *Game) doStyle(sess *net.Session, input string) {
+ p := sess.Player.(*player.Player)
+
+ styles := []string{"accurate", "aggressive", "defensive", "balanced"}
+ if input == "" {
+ sess.WriteLine("\nCombat styles:")
+ for _, s := range styles {
+ marker := " "
+ if string(p.AttackStyle) == s {
+ marker = "*"
+ }
+ sess.WriteLine(fmt.Sprintf(" %s %s", marker, s))
+ }
+ return
+ }
+
+ input = strings.ToLower(input)
+ var matches []string
+ for _, s := range styles {
+ if strings.HasPrefix(s, input) {
+ matches = append(matches, s)
+ }
+ }
+ if len(matches) == 1 {
+ p.AttackStyle = player.AttackStyle(matches[0])
+ g.AccountStore.SaveCharacter(p)
+ sess.WriteLine(fmt.Sprintf("\nCombat style set to %s.", matches[0]))
+ return
+ }
+ if len(matches) > 1 {
+ sess.WriteLine(fmt.Sprintf("\nAmbiguous style: %s. Choices: %s", input, strings.Join(matches, ", ")))
+ return
+ }
+ sess.WriteLine(fmt.Sprintf("\nUnknown style: %s. Choices: accurate, aggressive, defensive, balanced", input))
+}
diff --git a/internal/game/cmd_ticktest.go b/internal/game/cmd_ticktest.go
deleted file mode 100644
index 6cadab7..0000000
--- a/internal/game/cmd_ticktest.go
+++ /dev/null
@@ -1,21 +0,0 @@
-package game
-
-import (
- "fmt"
-
- "thirdcollapse/internal/net"
-)
-
-func (g *Game) doTickTest(sess *net.Session) {
- count := 0
- g.Ticks.Subscribe(5, func() bool {
- count++
- sess.WriteLine(fmt.Sprintf("[Tick #%d] 600ms heartbeat is working.", count))
- if count >= 3 {
- sess.WriteLine("[Tick test complete.]")
- return false
- }
- return true
- })
- sess.WriteLine("Tick test started — you'll see 3 messages at 5-tick intervals while commands still work.")
-}
diff --git a/internal/game/cmd_toggle.go b/internal/game/cmd_toggle.go
index 19c111c..25b8955 100644
--- a/internal/game/cmd_toggle.go
+++ b/internal/game/cmd_toggle.go
@@ -14,12 +14,13 @@ var toggles = []struct {
}{
{"description", "Long room descriptions"},
{"tinymap", "Mini-map display"},
- {"xpdrops", "XP drop messages in combat"},
+ {"xpdrops", "XP drop messages"},
{"exits", "Long exit display in look"},
{"mobenter", "Messages when mobs enter the room"},
{"mobleave", "Messages when mobs leave the room"},
{"mobspawn", "Messages when mobs spawn in the area"},
{"reserve", "Show full reserved item details"},
+ {"depletion", "Show depletion and despawn timers on objects"},
}
func (g *Game) doToggle(sess *net.Session, input string) {
diff --git a/internal/game/cmd_wear.go b/internal/game/cmd_wear.go
new file mode 100644
index 0000000..c4b5d12
--- /dev/null
+++ b/internal/game/cmd_wear.go
@@ -0,0 +1,146 @@
+package game
+
+import (
+ "fmt"
+ "strings"
+
+ "thirdcollapse/internal/net"
+ "thirdcollapse/internal/object"
+ "thirdcollapse/internal/player"
+)
+
+func (g *Game) doWear(sess *net.Session, input string) {
+ p := sess.Player.(*player.Player)
+
+ lower := strings.ToLower(strings.TrimSpace(input))
+ if lower == "all" {
+ g.doWearAll(sess)
+ return
+ }
+
+ if lower == "" {
+ sess.WriteLine("Wear what?")
+ return
+ }
+
+ g.CancelAction(p)
+
+ matches := g.findInventoryMatches(input, p)
+ if len(matches) == 0 {
+ sess.WriteLine(fmt.Sprintf("You don't have any '%s'.", input))
+ return
+ }
+
+ match := matches[0]
+ slot := p.InvSlot(match.Slot)
+ if slot == nil {
+ return
+ }
+
+ def, err := g.ItemStore.Load(slot.ItemID)
+ if err != nil || def.EquipSlot == "" {
+ sess.WriteLine(fmt.Sprintf("You can't wear %s.", match.Name))
+ return
+ }
+
+ existingItemID, slotOccupied := p.Equipment[def.EquipSlot]
+
+ if slotOccupied {
+ freeSlot := p.FirstFreeSlot()
+ if freeSlot == -1 && existingItemID != slot.ItemID {
+ sess.WriteLine("Your inventory is full.")
+ return
+ }
+ if freeSlot >= 0 {
+ p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: existingItemID, Quantity: 1})
+ }
+ }
+
+ if slot.Quantity > 1 {
+ slot.Quantity--
+ } else {
+ p.SetInvSlot(match.Slot, nil)
+ }
+
+ p.Equipment[def.EquipSlot] = slot.ItemID
+ g.AccountStore.SaveCharacter(p)
+
+ verb := "wear"
+ if def.WeaponType != "" {
+ verb = "wield"
+ }
+ sess.WriteLine(fmt.Sprintf("You %s %s.", verb, match.Name))
+}
+
+func (g *Game) doWearAll(sess *net.Session) {
+ p := sess.Player.(*player.Player)
+ g.CancelAction(p)
+
+ bestPerSlot := make(map[object.EquipSlot]struct {
+ itemID string
+ value int
+ invSlot int
+ })
+
+ for i := 0; i < 28; i++ {
+ slot := p.InvSlot(i)
+ if slot == nil {
+ continue
+ }
+ def, err := g.ItemStore.Load(slot.ItemID)
+ if err != nil || def.EquipSlot == "" {
+ continue
+ }
+ current, exists := bestPerSlot[def.EquipSlot]
+ if !exists || def.Value > current.value {
+ bestPerSlot[def.EquipSlot] = struct {
+ itemID string
+ value int
+ invSlot int
+ }{slot.ItemID, def.Value, i}
+ }
+ }
+
+ if len(bestPerSlot) == 0 {
+ sess.WriteLine("You have nothing to wear.")
+ return
+ }
+
+ var equipped []string
+ for eqSlot, best := range bestPerSlot {
+ if existing, ok := p.Equipment[eqSlot]; ok && existing == best.itemID {
+ continue
+ }
+
+ if existing, ok := p.Equipment[eqSlot]; ok {
+ freeSlot := p.FirstFreeSlot()
+ if freeSlot == -1 {
+ continue
+ }
+ p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: existing, Quantity: 1})
+ }
+
+ invSlot := p.InvSlot(best.invSlot)
+ if invSlot != nil && invSlot.Quantity > 1 {
+ invSlot.Quantity--
+ } else {
+ p.SetInvSlot(best.invSlot, nil)
+ }
+
+ p.Equipment[eqSlot] = best.itemID
+ def, _ := g.ItemStore.Load(best.itemID)
+ name := best.itemID
+ if def != nil {
+ name = def.Name
+ }
+ equipped = append(equipped, name)
+ }
+
+ if len(equipped) == 0 {
+ sess.WriteLine("Nothing new to wear.")
+ } else {
+ g.AccountStore.SaveCharacter(p)
+ sess.Write("You wear: ")
+ innerPickupReport(sess, equipped)
+ }
+}
diff --git a/internal/game/game.go b/internal/game/game.go
index 08096b4..50c8f3d 100644
--- a/internal/game/game.go
+++ b/internal/game/game.go
@@ -58,6 +58,7 @@ func (g *Game) SetHub(hub *net.Hub) {
}
func (g *Game) HandleSession(sess *net.Session, input string) {
+ input = player.StripControl(input)
switch sess.State {
case net.StateAccountName:
g.handleAccountName(sess, input)
@@ -129,13 +130,23 @@ func (g *Game) handleGameCommand(sess *net.Session, input string) {
if len(args) == 0 {
sess.WriteLine("Get what?")
} else if args[0] == "all" {
- g.doGetAll(sess)
+ if len(args) == 1 {
+ g.doGetAll(sess)
+ } else {
+ g.doGetAllNamed(sess, strings.Join(args[1:], " "))
+ }
} else {
g.doGet(sess, strings.Join(args, " "))
}
case "drop":
if len(args) == 0 {
sess.WriteLine("Drop what?")
+ } else if args[0] == "all" {
+ if len(args) == 1 {
+ g.doDropAll(sess)
+ } else {
+ g.doDropAllNamed(sess, strings.Join(args[1:], " "))
+ }
} else {
g.doDrop(sess, strings.Join(args, " "))
}
@@ -186,15 +197,14 @@ func (g *Game) handleGameCommand(sess *net.Session, input string) {
g.doToggle(sess, strings.Join(args, " "))
case "exits":
g.doExits(sess)
- case "ticktest":
- g.doTickTest(sess)
+
case "help":
if len(args) == 0 {
g.doHelp(sess, "")
} else {
g.doHelp(sess, strings.Join(args, " "))
}
- case "mine", "chop", "fish", "use", "pull", "push":
+ case "mine", "chop", "fish", "cut", "use", "pull", "push":
if len(args) == 0 {
sess.WriteLine(fmt.Sprintf("%s what?", cmd))
} else {
@@ -214,6 +224,19 @@ func (g *Game) handleGameCommand(sess *net.Session, input string) {
case "unalias":
g.doUnalias(sess, args)
return
+ case "search":
+ if len(args) == 0 {
+ sess.WriteLine("Search what?")
+ } else {
+ g.doSearch(sess, strings.Join(args, " "))
+ }
+ return
+ case "wear", "wield":
+ g.doWear(sess, strings.Join(args, " "))
+ return
+ case "remove", "unwear", "unwield":
+ g.doRemove(sess, strings.Join(args, " "))
+ return
default:
sess.WriteLine("Unknown command.")
}
diff --git a/internal/game/session.go b/internal/game/login.go
index 7fe45e7..3de70bc 100644
--- a/internal/game/session.go
+++ b/internal/game/login.go
@@ -19,11 +19,19 @@ func (g *Game) handleAccountName(sess *net.Session, input string) {
if err == nil {
sess.Account = &net.AccountEntry{Name: actualName}
sess.State = net.StatePassword
+ sess.Conn.SetEcho(false)
sess.Write("Password: ")
} else {
+ if verr := player.ValidName(name); verr != nil {
+ sess.WriteLine(verr.Error())
+ sess.Write("Account name: ")
+ return
+ }
sess.Account = &net.AccountEntry{Name: name}
sess.State = net.StateNewAccountPass
- sess.Write("A new visitor to this rock!\nChoose password: ")
+ sess.Conn.SetEcho(false)
+ sess.WriteLine("\r\nOh, a new visitor to this rock!")
+ sess.Write("Choose password: ")
}
}
@@ -32,19 +40,29 @@ func (g *Game) handlePassword(sess *net.Session, input string) {
sess.Write("Password: ")
return
}
+ if len(input) > 128 {
+ sess.Conn.SetEcho(true)
+ sess.WriteLine("Wrong password.")
+ sess.State = net.StateAccountName
+ sess.Write("Account name: ")
+ return
+ }
acc, err := g.AccountStore.LoadAccount(sess.Account.Name)
if err != nil {
+ sess.Conn.SetEcho(true)
sess.WriteLine("Error loading account.")
sess.State = net.StateAccountName
sess.Write("Account name: ")
return
}
if !player.CheckPassword(input, acc.PasswordHash) {
+ sess.Conn.SetEcho(true)
sess.WriteLine("Wrong password.")
sess.State = net.StateAccountName
sess.Write("Account name: ")
return
}
+ sess.Conn.SetEcho(true)
sess.Account = &net.AccountEntry{
Name: acc.Name,
PasswordHash: acc.PasswordHash,
@@ -60,11 +78,17 @@ func (g *Game) handlePassword(sess *net.Session, input string) {
func (g *Game) handleNewAccountPass(sess *net.Session, input string) {
input = strings.TrimSpace(input)
if input == "" {
+ sess.Conn.SetEcho(true)
sess.Account = nil
sess.State = net.StateAccountName
sess.Write("Account name: ")
return
}
+ if verr := player.ValidPassword(input); verr != nil {
+ sess.WriteLine(verr.Error())
+ sess.Write("Choose password: ")
+ return
+ }
sess.PendingPass = input
sess.State = net.StateNewAccountConfirm
sess.Write("Confirm password: ")
@@ -73,12 +97,14 @@ func (g *Game) handleNewAccountPass(sess *net.Session, input string) {
func (g *Game) handleNewAccountConfirm(sess *net.Session, input string) {
input = strings.TrimSpace(input)
if input == "" {
+ sess.Conn.SetEcho(true)
sess.Account = nil
sess.State = net.StateAccountName
sess.Write("Account name: ")
return
}
if input != sess.PendingPass {
+ sess.Conn.SetEcho(true)
sess.WriteLine("Passwords do not match.")
sess.Account = nil
sess.State = net.StateAccountName
@@ -88,6 +114,7 @@ func (g *Game) handleNewAccountConfirm(sess *net.Session, input string) {
hash, err := player.HashPassword(input)
if err != nil {
+ sess.Conn.SetEcho(true)
sess.WriteLine(fmt.Sprintf("Error creating account: %v", err))
sess.Account = nil
sess.State = net.StateAccountName
@@ -100,6 +127,7 @@ func (g *Game) handleNewAccountConfirm(sess *net.Session, input string) {
PasswordHash: hash,
}
if err := g.AccountStore.SaveAccount(acc); err != nil {
+ sess.Conn.SetEcho(true)
sess.WriteLine(fmt.Sprintf("Error creating account: %v", err))
sess.Account = nil
sess.State = net.StateAccountName
@@ -107,6 +135,7 @@ func (g *Game) handleNewAccountConfirm(sess *net.Session, input string) {
return
}
+ sess.Conn.SetEcho(true)
sess.Account = &net.AccountEntry{
Name: acc.Name,
PasswordHash: acc.PasswordHash,
@@ -120,7 +149,7 @@ func (g *Game) showMenu(sess *net.Session) {
sess.State = net.StateMenu
lines := []string{
"",
- fmt.Sprintf("SUCCESSFUL LOGIN as %s.", sess.Account.Name),
+ fmt.Sprintf("Welcome back to Gaia 04, %s.", sess.Account.Name),
"",
}
if len(sess.Account.Characters) > 0 {
@@ -138,7 +167,7 @@ func (g *Game) showMenu(sess *net.Session) {
" (A)ccount rename",
" (Q)uit",
"",
- "INPUT: ",
+ "> ",
)
sess.WriteLines(lines...)
}
@@ -146,11 +175,11 @@ func (g *Game) showMenu(sess *net.Session) {
func (g *Game) handleMenu(sess *net.Session, input string) {
switch strings.ToLower(strings.TrimSpace(input)) {
case "":
- sess.Write("INPUT: ")
+ sess.Write("> ")
case "c":
if len(sess.Account.Characters) == 0 {
- sess.WriteLine("\nSYSTEM ERROR: Make a character with 'N' first!")
- sess.Write("\nINPUT: ")
+ sess.WriteLine("\nHey, make a character with 'N' first!")
+ sess.Write("\n> ")
return
}
if len(sess.Account.Characters) == 1 {
@@ -163,18 +192,18 @@ func (g *Game) handleMenu(sess *net.Session, input string) {
}
sess.State = net.StateNewCharName
sess.PendingChar = "connect"
- sess.Write("\nChoice: ")
+ sess.Write("\n> ")
case "l":
if len(sess.Account.Characters) == 0 {
- sess.WriteLine("\nSYSTEM ERROR: Zero characters on this account.\n(Make a character with 'N' first)")
+ sess.WriteLine("\nYou don't have any characters! Make one with 'N'!")
} else {
- sess.WriteLine("\nSELECT Humans FROM GaiaZeroFour:")
+ sess.WriteLine("\nCharacters:")
for _, name := range sess.Account.Characters {
sess.WriteLine(fmt.Sprintf(" - %s", name))
}
}
- sess.Write("\nINPUT: ")
+ sess.Write("\n> ")
case "n":
sess.State = net.StateNewCharName
@@ -187,8 +216,8 @@ func (g *Game) handleMenu(sess *net.Session, input string) {
case "r":
if len(sess.Account.Characters) == 0 {
- sess.WriteLine("\nNo characters to rename.")
- sess.Write("\nChoice: ")
+ sess.WriteLine("\nRename who? You don't have any characters!")
+ sess.Write("\n> ")
return
}
if len(sess.Account.Characters) == 1 {
@@ -203,12 +232,12 @@ func (g *Game) handleMenu(sess *net.Session, input string) {
for i, name := range sess.Account.Characters {
sess.WriteLine(fmt.Sprintf(" %d) %s", i+1, name))
}
- sess.Write("\n> : ")
+ sess.Write("\n> ")
case "d":
if len(sess.Account.Characters) == 0 {
sess.WriteLine("\nNo characters to delete.")
- sess.Write("\nINPUT: ")
+ sess.Write("\n> ")
return
}
if len(sess.Account.Characters) == 1 {
@@ -219,7 +248,7 @@ func (g *Game) handleMenu(sess *net.Session, input string) {
for i, name := range sess.Account.Characters {
sess.WriteLine(fmt.Sprintf(" %d) %s", i+1, name))
}
- sess.Write("\n>: ")
+ sess.Write("\n> ")
return
}
g.showDeleteConfirm(sess)
@@ -231,11 +260,11 @@ func (g *Game) handleMenu(sess *net.Session, input string) {
case "q":
sess.WriteLine("Later.")
- sess.Conn.Close()
+ sess.Close()
return
default:
- sess.Write("SYNTAX ERROR\nINPUT: ")
+ sess.Write("> ")
}
}
@@ -249,7 +278,7 @@ func (g *Game) handleNewCharName(sess *net.Session, input string) {
return
}
sess.WriteLine("Invalid choice.")
- sess.Write("\nChoice: ")
+ sess.Write("\n> ")
return
}
@@ -258,6 +287,12 @@ func (g *Game) handleNewCharName(sess *net.Session, input string) {
return
}
+ if verr := player.ValidName(name); verr != nil {
+ sess.WriteLine(verr.Error())
+ sess.Write("Character name: ")
+ return
+ }
+
if g.AccountStore.CharacterExists(name) {
sess.WriteLine("A character with that name already exists.")
sess.Write("Character name: ")
@@ -324,6 +359,11 @@ func (g *Game) handleRenameAccount(sess *net.Session, input string) {
sess.Write("New account name: ")
return
}
+ if verr := player.ValidName(newName); verr != nil {
+ sess.WriteLine(verr.Error())
+ sess.Write("New account name: ")
+ return
+ }
oldName := sess.Account.Name
if newName == oldName {
sess.WriteLine("That's already your account name.")
@@ -382,6 +422,11 @@ func (g *Game) handleRenameCharName(sess *net.Session, input string) {
sess.Write("New name: ")
return
}
+ if verr := player.ValidName(newName); verr != nil {
+ sess.WriteLine(verr.Error())
+ sess.Write("New name: ")
+ return
+ }
if newName == oldName {
sess.WriteLine("That's already the character's name.")
g.showMenu(sess)
@@ -501,5 +546,5 @@ func (g *Game) handlePurgeAccount(sess *net.Session, input string) {
os.Remove(g.AccountStore.AccountPath(sess.Account.Name))
sess.WriteLine(fmt.Sprintf("Account %s has been purged. Thanks for playing.", sess.Account.Name))
- sess.Conn.Close()
+ sess.Close()
}
diff --git a/internal/game/tick.go b/internal/game/tick.go
index 9595668..7eb63eb 100644
--- a/internal/game/tick.go
+++ b/internal/game/tick.go
@@ -6,6 +6,7 @@ import (
"thirdcollapse/internal/combat"
"thirdcollapse/internal/player"
+ "thirdcollapse/internal/world"
)
func (g *Game) DisconnectTick() {
@@ -72,29 +73,32 @@ func (g *Game) WanderTick() {
var moves []moveEvent
- // Mob wandering
+ // Mob wandering — exit-based
for _, inst := range g.MobStore.AllInstances() {
- if inst.HP <= 0 || len(inst.WanderRooms) == 0 || inst.WanderInterval <= 0 {
+ if inst.HP <= 0 || inst.WanderInterval <= 0 {
continue
}
if combat.IsMobInCombat(inst.InstanceID) {
continue
}
inst.WanderTickCounter++
- if inst.WanderTickCounter >= inst.WanderInterval {
- inst.WanderTickCounter = 0
- toRoom := inst.WanderRooms[rand.Intn(len(inst.WanderRooms))]
- if toRoom == inst.RoomID {
- continue
- }
- moves = append(moves, moveEvent{
- name: mobDisplayName(inst, false),
- fromRoom: inst.RoomID,
- toRoom: toRoom,
- level: mobCombatLevel(inst),
- })
- inst.RoomID = toRoom
+ if inst.WanderTickCounter < inst.WanderInterval {
+ continue
}
+ inst.WanderTickCounter = 0
+
+ legal := g.legalMobExits(inst)
+ if len(legal) == 0 {
+ continue
+ }
+ toRoom := legal[rand.Intn(len(legal))]
+ moves = append(moves, moveEvent{
+ name: mobDisplayName(inst, false),
+ fromRoom: inst.RoomID,
+ toRoom: toRoom,
+ level: mobCombatLevel(inst),
+ })
+ inst.RoomID = toRoom
}
// Object wandering (fishing spots, etc.)
@@ -139,3 +143,61 @@ func (g *Game) WanderTick() {
}
}
}
+
+func (g *Game) WoodcuttingTick() {
+ all := g.World.AllSharedObjStates()
+ for _, st := range all {
+ if st.Depleted {
+ continue
+ }
+ key := g.World.ObjStateKey(st.RoomID, st.DefID, st.Index)
+ choppers := g.countChoppers(st.RoomID, key)
+ if choppers > 0 {
+ if st.SharedTimer > 0 {
+ st.SharedTimer--
+ }
+ } else {
+ if st.SharedTimer < st.SharedMax {
+ st.SharedTimer++
+ }
+ }
+ }
+}
+
+func (g *Game) countChoppers(roomID int, instanceKey string) int {
+ count := 0
+ for _, sess := range g.Hub.PlayersInRoom(roomID) {
+ p, ok := sess.Player.(*player.Player)
+ if !ok || p.Action == nil || p.Action.Type != "gather" {
+ continue
+ }
+ if key, ok := p.Action.Data["instance_key"].(string); ok && key == instanceKey {
+ count++
+ }
+ }
+ return count
+}
+
+func (g *Game) legalMobExits(inst *world.MobInstance) []int {
+ room, err := g.World.LoadRoom(inst.RoomID)
+ if err != nil || len(room.Exits) == 0 {
+ return nil
+ }
+ allowed := make(map[int]bool)
+ if len(inst.WanderRooms) > 0 {
+ for _, r := range inst.WanderRooms {
+ allowed[r] = true
+ }
+ }
+ var legal []int
+ for _, exitDef := range room.Exits {
+ if exitDef.Condition != nil {
+ continue
+ }
+ if len(allowed) > 0 && !allowed[exitDef.Room] {
+ continue
+ }
+ legal = append(legal, exitDef.Room)
+ }
+ return legal
+}
diff --git a/internal/game/utils.go b/internal/game/utils.go
index 0559f4f..c78889d 100644
--- a/internal/game/utils.go
+++ b/internal/game/utils.go
@@ -3,6 +3,8 @@ package game
import (
"fmt"
"math/rand"
+ "strconv"
+ "strings"
"thirdcollapse/internal/net"
"thirdcollapse/internal/object"
@@ -43,6 +45,16 @@ func plural(n int) string {
return "s"
}
+func parseQty(input string) (int, string) {
+ parts := strings.Fields(input)
+ if len(parts) > 1 {
+ if n, err := strconv.Atoi(parts[0]); err == nil && n > 0 {
+ return n, strings.Join(parts[1:], " ")
+ }
+ }
+ return 0, input
+}
+
func parseIndex(s string) (int, error) {
var idx int
_, err := fmt.Sscanf(s, "%d", &idx)
diff --git a/internal/net/conn.go b/internal/net/conn.go
new file mode 100644
index 0000000..e46031b
--- /dev/null
+++ b/internal/net/conn.go
@@ -0,0 +1,101 @@
+package net
+
+import (
+ "bufio"
+ "bytes"
+ "io"
+ "net"
+ "strings"
+)
+
+type Conn interface {
+ ReadMessage() (string, error)
+ io.Writer
+ io.Closer
+ SetEcho(bool) error
+}
+
+type tcpConn struct {
+ conn net.Conn
+ reader *bufio.Reader
+ echoOff bool
+}
+
+func newTCPConn(conn net.Conn) *tcpConn {
+ return &tcpConn{
+ conn: conn,
+ reader: bufio.NewReader(conn),
+ }
+}
+
+func (t *tcpConn) ReadMessage() (string, error) {
+ line, err := t.reader.ReadString('\n')
+ if err != nil {
+ return "", err
+ }
+ line = strings.TrimRight(line, "\r\n")
+ line = stripIAC(line)
+ if t.echoOff {
+ t.conn.Write([]byte("\r\n"))
+ }
+ return line, nil
+}
+
+func (t *tcpConn) Write(b []byte) (int, error) {
+ return t.conn.Write(b)
+}
+
+func (t *tcpConn) Close() error {
+ return t.conn.Close()
+}
+
+func (t *tcpConn) SetEcho(on bool) error {
+ t.echoOff = !on
+ if on {
+ return t.writeTelnetCmd(wont, echo)
+ }
+ return t.writeTelnetCmd(will, echo)
+}
+
+const (
+ iac = 255
+ will = 251
+ wont = 252
+ do = 253
+ dont = 254
+ sb = 250
+ se = 240
+ echo = 1
+)
+
+func (t *tcpConn) writeTelnetCmd(cmd, opt byte) error {
+ _, err := t.conn.Write([]byte{iac, cmd, opt})
+ return err
+}
+
+func stripIAC(s string) string {
+ b := []byte(s)
+ var out []byte
+ i := 0
+ iacSE := []byte{iac, se}
+ for i < len(b) {
+ if b[i] == iac && i+2 < len(b) {
+ if b[i+1] == sb {
+ end := bytes.Index(b[i+2:], iacSE)
+ if end >= 0 {
+ i += end + 4
+ continue
+ }
+ }
+ i += 3
+ continue
+ }
+ if b[i] == iac {
+ i++
+ continue
+ }
+ out = append(out, b[i])
+ i++
+ }
+ return string(out)
+}
diff --git a/internal/net/server.go b/internal/net/server.go
index 08d427f..7e73bc9 100644
--- a/internal/net/server.go
+++ b/internal/net/server.go
@@ -1,11 +1,13 @@
package net
import (
- "bufio"
+ "crypto/tls"
"fmt"
"log"
"net"
- "strings"
+ "net/http"
+
+ "thirdcollapse/internal/config"
)
type SessionState int
@@ -30,14 +32,13 @@ const (
)
type Session struct {
- Conn net.Conn
- Reader *bufio.Reader
- State SessionState
- Account *AccountEntry
- Player interface{} // *player.Player once character is selected
- PendingChar string // char being renamed/deleted
- PendingPass string // first password during signup
- Disconnecting bool
+ Conn Conn
+ State SessionState
+ Account *AccountEntry
+ Player interface{}
+ PendingChar string
+ PendingPass string
+ Disconnecting bool
DisconnectTicks int
}
@@ -49,14 +50,18 @@ type AccountEntry struct {
}
type Server struct {
- listener net.Listener
- hub *Hub
+ config *config.Config
+ telnetLn net.Listener
+ httpServer *http.Server
+ httpsServer *http.Server
+ hub *Hub
+ handler func(*Session, string)
}
type Hub struct {
- sessions map[*Session]bool
- rooms map[int]map[*Session]bool
- onRemove func(*Session)
+ sessions map[*Session]bool
+ rooms map[int]map[*Session]bool
+ onRemove func(*Session)
}
func NewHub() *Hub {
@@ -125,12 +130,40 @@ func (h *Hub) PlayersInRoom(roomID int) []*Session {
return out
}
-func NewServer(addr string) (*Server, error) {
- l, err := net.Listen("tcp", addr)
- if err != nil {
- return nil, err
+func NewServer(cfg *config.Config) (*Server, error) {
+ s := &Server{
+ config: cfg,
+ hub: NewHub(),
+ }
+
+ if cfg.Telnet.Enabled {
+ addr := fmt.Sprintf(":%d", cfg.Telnet.Port)
+ ln, err := net.Listen("tcp", addr)
+ if err != nil {
+ return nil, fmt.Errorf("telnet listen: %w", err)
+ }
+ s.telnetLn = ln
+ }
+
+ if cfg.HTTP.Enabled {
+ s.httpServer = &http.Server{
+ Addr: fmt.Sprintf(":%d", cfg.HTTP.Port),
+ Handler: newHTTPMux(s),
+ }
+ }
+
+ if cfg.HTTPS.Enabled {
+ if cfg.HTTPS.CertFile == "" || cfg.HTTPS.KeyFile == "" {
+ return nil, fmt.Errorf("https enabled but cert_file and key_file are required")
+ }
+ s.httpsServer = &http.Server{
+ Addr: fmt.Sprintf(":%d", cfg.HTTPS.Port),
+ Handler: newHTTPMux(s),
+ TLSConfig: &tls.Config{MinVersion: tls.VersionTLS12},
+ }
}
- return &Server{listener: l, hub: NewHub()}, nil
+
+ return s, nil
}
func (s *Server) Hub() *Hub {
@@ -138,31 +171,66 @@ func (s *Server) Hub() *Hub {
}
func (s *Server) ListenAndServe(handler func(*Session, string)) error {
+ s.handler = handler
+
+ if s.telnetLn != nil {
+ go s.serveTelnet()
+ }
+
+ if s.httpServer != nil {
+ go s.serveHTTP()
+ }
+
+ if s.httpsServer != nil {
+ go s.serveHTTPS()
+ }
+
+ if s.telnetLn == nil && s.httpServer == nil && s.httpsServer == nil {
+ return fmt.Errorf("no listeners configured")
+ }
+
+ select {}
+}
+
+func (s *Server) serveTelnet() {
for {
- conn, err := s.listener.Accept()
+ conn, err := s.telnetLn.Accept()
if err != nil {
- return err
+ return
}
- session := &Session{
- Conn: conn,
- Reader: bufio.NewReader(conn),
- State: StateAccountName,
+ sess := &Session{
+ Conn: newTCPConn(conn),
+ State: StateAccountName,
}
- s.hub.Add(session)
- go s.handleSession(session, handler)
+ s.hub.Add(sess)
+ go s.handleSession(sess, s.handler)
}
}
-func (s *Server) handleSession(session *Session, handler func(*Session, string)) {
+func (s *Server) serveHTTP() {
+ err := s.httpServer.ListenAndServe()
+ if err != nil && err != http.ErrServerClosed {
+ log.Printf("http server error: %v", err)
+ }
+}
+
+func (s *Server) serveHTTPS() {
+ err := s.httpsServer.ListenAndServeTLS(s.config.HTTPS.CertFile, s.config.HTTPS.KeyFile)
+ if err != nil && err != http.ErrServerClosed {
+ log.Printf("https server error: %v", err)
+ }
+}
+
+func (s *Server) handleSession(sess *Session, handler func(*Session, string)) {
defer func() {
if r := recover(); r != nil {
log.Printf("session panic: %v", r)
}
- s.hub.Remove(session)
- session.Conn.Close()
+ s.hub.Remove(sess)
+ sess.Close()
}()
- _, _ = session.Conn.Write([]byte("\033[2J\033[H")) // clear screen
+ sess.Conn.Write([]byte("\033[2J\033[H"))
welcomeart := `
, 3333333 333 333 333 3333333 3333333
@@ -179,29 +247,30 @@ func (s *Server) handleSession(session *Session, handler func(*Session, string))
:!! !!: !!! !!: !!: !!: !!! !!: !:! !!:
:: :: : : :. : : ::.: : : ::.: : : : : : ::.: : : :: :::
-
`
- session.Write(welcomeart)
- session.Write("ACCOUNT NAME> ")
+ sess.Write(welcomeart)
+ sess.Write("What's your account name? ")
for {
- line, err := session.Reader.ReadString('\n')
+ line, err := sess.Conn.ReadMessage()
if err != nil {
log.Printf("session read error: %v", err)
return
}
- line = strings.TrimSpace(line)
- handler(session, line)
+ if len(line) > 1024 {
+ line = line[:1024]
+ }
+ handler(sess, line)
}
}
func (sess *Session) Write(msg string) {
- _, _ = sess.Conn.Write([]byte(msg))
+ sess.Conn.Write([]byte(msg))
}
func (sess *Session) WriteLine(msg string) {
- _, _ = sess.Conn.Write([]byte(msg + "\r\n"))
+ sess.Conn.Write([]byte(msg + "\r\n"))
}
func (sess *Session) WriteLines(lines ...string) {
@@ -213,3 +282,7 @@ func (sess *Session) WriteLines(lines ...string) {
func (sess *Session) Writef(format string, args ...interface{}) {
sess.Write(fmt.Sprintf(format, args...))
}
+
+func (sess *Session) Close() error {
+ return sess.Conn.Close()
+}
diff --git a/internal/net/terminal.html b/internal/net/terminal.html
new file mode 100644
index 0000000..2d98178
--- /dev/null
+++ b/internal/net/terminal.html
@@ -0,0 +1,164 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Third Collapse</title>
+<style>
+* { margin: 0; padding: 0; box-sizing: border-box; }
+body {
+ background: #0a0a0a;
+ color: #c0c0c0;
+ font-family: "Courier New", monospace;
+ font-size: 15px;
+ line-height: 1.25;
+ height: 100vh;
+ display: flex;
+ flex-direction: column;
+}
+#output {
+ flex: 1;
+ overflow-y: auto;
+ padding: 10px 14px;
+ white-space: pre-wrap;
+ word-break: break-all;
+}
+#input-line {
+ display: flex;
+ border-top: 1px solid #222;
+}
+#input-line span {
+ padding: 8px 0 8px 14px;
+ color: #888;
+}
+#input {
+ flex: 1;
+ background: transparent;
+ border: none;
+ color: #c0c0c0;
+ font-family: inherit;
+ font-size: inherit;
+ padding: 8px 14px 8px 4px;
+ outline: none;
+}
+</style>
+</head>
+<body>
+<pre id="output"></pre>
+<div id="input-line">
+ <span>&gt;</span>
+ <input type="text" id="input" autofocus autocomplete="off" spellcheck="false">
+</div>
+<script>
+(function () {
+ const out = document.getElementById("output");
+ const inp = document.getElementById("input");
+ const proto = location.protocol === "https:" ? "wss:" : "ws:";
+ const ws = new WebSocket(proto + "//" + location.host + "/ws");
+
+ function ansiToHTML(text) {
+ let out = "";
+ let i = 0;
+ const stack = [];
+ while (i < text.length) {
+ if (text[i] === "\x1b" && text[i + 1] === "[") {
+ let j = i + 2;
+ while (j < text.length && (text[j] < "A" || text[j] > "z" || text[j] === "[" || text[j] === ";")) j++;
+ if (j < text.length) j++;
+ const seq = text.slice(i + 2, j);
+ out += ansiSeq(seq, stack);
+ i = j;
+ continue;
+ }
+ out += escapeHTML(text[i]);
+ i++;
+ }
+ for (let k = stack.length - 1; k >= 0; k--) out += "</span>";
+ return out;
+ }
+
+ function escapeHTML(c) {
+ if (c === "&") return "&amp;";
+ if (c === "<") return "&lt;";
+ if (c === ">") return "&gt;";
+ return c;
+ }
+
+ function ansiSeq(seq, stack) {
+ if (seq === "0" || seq === "") {
+ let s = "";
+ for (let k = stack.length - 1; k >= 0; k--) s += "</span>";
+ stack.length = 0;
+ return s;
+ }
+ const codes = seq.endsWith("m") ? seq.slice(0, -1).split(";") : [];
+ let style = "";
+ for (const c of codes) {
+ switch (c) {
+ case "1": style += "font-weight:bold;"; break;
+ case "2": style += "opacity:0.6;"; break;
+ case "30": style += "color:#000;"; break;
+ case "31": style += "color:#c44;"; break;
+ case "32": style += "color:#4c4;"; break;
+ case "33": style += "color:#cc4;"; break;
+ case "34": style += "color:#44c;"; break;
+ case "35": style += "color:#c4c;"; break;
+ case "36": style += "color:#4cc;"; break;
+ case "37": style += "color:#ccc;"; break;
+ case "40": style += "background:#000;"; break;
+ case "41": style += "background:#c44;"; break;
+ case "42": style += "background:#4c4;"; break;
+ case "43": style += "background:#cc4;"; break;
+ case "44": style += "background:#44c;"; break;
+ case "45": style += "background:#c4c;"; break;
+ case "46": style += "background:#4cc;"; break;
+ case "47": style += "background:#ccc;"; break;
+ }
+ }
+ if (style) {
+ stack.push(1);
+ return '<span style="' + style + '">';
+ }
+ return "";
+ }
+
+ ws.onmessage = function (e) {
+ var data = e.data;
+ var re = /\x1b\]10;([01])\x07/g;
+ var match;
+ while ((match = re.exec(data)) !== null) {
+ inp.type = match[1] === "1" ? "password" : "text";
+ }
+ data = data.replace(re, "");
+ if (data.length > 0) {
+ out.innerHTML += ansiToHTML(data);
+ out.scrollTop = out.scrollHeight;
+ }
+ };
+
+ inp.addEventListener("keydown", function (e) {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ var text = inp.value;
+ inp.value = "";
+ if (inp.type !== "password") {
+ out.innerHTML += ansiToHTML(text + "\n");
+ } else {
+ out.innerHTML += "\n";
+ }
+ out.scrollTop = out.scrollHeight;
+ ws.send(text);
+ }
+ });
+
+ ws.onclose = function () {
+ out.innerHTML += ansiToHTML("\n\u001b[31mConnection lost.\u001b[0m\n");
+ };
+
+ ws.onerror = function () {
+ out.innerHTML += ansiToHTML("\n\u001b[31mConnection error.\u001b[0m\n");
+ };
+})();
+</script>
+</body>
+</html>
diff --git a/internal/net/web.go b/internal/net/web.go
new file mode 100644
index 0000000..e573aba
--- /dev/null
+++ b/internal/net/web.go
@@ -0,0 +1,72 @@
+package net
+
+import (
+ "embed"
+ "log"
+ "net"
+ "net/http"
+ "strings"
+
+ "github.com/gorilla/websocket"
+)
+
+//go:embed terminal.html
+var terminalHTML embed.FS
+
+var upgrader = websocket.Upgrader{
+ ReadBufferSize: 1024,
+ WriteBufferSize: 1024,
+ CheckOrigin: func(r *http.Request) bool {
+ origin := r.Header.Get("Origin")
+ if origin == "" {
+ return true
+ }
+ host := r.Host
+ hostname, _, err := net.SplitHostPort(host)
+ if err != nil {
+ hostname = host
+ }
+ if strings.EqualFold(origin, "http://"+host) ||
+ strings.EqualFold(origin, "https://"+host) ||
+ strings.EqualFold(origin, "http://"+hostname) ||
+ strings.EqualFold(origin, "https://"+hostname) {
+ return true
+ }
+ if hn, _, err := net.SplitHostPort(r.RemoteAddr); err == nil &&
+ (hn == "127.0.0.1" || hn == "::1") {
+ return true
+ }
+ log.Printf("ws rejected origin: %s (host: %s)", origin, r.Host)
+ return false
+ },
+}
+
+func newHTTPMux(s *Server) *http.ServeMux {
+ mux := http.NewServeMux()
+
+ mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+ data, err := terminalHTML.ReadFile("terminal.html")
+ if err != nil {
+ http.Error(w, "not found", http.StatusNotFound)
+ return
+ }
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.Write(data)
+ })
+
+ mux.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
+ ws, err := upgrader.Upgrade(w, r, nil)
+ if err != nil {
+ log.Printf("ws upgrade error: %v", err)
+ return
+ }
+ sess := &Session{
+ Conn: newWSConn(ws),
+ State: StateAccountName,
+ }
+ s.hub.Add(sess)
+ go s.handleSession(sess, s.handler)
+ })
+
+ return mux
+}
diff --git a/internal/net/wsconn.go b/internal/net/wsconn.go
new file mode 100644
index 0000000..d6f45dc
--- /dev/null
+++ b/internal/net/wsconn.go
@@ -0,0 +1,34 @@
+package net
+
+import (
+ "github.com/gorilla/websocket"
+)
+
+type wsConn struct {
+ conn *websocket.Conn
+}
+
+func newWSConn(conn *websocket.Conn) *wsConn {
+ return &wsConn{conn: conn}
+}
+
+func (w *wsConn) ReadMessage() (string, error) {
+ _, msg, err := w.conn.ReadMessage()
+ return string(msg), err
+}
+
+func (w *wsConn) Write(b []byte) (int, error) {
+ err := w.conn.WriteMessage(websocket.TextMessage, b)
+ return len(b), err
+}
+
+func (w *wsConn) Close() error {
+ return w.conn.Close()
+}
+
+func (w *wsConn) SetEcho(on bool) error {
+ if on {
+ return w.conn.WriteMessage(websocket.TextMessage, []byte("\x1b]10;0\x07"))
+ }
+ return w.conn.WriteMessage(websocket.TextMessage, []byte("\x1b]10;1\x07"))
+}
diff --git a/internal/object/item.go b/internal/object/item.go
index 170e07f..c9b2717 100644
--- a/internal/object/item.go
+++ b/internal/object/item.go
@@ -37,7 +37,6 @@ type ItemDef struct {
WeaponType WeaponType `yaml:"weapon_type"`
Stats ItemStats `yaml:"stats"`
Speed int `yaml:"speed"`
- Toolbelt bool `yaml:"toolbelt"`
ToolType string `yaml:"tool_type"`
ToolSpeed int `yaml:"tool_speed"`
}
@@ -51,14 +50,46 @@ type ItemStats struct {
}
func (d *ItemDef) MatchesName(input string) bool {
- lower := strings.ToLower(input)
+ lower := strings.ToLower(strings.TrimSpace(input))
+ if lower == "" {
+ return false
+ }
if strings.ToLower(d.Name) == lower {
return true
}
- for _, word := range strings.Fields(d.Name) {
- if strings.HasPrefix(strings.ToLower(word), lower) {
+ for _, alias := range d.Aliases {
+ if strings.ToLower(alias) == lower {
+ return true
+ }
+ }
+ if wordPrefixMatch(lower, d.Name) {
+ return true
+ }
+ for _, alias := range d.Aliases {
+ if wordPrefixMatch(lower, alias) {
return true
}
}
return false
}
+
+func wordPrefixMatch(input, name string) bool {
+ inputWords := strings.Fields(input)
+ if len(inputWords) == 0 {
+ return false
+ }
+ nameWords := strings.Fields(strings.ToLower(name))
+ for _, iw := range inputWords {
+ found := false
+ for _, nw := range nameWords {
+ if strings.HasPrefix(nw, iw) || strings.HasPrefix(iw, nw) {
+ found = true
+ break
+ }
+ }
+ if !found {
+ return false
+ }
+ }
+ return true
+}
diff --git a/internal/player/player.go b/internal/player/player.go
index fee6578..cccf979 100644
--- a/internal/player/player.go
+++ b/internal/player/player.go
@@ -16,6 +16,7 @@ const (
Fishing SkillName = "fishing"
Cooking SkillName = "cooking"
Woodcutting SkillName = "woodcutting"
+ Firemaking SkillName = "firemaking"
Mining SkillName = "mining"
Smithing SkillName = "smithing"
Crafting SkillName = "crafting"
@@ -32,7 +33,7 @@ const (
var AllSkills = []SkillName{
Attack, Strength, Defense, Hitpoints, Ranged, Science, Technology,
- Fishing, Cooking, Woodcutting, Mining, Smithing, Crafting, Fletching,
+ Fishing, Cooking, Woodcutting, Firemaking, Mining, Smithing, Crafting, Fletching,
Alchemy, Thieving, Agility, Construction, Scavenging, Hacking, Assassin, Farming,
}
@@ -47,6 +48,7 @@ var SkillAbbr = map[SkillName]string{
Fishing: "fis",
Cooking: "cok",
Woodcutting: "wct",
+ Firemaking: "fmk",
Mining: "min",
Smithing: "smt",
Crafting: "cft",
@@ -89,7 +91,6 @@ type Player struct {
Skills map[SkillName]int `yaml:"skills"`
Inventory map[int]*InventorySlot `yaml:"inventory"`
Equipment map[object.EquipSlot]string `yaml:"equipment"`
- Toolbelt []string `yaml:"toolbelt"`
RoomID int `yaml:"room_id"`
HP int `yaml:"hp"`
Credits int `yaml:"credits"`
@@ -195,15 +196,6 @@ func (p *Player) StartRegen() {
}
}
-func (p *Player) HasToolbeltItem(itemID string) bool {
- for _, id := range p.Toolbelt {
- if id == itemID {
- return true
- }
- }
- return false
-}
-
func (p *Player) HasItem(itemID string) bool {
for _, slot := range p.Inventory {
if slot != nil && slot.ItemID == itemID && slot.Quantity > 0 {
diff --git a/internal/player/validate.go b/internal/player/validate.go
new file mode 100644
index 0000000..63f40d2
--- /dev/null
+++ b/internal/player/validate.go
@@ -0,0 +1,37 @@
+package player
+
+import (
+ "fmt"
+ "regexp"
+ "unicode"
+)
+
+var validNameRE = regexp.MustCompile(`^[A-Za-z0-9 ]{1,30}$`)
+
+func ValidName(name string) error {
+ if !validNameRE.MatchString(name) {
+ return fmt.Errorf("name must be 1-30 alphanumeric characters or spaces")
+ }
+ return nil
+}
+
+func ValidPassword(pw string) error {
+ if len(pw) > 128 {
+ return fmt.Errorf("Password too long.")
+ }
+ return nil
+}
+
+func StripControl(input string) string {
+ runes := make([]rune, 0, len(input))
+ for _, r := range input {
+ if r == '\n' || r == '\t' {
+ continue
+ }
+ if unicode.IsControl(r) {
+ continue
+ }
+ runes = append(runes, r)
+ }
+ return string(runes)
+}
diff --git a/internal/world/mob.go b/internal/world/mob.go
index 9dfec59..59c8a20 100644
--- a/internal/world/mob.go
+++ b/internal/world/mob.go
@@ -33,8 +33,6 @@ type MobDef struct {
Protected bool `yaml:"protected"`
Unique bool `yaml:"unique"`
RespawnTicks int `yaml:"respawn_ticks"`
- WanderRooms []int `yaml:"wander_rooms"`
- WanderInterval int `yaml:"wander_interval"`
Drops DropTable `yaml:"drops"`
}
@@ -70,13 +68,24 @@ const (
)
func WordPrefixMatch(input, name string) bool {
- lower := strings.ToLower(input)
- for _, word := range strings.Fields(name) {
- if strings.HasPrefix(strings.ToLower(word), lower) {
- return true
+ inputWords := strings.Fields(strings.ToLower(input))
+ if len(inputWords) == 0 {
+ return false
+ }
+ nameWords := strings.Fields(strings.ToLower(name))
+ for _, iw := range inputWords {
+ found := false
+ for _, nw := range nameWords {
+ if strings.HasPrefix(nw, iw) || strings.HasPrefix(iw, nw) {
+ found = true
+ break
+ }
+ }
+ if !found {
+ return false
}
}
- return false
+ return true
}
func (m *MobInstance) MatchQuality(input string) int {
@@ -198,20 +207,19 @@ func (s *MobStore) RollIdleDescription(inst *MobInstance) {
return
}
inst.IdleDescription = pickIdleDescription(def.IdleDescriptions)
- inst.WanderRooms = def.WanderRooms
- inst.WanderInterval = def.WanderInterval
inst.WanderTickCounter = 0
}
-func (s *MobStore) SeedMobs(roomID int, mobIDs []string) {
+func (s *MobStore) SeedMobs(roomID int, entries []RoomMob) {
type defWrapper struct {
def *MobDef
err error
+ rm RoomMob
}
- defs := make([]defWrapper, len(mobIDs))
- for i, defID := range mobIDs {
- d, err := s.LoadDef(defID)
- defs[i] = defWrapper{d, err}
+ defs := make([]defWrapper, len(entries))
+ for i, rm := range entries {
+ d, err := s.LoadDef(rm.ID)
+ defs[i] = defWrapper{d, err, rm}
}
s.mu.Lock()
@@ -220,11 +228,9 @@ func (s *MobStore) SeedMobs(roomID int, mobIDs []string) {
if dw.err != nil {
continue
}
- defID := mobIDs[i]
+ defID := dw.rm.ID
instID := fmt.Sprintf("%s_%d_%d", defID, roomID, i)
- if inst, exists := s.instances[instID]; exists {
- // Already exists — skip (respawn is handled by timers)
- _ = inst
+ if _, exists := s.instances[instID]; exists {
continue
}
inst := &MobInstance{
@@ -242,8 +248,8 @@ func (s *MobStore) SeedMobs(roomID int, mobIDs []string) {
Protected: dw.def.Protected,
Unique: dw.def.Unique,
RespawnTicks: dw.def.RespawnTicks,
- WanderRooms: dw.def.WanderRooms,
- WanderInterval: dw.def.WanderInterval,
+ WanderRooms: dw.rm.WanderRooms,
+ WanderInterval: dw.rm.WanderInterval,
RoomID: roomID,
HomeRoomID: roomID,
Drops: dw.def.Drops,
diff --git a/internal/world/room.go b/internal/world/room.go
index 58fa82b..46fb876 100644
--- a/internal/world/room.go
+++ b/internal/world/room.go
@@ -73,10 +73,29 @@ type Room struct {
Exits map[ExitDir]ExitDef `yaml:"exits"`
Objects []RoomObject `yaml:"objects"`
Spawns []SpawnDef `yaml:"spawns"`
- Mobs []string `yaml:"mobs"`
+ Mobs []RoomMob `yaml:"mobs"`
OnEnter []EnterStep `yaml:"on_enter"`
}
+type RoomMob struct {
+ ID string `yaml:"id"`
+ WanderRooms []int `yaml:"wander_rooms"`
+ WanderInterval int `yaml:"wander_interval"`
+}
+
+func (rm *RoomMob) UnmarshalYAML(value *yaml.Node) error {
+ if value.Kind == yaml.ScalarNode {
+ var s string
+ if err := value.Decode(&s); err != nil {
+ return err
+ }
+ rm.ID = s
+ return nil
+ }
+ type raw RoomMob
+ return value.Decode((*raw)(rm))
+}
+
type EnterStep struct {
Message string `yaml:"message"`
Condition *action.Condition `yaml:"condition"`
diff --git a/internal/world/world.go b/internal/world/world.go
index 71fd518..c8270b1 100644
--- a/internal/world/world.go
+++ b/internal/world/world.go
@@ -61,6 +61,8 @@ type ObjState struct {
WanderRooms []int
WanderInterval int
WanderCounter int
+ SharedMax int
+ SharedTimer int
}
func (w *World) ObjStateKey(roomID int, defID string, index int) string {
@@ -148,6 +150,8 @@ func (w *World) FindObjInstances(roomID int, name string) []ObjState {
RoomID: st.RoomID,
Depleted: st.Depleted,
DepleteTimer: st.DepleteTimer,
+ SharedMax: st.SharedMax,
+ SharedTimer: st.SharedTimer,
})
_ = key
}
@@ -161,21 +165,26 @@ func (w *World) FindObjInstances(roomID int, name string) []ObjState {
}
func wordMatchesObj(lower, defID, objName string) bool {
- // Check against display name (space-separated words)
- for _, word := range strings.Fields(objName) {
- if strings.HasPrefix(strings.ToLower(word), lower) {
- return true
- }
+ if WordPrefixMatch(lower, objName) {
+ return true
}
- // Check defID both as whole and split by underscores
- if strings.HasPrefix(strings.ToLower(defID), lower) {
+ nameWords := strings.Fields(strings.ToLower(objName))
+ inputWords := strings.Fields(strings.ToLower(lower))
+
+ // Check defID as whole (bidirectional)
+ if strings.HasPrefix(strings.ToLower(defID), lower) || strings.HasPrefix(lower, strings.ToLower(defID)) {
return true
}
+ // Check each defID part (split by underscore) against each input word (bidirectional)
for _, part := range strings.Split(defID, "_") {
- if strings.HasPrefix(strings.ToLower(part), lower) {
- return true
+ for _, iw := range inputWords {
+ pl := strings.ToLower(part)
+ if strings.HasPrefix(pl, iw) || strings.HasPrefix(iw, pl) {
+ return true
+ }
}
}
+ _ = nameWords
return false
}
@@ -202,6 +211,30 @@ func (w *World) SetObjWander(roomID int, defID string, rooms []int, interval int
}
}
+func (w *World) SetObjSharedDeplete(roomID int, defID string, max int) {
+ w.mu.Lock()
+ defer w.mu.Unlock()
+ for key, st := range w.objStates {
+ if st.RoomID == roomID && st.DefID == defID && st.SharedMax == 0 {
+ st.SharedMax = max
+ st.SharedTimer = max
+ }
+ _ = key
+ }
+}
+
+func (w *World) AllSharedObjStates() []*ObjState {
+ w.mu.Lock()
+ defer w.mu.Unlock()
+ var out []*ObjState
+ for _, st := range w.objStates {
+ if st.SharedMax > 0 {
+ out = append(out, st)
+ }
+ }
+ return out
+}
+
func (w *World) SetObjDepleted(roomID int, defID string, index int, delay int) {
w.mu.Lock()
defer w.mu.Unlock()
@@ -239,7 +272,7 @@ func (w *World) LoadRoom(id int) (*Room, error) {
room.Spawns = make([]SpawnDef, 0)
}
if room.Mobs == nil {
- room.Mobs = make([]string, 0)
+ room.Mobs = make([]RoomMob, 0)
}
return &room, nil
}
@@ -305,6 +338,14 @@ func (w *World) AddReservedGroundItem(roomID int, itemID string, qty int, owner
func (w *World) AddGroundItem(roomID int, itemID string, qty int) {
w.mu.Lock()
defer w.mu.Unlock()
+ for _, e := range w.groundItems[roomID] {
+ if e.quantity > 0 && strings.EqualFold(e.itemID, itemID) &&
+ (e.reserveTimer <= 0 || e.reservedFor == "") {
+ e.quantity += qty
+ e.despawnTimer = DropDespawnTicks
+ return
+ }
+ }
e := &groundEntry{
itemID: itemID,
quantity: qty,
@@ -395,14 +436,28 @@ func (w *World) SeedGroundItems(roomID int) {
defer w.mu.Unlock()
for _, s := range room.Spawns {
- e := &groundEntry{
- itemID: s.ItemID,
- quantity: s.Quantity,
- isSpawn: true,
- respawnDelay: s.RespawnTicks,
- respawnQty: s.Quantity,
+ merged := false
+ for _, e := range w.groundItems[roomID] {
+ if e.quantity > 0 && strings.EqualFold(e.itemID, s.ItemID) &&
+ (e.reserveTimer <= 0 || e.reservedFor == "") {
+ e.quantity += s.Quantity
+ e.respawnQty += s.Quantity
+ e.isSpawn = true
+ e.respawnDelay = s.RespawnTicks
+ merged = true
+ break
+ }
+ }
+ if !merged {
+ e := &groundEntry{
+ itemID: s.ItemID,
+ quantity: s.Quantity,
+ isSpawn: true,
+ respawnDelay: s.RespawnTicks,
+ respawnQty: s.Quantity,
+ }
+ w.groundItems[roomID] = append(w.groundItems[roomID], e)
}
- w.groundItems[roomID] = append(w.groundItems[roomID], e)
}
}
@@ -412,12 +467,19 @@ func (w *World) Tick() {
for _, entries := range w.groundItems {
for _, e := range entries {
- if e.respawnTimer > 0 {
- e.respawnTimer--
- if e.respawnTimer <= 0 {
- e.quantity = e.respawnQty
+ if e.respawnTimer > 0 {
+ e.respawnTimer--
+ if e.respawnTimer <= 0 {
+ e.quantity = e.respawnQty
+ for _, other := range entries {
+ if other != e && other.quantity > 0 && strings.EqualFold(other.itemID, e.itemID) &&
+ (other.reserveTimer <= 0 || other.reservedFor == "") {
+ e.quantity += other.quantity
+ other.quantity = 0
+ }
}
}
+ }
if e.despawnTimer > 0 {
e.despawnTimer--
if e.despawnTimer <= 0 {
@@ -439,6 +501,9 @@ func (w *World) Tick() {
if st.DepleteTimer <= 0 {
st.Depleted = false
st.JustRespawned = true
+ if st.SharedMax > 0 {
+ st.SharedTimer = st.SharedMax
+ }
}
}
}