5 Commits

Author SHA1 Message Date
8d9303c8bc stats manual in README 2023-04-04 12:29:36 -07:00
ad9e5764f1 Allow renaming of extracted stats via tag. 2023-04-04 12:14:04 -07:00
99e372a4db Fix it
Pointer vs. value receivers are... interesting.
2023-04-04 11:37:02 -07:00
3e34e25f54 Major stats upgrade.
StatLiteral: just emit a stat in the obvious way. Plus helper functions.

Can also identify stats via struct tags, no more Stored type!

Can also identify stat methods via name (with compatible types).
2023-04-04 11:12:07 -07:00
1464070339 Fix excess divider mess in message display. 2023-04-03 01:45:38 -07:00
8 changed files with 284 additions and 15 deletions

View File

@ -42,7 +42,7 @@ A bucket of game state.
### Stat ### Stat
An arbitrary variable (or function, if it's calculated) tagged with some stuff to make it easier to display to the player. An arbitrary variable (or function, if it's calculated) tagged with some stuff to make it easier to display to the player. Stats can be extracted automatically (see "Stat Extraction" below).
### StatsCollection ### StatsCollection
@ -57,3 +57,26 @@ There are some special errors that Rules can use to "communicate with" the rule
### Messages ### Messages
For now, strings but inconvenient. Intended to provide forwards compatibility when we eventually include some way to format text, where all the stuff written for "it's just a string" would break if not for having this extra type in the way to wrap it where we can stay compatible with "it's just a string" mode. For now, strings but inconvenient. Intended to provide forwards compatibility when we eventually include some way to format text, where all the stuff written for "it's just a string" would break if not for having this extra type in the way to wrap it where we can stay compatible with "it's just a string" mode.
## Stat Extraction
The function `ExtractStats` creates a stats list automatically by searching through a struct's fields and methods. The following things are recognized as stats:
* any method with a name like `StatFoo` or `HiddenStatFoo` (the latter are marked as invisible stats, which show up only in debug mode with the implementation in BasicStatsPane)
* any exported field with a type that is already a `Stat`; the `Stored[T]` and `Hidden[T]` generic types are containers for this
* any exported field tagged with `cardsim:"stat"`
* or `cardsim:"hidden"` for hidden stats. `"hiddenstat"` also works.
* you can use `"round2"` to round to two decimal places -- you can use any integer here, not just 2. works with both `float` types.
* `"hiddenround3"` (or any other number) creates a hidden rounded stat.
* To change the display name of a stat, use a separate tag phrase in addition to the stat tag, `cardsim_name:"name"`.
* For example: `cardsim:"stat" cardsim_name:"Stat Display Name"` creates a visible stat that shows up as "Stat Display Name".
* `cardsim:"hiddenround1" cardsim_name:"Hidden Rounded Stat"` creates an invisible stat that shows up, rounded to one decimal place, as "Hidden Rounded Stat".
Stat extraction can implement most or all of your type's `Stats` method for you:
```
func (e *ExampleType) Stats() []cardsim.Stat {
return cardsim.ExtractStats(e)
}
```
ExtractStats puts methods first (lexicographically), then fields (in the order they appear). You can use `cardsim.SortStats` to instead put visible stats before hidden stats, alphabetized (case-insensitive).

View File

@ -4,7 +4,11 @@ import (
"fmt" "fmt"
"reflect" "reflect"
"sort" "sort"
"strconv"
"strings" "strings"
"unicode"
"golang.org/x/exp/constraints"
) )
// A StatsCollection contains stats. // A StatsCollection contains stats.
@ -114,9 +118,30 @@ func (s statFunc[T]) Visible() bool {
return s.visible return s.visible
} }
// ExtractStats pulls all exported Stat fields (not functions) out of a struct. // ExtractStats pulls all exported stats out of a struct. It puts methods before
// If x cannot be resolved to a struct, it panics. It unwraps interfaces and // fields. If the calculated name of a method conflicts with the calculated
// follows pointers to try to find a struct. // 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.
//
// To override the name extracted from a field name, add `cardsim_name:"name"`
// to the tag, where the name part is the name you want to use. It will not be
// formatted further - use normal spaces, capitalization, etc.
func ExtractStats(x any) []Stat { func ExtractStats(x any) []Stat {
v := reflect.ValueOf(x) v := reflect.ValueOf(x)
for k := v.Kind(); k == reflect.Pointer || k == reflect.Interface; k = v.Kind() { for k := v.Kind(); k == reflect.Pointer || k == reflect.Interface; k = v.Kind() {
@ -125,19 +150,125 @@ func ExtractStats(x any) []Stat {
if v.Kind() != reflect.Struct { if v.Kind() != reflect.Struct {
panic(fmt.Errorf("%T is not a struct", x)) panic(fmt.Errorf("%T is not a struct", x))
} }
typ := v.Type()
var ret []Stat var ret []Stat
lim := v.NumField()
for i := 0; i < lim; i++ { known := make(map[string]bool)
f := v.Field(i) 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() { if !f.CanInterface() {
continue continue
} }
x := f.Interface() iface := f.Interface()
if s, ok := x.(Stat); ok { if s, ok := iface.(Stat); ok {
if known[s.StatName()] {
continue
}
known[s.StatName()] = true
ret = append(ret, s) 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 t := sf.Tag.Get("cardsim_name"); t != "" {
nm = t
}
if known[nm] {
continue
}
known[nm] = true
ret = append(ret, &StatLiteral{
Name: nm,
Value: val,
IsVisible: !isHidden,
})
continue
}
// Else, not a stat.
} }
return ret return ret
} }
@ -173,3 +304,105 @@ func (s statSorter) Less(i, j int) bool {
// Names differ only by capitalization, if that. // Names differ only by capitalization, if that.
return lhs.StatName() < rhs.StatName() 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
}

View File

@ -206,14 +206,11 @@ func displayMessageSection[C StatsCollection](p *Player[C]) bool {
if len(p.TemporaryMessages) == 0 { if len(p.TemporaryMessages) == 0 {
return false return false
} }
hasPrevious := false
for _, m := range p.TemporaryMessages { for _, m := range p.TemporaryMessages {
if m != nil { if m != nil {
if hasPrevious {
lightDivider()
}
display(m) display(m)
hasPrevious = true } else {
fmt.Println()
} }
} }
return true return true

1
go.mod
View File

@ -7,4 +7,5 @@ require github.com/kr/pretty v0.3.1
require ( require (
github.com/kr/text v0.2.0 // indirect github.com/kr/text v0.2.0 // indirect
github.com/rogpeppe/go-internal v1.9.0 // indirect github.com/rogpeppe/go-internal v1.9.0 // indirect
golang.org/x/exp v0.0.0-20230321023759-10a507213a29
) )

2
go.sum
View File

@ -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/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 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= 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=

View File

@ -85,7 +85,7 @@ func (i inverseDivision) OptionText(p *player) (cardsim.Message, error) {
func (i inverseDivision) Enact(p *player) (cardsim.Message, error) { func (i inverseDivision) Enact(p *player) (cardsim.Message, error) {
if p.Stats.Number.Value == 0 { if p.Stats.Number.Value == 0 {
return nil, errors.New("you can't divide by zero!") return nil, errors.New("you can't divide by zero")
} }
p.Stats.Number.Value = int(i) / p.Stats.Number.Value p.Stats.Number.Value = int(i) / p.Stats.Number.Value
return cardsim.MsgStr("Inverse divided."), nil return cardsim.MsgStr("Inverse divided."), nil

View File

@ -11,6 +11,11 @@ type SmokeTestCollection struct {
Turns cardsim.Invisible[int] Turns cardsim.Invisible[int]
Flavor cardsim.Stored[string] Flavor cardsim.Stored[string]
Things int `cardsim:"stat" cardsim_name:"A Renamed Thing"`
MoreThings int `cardsim:"hidden"`
FloatyThings float64 `cardsim:"round1"`
Label string `cardsim:"stat"`
} }
func (c *SmokeTestCollection) Average() float64 { func (c *SmokeTestCollection) Average() float64 {
@ -23,3 +28,7 @@ func (c *SmokeTestCollection) Stats() []cardsim.Stat {
cardsim.SortStats(stats) cardsim.SortStats(stats)
return stats return stats
} }
func (c *SmokeTestCollection) StatTotalThings() float64 {
return float64(c.Things+c.MoreThings) + c.FloatyThings
}

View File

@ -28,6 +28,10 @@ func main() {
Name: "Flavor", Name: "Flavor",
Value: "Lemon", Value: "Lemon",
}, },
Things: 5,
MoreThings: 9,
FloatyThings: 123.456,
Label: "whee",
}, },
) )
p.Name = "Dave" p.Name = "Dave"