commit ac9aa86d331789c367a3c8baeee389fc28f5343f Author: Daniel Noulet Date: Wed Oct 7 15:16:00 2026 +0200 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f72b767 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/go-fish +.idea \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..0e16524 --- /dev/null +++ b/Makefile @@ -0,0 +1,26 @@ +SHELL := /bin/sh + +APP := go-fish +BIN := $(APP) + +.PHONY: all build run clean test vet fmt + +all: build + +build: + go build -o $(BIN) ./cmd/$(APP)/ + +run: + go run ./cmd/$(APP)/ + +clean: + rm -f $(BIN) + +test: + go test -race ./... + +vet: + go vet ./... + +fmt: + go fmt ./... \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..5e8c925 --- /dev/null +++ b/README.md @@ -0,0 +1,71 @@ +# Go Fish + +A web-based implementation of the classic Go Fish card game. + +## Overview + +A single-page web application where players can join a waiting room, find opponents, +and play Go Fish against each other. Players already in a game are marked as +unavailable in the waiting room. + +## Features + +- **Waiting room**: View other online players, unavailable players marked in red +- **Matchmaking**: Click on available players to start a game +- **Go Fish gameplay**: Ask opponents for ranks, draw from deck if unavailable +- **Book collection**: Collect sets of 4 cards (books) +- **Win condition**: First player to collect 13 books wins + +## Getting Started + +### Prerequisites + +- Go 1.21 or later + +### Installation + +```bash +# Clone the repository +git clone go-fish +cd go-fish + +# Build the application +make build + +# Or run directly +make run +``` + +The application will start on `http://localhost:3000` + +### Running + +```bash +make run +``` + +Or build and run manually: + +```bash +./go-fish +``` + +## How to Play + +1. Open your browser and navigate to `http://localhost:3000` +2. Click "Join Waiting Room" to enter the waiting area +3. See other players - those with green backgrounds are available +4. Players already in a game appear with red backgrounds +5. Click on an available player to be matched and start a game +6. Use the rank buttons (A, 2, 3, ..., K) to ask your opponent for cards +7. Collect sets of 4 matching cards (books) +8. First to 13 books wins! + +## Game Rules + +- Each player starts with 7 cards from a standard 52-card deck +- On your turn, ask your opponent for a specific rank (A, 2-10, J, Q, K) +- If the opponent has the rank, they must give you all cards of that rank +- If the opponent doesn't have the rank, say "Go Fish" and you draw from the deck +- Collect 4 of a kind to score a book +- First player to 13 books wins \ No newline at end of file diff --git a/cmd/go-fish/main.go b/cmd/go-fish/main.go new file mode 100644 index 0000000..e74ce1a --- /dev/null +++ b/cmd/go-fish/main.go @@ -0,0 +1,306 @@ +package main + +import ( + "encoding/json" + "fmt" + "math/rand" + "net/http" + "text/template" + + "github.com/danyel/go-fish/internal/game" + "github.com/danyel/go-fish/internal/store" + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" +) + +type JoinResponse struct { + PlayerID string `json:"playerId"` +} + +type AskRequest struct { + PlayerID string `json:"playerId"` + OpponentID string `json:"opponentId"` + Rank string `json:"rank"` +} + +type AskResponse struct { + Status string `json:"status"` + MyHand []string `json:"myHand"` + MyBookRanks []string `json:"myBookRanks"` + OpponentBookRanks []string `json:"opponentBookRanks"` + GameOver bool `json:"gameOver"` + Message string `json:"message"` +} + +func main() { + s := store.NewPlayerStore() + + mux := chi.NewMux() + mux.Use(middleware.Logger) + mux.Use(middleware.Recoverer) + + mux.Get("/", homeHandler) + mux.Get("/waiting", waitingHandler(s)) + mux.Get("/play/{id}", playHandler(s)) + mux.Post("/join", joinHandler(s)) + mux.Post("/ask", askHandler(s)) + + fmt.Printf("Starting go-fish on :3000\n") + http.ListenAndServe(":3000", mux) +} + +func homeHandler(w http.ResponseWriter, r *http.Request) { + tmpl := template.Must(template.ParseFiles("web/templates/index.html")) + tmpl.Execute(w, nil) +} + +func waitingHandler(s *store.PlayerStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + players := s.GetAvailablePlayers() + hasBotOption := len(players) <= 1 + tmpl := template.Must(template.ParseFiles("web/templates/waiting.html")) + data := struct { + Players []*game.Player + HasBotOption bool + }{Players: players, HasBotOption: hasBotOption} + tmpl.Execute(w, data) + } +} + +func playHandler(s *store.PlayerStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + _ = id + p, ok := s.GetPlayer(id) + if !ok { + http.NotFound(w, r) + return + } + + available := s.GetAvailablePlayers() + var opponent *game.Player + for _, ap := range available { + if ap.ID != p.ID { + opponent = ap + break + } + } + + var isBot bool + var g *game.Game + + if opponent == nil && len(available) == 1 { + isBot = true + botID := generateID() + bot := game.NewPlayer(botID, "Bot") + s.AddPlayer(botID, "Bot") + opponent = bot + s.MarkPlaying(p.ID) + s.MarkPlaying(botID) + g = game.NewGame(p, opponent) + g.ID = "game-" + p.ID + "-" + botID + } else if opponent != nil { + isBot = false + s.MarkPlaying(p.ID) + s.MarkPlaying(opponent.ID) + g = game.NewGame(p, opponent) + g.ID = "game-" + p.ID + "-" + opponent.ID + } else { + isBot = true + botID := generateID() + bot := game.NewPlayer(botID, "Bot") + s.AddPlayer(botID, "Bot") + opponent = bot + s.MarkPlaying(p.ID) + s.MarkPlaying(botID) + g = game.NewGame(p, opponent) + g.ID = "game-" + p.ID + "-" + botID + } + + s.CreateGame(p.ID, g) + + for i := 0; i < 7; i++ { + if len(g.Deck) > 0 { + g.Player1.Draw(g.DrawCard()) + g.Player2.Draw(g.DrawCard()) + } + } + + tmpl := template.Must(template.ParseFiles("web/templates/play.html")) + data := struct { + Game *game.Game + Player *game.Player + IsBot bool + }{Game: g, Player: p, IsBot: isBot} + tmpl.Execute(w, data) + } +} + +func joinHandler(s *store.PlayerStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + name := r.FormValue("name") + if name == "" { + name = "Player" + } + id := generateID() + s.AddPlayer(id, name) + http.SetCookie(w, &http.Cookie{ + Name: "player_id", + Value: id, + Path: "/", + }) + respondJSON(w, http.StatusOK, JoinResponse{PlayerID: id}) + } +} + +func askHandler(s *store.PlayerStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req AskRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request", http.StatusBadRequest) + return + } + + g, ok := s.GetGame(req.PlayerID) + if !ok { + respondJSON(w, http.StatusNotFound, AskResponse{Message: "Game not found"}) + return + } + + rank := game.RankAce + switch req.Rank { + case "A": + rank = game.RankAce + case "2": + rank = game.Rank2 + case "3": + rank = game.Rank3 + case "4": + rank = game.Rank4 + case "5": + rank = game.Rank5 + case "6": + rank = game.Rank6 + case "7": + rank = game.Rank7 + case "8": + rank = game.Rank8 + case "9": + rank = game.Rank9 + case "10": + rank = game.Rank10 + case "J": + rank = game.RankJack + case "Q": + rank = game.RankQueen + case "K": + rank = game.RankKing + } + + asker := g.Player1 + target := g.Player2 + if g.Current == 2 { + asker = g.Player2 + target = g.Player1 + } + + var cardsRemoved []game.Card + + if !asker.HasCard(rank) { + card := g.DrawCard() + target.Draw(card) + } else { + cardsRemoved = asker.RemoveCard(rank) + if len(cardsRemoved) > 0 { + asker.Hand = append(asker.Hand, cardsRemoved...) + if asker.HasBook(rank) { + g.BookPile = append(g.BookPile, []game.Card{}) + } + } + } + + gameOver := false + message := "" + + myHand := asker.Hand + oppHand := target.Hand + + myBookCount := countBooks(g.BookPile) + oppBookCount := countOpponentBooks(g.BookPile) + + if len(myHand) == 0 && len(oppHand) == 0 { + gameOver = true + message = "Game over! No more cards." + } + + if myBookCount >= 13 || oppBookCount >= 13 { + gameOver = true + if myBookCount >= 13 && oppBookCount >= 13 { + message = "Draw! Both players have 13 books." + } else if myBookCount >= 13 { + message = "You win! You have 13 books." + } else { + message = "You lose! Opponent has 13 books." + } + } + + s.CreateGame(req.PlayerID, g) + + respondJSON(w, http.StatusOK, AskResponse{ + Status: "success", + MyHand: cardStrings(myHand), + MyBookRanks: bookRanks(g.BookPile), + OpponentBookRanks: bookRanks(g.BookPile), + GameOver: gameOver, + Message: message, + }) + } +} + +func countBooks(books [][]game.Card) int { + count := 0 + for _, book := range books { + if len(book) > 0 { + count++ + } + } + return count +} + +func countOpponentBooks(books [][]game.Card) int { + count := 0 + for _, book := range books { + if len(book) > 0 { + count++ + } + } + return count +} + +func bookRanks(books [][]game.Card) []string { + var ranks []string + for _, book := range books { + if len(book) > 0 { + ranks = append(ranks, book[0].Rank.String()) + } + } + return ranks +} + +func cardStrings(cards []game.Card) []string { + var result []string + for _, c := range cards { + result = append(result, c.Rank.String()+c.Suit.String()) + } + return result +} + +func respondJSON(w http.ResponseWriter, code int, data interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + json.NewEncoder(w).Encode(data) +} + +func generateID() string { + return fmt.Sprintf("player-%d", rand.Intn(10000)) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..1c51e98 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module github.com/danyel/go-fish + +go 1.21 + +require github.com/go-chi/chi/v5 v5.0.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a3efc1b --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/go-chi/chi/v5 v5.0.0 h1:DBPx88FjZJH3FsICfDAfIfnb7XxKIYVGG6lOPlhENAg= +github.com/go-chi/chi/v5 v5.0.0/go.mod h1:BBug9lr0cqtdAhsu6R4AAdvufI0/XBzAQSsUqJpoZOs= diff --git a/internal/game/bot.go b/internal/game/bot.go new file mode 100644 index 0000000..0368695 --- /dev/null +++ b/internal/game/bot.go @@ -0,0 +1,30 @@ +package game + +import "math/rand" + +type Bot struct { + Player +} + +func NewBot(id, name string) *Bot { + return &Bot{ + Player: *NewPlayer(id, name), + } +} + +func (b *Bot) ChooseRank(hand []Card) Rank { + // Bot simply picks a random card rank from its hand + if len(hand) == 0 { + return RankAce + } + idx := rand.Intn(len(hand)) + return hand[idx].Rank +} + +func (b *Bot) ChooseCardToAsk(availableRanks []Rank) Rank { + if len(availableRanks) > 0 { + idx := rand.Intn(len(availableRanks)) + return availableRanks[idx] + } + return RankAce +} diff --git a/internal/game/cards.go b/internal/game/cards.go new file mode 100644 index 0000000..caf1f17 --- /dev/null +++ b/internal/game/cards.go @@ -0,0 +1,111 @@ +package game + +import "math/rand" + +type Rank int + +const ( + RankAce Rank = iota + Rank2 + Rank3 + Rank4 + Rank5 + Rank6 + Rank7 + Rank8 + Rank9 + Rank10 + RankJack + RankQueen + RankKing +) + +func (r Rank) String() string { + switch r { + case RankAce: + return "A" + case Rank2: + return "2" + case Rank3: + return "3" + case Rank4: + return "4" + case Rank5: + return "5" + case Rank6: + return "6" + case Rank7: + return "7" + case Rank8: + return "8" + case Rank9: + return "9" + case Rank10: + return "10" + case RankJack: + return "J" + case RankQueen: + return "Q" + case RankKing: + return "K" + } + return "?" +} + +type Suit int + +const ( + SuitClubs Suit = iota + SuitDiamonds + SuitHearts + SuitSpades +) + +func (s Suit) String() string { + switch s { + case SuitClubs: + return "♣" + case SuitDiamonds: + return "♦" + case SuitHearts: + return "♥" + case SuitSpades: + return "♠" + } + return "?" +} + +type Card struct { + Rank Rank + Suit Suit +} + +func NewDeck() []Card { + var deck []Card + for suit := SuitClubs; suit <= SuitSpades; suit++ { + for rank := RankAce; rank <= RankKing; rank++ { + deck = append(deck, Card{Rank: rank, Suit: suit}) + } + } + return deck +} + +func Shuffle(deck []Card) []Card { + result := make([]Card, len(deck)) + copy(result, deck) + for i := len(result) - 1; i > 0; i-- { + j := rand.Intn(i + 1) + result[i], result[j] = result[j], result[i] + } + return result +} + +var faceCards = map[Rank]bool{ + RankJack: true, + RankQueen: true, + RankKing: true, +} + +func IsFaceCard(r Rank) bool { + return faceCards[r] +} diff --git a/internal/game/game.go b/internal/game/game.go new file mode 100644 index 0000000..da833e7 --- /dev/null +++ b/internal/game/game.go @@ -0,0 +1,81 @@ +package game + +type GameState int + +const ( + StateWaiting GameState = iota + StatePlaying +) + +type Game struct { + ID string + Player1 *Player + Player2 *Player + Current int // 1 or 2, whose turn it is + State GameState + Deck []Card + BookPile [][]Card +} + +func NewGame(p1, p2 *Player) *Game { + deck := Shuffle(NewDeck()) + return &Game{ + ID: "", + Player1: p1, + Player2: p2, + Current: 1, + State: StatePlaying, + Deck: deck, + BookPile: [][]Card{}, + } +} + +func (g *Game) DrawCard() Card { + if len(g.Deck) == 0 { + // Reshuffle book pile + var all []Card + for _, book := range g.BookPile { + all = append(all, book...) + } + if len(all) > 0 { + g.Deck = Shuffle(all) + g.BookPile = [][]Card{} + } + } + card := g.Deck[0] + g.Deck = g.Deck[1:] + return card +} + +func (g *Game) Ask(target *Player, rank Rank) bool { + if !target.HasCard(rank) { + // Player draws from deck + card := g.DrawCard() + target.Draw(card) + return false // turn passes + } + // Target gives the card + removed := target.RemoveCard(rank) + if len(removed) > 0 { + // Add to asker's hand + g.Player1.Hand = append(g.Player1.Hand, removed...) + // Check for book + if g.Player1.HasBook(rank) { + // Find and remove the 4 cards from hand + var book []Card + var remaining []Card + count := 0 + for _, c := range g.Player1.Hand { + if c.Rank == rank && count < 4 { + book = append(book, c) + count++ + } else { + remaining = append(remaining, c) + } + } + g.Player1.Hand = remaining + g.BookPile = append(g.BookPile, book) + } + } + return true // same player continues +} diff --git a/internal/game/player.go b/internal/game/player.go new file mode 100644 index 0000000..f0eee51 --- /dev/null +++ b/internal/game/player.go @@ -0,0 +1,56 @@ +package game + +type Player struct { + ID string + Name string + Hand []Card + Booked [][]Card // sets of 4 cards + Connected bool +} + +func NewPlayer(id, name string) *Player { + return &Player{ + ID: id, + Name: name, + Hand: []Card{}, + Booked: [][]Card{}, + Connected: true, + } +} + +func (p *Player) Draw(card Card) { + p.Hand = append(p.Hand, card) +} + +func (p *Player) RemoveCard(rank Rank) []Card { + var removed []Card + var remaining []Card + for _, c := range p.Hand { + if c.Rank == rank && len(removed) < 4 { + removed = append(removed, c) + } else { + remaining = append(remaining, c) + } + } + p.Hand = remaining + return removed +} + +func (p *Player) HasCard(rank Rank) bool { + for _, c := range p.Hand { + if c.Rank == rank { + return true + } + } + return false +} + +func (p *Player) HasBook(rank Rank) bool { + count := 0 + for _, c := range p.Hand { + if c.Rank == rank { + count++ + } + } + return count == 4 +} diff --git a/internal/store/store.go b/internal/store/store.go new file mode 100644 index 0000000..1bf1c7b --- /dev/null +++ b/internal/store/store.go @@ -0,0 +1,80 @@ +package store + +import ( + "github.com/danyel/go-fish/internal/game" + "sync" +) + +type PlayerStore struct { + mu sync.Mutex + players map[string]*game.Player + games map[string]*game.Game // keyed by player ID +} + +func NewPlayerStore() *PlayerStore { + return &PlayerStore{ + players: make(map[string]*game.Player), + games: make(map[string]*game.Game), + } +} + +func (s *PlayerStore) AddPlayer(id, name string) *game.Player { + s.mu.Lock() + defer s.mu.Unlock() + p := game.NewPlayer(id, name) + s.players[id] = p + return p +} + +func (s *PlayerStore) GetPlayer(id string) (*game.Player, bool) { + s.mu.Lock() + defer s.mu.Unlock() + p, ok := s.players[id] + return p, ok +} + +func (s *PlayerStore) GetAvailablePlayers() []*game.Player { + s.mu.Lock() + defer s.mu.Unlock() + var available []*game.Player + for _, p := range s.players { + if p.Connected && len(p.Hand) < 7 { + available = append(available, p) + } + } + return available +} + +func (s *PlayerStore) RemovePlayer(id string) { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.players, id) + delete(s.games, id) +} + +func (s *PlayerStore) MarkPlaying(id string) bool { + s.mu.Lock() + defer s.mu.Unlock() + p, ok := s.players[id] + if !ok { + return false + } + p.Connected = false // player is now in a game + return true +} + +func (s *PlayerStore) GetGame(id string) (*game.Game, bool) { + s.mu.Lock() + defer s.mu.Unlock() + g, ok := s.games[id] + return g, ok +} + +func (s *PlayerStore) CreateGame(playerID string, g *game.Game) { + s.mu.Lock() + defer s.mu.Unlock() + // Store game under the player's ID so ask handler can find it + s.games[playerID] = g + // Also store under game ID + s.games[g.ID] = g +} diff --git a/web/templates/index.html b/web/templates/index.html new file mode 100644 index 0000000..d7c4464 --- /dev/null +++ b/web/templates/index.html @@ -0,0 +1,37 @@ + + + + + + Go Fish - Welcome + + + +
+

Go Fish

+

Click below to join the waiting room and find an opponent

+ + + +
+ + \ No newline at end of file diff --git a/web/templates/play.html b/web/templates/play.html new file mode 100644 index 0000000..4dce6b6 --- /dev/null +++ b/web/templates/play.html @@ -0,0 +1,154 @@ + + + + + + Go Fish - Game + + + +
+

Go Fish

+ +
+ {{ .Player.Name }}'s turn - Ask for a rank + {{ if .IsBot }}(playing against a bot){{ end }} +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ + + + + + + + + + + + + +
+ +
+ Click a card in your hand to see its rank, then ask opponent for rank +
+
+ + + + \ No newline at end of file diff --git a/web/templates/waiting.html b/web/templates/waiting.html new file mode 100644 index 0000000..9a67c45 --- /dev/null +++ b/web/templates/waiting.html @@ -0,0 +1,60 @@ + + + + + + Go Fish - Waiting Room + + + +
+

Go Fish - Waiting Room

+
Looking for opponent...
+ +
+ {{ range .Players }} +
+ {{ .Name }} + (available) +
+ {{ else }} +
No available players
+ {{ end }} +
+ + +
Players online: 0
+
+ + + + \ No newline at end of file