diff --git a/.gitignore b/.gitignore index ff704c2..2993cb2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ dist/ -truco \ No newline at end of file +/truco +!/truco/ \ No newline at end of file diff --git a/exampleclient/ui.go b/exampleclient/ui.go index 022ba6c..0cc8918 100644 --- a/exampleclient/ui.go +++ b/exampleclient/ui.go @@ -23,11 +23,19 @@ func NewUI() *ui { sendKeyPressCh: make(chan rune), } ui.startKeyEventLoop() + err := termbox.Init() + if err != nil { + panic(err) + } return ui } +func (u *ui) Close() { + termbox.Close() +} + func (u *ui) play(playerID int, gameState truco.GameState) (truco.Action, error) { - err := u.printState(playerID, gameState, PRINT_MODE_NORMAL) + err := u.render(playerID, gameState, PRINT_MODE_NORMAL) if err != nil { return nil, err } @@ -52,7 +60,7 @@ func (u *ui) play(playerID int, gameState truco.GameState) (truco.Action, error) input = fmt.Sprintf(`{"name":"%v","score":%d}`, actionName, gameState.Hands[gameState.TurnPlayerID].EnvidoScore()) } if actionName == "reveal_card" { - err := u.printState(playerID, gameState, PRINT_MODE_WHICH_CARD_REVEAL) + err := u.render(playerID, gameState, PRINT_MODE_WHICH_CARD_REVEAL) if err != nil { return nil, err } @@ -100,7 +108,7 @@ const ( PRINT_MODE_END ) -func (u *ui) printState(playerID int, state truco.GameState, mode printMode) error { +func (u *ui) render(playerID int, state truco.GameState, mode printMode) error { err := termbox.Clear(termbox.ColorWhite, termbox.ColorBlack) if err != nil { return err diff --git a/exampleclient/websocket_client.go b/exampleclient/websocket_client.go index e8e81be..91f714a 100644 --- a/exampleclient/websocket_client.go +++ b/exampleclient/websocket_client.go @@ -7,71 +7,57 @@ import ( "github.com/gorilla/websocket" "github.com/marianogappa/truco/server" "github.com/marianogappa/truco/truco" - "github.com/nsf/termbox-go" ) func Player(playerID int, address string) { ui := NewUI() - - err := termbox.Init() - if err != nil { - panic(err) - } - defer termbox.Close() + defer ui.Close() conn, _, err := websocket.DefaultDialer.Dial(fmt.Sprintf("ws://%v/ws", address), nil) if err != nil { - log.Println("Failed to connect to WebSocket server:", err) - return + log.Fatalf("Failed to connect to WebSocket server: %v", err) } defer conn.Close() if err := server.WsSend(conn, server.NewMessageHello(playerID)); err != nil { - log.Println(err) - return + log.Fatal(err) } lastRound := 0 for { gameState, err := server.WsReadMessage[truco.GameState, server.MessageHeresGameState](conn, server.MessageTypeHeresGameState) if err != nil { - log.Println(err) - return + log.Fatal(err) } if gameState.IsEnded { - _ = ui.printState(playerID, *gameState, PRINT_MODE_END) + _ = ui.render(playerID, *gameState, PRINT_MODE_END) return } if gameState.RoundNumber != lastRound && lastRound != 0 { - err := ui.printState(playerID, *gameState, PRINT_MODE_SHOW_ROUND_RESULT) + err := ui.render(playerID, *gameState, PRINT_MODE_SHOW_ROUND_RESULT) if err != nil { - log.Println(err) - return + log.Fatal(err) } } lastRound = gameState.RoundNumber if gameState.TurnPlayerID != playerID { - err := ui.printState(playerID, *gameState, PRINT_MODE_NORMAL) - if err != nil { - log.Println(err) - return + if err := ui.render(playerID, *gameState, PRINT_MODE_NORMAL); err != nil { + log.Fatal(err) } continue } action, err := ui.play(playerID, *gameState) if err != nil { - fmt.Println("Invalid action:", err) - break + log.Fatal("Invalid action:", err) } msg, _ := server.NewMessageAction(action) if err := server.WsSend(conn, msg); err != nil { - log.Println(err) - return + log.Fatal(err) } } } diff --git a/truco/action_any_quiero.go b/truco/action_any_quiero.go index 2b8ff66..1b1e655 100644 --- a/truco/action_any_quiero.go +++ b/truco/action_any_quiero.go @@ -94,3 +94,10 @@ func (a ActionSayTrucoNoQuiero) Run(g *GameState) error { g.Scores[g.OpponentPlayerID()] += cost return nil } + +func (a ActionSayTrucoQuiero) YieldsTurn(g GameState) bool { + // Next turn belongs to the player who started the truco + // "sub-sequence". Thus, yield turn if the current player + // is not the one who started the sub-sequence. + return g.TurnPlayerID != g.TrucoSequence.StartingPlayerID +} diff --git a/truco/actions.go b/truco/actions.go index 6eb91a1..09d3920 100644 --- a/truco/actions.go +++ b/truco/actions.go @@ -11,3 +11,47 @@ func (a act) GetName() string { func (a act) YieldsTurn(g GameState) bool { return true } + +func newActionSayEnvido() Action { + return ActionSayEnvido{act: act{Name: SAY_ENVIDO}} +} + +func newActionSayEnvidoNoQuiero() Action { + return ActionSayEnvidoNoQuiero{act: act{Name: SAY_ENVIDO_NO_QUIERO}} +} + +func newActionSayEnvidoQuiero(score int) Action { + return ActionSayEnvidoQuiero{act: act{Name: SAY_ENVIDO_QUIERO}, Score: score} +} + +func newActionSayTrucoQuiero() Action { + return ActionSayTrucoQuiero{act: act{Name: SAY_TRUCO_QUIERO}} +} + +func newActionSayTrucoNoQuiero() Action { + return ActionSayTrucoNoQuiero{act: act{Name: SAY_TRUCO_NO_QUIERO}} +} + +func newActionSayTruco() Action { + return ActionSayTruco{act: act{Name: SAY_TRUCO}} +} + +func newActionSayQuieroRetruco() Action { + return ActionSayQuieroRetruco{act: act{Name: SAY_QUIERO_RETRUCO}} +} + +func newActionSayQuieroValeCuatro() Action { + return ActionSayQuieroValeCuatro{act: act{Name: SAY_QUIERO_VALE_CUATRO}} +} + +func newActionSaySonBuenas() Action { + return ActionSaySonBuenas{act: act{Name: SAY_SON_BUENAS}} +} + +func newActionSaySonMejores(score int) Action { + return ActionSaySonMejores{act: act{Name: SAY_SON_MEJORES}, Score: score} +} + +func newActionRevealCard(card Card) Action { + return ActionRevealCard{act: act{Name: REVEAL_CARD}, Card: card} +} diff --git a/truco/actions_any_truco.go b/truco/actions_any_truco.go index 216d39d..bbf6b07 100644 --- a/truco/actions_any_truco.go +++ b/truco/actions_any_truco.go @@ -44,5 +44,14 @@ func (g *GameState) AnyTrucoActionRunAction(at Action) error { if !ok { return errActionNotPossible } + + // Possible actions are "truco", "quiero retruco" and "quiero vale cuatro", not "quiero"/"no quiero". + // If this is the first action in a sub-sequence (subsequences are delimited by "quiero" actions), + // Store the player ID that started the sub-sequence, so that turn can be yielded correctly after + // a "quiero" action. + if g.TrucoSequence.IsSubsequenceStart() { + g.TrucoSequence.StartingPlayerID = g.TurnPlayerID + } + return nil } diff --git a/truco/deck.go b/truco/deck.go index 579237c..f39e9a4 100644 --- a/truco/deck.go +++ b/truco/deck.go @@ -27,7 +27,8 @@ func (c Card) String() string { } type deck struct { - cards []Card + cards []Card + dealHandFunc func() *Hand } // Hand represents a player's hand. Cards can be revealed or unrevealed. @@ -37,6 +38,23 @@ type Hand struct { Revealed []Card `json:"revealed"` } +func (h Hand) DeepCopy() Hand { + cpyUnrevealed := []Card{} + cpyRevealed := []Card{} + for _, c := range h.Unrevealed { + newC := c + cpyUnrevealed = append(cpyUnrevealed, newC) + } + for _, c := range h.Revealed { + newC := c + cpyRevealed = append(cpyRevealed, newC) + } + return Hand{ + Unrevealed: cpyUnrevealed, + Revealed: cpyRevealed, + } +} + func (h Hand) HasUnrevealedCard(c Card) bool { for _, card := range h.Unrevealed { if card == c { @@ -88,10 +106,15 @@ func makeSpanishCards() []Card { func newDeck() *deck { d := deck{cards: makeSpanishCards()} + d.dealHandFunc = d.defaultDealHand return &d } func (d *deck) dealHand() *Hand { + return d.dealHandFunc() +} + +func (d *deck) defaultDealHand() *Hand { hand := &Hand{} for i := 0; i < 3; i++ { hand.Unrevealed = append(hand.Unrevealed, d.cards[i]) diff --git a/truco/deck_test.go b/truco/deck_test.go index ac407bb..4ec36ae 100644 --- a/truco/deck_test.go +++ b/truco/deck_test.go @@ -3,6 +3,8 @@ package truco import ( "fmt" "testing" + + "github.com/stretchr/testify/assert" ) func TestCard_CompareTrucoScore(t *testing.T) { @@ -61,3 +63,54 @@ func TestCard_CompareTrucoScore(t *testing.T) { }) } } + +func withDeck(d *deck) func(*GameState) { + return func(g *GameState) { + g.deck = d + } +} + +func newTestDeck(hands []Hand) *deck { + if len(hands) < 2 { + panic("need at least 2 hands") + } + var i int + _dealHand := func() *Hand { + h := hands[i%len(hands)].DeepCopy() + i++ + return &h + } + return &deck{cards: nil, dealHandFunc: _dealHand} +} + +func TestEnvidoScore(t *testing.T) { + tests := []struct { + hands []Hand + expected1 int + expected2 int + }{ + { + hands: []Hand{ + { + Unrevealed: []Card{{Suit: ESPADA, Number: 1}, {Suit: ESPADA, Number: 7}}, + Revealed: []Card{{Suit: ORO, Number: 6}}, + }, + { + Unrevealed: []Card{{Suit: ESPADA, Number: 5}, {Suit: ESPADA, Number: 6}}, + Revealed: []Card{{Suit: ORO, Number: 1}}, + }, + }, + expected1: 28, + expected2: 31, + }, + } + + for _, tt := range tests { + t.Run(fmt.Sprintf("%v vs %v", tt.hands[0], tt.hands[1]), func(t *testing.T) { + gameState := New(withDeck(newTestDeck(tt.hands))) + + assert.Equal(t, tt.expected1, gameState.Hands[0].EnvidoScore()) + assert.Equal(t, tt.expected2, gameState.Hands[1].EnvidoScore()) + }) + } +} diff --git a/truco/envido_sequence_test.go b/truco/envido_sequence_test.go new file mode 100644 index 0000000..b1d8115 --- /dev/null +++ b/truco/envido_sequence_test.go @@ -0,0 +1,169 @@ +package truco + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestEnvidoSequence(t *testing.T) { + type testStep struct { + action Action + expectedIsValid bool + expectedPlayerTurnAfterRunning int + expectedIsFinishedAfterRunning bool + expectedCostAfterRunning int + ignoreAction bool + } + + tests := []struct { + name string + hands []Hand + steps []testStep + }{ + { + name: "cannot start with envido_quiero", + steps: []testStep{ + { + action: newActionSayEnvidoQuiero(30), + expectedIsValid: false, + }, + }, + }, + { + name: "cannot start with envido_no_quiero", + steps: []testStep{ + { + action: newActionSayEnvidoNoQuiero(), + expectedIsValid: false, + }, + }, + }, + { + name: "cannot start with son_buenas", + steps: []testStep{ + { + action: newActionSaySonBuenas(), + expectedIsValid: false, + }, + }, + }, + { + name: "cannot start with son_mejores", + steps: []testStep{ + { + action: newActionSaySonMejores(10), + expectedIsValid: false, + }, + }, + }, + { + name: "envido_quiero is valid after envido", + steps: []testStep{ + { + action: newActionSayEnvido(), + expectedIsValid: true, + expectedPlayerTurnAfterRunning: 1, + }, + { + action: newActionSayEnvidoQuiero(30), + expectedIsValid: true, + expectedPlayerTurnAfterRunning: 0, + }, + }, + }, + { + name: "basic envido finished sequence with son buenas", + steps: []testStep{ + { + action: newActionSayEnvido(), + expectedIsValid: true, + expectedPlayerTurnAfterRunning: 1, + }, + { + action: newActionSayEnvidoQuiero(30), + expectedIsValid: true, + expectedPlayerTurnAfterRunning: 0, + }, + { + action: newActionSaySonBuenas(), + expectedIsValid: true, + expectedPlayerTurnAfterRunning: 0, // doesn't yield turn because envido is over, so player who started gets to play + expectedIsFinishedAfterRunning: true, + expectedCostAfterRunning: 2, + }, + }, + }, + { + name: "basic envido finished sequence with son buenas, but this time player 1 starts", + steps: []testStep{ + { + action: newActionRevealCard(Card{Number: 1, Suit: ORO}), + ignoreAction: true, + }, + { + action: newActionSayEnvido(), + expectedIsValid: true, + expectedPlayerTurnAfterRunning: 0, + }, + { + action: newActionSayEnvidoQuiero(25), + expectedIsValid: true, + expectedPlayerTurnAfterRunning: 1, + }, + { + action: newActionSaySonMejores(31), + expectedIsValid: true, + expectedPlayerTurnAfterRunning: 1, // doesn't yield turn because envido is over, so player who started gets to play + expectedIsFinishedAfterRunning: true, + expectedCostAfterRunning: 2, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + defaultHands := []Hand{ + {Unrevealed: []Card{{Number: 1, Suit: ORO}, {Number: 2, Suit: ORO}, {Number: 3, Suit: ORO}}}, // 25 + {Unrevealed: []Card{{Number: 4, Suit: ORO}, {Number: 5, Suit: ORO}, {Number: 6, Suit: ORO}}}, // 31 + } + if len(tt.hands) == 0 { + tt.hands = defaultHands + } + gameState := New(withDeck(newTestDeck(tt.hands))) + + require.Equal(t, 0, gameState.TurnPlayerID) + + for i, step := range tt.steps { + if !step.ignoreAction { + actualIsValid := gameState.EnvidoSequence.CanAddStep(step.action.GetName()) + require.Equal(t, step.expectedIsValid, actualIsValid, "at step %v expected isValid to be %v but wasn't", i, step.expectedIsValid) + if !step.expectedIsValid { + continue + } + } + + err := gameState.RunAction(step.action) + require.NoError(t, err) + + if step.ignoreAction { + continue + } + + assert.Equal(t, step.expectedPlayerTurnAfterRunning, gameState.TurnPlayerID, "at step %v expected player turn %v but got %v", i, step.expectedPlayerTurnAfterRunning, gameState.TurnPlayerID) + + assert.Equal(t, step.expectedIsFinishedAfterRunning, gameState.EnvidoSequence.IsFinished(), "at step %v expected isFinished to be %v but wasn't", i, step.expectedIsFinishedAfterRunning) + + if !step.expectedIsFinishedAfterRunning { + continue + } + + cost, err := gameState.EnvidoSequence.Cost(gameState.Scores[gameState.TurnPlayerID], gameState.Scores[gameState.OpponentPlayerID()]) + require.NoError(t, err) + assert.Equal(t, step.expectedCostAfterRunning, cost, "at step %v expected cost %v but got %v", i, step.expectedCostAfterRunning, cost) + } + }) + } +} diff --git a/truco/truco.go b/truco/truco.go index 24bd09b..39b2aeb 100644 --- a/truco/truco.go +++ b/truco/truco.go @@ -115,9 +115,11 @@ type GameState struct { // // Note: this definitely should be inside an "ActionLog" slice, instead of here. ActionOwnerPlayerIDs []int `json:"actionOwnerPlayerIDs"` + + deck *deck `json:"-"` } -func New() *GameState { +func New(opts ...func(*GameState)) *GameState { // TODO: support taking player ids, ser/de, ... gs := &GameState{ RoundTurnPlayerID: 1, @@ -127,6 +129,11 @@ func New() *GameState { IsEnded: false, WinnerPlayerID: -1, Actions: []json.RawMessage{}, + deck: newDeck(), + } + + for _, opt := range opts { + opt(gs) } gs.startNewRound() @@ -135,7 +142,6 @@ func New() *GameState { } func (g *GameState) startNewRound() { - deck := newDeck() g.CurrentRoundResult = RoundResult{ EnvidoWinnerPlayerID: -1, EnvidoPoints: 0, @@ -148,8 +154,8 @@ func (g *GameState) startNewRound() { g.RoundNumber++ g.TurnPlayerID = g.RoundTurnPlayerID - handPlayer0 := deck.dealHand() - handPlayer1 := deck.dealHand() + handPlayer0 := g.deck.dealHand() + handPlayer1 := g.deck.dealHand() g.HandsDealt = append(g.HandsDealt, map[int]*Hand{ g.RoundTurnPlayerID: handPlayer0, g.OpponentOf(g.RoundTurnPlayerID): handPlayer1, diff --git a/truco/truco_sequence.go b/truco/truco_sequence.go index 103d5c3..76820bb 100644 --- a/truco/truco_sequence.go +++ b/truco/truco_sequence.go @@ -39,7 +39,17 @@ var ( ) type TrucoSequence struct { - Sequence []string `json:"sequence"` + // StartingPlayerID is the player ID that started the truco sub-sequence. + // + // It is used to determine "YieldsTurn" after a "quiero" action. + // + // Note the word "sub-sequence". There can be 0 to 3 truco sub-sequences in a round. + // + // Sub-sequences are separated by "quiero" actions. + // + // StartingPlayerID holds the player ID that started the _current_ sub-sequence. + StartingPlayerID int `json:"starting_player_id"` + Sequence []string `json:"sequence"` } func (ts TrucoSequence) String() string { @@ -88,6 +98,22 @@ func (ts TrucoSequence) Cost() (int, error) { return validTrucoSequenceCosts[ts.String()], nil } +func (ts TrucoSequence) IsSubsequenceStart() bool { + // Subsequences are delimited by "quiero" actions. + // It's necessary to store the playerID that started the current sub-sequence, + // so that we can determine "YieldsTurn" after a "quiero" action. + if len(ts.Sequence) == 0 { + return false + } + if len(ts.Sequence) == 1 { + return true + } + if ts.Sequence[len(ts.Sequence)-2] == SAY_TRUCO_QUIERO { + return true + } + return false +} + var ( errInvalidTrucoSequence = errors.New("invalid truco sequence") errUnfinishedTrucoSequence = errors.New("unfinished truco sequence") diff --git a/truco/truco_sequence_test.go b/truco/truco_sequence_test.go new file mode 100644 index 0000000..a515f52 --- /dev/null +++ b/truco/truco_sequence_test.go @@ -0,0 +1,123 @@ +package truco + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTrucoSequence(t *testing.T) { + type testStep struct { + action Action + expectedIsValid bool + expectedPlayerTurnAfterRunning int + expectedIsFinishedAfterRunning bool + expectedCostAfterRunning int + ignoreAction bool + } + + tests := []struct { + name string + hands []Hand + steps []testStep + }{ + { + name: "cannot start with truco_quiero", + steps: []testStep{ + { + action: newActionSayTrucoQuiero(), + expectedIsValid: false, + }, + }, + }, + { + name: "cannot start with truco_no_quiero", + steps: []testStep{ + { + action: newActionSayTrucoNoQuiero(), + expectedIsValid: false, + }, + }, + }, + { + name: "cannot start with quiero retruco", + steps: []testStep{ + { + action: newActionSayQuieroRetruco(), + expectedIsValid: false, + }, + }, + }, + { + name: "cannot start with quiero vale cuatro", + steps: []testStep{ + { + action: newActionSayQuieroValeCuatro(), + expectedIsValid: false, + }, + }, + }, + { + name: "truco_quiero is valid after truco", + steps: []testStep{ + { + action: newActionSayTruco(), + expectedIsValid: true, + expectedPlayerTurnAfterRunning: 1, + }, + { + action: newActionSayTrucoQuiero(), + expectedIsValid: true, + expectedPlayerTurnAfterRunning: 0, + expectedIsFinishedAfterRunning: true, + expectedCostAfterRunning: 2, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + defaultHands := []Hand{ + {Unrevealed: []Card{{Number: 1, Suit: ORO}, {Number: 2, Suit: ORO}, {Number: 3, Suit: ORO}}}, + {Unrevealed: []Card{{Number: 4, Suit: ORO}, {Number: 5, Suit: ORO}, {Number: 6, Suit: ORO}}}, + } + if len(tt.hands) == 0 { + tt.hands = defaultHands + } + gameState := New(withDeck(newTestDeck(tt.hands))) + + require.Equal(t, 0, gameState.TurnPlayerID) + + for i, step := range tt.steps { + if !step.ignoreAction { + actualIsValid := gameState.TrucoSequence.CanAddStep(step.action.GetName()) + require.Equal(t, step.expectedIsValid, actualIsValid, "at step %v expected isValid to be %v but wasn't", i, step.expectedIsValid) + if !step.expectedIsValid { + continue + } + } + + err := gameState.RunAction(step.action) + require.NoError(t, err) + + if step.ignoreAction { + continue + } + + assert.Equal(t, step.expectedPlayerTurnAfterRunning, gameState.TurnPlayerID, "at step %v expected player turn %v but got %v", i, step.expectedPlayerTurnAfterRunning, gameState.TurnPlayerID) + + assert.Equal(t, step.expectedIsFinishedAfterRunning, gameState.TrucoSequence.IsFinished(), "at step %v expected isFinished to be %v but wasn't", i, step.expectedIsFinishedAfterRunning) + + if !step.expectedIsFinishedAfterRunning { + continue + } + + cost, err := gameState.TrucoSequence.Cost() + require.NoError(t, err) + assert.Equal(t, step.expectedCostAfterRunning, cost, "at step %v expected cost %v but got %v", i, step.expectedCostAfterRunning, cost) + } + }) + } +}