package main import ( "bufio" "bytes" "context" "fmt" "log" "math" "net" "os" "regexp" "sort" "strings" "time" "code.rocket9labs.com/tslocum/bgammon" "nhooyr.io/websocket" ) var Debug int var Game = &bgammon.GameState{ Game: bgammon.NewGame(bgammon.VariantBackgammon), } var nonAlpha = regexp.MustCompile(`[^a-zA-Z]+`) type Client struct { Address string Username string Password string Events chan interface{} Out chan []byte points int connecting bool sentGreeting bool rolled bool lastActivity time.Time } func newClient(address string, username string, password string, points int) *Client { const bufferSize = 10 c := &Client{ Address: address, Username: username, Password: password, Events: make(chan interface{}, bufferSize), Out: make(chan []byte, bufferSize), points: points, } if address != "" { go c.handleTimeout() } return c } func (c *Client) Connect() { if c.connecting { return } c.connecting = true if strings.HasPrefix(c.Address, "ws://") || strings.HasPrefix(c.Address, "wss://") { c.connectWebSocket() return } c.connectTCP() } func (c *Client) logIn() []byte { loginInfo := strings.ReplaceAll(c.Username, " ", "_") if c.Username != "" && c.Password != "" { loginInfo = fmt.Sprintf("%s %s", strings.ReplaceAll(c.Username, " ", "_"), strings.ReplaceAll(c.Password, " ", "_")) } return []byte(fmt.Sprintf("lj bgammon-gnubg-bot %s\nlist\n", loginInfo)) } func (c *Client) LoggedIn() bool { return c.connecting } func (c *Client) connectWebSocket() { reconnect := func() { log.Println("*** Reconnecting...") time.Sleep(2 * time.Second) go c.connectWebSocket() } ctx, _ := context.WithTimeout(context.Background(), 10*time.Second) conn, _, err := websocket.Dial(ctx, c.Address, nil) if err != nil { reconnect() return } for _, msg := range bytes.Split(c.logIn(), []byte("\n")) { if len(msg) == 0 { continue } ctx, _ = context.WithTimeout(context.Background(), 10*time.Second) err = conn.Write(ctx, websocket.MessageText, msg) if err != nil { reconnect() return } } go c.handleWebSocketWrite(conn) c.handleWebSocketRead(conn) reconnect() } func (c *Client) handleWebSocketWrite(conn *websocket.Conn) { var ctx context.Context for buf := range c.Out { split := bytes.Split(buf, []byte("\n")) for i := range split { if len(split[i]) == 0 { continue } ctx, _ = context.WithTimeout(context.Background(), 10*time.Second) err := conn.Write(ctx, websocket.MessageText, split[i]) if err != nil { conn.Close(websocket.StatusNormalClosure, "Write error") return } if Debug > 0 { log.Printf("-> %s", split[i]) } } } } func (c *Client) handleWebSocketRead(conn *websocket.Conn) { for { msgType, msg, err := conn.Read(context.Background()) if err != nil || msgType != websocket.MessageText { conn.Close(websocket.StatusNormalClosure, "Read error") return } ev, err := bgammon.DecodeEvent(msg) if err != nil { log.Printf("error: failed to parse message: %s", msg) continue } c.Events <- ev if Debug > 0 { log.Printf("<- %s", msg) } } } func (c *Client) connectTCP() { address := c.Address if strings.HasPrefix(c.Address, "tcp://") { address = c.Address[6:] } reconnect := func() { os.Exit(0) // TODO Reconnecting is currently disabled. log.Println("*** Reconnecting...") time.Sleep(2 * time.Second) go c.connectTCP() } conn, err := net.DialTimeout("tcp", address, 10*time.Second) if err != nil { reconnect() return } // Read a single line of text and parse remaining output as JSON. buf := make([]byte, 1) var readBytes int for { _, err = conn.Read(buf) if err != nil { reconnect() return } if buf[0] == '\n' { break } readBytes++ if readBytes == 512 { reconnect() return } } conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) _, err = conn.Write(c.logIn()) if err != nil { reconnect() return } go c.handleTCPWrite(conn.(*net.TCPConn)) c.handleTCPRead(conn.(*net.TCPConn)) reconnect() } func (c *Client) handleTCPWrite(conn *net.TCPConn) { for buf := range c.Out { conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) _, err := conn.Write(buf) if err != nil { conn.Close() return } conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) _, err = conn.Write([]byte("\n")) if err != nil { conn.Close() return } if Debug > 0 { log.Printf("-> %s", buf) } } } func (c *Client) handleTCPRead(conn *net.TCPConn) { conn.SetReadDeadline(time.Now().Add(40 * time.Second)) scanner := bufio.NewScanner(conn) for scanner.Scan() { if scanner.Err() != nil { conn.Close() return } ev, err := bgammon.DecodeEvent(scanner.Bytes()) if err != nil { log.Printf("error: failed to parse message: %s", scanner.Bytes()) continue } c.Events <- ev if Debug > 0 { log.Printf("<- %s", scanner.Bytes()) } conn.SetReadDeadline(time.Now().Add(40 * time.Second)) } } func (c *Client) createMatch() { c.Out <- []byte(fmt.Sprintf("c public %d", c.points)) c.sentGreeting = false c.rolled = false } func (c *Client) handleTimeout() { t := time.NewTicker(time.Minute) for range t.C { if !c.rolled { continue } if len(Game.Player2.Name) == 0 { timeout := 2 * time.Minute if Game.Winner != 0 { timeout = 20 * time.Second } if time.Since(c.lastActivity) >= timeout { c.Out <- []byte("leave") continue } } limit := 5 * time.Minute if Game.Player1.Rating != 0 && Game.Player2.Rating != 0 { limit = 12 * time.Minute } t := time.Now() delta := t.Sub(c.lastActivity) if delta >= limit { c.Out <- []byte("say Sorry, this match has timed out.") c.Out <- []byte("leave") } else if t.Sub(c.lastActivity) >= limit/2 { mins := 1 + int(limit/time.Minute) - int(math.Ceil(delta.Minutes())) plural := "s" if mins == 1 { plural = "" } c.Out <- []byte(fmt.Sprintf("say Warning: This match will time out in %d minute%s. Please roll or make a move.", mins, plural)) } } } func (c *Client) handleEvents() { for e := range c.Events { switch ev := e.(type) { case *bgammon.EventWelcome: c.Username = ev.PlayerName areIs := "are" if ev.Clients == 1 { areIs = "is" } clientsPlural := "s" if ev.Clients == 1 { clientsPlural = "" } matchesPlural := "es" if ev.Games == 1 { matchesPlural = "" } log.Printf("*** Welcome, %s. There %s %d client%s playing %d match%s.", ev.PlayerName, areIs, ev.Clients, clientsPlural, ev.Games, matchesPlural) c.createMatch() case *bgammon.EventNotice: log.Printf("*** %s", ev.Message) case *bgammon.EventSay: log.Printf("<%s> %s", ev.Player, ev.Message) if nonAlpha.ReplaceAllString(strings.ToLower(ev.Message), "") == "brb" { c.lastActivity = time.Now().Add(10 * time.Minute) c.Out <- []byte("say I will wait 15 minutes for your return.") } case *bgammon.EventList: case *bgammon.EventJoined: if ev.PlayerNumber == 1 { Game.Player1.Name = ev.Player } else if ev.PlayerNumber == 2 { c.lastActivity = time.Now() Game.Player2.Name = ev.Player } if ev.Player != c.Username { log.Printf("%s joined the match.", ev.Player) if !c.sentGreeting { c.Out <- []byte(fmt.Sprintf("say Hello, %s. I am a bot powered by the gnubg backgammon engine. Good luck, have fun.", ev.Player)) c.sentGreeting = true } } case *bgammon.EventFailedJoin: log.Printf("*** Failed to join match: %s", ev.Reason) case *bgammon.EventFailedLeave: log.Printf("*** Failed to leave match: %s", ev.Reason) c.createMatch() case *bgammon.EventLeft: if Game.Player1.Name == ev.Player { Game.Player1.Name = "" } else if Game.Player2.Name == ev.Player { Game.Player2.Name = "" } if ev.Player == c.Username { c.createMatch() } else { log.Printf("%s left the match.", ev.Player) } case *bgammon.EventBoard: c.lastActivity = time.Now() *Game = ev.GameState *Game.Game = *ev.GameState.Game g := Game if Game.DoubleOffered { if g.Turn == Game.PlayerNumber { continue } moves, err := analyze(Game.Game) if err != nil { c.Out <- []byte("say failed to communicate with gnubg service") time.Sleep(100 * time.Millisecond) log.Fatalf("failed to communicate with gnubg service: %s", err) } else if moves[0][0] == -1 && moves[0][1] == -1 { c.Out <- []byte("ok") } else { c.Out <- []byte("resign") } c.lastActivity = time.Now() continue } if Game.Roll1 == 0 && len(Game.Player2.Name) != 0 && (Game.Turn == 0 || Game.Turn == Game.PlayerNumber) && Game.MayRoll() { if Game.MayDouble() { moves, err := analyze(Game.Game) if err != nil { c.Out <- []byte("say failed to communicate with gnubg service") time.Sleep(100 * time.Millisecond) log.Fatalf("failed to communicate with gnubg service: %s", err) } else if moves[0][0] == -2 && moves[0][1] == -2 { c.Out <- []byte("double") continue } } c.Out <- []byte("r") c.rolled = true continue } else if Game.Turn == 0 || Game.Turn != Game.PlayerNumber { continue } if len(Game.Moves) != 0 { continue } else if len(Game.Available) == 0 { c.Out <- []byte("ok") c.lastActivity = time.Now() continue } moves, err := analyze(Game.Game) if err != nil { c.Out <- []byte("say failed to communicate with gnubg service") time.Sleep(100 * time.Millisecond) log.Fatalf("failed to communicate with gnubg service: %s", err) } else if moves[0][0] == -1 && moves[0][1] == -1 { c.Out <- []byte("resign") c.lastActivity = time.Now() continue } var mv [][2]int8 for i := 0; i < 4; i++ { from, to := moves[i][0], moves[i][1] if from == 0 && to == 0 { break } else if from == 0 || from == 25 { mv = append(mv, [2]int8{from, to}) } else if to == 0 || to == 25 { diff := bgammon.SpaceDiff(from, to, g.Variant) if diff > 6 { c := from - g.Roll1 last := from for c > to { if bgammon.OpponentCheckers(g.Board[c], 1) <= 1 { mv = append(mv, [2]int8{last, c}) last = c } c -= g.Roll1 } mv = append(mv, [2]int8{last, to}) } else { mv = append(mv, [2]int8{from, to}) } } else { mv = append(mv, [2]int8{from, to}) } } sort.Slice(mv, func(i int, j int) bool { if mv[i][0] == mv[j][0] { return mv[i][1] > mv[j][1] } return mv[i][0] > mv[j][0] }) for _, m := range mv { from, to := m[0], m[1] if from == 0 || from == 25 { c.Out <- []byte(fmt.Sprintf("mv bar/%d", to)) } else if to == 0 || to == 25 { c.Out <- []byte(fmt.Sprintf("mv %d/off", from)) } else { c.Out <- []byte(fmt.Sprintf("mv %d/%d", from, to)) } } c.Out <- []byte("ok") c.lastActivity = time.Now() case *bgammon.EventRolled: Game.Roll1 = ev.Roll1 Game.Roll2 = ev.Roll2 var diceFormatted string if Game.Turn == 0 { if ev.Player == Game.Player1.Name { diceFormatted = fmt.Sprintf("%d", Game.Roll1) } else { diceFormatted = fmt.Sprintf("%d", Game.Roll2) } } else { diceFormatted = fmt.Sprintf("%d-%d", Game.Roll1, Game.Roll2) } if ev.Player == Game.Player2.Name { c.lastActivity = time.Now() } c.rolled = true log.Printf("%s rolled %s.", ev.Player, diceFormatted) if Game.Turn == 0 && Game.Roll1 == Game.Roll2 { c.Out <- []byte("r") } case *bgammon.EventFailedRoll: log.Printf("*** Failed to roll: %s", ev.Reason) case *bgammon.EventMoved: if ev.Player == Game.Player2.Name { c.lastActivity = time.Now() } log.Printf("%s moved %s.", ev.Player, bgammon.FormatMoves(ev.Moves)) case *bgammon.EventFailedMove: var extra string if ev.From != 0 || ev.To != 0 { extra = fmt.Sprintf(" from %s to %s", bgammon.FormatSpace(ev.From), bgammon.FormatSpace(ev.To)) } log.Printf("*** Failed to move checker%s: %s", extra, ev.Reason) log.Printf("*** Legal moves: %s", bgammon.FormatMoves(Game.Available)) case *bgammon.EventFailedOk: c.Out <- []byte("board") // Refresh game state. log.Printf("*** Failed to submit moves: %s", ev.Reason) case *bgammon.EventWin: log.Printf("%s wins!", ev.Player) c.Out <- []byte("rematch") case *bgammon.EventPing: c.Out <- []byte(fmt.Sprintf("pong %s", ev.Message)) default: log.Printf("*** Warning: Received unknown event: %+v", ev) log.Println("*** You may need to upgrade your client.") } } }