Compare commits
32 Commits
a6b2c92f86
...
v0.1.3
Author | SHA1 | Date | |
---|---|---|---|
1464070339
|
|||
066ec431ff
|
|||
3455579be6
|
|||
5a7cb58707
|
|||
74a2493ef4
|
|||
2c2e68ff93
|
|||
d13e04e2f4
|
|||
57348f7ebf
|
|||
9796c2e970
|
|||
0f21020647
|
|||
e96d81a7b4
|
|||
3a7bf9c2fb
|
|||
00ea284cbc
|
|||
159f6b6b5f
|
|||
2480a1631b
|
|||
2875dc5af8
|
|||
74ca51b21d
|
|||
5a2158f525
|
|||
25a9eed3f0
|
|||
592c877852
|
|||
e1eac9de0f
|
|||
7371cddab3
|
|||
aecd8683b2
|
|||
a62de999ea
|
|||
3eb087201f
|
|||
c73545fd07
|
|||
34e1f3166f
|
|||
af9d9a6579
|
|||
9e659ecf41
|
|||
09fdf19948
|
|||
45e1eeebf7
|
|||
576a2dd69e
|
2
.gitignore
vendored
2
.gitignore
vendored
@ -21,3 +21,5 @@
|
||||
# Go workspace file
|
||||
go.work
|
||||
|
||||
# Visual studio Code exclusions
|
||||
.vscode/settings.json
|
||||
|
@ -2,6 +2,8 @@
|
||||
|
||||
Basic engine for NationStates-like "make decisions on issues" simulation games. Very incomplete.
|
||||
|
||||
[](https://pkg.go.dev/git.chromaticdragon.app/kistaro/CardSimEngine)
|
||||
|
||||
## General turn model
|
||||
|
||||
1. Player has a hand of cards. Each card has one or more actions available.
|
||||
|
@ -5,9 +5,8 @@ package cardsim
|
||||
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)
|
||||
// player as an argument.
|
||||
Title(p *Player[C]) Message
|
||||
|
||||
// Urgent reports whether the card is considered urgent. If
|
||||
// the player has any urgent cards in hand, they cannot choose to act
|
||||
@ -50,8 +49,12 @@ type CardOption[C StatsCollection] interface {
|
||||
// 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).
|
||||
// repeatable, Enact must return it to the deck (on every option) or
|
||||
// the card needs to reinsert itself with its Then function.
|
||||
Enact(p *Player[C]) (Message, error)
|
||||
|
||||
// Enabled returns whether this option can curently be enacted.
|
||||
Enabled(p *Player[C]) bool
|
||||
}
|
||||
|
||||
// A BasicCard is a Card with fixed title, text, options, and optional
|
||||
@ -61,37 +64,45 @@ type BasicCard[C StatsCollection] struct {
|
||||
IsUrgent bool
|
||||
CardText Message
|
||||
CardOptions []CardOption[C]
|
||||
AfterOption func(p *Player[C], option CardOption[C]) error
|
||||
// AfterOption is given the card itself as its first argument.
|
||||
AfterOption func(c Card[C], p *Player[C], option CardOption[C]) error
|
||||
}
|
||||
|
||||
func (b *BasicCard[C]) Title(p *Player[C]) (Message, error) {
|
||||
return b.CardTitle, nil
|
||||
// Title implements Card.
|
||||
func (b *BasicCard[C]) Title(_ *Player[C]) Message {
|
||||
return b.CardTitle
|
||||
}
|
||||
|
||||
// Urgent implements Card.
|
||||
func (b *BasicCard[C]) Urgent(_ *Player[C]) bool {
|
||||
return b.IsUrgent
|
||||
}
|
||||
|
||||
func (b *BasicCard[C]) EventText(p *Player[C]) (Message, error) {
|
||||
// EventText implements Card.
|
||||
func (b *BasicCard[C]) EventText(_ *Player[C]) (Message, error) {
|
||||
return b.CardText, nil
|
||||
}
|
||||
|
||||
// Options implements Card.
|
||||
func (b *BasicCard[C]) Options(_ *Player[C]) ([]CardOption[C], error) {
|
||||
return b.CardOptions, nil
|
||||
}
|
||||
|
||||
// Then implements Card.
|
||||
func (b *BasicCard[C]) Then(p *Player[C], option CardOption[C]) error {
|
||||
if b.AfterOption == nil {
|
||||
return nil
|
||||
}
|
||||
return b.AfterOption(p, option)
|
||||
return b.AfterOption(b, p, option)
|
||||
}
|
||||
|
||||
func (b *BasicCard[C]) Drawn(p *Player[C]) bool {
|
||||
// Drawn implements Card.
|
||||
func (b *BasicCard[C]) Drawn(_ *Player[C]) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// A BasicOption is a CardOption with fixed text, effects, and output.
|
||||
// It's always enabled.
|
||||
type BasicOption[C StatsCollection] struct {
|
||||
Text Message
|
||||
Effect func(*Player[C]) error
|
||||
@ -108,6 +119,11 @@ func (b *BasicOption[C]) Enact(p *Player[C]) (Message, error) {
|
||||
return b.Output, b.Effect(p)
|
||||
}
|
||||
|
||||
// Enabled implements CardOption.
|
||||
func (b *BasicOption[C]) Enabled(p *Player[C]) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// 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
|
||||
@ -121,10 +137,17 @@ type optionFunc[C StatsCollection] struct {
|
||||
f func(*Player[C]) (Message, error)
|
||||
}
|
||||
|
||||
// OptionText implements CardOption.
|
||||
func (o *optionFunc[C]) OptionText(p *Player[C]) (Message, error) {
|
||||
return o.text, nil
|
||||
}
|
||||
|
||||
// Enact implements CardOption.
|
||||
func (o *optionFunc[C]) Enact(p *Player[C]) (Message, error) {
|
||||
return o.f(p)
|
||||
}
|
||||
|
||||
// Enabled implements CardOption.
|
||||
func (o *optionFunc[C]) Enabled(p *Player[C]) bool {
|
||||
return true
|
||||
}
|
||||
|
@ -52,6 +52,15 @@ func Warningf(f string, args ...any) *Warning {
|
||||
return &Warning{fmt.Errorf(f, args...)}
|
||||
}
|
||||
|
||||
// IsSeriousError returns whether e is a non-warning error. If e is nil, this
|
||||
// returns false.
|
||||
func IsSeriousError(e error) bool {
|
||||
if e == nil {
|
||||
return false
|
||||
}
|
||||
return !errors.Is(e, AnyWarning)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@ -144,8 +153,11 @@ func (f Failure) As(target any) bool {
|
||||
// - 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.
|
||||
//
|
||||
// An ErrorCollector's Errs should not be written by anything other than Add,
|
||||
// or HasFailure may get out of sync.
|
||||
type ErrorCollector struct {
|
||||
errs []error
|
||||
Errs []error
|
||||
hasFailure bool
|
||||
}
|
||||
|
||||
@ -157,12 +169,12 @@ func (ec *ErrorCollector) Add(e error) {
|
||||
if !errors.Is(e, AnyWarning) {
|
||||
ec.hasFailure = true
|
||||
}
|
||||
ec.errs = append(ec.errs, e)
|
||||
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
|
||||
return len(ec.Errs) == 0
|
||||
}
|
||||
|
||||
// HasFailure reports whether ec has at least one non-warning error.
|
||||
@ -181,16 +193,16 @@ func (ec *ErrorCollector) HasFailure() bool {
|
||||
// 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 {
|
||||
if len(ec.Errs) == 0 {
|
||||
return nil
|
||||
}
|
||||
if len(ec.errs) == 1 {
|
||||
return ec.errs[0]
|
||||
if len(ec.Errs) == 1 {
|
||||
return ec.Errs[0]
|
||||
}
|
||||
if ec.HasFailure() {
|
||||
return aggregateFailure(ec.errs)
|
||||
return aggregateFailure(ec.Errs)
|
||||
}
|
||||
return &aggregateError{ec.errs} // all these are recognizable warnings
|
||||
return &aggregateError{ec.Errs} // all these are recognizable warnings
|
||||
}
|
||||
|
||||
// An aggregateError is a collection of errors that is itself an error.
|
||||
|
@ -11,7 +11,7 @@ import (
|
||||
type InfoPanel[C StatsCollection] interface {
|
||||
// Title returns the title of this InfoPanel, which is also used as the
|
||||
// label presented to the player to access this panel.
|
||||
Title(p *Player[C]) (Message, error)
|
||||
Title(p *Player[C]) Message
|
||||
|
||||
// Info returns the displayable contents of this InfoPanel. A nil Message
|
||||
// in the output is interpreted as a paragraph break.
|
||||
@ -44,8 +44,8 @@ func VisibleOrDebug[C StatsCollection](p *Player[C], s Stat) bool {
|
||||
}
|
||||
|
||||
// Title implements `InfoPanel[C]` by returning b's `Name`.
|
||||
func (b *BasicStatsPanel[C]) Title(p *Player[C]) (Message, error) {
|
||||
return b.Name, nil
|
||||
func (b *BasicStatsPanel[C]) Title(p *Player[C]) Message {
|
||||
return b.Name
|
||||
}
|
||||
|
||||
// Info implements `InfoPanel[C]` by dumpiing p.Stats, showing those items for
|
||||
|
@ -1,6 +1,9 @@
|
||||
package cardsim
|
||||
|
||||
import "fmt"
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Message is an opaque interface representing a displayable message.
|
||||
// Using an interface here allows for implementation of new message display
|
||||
@ -25,3 +28,50 @@ func MsgStr(s string) Message {
|
||||
func Msgf(f string, args ...any) Message {
|
||||
return stringMessage(fmt.Sprintf(f, args...))
|
||||
}
|
||||
|
||||
// A SpecialMessage is a specific, uniquely identifiable message.
|
||||
type SpecialMessage struct {
|
||||
msg Message
|
||||
}
|
||||
|
||||
// String implements Message.
|
||||
func (s *SpecialMessage) String() string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
return s.msg.String()
|
||||
}
|
||||
|
||||
// Messages that various display surfaces or other components may have a special interpretation of
|
||||
// and identify specifically. These are largely sentinel values. Nil is a paragraph break.
|
||||
var (
|
||||
SectionBreak = &SpecialMessage{MsgStr(" -------------------------------------------------------------------- ")}
|
||||
ChapterBreak = &SpecialMessage{MsgStr(" ==================================================================== ")}
|
||||
)
|
||||
|
||||
// IsSpecialMessage returns whether a provided Message is a specific SpecialMessage.
|
||||
func IsSpecialMessage(m Message, s *SpecialMessage) bool {
|
||||
if m == nil {
|
||||
return s == nil
|
||||
}
|
||||
if s2, ok := m.(*SpecialMessage); ok {
|
||||
return s == s2
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// MultiMessage is a sequence of messages treated like one message.
|
||||
type MultiMessage []Message
|
||||
|
||||
func (m MultiMessage) String() string {
|
||||
s := make([]string, len(m))
|
||||
for i, msg := range m {
|
||||
if msg == nil {
|
||||
s[i] = ""
|
||||
continue
|
||||
}
|
||||
s[i] = msg.String()
|
||||
continue
|
||||
}
|
||||
return strings.Join(s, "\n")
|
||||
}
|
||||
|
@ -1,6 +1,21 @@
|
||||
package cardsim
|
||||
|
||||
import "math/rand"
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrUncooperativeCards = errors.New("a milion cards refused to join the hand")
|
||||
ErrInvalidCard = errors.New("invalid card specified")
|
||||
ErrInvalidChoice = errors.New("invalid choice specified")
|
||||
ErrNotUrgent = errors.New("action not urgent when urgent card is available")
|
||||
ErrNoActions = errors.New("no actions remaining")
|
||||
|
||||
WarningStalemate = errors.New("no actions can be taken")
|
||||
)
|
||||
|
||||
// Player stores all gameplay state for one player at a specific point in time.
|
||||
// Game-specific data is stored in Stats.
|
||||
@ -59,7 +74,7 @@ type Player[C StatsCollection] struct {
|
||||
Name string
|
||||
|
||||
// Rand is a source of randomness that other components can use.
|
||||
Rand rand.Rand
|
||||
Rand *rand.Rand
|
||||
|
||||
Deck *Deck[C]
|
||||
Hand []Card[C]
|
||||
@ -83,7 +98,10 @@ type Player[C StatsCollection] struct {
|
||||
ActionsPerTurn int
|
||||
ActionsRemaining int
|
||||
|
||||
// PermanentActions are an "extra hand" of cards that are not discarded when used.
|
||||
// PermanentActions are an "extra hand" of cards that are not discarded when
|
||||
// used. An Urgent PermanentAction does not block non-urgent actions and
|
||||
// cards in hand from being used, but it can be used even when an urgent
|
||||
// card is in the hand.
|
||||
PermanentActions []Card[C]
|
||||
|
||||
// InfoPanels lists informational views available to the player. The Prompt
|
||||
@ -134,7 +152,331 @@ const (
|
||||
GameStalled
|
||||
)
|
||||
|
||||
// InitPlayer returns a mostly-uninitialized Player with the fields
|
||||
// that require specific initialization already configured and the
|
||||
// provided StatsCollection (if any) already assigned to its Stats.
|
||||
// Most fields are not configured and need to be assigned after this.
|
||||
//
|
||||
// The Player is initialized with an empty deck, empty rule collection,
|
||||
// a hand limit of 1, an actions-per-turn limit of 1, and a random
|
||||
// number generator seeded with the nanosecond component of the current time.
|
||||
// The Deck shares this random number generator.
|
||||
func InitPlayer[C StatsCollection](stats C) *Player[C] {
|
||||
r := rand.New(rand.NewSource(time.Now().UnixNano()))
|
||||
return &Player[C]{
|
||||
Stats: stats,
|
||||
Rand: r,
|
||||
Deck: &Deck[C]{rand: r},
|
||||
HandLimit: 1,
|
||||
ActionsPerTurn: 1,
|
||||
Rules: NewRuleCollection[C](),
|
||||
}
|
||||
}
|
||||
|
||||
// Over returns whether this state represents a game that is over.
|
||||
func (g GameState) Over() bool {
|
||||
return g == GameLost || g == GameWon || g == GameCrashed || g == GameStalled
|
||||
}
|
||||
|
||||
// ChapterBreak apends a chapter break to p.TemporaryMessages, unless it is
|
||||
// empty or the most recent non-nil message is already a chapter break.
|
||||
func (p *Player[C]) ChapterBreak() {
|
||||
for i := len(p.TemporaryMessages) - 1; i >= 0; i-- {
|
||||
m := p.TemporaryMessages[i]
|
||||
if IsSpecialMessage(m, ChapterBreak) {
|
||||
return
|
||||
}
|
||||
if p.TemporaryMessages[i] != nil {
|
||||
p.TemporaryMessages = append(p.TemporaryMessages, ChapterBreak)
|
||||
return
|
||||
}
|
||||
}
|
||||
// No non-nil messages -- nothing to do.
|
||||
}
|
||||
|
||||
// SectionBreak apends a section break to p.TemporaryMessages, unless it is
|
||||
// empty or the most recent non-nil message is already a section/chapter break.
|
||||
func (p *Player[C]) SectionBreak() {
|
||||
for i := len(p.TemporaryMessages) - 1; i >= 0; i-- {
|
||||
m := p.TemporaryMessages[i]
|
||||
if IsSpecialMessage(m, ChapterBreak) || IsSpecialMessage(m, SectionBreak) {
|
||||
return
|
||||
}
|
||||
if m != nil {
|
||||
p.TemporaryMessages = append(p.TemporaryMessages, SectionBreak)
|
||||
return
|
||||
}
|
||||
}
|
||||
// No non-nil messages -- nothing to do.
|
||||
}
|
||||
|
||||
// Simulate executes the simulation up to the start of the next turn. If the
|
||||
// simulation crashes, the game state becomes GameCrashed. This returns any
|
||||
// generated errors; if the debugging mode is 0 or greater, they also become
|
||||
// temporary messages for the next turn.
|
||||
func (p *Player[C]) Simulate() error {
|
||||
var errs ErrorCollector
|
||||
p.TemporaryMessages = nil
|
||||
p.TemporaryPanels = nil
|
||||
errs.Add(p.Rules.Run(p))
|
||||
errs.Add(p.StartNextTurn())
|
||||
|
||||
if errs.HasFailure() {
|
||||
p.State = GameCrashed
|
||||
}
|
||||
|
||||
if p.DebugLevel > 0 && !errs.IsEmpty() {
|
||||
p.ChapterBreak()
|
||||
p.TemporaryMessages = append(p.TemporaryMessages, Msgf("%d ERRORS AND WARNINGS while simulating turn:", len(errs.Errs)))
|
||||
for i, e := range errs.Errs {
|
||||
yikes := " "
|
||||
if IsSeriousError(e) {
|
||||
yikes = "!"
|
||||
}
|
||||
p.TemporaryMessages = append(p.TemporaryMessages, Msgf("%s[%d]:\t%v", yikes, i, e))
|
||||
}
|
||||
}
|
||||
return errs.Emit()
|
||||
}
|
||||
|
||||
// StartNextTurn increments the turn counter, resets the action counter,
|
||||
// and draws back up to full. If the player cannot take any actions after
|
||||
// drawing is complete, the game stalls. (If a drawn card would like to
|
||||
// force the player to take no actions for a turn, the best approach is to
|
||||
// make that card Urgent and make it reset ActionsRemaining to 0 after it runs.)
|
||||
func (p *Player[C]) StartNextTurn() error {
|
||||
var errs ErrorCollector
|
||||
p.TurnNumber++
|
||||
p.ActionsRemaining = p.ActionsPerTurn
|
||||
errs.Add(p.FillHand())
|
||||
if len(p.Hand)+len(p.PermanentActions) == 0 {
|
||||
p.State = GameStalled
|
||||
errs.Add(Warningf("%w: no cards in hand, no permanent actions", WarningStalemate))
|
||||
}
|
||||
if p.ActionsRemaining == 0 {
|
||||
p.State = GameStalled
|
||||
errs.Add(Warningf("%w: 0 actions available in the turn", WarningStalemate))
|
||||
}
|
||||
return errs.Emit()
|
||||
}
|
||||
|
||||
// Draw draws a card into the hand, informing the card that it has been drawn.
|
||||
// If more than a million cards refuse to enter the hand, this crashes with
|
||||
// ErrUncooperativeCards. If the deck does not have enough cards, this
|
||||
// returns WarningTooFewCards.
|
||||
func (p *Player[C]) Draw() error {
|
||||
for attempts := 0; attempts < 1000000; attempts++ {
|
||||
if p.Deck.Len() == 0 {
|
||||
return WarningTooFewCards
|
||||
}
|
||||
c := p.Deck.Draw()
|
||||
if c.Drawn(p) {
|
||||
p.Hand = append(p.Hand, c)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return ErrUncooperativeCards
|
||||
}
|
||||
|
||||
// FillHand draws up to the hand limit, informing cards that they have been
|
||||
// drawn. If more than a million cards refuse to enter the hand, this crashes
|
||||
// with ErrUncooperativeCards. If the deck does not have enough cards, this
|
||||
// returns WarningTooFewCards.
|
||||
func (p *Player[C]) FillHand() error {
|
||||
var lastErr error
|
||||
for p.Deck.Len() > 0 && len(p.Hand) < p.HandLimit {
|
||||
lastErr = p.Draw()
|
||||
if IsSeriousError(lastErr) {
|
||||
return lastErr
|
||||
}
|
||||
}
|
||||
|
||||
if len(p.Hand) >= p.HandLimit {
|
||||
return nil
|
||||
}
|
||||
|
||||
return WarningTooFewCards
|
||||
}
|
||||
|
||||
// HasUrgentCards returns whether any cards in the Hand think they are Urgent.
|
||||
func (p *Player[C]) HasUrgentCards() bool {
|
||||
for _, c := range p.Hand {
|
||||
if c.Urgent(p) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// EnactCardUnchecked executes a card choice, removes it from the hand, and
|
||||
// decrements the ActionsRemaining. It does not check for conflicting Urgent
|
||||
// cards or already being out of actions. If no such card or card choice
|
||||
// exists, or the specified choice is not enabled, this returns nil and
|
||||
// ErrInvalidCard/ErrInvalidChoice without changing anything. Otherwise, this
|
||||
// returns the result of enacting the card. If enacting the card causes a
|
||||
// serious error, the State becomes GameCrashed.
|
||||
func (p *Player[C]) EnactCardUnchecked(cardIdx, choiceIdx int) (Message, error) {
|
||||
if cardIdx < 0 || cardIdx >= len(p.Hand) {
|
||||
return nil, fmt.Errorf("%w: no card #%d when %d cards in hand", ErrInvalidCard, cardIdx, len(p.Hand))
|
||||
}
|
||||
card := p.Hand[cardIdx]
|
||||
var errs ErrorCollector
|
||||
options, err := card.Options(p)
|
||||
if IsSeriousError(err) {
|
||||
p.State = GameCrashed
|
||||
return nil, err
|
||||
}
|
||||
errs.Add(err)
|
||||
if choiceIdx < 0 || choiceIdx > len(options) {
|
||||
errs.Add(fmt.Errorf("%w: no option #%d on card #%d with %d options", ErrInvalidChoice, choiceIdx, cardIdx, len(options)))
|
||||
return nil, errs.Emit()
|
||||
}
|
||||
|
||||
chosen := options[choiceIdx]
|
||||
if !chosen.Enabled(p) {
|
||||
errs.Add(fmt.Errorf("%w: option %d on card %d was not enabled", ErrInvalidChoice, choiceIdx, cardIdx))
|
||||
return nil, errs.Emit()
|
||||
}
|
||||
|
||||
p.Hand = DeleteFrom(p.Hand, cardIdx)
|
||||
p.ActionsRemaining--
|
||||
|
||||
ret, err := options[choiceIdx].Enact(p)
|
||||
errs.Add(err)
|
||||
if IsSeriousError(err) {
|
||||
p.State = GameCrashed
|
||||
return ret, errs.Emit()
|
||||
}
|
||||
|
||||
err = card.Then(p, options[choiceIdx])
|
||||
errs.Add(err)
|
||||
if IsSeriousError(err) {
|
||||
p.State = GameCrashed
|
||||
}
|
||||
return ret, errs.Emit()
|
||||
}
|
||||
|
||||
// EnactCard executes a card choice, removes it from the hand, and decrements
|
||||
// the ActionsRemaining. If the card is not Urgent but urgent cards are
|
||||
// available, or the player is out of actions, this returns ErrNotUrgent or
|
||||
// ErrNoActions. Otherwise, this acts like EnactCardUnchecked.
|
||||
func (p *Player[C]) EnactCard(cardIdx, choiceIdx int) (Message, error) {
|
||||
if p.ActionsRemaining <= 0 {
|
||||
return nil, ErrNoActions
|
||||
}
|
||||
if cardIdx < 0 || cardIdx >= len(p.Hand) {
|
||||
return nil, fmt.Errorf("%w: no card #%d when %d cards in hand", ErrInvalidCard, cardIdx, len(p.Hand))
|
||||
}
|
||||
if !p.Hand[cardIdx].Urgent(p) && p.HasUrgentCards() {
|
||||
return nil, ErrNotUrgent
|
||||
}
|
||||
return p.EnactCardUnchecked(cardIdx, choiceIdx)
|
||||
}
|
||||
|
||||
// EnactPermanentActionUnchecked executes a permanently-available action and
|
||||
// decrements the ActionsRemaining. It does not check for conflicting Urgent
|
||||
// cards or already being out of actions. If no such action or card option
|
||||
// exists, or the option is not enabled, this returns nil and ErrInvalidCard
|
||||
// or ErrInvalidChoice without changing anything. Otherwise, this returns the
|
||||
// result of enacting the permanent action. If enacting the card causes a
|
||||
// serious error, the State becomes GameCrashed.
|
||||
func (p *Player[C]) EnactPermanentActionUnchecked(actionIdx, choiceIdx int) (Message, error) {
|
||||
if actionIdx < 0 || actionIdx >= len(p.PermanentActions) {
|
||||
return nil, fmt.Errorf("%w: no action #%d when %d permanent actions exist", ErrInvalidCard, actionIdx, len(p.PermanentActions))
|
||||
}
|
||||
card := p.PermanentActions[actionIdx]
|
||||
var errs ErrorCollector
|
||||
options, err := card.Options(p)
|
||||
if IsSeriousError(err) {
|
||||
p.State = GameCrashed
|
||||
return nil, err
|
||||
}
|
||||
errs.Add(err)
|
||||
if choiceIdx < 0 || choiceIdx > len(options) {
|
||||
errs.Add(fmt.Errorf("%w: no option #%d on permanent action #%d with %d options", ErrInvalidChoice, choiceIdx, actionIdx, len(options)))
|
||||
return nil, errs.Emit()
|
||||
}
|
||||
chosen := options[choiceIdx]
|
||||
if !chosen.Enabled(p) {
|
||||
errs.Add(fmt.Errorf("%w: option #%d on permanent action #%d is not enabled", ErrInvalidChoice, choiceIdx, actionIdx))
|
||||
return nil, errs.Emit()
|
||||
}
|
||||
|
||||
p.ActionsRemaining--
|
||||
|
||||
ret, err := chosen.Enact(p)
|
||||
errs.Add(err)
|
||||
if IsSeriousError(err) {
|
||||
p.State = GameCrashed
|
||||
return ret, errs.Emit()
|
||||
}
|
||||
|
||||
err = card.Then(p, chosen)
|
||||
errs.Add(err)
|
||||
if IsSeriousError(err) {
|
||||
p.State = GameCrashed
|
||||
}
|
||||
return ret, errs.Emit()
|
||||
}
|
||||
|
||||
// EnactPermanentAction executes a permanently-available card and decrements
|
||||
// the ActionsRemaining. If the action is not Urgent but urgent cards are
|
||||
// available, or the player is out of actions, this returns ErrNotUrgent or
|
||||
// ErrNoActions. Otherwise, this acts like EnactPermanentActionUnchecked.
|
||||
func (p *Player[C]) EnactPermanentAction(actionIdx, choiceIdx int) (Message, error) {
|
||||
if p.ActionsRemaining <= 0 {
|
||||
return nil, ErrNoActions
|
||||
}
|
||||
if actionIdx < 0 || actionIdx >= len(p.PermanentActions) {
|
||||
return nil, fmt.Errorf("%w: no action #%d when %d permanent actions available", ErrInvalidCard, actionIdx, len(p.PermanentActions))
|
||||
}
|
||||
if !p.PermanentActions[actionIdx].Urgent(p) && p.HasUrgentCards() {
|
||||
return nil, ErrNotUrgent
|
||||
}
|
||||
return p.EnactPermanentActionUnchecked(actionIdx, choiceIdx)
|
||||
}
|
||||
|
||||
// ReportError adds an error to the temporary messages, depending on
|
||||
// its severity and debug settings:
|
||||
//
|
||||
// - If the error is nil, this never does anything.
|
||||
// - If the error is serious, this emits the error if the debug level is
|
||||
// -1 or greater.
|
||||
// - If the error is only a warning, this emits the error if the debug
|
||||
// level is 0 or greater.
|
||||
func (p *Player[C]) ReportError(e error) {
|
||||
if e == nil || p.DebugLevel < -1 {
|
||||
return
|
||||
}
|
||||
if p.DebugLevel < 0 && !IsSeriousError(e) {
|
||||
return
|
||||
}
|
||||
p.ChapterBreak()
|
||||
severity := "[Warning]"
|
||||
if IsSeriousError(e) {
|
||||
severity = "[ERROR]"
|
||||
}
|
||||
p.TemporaryMessages = append(p.TemporaryMessages, Msgf("%s: %v", severity, e))
|
||||
}
|
||||
|
||||
// CanAct returns whether the player has actions theoretically available.
|
||||
func (p *Player[C]) CanAct() bool {
|
||||
return p.ActionsRemaining > 0 && (len(p.Hand) > 0 || len(p.PermanentActions) > 0)
|
||||
}
|
||||
|
||||
// Debug adds a message to the player's temporary messages if their debug level
|
||||
// is at least the level specified.
|
||||
func (p *Player[C]) Debug(minLevel int, msg Message) {
|
||||
if p.DebugLevel < minLevel || msg == nil {
|
||||
return
|
||||
}
|
||||
p.TemporaryMessages = append(p.TemporaryMessages, msg)
|
||||
}
|
||||
|
||||
// Emit adds a message to the player's temporary messages.
|
||||
func (p *Player[C]) Emit(msg Message) {
|
||||
if msg == nil {
|
||||
return
|
||||
}
|
||||
p.TemporaryMessages = append(p.TemporaryMessages, msg)
|
||||
}
|
||||
|
@ -141,7 +141,7 @@ func (r *RuleCollection[C]) performInsert(k *keyedRule[C]) {
|
||||
r.rules[k.id] = k
|
||||
|
||||
s := r.byStep[k.Step()]
|
||||
if s == nil {
|
||||
if len(s) == 0 {
|
||||
r.steps = nil
|
||||
}
|
||||
s = append(s, k.id)
|
||||
@ -326,7 +326,7 @@ 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))
|
||||
steps = make([]int, 0, len(r.byStep))
|
||||
for step := range r.byStep {
|
||||
steps = append(steps, step)
|
||||
}
|
||||
@ -334,18 +334,21 @@ func (r *RuleCollection[C]) Run(p *Player[C]) error {
|
||||
r.steps = steps
|
||||
}
|
||||
|
||||
p.Debug(2, Msgf("Executing steps: %v", 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]
|
||||
})
|
||||
p.Debug(3, Msgf("Executing step %d; length %d", step, len(stepRules)))
|
||||
ShuffleAll(stepRules, p.Rand)
|
||||
var remove []RuleID
|
||||
halt := false
|
||||
for _, id := range stepRules {
|
||||
rule := r.rules[id]
|
||||
p.Debug(4, Msgf("Executing rule %x (labeled %q)", id, rule.Label()))
|
||||
err := rule.Enact(p)
|
||||
if err != nil {
|
||||
p.Debug(2, Msgf("Rule %x (%q): error: %v", id, rule.Label(), err))
|
||||
ignore := false
|
||||
if errors.Is(err, ErrDeleteRule) {
|
||||
remove = append(remove, id)
|
||||
@ -377,10 +380,14 @@ func (r *RuleCollection[C]) Run(p *Player[C]) error {
|
||||
}
|
||||
}
|
||||
if halt {
|
||||
return errs.Emit()
|
||||
ret := errs.Emit()
|
||||
p.Debug(2, Msgf("Rules stopping early. Result: %v", ret))
|
||||
return ret
|
||||
}
|
||||
}
|
||||
return errs.Emit()
|
||||
ret := errs.Emit()
|
||||
p.Debug(2, Msgf("Rules complete. Result: %v", ret))
|
||||
return ret
|
||||
}
|
||||
|
||||
func (r *RuleCollection[C]) applyDelayedUpdates() {
|
||||
|
528
cardsim/terminalui.go
Normal file
528
cardsim/terminalui.go
Normal file
@ -0,0 +1,528 @@
|
||||
package cardsim
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func RunSimpleTerminalUI[C StatsCollection](p *Player[C]) error {
|
||||
|
||||
err := p.StartNextTurn()
|
||||
if p.DebugLevel < 1 && IsSeriousError(err) {
|
||||
return err
|
||||
}
|
||||
p.ReportError(err)
|
||||
|
||||
for {
|
||||
for p.CanAct() {
|
||||
isCard, cardIdx, choiceIdx, err := pickNextAction(p)
|
||||
p.ReportError(err)
|
||||
if IsSeriousError(err) {
|
||||
if p.DebugLevel < 1 {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
var msg Message
|
||||
if isCard {
|
||||
msg, err = p.EnactCard(cardIdx, choiceIdx)
|
||||
} else {
|
||||
msg, err = p.EnactPermanentAction(cardIdx, choiceIdx)
|
||||
}
|
||||
p.ReportError(err)
|
||||
if IsSeriousError(err) {
|
||||
if p.DebugLevel < 1 {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
display(msg)
|
||||
wait()
|
||||
}
|
||||
|
||||
// Allow player to review state before continuing simulation.
|
||||
// Errors from review mode are reported *after* the simulation
|
||||
// step because the first thing Simulate does is throw out old
|
||||
// messages -- like these errors.
|
||||
reviewErr := review(p)
|
||||
if p.DebugLevel < 1 && IsSeriousError(err) {
|
||||
return reviewErr
|
||||
}
|
||||
err = p.Simulate()
|
||||
if p.DebugLevel < 1 && IsSeriousError(err) {
|
||||
return err
|
||||
}
|
||||
// Simulation errors are already in messages; now add the review error.
|
||||
p.ReportError(reviewErr)
|
||||
|
||||
if p.DebugLevel < 1 && p.State.Over() {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
// loop forever until the game ends or the player quits
|
||||
}
|
||||
|
||||
func display(m Message) {
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
fmt.Println(m.String())
|
||||
}
|
||||
|
||||
func wait() {
|
||||
fmt.Println()
|
||||
fmt.Println("<press ENTER to continue>")
|
||||
fmt.Scanln()
|
||||
}
|
||||
|
||||
func displayMainMenu[C StatsCollection](p *Player[C]) (actionsOffset, handOffset, max int) {
|
||||
cls()
|
||||
needsDivider := displayMessageSection(p)
|
||||
if needsDivider {
|
||||
divider()
|
||||
}
|
||||
displayOnePanel(p, p.Prompt)
|
||||
divider()
|
||||
actionsOffset = displayStatsMenu(p)
|
||||
if actionsOffset > 0 {
|
||||
divider()
|
||||
}
|
||||
handOffset = displayPermanentActionsMenu(p, actionsOffset)
|
||||
if handOffset > actionsOffset {
|
||||
fmt.Println()
|
||||
}
|
||||
max = displayHandMenu(p, handOffset)
|
||||
return // uses named return values
|
||||
}
|
||||
|
||||
func pickNextAction[C StatsCollection](p *Player[C]) (isCard bool, cardIdx int, choiceIdx int, err error) {
|
||||
for {
|
||||
actionsOffset, handOffset, max := displayMainMenu(p)
|
||||
|
||||
divider()
|
||||
fmt.Printf("%d actions remaining.\n", p.ActionsRemaining)
|
||||
fmt.Printf("Show just (M)essages, (I)nfo Panels, (A)ctions, or consider an item (1-%d), or (Q)uit? > ", max)
|
||||
input := getResponse()
|
||||
switch input {
|
||||
// Special cases
|
||||
case "m", "msg", "message", "messages":
|
||||
cls()
|
||||
if displayMessageSection(p) {
|
||||
divider()
|
||||
}
|
||||
displayOnePanel(p, p.Prompt)
|
||||
wait()
|
||||
case "s", "stat", "stats", "i", "info", "p", "panel", "panels", "infopanel", "infopanels":
|
||||
statsMode(p)
|
||||
case "a", "act", "actions":
|
||||
var committed bool
|
||||
isCard, cardIdx, choiceIdx, committed, err = actionsMode(p, true)
|
||||
if committed {
|
||||
return
|
||||
}
|
||||
case "q", "quit", "exit":
|
||||
confirmQuit()
|
||||
default:
|
||||
i, err := strconv.Atoi(input)
|
||||
if err != nil {
|
||||
fmt.Println("Sorry, I don't understand.")
|
||||
wait()
|
||||
} else if i > max {
|
||||
fmt.Println("That's not on this menu. If the menu is too big to read, choose a detail view.")
|
||||
wait()
|
||||
} else if i <= actionsOffset {
|
||||
cls()
|
||||
displayOnePanel(p, p.InfoPanels[i-1])
|
||||
wait()
|
||||
} else if i <= handOffset {
|
||||
i = i - actionsOffset - 1
|
||||
option, promptErr := promptCard(p, p.PermanentActions[i])
|
||||
if option >= 0 || IsSeriousError(promptErr) {
|
||||
return false, i, option, promptErr
|
||||
}
|
||||
} else {
|
||||
i = i - handOffset - 1
|
||||
option, promptErr := promptCard(p, p.Hand[i])
|
||||
if option >= 0 || IsSeriousError(promptErr) {
|
||||
return true, i, option, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func cls() {
|
||||
fmt.Println("\033[H\033[2J")
|
||||
}
|
||||
|
||||
func getResponse() string {
|
||||
var input string
|
||||
fmt.Scanln(&input)
|
||||
input = strings.TrimSpace(input)
|
||||
input = strings.ToLower(input)
|
||||
return input
|
||||
}
|
||||
|
||||
func divider() {
|
||||
fmt.Println()
|
||||
fmt.Println(ChapterBreak.String())
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
func lightDivider() {
|
||||
fmt.Println()
|
||||
fmt.Println(SectionBreak.String())
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
func confirmQuit() {
|
||||
divider()
|
||||
fmt.Println("Are you sure you want to quit? (Y/N) > ")
|
||||
s := getResponse()
|
||||
if s == "y" || s == "yes" {
|
||||
fmt.Println("Bye!")
|
||||
os.Exit(0)
|
||||
}
|
||||
}
|
||||
|
||||
func displayOnePanel[C StatsCollection](p *Player[C], panel InfoPanel[C]) error {
|
||||
ts := panel.Title(p).String()
|
||||
if len(ts) > 0 {
|
||||
fmt.Println(ts)
|
||||
fmt.Println(strings.Repeat("-", len(ts)))
|
||||
fmt.Println()
|
||||
}
|
||||
m, err := panel.Info(p)
|
||||
if IsSeriousError(err) {
|
||||
return err
|
||||
}
|
||||
display(MultiMessage(m))
|
||||
return err
|
||||
}
|
||||
|
||||
func displayMessageSection[C StatsCollection](p *Player[C]) bool {
|
||||
if len(p.TemporaryMessages) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, m := range p.TemporaryMessages {
|
||||
if m != nil {
|
||||
display(m)
|
||||
} else {
|
||||
fmt.Println()
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func displayStatsMenu[C StatsCollection](p *Player[C]) int {
|
||||
if len(p.InfoPanels) == 0 {
|
||||
return 0
|
||||
}
|
||||
fmt.Println("Info Panels")
|
||||
fmt.Println("-----------")
|
||||
for i, s := range p.InfoPanels {
|
||||
fmt.Printf("[%2d]: %s\n", i+1, s.Title(p).String())
|
||||
}
|
||||
return len(p.InfoPanels)
|
||||
}
|
||||
|
||||
func displayPermanentActionsMenu[C StatsCollection](p *Player[C], offset int) int {
|
||||
if len(p.PermanentActions) == 0 {
|
||||
return offset
|
||||
}
|
||||
fmt.Println("Always Available")
|
||||
fmt.Println("----------------")
|
||||
for i, s := range p.PermanentActions {
|
||||
fmt.Printf("[%2d]: %s\n", i+offset+1, s.Title(p))
|
||||
}
|
||||
return offset + len(p.PermanentActions)
|
||||
}
|
||||
|
||||
func displayHandMenu[C StatsCollection](p *Player[C], offset int) int {
|
||||
if len(p.Hand) == 0 {
|
||||
return offset
|
||||
}
|
||||
fmt.Println("Hand")
|
||||
fmt.Println("----")
|
||||
for i, s := range p.Hand {
|
||||
fmt.Printf("[%2d]: %s\n", i+offset+1, s.Title(p))
|
||||
}
|
||||
return offset + len(p.Hand)
|
||||
}
|
||||
|
||||
// promptCard asks the player to take an action on a card. Returns the option
|
||||
// they chose, or -1 if there was a serious error or they cancelled selection.
|
||||
func promptCard[C StatsCollection](p *Player[C], card Card[C]) (optionIdx int, err error) {
|
||||
// Iterate until the player makes a valid choice.
|
||||
for {
|
||||
opts, valid, err := displayCard(p, card, true)
|
||||
if IsSeriousError(err) {
|
||||
return -1, err
|
||||
}
|
||||
fmt.Println()
|
||||
if valid {
|
||||
fmt.Printf("Go (B)ack, (Q)uit, or enact a choice (1 - %d)? > ", len(opts))
|
||||
} else {
|
||||
fmt.Print("Go (B)ack or (Q)uit? > ")
|
||||
}
|
||||
read := getResponse()
|
||||
switch read {
|
||||
case "b", "back":
|
||||
return -1, err
|
||||
case "q", "quit":
|
||||
confirmQuit()
|
||||
default:
|
||||
i, convErr := strconv.Atoi(read)
|
||||
if convErr != nil {
|
||||
fmt.Println("Sorry, I don't understand.")
|
||||
wait()
|
||||
} else if !valid {
|
||||
fmt.Println("You can't enact anything here.")
|
||||
wait()
|
||||
} else if i <= 0 || i > len(opts) {
|
||||
fmt.Println("That's not one of the options.")
|
||||
wait()
|
||||
} else if !opts[i-1].Enabled(p) {
|
||||
fmt.Println("That option is not available to you right now.")
|
||||
wait()
|
||||
} else {
|
||||
return i - 1, err
|
||||
}
|
||||
}
|
||||
// Invalid selection made -- loop to prompt again.
|
||||
}
|
||||
}
|
||||
|
||||
func displayCard[C StatsCollection](p *Player[C], card Card[C], canAct bool) ([]CardOption[C], bool, error) {
|
||||
cls()
|
||||
t := card.Title(p).String()
|
||||
urgent := card.Urgent(p)
|
||||
if urgent {
|
||||
t = "[URGENT!] " + t
|
||||
}
|
||||
fmt.Println(t)
|
||||
fmt.Println(strings.Repeat("-", len(t)))
|
||||
fmt.Println()
|
||||
event, err := card.EventText(p)
|
||||
if IsSeriousError(err) {
|
||||
return nil, false, err
|
||||
}
|
||||
var errs ErrorCollector
|
||||
errs.Add(err)
|
||||
fmt.Println(event.String())
|
||||
fmt.Println()
|
||||
fmt.Println(SectionBreak.String())
|
||||
fmt.Println()
|
||||
if !urgent && p.HasUrgentCards() {
|
||||
fmt.Println("<You have more urgent matters to attend to! You cannot act on this right now.>")
|
||||
fmt.Println()
|
||||
canAct = false
|
||||
}
|
||||
opts, optErr := card.Options(p)
|
||||
errs.Add(optErr)
|
||||
if IsSeriousError(optErr) {
|
||||
return nil, false, errs.Emit()
|
||||
}
|
||||
valid := false
|
||||
for i, opt := range opts {
|
||||
pfx := "[xx]:"
|
||||
if opt.Enabled(p) {
|
||||
if canAct {
|
||||
pfx = fmt.Sprintf("[%2d]:", i+1)
|
||||
valid = true
|
||||
} else {
|
||||
pfx = "[--]:"
|
||||
}
|
||||
}
|
||||
t, err := opt.OptionText(p)
|
||||
errs.Add(err)
|
||||
if IsSeriousError(err) {
|
||||
return nil, false, errs.Emit()
|
||||
}
|
||||
fmt.Println(pfx, t.String())
|
||||
}
|
||||
return opts, valid, errs.Emit()
|
||||
}
|
||||
|
||||
func statsMode[C StatsCollection](p *Player[C]) error {
|
||||
var errs ErrorCollector
|
||||
for {
|
||||
cls()
|
||||
n := displayStatsMenu(p)
|
||||
if n <= 0 {
|
||||
fmt.Println("No info panels are available.")
|
||||
wait()
|
||||
return errs.Emit()
|
||||
}
|
||||
fmt.Println()
|
||||
fmt.Printf("Go (B)ack, (Q)uit, or view an info panel (1-%d)? > ", n)
|
||||
s := getResponse()
|
||||
switch s {
|
||||
case "b", "back":
|
||||
return errs.Emit()
|
||||
case "q", "quit":
|
||||
confirmQuit()
|
||||
default:
|
||||
v, err := strconv.Atoi(s)
|
||||
if err != nil {
|
||||
fmt.Println("Sorry, I don't understand.")
|
||||
wait()
|
||||
} else if v <= 0 || v > n {
|
||||
fmt.Println("There's no info panel with that index.")
|
||||
} else {
|
||||
cls()
|
||||
err := displayOnePanel(p, p.InfoPanels[v-1])
|
||||
errs.Add(err)
|
||||
if IsSeriousError(err) {
|
||||
return errs.Emit()
|
||||
}
|
||||
wait()
|
||||
}
|
||||
}
|
||||
// Loop to re-display info panels menu.
|
||||
}
|
||||
}
|
||||
|
||||
func actionsMode[C StatsCollection](p *Player[C], canAct bool) (isCard bool, cardIdx, choiceIdx int, committed bool, err error) {
|
||||
var errs ErrorCollector
|
||||
for {
|
||||
cls()
|
||||
pOff := displayPermanentActionsMenu(p, 0)
|
||||
if pOff > 0 {
|
||||
fmt.Println()
|
||||
}
|
||||
max := displayHandMenu(p, pOff)
|
||||
|
||||
if max <= 0 {
|
||||
fmt.Println("There are no actions available and no cards in hand.")
|
||||
fmt.Println("That's a problem. The game is stuck.")
|
||||
confirmQuit()
|
||||
errs.Add(WarningStalemate)
|
||||
return false, -1, -1, true, errs.Emit()
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
if canAct {
|
||||
fmt.Printf("Go (B)ack, (Q)uit, or consider an action (1-%d)? > ", max)
|
||||
} else {
|
||||
fmt.Printf("Go (B)ack, (Q)uit, or view an action (1-%d)? > ", max)
|
||||
}
|
||||
input := getResponse()
|
||||
switch input {
|
||||
case "b", "back":
|
||||
return false, -1, -1, false, errs.Emit()
|
||||
case "q", "quit":
|
||||
confirmQuit()
|
||||
default:
|
||||
v, err := strconv.Atoi(input)
|
||||
if err != nil {
|
||||
fmt.Println("Sorry, I don't understand.")
|
||||
wait()
|
||||
} else if v < 1 || v > max {
|
||||
fmt.Println("That's not a card or action.")
|
||||
wait()
|
||||
} else if v <= pOff {
|
||||
v--
|
||||
if canAct {
|
||||
optIdx, err := promptCard(p, p.PermanentActions[v])
|
||||
errs.Add(err)
|
||||
if optIdx >= 0 || IsSeriousError(err) {
|
||||
return false, v, optIdx, true, errs.Emit()
|
||||
}
|
||||
} else {
|
||||
_, _, err := displayCard(p, p.PermanentActions[v], false)
|
||||
errs.Add(err)
|
||||
if IsSeriousError(err) {
|
||||
return false, -1, -1, true, errs.Emit()
|
||||
}
|
||||
wait()
|
||||
}
|
||||
} else {
|
||||
v = v - pOff - 1
|
||||
if canAct {
|
||||
optIdx, err := promptCard(p, p.Hand[v])
|
||||
errs.Add(err)
|
||||
if optIdx >= 0 || IsSeriousError(err) {
|
||||
return true, v, optIdx, false, errs.Emit()
|
||||
}
|
||||
} else {
|
||||
_, _, err := displayCard(p, p.Hand[v], false)
|
||||
errs.Add(err)
|
||||
if IsSeriousError(err) {
|
||||
return false, -1, -1, false, errs.Emit()
|
||||
}
|
||||
wait()
|
||||
}
|
||||
}
|
||||
}
|
||||
// Re-prompt to get a valid choice.
|
||||
}
|
||||
}
|
||||
|
||||
func review[C StatsCollection](p *Player[C]) error {
|
||||
var errs ErrorCollector
|
||||
for {
|
||||
actionsOffset, handOffset, max := displayMainMenu(p)
|
||||
divider()
|
||||
fmt.Println("No actions remaining.")
|
||||
fmt.Printf("(C)ontinue, review just (M)essages, (I)nfo Panels, (A)ctions, or an item (1-%d), or (Q)uit? > ", max)
|
||||
input := getResponse()
|
||||
switch input {
|
||||
// Special cases
|
||||
case "m", "msg", "message", "messages":
|
||||
cls()
|
||||
if displayMessageSection(p) {
|
||||
divider()
|
||||
}
|
||||
displayOnePanel(p, p.Prompt)
|
||||
wait()
|
||||
case "s", "stat", "stats", "i", "info", "p", "panel", "panels", "infopanel", "infopanels":
|
||||
err := statsMode(p)
|
||||
errs.Add(err)
|
||||
if IsSeriousError(err) {
|
||||
return errs.Emit()
|
||||
}
|
||||
case "a", "act", "actions":
|
||||
_, _, _, _, err := actionsMode(p, false)
|
||||
errs.Add(err)
|
||||
if IsSeriousError(err) {
|
||||
return errs.Emit()
|
||||
}
|
||||
case "q", "quit", "exit":
|
||||
confirmQuit()
|
||||
case "c", "continue", "ok":
|
||||
return errs.Emit()
|
||||
default:
|
||||
i, err := strconv.Atoi(input)
|
||||
if err != nil {
|
||||
fmt.Println("Sorry, I don't understand.")
|
||||
wait()
|
||||
} else if i <= 0 || i > max {
|
||||
fmt.Println("That's not on this menu. If the menu is too big to read, choose a detail view.")
|
||||
wait()
|
||||
} else if i <= actionsOffset {
|
||||
cls()
|
||||
displayOnePanel(p, p.InfoPanels[i-1])
|
||||
wait()
|
||||
} else if i <= handOffset {
|
||||
i = i - actionsOffset - 1
|
||||
_, _, err := displayCard(p, p.PermanentActions[i], false)
|
||||
errs.Add(err)
|
||||
if IsSeriousError(err) {
|
||||
return errs.Emit()
|
||||
}
|
||||
wait()
|
||||
} else {
|
||||
i = i - handOffset - 1
|
||||
_, _, err := displayCard(p, p.Hand[i], false)
|
||||
errs.Add(err)
|
||||
if IsSeriousError(err) {
|
||||
return errs.Emit()
|
||||
}
|
||||
wait()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
9
go.mod
9
go.mod
@ -1,3 +1,10 @@
|
||||
module cardSimEngine
|
||||
module git.chromaticdragon.app/kistaro/CardSimEngine
|
||||
|
||||
go 1.20
|
||||
|
||||
require github.com/kr/pretty v0.3.1
|
||||
|
||||
require (
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/rogpeppe/go-internal v1.9.0 // indirect
|
||||
)
|
||||
|
8
go.sum
Normal file
8
go.sum
Normal file
@ -0,0 +1,8 @@
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
163
smoketest/cards.go
Normal file
163
smoketest/cards.go
Normal file
@ -0,0 +1,163 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"git.chromaticdragon.app/kistaro/CardSimEngine/cardsim"
|
||||
)
|
||||
|
||||
// Type aliases, unlike distinctly named types, are fully substitutable for
|
||||
// the original type. This trims off some annoying-to-type things.
|
||||
type player = cardsim.Player[*SmokeTestCollection]
|
||||
type card = cardsim.Card[*SmokeTestCollection]
|
||||
type cardOption = cardsim.CardOption[*SmokeTestCollection]
|
||||
|
||||
func makeAdditionCard(amt int) cardsim.Card[*SmokeTestCollection] {
|
||||
c := &cardsim.BasicCard[*SmokeTestCollection]{
|
||||
CardTitle: cardsim.Msgf("Additive %d", amt),
|
||||
CardText: cardsim.Msgf("You can change the Number by %d.", amt),
|
||||
CardOptions: []cardsim.CardOption[*SmokeTestCollection]{
|
||||
&cardsim.BasicOption[*SmokeTestCollection]{
|
||||
Text: cardsim.Msgf("Add %d", amt),
|
||||
Effect: func(p *player) error {
|
||||
p.Stats.Number.Value += amt
|
||||
return nil
|
||||
},
|
||||
Output: cardsim.MsgStr("Added."),
|
||||
},
|
||||
&cardsim.BasicOption[*SmokeTestCollection]{
|
||||
Text: cardsim.Msgf("Subtract %d", amt),
|
||||
Effect: func(p *player) error {
|
||||
p.Stats.Number.Value -= amt
|
||||
return nil
|
||||
},
|
||||
Output: cardsim.MsgStr("Subtracted."),
|
||||
},
|
||||
},
|
||||
AfterOption: func(c card, p *player, _ cardOption) error {
|
||||
p.Deck.InsertRandomBottom(0.5, c)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func makeMultiplicationCard(amt int) cardsim.Card[*SmokeTestCollection] {
|
||||
c := &cardsim.BasicCard[*SmokeTestCollection]{
|
||||
CardTitle: cardsim.Msgf("Multiplicative %d", amt),
|
||||
CardText: cardsim.Msgf("You can multiply or divide the Number by %d, or maybe divide the Number by that.", amt),
|
||||
CardOptions: []cardsim.CardOption[*SmokeTestCollection]{
|
||||
&cardsim.BasicOption[*SmokeTestCollection]{
|
||||
Text: cardsim.Msgf("Multiply by %d", amt),
|
||||
Effect: func(p *player) error {
|
||||
p.Stats.Number.Value *= amt
|
||||
return nil
|
||||
},
|
||||
Output: cardsim.MsgStr("Multiplied."),
|
||||
},
|
||||
&cardsim.BasicOption[*SmokeTestCollection]{
|
||||
Text: cardsim.Msgf("Integer divide by %d", amt),
|
||||
Effect: func(p *player) error {
|
||||
p.Stats.Number.Value /= amt
|
||||
return nil
|
||||
},
|
||||
Output: cardsim.MsgStr("Divided."),
|
||||
},
|
||||
inverseDivision(amt),
|
||||
},
|
||||
AfterOption: func(c card, p *player, _ cardOption) error {
|
||||
p.Deck.InsertRandomBottom(0.5, c)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
type inverseDivision int
|
||||
|
||||
func (i inverseDivision) OptionText(p *player) (cardsim.Message, error) {
|
||||
if p.Stats.Number.Value == 0 {
|
||||
return cardsim.MsgStr("You can't divide by zero!"), nil
|
||||
}
|
||||
return cardsim.Msgf("Divide %d by the Number", int(i)), nil
|
||||
}
|
||||
|
||||
func (i inverseDivision) Enact(p *player) (cardsim.Message, error) {
|
||||
if p.Stats.Number.Value == 0 {
|
||||
return nil, errors.New("you can't divide by zero!")
|
||||
}
|
||||
p.Stats.Number.Value = int(i) / p.Stats.Number.Value
|
||||
return cardsim.MsgStr("Inverse divided."), nil
|
||||
}
|
||||
|
||||
func (i inverseDivision) Enabled(p *player) bool {
|
||||
return p.Stats.Number.Value != 0
|
||||
}
|
||||
|
||||
func initDeck(d *cardsim.Deck[*SmokeTestCollection]) {
|
||||
addMe := []int{
|
||||
0, 1, 2, 5, 10, 50, 100, 1000, 2500, 500000, 9876543,
|
||||
}
|
||||
for _, n := range addMe {
|
||||
d.Insert(cardsim.BottomOfDeck, makeAdditionCard(n))
|
||||
}
|
||||
|
||||
multiplyMe := []int{
|
||||
2, 4, 8, 16, 32, 64, 128, 512, 1024, 9999, 84720413,
|
||||
}
|
||||
for _, n := range multiplyMe {
|
||||
d.Insert(cardsim.BottomOfDeck, makeMultiplicationCard(n))
|
||||
}
|
||||
if err := d.Shuffle(); cardsim.IsSeriousError(err) {
|
||||
panic(err)
|
||||
} else if err != nil {
|
||||
fmt.Printf("Error shuffling: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
func installPermanentActions(pa *[]card) {
|
||||
*pa = []card{
|
||||
&cardsim.BasicCard[*SmokeTestCollection]{
|
||||
CardTitle: cardsim.MsgStr("Reset to 0"),
|
||||
CardText: cardsim.MsgStr("Resets Number to 0."),
|
||||
CardOptions: []cardOption{
|
||||
&cardsim.BasicOption[*SmokeTestCollection]{
|
||||
Text: cardsim.MsgStr("Reset to 0."),
|
||||
Effect: func(p *player) error {
|
||||
p.Stats.Number.Value = 0
|
||||
return nil
|
||||
},
|
||||
Output: cardsim.MsgStr("Done."),
|
||||
},
|
||||
},
|
||||
},
|
||||
&cardsim.BasicCard[*SmokeTestCollection]{
|
||||
CardTitle: cardsim.MsgStr("Reset to 1000000"),
|
||||
CardText: cardsim.MsgStr("Resets Number to one million."),
|
||||
CardOptions: []cardOption{
|
||||
&cardsim.BasicOption[*SmokeTestCollection]{
|
||||
Text: cardsim.MsgStr("Reset to 1,000,000"),
|
||||
Effect: func(p *player) error {
|
||||
p.Stats.Number.Value = 1000000
|
||||
return nil
|
||||
},
|
||||
Output: cardsim.MsgStr("Done."),
|
||||
},
|
||||
},
|
||||
},
|
||||
&cardsim.BasicCard[*SmokeTestCollection]{
|
||||
CardTitle: cardsim.MsgStr("Draw a card"),
|
||||
CardText: cardsim.MsgStr("Draw an extra card."),
|
||||
CardOptions: []cardOption{
|
||||
&cardsim.BasicOption[*SmokeTestCollection]{
|
||||
Text: cardsim.MsgStr("Draw an extra card."),
|
||||
Effect: func(p *player) error {
|
||||
return p.Draw()
|
||||
},
|
||||
Output: cardsim.MsgStr("Drawn. Probably."),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
25
smoketest/collection.go
Normal file
25
smoketest/collection.go
Normal file
@ -0,0 +1,25 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"git.chromaticdragon.app/kistaro/CardSimEngine/cardsim"
|
||||
)
|
||||
|
||||
// SmokeTestCollection is a stats collection for the simple test sim.
|
||||
type SmokeTestCollection struct {
|
||||
Number cardsim.Stored[int]
|
||||
Total cardsim.Stored[int64]
|
||||
Turns cardsim.Invisible[int]
|
||||
|
||||
Flavor cardsim.Stored[string]
|
||||
}
|
||||
|
||||
func (c *SmokeTestCollection) Average() float64 {
|
||||
return float64(c.Total.Value) / float64(c.Turns.Value)
|
||||
}
|
||||
|
||||
func (c *SmokeTestCollection) Stats() []cardsim.Stat {
|
||||
stats := cardsim.ExtractStats(c)
|
||||
stats = append(stats, cardsim.StatFunc("Average", c.Average))
|
||||
cardsim.SortStats(stats)
|
||||
return stats
|
||||
}
|
79
smoketest/main.go
Normal file
79
smoketest/main.go
Normal file
@ -0,0 +1,79 @@
|
||||
// Binary smoketest runs a very simple cardsim thing.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"git.chromaticdragon.app/kistaro/CardSimEngine/cardsim"
|
||||
|
||||
"github.com/kr/pretty"
|
||||
)
|
||||
|
||||
func main() {
|
||||
p := cardsim.InitPlayer(
|
||||
&SmokeTestCollection{
|
||||
Number: cardsim.Stored[int]{
|
||||
Name: "Number",
|
||||
Value: 0,
|
||||
},
|
||||
Total: cardsim.Stored[int64]{
|
||||
Name: "Total",
|
||||
Value: 0,
|
||||
},
|
||||
Turns: cardsim.Invisible[int]{
|
||||
Name: "Turns",
|
||||
Value: 0,
|
||||
},
|
||||
Flavor: cardsim.Stored[string]{
|
||||
Name: "Flavor",
|
||||
Value: "Lemon",
|
||||
},
|
||||
},
|
||||
)
|
||||
p.Name = "Dave"
|
||||
p.HandLimit = 3
|
||||
p.ActionsPerTurn = 2
|
||||
installRules(p.Rules)
|
||||
initDeck(p.Deck)
|
||||
installPermanentActions(&p.PermanentActions)
|
||||
p.InfoPanels = []cardsim.InfoPanel[*SmokeTestCollection]{
|
||||
&cardsim.BasicStatsPanel[*SmokeTestCollection]{
|
||||
Name: cardsim.MsgStr("Stats"),
|
||||
Intro: cardsim.MsgStr("Hi! These are the smoke test stats."),
|
||||
},
|
||||
ruledumper{},
|
||||
}
|
||||
p.Prompt = prompt{}
|
||||
p.DebugLevel = 5
|
||||
|
||||
err := cardsim.RunSimpleTerminalUI(p)
|
||||
if err != nil {
|
||||
fmt.Println("Terminated with error:")
|
||||
fmt.Println(err)
|
||||
} else {
|
||||
fmt.Println("Terminated without error.")
|
||||
}
|
||||
}
|
||||
|
||||
type prompt struct{}
|
||||
|
||||
func (prompt) Title(p *cardsim.Player[*SmokeTestCollection]) cardsim.Message {
|
||||
return cardsim.MsgStr("Smoke Test")
|
||||
}
|
||||
|
||||
func (prompt) Info(p *cardsim.Player[*SmokeTestCollection]) ([]cardsim.Message, error) {
|
||||
return []cardsim.Message{
|
||||
cardsim.MsgStr("Here, have some stuff."),
|
||||
cardsim.Msgf("It's turn %d according to the player and turn %d according to me.", p.TurnNumber, p.Stats.Turns.Value),
|
||||
}, nil
|
||||
}
|
||||
|
||||
type ruledumper struct{}
|
||||
|
||||
func (ruledumper) Title(p *player) cardsim.Message {
|
||||
return cardsim.MsgStr("Rule Dumper")
|
||||
}
|
||||
|
||||
func (ruledumper) Info(p *player) ([]cardsim.Message, error) {
|
||||
return []cardsim.Message{cardsim.Msgf("%# v", pretty.Formatter(p.Rules))}, nil
|
||||
}
|
32
smoketest/rules.go
Normal file
32
smoketest/rules.go
Normal file
@ -0,0 +1,32 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"math"
|
||||
|
||||
"git.chromaticdragon.app/kistaro/CardSimEngine/cardsim"
|
||||
)
|
||||
|
||||
var (
|
||||
updateTotal = cardsim.RuleFunc[*SmokeTestCollection]{
|
||||
Name: "updateTotal",
|
||||
Seq: 1,
|
||||
F: func(p *cardsim.Player[*SmokeTestCollection]) error {
|
||||
p.Stats.Total.Value += int64(p.Stats.Number.Value)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
countTurn = cardsim.RuleFunc[*SmokeTestCollection]{
|
||||
Name: "countTurn",
|
||||
Seq: math.MinInt,
|
||||
F: func(p *cardsim.Player[*SmokeTestCollection]) error {
|
||||
p.Stats.Turns.Value++
|
||||
return nil
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
func installRules(rules *cardsim.RuleCollection[*SmokeTestCollection]) {
|
||||
rules.Insert(&updateTotal)
|
||||
rules.Insert(&countTurn)
|
||||
}
|
Reference in New Issue
Block a user