Add game logic

This commit is contained in:
Trevor Slocum 2023-08-22 17:05:35 -07:00
parent cfbdd632a9
commit 42b74236e6
10 changed files with 686 additions and 57 deletions

View file

@ -10,6 +10,12 @@ Log in to bgammon. A random username is assigned when none is provided.
This must be the first command sent when a client connects to bgammon.
### `json [on/off]`
Turn JSON formatted messages on or off. JSON messages are not sent by default.
Graphical clients should send the `json on` command immediately after sending `login`.
### `help [command]`
Request help for all commands, or optionally a specific command.
@ -22,7 +28,7 @@ List all games.
List all games.
### `join [ID] [password]`
### `join [id] [password]`
Join game.
@ -42,6 +48,18 @@ Reset pending checker movement.
Confirm checker movement and pass turn to next player.
### `say <message>`
Send a chat message.
This command can only be used after creating or joining a game.
### `board`
Print current game state in human-readable form.
This command is not normally used, as the game state is provided in JSON format.
### `disconnect`
Disconnect from the server.
@ -73,10 +91,58 @@ Server message. This should always be displayed to the user.
Start of games list.
### `game <ID:integer> <password:boolean> <players:integer> <name:line>`
### `game <id:integer> <password:boolean> <players:integer> <name:line>`
Game description.
### `listend End of games list.`
End of games list.
End of games list.
### `joined <id:integer> <player:text> <name:line>`
Sent after successfully creating or joining a game, and when another player
joins a game you are in.
The server will always send a `board` response immediately after `joined` to
provide clients with the initial game state.
### `failedjoin <message:line>`
Sent after failing to join a game.
### `parted <gameID:integer> <gameID:integer>`
Sent after leaving a game.
### `json <message:line>`
Server confirmation of client requested JSON formatting.
This message does not normally need to be displayed when using a graphical client.
### `board <json:line>`
Game state in JSON format.
This message is only sent to clients that have explicitly enabled JSON formatted messages.
```
type Player struct {
Number int // 1 black, 2 white
Name string
}
type Game struct {
Board []int
Player1 Player
Player2 Player
Turn int
Roll1 int
Roll2 int
}
```
### `say <player:text> <message:line>`
Chat message from another player.

View file

@ -4,6 +4,9 @@ package bgammon
// all state sent to white, and input received from white is reversed
// handle this transparently by translating at the message level rather than each time spaces are used
// HomePlayer is the real Player1 home, HomeOpponent is the real Player2 home
// HomeBoardPlayer (Player1) ranges 1-6, HomeBoardOpponent (Player2) ranges 24-19 (visible to them as 1-6)
// 1-24 for 24 spaces, 2 spaces for bar, 2 spaces for home
const (
SpaceHomePlayer = 0
@ -12,14 +15,20 @@ const (
SpaceHomeOpponent = 27
)
const numBoardSpaces = 28
const BoardSpaces = 28
type Board struct {
Space []int // Positive values represent player 1 (black), negative values represent player 2 (white).
func NewBoard() []int {
space := make([]int, BoardSpaces)
space[24], space[1] = 2, -2
space[19], space[6] = -5, 5
space[17], space[8] = -3, 3
space[13], space[12] = 5, -5
return space
}
func NewBoard() *Board {
return &Board{
Space: make([]int, numBoardSpaces),
func HomeRange(player int) (from int, to int) {
if player == 2 {
return 24, 19
}
return 1, 6
}

View file

@ -4,6 +4,7 @@ import "code.rocket9labs.com/tslocum/bgammon"
type serverClient struct {
id int
json bool
name []byte
account int
connected int64

View file

@ -31,11 +31,11 @@ func newSocketClient(conn net.Conn, commands chan<- []byte, events <-chan []byte
func (c *socketClient) readCommands() {
var scanner = bufio.NewScanner(c.conn)
for scanner.Scan() {
log.Printf("READ COMMAND %s", scanner.Text())
buf := make([]byte, len(scanner.Bytes()))
copy(buf, scanner.Bytes())
c.commands <- buf
log.Printf("<- %s", scanner.Bytes())
}
}
@ -44,7 +44,8 @@ func (c *socketClient) writeEvents() {
for event = range c.events {
c.conn.Write(event)
c.conn.Write([]byte("\n"))
log.Printf("write event %s", event)
log.Printf("-> %s", event)
}
}

View file

@ -1,8 +1,15 @@
package main
import (
"code.rocket9labs.com/tslocum/bgammon"
"bufio"
"bytes"
"encoding/json"
"fmt"
"log"
"math/rand"
"time"
"code.rocket9labs.com/tslocum/bgammon"
)
type serverGame struct {
@ -11,8 +18,9 @@ type serverGame struct {
lastActive int64
name []byte
password []byte
client1 bgammon.Client
client2 bgammon.Client
client1 *serverClient
client2 *serverClient
r *rand.Rand
*bgammon.Game
}
@ -23,5 +31,100 @@ func newServerGame(id int) *serverGame {
created: now,
lastActive: now,
Game: bgammon.NewGame(),
r: rand.New(rand.NewSource(time.Now().UnixNano() + rand.Int63n(1000000))),
}
}
func (g *serverGame) roll(player int) bool {
if g.Turn == 0 {
if player == 1 {
if g.Roll1 != 0 {
return false
}
g.Roll1 = g.r.Intn(6) + 1
return true
} else {
if g.Roll2 != 0 {
return false
}
g.Roll2 = g.r.Intn(6) + 1
return true
}
} else if player != g.Turn || g.Roll1 != 0 || g.Roll2 != 0 {
return false
}
g.Roll1, g.Roll2 = g.r.Intn(6)+1, g.r.Intn(6)+1
return true
}
func (g *serverGame) sendBoard(client *serverClient) {
if client.json {
buf, err := json.Marshal(g.Game)
if err != nil {
log.Fatalf("failed to marshal json for %+v: %s", g.Game, err)
}
client.events <- []byte(fmt.Sprintf("board %s", buf))
return
}
playerNumber := 1
if g.client2 == client {
playerNumber = 2
}
scanner := bufio.NewScanner(bytes.NewReader(g.BoardState(playerNumber)))
for scanner.Scan() {
client.events <- append([]byte("notice "), scanner.Bytes()...)
}
}
func (g *serverGame) eachClient(f func(client *serverClient)) {
if g.client1 != nil {
f(g.client1)
}
if g.client2 != nil {
f(g.client2)
}
}
func (g *serverGame) addClient(client *serverClient) bool {
var ok bool
defer func() {
if !ok {
return
}
joinMessage := []byte(fmt.Sprintf("joined %d %s %s", g.id, client.name, g.name))
client.events <- joinMessage
opponent := g.opponent(client)
if opponent != nil {
opponent.events <- joinMessage
}
}()
switch {
case g.client1 != nil && g.client2 != nil:
ok = false
case g.client1 != nil:
g.client2 = client
ok = true
case g.client2 != nil:
g.client1 = client
ok = true
default:
i := rand.Intn(2)
if i == 0 {
g.client1 = client
} else {
g.client2 = client
}
ok = true
}
return ok
}
func (g *serverGame) opponent(client *serverClient) *serverClient {
if g.client1 == client {
return g.client2
} else if g.client2 == client {
return g.client1
}
return nil
}

View file

@ -1,7 +1,10 @@
package main
import (
"bufio"
"bytes"
"log"
"time"
"code.rocket9labs.com/tslocum/bgammon"
)
@ -13,5 +16,23 @@ func main() {
s := newServer()
go s.listen("tcp", "127.0.0.1:1337")
g := newServerGame(1)
g.Board[bgammon.SpaceBarPlayer] = 3
g.Board[bgammon.SpaceBarOpponent] = -2
g.Roll1 = 3
g.Roll2 = 2
g.Turn = 1
log.Printf("%+v", g.LegalMoves())
playerNumber := 1
go func() {
time.Sleep(100 * time.Millisecond)
scanner := bufio.NewScanner(bytes.NewReader(g.BoardState(playerNumber)))
for scanner.Scan() {
log.Printf("%s", append([]byte("notice "), scanner.Bytes()...))
}
}()
select {}
}

View file

@ -1,6 +1,7 @@
package main
import (
"bufio"
"bytes"
"fmt"
"log"
@ -166,7 +167,7 @@ COMMANDS:
// Require users to send login command first.
if cmd.client.account == -1 {
if keyword == bgammon.CommandLogin {
if keyword == bgammon.CommandLogin || keyword == "l" {
var username []byte
var password []byte
switch len(params) {
@ -197,16 +198,46 @@ COMMANDS:
}
switch keyword {
case bgammon.CommandHelp:
case bgammon.CommandHelp, "h":
// TODO get extended help by specifying a command after help
cmd.client.events <- []byte("helpstart Help text:")
cmd.client.events <- []byte("help Test help text")
cmd.client.events <- []byte("helpend End of help text.")
case bgammon.CommandSay:
case bgammon.CommandJSON:
sendUsage := func() {
cmd.client.events <- []byte("notice To enable JSON formatted messages, send 'json on'. To disable JSON formatted messages, send 'json off'.")
}
if len(params) != 1 {
sendUsage()
continue
}
paramLower := strings.ToLower(string(params[0]))
switch paramLower {
case "on":
cmd.client.json = true
cmd.client.events <- []byte("json JSON formatted messages enabled.")
case "off":
cmd.client.json = false
cmd.client.events <- []byte("json JSON formatted messages disabled.")
default:
sendUsage()
}
case bgammon.CommandSay, "s":
if len(params) == 0 {
continue
}
case bgammon.CommandList:
g := s.gameByClient(cmd.client)
if g == nil {
cmd.client.events <- []byte("notice Message not sent. You are not currently in a game.")
continue
}
opponent := g.opponent(cmd.client)
if opponent == nil {
cmd.client.events <- []byte("notice Message not sent. There is no one else in the game.")
continue
}
opponent.events <- []byte(fmt.Sprintf("say %s %s", cmd.client.name, bytes.Join(params, []byte(" "))))
case bgammon.CommandList, "ls":
cmd.client.events <- []byte("liststart Games list:")
players := 0
password := 0
@ -215,7 +246,7 @@ COMMANDS:
cmd.client.events <- []byte(fmt.Sprintf("game %d %d %d %s", g.id, password, players, name))
}
cmd.client.events <- []byte("listend End of games list.")
case bgammon.CommandCreate:
case bgammon.CommandCreate, "c":
sendUsage := func() {
cmd.client.events <- []byte("notice To create a public game specify whether it is public or private. When creating a private game, a password must also be provided.")
}
@ -246,24 +277,13 @@ COMMANDS:
g := newServerGame(<-s.newGameIDs)
g.name = gameName
g.password = gamePassword
if rand.Intn(2) == 0 { // Start as black.
g.client1 = cmd.client
g.Player1.Name = string(cmd.client.name)
} else { // Start as white.
g.client2 = cmd.client
g.Player2.Name = string(cmd.client.name)
if !g.addClient(cmd.client) {
log.Panicf("failed to add client to newly created game %+v %+v", g, cmd.client)
}
s.games = append(s.games, g) // TODO lock
s.games = append(s.games, g)
players := 1
password := 0
if len(gamePassword) != 0 {
password = 1
}
cmd.client.events <- []byte(fmt.Sprintf("joined %d %d %d %s", g.id, password, players, gameName))
case bgammon.CommandJoin:
g.sendBoard(cmd.client)
case bgammon.CommandJoin, "j":
if s.gameByClient(cmd.client) != nil {
cmd.client.events <- []byte("failedjoin Please leave the game you are in before joining another game.")
continue
@ -277,7 +297,7 @@ COMMANDS:
sendUsage()
continue
}
gameID, err := strconv.Atoi(string(params[1]))
gameID, err := strconv.Atoi(string(params[0]))
if err != nil || gameID < 1 {
sendUsage()
continue
@ -290,18 +310,63 @@ COMMANDS:
continue COMMANDS
}
log.Printf("join existing game %+v", g)
// cmd.client.events <- []byte(fmt.Sprintf("joined %d %d %d %s", g.id, players, password, gameName))
if !g.addClient(cmd.client) {
cmd.client.events <- []byte("failedjoin Game is full.")
continue COMMANDS
}
g.sendBoard(cmd.client)
continue COMMANDS
}
}
case bgammon.CommandLeave:
case bgammon.CommandRoll:
case bgammon.CommandMove:
case bgammon.CommandLeave, "l":
g := s.gameByClient(cmd.client)
if g == nil {
cmd.client.events <- []byte("failedleave You are not currently in a game.")
continue
}
id := g.id
// TODO remove
cmd.client.events <- []byte(fmt.Sprintf("left %d", id))
case bgammon.CommandRoll, "r":
g := s.gameByClient(cmd.client)
if g == nil {
cmd.client.events <- []byte("notice You are not currently in a game.")
continue COMMANDS
}
playerNumber := 1
if g.client2 == cmd.client {
playerNumber = 2
}
if !g.roll(playerNumber) {
cmd.client.events <- []byte("notice It is not your turn to roll.")
} else {
g.eachClient(func(client *serverClient) {
client.events <- []byte(fmt.Sprintf("rolled %d %d", g.Roll1, g.Roll2))
})
}
case bgammon.CommandMove, "m":
case bgammon.CommandBoard, "b":
g := s.gameByClient(cmd.client)
if g == nil {
cmd.client.events <- []byte("notice You are not currently in a game.")
} else {
playerNumber := 1
if g.client2 == cmd.client {
playerNumber = 2
}
scanner := bufio.NewScanner(bytes.NewReader(g.BoardState(playerNumber)))
for scanner.Scan() {
cmd.client.events <- append([]byte("notice "), scanner.Bytes()...)
}
}
case bgammon.CommandDisconnect:
g := s.gameByClient(cmd.client)
if g != nil {
// todo leave game
// todo remove client from game
}
cmd.client.Terminate("Client disconnected")
default:

View file

@ -7,6 +7,7 @@ type Command string
const (
CommandLogin = "login" // Log in with username and password, or as a guest.
CommandHelp = "help" // Print help information.
CommandJSON = "json" // Enable or disable JSON formatted messages.
CommandSay = "say" // Send chat message.
CommandList = "list" // List available games.
CommandCreate = "create" // Create game.
@ -16,5 +17,6 @@ const (
CommandMove = "move" // Move checkers.
CommandReset = "reset" // Reset checker movement.
CommandOk = "ok" // Confirm checker movement and pass turn to next player.
CommandBoard = "board" // Print current board state in human-readable form.
CommandDisconnect = "disconnect" // Disconnect from server.
)

385
game.go
View file

@ -1,11 +1,21 @@
package bgammon
import "math/rand"
import (
"bytes"
"fmt"
"strconv"
)
var boardTopBlack = []byte("+13-14-15-16-17-18-+---+19-20-21-22-23-24-+")
var boardBottomBlack = []byte("+12-11-10--9--8--7-+---+-6--5--4--3--2--1-+")
var boardTopWhite = []byte("+24-23-22-21-20-19-+---+18-17-16-15-14-13-+")
var boardBottomWhite = []byte("+-1--2--3--4--5--6-+---+-7--8--9-10-11-12-+")
type Game struct {
Board *Board
Player1 *Player
Player2 *Player
Board []int
Player1 Player
Player2 Player
Turn int
Roll1 int
Roll2 int
@ -19,16 +29,367 @@ func NewGame() *Game {
}
}
func (g *Game) roll(r rand.Rand, player int) {
if player != g.Turn || g.Roll1 != 0 || g.Roll2 != 0 {
return
func (g *Game) turnPlayer() Player {
switch g.Turn {
case 2:
return g.Player2
default:
return g.Player1
}
g.Roll1, g.Roll2 = r.Intn(6)+1, r.Intn(6)+1
}
func (g *Game) LegalMoves() []int {
// todo get current player based on turn and enumerate spaces and roll to get available moves
// sent to clients and used to validate moves
var moves []int
func (g *Game) opponentPlayer() Player {
switch g.Turn {
case 2:
return g.Player1
default:
return g.Player2
}
}
func (g *Game) iterateSpaces(from int, to int, f func(space int, spaceCount int)) {
if from == to {
return
}
i := 1
if to > from {
for space := from; space <= to; space++ {
f(space, i)
i++
}
} else {
for space := from; space >= to; space-- {
f(space, i)
i++
}
}
}
func (g *Game) LegalMoves() [][]int {
if g.Roll1 == 0 || g.Roll2 == 0 {
return nil
}
var moves [][]int
for space := range g.Board {
if space == SpaceHomePlayer || space == SpaceHomeOpponent {
continue
}
checkers := g.Board[space]
playerCheckers := numPlayerCheckers(checkers, g.Turn)
if playerCheckers == 0 {
continue
}
if space == SpaceBarPlayer || space == SpaceBarOpponent {
// Enter from bar.
from, to := HomeRange(g.Turn)
g.iterateSpaces(from, to, func(homeSpace int, spaceCount int) {
if spaceCount != g.Roll1 && spaceCount != g.Roll2 {
return
}
opponentCheckers := numOpponentCheckers(g.Board[homeSpace], g.Turn)
if opponentCheckers <= 1 {
moves = append(moves, []int{space, homeSpace})
}
})
} else {
// Move normally.
lastSpace := 1
dir := -1
if g.Turn == 2 {
lastSpace = 24
dir = 1
}
if space == lastSpace {
continue // TODO check if all pieces in home
}
g.iterateSpaces(space+dir, lastSpace, func(to int, spaceCount int) {
if spaceCount != g.Roll1 && spaceCount != g.Roll2 {
return
}
if to == SpaceHomePlayer || to == SpaceHomeOpponent {
return // TODO
}
opponentCheckers := numOpponentCheckers(g.Board[to], g.Turn)
if opponentCheckers <= 1 {
movable := 1
if g.Roll1 == g.Roll2 {
movable = playerCheckers
if movable > 4 {
movable = 4
}
}
for i := 0; i < movable; i++ {
moves = append(moves, []int{space, to})
//log.Printf("ADD MOVE %d-%d", space, to)
}
}
})
}
}
return moves
}
func (g *Game) RenderSpace(player int, space int, spaceValue int, legalMoves [][]int) []byte {
var playerColor = "x"
var opponentColor = "o"
if player == 2 {
playerColor = "o"
opponentColor = "x"
}
var pieceColor string
value := g.Board[space]
if space == SpaceBarPlayer {
pieceColor = playerColor
} else if space == SpaceBarOpponent {
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 := space > 12
if player == 2 {
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
}
}
if abs > 5 {
abs = 5
}
var r []byte
if abs > 0 && spaceValue <= abs {
r = []byte(pieceColor)
} else {
r = []byte(" ")
}
return append(append([]byte(" "), r...), ' ')
}
func (g *Game) BoardState(player int) []byte {
var t bytes.Buffer
playerRating := "0"
opponentRating := "0"
var white bool
if player == 2 {
white = true
}
var opponentName = g.Player2.Name
var playerName = g.Player1.Name
if playerName == "" {
playerName = "Waiting..."
}
if opponentName == "" {
opponentName = "Waiting..."
}
if white {
playerName, opponentName = opponentName, playerName
}
var playerColor = "x"
var opponentColor = "o"
if white {
playerColor = "o"
opponentColor = "x"
}
if white {
t.Write(boardTopWhite)
} else {
t.Write(boardTopBlack)
}
t.WriteString(" ")
t.WriteByte('\n')
legalMoves := g.LegalMoves()
space := func(row int, col int) []byte {
spaceValue := row + 1
if row > 5 {
spaceValue = 5 - (row - 6)
}
if col == -1 {
if row <= 4 {
return g.RenderSpace(player, SpaceBarOpponent, spaceValue, legalMoves)
}
return g.RenderSpace(player, SpaceBarPlayer, spaceValue, legalMoves)
}
var index int
if !white {
if row < 6 {
col = 12 - col
} else {
col = 11 - col
}
index = col
if row > 5 {
index = 11 - col + 13
}
} else {
index = col + 3
if row > 5 {
index = 11 - col + 15
}
}
if white {
index = BoardSpaces - 1 - index
}
if row == 5 {
return []byte(" ")
}
return g.RenderSpace(player, index, spaceValue, legalMoves)
}
for i := 0; i < 11; i++ {
t.WriteRune(VerticalBar)
t.Write([]byte(""))
for j := 0; j < 12; j++ {
t.Write(space(i, j))
if j == 5 {
t.WriteRune(VerticalBar)
t.Write(space(i, -1))
t.WriteRune(VerticalBar)
}
}
t.Write([]byte("" + string(VerticalBar) + " "))
if i == 0 {
t.Write([]byte(opponentColor + " " + opponentName + " (" + opponentRating + ")"))
if g.Board[SpaceHomeOpponent] != 0 {
v := g.Board[SpaceHomeOpponent]
if v < 0 {
v *= -1
}
t.Write([]byte(fmt.Sprintf(" %d off", v)))
}
} else if i == 2 {
if g.Turn != player && g.Roll1 > 0 {
t.Write([]byte(fmt.Sprintf(" %d %d ", g.Roll1, g.Roll2)))
} else {
t.Write([]byte(fmt.Sprintf(" - - ")))
}
} else if i == 8 {
if g.Turn == player && g.Roll1 > 0 {
t.Write([]byte(fmt.Sprintf(" %d %d ", g.Roll1, g.Roll2)))
} else {
t.Write([]byte(fmt.Sprintf(" - - ")))
}
} else if i == 10 {
t.Write([]byte(playerColor + " " + playerName + " (" + playerRating + ")"))
if g.Board[SpaceHomePlayer] != 0 {
v := g.Board[SpaceHomePlayer]
if v < 0 {
v *= -1
}
t.Write([]byte(fmt.Sprintf(" %d off", v)))
}
}
t.Write([]byte(" "))
t.WriteByte('\n')
}
if white {
t.Write(boardBottomWhite)
} else {
t.Write(boardBottomBlack)
}
t.WriteString(" \n")
return t.Bytes()
}
func spaceDiff(from int, to int) int {
diff := to - from
if diff < 0 {
return diff * -1
}
return diff
}
func numPlayerCheckers(checkers int, player int) int {
if player == 1 {
if checkers > 0 {
return checkers
}
return 0
} else {
if checkers < 0 {
return checkers * -1
}
return 0
}
}
func numOpponentCheckers(checkers int, player int) int {
if player == 2 {
if checkers > 0 {
return checkers
}
return 0
} else {
if checkers < 0 {
return checkers * -1
}
return 0
}
}
const (
VerticalBar rune = '\u2502' // │
)

View file

@ -5,8 +5,8 @@ type Player struct {
Name string
}
func NewPlayer(number int) *Player {
return &Player{
func NewPlayer(number int) Player {
return Player{
Number: number,
}
}