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) }