diff --git a/.idea/.gitignore b/.idea/.gitignore
new file mode 100644
index 0000000..13566b8
--- /dev/null
+++ b/.idea/.gitignore
@@ -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
diff --git a/.idea/cardSimEngine.iml b/.idea/cardSimEngine.iml
new file mode 100644
index 0000000..5e764c4
--- /dev/null
+++ b/.idea/cardSimEngine.iml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/modules.xml b/.idea/modules.xml
new file mode 100644
index 0000000..4c4c428
--- /dev/null
+++ b/.idea/modules.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
new file mode 100644
index 0000000..35eb1dd
--- /dev/null
+++ b/.idea/vcs.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/cardsim/card.go b/cardsim/card.go
new file mode 100644
index 0000000..9e8f1ed
--- /dev/null
+++ b/cardsim/card.go
@@ -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)
+}
diff --git a/cardsim/doc.go b/cardsim/doc.go
new file mode 100644
index 0000000..8d0407d
--- /dev/null
+++ b/cardsim/doc.go
@@ -0,0 +1,2 @@
+// Package cardsim is a general engine for NationStates-like simulations.
+package cardsim
diff --git a/cardsim/errors.go b/cardsim/errors.go
new file mode 100644
index 0000000..7b5474f
--- /dev/null
+++ b/cardsim/errors.go
@@ -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}
+}
diff --git a/cardsim/messages.go b/cardsim/messages.go
new file mode 100644
index 0000000..b370696
--- /dev/null
+++ b/cardsim/messages.go
@@ -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...))
+}
diff --git a/cardsim/player.go b/cardsim/player.go
new file mode 100644
index 0000000..1c56c0e
--- /dev/null
+++ b/cardsim/player.go
@@ -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
+}
diff --git a/cardsim/rules.go b/cardsim/rules.go
new file mode 100644
index 0000000..6e0af2a
--- /dev/null
+++ b/cardsim/rules.go
@@ -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()
+}
diff --git a/cardsim/stats.go b/cardsim/stats.go
new file mode 100644
index 0000000..08aafce
--- /dev/null
+++ b/cardsim/stats.go
@@ -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()
+}
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..6af515c
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,3 @@
+module cardSimEngine
+
+go 1.20