├── .github └── workflows │ ├── build.yml │ └── goreleaser.yml ├── .gitignore ├── .goreleaser.yaml ├── Dockerfile ├── LICENSE ├── README.md ├── assets ├── chess.gif └── gambit.png ├── board └── board.go ├── border ├── border.go └── border_test.go ├── cmd └── serve.go ├── demo.tape ├── docker-compose.yml ├── fen └── fen.go ├── game └── game.go ├── go.mod ├── go.sum ├── main.go ├── moves └── moves.go ├── out.gif ├── pieces └── pieces.go ├── position └── position.go ├── server ├── game.go ├── middleware.go ├── player.go ├── room.go └── server.go ├── square └── square.go └── style └── style.go /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | env: 9 | GO111MODULE: "on" 10 | steps: 11 | - name: Install Go 12 | uses: actions/setup-go@v2 13 | with: 14 | go-version: 1.17 15 | 16 | - name: Checkout code 17 | uses: actions/checkout@v2 18 | 19 | - name: Download Go modules 20 | run: go mod download 21 | 22 | - name: Build 23 | run: go build -v ./... 24 | 25 | - name: Test 26 | run: go test ./... 27 | -------------------------------------------------------------------------------- /.github/workflows/goreleaser.yml: -------------------------------------------------------------------------------- 1 | name: goreleaser 2 | 3 | on: 4 | push: 5 | tags: 6 | - v*.*.* 7 | 8 | concurrency: 9 | group: goreleaser 10 | cancel-in-progress: true 11 | 12 | jobs: 13 | goreleaser: 14 | runs-on: ubuntu-latest 15 | permissions: 16 | contents: write 17 | id-token: write 18 | packages: write 19 | env: 20 | DOCKER_CLI_EXPERIMENTAL: enabled 21 | steps: 22 | - uses: actions/checkout@v2 23 | with: 24 | fetch-depth: 0 25 | - uses: actions/setup-go@v2 26 | with: 27 | go-version: 1.17 28 | - uses: actions/cache@v2 29 | with: 30 | path: ~/go/pkg/mod 31 | key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} 32 | restore-keys: | 33 | ${{ runner.os }}-go- 34 | - uses: docker/setup-qemu-action@v1 35 | - uses: docker/setup-buildx-action@v1 36 | - uses: docker/login-action@v1 37 | name: ghcr.io login 38 | with: 39 | registry: ghcr.io 40 | username: ${{ github.repository_owner }} 41 | password: ${{ secrets.GITHUB_TOKEN }} 42 | - uses: goreleaser/goreleaser-action@v2 43 | with: 44 | version: latest 45 | distribution: goreleaser 46 | args: release --rm-dist 47 | env: 48 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 49 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | gambit_ed25519 2 | gambit_ed25519.pub 3 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | project_name: gambit 2 | 3 | env: 4 | - GO111MODULE=on 5 | - CGO_ENABLED=0 6 | 7 | before: 8 | hooks: 9 | - go mod download 10 | - go mod tidy 11 | 12 | builds: 13 | - id: "gambit" 14 | binary: "gambit" 15 | ldflags: -s -w -X main.Version=v{{ .Version }} -X main.CommitSHA={{ .Commit }} 16 | goos: 17 | - linux 18 | - darwin 19 | - windows 20 | goarch: 21 | - amd64 22 | - arm64 23 | ignore: 24 | - goos: windows 25 | goarch: arm64 26 | 27 | archives: 28 | - format_overrides: 29 | - goos: windows 30 | format: zip 31 | replacements: 32 | windows: Windows 33 | darwin: Darwin 34 | 386: i386 35 | amd64: x86_64 36 | 37 | changelog: 38 | sort: asc 39 | use: github 40 | filters: 41 | exclude: 42 | - "^docs:" 43 | - "^test:" 44 | groups: 45 | - title: 'New Features' 46 | regexp: "^.*feat[(\\w)]*:+.*$" 47 | order: 0 48 | - title: 'Bug fixes' 49 | regexp: "^.*fix[(\\w)]*:+.*$" 50 | order: 10 51 | - title: Others 52 | order: 999 53 | 54 | dockers: 55 | - image_templates: 56 | - "ghcr.io/maaslalani/gambit:latest" 57 | - "ghcr.io/maaslalani/gambit:v{{ .Version }}" 58 | ids: [gambit] 59 | goarch: amd64 60 | build_flag_templates: 61 | - --platform=linux/amd64 62 | - --label=org.opencontainers.image.title={{ .ProjectName }} 63 | - --label=org.opencontainers.image.description={{ .ProjectName }} 64 | - --label=org.opencontainers.image.url=https://github.com/maaslalani/gambit 65 | - --label=org.opencontainers.image.source=https://github.com/maaslalani/gambit 66 | - --label=org.opencontainers.image.version=v{{ .Version }} 67 | - --label=org.opencontainers.image.created={{ .Date }} 68 | - --label=org.opencontainers.image.revision={{ .FullCommit }} 69 | - --label=org.opencontainers.image.licenses=MIT 70 | dockerfile: Dockerfile 71 | use: buildx 72 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM scratch 2 | COPY gambit /usr/local/bin/gambit 3 | 4 | # Expose data volume 5 | VOLUME /data 6 | 7 | # Expose ports 8 | EXPOSE 53531/tcp 9 | 10 | # Set the default command 11 | ENTRYPOINT [ "/usr/local/bin/gambit" ] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Maas Lalani 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Gambit: Play chess in your terminal 2 | 3 | # Gambit 4 | 5 | Chess board in your terminal. 6 | 7 | ### Installation 8 | 9 | ```sh 10 | go install github.com/maaslalani/gambit@latest 11 | ``` 12 | 13 | or run from source 14 | 15 | ```sh 16 | git clone https://github.com/maaslalani/gambit 17 | go run ./... 18 | ``` 19 | 20 | #### Docker 21 | 22 | Gambit is available as a Docker image at [ghcr.io/maaslalani/gambit](https://ghcr.io/maaslalani/gambit). 23 | 24 | ### Play 25 | 26 | You can play a game by running: 27 | 28 | ``` 29 | gambit 30 | ``` 31 | 32 | You can press ctrl+f to flip the board to give a better perspective 33 | for the second player. 34 | 35 |
36 |

37 | Terminal chess 38 |

39 | 40 | ### Networked Play 41 | 42 | You can play chess with `gambit` over `ssh`. 43 | 44 | ``` 45 | ssh [@] -p -t [] 46 | ``` 47 | 48 | You can host your own `gambit` SSH server with: 49 | 50 | ``` 51 | gambit serve 52 | ``` 53 | 54 | ### Move 55 | 56 | There are two ways to move in `gambit`: 57 | 58 | * Type out the square the piece you want to move is on, then type out the square to which you want to move the piece. 59 | * With the mouse, click on the target piece and target square. 60 | -------------------------------------------------------------------------------- /assets/chess.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maaslalani/gambit/9c3cb904eec3cff1853e992b9aa638f307232969/assets/chess.gif -------------------------------------------------------------------------------- /assets/gambit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maaslalani/gambit/9c3cb904eec3cff1853e992b9aa638f307232969/assets/gambit.png -------------------------------------------------------------------------------- /board/board.go: -------------------------------------------------------------------------------- 1 | package board 2 | 3 | const ( 4 | Cols = 8 5 | Rows = 8 6 | ) 7 | 8 | const ( 9 | FirstCol = 0 10 | FirstRow = 0 11 | LastCol = Cols - 1 12 | LastRow = Rows - 1 13 | ) 14 | -------------------------------------------------------------------------------- /border/border.go: -------------------------------------------------------------------------------- 1 | package border 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/maaslalani/gambit/board" 8 | "github.com/maaslalani/gambit/position" 9 | ) 10 | 11 | const ( 12 | // cellHeight represents how many rows are in a cell 13 | cellHeight = 2 14 | // cellWidth represents how many columns are in a cell 15 | cellWidth = 4 16 | 17 | // marginLeft and marginTop represent the offset of the chess 18 | // board from the top left of the terminal window. This is to 19 | // account for padding and rank labels 20 | marginLeft = 3 21 | marginTop = 1 22 | 23 | Vertical = "│" 24 | Horizontal = "─" 25 | ) 26 | 27 | // Cell returns the square that was clicked based on mouse coordinates adjusted 28 | // for margins and cell dimensions. 29 | func Cell(x, y int, flipped bool) string { 30 | col := (x - marginLeft) / cellWidth 31 | row := (y - marginTop) / cellHeight 32 | return position.ToSquare(row, col, flipped) 33 | } 34 | 35 | // withMarginLeft returns a string with a prepended left margin 36 | func withMarginLeft(s string) string { 37 | return strings.Repeat(" ", marginLeft) + s 38 | } 39 | 40 | // Build returns a string with a border for a given row (top, middle, bottom) 41 | func Build(left, middle, right string) string { 42 | border := left + Horizontal + strings.Repeat(Horizontal+Horizontal+middle+Horizontal, board.LastRow) 43 | border += Horizontal + Horizontal + right + "\n" 44 | return withMarginLeft(border) 45 | } 46 | 47 | // Top returns a built border with the top row 48 | func Top() string { 49 | return Build("┌", "┬", "┐") 50 | } 51 | 52 | // Middle returns a built border with the middle row 53 | func Middle() string { 54 | return Build("├", "┼", "┤") 55 | } 56 | 57 | // Bottom returns a built border with the bottom row 58 | func Bottom() string { 59 | return Build("└", "┴", "┘") 60 | } 61 | 62 | // BottomLabels returns the labels for the files 63 | func BottomLabels(flipped bool) string { 64 | labels := "" 65 | for i := 0; i < board.Cols; i++ { 66 | c := i 67 | if flipped { 68 | c = board.LastCol - i 69 | } 70 | labels += fmt.Sprintf("%c", c+'A') 71 | if i != board.LastCol { 72 | labels += withMarginLeft("") 73 | } 74 | } 75 | return withMarginLeft(fmt.Sprintf(" %s\n", labels)) 76 | } 77 | -------------------------------------------------------------------------------- /border/border_test.go: -------------------------------------------------------------------------------- 1 | package border_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/maaslalani/gambit/border" 7 | ) 8 | 9 | func TestTopBorder(t *testing.T) { 10 | tests := []struct { 11 | borderFunc func() string 12 | want string 13 | }{ 14 | 15 | { 16 | border.Top, 17 | " ┌───┬───┬───┬───┬───┬───┬───┬───┐\n", 18 | }, 19 | { 20 | border.Middle, 21 | " ├───┼───┼───┼───┼───┼───┼───┼───┤\n", 22 | }, 23 | { 24 | border.Bottom, 25 | " └───┴───┴───┴───┴───┴───┴───┴───┘\n", 26 | }, 27 | } 28 | 29 | for _, test := range tests { 30 | got := test.borderFunc() 31 | if got != test.want { 32 | t.Errorf("want %s, got %s", test.want, got) 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /cmd/serve.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "os" 7 | "os/signal" 8 | "strconv" 9 | "syscall" 10 | "time" 11 | 12 | "github.com/maaslalani/gambit/server" 13 | "github.com/muesli/coral" 14 | ) 15 | 16 | var ( 17 | host string 18 | port int 19 | key string 20 | 21 | ServeCmd = &coral.Command{ 22 | Use: "serve", 23 | Aliases: []string{"server"}, 24 | Short: "Start a Gambit server", 25 | Args: coral.NoArgs, 26 | RunE: func(cmd *coral.Command, args []string) error { 27 | k := os.Getenv("GAMBIT_SERVER_KEY_PATH") 28 | if k != "" { 29 | key = k 30 | } 31 | h := os.Getenv("GAMBIT_SERVER_HOST") 32 | if h != "" { 33 | host = h 34 | } 35 | p := os.Getenv("GAMBIT_SERVER_PORT") 36 | if p != "" { 37 | port, _ = strconv.Atoi(p) 38 | } 39 | s, err := server.NewServer(key, host, port) 40 | if err != nil { 41 | return err 42 | } 43 | 44 | done := make(chan os.Signal, 1) 45 | signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM) 46 | log.Printf("Starting Gambit server on %s:%d", host, port) 47 | go func() { 48 | if err = s.Start(); err != nil { 49 | log.Fatalln(err) 50 | } 51 | }() 52 | 53 | <-done 54 | log.Print("Stopping Gambit server") 55 | ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) 56 | defer func() { cancel() }() 57 | if err := s.Shutdown(ctx); err != nil { 58 | log.Fatalln(err) 59 | } 60 | 61 | return nil 62 | }, 63 | } 64 | ) 65 | 66 | func init() { 67 | ServeCmd.Flags().StringVar(&key, "key", "gambit", "Server private key path") 68 | ServeCmd.Flags().StringVar(&host, "host", "", "Server host to bind to") 69 | ServeCmd.Flags().IntVar(&port, "port", 53531, "Server port to bind to") 70 | } 71 | -------------------------------------------------------------------------------- /demo.tape: -------------------------------------------------------------------------------- 1 | Output out.gif 2 | 3 | Set FontSize 26 4 | Set Height 825 5 | Set Width 1000 6 | Set Padding 10 7 | Set Margin 30 8 | Set MarginFill "#0C1015" 9 | Set BorderRadius 8 10 | Set WindowBar Colorful 11 | 12 | Hide 13 | Type "gambit" 14 | Enter 15 | Show 16 | 17 | Type "e2" 18 | Sleep 1 19 | Type "e4" 20 | Sleep 1 21 | Type "e7" 22 | Sleep 1 23 | Type "e5" 24 | Sleep 1 25 | Type "g1" 26 | Sleep 1 27 | Type "f3" 28 | Sleep 1 29 | Type "b8" 30 | Sleep 1 31 | Type "c6" 32 | Sleep 1 33 | Type "f1" 34 | Sleep 1 35 | Type "b5" 36 | Sleep 1 37 | Type "g8" 38 | Sleep 1 39 | Type "f6" 40 | Sleep 1 41 | Type "e1" 42 | Sleep 1 43 | Type "g1" 44 | 45 | Sleep 3 46 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.1" 2 | services: 3 | soft-serve: 4 | image: ghcr.io/maaslalani/gambit:latest 5 | container_name: gambit 6 | volumes: 7 | - ./data:/data 8 | ports: 9 | - 53531:53531 10 | environment: 11 | - GAMBIT_SERVER_HOST= 12 | - GAMBIT_SERVER_PORT=53531 13 | - GAMBIT_SERVER_KEY_PATH="/data/gambit" 14 | command: serve 15 | restart: unless-stopped -------------------------------------------------------------------------------- /fen/fen.go: -------------------------------------------------------------------------------- 1 | package fen 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | "strings" 7 | ) 8 | 9 | var fenRegex = regexp.MustCompile(`\s*([rnbqkpRNBQKP1-8]+\/){7}([rnbqkpRNBQKP1-8]+)\s[bw-]\s(([a-hkqA-HKQ]{1,4})|(-))\s(([a-h][36])|(-))\s\d+\s\d+\s*`) 10 | 11 | // IsValid returns whether a FEN string is valid by checking a naive regular expression 12 | func IsValid(fen string) bool { 13 | return fenRegex.MatchString(fen) 14 | } 15 | 16 | // Tokens returns the (6) tokens of a FEN string 17 | // 18 | // [Pieces, Turn, Castling, En passant, Halfmove Clock, Fullmove number] 19 | func Tokens(fen string) []string { 20 | return strings.Split(fen, " ") 21 | } 22 | 23 | // Ranks returns a slice of ranks from the first token of a FEN string 24 | func Ranks(fen string) []string { 25 | return strings.Split(Tokens(fen)[0], "/") 26 | } 27 | 28 | // Grid returns a 8x8 grid of the board represented by the FEN string 29 | func Grid(fen string) [8][8]string { 30 | var grid [8][8]string 31 | for r, rank := range Ranks(fen) { 32 | var row [8]string 33 | c := 0 34 | for _, col := range rank { 35 | skip := 1 36 | if isNumeric(col) { 37 | skip = runeToInt(col) 38 | } else { 39 | row[c] = fmt.Sprintf("%c", col) 40 | } 41 | c += skip 42 | } 43 | grid[r] = row 44 | } 45 | return grid 46 | } 47 | 48 | // isNumeric returns whether a rune is a number 49 | func isNumeric(r rune) bool { 50 | return r >= '0' && r <= '9' 51 | } 52 | 53 | // runeToInt converts a rune to an integer 54 | func runeToInt(r rune) int { 55 | return int(r - '0') 56 | } 57 | -------------------------------------------------------------------------------- /game/game.go: -------------------------------------------------------------------------------- 1 | package game 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | tea "github.com/charmbracelet/bubbletea" 8 | dt "github.com/dylhunn/dragontoothmg" 9 | 10 | "github.com/maaslalani/gambit/board" 11 | "github.com/maaslalani/gambit/border" 12 | "github.com/maaslalani/gambit/fen" 13 | "github.com/maaslalani/gambit/moves" 14 | "github.com/maaslalani/gambit/pieces" 15 | "github.com/maaslalani/gambit/position" 16 | . "github.com/maaslalani/gambit/style" 17 | ) 18 | 19 | // MoveMsg is a message that controls the board from outside the model. 20 | type MoveMsg struct { 21 | From string 22 | To string 23 | } 24 | 25 | // NotifyMsg is a message that gets emitted when the user makes a move. 26 | type NotifyMsg struct { 27 | From string 28 | To string 29 | Turn bool 30 | Check bool 31 | Checkmate bool 32 | } 33 | 34 | // Game stores the state of the chess game. 35 | // 36 | // It tracks the board, legal moves, and the selected piece. It also keeps 37 | // track of the subset of legal moves for the currently selected piece 38 | type Game struct { 39 | board *dt.Board 40 | moves []dt.Move 41 | pieceMoves []dt.Move 42 | selected string 43 | buffer string 44 | flipped bool 45 | } 46 | 47 | // NewGame returns an initial model of the game board. 48 | func NewGame() *Game { 49 | return NewGameWithPosition(dt.Startpos) 50 | } 51 | 52 | // NewGameWithPosition returns an initial model of the game board with the 53 | // specified FEN position. 54 | func NewGameWithPosition(position string) *Game { 55 | m := &Game{} 56 | 57 | if !fen.IsValid(position) { 58 | position = dt.Startpos 59 | } 60 | board := dt.ParseFen(position) 61 | m.board = &board 62 | m.moves = m.board.GenerateLegalMoves() 63 | 64 | return m 65 | } 66 | 67 | // Init Initializes the model 68 | func (m *Game) Init() tea.Cmd { 69 | return nil 70 | } 71 | 72 | // View converts a FEN string into a human readable chess board. All pieces and 73 | // empty squares are arranged in a grid-like pattern. The selected piece is 74 | // highlighted and the legal moves for the selected piece are indicated by a 75 | // dot (.) for empty squares. Pieces that may be captured by the selected piece 76 | // are highlighted. 77 | // 78 | // For example, if the user selects the white pawn on E2 we indicate that they 79 | // can move to E3 and E4 legally. 80 | // 81 | // ┌───┬───┬───┬───┬───┬───┬───┬───┐ 82 | // 8 │ ♖ │ ♘ │ ♗ │ ♕ │ ♔ │ ♗ │ ♘ │ ♖ │ 83 | // ├───┼───┼───┼───┼───┼───┼───┼───┤ 84 | // 7 │ ♙ │ ♙ │ ♙ │ ♙ │ ♙ │ ♙ │ ♙ │ ♙ │ 85 | // ├───┼───┼───┼───┼───┼───┼───┼───┤ 86 | // 6 │ │ │ │ │ │ │ │ │ 87 | // ├───┼───┼───┼───┼───┼───┼───┼───┤ 88 | // 5 │ │ │ │ │ │ │ │ │ 89 | // ├───┼───┼───┼───┼───┼───┼───┼───┤ 90 | // 4 │ │ │ │ │ . │ │ │ │ 91 | // ├───┼───┼───┼───┼───┼───┼───┼───┤ 92 | // 3 │ │ │ │ │ . │ │ │ │ 93 | // ├───┼───┼───┼───┼───┼───┼───┼───┤ 94 | // 2 │ ♟ │ ♟ │ ♟ │ ♟ │ ♟ │ ♟ │ ♟ │ ♟ │ 95 | // ├───┼───┼───┼───┼───┼───┼───┼───┤ 96 | // 1 │ ♜ │ ♞ │ ♝ │ ♛ │ ♚ │ ♝ │ ♞ │ ♜ │ 97 | // └───┴───┴───┴───┴───┴───┴───┴───┘ 98 | // A B C D E F G H 99 | // 100 | func (m *Game) View() string { 101 | var s strings.Builder 102 | s.WriteString(border.Top()) 103 | 104 | // Traverse through the rows and columns of the board and print out the 105 | // pieces and empty squares. Once a piece is selected, highlight the legal 106 | // moves and pieces that may be captured by the selected piece. 107 | var rows = fen.Grid(m.board.ToFen()) 108 | 109 | for r := board.FirstRow; r < board.Rows; r++ { 110 | row := pieces.ToPieces(rows[r]) 111 | rr := board.LastRow - r 112 | 113 | // reverse the row if the board is flipped 114 | if m.flipped { 115 | row = pieces.ToPieces(rows[board.LastRow-r]) 116 | for i, j := 0, len(row)-1; i < j; i, j = i+1, j-1 { 117 | row[i], row[j] = row[j], row[i] 118 | } 119 | rr = r 120 | } 121 | 122 | s.WriteString(Faint(fmt.Sprintf(" %d ", rr+1)) + border.Vertical) 123 | 124 | for c, piece := range row { 125 | whiteTurn := m.board.Wtomove 126 | display := piece.Display() 127 | check := m.board.OurKingInCheck() 128 | selected := position.ToSquare(r, c, m.flipped) 129 | 130 | // The user selected the current cell, highlight it so they know it is 131 | // selected. If it is a check, highlight the king in red. 132 | if m.selected == selected { 133 | display = Cyan(display) 134 | } else if check && piece.IsKing() { 135 | if (whiteTurn && piece.IsWhite()) || (!whiteTurn && piece.IsBlack()) { 136 | display = Red(display) 137 | } 138 | } 139 | 140 | // Show all the cells to which the piece may move. If it is an empty cell 141 | // we present a coloured dot, otherwise color the capturable piece. 142 | if moves.IsLegal(m.pieceMoves, selected) { 143 | if piece.IsEmpty() { 144 | display = "." 145 | } 146 | display = Magenta(display) 147 | } 148 | 149 | s.WriteString(fmt.Sprintf(" %s %s", display, border.Vertical)) 150 | } 151 | s.WriteRune('\n') 152 | 153 | if r != board.LastRow { 154 | s.WriteString(border.Middle()) 155 | } 156 | } 157 | 158 | s.WriteString(border.Bottom() + Faint(border.BottomLabels(m.flipped))) 159 | return s.String() 160 | } 161 | 162 | func (m *Game) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 163 | switch msg := msg.(type) { 164 | case tea.MouseMsg: 165 | if msg.Type != tea.MouseLeft { 166 | return m, nil 167 | } 168 | 169 | // Find the square the user clicked on, this will either be our square 170 | // square for our piece or the destination square for a move if a piece is 171 | // already square and that destination square completes a legal move 172 | square := border.Cell(msg.X, msg.Y, m.flipped) 173 | return m.Select(square) 174 | case tea.KeyMsg: 175 | switch msg.String() { 176 | case "ctrl+c", "q": 177 | return m, tea.Quit 178 | case "ctrl+f": 179 | m.flipped = !m.flipped 180 | case "a", "b", "c", "d", "e", "f", "g", "h": 181 | m.buffer = msg.String() 182 | case "1", "2", "3", "4", "5", "6", "7", "8": 183 | var move string 184 | if m.buffer != "" { 185 | move = m.buffer + msg.String() 186 | m.buffer = "" 187 | } 188 | return m.Select(move) 189 | case "esc": 190 | return m.Deselect() 191 | } 192 | case MoveMsg: 193 | m.selected = msg.From 194 | m.pieceMoves = moves.LegalSelected(m.moves, m.selected) 195 | return m.Select(msg.To) 196 | } 197 | 198 | return m, nil 199 | } 200 | 201 | func (m *Game) Notify(from, to string, turn, check, checkmate bool) tea.Cmd { 202 | return func() tea.Msg { 203 | return NotifyMsg{ 204 | From: from, To: to, Turn: turn, 205 | Check: check, Checkmate: checkmate, 206 | } 207 | } 208 | } 209 | 210 | func (m *Game) Deselect() (tea.Model, tea.Cmd) { 211 | m.selected = "" 212 | m.pieceMoves = []dt.Move{} 213 | return m, nil 214 | } 215 | 216 | func (m *Game) Select(square string) (tea.Model, tea.Cmd) { 217 | // If the user has already selected a piece, check see if the square that 218 | // the user clicked on is a legal move for that piece. If so, make the move. 219 | if m.selected != "" { 220 | from := m.selected 221 | to := square 222 | 223 | for _, move := range m.pieceMoves { 224 | if move.String() == from+to || (move.Promote() > 1 && move.String() == from+to+"q") { 225 | var cmds []tea.Cmd 226 | m.board.Apply(move) 227 | 228 | // We have applied a new move and the chess board is in a new state. 229 | // We must generate the new legal moves for the new state. 230 | m.moves = m.board.GenerateLegalMoves() 231 | check := m.board.OurKingInCheck() 232 | checkmate := check && len(m.moves) == 0 233 | 234 | // We have made a move, so we no longer have a selected piece or 235 | // legal moves for any selected pieces. 236 | g, cmd := m.Deselect() 237 | cmds = append(cmds, cmd, m.Notify(from, to, m.board.Wtomove, check, checkmate)) 238 | return g, tea.Batch(cmds...) 239 | } 240 | } 241 | 242 | // The user clicked on a square that wasn't a legal move for the selected 243 | // piece, so we select the piece that was clicked on instead 244 | m.selected = to 245 | } else { 246 | m.selected = square 247 | } 248 | 249 | // After a mouse click, we must generate the legal moves for the selected 250 | // piece, if there is a newly selected piece 251 | m.pieceMoves = moves.LegalSelected(m.moves, m.selected) 252 | 253 | return m, nil 254 | } 255 | 256 | // SetFlipped sets the board to be flipped or not. 257 | func (g *Game) SetFlipped(flip bool) { 258 | g.flipped = flip 259 | } 260 | 261 | // Position returns the current FEN position of the board. 262 | func (g *Game) Position() string { 263 | return g.board.ToFen() 264 | } 265 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/maaslalani/gambit 2 | 3 | go 1.18 4 | 5 | require ( 6 | github.com/charmbracelet/bubbletea v0.24.2 7 | github.com/charmbracelet/lipgloss v0.9.1 8 | github.com/charmbracelet/ssh v0.0.0-20230822194956-1a051f898e09 9 | github.com/charmbracelet/wish v1.2.0 10 | github.com/dylhunn/dragontoothmg v0.0.0-20220917014754-e79413b50d93 11 | github.com/gliderlabs/ssh v0.3.5 12 | github.com/muesli/coral v1.0.0 13 | github.com/muesli/termenv v0.15.2 14 | golang.org/x/crypto v0.14.0 15 | ) 16 | 17 | require ( 18 | github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect 19 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 20 | github.com/charmbracelet/keygen v0.5.0 // indirect 21 | github.com/charmbracelet/log v0.2.5 // indirect 22 | github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect 23 | github.com/go-logfmt/logfmt v0.6.0 // indirect 24 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 25 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 26 | github.com/mattn/go-isatty v0.0.20 // indirect 27 | github.com/mattn/go-localereader v0.0.1 // indirect 28 | github.com/mattn/go-runewidth v0.0.15 // indirect 29 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect 30 | github.com/muesli/cancelreader v0.2.2 // indirect 31 | github.com/muesli/reflow v0.3.0 // indirect 32 | github.com/rivo/uniseg v0.4.4 // indirect 33 | github.com/spf13/pflag v1.0.5 // indirect 34 | golang.org/x/sync v0.4.0 // indirect 35 | golang.org/x/sys v0.13.0 // indirect 36 | golang.org/x/term v0.13.0 // indirect 37 | golang.org/x/text v0.13.0 // indirect 38 | ) 39 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= 2 | github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= 3 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 4 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 5 | github.com/charmbracelet/bubbletea v0.24.2 h1:uaQIKx9Ai6Gdh5zpTbGiWpytMU+CfsPp06RaW2cx/SY= 6 | github.com/charmbracelet/bubbletea v0.24.2/go.mod h1:XdrNrV4J8GiyshTtx3DNuYkR1FDaJmO3l2nejekbsgg= 7 | github.com/charmbracelet/keygen v0.5.0 h1:XY0fsoYiCSM9axkrU+2ziE6u6YjJulo/b9Dghnw6MZc= 8 | github.com/charmbracelet/keygen v0.5.0/go.mod h1:DfvCgLHxZ9rJxdK0DGw3C/LkV4SgdGbnliHcObV3L+8= 9 | github.com/charmbracelet/lipgloss v0.9.1 h1:PNyd3jvaJbg4jRHKWXnCj1akQm4rh8dbEzN1p/u1KWg= 10 | github.com/charmbracelet/lipgloss v0.9.1/go.mod h1:1mPmG4cxScwUQALAAnacHaigiiHB9Pmr+v1VEawJl6I= 11 | github.com/charmbracelet/log v0.2.5 h1:1yVvyKCKVV639RR4LIq1iy1Cs1AKxuNO+Hx2LJtk7Wc= 12 | github.com/charmbracelet/log v0.2.5/go.mod h1:nQGK8tvc4pS9cvVEH/pWJiZ50eUq1aoXUOjGpXvdD0k= 13 | github.com/charmbracelet/ssh v0.0.0-20230822194956-1a051f898e09 h1:ZDIQmTtohv0S/AAYE//w8mYTxCzqphhF1+4ACPDMiLU= 14 | github.com/charmbracelet/ssh v0.0.0-20230822194956-1a051f898e09/go.mod h1:F1vgddWsb/Yr/OZilFeRZEh5sE/qU0Dt1mKkmke6Zvg= 15 | github.com/charmbracelet/wish v1.2.0 h1:h5Wj9pr97IQz/l4gM5Xep2lXcY/YM+6O2RC2o3x0JIQ= 16 | github.com/charmbracelet/wish v1.2.0/go.mod h1:JX3fC+178xadJYAhPu6qWtVDpJTwpnFvpdjz9RKJlUE= 17 | github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2wIvVRd/hEHD7lacgqrCPS+k8g1MndzfWY= 18 | github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk= 19 | github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 20 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 21 | github.com/dylhunn/dragontoothmg v0.0.0-20220917014754-e79413b50d93 h1:+seiDwiD3oVmo7Lem5B5sI4feILZDJLgOK6gZYd6g0Y= 22 | github.com/dylhunn/dragontoothmg v0.0.0-20220917014754-e79413b50d93/go.mod h1:L6ZI7rasNVYqjj/tpfqYRowKPuSQO71UCBBhPxamiDQ= 23 | github.com/gliderlabs/ssh v0.3.5 h1:OcaySEmAQJgyYcArR+gGGTHCyE7nvhEMTlYY+Dp8CpY= 24 | github.com/gliderlabs/ssh v0.3.5/go.mod h1:8XB4KraRrX39qHhT6yxPsHedjA08I/uBVwj4xC+/+z4= 25 | github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= 26 | github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= 27 | github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= 28 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 29 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 30 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 31 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 32 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 33 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 34 | github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= 35 | github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= 36 | github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= 37 | github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= 38 | github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 39 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= 40 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= 41 | github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= 42 | github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 43 | github.com/muesli/coral v1.0.0 h1:odyqkoEg4aJAINOzvnjN4tUsdp+Zleccs7tRIAkkYzU= 44 | github.com/muesli/coral v1.0.0/go.mod h1:bf91M/dkp7iHQw73HOoR9PekdTJMTD6ihJgWoDitde8= 45 | github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= 46 | github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= 47 | github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= 48 | github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= 49 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 50 | github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 51 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 52 | github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis= 53 | github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 54 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 55 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 56 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 57 | github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= 58 | golang.org/x/crypto v0.0.0-20220826181053-bd7e27e6170d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= 59 | golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc= 60 | golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= 61 | golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 62 | golang.org/x/net v0.0.0-20220826154423-83b083e8dc8b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= 63 | golang.org/x/sync v0.4.0 h1:zxkM55ReGkDlKSM+Fu41A+zmbZuaPVbGMzvvdUPznYQ= 64 | golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= 65 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 66 | golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 67 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 68 | golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 69 | golang.org/x/sys v0.0.0-20220825204002-c680a09ffe64/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 70 | golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 71 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 72 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 73 | golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= 74 | golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 75 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 76 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 77 | golang.org/x/term v0.0.0-20220722155259-a9ba230a4035/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 78 | golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 79 | golang.org/x/term v0.13.0 h1:bb+I9cTfFazGW51MZqBVmZy7+JEJMouUHTUSKVQLBek= 80 | golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= 81 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 82 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 83 | golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= 84 | golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= 85 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 86 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 87 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 88 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 89 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "errors" 6 | "io" 7 | "log" 8 | "os" 9 | "runtime/debug" 10 | "strings" 11 | 12 | tea "github.com/charmbracelet/bubbletea" 13 | "github.com/maaslalani/gambit/cmd" 14 | "github.com/maaslalani/gambit/game" 15 | "github.com/muesli/coral" 16 | ) 17 | 18 | var ( 19 | Version = "" 20 | CommitSHA = "" 21 | 22 | rootCmd = &coral.Command{ 23 | Use: "gambit", 24 | Short: "Play chess in your terminal", 25 | RunE: func(cmd *coral.Command, args []string) error { 26 | if len(args) == 0 { 27 | startPos, _ := readStdin() 28 | 29 | debug := os.Getenv("DEBUG") 30 | if debug != "" { 31 | f, err := tea.LogToFile(debug, "") 32 | if err != nil { 33 | log.Fatal(err) 34 | } 35 | defer f.Close() 36 | } 37 | 38 | p := tea.NewProgram( 39 | game.NewGameWithPosition(startPos), 40 | tea.WithAltScreen(), 41 | tea.WithMouseCellMotion(), 42 | ) 43 | 44 | _, err := p.Run() 45 | return err 46 | } 47 | 48 | return cmd.Help() 49 | }, 50 | DisableFlagsInUseLine: true, 51 | } 52 | ) 53 | 54 | func init() { 55 | if len(CommitSHA) >= 7 { 56 | vt := rootCmd.VersionTemplate() 57 | rootCmd.SetVersionTemplate(vt[:len(vt)-1] + " (" + CommitSHA[0:7] + ")\n") 58 | } 59 | if Version == "" { 60 | if info, ok := debug.ReadBuildInfo(); ok && info.Main.Sum != "" { 61 | Version = info.Main.Version 62 | } else { 63 | Version = "unknown (built from source)" 64 | } 65 | } 66 | rootCmd.Version = Version 67 | 68 | rootCmd.AddCommand( 69 | cmd.ServeCmd, 70 | ) 71 | } 72 | 73 | func main() { 74 | if err := rootCmd.Execute(); err != nil { 75 | os.Exit(1) 76 | } 77 | } 78 | 79 | func readStdin() (string, error) { 80 | stat, err := os.Stdin.Stat() 81 | if err != nil { 82 | return "", err 83 | } 84 | 85 | if stat.Mode()&os.ModeNamedPipe == 0 && stat.Size() == 0 { 86 | return "", errors.New("no starting position provided") 87 | } 88 | 89 | reader := bufio.NewReader(os.Stdin) 90 | var b strings.Builder 91 | 92 | for { 93 | r, _, err := reader.ReadRune() 94 | if err != nil && err == io.EOF { 95 | break 96 | } 97 | _, err = b.WriteRune(r) 98 | if err != nil { 99 | return "", err 100 | } 101 | } 102 | 103 | return b.String(), nil 104 | } 105 | -------------------------------------------------------------------------------- /moves/moves.go: -------------------------------------------------------------------------------- 1 | package moves 2 | 3 | import ( 4 | "strings" 5 | 6 | dt "github.com/dylhunn/dragontoothmg" 7 | ) 8 | 9 | // IsLegal determines whether it is legal to move to a destination square given 10 | // all of the legal moves that can be made by a piece. 11 | func IsLegal(legalMoves []dt.Move, destination string) bool { 12 | for _, move := range legalMoves { 13 | if strings.HasSuffix(move.String(), destination) { 14 | return true 15 | } 16 | 17 | if move.Promote() > 1 && strings.HasSuffix(move.String(), destination+"q") { 18 | return true 19 | } 20 | 21 | } 22 | return false 23 | } 24 | 25 | // LegalSelected returns the legal moves for a given piece based on an origin 26 | // square given all the current legal moves for all pieces on the board. 27 | func LegalSelected(moves []dt.Move, selected string) []dt.Move { 28 | var legalMoves []dt.Move 29 | 30 | // Return an empty slice if there is no square selected 31 | if selected == "" { 32 | return legalMoves 33 | } 34 | 35 | for _, move := range moves { 36 | if strings.HasPrefix(move.String(), selected) { 37 | legalMoves = append(legalMoves, move) 38 | } 39 | } 40 | 41 | return legalMoves 42 | } 43 | -------------------------------------------------------------------------------- /out.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maaslalani/gambit/9c3cb904eec3cff1853e992b9aa638f307232969/out.gif -------------------------------------------------------------------------------- /pieces/pieces.go: -------------------------------------------------------------------------------- 1 | package pieces 2 | 3 | import "strings" 4 | 5 | // Piece represents a chess piece. 6 | type Piece string 7 | 8 | // display maps pieces from their FEN representations to their ASCII 9 | // representations for a more human readable experience. 10 | var display = map[Piece]string{ 11 | "": " ", 12 | "B": "♝", 13 | "K": "♚", 14 | "N": "♞", 15 | "P": "♟", 16 | "Q": "♛", 17 | "R": "♜", 18 | "b": "♗", 19 | "k": "♔", 20 | "n": "♘", 21 | "p": "♙", 22 | "q": "♕", 23 | "r": "♖", 24 | } 25 | 26 | // IsWhite returns true if the piece is white. 27 | func (p Piece) IsWhite() bool { 28 | s := p.String() 29 | return strings.ToUpper(s) == s 30 | } 31 | 32 | // IsBlack returns true if the piece is black. 33 | func (p Piece) IsBlack() bool { 34 | s := p.String() 35 | return strings.ToLower(s) == s 36 | } 37 | 38 | // Display returns the ASCII representation of the piece. 39 | func (p Piece) Display() string { 40 | return display[p] 41 | } 42 | 43 | // String implements the stringer interface. 44 | func (p Piece) String() string { 45 | return string(p) 46 | } 47 | 48 | // IsKing returns true if the piece is a king. 49 | func (p Piece) IsKing() bool { 50 | return p == "K" || p == "k" 51 | } 52 | 53 | // IsPawn returns true if the piece is a pawn. 54 | func (p Piece) IsPawn() bool { 55 | return p == "P" || p == "p" 56 | } 57 | 58 | // IsRook returns true if the piece is a rook. 59 | func (p Piece) IsRook() bool { 60 | return p == "R" || p == "r" 61 | } 62 | 63 | // IsBishop returns true if the piece is a bishop. 64 | func (p Piece) IsBishop() bool { 65 | return p == "B" || p == "b" 66 | } 67 | 68 | // IsKnight returns true if the piece is a knight. 69 | func (p Piece) IsKnight() bool { 70 | return p == "N" || p == "n" 71 | } 72 | 73 | // IsQueen returns true if the piece is a queen. 74 | func (p Piece) IsQueen() bool { 75 | return p == "Q" || p == "q" 76 | } 77 | 78 | // IsEmpty returns true if the piece is empty. 79 | func (p Piece) IsEmpty() bool { 80 | return p == "" 81 | } 82 | 83 | // ToPieces converts a slice of FEN string to a slice of pieces. 84 | func ToPieces(r [8]string) [8]Piece { 85 | var p [8]Piece 86 | for i, s := range r { 87 | p[i] = Piece(s) 88 | } 89 | return p 90 | } 91 | -------------------------------------------------------------------------------- /position/position.go: -------------------------------------------------------------------------------- 1 | package position 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | 7 | "github.com/maaslalani/gambit/board" 8 | ) 9 | 10 | // colToFile returns the file given a column 11 | func colToFile(col int) string { 12 | if col < board.FirstCol { 13 | col = board.FirstCol 14 | } else if col > board.LastCol { 15 | col = board.LastCol 16 | } 17 | return fmt.Sprintf("%c", col+'a') 18 | } 19 | 20 | // rowToRank returns a rank given a row 21 | func rowToRank(row int) int { 22 | if row < board.FirstRow { 23 | row = board.FirstRow 24 | } else if row > board.LastRow { 25 | row = board.LastRow 26 | } 27 | return row + 1 28 | } 29 | 30 | // ToSquare returns the square position (e.g. a1) of a given row and column 31 | // (e.g. 0,0) for display or checking legal moves. 32 | func ToSquare(row, col int, flipped bool) string { 33 | // If the board is flipped, the row and column are reversed 34 | // i.e. h becomes a and 8 becomes 1 35 | if flipped { 36 | col = board.LastCol - col 37 | } else { 38 | row = board.LastRow - row 39 | } 40 | return colToFile(col) + strconv.Itoa(rowToRank(row)) 41 | } 42 | -------------------------------------------------------------------------------- /server/game.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | tea "github.com/charmbracelet/bubbletea" 8 | "github.com/maaslalani/gambit/game" 9 | "github.com/maaslalani/gambit/style" 10 | ) 11 | 12 | // NoteMsg is a message that is sent to the client when a message is added to 13 | // the game. 14 | type NoteMsg string 15 | 16 | // SharedGame is a game that is shared between players. It wraps gambit bubble 17 | // tea model and synchronizes messages among players and server. 18 | type SharedGame struct { 19 | player *Player 20 | game *game.Game 21 | note string 22 | turn bool 23 | observer bool 24 | whiteToMove *bool 25 | sync chan tea.Msg 26 | } 27 | 28 | // NewSharedGame creates a new shared game for a player. 29 | func NewSharedGame(p *Player, sync chan tea.Msg, roomTurn *bool, turn, observer bool, pos string) *SharedGame { 30 | g := game.NewGameWithPosition(pos) 31 | g.SetFlipped(!turn) 32 | r := &SharedGame{ 33 | player: p, 34 | game: g, 35 | turn: turn, 36 | observer: observer, 37 | whiteToMove: roomTurn, 38 | sync: sync, 39 | } 40 | return r 41 | } 42 | 43 | // Init implements bubble tea model. 44 | func (r *SharedGame) Init() tea.Cmd { 45 | return nil 46 | } 47 | 48 | // SendMsg sends a message to the room. 49 | func (r *SharedGame) SendMsg(msg tea.Msg) { 50 | go func() { 51 | r.sync <- msg 52 | }() 53 | } 54 | 55 | // Update implements bubble tea model. 56 | func (r *SharedGame) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 57 | var cmds []tea.Cmd 58 | switch msg := msg.(type) { 59 | case game.NotifyMsg: 60 | if !r.observer && r.turn != msg.Turn { 61 | r.SendMsg(game.MoveMsg{From: msg.From, To: msg.To}) 62 | if msg.Checkmate { 63 | r.SendMsg(NoteMsg(fmt.Sprintf("%s wins!", r.player))) 64 | } 65 | } 66 | case game.MoveMsg: 67 | g, cmd := r.game.Update(msg) 68 | r.game = g.(*game.Game) 69 | cmds = append(cmds, cmd) 70 | case NoteMsg: 71 | r.note = string(msg) 72 | return r, nil 73 | case tea.MouseMsg: 74 | if !r.observer && r.turn == *r.whiteToMove { 75 | if msg.Type != tea.MouseLeft { 76 | return r, nil 77 | } 78 | g, cmd := r.game.Update(msg) 79 | cmds = append(cmds, cmd) 80 | r.game = g.(*game.Game) 81 | } 82 | case tea.KeyMsg: 83 | switch msg.String() { 84 | case "ctrl+c", "q": 85 | g, cmd := r.game.Update(msg) 86 | r.game = g.(*game.Game) 87 | cmds = append(cmds, cmd) 88 | cmds = append(cmds, tea.Quit) 89 | case "ctrl+f": 90 | g, cmd := r.game.Update(msg) 91 | cmds = append(cmds, cmd) 92 | r.game = g.(*game.Game) 93 | default: 94 | if !r.observer && r.turn == *r.whiteToMove { 95 | g, cmd := r.game.Update(msg) 96 | cmds = append(cmds, cmd) 97 | r.game = g.(*game.Game) 98 | } 99 | } 100 | default: 101 | if !r.observer && r.turn == *r.whiteToMove { 102 | g, cmd := r.game.Update(msg) 103 | cmds = append(cmds, cmd) 104 | r.game = g.(*game.Game) 105 | } 106 | } 107 | return r, tea.Batch(cmds...) 108 | } 109 | 110 | // View implements bubble tea model. 111 | func (r *SharedGame) View() string { 112 | s := strings.Builder{} 113 | 114 | turn := "Black's move" 115 | if *r.whiteToMove { 116 | turn = "White's move" 117 | } 118 | 119 | s.WriteString(r.game.View()) 120 | 121 | s.WriteRune('\n') 122 | s.WriteString(fmt.Sprintf(" %s %s", style.Title("Gambit"), turn)) 123 | s.WriteRune('\n') 124 | s.WriteString(style.Faint(fmt.Sprintf(" Room %s as %s playing %s", r.player.room.id, r.player.session.User(), r.player.ptype))) 125 | s.WriteRune('\n') 126 | 127 | return s.String() 128 | } 129 | -------------------------------------------------------------------------------- /server/middleware.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "log" 5 | "strings" 6 | 7 | "github.com/charmbracelet/lipgloss" 8 | "github.com/charmbracelet/ssh" 9 | "github.com/charmbracelet/wish" 10 | "github.com/muesli/termenv" 11 | ) 12 | 13 | // gambitMiddleware is a middleware that handles the Gambit ssh server. It 14 | // creates rooms and assigns players to them. 15 | func gambitMiddleware(srv *Server) wish.Middleware { 16 | return func(sh ssh.Handler) ssh.Handler { 17 | lipgloss.SetColorProfile(termenv.ANSI256) 18 | 19 | return func(s ssh.Session) { 20 | _, _, active := s.Pty() 21 | 22 | cmds := s.Command() 23 | if len(cmds) < 1 || !active { 24 | s.Write([]byte(help("No TTY"))) 25 | s.Exit(1) 26 | return 27 | } 28 | 29 | password := "" 30 | 31 | id := cmds[0] 32 | if len(cmds) > 1 { 33 | password = cmds[1] 34 | } 35 | 36 | room := srv.FindRoom(id) 37 | if room == nil { 38 | log.Printf("room %s is created with password %q", id, password) 39 | room = srv.NewRoom(id, password) 40 | } 41 | 42 | if room.password != password { 43 | s.Write([]byte(help("Incorrect password"))) 44 | s.Exit(1) 45 | return 46 | } 47 | 48 | p, err := room.AddPlayer(s) 49 | if err != nil { 50 | s.Write([]byte(err.Error() + "\n")) 51 | s.Exit(1) 52 | return 53 | } 54 | 55 | log.Printf("%s joined room %s [%s]", s.User(), id, s.RemoteAddr()) 56 | p.StartGame() 57 | log.Printf("%s left room %s [%s]", s.User(), id, s.RemoteAddr()) 58 | 59 | sh(s) 60 | } 61 | } 62 | } 63 | 64 | func help(s string) string { 65 | return strings.Join([]string{ 66 | "Gambit: Play chess in your terminal", 67 | "Usage: ssh [@] -p -t []", 68 | s, 69 | "\n", 70 | }, "\n") 71 | } 72 | -------------------------------------------------------------------------------- /server/player.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "sync" 7 | 8 | tea "github.com/charmbracelet/bubbletea" 9 | "github.com/charmbracelet/ssh" 10 | ) 11 | 12 | // PlayerType is the type of a player in a chess game. 13 | type PlayerType int 14 | 15 | const ( 16 | whitePlayer PlayerType = iota 17 | blackPlayer 18 | observerPlayer 19 | ) 20 | 21 | // String implements the Stringer interface. 22 | func (pt PlayerType) String() string { 23 | switch pt { 24 | case whitePlayer: 25 | return "White" 26 | case blackPlayer: 27 | return "Black" 28 | case observerPlayer: 29 | return "Observer" 30 | default: 31 | return "" 32 | } 33 | } 34 | 35 | // Player is a player in a chess game who belongs to a room, has a ssh session 36 | // and a bubble tea program. 37 | type Player struct { 38 | room *Room 39 | session ssh.Session 40 | program *tea.Program 41 | game *SharedGame 42 | ptype PlayerType 43 | key PublicKey 44 | once sync.Once 45 | } 46 | 47 | // String implements the Stringer interface. 48 | func (p *Player) String() string { 49 | u := p.session.User() 50 | return fmt.Sprintf("%s (%s)", u, p.ptype) 51 | } 52 | 53 | // Position returns the player's board FEN position. 54 | func (p *Player) Position() string { 55 | if p.game != nil && p.game.game != nil { 56 | return p.game.game.Position() 57 | } 58 | return "" 59 | } 60 | 61 | // Send sends a message to the bubble tea program. 62 | func (p *Player) Send(m tea.Msg) { 63 | if p.program != nil { 64 | p.program.Send(m) 65 | } else { 66 | log.Printf("error sending message to player, program is nil") 67 | } 68 | } 69 | 70 | // Write writes data to the ssh session. 71 | func (p *Player) Write(b []byte) (int, error) { 72 | return p.session.Write(b) 73 | } 74 | 75 | // WriteString writes a string to the ssh session. 76 | func (p *Player) WriteString(s string) (int, error) { 77 | return p.session.Write([]byte(s)) 78 | } 79 | 80 | // Close closes the the bubble tea program and deletes the player from the room. 81 | func (p *Player) Close() error { 82 | p.once.Do(func() { 83 | defer delete(p.room.players, p.key.String()) 84 | if p.program != nil { 85 | p.program.Kill() 86 | } 87 | p.session.Close() 88 | }) 89 | return nil 90 | } 91 | 92 | // StartGame starts the bubble tea program. 93 | func (p *Player) StartGame() { 94 | _, wchan, _ := p.session.Pty() 95 | errc := make(chan error, 1) 96 | go func() { 97 | select { 98 | case err := <-errc: 99 | log.Printf("error starting program %s", err) 100 | case w := <-wchan: 101 | if p.program != nil { 102 | p.program.Send(tea.WindowSizeMsg{Width: w.Width, Height: w.Height}) 103 | } 104 | case <-p.session.Context().Done(): 105 | p.Close() 106 | } 107 | }() 108 | defer p.room.SendMsg(NoteMsg(fmt.Sprintf("%s left the room", p))) 109 | m, err := p.program.StartReturningModel() 110 | if m != nil { 111 | p.game = m.(*SharedGame) 112 | } 113 | errc <- err 114 | p.Close() 115 | } 116 | -------------------------------------------------------------------------------- /server/room.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "time" 7 | 8 | tea "github.com/charmbracelet/bubbletea" 9 | "github.com/charmbracelet/ssh" 10 | "github.com/maaslalani/gambit/game" 11 | ) 12 | 13 | var ( 14 | idleTimeout = time.Minute * 3 15 | ) 16 | 17 | // Room is a game room with a unique id, password, and a list of players. 18 | type Room struct { 19 | id string 20 | password string 21 | players map[string]*Player 22 | whiteToMove bool 23 | sync chan tea.Msg 24 | done chan struct{} 25 | finish chan string 26 | } 27 | 28 | // String implements the Stringer interface. 29 | func (r *Room) String() string { 30 | return r.id 31 | } 32 | 33 | // NewRoom creates a new room with a unique id and password. 34 | func NewRoom(id, password string, finish chan string) *Room { 35 | s := make(chan tea.Msg) 36 | r := &Room{ 37 | id: id, 38 | password: password, 39 | players: make(map[string]*Player, 0), 40 | whiteToMove: true, 41 | sync: s, 42 | done: make(chan struct{}, 1), 43 | finish: finish, 44 | } 45 | go func() { 46 | r.Listen() 47 | }() 48 | return r 49 | } 50 | 51 | // P1 returns the player with the first turn. 52 | func (r *Room) P1() *Player { 53 | for _, p := range r.players { 54 | if p.ptype == whitePlayer { 55 | return p 56 | } 57 | } 58 | return nil 59 | } 60 | 61 | // P2 returns the player with the second turn. 62 | func (r *Room) P2() *Player { 63 | for _, p := range r.players { 64 | if p.ptype == blackPlayer { 65 | return p 66 | } 67 | } 68 | return nil 69 | } 70 | 71 | // FindPlayer returns the player for the given public key. 72 | func (r *Room) FindPlayer(pub PublicKey) *Player { 73 | p, ok := r.players[pub.String()] 74 | if ok { 75 | return p 76 | } 77 | return nil 78 | } 79 | 80 | // Write writes data to all players in the room. 81 | func (r *Room) Write(b []byte) (n int, err error) { 82 | for _, p := range r.players { 83 | n, err = p.Write(b) 84 | if err != nil { 85 | return 86 | } 87 | } 88 | return 89 | } 90 | 91 | // WriteString writes a string to all players in the room. 92 | func (r *Room) WriteString(s string) (int, error) { 93 | return r.Write([]byte(s)) 94 | } 95 | 96 | // Close closes the room and deletes the room from the server memory. 97 | func (r *Room) Close() { 98 | log.Printf("closing room %s", r) 99 | 100 | for _, p := range r.players { 101 | p.WriteString("Idle timeout.\n") 102 | p.Close() 103 | } 104 | 105 | r.done <- struct{}{} 106 | r.finish <- r.id 107 | close(r.sync) 108 | close(r.done) 109 | } 110 | 111 | // Listen listens for messages from players in the room and other events. 112 | func (r *Room) Listen() { 113 | for { 114 | select { 115 | case <-r.done: 116 | return 117 | case <-time.After(idleTimeout): 118 | log.Printf("idle timeout for room %s", r) 119 | r.Close() 120 | case m := <-r.sync: 121 | color := whitePlayer 122 | if !r.whiteToMove { 123 | color = blackPlayer 124 | } 125 | switch msg := m.(type) { 126 | case NoteMsg: 127 | r.SendMsg(msg) 128 | case game.MoveMsg: 129 | note := fmt.Sprintf("%s moved %s to %s", color, msg.From, msg.To) 130 | r.whiteToMove = !r.whiteToMove 131 | r.SendMsg(m) 132 | r.SendMsg(NoteMsg(note)) 133 | } 134 | } 135 | } 136 | } 137 | 138 | // SendMsg sends a bubble tea message to all players in the room. 139 | func (r *Room) SendMsg(m tea.Msg) { 140 | go func() { 141 | for _, p := range r.players { 142 | p.Send(m) 143 | } 144 | }() 145 | } 146 | 147 | // Position returns the FEN position of the game in the room. 148 | func (r *Room) Position() string { 149 | p1 := r.P1() 150 | p2 := r.P2() 151 | switch { 152 | case !r.whiteToMove && p1 != nil: 153 | fallthrough 154 | case r.whiteToMove && p2 == nil && p1 != nil: 155 | return p1.Position() 156 | case r.whiteToMove && p2 != nil: 157 | fallthrough 158 | case !r.whiteToMove && p1 == nil && p2 != nil: 159 | return p2.Position() 160 | default: 161 | for _, p := range r.players { 162 | if p.ptype == observerPlayer { 163 | return p.Position() 164 | } 165 | } 166 | return "" 167 | } 168 | } 169 | 170 | // MakePlayer creates a new player with the given type and session. 171 | func (r *Room) MakePlayer(pt PlayerType, s ssh.Session) *Player { 172 | pos := r.Position() 173 | pl := &Player{ 174 | room: r, 175 | session: s, 176 | ptype: pt, 177 | key: PublicKey{key: s.PublicKey()}, 178 | } 179 | m := NewSharedGame(pl, r.sync, &r.whiteToMove, pt == whitePlayer, pt == observerPlayer, pos) 180 | p := tea.NewProgram( 181 | m, 182 | tea.WithAltScreen(), 183 | tea.WithMouseCellMotion(), 184 | tea.WithInput(s), 185 | tea.WithOutput(s), 186 | ) 187 | pl.program = p 188 | pl.game = m 189 | return pl 190 | } 191 | 192 | // AddPlayer adds a player to the room. 193 | func (r *Room) AddPlayer(s ssh.Session) (*Player, error) { 194 | k := s.PublicKey() 195 | if k == nil { 196 | return nil, fmt.Errorf("no public key") 197 | } 198 | pub := PublicKey{key: k} 199 | p, ok := r.players[pub.String()] 200 | if ok { 201 | return nil, fmt.Errorf("Player %s is already in the room", p) 202 | } 203 | p1 := r.P1() 204 | p2 := r.P2() 205 | if p1 == nil { 206 | p = r.MakePlayer(whitePlayer, s) 207 | } else if p2 == nil { 208 | p = r.MakePlayer(blackPlayer, s) 209 | } else { 210 | p = r.MakePlayer(observerPlayer, s) 211 | } 212 | r.players[pub.String()] = p 213 | r.SendMsg(NoteMsg(fmt.Sprintf("%s joined the room", p))) 214 | return p, nil 215 | } 216 | 217 | // ObserversCount returns the number of observer players in the room. 218 | func (r *Room) ObserversCount() int { 219 | n := 0 220 | for _, p := range r.players { 221 | if p.ptype == observerPlayer { 222 | n++ 223 | } 224 | } 225 | return n 226 | } 227 | -------------------------------------------------------------------------------- /server/server.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | 8 | gossh "golang.org/x/crypto/ssh" 9 | 10 | "github.com/charmbracelet/ssh" 11 | "github.com/charmbracelet/wish" 12 | ) 13 | 14 | // PublicKey wraps ssh.PublicKey. 15 | type PublicKey struct { 16 | key ssh.PublicKey 17 | } 18 | 19 | // String implements the Stringer interface. 20 | func (pk PublicKey) String() string { 21 | return fmt.Sprintf("%s", gossh.MarshalAuthorizedKey(pk.key)) 22 | } 23 | 24 | // Server is a server that manages chess games. 25 | type Server struct { 26 | host string 27 | port int 28 | srv *ssh.Server 29 | rooms map[string]*Room 30 | } 31 | 32 | // NewServer creates a new server. 33 | func NewServer(keyPath, host string, port int) (*Server, error) { 34 | s := &Server{ 35 | host: host, 36 | port: port, 37 | rooms: make(map[string]*Room), 38 | } 39 | ws, err := wish.NewServer( 40 | ssh.PasswordAuth(passwordHandler), 41 | ssh.PublicKeyAuth(publicKeyHandler), 42 | wish.WithHostKeyPath(keyPath), 43 | wish.WithAddress(fmt.Sprintf("%s:%d", host, port)), 44 | wish.WithMiddleware( 45 | gambitMiddleware(s), 46 | ), 47 | ) 48 | if err != nil { 49 | return nil, err 50 | } 51 | s.srv = ws 52 | return s, nil 53 | } 54 | 55 | // Start starts the Gambit ssh server. 56 | func (s *Server) Start() error { 57 | return s.srv.ListenAndServe() 58 | } 59 | 60 | // Shutdown shuts down the server. 61 | func (s *Server) Shutdown(ctx context.Context) error { 62 | for _, room := range s.rooms { 63 | room.Close() 64 | } 65 | return s.srv.Shutdown(ctx) 66 | } 67 | 68 | func passwordHandler(ctx ssh.Context, password string) bool { 69 | return true 70 | } 71 | 72 | func publicKeyHandler(ctx ssh.Context, key ssh.PublicKey) bool { 73 | return true 74 | } 75 | 76 | // FindRoom finds a room with the given id. 77 | func (s *Server) FindRoom(id string) *Room { 78 | r, ok := s.rooms[id] 79 | if !ok { 80 | return nil 81 | } 82 | return r 83 | } 84 | 85 | // NewRoom creates a new room with the given id and password. 86 | func (s *Server) NewRoom(id, password string) *Room { 87 | finish := make(chan string, 1) 88 | go func() { 89 | id := <-finish 90 | log.Printf("deleting room %s", id) 91 | delete(s.rooms, id) 92 | close(finish) 93 | }() 94 | 95 | room := NewRoom(id, password, finish) 96 | s.rooms[id] = room 97 | return room 98 | } 99 | -------------------------------------------------------------------------------- /square/square.go: -------------------------------------------------------------------------------- 1 | package square 2 | 3 | import ( 4 | "strconv" 5 | 6 | "github.com/maaslalani/gambit/board" 7 | ) 8 | 9 | // fileToCol returns column number (e.g. 0) for a given file (e.g. 'a'). 10 | func fileToCol(file rune) int { 11 | col := int(file - 'a') 12 | if col < board.FirstCol { 13 | col = board.FirstCol 14 | } else if col > board.LastCol { 15 | col = board.LastCol 16 | } 17 | return col 18 | } 19 | 20 | // rankToRow returns a row number (e.g. 0) for a given rank (e.g. 1). 21 | func rankToRow(rank int) int { 22 | row := rank - 1 23 | if row < board.FirstRow { 24 | row = board.FirstRow 25 | } else if row > board.LastRow { 26 | row = board.LastRow 27 | } 28 | return row 29 | } 30 | 31 | // ToPosition takes a square (e.g. a1) and returns the corresponding row and 32 | // column (e.g. 0,0) for compatibility with the grid (8x8 matrix). 33 | func ToPosition(square string) (int, int) { 34 | col := fileToCol(rune(square[0])) 35 | row, _ := strconv.Atoi(string(square[1])) 36 | row = rankToRow(row) 37 | return col, row 38 | } 39 | -------------------------------------------------------------------------------- /style/style.go: -------------------------------------------------------------------------------- 1 | package style 2 | 3 | import . "github.com/charmbracelet/lipgloss" 4 | 5 | type colorFunc func(s ...string) string 6 | 7 | func fg(color string) colorFunc { 8 | return NewStyle().Foreground(Color(color)).Render 9 | } 10 | 11 | var Cyan = fg("6") 12 | var Faint = fg("8") 13 | var Magenta = fg("5") 14 | var Red = fg("1") 15 | 16 | var Title = NewStyle().Foreground(Color("5")).Italic(true).Render 17 | --------------------------------------------------------------------------------