aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-06-12 23:52:06 -0400
committerhistoria <[not public]>2026-06-12 23:52:06 -0400
commita19e6b6ab01670a5932308c7bd0e0e5eed8cbcad (patch)
tree80a2e46c3e6cc98bb8b4f708cc403949b14742eb
parentf3a6ae488bbd2128af8356b0da016699c5abb70e (diff)
downloadthehouseoficarus-a19e6b6ab01670a5932308c7bd0e0e5eed8cbcad.tar.gz
feat: firemaking skill implemented. overhauled how ground items and despawn are handled.
-rw-r--r--AGENTS.md40
-rw-r--r--TODO.md25
-rw-r--r--cmd/mud/main.go1
-rw-r--r--data/behaviors/chop_tree.yaml1
-rw-r--r--data/help/burn.yaml25
-rw-r--r--data/help/firemaking.yaml59
-rw-r--r--data/help/stoke.yaml26
-rw-r--r--data/items/ashes.yaml6
-rw-r--r--data/items/firesteel.yaml8
-rw-r--r--data/items/lighter.yaml11
-rw-r--r--data/items/logs.yaml3
-rw-r--r--data/items/magic_logs.yaml3
-rw-r--r--data/items/mahogany_logs.yaml7
-rw-r--r--data/items/maple_logs.yaml5
-rw-r--r--data/items/matches.yaml9
-rw-r--r--data/items/oak_logs.yaml7
-rw-r--r--data/items/redwood_logs.yaml7
-rw-r--r--data/items/teak_logs.yaml5
-rw-r--r--data/items/willow_logs.yaml5
-rw-r--r--data/items/yew_logs.yaml3
-rw-r--r--data/objects/fire.yaml9
-rw-r--r--data/rooms/4.yaml12
-rw-r--r--internal/action/behavior.go1
-rw-r--r--internal/game/action.go4
-rw-r--r--internal/game/action_burn.go515
-rw-r--r--internal/game/action_gather.go19
-rw-r--r--internal/game/action_use.go4
-rw-r--r--internal/game/cmd_attack.go15
-rw-r--r--internal/game/cmd_get.go4
-rw-r--r--internal/game/cmd_look.go65
-rw-r--r--internal/game/cmd_remove.go10
-rw-r--r--internal/game/cmd_wear.go14
-rw-r--r--internal/game/game.go6
-rw-r--r--internal/game/utils.go13
-rw-r--r--internal/object/item.go5
-rw-r--r--internal/object/object.go12
-rw-r--r--internal/player/player.go18
-rw-r--r--internal/world/world.go71
38 files changed, 1000 insertions, 53 deletions
diff --git a/AGENTS.md b/AGENTS.md
index 5de818b..624034b 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -90,6 +90,46 @@ 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
+## Firemaking (`burn` / `stoke`)
+
+Firemaking is implemented as standalone actions (not behavior-YAML-driven) in `internal/game/action_burn.go`. The flow:
+
+| Command | Action | Description |
+|---------|--------|-------------|
+| `burn [item]` | `"burn"` | Start a fire from logs. Checks ground items first, then inventory. Requires a `tool_type: fire` item. |
+| `stoke [item]` | `"stoke"` | Add logs from inventory to an existing fire object for XP. No tool required. |
+
+**Burn flow (3 phases):**
+- Phase 0 (inventory only): Drops 1 log to ground, outputs "You drop the X and begin to make a fire..."
+- Phase 1: "You try burning the X on the ground..." (waits tool_speed ticks)
+- Phase 2: Skill check (firemaking level vs log's `fire_level`). On fail, outputs "You can't seem to get a fire going" and retries at Phase 1. On success, creates a `fire` object (`quality` = log's `burn_ticks`), gives XP, broadcasts to room.
+
+**Stoke flow:**
+- One-time "You begin to tend to the fire."
+- Loops: waits 10 ticks → "You throw X onto the fire", gives XP, adds `burn_ticks/2` to fire `quality`
+- Ends when out of the item: "You're out of X and the fire is roaring."
+
+**Fire objects:**
+- Created dynamically via `World.AddObjInstance()`, tracked by `ObjState.Quality`.
+- Each tick, `FireTick()` decrements quality; at 0, `RemoveObjInstance()` cleans up.
+- `inroom_description` on object YAML replaces the generic "A X is here."
+- `props.quality_description` (with `{quality}` placeholder) is used by `look <fire>`.
+
+**Item fields for firemaking:**
+- `burn_ticks: <int>` — how long the log burns (fire quality). Only items with `burn_ticks > 0` are burnable.
+- `fire_level: <int>` — required firemaking level to burn.
+- `fire_xp: <int>` — XP awarded.
+- `quality: <int>` / `max_quality: <int>` — for tools like lighter (start value / max value).
+- `EquipQuality map[string]int` on Player tracks quality of equipped items with quality.
+
+**Fire tools:** `matches` (stackable, consumed on use), `firesteel` (non-consumable), `lighter` (quality-based, `MaxQuality: 100`, decreases per use).
+
+**Smart defaults:** `burn` auto-selects if only one burnable type on ground OR in inventory. `stoke` auto-selects if only one burnable type in inventory.
+
+**Level-up messages:** All skills that gain XP (gathering, combat, use, firemaking) output `*** You are now level N <skill>! ***` when the player levels up.
+
+**Stoke error messages:** "There's no fire here to stoke" (no fire), "You have no logs to stoke with!" (no burnable items), "Stoke what?" (ambiguous). Fire going out mid-stoke interrupts immediately with "The fire went out!".
+
## 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.
diff --git a/TODO.md b/TODO.md
index 52a4d54..fd81cb6 100644
--- a/TODO.md
+++ b/TODO.md
@@ -149,6 +149,8 @@ Aliases resolve before built-in commands and support argument passthrough (`alia
- [x] `toggle [name]` — 9 character settings
- [x] `description / desc / help [topic] / quit`
- [x] `search <item>` — search bird's nests for loot
+- [x] `burn [item]` — start a fire from logs on ground or in inventory (smart default)
+- [x] `stoke [item]` — add logs to an existing fire for firemaking XP
### Combat
- [x] RSC-based formulas (attack/defense rolls, max hit)
@@ -162,11 +164,13 @@ Aliases resolve before built-in commands and support argument passthrough (`alia
### 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] Woodcutting (9 tree types, axes, shared depletion, bird's nests, exhausted message)
+- [x] Firemaking (9 log tiers, burn/stoke, fire objects, fire tools: matches/firesteel/lighter)
- [x] Tool system (tool_type + tool_speed on items, tool requirement on behaviors)
- [x] Shared drop tables (referenced by multiple behaviors)
- [x] XP drops for all skill actions (combat, gathering, use)
-- [x] 23 skill definitions with RSC XP table (3 implemented: Mining, Fishing, Woodcutting)
+- [x] Level-up messages across all skills ("*** You are now level N <skill>! ***")
+- [x] 23 skill definitions with RSC XP table (4 implemented: Mining, Fishing, Woodcutting, Firemaking)
### World & Data
- [x] Room, item, object, mob, behavior, drop table YAML loaders
@@ -182,6 +186,14 @@ Aliases resolve before built-in commands and support argument passthrough (`alia
- [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
+- [x] Smart default targeting for mine/chop/fish (single type → auto-select)
+- [x] Smart default targeting for attack/kill (same mob type → auto-target)
+- [x] Smart default targeting for burn/stoke (single log type → auto-select)
+- [x] Object inroom_description (replaces generic "A X is here.")
+- [x] Object removal_item (drops item when object instance is removed)
+- [x] Ground item independent despawn timers (no stack merging)
+- [x] Ground item despawn display (option: despawn)
+- [x] Reserve display format: "reserved for <player> <ticks>t"
### Documentation
- [x] WORLDBUILDING.md — comprehensive guide with examples
@@ -207,6 +219,15 @@ See WORLDBUILDING.md for full examples of every data type with explanations.
### Phase 2: Core Skill Chains (breadth over depth)
- [x] Woodcutting — tree objects + axes, shared depletion, bird's nests
+- [x] Firemaking — burn (inventory/ground logs → fire object), stoke (add logs to fire), 3 fire tools
+- [x] Exhausted message on gather behaviors (custom tree-fall message)
+- [x] Depleted message on gather behaviors (custom depleted rock/tree message)
+- [x] Level-up messages across all skills
+- [x] Smart default targeting for gather/combat/burn/stoke
+- [x] Ground item despawn display via new `despawn` option
+- [x] Object `inroom_description` replacing generic "A X is here."
+- [x] Object `removal_item` for generic cleanup drops (fire → ashes)
+- [x] Independent despawn timers per ground item entry
- [ ] 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/cmd/mud/main.go b/cmd/mud/main.go
index 161392c..4f11721 100644
--- a/cmd/mud/main.go
+++ b/cmd/mud/main.go
@@ -46,6 +46,7 @@ func main() {
g.DisconnectTick()
g.WanderTick()
g.WoodcuttingTick()
+ g.FireTick()
g.AdvanceActions()
g.BroadcastRespawns()
return true
diff --git a/data/behaviors/chop_tree.yaml b/data/behaviors/chop_tree.yaml
index 9072e33..494cc1d 100644
--- a/data/behaviors/chop_tree.yaml
+++ b/data/behaviors/chop_tree.yaml
@@ -18,5 +18,6 @@ drops:
depletes: true
message: "You get some logs."
deplete_delay: 80
+exhausted_message: "The tree comes crashing down!"
respawn_message: "A new tree grows in its place."
respawn_broadcast: "A {name} grows back."
diff --git a/data/help/burn.yaml b/data/help/burn.yaml
new file mode 100644
index 0000000..63b6623
--- /dev/null
+++ b/data/help/burn.yaml
@@ -0,0 +1,25 @@
+name: "burn"
+category: "Skills"
+description: |
+ Start a fire from logs.
+
+ Usage: burn [item]
+
+ Attempts to light a fire using burnable logs. Checks for logs on the
+ ground first, then in your inventory.
+
+ Requires a fire starter tool (matches, firesteel, or lighter).
+
+ From inventory:
+ You drop a log to the ground, then try to light it.
+
+ From ground:
+ You attempt to light the log already on the ground.
+
+ After a short wait, your firemaking level is checked against the
+ log's difficulty. On success, a fire object appears in the room
+ that burns for a number of ticks based on the log type. On failure,
+ you may try again.
+
+ Only one fire can burn in a room at a time.
+ See also: help firemaking, help stoke
diff --git a/data/help/firemaking.yaml b/data/help/firemaking.yaml
new file mode 100644
index 0000000..081e10e
--- /dev/null
+++ b/data/help/firemaking.yaml
@@ -0,0 +1,59 @@
+name: "firemaking"
+category: "Skills"
+description: |
+ Start fires from logs and add fuel to keep them burning.
+
+ Usage: burn [item] - Start a fire from logs
+ stoke [item] - Add logs to an existing fire
+
+ Firemaking requires a fire starter — matches, a firesteel, or a
+ lighter — either wielded or in your inventory. Matches are consumed
+ on each use, while a firesteel lasts forever. Lighters use butane
+ (quality) that depletes per use; when empty, they stop working.
+
+ BURN — Starting a Fire
+ ----------------------
+ The burn command checks for burnable logs on the ground first, then
+ in your inventory. If the logs are in your inventory, you drop one
+ to the ground and begin making a fire. After a wait (reduced by
+ tool_speed), the game checks your firemaking level against the
+ log's difficulty:
+
+ Success → You start a fire! The fire object appears in the room
+ with a burn time based on the log used. You gain XP.
+
+ Failure → You can't seem to get the fire going. You may try again
+ (the log stays on the ground).
+
+ Only one fire can burn in a room at a time. Other players are
+ notified when a fire is started.
+
+ STOKE — Adding Fuel
+ -------------------
+ Stoke adds more logs from your inventory to an existing fire. Each
+ log extends the fire's burn time by half its burn_ticks and awards
+ firemaking XP. No skill check is needed to stoke — the XP is
+ guaranteed. Stoking continues automatically until you're out of
+ the item or the fire dies out.
+
+ SMART DEFAULTS
+ --------------
+ Running "burn" or "stoke" without specifying an item auto-selects
+ the target if only one type of burnable is available. If multiple
+ log types are present, you'll be asked "Burn what?" or "Stoke what?"
+
+ LOG TYPES
+ ---------
+ Level Log XP Burn Ticks
+ 1 Normal 40 24
+ 15 Oak 60 28
+ 30 Willow 90 32
+ 35 Teak 105 36
+ 45 Maple 135 40
+ 50 Mahogany 158 44
+ 60 Yew 202 48
+ 75 Magic 303 52
+ 90 Redwood 350 60
+
+ Enable the "depletion" toggle to see how long a fire will burn.
+ Enable the "xpdrops" toggle to see XP gains with each log.
diff --git a/data/help/stoke.yaml b/data/help/stoke.yaml
new file mode 100644
index 0000000..96e53e3
--- /dev/null
+++ b/data/help/stoke.yaml
@@ -0,0 +1,26 @@
+name: "stoke"
+category: "Skills"
+description: |
+ Add logs to an existing fire.
+
+ Usage: stoke [item]
+
+ Adds burnable logs from your inventory to a fire already burning in
+ the current room. No tool is needed to stoke.
+
+ Each log extends the fire's remaining burn time and awards guaranteed
+ firemaking XP (no skill check). Stoking continues automatically until
+ you run out of the item or the fire dies out.
+
+ Fire Type Quality Gained
+ Normal +12 ticks
+ Oak +14 ticks
+ Willow +16 ticks
+ Teak +18 ticks
+ Maple +20 ticks
+ Mahogany +22 ticks
+ Yew +24 ticks
+ Magic +26 ticks
+ Redwood +30 ticks
+
+ See also: help firemaking, help burn
diff --git a/data/items/ashes.yaml b/data/items/ashes.yaml
new file mode 100644
index 0000000..9cbe67f
--- /dev/null
+++ b/data/items/ashes.yaml
@@ -0,0 +1,6 @@
+id: ashes
+name: ashes
+aliases: ["ash"]
+description: "A pile of cold ashes. What was once ablaze is now mere dust."
+value: 1
+stackable: false
diff --git a/data/items/firesteel.yaml b/data/items/firesteel.yaml
new file mode 100644
index 0000000..0cdb354
--- /dev/null
+++ b/data/items/firesteel.yaml
@@ -0,0 +1,8 @@
+id: firesteel
+name: firesteel
+aliases: ["steel", "flint"]
+description: "A rugged firesteel that never wears out."
+value: 20
+stackable: false
+tool_type: fire
+tool_speed: 4
diff --git a/data/items/lighter.yaml b/data/items/lighter.yaml
new file mode 100644
index 0000000..17158f8
--- /dev/null
+++ b/data/items/lighter.yaml
@@ -0,0 +1,11 @@
+id: lighter
+name: lighter
+aliases: ["light"]
+description: "A reliable butane lighter."
+value: 15
+stackable: false
+equip_slot: main_hand
+tool_type: fire
+tool_speed: 4
+quality: 100
+max_quality: 100
diff --git a/data/items/logs.yaml b/data/items/logs.yaml
index 9b148ae..d838348 100644
--- a/data/items/logs.yaml
+++ b/data/items/logs.yaml
@@ -4,3 +4,6 @@ aliases: ["log", "wood"]
description: "Some regular logs, good for firemaking and fletching."
value: 2
stackable: false
+fire_level: 1
+fire_xp: 40
+burn_ticks: 24
diff --git a/data/items/magic_logs.yaml b/data/items/magic_logs.yaml
index baac96b..1cb76a9 100644
--- a/data/items/magic_logs.yaml
+++ b/data/items/magic_logs.yaml
@@ -4,3 +4,6 @@ aliases: ["logs", "magic", "log"]
description: "Some mystical magic logs."
value: 80
stackable: false
+fire_level: 75
+fire_xp: 303
+burn_ticks: 52
diff --git a/data/items/mahogany_logs.yaml b/data/items/mahogany_logs.yaml
index 87ea061..7fc3a7c 100644
--- a/data/items/mahogany_logs.yaml
+++ b/data/items/mahogany_logs.yaml
@@ -1,6 +1,9 @@
id: mahogany_logs
name: mahogany logs
aliases: ["logs", "mahogany", "log"]
-description: "Some beautiful mahogany logs."
-value: 24
+description: "Some rich mahogany logs."
+value: 20
stackable: false
+fire_level: 50
+fire_xp: 158
+burn_ticks: 44
diff --git a/data/items/maple_logs.yaml b/data/items/maple_logs.yaml
index 11f7dcd..d162471 100644
--- a/data/items/maple_logs.yaml
+++ b/data/items/maple_logs.yaml
@@ -1,6 +1,9 @@
id: maple_logs
name: maple logs
aliases: ["logs", "maple", "log"]
-description: "Some sturdy maple logs."
+description: "Some smooth maple logs."
value: 16
stackable: false
+fire_level: 45
+fire_xp: 135
+burn_ticks: 40
diff --git a/data/items/matches.yaml b/data/items/matches.yaml
new file mode 100644
index 0000000..95a8da9
--- /dev/null
+++ b/data/items/matches.yaml
@@ -0,0 +1,9 @@
+id: matches
+name: matches
+aliases: ["match"]
+description: "A small box of matches for starting fires."
+value: 5
+stackable: true
+equip_slot: main_hand
+tool_type: fire
+tool_speed: 4
diff --git a/data/items/oak_logs.yaml b/data/items/oak_logs.yaml
index 24cd0d0..2ed87a8 100644
--- a/data/items/oak_logs.yaml
+++ b/data/items/oak_logs.yaml
@@ -1,6 +1,9 @@
-id: "oak_logs"
-name: "oak logs"
+id: oak_logs
+name: oak logs
aliases: ["logs", "oak", "log"]
description: "Some sturdy oak logs."
value: 4
stackable: false
+fire_level: 15
+fire_xp: 60
+burn_ticks: 28
diff --git a/data/items/redwood_logs.yaml b/data/items/redwood_logs.yaml
index 7a2c66b..8e492c7 100644
--- a/data/items/redwood_logs.yaml
+++ b/data/items/redwood_logs.yaml
@@ -1,6 +1,9 @@
id: redwood_logs
name: redwood logs
aliases: ["logs", "redwood", "log"]
-description: "Some massive redwood logs."
-value: 120
+description: "Some rare redwood logs."
+value: 100
stackable: false
+fire_level: 90
+fire_xp: 350
+burn_ticks: 60
diff --git a/data/items/teak_logs.yaml b/data/items/teak_logs.yaml
index ac9b736..49be224 100644
--- a/data/items/teak_logs.yaml
+++ b/data/items/teak_logs.yaml
@@ -1,6 +1,9 @@
id: teak_logs
name: teak logs
aliases: ["logs", "teak", "log"]
-description: "Some fine teak logs."
+description: "Some solid teak logs."
value: 12
stackable: false
+fire_level: 35
+fire_xp: 105
+burn_ticks: 36
diff --git a/data/items/willow_logs.yaml b/data/items/willow_logs.yaml
index 1669e66..f337168 100644
--- a/data/items/willow_logs.yaml
+++ b/data/items/willow_logs.yaml
@@ -1,6 +1,9 @@
id: willow_logs
name: willow logs
aliases: ["logs", "willow", "log"]
-description: "Some weeping willow logs."
+description: "Some flexible willow logs."
value: 8
stackable: false
+fire_level: 30
+fire_xp: 90
+burn_ticks: 32
diff --git a/data/items/yew_logs.yaml b/data/items/yew_logs.yaml
index 5f1ed40..6e120b1 100644
--- a/data/items/yew_logs.yaml
+++ b/data/items/yew_logs.yaml
@@ -4,3 +4,6 @@ aliases: ["logs", "yew", "log"]
description: "Some high-quality yew logs."
value: 40
stackable: false
+fire_level: 60
+fire_xp: 202
+burn_ticks: 48
diff --git a/data/objects/fire.yaml b/data/objects/fire.yaml
new file mode 100644
index 0000000..58ccc92
--- /dev/null
+++ b/data/objects/fire.yaml
@@ -0,0 +1,9 @@
+id: fire
+name: fire
+behavior: ""
+hidden: false
+inroom_description: "A fire is burning warmly."
+removal_item: ashes
+props:
+ description: "A crackling fire."
+ quality_description: "Burning for {quality} more ticks."
diff --git a/data/rooms/4.yaml b/data/rooms/4.yaml
index f1d7d08..74bee96 100644
--- a/data/rooms/4.yaml
+++ b/data/rooms/4.yaml
@@ -8,7 +8,19 @@ exits:
west: 2
objects:
- id: tree
+ - id: tree
+ - id: tree
+ - id: tree
spawns:
- item_id: bronze_axe
quantity: 1
respawn_ticks: 60
+ - item_id: matches
+ quantity: 100
+ respawn_ticks: 120
+ - item_id: firesteel
+ quantity: 1
+ respawn_ticks: 120
+ - item_id: lighter
+ quantity: 1
+ respawn_ticks: 120
diff --git a/internal/action/behavior.go b/internal/action/behavior.go
index 4ffd7e5..7abe0ae 100644
--- a/internal/action/behavior.go
+++ b/internal/action/behavior.go
@@ -10,6 +10,7 @@ type GatherConfig struct {
Success SuccessFormula `yaml:"success"`
GatherMsg string `yaml:"gather_message"`
DepletedMessage string `yaml:"depleted_message"`
+ ExhaustedMessage string `yaml:"exhausted_message"`
FailMsg string `yaml:"fail_message"`
Drops []DropEntry `yaml:"drops"`
DepleteDelay int `yaml:"deplete_delay"`
diff --git a/internal/game/action.go b/internal/game/action.go
index 018c4fb..7d2549f 100644
--- a/internal/game/action.go
+++ b/internal/game/action.go
@@ -190,6 +190,10 @@ func (g *Game) AdvanceActions() {
g.advanceGather(sess, p)
case "use":
g.advanceUse(sess, p)
+ case "burn":
+ g.advanceBurn(sess, p)
+ case "stoke":
+ g.advanceStoke(sess, p)
}
}
}
diff --git a/internal/game/action_burn.go b/internal/game/action_burn.go
new file mode 100644
index 0000000..d57307a
--- /dev/null
+++ b/internal/game/action_burn.go
@@ -0,0 +1,515 @@
+package game
+
+import (
+ "fmt"
+ "math/rand"
+
+ "thirdcollapse/internal/action"
+ "thirdcollapse/internal/net"
+ "thirdcollapse/internal/object"
+ "thirdcollapse/internal/player"
+)
+
+func (g *Game) doBurn(sess *net.Session, p *player.Player, input string) {
+ if p.Action != nil {
+ g.CancelAction(p)
+ }
+
+ itemID, fromGround, ambiguous := g.resolveBurnTarget(p, input)
+ if ambiguous {
+ sess.WriteLine("Burn what?")
+ return
+ }
+ if itemID == "" {
+ sess.WriteLine("Burn what?")
+ return
+ }
+
+ g.startBurn(sess, p, itemID, fromGround)
+}
+
+func (g *Game) doStoke(sess *net.Session, p *player.Player, input string) {
+ if p.Action != nil {
+ g.CancelAction(p)
+ }
+
+ if !g.hasFireObject(p.RoomID) {
+ sess.WriteLine("There's no fire here to stoke.")
+ return
+ }
+
+ itemID, ambiguous := g.resolveStokeTarget(p, input)
+ if ambiguous {
+ sess.WriteLine("Stoke what?")
+ return
+ }
+ if itemID == "" {
+ sess.WriteLine("You have no logs to stoke with!")
+ return
+ }
+
+ g.startStoke(sess, p, itemID)
+}
+
+func (g *Game) startBurn(sess *net.Session, p *player.Player, itemID string, fromGround bool) {
+ if p.FirstFreeSlot() == -1 && !fromGround {
+ sess.WriteLine("Your inventory is too full!")
+ return
+ }
+
+ if g.hasFireObject(p.RoomID) {
+ sess.WriteLine("There is already a fire burning here.")
+ return
+ }
+
+ logDef, err := g.ItemStore.Load(itemID)
+ if err != nil {
+ sess.WriteLine("Something is wrong with that item.")
+ return
+ }
+
+ if logDef.BurnTicks <= 0 {
+ sess.WriteLine(fmt.Sprintf("You can't burn the %s.", logDef.Name))
+ return
+ }
+
+ skillLevel := p.Level(player.Firemaking)
+ if logDef.FireLevel > 0 && skillLevel < logDef.FireLevel {
+ sess.WriteLine(fmt.Sprintf("You need level %d firemaking to burn %s.", logDef.FireLevel, logDef.Name))
+ return
+ }
+
+ toolSpeed, ok := g.findFireTool(sess, p)
+ if !ok {
+ return
+ }
+
+ if !fromGround {
+ if !p.HasItem(itemID) {
+ sess.WriteLine(fmt.Sprintf("You don't have any %s.", logDef.Name))
+ return
+ }
+ } else {
+ ground := g.World.GroundItems(p.RoomID)
+ if ground[itemID] <= 0 {
+ sess.WriteLine(fmt.Sprintf("There are no %s on the ground here.", logDef.Name))
+ return
+ }
+ }
+
+ phase := 1
+ if !fromGround {
+ phase = 0
+ }
+
+ p.Action = &action.Action{
+ Type: "burn",
+ TargetID: itemID,
+ TargetName: logDef.Name,
+ WaitLeft: 0,
+ Data: map[string]any{
+ "item_id": itemID,
+ "tool_speed": toolSpeed,
+ "level": logDef.FireLevel,
+ "xp": logDef.FireXP,
+ "burn_ticks": logDef.BurnTicks,
+ "phase": phase,
+ },
+ }
+}
+
+func (g *Game) startStoke(sess *net.Session, p *player.Player, itemID string) {
+ fireKey := g.findFireObjectKey(p.RoomID)
+ if fireKey == "" {
+ sess.WriteLine("There's no fire here to stoke.")
+ return
+ }
+
+ logDef, err := g.ItemStore.Load(itemID)
+ if err != nil || logDef.BurnTicks <= 0 {
+ sess.WriteLine(fmt.Sprintf("You can't stoke the fire with that."))
+ return
+ }
+
+ if !p.HasItem(itemID) {
+ sess.WriteLine(fmt.Sprintf("You don't have any %s.", logDef.Name))
+ return
+ }
+
+ sess.WriteLine("You begin to tend to the fire.")
+
+ p.Action = &action.Action{
+ Type: "stoke",
+ TargetID: itemID,
+ TargetName: logDef.Name,
+ WaitLeft: 10,
+ Data: map[string]any{
+ "item_id": itemID,
+ "fire_key": fireKey,
+ "burn_ticks": logDef.BurnTicks,
+ "xp": logDef.FireXP,
+ },
+ }
+}
+
+func (g *Game) advanceBurn(sess *net.Session, p *player.Player) {
+ data := p.Action.Data
+ itemID := data["item_id"].(string)
+ toolSpeed := data["tool_speed"].(int)
+ phase := data["phase"].(int)
+
+ logDef, err := g.ItemStore.Load(itemID)
+ if err != nil {
+ g.CancelAction(p)
+ return
+ }
+
+ if phase == 0 {
+ if !p.HasItem(itemID) {
+ sess.WriteLine(fmt.Sprintf("You're out of %s.", logDef.Name))
+ g.CancelAction(p)
+ return
+ }
+ p.RemoveItem(itemID, 1)
+ g.World.AddGroundItem(p.RoomID, itemID, 1)
+ g.AccountStore.SaveCharacter(p)
+
+ sess.WriteLine(fmt.Sprintf("You drop the %s and begin to make a fire...", logDef.Name))
+ g.broadcastDrop(p, logDef.Name)
+
+ data["phase"] = 1
+ p.Action.WaitLeft = toolSpeed
+ return
+ }
+
+ ground := g.World.GroundItems(p.RoomID)
+ if ground[itemID] <= 0 {
+ sess.WriteLine("The thing you were trying to burn is gone.")
+ g.CancelAction(p)
+ return
+ }
+
+ if phase == 1 {
+ sess.WriteLine(fmt.Sprintf("You try burning the %s on the ground...", logDef.Name))
+ data["phase"] = 2
+ p.Action.WaitLeft = toolSpeed
+ return
+ }
+
+ if phase == 2 {
+ skillLevel := p.Level(player.Firemaking)
+ chance := 0.5 + float64(skillLevel-logDef.FireLevel)*0.02
+ if chance > 0.95 {
+ chance = 0.95
+ }
+ if chance < 0.05 {
+ chance = 0.05
+ }
+
+ if rand.Float64() < chance {
+ g.World.RemoveGroundItem(p.RoomID, itemID, 1)
+ g.World.AddObjInstance(p.RoomID, "fire", logDef.BurnTicks)
+
+ xp := logDef.FireXP
+ if xp > 0 {
+ if newLevel := p.AddSkillXP(player.Firemaking, xp); newLevel > 0 {
+ sess.WriteLine(fmt.Sprintf("*** You are now level %d firemaking! ***", newLevel))
+ }
+ }
+
+ g.AccountStore.SaveCharacter(p)
+ g.CancelAction(p)
+
+ msg := fmt.Sprintf("You manage to get a fire going!")
+ if xp > 0 && p.OptionBool("xpdrops") {
+ msg += fmt.Sprintf(" (+%dxp %s)", xp, player.SkillAbbr[player.Firemaking])
+ }
+ sess.WriteLine(msg)
+
+ for _, other := range g.Hub.PlayersInRoom(p.RoomID) {
+ if other != sess {
+ other.WriteLine(fmt.Sprintf("\n%s started a fire!", p.Name))
+ }
+ }
+ return
+ }
+
+ sess.WriteLine("You can't seem to get a fire going.")
+ data["phase"] = 1
+ p.Action.WaitLeft = toolSpeed
+ }
+}
+
+func (g *Game) advanceStoke(sess *net.Session, p *player.Player) {
+ data := p.Action.Data
+ itemID := data["item_id"].(string)
+ fireKey := data["fire_key"].(string)
+ burnTicks := data["burn_ticks"].(int)
+ xp := data["xp"].(int)
+
+ st := g.World.GetObjStateByKey(fireKey)
+ if st == nil || st.Quality <= 0 {
+ sess.WriteLine("The fire went out!")
+ g.CancelAction(p)
+ return
+ }
+
+ logDef, _ := g.ItemStore.Load(itemID)
+ name := itemID
+ if logDef != nil {
+ name = logDef.Name
+ }
+
+ if !p.HasItem(itemID) {
+ sess.WriteLine(fmt.Sprintf("You're out of %s and the fire is roaring.", name))
+ g.CancelAction(p)
+ return
+ }
+
+ p.RemoveItem(itemID, 1)
+ st.Quality += burnTicks / 2
+
+ if xp > 0 {
+ if newLevel := p.AddSkillXP(player.Firemaking, xp); newLevel > 0 {
+ sess.WriteLine(fmt.Sprintf("*** You are now level %d firemaking! ***", newLevel))
+ }
+ }
+
+ g.AccountStore.SaveCharacter(p)
+
+ msg := fmt.Sprintf("You throw %s onto the fire.", name)
+ if xp > 0 && p.OptionBool("xpdrops") {
+ msg += fmt.Sprintf(" (+%dxp %s)", xp, player.SkillAbbr[player.Firemaking])
+ }
+ sess.WriteLine(msg)
+
+ if !p.HasItem(itemID) {
+ sess.WriteLine(fmt.Sprintf("You're out of %s and the fire is roaring.", name))
+ g.CancelAction(p)
+ return
+ }
+
+ p.Action.WaitLeft = 10
+}
+
+func (g *Game) findFireTool(sess *net.Session, p *player.Player) (toolSpeed int, ok bool) {
+ toolSpeed = -1
+ var toolItemID string
+
+ if itemID, equipped := p.Equipment[object.SlotMainHand]; equipped {
+ def, err := g.ItemStore.Load(itemID)
+ if err == nil && def.ToolType == "fire" {
+ toolSpeed = def.ToolSpeed
+ toolItemID = itemID
+ }
+ }
+
+ if toolSpeed < 0 {
+ for _, slot := range p.Inventory {
+ if slot == nil || slot.Quantity <= 0 {
+ continue
+ }
+ def, err := g.ItemStore.Load(slot.ItemID)
+ if err != nil || def.ToolType != "fire" {
+ continue
+ }
+ toolSpeed = def.ToolSpeed
+ toolItemID = slot.ItemID
+ break
+ }
+ }
+
+ if toolSpeed < 0 {
+ sess.WriteLine("You need a fire starter to do that.")
+ return 0, false
+ }
+
+ if !g.consumeFireTool(sess, p, toolItemID) {
+ return 0, false
+ }
+
+ return toolSpeed, true
+}
+
+func (g *Game) consumeFireTool(sess *net.Session, p *player.Player, toolItemID string) bool {
+ def, _ := g.ItemStore.Load(toolItemID)
+
+ if def != nil && def.MaxQuality > 0 {
+ for _, slot := range p.Inventory {
+ if slot != nil && slot.ItemID == toolItemID && slot.MaxQuality > 0 {
+ if slot.Quality <= 0 {
+ sess.WriteLine("The lighter is out of butane!")
+ return false
+ }
+ slot.Quality--
+ g.AccountStore.SaveCharacter(p)
+ return true
+ }
+ }
+ if q, ok := p.EquipQuality[toolItemID]; ok {
+ if q <= 0 {
+ sess.WriteLine("The lighter is out of butane!")
+ return false
+ }
+ p.EquipQuality[toolItemID] = q - 1
+ g.AccountStore.SaveCharacter(p)
+ return true
+ }
+ }
+
+ if def != nil && def.Stackable {
+ if p.HasItem(toolItemID) {
+ p.RemoveItem(toolItemID, 1)
+ g.AccountStore.SaveCharacter(p)
+ return true
+ }
+ for slot, itemID := range p.Equipment {
+ if itemID == toolItemID {
+ delete(p.Equipment, slot)
+ g.AccountStore.SaveCharacter(p)
+ return true
+ }
+ }
+ sess.WriteLine("You need a fire starter to do that.")
+ return false
+ }
+
+ return true
+}
+
+func (g *Game) findFireObjectKey(roomID int) string {
+ objs := g.World.AllObjInstances(roomID)
+ for _, st := range objs {
+ if st.DefID == "fire" && st.Quality > 0 {
+ return g.World.ObjStateKey(st.RoomID, st.DefID, st.Index)
+ }
+ }
+ return ""
+}
+
+func (g *Game) hasFireObject(roomID int) bool {
+ return g.findFireObjectKey(roomID) != ""
+}
+
+func (g *Game) broadcastDrop(p *player.Player, itemName string) {
+ for _, other := range g.Hub.PlayersInRoom(p.RoomID) {
+ op, ok := other.Player.(*player.Player)
+ if ok && op.Name != p.Name {
+ other.WriteLine(fmt.Sprintf("\n%s drops a %s.", p.Name, itemName))
+ }
+ }
+}
+
+func (g *Game) FireTick() {
+ for _, st := range g.World.AllFireStates() {
+ st.Quality--
+ if st.Quality <= 0 {
+ g.World.RemoveObjInstance(st.RoomID, st.DefID)
+ g.cancelStokers(st.RoomID)
+ if def, err := g.ObjectStore.Load(st.DefID); err == nil && def.RemovalItem != "" {
+ g.World.AddGroundItem(st.RoomID, def.RemovalItem, 1)
+ }
+ }
+ }
+}
+
+func (g *Game) cancelStokers(roomID int) {
+ for _, other := range g.Hub.PlayersInRoom(roomID) {
+ op, ok := other.Player.(*player.Player)
+ if ok && op.Action != nil && op.Action.Type == "stoke" {
+ other.WriteLine("The fire went out!")
+ g.CancelAction(op)
+ }
+ }
+}
+
+func (g *Game) resolveBurnTarget(p *player.Player, input string) (itemID string, fromGround bool, ambiguous bool) {
+ if input != "" {
+ return g.resolveNamedBurnTarget(p, input)
+ }
+ return g.resolveDefaultBurnTarget(p)
+}
+
+func (g *Game) collectBurnableGround(roomID int, name string) []string {
+ var out []string
+ for itemID := range g.World.GroundItems(roomID) {
+ def, err := g.ItemStore.Load(itemID)
+ if err != nil || def.BurnTicks <= 0 {
+ continue
+ }
+ if name == "" || def.MatchesName(name) {
+ out = append(out, itemID)
+ }
+ }
+ return out
+}
+
+func (g *Game) collectBurnableInv(p *player.Player, name string) []string {
+ var out []string
+ seen := make(map[string]bool)
+ for _, slot := range p.Inventory {
+ if slot == nil || slot.Quantity <= 0 {
+ continue
+ }
+ def, err := g.ItemStore.Load(slot.ItemID)
+ if err != nil || def.BurnTicks <= 0 || seen[slot.ItemID] {
+ continue
+ }
+ if name == "" || def.MatchesName(name) {
+ seen[slot.ItemID] = true
+ out = append(out, slot.ItemID)
+ }
+ }
+ return out
+}
+
+func (g *Game) resolveNamedBurnTarget(p *player.Player, input string) (itemID string, fromGround bool, ambiguous bool) {
+ groundMatches := g.collectBurnableGround(p.RoomID, input)
+ if len(groundMatches) > 1 {
+ return "", false, true
+ }
+ if len(groundMatches) == 1 {
+ return groundMatches[0], true, false
+ }
+
+ invMatches := g.collectBurnableInv(p, input)
+ if len(invMatches) > 1 {
+ return "", false, true
+ }
+ if len(invMatches) == 1 {
+ return invMatches[0], false, false
+ }
+
+ return "", false, false
+}
+
+func (g *Game) resolveDefaultBurnTarget(p *player.Player) (itemID string, fromGround bool, ambiguous bool) {
+ groundBurnable := g.collectBurnableGround(p.RoomID, "")
+ if len(groundBurnable) == 1 {
+ return groundBurnable[0], true, false
+ }
+ if len(groundBurnable) > 1 {
+ return "", false, true
+ }
+
+ invBurnable := g.collectBurnableInv(p, "")
+ if len(invBurnable) == 1 {
+ return invBurnable[0], false, false
+ }
+ if len(invBurnable) > 1 {
+ return "", false, true
+ }
+
+ return "", false, false
+}
+
+func (g *Game) resolveStokeTarget(p *player.Player, input string) (string, bool) {
+ invBurnable := g.collectBurnableInv(p, input)
+ if len(invBurnable) == 1 {
+ return invBurnable[0], false
+ }
+ if len(invBurnable) > 1 {
+ return "", true
+ }
+ return "", false
+}
diff --git a/internal/game/action_gather.go b/internal/game/action_gather.go
index 2ca2e82..7e53e87 100644
--- a/internal/game/action_gather.go
+++ b/internal/game/action_gather.go
@@ -214,7 +214,9 @@ func (g *Game) advanceGather(sess *net.Session, p *player.Player) {
xp = cfg.XP
}
if xp > 0 {
- p.AddXP(player.SkillName(cfg.Skill), xp)
+ if newLevel := p.AddSkillXP(player.SkillName(cfg.Skill), xp); newLevel > 0 {
+ sess.WriteLine(fmt.Sprintf("*** You are now level %d %s! ***", newLevel, cfg.Skill))
+ }
}
g.AccountStore.SaveCharacter(p)
@@ -232,7 +234,7 @@ func (g *Game) advanceGather(sess *net.Session, p *player.Player) {
if shared {
st := g.World.GetObjStateByKey(instanceKey)
if st != nil && st.SharedTimer <= 0 {
- g.depleteSharedTree(sess, p, st, cfg.DepleteDelay, instanceKey)
+ g.depleteSharedTree(sess, p, st, cfg, instanceKey)
return
}
}
@@ -301,12 +303,17 @@ func filterDropsByLevel(drops []action.DropEntry, level int) []action.DropEntry
return eligible
}
-func (g *Game) depleteSharedTree(sess *net.Session, p *player.Player, st *world.ObjState, respawnTicks int, instanceKey string) {
+func (g *Game) depleteSharedTree(sess *net.Session, p *player.Player, st *world.ObjState, cfg *action.GatherConfig, instanceKey string) {
st.Depleted = true
- st.DepleteTimer = respawnTicks
+ st.DepleteTimer = cfg.DepleteDelay
st.SharedTimer = 0
- sess.WriteLine(fmt.Sprintf("\nThe %s falls to the ground!", p.Action.TargetName))
+ msg := cfg.ExhaustedMessage
+ if msg == "" {
+ msg = fmt.Sprintf("The %s falls to the ground!", p.Action.TargetName)
+ }
+
+ sess.WriteLine(fmt.Sprintf("\n%s", msg))
for _, other := range g.Hub.PlayersInRoom(p.RoomID) {
if other == sess {
@@ -317,7 +324,7 @@ func (g *Game) depleteSharedTree(sess *net.Session, p *player.Player, st *world.
continue
}
if op.Action.Data["instance_key"] == instanceKey {
- other.WriteLine(fmt.Sprintf("\nThe %s falls to the ground!", p.Action.TargetName))
+ other.WriteLine(fmt.Sprintf("\n%s", msg))
g.CancelAction(op)
}
}
diff --git a/internal/game/action_use.go b/internal/game/action_use.go
index 69d29c1..497ceac 100644
--- a/internal/game/action_use.go
+++ b/internal/game/action_use.go
@@ -96,7 +96,9 @@ func (g *Game) advanceUse(sess *net.Session, p *player.Player) {
p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: cfg.Reward.ItemID, Quantity: qty})
if cfg.XP > 0 {
- p.AddXP(player.SkillName(cfg.Skill), cfg.XP)
+ if newLevel := p.AddSkillXP(player.SkillName(cfg.Skill), cfg.XP); newLevel > 0 {
+ sess.WriteLine(fmt.Sprintf("*** You are now level %d %s! ***", newLevel, cfg.Skill))
+ }
}
g.AccountStore.SaveCharacter(p)
diff --git a/internal/game/cmd_attack.go b/internal/game/cmd_attack.go
index f6ccf0c..2f79c16 100644
--- a/internal/game/cmd_attack.go
+++ b/internal/game/cmd_attack.go
@@ -195,7 +195,11 @@ func (g *Game) playerAttack(sess *net.Session, p *player.Player, mob *world.MobI
if mob.HP < mob.MaxHP && mob.HP > 0 {
mob.StartRegen()
}
- gains := g.awardCombatXP(p, dmg)
+ gains, leveled := g.awardCombatXP(p, dmg)
+
+ for _, skill := range leveled {
+ sess.WriteLine(fmt.Sprintf("*** You are now level %d %s! ***", p.Level(skill), skill))
+ }
mobName := mobDisplayName(mob, true)
attacker := mob.Name
@@ -348,9 +352,10 @@ func (g *Game) endCombat(sess *net.Session, p *player.Player, mob *world.MobInst
}
}
-func (g *Game) awardCombatXP(p *player.Player, dmg int) []xpGain {
+func (g *Game) awardCombatXP(p *player.Player, dmg int) ([]xpGain, []player.SkillName) {
baseXP := dmg * 4
var gains []xpGain
+ var leveledUp []player.SkillName
switch p.AttackStyle {
case player.Accurate:
@@ -370,10 +375,12 @@ func (g *Game) awardCombatXP(p *player.Player, dmg int) []xpGain {
}
for _, gain := range gains {
- p.AddXP(player.SkillName(gain.Skill), gain.XP)
+ if newLevel := p.AddSkillXP(player.SkillName(gain.Skill), gain.XP); newLevel > 0 {
+ leveledUp = append(leveledUp, player.SkillName(gain.Skill))
+ }
}
g.AccountStore.SaveCharacter(p)
- return gains
+ return gains, leveledUp
}
func (g *Game) dropItemsOnDeath(p *player.Player) {
diff --git a/internal/game/cmd_get.go b/internal/game/cmd_get.go
index 70b458e..f4c7426 100644
--- a/internal/game/cmd_get.go
+++ b/internal/game/cmd_get.go
@@ -96,7 +96,7 @@ func (g *Game) doGet(sess *net.Session, input string) {
return
}
_ = removed
- p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: itemID, Quantity: qty})
+ p.SetInvSlot(freeSlot, g.newInvSlot(itemID, qty))
g.AccountStore.SaveCharacter(p)
if qty == 1 {
sess.WriteLine(fmt.Sprintf("You pick up a %s.", def.Name))
@@ -329,7 +329,7 @@ func (g *Game) doGetAll(sess *net.Session) {
if !ok {
break
}
- p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: itemID, Quantity: qty})
+ p.SetInvSlot(freeSlot, g.newInvSlot(itemID, qty))
name := itemID
if def != nil {
name = def.Name
diff --git a/internal/game/cmd_look.go b/internal/game/cmd_look.go
index 6d19023..e0e98fe 100644
--- a/internal/game/cmd_look.go
+++ b/internal/game/cmd_look.go
@@ -96,6 +96,7 @@ func (g *Game) doLook(sess *net.Session) {
sharedMax int
sharedCur int
respawnIn int
+ quality int
}
var instances []instInfo
for i := 0; i < count; i++ {
@@ -109,6 +110,7 @@ func (g *Game) doLook(sess *net.Session) {
sharedMax: st.SharedMax,
sharedCur: st.SharedTimer,
respawnIn: st.DepleteTimer,
+ quality: st.Quality,
})
}
multi := len(instances) > 1
@@ -126,19 +128,35 @@ func (g *Game) doLook(sess *net.Session) {
}
}
+ roomDesc := def.InRoomDescription
+
if len(freshIdxs) > 0 {
+ var line string
+ if roomDesc != "" {
+ line = roomDesc
+ } else if len(freshIdxs) == 1 {
+ line = "A " + def.Name + " is here."
+ } else {
+ line = fmt.Sprintf("%d %ss are here.", len(freshIdxs), def.Name)
+ }
var suffix string
if multi && (len(timed) > 0 || len(depleted) > 0) {
suffix = fmt.Sprintf(" [%s]", intsJoin(freshIdxs))
}
- if len(freshIdxs) == 1 {
- sess.WriteLine(fmt.Sprintf(" A %s is here.%s", def.Name, suffix))
- } else {
- sess.WriteLine(fmt.Sprintf(" %d %ss are here.%s", len(freshIdxs), def.Name, suffix))
+ qualityTimer := ""
+ if showTimers && len(instances) > 0 && instances[0].quality > 0 {
+ qualityTimer = fmt.Sprintf(" (Burning for %d more ticks)", instances[0].quality)
}
+ sess.WriteLine(fmt.Sprintf(" %s%s%s", line, suffix, qualityTimer))
}
for _, ins := range timed {
+ var line string
+ if roomDesc != "" {
+ line = roomDesc
+ } else {
+ line = "A " + def.Name + " is here."
+ }
tag := ""
if multi {
tag = fmt.Sprintf(" [%d]", ins.idx)
@@ -147,10 +165,16 @@ func (g *Game) doLook(sess *net.Session) {
if showTimers {
timer = fmt.Sprintf(" (despawn: %d/%d)", ins.sharedCur, ins.sharedMax)
}
- sess.WriteLine(fmt.Sprintf(" A %s is here.%s%s", def.Name, tag, timer))
+ sess.WriteLine(fmt.Sprintf(" %s%s%s", line, tag, timer))
}
for _, ins := range depleted {
+ var line string
+ if roomDesc != "" {
+ line = roomDesc
+ } else {
+ line = "A " + def.Name + " is here."
+ }
tag := ""
if multi {
tag = fmt.Sprintf(" [%d]", ins.idx)
@@ -159,7 +183,7 @@ func (g *Game) doLook(sess *net.Session) {
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))
+ sess.WriteLine(fmt.Sprintf(" %s (depleted)%s%s", line, tag, timer))
}
}
}
@@ -203,14 +227,21 @@ func (g *Game) doLook(sess *net.Session) {
if len(prefix) > maxPrefix {
maxPrefix = len(prefix)
}
- reserved := ""
+ var parts []string
if info.ReservedFor != "" {
if p.OptionBool("reserve") {
- reserved = fmt.Sprintf(" (reserved for %s for %d ticks)", info.ReservedFor, info.ReserveTimer)
+ parts = append(parts, fmt.Sprintf("reserved for %s %dt", info.ReservedFor, info.ReserveTimer))
} else {
- reserved = " (reserved)"
+ parts = append(parts, "reserved")
}
}
+ if p.OptionBool("despawn") && !info.IsSpawn {
+ parts = append(parts, fmt.Sprintf("despawns %dt", info.DespawnTimer))
+ }
+ reserved := ""
+ if len(parts) > 0 {
+ reserved = fmt.Sprintf(" (%s)", strings.Join(parts, ", "))
+ }
lines = append(lines, groundLine{prefix, reserved})
}
@@ -362,6 +393,14 @@ func (g *Game) doLookTarget(sess *net.Session, input string) {
}
}
}
+ for _, ist := range instances {
+ if ist.Quality > 0 {
+ if qdesc, ok := def.Props["quality_description"].(string); ok && qdesc != "" {
+ qdesc = strings.ReplaceAll(qdesc, "{quality}", fmt.Sprintf("%d", ist.Quality))
+ sess.WriteLine(fmt.Sprintf(" %s", qdesc))
+ }
+ }
+ }
return
}
@@ -389,12 +428,16 @@ func (g *Game) doLookTarget(sess *net.Session, input string) {
if err != nil || !def.MatchesName(input) {
continue
}
- sess.WriteLines(
+ lines := []string{
"",
def.Name,
fmt.Sprintf(" %s", def.Description),
fmt.Sprintf(" Value: %d credits", def.Value),
- )
+ }
+ if slot.MaxQuality > 0 {
+ lines = append(lines, fmt.Sprintf(" Has %d units of butane left.", slot.Quality))
+ }
+ sess.WriteLines(lines...)
return
}
diff --git a/internal/game/cmd_remove.go b/internal/game/cmd_remove.go
index 9f0cbe8..f511ec4 100644
--- a/internal/game/cmd_remove.go
+++ b/internal/game/cmd_remove.go
@@ -56,7 +56,15 @@ func (g *Game) doRemove(sess *net.Session, input string) {
}
delete(p.Equipment, foundSlot)
- p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: foundItemID, Quantity: 1})
+ invSlot := &player.InventorySlot{ItemID: foundItemID, Quantity: 1}
+ if q, ok := p.EquipQuality[foundItemID]; ok {
+ invSlot.Quality = q
+ if def, _ := g.ItemStore.Load(foundItemID); def != nil {
+ invSlot.MaxQuality = def.MaxQuality
+ }
+ delete(p.EquipQuality, foundItemID)
+ }
+ p.SetInvSlot(freeSlot, invSlot)
g.AccountStore.SaveCharacter(p)
name := foundItemID
diff --git a/internal/game/cmd_wear.go b/internal/game/cmd_wear.go
index c4b5d12..4554dfc 100644
--- a/internal/game/cmd_wear.go
+++ b/internal/game/cmd_wear.go
@@ -62,6 +62,13 @@ func (g *Game) doWear(sess *net.Session, input string) {
p.SetInvSlot(match.Slot, nil)
}
+ if slot.Quality > 0 && slot.MaxQuality > 0 {
+ if p.EquipQuality == nil {
+ p.EquipQuality = make(map[string]int)
+ }
+ p.EquipQuality[slot.ItemID] = slot.Quality
+ }
+
p.Equipment[def.EquipSlot] = slot.ItemID
g.AccountStore.SaveCharacter(p)
@@ -127,6 +134,13 @@ func (g *Game) doWearAll(sess *net.Session) {
p.SetInvSlot(best.invSlot, nil)
}
+ if invSlot != nil && invSlot.Quality > 0 && invSlot.MaxQuality > 0 {
+ if p.EquipQuality == nil {
+ p.EquipQuality = make(map[string]int)
+ }
+ p.EquipQuality[best.itemID] = invSlot.Quality
+ }
+
p.Equipment[eqSlot] = best.itemID
def, _ := g.ItemStore.Load(best.itemID)
name := best.itemID
diff --git a/internal/game/game.go b/internal/game/game.go
index cc187a7..21dee76 100644
--- a/internal/game/game.go
+++ b/internal/game/game.go
@@ -234,6 +234,12 @@ func (g *Game) handleGameCommand(sess *net.Session, input string) {
g.StartAction(sess, "talk", strings.Join(args, " "))
return
}
+ case "burn":
+ g.doBurn(sess, p, strings.Join(args, " "))
+ return
+ case "stoke":
+ g.doStoke(sess, p, strings.Join(args, " "))
+ return
case "alias":
g.doAlias(sess, args)
return
diff --git a/internal/game/utils.go b/internal/game/utils.go
index c78889d..969eba4 100644
--- a/internal/game/utils.go
+++ b/internal/game/utils.go
@@ -121,6 +121,19 @@ func actionDesc(p *player.Player) string {
return "talking to " + target
case "use":
return "using a " + target
+ case "burn":
+ return "trying to start a fire"
+ case "stoke":
+ return "tending to a fire"
}
return ""
}
+
+func (g *Game) newInvSlot(itemID string, qty int) *player.InventorySlot {
+ slot := &player.InventorySlot{ItemID: itemID, Quantity: qty}
+ if def, err := g.ItemStore.Load(itemID); err == nil && def.MaxQuality > 0 {
+ slot.Quality = def.Quality
+ slot.MaxQuality = def.MaxQuality
+ }
+ return slot
+}
diff --git a/internal/object/item.go b/internal/object/item.go
index c9b2717..009f10e 100644
--- a/internal/object/item.go
+++ b/internal/object/item.go
@@ -39,6 +39,11 @@ type ItemDef struct {
Speed int `yaml:"speed"`
ToolType string `yaml:"tool_type"`
ToolSpeed int `yaml:"tool_speed"`
+ BurnTicks int `yaml:"burn_ticks"`
+ FireLevel int `yaml:"fire_level"`
+ FireXP int `yaml:"fire_xp"`
+ Quality int `yaml:"quality"`
+ MaxQuality int `yaml:"max_quality"`
}
type ItemStats struct {
diff --git a/internal/object/object.go b/internal/object/object.go
index 9e06804..2810e07 100644
--- a/internal/object/object.go
+++ b/internal/object/object.go
@@ -1,9 +1,11 @@
package object
type ObjectDef struct {
- ID string `yaml:"id"`
- Name string `yaml:"name"`
- BehaviorID string `yaml:"behavior"`
- Hidden bool `yaml:"hidden"`
- Props map[string]any `yaml:"props"`
+ ID string `yaml:"id"`
+ Name string `yaml:"name"`
+ BehaviorID string `yaml:"behavior"`
+ Hidden bool `yaml:"hidden"`
+ InRoomDescription string `yaml:"inroom_description"`
+ RemovalItem string `yaml:"removal_item"`
+ Props map[string]any `yaml:"props"`
}
diff --git a/internal/player/player.go b/internal/player/player.go
index 97874e3..332656c 100644
--- a/internal/player/player.go
+++ b/internal/player/player.go
@@ -82,8 +82,10 @@ func (s Skill) Level() int {
}
type InventorySlot struct {
- ItemID string `yaml:"item_id"`
- Quantity int `yaml:"quantity"`
+ ItemID string `yaml:"item_id"`
+ Quantity int `yaml:"quantity"`
+ Quality int `yaml:"quality,omitempty"`
+ MaxQuality int `yaml:"max_quality,omitempty"`
}
type OptionType int
@@ -113,6 +115,7 @@ var OptionDefs = []OptionDef{
{"mobspawn", OptBool, true, nil, "Messages when mobs spawn in the area"},
{"reserve", OptBool, true, nil, "Show full reserved item details"},
{"depletion", OptBool, false, nil, "Show depletion and despawn timers"},
+ {"despawn", OptBool, false, nil, "Show ground item despawn timers"},
{"color", OptString, "none", []string{"none", "ansi", "xterm256"}, "Color output mode"},
{"mapwidth", OptInt, 30, nil, "Map width for the map command"},
{"mapheight", OptInt, 20, nil, "Map height for the map command"},
@@ -138,6 +141,7 @@ type Player struct {
Skills map[SkillName]int `yaml:"skills"`
Inventory map[int]*InventorySlot `yaml:"inventory"`
Equipment map[object.EquipSlot]string `yaml:"equipment"`
+ EquipQuality map[string]int `yaml:"equip_quality,omitempty"`
RoomID int `yaml:"room_id"`
HP int `yaml:"hp"`
Credits int `yaml:"credits"`
@@ -273,6 +277,16 @@ func (p *Player) AddXP(s SkillName, amount int) {
p.Skills[s] += amount
}
+func (p *Player) AddSkillXP(s SkillName, amount int) int {
+ oldLevel := p.Level(s)
+ p.AddXP(s, amount)
+ newLevel := p.Level(s)
+ if newLevel > oldLevel {
+ return newLevel
+ }
+ return 0
+}
+
func (p *Player) CombatLevel() int {
base := 0.25 * float64(p.Level(Defense)+p.Level(Hitpoints)+p.Level(Science))
att := float64(p.Level(Attack))
diff --git a/internal/world/world.go b/internal/world/world.go
index c8270b1..e2d12ba 100644
--- a/internal/world/world.go
+++ b/internal/world/world.go
@@ -20,6 +20,8 @@ type GroundItemInfo struct {
Quantity int
ReservedFor string
ReserveTimer int
+ DespawnTimer int
+ IsSpawn bool
}
type groundEntry struct {
@@ -63,6 +65,7 @@ type ObjState struct {
WanderCounter int
SharedMax int
SharedTimer int
+ Quality int
}
func (w *World) ObjStateKey(roomID int, defID string, index int) string {
@@ -76,6 +79,10 @@ func (w *World) objStateKey(roomID int, defID string, index int) string {
func (w *World) EnsureObjectStates(roomID int, defIDs []string) {
w.mu.Lock()
defer w.mu.Unlock()
+ w.ensureObjectStatesLocked(roomID, defIDs)
+}
+
+func (w *World) ensureObjectStatesLocked(roomID int, defIDs []string) {
if w.objStates == nil {
w.objStates = make(map[string]*ObjState)
}
@@ -95,6 +102,55 @@ func (w *World) EnsureObjectStates(roomID int, defIDs []string) {
}
}
+func (w *World) AddObjInstance(roomID int, defID string, quality int) {
+ w.mu.Lock()
+ defer w.mu.Unlock()
+ if w.objStates == nil {
+ w.objStates = make(map[string]*ObjState)
+ }
+ idx := 0
+ for {
+ key := w.objStateKey(roomID, defID, idx)
+ if _, exists := w.objStates[key]; !exists {
+ w.objStates[key] = &ObjState{
+ DefID: defID,
+ Name: defID,
+ Index: idx,
+ RoomID: roomID,
+ Quality: quality,
+ }
+ return
+ }
+ idx++
+ }
+}
+
+func (w *World) RemoveObjInstance(roomID int, defID string) {
+ w.mu.Lock()
+ defer w.mu.Unlock()
+ idx := 0
+ for {
+ key := w.objStateKey(roomID, defID, idx)
+ if _, exists := w.objStates[key]; !exists {
+ return
+ }
+ delete(w.objStates, key)
+ idx++
+ }
+}
+
+func (w *World) AllFireStates() []*ObjState {
+ w.mu.Lock()
+ defer w.mu.Unlock()
+ var out []*ObjState
+ for _, st := range w.objStates {
+ if st.DefID == "fire" && st.Quality > 0 {
+ out = append(out, st)
+ }
+ }
+ return out
+}
+
func (w *World) GetObjState(roomID int, defID string, index int) *ObjState {
w.mu.Lock()
defer w.mu.Unlock()
@@ -152,6 +208,7 @@ func (w *World) FindObjInstances(roomID int, name string) []ObjState {
DepleteTimer: st.DepleteTimer,
SharedMax: st.SharedMax,
SharedTimer: st.SharedTimer,
+ Quality: st.Quality,
})
_ = key
}
@@ -298,8 +355,10 @@ func (w *World) GroundItemsDetailed(roomID int) []GroundItemInfo {
continue
}
info := GroundItemInfo{
- ItemID: e.itemID,
- Quantity: e.quantity,
+ ItemID: e.itemID,
+ Quantity: e.quantity,
+ DespawnTimer: e.despawnTimer,
+ IsSpawn: e.isSpawn,
}
if e.reserveTimer > 0 && e.reservedFor != "" {
info.ReservedFor = e.reservedFor
@@ -338,14 +397,6 @@ 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,