From 42490e2b4befde5e472205581ece280b0b0dbc66 Mon Sep 17 00:00:00 2001 From: mappu Date: Sun, 7 Jan 2024 13:09:47 +1300 Subject: [PATCH] initial commit --- README.md | 3 + card.go | 95 +++++++++++++++++++++ card_test.go | 20 +++++ game.go | 154 ++++++++++++++++++++++++++++++++++ game_test.go | 9 ++ go.mod | 11 +++ go.sum | 11 +++ search.go | 223 +++++++++++++++++++++++++++++++++++++++++++++++++ search_test.go | 45 ++++++++++ 9 files changed, 571 insertions(+) create mode 100644 README.md create mode 100644 card.go create mode 100644 card_test.go create mode 100644 game.go create mode 100644 game_test.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 search.go create mode 100644 search_test.go diff --git a/README.md b/README.md new file mode 100644 index 0000000..c538ad0 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# crowns + +AI and simulator for the card game [Five Crowns](https://setgame.com/sites/default/files/instructions/FIVE%20CROWNS%20-%20ENGLISH_2.pdf). diff --git a/card.go b/card.go new file mode 100644 index 0000000..db82748 --- /dev/null +++ b/card.go @@ -0,0 +1,95 @@ +package main + +import ( + "fmt" + "math/rand" +) + +type Suit int + +func (s Suit) String() string { + switch s { + case 0: + return "♠" + case 1: + return "♥" + case 2: + return "♦" + case 3: + return "♣" + case 4: + return "★" + default: + panic("bad suit") + } +} + +type Card int + +func (c Card) Joker() bool { + return (c == 0) +} + +func (c Card) Suit() Suit { + return Suit(int(c) % 5) +} + +func (c Card) Face() int { + return int(c) / 5 +} + +func (c Card) String() string { + if c.Joker() { + return "Jok" // 🂿 U+1F0BF + } + + if c == -1 { + return "INV" // Invalid + } + + switch c.Face() { + case 11: + return fmt.Sprintf(" J%s", c.Suit().String()) + + case 12: + return fmt.Sprintf(" Q%s", c.Suit().String()) + + case 13: + return fmt.Sprintf(" K%s", c.Suit().String()) + + default: + return fmt.Sprintf("%2d%s", c.Face(), c.Suit().String()) + } + +} + +func NewJoker() Card { + return Card(0) +} + +func NewCardFrom(face int, suit Suit) Card { + return Card(face*5 + int(suit)) +} + +func Shuffle(c []Card) { + rand.Shuffle(len(c), func(i, j int) { + c[i], c[j] = c[j], c[i] + }) +} + +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) + for i := 0; i < 6; i++ { + ret = append(ret, NewJoker()) // Joker + } + + for s := 0; s < 5; s++ { + for f := 3; f < 14; f++ { + ret = append(ret, NewCardFrom(f, Suit(s)), NewCardFrom(f, Suit(s))) + } + } + + return ret +} diff --git a/card_test.go b/card_test.go new file mode 100644 index 0000000..c412454 --- /dev/null +++ b/card_test.go @@ -0,0 +1,20 @@ +package main + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestCards(t *testing.T) { + d := Deck() + + assert.Len(t, d, 58*2) + + 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) + +} diff --git a/game.go b/game.go new file mode 100644 index 0000000..80910f4 --- /dev/null +++ b/game.go @@ -0,0 +1,154 @@ +package main + +import ( + "fmt" +) + +func PlayGame(numPlayers int) { + + currentPlayer := 0 + + for round := 3; round < 14; round++ { + + d := Deck() + Shuffle(d) + + 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]) + } + + // 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 + + } +} diff --git a/game_test.go b/game_test.go new file mode 100644 index 0000000..c2f1024 --- /dev/null +++ b/game_test.go @@ -0,0 +1,9 @@ +package main + +import ( + "testing" +) + +func TestPlayGame(t *testing.T) { + PlayGame(4) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..8a72010 --- /dev/null +++ b/go.mod @@ -0,0 +1,11 @@ +module code.ivysaur.me/crowns + +go 1.19 + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/mxschmitt/golang-combinations v1.2.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/stretchr/testify v1.8.4 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..0a541c7 --- /dev/null +++ b/go.sum @@ -0,0 +1,11 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/mxschmitt/golang-combinations v1.2.0 h1:V5E7MncIK8Yr1SL/SpdqMuSquFsfoIs5auI7Y3n8z14= +github.com/mxschmitt/golang-combinations v1.2.0/go.mod h1:RCm5eR03B+JrBOMRDLsKZWShluXdrHu+qwhPEJ0miBM= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/search.go b/search.go new file mode 100644 index 0000000..5b153af --- /dev/null +++ b/search.go @@ -0,0 +1,223 @@ +package main + +import ( + "sort" + + "github.com/mxschmitt/golang-combinations" +) + +func MakeBooks(hand []Card, wildFace int) [][]int { + // Return all the possible single books that could be made from this hand. + // Caller should call this tree-recursively to find multiple grouping options. + pairings := [][]int{} + + // Check for books + for i := 3; i < 14; i++ { + book := []int{} + for idx, c := range hand { + if c.Face() == i || c.Joker() || c.Face() == wildFace { + book = append(book, idx) + } + } + + if len(book) == 3 { + // That's an option + pairings = append(pairings, book) + + } else if len(book) > 3 { + // For length over 3, all combinations of this are an option + + for booksz := 3; booksz < len(book)+1; booksz++ { + pairings = append(pairings, combinations.Combinations(book, booksz)...) + } + } + } + + // Those are all the pairings we found + 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{}{ + takenIndexes, + } + + // Do you have any XX|wild + posns := searchExcept(hand, wildFace, NewCardFrom(searchNextFace, requireSuit), takenIndexes) + + // For each match: + for _, midx := range posns { + + // Take out that card + branchTaken := forkPossibilities(takenIndexes) + branchTaken[midx] = struct{}{} + + // Try and continue the run upwards to the next card, using only the remaining parts of the hand + // (We will never find K+1) + continuations := continueRun(hand, wildFace, requireSuit, searchNextFace+1, branchTaken) + + // We found those possibilities for this run + possibilities = append(possibilities, continuations...) + } + + return possibilities +} + +func MakeRuns(hand []Card, wildFace int) [][]int { + pairings := [][]int{} + + for s := 0; s < 5; s++ { + + for f := 3; f < 12; f++ { // -2 from usual 14, as you can't start an upward run from Q/K + + // Starting with this card, find all runs going upward + found := continueRun(hand, wildFace, Suit(s), f, map[int]struct{}{}) + + // For each possible run we could make starting here: + for _, ff := range found { + if len(ff) < 3 { + continue // Short runs are no good + } + pairings = append(pairings, flattenPossibility(ff)) + } + + } + } + + return pairings +} + +func MakeMultiGroups(hand []Card, wildface int) [][][]int { + // Depth-first recurse to find all groups in the hand + + ret := [][][]int{} + + poss := [][]int{} + poss = append(poss, MakeBooks(hand, wildface)...) + poss = append(poss, MakeRuns(hand, wildface)...) + + for _, p := range poss { + // One groupup would be: to take that possibility alone + ret = append(ret, [][]int{p}) + + // Can we do anything more? + + cloneHand := forkHand(hand) + for _, midx := range p { + cloneHand[midx] = Card(-1) // can't match with anything + } + + chposs := MakeMultiGroups(cloneHand, wildface) + // Additional groupups would be: p plus chposs + for _, p2 := range chposs { + groupup := [][]int{p} + groupup = append(groupup, p2...) + ret = append(ret, groupup) + } + + } + + return ret + +} + +func ScoreAsUnpaired(hand []Card) int { + score := 0 + for _, c := range hand { + if c.Joker() { + score += 50 + } else if c == Card(-1) { + // skip (already accounted for) + } else { + score += c.Face() + } + } + return score +} + +func FindBestGrouping(hand []Card, wildface int) ([][]int, int) { + groupings := MakeMultiGroups(hand, wildface) + + bestScore := 50 * len(hand) // Worst possible score + bestGroupIdx := -1 + for ggidx, grouping := range groupings { + + cloneHand := make([]Card, len(hand)) + for _, group := range grouping { + for _, cidx := range group { + cloneHand[cidx] = Card(-1) // invalid + } + } + + score := ScoreAsUnpaired(cloneHand) + if score < bestScore { + bestScore = score + bestGroupIdx = ggidx + } + + } + + if bestGroupIdx == -1 { + return [][]int{}, bestScore // No pairings could be found, full points + } + return groupings[bestGroupIdx], bestScore +} diff --git a/search_test.go b/search_test.go new file mode 100644 index 0000000..7c0bfb0 --- /dev/null +++ b/search_test.go @@ -0,0 +1,45 @@ +package main + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestFindBooks(t *testing.T) { + + hand := []Card{15, 16, 17} // [ 3♠ 3♥ 3♦] + + t.Logf("Finding books in hand: %v", hand) + + p := MakeBooks(hand, 10) + assert.EqualValues(t, [][]int{[]int{0, 1, 2}}, p) +} + +func TestFindRuns(t *testing.T) { + hand := []Card{15, 20, 25, 25, 30} // [ 3♠ 4♠ 5♠ 5♠ 6♠] + + t.Logf("Finding runs in hand: %v", hand) + + p := MakeRuns(hand, 10) + assert.EqualValues(t, [][]int{[]int{0, 1, 2}, []int{0, 1, 2, 4}, []int{0, 1, 3}, []int{0, 1, 3, 4}, []int{1, 2, 4}, []int{1, 3, 4}}, p) +} + +func TestFindMultiGroups(t *testing.T) { + hand := []Card{15, 20, 25, 30, 16, 17} + + t.Logf("Finding all groupups in hand: %v", hand) + + p := MakeMultiGroups(hand, 10) + assert.EqualValues(t, [][][]int{[][]int{[]int{0, 4, 5}}, [][]int{[]int{0, 4, 5}, []int{1, 2, 3}}, [][]int{[]int{0, 1, 2}}, [][]int{[]int{0, 1, 2, 3}}, [][]int{[]int{1, 2, 3}}, [][]int{[]int{1, 2, 3}, []int{0, 4, 5}}}, p) +} + +func TestSolveHand(t *testing.T) { + hand := []Card{15, 20, 25, 30, 16, 17} + + t.Logf("Finding best groupups in hand: %v", hand) + + grouping, score := FindBestGrouping(hand, 10) + assert.EqualValues(t, [][]int{[]int{0, 4, 5}, []int{1, 2, 3}}, grouping) + assert.EqualValues(t, 0, score) +}