From 45bbfe4e8fbf73fb9b9561eae4e7bb27ca0a3646 Mon Sep 17 00:00:00 2001 From: Kistaro Windrider Date: Sun, 26 Mar 2023 23:40:44 -0700 Subject: [PATCH] Initial commit. No tests. Not complete. --- .idea/.gitignore | 8 + .idea/cardSimEngine.iml | 9 ++ .idea/modules.xml | 8 + .idea/vcs.xml | 6 + cardsim/card.go | 93 ++++++++++++ cardsim/doc.go | 2 + cardsim/errors.go | 221 +++++++++++++++++++++++++++ cardsim/messages.go | 27 ++++ cardsim/player.go | 16 ++ cardsim/rules.go | 326 ++++++++++++++++++++++++++++++++++++++++ cardsim/stats.go | 173 +++++++++++++++++++++ go.mod | 3 + 12 files changed, 892 insertions(+) create mode 100644 .idea/.gitignore create mode 100644 .idea/cardSimEngine.iml create mode 100644 .idea/modules.xml create mode 100644 .idea/vcs.xml create mode 100644 cardsim/card.go create mode 100644 cardsim/doc.go create mode 100644 cardsim/errors.go create mode 100644 cardsim/messages.go create mode 100644 cardsim/player.go create mode 100644 cardsim/rules.go create mode 100644 cardsim/stats.go create mode 100644 go.mod 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