Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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
12 changes: 9 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
6 changes: 3 additions & 3 deletions api/admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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)
}
16 changes: 12 additions & 4 deletions api/admin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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) {
Expand All @@ -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)
Expand Down Expand Up @@ -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()
Expand All @@ -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)
Expand Down
54 changes: 45 additions & 9 deletions api/game.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@ package api

import (
"fmt"
"math"
"net/http"
"os"
"strconv"
"strings"
"sync"
)

Expand All @@ -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" {
Expand Down
41 changes: 39 additions & 2 deletions api/game_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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")
}
Loading