diff --git a/.env.example b/.env.example index 44c3c32..0c1d803 100644 --- a/.env.example +++ b/.env.example @@ -1 +1,5 @@ ADMIN_PASSWORD=replace-with-a-long-random-password +MIN_LAT=49.27462710773634 +MIN_LON=-122.91628624024605 +MAX_LAT=49.28099313727333 +MAX_LON=-122.90273076431673 diff --git a/README.md b/README.md index ac173ef..2c57931 100644 --- a/README.md +++ b/README.md @@ -9,13 +9,19 @@ This version of PacMacro consists of a **Go API** accessed under `/api` and two ## Deployment -The backend requires `ADMIN_PASSWORD`. For local development, add it to the ignored `.env` file in the repository root (see `.env.example`): +The backend requires `ADMIN_PASSWORD` and the map bounds shown below. +For local development, add them to the ignored `.env` file in the repository root (see `.env.example`): ```dotenv ADMIN_PASSWORD=replace-with-a-long-random-password +# These are the boundaries for UniverCity +MIN_LAT=49.27462710773634 +MIN_LON=-122.91628624024605 +MAX_LAT=49.28099313727333 +MAX_LON=-122.90273076431673 ``` -The binary loads `.env` from its working directory when present. For the production systemd service, put the same setting in `/etc/pacmacro/pacmacro.env` and retain this service setting: +The binary loads `.env` from its working directory when present. For the production systemd service, put the same variables in `/etc/pacmacro/pacmacro.env` and retain this service setting: ```systemd EnvironmentFile=/etc/pacmacro/pacmacro.env @@ -35,7 +41,7 @@ Install the GoLang toolchain and run `go build -o pacmacro`. ### Backend To build the PacMacro server, run `go build -o pacmacro .` from the root directory. -The backend refuses to start when `ADMIN_PASSWORD` is missing. +The backend refuses to start when `ADMIN_PASSWORD` or any map bound is missing or invalid. ### Frontend diff --git a/api/admin.go b/api/admin.go index 3b90d41..076008f 100644 --- a/api/admin.go +++ b/api/admin.go @@ -171,8 +171,8 @@ func (a *Admin) ServeUpdate(w http.ResponseWriter, r *http.Request) { return } - targetID := strings.TrimPrefix(r.URL.Path, "/api/admin/update/") - _, found, connected := a.players.UpdateConnected( + targetID := PlayerID(strings.TrimPrefix(r.URL.Path, "/api/admin/update/")) + found, connected := a.players.UpdateConnected( targetID, uint64(playerType), uint64(representation), @@ -185,7 +185,7 @@ func (a *Admin) ServeUpdate(w http.ResponseWriter, r *http.Request) { writeJSONError(w, http.StatusConflict) return } - a.sockets.Inform(targetID) + a.sockets.Inform(targetID) w.WriteHeader(http.StatusNoContent) } diff --git a/api/admin_test.go b/api/admin_test.go index 78cfc27..8f20803 100644 --- a/api/admin_test.go +++ b/api/admin_test.go @@ -113,13 +113,16 @@ func TestAdminCookieAuthorizesPlayerUpdate(t *testing.T) { players, admin := newAdminTestState(t, "top-secret") cookie := registerTestAdmin(t, admin, "top-secret") playerID := players.New(TypePlayer, "Player", RepsGhost, StatusConn) + gameConnection := newTestConnection(playerID) + admin.sockets.hub.registerConnection(gameConnection) + _ = receiveTestMessage(t, gameConnection) // initial player snapshot playerType := TypeLeader representation := RepsEdible request := newJSONRequest( t, http.MethodPost, - "/api/admin/update/"+playerID, + "/api/admin/update/"+string(playerID), AdminUpdateRequest{Type: &playerType, Reps: &representation}, ) request.AddCookie(cookie) @@ -133,6 +136,11 @@ func TestAdminCookieAuthorizesPlayerUpdate(t *testing.T) { if player.Type != TypeLeader || player.Reps != RepsEdible { t.Errorf("updated player = type %d reps %d, want type %d reps %d", player.Type, player.Reps, TypeLeader, RepsEdible) } + + updatedPlayer := informPlayer(t, receiveTestMessage(t, gameConnection)) + if updatedPlayer.ID != playerID || updatedPlayer.Type != TypeLeader || updatedPlayer.Reps != RepsEdible { + t.Errorf("informed player = %#v, want updated player %q", updatedPlayer, playerID) + } } func TestAdminUpdateRejectsDisconnectedPlayer(t *testing.T) { @@ -144,7 +152,7 @@ func TestAdminUpdateRejectsDisconnectedPlayer(t *testing.T) { request := newJSONRequest( t, http.MethodPost, - "/api/admin/update/"+playerID, + "/api/admin/update/"+string(playerID), AdminUpdateRequest{Type: &playerType, Reps: &representation}, ) request.AddCookie(cookie) @@ -224,7 +232,7 @@ func TestAdminUpdateRequiresCookieAndRejectsAdminPlayerType(t *testing.T) { unauthorizedRequest := newJSONRequest( t, http.MethodPost, - "/api/admin/update/"+playerID, + "/api/admin/update/"+string(playerID), requestBody, ) unauthorizedResponse := httptest.NewRecorder() @@ -236,7 +244,7 @@ func TestAdminUpdateRequiresCookieAndRejectsAdminPlayerType(t *testing.T) { adminTypeRequest := newJSONRequest( t, http.MethodPost, - "/api/admin/update/"+playerID, + "/api/admin/update/"+string(playerID), requestBody, ) adminTypeRequest.AddCookie(cookie) diff --git a/api/game.go b/api/game.go index 841c65e..2aaf72b 100644 --- a/api/game.go +++ b/api/game.go @@ -4,7 +4,11 @@ package api import ( "fmt" + "math" "net/http" + "os" + "strconv" + "strings" "sync" ) @@ -20,26 +24,58 @@ type Game struct { Height uint64 `json:"height"` } -func (g *Game) Init(players *Players) { - g.players = players +func (g *Game) Init(players *Players) error { + minLatitude, err := requiredEnvironmentFloat("MIN_LAT") + if err != nil { + return err + } + minLongitude, err := requiredEnvironmentFloat("MIN_LON") + if err != nil { + return err + } + maxLatitude, err := requiredEnvironmentFloat("MAX_LAT") + if err != nil { + return err + } + maxLongitude, err := requiredEnvironmentFloat("MAX_LON") + if err != nil { + return err + } + if minLatitude >= maxLatitude { + return fmt.Errorf("MIN_LAT must be less than MAX_LAT") + } + if minLongitude >= maxLongitude { + return fmt.Errorf("MIN_LON must be less than MAX_LON") + } - // hardcoded UniverCity coordinates - // (matches the map used in the HTML) - g.Min.Latitude = 49.27462710773634 - g.Min.Longitude = -122.91628624024605 - g.Max.Latitude = 49.28099313727333 - g.Max.Longitude = -122.90273076431673 + g.players = players + g.Min = Coordinate{Latitude: minLatitude, Longitude: minLongitude} + g.Max = Coordinate{Latitude: maxLatitude, Longitude: maxLongitude} // coordinate size of map g.Width = 32 g.Height = 32 fmt.Print("Game handler initialized.\n") + return nil +} + +func requiredEnvironmentFloat(name string) (float64, error) { + rawValue := strings.TrimSpace(os.Getenv(name)) + if rawValue == "" { + return 0, fmt.Errorf("%s is required", name) + } + + value, err := strconv.ParseFloat(rawValue, 64) + if err != nil || math.IsNaN(value) || math.IsInf(value, 0) { + return 0, fmt.Errorf("%s must be a finite number", name) + } + return value, nil } // /api/game/* func (g *Game) ServeHTTP(w http.ResponseWriter, r *http.Request) { - path := r.URL.Path[10:] + path := strings.TrimPrefix(r.URL.Path, "/api/game/") // GET /api/game/map.json if path == "map.json" { diff --git a/api/game_test.go b/api/game_test.go index 5b2cf23..ea716ee 100644 --- a/api/game_test.go +++ b/api/game_test.go @@ -8,10 +8,14 @@ import ( ) func TestMapUsesJSONObjectResponse(t *testing.T) { + setGameEnvironment(t) + players := new(Players) players.Init() game := new(Game) - game.Init(players) + if err := game.Init(players); err != nil { + t.Fatalf("initialize game: %v", err) + } request := httptest.NewRequest(http.MethodGet, "/api/game/map.json", nil) response := httptest.NewRecorder() game.ServeHTTP(response, request) @@ -28,6 +32,39 @@ func TestMapUsesJSONObjectResponse(t *testing.T) { t.Fatalf("decode map response: %v", err) } if body.Width != game.Width || body.Height != game.Height || body.Min != game.Min || body.Max != game.Max { - t.Errorf("map response = %#v, want %#v", body, game) + t.Errorf( + "map response = min %#v, max %#v, width %d, height %d; want min %#v, max %#v, width %d, height %d", + body.Min, + body.Max, + body.Width, + body.Height, + game.Min, + game.Max, + game.Width, + game.Height, + ) } } + +func TestGameInitUsesEnvironmentBounds(t *testing.T) { + setGameEnvironment(t) + + game := new(Game) + if err := game.Init(new(Players)); err != nil { + t.Fatalf("initialize game: %v", err) + } + + wantMin := Coordinate{Latitude: 49.27462710773634, Longitude: -122.91628624024605} + wantMax := Coordinate{Latitude: 49.28099313727333, Longitude: -122.90273076431673} + if game.Min != wantMin || game.Max != wantMax { + t.Errorf("bounds = min %#v, max %#v; want min %#v, max %#v", game.Min, game.Max, wantMin, wantMax) + } +} + +func setGameEnvironment(t *testing.T) { + t.Helper() + t.Setenv("MIN_LAT", "49.27462710773634") + t.Setenv("MIN_LON", "-122.91628624024605") + t.Setenv("MAX_LAT", "49.28099313727333") + t.Setenv("MAX_LON", "-122.90273076431673") +} diff --git a/api/hub.go b/api/hub.go new file mode 100644 index 0000000..fa8dbcc --- /dev/null +++ b/api/hub.go @@ -0,0 +1,238 @@ +package api + +import ( + "encoding/json" + "fmt" +) + +// Hub owns the connections and handles communication. +type Hub struct { + // This map acts like a set. + // The value is a dummy to test membership in the set. + players *Players + connections map[*Conn]struct{} + coordinates map[PlayerID]Coordinate + + register chan *Conn // adds a connection + unregister chan *Conn // removes a connection + move chan moveEvent // channel for movement + inform chan PlayerID // channel for other communication +} + +func NewHub(players *Players) *Hub { + return &Hub{ + players: players, + connections: make(map[*Conn]struct{}), + coordinates: make(map[PlayerID]Coordinate), + register: make(chan *Conn), + unregister: make(chan *Conn), + move: make(chan moveEvent), + inform: make(chan PlayerID), + } +} + +func (h *Hub) Run() { + for { + select { + case connection := <-h.register: + h.registerConnection(connection) + + case connection := <-h.unregister: + h.unregisterConnection(connection) + + case event := <-h.move: + if _, exists := h.connections[event.connection]; !exists { + continue + } + playerID := event.connection.playerID + h.coordinates[playerID] = event.coord + h.broadcastMove(playerID, event.coord) + + case playerID := <-h.inform: + h.broadcastInform(playerID, nil) + } + } +} + +func (h *Hub) hasConnectionForID(id PlayerID) bool { + for connection := range h.connections { + if connection.playerID == id { + return true + } + } + + return false +} + +func (h *Hub) registerConnection(connection *Conn) { + // Don't register the same connection twice + if _, exists := h.connections[connection]; exists { + return + } + + // Check if the player is creating a separate connection + // e.g. from opening a new tab + firstConnection := !h.hasConnectionForID(connection.playerID) + h.connections[connection] = struct{}{} + + // If it's the player's first connection, give them coordinates + if firstConnection { + h.coordinates[connection.playerID] = Coordinate{} + h.players.SetStatus(connection.playerID, StatusConn) + } + + if !h.sendSnapshot(connection) { + h.unregisterConnection(connection) + return + } + + // Existing clients only need an announcement when + // this is the first player's active connection + if firstConnection { + h.broadcastInform(connection.playerID, connection) + } +} + +func (h *Hub) unregisterConnection(connection *Conn) { + // Ensure the connection exists before unregistering + if _, exists := h.connections[connection]; !exists { + return + } + + delete(h.connections, connection) + + if connection.socket != nil { + _ = connection.socket.Close() + } + close(connection.send) + + // The player may still have another connection, don't clean up everything else + if h.hasConnectionForID(connection.playerID) { + return + } + + delete(h.coordinates, connection.playerID) + + h.players.SetStatus(connection.playerID, StatusDisc) + + // When a player's coordinates are (0.0, 0.0) they should be removed + // from the game map + h.broadcastMove(connection.playerID, Coordinate{}) +} + +// sendSnapshot will create a game state to send to new connections +func (h *Hub) sendSnapshot(target *Conn) bool { + // Players may have multiple connections so we keep track of ones we have seen. + seen := make(map[PlayerID]struct{}) + + // Only iterate through players that have active connections + for connection := range h.connections { + playerID := connection.playerID + + // We've already recorded a player's position, so avoid recording it again + if _, exists := seen[playerID]; exists { + continue + } + seen[playerID] = struct{}{} + + message, ok := h.informMessage(playerID) + if !ok { + continue + } + + if !h.enqueue(target, message) { + return false + } + } + + return true +} + +// broadcast sends a message to all connections except the origin. +func (h *Hub) broadcast(message []byte, origin *Conn) { + var slowConnections []*Conn + + for connection := range h.connections { + if connection == origin { + continue + } + + if !h.enqueue(connection, message) { + slowConnections = append(slowConnections, connection) + } + } + + for _, connection := range slowConnections { + h.unregisterConnection(connection) + } +} + +func (h *Hub) enqueue(connection *Conn, message []byte) bool { + select { + case connection.send <- message: + return true + default: + // The queue is full, means the connection is slow + return false + } +} + +// informMessage constructs the message to send. +func (h *Hub) informMessage(playerID PlayerID) ([]byte, bool) { + player, exists := h.playerResponse(playerID) + + if !exists { + return nil, false + } + + playerJSON, err := json.Marshal(player) + if err != nil { + return nil, false + } + + messageJSON, err := json.Marshal(Message{ + Coord: h.coordinates[playerID], + Command: CMD_INFORM, + Data: string(playerJSON), + }) + if err != nil { + fmt.Printf("Error marshalling inform message: %v\n", err) + return nil, false + } + + return messageJSON, true +} + +// broadcastInform sends a message to all connection except the origin +func (h *Hub) broadcastInform(playerID PlayerID, origin *Conn) { + message, ok := h.informMessage(playerID) + if !ok { + return + } + + h.broadcast(message, origin) +} + +func (h *Hub) playerResponse(playerID PlayerID) (PlayerResponse, bool) { + for _, player := range h.players.List() { + if player.ID == playerID { + return player, true + } + } + + return PlayerResponse{}, false +} + +func (h *Hub) broadcastMove(playerID PlayerID, coordinate Coordinate) { + message, err := json.Marshal(Message{ + Coord: coordinate, + Command: CMD_MOVE, + Data: string(playerID), + }) + if err != nil { + fmt.Printf("Error marshalling move message: %v\n", err) + return + } + + h.broadcast(message, nil) +} diff --git a/api/player.go b/api/player.go index 6697220..8f86aa1 100644 --- a/api/player.go +++ b/api/player.go @@ -11,21 +11,23 @@ import ( "sync" ) +type PlayerID string + type PlayerRegistrationRequest struct { Type *int `json:"type"` Name string `json:"name"` } type PlayerRegistrationResponse struct { - ID string `json:"id"` + ID PlayerID `json:"id"` } type PlayerResponse struct { - ID string `json:"id"` - Type uint64 `json:"type"` - Name string `json:"name"` - Reps uint64 `json:"reps"` - Status uint64 `json:"status"` + ID PlayerID `json:"id"` + Type uint64 `json:"type"` + Name string `json:"name"` + Reps uint64 `json:"reps"` + Status uint64 `json:"status"` } // zero-value player: player, pacman, disconnected @@ -36,12 +38,12 @@ type Player struct { Status uint64 `json:"status"` } -func (p *Player) Format(ID string) string { +func (p *Player) Format(ID PlayerID) string { JSON, _ := json.Marshal(newPlayerResponse(ID, p)) return string(JSON) } -func newPlayerResponse(ID string, player *Player) PlayerResponse { +func newPlayerResponse(ID PlayerID, player *Player) PlayerResponse { return PlayerResponse{ ID: ID, Type: player.Type, @@ -52,13 +54,13 @@ func newPlayerResponse(ID string, player *Player) PlayerResponse { } type Players struct { - players map[string]*Player + players map[PlayerID]*Player mutex sync.Mutex observer func(PlayerResponse) } func (p *Players) Init() { - p.players = make(map[string]*Player) + p.players = make(map[PlayerID]*Player) fmt.Print("Players handler initialized.\n") } @@ -73,10 +75,10 @@ func (p *Players) notify(response PlayerResponse) { } } -func (p *Players) New(t uint64, name string, reps uint64, status uint64) string { +func (p *Players) New(t uint64, name string, reps uint64, status uint64) PlayerID { p.mutex.Lock() - var ID string + var ID PlayerID for { // create random session ID @@ -85,7 +87,7 @@ func (p *Players) New(t uint64, name string, reps uint64, status uint64) string ID_b[i] = id_letters[rand.Intn(len(id_letters))] } - ID = string(ID_b) + ID = PlayerID(ID_b) // break if this ID isn't in use if _, found := p.players[ID]; !found { @@ -108,14 +110,14 @@ func (p *Players) New(t uint64, name string, reps uint64, status uint64) string return ID } -func (p *Players) Delete(ID string) { +func (p *Players) Delete(ID PlayerID) { p.mutex.Lock() defer p.mutex.Unlock() delete(p.players, ID) } -func (p *Players) SetStatus(ID string, status uint64) { +func (p *Players) SetStatus(ID PlayerID, status uint64) { p.mutex.Lock() player, found := p.players[ID] if found { @@ -132,7 +134,7 @@ func (p *Players) SetStatus(ID string, status uint64) { } } -func (p *Players) Get(ID string) *Player { +func (p *Players) Get(ID PlayerID) *Player { p.mutex.Lock() defer p.mutex.Unlock() @@ -154,20 +156,27 @@ func (p *Players) List() []PlayerResponse { return players } +// UpdateConnected updates a connected player. Potential return values are: +// +// (false, false) - player not found +// +// (true, false) - player is found, but not connected +// +// (true, true) - player exists and is found func (p *Players) UpdateConnected( - ID string, + ID PlayerID, playerType uint64, representation uint64, -) (PlayerResponse, bool, bool) { +) (bool, bool) { p.mutex.Lock() player, found := p.players[ID] if !found { p.mutex.Unlock() - return PlayerResponse{}, false, false + return false, false } if player.Status != StatusConn { p.mutex.Unlock() - return PlayerResponse{}, true, false + return true, false } player.Type = playerType @@ -175,7 +184,7 @@ func (p *Players) UpdateConnected( response := newPlayerResponse(ID, player) p.mutex.Unlock() p.notify(response) - return response, true, true + return true, true } // /api/player/* @@ -229,7 +238,7 @@ func (p *Players) ServeRegister(w http.ResponseWriter, r *http.Request) { t := *request.Type name := request.Name - var ID string + var ID PlayerID if t < TypePlayer || t > TypeLeader { // admins register separately writeJSONError(w, http.StatusBadRequest) diff --git a/api/socket.go b/api/socket.go index ff99504..5688148 100644 --- a/api/socket.go +++ b/api/socket.go @@ -6,268 +6,134 @@ import ( "encoding/json" "fmt" "net/http" + "strings" "sync" + "time" ws "github.com/gorilla/websocket" ) -type Conn struct { - c *ws.Conn - coord Coordinate - id string +const ( + socketSendQueueSize = 256 + socketWriteTimeout = 10 * time.Second +) + +type moveEvent struct { + connection *Conn + coord Coordinate } type Sockets struct { // private players *Players - conn []*Conn // active connections; nil if broken - mutex sync.Mutex + hub *Hub } func (s *Sockets) Init(players *Players) { s.players = players + s.hub = NewHub(players) + + go s.hub.Run() fmt.Print("Sockets handler initialized.\n") } -func (s *Sockets) Find(id string) *Conn { - s.mutex.Lock() - defer s.mutex.Unlock() - - for _, conn := range s.conn { - if conn == nil { - continue - } - - if conn.id == id { - return conn - } - } - - return nil +func (s *Sockets) Inform(playerID PlayerID) { + s.hub.inform <- playerID } -func (s *Sockets) Inform(id string) { - s.mutex.Lock() - defer s.mutex.Unlock() - - p := s.players.Get(id) - if p == nil { - return // player doesn't exist - } - - var msg Message - for _, conn := range s.conn { - // was disconnected - if conn == nil { - continue - } +type Conn struct { + socket *ws.Conn + playerID PlayerID + send chan []byte - if conn.id == id { - msg.Coord = conn.coord - } - } - msg.Command = CMD_INFORM - msg.Data = p.Format(id) + unregisterOnce sync.Once +} - msg_json, err := json.Marshal(msg) - if err != nil { - return - } +func (c *Conn) unregister(hub *Hub) { + c.unregisterOnce.Do(func() { + hub.unregister <- c + }) +} - // for every connection - for _, conn := range s.conn { - if conn == nil { - continue +// writePump the only goroutine that writes to a WebSocket +func (c *Conn) writePump(hub *Hub) { + defer func() { + _ = c.socket.Close() + c.unregister(hub) + }() + + for message := range c.send { + _ = c.socket.SetWriteDeadline(time.Now().Add(socketWriteTimeout)) + if err := c.socket.WriteMessage( + ws.TextMessage, + message, + ); err != nil { + return } - - // inform - conn.c.WriteMessage(ws.TextMessage, msg_json) } } -func (s *Sockets) Informs() []string { - s.mutex.Lock() - defer s.mutex.Unlock() - - var ret []string +// readPump the only goroutine that reads from a Websocket +func (c *Conn) readPump(hub *Hub) error { + defer c.unregister(hub) - for _, conn := range s.conn { - // disconnected - if conn == nil { - continue - } - - p := s.players.Get(conn.id) - // invalid connection - if p == nil { - continue - } - - var msg Message - msg.Coord = conn.coord - msg.Command = CMD_INFORM - msg.Data = p.Format(conn.id) - - msg_json, err := json.Marshal(msg) + for { + messageType, data, err := c.socket.ReadMessage() if err != nil { - continue + return err } - ret = append(ret, string(msg_json)) - } - - return ret -} - -// move a player; notify connections -func (s *Sockets) Move(conn_i int, coord Coordinate) { - s.mutex.Lock() - defer s.mutex.Unlock() - - conn := s.conn[conn_i] - conn.coord = coord - - var msg Message - msg.Coord = coord - msg.Command = CMD_MOVE - msg.Data = conn.id - - msg_json, err := json.Marshal(msg) - if err != nil { - return // failure - } + if messageType == ws.CloseMessage { + return nil + } - // for every connection - for _, conn := range s.conn { - // check if unactive - if conn == nil { + var coordinate Coordinate + if err := json.Unmarshal(data, &coordinate); err != nil { continue } - // send update message - conn.c.WriteMessage(ws.TextMessage, msg_json) - } -} - -func (s *Sockets) Connect(c *ws.Conn, id string) int { - s.mutex.Lock() - defer s.mutex.Unlock() - - p := s.players.Get(id) - if p == nil { - return -1 // player doesn't exist - } - - var conn *Conn - conn = new(Conn) - conn.c = c - conn.id = id - conn_i := -1 - - // iterate over active connections - for i := range s.conn { - if s.conn[i] == nil { - s.conn[i] = conn - conn_i = i - break + hub.move <- moveEvent{ + connection: c, + coord: coordinate, } } - - // if there are no empty spaces; append - if conn_i == -1 { - conn_i = len(s.conn) - s.conn = append(s.conn, conn) - } - s.players.SetStatus(id, StatusConn) - - return conn_i } -func (s *Sockets) Disconnect(conn_i int) { - s.mutex.Lock() - defer s.mutex.Unlock() - - connection := s.conn[conn_i] - if connection == nil { +// WS /api/ws/ +// ServeHTTP upgrades the connection to a websocket connection +func (s *Sockets) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeJSONError(w, http.StatusMethodNotAllowed) return } - s.conn[conn_i] = nil - for _, activeConnection := range s.conn { - if activeConnection != nil && activeConnection.id == connection.id { - return - } - } - s.players.SetStatus(connection.id, StatusDisc) -} -// WS /api/ws/ -func (s *Sockets) ServeHTTP(w http.ResponseWriter, r *http.Request) { - ID := r.URL.Path[8:] - if s.players.Get(ID) == nil { - http.Error(w, - http.StatusText(http.StatusBadRequest), - http.StatusBadRequest) + playerID := PlayerID(strings.TrimPrefix(r.URL.Path, "/api/ws/")) + if s.players.Get(playerID) == nil { + writeJSONError(w, http.StatusBadRequest) return } // attempt to upgrade connection to websocket connection - conn, err := Upgrader.Upgrade(w, r, nil) + socket, err := Upgrader.Upgrade(w, r, nil) if err != nil { - http.Error(w, - http.StatusText(http.StatusBadRequest), - http.StatusBadRequest) return } - defer conn.Close() - // add connection - conn_i := s.Connect(conn, ID) - if conn_i == -1 { - return // error + connection := &Conn{ + socket: socket, + playerID: playerID, + send: make(chan []byte, socketSendQueueSize), } - defer s.Disconnect(conn_i) - fmt.Printf("Sockets\tServeHTTP (/api/ws/):\tID %q: Connection opened.\n", ID) - - // inform new connection of existing connections - informs := s.Informs() - for _, msg := range informs { - conn.WriteMessage(ws.TextMessage, []byte(msg)) - } + go connection.writePump(s.hub) + s.hub.register <- connection - // let existing connections know about this player - s.Inform(ID) + fmt.Printf("Sockets\tServeHTTP (/api/ws/):\tID %q: Connection opened.\n", playerID) - // hold connection open; receive location information - for { - // receive message from connection - msgType, msg, err := conn.ReadMessage() - // check if either the connection failed or was closed - if err != nil || msgType == ws.CloseMessage { - fmt.Printf("Sockets\tServeHTTP (/api/ws/):\tID %q: Connection closed", ID) - - if err != nil { - fmt.Printf(": %v.\n", err) - } else { - fmt.Print(" by user.") - } - - // exit loop; close goroutine - break - } - - var coord Coordinate - if err := json.Unmarshal(msg, &coord); err != nil { - continue - } - - s.Move(conn_i, coord) + if err := connection.readPump(s.hub); err != nil { + fmt.Printf("Sockets\tServeHTTP (/api/ws/):\tID %q: Connection closed by error: %v.\n", playerID, err) + } else { + fmt.Printf("Sockets\tServeHTTP (/api/ws/):\tID %q: Connection closed by user.\n", playerID) } - - // on disconnect, tell everyone you moved to 0.0, 0.0, so you disappear from the map - var coord Coordinate - coord.Latitude = 0.0 - coord.Longitude = 0.0 - s.Move(conn_i, coord) } diff --git a/api/socket_test.go b/api/socket_test.go index a854d75..43ed3c5 100644 --- a/api/socket_test.go +++ b/api/socket_test.go @@ -1,30 +1,205 @@ package api -import "testing" +import ( + "encoding/json" + "testing" + "time" +) func TestPlayerStaysConnectedUntilLastSocketDisconnects(t *testing.T) { players := new(Players) players.Init() - sockets := new(Sockets) - sockets.Init(players) playerID := players.New(TypePlayer, "Test", RepsNothing, StatusDisc) + hub := NewHub(players) - firstConnection := sockets.Connect(nil, playerID) - secondConnection := sockets.Connect(nil, playerID) - if firstConnection < 0 || secondConnection < 0 { - t.Fatal("connect player sockets") - } + firstConnection := newTestConnection(playerID) + secondConnection := newTestConnection(playerID) + hub.registerConnection(firstConnection) + hub.registerConnection(secondConnection) if player := players.Get(playerID); player == nil || player.Status != StatusConn { t.Fatalf("player status after connect = %#v, want connected", player) } - sockets.Disconnect(firstConnection) + hub.unregisterConnection(firstConnection) if player := players.Get(playerID); player == nil || player.Status != StatusConn { t.Errorf("player status after first disconnect = %#v, want connected", player) } - sockets.Disconnect(secondConnection) + hub.unregisterConnection(secondConnection) if player := players.Get(playerID); player == nil || player.Status != StatusDisc { t.Errorf("player status after last disconnect = %#v, want disconnected", player) } } + +func TestRegisterConnectionSendsSnapshotAndJoinAnnouncement(t *testing.T) { + players := new(Players) + players.Init() + firstPlayerID := players.New(TypePlayer, "First", RepsPacman, StatusDisc) + secondPlayerID := players.New(TypeLeader, "Second", RepsGhost, StatusDisc) + hub := NewHub(players) + + firstConnection := newTestConnection(firstPlayerID) + hub.registerConnection(firstConnection) + firstSnapshot := receiveTestMessage(t, firstConnection) + if player := informPlayer(t, firstSnapshot); player.ID != firstPlayerID { + t.Errorf("first snapshot player ID = %q, want %q", player.ID, firstPlayerID) + } + + secondConnection := newTestConnection(secondPlayerID) + hub.registerConnection(secondConnection) + + snapshotPlayers := make(map[PlayerID]PlayerResponse) + for range 2 { + message := receiveTestMessage(t, secondConnection) + player := informPlayer(t, message) + snapshotPlayers[player.ID] = player + } + if len(snapshotPlayers) != 2 { + t.Fatalf("snapshot players = %d, want 2", len(snapshotPlayers)) + } + if _, exists := snapshotPlayers[firstPlayerID]; !exists { + t.Errorf("snapshot does not contain first player %q", firstPlayerID) + } + if _, exists := snapshotPlayers[secondPlayerID]; !exists { + t.Errorf("snapshot does not contain second player %q", secondPlayerID) + } + + joinMessage := receiveTestMessage(t, firstConnection) + if player := informPlayer(t, joinMessage); player.ID != secondPlayerID { + t.Errorf("join announcement player ID = %q, want %q", player.ID, secondPlayerID) + } +} + +func TestSnapshotDeduplicatesMultipleConnectionsForPlayer(t *testing.T) { + players := new(Players) + players.Init() + playerID := players.New(TypePlayer, "Player", RepsPacman, StatusDisc) + observerID := players.New(TypeLeader, "Observer", RepsNothing, StatusDisc) + hub := NewHub(players) + + firstConnection := newTestConnection(playerID) + secondConnection := newTestConnection(playerID) + hub.registerConnection(firstConnection) + hub.registerConnection(secondConnection) + + observerConnection := newTestConnection(observerID) + hub.registerConnection(observerConnection) + + snapshotIDs := make(map[PlayerID]int) + for range 2 { + player := informPlayer(t, receiveTestMessage(t, observerConnection)) + snapshotIDs[player.ID]++ + } + if snapshotIDs[playerID] != 1 { + t.Errorf("player appears in snapshot %d times, want 1", snapshotIDs[playerID]) + } + if snapshotIDs[observerID] != 1 { + t.Errorf("observer appears in snapshot %d times, want 1", snapshotIDs[observerID]) + } +} + +func TestBroadcastMoveQueuesMessageForEveryConnection(t *testing.T) { + players := new(Players) + players.Init() + firstPlayerID := players.New(TypePlayer, "First", RepsPacman, StatusDisc) + secondPlayerID := players.New(TypePlayer, "Second", RepsGhost, StatusDisc) + hub := NewHub(players) + firstConnection := newTestConnection(firstPlayerID) + secondConnection := newTestConnection(secondPlayerID) + hub.registerConnection(firstConnection) + drainTestMessages(firstConnection) + hub.registerConnection(secondConnection) + drainTestMessages(firstConnection) + drainTestMessages(secondConnection) + + coordinate := Coordinate{Latitude: 49.27, Longitude: -122.91} + hub.coordinates[firstPlayerID] = coordinate + hub.broadcastMove(firstPlayerID, coordinate) + + for _, connection := range []*Conn{firstConnection, secondConnection} { + message := receiveTestMessage(t, connection) + if message.Command != CMD_MOVE { + t.Errorf("movement command = %q, want %q", message.Command, CMD_MOVE) + } + if message.Data != string(firstPlayerID) { + t.Errorf("movement player ID = %q, want %q", message.Data, firstPlayerID) + } + if message.Coord != coordinate { + t.Errorf("movement coordinate = %#v, want %#v", message.Coord, coordinate) + } + } +} + +func TestBroadcastDisconnectsConnectionWithFullQueue(t *testing.T) { + players := new(Players) + players.Init() + playerID := players.New(TypePlayer, "Slow", RepsPacman, StatusDisc) + hub := NewHub(players) + connection := &Conn{ + playerID: playerID, + send: make(chan []byte, 1), + } + hub.registerConnection(connection) + + hub.broadcastMove(playerID, Coordinate{Latitude: 49.27, Longitude: -122.91}) + + if _, exists := hub.connections[connection]; exists { + t.Error("slow connection remains registered") + } + if _, exists := hub.coordinates[playerID]; exists { + t.Error("slow player's coordinate remains registered") + } + if player := players.Get(playerID); player == nil || player.Status != StatusDisc { + t.Errorf("slow player status = %#v, want disconnected", player) + } +} + +func newTestConnection(playerID PlayerID) *Conn { + return &Conn{ + playerID: playerID, + send: make(chan []byte, socketSendQueueSize), + } +} + +func receiveTestMessage(t *testing.T, connection *Conn) Message { + t.Helper() + select { + case data, open := <-connection.send: + if !open { + t.Fatal("connection message queue is closed") + } + var message Message + if err := json.Unmarshal(data, &message); err != nil { + t.Fatalf("decode connection message: %v", err) + } + return message + case <-time.After(time.Second): + t.Fatal("timed out waiting for a connection message") + return Message{} + } +} + +func informPlayer(t *testing.T, message Message) PlayerResponse { + t.Helper() + if message.Command != CMD_INFORM { + t.Fatalf("message command = %q, want %q", message.Command, CMD_INFORM) + } + var player PlayerResponse + if err := json.Unmarshal([]byte(message.Data), &player); err != nil { + t.Fatalf("decode informed player: %v", err) + } + return player +} + +func drainTestMessages(connection *Conn) { + for { + select { + case _, open := <-connection.send: + if !open { + return + } + default: + return + } + } +} diff --git a/frontend/src/app/core/game-socket.service.spec.ts b/frontend/src/app/core/game-socket.service.spec.ts index 870db7f..f4d94cf 100644 --- a/frontend/src/app/core/game-socket.service.spec.ts +++ b/frontend/src/app/core/game-socket.service.spec.ts @@ -72,6 +72,25 @@ describe('GameSocketService', () => { expect(service.state()).toBe('connected'); }); + it('stays active while the game tab is hidden', () => { + const originalVisibilityState = Object.getOwnPropertyDescriptor(document, 'visibilityState'); + Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'hidden' }); + + try { + service.start('ABCD', () => undefined); + + expect(MockWebSocket.instances).toHaveLength(1); + MockWebSocket.instances[0].open(); + expect(service.state()).toBe('connected'); + } finally { + if (originalVisibilityState) { + Object.defineProperty(document, 'visibilityState', originalVisibilityState); + } else { + Reflect.deleteProperty(document, 'visibilityState'); + } + } + }); + it('accepts inform and move messages without unsafe rendering', () => { service.start('ABCD', () => undefined); const socket = MockWebSocket.instances[0]; diff --git a/frontend/src/app/core/game-socket.service.ts b/frontend/src/app/core/game-socket.service.ts index fc3b35d..2c9d1c8 100644 --- a/frontend/src/app/core/game-socket.service.ts +++ b/frontend/src/app/core/game-socket.service.ts @@ -41,12 +41,6 @@ export class GameSocketService { this.status.set('Offline. Waiting for a network connection…'); return; } - if (this.browserWindow.document.visibilityState === 'hidden') { - this.state.set('suspended'); - this.status.set('Paused while the page is hidden.'); - return; - } - this.intentionallyClosed = false; this.connect(); } @@ -115,7 +109,7 @@ export class GameSocketService { return; } // A reconnect receives a fresh list of active players from the server. - // Clear missed disconnects from a period where this page was sleeping. + // Clear missed disconnects from a period when this connection was unavailable. this.players.set({}); this.reconnectAttempt = 0; this.state.set('connected'); @@ -150,12 +144,6 @@ export class GameSocketService { this.status.set('Offline. Waiting for a network connection…'); return; } - if (this.browserWindow.document.visibilityState === 'hidden') { - this.state.set('suspended'); - this.status.set('Paused while the page is hidden.'); - return; - } - const delay = RECONNECT_DELAYS[Math.min(this.reconnectAttempt, RECONNECT_DELAYS.length - 1)]; this.reconnectAttempt += 1; this.status.set(`Connection lost. Retrying in ${delay / 1000} s…`); diff --git a/frontend/src/app/pages/game-page/game-page.component.ts b/frontend/src/app/pages/game-page/game-page.component.ts index 011a39e..b835f6a 100644 --- a/frontend/src/app/pages/game-page/game-page.component.ts +++ b/frontend/src/app/pages/game-page/game-page.component.ts @@ -47,12 +47,6 @@ export class GamePageComponent { }); private readonly onVisibilityChange = () => { - if (this.browserWindow?.document.visibilityState === 'hidden') { - this.geolocation.stop(); - this.socket.suspend(); - return; - } - this.socket.resume(); void this.wakeLock.handleVisibilityChange(); }; private readonly onOnline = () => this.socket.resume(); diff --git a/htdocs/admin.html b/htdocs/admin.html deleted file mode 100644 index fca3297..0000000 --- a/htdocs/admin.html +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - Admin | PacMacro - - - - -

Admin

-
- -
- -
-
-

All good.

- - -
- -
- - diff --git a/htdocs/create.html b/htdocs/create.html deleted file mode 100644 index 842a27d..0000000 --- a/htdocs/create.html +++ /dev/null @@ -1,82 +0,0 @@ - - - - - - Create Utilities | PacMacro - - - - - -

Create Utilities

- -
-

Admin Information

- - -
- - -
- - -

...

- -
-

Map Configuration

- - -

Set Map

- - - - -

...

- -
- -

Map Bounds

- -
- -
- -
- -
- -
- -
- -
- -

Map Scale

- -
- -
- -
- -
- -
- -
- -
- -

Populate Map

- - - - - -

...

- - - diff --git a/htdocs/css/index.css b/htdocs/css/index.css deleted file mode 100644 index 21e52e2..0000000 --- a/htdocs/css/index.css +++ /dev/null @@ -1,33 +0,0 @@ -#self-summary { - position: fixed; - left: 50vw; - top: 168px; - transform: translateX(-50%); - font-size: 16pt; - font-weight: 400; -} -#pacmacro-status { - position: fixed; - left: 50vw; - top: 192px; - transform: translateX(-50%); - font-size: 10pt; - font-weight: 400; -} -#pacmacro-canvas { - z-index: 99; - position: fixed; - top: 240px; - left: 50%; - transform: translateX(-50%); - width: calc(min(480px, 100vw)); /* canvas should be stretched */ - border: 4px solid gray; -} -#coords { - position: absolute; -} - -#latlong { - position: absolute; - top: 2rem; -} diff --git a/htdocs/css/main.css b/htdocs/css/main.css deleted file mode 100644 index 57462c3..0000000 --- a/htdocs/css/main.css +++ /dev/null @@ -1,63 +0,0 @@ -body { - position: relative; - margin: 0; - padding: 0; - font-family: "Inter", sans-serif; - background-color: black; - color: white; -} -body.admin { - background-color: white; - color: black; - padding: 16px; -} -h1, h2, h3, p { - margin: 8px 0; -} -input, select, button { - margin: 2px; - border-radius: 4px; - border: 1px solid #a0a0a0; - padding: 4px; - background-color: white; - color: black; -} -.form { - border: 1px solid #808080; - padding: 32px; - background-color: #f0f0f0; - color: black; -} -.center { - position: fixed; - z-index: 99; - left: 50vw; - top: 50vh; - transform: translate(-50%, -50%); - width: 100vw; - max-width: 512px; - height: fit-content; -} -div.list { - padding: 32px; -} -div.player { - display: flex; - flex-direction: row; - flex-wrap: nowrap; - justify-content: flex-start; - justify-items: flex-start; - align-content: center; - align-items: center; - width: 100%; - height: 48px; - border-bottom: 1px solid #d0d0d0; -} -div.player.hidden { - opacity: 0.25; -} -div.player h1 { - margin: 0 8px; - font-size: 12pt; - font-weight: 400; -} diff --git a/htdocs/css/ribbons.css b/htdocs/css/ribbons.css deleted file mode 100644 index 8741808..0000000 --- a/htdocs/css/ribbons.css +++ /dev/null @@ -1,51 +0,0 @@ -header { - position: fixed; - top: 0; - left: 0; - z-index: 0; - background-color: black; - margin: 0; - padding :0; - width: 100vw; - height: 160px; -} -header img.top-grid, header img.bottom-grid { - position: absolute; - z-index: 2; - left: 50%; - height: 33%; -} -header img.top-grid { - top: 0; - transform: translateX(-50%); -} -header img.bottom-grid { - bottom: 0; - transform: translateX(-50%) rotate(180deg); -} -header img.pacmacro-logo { - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - height: 64px; - filter: drop-shadow(0 0 8px #f30180); -} -footer { - position: fixed; - left: 0; - right: 0; - bottom: 0; - height: 120px; - display: flex; - flex-direction: row; - flex-wrap: nowrap; - justify-content: center; - justify-items: center; - align-content: center; - align-items: center; - filter: drop-shadow(0 0 8px #f30180); -} -footer img.frosh-logo { - height: 80px; -} diff --git a/htdocs/index.html b/htdocs/index.html deleted file mode 100644 index ed05099..0000000 --- a/htdocs/index.html +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - PacMacro - - - - - - - -

-
-
-

Joining PacMacro...

- - - diff --git a/htdocs/js/admin.js b/htdocs/js/admin.js deleted file mode 100644 index 7fd8895..0000000 --- a/htdocs/js/admin.js +++ /dev/null @@ -1,189 +0,0 @@ -// admin.js -// for admin.html - -import { NTYPE, NREPS, type, reps } from "./pacmacro.js"; - -window.onload = () => { - let stat = document.getElementById("status"); - let load = document.getElementById("load"); - let list = document.getElementById("list"); - - stat.innerHTML = "--"; // no status - - document.getElementById("all_edible").onclick = async () => { - let players; - try { - players = await fetch("/api/player/list.json"); - if (!players.ok) { - throw "Response not OK."; - } - - players = await players.json(); - } catch { - list.innerHTML = ` -

There was a problem contacting the API.

- `; - return; - } - - let id = document.getElementById("id"); - let pass = document.getElementById("pass"); - - for (const p of players) { - if (p.reps != 3 || p.type == 2) continue; - - let form = new FormData(); - - form.append("id", id.value); - form.append("pass", pass.value); - form.append("type", p.type); - form.append("reps", 4); // 4 is RepsEdible - - try { - let resp = await fetch(`/api/admin/update/${p.id}`, { - method: "POST", - body: form, - }); - if (resp.ok) { - stat.innerHTML = `Success ${p.id}`; - } - } catch {} - } - list.innerHTML = "Please load players again."; - }; - - document.getElementById("all_nothing").onclick = async () => { - let players; - try { - players = await fetch("/api/player/list.json"); - if (!players.ok) { - throw "Response not OK."; - } - - players = await players.json(); - } catch { - list.innerHTML = ` -

There was a problem contacting the API.

- `; - return; - } - - let id = document.getElementById("id"); - let pass = document.getElementById("pass"); - - for (const p of players) { - // don't change admin - if (p.type == 2) continue; - - let form = new FormData(); - - form.append("id", id.value); - form.append("pass", pass.value); - form.append("type", p.type); - form.append("reps", 0); // 0 is RepsNothing - - try { - let resp = await fetch(`/api/admin/update/${p.id}`, { - method: "POST", - body: form, - }); - if (resp.ok) { - stat.innerHTML = `Success ${p.id}`; - } - } catch {} - } - list.innerHTML = "Please load players again."; - }; - - load.onclick = async () => { - list.innerHTML = ""; // clear list - - let players; - try { - players = await fetch("/api/player/list.json"); - if (!players.ok) { - throw "Response not OK."; - } - - players = await players.json(); - } catch { - list.innerHTML = ` -

There was a problem contacting the API.

- `; - return; - } - // players is a list of all online players - - for (let i = 0; i < players.length; i++) { - const p = players[i]; - - let types = ""; - for (let j = 0; j < NTYPE; j++) { - types += ``; - } - - let reps_ = ""; - for (let j = 0; j < NREPS; j++) { - reps_ += ``; - } - - let player = document.createElement("div"); - player.classList.add("player"); - - if (p.type == 3) - // TypeHidden - player.classList.add("hidden"); - - player.innerHTML = ` -

${p.name} (${p.id})

- - - `; - - let submit = document.createElement("button"); - submit.onclick = eval(` - async () => { - let id = document.getElementById("id"); - let pass = document.getElementById("pass"); - let stat = document.getElementById("status"); - let _type = document.getElementById("type${i}"); - let _reps = document.getElementById("reps${i}"); - - let form = new FormData(); - - form.append("id", id.value); - form.append("pass", pass.value); - form.append("type", _type.value); - form.append("reps", _reps.value); - - try { - let resp = await fetch("/api/admin/update/${p.id}", { - method: "POST", - body: form - }); - if (!resp.ok) { - stat.innerHTML = "Response not OK."; - } else { - stat.innerHTML = "Success."; - setTimeout(() => { - stat.innerHTML = "--"; - }, 2000); // after two seconds, update status - } - } catch { - stat.innerHTML = "Error: couldn't contact API."; - } - } - `); - submit.innerHTML = `Update ${p.id}`; - - player.appendChild(submit); - list.appendChild(player); - } - }; -}; diff --git a/htdocs/js/create.js b/htdocs/js/create.js deleted file mode 100644 index c0d6d15..0000000 --- a/htdocs/js/create.js +++ /dev/null @@ -1,232 +0,0 @@ -// create.js -// programming for the Create Utilities page (create.html) - -import { - WS, - URL_ROOT, - EXPAND_X, - EXPAND_Y, - pacmacro_init, - watchLocation, - stopWatchLocation, - convertCoords, -} from "./pacmacro.js"; - -window.onload = async () => { - pacmacro_init(); - - /* ADMIN INFORMATION */ - - let admin_id = document.getElementById("admin-id"); - let admin_pass = document.getElementById("admin-pass"); - - /* - document.getElementById("load-id").onclick = () => { - admin_id.value = getID(); - } - */ - - /* REGISTRATION */ - - let register_button = document.getElementById("register-button"); - let register_status = document.getElementById("register-status"); - - register_button.onclick = async () => { - const form = new FormData(); - - form.append("type", "2"); // TypeAdmin - form.append("pass", admin_pass.value); - - let resp; - - // attempt to register admin - try { - resp = await fetch("/api/player/register", { - method: "POST", - body: form, - }); - } catch { - // on fetch error - register_status.innerHTML = "Error"; - } - - if (resp.ok) { - let ID = await resp.text(); - - // store ID in cookie - document.cookie = `id=${ID}`; - - register_status.innerHTML = ID; - } else - // on form error - register_status.innerHTML = `${resp.status}`; - }; - - /* LOCATION */ - - let lopen_button = document.getElementById("location-open-button"); - let lwrite_button = document.getElementById("location-write-button"); - let lclose_button = document.getElementById("location-close-button"); - let location_status = document.getElementById("location-status"); - - // on connection opened - let set_ws_open = () => { - location_status.innerHTML = "Connected."; - - // on server message - window.pacmacro_set_ws.addEventListener("message", (e) => { - // show server message in status - location_status.innerHTML = e.data; - }); - - let log_in = { - coordinate: { - latitude: 0, - longitude: 0, - }, - command: "password", - data: admin_pass.value, - }; - - // send log-in information - window.pacmacro_set_ws.send(JSON.stringify(log_in)); - - // watch location and pass it along to the server - watchLocation((p) => { - let msg = { - coordinate: { - latitude: p.coords.latitude, - longitude: p.coords.longitude, - }, - command: "location", - data: "", - }; - - window.pacmacro_set_ws.send(JSON.stringify(msg)); - }); - }; // set_ws_open - - // on connection closed - let set_ws_close = () => { - location_status.innerHTML = "Closed."; - - // stop watching location - stopWatchLocation(); - - if (window.pacmacro_set_ws !== undefined) { - // close connection ourselves (on lclose_button.onclick()) - window.pacmacro_set_ws.close(); - window.pacmacro_set_ws = undefined; - } - }; // set_ws_close - - // on prompt to start map setting - lopen_button.onclick = () => { - if (window.pacmacro_set_ws !== undefined) return; // websocket is already opened - - location_status.innerHTML = "Connecting..."; - - // try to open connection - window.pacmacro_set_ws = new WebSocket( - `${WS}://${URL_ROOT}/api/admin/set/${admin_id.value}`, - ); - window.pacmacro_set_ws.onopen = set_ws_open; - window.pacmacro_set_ws.onclose = set_ws_close; - }; // location_button.onclick - - // on prompt to write collected location data to server - lwrite_button.onclick = () => { - if (window.pacmacro_set_ws === undefined) return; // there isn't an open connection - - let msg = { - coordinate: { - latitude: 0, - longitude: 0, - }, - command: "write", - data: "", - }; - - // send command to write data - window.pacmacro_set_ws.send(JSON.stringify(msg)); - }; // lwrite_button.onclick - - lclose_button.onclick = set_ws_close; - - /* POPULATE */ - - let pgenerate_button = document.getElementById("populate-generate-button"); - let pdrawpath_button = document.getElementById("populate-draw-path-button"); - let pstopdraw_button = document.getElementById("populate-stop-draw-button"); - let psubmit_button = document.getElementById("populate-submit-button"); - let populate_status = document.getElementById("populate-status"); - let pacmacro_map = document.getElementById("pacmacro-map"); - - // generate table for filling map data - pgenerate_button.onclick = async () => { - try { - window.pacmacro_map = await fetch("/api/game/map.json"); - } catch { - populate_status.innerHTML = "Error"; - } - - window.pacmacro_map = await window.pacmacro_map.json(); - console.log(window.pacmacro_map); - - window.pacmacro_ctx = pacmacro_map.getContext("2d"); - let ctx = window.pacmacro_ctx; - - ctx.canvas.width = window.pacmacro_map.width * EXPAND_X; - ctx.canvas.height = window.pacmacro_map.height * EXPAND_Y; - ctx.fillStyle = "silver"; - ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height); - - let start_tile = 0, - tile = 0; - let n = window.pacmacro_map.width * window.pacmacro_map.height; - - // fill map with grid - for (let i = 0; i < n; i++) { - let x = i % window.pacmacro_map.width; - let y = Math.floor(i / window.pacmacro_map.width); - - tile = tile == 1 ? 0 : 1; - - if (x == 0) { - tile = start_tile; - start_tile = start_tile == 1 ? 0 : 1; - } - - let rendx = x * EXPAND_X; - let rendy = y * EXPAND_Y; - - ctx.fillStyle = tile == 1 ? "gray" : "silver"; - ctx.fillRect(rendx, rendy, rendx + EXPAND_X, rendy + EXPAND_Y); - } - }; - - pdrawpath_button.onclick = () => { - watchLocation((p) => { - let plot = convertCoords( - window.pacmacro_map, - p.coords.latitude, - p.coords.longitude, - ); - - let ctx = window.pacmacro_ctx; - let rendx = plot.x * EXPAND_X; - let rendy = plot.y * EXPAND_Y; - - ctx.fillStyle = "black"; - ctx.fillRect(rendx, rendy, rendx + 1, rendy + 1); - }); - }; - - pstopdraw_button.onclick = () => { - stopWatchLocation(); - }; - - psubmit_button.onclick = async () => { - // do nothing - }; -}; diff --git a/htdocs/js/index.js b/htdocs/js/index.js deleted file mode 100644 index b1e6225..0000000 --- a/htdocs/js/index.js +++ /dev/null @@ -1,211 +0,0 @@ -// index.js -// programming for the lobby and PacMacro game - -import { - ribbons, - EXPAND_X, - EXPAND_Y, - pacmacro_init, - connectWS, - watchLocation, - convertCoords, - NREPS, - getCredentials, - reps, -} from "./pacmacro.js"; - -window.onload = async () => { - ribbons(); - // reset globals - pacmacro_init(); - - let self_summary = document.getElementById("self-summary"); - let pacmacro_status = document.getElementById("pacmacro-status"); - let pacmacro_canvas = document.getElementById("pacmacro-canvas"); - let pass = ""; // received upon window.prompt(...) - - pacmacro_status.style.visibility = "hidden"; // hides debug - - try { - let pacmacro_map = await fetch("/api/game/map.json"); - window.pacmacro_map = await pacmacro_map.json(); - } catch { - pacmacro_status.innerHTML = "Error: Couldn't receive map information."; - if (window.pacmacro_map) { - window.pacmacro_ws.close(); // close websocket connection - } - return; // do not proceed - } - - window.pacmacro_ctx = pacmacro_canvas.getContext("2d"); - // set canvas size as per window.pacmacro_map - window.pacmacro_ctx.canvas.width = window.pacmacro_map.width * EXPAND_X; - window.pacmacro_ctx.canvas.height = window.pacmacro_map.height * EXPAND_Y; - window.pacmacro_ctx.font = "16pt sans-serif"; - - let pacmacro_draw = () => { - console.log("draw"); - - if (window.pacmacro_ctx === undefined) return; - - // fill canvas with map drawing - window.pacmacro_ctx.drawImage( - window.pacmacro_img_map, - 0, - 0, - window.pacmacro_ctx.canvas.width, - window.pacmacro_ctx.canvas.height, - ); - - // render each player - Object.keys(window.pacmacro_players).forEach((ID) => { - console.log("---- DRAWING ----"); - - const p = window.pacmacro_players[ID]; - if (p.player.type == 3) - // TypeHidden - return; - let img, text; - - console.log(p); - - if (p.player.type == 1) { - if (p.player.reps == 1) { - img = window.pacmacro_img_coin; - } else { - img = window.pacmacro_img_leader; - } - } else { - switch (p.player.reps) { - case 1: // pacman - img = window.pacmacro_img_pacman; - break; - case 2: // antipac - img = window.pacmacro_img_anti; - break; - case 3: // ghost - img = window.pacmacro_img_ghost; - break; - case 4: // edible - img = window.pacmacro_img_edible; - break; - default: // nothing; watcher; error - return; - } - } - - window.pacmacro_ctx.drawImage( - img, - p.plot.x * EXPAND_X - 48, - p.plot.y * EXPAND_Y - 88, - 96, - 96, - ); - window.pacmacro_ctx.textAlign = "center"; - window.pacmacro_ctx.fillStyle = "#ffffff"; - - if (ID == window.pacmacro_ID) { - text = `${p.player.name} (You)`; - document.getElementById("coords").innerHTML = - `${p.plot.x}, ${p.plot.y}`; - } else { - text = `${p.player.name} (${ID})`; - } - - console.log(p.plot); - - window.pacmacro_ctx.fillText( - text, - p.plot.x * EXPAND_X, - p.plot.y * EXPAND_Y - 112, - ); - }); - }; - - let pacmacro_open = async () => { - // send password to authenticate player - window.pacmacro_ws.send(pass); - - watchLocation((p) => { - let coordinate = { - latitude: p.coords.latitude, - longitude: p.coords.longitude, - }; - - document.getElementById("latlong").innerHTML = - `${coordinate.latitude}, ${coordinate.longitude}`; - window.pacmacro_ws.send(JSON.stringify(coordinate)); - }); - - pacmacro_status.innerHTML = "Good to go."; - }; - - let pacmacro_redirect = () => { - // window.location.href = "/login"; - }; - - let pacmacro_recv = (e) => { - let msg = JSON.parse(e.data); - console.log(`COMMAND: ${msg.command}`); - console.log(`DATA: ${msg.data}`); - - if (msg.command === undefined) return; // invalid message - - if (msg.command == "inform") { - let plot = convertCoords( - window.pacmacro_map, - msg.coordinate.latitude, - msg.coordinate.longitude, - ); - const p = JSON.parse(msg.data); - - if (p.id === undefined) return; // invalid - - // set player - window.pacmacro_players[p.id] = { - plot: plot, - player: p, - }; - - if (p.id == window.pacmacro_ID) { - self_summary.innerHTML = `${p.name} (${p.id}) is ${reps(p.reps)}`; - } - } else if (msg.command == "move") { - console.log("moving"); - const p = window.pacmacro_players[msg.data]; // data is ID - - if (p === undefined) return; // invalid - - // update plot - p.plot = convertCoords( - window.pacmacro_map, - msg.coordinate.latitude, - msg.coordinate.longitude, - ); - } - - // write server message to status element - pacmacro_status.innerHTML = e.data; - pacmacro_draw(); // re-draw the map; maybe there was a player update - }; - - const params = new URLSearchParams(window.location.search); - - const { ID, password } = getCredentials(); - - if (ID.length == 0 || password.length == 0) { - window.location.href = "/register"; - return; - } - - pass = password; - window.pacmacro_ID = ID; - - connectWS( - ID, - pacmacro_open, // on open - pacmacro_redirect, // on close - pacmacro_redirect, // on error - pacmacro_recv, - ); // on message -}; diff --git a/htdocs/js/login.js b/htdocs/js/login.js deleted file mode 100644 index f859148..0000000 --- a/htdocs/js/login.js +++ /dev/null @@ -1,16 +0,0 @@ -// login.js -// programming for login page - -import { ribbons, getCredentials } from "./pacmacro.js"; - -window.onload = () => { - ribbons(); - - let ID = document.getElementById("login-id"); - ID.value = getCredentials().ID; - let submit_button = document.getElementById("login-submit"); - - submit_button.onclick = () => { - window.location.href = `/?id=${ID.value}`; // go to index - }; -}; diff --git a/htdocs/js/pacmacro.js b/htdocs/js/pacmacro.js deleted file mode 100644 index 3267407..0000000 --- a/htdocs/js/pacmacro.js +++ /dev/null @@ -1,211 +0,0 @@ -// pacmacro.js -// general programming for all pages - -const WS = window.location.protocol === "https:" ? "wss" : "ws"; // change to "wss" in production -const URL_ROOT = "localhost:49152"; // must be root domain of server hosting API -const EXPAND_X = 32; -const EXPAND_Y = 32; - -/* GLOBALS - * window.pacmacro_set_ws : Admin; setting map minimum and maximum latitude and longitude values - * window.pacmacro_ws : websocket connection to API for lobby or PacMacro game - * window.pacmacro_geo : geolocation value associated with current watch function - * window.pacmacro_ctx : canvas API 2d context for drawing PacMacro game - * window.pacmacro_map : PacMacro map information (JSON; /api/game/map.json) - */ - -function ribbons() { - document.body.innerHTML = ` -
- - (grid) - (grid) - - - - - -
- - ${document.body.innerHTML} - `; -} - -// init pacmacro -function pacmacro_init() { - // undefine globals - window.pacmacro_set_ws = undefined; - window.pacmacro_ws = undefined; - window.pacmacro_geo = undefined; - window.pacmacro_ctx = undefined; - window.pacmacro_map = undefined; - - window.pacmacro_players = {}; // prepare player map - - // load images (for the canvas) - window.pacmacro_img_map = new Image(EXPAND_X, EXPAND_Y); - window.pacmacro_img_map.src = "static/game/map.svg"; - window.pacmacro_img_pacman = new Image(96, 96); - window.pacmacro_img_pacman.src = "static/game/pacman.png"; - window.pacmacro_img_pacman_flag = new Image(96, 96); - window.pacmacro_img_pacman_flag.src = "static/game/pacman_flag.png"; - window.pacmacro_img_anti = new Image(96, 96); - window.pacmacro_img_anti.src = "static/game/anti_pacman.png"; - window.pacmacro_img_ghost = new Image(96, 96); - window.pacmacro_img_ghost.src = "static/game/ghost.png"; - window.pacmacro_img_edible = new Image(96, 96); - window.pacmacro_img_edible.src = "static/game/edible.png"; - window.pacmacro_img_coin = new Image(96, 96); - window.pacmacro_img_coin.src = "static/game/coin.png"; - window.pacmacro_img_leader = new Image(96, 96); - window.pacmacro_img_leader.src = "static/game/leader.png"; -} - -// save credentials -function saveCredentials(ID, password) { - document.cookie = `id=${ID}`; - document.cookie = `password=${password}`; -} - -// get player ID from cookies -function getCredentials() { - let ID = "", - password = "1234"; - - let cookies = document.cookie; - cookies = cookies.split(";").map((v) => v.split("=")); - - for (const c of cookies) { - if (c[0].trim() == "id") ID = c[1]; - - if (c[0].trim() == "password") password = c[1]; - } - - return { ID, password }; -} - -// connect to websocket as player -function connectWS(ID, onopen, onclose, onerror, onmessage) { - // calling function should run connectWS in a try-catch block - window.pacmacro_ws = new WebSocket(`${WS}://${URL_ROOT}/api/ws/${ID}`); - window.pacmacro_ws.onopen = onopen; - window.pacmacro_ws.onclose = onclose; - window.pacmacro_ws.onerror = onerror; - window.pacmacro_ws.onmessage = onmessage; -} - -// watch location of user; run update_func on each update -function watchLocation(update_func) { - if ("geolocation" in navigator) { - // check if watchLocation was previously run; - // if yes, stop watching location. - if (window.pacmacro_geo !== undefined) - navigator.geolocation.clearWatch(window.pacmacro_geo); - - // start watching location - window.pacmacro_geo = navigator.geolocation.watchPosition( - // on each update... - update_func, - // in the event of an error... - (e) => { - console.log(`watchLocation error: ${e}.`); - }, - // watch options - { - maximumAge: 0, // don't stop watching - timeout: 5000, - enableHighAccuracy: true, - }, - ); - } else { - return false; - } - - return true; -} - -function stopWatchLocation() { - if (navigator.geolocation !== undefined && window.pacmacro_geo !== undefined) - navigator.geolocation.clearWatch(window.pacmacro_geo); - - window.pacmacro_geo = undefined; -} - -function convertCoords(map, lat, lon) { - let plot = { - x: 0, - y: 0, - }; - - let dlat = map.max.latitude - map.min.latitude; - let dlon = map.max.longitude - map.min.longitude; - - // denominator cannot be zero - if (dlat == 0 || dlon == 0) return plot; - - plot.x = ((lon - map.min.longitude) / dlon) * map.width; - plot.y = ((lat - map.min.latitude) / dlat) * map.height; - plot.y = map.height - plot.y; - - console.log(`[${lat}, ${lon}] => [${plot.x}, ${plot.y}`); - - return plot; -} - -const NREPS = 5; -const NTYPE = 4; - -function reps(n) { - switch (n) { - case 0: - return "Nothing"; - case 1: - return "Pacman"; - case 2: - return "Antipac"; - case 3: - return "Ghost"; - case 4: - return "Edible"; - default: - return "Error"; - } -} - -function type(n) { - switch (n) { - case 0: - return "Player"; - case 1: - return "Leader"; - case 2: - return "Admin"; - case 3: - return "Hidden"; - default: - return "Error"; - } -} - -export { - WS, - URL_ROOT, - EXPAND_X, - EXPAND_Y, - ribbons, - pacmacro_init, - saveCredentials, - getCredentials, - connectWS, - watchLocation, - stopWatchLocation, - convertCoords, - NREPS, - NTYPE, - reps, - type, -}; diff --git a/htdocs/js/register.js b/htdocs/js/register.js deleted file mode 100644 index ff8c57d..0000000 --- a/htdocs/js/register.js +++ /dev/null @@ -1,43 +0,0 @@ -// register.js -// programming for the Player Registration page (register.html) - -import { saveCredentials, ribbons } from "./pacmacro.js"; - -window.onload = () => { - ribbons(); - - let submit_button = document.getElementById("register-submit"); - - submit_button.onclick = async () => { - let type = document.getElementById("register-type").value; - let name = document.getElementById("register-name").value; - let stat = document.getElementById("register-status"); - let pass = "1234"; - - let form = new FormData(); - form.append("type", type); - form.append("name", name); - form.append("pass", pass); - - let ID; - - try { - ID = await fetch("/api/player/register", { - method: "POST", - body: form, - }); - } catch { - stat.innerHTML = "Couldn't contact API."; - return; - } - - if (!ID.ok) { - stat.innerHTML = `Error ${ID.status}`; - return; - } - - ID = await ID.text(); - saveCredentials(ID, pass); // save ID in cookies - window.location.href = "/"; // go to index - }; -}; diff --git a/htdocs/login.html b/htdocs/login.html deleted file mode 100644 index 005070c..0000000 --- a/htdocs/login.html +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - Login | PacMacro - - - - - - -
-
-

Login

-
- - -
- -

Don't have an account? Register

-
-
- - diff --git a/htdocs/register.html b/htdocs/register.html deleted file mode 100644 index d85b8a8..0000000 --- a/htdocs/register.html +++ /dev/null @@ -1,34 +0,0 @@ - - - - - - Register | PacMacro - - - - - - -
-
-

Register

-

Leaders: please register as Leader and send the Admin your ID.

-
- - -
- - -
- -

Already have an account? Login

-

-
-
- - diff --git a/htdocs/static/.DS_Store b/htdocs/static/.DS_Store deleted file mode 100644 index cf95530..0000000 Binary files a/htdocs/static/.DS_Store and /dev/null differ diff --git a/htdocs/static/flag_background.gif b/htdocs/static/flag_background.gif deleted file mode 100644 index a4d8743..0000000 Binary files a/htdocs/static/flag_background.gif and /dev/null differ diff --git a/htdocs/static/flag_grid.png b/htdocs/static/flag_grid.png deleted file mode 100644 index e88fa0d..0000000 Binary files a/htdocs/static/flag_grid.png and /dev/null differ diff --git a/htdocs/static/flag_grid_t.png b/htdocs/static/flag_grid_t.png deleted file mode 100644 index 37e6af2..0000000 Binary files a/htdocs/static/flag_grid_t.png and /dev/null differ diff --git a/htdocs/static/frosh.png b/htdocs/static/frosh.png deleted file mode 100644 index 539a14b..0000000 Binary files a/htdocs/static/frosh.png and /dev/null differ diff --git a/htdocs/static/game/.DS_Store b/htdocs/static/game/.DS_Store deleted file mode 100644 index ea37f4c..0000000 Binary files a/htdocs/static/game/.DS_Store and /dev/null differ diff --git a/htdocs/static/game/anti.png b/htdocs/static/game/anti.png deleted file mode 100644 index 5c9b851..0000000 Binary files a/htdocs/static/game/anti.png and /dev/null differ diff --git a/htdocs/static/game/anti_pacman.png b/htdocs/static/game/anti_pacman.png deleted file mode 100644 index fddde79..0000000 Binary files a/htdocs/static/game/anti_pacman.png and /dev/null differ diff --git a/htdocs/static/game/coin.png b/htdocs/static/game/coin.png deleted file mode 100644 index c192818..0000000 Binary files a/htdocs/static/game/coin.png and /dev/null differ diff --git a/htdocs/static/game/edible.png b/htdocs/static/game/edible.png deleted file mode 100644 index 504a18a..0000000 Binary files a/htdocs/static/game/edible.png and /dev/null differ diff --git a/htdocs/static/game/ghost.png b/htdocs/static/game/ghost.png deleted file mode 100644 index ee5ac85..0000000 Binary files a/htdocs/static/game/ghost.png and /dev/null differ diff --git a/htdocs/static/game/leader.png b/htdocs/static/game/leader.png deleted file mode 100644 index 2e63d10..0000000 Binary files a/htdocs/static/game/leader.png and /dev/null differ diff --git a/htdocs/static/game/map.svg b/htdocs/static/game/map.svg deleted file mode 100644 index f419cf4..0000000 --- a/htdocs/static/game/map.svg +++ /dev/null @@ -1,114 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/htdocs/static/game/pacman.png b/htdocs/static/game/pacman.png deleted file mode 100644 index 0cc8586..0000000 Binary files a/htdocs/static/game/pacman.png and /dev/null differ diff --git a/htdocs/static/game/pacman_flag.png b/htdocs/static/game/pacman_flag.png deleted file mode 100644 index 813e3f1..0000000 Binary files a/htdocs/static/game/pacman_flag.png and /dev/null differ diff --git a/htdocs/static/pacmacro.svg b/htdocs/static/pacmacro.svg deleted file mode 100644 index e1477bf..0000000 --- a/htdocs/static/pacmacro.svg +++ /dev/null @@ -1,50 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/main.go b/main.go index 53365cb..95111ac 100644 --- a/main.go +++ b/main.go @@ -52,8 +52,10 @@ func main() { sock api.Sockets ) - players.Init() // initialize players handler - game.Init(&players) // initialize game handler + players.Init() // initialize players handler + if err := game.Init(&players); err != nil { + log.Fatalf("initialize game: %v", err) + } sock.Init(&players) // initialize sockets handler admin.Init(&players, &sock, adminPassword) // initialize admin handler