Compare commits

...

2 Commits

Author SHA1 Message Date
45e1eeebf7
Play cards. 2023-04-01 21:18:48 -07:00
576a2dd69e
Implement drawing and running a turn. 2023-04-01 20:52:46 -07:00
3 changed files with 300 additions and 10 deletions

View File

@ -52,6 +52,12 @@ func Warningf(f string, args ...any) *Warning {
return &Warning{fmt.Errorf(f, args...)} 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 // A Failure is an error that is definitely not a Warning. If a Warning is
// wrapped in a Failure, it stops being a Warning. // wrapped in a Failure, it stops being a Warning.
type Failure struct { 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. // - 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 // The aggregated error is a Warning if and only if all collected errors
// were also warnings. // 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 { type ErrorCollector struct {
errs []error Errs []error
hasFailure bool hasFailure bool
} }
@ -157,12 +166,12 @@ func (ec *ErrorCollector) Add(e error) {
if !errors.Is(e, AnyWarning) { if !errors.Is(e, AnyWarning) {
ec.hasFailure = true ec.hasFailure = true
} }
ec.errs = append(ec.errs, e) ec.Errs = append(ec.Errs, e)
} }
// IsEmpty reports whether ec has zero accumulated errors/warnings. // IsEmpty reports whether ec has zero accumulated errors/warnings.
func (ec *ErrorCollector) IsEmpty() bool { 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. // 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 // errors.Is does not erroneously represent a failure as a warning because it
// contains a warning as a subcomponent. // contains a warning as a subcomponent.
func (ec *ErrorCollector) Emit() error { func (ec *ErrorCollector) Emit() error {
if len(ec.errs) == 0 { if len(ec.Errs) == 0 {
return nil return nil
} }
if len(ec.errs) == 1 { if len(ec.Errs) == 1 {
return ec.errs[0] return ec.Errs[0]
} }
if ec.HasFailure() { 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. // An aggregateError is a collection of errors that is itself an error.

View File

@ -25,3 +25,34 @@ func MsgStr(s string) Message {
func Msgf(f string, args ...any) Message { func Msgf(f string, args ...any) Message {
return stringMessage(fmt.Sprintf(f, args...)) 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
}

View File

@ -1,6 +1,20 @@
package cardsim package cardsim
import "math/rand" import (
"errors"
"fmt"
"math/rand"
)
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. // Player stores all gameplay state for one player at a specific point in time.
// Game-specific data is stored in Stats. // Game-specific data is stored in Stats.
@ -83,7 +97,10 @@ type Player[C StatsCollection] struct {
ActionsPerTurn int ActionsPerTurn int
ActionsRemaining 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] PermanentActions []Card[C]
// InfoPanels lists informational views available to the player. The Prompt // InfoPanels lists informational views available to the player. The Prompt
@ -138,3 +155,236 @@ const (
func (g GameState) Over() bool { func (g GameState) Over() bool {
return g == GameLost || g == GameWon || g == GameCrashed || g == GameStalled 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
}
// 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, this returns nil and ErrInvalidCard/ErrInvalidChoice. Otherwise, this returns
// the result of enacting the card. If enacting the card causes an 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", ErrInvalidCard, choiceIdx, cardIdx, len(options)))
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, this returns nil and ErrInvalidCard/ErrInvalidChoice. Otherwise, this returns
// the result of enacting the permanent action. If enacting the card causes an 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()
}
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()
}
// 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)
}