Initial commit.
No tests. Not complete.
This commit is contained in:
parent
d820204e7f
commit
45bbfe4e8f
8
.idea/.gitignore
vendored
Normal file
8
.idea/.gitignore
vendored
Normal 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
9
.idea/cardSimEngine.iml
Normal 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
8
.idea/modules.xml
Normal 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
6
.idea/vcs.xml
Normal 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
93
cardsim/card.go
Normal 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
2
cardsim/doc.go
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
// Package cardsim is a general engine for NationStates-like simulations.
|
||||||
|
package cardsim
|
221
cardsim/errors.go
Normal file
221
cardsim/errors.go
Normal 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
27
cardsim/messages.go
Normal 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
16
cardsim/player.go
Normal 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
326
cardsim/rules.go
Normal 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
173
cardsim/stats.go
Normal 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()
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user