├── .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 |
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 |
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 |
--------------------------------------------------------------------------------