initial commit

This commit is contained in:
mappu 2024-01-07 13:09:47 +13:00
commit 42490e2b4b
9 changed files with 571 additions and 0 deletions

3
README.md Normal file
View File

@ -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).

95
card.go Normal file
View File

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

20
card_test.go Normal file
View File

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

154
game.go Normal file
View File

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

9
game_test.go Normal file
View File

@ -0,0 +1,9 @@
package main
import (
"testing"
)
func TestPlayGame(t *testing.T) {
PlayGame(4)
}

11
go.mod Normal file
View File

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

11
go.sum Normal file
View File

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

223
search.go Normal file
View File

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

45
search_test.go Normal file
View File

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