Skip to content
Draft
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,13 @@
{{- $hpDisplay := printf "%s" ( healthStr .Character.Health .Character.HealthMax.Value 22 ) }}
{{- $mpDisplay := printf "%s" ( manaStr .Character.Mana .Character.ManaMax.Value 22 ) }}
┌─ <ansi fg="black-bold">.:</ansi><ansi fg="20">Info</ansi> ──────────────────────┐ ┌─ <ansi fg="black-bold">.:</ansi><ansi fg="20">Attributes</ansi> ───────────────────────────┐
│ <ansi fg="yellow">Area: </ansi>{{ printf "%-22s" .Character.Zone }}│ │ <ansi fg="yellow">Strength: </ansi>{{ printf "<ansi fg=\"stat\">%-4d</ansi><ansi fg=\"statmod\">(%-3d)</ansi>" .Character.Stats.Strength.Value (.Character.StatMod "strength") }} <ansi fg="yellow">Vitality: </ansi>{{ printf "<ansi fg=\"stat\">%-4d</ansi><ansi fg=\"statmod\">(%-3d)</ansi>" .Character.Stats.Vitality.Value (.Character.StatMod "vitality") }} │
<ansi fg="yellow">Race: </ansi>{{ printf "%-22s" .Character.Race }} <ansi fg="yellow">Speed: </ansi>{{ printf "<ansi fg=\"stat\">%-4d</ansi><ansi fg=\"statmod\">(%-3d)</ansi>" .Character.Stats.Speed.Value (.Character.StatMod "speed") }} <ansi fg="yellow">Mysticism: </ansi>{{ printf "<ansi fg=\"stat\">%-4d</ansi><ansi fg=\"statmod\">(%-3d)</ansi>" .Character.Stats.Mysticism.Value (.Character.StatMod "mysticism") }}
<ansi fg="yellow">Level: </ansi>{{ printf "%-22d" .Character.Level }} │ <ansi fg="yellow">Smarts: </ansi>{{ printf "<ansi fg=\"stat\">%-4d</ansi><ansi fg=\"statmod\">(%-3d)</ansi>" .Character.Stats.Smarts.Value (.Character.StatMod "smarts") }} <ansi fg="yellow">Percept: </ansi>{{ printf "<ansi fg=\"stat\">%-4d</ansi><ansi fg=\"statmod\">(%-3d)</ansi>" .Character.Stats.Perception.Value (.Character.StatMod "perception") }} │
│ <ansi fg="yellow">Area: </ansi>{{ printf "%-22s" .Character.Zone }}│ │ <ansi fg="yellow">Strength: </ansi>{{ printf "<ansi fg=\"stat\">%-4d</ansi><ansi fg=\"statmod\">(%-3d)</ansi>" (charStatValue .Character "strength") (.Character.StatMod "strength") }} <ansi fg="yellow">Vitality: </ansi>{{ printf "<ansi fg=\"stat\">%-4d</ansi><ansi fg=\"statmod\">(%-3d)</ansi>" (charStatValue .Character "Vitality") (.Character.StatMod "vitality") }} │
<ansi fg="yellow">Race: </ansi>{{ printf "%-22s" .Character.Race }} <ansi fg="yellow">Speed: </ansi>{{ printf "<ansi fg=\"stat\">%-4d</ansi><ansi fg=\"statmod\">(%-3d)</ansi>" (charStatValue .Character "Speed") (.Character.StatMod "speed") }} <ansi fg="yellow">Mysticism: </ansi>{{ printf "<ansi fg=\"stat\">%-4d</ansi><ansi fg=\"statmod\">(%-3d)</ansi>" (charStatValue .Character "Mysticism") (.Character.StatMod "mysticism") }}
<ansi fg="yellow">Level: </ansi>{{ printf "%-22d" .Character.Level }} │ <ansi fg="yellow">Smarts: </ansi>{{ printf "<ansi fg=\"stat\">%-4d</ansi><ansi fg=\"statmod\">(%-3d)</ansi>" (charStatValue .Character "Smarts") (.Character.StatMod "smarts") }} <ansi fg="yellow">Percept: </ansi>{{ printf "<ansi fg=\"stat\">%-4d</ansi><ansi fg=\"statmod\">(%-3d)</ansi>" (charStatValue .Character "Perception") (.Character.StatMod "perception") }} │
<ansi fg="yellow">Exp: </ansi>{{ printf "%-22s" ( tnl .UserId ) }} └──────────────────────────────────────────┘
<ansi fg="yellow">Health: </ansi>{{ printf "%s" $hpDisplay }} ┌─ <ansi fg="black-bold">.:</ansi><ansi fg="20">Wealth</ansi> ────────┐ ┌─ <ansi fg="black-bold">.:</ansi><ansi fg="20">Training</ansi> ───────┐
<ansi fg="yellow">Mana: </ansi>{{ printf "%s" $mpDisplay }} │ <ansi fg="yellow">Gold: </ansi>{{ printf "%-11s" (numberFormat .Character.Gold) }} │ │ <ansi fg="yellow">Train Pts:</ansi> {{ printf "%-7d" .Character.TrainingPoints }} │
│ <ansi fg="yellow">Armor: </ansi>{{ printf "%-6s" ( printf "%d" (.Character.GetDefense)) }} {{ if permadeath }}<ansi fg="yellow">Lives: </ansi>{{ printf "%-7d" .Character.ExtraLives }}{{ else }} {{ end }} │ │ <ansi fg="yellow">Bank: </ansi>{{ printf "%-11s" (numberFormat .Character.Bank) }} │ │ <ansi fg="yellow">Stat Pts:</ansi> {{ printf "%-7d" .Character.StatPoints }} │
└───────────────────────────────┘ └───────────────────┘ └────────────────────┘
{{- if gt .Character.StatPoints 0 }}{{ if lt .Character.Level 5 }}
<ansi fg="alert-5">TIP:</ansi> <ansi fg="alert-2">Type <ansi fg="command">status train</ansi> to spend stat points on improvements.</ansi> {{ end }}{{ end -}}
<ansi fg="alert-5">TIP:</ansi> <ansi fg="alert-2">Type <ansi fg="command">status train</ansi> to spend stat points on improvements.</ansi> {{ end }}{{ end -}}
127 changes: 66 additions & 61 deletions internal/characters/character.go
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@ func (c *Character) GetBaseCastSuccessChance(spellId string) int {
}
targetNumber += proficiency

targetNumber += int(math.Floor(float64(c.Stats.Mysticism.ValueAdj) / 5))
targetNumber += int(math.Floor(float64(c.Stats.Get("Mysticism").ValueAdj) / 5))
Copy link

Copilot AI Jul 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Calling Get("Mysticism") inside hot loops involves string lookups and a switch; consider caching the StatInfo pointer in a local variable to reduce overhead.

Suggested change
targetNumber += int(math.Floor(float64(c.Stats.Get("Mysticism").ValueAdj) / 5))
mysticismStat := c.Stats.Get("Mysticism")
targetNumber += int(math.Floor(float64(mysticismStat.ValueAdj) / 5))

Copilot uses AI. Check for mistakes.

// add by any stat mods for casting, or casting school
// 0-xx
Expand All @@ -222,7 +222,7 @@ func (c *Character) GetBaseCastSuccessChance(spellId string) int {
}

func (c *Character) CarryCapacity() int {
return 5 + c.Stats.Strength.ValueAdj/3
return 5 + c.Stats.Get("Strength").ValueAdj/3
}

func (c *Character) DeductActionPoints(amount int) bool {
Expand Down Expand Up @@ -367,9 +367,9 @@ func (c *Character) GetDefaultDiceRoll() (attacks int, dCount int, dSides int, b
bonus = raceInfo.Damage.BonusDamage
buffOnCrit = raceInfo.Damage.CritBuffIds

dCount += int(math.Floor((float64(c.Stats.Speed.ValueAdj) / 50)))
dSides += int(math.Floor((float64(c.Stats.Strength.ValueAdj) / 12)))
bonus += int(math.Floor((float64(c.Stats.Perception.ValueAdj) / 25)))
dCount += int(math.Floor((float64(c.Stats.Get("Speed").ValueAdj) / 50)))
dSides += int(math.Floor((float64(c.Stats.Get("Strength").ValueAdj) / 12)))
bonus += int(math.Floor((float64(c.Stats.Get("Perception").ValueAdj) / 25)))

if dCount < raceInfo.Damage.DiceCount {
dCount = raceInfo.Damage.DiceCount
Expand Down Expand Up @@ -958,15 +958,15 @@ func (c *Character) GetMaxCharmedCreatures() int {
}

func (c *Character) GetMemoryCapacity() int {
memCap := c.GetSkillLevel(skills.Map) * c.Stats.Smarts.ValueAdj
memCap := c.GetSkillLevel(skills.Map) * c.Stats.Get("Smarts").ValueAdj
if memCap < 0 {
memCap = 0
}
return memCap + 5
}

func (c *Character) GetMapSprawlCapacity() int {
sprawlCap := c.GetSkillLevel(skills.Map) + (c.Stats.Smarts.ValueAdj >> 2)
sprawlCap := c.GetSkillLevel(skills.Map) + (c.Stats.Get("Smarts").ValueAdj >> 2)
if sprawlCap < 0 {
sprawlCap = 0
}
Expand Down Expand Up @@ -1256,7 +1256,7 @@ func (c *Character) ApplyManaChange(manaChange int) int {
}

func (c *Character) BarterPrice(startPrice int) int {
factor := (float64(c.Stats.Perception.ValueAdj) / 3) / 100 // 100 = 33% discount, 0 = 0% discount, 300 = 100% discount
factor := (float64(c.Stats.Get("Perception").ValueAdj) / 3) / 100 // 100 = 33% discount, 0 = 0% discount, 300 = 100% discount
if factor > .75 {
factor = .75
}
Expand Down Expand Up @@ -1308,12 +1308,12 @@ func (c *Character) LevelUp() (bool, stats.Statistics) {

var statsDelta stats.Statistics = c.Stats

statsDelta.Strength.Value -= statsBefore.Strength.Value
statsDelta.Speed.Value -= statsBefore.Speed.Value
statsDelta.Smarts.Value -= statsBefore.Smarts.Value
statsDelta.Vitality.Value -= statsBefore.Vitality.Value
statsDelta.Mysticism.Value -= statsBefore.Mysticism.Value
statsDelta.Perception.Value -= statsBefore.Perception.Value
statsDelta.Get("Strength").Value -= statsBefore.Get("Strength").Value
statsDelta.Get("Speed").Value -= statsBefore.Get("Speed").Value
statsDelta.Get("Smarts").Value -= statsBefore.Get("Smarts").Value
statsDelta.Get("Vitality").Value -= statsBefore.Get("Vitality").Value
statsDelta.Get("Mysticism").Value -= statsBefore.Get("Mysticism").Value
statsDelta.Get("Perception").Value -= statsBefore.Get("Perception").Value

c.Health = c.HealthMax.Value
c.Mana = c.ManaMax.Value
Expand All @@ -1340,7 +1340,7 @@ func (c *Character) Heal(hp int, mana int) (int, int) {
func (c *Character) HealthPerRound() int {
return 1 + c.StatMod(string(statmods.HealthRecovery))
/*
healAmt := math.Round(float64(c.Stats.Vitality.ValueAdj)/8) +
healAmt := math.Round(float64(c.Stats.Get("Vitality").ValueAdj)/8) +
math.Round(float64(c.Level)/12) +
1.0

Expand All @@ -1351,7 +1351,7 @@ func (c *Character) HealthPerRound() int {
func (c *Character) ManaPerRound() int {
return 1 + c.StatMod(string(statmods.ManaRecovery))
/*
healAmt := math.Round(float64(c.Stats.Mysticism.ValueAdj)/8) +
healAmt := math.Round(float64(c.Stats.Get("Mysticism").ValueAdj)/8) +
math.Round(float64(c.Level)/12) +
1.0

Expand All @@ -1361,9 +1361,9 @@ func (c *Character) ManaPerRound() int {

// Where 1000 = a full round
func (c *Character) MovementCost() int {
modifier := 3 // by default they should be able to move 3 times per round.
modifier += int(c.Level / 15) // Every 15 levels, get an extra movement.
modifier += int(c.Stats.Speed.ValueAdj / 15) // Every 15 speed, get an extra movement
modifier := 3 // by default they should be able to move 3 times per round.
modifier += int(c.Level / 15) // Every 15 levels, get an extra movement.
modifier += int(c.Stats.Get("Speed").ValueAdj / 15) // Every 15 speed, get an extra movement
return int(1000 / modifier)
}

Expand All @@ -1372,6 +1372,8 @@ func (c *Character) StatMod(statName string) int {
}

// returns true if something has changed.
// TODO: [nitpick] There are many repetitive Get("X") calls; consider iterating over a slice of stat names or using a helper to apply updates in a loop for readability.
Copy link

Copilot AI Jul 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] Implement the suggested refactoring by looping over Statistics.GetStatInfoNames() and applying the same operations to each stat, reducing boilerplate and potential copy-paste errors.

Copilot uses AI. Check for mistakes.

func (c *Character) RecalculateStats() {

// Make sure racial base stats are set
Expand All @@ -1385,43 +1387,52 @@ func (c *Character) RecalculateStats() {
if c.TNLScale == 0 {
c.TNLScale = 1.0
}
c.Stats.Strength.Base = raceInfo.Stats.Strength.Base
c.Stats.Speed.Base = raceInfo.Stats.Speed.Base
c.Stats.Smarts.Base = raceInfo.Stats.Smarts.Base
c.Stats.Vitality.Base = raceInfo.Stats.Vitality.Base
c.Stats.Mysticism.Base = raceInfo.Stats.Mysticism.Base
c.Stats.Perception.Base = raceInfo.Stats.Perception.Base
for _, statName := range c.Stats.GetStatInfoNames() {
c.Stats.Get(statName).Base = raceInfo.Stats.Get(statName).Base
}
// c.Stats.Get("Strength").Base = raceInfo.Stats.Get("Strength").Base
// c.Stats.Get("Speed").Base = raceInfo.Stats.Get("Speed").Base
// c.Stats.Get("Smarts").Base = raceInfo.Stats.Get("Smarts").Base
// c.Stats.Get("Vitality").Base = raceInfo.Stats.Get("Vitality").Base
// c.Stats.Get("Mysticism").Base = raceInfo.Stats.Get("Mysticism").Base
// c.Stats.Get("Perception").Base = raceInfo.Stats.Get("Perception").Base
}

// Add any mods for equipment
c.Stats.Strength.Mods = c.StatMod(string(statmods.Strength))
c.Stats.Speed.Mods = c.StatMod(string(statmods.Speed))
c.Stats.Smarts.Mods = c.StatMod(string(statmods.Smarts))
c.Stats.Vitality.Mods = c.StatMod(string(statmods.Vitality))
c.Stats.Mysticism.Mods = c.StatMod(string(statmods.Mysticism))
c.Stats.Perception.Mods = c.StatMod(string(statmods.Perception))
for _, statName := range c.Stats.GetStatInfoNames() {
c.Stats.Get(statName).Mods = c.StatMod(statName)
}
// c.Stats.Get("Strength").Mods = c.StatMod(string(statmods.Strength))
// c.Stats.Get("Speed").Mods = c.StatMod(string(statmods.Speed))
// c.Stats.Get("Smarts").Mods = c.StatMod(string(statmods.Smarts))
// c.Stats.Get("Vitality").Mods = c.StatMod(string(statmods.Vitality))
// c.Stats.Get("Mysticism").Mods = c.StatMod(string(statmods.Mysticism))
// c.Stats.Get("Perception").Mods = c.StatMod(string(statmods.Perception))

// Recalculate stats
// Stats are basically:
// level*base + training + mods
c.Stats.Strength.Recalculate(c.Level)
c.Stats.Speed.Recalculate(c.Level)
c.Stats.Smarts.Recalculate(c.Level)
c.Stats.Vitality.Recalculate(c.Level)
c.Stats.Mysticism.Recalculate(c.Level)
c.Stats.Perception.Recalculate(c.Level)
for _, statName := range c.Stats.GetStatInfoNames() {
c.Stats.Get(statName).Recalculate(c.Level)
}
// c.Stats.Get("Strength").Recalculate(c.Level)
// c.Stats.Get("Speed").Recalculate(c.Level)
// c.Stats.Get("Smarts").Recalculate(c.Level)
// c.Stats.Get("Vitality").Recalculate(c.Level)
// c.Stats.Get("Mysticism").Recalculate(c.Level)
// c.Stats.Get("Perception").Recalculate(c.Level)

// Set HP/MP maxes
// This relies on the above stats so has to be calculated afterwards
c.HealthMax.Mods = 5 +
c.StatMod(string(statmods.HealthMax)) + // Any sort of spell buffs etc. are just direct modifiers
c.Level + // For every level you get 1 hp
c.Stats.Vitality.ValueAdj*4 // for every vitality you get 3hp
c.Stats.Get("Vitality").ValueAdj*4 // for every vitality you get 3hp

c.ManaMax.Mods = 4 +
c.StatMod(string(statmods.ManaMax)) + // Any sort of spell buffs etc. are just direct modifiers
c.Level + // For every level you get 1 mp
c.Stats.Mysticism.ValueAdj*3 // for every Mysticism you get 2mp
c.Stats.Get("Mysticism").ValueAdj*3 // for every Mysticism you get 2mp

// Set max action points
c.ActionPointsMax.Mods = 200 // hard coded for now
Expand All @@ -1445,22 +1456,16 @@ func (c *Character) RecalculateStats() {
if c.userId != 0 {
changed := false
// return true if something has changed.
if beforeStats.Strength.ValueAdj != c.Stats.Strength.ValueAdj {
changed = true
} else if beforeStats.Speed.ValueAdj != c.Stats.Speed.ValueAdj {
changed = true
} else if beforeStats.Smarts.ValueAdj != c.Stats.Smarts.ValueAdj {
changed = true
} else if beforeStats.Vitality.ValueAdj != c.Stats.Vitality.ValueAdj {
changed = true
} else if beforeStats.Mysticism.ValueAdj != c.Stats.Mysticism.ValueAdj {
changed = true
} else if beforeStats.Perception.ValueAdj != c.Stats.Perception.ValueAdj {
changed = true
} else if beforeHealthMax != c.HealthMax {
changed = true
} else if beforeManaMax != c.ManaMax {
changed = true
for _, statName := range c.Stats.GetStatInfoNames() {
if beforeStats.Get(statName).ValueAdj != c.Stats.Get(statName).ValueAdj {
changed = true
break
}
}
if !changed {
if beforeHealthMax != c.HealthMax || beforeManaMax != c.ManaMax {
changed = true
}
}

if changed {
Expand All @@ -1481,17 +1486,17 @@ func (c *Character) AutoTrain() {

switch util.Rand(6) {
case 0:
c.Stats.Strength.Training++
c.Stats.Get("Strength").Training++
case 1:
c.Stats.Speed.Training++
c.Stats.Get("Speed").Training++
case 2:
c.Stats.Smarts.Training++
c.Stats.Get("Smarts").Training++
case 3:
c.Stats.Vitality.Training++
c.Stats.Get("Vitality").Training++
case 4:
c.Stats.Mysticism.Training++
c.Stats.Get("Mysticism").Training++
case 5:
c.Stats.Perception.Training++
c.Stats.Get("Perception").Training++
}

c.StatPoints--
Expand Down
47 changes: 46 additions & 1 deletion internal/stats/stats.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package stats

import "math"
import (
"math"
"strings"
)

const (
BaseModFactor = 0.3333333334 // How much of a scaling to aply to levels before multiplying by racial stat
Expand All @@ -16,6 +19,48 @@ type Statistics struct {
Perception StatInfo `yaml:"perception,omitempty"` // How well you notice things
}

// GetStatInfoNames returns a list of all stat names in the order they are defined.
// TODO: This should be a representation of the stats in the game, not hardcoded.
func (s *Statistics) GetStatInfoNames() []string {
Copy link

Copilot AI Jul 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] Rather than hardcoding stat names here, consider driving this list from a single source of truth (e.g., a slice or config) or generating it via reflection to avoid divergence when stats change.

Copilot uses AI. Check for mistakes.
names := []string{
"Strength",
"Speed",
"Smarts",
"Vitality",
"Mysticism",
"Perception",
}
return names
}

// Get returns a pointer to the StatInfo for the given name.
func (s *Statistics) Get(name string) *StatInfo {
key := strings.ToLower(name)

// TODO: When we load the stats from a file, we need to check the map
// if stat, ok := s.Stats[key]; ok {
// copy := stat
// return &copy
// }

switch key {
case "strength":
return &s.Strength
case "speed":
return &s.Speed
case "smarts":
return &s.Smarts
case "vitality":
return &s.Vitality
case "mysticism":
return &s.Mysticism
case "perception":
return &s.Perception
}

return &StatInfo{}
}

// When saving to a file, we don't need to write all the properties that we calculate.
// Just keep track of "Training" because that's not calculated.
type StatInfo struct {
Expand Down
18 changes: 18 additions & 0 deletions internal/templates/templatesfunctions.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,24 @@ import (

var (
funcMap = template.FuncMap{
"charStatBase": func(char *characters.Character, name string) int {
return char.Stats.Get(name).Base
},
"charStatTraining": func(char *characters.Character, name string) int {
return char.Stats.Get(name).Training
},
"charStatMods": func(char *characters.Character, name string) int {
return char.Stats.Get(name).Mods
},
"charStatValue": func(char *characters.Character, name string) int {
return char.Stats.Get(name).Value
},
"charStatValueAdj": func(char *characters.Character, name string) int {
return char.Stats.Get(name).ValueAdj
},
"charStatRacial": func(char *characters.Character, name string) int {
return char.Stats.Get(name).Racial
},
"pad": pad,
"padLeft": padLeft,
"padRight": padRight,
Expand Down
Loading