From 6e6c4a157564100249bb58f412485055bb2d10cf Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Fri, 17 Jul 2026 17:12:39 -0400 Subject: feat: implement run energy, shorten walk time to 1-2 ticks between rooms, update graceful set --- internal/game/cmd_move.go | 53 +++------ internal/game/cmd_score.go | 8 ++ internal/game/cmd_walk.go | 2 +- internal/game/combat_mob.go | 1 + internal/game/core_skill.go | 3 + internal/game/render_prompt.go | 2 + internal/game/sys_energy.go | 149 +++++++++++++++++++++++ internal/game/sys_energy_test.go | 250 +++++++++++++++++++++++++++++++++++++++ internal/player/player.go | 24 ++++ 9 files changed, 452 insertions(+), 40 deletions(-) create mode 100644 internal/game/sys_energy.go create mode 100644 internal/game/sys_energy_test.go (limited to 'internal') 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 -- cgit v1.2.3