Terminal-based online backgammon client (FIBS)
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

621 lines
16 KiB

package main
import (
"bytes"
"fmt"
"log"
"math/rand"
"regexp"
"strconv"
"strings"
"time"
"golang.org/x/text/language"
"golang.org/x/text/message"
"code.rocketnine.space/tslocum/cview"
"github.com/reiver/go-oi"
"github.com/reiver/go-telnet"
)
const whoInfoSize = 12
const (
whoInfoDataName = iota
whoInfoDataOpponent
whoInfoDataWatching
whoInfoDataReady
whoInfoDataAway
whoInfoDataRating
whoInfoDataExperience
whoInfoDataIdleTime
whoInfoDataLoginTime
whoInfoDataHostname
whoInfoDataClientName
whoInfoDataEmail
)
var (
TypeWelcome = []byte("1")
TypeOwnInfo = []byte("2")
TypeMOTD = []byte("3")
TypeEndMOTD = []byte("4")
TypeWhoInfo = []byte("5")
TypeEndWhoInfo = []byte("6")
TypeLogin = []byte("7")
TypeLogout = []byte("8")
TypeMsg = []byte("9")
TypeMsgDelivered = []byte("10")
TypeMsgSaved = []byte("11")
TypeSay = []byte("12")
TypeShout = []byte("13")
TypeWhisper = []byte("14")
TypeKibitz = []byte("15")
TypeYouSay = []byte("16")
TypeYouShout = []byte("17")
TypeYouWhisper = []byte("18")
TypeYouKibitz = []byte("19")
TypeBoardState = []byte("board:")
)
var numberPrinter = message.NewPrinter(language.English)
type WhoInfo struct {
username string
opponent string
watching string
ready bool
away bool
rating int
experience int
idle int
loginTime int
clientName string
listItem *cview.ListItem
}
func (w *WhoInfo) String() string {
opponent := "in the lobby"
if w.opponent != "" && w.opponent != "-" {
opponent = "playing against " + w.opponent
}
clientName := ""
if w.clientName != "" && w.clientName != "-" {
clientName = " using " + w.clientName
}
return fmt.Sprintf("%s (rated %d with %d exp) is %s%s", w.username, w.rating, w.experience, opponent, clientName)
}
type Client struct {
in chan []byte
out chan []byte
username string
password string
loggedin bool
motd []byte
rawMode bool
who map[string]*WhoInfo
notified map[string]bool
}
func NewClient(username string, password string) *Client {
return &Client{
in: make(chan []byte, 100),
out: make(chan []byte, 100),
username: username,
password: password,
who: make(map[string]*WhoInfo),
notified: make(map[string]bool),
}
}
// CallTELNET is called when a connection is made with the server.
func (c *Client) CallTELNET(ctx telnet.Context, w telnet.Writer, r telnet.Reader) {
go func() {
var b = &bytes.Buffer{}
var buffer [1]byte // Seems like the length of the buffer needs to be small, otherwise will have to wait for buffer to fill up.
p := buffer[:]
var motd bool // Parse all messages as MOTD text until MsgEndMOTD
for {
// Read 1 byte.
n, err := r.Read(p)
if n <= 0 && nil == err {
continue
} else if n <= 0 && nil != err {
if err.Error() != "EOF" {
lf("** Disconnected: %s", err)
} else {
l("** Disconnected")
}
return
}
b.WriteByte(p[0])
if p[0] == '\n' {
buf := make([]byte, b.Len())
copy(buf, b.Bytes())
if debug > 0 {
l("<- " + string(bytes.TrimSpace(buf)))
}
if c.loggedin {
if !motd {
buf = bytes.TrimSpace(buf)
if len(buf) == 0 {
b.Reset()
continue
}
}
if c.rawMode {
c.in <- append([]byte("** "), buf...)
b.Reset()
continue
}
if bytes.HasPrefix(b.Bytes(), TypeMOTD) && !motd {
motd = true
c.motd = append(c.motd, buf[1:]...)
b.Reset()
continue
} else if bytes.HasPrefix(b.Bytes(), TypeEndMOTD) && motd {
motd = false
c.motd = bytes.TrimSpace(c.motd)
c.in <- append([]byte("3 "), c.motd...)
b.Reset()
continue
} else if motd {
c.motd = append(c.motd, buf...)
b.Reset()
continue
}
c.in <- buf
}
b.Reset()
}
if !c.loggedin {
bt := strings.TrimSpace(b.String())
if bt == "login:" {
b.Reset()
c.out <- []byte(fmt.Sprintf("login bgammon 1008 %s %s", c.username, c.password))
c.loggedin = true
go func() {
time.Sleep(time.Second)
app.QueueUpdateDraw(func() {
statusBuffer.ScrollToEnd()
})
}()
}
}
}
}()
var buffer bytes.Buffer
var p []byte
var crlfBuffer [2]byte = [2]byte{'\r', '\n'}
crlf := crlfBuffer[:]
help := []byte("help")
who := []byte("who")
quit := []byte("quit")
bye := []byte("bye")
watch := []byte("watch")
about := []byte("about")
show := []byte("show")
average := []byte("average")
dicetest := []byte("dicetest")
boardstate := []byte("boardstate")
stat := []byte("stat")
for b := range c.out {
if bytes.Equal(bytes.ToLower(b), watch) {
c.watchRandomGame()
continue
} else if bytes.Equal(bytes.ToLower(b), who) {
for username := range c.who {
lf("%s", c.who[username])
}
continue
} else if bytes.HasPrefix(bytes.ToLower(b), help) || bytes.HasPrefix(bytes.ToLower(b), about) || bytes.HasPrefix(bytes.ToLower(b), average) ||
bytes.HasPrefix(bytes.ToLower(b), dicetest) || bytes.HasPrefix(bytes.ToLower(b), show) || bytes.HasPrefix(bytes.ToLower(b), stat) {
c.rawMode = true
go func() {
time.Sleep(time.Second)
c.rawMode = false
}()
} else if bytes.Equal(bytes.ToLower(b), boardstate) {
lf("Board state: %s", board.GetState())
lf("Player color: %d", board.v[StatePlayerColor])
lf("Direction: %d", board.v[StateDirection])
lf("Current turn: %d", board.v[StateTurn])
lf("Moves: %+v", board.moves)
lf("Pre-moves: %+v", board.premove)
continue
} else if bytes.Equal(bytes.ToLower(b), quit) || bytes.Equal(bytes.ToLower(b), bye) {
// TODO match command, not exact string
c.rawMode = true
}
if debug > 0 {
l("-> " + string(bytes.TrimSpace(b)))
}
buffer.Write(b)
buffer.Write(crlf)
p = buffer.Bytes()
n, err := oi.LongWrite(w, p)
if nil != err {
break
}
if expected, actual := int64(len(p)), n; expected != actual {
log.Fatalf("Transmission problem: tried sending %d bytes, but actually only sent %d bytes.", expected, actual)
}
buffer.Reset()
}
}
func (c *Client) keepAlive() {
t := time.NewTicker(5 * time.Minute)
for range t.C {
c.out <- []byte("set boardstyle 3")
}
}
func (c *Client) updateWhoInfo(b []byte) {
s := bytes.Split(b, []byte(" "))
if len(s) != whoInfoSize {
return
}
var listItem *cview.ListItem
if existingInfo, ok := c.who[string(s[whoInfoDataName])]; ok {
listItem = existingInfo.listItem
}
info := &WhoInfo{
username: string(s[whoInfoDataName]),
listItem: listItem,
}
r := s[whoInfoDataRating]
if bytes.ContainsRune(r, '.') {
r = s[whoInfoDataRating][:bytes.IndexByte(s[whoInfoDataRating], '.')]
}
rating, err := strconv.Atoi(string(r))
if err != nil {
rating = 0
}
info.rating = rating
experience, err := strconv.Atoi(string(s[whoInfoDataExperience]))
if err != nil {
experience = 0
}
info.experience = experience
opponent := ""
if len(s[whoInfoDataOpponent]) > 1 || s[whoInfoDataOpponent][0] != '-' {
opponent = string(s[whoInfoDataOpponent])
}
info.opponent = opponent
watching := ""
if len(s[whoInfoDataWatching]) > 1 || s[whoInfoDataWatching][0] != '-' {
watching = string(s[whoInfoDataWatching])
}
info.watching = watching
ready := false
if string(s[whoInfoDataReady]) == "1" {
ready = true
}
info.ready = ready
clientName := ""
if len(s[whoInfoDataClientName]) > 1 || s[whoInfoDataClientName][0] != '-' {
clientName = string(s[whoInfoDataClientName])
}
info.clientName = clientName
status := "Unavailable"
if info.opponent != "" {
status = "vs. " + info.opponent
} else if info.ready {
status = "Available"
}
itemText := info.username + strings.Repeat(" ", 18-len(info.username))
ratingString := numberPrinter.Sprintf("%d", info.rating)
itemText += ratingString + strings.Repeat(" ", 8-len(ratingString))
experienceString := numberPrinter.Sprintf("%d", info.experience)
itemText += experienceString + strings.Repeat(" ", 12-len(experienceString))
itemText += status
if info.listItem != nil {
info.listItem.SetMainText(itemText)
} else {
info.listItem = cview.NewListItem(itemText)
}
c.who[string(s[whoInfoDataName])] = info
if notify && info.username != c.username && info.clientName == "bgammon" && !c.notified[info.username] {
lf("** %s is also playing via bgammon.", info.username)
c.notified[info.username] = true
}
}
func (c *Client) watchRandomGame() {
var options []string
for username, whoInfo := range c.who {
if username != "" && whoInfo.opponent != "" &&
!strings.Contains(username, "Bot") && !strings.Contains(whoInfo.opponent, "Bot") {
options = append(options, username, whoInfo.opponent)
}
}
if len(options) == 0 {
for username, whoInfo := range c.who {
if username != "" && whoInfo.opponent != "" {
options = append(options, username, whoInfo.opponent)
}
}
if len(options) == 0 {
return
}
}
option := options[rand.Intn(len(options))]
c.out <- []byte("watch " + option)
}
func (c *Client) connect() {
l(fmt.Sprintf("Connecting to %s...", serverAddress))
go c.keepAlive()
err := telnet.DialToAndCall(serverAddress, c)
if err != nil {
lf("** Disconnected: %s", err)
}
}
func (c *Client) eventLoop() {
var setBoardStyle bool
var turnRegexp = regexp.MustCompile(`^turn: (\w+)\.`)
var movesRegexp = regexp.MustCompile(`^(\w+) moves (.*)`)
var rollsRegexp = regexp.MustCompile(`^(\w+) rolls? (.*)`)
var logInOutRegexp = regexp.MustCompile(`^\w+ logs [in|out]\.`)
var dropsConnection = regexp.MustCompile(`^\w+ drops connection\.`)
var startMatchRegexp = regexp.MustCompile(`^\w+ and \w+ start a .*`)
var winsMatchRegexp = regexp.MustCompile(`^\w+ wins a [0-9]+ point match against .*`)
var newGameRegexp = regexp.MustCompile(`^Starting a new game with .*`)
var gameBufferRegexp = regexp.MustCompile(`^\w+ (makes|roll|rolls|rolled|move|moves) .*`)
var pleaseMoveRegexp = regexp.MustCompile(`^Please move ([0-9]) pieces?.`)
var cantMoveRegexp = regexp.MustCompile(`^(\w+) can't move.`)
var doublesRegexp = regexp.MustCompile(`^\w+ doubles.`)
var acceptDoubleRegexp = regexp.MustCompile(`^\w+ accepts the double.`)
for b := range c.in {
b = bytes.Replace(b, []byte{7}, []byte{}, -1)
b = bytes.TrimSpace(b)
// Select 10+ first to read prefixes correctly
if bytes.HasPrefix(b, TypeMsgSaved) {
lf("Message to %s saved", b[3:])
continue
} else if bytes.HasPrefix(b, TypeMsgDelivered) {
lf("Message to %s delivered", b[3:])
continue
} else if bytes.HasPrefix(b, TypeSay) {
s := bytes.SplitN(b[3:], []byte(" "), 2)
lf("%s says: %s", s[0], s[1])
continue
} else if bytes.HasPrefix(b, TypeShout) {
s := bytes.SplitN(b[3:], []byte(" "), 2)
lf("%s shouts: %s", s[0], s[1])
continue
} else if bytes.HasPrefix(b, TypeWhisper) {
s := bytes.SplitN(b[3:], []byte(" "), 2)
lf("%s whispers: %s", s[0], s[1])
continue
} else if bytes.HasPrefix(b, TypeKibitz) {
s := bytes.SplitN(b[3:], []byte(" "), 2)
lf("%s kibitzes: %s", s[0], s[1])
continue
} else if bytes.HasPrefix(b, TypeYouSay) {
s := bytes.SplitN(b[3:], []byte(" "), 2)
lf("You say to %s: %s", s[0], s[1])
continue
} else if bytes.HasPrefix(b, TypeYouShout) {
lf("You shout: %s", b[3:])
continue
} else if bytes.HasPrefix(b, TypeYouWhisper) {
lf("You whisper: %s", b[3:])
continue
} else if bytes.HasPrefix(b, TypeYouKibitz) {
lf("You kibitz: %s", b[3:])
continue
} else if bytes.HasPrefix(b, TypeWelcome) {
s := bytes.Split(b[2:], []byte(" "))
loginTimestamp, err := strconv.Atoi(string(s[1]))
if err != nil {
loginTimestamp = 0
}
loginTime := time.Unix(int64(loginTimestamp), 0)
lf("Welcome, %s! Last login at %s", s[0], loginTime)
continue
} else if bytes.HasPrefix(b, TypeOwnInfo) {
// TODO Print own info
continue
} else if bytes.HasPrefix(b, TypeMOTD) {
for _, line := range bytes.Split(c.motd, []byte("\n")) {
l(string(line))
}
if !setBoardStyle {
c.out <- []byte("set boardstyle 3")
setBoardStyle = true
}
continue
} else if bytes.HasPrefix(b, TypeWhoInfo) {
c.updateWhoInfo(b[2:])
// TODO who window
continue
} else if bytes.HasPrefix(b, TypeEndWhoInfo) {
if viewScreen == ScreenLobby {
app.Draw() // Show changes
}
continue
} else if bytes.HasPrefix(b, TypeLogin) || bytes.HasPrefix(b, TypeLogout) {
b = b[2:]
b = b[bytes.IndexByte(b, ' ')+1:]
//l(string(b))
// TODO enable showing log in and out messages
// TODO remove from who
continue
} else if bytes.HasPrefix(b, TypeMsg) {
lf("Received message: %s", b[3:])
continue
} else if bytes.HasPrefix(b, TypeBoardState) {
board.SetState(string(bytes.SplitN(b, []byte(" "), 2)[0][6:]))
continue
} else if turnRegexp.Match(b) {
turn := turnRegexp.FindSubmatch(b)
if string(turn[1]) == c.username || string(turn[1]) == board.s[0] || string(turn[1]) == "You" {
board.v[StateTurn] = board.v[StatePlayerColor]
} else {
board.v[StateTurn] = board.v[StatePlayerColor] * -1
}
} else if rollsRegexp.Match(b) {
roll := rollsRegexp.FindSubmatch(b)
periodIndex := bytes.IndexRune(roll[2], '.')
if periodIndex > -1 {
roll[2] = roll[2][:periodIndex]
}
s := bytes.Split(roll[2], []byte(" "))
var dice [2]int
var i int
for _, m := range s {
v, err := strconv.Atoi(string(m))
if err == nil {
dice[i] = v
i++
}
}
if string(roll[1]) == board.s[0] || string(roll[1]) == "You" {
board.v[StateTurn] = board.v[StatePlayerColor]
board.playerDice = dice
} else {
board.v[StateTurn] = board.v[StatePlayerColor] * -1
board.opponentDice = dice
}
board.Update()
} else if movesRegexp.Match(b) {
match := movesRegexp.FindSubmatch(b)
player := board.v[StatePlayerColor]
if string(match[1]) == board.s[1] {
player *= -1
}
board.ResetMoves()
s := bytes.Split(match[2], []byte(" "))
for _, m := range s {
move := bytes.Split(m, []byte("-"))
if len(move) == 2 {
board.Move(player, string(move[0]), string(move[1]))
}
}
board.SimplifyMoves()
board.Update()
bs := string(b)
if strings.HasSuffix(bs, " .") {
bs = bs[:len(bs)-2] + "."
}
lg(bs)
continue
} else if string(b) == "Value of 'boardstyle' set to 3." {
continue
} else if string(b) == "It's your turn to move." || strings.TrimSpace(string(b)) == "It's your turn to roll or double." || strings.TrimSpace(string(b)) == "It's your turn. Please roll or double" {
board.v[StateTurn] = board.v[StatePlayerColor]
board.Update()
if strings.TrimSpace(string(b)) == "It's your turn to roll or double." || strings.TrimSpace(string(b)) == "It's your turn. Please roll or double" {
c.out <- []byte("roll") // TODO Delay and allow configuring
}
} else if cantMoveRegexp.Match(b) {
match := cantMoveRegexp.FindSubmatch(b)
u := string(match[1])
if u == c.username || u == board.s[0] || u == "You" {
board.opponentDice[0] = 0
board.opponentDice[1] = 0
board.v[StateTurn] = board.v[StatePlayerColor] * -1
} else if u == board.s[1] {
board.playerDice[0] = 0
board.playerDice[1] = 0
board.v[StateTurn] = board.v[StatePlayerColor]
}
} else if pleaseMoveRegexp.Match(b) {
match := pleaseMoveRegexp.FindSubmatch(b)
n, err := strconv.Atoi(string(match[1]))
if err == nil {
board.v[StateMovablePieces] = n
}
} else if logInOutRegexp.Match(b) {
continue
} else if dropsConnection.Match(b) {
continue
} else if startMatchRegexp.Match(b) {
continue
} else if winsMatchRegexp.Match(b) {
continue
} else if newGameRegexp.Match(b) {
board.ResetMoves()
board.resetSelection()
// TODO reset premove
continue
}
if gameBufferRegexp.Match(b) || cantMoveRegexp.Match(b) ||
doublesRegexp.Match(b) || acceptDoubleRegexp.Match(b) ||
strings.HasPrefix(string(b), "You're now watching") || strings.HasPrefix(string(b), "** You stop watching") ||
strings.HasPrefix(string(b), "Bearing off:") || strings.HasPrefix(string(b), "The only possible move") {
lg(string(b))
if !strings.HasPrefix(string(b), "You're now watching") && !strings.HasPrefix(string(b), "** You stop watching") {
continue
}
}
l(string(b))
}
}