From: Skullheadx Date: Tue, 9 Jun 2026 00:12:01 +0000 (-0400) Subject: refactor + better error msg for validation X-Git-Url: http://git.skullheadx.com/nixos/static/batman.png?a=commitdiff_plain;h=540b3982f5d7f348d0f543a76279dfb3d6c1d7ab;p=monopoly-web.git refactor + better error msg for validation --- diff --git a/game/actions.go b/game/actions.go new file mode 100644 index 0000000..bd7c2f1 --- /dev/null +++ b/game/actions.go @@ -0,0 +1,50 @@ +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 +} diff --git a/game/chance.go b/game/chance.go index 1303475..3baa945 100644 --- a/game/chance.go +++ b/game/chance.go @@ -61,7 +61,7 @@ func (ctx *Context) ProcessChance() { 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: diff --git a/game/chest.go b/game/chest.go index 9a31622..9dbfb89 100644 --- a/game/chest.go +++ b/game/chest.go @@ -65,7 +65,7 @@ func (ctx *Context) ProcessChest() { 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) } diff --git a/game/config.go b/game/config.go index 5f01d9f..9e309ce 100644 --- a/game/config.go +++ b/game/config.go @@ -218,7 +218,7 @@ const GoSalary int32 = 200 const ColorMaxHouses = 4 const StartingMoney int32 = 1500 -const StartingGetOutOfJailFreeCards int32 = 0 +const StartingPardonCards int32 = 0 var ChestCards = [...]string{ 0: "Advance to Go. (Collect $200)", diff --git a/game/game.go b/game/game.go index 862d00b..e2dc5d3 100644 --- a/game/game.go +++ b/game/game.go @@ -2,342 +2,18 @@ package game 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() { @@ -367,272 +43,15 @@ 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() } diff --git a/game/handlers.go b/game/handlers.go new file mode 100644 index 0000000..ef43a61 --- /dev/null +++ b/game/handlers.go @@ -0,0 +1,272 @@ +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() +// } +// } +// +// } diff --git a/game/helpers.go b/game/helpers.go index 467e1dd..c6c1081 100644 --- a/game/helpers.go +++ b/game/helpers.go @@ -1,5 +1,11 @@ package game +import ( + "encoding/json" + "fmt" + "math/rand/v2" +) + func (ctx *Context) AdjustPlayerMoney(playerID PlayerID, amount int32) { ctx.Players.Alive[playerID.Index()].Money += amount @@ -10,3 +16,147 @@ func (ctx *Context) AdjustPlayerMoney(playerID PlayerID, amount int32) { } } + +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)) +} diff --git a/game/jail.go b/game/jail.go index df32d55..a88674f 100644 --- a/game/jail.go +++ b/game/jail.go @@ -1,9 +1,5 @@ package game -import ( - "errors" -) - func (ctx *Context) RemovePlayerFromJail(playerID PlayerID) { for i, iJV := range ctx.Visitors.InJail { if playerID == iJV.VisitorID { @@ -44,28 +40,14 @@ func (ctx *Context) ProcessJail() { } } -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) } diff --git a/game/movement.go b/game/movement.go index 9819009..0b165aa 100644 --- a/game/movement.go +++ b/game/movement.go @@ -23,6 +23,22 @@ func (ctx *Context) ProcessMovement() { } +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)) @@ -59,7 +75,7 @@ func (ctx *Context) AdvancePlayer(playerID PlayerID, currentPosition SpaceID, di 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 @@ -69,7 +85,7 @@ func (ctx *Context) AdvancePlayer(playerID PlayerID, currentPosition SpaceID, di 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 @@ -79,7 +95,7 @@ func (ctx *Context) AdvancePlayer(playerID PlayerID, currentPosition SpaceID, di 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 diff --git a/game/server.go b/game/server.go new file mode 100644 index 0000000..f420e60 --- /dev/null +++ b/game/server.go @@ -0,0 +1,43 @@ +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) +} diff --git a/game/todo b/game/todo index bb30a25..f679787 100644 --- a/game/todo +++ b/game/todo @@ -10,4 +10,14 @@ - 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 + diff --git a/game/types.go b/game/types.go index e0c5b6d..5f7d9dd 100644 --- a/game/types.go +++ b/game/types.go @@ -1,14 +1,17 @@ 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 @@ -189,3 +192,27 @@ type Context struct { 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() +} diff --git a/game/validate.go b/game/validate.go new file mode 100644 index 0000000..b174a95 --- /dev/null +++ b/game/validate.go @@ -0,0 +1,138 @@ +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 +} diff --git a/monopoly-web b/monopoly-web index 0ae8eef..bd523d0 100755 Binary files a/monopoly-web and b/monopoly-web differ diff --git a/public/index.js b/public/index.js index 83c1844..c6af839 100644 --- a/public/index.js +++ b/public/index.js @@ -75,7 +75,7 @@ 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() @@ -89,8 +89,11 @@ 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}`) @@ -103,7 +106,9 @@ 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}`) @@ -116,7 +121,9 @@ 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}`)