commit ad9ece3682a25a86aa68d7ae4823263a6ddcb821 Author: Trevor Slocum Date: Mon Aug 30 18:26:34 2021 -0700 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..485dee6 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.idea diff --git a/README.md b/README.md new file mode 100644 index 0000000..fbcd679 --- /dev/null +++ b/README.md @@ -0,0 +1,15 @@ +# fibs +[![GoDoc](https://code.rocketnine.space/tslocum/godoc-static/raw/branch/master/badge.svg)](https://docs.rocketnine.space/code.rocketnine.space/tslocum/fibs) +[![Donate](https://img.shields.io/liberapay/receives/rocketnine.space.svg?logo=liberapay)](https://liberapay.com/rocketnine.space) + +[FIBS](https://fibs.com) (online backgammon) client library + +**Currently in pre-alpha state. Here be dragons.** + +## Documentation + +Documentation is available via [godoc](https://docs.rocketnine.space/code.rocketnine.space/tslocum/fibs). + +## Support + +Please share issues and suggestions [here](https://code.rocketnine.space/tslocum/fibs/issues). diff --git a/board.go b/board.go new file mode 100644 index 0000000..a82bfeb --- /dev/null +++ b/board.go @@ -0,0 +1,758 @@ +package fibs + +import ( + "bytes" + "fmt" + "log" + "sort" + "strconv" + "strings" + "sync" +) + +// TODO Add PlayerName, etc + +const ( + BoxDrawingsLightVertical = '|' +) + +const ( + StateLength = iota + StatePlayerScore + StateOpponentScore + StateBoardSpace0 +) + +const ( + StateTurn = 29 + iota + StatePlayerDice1 + StatePlayerDice2 + StateOpponentDice1 + StateOpponentDice2 + StateDoublingValue + StatePlayerMayDouble + StateOpponentMayDouble + StateWasDoubled + StatePlayerColor + StateDirection + StateObsoleteHome + StateObsoleteBar + StatePlayerHome + StateOpponentHome + StatePlayerBar + StateOpponentBar + StateMovablePieces + StateObsoletePlayerForced + StateObsoleteOpponentForced + StateRedoubles +) + +const ( + SpaceUnknown = -1 +) + +const initialState = "FIBS:Welcome:5:0:2:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:-1:0:0:0:0:1:1:1:0:1:-1:0:25:0:0:0:0:4:0:0:0" + +var boardTopWhite = []byte("+13-14-15-16-17-18-+---+19-20-21-22-23-24-+") +var boardBottomWhite = []byte("+12-11-10--9--8--7-+---+-6--5--4--3--2--1-+") + +var boardTopBlack = []byte("+-1--2--3--4--5--6-+---+-7--8--9-10-11-12-+") +var boardBottomBlack = []byte("+24-23-22-21-20-19-+---+18-17-16-15-14-13-+") + +type Board struct { + client *Client + + state string + + s []string + v []int + + moves [][2]int + movesColor int + + validMoves map[int][]int + + from map[int]int + to map[int]int + + playerDice [2]int + opponentDice [2]int + + selected [2]int + + premove [][2]int + premovefrom map[int]int + premoveto map[int]int + + dragFromX int + dragFromY int + + sync.Mutex +} + +func NewBoard(client *Client) *Board { + b := &Board{ + client: client, + } + + b.ResetMoves() + b.ResetPreMoves() + + b.SetState(initialState) + + // TODO + /* + b.v[StatePlayerColor] = -1 + b.v[StateBoardSpace0+11] = 12 + b.v[StateBoardSpace0+9] = 7 + b.v[StateBoardSpace0+13] = -13 + b.v[StateBoardSpace0+24] = -3 + b.v[StatePlayerBar] = 3 + b.Update() + */ + + return b +} + +// TODO refactor +func (b *Board) GetIntState() []int { + return b.v +} + +func (b *Board) resetSelection() { + b.selected[0] = 0 + b.selected[1] = 0 +} + +func (b *Board) autoSendMoves() { + movable := 2 + if b.playerDice[0] > 0 && b.playerDice[0] == b.playerDice[1] { + movable = 4 + } + if b.v[StateMovablePieces] > 0 { + movable = b.v[StateMovablePieces] + } + if len(b.premove) == 1 { + abs := b.premove[0][1] - b.premove[0][0] + direction := 1 + if abs < 0 { + abs *= -1 + direction = -1 + } + if b.playerDice[0] == b.playerDice[1] { + for expandDoubles := 4; expandDoubles >= 2; expandDoubles-- { + if abs != b.playerDice[0]*expandDoubles { + continue + } + + from, _ := b.premove[0][0], b.premove[0][1] + + b.premove = nil + for i := 1; i <= expandDoubles; i++ { + b.premove = append(b.premove, [2]int{from + ((b.playerDice[0]*i - 1) * direction), from + ((b.playerDice[0] * i) * direction)}) + } + break + } + } + } + if len(b.premove) < movable { + return + } + + moveCommand := []byte("move") + for j := 0; j < 2; j++ { + for i := range b.premove { + var from string + if b.premove[i][0] == 0 || b.premove[i][0] == 25 { + from = "bar" + } else { + from = strconv.Itoa(b.premove[i][0]) + } + + if (j == 0) != (from == "bar") { + continue // Always send bar moves first + } + + var to string + if b.premove[i][1] == b.playerHomeSpace() { + to = "off" + } else { + to = strconv.Itoa(b.premove[i][1]) + } + + moveCommand = append(moveCommand, []byte(" "+from+"-"+to)...) + } + + } + + b.ResetPreMoves() + + b.client.Out <- moveCommand +} + +func (b *Board) GetState() string { + var s = strings.Join(b.s, ":") + for i := range b.v { + s += ":" + strconv.Itoa(b.v[i]) + } + return s +} + +func (b *Board) SetState(state string) { + b.Lock() + + lastTurn := 0 + if len(b.v) > 0 { + lastTurn = b.v[StateTurn] + } + + b.s = strings.Split(state, ":") + + b.v = make([]int, len(b.s)-2) + var err error + for i := 0; i < 46; i++ { + b.v[i], err = strconv.Atoi(b.s[i+2]) + if err != nil { + log.Fatal(err) + } + } + + if b.v[StateTurn] != lastTurn { + if lastTurn == b.v[StatePlayerColor] { + b.playerDice = [2]int{0, 0} + } else { + b.opponentDice = [2]int{0, 0} + } + } + + if b.v[StatePlayerDice1] > 0 { + b.playerDice = [2]int{b.v[StatePlayerDice1], b.v[StatePlayerDice2]} + } + if b.v[StateOpponentDice1] > 0 { + b.opponentDice = [2]int{b.v[StateOpponentDice1], b.v[StateOpponentDice2]} + } + + b.Unlock() + b.Draw() +} + +func (b *Board) Draw() { + // TODO +} + +func (b *Board) renderSpace(index int, spaceValue int) []byte { + var playerColor = "x" + var opponentColor = "o" + if b.v[StatePlayerColor] == 1 { + playerColor = "o" + opponentColor = "x" + } + + var pieceColor string + value := b.v[StateBoardSpace0+index] + if index == b.playerBarSpace() { + value = b.v[StatePlayerBar] + pieceColor = playerColor + } else if index == 25-b.playerBarSpace() { + value = b.v[StateOpponentBar] + pieceColor = opponentColor + } else { + if value < 0 { + pieceColor = "x" + } else if value > 0 { + pieceColor = "o" + } else { + pieceColor = playerColor + } + } + + abs := value + if value < 0 { + abs = value * -1 + } + + top := index <= 12 + if b.v[StatePlayerColor] == 1 { + top = !top + } + + firstDigit := 4 + secondDigit := 5 + if !top { + firstDigit = 5 + secondDigit = 4 + } + + var firstNumeral string + var secondNumeral string + if abs > 5 { + if abs > 9 { + firstNumeral = "1" + } else { + firstNumeral = strconv.Itoa(abs) + } + if abs > 9 { + secondNumeral = strconv.Itoa(abs - 10) + } + + if spaceValue == firstDigit && (!top || abs > 9) { + pieceColor = firstNumeral + } else if spaceValue == secondDigit && abs > 9 { + pieceColor = secondNumeral + } else if top && spaceValue == secondDigit { + pieceColor = firstNumeral + } + } + + // TODO + if abs > 5 { + abs = 5 + } + + var r []byte + foregroundColor := "#FFFFFF" + backgroundColor := "#000000" + if index != 0 && index != 25 { + if true { // default theme + if index%2 == 0 { + backgroundColor = "#303030" + } else { + backgroundColor = "#101010" + } + } else { // rainbow + foregroundColor = "#000000" + switch index % 6 { + case 1: + backgroundColor = "#FF0000" + case 2: + backgroundColor = "#FFA500" + case 3: + backgroundColor = "#FFFF00" + case 4: + backgroundColor = "#008000" + case 5: + backgroundColor = "#0000FF" + case 0: + backgroundColor = "#4B0082" + } + } + } + // Highlight legal moves + highlightSpace := b.validMove(b.selected[0], index) + highlightSpace = false // TODO Make configurable, disable by default + //+(b.playerDice[0]*b.v[StatePlayerColor]) ||b.selected[0] == index+(b.playerDice[1]*b.v[StatePlayerColor])) && b.selected[1] > 0 + if b.selected[1] > 0 && highlightSpace && index != 25 && index != 0 { + foregroundColor = "black" + backgroundColor = "yellow" + } + if abs > 0 && spaceValue <= abs { + r = []byte(pieceColor) + } else { + r = []byte(" ") + } + + rightArrowFrom := (b.v[StateDirection] == b.movesColor) == (index > 12) + if b.selected[0] == index && b.selected[1] > 0 && spaceValue <= abs && spaceValue > abs-b.selected[1] { + r = []byte("*") + } else if b.premovefrom[index] > 0 && spaceValue > (abs+b.premoveto[index])-b.premovefrom[index] && spaceValue <= abs+b.premoveto[index] { + if index == 25-b.playerBarSpace() { + r = []byte("▾") + } else if index == b.playerBarSpace() { + r = []byte("▴") + } else if rightArrowFrom { + r = []byte("▸") + } else { + r = []byte("◂") + } + foregroundColor = "yellow" + } else if b.premoveto[index] > 0 && spaceValue > abs && spaceValue <= abs+(b.premoveto[index]) { + r = []byte(playerColor) + foregroundColor = "yellow" + } else if b.from[index] > 0 && spaceValue > abs && spaceValue <= abs+b.from[index] { + if rightArrowFrom { + r = []byte("▸") + } else { + r = []byte("◂") + } + if b.movesColor == b.v[StatePlayerColor] { + foregroundColor = "yellow" + } else { + foregroundColor = "green" + } + } else if b.to[index] > 0 && spaceValue > abs-(b.to[index]+b.from[index]) { + if b.movesColor == b.v[StatePlayerColor] { + foregroundColor = "yellow" + } else { + foregroundColor = "green" + } + } + + return append(append([]byte(fmt.Sprintf("[\"space-%d\"][%s:%s:b] ", index, foregroundColor, backgroundColor)), r...), []byte(" [-:-:-][\"\"]")...) +} + +func (b *Board) ResetMoves() { + b.moves = nil + b.movesColor = 0 + b.validMoves = make(map[int][]int) + b.from = make(map[int]int) + b.to = make(map[int]int) +} + +func (b *Board) ResetPreMoves() { + b.premove = nil + b.premovefrom = make(map[int]int) + b.premoveto = make(map[int]int) +} + +func (b *Board) allPlayerPiecesInHomeBoard() bool { + homeBoardStart := 1 + homeBoardEnd := 6 + if b.v[StateDirection] == -1 { + homeBoardStart = 19 + homeBoardEnd = 24 + } + hasPlayerPiece := func(index int) bool { + if index < 0 || index > 25 { + return false + } + return (b.v[StatePlayerColor] == 1 && b.v[StateBoardSpace0+index] > 0) || + (b.v[StatePlayerColor] == -1 && b.v[StateBoardSpace0+index] < 0) + } + for i := 1; i < 24; i++ { + if i >= homeBoardStart && i <= homeBoardEnd { + continue + } + if hasPlayerPiece(i) { + return false + } + } + return true +} + +func (b *Board) spaceAvailable(index int) bool { + if index < 0 || index > 25 { + return false + } + if index == 0 || index == 25 { + return b.allPlayerPiecesInHomeBoard() + } + return (b.v[StatePlayerColor] == 1 && b.v[StateBoardSpace0+index] >= -1) || + (b.v[StatePlayerColor] == -1 && b.v[StateBoardSpace0+index] <= 1) +} + +func (b *Board) GetValidMoves(from int) []int { + if validMoves, ok := b.validMoves[from]; ok { + return validMoves + } + + var validMoves []int + defer func() { + b.validMoves[from] = validMoves + }() + + if b.v[StateTurn] != b.v[StatePlayerColor] || b.playerDice[0] == 0 || b.playerDice[1] == 0 { + return validMoves + } + + // TODO consider opponent blocking midway In full move + trySpaces := [][]int{ + {b.playerDice[0]}, + {b.playerDice[1]}, + {b.playerDice[0], b.playerDice[1]}, + {b.playerDice[1], b.playerDice[0]}, + } + if b.playerDice[0] == b.playerDice[1] { + trySpaces = append(trySpaces, + []int{b.playerDice[0], b.playerDice[0], b.playerDice[0]}, + []int{b.playerDice[0], b.playerDice[0], b.playerDice[0], b.playerDice[0]}) + } + + if b.allPlayerPiecesInHomeBoard() { + homeSpace := b.playerHomeSpace() + spacesHome := from - homeSpace + if spacesHome < 0 { + spacesHome *= -1 + } + if spacesHome <= b.playerDice[0] || spacesHome <= b.playerDice[1] { + trySpaces = append(trySpaces, []int{spacesHome}) + } + } + foundMoves := make(map[int]bool) +CHECKSPACES: + for i := range trySpaces { + checkSpace := 0 + for _, space := range trySpaces[i] { + checkSpace += space + if !b.spaceAvailable(from + (checkSpace * b.v[StateDirection])) { + continue CHECKSPACES + } + } + space := from + (checkSpace * b.v[StateDirection]) + if _, value := foundMoves[space]; !value { + foundMoves[space] = true + validMoves = append(validMoves, space) + } + } + + sort.Ints(validMoves) + + return validMoves +} + +func (b *Board) playerBarSpace() int { + return 25 - b.playerHomeSpace() +} + +func (b *Board) playerHomeSpace() int { + if b.v[StateDirection] == -1 { + return 0 + } + return 25 +} + +func (b *Board) validMove(f int, t int) bool { + if b.v[StateTurn] != b.v[StatePlayerColor] || b.playerDice[0] == 0 || b.playerDice[1] == 0 { + return false + } + + if t == b.playerHomeSpace() { + // TODO bear off logic, only allow high roll + return b.allPlayerPiecesInHomeBoard() + } + + validMoves := b.GetValidMoves(f) + for i := range validMoves { + if validMoves[i] == t { + return true + } + } + return false +} + +func (b *Board) parseMoveString(player int, s string) int { + space, err := strconv.Atoi(s) + if err != nil { + space = SpaceUnknown + if s == "bar" { + barSpace := b.playerBarSpace() + if b.v[StatePlayerColor] == player { + space = barSpace + } else { + space = 25 - barSpace + } + } else if s == "off" { + space = b.playerHomeSpace() + } + } + return space +} + +func (b *Board) Move(player int, f string, t string) { + from := b.parseMoveString(player, f) + to := b.parseMoveString(player, t) + + if from == SpaceUnknown || to == SpaceUnknown { + // TODO debug print ("error: failed to parse move: player %d, from %s, to %s", player, f, t) + return + } + + b.moves = append(b.moves, [2]int{from, to}) + b.movesColor = player + + b.from[from]++ + b.to[to]++ + + b.v[StateTurn] = player * -1 + + b.validMoves = make(map[int][]int) + b.ResetPreMoves() +} + +func (b *Board) SimplifyMoves() { + for i := range b.moves { + for j := range b.moves { + if b.moves[i][1] == b.moves[j][0] { + // Same to space as from space + b.moves[j][0] = b.moves[i][0] // Set from space to earlier from space + b.moves = append(b.moves[:i], b.moves[i+1:]...) + b.SimplifyMoves() + return + } else if b.moves[i][0] == b.moves[j][1] { + // Same to space as from space + b.moves[j][1] = b.moves[i][1] // Set to space to earlier to space + b.moves = append(b.moves[:i], b.moves[i+1:]...) + b.SimplifyMoves() + return + } + } + } +} + +func (b *Board) addPreMove(from int, to int) bool { + if !b.validMove(from, to) { + return false + } + b.premove = append(b.premove, [2]int{from, to}) + b.premovefrom[from]++ + b.premoveto[to]++ + return true +} + +func (b *Board) Render() []byte { + b.Lock() + + var white bool + if b.v[StatePlayerColor] == 1 { + white = true + } + + var opponentName = b.s[1] + var playerName = b.s[0] + + var playerColor = "x" + var opponentColor = "o" + if white { + playerColor = "o" + opponentColor = "x" + } + + var t bytes.Buffer + t.WriteString("[\"space-off\"] [\"\"]\n") + t.WriteString("[\"space-off\"] [\"\"]\n") + t.WriteString("[\"space-off\"] ") + if white { + t.Write(boardTopWhite) + } else { + t.Write(boardTopBlack) + } + t.WriteString("[\"\"] ") + t.WriteByte('\n') + + space := func(i int, j int) []byte { + spaceValue := i + 1 + if i > 5 { + spaceValue = 5 - (i - 6) + } + + if j == -1 { + if i <= 4 { + return b.renderSpace(25-b.playerBarSpace(), spaceValue) + } + return b.renderSpace(b.playerBarSpace(), spaceValue) + } + + var index int + if !white { + if i < 6 { + j = 12 - j + } else { + j = 11 - j + } + + index = 12 + j + if i > 5 { + index = 12 - j + } + } else { + index = 12 + j + if i > 5 { + index = 11 - j + } + } + if !white { + index = 24 - index + } + index++ // increment to get actual space number (0 is bar) + + if i == 5 { + return []byte("[-:#000000] [-:-]") + } + + return b.renderSpace(index, spaceValue) + } + + for i := 0; i < 11; i++ { + t.Write([]byte("[\"space-off\"]")) + + if i == 5 && b.v[StateDoublingValue] > 1 { + t.WriteString(fmt.Sprintf("%2d ", b.v[StateDoublingValue])) + if b.v[StatePlayerMayDouble] == 1 { + t.WriteByte('v') + } else { + t.WriteByte('^') + } + } else { + t.WriteByte(' ') + t.WriteByte(' ') + t.WriteByte(' ') + t.WriteByte(' ') + } + + t.WriteRune(BoxDrawingsLightVertical) + t.Write([]byte("[\"\"]")) + for j := 0; j < 12; j++ { + t.Write(space(i, j)) + + if j == 5 { + t.WriteRune(BoxDrawingsLightVertical) + t.Write(space(i, -1)) + t.WriteRune(BoxDrawingsLightVertical) + } + } + + t.Write([]byte("[\"space-off\"]" + string(BoxDrawingsLightVertical) + " ")) + + playerRollColor := "yellow" + playerBold := "b" + opponentRollColor := "white" + opponentBold := "" + if b.v[StateTurn] != b.v[StatePlayerColor] { + playerRollColor = "white" + opponentRollColor = "green" + playerBold = "" + opponentBold = "b" + } + + if i == 0 { + t.Write([]byte("[" + opponentRollColor + "::" + opponentBold + "]" + opponentColor + " " + opponentName + " (" + b.s[4] + ")")) + if b.v[StateOpponentHome] > 0 { + t.Write([]byte(fmt.Sprintf(" %d off", b.v[StateOpponentHome]))) + } + t.Write([]byte("[-::-]")) + } else if i == 2 { + if b.opponentDice[0] > 0 { + t.Write([]byte(fmt.Sprintf(" [%s::%s]%d %d[-::-] ", opponentRollColor, opponentBold, b.opponentDice[0], b.opponentDice[1]))) + } else { + t.Write([]byte(fmt.Sprintf(" [%s]- -[-] ", opponentRollColor))) + } + } else if i == 8 { + if b.playerDice[0] > 0 { + t.Write([]byte(fmt.Sprintf(" [%s::%s]%d %d[-::-] ", playerRollColor, playerBold, b.playerDice[0], b.playerDice[1]))) + } else { + t.Write([]byte(fmt.Sprintf(" [%s]- -[-] ", playerRollColor))) + } + } else if i == 10 { + t.Write([]byte("[" + playerRollColor + "::" + playerBold + "]" + playerColor + " " + playerName + " (" + b.s[3] + ")")) + if b.v[StatePlayerHome] > 0 { + t.Write([]byte(fmt.Sprintf(" %d off", b.v[StatePlayerHome]))) + } + t.Write([]byte("[-::-]")) + } + + t.Write([]byte("[\"\"] ")) + t.WriteByte('\n') + } + + t.WriteString("[\"space-off\"] ") + if white { + t.Write(boardBottomWhite) + } else { + t.Write(boardBottomBlack) + } + t.WriteString(" [\"\"]\n") + t.WriteString("[\"space-off\"] [\"\"]") + + b.Unlock() + + return t.Bytes() +} diff --git a/board_test.go b/board_test.go new file mode 100644 index 0000000..d48a630 --- /dev/null +++ b/board_test.go @@ -0,0 +1,62 @@ +package fibs + +import ( + "fmt" + "testing" +) + +func TestBoard_GetValidMoves(t *testing.T) { + client := NewClient("", "", "") + + b := NewBoard(client) + + b.v[StatePlayerColor] = 1 + b.v[StateTurn] = 1 + b.v[StateDirection] = -1 + b.v[StateBoardSpace0+11] = 3 + b.v[StateBoardSpace0+9] = 7 + b.v[StateBoardSpace0+13] = -4 + b.v[StateBoardSpace0+24] = -3 + b.v[StatePlayerBar] = 1 + + testCases := []struct { + roll [2]int + from int + moves []int + }{ + { + roll: [2]int{1, 5}, + from: 25, + moves: []int{19, 20}, + }, + { + roll: [2]int{1, 5}, + from: 1, + moves: []int{}, + }, + } + for _, c := range testCases { + c := c + t.Run(fmt.Sprintf("With-%d-%d-From-%s", c.roll[0], c.roll[1], fmt.Sprintf("%02d", c.from)), func(t *testing.T) { + b.playerDice = c.roll + b.Draw() + + validMoves := b.GetValidMoves(c.from) + if !equalInts(validMoves, c.moves) { + t.Errorf("unexpected valid moves: expected %+v, got %+v\n%s", c.moves, validMoves, b.Render()) + } + }) + } +} + +func equalInts(a, b []int) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/client.go b/client.go new file mode 100644 index 0000000..1b7c47b --- /dev/null +++ b/client.go @@ -0,0 +1,643 @@ +package fibs + +import ( + "bytes" + "fmt" + "log" + "math/rand" + "regexp" + "strconv" + "strings" + "time" + + "github.com/reiver/go-oi" + "github.com/reiver/go-telnet" + "golang.org/x/text/language" + "golang.org/x/text/message" +) + +const debug = 1 // TODO + +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 +} + +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 + Event chan interface{} + + Username string + Password string + + loggedin bool + motd []byte + rawMode bool + + who map[string]*WhoInfo + + notified map[string]bool + + serverAddress string + + Board *Board +} + +func NewClient(serverAddress string, username string, password string) *Client { + c := &Client{ + In: make(chan []byte, 100), + Out: make(chan []byte, 100), + Event: make(chan interface{}, 100), + + serverAddress: serverAddress, + + Username: username, + Password: password, + + who: make(map[string]*WhoInfo), + + notified: make(map[string]bool), + } + + c.Board = NewBoard(c) + + go c.eventLoop() + + return c +} + +// 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 + } + } + } + }() + + 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", c.Board.GetState()) + lf("Player color: %d", c.Board.v[StatePlayerColor]) + lf("Direction: %d", c.Board.v[StateDirection]) + lf("Current turn: %d", c.Board.v[StateTurn]) + lf("Moves: %+v", c.Board.moves) + lf("Pre-moves: %+v", c.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 + } + + info := &WhoInfo{ + Username: string(s[whoInfoDataName]), + } + + 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 + + c.who[string(s[whoInfoDataName])] = info + + // TODO who info event +} + +func (c *Client) GetAllWhoInfo() []*WhoInfo { + w := make([]*WhoInfo, len(c.who)) + var i int + for _, whoInfo := range c.who { + w[i] = whoInfo + i++ + } + return w +} + +func (c *Client) LoggedIn() bool { + // TODO lock + return c.loggedin +} + +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() error { + l(fmt.Sprintf("Connecting to %s...", c.serverAddress)) + + go c.keepAlive() + + err := telnet.DialToAndCall(c.serverAddress, c) + if err != nil { + lf("** Disconnected: %s", err) + } + return 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) + bl := bytes.ToLower(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) { + // TODO draw who info event + 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) { + c.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]) == c.Board.s[0] || string(turn[1]) == "You" { + c.Board.v[StateTurn] = c.Board.v[StatePlayerColor] + } else { + c.Board.v[StateTurn] = c.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]) == c.Board.s[0] || string(roll[1]) == "You" { + c.Board.v[StateTurn] = c.Board.v[StatePlayerColor] + c.Board.playerDice = dice + } else { + c.Board.v[StateTurn] = c.Board.v[StatePlayerColor] * -1 + c.Board.opponentDice = dice + } + + c.Board.ResetMoves() + + c.Board.Draw() + } else if movesRegexp.Match(b) { + match := movesRegexp.FindSubmatch(b) + + player := c.Board.v[StatePlayerColor] + if string(match[1]) == c.Board.s[1] { + player *= -1 + } + + c.Board.ResetMoves() + + from := -1 + to := -1 + + s := bytes.Split(match[2], []byte(" ")) + for _, m := range s { + move := bytes.Split(m, []byte("-")) + if len(move) == 2 { + from = c.Board.parseMoveString(player, string(move[0])) + to = c.Board.parseMoveString(player, string(move[1])) + if from >= 0 && to >= 0 { + c.Event <- &EventMove{ + Player: player, + From: from, + To: to, + } + } + + c.Board.Move(player, string(move[0]), string(move[1])) + } + } + + c.Board.SimplifyMoves() + c.Board.Draw() + + 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" { + c.Board.v[StateTurn] = c.Board.v[StatePlayerColor] + c.Board.Draw() + 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 == c.Board.s[0] || u == "You" { + c.Board.opponentDice[0] = 0 + c.Board.opponentDice[1] = 0 + c.Board.v[StateTurn] = c.Board.v[StatePlayerColor] * -1 + } else if u == c.Board.s[1] { + c.Board.playerDice[0] = 0 + c.Board.playerDice[1] = 0 + c.Board.v[StateTurn] = c.Board.v[StatePlayerColor] + } + } else if pleaseMoveRegexp.Match(b) { + match := pleaseMoveRegexp.FindSubmatch(b) + + n, err := strconv.Atoi(string(match[1])) + if err == nil { + c.Board.v[StateMovablePieces] = n + } + } else if bytes.HasPrefix(bl, []byte("you're now watching")) { + // Board state is not sent automatically when watching + log.Println("WRITE BOARDD") + c.Out <- []byte("board") + } 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) { + c.Board.ResetMoves() + c.Board.resetSelection() + // TODO reset premove + continue + } + + if gameBufferRegexp.Match(b) || cantMoveRegexp.Match(b) || + doublesRegexp.Match(b) || acceptDoubleRegexp.Match(b) || + bytes.HasPrefix(bl, []byte("you're now watching")) || bytes.HasPrefix(bl, []byte("** you stop watching")) || + strings.HasPrefix(string(b), "Bearing off:") || strings.HasPrefix(string(b), "The only possible move") { + lg(string(b)) + + if !bytes.HasPrefix(bl, []byte("you're now watching")) && !bytes.HasPrefix(bl, []byte("** you stop watching")) { + continue + } + } + + l(string(b)) + } +} diff --git a/doc.go b/doc.go new file mode 100644 index 0000000..1628a73 --- /dev/null +++ b/doc.go @@ -0,0 +1,4 @@ +/* +Package fibs provides a FIBS.com (online backgammon) client library. +*/ +package fibs diff --git a/event.go b/event.go new file mode 100644 index 0000000..a34224e --- /dev/null +++ b/event.go @@ -0,0 +1,14 @@ +package fibs + +type EventGame struct { +} + +type EventMove struct { + Player int + From int + To int +} + +type EventMessage struct { + Message string +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..05612ae --- /dev/null +++ b/go.mod @@ -0,0 +1,9 @@ +module code.rocketnine.space/tslocum/fibs + +go 1.17 + +require ( + github.com/reiver/go-oi v1.0.0 + github.com/reiver/go-telnet v0.0.0-20180421082511-9ff0b2ab096e + golang.org/x/text v0.3.7 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..7f41126 --- /dev/null +++ b/go.sum @@ -0,0 +1,7 @@ +github.com/reiver/go-oi v1.0.0 h1:nvECWD7LF+vOs8leNGV/ww+F2iZKf3EYjYZ527turzM= +github.com/reiver/go-oi v1.0.0/go.mod h1:RrDBct90BAhoDTxB1fenZwfykqeGvhI6LsNfStJoEkI= +github.com/reiver/go-telnet v0.0.0-20180421082511-9ff0b2ab096e h1:quuzZLi72kkJjl+f5AQ93FMcadG19WkS7MO6TXFOSas= +github.com/reiver/go-telnet v0.0.0-20180421082511-9ff0b2ab096e/go.mod h1:+5vNVvEWwEIx86DB9Ke/+a5wBI464eDRo3eF0LcfpWg= +golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/util.go b/util.go new file mode 100644 index 0000000..a86a14c --- /dev/null +++ b/util.go @@ -0,0 +1,48 @@ +package fibs + +import ( + "fmt" + "io" + "log" + "time" +) + +var ( + StatusWriter io.Writer + GameWriter io.Writer + + statusLogged bool + gameLogged bool +) + +func l(s string) { + m := time.Now().Format("15:04") + "| " + s + if StatusWriter != nil { + if statusLogged { + StatusWriter.Write([]byte("\n" + m)) + return + } + StatusWriter.Write([]byte(m)) + statusLogged = true + return + } + log.Print(m) +} + +func lf(format string, a ...interface{}) { + l(fmt.Sprintf(format, a...)) +} + +func lg(s string) { + m := time.Now().Format("15:04") + "| " + s + if GameWriter != nil { + if gameLogged { + GameWriter.Write([]byte("\n" + m)) + return + } + GameWriter.Write([]byte(m)) + gameLogged = true + return + } + log.Print(m) +}