aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--cmd/thoi/main.go3
-rw-r--r--data/help/agility.yaml5
-rw-r--r--data/help/movement.yaml32
-rw-r--r--data/help/prompt.yaml2
-rw-r--r--data/help/score.yaml4
-rw-r--r--data/help/walk.yaml17
-rw-r--r--internal/game/cmd_move.go53
-rw-r--r--internal/game/cmd_score.go8
-rw-r--r--internal/game/cmd_walk.go2
-rw-r--r--internal/game/combat_mob.go1
-rw-r--r--internal/game/core_skill.go3
-rw-r--r--internal/game/render_prompt.go2
-rw-r--r--internal/game/sys_energy.go149
-rw-r--r--internal/game/sys_energy_test.go250
-rw-r--r--internal/player/player.go24
15 files changed, 498 insertions, 57 deletions
diff --git a/cmd/thoi/main.go b/cmd/thoi/main.go
index 4608536..254db69 100644
--- a/cmd/thoi/main.go
+++ b/cmd/thoi/main.go
@@ -46,13 +46,14 @@ func main() {
defer g.Ticks.Stop()
g.Ticks.Subscribe(1, func() bool {
- g.MoveTick()
g.ProcessQueuedCommands()
+ g.MoveTick()
g.SequenceTick()
g.TransientMobTick()
g.World.Tick()
g.MobStore.Tick()
g.RegenTick()
+ g.EnergyRegenTick()
g.DisconnectTick()
g.WanderTick()
g.SharedDepletionTick()
diff --git a/data/help/agility.yaml b/data/help/agility.yaml
index 5f3747c..f28c3c0 100644
--- a/data/help/agility.yaml
+++ b/data/help/agility.yaml
@@ -28,7 +28,10 @@ body: |
Grounds without penalty (but no XP either).
- Agility level also determines how many steps you can queue with the
"walk" command.
- - Graceful equipment and Cape of Agility reduce movement speed.
+ - Agility level determines your maximum run energy (3x your level).
+ - The Graceful outfit increases your run-energy recovery rate (+8% per
+ piece, +10% extra for the full 6-piece set) instead of changing
+ movement speed; the Cape of Agility grants infinite run energy.
related:
- skills
- walk
diff --git a/data/help/movement.yaml b/data/help/movement.yaml
index 0623510..426e667 100644
--- a/data/help/movement.yaml
+++ b/data/help/movement.yaml
@@ -6,19 +6,26 @@ description: |
Usage: north, south, east, west, up, down
Aliases: n, s, e, w, u, d
- Movement takes 2 game ticks by default. Certain equipment reduces
- this delay:
+ Movement speed is controlled by your Run Energy:
- Cape of Agility Instant movement (1 tick)
- Graceful set Each piece reduces movement by 0.15 ticks.
- Full set (6 pieces) gives an extra 0.1 bonus,
- for 1 tick total. Uses fractional ticks.
+ Run energy present ........ 1 tick to move, drains 1 run energy
+ No run energy ........ 2 ticks to move, drains nothing
- The Cape of Agility overrides all Graceful bonuses.
+ Your maximum run energy is 3x your Agility level. Run energy regenerates
+ automatically over time:
- The "walk" command (BFSPathfinding or direction sequences) takes
- twice as long as normal movement per step (4 ticks default, 2 ticks
- with the full Graceful set).
+ Idle 1 per 5 ticks
+ Busy 1 per 10 ticks (any action: combat, gathering, crafting,
+ walking, hiding, hacking, etc.)
+
+ Equipment effects on run energy:
+
+ Cape of Agility Infinite run energy. Movement is instant and
+ never drains energy.
+ Graceful outfit Each piece +8% run-energy recovery rate; the
+ complete 6-piece set adds a further +10% (so a
+ full set regenerates energy ~1.63x faster). The
+ Graceful outfit no longer changes movement speed.
Moving interrupts any active gathering or crafting action.
@@ -33,3 +40,8 @@ description: |
With "description" toggled on, moving shows the full room
description instead of just the room name.
+
+ Dying refills your run energy to full (alongside your HP).
+related:
+ - walk
+ - agility \ No newline at end of file
diff --git a/data/help/prompt.yaml b/data/help/prompt.yaml
index 961b605..7f22ec4 100644
--- a/data/help/prompt.yaml
+++ b/data/help/prompt.yaml
@@ -16,6 +16,8 @@ description: |
%H Maximum HP
%b Current battery level
%B Maximum battery level
+ %r Current run energy
+ %R Maximum run energy
%c Credits
%m Current mob HP (blank unless in combat)
%M Mob max HP (blank unless in combat)
diff --git a/data/help/score.yaml b/data/help/score.yaml
index 9607a9c..cacf0ba 100644
--- a/data/help/score.yaml
+++ b/data/help/score.yaml
@@ -6,3 +6,7 @@ description: |
Usage: score, sc, /
Shows your name, combat level, HP, credits, skill levels, and XP.
+
+ The vitals block includes three bars: HP (red/green/yellow), BAT
+ (battery, blue), and ENG (run energy, green). Run energy maximum
+ is 3x your Agility level; see 'help movement' for run energy rules.
diff --git a/data/help/walk.yaml b/data/help/walk.yaml
index 11b8338..847262c 100644
--- a/data/help/walk.yaml
+++ b/data/help/walk.yaml
@@ -8,13 +8,17 @@ description: |
Direction mode:
Provide a string of direction characters (n, s, e, w, u, d).
- Each character queues one movement step. Each step takes the
- normal movement tick delay (default 4 ticks per step, reduced
- by movement equipment like Graceful or Cape of Agility).
+ Each character queues one movement step. By default each walk step
+ takes 2 game ticks and does NOT use run energy.
+
+ However, if you wear the complete Graceful outfit (6 pieces) or the
+ Cape of Agility, walk steps use run energy like single moves:
+ 1 tick per step while you have energy (draining 1 per step), and 2
+ ticks per step once your run energy is empty.
Examples:
- walk nnewn Walk north, north, east, west, north (5 ticks)
- walk 2e3nwwn Walk 2 east, 3 north, west, west, north (8 ticks)
+ walk nnewn Walk north, north, east, west, north
+ walk 2e3nwwn Walk 2 east, 3 north, west, west, north
Shorthand numbers multiply the following direction, so 3n means
three north moves.
@@ -37,3 +41,6 @@ description: |
While walking, you appear as "walking somewhere with a purpose!"
when other players look at the room. The walk action interrupts
any other active action you were performing.
+related:
+ - movement
+ - agility \ No newline at end of file
diff --git a/internal/game/cmd_move.go b/internal/game/cmd_move.go
index 2967e79..594ecd7 100644
--- a/internal/game/cmd_move.go
+++ b/internal/game/cmd_move.go
@@ -5,15 +5,13 @@ import (
"strings"
"thehouseoficarus/internal/behavior"
- "thehouseoficarus/internal/engine"
- "thehouseoficarus/internal/item"
"thehouseoficarus/internal/net"
"thehouseoficarus/internal/object"
"thehouseoficarus/internal/player"
"thehouseoficarus/internal/world"
)
-func (g *Game) doMove(sess *net.Session, dir string, multiplier float64) {
+func (g *Game) doMove(sess *net.Session, dir string, isWalk bool) {
p := sess.Player
exitDir := g.World.ResolveExit(dir)
if exitDir == "" {
@@ -81,7 +79,7 @@ func (g *Game) doMove(sess *net.Session, dir string, multiplier float64) {
g.cancelAction(p)
}
- ticks := g.moveTicks(p, multiplier)
+ ticks := g.moveTicks(p, isWalk)
p.MoveDirection = string(exitDir)
p.MoveTarget = targetID
@@ -90,6 +88,8 @@ func (g *Game) doMove(sess *net.Session, dir string, multiplier float64) {
return
}
+ // moveTicks already set p.MoveUsesEnergy based on this move; preserve it
+ // across the wait by leaving it set (ClearMoveState resets it on arrival).
p.MoveTicks = ticks
if inCombat && p.OptionBool("run_countdown") {
@@ -116,46 +116,21 @@ func (g *Game) matchExitTrigger(sess *net.Session, p *player.Player, exitDef wor
return nil
}
-var gracefulItems = map[string]bool{
- "graceful_hat": true,
- "graceful_torso": true,
- "graceful_legs": true,
- "graceful_gloves": true,
- "graceful_boots": true,
- "graceful_cape": true,
-}
-
-func (g *Game) moveTicks(p *player.Player, multiplier float64) int {
- if p.GodMode {
- return 0
- }
- if id, ok := p.Equipment[item.SlotBack]; ok && id == "cape_of_agility" {
- return 0
- }
-
- count := 0
- for _, id := range p.Equipment {
- if gracefulItems[id] {
- count++
- }
- }
-
- base := 2.0 - float64(count)*0.15
- if count == 6 {
- base -= 0.1
- }
-
- base *= multiplier
- return engine.ToTicks(base) - 1
-}
-
func (g *Game) completeMove(sess *net.Session, p *player.Player) {
exitDir := p.MoveDirection
targetID := p.MoveTarget
pendingTrigger := p.MovePendingTrigger
+ usesEnergy := p.MoveUsesEnergy
p.ClearMoveState()
+ // Drain one run energy point for moves that ran on energy (single moves
+ // with energy, or run-walks enabled by the full Graceful set). Cape of
+ // Agility and plain (non-full-set) walks never drain.
+ if usesEnergy && p.HasRunEnergy() {
+ p.RunEnergy--
+ }
+
if g.Combat.Get(p.Name) != nil {
g.stopCombat(p.Name)
}
@@ -242,7 +217,7 @@ func (g *Game) seedRoomMobs(roomID int) {
}
func (g *Game) executeMove(sess *net.Session, args []string, rawInput string) {
- g.doMove(sess, strings.TrimSpace(rawInput), 1.0)
+ g.doMove(sess, strings.TrimSpace(rawInput), false)
}
// handleDangerConfirm processes the [Y/n] reply to the dangerous-area warning.
@@ -270,7 +245,7 @@ func (g *Game) handleDangerConfirm(sess *net.Session, input string) {
g.writePrompt(sess)
return
}
- g.doMove(sess, dir, 1.0)
+ g.doMove(sess, dir, false)
g.writePrompt(sess)
}
diff --git a/internal/game/cmd_score.go b/internal/game/cmd_score.go
index 488979c..5e850d1 100644
--- a/internal/game/cmd_score.go
+++ b/internal/game/cmd_score.go
@@ -123,6 +123,14 @@ func (g *Game) doScore(sess *net.Session) {
color.Render(mode, color.Parse("FA"), fmt.Sprintf("%.0f", p.MaxBattery())))
content(batLine)
+ engStyle := &ui.BarStyle{FilledColor: 108, EmptyColor: 237, EmptyDim: true}
+ engBarStr := ui.RenderColoredBar(p.RunEnergy, p.MaxRunEnergy(), 26, unicode, engStyle, mode)
+ engLine := fmt.Sprintf("ENG [%s] %s / %s",
+ engBarStr,
+ color.Render(mode, color.ColorSpec{Fg: 108}, fmt.Sprint(p.RunEnergy)),
+ color.Render(mode, color.Parse("FA"), fmt.Sprint(p.MaxRunEnergy())))
+ content(engLine)
+
gap()
actionDesc := "Idle"
diff --git a/internal/game/cmd_walk.go b/internal/game/cmd_walk.go
index de10cbd..66e7548 100644
--- a/internal/game/cmd_walk.go
+++ b/internal/game/cmd_walk.go
@@ -124,7 +124,7 @@ func (g *Game) advanceWalk(sess *net.Session, p *player.Player) {
p.WalkSequence = p.WalkSequence[1:]
oldRoom := p.RoomID
- g.doMove(sess, dir, 2.0)
+ g.doMove(sess, dir, true)
if p.MoveTicks == 0 && p.RoomID == oldRoom {
p.WalkSequence = nil
diff --git a/internal/game/combat_mob.go b/internal/game/combat_mob.go
index 55a2ea3..7768432 100644
--- a/internal/game/combat_mob.go
+++ b/internal/game/combat_mob.go
@@ -352,6 +352,7 @@ func (g *Game) killPlayer(sess *net.Session, p *player.Player, mob *world.MobIns
}
g.dropItemsOnDeath(p)
p.HP = p.MaxHP()
+ p.RunEnergy = p.MaxRunEnergy()
p.RoomID = g.StartingRoom
g.AccountStore.SaveCharacter(p)
if g.Hub != nil {
diff --git a/internal/game/core_skill.go b/internal/game/core_skill.go
index c02cdf3..3b9696c 100644
--- a/internal/game/core_skill.go
+++ b/internal/game/core_skill.go
@@ -11,5 +11,8 @@ func (g *Game) awardSkillXP(sess *net.Session, p *player.Player, skill player.Sk
newLevel := p.AddSkillXP(skill, xp)
if newLevel > 0 {
sess.WriteLine(g.colorize(sess, "level_up", fmt.Sprintf("*** You are now level %d %s! ***", newLevel, skill)))
+ if skill == player.Agility {
+ p.ClampRunEnergy()
+ }
}
}
diff --git a/internal/game/render_prompt.go b/internal/game/render_prompt.go
index ad1ba9c..9d7b20a 100644
--- a/internal/game/render_prompt.go
+++ b/internal/game/render_prompt.go
@@ -36,6 +36,8 @@ func (g *Game) expandPromptVars(sess *net.Session, text string) string {
text = strings.ReplaceAll(text, "%H", fmt.Sprint(p.MaxHP()))
text = strings.ReplaceAll(text, "%b", fmt.Sprintf("%.1f", p.Battery))
text = strings.ReplaceAll(text, "%B", fmt.Sprintf("%.0f", p.MaxBattery()))
+ text = strings.ReplaceAll(text, "%r", fmt.Sprint(p.RunEnergy))
+ text = strings.ReplaceAll(text, "%R", fmt.Sprint(p.MaxRunEnergy()))
text = strings.ReplaceAll(text, "%c", fmt.Sprint(p.Credits))
text = strings.ReplaceAll(text, "%k", fmt.Sprint(countChips(p)))
text = strings.ReplaceAll(text, "%i", fmt.Sprint(p.FreeSlots()))
diff --git a/internal/game/sys_energy.go b/internal/game/sys_energy.go
new file mode 100644
index 0000000..7331fa8
--- /dev/null
+++ b/internal/game/sys_energy.go
@@ -0,0 +1,149 @@
+package game
+
+import (
+ "thehouseoficarus/internal/item"
+ "thehouseoficarus/internal/player"
+)
+
+// gracefulItems is the set of equipment IDs that count toward the Graceful
+// outfit. Wearing pieces of the set increases run-energy regeneration speed
+// (see EnergyRegenTick); wearing the complete 6-piece set grants an additional
+// bonus. The Cape of Agility and graceful_cape share the back slot and are
+// therefore mutually exclusive, so the full-set bonus requires graceful_cape.
+var gracefulItems = map[string]bool{
+ "graceful_hat": true,
+ "graceful_torso": true,
+ "graceful_legs": true,
+ "graceful_gloves": true,
+ "graceful_boots": true,
+ "graceful_cape": true,
+}
+
+const capeOfAgilityID = "cape_of_agility"
+
+// gracefulCount returns how many pieces of the Graceful outfit the player is
+// currently wearing (maximum 6).
+func gracefulCount(p *player.Player) int {
+ count := 0
+ for _, id := range p.Equipment {
+ if gracefulItems[id] {
+ count++
+ }
+ }
+ return count
+}
+
+// hasCapeOfAgility reports whether the Cape of Agility is worn in the back
+// slot. The cape grants infinite run energy (movement is instant and never
+// drains energy).
+func hasCapeOfAgility(p *player.Player) bool {
+ id, ok := p.Equipment[item.SlotBack]
+ return ok && id == capeOfAgilityID
+}
+
+// moveTicks returns how many ticks the upcoming move will take, and sets
+// p.MoveUsesEnergy to indicate whether completing the move should drain one
+// point of run energy.
+//
+// - GodMode and Cape of Agility: instant (0 ticks), never drains energy.
+// - A single move (north/e/w...): 1 tick if energy remains, otherwise 2.
+// - The "walk" command: 2 ticks per step and never drains energy unless the
+// player wears the complete Graceful outfit (or Cape of Agility), in which
+// case it behaves like a single run move (1 tick with energy, 2 without).
+func (g *Game) moveTicks(p *player.Player, isWalk bool) int {
+ if p.GodMode {
+ p.MoveUsesEnergy = false
+ return 0
+ }
+ if hasCapeOfAgility(p) {
+ p.MoveUsesEnergy = false
+ return 0
+ }
+
+ if isWalk && gracefulCount(p) != 6 {
+ // Plain walk: slow, no energy cost.
+ p.MoveUsesEnergy = false
+ return 2
+ }
+
+ // Single move, or a run-walk enabled by the full Graceful set.
+ if p.HasRunEnergy() {
+ p.MoveUsesEnergy = true
+ return 1
+ }
+ p.MoveUsesEnergy = false
+ return 2
+}
+
+// isPlayerBusy reports whether the player is currently performing an action
+// (combat, gathering, crafting, walking, hiding, hacking, etc.). A busy player
+// regenerates run energy twice as slowly as an idle one.
+func (g *Game) isPlayerBusy(p *player.Player) bool {
+ if p.Action != nil || p.BackgroundAction != nil {
+ return true
+ }
+ if g.Combat.Get(p.Name) != nil {
+ return true
+ }
+ if p.MoveTicks > 0 || len(p.WalkSequence) > 0 {
+ return true
+ }
+ if _, ok := g.safespot.Get(p.Name); ok {
+ return true
+ }
+ if _, ok := g.hackingStates[p.Name]; ok {
+ return true
+ }
+ return false
+}
+
+// EnergyRegenTick advances run-energy regeneration for every online player.
+//
+// Idle: 1 energy per 5 ticks
+// Busy: 1 energy per 10 ticks
+// Graceful: +8% recovery rate per equipped piece
+// full 6-piece set adds a further +10%
+//
+// Fractional recovery is accumulated across ticks in thousandths of an energy
+// point (RunEnergyAccum, int) so that per-tick float drift never prevents a
+// point from arriving exactly when its rate says it should.
+func (g *Game) EnergyRegenTick() {
+ if g.Hub == nil {
+ return
+ }
+ const perEnergy = 1000
+ for _, sess := range g.Hub.AllSessions() {
+ p := sess.Player
+ if p == nil {
+ continue
+ }
+ maxE := p.MaxRunEnergy()
+ if maxE <= 0 || p.RunEnergy >= maxE {
+ p.RunEnergyAccum = 0
+ continue
+ }
+
+ // Base recovery rate in thousandths of an energy point per tick.
+ base := perEnergy / 5 // idle: 1/5
+ if g.isPlayerBusy(p) {
+ base = perEnergy / 10 // busy: 1/10
+ }
+
+ // Graceful: +8% per piece, +10% bonus for the complete 6-piece set.
+ count := gracefulCount(p)
+ mult := 1.0 + 0.08*float64(count)
+ if count == 6 {
+ mult *= 1.10
+ }
+
+ perTick := int(float64(base)*mult + 0.5) // round to nearest
+ p.RunEnergyAccum += perTick
+ for p.RunEnergyAccum >= perEnergy && p.RunEnergy < maxE {
+ p.RunEnergyAccum -= perEnergy
+ p.RunEnergy++
+ }
+ if p.RunEnergy >= maxE {
+ p.RunEnergyAccum = 0
+ }
+ }
+}
diff --git a/internal/game/sys_energy_test.go b/internal/game/sys_energy_test.go
new file mode 100644
index 0000000..ff897f0
--- /dev/null
+++ b/internal/game/sys_energy_test.go
@@ -0,0 +1,250 @@
+package game
+
+import (
+ "testing"
+
+ "thehouseoficarus/internal/combat"
+ "thehouseoficarus/internal/game/hacking"
+ "thehouseoficarus/internal/item"
+ "thehouseoficarus/internal/net"
+ "thehouseoficarus/internal/player"
+)
+
+func energyTestGame() *Game {
+ return &Game{
+ Combat: combat.NewTracker(),
+ safespot: NewSafespotManager(),
+ hackingStates: make(map[string]*hacking.Session),
+ Hub: net.NewHub(),
+ }
+}
+
+// addSession registers a player session with the test game's hub so that
+// EnergyRegenTick iterates over it.
+func addSession(g *Game, p *player.Player) {
+ sess := &net.Session{Player: p}
+ g.Hub.Add(sess)
+}
+
+func TestMaxRunEnergy(t *testing.T) {
+ p := player.New("tester")
+ if p.MaxRunEnergy() != 3 { // agility level 1
+ t.Fatalf("fresh player max run energy = %d, want 3", p.MaxRunEnergy())
+ }
+ if p.RunEnergy != 3 {
+ t.Fatalf("fresh player run energy not initialized to max: %d", p.RunEnergy)
+ }
+ p.Skills[player.Agility] = player.XPForLevel(20)
+ if p.MaxRunEnergy() != 60 {
+ t.Fatalf("agility 20 max run energy = %d, want 60", p.MaxRunEnergy())
+ }
+}
+
+func TestMoveTicks(t *testing.T) {
+ g := energyTestGame()
+
+ // Single move, energy present -> 1 tick, uses energy.
+ p := player.New("a")
+ if ticks := g.moveTicks(p, false); ticks != 1 {
+ t.Errorf("single move with energy: ticks=%d want 1", ticks)
+ } else if !p.MoveUsesEnergy {
+ t.Error("single move with energy should set MoveUsesEnergy")
+ }
+
+ // Single move, no energy -> 2 ticks, no drain.
+ p2 := player.New("b")
+ p2.RunEnergy = 0
+ if ticks := g.moveTicks(p2, false); ticks != 2 {
+ t.Errorf("single move no energy: ticks=%d want 2", ticks)
+ } else if p2.MoveUsesEnergy {
+ t.Error("single move no energy should not set MoveUsesEnergy")
+ }
+
+ // Walk without full graceful set -> 2 ticks, no drain.
+ p3 := player.New("c")
+ p3.RunEnergy = 10
+ if ticks := g.moveTicks(p3, true); ticks != 2 {
+ t.Errorf("walk no full set: ticks=%d want 2", ticks)
+ } else if p3.MoveUsesEnergy {
+ t.Error("walk without full set should not drain energy")
+ }
+
+ // Cape of Agility -> 0 ticks, no drain regardless of walk.
+ p4 := player.New("d")
+ p4.Equipment[item.SlotBack] = capeOfAgilityID
+ if ticks := g.moveTicks(p4, false); ticks != 0 {
+ t.Errorf("cape single move: ticks=%d want 0", ticks)
+ } else if p4.MoveUsesEnergy {
+ t.Error("cape should not drain energy")
+ }
+ if ticks := g.moveTicks(p4, true); ticks != 0 {
+ t.Errorf("cape walk: ticks=%d want 0", ticks)
+ }
+
+ // GodMode -> 0 ticks, no drain.
+ p5 := player.New("e")
+ p5.GodMode = true
+ if ticks := g.moveTicks(p5, false); ticks != 0 {
+ t.Errorf("godmode: ticks=%d want 0", ticks)
+ } else if p5.MoveUsesEnergy {
+ t.Error("godmode should not drain energy")
+ }
+
+ // Full graceful set, walk, with energy -> 1 tick, drains.
+ p6 := player.New("f")
+ p6.Equipment[item.SlotBack] = "graceful_cape"
+ p6.Equipment[item.SlotHead] = "graceful_hat"
+ p6.Equipment[item.SlotTorso] = "graceful_torso"
+ p6.Equipment[item.SlotLegs] = "graceful_legs"
+ p6.Equipment[item.SlotHands] = "graceful_gloves"
+ p6.Equipment[item.SlotFeet] = "graceful_boots"
+ if got := gracefulCount(p6); got != 6 {
+ t.Fatalf("gracefulCount=%d want 6", got)
+ }
+ if ticks := g.moveTicks(p6, true); ticks != 1 {
+ t.Errorf("full-set walk with energy: ticks=%d want 1", ticks)
+ } else if !p6.MoveUsesEnergy {
+ t.Error("full-set walk with energy should drain")
+ }
+
+ // Full graceful set, walk, no energy -> 2 ticks, no drain.
+ p7 := player.New("g")
+ for slot, id := range p6.Equipment {
+ p7.Equipment[slot] = id
+ }
+ p7.RunEnergy = 0
+ if ticks := g.moveTicks(p7, true); ticks != 2 {
+ t.Errorf("full-set walk no energy: ticks=%d want 2", ticks)
+ } else if p7.MoveUsesEnergy {
+ t.Error("full-set walk with no energy should not drain")
+ }
+}
+
+func TestGracefulCountCapeExcludesFullSet(t *testing.T) {
+ // Cape of Agility shares the back slot — 5 graceful pieces + cape = 5,
+ // not 6, so no full-set bonus.
+ p := player.New("h")
+ p.Equipment[item.SlotBack] = capeOfAgilityID
+ p.Equipment[item.SlotHead] = "graceful_hat"
+ p.Equipment[item.SlotTorso] = "graceful_torso"
+ p.Equipment[item.SlotLegs] = "graceful_legs"
+ p.Equipment[item.SlotHands] = "graceful_gloves"
+ p.Equipment[item.SlotFeet] = "graceful_boots"
+ if got := gracefulCount(p); got != 5 {
+ t.Errorf("5 graceful + cape: count=%d want 5", got)
+ }
+ if hasCapeOfAgility(p) != true {
+ t.Error("should detect cape of agility")
+ }
+}
+
+func TestEnergyRegenIdle(t *testing.T) {
+ g := energyTestGame()
+ p := player.New("idle")
+ p.RunEnergy = 0
+ p.Skills[player.Agility] = player.XPForLevel(10) // max 30
+ addSession(g, p)
+
+ // Idle baseline: 1 energy per 5 ticks.
+ for i := 0; i < 4; i++ {
+ g.EnergyRegenTick()
+ }
+ if p.RunEnergy != 0 {
+ t.Errorf("after 4 idle ticks energy=%d want 0", p.RunEnergy)
+ }
+ g.EnergyRegenTick() // 5th tick
+ if p.RunEnergy != 1 {
+ t.Errorf("after 5 idle ticks energy=%d want 1", p.RunEnergy)
+ }
+}
+
+func TestEnergyRegenBusyCombat(t *testing.T) {
+ g := energyTestGame()
+ p := player.New("busy")
+ p.RunEnergy = 0
+ p.Skills[player.Agility] = player.XPForLevel(10)
+ addSession(g, p)
+ // Mark busy via combat tracker.
+ g.Combat.Enter(p.Name, "mob1")
+
+ // Busy baseline: 1 energy per 10 ticks.
+ for i := 0; i < 9; i++ {
+ g.EnergyRegenTick()
+ }
+ if p.RunEnergy != 0 {
+ t.Errorf("after 9 busy ticks energy=%d want 0", p.RunEnergy)
+ }
+ g.EnergyRegenTick() // 10th
+ if p.RunEnergy != 1 {
+ t.Errorf("after 10 busy ticks energy=%d want 1", p.RunEnergy)
+ }
+}
+
+func TestEnergyRegenGracefulBonus(t *testing.T) {
+ g := energyTestGame()
+ p := player.New("graceful")
+ p.RunEnergy = 0
+ p.Skills[player.Agility] = player.XPForLevel(10)
+ p.Equipment[item.SlotBack] = "graceful_cape"
+ p.Equipment[item.SlotHead] = "graceful_hat"
+ p.Equipment[item.SlotTorso] = "graceful_torso"
+ p.Equipment[item.SlotLegs] = "graceful_legs"
+ p.Equipment[item.SlotHands] = "graceful_gloves"
+ p.Equipment[item.SlotFeet] = "graceful_boots"
+ addSession(g, p)
+
+ // Full set idle rate = (1/5) * 1.48 * 1.10 = ~0.3256 per tick.
+ // First point should arrive between tick 3 and 5.
+ totalTicks := 0
+ for p.RunEnergy == 0 && totalTicks < 10 {
+ g.EnergyRegenTick()
+ totalTicks++
+ }
+ if totalTicks > 5 {
+ t.Errorf("full-set regen too slow: took %d ticks for first point", totalTicks)
+ }
+ if totalTicks < 3 {
+ t.Errorf("full-set regen unexpectedly fast: %d ticks", totalTicks)
+ }
+}
+
+func TestEnergyRegenCapsAtMax(t *testing.T) {
+ g := energyTestGame()
+ p := player.New("cap")
+ p.Skills[player.Agility] = player.XPForLevel(5) // max 15
+ p.RunEnergy = 15
+ addSession(g, p)
+ g.EnergyRegenTick()
+ if p.RunEnergy != 15 {
+ t.Errorf("energy above max not capped: %d want 15", p.RunEnergy)
+ }
+ if p.RunEnergyAccum != 0 {
+ t.Errorf("accumulator not reset at max: %v", p.RunEnergyAccum)
+ }
+}
+
+func TestClampRunEnergyOnAgilityLevelUp(t *testing.T) {
+ p := player.New("clamp")
+ p.Skills[player.Agility] = player.XPForLevel(10) // max 30
+ p.RunEnergy = 30 // at max
+
+ // Grant agility XP to reach level 11 (max 33). Energy stays at 30 (no auto-fill).
+ p.AddXP(player.Agility, player.XPForLevel(11)-p.Skills[player.Agility])
+ p.ClampRunEnergy()
+ if p.Level(player.Agility) != 11 {
+ t.Fatalf("expected agility 11, got %d", p.Level(player.Agility))
+ }
+ if p.RunEnergy != 30 {
+ t.Errorf("level-up should not auto-fill energy: got %d want 30", p.RunEnergy)
+ }
+ if p.MaxRunEnergy() != 33 {
+ t.Errorf("max after level-up = %d want 33", p.MaxRunEnergy())
+ }
+
+ // If somehow over max, it clamps down.
+ p.RunEnergy = 50
+ p.ClampRunEnergy()
+ if p.RunEnergy != 33 {
+ t.Errorf("clamp down failed: %d want 33", p.RunEnergy)
+ }
+}
diff --git a/internal/player/player.go b/internal/player/player.go
index d78ae9d..ef3acb3 100644
--- a/internal/player/player.go
+++ b/internal/player/player.go
@@ -184,6 +184,7 @@ type Player struct {
MoveDirection string `yaml:"-"`
MoveTarget int `yaml:"-"`
MovePendingTrigger *behavior.Trigger `yaml:"-"`
+ MoveUsesEnergy bool `yaml:"-"`
VisualTickCurrent int `yaml:"-"`
AutotriggerMod string `yaml:"-"`
QueuedTrigger string `yaml:"-"`
@@ -191,6 +192,8 @@ type Player struct {
HazardTimer int `yaml:"-"`
PendingDangerDir string `yaml:"-"`
Battery float64 `yaml:"battery"`
+ RunEnergy int `yaml:"run_energy,omitempty"`
+ RunEnergyAccum int `yaml:"-"`
ActiveTechs map[string]bool `yaml:"-"`
QuickTech string `yaml:"quick_tech,omitempty"`
TechActivatedSinceTick map[string]bool `yaml:"-"`
@@ -231,6 +234,7 @@ func (p *Player) ClearMoveState() {
p.MoveDirection = ""
p.MoveTarget = 0
p.MovePendingTrigger = nil
+ p.MoveUsesEnergy = false
}
func (p *Player) OptionBool(name string) bool {
@@ -352,6 +356,7 @@ func New(name string) *Player {
p.Skills[Hitpoints] = XPForLevel(10)
p.HP = p.MaxHP()
p.Battery = p.MaxBattery()
+ p.RunEnergy = p.MaxRunEnergy()
return p
}
@@ -467,6 +472,25 @@ func (p *Player) MaxBattery() float64 {
return float64(p.Level(Technology))
}
+// MaxRunEnergy is 3x the player's Agility level (the OSRS-inspired cap).
+func (p *Player) MaxRunEnergy() int {
+ return 3 * p.Level(Agility)
+}
+
+func (p *Player) HasRunEnergy() bool {
+ return p.RunEnergy > 0
+}
+
+// ClampRunEnergy caps current run energy to the player's current maximum.
+// Called whenever the Agility level can change (XP gains) since leveling up
+// raises the cap but should not auto-refill the missing energy.
+func (p *Player) ClampRunEnergy() {
+ max := p.MaxRunEnergy()
+ if p.RunEnergy > max {
+ p.RunEnergy = max
+ }
+}
+
func (p *Player) HasActiveTech(techID string) bool {
if p.ActiveTechs == nil {
return false