Initial commit.

No tests. Not complete.
This commit is contained in:
Kistaro Windrider 2023-03-26 23:40:44 -07:00
parent d820204e7f
commit 45bbfe4e8f
Signed by: kistaro
SSH Key Fingerprint: SHA256:TBE2ynfmJqsAf0CP6gsflA0q5X5wD5fVKWPsZ7eVUg8
12 changed files with 892 additions and 0 deletions

8
.idea/.gitignore vendored Normal file
View File

@ -0,0 +1,8 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

9
.idea/cardSimEngine.iml Normal file
View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="Go" enabled="true" />
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

8
.idea/modules.xml Normal file
View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/cardSimEngine.iml" filepath="$PROJECT_DIR$/.idea/cardSimEngine.iml" />
</modules>
</component>
</project>

6
.idea/vcs.xml Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

93
cardsim/card.go Normal file
View File

@ -0,0 +1,93 @@
package cardsim
// A Card represents an option available to the player. Its methods may be
// called many times per turn as the player considers their options.
type Card[C StatsCollection] interface {
// Title is the short name of the card displayed in the hand
// and at the top of the card output. It receives the current
// player as an argument. If it returns an error that is not
// a warning, the game crashes.
Title(p *Player[C]) (Message, error)
// EventText returns the text to display on the card. If it returns an
// error that is not a warning, the game crashes.
EventText(p *Player[C]) (Message, error)
// Options returns the possible actions the player can take for this card.
// There must be at least one option.
Options(p *Player[C]) ([]CardOption[C], error)
}
// A CardOption represents a choice a player could make for some card.
type CardOption[C StatsCollection] interface {
// OptionText returns the text displayed for this option. It may be called
// many times within a turn as the player considers their options. If it
// returns an error that is not a warning, the game crashes.
OptionText(p *Player[C]) (Message, error)
// Enact is called exactly once if the player chooses the option. It is
// expected to update values in `p`. It returns the text displayed to the
// player as a result of their action. If it returns an error that is not
// a warning, the game crashes.
//
// After an option is enacted, the card is deleted. If a card should be
// repeatable, Enact must return it to the deck (on every option).
Enact(p *Player[C]) (Message, error)
}
// A BasicCard is a Card with fixed title, text, and options.
type BasicCard[C StatsCollection] struct {
CardTitle Message
CardText Message
CardOptions []CardOption[C]
}
func (b *BasicCard[C]) Title(p *Player[C]) (Message, error) {
return b.CardTitle, nil
}
func (b *BasicCard[C]) EventText(p *Player[C]) (Message, error) {
return b.CardText, nil
}
func (b *BasicCard[C]) Options(_ *Player[C]) ([]CardOption[C], error) {
return b.CardOptions, nil
}
// A BasicOption is a CardOption with fixed text, effects, and output.
type BasicOption[C StatsCollection] struct {
Text Message
Effect func(*Player[C]) error
Output Message
}
// OptionText implements CardOption.
func (b *BasicOption[C]) OptionText(p *Player[C]) (Message, error) {
return b.Text, nil
}
// Enact implements CardOption.
func (b *BasicOption[C]) Enact(p *Player[C]) (Message, error) {
return b.Output, b.Effect(p)
}
// OptionFunc attaches a fixed prompt to an Enact-like function. Unlike
// BasicOption, the enactment function provided to OptionFunc returns
// the text that should be output as a result of the action, so it is
// possible to dynamically generate this text.
func OptionFunc[C StatsCollection](text Message, f func(*Player[C]) (Message, error)) CardOption[C] {
return &optionFunc[C]{text, f}
}
type optionFunc[C StatsCollection] struct {
text Message
f func(*Player[C]) (Message, error)
}
func (o *optionFunc[C]) OptionText(p *Player[C]) (Message, error) {
return o.text, nil
}
func (o *optionFunc[C]) Enact(p *Player[C]) (Message, error) {
return o.f(p)
}

2
cardsim/doc.go Normal file
View File

@ -0,0 +1,2 @@
// Package cardsim is a general engine for NationStates-like simulations.
package cardsim

221
cardsim/errors.go Normal file
View File

@ -0,0 +1,221 @@
package cardsim
import (
"errors"
"fmt"
"reflect"
"strings"
)
// Warning is an error that does not crash the game, but is displayed immediately.
type Warning struct {
E error
}
// AnyWarning is a special Warning, not intended to be returned from anything,
// which every Warning recognizes that they "are" -- errors.Is(w, AnyWarning)
// returns true when w is a Warning.
var AnyWarning = &Warning{
E: errors.New("unspecified warning"),
}
// Error implements the error interface. A warning's error message is the
// message of its underlying error, unmodified.
func (w *Warning) Error() string {
return w.E.Error()
}
// Unwrap implements the Go 1.13 error handling system by allow error identity
// searches to continue to the underlying error.
func (w *Warning) Unwrap() error {
return w.E
}
// Is recognizes every Warning as equivalent to AnyWarning.
func (*Warning) Is(target error) bool {
w, ok := target.(*Warning)
if !ok {
return false
}
return w == AnyWarning
}
// Warningf calls fmt.Errorf with the provided arguments, wraps the error
// created as a Warning, and returns it.
func Warningf(f string, args ...any) *Warning {
return &Warning{fmt.Errorf(f, args...)}
}
// A Failure is an error that is definitely not a Warning. If a Warning is
// wrapped in a Failure, it stops being a Warning.
type Failure struct {
e error
}
// Error implements error. It returns the message of the underlying error,
// unchanged.
func (f Failure) Error() string {
return f.e.Error()
}
type singleUnwrappable interface {
Unwrap() error
}
type multiUnwrappable interface {
Unwrap() []error
}
// Unwrap returns a new Failure wrapping the error wrapped by the underlying
// error, if any. If the underlying error wraps nothing, it returns nil.
func (f Failure) Unwrap() error {
if su, ok := f.e.(singleUnwrappable); ok {
w := su.Unwrap()
if w != nil {
// Do not wrap Failure in Failure, to avoid an infinite loop.
// If Failure already wraps Failure, this will "collapse" the
// chain down to a single Failure.
if f, ok := w.(Failure); ok {
return f
}
return Failure{w}
}
return nil
}
if mu, ok := f.e.(multiUnwrappable); ok {
w := mu.Unwrap()
if len(w) == 0 {
return nil
}
return aggregateFailure(w)
}
// Underlying error cannot be unwrapped.
return nil
}
// Is implements Go 1.13 error handling for a Failure. It specifically rejects
// the notion that it could be AnyWarning, but otherwise forwards the check
// to its underlying
// However, it allows comparisons to other warnings to proceed as normal.
func (f Failure) Is(target error) bool {
if w, ok := target.(*Warning); ok {
if w == AnyWarning {
return false
}
// Strip the warning of its warning-ness and recurse.
return errors.Is(f, w.E)
}
return false // let Unwrap handle the rest
}
// As implements Go 1.13 error handling for a Failure. It directly unpacks its
// contained error if possible, bypassing the "re-wrap in failure" behavior that
// Unwrap otherwise uses.
func (f Failure) As(target any) bool {
// Most of this algorithm is copied from the Go standard library.
val := reflect.ValueOf(target)
typ := val.Type()
if typ.Kind() != reflect.Pointer || val.IsNil() {
return false
}
if reflect.TypeOf(f.e).AssignableTo(typ.Elem()) {
val.Elem().Set(reflect.ValueOf(f.e))
return true
}
if asable, ok := f.e.(interface{ As(any) bool }); ok {
return asable.As(target)
}
return false
}
// ErrorCollector accumulates errors as an operation progresses. The zero
// ErrorCollector is its correct starting value. When it emits a final error:
//
// * If it contains exactly zero errors, it returns nil.
// * If it contains exactly one error, it returns it.
// * Otherwise, it returns an error that combines all the errors it collected.
// The aggregated error is a Warning if and only if all collected errors
// were also warnings.
type ErrorCollector struct {
errs []error
hasFailure bool
}
// Add inserts e into the error collection. If e is nil, this does nothing.
func (ec *ErrorCollector) Add(e error) {
if e == nil {
return
}
if !errors.Is(e, AnyWarning) {
ec.hasFailure = true
}
ec.errs = append(ec.errs, e)
}
// IsEmpty reports whether ec has zero accumulated errors/warnings.
func (ec *ErrorCollector) IsEmpty() bool {
return len(ec.errs) == 0
}
// HasFailure reports whether ec has at least one non-warning error.
func (ec *ErrorCollector) HasFailure() bool {
return ec.hasFailure
}
// Emit returns the final error from this ErrorCollector. Do not Add
// to this ErrorCollector after calling Emit, or the error it emitted might
// be modified.
//
// If the error collector has collected no errors, this returns nil. If it has
// collected exactly one error, it returns that error. Otherwise, it returns an
// aggregate error, which is itself a warning if and only if all aggregated
// errors are warnings. This may involve wrapping contained warnings, so
// errors.Is does not erroneously represent a failure as a warning because it
// contains a warning as a subcomponent.
func (ec *ErrorCollector) Emit() error {
if len(ec.errs) == 0 {
return nil
}
if len(ec.errs) == 1 {
return ec.errs[0]
}
if !ec.HasFailure() {
return aggregateFailure(ec.errs)
}
return &aggregateError{ec.errs} // all these are recognizable failures
}
// An aggregateError is a collection of errors that is itself an error.
type aggregateError struct {
errs []error
}
// Error returns a multi-line indented error message.
func (a *aggregateError) Error() string {
var messages []string
for i, e := range a.errs {
m := fmt.Sprintf("\t[%d]:\t%s", i, strings.ReplaceAll(e.Error(), "\n", "\n\t\t"))
messages = append(messages, m)
}
return fmt.Sprintf("%d errors: <\n%s\n>", len(a.errs), strings.Join(messages, "\n"))
}
// Unwrap implements Go 1.13 error handling. It returns its contained errors.
func (a *aggregateError) Unwrap() []error {
return a.errs
}
// aggregateFailure wraps every error in a slice in a Failure, cancelling any warnings.
func aggregateFailure(errs []error) *aggregateError {
ret := make([]error, len(errs))
for i, e := range errs {
if f, ok := e.(Failure); ok {
ret[i] = f
} else {
ret[i] = Failure{e}
}
}
return &aggregateError{ret}
}

27
cardsim/messages.go Normal file
View File

@ -0,0 +1,27 @@
package cardsim
import "fmt"
// Message is an opaque interface representing a displayable message.
// Using an interface here allows for implementation of new message display
// and formatting features without rewriting all existing callers.
type Message interface {
fmt.Stringer
}
type stringMessage string
func (s stringMessage) String() string {
return string(s)
}
// MsgStr returns a Message representing a fixed string.
func MsgStr(s string) Message {
return stringMessage(s)
}
// Msgf is a Sprintf-like function that produces a Message equivalent to the
// one created by MsgStr.
func Msgf(f string, args ...any) Message {
return stringMessage(fmt.Sprintf(f, args...))
}

16
cardsim/player.go Normal file
View File

@ -0,0 +1,16 @@
package cardsim
import "math/rand"
// Player stores all gameplay state for one player.
type Player[C StatsCollection] struct {
Stats C
Name string
Deck []Card[C]
Hand []Card[C]
HandLimit int
Rules *RuleCollection[C]
Rand rand.Rand
Turn int
PendingMessages []Message
}

326
cardsim/rules.go Normal file
View File

@ -0,0 +1,326 @@
package cardsim
import (
"errors"
"fmt"
"sort"
)
// A Rule implements an operation run on every game turn.
type Rule[C StatsCollection] interface {
// Label is an internal name the rule can be recognized by.
// Some things may be easier if it is unique, but it does not have to be.
// Label must be constant for the life of the Rule, including after the
// Rule is removed from the collection (it is called as part of the
// removal process).
Label() string
// Step returns which numerical step this rule should be executed on.
// It is somewhat arbitrary. Rules are enacted in order from lowest step
// to highest step; ties are broken randomly every turn. Step must be
// constant for the life of the Rule, including after the Rule is removed
// from the collection (it is called during removal).
Step() int
// Enact operates the rule. Some special errors are recognized and change
// the flow of the turn:
//
// * ErrHaltTurn skips all further rules (including rules on the same step)
// and immediately proceeds to the next turn.
// * ErrDeleteRule removes the rule from the collection. It will not be
// executed on future turns unless it is reinserted into the active
// ruleset (by some other rule, option, etc.)
// * ErrHaltDelete acts like both ErrHaltTurn and ErrDeleteRule.
// * Any Warning is collected and displayed at the end of the turn.
// * Any other error crashes the game.
Enact(*Player[C]) error
}
var (
ErrHaltTurn = errors.New("turn halted")
ErrDeleteRule = errors.New("delete this rule")
ErrHaltDelete = fmt.Errorf("%w; %w", ErrHaltTurn, ErrDeleteRule)
// ErrNotUnique matches errors from functions attempting to identify
// a single rule by an identifier that is not unique.
ErrNotUnique = errors.New("not unique")
)
// RuleFunc implements a Rule represented by a single function. It is the most
// common type of Rule. (You'll probably only want to implement a full Rule
// type if the rule needs to have fields of its own or be identifiable by
// something other than its Name.)
type RuleFunc[C StatsCollection] struct {
Name string
Seq int
F func(*Player[C]) error
}
func (r *RuleFunc[C]) Label() string {
return r.Name
}
func (r *RuleFunc[C]) Step() int {
return r.Seq
}
func (r *RuleFunc[C]) Enact(p *Player[C]) error {
return r.F(p)
}
// RuleID is a unique opaque ID for a rule inserted in a collection.
type RuleID uint64
// keyedRule attaches a unique ID to each Rule.
type keyedRule[C StatsCollection] struct {
id RuleID
Rule[C]
}
// RuleCollection stores, organizes, and runs all rules currently active for a Player.
type RuleCollection[C StatsCollection] struct {
rules map[RuleID]*keyedRule[C]
byStep map[int][]RuleID
byLabel map[string][]RuleID
nextID RuleID
steps []int
}
// NewRuleCollection initializes an empty RuleCollection.
func NewRuleCollection[C StatsCollection]() *RuleCollection[C] {
return &RuleCollection[C]{
rules: make(map[RuleID]*keyedRule[C]),
byStep: make(map[int][]RuleID),
byLabel: make(map[string][]RuleID),
nextID: 1,
}
}
// Insert adds a Rule to a RuleCollection, returning the ID it assigned to the rule.
func (r *RuleCollection[C]) Insert(rule Rule[C]) RuleID {
id := r.nextID
r.nextID++
k := &keyedRule[C]{id, rule}
r.rules[id] = k
s := r.byStep[k.Step()]
if s == nil {
r.steps = nil
}
s = append(s, id)
r.byStep[k.Step()] = s
lbl := r.byLabel[k.Label()]
lbl = append(lbl, id)
r.byLabel[k.Label()] = lbl
return id
}
// RemoveID removes the rule with the given ID from the collection, if present.
// It returns whether it found and removed anything.
func (r *RuleCollection[C]) RemoveID(id RuleID) bool {
rule := r.rules[id]
if rule == nil {
return false
}
if rule.id != id {
panic(fmt.Errorf("rule stored in slot %d had internal ID %d", id, rule.id))
}
delete(r.rules, id)
r.removeFromLabel(rule)
r.removeFromStep(rule)
return true
}
// removeFromStep removes a specific ID from r.byStep, if present.
// It's fine for this rule (or this entire step) to be gone already.
func (r *RuleCollection[C]) removeFromStep(rule *keyedRule[C]) {
s := r.byStep[rule.Step()]
for i, v := range s {
if v == rule.id {
// special case: only item?
if len(s) == 1 {
s = nil
delete(r.byStep, rule.Step())
r.steps = nil
break
}
end := len(s) - 1
if i != end {
s[i], s[end] = s[end], s[i]
}
s = s[:end]
break
}
}
if s != nil {
r.byStep[rule.Step()] = s
}
}
// removeFromLabel removes a specific ID from r.byLabel, if present.
// It's fine for this rule (or this entire label) to be gone already.
func (r *RuleCollection[C]) removeFromLabel(rule *keyedRule[C]) {
lbl := r.byLabel[rule.Label()]
for i, v := range lbl {
if v == rule.id {
// special case: only item?
if len(lbl) == 1 {
lbl = nil
delete(r.byLabel, rule.Label())
break
}
end := len(lbl) - 1
if i != end {
lbl[i], lbl[end] = lbl[end], lbl[i]
}
lbl = lbl[:end]
break
}
}
if lbl != nil {
r.byLabel[rule.Label()] = lbl
}
}
// RemoveUniqueLabel removes the sole rule with a specific label. If there are
// no rules with that label, this returns false and nil. If there is exactly
// one rule with that label, it is removed and this returns true and nil.
// If there are multiple rules with this label, this returns false and
// an error like ErrNotUnique.
func (r *RuleCollection[C]) RemoveUniqueLabel(label string) (bool, error) {
target := r.byLabel[label]
if len(target) == 0 {
return false, nil
}
if len(target) != 1 {
return false, fmt.Errorf("%w: %q", ErrNotUnique, label)
}
id := target[0]
rule := r.rules[id]
delete(r.rules, id)
delete(r.byLabel, label)
r.removeFromStep(rule)
return true, nil
}
// RemoveAllLabel removes every rule with a specific label. It returns how many
// rules were thus removed.
func (r *RuleCollection[C]) RemoveAllLabel(label string) int {
target := r.byLabel[label]
delete(r.byLabel, label)
for _, t := range target {
// RemoveID works fine when the label is already completely gone; it
// calls into removeFromLabel, which is a no-op in this case.
r.RemoveID(t)
}
return len(target)
}
// RemoveUniqueStep removes the sole rule on a specific step. Its semantics
// are equivalent to RemoveUniqueLabel.
func (r *RuleCollection[C]) RemoveUniqueStep(step int) (bool, error) {
target := r.byStep[step]
if len(target) == 0 {
return false, nil
}
if len(target) != 1 {
return false, fmt.Errorf("%w: %d", ErrNotUnique, step)
}
id := target[0]
rule := r.rules[id]
delete(r.rules, id)
delete(r.byStep, step)
r.removeFromLabel(rule)
return true, nil
}
// RemoveAllStep removes every rule on a specific step. It returns how many
// rules were thus removed.
func (r *RuleCollection[C]) RemoveAllStep(step int) int {
target := r.byStep[step]
if target == nil {
return 0
}
delete(r.byStep, step)
r.steps = nil
for _, t := range target {
// RemoveID works fine when the label is already completely gone; it
// calls into removeFromLabel, which is a no-op in this case.
r.RemoveID(t)
}
return len(target)
}
// Run runs all rules in the collection against the specified Player, in
// step order, with ties broken randomly.
//
// It stops early if it observes a failure other than the errors described in
// Rule.Enact as having special meaning to the rules engine.
func (r *RuleCollection[C]) Run(p *Player[C]) error {
steps := r.steps
if steps == nil {
// Step set changed, recalculate.
steps := make([]int, 0, len(r.byStep))
for step := range r.byStep {
steps = append(steps, step)
}
sort.Ints(steps)
r.steps = steps
}
var errs ErrorCollector
for _, step := range steps {
stepRules := r.byStep[step]
p.Rand.Shuffle(len(stepRules), func(i, j int) {
stepRules[i], stepRules[j] = stepRules[j], stepRules[i]
})
var remove []RuleID
halt := false
for _, id := range stepRules {
rule := r.rules[id]
err := rule.Enact(p)
if err != nil {
ignore := false
if errors.Is(err, ErrDeleteRule) {
remove = append(remove, id)
ignore = true
}
if errors.Is(err, ErrHaltTurn) {
halt = true
ignore = true
}
if !ignore {
errs.Add(err)
if !errors.Is(err, AnyWarning) {
halt = true
}
}
}
if halt {
break
}
}
// Remove rules flagged for removal. We're done iterating this set of
// steps so removing the rule from the step collection won't mess up
// iteration, and we're iterating off a local reference to the list of
// steps so we also won't mess up iteration if we drop the list because
// we removed the last rule from this step.
for _, id := range remove {
if !r.RemoveID(id) {
panic(fmt.Errorf("can't find rule %d after it asked to remove itself", id))
}
}
if halt {
return errs.Emit()
}
}
return errs.Emit()
}

173
cardsim/stats.go Normal file
View File

@ -0,0 +1,173 @@
package cardsim
import (
"fmt"
"reflect"
"sort"
"strings"
)
// 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. Stats will be presented
// to the player in this order.
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 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.
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))
}
var ret []Stat
lim := v.NumField()
for i := 0; i < lim; i++ {
f := v.Field(i)
if !f.CanInterface() {
continue
}
x := f.Interface()
if s, ok := x.(Stat); ok {
ret = append(ret, s)
}
}
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()
}

3
go.mod Normal file
View File

@ -0,0 +1,3 @@
module cardSimEngine
go 1.20