initial commit
This commit is contained in:
commit
42490e2b4b
3
README.md
Normal file
3
README.md
Normal 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
95
card.go
Normal 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
20
card_test.go
Normal 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
154
game.go
Normal 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
9
game_test.go
Normal file
@ -0,0 +1,9 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPlayGame(t *testing.T) {
|
||||
PlayGame(4)
|
||||
}
|
11
go.mod
Normal file
11
go.mod
Normal 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
11
go.sum
Normal 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
223
search.go
Normal 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
45
search_test.go
Normal 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)
|
||||
}
|
Loading…
Reference in New Issue
Block a user