diff --git a/cardsim/deck.go b/cardsim/deck.go new file mode 100644 index 0000000..2b10db4 --- /dev/null +++ b/cardsim/deck.go @@ -0,0 +1,205 @@ +package cardsim + +import ( + "errors" + "fmt" + "math" + "math/rand" +) + +var ( + WarningBackwardsRange = Warningf("low end of range was above high end of range, flipping it") + WarningTopClamped = Warningf("target above top of deck moved to top") + WarningBottomClamped = Warningf("target below bottom of deck moved to bottom") + WarningTooFewCards = Warningf("too few cards") + + ErrorNoCards = errors.New("totally out of cards") +) + +const ( + BottomOfDeck = math.MaxInt +) + +// The Deck stores cards yet-to-be-dealt. +type Deck[C StatsCollection] struct { + cards []Card[C] + rand rand.Rand +} + +// Len returns the number of cards in the Deck. +func (d *Deck[C]) Len() int { + return len(d.cards) +} + +// Insert puts a card at a specific location in the Deck. The card previously +// at that location and all locations after are shifted one card later. +// Negative indexes are counted from the bottom of the deck. BottomOfDeck is +// a sentinel value for the bottommost position; -1 is one card above. +// +// Inserting at "one past the end", or using the BottomOfDeck sentinel, +// adds a card to the bottom of the deck without error. Adding it deeper +// than that does the same but returns WarningBottomClamped. Negative indexes +// that exceed the number of cards in the deck go to the top of the deck; if +// it's more negative than "one before the beginning", this returns +// WarningTopClamped. Like all warnings, these can be safely ignored and the +// program is in a well-defined state, but you may want to check for them +// if you expect some other behavior. +func (d *Deck[C]) Insert(idx int, card Card[C]) error { + var errs ErrorCollector + // Calculate actual target index. + switch { + case idx == BottomOfDeck: + idx = d.Len() + case idx > d.Len(): + errs.Add(fmt.Errorf("%w: position %d requested in %d cards", WarningBottomClamped, idx, d.Len())) + idx = d.Len() + case idx < -d.Len(): + errs.Add(fmt.Errorf("%w: position %d (backwards) requested in %d cards", WarningTopClamped, idx, d.Len())) + idx = 0 + case idx < 0: + idx += d.Len() + } + // remaining case: 0 <= idx <= d.Len(), which is a normal forward insert index. + + // Place new card on bottom and "bubble" into position. + // Takes O(N) time. If this turns out to be a problem, implement a more + // efficient data structure. + d.cards = append(d.cards, card) + for i := len(d.cards) - 1; i > idx; i-- { + d.cards[i], d.cards[i-1] = d.cards[i-1], d.cards[i] + } + return errs.Emit() +} + +// Draw pops the 0th card off the deck and returns it. If the deck has no cards, it returns nil. +func (d *Deck[C]) Draw() Card[C] { + if d.Len() == 0 { + return nil + } + ret := d.cards[0] + // Discard the top card via reslicing. This means that cards and deck slots + // hang around until an Append copies the deck structure somewhere else. + // If this turns out to be a problem, implement a more efficient algorithm + // or data structure. + d.cards = d.cards[1:] + return ret +} + +// DrawN pops the top N cards off the deck and returns them. If the deck does +// not have that many cards, this returns as many cards as it has and +// WarningTooFewCards, unless the deck has no cards at all, in which case it +// returns nil, ErrorNoCards. +func (d *Deck[C]) DrawN(n int) ([]Card[C], error) { + if d.Len() == 0 { + return nil, ErrorNoCards + } + if d.Len() <= n { + ret := d.cards + d.cards = nil + if len(ret) < n { + return ret, WarningTooFewCards + } + return ret, nil + } + ret := d.cards[:n] + d.cards = d.cards[n:] + return ret, nil +} + +// InsertRandom puts a card randomly in the deck, with equal probability for +// any of the Len()+1 possible locations. +func (d *Deck[C]) InsertRandom(card Card[C]) error { + return d.InsertRandomRange(0.0, 1.0, card) +} + +// InsertRandomTop puts a card randomly in the top part of the deck, within +// the fraction of the deck specified by frac, between 0.0 and 1.0 inclusive. +// If frac is not in this range, it returns ErrBadFrac and does not insert +// the card. +// +// InsertRandomTop chooses its insertion location uniformly randomly within +// the provided fractional range. If `frac * (d.Len()+1)` is not an integer, +// InsertRandomTop places the card slightly past the end of the specified range +// if its random location lands within the "fractional slot" at the end that +// didn't line up evenly. +// +// Example +// ------- +// +// Consider a 1-card deck, `[A]`. This deck has two places a card (we'll call +// it `B`) can be inserted: before or after the existing card, yielding decks +// `[A, B]` and `[B, A]`. If `InsertRandomTop` is invoked on this deck, then: +// +// - **If `frac <= 0.5`:** The only possible result is `[B, A]`. The range +// covering 50% of the open slots does not include any portion of any slot +// beyond the first. It may include less than the entire first slot, but +// there are no other slots possible. +// - **If `frac == 1.0`:** Both placements are equally likely. All slots are +// completely covered. +// - **If `frac == 0.75`:** It will choose `[B, A]` two-thirds of the time and +// `[A, B]` one-third of the time. The first 0.5 of the range covers the +// first slot (before `A`); the remaining 0.25 covers _half_ of the slot +// after A. This slot is therefore half as likely to be chosen as any other +// slot -- the only other slot, in this simplified example. +// +// For most practical purposes, this detail can be safely ignored. +func (d *Deck[C]) InsertRandomTop(frac float64, card Card[C]) error { + return d.InsertRandomRange(0.0, frac, card) +} + +// InsertRandomBottom puts a card randomly somewhere in the bottom part of +// the deck, within the fraction of the deck specified by frac. See +// InsertRandomTop for details on edge conditions of range calculation. +func (d *Deck[C]) InsertRandomBottom(frac float64, card Card[C]) error { + return d.InsertRandomRange(frac, 1.0, card) +} + +// InsertRandomRange puts a card randomly somewhere between the loFrac and +// hiFrac fraction of the deck. See InsertRandomTop for details on edge +// conditions of range calculation. 0.0 is the slot before the top of the +// deck and 1.0 is the slot after the end. +// +// Warnings it may return include WarningBackwardsRange, WarningTopClamped, +// and WarningBottomClamped, and any combination of these (multiple errors +// can be aggregated into one and errors.Is will recognize all of them). +func (d *Deck[C]) InsertRandomRange(loFrac, hiFrac float64, card Card[C]) error { + var errs ErrorCollector + + // Check argument ordering and bounds. + if loFrac > hiFrac { + errs.Add(Warningf("%w: %f > %f", WarningBackwardsRange, loFrac, hiFrac)) + loFrac, hiFrac = hiFrac, loFrac + } + if loFrac < 0.0 { + errs.Add(Warningf("%w: loFrac was %d", WarningTopClamped, loFrac)) + loFrac = 0.0 + } + if loFrac > 1.0 { + errs.Add(Warningf("%w: loFrac was %d", WarningBottomClamped, loFrac)) + loFrac = 1.0 + } + if hiFrac < 0.0 { + errs.Add(Warningf("%w: hiFrac was %d", WarningTopClamped, hiFrac)) + hiFrac = 0.0 + } + if hiFrac > 1.0 { + errs.Add(Warningf("%w: hiFrac was %d", WarningBottomClamped, hiFrac)) + hiFrac = 1.0 + } + + // Pick a random spot in this range and translate it to a card slot. + scale := hiFrac - loFrac + spotFrac := d.rand.Float64()*scale + loFrac + slot := int(spotFrac * float64(d.Len()+1)) + if slot > d.Len() { + // This can't happen. rand.Float64 cannot return 1.0, so len+1 itself + // should not be reachable. + slot = d.Len() + errs.Add(Warningf("%w: InsertRandomRange(%f, %f, ...) chose slot end-plus-one: %d (limit %d). fraction %f", + WarningRecoverableBug, loFrac, hiFrac, slot, d.Len(), spotFrac)) + } + + // okay, after all that, we have a slot! + errs.Add(d.Insert(slot, card)) + return errs.Emit() +} diff --git a/cardsim/errors.go b/cardsim/errors.go index 7b5474f..15fe45e 100644 --- a/cardsim/errors.go +++ b/cardsim/errors.go @@ -19,6 +19,12 @@ var AnyWarning = &Warning{ E: errors.New("unspecified warning"), } +// Generic warnings that may be used in multiple spots in the library without +// any particular comment that the function may return these. +var ( + WarningRecoverableBug = Warningf("cardsim library has a bug, but can keep going") +) + // Error implements the error interface. A warning's error message is the // message of its underlying error, unmodified. func (w *Warning) Error() string { @@ -133,11 +139,11 @@ func (f Failure) As(target any) bool { // ErrorCollector accumulates errors as an operation progresses. The zero // ErrorCollector is its correct starting value. When it emits a final error: // -// * If it contains exactly zero errors, it returns nil. -// * If it contains exactly one error, it returns it. -// * 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. +// - If it contains exactly zero errors, it returns nil. +// - If it contains exactly one error, it returns it. +// - 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. type ErrorCollector struct { errs []error hasFailure bool @@ -181,10 +187,10 @@ func (ec *ErrorCollector) Emit() error { if len(ec.errs) == 1 { return ec.errs[0] } - if !ec.HasFailure() { + if ec.HasFailure() { return aggregateFailure(ec.errs) } - return &aggregateError{ec.errs} // all these are recognizable failures + 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/player.go b/cardsim/player.go index 1c56c0e..f487f41 100644 --- a/cardsim/player.go +++ b/cardsim/player.go @@ -6,7 +6,7 @@ import "math/rand" type Player[C StatsCollection] struct { Stats C Name string - Deck []Card[C] + Deck *Deck[C] Hand []Card[C] HandLimit int Rules *RuleCollection[C]