diff --git a/cardsim/stats.go b/cardsim/stats.go index 51f5a8d..59b5d22 100644 --- a/cardsim/stats.go +++ b/cardsim/stats.go @@ -4,7 +4,11 @@ import ( "fmt" "reflect" "sort" + "strconv" "strings" + "unicode" + + "golang.org/x/exp/constraints" ) // A StatsCollection contains stats. @@ -114,9 +118,25 @@ func (s statFunc[T]) Visible() bool { return s.visible } -// ExtractStats pulls all exported Stat fields (not functions) out of a struct. -// If x cannot be resolved to a struct, it panics. It unwraps interfaces and -// follows pointers to try to find a struct. +// ExtractStats pulls all exported stats out of a struct. It puts fields before +// method. +// +// 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() { @@ -125,18 +145,101 @@ func ExtractStats(x any) []Stat { if v.Kind() != reflect.Struct { panic(fmt.Errorf("%T is not a struct", x)) } + typ := v.Type() var ret []Stat - lim := v.NumField() - for i := 0; i < lim; i++ { - f := v.Field(i) + fields := reflect.VisibleFields(typ) + for _, sf := range fields { + if !sf.IsExported() { + continue + } + f := v.FieldByIndex(sf.Index) if !f.CanInterface() { continue } - x := f.Interface() - if s, ok := x.(Stat); ok { + if s, ok := f.Interface().(Stat); ok { 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, f.Interface()) + } else if isStat { + val = fmt.Sprint(f.Interface()) + } else { + continue // not identifiably a stat + } + ret = append(ret, &StatLiteral{ + Name: strings.Join(explode(sf.Name), " "), + Value: val, + IsVisible: !isHidden, + }) + continue + } + // Else, not a stat. + } + + lim := typ.NumMethod() + for i := 0; i < lim; i++ { + m := typ.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 + } + val := v.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, + }) } return ret } @@ -173,3 +276,105 @@ func (s statSorter) Less(i, j int) bool { // 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 +} diff --git a/go.mod b/go.mod index b649256..7f4063c 100644 --- a/go.mod +++ b/go.mod @@ -7,4 +7,5 @@ require github.com/kr/pretty v0.3.1 require ( github.com/kr/text v0.2.0 // indirect github.com/rogpeppe/go-internal v1.9.0 // indirect + golang.org/x/exp v0.0.0-20230321023759-10a507213a29 ) diff --git a/go.sum b/go.sum index f5733ef..3a0cd5d 100644 --- a/go.sum +++ b/go.sum @@ -6,3 +6,5 @@ github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +golang.org/x/exp v0.0.0-20230321023759-10a507213a29 h1:ooxPy7fPvB4kwsA2h+iBNHkAbp/4JxTSwCmvdjEYmug= +golang.org/x/exp v0.0.0-20230321023759-10a507213a29/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc= diff --git a/smoketest/collection.go b/smoketest/collection.go index a1faf58..018721a 100644 --- a/smoketest/collection.go +++ b/smoketest/collection.go @@ -11,6 +11,10 @@ type SmokeTestCollection struct { Turns cardsim.Invisible[int] Flavor cardsim.Stored[string] + + Things int `cardsim:"stat"` + MoreThings int `cardsim:"hidden"` + FloatyThings float64 `cardsim:"round1"` } func (c *SmokeTestCollection) Average() float64 { @@ -23,3 +27,7 @@ func (c *SmokeTestCollection) Stats() []cardsim.Stat { cardsim.SortStats(stats) return stats } + +func (c *SmokeTestCollection) StatTotalThings() float64 { + return float64(c.Things+c.MoreThings) + c.FloatyThings +}