402 lines
9.3 KiB
Go
402 lines
9.3 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 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
|
|
}
|