Kistaro Windrider
3e34e25f54
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).
381 lines
8.8 KiB
Go
381 lines
8.8 KiB
Go
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 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() {
|
|
v = v.Elem()
|
|
}
|
|
if v.Kind() != reflect.Struct {
|
|
panic(fmt.Errorf("%T is not a struct", x))
|
|
}
|
|
typ := v.Type()
|
|
|
|
var ret []Stat
|
|
fields := reflect.VisibleFields(typ)
|
|
for _, sf := range fields {
|
|
if !sf.IsExported() {
|
|
continue
|
|
}
|
|
f := v.FieldByIndex(sf.Index)
|
|
if !f.CanInterface() {
|
|
continue
|
|
}
|
|
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
|
|
}
|
|
|
|
// 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
|
|
}
|