529 lines
12 KiB
Go
529 lines
12 KiB
Go
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.")
|
|
}
|
|
}
|
|
}
|