bgammon-bei-bot/bot/client.go

603 lines
15 KiB
Go

package bot
import (
"bufio"
"bytes"
"context"
"fmt"
"log"
"math"
"math/rand"
"net"
"os"
"regexp"
"strings"
"time"
"code.rocket9labs.com/tslocum/bei"
"code.rocket9labs.com/tslocum/bgammon"
"github.com/coder/websocket"
)
var Debug int
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
variant int8
beiClient *BEIClient
game *bgammon.GameState
}
func NewClient(beiAddress string, serverAddress string, username string, password string, points int, variant int8, timeout bool) *Client {
const bufferSize = 10
c := &Client{
Address: serverAddress,
Username: username,
Password: password,
Events: make(chan interface{}, bufferSize),
Out: make(chan []byte, bufferSize),
points: points,
variant: variant,
game: &bgammon.GameState{
Game: bgammon.NewGame(bgammon.VariantBackgammon),
},
}
if beiAddress != "" {
c.beiClient = NewBEIClient(beiAddress, false)
}
go c.handleEvents()
if serverAddress != "" {
go c.handleTimeout(timeout)
}
return c
}
func NewLocalClient(conn net.Conn, serverAddress string, username string, password string, points int, variant int8, timeout bool, beiClient *BEIClient) *Client {
c := NewClient("", serverAddress, username, password, points, variant, timeout)
c.beiClient = beiClient
c.connecting = true
go c.connectTCP(conn)
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(nil)
}
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-bei-bot %s\nset autoplay 0\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(conn net.Conn) {
address := c.Address
if strings.HasPrefix(c.Address, "tcp://") {
address = c.Address[6:]
}
reconnect := func() {
log.Println("*** Disconnected")
os.Exit(0) // TODO Reconnecting is currently disabled.
log.Println("*** Reconnecting...")
time.Sleep(2 * time.Second)
go c.connectTCP(nil)
}
if conn == nil {
var err error
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)
c.handleTCPRead(conn)
reconnect()
}
func (c *Client) handleTCPWrite(conn net.Conn) {
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.Conn) {
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 %d", c.points, c.variant))
c.sentGreeting = false
c.rolled = false
}
func (c *Client) handleTimeout(inactive bool) {
t := time.NewTicker(time.Minute)
for range t.C {
if !c.rolled {
continue
}
if len(c.game.Player2.Name) == 0 {
timeout := 2 * time.Minute
if c.game.Winner != 0 {
timeout = 20 * time.Second
}
if time.Since(c.lastActivity) >= timeout {
c.Out <- []byte("leave")
continue
}
} else if !inactive {
continue
}
limit := 5 * time.Minute
if c.game.Player1.Rating != 0 && c.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)
stripped := nonAlpha.ReplaceAllString(strings.ToLower(ev.Message), "")
switch stripped {
case "help":
c.Out <- []byte("say Commands: help, brb, version")
case "brb":
c.lastActivity = time.Now().Add(10 * time.Minute)
c.Out <- []byte("say I will wait 15 minutes for your return.")
case "version":
// TODO
c.Out <- []byte("say This bot is running the latest version of Tabula. For more information visit https://code.rocket9labs.com/tslocum/tabula")
case "rematch":
c.Out <- []byte("say Don't forget the slash at the beginning, type: /rematch")
}
case *bgammon.EventList:
case *bgammon.EventJoined:
if ev.PlayerNumber == 1 {
c.game.Player1.Name = ev.Player
} else if ev.PlayerNumber == 2 {
c.lastActivity = time.Now()
c.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 tabula 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 c.game.Player1.Name == ev.Player {
c.game.Player1.Name = ""
} else if c.game.Player2.Name == ev.Player {
c.game.Player2.Name = ""
}
if ev.Player == c.Username {
c.createMatch()
} else {
log.Printf("%s left the match.", ev.Player)
if c.Address == "" {
c.Out <- []byte("leave")
c.createMatch()
}
}
case *bgammon.EventBoard:
*c.game = ev.GameState
*c.game.Game = *ev.GameState.Game
if c.game.Winner != 0 {
continue
}
c.lastActivity = time.Now()
if c.game.DoubleOffered && c.game.DoublePlayer != c.game.PlayerNumber {
acceptCube := true
if c.beiClient != nil {
event, err := c.beiClient.Double(beiState(c.game.Game))
if err != nil {
log.Fatalf("failed to retrieve cube evaluation from engine: %s", err)
}
switch ev := event.(type) {
case *bei.EventFailDouble:
log.Fatalf("failed to retrieve cube evaluation from engine: %s", ev.Reason)
case *bei.EventOkDouble:
acceptCube = ev.Cube.Take
default:
log.Fatalf("unexpected engine reply: %+v", ev)
}
}
if acceptCube {
c.Out <- []byte("ok")
} else {
c.Out <- []byte("resign")
}
c.rolled = true
continue
}
if c.game.Roll1 == 0 && len(c.game.Player2.Name) != 0 && (c.game.Turn == 0 || c.game.Turn == c.game.PlayerNumber) && c.game.MayRoll() {
if c.game.MayDouble() && c.beiClient != nil {
event, err := c.beiClient.Double(beiState(c.game.Game))
if err != nil {
log.Fatalf("failed to retrieve cube evaluation from engine: %s", err)
}
switch ev := event.(type) {
case *bei.EventFailDouble:
log.Fatalf("failed to retrieve cube evaluation from engine: %s", ev.Reason)
case *bei.EventOkDouble:
if ev.Cube.Offer {
c.Out <- []byte("d")
c.lastActivity = time.Now()
continue
}
default:
log.Fatalf("unexpected engine reply: %+v", ev)
}
}
c.Out <- []byte("r")
c.rolled = true
continue
} else if c.game.Turn == 0 || c.game.Turn != c.game.PlayerNumber {
continue
}
if len(c.game.Available) == 0 {
if c.game.MayChooseRoll() {
event, err := c.beiClient.Choose(beiState(c.game.Game))
if err != nil {
log.Fatalf("failed to retrieve cube evaluation from engine: %s", err)
}
switch ev := event.(type) {
case *bei.EventFailChoose:
log.Fatalf("failed to retrieve roll choice from engine: %s", ev.Reason)
case *bei.EventOkChoose:
if len(ev.Rolls) == 0 {
log.Fatalf("failed to retrieve roll choice from engine: no rolls were returned: %+v", ev)
}
c.Out <- []byte(fmt.Sprintf("ok %d", ev.Rolls[0].Roll))
c.lastActivity = time.Now()
continue
default:
log.Fatalf("unexpected engine reply: %+v", ev)
}
}
c.Out <- []byte("ok")
c.lastActivity = time.Now()
continue
}
if c.beiClient == nil {
log.Fatal("NO BEI CLIENT")
continue
} else if len(c.game.Moves) != 0 {
continue
}
event, err := c.beiClient.Move(beiState(c.game.Game))
if err != nil {
log.Fatalf("failed to retrieve move from engine: %s", err)
}
switch ev := event.(type) {
case *bei.EventFailMove:
case *bei.EventOkMove:
if len(ev.Moves) == 0 || len(ev.Moves[0].Play) == 0 {
log.Fatalf("failed to retrieve move from engine: no moves were returned")
}
for _, play := range ev.Moves[0].Play {
if play.From == 0 || play.From == 25 {
c.Out <- []byte(fmt.Sprintf("mv off/%d", play.To))
} else if play.From == 26 || play.From == 27 {
c.Out <- []byte(fmt.Sprintf("mv bar/%d", play.To))
} else {
c.Out <- []byte(fmt.Sprintf("mv %d/%d", play.From, play.To))
}
}
c.lastActivity = time.Now()
default:
log.Fatalf("unexpected engine reply: %+v", ev)
}
case *bgammon.EventRolled:
c.game.Roll1 = ev.Roll1
c.game.Roll2 = ev.Roll2
c.game.Roll3 = ev.Roll3
var diceFormatted string
if c.game.Turn == 0 {
if ev.Player == c.game.Player1.Name {
diceFormatted = fmt.Sprintf("%d", c.game.Roll1)
} else {
diceFormatted = fmt.Sprintf("%d", c.game.Roll2)
}
} else {
diceFormatted = fmt.Sprintf("%d-%d", c.game.Roll1, c.game.Roll2)
if c.game.Roll3 != 0 {
diceFormatted += fmt.Sprintf("-%d", c.game.Roll3)
}
}
if ev.Player == c.game.Player2.Name {
c.lastActivity = time.Now()
}
c.rolled = true
log.Printf("%s rolled %s.", ev.Player, diceFormatted)
case *bgammon.EventFailedRoll:
log.Printf("*** Failed to roll: %s", ev.Reason)
case *bgammon.EventMoved:
if ev.Player == c.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(c.game.Available))
case *bgammon.EventFailedOk:
log.Printf("*** Failed to submit moves: %s", ev.Reason)
case *bgammon.EventWin:
log.Printf("%s wins!", ev.Player)
if ev.Player != c.game.Player1.Name {
c.Out <- []byte(fmt.Sprintf("say %s", phrases[rand.Intn(len(phrases))]))
}
c.Out <- []byte("say Good game. Play again?")
c.Out <- []byte("rematch")
case *bgammon.EventPing:
c.Out <- []byte(fmt.Sprintf("pong %s", ev.Message))
case *bgammon.EventSettings:
// Do nothing.
default:
log.Printf("*** Warning: Received unknown event: %+v", ev)
log.Println("*** You may need to upgrade your client.")
}
}
}
var phrases = [][]byte{
[]byte("Gadzooks! I accidentally lost by mistake."),
[]byte("My checkers began too close to your home board."),
[]byte("Far too many checkers stood in the path my own."),
[]byte("My hopes perished when my checkers were sent returning."),
[]byte("Forsooth, I couldst not choose the best move."),
[]byte("All my beginning checkers were upside-down."),
[]byte("Thy heraldic checkers were superior to mine own."),
[]byte("My machinations couldst not outwit thine own!"),
[]byte("Thou art human, with soul and wit. I am naught but clockwork!"),
[]byte("My chair was most uncomfortable."),
[]byte("Forsooth, the moving checkers tapped and clacked too loudly."),
[]byte("'Zounds! I couldst not move a checker to save mine artificial life."),
[]byte("I couldst not comprehend mine dice rolls!"),
[]byte("My checkers all had different rotations!"),
[]byte("My checkers foolishly walk on single rolls instead of astride doubles."),
[]byte("Alas, I could find naught but tiny rolls."),
}