diff options
| author | nsfisis <nsfisis@gmail.com> | 2024-07-19 19:05:55 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2024-07-19 19:19:55 +0900 |
| commit | df5abfc272a151c51f0e5e82214cf7aff8cfa880 (patch) | |
| tree | 87395be420f16296fab56b55a03f83f87af59366 /backend | |
| parent | b0662e8add4864fed69f49a4a5cfb0d8e26523a8 (diff) | |
| download | phperkaigi-2025-albatross-df5abfc272a151c51f0e5e82214cf7aff8cfa880.tar.gz phperkaigi-2025-albatross-df5abfc272a151c51f0e5e82214cf7aff8cfa880.tar.zst phperkaigi-2025-albatross-df5abfc272a151c51f0e5e82214cf7aff8cfa880.zip | |
initial commit
Diffstat (limited to 'backend')
| -rw-r--r-- | backend/game.go | 328 | ||||
| -rw-r--r-- | backend/go.mod | 9 | ||||
| -rw-r--r-- | backend/go.sum | 12 | ||||
| -rw-r--r-- | backend/main.go | 259 | ||||
| -rw-r--r-- | backend/message.go | 104 |
5 files changed, 712 insertions, 0 deletions
diff --git a/backend/game.go b/backend/game.go new file mode 100644 index 0000000..bc2069a --- /dev/null +++ b/backend/game.go @@ -0,0 +1,328 @@ +package main + +import ( + "log" + "net/http" + "time" + + "github.com/gorilla/websocket" +) + +type GameHub struct { + game *Game + clients map[*GameClient]bool + receive chan *MessageWithClient + register chan *GameClient + unregister chan *GameClient + watchers map[*GameWatcher]bool + registerWatcher chan *GameWatcher + unregisterWatcher chan *GameWatcher + state int + finishTime time.Time +} + +func NewGameHub(game *Game) *GameHub { + return &GameHub{ + game: game, + clients: make(map[*GameClient]bool), + receive: make(chan *MessageWithClient), + register: make(chan *GameClient), + unregister: make(chan *GameClient), + watchers: make(map[*GameWatcher]bool), + registerWatcher: make(chan *GameWatcher), + unregisterWatcher: make(chan *GameWatcher), + state: 0, + } +} + +func (h *GameHub) Run() { + ticker := time.NewTicker(10 * time.Second) + defer func() { + ticker.Stop() + }() + + for { + select { + case client := <-h.register: + h.clients[client] = true + log.Printf("client registered: %d", len(h.clients)) + case client := <-h.unregister: + if _, ok := h.clients[client]; ok { + h.closeClient(client) + } + log.Printf("client unregistered: %d", len(h.clients)) + if len(h.clients) == 0 { + h.Close() + return + } + case watcher := <-h.registerWatcher: + h.watchers[watcher] = true + log.Printf("watcher registered: %d", len(h.watchers)) + case watcher := <-h.unregisterWatcher: + if _, ok := h.watchers[watcher]; ok { + h.closeWatcher(watcher) + } + log.Printf("watcher unregistered: %d", len(h.watchers)) + case message := <-h.receive: + log.Printf("received message: %s", message.Message.Type) + switch message.Message.Type { + case "connect": + if h.state == 0 { + h.state = 1 + } else if h.state == 1 { + h.state = 2 + for client := range h.clients { + client.send <- &Message{Type: "prepare", Data: MessageDataPrepare{Problem: "1 から 100 までの FizzBuzz を実装せよ (終端を含む)。"}} + } + } else { + log.Printf("invalid state: %d", h.state) + h.closeClient(message.Client) + } + case "ready": + if h.state == 2 { + h.state = 3 + } else if h.state == 3 { + h.state = 4 + for client := range h.clients { + client.send <- &Message{Type: "start", Data: MessageDataStart{StartTime: time.Now().Add(10 * time.Second).UTC().Format(time.RFC3339)}} + } + h.finishTime = time.Now().Add(3 * time.Minute) + } else { + log.Printf("invalid state: %d", h.state) + h.closeClient(message.Client) + } + case "code": + if h.state == 4 { + code := message.Message.Data.(MessageDataCode).Code + message.Client.code = code + message.Client.send <- &Message{Type: "score", Data: MessageDataScore{Score: 100}} + if message.Client.score == nil { + message.Client.score = new(int) + } + *message.Client.score = 100 + + var scoreA, scoreB *int + var codeA, codeB string + for client := range h.clients { + if client.team == "a" { + scoreA = client.score + codeA = client.code + } else { + scoreB = client.score + codeB = client.code + } + } + for watcher := range h.watchers { + watcher.send <- &Message{ + Type: "watch", + Data: MessageDataWatch{ + Problem: "1 から 100 までの FizzBuzz を実装せよ (終端を含む)。", + ScoreA: scoreA, + CodeA: codeA, + ScoreB: scoreB, + CodeB: codeB, + }, + } + } + } else { + log.Printf("invalid state: %d", h.state) + h.closeClient(message.Client) + } + default: + log.Printf("unknown message type: %s", message.Message.Type) + h.closeClient(message.Client) + } + case <-ticker.C: + log.Printf("state: %d", h.state) + if h.state == 4 { + if time.Now().After(h.finishTime) { + h.state = 5 + clientAndScores := make(map[*GameClient]*int) + for client := range h.clients { + clientAndScores[client] = client.score + } + for client, score := range clientAndScores { + var opponentScore *int + for c2, s2 := range clientAndScores { + if c2 != client { + opponentScore = s2 + break + } + } + client.send <- &Message{Type: "finish", Data: MessageDataFinish{YourScore: score, OpponentScore: opponentScore}} + } + } + } + } + } +} + +func (h *GameHub) Close() { + for client := range h.clients { + h.closeClient(client) + } + close(h.receive) + close(h.register) + close(h.unregister) + for watcher := range h.watchers { + h.closeWatcher(watcher) + } + close(h.registerWatcher) + close(h.unregisterWatcher) +} + +func (h *GameHub) closeClient(client *GameClient) { + delete(h.clients, client) + close(client.send) +} + +func (h *GameHub) closeWatcher(watcher *GameWatcher) { + delete(h.watchers, watcher) + close(watcher.send) +} + +const ( + writeWait = 10 * time.Second + pongWait = 60 * time.Second + pingPeriod = (pongWait * 9) / 10 + maxMessageSize = 512 +) + +var ( + newline = []byte{'\n'} + space = []byte{' '} +) + +var upgrader = websocket.Upgrader{ + ReadBufferSize: 1024, + WriteBufferSize: 1024, +} + +type GameClient struct { + hub *GameHub + conn *websocket.Conn + send chan *Message + score *int + code string + team string +} + +type GameWatcher struct { + hub *GameHub + conn *websocket.Conn + send chan *Message +} + +// Receives messages from the client and sends them to the hub. +func (c *GameClient) readPump() { + defer func() { + log.Printf("closing client") + c.hub.unregister <- c + c.conn.Close() + }() + c.conn.SetReadLimit(maxMessageSize) + c.conn.SetReadDeadline(time.Now().Add(pongWait)) + c.conn.SetPongHandler(func(string) error { c.conn.SetReadDeadline(time.Now().Add(pongWait)); return nil }) + for { + var message Message + err := c.conn.ReadJSON(&message) + if err != nil { + log.Printf("error: %v", err) + return + } + c.hub.receive <- &MessageWithClient{c, &message} + } +} + +// Receives messages from the hub and sends them to the client. +func (c *GameClient) writePump() { + ticker := time.NewTicker(pingPeriod) + defer func() { + ticker.Stop() + c.conn.Close() + }() + for { + select { + case message, ok := <-c.send: + c.conn.SetWriteDeadline(time.Now().Add(writeWait)) + if !ok { + c.conn.WriteMessage(websocket.CloseMessage, []byte{}) + return + } + + err := c.conn.WriteJSON(message) + if err != nil { + return + } + case <-ticker.C: + c.conn.SetWriteDeadline(time.Now().Add(writeWait)) + if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil { + return + } + } + } +} + +func serveWs(hub *GameHub, w http.ResponseWriter, r *http.Request, team string) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + log.Println(err) + return + } + client := &GameClient{hub: hub, conn: conn, send: make(chan *Message), team: team} + client.hub.register <- client + + go client.writePump() + go client.readPump() +} + +func serveWsWatcher(hub *GameHub, w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + log.Println(err) + return + } + watcher := &GameWatcher{hub: hub, conn: conn, send: make(chan *Message)} + watcher.hub.registerWatcher <- watcher + + go watcher.writePump() + go watcher.readPump() +} + +// Receives messages from the client and sends them to the hub. +func (c *GameWatcher) readPump() { + c.conn.SetReadLimit(maxMessageSize) + c.conn.SetReadDeadline(time.Now().Add(pongWait)) + c.conn.SetPongHandler(func(string) error { c.conn.SetReadDeadline(time.Now().Add(pongWait)); return nil }) +} + +// Receives messages from the hub and sends them to the client. +func (c *GameWatcher) writePump() { + ticker := time.NewTicker(pingPeriod) + defer func() { + ticker.Stop() + c.conn.Close() + log.Printf("closing watcher") + c.hub.unregisterWatcher <- c + }() + for { + select { + case message, ok := <-c.send: + c.conn.SetWriteDeadline(time.Now().Add(writeWait)) + if !ok { + c.conn.WriteMessage(websocket.CloseMessage, []byte{}) + return + } + + err := c.conn.WriteJSON(message) + if err != nil { + return + } + case <-ticker.C: + c.conn.SetWriteDeadline(time.Now().Add(writeWait)) + if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil { + return + } + } + } +} diff --git a/backend/go.mod b/backend/go.mod new file mode 100644 index 0000000..63e750f --- /dev/null +++ b/backend/go.mod @@ -0,0 +1,9 @@ +module iosdc-code-battle-poc + +go 1.22.3 + +require ( + github.com/gorilla/websocket v1.5.3 + github.com/jmoiron/sqlx v1.4.0 + github.com/lib/pq v1.10.9 +) diff --git a/backend/go.sum b/backend/go.sum new file mode 100644 index 0000000..b60655d --- /dev/null +++ b/backend/go.sum @@ -0,0 +1,12 @@ +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= +github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= diff --git a/backend/main.go b/backend/main.go new file mode 100644 index 0000000..68df25b --- /dev/null +++ b/backend/main.go @@ -0,0 +1,259 @@ +package main + +import ( + "fmt" + "log" + "net/http" + "os" + "strconv" + "time" + + "github.com/jmoiron/sqlx" + _ "github.com/lib/pq" +) + +type Config struct { + dbHost string + dbPort string + dbUser string + dbPassword string + dbName string +} + +var config *Config + +var db *sqlx.DB + +func loadEnv() (*Config, error) { + dbHost, exists := os.LookupEnv("ALBATROSS_DB_HOST") + if !exists { + return nil, fmt.Errorf("ALBATROSS_DB_HOST not set") + } + dbPort, exists := os.LookupEnv("ALBATROSS_DB_PORT") + if !exists { + return nil, fmt.Errorf("ALBATROSS_DB_PORT not set") + } + dbUser, exists := os.LookupEnv("ALBATROSS_DB_USER") + if !exists { + return nil, fmt.Errorf("ALBATROSS_DB_USER not set") + } + dbPassword, exists := os.LookupEnv("ALBATROSS_DB_PASSWORD") + if !exists { + return nil, fmt.Errorf("ALBATROSS_DB_PASSWORD not set") + } + dbName, exists := os.LookupEnv("ALBATROSS_DB_NAME") + if !exists { + return nil, fmt.Errorf("ALBATROSS_DB_NAME not set") + } + return &Config{ + dbHost: dbHost, + dbPort: dbPort, + dbUser: dbUser, + dbPassword: dbPassword, + dbName: dbName, + }, nil +} + +const ( + gameTypeGolf = "golf" + gameTypeRace = "race" +) + +const ( + gameStateWaiting = "waiting" + gameStateReady = "ready" + gameStatePlaying = "playing" + gameStateFinished = "finished" +) + +type Game struct { + GameID int `db:"game_id"` + // "golf" or "race" + Type string `db:"type"` + CreatedAt string `db:"created_at"` + State string `db:"state"` +} + +func initDB() error { + _, err := db.Exec(` + CREATE TABLE IF NOT EXISTS games ( + game_id SERIAL PRIMARY KEY, + type VARCHAR(255) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + state VARCHAR(255) NOT NULL + ); + `) + return err +} + +var gameHubs = map[int]*GameHub{} + +func startGame(game *Game) { + if gameHubs[game.GameID] != nil { + return + } + gameHubs[game.GameID] = NewGameHub(game) + go gameHubs[game.GameID].Run() +} + +func handleGolfPost(w http.ResponseWriter, r *http.Request) { + var yourTeam string + waitingGolfGames := []Game{} + err := db.Select(&waitingGolfGames, "SELECT * FROM games WHERE type = $1 AND state = $2 ORDER BY created_at", gameTypeGolf, gameStateWaiting) + if err != nil { + http.Error(w, "Error getting games", http.StatusInternalServerError) + return + } + if len(waitingGolfGames) == 0 { + _, err = db.Exec("INSERT INTO games (type, state) VALUES ($1, $2)", gameTypeGolf, gameStateWaiting) + if err != nil { + http.Error(w, "Error creating game", http.StatusInternalServerError) + return + } + waitingGolfGames = []Game{} + err = db.Select(&waitingGolfGames, "SELECT * FROM games WHERE type = $1 AND state = $2 ORDER BY created_at", gameTypeGolf, gameStateWaiting) + if err != nil { + http.Error(w, "Error getting games", http.StatusInternalServerError) + return + } + yourTeam = "a" + startGame(&waitingGolfGames[0]) + } else { + yourTeam = "b" + db.Exec("UPDATE games SET state = $1 WHERE game_id = $2", gameStateReady, waitingGolfGames[0].GameID) + } + waitingGame := waitingGolfGames[0] + + http.Redirect(w, r, fmt.Sprintf("/golf/%d/%s/", waitingGame.GameID, yourTeam), http.StatusSeeOther) +} + +func handleRacePost(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "/race/1/a/", http.StatusSeeOther) +} + +func main() { + var err error + config, err = loadEnv() + if err != nil { + fmt.Printf("Error loading env %v", err) + return + } + + for i := 0; i < 5; i++ { + db, err = sqlx.Connect("postgres", fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable", config.dbHost, config.dbPort, config.dbUser, config.dbPassword, config.dbName)) + if err == nil { + break + } + time.Sleep(5 * time.Second) + } + if err != nil { + log.Fatalf("Error connecting to db %v", err) + } + defer db.Close() + + err = initDB() + if err != nil { + log.Fatalf("Error initializing db %v", err) + } + + server := http.NewServeMux() + + server.HandleFunc("GET /js/", func(w http.ResponseWriter, r *http.Request) { + http.ServeFile(w, r, "./public"+r.URL.Path) + }) + + server.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) { + http.ServeFile(w, r, "./public/index.html") + }) + + server.HandleFunc("GET /golf/{$}", func(w http.ResponseWriter, r *http.Request) { + http.ServeFile(w, r, "./public/golf/index.html") + }) + + server.HandleFunc("POST /golf/{$}", func(w http.ResponseWriter, r *http.Request) { + handleGolfPost(w, r) + }) + + server.HandleFunc("GET /golf/{gameId}/watch/{$}", func(w http.ResponseWriter, r *http.Request) { + http.ServeFile(w, r, "./public/golf/watch.html") + }) + + server.HandleFunc("GET /sock/golf/{gameId}/watch/{$}", func(w http.ResponseWriter, r *http.Request) { + gameId := r.PathValue("gameId") + gameIdInt, err := strconv.Atoi(gameId) + if err != nil { + http.Error(w, "Invalid game id", http.StatusBadRequest) + return + } + var hub *GameHub + for _, h := range gameHubs { + if h.game.GameID == gameIdInt { + hub = h + break + } + } + if hub == nil { + http.Error(w, "Game not found", http.StatusNotFound) + return + } + serveWsWatcher(hub, w, r) + }) + + server.HandleFunc("GET /golf/{gameId}/{team}/{$}", func(w http.ResponseWriter, r *http.Request) { + http.ServeFile(w, r, "./public/golf/game.html") + }) + + server.HandleFunc("GET /sock/golf/{gameId}/{team}/{$}", func(w http.ResponseWriter, r *http.Request) { + gameId := r.PathValue("gameId") + gameIdInt, err := strconv.Atoi(gameId) + if err != nil { + http.Error(w, "Invalid game id", http.StatusBadRequest) + return + } + var hub *GameHub + for _, h := range gameHubs { + if h.game.GameID == gameIdInt { + hub = h + break + } + } + if hub == nil { + http.Error(w, "Game not found", http.StatusNotFound) + return + } + team := r.PathValue("team") + serveWs(hub, w, r, team) + }) + + server.HandleFunc("GET /race/{$}", func(w http.ResponseWriter, r *http.Request) { + http.ServeFile(w, r, "./public/race/index.html") + }) + + server.HandleFunc("POST /race/{$}", func(w http.ResponseWriter, r *http.Request) { + handleRacePost(w, r) + }) + + server.HandleFunc("GET /race/{gameId}/watch/{$}", func(w http.ResponseWriter, r *http.Request) { + http.ServeFile(w, r, "./public/race/watch.html") + }) + + server.HandleFunc("GET /sock/race/{gameId}/watch/{$}", func(w http.ResponseWriter, r *http.Request) { + // TODO + }) + + server.HandleFunc("GET /race/{gameId}/{team}/{$}", func(w http.ResponseWriter, r *http.Request) { + http.ServeFile(w, r, "./public/race/game.html") + }) + + server.HandleFunc("GET /sock/race/{gameId}/{team}/{$}", func(w http.ResponseWriter, r *http.Request) { + // TODO + }) + + defer func() { + for _, hub := range gameHubs { + hub.Close() + } + }() + + http.ListenAndServe(":80", server) +} diff --git a/backend/message.go b/backend/message.go new file mode 100644 index 0000000..f466a8f --- /dev/null +++ b/backend/message.go @@ -0,0 +1,104 @@ +package main + +import ( + "encoding/json" + "fmt" +) + +type MessageWithClient struct { + Client *GameClient + Message *Message +} + +type Message struct { + Type string `json:"type"` + Data MessageData `json:"data"` +} + +type MessageData interface{} + +type MessageDataConnect struct { +} + +type MessageDataPrepare struct { + Problem string `json:"problem"` +} + +type MessageDataReady struct { +} + +type MessageDataStart struct { + StartTime string `json:"startTime"` +} + +type MessageDataCode struct { + Code string `json:"code"` +} + +type MessageDataScore struct { + Score int `json:"score"` +} + +type MessageDataFinish struct { + YourScore *int `json:"yourScore"` + OpponentScore *int `json:"opponentScore"` +} + +type MessageDataWatch struct { + Problem string `json:"problem"` + ScoreA *int `json:"scoreA"` + CodeA string `json:"codeA"` + ScoreB *int `json:"scoreB"` + CodeB string `json:"codeB"` +} + +func (m *Message) UnmarshalJSON(data []byte) error { + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + + if err := json.Unmarshal(raw["type"], &m.Type); err != nil { + return err + } + + var err error + switch m.Type { + case "connect": + var data MessageDataConnect + err = json.Unmarshal(raw["data"], &data) + m.Data = data + case "prepare": + var data MessageDataPrepare + err = json.Unmarshal(raw["data"], &data) + m.Data = data + case "ready": + var data MessageDataReady + err = json.Unmarshal(raw["data"], &data) + m.Data = data + case "start": + var data MessageDataStart + err = json.Unmarshal(raw["data"], &data) + m.Data = data + case "code": + var data MessageDataCode + err = json.Unmarshal(raw["data"], &data) + m.Data = data + case "score": + var data MessageDataScore + err = json.Unmarshal(raw["data"], &data) + m.Data = data + case "finish": + var data MessageDataFinish + err = json.Unmarshal(raw["data"], &data) + m.Data = data + case "watch": + var data MessageDataWatch + err = json.Unmarshal(raw["data"], &data) + m.Data = data + default: + err = fmt.Errorf("unknown message type: %s", m.Type) + } + + return err +} |
