diff --git a/card.go b/card.go index db82748..25ac114 100644 --- a/card.go +++ b/card.go @@ -30,11 +30,23 @@ func (c Card) Joker() bool { return (c == 0) } +func (c Card) Masked() bool { + return (c == -1) +} + func (c Card) Suit() Suit { + if c.Joker() || c.Masked() { + panic("Suit on invalid") + } + return Suit(int(c) % 5) } func (c Card) Face() int { + if c.Joker() || c.Masked() { + panic("Face on invalid") + } + return int(c) / 5 } @@ -43,7 +55,7 @@ func (c Card) String() string { return "Jok" // 🂿 U+1F0BF } - if c == -1 { + if c.Masked() { return "INV" // Invalid } @@ -63,6 +75,10 @@ func (c Card) String() string { } +func NewMasked() Card { + return Card(-1) +} + func NewJoker() Card { return Card(0) } @@ -77,10 +93,12 @@ func Shuffle(c []Card) { }) } +const DeckSize = 58 * 2 + func Deck() []Card { // The full game deck is two 58-card decks // Each 58-card deck has 3 jokers plus {3..10 J Q K} in 5 suits. - ret := make([]Card, 0, 58*2) + ret := make([]Card, 0, DeckSize) for i := 0; i < 6; i++ { ret = append(ret, NewJoker()) // Joker } diff --git a/card_test.go b/card_test.go index c412454..bc71d57 100644 --- a/card_test.go +++ b/card_test.go @@ -9,12 +9,11 @@ import ( func TestCards(t *testing.T) { d := Deck() - assert.Len(t, d, 58*2) + assert.Len(t, d, DeckSize) assert.EqualValues(t, []Card{0, 0, 0, 0, 0, 0, 15, 15, 20, 20, 25, 25, 30, 30, 35, 35, 40, 40, 45, 45, 50, 50, 55, 55, 60, 60, 65, 65, 16, 16, 21, 21, 26, 26, 31, 31, 36, 36, 41, 41, 46, 46, 51, 51, 56, 56, 61, 61, 66, 66, 17, 17, 22, 22, 27, 27, 32, 32, 37, 37, 42, 42, 47, 47, 52, 52, 57, 57, 62, 62, 67, 67, 18, 18, 23, 23, 28, 28, 33, 33, 38, 38, 43, 43, 48, 48, 53, 53, 58, 58, 63, 63, 68, 68, 19, 19, 24, 24, 29, 29, 34, 34, 39, 39, 44, 44, 49, 49, 54, 54, 59, 59, 64, 64, 69, 69}, d) Shuffle(d) - assert.Len(t, d, 58*2) - + assert.Len(t, d, DeckSize) } diff --git a/game.go b/game.go index 80910f4..7f45eaf 100644 --- a/game.go +++ b/game.go @@ -8,147 +8,145 @@ func PlayGame(numPlayers int) { currentPlayer := 0 + runningScores := make([]int, numPlayers) + for round := 3; round < 14; round++ { - d := Deck() - Shuffle(d) + r := NewRound(round, numPlayers, currentPlayer) + roundScores := r.Play() + currentPlayer = r.currentPlayer - discard := []Card{} - - fmt.Printf("# Round %d\n\n", round) - - // Deal starting cards - - hands := make([][]Card, numPlayers) for p := 0; p < numPlayers; p++ { - hands[p] = d[0:round] // Deal from the bottom (0) - d = d[round:] - - fmt.Printf("P%d starting hand: %v\n", p, hands[p]) + runningScores[p] += roundScores[p] } - // Deal one into the discard pile - - discard = append(discard, d[0]) - d = d[1:] - - fmt.Printf("Discard stack: %v\n", discard) - - fmt.Printf("Deck has %d cards remaining\n", len(d)) - - roundEndsWhenPlayerIs := -1 - - for { - if roundEndsWhenPlayerIs == currentPlayer { - break - } - - // Score our hand as if we pick up the discard and discard it immediately - _, score := FindBestGrouping(hands[currentPlayer], round) - baseScore := score - bestDiscardBasedIdx := -1 - bestDiscardBasedScore := score - - // Score our hand as if we pick up the discard card and discard each other card - - for cidx := 0; cidx < round; cidx++ { - // Pick up the last discard card, and discard card@cidx - theoretically := forkHand(hands[currentPlayer]) - theoretically[cidx] = discard[len(discard)-1] - _, score := FindBestGrouping(theoretically, round) - - if score < bestDiscardBasedScore { - // TODO if the points are equal or near-equal, prefer not to discard a wild for the next player - bestDiscardBasedIdx = cidx - bestDiscardBasedScore = score - } - } - - // If taking the discard would let us win, or reduce our score by >5, take it - if bestDiscardBasedScore == 0 || (baseScore-bestDiscardBasedScore) > 5 { - - fmt.Printf("P%d taking %v from the discard pile\n", currentPlayer, discard[len(discard)-1]) - - // We're going to win - if bestDiscardBasedIdx == -1 { - // Take the discard card + put it back again i.e. no change - - } else { - // Take the discard card and discard cidx - newCard := discard[len(discard)-1] - discard = discard[0 : len(discard)-1] - - unwantedCard := hands[currentPlayer][bestDiscardBasedIdx] - hands[currentPlayer][bestDiscardBasedIdx] = newCard - - discard = append(discard, unwantedCard) // On top = available for next player - - fmt.Printf("P%d discards %v\n", currentPlayer, unwantedCard) - - } - - } else { - // The discard card is not going to make us win or significantly lower our - // score - // Take from the deck - - newCard := d[0] - d = d[1:] - hands[currentPlayer] = append(hands[currentPlayer], newCard) - - fmt.Printf("P%d taking %v from the deck\n", currentPlayer, newCard) - - bestRandomBasedIdx := -1 - bestRandomBasedScore := (round * 50) + 51 // Worse than the worst possible score - - // Look at what we can now drop (hand size + 1 new card). We have to drop something - for cidx := 0; cidx < round+1; cidx++ { - // Assume we discard that card, - theoretically := forkHand(hands[currentPlayer]) - theoretically[cidx] = Card(-1) // Never matches + worth 0 points - _, score := FindBestGrouping(theoretically, round) - - if score < bestRandomBasedScore { - // TODO if the points are equal or near-equal, prefer not to discard a wild for the next player - bestRandomBasedIdx = cidx - bestRandomBasedScore = score - } - - } - - // Discard our chosen card - // Slice tricks: Copy end card into this slot and reslice bounds - - unwantedCard := hands[currentPlayer][bestRandomBasedIdx] - - fmt.Printf("P%d discards %v\n", currentPlayer, unwantedCard) - - hands[currentPlayer][bestRandomBasedIdx] = hands[currentPlayer][len(hands[currentPlayer])-1] - hands[currentPlayer] = hands[currentPlayer][0 : len(hands[currentPlayer])-1] - - discard = append(discard, unwantedCard) // On top = available for next player - - } - - // Check, one more time, if we have a winning hand - - currentBestGrouping, currentScore := FindBestGrouping(hands[currentPlayer], round) - if currentScore == 0 { - - // Declare victory - fmt.Printf("P%d declares victory\n- Hand: %v\n- Groupings: %v\n", currentPlayer, hands[currentPlayer], currentBestGrouping) - - roundEndsWhenPlayerIs = currentPlayer - } - - // Move to the next player - currentPlayer = (currentPlayer + 1) % numPlayers - } - - // The round has ended - // Figure out what each player scored - - return - + fmt.Printf("The round has ended\n") + fmt.Printf("Running total scores: %v\n", runningScores) } + + fmt.Printf("The game has ended\n") +} + +type Round struct { + d []Card + discard []Card + round int + numPlayers int + currentPlayer int + hands [][]Card +} + +func NewRound(round, numPlayers, currentPlayer int) *Round { + + r := Round{ + round: round, + numPlayers: numPlayers, + currentPlayer: currentPlayer, + } + + r.d = Deck() + Shuffle(r.d) + + r.discard = []Card{} + + fmt.Printf("# Round %d\n\n", r.round) + + // Deal starting cards + + r.hands = make([][]Card, numPlayers) + for p := 0; p < numPlayers; p++ { + r.hands[p] = r.d[0:round] // Deal from the bottom (0) + r.d = r.d[round:] + + fmt.Printf("P%d starting hand: %v\n", p, r.hands[p]) + } + + // Deal one into the discard pile + + r.discard = append(r.discard, r.d[0]) + r.d = r.d[1:] + + // Done + + return &r +} + +func FormatHandGroupings(hand []Card, groupings [][]int) string { + tmp := forkHand(hand) + + cgroups := [][]Card{} + for _, group := range groupings { + cgroup := []Card{} + for _, cidx := range group { + cgroup = append(cgroup, hand[cidx]) + + tmp[cidx] = Card(-1) + } + cgroups = append(cgroups, cgroup) + } + + leftover := []Card{} + for _, cv := range tmp { + if cv != Card(-1) { + leftover = append(leftover, cv) + } + } + + return fmt.Sprintf("[ %v leftover %v ]", cgroups, leftover) +} + +func (r *Round) Play() []int { + + fmt.Printf("Discard stack: %v\n", r.discard) + + fmt.Printf("Deck has %d cards remaining\n", len(r.d)) + + roundEndsWhenPlayerIs := -1 + + for { + if roundEndsWhenPlayerIs == r.currentPlayer { + break + } + + // Play the strategy for the current player + r.PlayDefaultStrategy() + + // Check, one more time, if we have a winning hand + + currentBestGrouping, currentScore := FindBestGrouping(r.hands[r.currentPlayer], r.round) + if currentScore == 0 { + + // Declare victory + fmt.Printf("P%d declares victory\n- Hand: %v\n- Groupings: %v\n", r.currentPlayer, r.hands[r.currentPlayer], currentBestGrouping) + + if roundEndsWhenPlayerIs == -1 { + roundEndsWhenPlayerIs = r.currentPlayer + } // Otherwise, the round is already ending anyway + } + + // Move to the next player + r.currentPlayer = (r.currentPlayer + 1) % r.numPlayers + } + + // The round has ended + // Figure out what each player scored + + roundScores := make([]int, r.numPlayers) + + for p := 0; p < r.numPlayers; p++ { + + gr, score := FindBestGrouping(r.hands[p], r.round) + + fmt.Printf("P%d has %v (score: %d)\n", p, FormatHandGroupings(r.hands[p], gr) /*r.hands[p], gr,*/, score) + + roundScores[p] = score + } + + // Check stack alignment + if len(r.discard)+len(r.d)+(r.numPlayers*len(r.hands[0])) != DeckSize { + panic("Cards on the floor") + } + + // Done + return roundScores } diff --git a/search.go b/search.go index 5b153af..c2d120a 100644 --- a/search.go +++ b/search.go @@ -1,7 +1,7 @@ package main import ( - "sort" + "fmt" "github.com/mxschmitt/golang-combinations" ) @@ -15,7 +15,11 @@ func MakeBooks(hand []Card, wildFace int) [][]int { for i := 3; i < 14; i++ { book := []int{} for idx, c := range hand { - if c.Face() == i || c.Joker() || c.Face() == wildFace { + if c.Masked() { + continue // Doesn't match anything + } + + if c.Joker() || c.Face() == i || c.Face() == wildFace { book = append(book, idx) } } @@ -37,64 +41,6 @@ func MakeBooks(hand []Card, wildFace int) [][]int { return pairings } -func search(hand []Card, wildFace int, search Card) []int { - ret := []int{} - for idx, c := range hand { - if c == search || c.Joker() || c.Face() == wildFace { - ret = append(ret, idx) - } - } - return ret -} - -func searchExcept(hand []Card, wildFace int, search Card, blockindexes map[int]struct{}) []int { - ret := []int{} - for idx, c := range hand { - if _, blocked := blockindexes[idx]; blocked { - continue - } - - if c == search || c.Joker() || c.Face() == wildFace { - ret = append(ret, idx) - } - } - return ret -} - -// take takes a single card out of a hand by its index, returning the remaining cards. -func take(hand []Card, index int) (Card, []Card) { - cc := hand[index] - - rem := make([]Card, 0, len(hand)-1) - rem = append(rem, hand[0:index]...) - rem = append(rem, hand[index+1:]...) - - return cc, rem -} - -func forkPossibilities(in map[int]struct{}) map[int]struct{} { - ret := map[int]struct{}{} - for prev, _ := range in { - ret[prev] = struct{}{} - } - return ret -} - -func forkHand(hand []Card) []Card { - ret := make([]Card, len(hand)) - copy(ret, hand) - return ret -} - -func flattenPossibility(in map[int]struct{}) []int { - flat := make([]int, 0, len(in)) - for cidx, _ := range in { - flat = append(flat, cidx) - } - sort.Ints(flat) - return flat -} - func continueRun(hand []Card, wildFace int, requireSuit Suit, searchNextFace int, takenIndexes map[int]struct{}) []map[int]struct{} { possibilities := []map[int]struct{}{ @@ -163,7 +109,20 @@ func MakeMultiGroups(hand []Card, wildface int) [][][]int { cloneHand := forkHand(hand) for _, midx := range p { - cloneHand[midx] = Card(-1) // can't match with anything + cloneHand[midx] = NewMasked() // can't match with anything + } + + // If that uses up all cards, then early-exit + anyUnpaired := false + for cidx := 0; cidx < len(cloneHand); cidx++ { + if cloneHand[cidx] != Card(-1) { + anyUnpaired = true + break + } + } + + if !anyUnpaired { + return [][][]int{[][]int{p}} // Shut out all other possibilities, this is a winner } chposs := MakeMultiGroups(cloneHand, wildface) @@ -185,30 +144,37 @@ func ScoreAsUnpaired(hand []Card) int { for _, c := range hand { if c.Joker() { score += 50 - } else if c == Card(-1) { + } else if c.Masked() { // skip (already accounted for) } else { score += c.Face() } } + return score } func FindBestGrouping(hand []Card, wildface int) ([][]int, int) { groupings := MakeMultiGroups(hand, wildface) + fmt.Printf("Found %d possible groupings\n", len(groupings)) + bestScore := 50 * len(hand) // Worst possible score bestGroupIdx := -1 for ggidx, grouping := range groupings { - cloneHand := make([]Card, len(hand)) + cloneHand := forkHand(hand) for _, group := range grouping { for _, cidx := range group { - cloneHand[cidx] = Card(-1) // invalid + cloneHand[cidx] = NewMasked() // invalid } } score := ScoreAsUnpaired(cloneHand) + if score == 0 { + return groupings[ggidx], 0 // Early exit + } + if score < bestScore { bestScore = score bestGroupIdx = ggidx @@ -217,7 +183,8 @@ func FindBestGrouping(hand []Card, wildface int) ([][]int, int) { } if bestGroupIdx == -1 { - return [][]int{}, bestScore // No pairings could be found, full points + // No pairings could be found, score all remaining cards as ungrouped + return [][]int{}, ScoreAsUnpaired(hand) } return groupings[bestGroupIdx], bestScore } diff --git a/search_test.go b/search_test.go index 7c0bfb0..14b999e 100644 --- a/search_test.go +++ b/search_test.go @@ -43,3 +43,15 @@ func TestSolveHand(t *testing.T) { assert.EqualValues(t, [][]int{[]int{0, 4, 5}, []int{1, 2, 3}}, grouping) assert.EqualValues(t, 0, score) } + +func TestScoreHand(t *testing.T) { + // P2 has [ [[ J★ 8♥ 8★] [ 9♥ 9♥ 9♠] [ Q♠ Jok Q♠]] leftover [10♣ 4♣] ] (score: 100) + + hand := []Card{15, 20, 25, 30, 16, 17, 55, 60} + + t.Logf("Finding score for hand: %v", hand) + + grouping, score := FindBestGrouping(hand, 10) + assert.EqualValues(t, [][]int{[]int{0, 4, 5}, []int{1, 2, 3}}, grouping) + assert.EqualValues(t, 23, score) +} diff --git a/strategy.go b/strategy.go new file mode 100644 index 0000000..e450031 --- /dev/null +++ b/strategy.go @@ -0,0 +1,103 @@ +package main + +import ( + "fmt" +) + +func (r *Round) PlayDefaultStrategy() { + + currentPlayer := r.currentPlayer + + // Score our hand as if we pick up the discard and discard it immediately + _, score := FindBestGrouping(r.hands[r.currentPlayer], r.round) + baseScore := score + bestDiscardBasedIdx := -1 + bestDiscardBasedScore := score + + if score != 0 { + + // Score our hand as if we pick up the discard card and discard each other card + + for cidx := 0; cidx < r.round; cidx++ { + // Pick up the last discard card, and discard card@cidx + theoretically := forkHand(r.hands[r.currentPlayer]) + theoretically[cidx] = r.discard[len(r.discard)-1] + _, score := FindBestGrouping(theoretically, r.round) + + if score < bestDiscardBasedScore { + // TODO if the points are equal or near-equal, prefer not to discard a wild for the next player + bestDiscardBasedIdx = cidx + bestDiscardBasedScore = score + } + } + + } + + // If taking the discard would let us win, or reduce our score by >5, take it + if bestDiscardBasedScore == 0 || (baseScore-bestDiscardBasedScore) > 5 { + + fmt.Printf("P%d taking %v from the discard pile\n", r.currentPlayer, r.discard[len(r.discard)-1]) + + // We're going to win + if bestDiscardBasedIdx == -1 { + // Take the discard card + put it back again i.e. no change + fmt.Printf("P%d discards %v immediately\n", r.currentPlayer, r.discard[len(r.discard)-1]) + + } else { + // Take the discard card and discard cidx + newCard := r.discard[len(r.discard)-1] + r.discard = r.discard[0 : len(r.discard)-1] + + unwantedCard := r.hands[r.currentPlayer][bestDiscardBasedIdx] + r.hands[r.currentPlayer][bestDiscardBasedIdx] = newCard + + r.discard = append(r.discard, unwantedCard) // On top = available for next player + + fmt.Printf("P%d discards %v (position %d)\n", currentPlayer, unwantedCard, bestDiscardBasedIdx) + + } + + } else { + // The discard card is not going to make us win or significantly lower our + // score + // Take from the deck + + newCard := r.d[0] + r.d = r.d[1:] + r.hands[r.currentPlayer] = append(r.hands[r.currentPlayer], newCard) + + fmt.Printf("P%d taking %v from the deck\n", r.currentPlayer, newCard) + + bestRandomBasedIdx := -1 + bestRandomBasedScore := (r.round * 50) + 51 // Worse than the worst possible score + + // Look at what we can now drop (hand size + 1 new card). We have to drop something + for cidx := 0; cidx < r.round+1; cidx++ { + // Assume we discard that card, + theoretically := forkHand(r.hands[currentPlayer]) + theoretically[cidx] = Card(-1) // Never matches + worth 0 points + _, score := FindBestGrouping(theoretically, r.round) + + if score < bestRandomBasedScore { + // TODO if the points are equal or near-equal, prefer not to discard a wild for the next player + bestRandomBasedIdx = cidx + bestRandomBasedScore = score + } + + } + + // Discard our chosen card + // Slice tricks: Copy end card into this slot and reslice bounds + + unwantedCard := r.hands[r.currentPlayer][bestRandomBasedIdx] + + fmt.Printf("P%d discards %v (position %d)\n", currentPlayer, unwantedCard, bestRandomBasedIdx) + + r.hands[r.currentPlayer][bestRandomBasedIdx] = r.hands[r.currentPlayer][len(r.hands[r.currentPlayer])-1] + r.hands[r.currentPlayer] = r.hands[r.currentPlayer][0 : len(r.hands[r.currentPlayer])-1] + + r.discard = append(r.discard, unwantedCard) // On top = available for next player + + } + +} diff --git a/util.go b/util.go new file mode 100644 index 0000000..90df277 --- /dev/null +++ b/util.go @@ -0,0 +1,67 @@ +package main + +import ( + "sort" +) + +func search(hand []Card, wildFace int, search Card) []int { + ret := []int{} + for idx, c := range hand { + if c == search || c.Joker() || c.Face() == wildFace { + ret = append(ret, idx) + } + } + return ret +} + +func searchExcept(hand []Card, wildFace int, search Card, blockindexes map[int]struct{}) []int { + ret := []int{} + for idx, c := range hand { + if _, blocked := blockindexes[idx]; blocked { + continue + } + + if c.Masked() { + continue // Doesn't match anything + } + + if c.Joker() || c == search || c.Face() == wildFace { + ret = append(ret, idx) + } + } + return ret +} + +// take takes a single card out of a hand by its index, returning the remaining cards. +func take(hand []Card, index int) (Card, []Card) { + cc := hand[index] + + rem := make([]Card, 0, len(hand)-1) + rem = append(rem, hand[0:index]...) + rem = append(rem, hand[index+1:]...) + + return cc, rem +} + +func forkPossibilities(in map[int]struct{}) map[int]struct{} { + ret := map[int]struct{}{} + for prev, _ := range in { + ret[prev] = struct{}{} + } + return ret +} + +func forkHand(hand []Card) []Card { + ret := make([]Card, len(hand)) + copy(ret, hand) + return ret +} + +func flattenPossibility(in map[int]struct{}) []int { + flat := make([]int, 0, len(in)) + for cidx, _ := range in { + flat = append(flat, cidx) + } + sort.Ints(flat) + return flat +}