package cardsim import ( "fmt" "reflect" "sort" "strconv" "strings" "unicode" "golang.org/x/exp/constraints" ) // A StatsCollection contains stats. type StatsCollection interface { // Stats returns all the stats in this collection. It's okay for // these to be copies rather than pointers. BasicStatsPanel presents // stats to the player in this order. It's okay for this list to // contain nil entries; these are interpreted as line breaks, // section breaks, etc. Stats() []Stat } // A Stat is some value that can be printed as part of player status. // It may not be an actual stored value -- it might only be calculated. type Stat interface { // StatName returns the name of this stat, as displayed to the player. StatName() string // String prints the value of the stat. String() string // compatible with fmt.Stringer // Visible returns whether this stat should be displayed to the player // during regular gameplay. (Invisible stats are important for debugging.) Visible() bool } // Stored is a generic Stat implementation that stores a stat value and name. // It's visible to the player. type Stored[T any] struct { // Display name of this Stat. Name string // Value of this Stat. Can be overwritten. Value T } // Statname implements Stat. func (s Stored[T]) StatName() string { return s.Name } // String implements Stat and fmt.Stringer. func (s Stored[T]) String() string { return fmt.Sprint(s.Value) } // Visible implements Stat. func (Stored[T]) Visible() bool { return true } // Invisible is a generic Stat implementation that stores a stat value and name. // It's not visible to the player. type Invisible[T any] struct { // Display name of this Stat. Name string // Value of this Stat. Can be overwritten. Value T } // Statname implements Stat. func (i Invisible[T]) StatName() string { return i.Name } // String implements Stat and fmt.Stringer. func (i Invisible[T]) String() string { return fmt.Sprint(i.Value) } // Visible implements Stat. func (Invisible[T]) Visible() bool { return false } // StatFunc names a function as a stat visible to the player. func StatFunc[T any](name string, f func() T) Stat { return statFunc[T]{ f: f, name: name, visible: true, } } // InvisibleStatFunc names a function as a stat not visible to the player. func InvisibleStatFunc[T any](name string, f func() T) Stat { return statFunc[T]{ f: f, name: name, visible: false, } } // statFunc wraps a function as a stat. type statFunc[T any] struct { f func() T name string visible bool } func (s statFunc[T]) StatName() string { return s.name } func (s statFunc[T]) String() string { return fmt.Sprint(s.f()) } func (s statFunc[T]) Visible() bool { return s.visible } // ExtractStats pulls all exported stats out of a struct. It puts methods before // fields. If the calculated name of a method conflicts with the calculated // name of a stat from a field, the method wins. // // A field is a stat if it is of some Stat type or is tagged with `cardsim:"stat"`, // `cardsim:"hidden"` (invisible stat), `cardsim:"round2"` (or any integer, 2 is // just an example), or `cardsim:"hiddenround3"`. `hiddenstat`, `statround`, and // `hiddenstatround` are also accepted, but other orders of these directives // are not. A "round" stat must be a float type and it will be rounded to // this number of decimal places. // // A method is a Stat if it takes 0 arguments, returns exactly 1 value, and // starts with Stat or HiddenStat. // // The name of these inferred stats is calculated by breaking the name into // separate words before each capital letter, unless there are consecutive // capital letters, which it interprets as an initialism (followed by the // start of another word, if it's not at the end). To insert a space between // consecutive capital letters, insert an underscore (`_`). This name inference // trims "Stat" and "HiddenStat" off the front of method names. func ExtractStats(x any) []Stat { v := reflect.ValueOf(x) for k := v.Kind(); k == reflect.Pointer || k == reflect.Interface; k = v.Kind() { v = v.Elem() } if v.Kind() != reflect.Struct { panic(fmt.Errorf("%T is not a struct", x)) } typ := v.Type() var ret []Stat known := make(map[string]bool) for _, vv := range []reflect.Value{v, v.Addr()} { xt := vv.Type() lim := xt.NumMethod() for i := 0; i < lim; i++ { m := xt.Method(i) if !m.IsExported() { continue } tm := m.Type if tm.NumIn() != 1 { // 1 arg -- receiver continue } if tm.NumOut() != 1 { continue } nameParts := explode(m.Name) if len(nameParts) < 2 { continue } isHidden := false if nameParts[0] == "Hidden" { isHidden = true nameParts = nameParts[1:] } if nameParts[0] != "Stat" { continue } n := strings.Join(nameParts[1:], " ") if n == "" { continue } if known[n] { continue } known[n] = true val := vv.Method(i).Call([]reflect.Value{}) if len(val) != 1 { // This shouldn't happen - we already checked Out. Weird. continue } if !val[0].CanInterface() { continue } ret = append(ret, &StatLiteral{ Name: n, Value: fmt.Sprint(val[0].Interface()), IsVisible: !isHidden, }) } } fields := reflect.VisibleFields(typ) for _, sf := range fields { if !sf.IsExported() { continue } f := v.FieldByIndex(sf.Index) if !f.CanInterface() { continue } iface := f.Interface() if s, ok := iface.(Stat); ok { if known[s.StatName()] { continue } known[s.StatName()] = true ret = append(ret, s) continue } if t := sf.Tag.Get("cardsim"); t != "" { isStat := false isHidden := false t = strings.ToLower(t) t = strings.TrimSpace(t) if strings.HasPrefix(t, "hidden") { isStat = true isHidden = true t = t[6:] } if strings.HasPrefix(t, "stat") { isStat = true t = t[4:] } var val string if strings.HasPrefix(t, "round") { isStat = true t = t[5:] n, _ := strconv.Atoi(t) fs := fmt.Sprintf("%%.%df", n) val = fmt.Sprintf(fs, iface) } else if isStat { val = fmt.Sprint(iface) } else { continue // not identifiably a stat } nm := strings.Join(explode(sf.Name), " ") if known[nm] { continue } known[nm] = true ret = append(ret, &StatLiteral{ Name: nm, Value: val, IsVisible: !isHidden, }) continue } // Else, not a stat. } return ret } // SortStats sorts the provided slice of stats in place. It puts all visible // stats before all invisible stats, then alphabetizes (case-insensitive). func SortStats(ss []Stat) { sort.Sort(statSorter(ss)) } // statSorter implements sort.Interface for []Stat. type statSorter []Stat // Len implements sort.Interface. func (s statSorter) Len() int { return len(s) } // Swap implements sort.Interface. func (s statSorter) Swap(i, j int) { s[i], s[j] = s[j], s[i] } // Less implements sort.Interface. func (s statSorter) Less(i, j int) bool { lhs, rhs := s[i], s[j] if lhs.Visible() != rhs.Visible() { return lhs.Visible() } ln, rn := strings.ToLower(lhs.StatName()), strings.ToLower(rhs.StatName()) if ln != rn { return ln < rn } // Names differ only by capitalization, if that. return lhs.StatName() < rhs.StatName() } // StatLiteral stores a ready-to-emit stat value. type StatLiteral struct { Name string Value string IsVisible bool } func (s *StatLiteral) StatName() string { return s.Name } func (s *StatLiteral) String() string { return s.Value } func (s *StatLiteral) Visible() bool { return s.IsVisible } func EmitStat(name string, v any) *StatLiteral { return &StatLiteral{ Name: name, Value: fmt.Sprint(v), IsVisible: true, } } func EmitHiddenStat(name string, v any) *StatLiteral { return &StatLiteral{ Name: name, Value: fmt.Sprint(v), IsVisible: false, } } func Statf(name string, f string, args ...any) *StatLiteral { return &StatLiteral{ Name: name, Value: fmt.Sprintf(f, args...), IsVisible: true, } } func HiddentStatf(name string, f string, args ...any) *StatLiteral { return &StatLiteral{ Name: name, Value: fmt.Sprintf(f, args...), IsVisible: false, } } func RoundStat[N constraints.Float](name string, val N, decimals int) *StatLiteral { f := fmt.Sprintf("%%.%df", decimals) return &StatLiteral{ Name: name, Value: fmt.Sprintf(f, val), IsVisible: true, } } func RoundHiddenStat[N constraints.Float](name string, val N, decimals int) *StatLiteral { r := RoundStat(name, val, decimals) r.IsVisible = false return r } // explode turns CamelCase into multiple strings. It recognizes initialisms. To // split consecutive capital letters into separate words instead of recognizing // them as an initialism, insert underscores. func explode(s string) []string { var parts []string started := 0 initialism := false for i, r := range s { if unicode.IsUpper(r) { if initialism || (started == i) { continue } if started == i-1 { initialism = true continue } parts = append(parts, s[started:i]) started = i continue } if r == '_' { parts = append(parts, s[started:i]) initialism = false started = i + 1 continue } if initialism { parts = append(parts, s[started:i-1]) initialism = false started = i - 1 } } parts = append(parts, s[started:]) return parts }