454 lines
16 KiB
Go
454 lines
16 KiB
Go
package cardsim
|
|
|
|
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.
|
|
//
|
|
// Player is a generic type -- see https://go.dev/blog/intro-generics for more
|
|
// information on how these work. Think of "Player" as a "type of type" --
|
|
// when you create one, you tell it what kind of data it needs to keep for
|
|
// the simulation itself, and each Player that works with a different kind of
|
|
// data is a different kind of Player and the compiler will help you with that.
|
|
// This is the same idea as "slice of something" or "map from something to
|
|
// something" -- different kinds of Players are different from each other and
|
|
// "know" what type of data they use, so the compiler can tell you if you're
|
|
// using the wrong type.
|
|
//
|
|
// Generic types have to use a placeholder to represent the type (or types --
|
|
// consider maps, which have both keys and values) that will be more specific
|
|
// when the type is actually used. They're called "type parameters", like
|
|
// function parameters, because they're the same kind of idea. A function puts
|
|
// its parameters into variables so you can write a function that works with
|
|
// whatever data it gets; a generic type takes type parameters and represents
|
|
// them with type placeholders so you can write a *type* that works with
|
|
// whatever specific other types it gets.
|
|
//
|
|
// Just like function parameters have a type that says what kind of data the
|
|
// function works with, type parameters have a "type constraint" that says what
|
|
// kind of types the generic type works with. Go already has a familiar way
|
|
// to express the idea of "what a type has to do": `interface`. In Go, type
|
|
// constraints are just interfaces.
|
|
//
|
|
// But wait, why use generics at all? Can't we just use an interface in the
|
|
// normal way instead of doing this thing? Well, yes, we could, but then the
|
|
// compiler doesn't know that the "real types" for things matching these
|
|
// interfaces all have to actually be the same type. The compiler will stop
|
|
// you from putting an `Orange` into a `[]Apple`, but it wouldn't stop you from
|
|
// putting a `Fruit` into a `[]Fruit` because, well, of course it wouldn't,
|
|
// they're the same type.
|
|
//
|
|
// Different simulation games made with `cardsim` are different. Rules made for
|
|
// simulating the economy of a kobold colony and mine wouldn't work at all with
|
|
// data for a simulation about three flocks of otter-gryphons having a
|
|
// territory conflict over a river full of fish. By using generics, the compiler
|
|
// can recognize functions and data and types intended for different simulation
|
|
// games and prevent you from using the wrong one, when it wouldn't be able to
|
|
// if all this stuff was written for "some simulation game, don't care what".
|
|
//
|
|
// Generic interfaces (like `Card[C]`, `Rule[C]`, `InfoPanel[C]`, and more)
|
|
// don't mean you have to write generics of your own. It's exactly the opposite!
|
|
// Because the interface has this extra type in it, you only need to implement
|
|
// the specific kind of interface that works with your game. There's more detail
|
|
// on this in the comment on `Rule[C]`.
|
|
type Player[C StatsCollection] struct {
|
|
// Stats stores simulation-specific state.
|
|
Stats C
|
|
|
|
// Name stores the player's name.
|
|
Name string
|
|
|
|
// Rand is a source of randomness that other components can use.
|
|
Rand *rand.Rand
|
|
|
|
Deck *Deck[C]
|
|
Hand []Card[C]
|
|
TurnNumber int
|
|
State GameState
|
|
|
|
// HandLimit is number of cards to draw to at the start of each turn.
|
|
// If the player has more cards than this already, none will be drawn,
|
|
// but the player will keep them all.
|
|
//
|
|
// If this is 0 or less and the player has no cards in hand, no permanent
|
|
// actions available, and must take an action, the game ends in stalemate.
|
|
HandLimit int
|
|
|
|
// ActionsPerTurn is what ActionsRemaining resets to at the start of each
|
|
// turn. If this is 0 or less at the start of a turn, the game ends in
|
|
// stalemate. Activating a card or permanent action spends an action, but
|
|
// the card or action itself can counter this by changing the player's
|
|
// ActionsRemaining by giving the action back -- or force the turn to
|
|
// progress immediately to simulation by setting it to 0.
|
|
ActionsPerTurn int
|
|
ActionsRemaining int
|
|
|
|
// 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
|
|
// is the InfoPanel shown before the main action menu.
|
|
InfoPanels []InfoPanel[C]
|
|
Prompt InfoPanel[C]
|
|
|
|
// Rules are the simulation rules executed every turn after the player has
|
|
// run out of remaining actions. See `RuleCollection`'s documentation for
|
|
// more information about how rule execution works.
|
|
Rules *RuleCollection[C]
|
|
|
|
// Temporary messages are shown *before* the Prompt. They're cleared just
|
|
// before executing rules for the turn, so rules adding to TemporaryMessages
|
|
// are creating messages that will show up for the next turn. Temporary
|
|
// panels are cleared out at the same time as temporary messages; when
|
|
// available, they are listed separately from standard panels (before them).
|
|
TemporaryMessages []Message
|
|
TemporaryPanels []InfoPanel[C]
|
|
|
|
// DebugLevel stores how verbose the game should be about errors. If this
|
|
// is greater than 0, invisible stats will usually be shown to the player
|
|
// (this is up to individual info panels, though). If this is -1 or lower,
|
|
// warning messages will not be displayed.
|
|
DebugLevel int
|
|
}
|
|
|
|
// GameState represents various states a player's Game can be in.
|
|
type GameState int
|
|
|
|
const (
|
|
// The game has not started.
|
|
GameUninitialized = GameState(iota)
|
|
|
|
// The game is ready to play.
|
|
GameActive
|
|
|
|
// The game is over and the player has lost.
|
|
GameLost
|
|
|
|
// The game is over and the player has won.
|
|
GameWon
|
|
|
|
// The game is over because of an error.
|
|
GameCrashed
|
|
|
|
// The game is over because the player cannot take any actions.
|
|
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:", 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, 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)
|
|
}
|