--- /dev/null
+package game
+
+func (ctx *Context) RollDice() {
+ ctx.EventPop()
+
+ // Roll Dice
+ diceRoll1 := ctx.Random.Int32N(6) + 1
+ diceRoll2 := ctx.Random.Int32N(6) + 1
+
+ ctx.Turn.NumDiceRolled++
+ ctx.Turn.DiceRollsRemaining--
+
+ if diceRoll1 == diceRoll2 {
+ ctx.Turn.RolledDoubles = true
+ ctx.Turn.DiceRollsRemaining++
+
+ ctx.RemovePlayerFromJail(ctx.Turn.Current)
+
+ ctx.Turn.EventStack = append(ctx.Turn.EventStack, EventRollDice)
+ }
+
+ if ctx.Turn.NumDiceRolled >= 3 {
+ ctx.PutPlayerInJail(ctx.Turn.Current)
+ }
+
+ ctx.Turn.MoveQueue = append(ctx.Turn.MoveQueue, diceRoll1+diceRoll2)
+}
+
+func (ctx *Context) Buy() {
+ ctx.EventPop()
+
+ playerID := ctx.Turn.Current
+ player := ctx.Players.Alive[playerID.Index()]
+ prop := ColorProperties[BoardSpaces[player.CurrentSpaceID.Index()].SubIndexID]
+
+ for i, p := range ctx.Properties.Owners {
+ if p.SpaceID == player.CurrentSpaceID {
+ ctx.Properties.Owners[i].OwnerID = playerID
+ }
+ }
+
+ ctx.AdjustPlayerMoney(playerID, -prop.Price)
+}
+
+func (ctx *Context) EndTurn() {
+ ctx.EventPop()
+ nextTurnPlayerID := PlayerID{ID: (ctx.Turn.Current.ID + 1) % int32(len(ctx.Players.Alive))}
+ ctx.Turn = initTurn(nextTurnPlayerID, !ctx.PlayerCanMove(nextTurnPlayerID))
+ // TODO: send options list to the next player
+}
case 6:
ctx.Turn.MoveQueue = append(ctx.Turn.MoveQueue, GetPlayerMoveDistance(currentPos, SpecialSpaces.Go))
case 7:
- ctx.Players.Alive[ctx.Turn.Current.Index()].GetOutOfJailCards++
+ ctx.Players.Alive[ctx.Turn.Current.Index()].PardonCards++
case 8:
ctx.Turn.MoveQueue = append(ctx.Turn.MoveQueue, GetPlayerMoveDistance(currentPos, SpecialSpaces.Boardwalk))
case 9:
case 13:
ctx.PutPlayerInJail(visitorID)
case 14:
- ctx.Players.Alive[ctx.Turn.Current.Index()].GetOutOfJailCards++
+ ctx.Players.Alive[ctx.Turn.Current.Index()].PardonCards++
case 15:
ctx.AdjustPlayerMoney(visitorID, 10)
}
const ColorMaxHouses = 4
const StartingMoney int32 = 1500
-const StartingGetOutOfJailFreeCards int32 = 0
+const StartingPardonCards int32 = 0
var ChestCards = [...]string{
0: "Advance to Go. (Collect $200)",
import (
"context"
- "encoding/json"
- "errors"
- "fmt"
- "github.com/coder/websocket"
- "github.com/google/uuid"
- "golang.org/x/time/rate"
- "io"
- "log"
- "math/rand/v2"
- "net"
- "net/http"
- "sync"
- "time"
)
-type MonopolyServer struct {
- subscriberMessageBuffer int
- publishLimiter *rate.Limiter
-
- logf func(f string, v ...any)
-
- serveMux http.ServeMux
-
- subscribersMu sync.Mutex
- subscribers map[*subscriber]string
-
- // uuid to username
- users map[string]string
-
- gameCtxMu sync.Mutex
- gameCtx *Context
- randSeed *rand.PCG
-}
-
-func (ctx *Context) logGameCtx() {
-
- type miniContext struct {
- Players Players
- Turn Turn
- Visitors Visitors
- properties Properties
- }
-
- data, _ := json.MarshalIndent(miniContext{
- Players: ctx.Players,
- Turn: ctx.Turn,
- Visitors: ctx.Visitors,
- properties: ctx.Properties,
- }, "", " ")
- fmt.Println(string(data))
-}
-
-func NewMonopolyServer() *MonopolyServer {
- ms := &MonopolyServer{
- subscriberMessageBuffer: 16,
- logf: log.Printf,
- subscribers: make(map[*subscriber]string),
- users: make(map[string]string),
- publishLimiter: rate.NewLimiter(rate.Every(time.Millisecond*100), 8),
- gameCtx: nil,
- randSeed: rand.NewPCG(20, 26),
- }
- ms.serveMux.Handle("/", http.FileServer(http.Dir("public/")))
- ms.serveMux.HandleFunc("/login", ms.loginHandler)
- ms.serveMux.HandleFunc("/loggedin", ms.loggedInHandler)
- ms.serveMux.HandleFunc("/subscribe", ms.subscribeHandler)
- ms.serveMux.HandleFunc("/start", ms.startHandler)
- ms.serveMux.HandleFunc("/roll", ms.rollHandler)
- ms.serveMux.HandleFunc("/buy", ms.buyHandler)
- // ms.serveMux.HandleFunc("/auction", ms.auctionHandler)
-
- return ms
-}
-
-type subscriber struct {
- msgs chan []byte
- closeSlow func()
-}
-
-func (ms *MonopolyServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
- ms.serveMux.ServeHTTP(w, r)
-}
-
-func (ms *MonopolyServer) subscribeHandler(w http.ResponseWriter, r *http.Request) {
- err := ms.subscribe(w, r)
- if errors.Is(err, context.Canceled) {
- return
- }
-
- if websocket.CloseStatus(err) == websocket.StatusNormalClosure || websocket.CloseStatus(err) == websocket.StatusGoingAway {
- return
- }
-
- if err != nil {
- ms.logf("%v", err)
- return
- }
-}
-
-func (ms *MonopolyServer) loggedInHandler(w http.ResponseWriter, r *http.Request) {
- if r.Method != "GET" {
- http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
- return
- }
-
- cookie, err := r.Cookie("user")
- if err != nil {
- if err == http.ErrNoCookie {
- http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
- return
- }
-
- http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
- return
- }
-
- userUUID := cookie.Value
-
- _, ok := ms.users[userUUID]
- if !ok {
- http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
- return
- }
-
- w.WriteHeader(http.StatusOK)
-}
-
-func (ms *MonopolyServer) loginHandler(w http.ResponseWriter, r *http.Request) {
- if r.Method != "POST" {
- http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
- return
- }
-
- body := http.MaxBytesReader(w, r.Body, 8192)
- username, err := io.ReadAll(body)
- if err != nil {
- http.Error(w, http.StatusText(http.StatusRequestEntityTooLarge), http.StatusRequestEntityTooLarge)
- return
- }
- userUUID := uuid.NewString()
-
- ms.users[userUUID] = string(username)
-
- http.SetCookie(w, &http.Cookie{
- Name: "user",
- Value: userUUID,
- Path: "/",
- Domain: "",
- MaxAge: int(7 * 24 * time.Hour / time.Second),
- HttpOnly: true,
- Secure: true,
- SameSite: http.SameSiteLaxMode,
- })
- w.WriteHeader(http.StatusOK)
-}
-
-func (ms *MonopolyServer) startHandler(w http.ResponseWriter, r *http.Request) {
- if r.Method != "POST" {
- http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
- return
- }
-
- cookie, err := r.Cookie("user")
- if err != nil {
- if err == http.ErrNoCookie {
- http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
- return
- }
-
- http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
- return
- }
-
- userUUID := cookie.Value
- _, ok := ms.users[userUUID]
- if !ok {
- http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
- return
- }
-
- if ms.gameCtx != nil {
- http.Error(w, http.StatusText(http.StatusConflict), http.StatusConflict)
- return
- }
-
- ms.start()
-
- w.WriteHeader(http.StatusAccepted)
-}
-
-func (ms *MonopolyServer) buyHandler(w http.ResponseWriter, r *http.Request) {
- if r.Method != "POST" {
- http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
- return
- }
-
- cookie, err := r.Cookie("user")
- if err != nil {
- if err == http.ErrNoCookie {
- http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
- return
- }
-
- http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
- return
- }
-
- userUUID := cookie.Value
-
- _, ok := ms.users[userUUID]
- if !ok {
- http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
- return
- }
-
- if ms.gameCtx == nil {
- http.Error(w, "Game has not started yet", http.StatusConflict)
- return
- }
- if !ms.gameCtx.ValidateCanBuy(userUUID) {
- http.Error(w, "Not your turn", http.StatusForbidden)
- return
- }
- ms.buy()
- w.WriteHeader(http.StatusOK)
-}
-
-func (ms *MonopolyServer) buy() {
- ms.gameCtx.Buy()
-
- ms.gameCtx.logGameCtx()
-}
-
-func (ms *MonopolyServer) rollHandler(w http.ResponseWriter, r *http.Request) {
- if r.Method != "POST" {
- http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
- return
- }
-
- cookie, err := r.Cookie("user")
- if err != nil {
- if err == http.ErrNoCookie {
- http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
- return
- }
-
- http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
- return
- }
-
- userUUID := cookie.Value
-
- _, ok := ms.users[userUUID]
- if !ok {
- http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
- return
- }
-
- if ms.gameCtx == nil {
- http.Error(w, "Game has not started yet", http.StatusConflict)
- return
- }
- if !ms.gameCtx.ValidateCanRoll(userUUID) {
- http.Error(w, "Not your turn", http.StatusForbidden)
- return
- }
- ms.roll()
- w.WriteHeader(http.StatusOK)
-}
-
-func (ms *MonopolyServer) roll() {
- ms.gameCtx.RollDice()
- ms.gameCtx.ProcessMovement()
-
- ms.gameCtx.logGameCtx()
+func (ms *MonopolyServer) addSubscriber(s *subscriber, userUUID string) {
+ ms.subscribersMu.Lock()
+ ms.subscribers[s] = userUUID
+ ms.subscribersMu.Unlock()
}
-func (ms *MonopolyServer) subscribe(w http.ResponseWriter, r *http.Request) error {
- cookie, err := r.Cookie("user")
- if err != nil {
- if err == http.ErrNoCookie {
- http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
- return err
- }
-
- http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
- return err
- }
-
- userUUID := cookie.Value
-
- var mu sync.Mutex
- var c *websocket.Conn
- var closed bool
-
- s := &subscriber{
- msgs: make(chan []byte, ms.subscriberMessageBuffer),
- closeSlow: func() {
- mu.Lock()
- defer mu.Unlock()
- closed = true
- if c != nil {
- c.Close(websocket.StatusPolicyViolation, "connection too slow to keep up with messages")
- }
- },
- }
- ms.addSubscriber(s, userUUID)
- defer ms.deleteSubscriber(s)
-
- c2, err := websocket.Accept(w, r, nil)
- if err != nil {
- return err
- }
-
- mu.Lock()
- if closed {
- mu.Unlock()
- return net.ErrClosed
- }
-
- c = c2
- mu.Unlock()
- defer c.CloseNow()
-
- ctx := c.CloseRead(context.Background())
- for {
- select {
- case msg := <-s.msgs:
- err := writeTimeout(ctx, time.Second*5, c, msg)
- if err != nil {
- return err
- }
- case <-ctx.Done():
- return ctx.Err()
- }
- }
+func (ms *MonopolyServer) deleteSubscriber(s *subscriber) {
+ ms.subscribersMu.Lock()
+ delete(ms.subscribers, s)
+ ms.subscribersMu.Unlock()
}
func (ms *MonopolyServer) start() {
}
-// func (ms *MonopolyServer) publish(msg []byte) {
-// ms.subscribersMu.Lock()
-// defer ms.subscribersMu.Unlock()
-//
-// ms.publishLimiter.Wait(context.Background())
-//
-// for s := range ms.subscribers {
-// select {
-// case s.msgs <- msg:
-// default:
-// go s.closeSlow()
-// }
-// }
-//
-// }
-
-func (ms *MonopolyServer) addSubscriber(s *subscriber, userUUID string) {
- ms.subscribersMu.Lock()
- ms.subscribers[s] = userUUID
- ms.subscribersMu.Unlock()
-}
-
-func (ms *MonopolyServer) deleteSubscriber(s *subscriber) {
- ms.subscribersMu.Lock()
- delete(ms.subscribers, s)
- ms.subscribersMu.Unlock()
-}
-
-func writeTimeout(ctx context.Context, timeout time.Duration, c *websocket.Conn, msg []byte) error {
- ctx, cancel := context.WithTimeout(ctx, timeout)
- defer cancel()
-
- return c.Write(ctx, websocket.MessageText, msg)
-}
-
-func initTurn(pID PlayerID, inJail bool) Turn {
-
- eS := []EventType{EventEndTurn}
- if inJail {
- eS = append(eS, EventJail)
- } else {
- eS = append(eS, EventRollDice)
- }
-
- return Turn{
- Current: pID,
- Ended: false,
- DiceRollsRemaining: StartingDiceRolls,
- NumDiceRolled: 0,
- RolledDoubles: false,
- MoveQueue: []int32{},
- EventStack: eS,
- InDebt: false,
- Modifier: Modifiers{
- RailroadRentMultiplier: 1,
- UtilityForceRentMultiplier: false,
- },
- }
-}
-
-func InitCtx(randSeed rand.Source, players []Player) *Context {
- startingPlayerID := PlayerID{ID: 0}
-
- playerIDs := []PlayerID{}
- for i := range players {
- playerIDs = append(playerIDs, PlayerID{ID: int32(i)})
- }
-
- ownableProps := []OwnableProperty{}
- for i, s := range BoardSpaces {
- spaceID := SpaceID{ID: int32(i)}
-
- propertyType := s.PropertyType
- if propertyType == TypeColor || propertyType == TypeRailroad || propertyType == TypeUtility {
- ownableProps = append(ownableProps, OwnableProperty{
- OwnerID: BankPlayerID,
- SpaceID: spaceID,
- })
- }
- }
-
- return &Context{
- Random: rand.New(randSeed),
- Players: Players{
- Alive: players,
- CanMove: playerIDs,
- },
- Turn: initTurn(startingPlayerID, false),
- Visitors: Visitors{
- Unowned: []UnownedPropertyVisitor{},
- Color: []OwnedColorVisitor{},
- Railroad: []OwnedRailroadVisitor{},
- Utility: []OwnedUtilityVisitor{},
- Go: []PlayerID{},
- Tax: []TaxVisitor{},
- Chance: []PlayerID{},
- Chest: []PlayerID{},
- InJail: []InJailVisitor{},
- // Parking: []PlayerID{},
- // Police: []PlayerID{},
- },
- Properties: Properties{
- Owners: ownableProps,
- Mortgages: []PropertyID{},
- },
- }
-}
-
-func InitPlayer(userUUID string) Player {
- return Player{
- UserUUID: userUUID,
- Money: StartingMoney,
- CurrentSpaceID: SpecialSpaces.Go,
- GetOutOfJailCards: StartingGetOutOfJailFreeCards,
- }
-}
-
-func (ctx *Context) PlayerCanMove(pID PlayerID) bool {
- for i, _ := range ctx.Players.CanMove {
- if i == pID.Index() {
- return true
- }
- }
- return false
-}
-
-func (ctx *Context) GetCurrentTurnPlayer() *Player {
- return &ctx.Players.Alive[ctx.Turn.Current.Index()]
-}
-
-func (ctx *Context) ValidateIsTurn(userUUID string) bool {
- if ctx.GetCurrentTurnPlayer().UserUUID == userUUID {
- return true
- }
- return false
-}
-
-func (ctx *Context) ValidateCanBuy(userUUID string) bool {
- player := ctx.Players.Alive[ctx.Turn.Current.Index()]
- prop := ColorProperties[BoardSpaces[player.CurrentSpaceID.Index()].SubIndexID]
-
- if ctx.ValidateIsTurn(userUUID) && ctx.EventPeek() == EventLandUnowned && player.Money >= prop.Price {
- return true
- }
- return false
-}
-
-func (ctx *Context) ValidateCanRoll(userUUID string) bool {
- if ctx.ValidateIsTurn(userUUID) && (ctx.EventPeek() == EventRollDice || ctx.EventPeek() == EventJail) {
- return true
- }
- return false
-}
-
-func (ctx *Context) ValidateCanEndTurn(userUUID string) bool {
- if ctx.ValidateIsTurn(userUUID) && ctx.EventPeek() == EventEndTurn && ctx.GetCurrentTurnPlayer().Money >= 0 {
- return true
- }
- return false
-}
-
-func (ctx *Context) ValidateCanExitJail(userUUID string) bool {
- inJail := false
- for _, iJV := range ctx.Visitors.InJail {
- if ctx.Players.Alive[iJV.VisitorID.Index()].UserUUID == userUUID {
- inJail = true
- }
- }
- if ctx.ValidateIsTurn(userUUID) && inJail && ctx.EventPeek() == EventJail {
- return true
- }
- return false
-}
-
-func (ctx *Context) ProcessLanding() {
- ctx.ProcessGo()
- ctx.ProcessTax()
-
- ctx.ProcessOwnedColors()
- ctx.ProcessOwnedUtility()
- ctx.ProcessOwnedRailroad()
- ctx.ProcessUnowned()
-
- ctx.ProcessChance()
- ctx.ProcessChest()
- // ProcessPolice()
-
- ctx.ProcessJail()
-}
-
-func (ctx *Context) RollDice() {
- ctx.EventPop()
-
- // Roll Dice
- diceRoll1 := ctx.Random.Int32N(6) + 1
- diceRoll2 := ctx.Random.Int32N(6) + 1
-
- ctx.Turn.NumDiceRolled++
- ctx.Turn.DiceRollsRemaining--
-
- if diceRoll1 == diceRoll2 {
- ctx.Turn.RolledDoubles = true
- ctx.Turn.DiceRollsRemaining++
-
- ctx.RemovePlayerFromJail(ctx.Turn.Current)
-
- ctx.Turn.EventStack = append(ctx.Turn.EventStack, EventRollDice)
- }
-
- if ctx.Turn.NumDiceRolled >= 3 {
- ctx.PutPlayerInJail(ctx.Turn.Current)
- }
-
- ctx.Turn.MoveQueue = append(ctx.Turn.MoveQueue, diceRoll1+diceRoll2)
-}
-
-func (ctx *Context) Buy() {
- ctx.EventPop()
-
- playerID := ctx.Turn.Current
- player := ctx.Players.Alive[playerID.Index()]
- prop := ColorProperties[BoardSpaces[player.CurrentSpaceID.Index()].SubIndexID]
-
- for i, p := range ctx.Properties.Owners {
- if p.SpaceID == player.CurrentSpaceID {
- ctx.Properties.Owners[i].OwnerID = playerID
- }
- }
-
- ctx.AdjustPlayerMoney(playerID, -prop.Price)
-}
-
-func (ctx *Context) EndTurn() {
- ctx.EventPop()
- nextTurnPlayerID := PlayerID{ID: (ctx.Turn.Current.ID + 1) % int32(len(ctx.Players.Alive))}
- ctx.Turn = initTurn(nextTurnPlayerID, !ctx.PlayerCanMove(nextTurnPlayerID))
- // TODO: send options list to the next player
-}
+func (ms *MonopolyServer) roll() {
+ ms.gameCtx.RollDice()
+ ms.gameCtx.ProcessMovement()
-func (ctx *Context) IsMortgaged(propID PropertyID) bool {
- for _, oPID := range ctx.Properties.Mortgages {
- if oPID == propID {
- return true
- }
- }
- return false
+ ms.gameCtx.logGameCtx()
}
-func (ctx *Context) IsOwned(spaceID SpaceID) bool {
- for _, prop := range ctx.Properties.Owners {
- if spaceID == prop.SpaceID {
- if prop.OwnerID == BankPlayerID {
- return false
- } else {
- return true
- }
- }
- }
- panic("Space is not an ownable property")
-}
+func (ms *MonopolyServer) buy() {
+ ms.gameCtx.Buy()
-func (ctx *Context) getPropID(spaceID SpaceID) PropertyID {
- for i, prop := range ctx.Properties.Owners {
- if spaceID == prop.SpaceID {
- return PropertyID{ID: int32(i)}
- }
- }
- panic("Space is not an ownable property")
+ ms.gameCtx.logGameCtx()
}
--- /dev/null
+package game
+
+import (
+ "context"
+ "errors"
+ "github.com/coder/websocket"
+ "github.com/google/uuid"
+ "io"
+ "net"
+ "net/http"
+ "sync"
+ "time"
+)
+
+func (ms *MonopolyServer) subscribeHandler(w http.ResponseWriter, r *http.Request) {
+ err := ms.subscribe(w, r)
+ if errors.Is(err, context.Canceled) {
+ return
+ }
+
+ if websocket.CloseStatus(err) == websocket.StatusNormalClosure || websocket.CloseStatus(err) == websocket.StatusGoingAway {
+ return
+ }
+
+ if err != nil {
+ ms.logf("%v", err)
+ return
+ }
+}
+
+func (ms *MonopolyServer) loggedInHandler(w http.ResponseWriter, r *http.Request) {
+ if r.Method != "GET" {
+ http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
+ return
+ }
+
+ cookie, err := r.Cookie("user")
+ if err != nil {
+ if err == http.ErrNoCookie {
+ http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
+ return
+ }
+
+ http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
+ return
+ }
+
+ userUUID := cookie.Value
+
+ _, ok := ms.users[userUUID]
+ if !ok {
+ http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
+ return
+ }
+
+ w.WriteHeader(http.StatusOK)
+}
+
+func (ms *MonopolyServer) loginHandler(w http.ResponseWriter, r *http.Request) {
+ if r.Method != "POST" {
+ http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
+ return
+ }
+
+ body := http.MaxBytesReader(w, r.Body, 8192)
+ username, err := io.ReadAll(body)
+ if err != nil {
+ http.Error(w, http.StatusText(http.StatusRequestEntityTooLarge), http.StatusRequestEntityTooLarge)
+ return
+ }
+ userUUID := uuid.NewString()
+
+ ms.users[userUUID] = string(username)
+
+ http.SetCookie(w, &http.Cookie{
+ Name: "user",
+ Value: userUUID,
+ Path: "/",
+ Domain: "",
+ MaxAge: int(7 * 24 * time.Hour / time.Second),
+ HttpOnly: true,
+ Secure: true,
+ SameSite: http.SameSiteLaxMode,
+ })
+ w.WriteHeader(http.StatusOK)
+}
+
+func (ms *MonopolyServer) startHandler(w http.ResponseWriter, r *http.Request) {
+ if r.Method != "POST" {
+ http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
+ return
+ }
+
+ cookie, err := r.Cookie("user")
+ if err != nil {
+ if err == http.ErrNoCookie {
+ http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
+ return
+ }
+
+ http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
+ return
+ }
+
+ userUUID := cookie.Value
+ _, ok := ms.users[userUUID]
+ if !ok {
+ http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
+ return
+ }
+
+ if ms.gameCtx != nil {
+ http.Error(w, http.StatusText(http.StatusConflict), http.StatusConflict)
+ return
+ }
+
+ ms.start()
+
+ w.WriteHeader(http.StatusAccepted)
+}
+
+func (ms *MonopolyServer) buyHandler(w http.ResponseWriter, r *http.Request) {
+ if r.Method != "POST" {
+ http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
+ return
+ }
+
+ cookie, err := r.Cookie("user")
+ if err != nil {
+ if err == http.ErrNoCookie {
+ http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
+ return
+ }
+
+ http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
+ return
+ }
+
+ userUUID := cookie.Value
+
+ _, ok := ms.users[userUUID]
+ if !ok {
+ http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
+ return
+ }
+
+ if ms.gameCtx == nil {
+ http.Error(w, "Game has not started yet", http.StatusConflict)
+ return
+ }
+ if err := ms.gameCtx.ValidateCanBuy(userUUID); err != nil {
+ http.Error(w, err.Error(), ErrStatusCode(err))
+ return
+ }
+
+ ms.buy()
+ w.WriteHeader(http.StatusOK)
+}
+
+func (ms *MonopolyServer) rollHandler(w http.ResponseWriter, r *http.Request) {
+ if r.Method != "POST" {
+ http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
+ return
+ }
+
+ cookie, err := r.Cookie("user")
+ if err != nil {
+ if err == http.ErrNoCookie {
+ http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
+ return
+ }
+
+ http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
+ return
+ }
+
+ userUUID := cookie.Value
+
+ _, ok := ms.users[userUUID]
+ if !ok {
+ http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
+ return
+ }
+
+ if ms.gameCtx == nil {
+ http.Error(w, "Game has not started yet", http.StatusConflict)
+ return
+ }
+ if err := ms.gameCtx.ValidateCanRoll(userUUID); err != nil {
+ http.Error(w, err.Error(), ErrStatusCode(err))
+ return
+ }
+ ms.roll()
+ w.WriteHeader(http.StatusOK)
+}
+
+func (ms *MonopolyServer) subscribe(w http.ResponseWriter, r *http.Request) error {
+ cookie, err := r.Cookie("user")
+ if err != nil {
+ if err == http.ErrNoCookie {
+ http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
+ return err
+ }
+
+ http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
+ return err
+ }
+
+ userUUID := cookie.Value
+
+ var mu sync.Mutex
+ var c *websocket.Conn
+ var closed bool
+
+ s := &subscriber{
+ msgs: make(chan []byte, ms.subscriberMessageBuffer),
+ closeSlow: func() {
+ mu.Lock()
+ defer mu.Unlock()
+ closed = true
+ if c != nil {
+ c.Close(websocket.StatusPolicyViolation, "connection too slow to keep up with messages")
+ }
+ },
+ }
+ ms.addSubscriber(s, userUUID)
+ defer ms.deleteSubscriber(s)
+
+ c2, err := websocket.Accept(w, r, nil)
+ if err != nil {
+ return err
+ }
+
+ mu.Lock()
+ if closed {
+ mu.Unlock()
+ return net.ErrClosed
+ }
+
+ c = c2
+ mu.Unlock()
+ defer c.CloseNow()
+
+ ctx := c.CloseRead(context.Background())
+ for {
+ select {
+ case msg := <-s.msgs:
+ err := writeTimeout(ctx, time.Second*5, c, msg)
+ if err != nil {
+ return err
+ }
+ case <-ctx.Done():
+ return ctx.Err()
+ }
+ }
+}
+
+// func (ms *MonopolyServer) publish(msg []byte) {
+// ms.subscribersMu.Lock()
+// defer ms.subscribersMu.Unlock()
+//
+// ms.publishLimiter.Wait(context.Background())
+//
+// for s := range ms.subscribers {
+// select {
+// case s.msgs <- msg:
+// default:
+// go s.closeSlow()
+// }
+// }
+//
+// }
package game
+import (
+ "encoding/json"
+ "fmt"
+ "math/rand/v2"
+)
+
func (ctx *Context) AdjustPlayerMoney(playerID PlayerID, amount int32) {
ctx.Players.Alive[playerID.Index()].Money += amount
}
}
+
+func (ctx *Context) IsMortgaged(propID PropertyID) bool {
+ for _, oPID := range ctx.Properties.Mortgages {
+ if oPID == propID {
+ return true
+ }
+ }
+ return false
+}
+
+func (ctx *Context) IsOwned(spaceID SpaceID) bool {
+ for _, prop := range ctx.Properties.Owners {
+ if spaceID == prop.SpaceID {
+ if prop.OwnerID == BankPlayerID {
+ return false
+ } else {
+ return true
+ }
+ }
+ }
+ panic("Space is not an ownable property")
+}
+
+func (ctx *Context) getOwnablePropID(spaceID SpaceID) PropertyID {
+ for i, prop := range ctx.Properties.Owners {
+ if spaceID == prop.SpaceID {
+ return PropertyID{ID: int32(i)}
+ }
+ }
+ panic("Space is not an ownable property")
+}
+
+func (ctx *Context) GetCurrentTurnPlayer() *Player {
+ return &ctx.Players.Alive[ctx.Turn.Current.Index()]
+}
+
+func (ctx *Context) PlayerCanMove(pID PlayerID) bool {
+ for i, _ := range ctx.Players.CanMove {
+ if i == pID.Index() {
+ return true
+ }
+ }
+ return false
+}
+
+func initTurn(pID PlayerID, inJail bool) Turn {
+
+ eS := []EventType{EventEndTurn}
+ if inJail {
+ eS = append(eS, EventJail)
+ } else {
+ eS = append(eS, EventRollDice)
+ }
+
+ return Turn{
+ Current: pID,
+ Ended: false,
+ DiceRollsRemaining: StartingDiceRolls,
+ NumDiceRolled: 0,
+ RolledDoubles: false,
+ MoveQueue: []int32{},
+ EventStack: eS,
+ InDebt: false,
+ Modifier: Modifiers{
+ RailroadRentMultiplier: 1,
+ UtilityForceRentMultiplier: false,
+ },
+ }
+}
+
+func InitCtx(randSeed rand.Source, players []Player) *Context {
+ startingPlayerID := PlayerID{ID: 0}
+
+ playerIDs := []PlayerID{}
+ for i := range players {
+ playerIDs = append(playerIDs, PlayerID{ID: int32(i)})
+ }
+
+ ownableProps := []OwnableProperty{}
+ for i, s := range BoardSpaces {
+ spaceID := SpaceID{ID: int32(i)}
+
+ propertyType := s.PropertyType
+ if propertyType == TypeColor || propertyType == TypeRailroad || propertyType == TypeUtility {
+ ownableProps = append(ownableProps, OwnableProperty{
+ OwnerID: BankPlayerID,
+ SpaceID: spaceID,
+ })
+ }
+ }
+
+ return &Context{
+ Random: rand.New(randSeed),
+ Players: Players{
+ Alive: players,
+ CanMove: playerIDs,
+ },
+ Turn: initTurn(startingPlayerID, false),
+ Visitors: Visitors{
+ Unowned: []UnownedPropertyVisitor{},
+ Color: []OwnedColorVisitor{},
+ Railroad: []OwnedRailroadVisitor{},
+ Utility: []OwnedUtilityVisitor{},
+ Go: []PlayerID{},
+ Tax: []TaxVisitor{},
+ Chance: []PlayerID{},
+ Chest: []PlayerID{},
+ InJail: []InJailVisitor{},
+ // Parking: []PlayerID{},
+ // Police: []PlayerID{},
+ },
+ Properties: Properties{
+ Owners: ownableProps,
+ Mortgages: []PropertyID{},
+ },
+ }
+}
+
+func InitPlayer(userUUID string) Player {
+ return Player{
+ UserUUID: userUUID,
+ Money: StartingMoney,
+ CurrentSpaceID: SpecialSpaces.Go,
+ PardonCards: StartingPardonCards,
+ }
+}
+
+func (ctx *Context) logGameCtx() {
+
+ type miniContext struct {
+ Players Players
+ Turn Turn
+ Visitors Visitors
+ properties Properties
+ }
+
+ data, _ := json.MarshalIndent(miniContext{
+ Players: ctx.Players,
+ Turn: ctx.Turn,
+ Visitors: ctx.Visitors,
+ properties: ctx.Properties,
+ }, "", " ")
+ fmt.Println(string(data))
+}
package game
-import (
- "errors"
-)
-
func (ctx *Context) RemovePlayerFromJail(playerID PlayerID) {
for i, iJV := range ctx.Visitors.InJail {
if playerID == iJV.VisitorID {
}
}
-var ErrNotEnoughJailCards = errors.New("Cannot use jail card: player does not have enough get out of jail free cards")
-var ErrNotEnoughMoney = errors.New("Cannot execute action: player does not have enough money")
-
-func (ctx *Context) JailUseCard() error {
+func (ctx *Context) JailPardon() {
currID := ctx.Turn.Current
- if ctx.Players.Alive[currID.Index()].GetOutOfJailCards > 0 {
- ctx.RemovePlayerFromJail(currID)
- ctx.Players.Alive[currID.Index()].GetOutOfJailCards -= 1
- return nil
-
- } else {
- return ErrNotEnoughJailCards
- }
+ ctx.RemovePlayerFromJail(currID)
+ ctx.Players.Alive[currID.Index()].PardonCards -= 1
}
-func (ctx *Context) JailBuyout() error {
+func (ctx *Context) JailBuyout() {
currID := ctx.Turn.Current
- if ctx.Players.Alive[currID.Index()].Money >= JailBuyoutCost {
- ctx.RemovePlayerFromJail(currID)
- ctx.AdjustPlayerMoney(currID, -JailBuyoutCost)
- return nil
- } else {
- return ErrNotEnoughMoney
- }
+ ctx.RemovePlayerFromJail(currID)
+ ctx.AdjustPlayerMoney(currID, -JailBuyoutCost)
}
}
+func (ctx *Context) ProcessLanding() {
+ ctx.ProcessGo()
+ ctx.ProcessTax()
+
+ ctx.ProcessOwnedColors()
+ ctx.ProcessOwnedUtility()
+ ctx.ProcessOwnedRailroad()
+ ctx.ProcessUnowned()
+
+ ctx.ProcessChance()
+ ctx.ProcessChest()
+ // ProcessPolice()
+
+ ctx.ProcessJail()
+}
+
func CalculateNextPos(currentPosition SpaceID, distance int32) SpaceID {
nextPos := Add(currentPosition, distance)
nextPos.ID %= int32(len(BoardSpaces))
case TypePolice: // hardcoding to send straight to jail
ctx.PutPlayerInJail(playerID)
case TypeColor:
- propID := ctx.getPropID(nextPos)
+ propID := ctx.getOwnablePropID(nextPos)
ownerID := ctx.Properties.Owners[propID.Index()].OwnerID
if ownerID != BankPlayerID { // property owned?
if ownerID != playerID && !ctx.IsMortgaged(propID) { // not by you
ctx.Visitors.Unowned = append(ctx.Visitors.Unowned, UnownedPropertyVisitor{VisitorID: playerID, PropertyID: propID})
}
case TypeRailroad:
- propID := ctx.getPropID(nextPos)
+ propID := ctx.getOwnablePropID(nextPos)
ownerID := ctx.Properties.Owners[propID.Index()].OwnerID
if ownerID != BankPlayerID { // property owned?
if ownerID != playerID && !ctx.IsMortgaged(propID) { // not by you
ctx.Visitors.Unowned = append(ctx.Visitors.Unowned, UnownedPropertyVisitor{VisitorID: playerID, PropertyID: propID})
}
case TypeUtility:
- propID := ctx.getPropID(nextPos)
+ propID := ctx.getOwnablePropID(nextPos)
ownerID := ctx.Properties.Owners[propID.Index()].OwnerID
if ownerID != BankPlayerID { // property owned?
if ownerID != playerID && !ctx.IsMortgaged(propID) { // not by you
--- /dev/null
+package game
+
+import (
+ "context"
+ "github.com/coder/websocket"
+ "golang.org/x/time/rate"
+ "log"
+ "math/rand/v2"
+ "net/http"
+ "time"
+)
+
+func NewMonopolyServer() *MonopolyServer {
+ ms := &MonopolyServer{
+ subscriberMessageBuffer: 16,
+ logf: log.Printf,
+ subscribers: make(map[*subscriber]string),
+ users: make(map[string]string),
+ publishLimiter: rate.NewLimiter(rate.Every(time.Millisecond*100), 8),
+ gameCtx: nil,
+ randSeed: rand.NewPCG(20, 26),
+ }
+ ms.serveMux.Handle("/", http.FileServer(http.Dir("public/")))
+ ms.serveMux.HandleFunc("/login", ms.loginHandler)
+ ms.serveMux.HandleFunc("/loggedin", ms.loggedInHandler)
+ ms.serveMux.HandleFunc("/subscribe", ms.subscribeHandler)
+ ms.serveMux.HandleFunc("/start", ms.startHandler)
+ ms.serveMux.HandleFunc("/roll", ms.rollHandler)
+ ms.serveMux.HandleFunc("/buy", ms.buyHandler)
+ // ms.serveMux.HandleFunc("/auction", ms.auctionHandler)
+
+ return ms
+}
+
+func (ms *MonopolyServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ ms.serveMux.ServeHTTP(w, r)
+}
+func writeTimeout(ctx context.Context, timeout time.Duration, c *websocket.Conn, msg []byte) error {
+ ctx, cancel := context.WithTimeout(ctx, timeout)
+ defer cancel()
+
+ return c.Write(ctx, websocket.MessageText, msg)
+}
- TODO Bankruptcy
-- TODO Unowned event
+* TODO Unowned event
+** DONE buy prop
+** TODO auction
+
+* login multiple times makes new websocket
+* DONE Better errors for validators
+* TODO Response for frontend game log
+** TODO start
+** TODO roll
+** TODO buy
+
package game
import (
+ "golang.org/x/time/rate"
"math/rand/v2"
+ "net/http"
+ "sync"
)
type Player struct {
- UserUUID string
- Money int32
- CurrentSpaceID SpaceID
- GetOutOfJailCards int32
+ UserUUID string
+ Money int32
+ CurrentSpaceID SpaceID
+ PardonCards int32
}
type PropertyType int
Visitors Visitors
Properties Properties
}
+
+type MonopolyServer struct {
+ subscriberMessageBuffer int
+ publishLimiter *rate.Limiter
+
+ logf func(f string, v ...any)
+
+ serveMux http.ServeMux
+
+ subscribersMu sync.Mutex
+ subscribers map[*subscriber]string
+
+ // uuid to username
+ users map[string]string
+
+ gameCtxMu sync.Mutex
+ gameCtx *Context
+ randSeed *rand.PCG
+}
+
+type subscriber struct {
+ msgs chan []byte
+ closeSlow func()
+}
--- /dev/null
+package game
+
+import (
+ "errors"
+ "net/http"
+)
+
+var (
+ ErrNotYourTurn = errors.New("Not your turn")
+ ErrEventDoesNotPermit = errors.New("Event does not permit this action")
+ ErrNotEnoughMoney = errors.New("Not enough money")
+ ErrNotInJail = errors.New("Not in jail")
+ ErrNotEnoughPardons = errors.New("Not enough pardons")
+)
+
+func ErrStatusCode(err error) int {
+ switch {
+ case errors.Is(err, ErrNotYourTurn), errors.Is(err, ErrNotInJail):
+ return http.StatusForbidden
+ case errors.Is(err, ErrEventDoesNotPermit):
+ return http.StatusConflict
+ case errors.Is(err, ErrNotEnoughMoney), errors.Is(err, ErrNotEnoughPardons):
+ return http.StatusBadRequest
+ default:
+ return http.StatusInternalServerError
+ }
+}
+
+func (ctx *Context) ValidateIsTurn(userUUID string) error {
+ if ctx.GetCurrentTurnPlayer().UserUUID != userUUID {
+ return ErrNotYourTurn
+ }
+
+ return nil
+}
+
+func (ctx *Context) ValidateCanBuy(userUUID string) error {
+ player := ctx.Players.Alive[ctx.Turn.Current.Index()]
+ prop := ColorProperties[BoardSpaces[player.CurrentSpaceID.Index()].SubIndexID]
+
+ if err := ctx.ValidateIsTurn(userUUID); err != nil {
+ return err
+ }
+
+ if ctx.EventPeek() != EventLandUnowned {
+ return ErrEventDoesNotPermit
+ }
+
+ if player.Money < prop.Price {
+ return ErrNotEnoughMoney
+ }
+
+ return nil
+}
+
+func (ctx *Context) ValidateCanRoll(userUUID string) error {
+ if err := ctx.ValidateIsTurn(userUUID); err != nil {
+ return err
+ }
+
+ if !(ctx.EventPeek() == EventRollDice || ctx.EventPeek() == EventJail) {
+ return ErrEventDoesNotPermit
+ }
+
+ return nil
+}
+
+func (ctx *Context) ValidateCanEndTurn(userUUID string) error {
+ if err := ctx.ValidateIsTurn(userUUID); err != nil {
+ return err
+ }
+
+ if ctx.EventPeek() != EventEndTurn {
+ return ErrEventDoesNotPermit
+ }
+
+ if ctx.GetCurrentTurnPlayer().Money >= 0 {
+ return ErrNotEnoughMoney
+ }
+
+ return nil
+}
+
+func (ctx *Context) ValidateCanPardonJail(userUUID string) error {
+ if err := ctx.ValidateIsTurn(userUUID); err != nil {
+ return err
+ }
+
+ if ctx.EventPeek() != EventJail {
+ return ErrEventDoesNotPermit
+ }
+
+ inJail := false
+ for _, iJV := range ctx.Visitors.InJail {
+ if ctx.Players.Alive[iJV.VisitorID.Index()].UserUUID == userUUID {
+ inJail = true
+ }
+ }
+
+ if !inJail {
+ return ErrNotInJail
+ }
+
+ player := ctx.Players.Alive[ctx.Turn.Current.Index()]
+ if player.PardonCards == 0 {
+ return ErrNotEnoughPardons
+ }
+
+ return nil
+}
+
+func (ctx *Context) ValidateCanBuyoutJail(userUUID string) error {
+ if err := ctx.ValidateIsTurn(userUUID); err != nil {
+ return err
+ }
+
+ if ctx.EventPeek() != EventJail {
+ return ErrEventDoesNotPermit
+ }
+
+ inJail := false
+ for _, iJV := range ctx.Visitors.InJail {
+ if ctx.Players.Alive[iJV.VisitorID.Index()].UserUUID == userUUID {
+ inJail = true
+ }
+ }
+
+ if !inJail {
+ return ErrNotInJail
+ }
+
+ player := ctx.Players.Alive[ctx.Turn.Current.Index()]
+ if player.Money < JailBuyoutCost {
+ return ErrNotEnoughMoney
+ }
+
+ return nil
+}
body: msg,
})
if (resp.status !== 200) {
- throw new Error(`Unexpected HTTP Status ${resp.status} ${resp.statusText} ${resp.message}`)
+ throw new Error(`Unexpected HTTP Status ${resp.status} ${resp.statusText}`)
}
dial()
const resp = await fetch('/start', {
method: 'POST',
})
+
if (resp.status !== 202) {
- throw new Error(`Unexpected HTTP Status ${resp.status} ${resp.statusText} ${resp.message}`)
+ const errMsg = await resp.text()
+ appendGameLog(errMsg)
+ throw new Error(`Unexpected HTTP Status ${resp.status} ${resp.statusText}`)
}
} catch (err) {
console.error(`Start failed: ${err.message}`)
method: 'POST',
})
if (resp.status !== 200) {
- throw new Error(`Unexpected HTTP Status ${resp.status} ${resp.statusText} ${resp.message}`)
+ const errMsg = await resp.text()
+ appendGameLog(errMsg)
+ throw new Error(`Unexpected HTTP Status ${resp.status} ${resp.statusText}`)
}
} catch (err) {
console.error(`Start failed: ${err.message}`)
method: 'POST',
})
if (resp.status !== 200) {
- throw new Error(`Unexpected HTTP Status ${resp.status} ${resp.statusText} ${resp.message}`)
+ const errMsg = await resp.text()
+ appendGameLog(errMsg)
+ throw new Error(`Unexpected HTTP Status ${resp.status} ${resp.statusText}`)
}
} catch (err) {
console.error(`Start failed: ${err.message}`)