Kistaro Windrider
09fdf19948
Make it possible for a card to display an option but not actually allow it to be selected. It's up to the UI layer to decide how to display options that are not enabled. The option text should probably contiain a note on why the option cannot be selected...
404 lines
15 KiB
Go
404 lines
15 KiB
Go
package cardsim
|
|
|
|
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.
|
|
// 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
|
|
)
|
|
|
|
// 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)
|
|
}
|