diff --git a/cardsim/errors.go b/cardsim/errors.go index 15fe45e..90ffdfb 100644 --- a/cardsim/errors.go +++ b/cardsim/errors.go @@ -52,6 +52,12 @@ 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 { + 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 +150,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 +166,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 +190,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. diff --git a/cardsim/messages.go b/cardsim/messages.go index b370696..acb0a6a 100644 --- a/cardsim/messages.go +++ b/cardsim/messages.go @@ -25,3 +25,34 @@ 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 +} diff --git a/cardsim/player.go b/cardsim/player.go index 1f0da89..79a3a20 100644 --- a/cardsim/player.go +++ b/cardsim/player.go @@ -1,6 +1,15 @@ package cardsim -import "math/rand" +import ( + "errors" + "math/rand" +) + +var ( + ErrUncooperativeCards = errors.New("a milion cards refused to join the hand") + + 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. @@ -138,3 +147,111 @@ const ( 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:", 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() +} + +// 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 { + failureLimit := 1000000 + for failureLimit > 0 && p.Deck.Len() > 0 && len(p.Hand) < p.HandLimit { + c := p.Deck.Draw() + if c.Drawn(p) { + p.Hand = append(p.Hand, c) + } else { + failureLimit-- + } + } + + if len(p.Hand) >= p.HandLimit { + return nil + } + + if failureLimit <= 0 { + return ErrUncooperativeCards + } + + return WarningTooFewCards +}