Start implementing Deck as a distinct type.

Deck does enough stuff it should be its own thing; its internal representation might change to make multiple insertions not quadratic, among other reasons. Currently it just does the obvious stuff, though. Allows inserting cards in random or specific slots and drawing cards. Shuffling a range of the deck comes later.
This commit is contained in:
Kistaro Windrider 2023-04-01 11:50:39 -07:00
parent 8153a7c083
commit f8b6b6b376
Signed by: kistaro
SSH Key Fingerprint: SHA256:TBE2ynfmJqsAf0CP6gsflA0q5X5wD5fVKWPsZ7eVUg8
3 changed files with 219 additions and 8 deletions

205
cardsim/deck.go Normal file
View File

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

View File

@ -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,9 +139,9 @@ 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.
// - 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 {
@ -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.

View File

@ -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]