From a19e6b6ab01670a5932308c7bd0e0e5eed8cbcad Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Fri, 12 Jun 2026 23:52:06 -0400 Subject: feat: firemaking skill implemented. overhauled how ground items and despawn are handled. --- AGENTS.md | 40 ++++ TODO.md | 25 +- cmd/mud/main.go | 1 + data/behaviors/chop_tree.yaml | 1 + data/help/burn.yaml | 25 ++ data/help/firemaking.yaml | 59 +++++ data/help/stoke.yaml | 26 +++ data/items/ashes.yaml | 6 + data/items/firesteel.yaml | 8 + data/items/lighter.yaml | 11 + data/items/logs.yaml | 3 + data/items/magic_logs.yaml | 3 + data/items/mahogany_logs.yaml | 7 +- data/items/maple_logs.yaml | 5 +- data/items/matches.yaml | 9 + data/items/oak_logs.yaml | 7 +- data/items/redwood_logs.yaml | 7 +- data/items/teak_logs.yaml | 5 +- data/items/willow_logs.yaml | 5 +- data/items/yew_logs.yaml | 3 + data/objects/fire.yaml | 9 + data/rooms/4.yaml | 12 + internal/action/behavior.go | 1 + internal/game/action.go | 4 + internal/game/action_burn.go | 515 +++++++++++++++++++++++++++++++++++++++++ internal/game/action_gather.go | 19 +- internal/game/action_use.go | 4 +- internal/game/cmd_attack.go | 15 +- internal/game/cmd_get.go | 4 +- internal/game/cmd_look.go | 65 +++++- internal/game/cmd_remove.go | 10 +- internal/game/cmd_wear.go | 14 ++ internal/game/game.go | 6 + internal/game/utils.go | 13 ++ internal/object/item.go | 5 + internal/object/object.go | 12 +- internal/player/player.go | 18 +- internal/world/world.go | 71 +++++- 38 files changed, 1000 insertions(+), 53 deletions(-) create mode 100644 data/help/burn.yaml create mode 100644 data/help/firemaking.yaml create mode 100644 data/help/stoke.yaml create mode 100644 data/items/ashes.yaml create mode 100644 data/items/firesteel.yaml create mode 100644 data/items/lighter.yaml create mode 100644 data/items/matches.yaml create mode 100644 data/objects/fire.yaml create mode 100644 internal/game/action_burn.go 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 `. + +**Item fields for firemaking:** +- `burn_ticks: ` — how long the log burns (fire quality). Only items with `burn_ticks > 0` are burnable. +- `fire_level: ` — required firemaking level to burn. +- `fire_xp: ` — XP awarded. +- `quality: ` / `max_quality: ` — 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 ! ***` 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 ` — 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 ! ***") +- [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 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, -- cgit v1.2.3