diff --git a/cmd/api/main.go b/cmd/api/main.go index df5f521..0df2e61 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -124,14 +124,15 @@ func main() { // Configure the routes router.GET("/api/v1/profile", auth.EnsureValidTokenGin([]string{auth.ReadGame}), profileHandler.Get) router.PUT("/api/v1/profile", auth.EnsureValidTokenGin([]string{auth.ReadGame}), profileHandler.Update) - router.GET("/api/v1/profile/all", auth.EnsureValidTokenGin([]string{auth.ReadAdmin}), profileHandler.GetAll) + router.GET("/api/v1/profile/all", auth.EnsureValidTokenGin([]string{auth.ReadGame}), profileHandler.GetAll) router.GET("/api/v1/settings", auth.EnsureValidTokenGin([]string{auth.ReadGame}), settingsHandler.Get) router.PUT("/api/v1/settings", auth.EnsureValidTokenGin([]string{auth.ReadGame}), settingsHandler.Update) router.GET("/api/v1/game/:gameId", auth.EnsureValidTokenGin([]string{auth.ReadGame}), gameHandler.Get) router.GET("/api/v1/game/:gameId/state", auth.EnsureValidTokenGin([]string{auth.ReadGame}), gameHandler.GetState) + router.PUT("/api/v1/game/:gameId/call", auth.EnsureValidTokenGin([]string{auth.WriteGame}), gameHandler.Call) router.GET("/api/v1/game/all", auth.EnsureValidTokenGin([]string{auth.ReadGame}), gameHandler.GetAll) router.PUT("/api/v1/game", auth.EnsureValidTokenGin([]string{auth.WriteAdmin}), gameHandler.Create) - router.DELETE("/api/v1/game/:gameId", auth.EnsureValidTokenGin([]string{auth.WriteAdmin}), gameHandler.Cancel) + router.DELETE("/api/v1/game/:gameId", auth.EnsureValidTokenGin([]string{auth.WriteAdmin}), gameHandler.Delete) router.GET("/api/v1/stats", auth.EnsureValidTokenGin([]string{auth.ReadGame}), statsHandler.GetStats) router.GET("/api/v1/stats/:playerId", auth.EnsureValidTokenGin([]string{auth.ReadAdmin}), statsHandler.GetStatsForPlayer) diff --git a/pkg/db/collection.go b/pkg/db/collection.go index 1c4eeae..c571d84 100644 --- a/pkg/db/collection.go +++ b/pkg/db/collection.go @@ -18,6 +18,7 @@ type CollectionI[T any] interface { UpdateOne(ctx context.Context, t T, id string) error Upsert(ctx context.Context, t T, id string) error Aggregate(ctx context.Context, pipeline interface{}) (*mongo.Cursor, error) + DeleteOne(ctx context.Context, id string) error } type Collection[T any] struct { @@ -140,3 +141,10 @@ func (c *Collection[T]) Upsert(ctx context.Context, t T, id string) error { func (c *Collection[T]) Aggregate(ctx context.Context, pipeline interface{}) (*mongo.Cursor, error) { return c.Col.Aggregate(ctx, pipeline) } + +func (c *Collection[T]) DeleteOne(ctx context.Context, id string) error { + _, err := c.Col.DeleteOne(ctx, bson.M{ + "_id": id, + }) + return err +} diff --git a/pkg/db/collection_mocks.go b/pkg/db/collection_mocks.go index 6abd553..9c9e325 100644 --- a/pkg/db/collection_mocks.go +++ b/pkg/db/collection_mocks.go @@ -15,6 +15,7 @@ type MockCollection[T any] struct { MockFindErr *[]error MockUpsertErr *[]error MockUpdateOneErr *[]error + MockDeleteOneErr *[]error } func (m *MockCollection[T]) FindOne(ctx context.Context, filter bson.M) (T, bool, error) { @@ -99,3 +100,16 @@ func (m *MockCollection[T]) FindOneAndReplace(ctx context.Context, filter bson.M var result T return result, nil } + +func (m *MockCollection[T]) DeleteOne(ctx context.Context, id string) error { + // Get the first element of the error array and remove it from the array, return nil if the array is empty + var err error + if len(*m.MockDeleteOneErr) > 0 { + err = (*m.MockDeleteOneErr)[0] + *m.MockDeleteOneErr = (*m.MockDeleteOneErr)[1:] + } else { + err = nil + } + + return err +} diff --git a/pkg/game/game-handler.go b/pkg/game/game-handler.go index be948fc..e3a6b71 100644 --- a/pkg/game/game-handler.go +++ b/pkg/game/game-handler.go @@ -11,6 +11,11 @@ type Handler struct { S ServiceI } +type CreateGameRequest struct { + PlayerIDs []string `json:"players"` + Name string `json:"name"` +} + // Create @Summary Create a new game // @Description Creates a new game with the given name and players // @Tags Game @@ -163,18 +168,17 @@ func (h *Handler) GetAll(c *gin.Context) { c.IndentedJSON(http.StatusOK, games) } -// Cancel @Summary Cancel a game -// @Description Cancels a game with the given ID +// Delete @Summary Delete a game +// @Description Deletes a game with the given ID // @Tags Game -// @ID cancel-game -// @Produce json +// @ID delete-game // @Security Bearer // @Param gameId path string true "Game ID" -// @Success 200 {object} Game +// @Success 200 // @Failure 400 {object} api.ErrorResponse // @Failure 500 {object} api.ErrorResponse // @Router /game/{gameId} [delete] -func (h *Handler) Cancel(c *gin.Context) { +func (h *Handler) Delete(c *gin.Context) { // Check the user is correctly authenticated id, ok := auth.CheckValidated(c) if !ok { @@ -188,7 +192,56 @@ func (h *Handler) Cancel(c *gin.Context) { gameId := c.Param("gameId") // Cancel the game - game, err := h.S.Cancel(ctx, gameId, id) + err := h.S.Delete(ctx, gameId, id) + + if err != nil { + c.JSON(http.StatusInternalServerError, api.ErrorResponse{Message: err.Error()}) + return + } + + c.Status(http.StatusOK) +} + +// Call @Summary Make a call +// @Description Makes a call for the current user in the game with the given ID +// @Tags Game +// @ID call +// @Produce json +// @Security Bearer +// @Param gameId path string true "Game ID" +// @Param call query int true "Call" +// @Success 200 {object} Game +// @Failure 400 {object} api.ErrorResponse +// @Failure 500 {object} api.ErrorResponse +// @Router /game/{gameId}/call [put] +func (h *Handler) Call(c *gin.Context) { + // Check the user is correctly authenticated + id, ok := auth.CheckValidated(c) + if !ok { + return + } + + // Get the context from the request + ctx := c.Request.Context() + + // Get the game ID from the request + gameId := c.Param("gameId") + + // Get the call from the request + ca, exists := c.GetQuery("call") + if !exists { + c.JSON(http.StatusBadRequest, api.ErrorResponse{Message: "Missing call"}) + return + } + // Check if is a valid call + call, err := ParseCall(ca) + if err != nil { + c.JSON(http.StatusBadRequest, api.ErrorResponse{Message: err.Error()}) + return + } + + // Make the call + game, err := h.S.Call(ctx, gameId, id, call) if err != nil { c.JSON(http.StatusInternalServerError, api.ErrorResponse{Message: err.Error()}) diff --git a/pkg/game/game-service.go b/pkg/game/game-service.go index 1adc751..16298db 100644 --- a/pkg/game/game-service.go +++ b/pkg/game/game-service.go @@ -12,7 +12,8 @@ type ServiceI interface { Create(ctx context.Context, playerIDs []string, name string, adminID string) (Game, error) Get(ctx context.Context, gameId string) (Game, bool, error) GetAll(ctx context.Context) ([]Game, error) - Cancel(ctx context.Context, gameId string, adminId string) (Game, error) + Delete(ctx context.Context, gameId string, adminId string) error + Call(ctx context.Context, gameId string, playerId string, call Call) (Game, error) } type Service struct { @@ -57,24 +58,47 @@ func (s *Service) GetAll(ctx context.Context) ([]Game, error) { return s.Col.Find(ctx, bson.M{}) } -// Cancel a game. -func (s *Service) Cancel(ctx context.Context, gameId string, adminId string) (Game, error) { +// Delete a game. +func (s *Service) Delete(ctx context.Context, gameId string, adminId string) error { // Get the game from the database. game, has, err := s.Get(ctx, gameId) if err != nil { - return Game{}, err + return err } if !has { - return Game{}, errors.New("game not found") + return errors.New("game not found") } // Check correct admin if game.AdminID != adminId { - return Game{}, errors.New("not admin") + return errors.New("not admin") + } + + // Can only remove a game that is in an active state + if game.Status != Active { + return errors.New("can only delete games that are in an active state") + } + + // Delete the game from the database. + return s.Col.DeleteOne(ctx, game.ID) +} + +// Call make a call +func (s *Service) Call(ctx context.Context, gameId string, playerId string, call Call) (Game, error) { + // Get the game from the database. + game, has, err := s.Get(ctx, gameId) + if err != nil { + return Game{}, err + } + if !has { + return Game{}, errors.New("game not found") } - // Cancel the game. - game.Cancel() + // Make the call. + err = game.Call(playerId, call) + if err != nil { + return Game{}, err + } // Save the game to the database. err = s.Col.UpdateOne(ctx, game, game.ID) diff --git a/pkg/game/game-service_test.go b/pkg/game/game-service_test.go index 4b9291b..8fb6b7f 100644 --- a/pkg/game/game-service_test.go +++ b/pkg/game/game-service_test.go @@ -227,30 +227,28 @@ func TestGetAll(t *testing.T) { } } -func TestCancel(t *testing.T) { +func TestDelete(t *testing.T) { ctx := context.Background() tests := []struct { - name string - gameToCancel string - adminID string - mockGetResult *[]Game - mockGetExists *[]bool - mockGetError *[]error - mockUpdateError *[]error - expectedResult Game - expectingError bool + name string + gameToCancel string + adminID string + mockGetResult *[]Game + mockGetExists *[]bool + mockGetError *[]error + mockDeleteOneError *[]error + expectingError bool }{ { - name: "simple cancel", - gameToCancel: TwoPlayerGame().ID, - adminID: "1", - mockGetResult: &[]Game{TwoPlayerGame()}, - mockGetExists: &[]bool{true}, - mockGetError: &[]error{nil}, - mockUpdateError: &[]error{nil}, - expectedResult: TwoPlayerGame(), - expectingError: false, + name: "simple cancel", + gameToCancel: TwoPlayerGame().ID, + adminID: "1", + mockGetResult: &[]Game{TwoPlayerGame()}, + mockGetExists: &[]bool{true}, + mockGetError: &[]error{nil}, + mockDeleteOneError: &[]error{nil}, + expectingError: false, }, { name: "error thrown", @@ -259,21 +257,20 @@ func TestCancel(t *testing.T) { mockGetResult: &[]Game{ {}, }, - mockGetExists: &[]bool{false}, - mockGetError: &[]error{errors.New("something went wrong")}, - mockUpdateError: &[]error{nil}, - expectedResult: Game{}, - expectingError: true, + mockGetExists: &[]bool{false}, + mockGetError: &[]error{errors.New("something went wrong")}, + mockDeleteOneError: &[]error{nil}, + expectingError: true, }, { - name: "not found", - gameToCancel: TwoPlayerGame().ID, - adminID: "1", - mockGetResult: &[]Game{{}}, - mockGetExists: &[]bool{false}, - mockGetError: &[]error{nil}, - expectedResult: Game{}, - expectingError: true, + name: "not found", + gameToCancel: TwoPlayerGame().ID, + adminID: "1", + mockGetResult: &[]Game{{}}, + mockGetExists: &[]bool{false}, + mockGetError: &[]error{nil}, + mockDeleteOneError: &[]error{nil}, + expectingError: true, }, { name: "update error", @@ -282,11 +279,10 @@ func TestCancel(t *testing.T) { mockGetResult: &[]Game{ TwoPlayerGame(), }, - mockGetExists: &[]bool{true}, - mockGetError: &[]error{nil}, - mockUpdateError: &[]error{errors.New("something went wrong")}, - expectedResult: Game{}, - expectingError: true, + mockGetExists: &[]bool{true}, + mockGetError: &[]error{nil}, + mockDeleteOneError: &[]error{errors.New("something went wrong")}, + expectingError: true, }, { name: "not admin", @@ -295,11 +291,22 @@ func TestCancel(t *testing.T) { mockGetResult: &[]Game{ TwoPlayerGame(), }, - mockGetExists: &[]bool{true}, - mockGetError: &[]error{nil}, - mockUpdateError: &[]error{nil}, - expectedResult: Game{}, - expectingError: true, + mockGetExists: &[]bool{true}, + mockGetError: &[]error{nil}, + mockDeleteOneError: &[]error{nil}, + expectingError: true, + }, + { + name: "Game completed", + gameToCancel: CompletedGame().ID, + adminID: "1", + mockGetResult: &[]Game{ + CompletedGame(), + }, + mockGetExists: &[]bool{true}, + mockGetError: &[]error{nil}, + mockDeleteOneError: &[]error{nil}, + expectingError: true, }, } @@ -309,40 +316,17 @@ func TestCancel(t *testing.T) { MockFindOneResult: test.mockGetResult, MockFindOneExists: test.mockGetExists, MockFindOneErr: test.mockGetError, - MockUpdateOneErr: test.mockUpdateError, + MockDeleteOneErr: test.mockDeleteOneError, } ds := &Service{ Col: mockCol, } - result, err := ds.Cancel(ctx, test.gameToCancel, test.adminID) - - if test.expectingError { - if err == nil { - t.Errorf("expected error %v, got %v", test.expectingError, err) - } - } else { - if result.Status != CANCELLED { - t.Errorf("expected result %v, got %v", test.expectedResult, result) - } + err := ds.Delete(ctx, test.gameToCancel, test.adminID) - // Check all fields are the same except status - if result.ID != test.expectedResult.ID { - t.Errorf("expected result %v, got %v", test.expectedResult, result) - } - if !reflect.DeepEqual(result.Players, test.expectedResult.Players) { - t.Errorf("expected result %v, got %v", test.expectedResult, result) - } - if !reflect.DeepEqual(result.CurrentRound, test.expectedResult.CurrentRound) { - t.Errorf("expected result %v, got %v", test.expectedResult, result) - } - if result.AdminID != test.expectedResult.AdminID { - t.Errorf("expected result %v, got %v", test.expectedResult, result) - } - if result.Name != test.expectedResult.Name { - t.Errorf("expected result %v, got %v", test.expectedResult, result) - } + if test.expectingError && err == nil { + t.Errorf("expected error %v, got %v", test.expectingError, err) } }) } diff --git a/pkg/game/game-utils.go b/pkg/game/game-utils.go index 2fee3cd..e50628c 100644 --- a/pkg/game/game-utils.go +++ b/pkg/game/game-utils.go @@ -90,15 +90,17 @@ func createFirstRound(players []Player, dealerID string) (Round, error) { hand := Hand{ Timestamp: timestamp, CurrentPlayerID: currentPlayer.ID, + PlayedCards: make([]PlayedCard, 0), } // Create the round round := Round{ - Timestamp: timestamp, - Number: 1, - DealerID: dealerID, - Status: CALLING, - CurrentHand: hand, + Timestamp: timestamp, + Number: 1, + DealerID: dealerID, + Status: Calling, + CurrentHand: hand, + CompletedHands: make([]Hand, 0), } return round, nil @@ -133,7 +135,7 @@ func NewGame(playerIDs []string, name string, adminID string) (Game, error) { ID: "game-" + strconv.Itoa(rand.Intn(1000000)), Timestamp: time.Now(), Name: name, - Status: ACTIVE, + Status: Active, AdminID: adminID, Players: players, CurrentRound: round, diff --git a/pkg/game/game.go b/pkg/game/game.go index 76af0de..466d88c 100644 --- a/pkg/game/game.go +++ b/pkg/game/game.go @@ -2,31 +2,58 @@ package game import ( "fmt" + "log" "time" ) type Status string const ( - ACTIVE Status = "ACTIVE" - FINISHED = "FINISHED" - COMPLETED = "COMPLETED" - CANCELLED = "CANCELLED" + Active Status = "ACTIVE" + Completed = "COMPLETED" ) type RoundStatus string const ( - CALLING RoundStatus = "CALLING" - CALLED = "CALLED" - BUYING = "BUYING" - PLAYING = "PLAYING" + Calling RoundStatus = "CALLING" + Called = "CALLED" + Buying = "BUYING" + Playing = "PLAYING" ) +type Call int + +const ( + Pass Call = 0 + Ten = 10 + Fifteen = 15 + Twenty = 20 + TwentyFive = 25 + Jink = 30 +) + +func ParseCall(c string) (Call, error) { + switch c { + case "10": + return Ten, nil + case "15": + return Fifteen, nil + case "20": + return Twenty, nil + case "25": + return TwentyFive, nil + case "30": + return Jink, nil + default: + return 0, fmt.Errorf("invalid call") + } +} + type Player struct { ID string `bson:"_id,omitempty" json:"id"` Seat int `bson:"seatNumber" json:"seatNumber"` - Call int `bson:"call" json:"call"` + Call Call `bson:"call" json:"call"` Cards []CardName `bson:"cards" json:"-"` Bought int `bson:"cardsBought" json:"cardsBought"` Score int `bson:"score" json:"score"` @@ -80,23 +107,26 @@ type GameState struct { IamGoer bool `json:"iamGoer"` IamDealer bool `json:"iamDealer"` IamAdmin bool `json:"iamAdmin"` - MaxCall int `json:"maxCall"` + MaxCall Call `json:"maxCall"` Players []Player `json:"players"` Round Round `json:"round"` Cards []CardName `json:"cards"` } -func (g *Game) GetState(playerID string) (GameState, error) { - // Get the current player - var me Player +func (g *Game) Me(playerID string) (Player, error) { for _, p := range g.Players { if p.ID == playerID { - me = p - break + return p, nil } } - if me.ID == "" { - return GameState{}, fmt.Errorf("player not found in game") + return Player{}, fmt.Errorf("player not found in game") +} + +func (g *Game) GetState(playerID string) (GameState, error) { + // Get player + me, err := g.Me(playerID) + if err != nil { + return GameState{}, err } // 1. Find dummy @@ -111,7 +141,7 @@ func (g *Game) GetState(playerID string) (GameState, error) { } // 2. Get max call - maxCall := -1 + maxCall := Pass for _, p := range g.Players { if p.Call > maxCall { maxCall = p.Call @@ -120,7 +150,7 @@ func (g *Game) GetState(playerID string) (GameState, error) { // 3. Add dummy if applicable iamGoer := g.CurrentRound.GoerID == playerID - if iamGoer && g.CurrentRound.Status == CALLED && dummy.ID != "" { + if iamGoer && g.CurrentRound.Status == Called && dummy.ID != "" { me.Cards = append(me.Cards, dummy.Cards...) } @@ -149,19 +179,177 @@ func (g *Game) GetState(playerID string) (GameState, error) { return gameState, nil } -func (g *Game) Cancel() { - g.Status = CANCELLED -} +func (g *Game) EndRound() error { + // Add the current round to the completed rounds + g.Completed = append(g.Completed, g.CurrentRound) + + // Get next dealer + nextDealer, err := nextPlayer(g.Players, g.CurrentRound.DealerID) + if err != nil { + return err + } + + // Create next hand + nextPlayer, err := nextPlayer(g.Players, nextDealer.ID) + if err != nil { + return err + } + nextHand := Hand{ + Timestamp: time.Now(), + CurrentPlayerID: nextPlayer.ID, + } + + // Create a new round + g.CurrentRound = Round{ + Timestamp: time.Now(), + Number: g.CurrentRound.Number + 1, + DealerID: nextDealer.ID, + Status: Calling, + CurrentHand: nextHand, + } -type PlayerStats struct { - GameID string `bson:"gameId" json:"gameId"` - Timestamp time.Time `bson:"timestamp" json:"timestamp"` - Winner bool `bson:"winner" json:"winner"` - Score int `bson:"score" json:"score"` - Rings int `bson:"rings" json:"rings"` + // Deal the cards + deck := ShuffleCards(NewDeck()) + g.Deck, g.Players = DealCards(deck, g.Players) + + return nil } -type CreateGameRequest struct { - PlayerIDs []string `json:"players"` - Name string `json:"name"` +func (g *Game) Call(playerID string, call Call) error { + // Check the game is active + if g.Status != Active { + return fmt.Errorf("game not active") + } + + // Check current round is calling + if g.CurrentRound.Status != Calling { + return fmt.Errorf("round not calling") + } + + // Check the player is the current player + if g.CurrentRound.CurrentHand.CurrentPlayerID != playerID { + return fmt.Errorf("not current player") + } + + // If they are in the bunker (score < -30) they can only pass + state, err := g.GetState(playerID) + if err != nil { + return err + } + if state.Me.Score < -30 && call != Pass { + return fmt.Errorf("player in bunker") + } + + // Check the call is valid i.e. > all previous calls or a pass + // The dealer can take a call of greater than 10 + if call != Pass { + callForComparison := call + if state.IamDealer { + callForComparison++ + } + for _, p := range g.Players { + if p.Call >= callForComparison { + return fmt.Errorf("invalid call") + } + } + } + + // Validate 10 call + if call == Ten { + if len(g.Players) != 6 { + return fmt.Errorf("can only call 10 in doubles") + } + } + + // Set the player's call + for i, p := range g.Players { + if p.ID == playerID { + g.Players[i].Call = call + break + } + } + + // Set next player/round status + if call == Jink { + log.Printf("Jink called by %s", playerID) + if state.IamDealer { + // If the dealer calls Jink, calling is complete + g.CurrentRound.Status = Called + g.CurrentRound.GoerID = playerID + g.CurrentRound.CurrentHand.CurrentPlayerID = playerID + } else { + // If any other player calls Jink, jump to the dealer + g.CurrentRound.CurrentHand.CurrentPlayerID = g.CurrentRound.DealerID + } + } else if state.IamDealer { + // Get the highest calls + var topCall Call + for _, p := range g.Players { + if p.Call > topCall { + topCall = p.Call + } + } + + if topCall <= Ten { + log.Printf("No one called. Starting new round...") + err = g.EndRound() + return err + } + + // Get the players who made the top call (the dealer may have taken a call, this will result in more than one player) + var topCallPlayers []Player + for _, p := range g.Players { + if p.Call == topCall { + topCallPlayers = append(topCallPlayers, p) + } + } + if len(topCallPlayers) == 0 || len(topCallPlayers) > 2 { + return fmt.Errorf("invalid call state. There are %d top callers of %d", len(topCallPlayers), topCall) + } + var takenPlayer Player + var caller Player + if len(topCallPlayers) == 2 { + for _, p := range topCallPlayers { + if p.ID == g.CurrentRound.DealerID { + caller = p + } else { + takenPlayer = p + } + } + } else { + caller = topCallPlayers[0] + } + + if takenPlayer.ID != "" { + log.Printf("Dealer seeing call by %s", takenPlayer.ID) + g.CurrentRound.DealerSeeing = true + g.CurrentRound.CurrentHand.CurrentPlayerID = takenPlayer.ID + } else { + log.Printf("Call successful. %s is goer", caller.ID) + g.CurrentRound.Status = Called + g.CurrentRound.GoerID = caller.ID + g.CurrentRound.CurrentHand.CurrentPlayerID = caller.ID + } + + } else if g.CurrentRound.DealerSeeing { + log.Printf("%s was taken by the dealer.", playerID) + if call == Pass { + log.Printf("%s is letting the dealer go.", playerID) + g.CurrentRound.Status = Called + g.CurrentRound.GoerID = g.CurrentRound.DealerID + g.CurrentRound.CurrentHand.CurrentPlayerID = g.CurrentRound.DealerID + } else { + log.Printf("%s has raised the call.", playerID) + g.CurrentRound.CurrentHand.CurrentPlayerID = g.CurrentRound.DealerID + g.CurrentRound.DealerSeeing = false + } + } else { + log.Printf("Calling not complete. Next player...") + nextPlayer, err := nextPlayer(g.Players, playerID) + if err != nil { + return err + } + g.CurrentRound.CurrentHand.CurrentPlayerID = nextPlayer.ID + } + return nil } diff --git a/pkg/game/game_test.go b/pkg/game/game_test.go new file mode 100644 index 0000000..9f3d29f --- /dev/null +++ b/pkg/game/game_test.go @@ -0,0 +1,208 @@ +package game + +import "testing" + +type CallWrap struct { + playerID string + call Call + expectedNextPlayerID string + expectingError bool +} + +func TestCall(t *testing.T) { + tests := []struct { + name string + game Game + calls []CallWrap + expectedGoerID string + }{ + { + name: "simple call", + game: TwoPlayerGame(), + calls: []CallWrap{{ + playerID: "2", + call: Fifteen, + expectedNextPlayerID: "1", + }}, + }, + { + name: "Completed game", + game: CompletedGame(), + calls: []CallWrap{{ + playerID: "2", + call: Fifteen, + expectingError: true, + }}, + }, + { + name: "Game in called state", + game: CalledGame(), + calls: []CallWrap{{ + playerID: "2", + call: Fifteen, + expectingError: true, + }}, + }, + { + name: "Invalid call 10 in a 2 player game", + game: TwoPlayerGame(), + calls: []CallWrap{{ + playerID: "2", + call: Ten, + expectingError: true, + }}, + }, + { + name: "Valid 10 call in 6 player game", + game: SixPlayerGame(), + calls: []CallWrap{{ + playerID: "5", + call: Ten, + expectedNextPlayerID: "6", + }}, + }, + { + name: "Call too low", + game: TwoPlayerGame(), + calls: []CallWrap{ + { + playerID: "2", + call: Twenty, + expectedNextPlayerID: "1", + }, + { + playerID: "1", + call: Fifteen, + expectingError: true, + }, + }, + }, + { + name: "Dealer seeing call", + game: TwoPlayerGame(), + calls: []CallWrap{ + { + playerID: "2", + call: Twenty, + expectedNextPlayerID: "1", + }, + { + playerID: "1", + call: Twenty, + expectedNextPlayerID: "2", + }, + }, + }, + { + name: "Dealer seeing call, dealer passes", + game: TwoPlayerGame(), + calls: []CallWrap{ + { + playerID: "2", + call: Fifteen, + expectedNextPlayerID: "1", + }, + { + playerID: "1", + call: Twenty, + expectedNextPlayerID: "1", + }, + }, + expectedGoerID: "1", + }, + { + name: "All players pass", + game: TwoPlayerGame(), + calls: []CallWrap{ + { + playerID: "2", + call: Pass, + expectedNextPlayerID: "1", + }, + { + playerID: "1", + call: Pass, + expectedNextPlayerID: "1", + }, + }, + }, + { + name: "Player calls JINK in 6 player game", + game: SixPlayerGame(), + calls: []CallWrap{ + { + playerID: "5", + call: Jink, + expectedNextPlayerID: "1", + }, + }, + }, + { + name: "Dealer takes JINK in 6 player game", + game: SixPlayerGame(), + calls: []CallWrap{ + { + playerID: "5", + call: Jink, + expectedNextPlayerID: "1", + }, + { + playerID: "1", + call: Jink, + expectedNextPlayerID: "1", + }, + }, + expectedGoerID: "1", + }, + { + name: "Dealer lets JINK go in 6 player game", + game: SixPlayerGame(), + calls: []CallWrap{ + { + playerID: "5", + call: Jink, + expectedNextPlayerID: "1", + }, + { + playerID: "1", + call: Pass, + expectedNextPlayerID: "5", + }, + }, + expectedGoerID: "5", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + for _, call := range test.calls { + err := test.game.Call(call.playerID, call.call) + if call.expectingError { + if err == nil { + t.Errorf("expected an error, got nil") + } + } else { + var player Player + for _, p := range test.game.Players { + if p.ID == call.playerID { + player = p + break + } + } + if player.Call != call.call { + t.Errorf("expected player %s to call %d, got %d", call.playerID, call.call, test.game.Players[0].Call) + } + if test.game.CurrentRound.CurrentHand.CurrentPlayerID != call.expectedNextPlayerID { + t.Errorf("expected player %s to be current player, got %s", call.expectedNextPlayerID, test.game.CurrentRound.CurrentHand.CurrentPlayerID) + } + } + } + if test.expectedGoerID != "" { + if test.game.CurrentRound.GoerID != test.expectedGoerID { + t.Errorf("expected goer to be %s, got %s", test.expectedGoerID, test.game.CurrentRound.GoerID) + } + } + + }) + } +} diff --git a/pkg/game/testdata.go b/pkg/game/testdata.go index 36dfe80..d677854 100644 --- a/pkg/game/testdata.go +++ b/pkg/game/testdata.go @@ -70,7 +70,62 @@ func TwoPlayerGame() Game { return Game{ ID: "1", Name: "Test Game", - Status: ACTIVE, + Status: Active, + Timestamp: time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC), + Players: []Player{Player1(), Player2()}, + CurrentRound: Round{ + DealerID: "1", + Status: Calling, + CurrentHand: Hand{ + CurrentPlayerID: "2", + }, + }, + AdminID: "1", + } +} + +func SixPlayerGame() Game { + return Game{ + ID: "1", + Name: "Test Game", + Status: Active, + Timestamp: time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC), + Players: []Player{Player1(), Player2(), Player3(), Player4(), Player5(), Player6()}, + CurrentRound: Round{ + DealerID: "1", + Status: Calling, + CurrentHand: Hand{ + CurrentPlayerID: "5", + }, + }, + AdminID: "1", + } +} + +func CalledGame() Game { + return Game{ + ID: "1", + Name: "Test Game", + Status: Active, + Timestamp: time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC), + Players: []Player{Player1(), Player2()}, + CurrentRound: Round{ + DealerID: "1", + GoerID: "2", + Status: Called, + CurrentHand: Hand{ + CurrentPlayerID: "2", + }, + }, + AdminID: "1", + } +} + +func CompletedGame() Game { + return Game{ + ID: "2", + Name: "Test Game", + Status: Completed, Timestamp: time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC), Players: []Player{Player1(), Player2()}, AdminID: "1", diff --git a/pkg/stats/stats-service.go b/pkg/stats/stats-service.go index 23ded39..cccb5be 100644 --- a/pkg/stats/stats-service.go +++ b/pkg/stats/stats-service.go @@ -13,7 +13,7 @@ import ( ) type ServiceI interface { - GetStats(ctx context.Context, playerId string) ([]game.PlayerStats, error) + GetStats(ctx context.Context, playerId string) ([]PlayerStats, error) } type Service struct { @@ -21,7 +21,7 @@ type Service struct { } // GetStats Get the stats for a player. -func (s *Service) GetStats(ctx context.Context, playerId string) ([]game.PlayerStats, error) { +func (s *Service) GetStats(ctx context.Context, playerId string) ([]PlayerStats, error) { pipeline := mongo.Pipeline{ {{Key: "$match", Value: bson.D{{Key: "status", Value: "FINISHED"}, {Key: "players._id", Value: playerId}}}}, @@ -38,7 +38,7 @@ func (s *Service) GetStats(ctx context.Context, playerId string) ([]game.PlayerS cursor, err := s.Col.Aggregate(ctx, pipeline) if err != nil { - return []game.PlayerStats{}, err + return []PlayerStats{}, err } defer func(cursor *mongo.Cursor, ctx context.Context) { @@ -50,28 +50,28 @@ func (s *Service) GetStats(ctx context.Context, playerId string) ([]game.PlayerS // Return an empty slice if there are no results. if cursor.RemainingBatchLength() == 0 { - return []game.PlayerStats{}, nil + return []PlayerStats{}, nil } // Iterate over the cursor and decode each result. - var results []game.PlayerStats + var results []PlayerStats for cursor.Next(ctx) { var result bson.M if err = cursor.Decode(&result); err != nil { // Log the detailed error log.Printf("Error decoding cursor result: %v", err) - return []game.PlayerStats{}, err + return []PlayerStats{}, err } // Map the result to a PlayerStats struct. - playerStats := game.PlayerStats{} + playerStats := PlayerStats{} // Safely assert types if gameId, ok := result["gameId"].(string); ok { playerStats.GameID = gameId } else { // Handle missing or invalid gameId - return []game.PlayerStats{}, fmt.Errorf("failed to decode gameID") + return []PlayerStats{}, fmt.Errorf("failed to decode gameID") } if timestamp, ok := result["timestamp"].(primitive.DateTime); ok { @@ -79,25 +79,25 @@ func (s *Service) GetStats(ctx context.Context, playerId string) ([]game.PlayerS playerStats.Timestamp = time.Unix(int64(timestamp)/1000, 0) } else { // Handle missing or invalid timestamp - return []game.PlayerStats{}, fmt.Errorf("timestamp is not a valid DateTime") + return []PlayerStats{}, fmt.Errorf("timestamp is not a valid DateTime") } if winner, ok := result["winner"].(bool); ok { playerStats.Winner = winner } else { - return []game.PlayerStats{}, fmt.Errorf("winner is not a bool") + return []PlayerStats{}, fmt.Errorf("winner is not a bool") } if score, ok := result["score"].(int32); ok { playerStats.Score = int(score) } else { - return []game.PlayerStats{}, fmt.Errorf("score is not an int") + return []PlayerStats{}, fmt.Errorf("score is not an int") } if rings, ok := result["rings"].(int32); ok { playerStats.Rings = int(rings) } else { - return []game.PlayerStats{}, fmt.Errorf("rings is not an int") + return []PlayerStats{}, fmt.Errorf("rings is not an int") } results = append(results, playerStats) diff --git a/pkg/stats/stats.go b/pkg/stats/stats.go new file mode 100644 index 0000000..1831617 --- /dev/null +++ b/pkg/stats/stats.go @@ -0,0 +1,11 @@ +package stats + +import "time" + +type PlayerStats struct { + GameID string `bson:"gameId" json:"gameId"` + Timestamp time.Time `bson:"timestamp" json:"timestamp"` + Winner bool `bson:"winner" json:"winner"` + Score int `bson:"score" json:"score"` + Rings int `bson:"rings" json:"rings"` +}