diff options
| -rw-r--r-- | backend/Dockerfile | 3 | ||||
| -rw-r--r-- | backend/api/generated.go | 455 | ||||
| -rw-r--r-- | backend/api/handlers.go | 35 | ||||
| -rw-r--r-- | backend/db/query.sql.go | 70 | ||||
| -rw-r--r-- | backend/game/client.go | 113 | ||||
| -rw-r--r-- | backend/game/game.go | 385 | ||||
| -rw-r--r-- | backend/game/http.go | 6 | ||||
| -rw-r--r-- | backend/game/hub.go | 273 | ||||
| -rw-r--r-- | backend/game/message.go | 143 | ||||
| -rw-r--r-- | backend/game/models.go | 34 | ||||
| -rw-r--r-- | backend/game/ws.go | 28 | ||||
| -rw-r--r-- | backend/main.go | 34 | ||||
| -rw-r--r-- | backend/query.sql | 16 | ||||
| -rw-r--r-- | frontend/app/.server/api/schema.d.ts | 105 | ||||
| -rw-r--r-- | frontend/app/components/GolfPlayApp.tsx | 146 | ||||
| -rw-r--r-- | frontend/app/components/GolfPlayApps/GolfPlayAppConnecting.tsx | 3 | ||||
| -rw-r--r-- | frontend/app/components/GolfPlayApps/GolfPlayAppFinished.tsx | 3 | ||||
| -rw-r--r-- | frontend/app/components/GolfPlayApps/GolfPlayAppGaming.tsx | 30 | ||||
| -rw-r--r-- | frontend/app/components/GolfPlayApps/GolfPlayAppStarting.tsx | 7 | ||||
| -rw-r--r-- | frontend/app/components/GolfPlayApps/GolfPlayAppWaiting.tsx | 3 | ||||
| -rw-r--r-- | frontend/app/routes/golf.$gameId.play.tsx | 33 | ||||
| -rw-r--r-- | openapi.yaml | 155 |
22 files changed, 1571 insertions, 509 deletions
diff --git a/backend/Dockerfile b/backend/Dockerfile index 1d1523d..acf7a68 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,6 +1,9 @@ FROM golang:1.22.3 AS builder WORKDIR /build +COPY go.mod go.sum ./ +RUN go mod download + COPY . /build RUN go build -o /build/server . diff --git a/backend/api/generated.go b/backend/api/generated.go index 921f4b3..e39e3ba 100644 --- a/backend/api/generated.go +++ b/backend/api/generated.go @@ -32,6 +32,11 @@ const ( WaitingStart GameState = "waiting_start" ) +// Defines values for GamePlayerMessageS2CExecResultPayloadStatus. +const ( + Success GamePlayerMessageS2CExecResultPayloadStatus = "success" +) + // Game defines model for Game. type Game struct { DisplayName string `json:"display_name"` @@ -45,6 +50,79 @@ type Game struct { // GameState defines model for Game.State. type GameState string +// GamePlayerMessage defines model for GamePlayerMessage. +type GamePlayerMessage struct { + union json.RawMessage +} + +// GamePlayerMessageC2S defines model for GamePlayerMessageC2S. +type GamePlayerMessageC2S struct { + union json.RawMessage +} + +// GamePlayerMessageC2SCode defines model for GamePlayerMessageC2SCode. +type GamePlayerMessageC2SCode struct { + Data GamePlayerMessageC2SCodePayload `json:"data"` + Type string `json:"type"` +} + +// GamePlayerMessageC2SCodePayload defines model for GamePlayerMessageC2SCodePayload. +type GamePlayerMessageC2SCodePayload struct { + Code string `json:"code"` +} + +// GamePlayerMessageC2SEntry defines model for GamePlayerMessageC2SEntry. +type GamePlayerMessageC2SEntry struct { + Type string `json:"type"` +} + +// GamePlayerMessageC2SReady defines model for GamePlayerMessageC2SReady. +type GamePlayerMessageC2SReady struct { + Type string `json:"type"` +} + +// GamePlayerMessageS2C defines model for GamePlayerMessageS2C. +type GamePlayerMessageS2C struct { + union json.RawMessage +} + +// GamePlayerMessageS2CExecResult defines model for GamePlayerMessageS2CExecResult. +type GamePlayerMessageS2CExecResult struct { + Data GamePlayerMessageS2CExecResultPayload `json:"data"` + Type string `json:"type"` +} + +// GamePlayerMessageS2CExecResultPayload defines model for GamePlayerMessageS2CExecResultPayload. +type GamePlayerMessageS2CExecResultPayload struct { + Score *int `json:"score"` + Status GamePlayerMessageS2CExecResultPayloadStatus `json:"status"` +} + +// GamePlayerMessageS2CExecResultPayloadStatus defines model for GamePlayerMessageS2CExecResultPayload.Status. +type GamePlayerMessageS2CExecResultPayloadStatus string + +// GamePlayerMessageS2CPrepare defines model for GamePlayerMessageS2CPrepare. +type GamePlayerMessageS2CPrepare struct { + Data GamePlayerMessageS2CPreparePayload `json:"data"` + Type string `json:"type"` +} + +// GamePlayerMessageS2CPreparePayload defines model for GamePlayerMessageS2CPreparePayload. +type GamePlayerMessageS2CPreparePayload struct { + Problem Problem `json:"problem"` +} + +// GamePlayerMessageS2CStart defines model for GamePlayerMessageS2CStart. +type GamePlayerMessageS2CStart struct { + Data GamePlayerMessageS2CStartPayload `json:"data"` + Type string `json:"type"` +} + +// GamePlayerMessageS2CStartPayload defines model for GamePlayerMessageS2CStartPayload. +type GamePlayerMessageS2CStartPayload struct { + StartAt int `json:"start_at"` +} + // JwtPayload defines model for JwtPayload. type JwtPayload struct { DisplayName string `json:"display_name"` @@ -67,6 +145,11 @@ type GetGamesParams struct { Authorization string `json:"Authorization"` } +// GetGamesGameIdParams defines parameters for GetGamesGameId. +type GetGamesGameIdParams struct { + Authorization string `json:"Authorization"` +} + // PostLoginJSONBody defines parameters for PostLogin. type PostLoginJSONBody struct { Password string `json:"password"` @@ -76,11 +159,252 @@ type PostLoginJSONBody struct { // PostLoginJSONRequestBody defines body for PostLogin for application/json ContentType. type PostLoginJSONRequestBody PostLoginJSONBody +// AsGamePlayerMessageS2C returns the union data inside the GamePlayerMessage as a GamePlayerMessageS2C +func (t GamePlayerMessage) AsGamePlayerMessageS2C() (GamePlayerMessageS2C, error) { + var body GamePlayerMessageS2C + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromGamePlayerMessageS2C overwrites any union data inside the GamePlayerMessage as the provided GamePlayerMessageS2C +func (t *GamePlayerMessage) FromGamePlayerMessageS2C(v GamePlayerMessageS2C) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeGamePlayerMessageS2C performs a merge with any union data inside the GamePlayerMessage, using the provided GamePlayerMessageS2C +func (t *GamePlayerMessage) MergeGamePlayerMessageS2C(v GamePlayerMessageS2C) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +// AsGamePlayerMessageC2S returns the union data inside the GamePlayerMessage as a GamePlayerMessageC2S +func (t GamePlayerMessage) AsGamePlayerMessageC2S() (GamePlayerMessageC2S, error) { + var body GamePlayerMessageC2S + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromGamePlayerMessageC2S overwrites any union data inside the GamePlayerMessage as the provided GamePlayerMessageC2S +func (t *GamePlayerMessage) FromGamePlayerMessageC2S(v GamePlayerMessageC2S) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeGamePlayerMessageC2S performs a merge with any union data inside the GamePlayerMessage, using the provided GamePlayerMessageC2S +func (t *GamePlayerMessage) MergeGamePlayerMessageC2S(v GamePlayerMessageC2S) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +func (t GamePlayerMessage) MarshalJSON() ([]byte, error) { + b, err := t.union.MarshalJSON() + return b, err +} + +func (t *GamePlayerMessage) UnmarshalJSON(b []byte) error { + err := t.union.UnmarshalJSON(b) + return err +} + +// AsGamePlayerMessageC2SEntry returns the union data inside the GamePlayerMessageC2S as a GamePlayerMessageC2SEntry +func (t GamePlayerMessageC2S) AsGamePlayerMessageC2SEntry() (GamePlayerMessageC2SEntry, error) { + var body GamePlayerMessageC2SEntry + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromGamePlayerMessageC2SEntry overwrites any union data inside the GamePlayerMessageC2S as the provided GamePlayerMessageC2SEntry +func (t *GamePlayerMessageC2S) FromGamePlayerMessageC2SEntry(v GamePlayerMessageC2SEntry) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeGamePlayerMessageC2SEntry performs a merge with any union data inside the GamePlayerMessageC2S, using the provided GamePlayerMessageC2SEntry +func (t *GamePlayerMessageC2S) MergeGamePlayerMessageC2SEntry(v GamePlayerMessageC2SEntry) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +// AsGamePlayerMessageC2SReady returns the union data inside the GamePlayerMessageC2S as a GamePlayerMessageC2SReady +func (t GamePlayerMessageC2S) AsGamePlayerMessageC2SReady() (GamePlayerMessageC2SReady, error) { + var body GamePlayerMessageC2SReady + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromGamePlayerMessageC2SReady overwrites any union data inside the GamePlayerMessageC2S as the provided GamePlayerMessageC2SReady +func (t *GamePlayerMessageC2S) FromGamePlayerMessageC2SReady(v GamePlayerMessageC2SReady) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeGamePlayerMessageC2SReady performs a merge with any union data inside the GamePlayerMessageC2S, using the provided GamePlayerMessageC2SReady +func (t *GamePlayerMessageC2S) MergeGamePlayerMessageC2SReady(v GamePlayerMessageC2SReady) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +// AsGamePlayerMessageC2SCode returns the union data inside the GamePlayerMessageC2S as a GamePlayerMessageC2SCode +func (t GamePlayerMessageC2S) AsGamePlayerMessageC2SCode() (GamePlayerMessageC2SCode, error) { + var body GamePlayerMessageC2SCode + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromGamePlayerMessageC2SCode overwrites any union data inside the GamePlayerMessageC2S as the provided GamePlayerMessageC2SCode +func (t *GamePlayerMessageC2S) FromGamePlayerMessageC2SCode(v GamePlayerMessageC2SCode) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeGamePlayerMessageC2SCode performs a merge with any union data inside the GamePlayerMessageC2S, using the provided GamePlayerMessageC2SCode +func (t *GamePlayerMessageC2S) MergeGamePlayerMessageC2SCode(v GamePlayerMessageC2SCode) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +func (t GamePlayerMessageC2S) MarshalJSON() ([]byte, error) { + b, err := t.union.MarshalJSON() + return b, err +} + +func (t *GamePlayerMessageC2S) UnmarshalJSON(b []byte) error { + err := t.union.UnmarshalJSON(b) + return err +} + +// AsGamePlayerMessageS2CPrepare returns the union data inside the GamePlayerMessageS2C as a GamePlayerMessageS2CPrepare +func (t GamePlayerMessageS2C) AsGamePlayerMessageS2CPrepare() (GamePlayerMessageS2CPrepare, error) { + var body GamePlayerMessageS2CPrepare + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromGamePlayerMessageS2CPrepare overwrites any union data inside the GamePlayerMessageS2C as the provided GamePlayerMessageS2CPrepare +func (t *GamePlayerMessageS2C) FromGamePlayerMessageS2CPrepare(v GamePlayerMessageS2CPrepare) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeGamePlayerMessageS2CPrepare performs a merge with any union data inside the GamePlayerMessageS2C, using the provided GamePlayerMessageS2CPrepare +func (t *GamePlayerMessageS2C) MergeGamePlayerMessageS2CPrepare(v GamePlayerMessageS2CPrepare) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +// AsGamePlayerMessageS2CStart returns the union data inside the GamePlayerMessageS2C as a GamePlayerMessageS2CStart +func (t GamePlayerMessageS2C) AsGamePlayerMessageS2CStart() (GamePlayerMessageS2CStart, error) { + var body GamePlayerMessageS2CStart + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromGamePlayerMessageS2CStart overwrites any union data inside the GamePlayerMessageS2C as the provided GamePlayerMessageS2CStart +func (t *GamePlayerMessageS2C) FromGamePlayerMessageS2CStart(v GamePlayerMessageS2CStart) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeGamePlayerMessageS2CStart performs a merge with any union data inside the GamePlayerMessageS2C, using the provided GamePlayerMessageS2CStart +func (t *GamePlayerMessageS2C) MergeGamePlayerMessageS2CStart(v GamePlayerMessageS2CStart) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +// AsGamePlayerMessageS2CExecResult returns the union data inside the GamePlayerMessageS2C as a GamePlayerMessageS2CExecResult +func (t GamePlayerMessageS2C) AsGamePlayerMessageS2CExecResult() (GamePlayerMessageS2CExecResult, error) { + var body GamePlayerMessageS2CExecResult + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromGamePlayerMessageS2CExecResult overwrites any union data inside the GamePlayerMessageS2C as the provided GamePlayerMessageS2CExecResult +func (t *GamePlayerMessageS2C) FromGamePlayerMessageS2CExecResult(v GamePlayerMessageS2CExecResult) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeGamePlayerMessageS2CExecResult performs a merge with any union data inside the GamePlayerMessageS2C, using the provided GamePlayerMessageS2CExecResult +func (t *GamePlayerMessageS2C) MergeGamePlayerMessageS2CExecResult(v GamePlayerMessageS2CExecResult) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +func (t GamePlayerMessageS2C) MarshalJSON() ([]byte, error) { + b, err := t.union.MarshalJSON() + return b, err +} + +func (t *GamePlayerMessageS2C) UnmarshalJSON(b []byte) error { + err := t.union.UnmarshalJSON(b) + return err +} + // ServerInterface represents all server handlers. type ServerInterface interface { // List games // (GET /games) GetGames(ctx echo.Context, params GetGamesParams) error + // Get a game + // (GET /games/{game_id}) + GetGamesGameId(ctx echo.Context, gameId int, params GetGamesGameIdParams) error // User login // (POST /login) PostLogin(ctx echo.Context) error @@ -128,6 +452,44 @@ func (w *ServerInterfaceWrapper) GetGames(ctx echo.Context) error { return err } +// GetGamesGameId converts echo context to params. +func (w *ServerInterfaceWrapper) GetGamesGameId(ctx echo.Context) error { + var err error + // ------------- Path parameter "game_id" ------------- + var gameId int + + err = runtime.BindStyledParameterWithOptions("simple", "game_id", ctx.Param("game_id"), &gameId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter game_id: %s", err)) + } + + // Parameter object where we will unmarshal all parameters from the context + var params GetGamesGameIdParams + + headers := ctx.Request().Header + // ------------- Required header parameter "Authorization" ------------- + if valueList, found := headers[http.CanonicalHeaderKey("Authorization")]; found { + var Authorization string + n := len(valueList) + if n != 1 { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Expected one value for Authorization, got %d", n)) + } + + err = runtime.BindStyledParameterWithOptions("simple", "Authorization", valueList[0], &Authorization, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationHeader, Explode: false, Required: true}) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter Authorization: %s", err)) + } + + params.Authorization = Authorization + } else { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Header parameter Authorization is required, but not found")) + } + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.GetGamesGameId(ctx, gameId, params) + return err +} + // PostLogin converts echo context to params. func (w *ServerInterfaceWrapper) PostLogin(ctx echo.Context) error { var err error @@ -166,6 +528,7 @@ func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL } router.GET(baseURL+"/games", wrapper.GetGames) + router.GET(baseURL+"/games/:game_id", wrapper.GetGamesGameId) router.POST(baseURL+"/login", wrapper.PostLogin) } @@ -200,6 +563,35 @@ func (response GetGames403JSONResponse) VisitGetGamesResponse(w http.ResponseWri return json.NewEncoder(w).Encode(response) } +type GetGamesGameIdRequestObject struct { + GameId int `json:"game_id"` + Params GetGamesGameIdParams +} + +type GetGamesGameIdResponseObject interface { + VisitGetGamesGameIdResponse(w http.ResponseWriter) error +} + +type GetGamesGameId200JSONResponse Game + +func (response GetGamesGameId200JSONResponse) VisitGetGamesGameIdResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type GetGamesGameId403JSONResponse struct { + Message string `json:"message"` +} + +func (response GetGamesGameId403JSONResponse) VisitGetGamesGameIdResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(403) + + return json.NewEncoder(w).Encode(response) +} + type PostLoginRequestObject struct { Body *PostLoginJSONRequestBody } @@ -235,6 +627,9 @@ type StrictServerInterface interface { // List games // (GET /games) GetGames(ctx context.Context, request GetGamesRequestObject) (GetGamesResponseObject, error) + // Get a game + // (GET /games/{game_id}) + GetGamesGameId(ctx context.Context, request GetGamesGameIdRequestObject) (GetGamesGameIdResponseObject, error) // User login // (POST /login) PostLogin(ctx context.Context, request PostLoginRequestObject) (PostLoginResponseObject, error) @@ -277,6 +672,32 @@ func (sh *strictHandler) GetGames(ctx echo.Context, params GetGamesParams) error return nil } +// GetGamesGameId operation middleware +func (sh *strictHandler) GetGamesGameId(ctx echo.Context, gameId int, params GetGamesGameIdParams) error { + var request GetGamesGameIdRequestObject + + request.GameId = gameId + request.Params = params + + handler := func(ctx echo.Context, request interface{}) (interface{}, error) { + return sh.ssi.GetGamesGameId(ctx.Request().Context(), request.(GetGamesGameIdRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetGamesGameId") + } + + response, err := handler(ctx, request) + + if err != nil { + return err + } else if validResponse, ok := response.(GetGamesGameIdResponseObject); ok { + return validResponse.VisitGetGamesGameIdResponse(ctx.Response()) + } else if response != nil { + return fmt.Errorf("unexpected response type: %T", response) + } + return nil +} + // PostLogin operation middleware func (sh *strictHandler) PostLogin(ctx echo.Context) error { var request PostLoginRequestObject @@ -309,20 +730,26 @@ func (sh *strictHandler) PostLogin(ctx echo.Context) error { // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "H4sIAAAAAAAC/6xUTW/jNhD9K8K0R8FW4iDY+pai6CKLPRhoe1oExlgcS0wlkssZJesG+u8FqQ9btoqk", - "SHKIZGo+3nvzOC+Q29pZQ0YY1i/AeUk1xtfPWFN4Om8dedEUT5VmV+Fha/qv9ANrVxGsY3xyBSnIwYXf", - "LF6bAtoUVONRtDVbptwaxZO81W02pmgjVJAPOQXWtNVqEno1F+i83VVUh8CfPe1hDT8tj5yWPaHlpg9r", - "U2BBL6S2KJPqv9zc3n66+ZTNwmFB6fiapob1N8gry6QghWfUok2xJSM+aHQ8iX0gICSHnqDvHESJ/LqX", - "vTaaS1LwkJ6IibnoJ7oUs03B0/dGe1IBxaDSADCdzmdG+oexpN09Ui6B3Jdn2eChsqj+z7y/2NIkv1ma", - "m7jOrdk6lHKastQ1FsTLR1uaxaMrZlN5i6rWZpK5x4ppDN5ZWxGaEN0w+QubXK/mRhhCL1kEKK/KPHQ5", - "KXKh9Ih7TuHN0aRn8hLnXrswoimuP0vNieYEk8HgM1r1n950T0RLdca9RzV3ac8EOGk0VEon2C9JhxLa", - "7G1o2feGu2qH4i1zEoB5g1XyTLvkbnMPKTyR5ygDZIurRRYwW0cGnYY1rBbZIgt3CaWMwi2D9eNbQfEe", - "B1Wj1e9VWEYkn2NASPFYk5BnWH97geAs+N6QP0AKnR8gzHGYcLcwIuozDdu0zy4JFflj+l0jpfX6n9ge", - "TpUT39BMyVHlhxDMzhruuFxnWXjk1giZSAudq3QeKy8fuXPJsd7UTKMkWqjm1zZi3O/tODf0Hg+zC4b/", - "Y7oT78JXzZLYfdJltCncZKt3cKmJGYszw/5u/U4rRSYZp/2qdYdCb+Ew1o9VuKlr9IeBW0+sTWFZ2aJb", - "UM7yjPk2luVrDOmgEMuvVh3eoYZD5mfrp9d8PL26Xs1th3cuvH6vja3nBZx6vf1QP4v9m87W4o/wtzj5", - "/yqVrshbpv9Hk+fEvG+q6pBgIyUZCVBJdXa++mg735snrLRKck8q9MKKP9TOQ/1hmon1yTjOqcP/YvJJ", - "Z+u2bdt/AwAA//+RpToKFwoAAA==", + "H4sIAAAAAAAC/9xX3W7jNhN9FX38CmwLCP7LItj6Lk23aRZb1Fi3V4vAoMWxzZQitRxqEzfQuxf8kWRZ", + "8kpJfFE0F44tcYZnzpwhZ55IotJMSZAGyfyJYLKDlLqvNzQF+z/TKgNtOLinjGMm6H4lw1t4pGkmgMzd", + "+mhKYmL2mf2NRnO5JUVMWK6p4UquEBIlGTbsLi4nlQmXBragrc2WprDirLF02rUw02otILULv9OwIXPy", + "/3Ed0zgENF6EZUVM0FBtgK2oaXj/8e3l5bu37yadcNBQ4+OVeUrmn0kiFAIjMXmg3HC5XYE02nJUP3H7", + "EIsQMqqBhJ0tKS4+/2XDJccdMHIXH5BZuT8is4iJhi8518AsipKlEmDczE8H9XeVS7W+h8TY4GzmFoLu", + "Qf8GiHTrAlUSft+Q+edv09oyXc6uSRE/0+h6tiTFXRcS++blYK5ny/fS6P2LEH0Cyl5mea0YnI7HvW3X", + "FTW0T8OnvC3oXijKbCp9bm1VSzRkTjK3fJ7McJ7YffsE5d7GHs0gqRxBaMWVhGhraWeaS/P9m19BCBVH", + "D0oL9r83P/Qic46GQvJZb4H5BjvgLAbRMxSEF9BzQGhncT4QthhfVcqLcHQ9uwqWs+ulO/5eYvn+EZJP", + "gLkwJ6qoueY8tdTw2V9ROEvm8AiJ9hjOXledcFqRYqJ0s7ym9v6SuRB0bX8ancOp+yzHwwsN8yQBxOY1", + "VD7sCy+4iwOgoRGW8jpbBoPDYemr7+Xz5+4ISCvA53YtR5BK86FwfC2ejWbnbhjJZRN0foobINqVYd8+", + "o8VrC9qbd6H58HB639P98Qe1k9HPCro6ZJ4oucqo2TVNxjylW8DxvdrJ0X227TTFFWUplw3LDRVY1/1a", + "KQFU2tU5gm611bOLriPCLm1HYaH0prPc5cBJqzOtcHcxvKjL44hewETzzLa0TVx/7DhGHCMalbXRwVV4", + "NWiuMNyIo9gDqq4hp7s+PQfeU9zA3g7auuByo1yL4PcmV2JNjVaIkQWmJRXRA6yjq8UticlX0OhoIJPR", + "dDSxmFUGkmaczMnFaDKa2NmDmp0jbmxHBfdtC64oLKtuNLhldngDc+MWWBNNUzCg0bUMVlnkSw6uO/J6", + "CPUdBg93TtSNzWFJBesdUAa6Nr/KzU5p/rfbnhwy56+rlsuK5Tu7GDMl0ccym0zCuWNAurBolgmeOM/j", + "e/Qqqf01xVRRwg2kOOQgrA87QrWm+86BDE9kt6Fd8pGjidQm8hZFTN5OLl4RS1pPbrVgf1F6zRkDGVXZ", + "7pVu6WhIDJV/5wXzNKW27faxhcCKOGhv/BSm1aJXhfbjlp3QojsmKy3VE3Cviv7NwuwXXpv9K0fxf0w6", + "N2AiGgKz0hFq6++2TGGHYhYKzUe3xEMBND8pP3W9kI2MIj4ozY6G1vB0OrvoulheeVeGK7HaupvAphqL", + "sx6FRv0FRzfqo/0bHXz2d3HOyZDsL/1cscmF2Ec0NzuQxkIF5uU8Pbecb+VXKjiLEg3M7kUFnlXOpf8y", + "m5HSUZXOpsL/RNCRl3VRFMU/AQAA///TQodrghUAAA==", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/backend/api/handlers.go b/backend/api/handlers.go index 54a3fc4..91cdbc8 100644 --- a/backend/api/handlers.go +++ b/backend/api/handlers.go @@ -133,6 +133,41 @@ func (h *ApiHandler) GetGames(ctx context.Context, request GetGamesRequestObject } } +func (h *ApiHandler) GetGamesGameId(ctx context.Context, request GetGamesGameIdRequestObject) (GetGamesGameIdResponseObject, error) { + // TODO: user permission + // user := ctx.Value("user").(*auth.JWTClaims) + gameId := request.GameId + row, err := h.q.GetGameById(ctx, int32(gameId)) + if err != nil { + return nil, echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + var startedAt *int + if row.StartedAt.Valid { + startedAtTimestamp := int(row.StartedAt.Time.Unix()) + startedAt = &startedAtTimestamp + } + var problem *Problem + if row.ProblemID.Valid && GameState(row.State) != Closed && GameState(row.State) != WaitingEntries { + if !row.Title.Valid || !row.Description.Valid { + panic("inconsistent data") + } + problem = &Problem{ + ProblemId: int(row.ProblemID.Int32), + Title: row.Title.String, + Description: row.Description.String, + } + } + game := Game{ + GameId: int(row.GameID), + State: GameState(row.State), + DisplayName: row.DisplayName, + DurationSeconds: int(row.DurationSeconds), + StartedAt: startedAt, + Problem: problem, + } + return GetGamesGameId200JSONResponse(game), nil +} + func _assertJwtPayloadIsCompatibleWithJWTClaims() { var c auth.JWTClaims var p JwtPayload diff --git a/backend/db/query.sql.go b/backend/db/query.sql.go index 20a7dc1..1b9f392 100644 --- a/backend/db/query.sql.go +++ b/backend/db/query.sql.go @@ -11,6 +11,44 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) +const getGameById = `-- name: GetGameById :one +SELECT game_id, state, display_name, duration_seconds, created_at, started_at, games.problem_id, problems.problem_id, title, description FROM games +LEFT JOIN problems ON games.problem_id = problems.problem_id +WHERE games.game_id = $1 +LIMIT 1 +` + +type GetGameByIdRow struct { + GameID int32 + State string + DisplayName string + DurationSeconds int32 + CreatedAt pgtype.Timestamp + StartedAt pgtype.Timestamp + ProblemID pgtype.Int4 + ProblemID_2 pgtype.Int4 + Title pgtype.Text + Description pgtype.Text +} + +func (q *Queries) GetGameById(ctx context.Context, gameID int32) (GetGameByIdRow, error) { + row := q.db.QueryRow(ctx, getGameById, gameID) + var i GetGameByIdRow + err := row.Scan( + &i.GameID, + &i.State, + &i.DisplayName, + &i.DurationSeconds, + &i.CreatedAt, + &i.StartedAt, + &i.ProblemID, + &i.ProblemID_2, + &i.Title, + &i.Description, + ) + return i, err +} + const getUserAuthByUsername = `-- name: GetUserAuthByUsername :one SELECT users.user_id, username, display_name, icon_path, is_admin, created_at, user_auth_id, user_auths.user_id, auth_type, password_hash FROM users JOIN user_auths ON users.user_id = user_auths.user_id @@ -172,3 +210,35 @@ func (q *Queries) ListGamesForPlayer(ctx context.Context, userID int32) ([]ListG } return items, nil } + +const updateGameStartedAt = `-- name: UpdateGameStartedAt :exec +UPDATE games +SET started_at = $2 +WHERE game_id = $1 +` + +type UpdateGameStartedAtParams struct { + GameID int32 + StartedAt pgtype.Timestamp +} + +func (q *Queries) UpdateGameStartedAt(ctx context.Context, arg UpdateGameStartedAtParams) error { + _, err := q.db.Exec(ctx, updateGameStartedAt, arg.GameID, arg.StartedAt) + return err +} + +const updateGameState = `-- name: UpdateGameState :exec +UPDATE games +SET state = $2 +WHERE game_id = $1 +` + +type UpdateGameStateParams struct { + GameID int32 + State string +} + +func (q *Queries) UpdateGameState(ctx context.Context, arg UpdateGameStateParams) error { + _, err := q.db.Exec(ctx, updateGameState, arg.GameID, arg.State) + return err +} diff --git a/backend/game/client.go b/backend/game/client.go new file mode 100644 index 0000000..7cd66a4 --- /dev/null +++ b/backend/game/client.go @@ -0,0 +1,113 @@ +package game + +import ( + "encoding/json" + "log" + "time" + + "github.com/gorilla/websocket" +) + +type playerClient struct { + hub *gameHub + conn *websocket.Conn + s2cMessages chan playerMessageS2C +} + +// Receives messages from the client and sends them to the hub. +func (c *playerClient) readPump() { + defer func() { + log.Printf("closing client") + c.hub.unregisterPlayer <- 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 rawMessage map[string]json.RawMessage + if err := c.conn.ReadJSON(&rawMessage); err != nil { + log.Printf("error: %v", err) + return + } + message, err := asPlayerMessageC2S(rawMessage) + if err != nil { + log.Printf("error: %v", err) + return + } + c.hub.playerC2SMessages <- &playerMessageC2SWithClient{c, message} + } +} + +// Receives messages from the hub and sends them to the client. +func (c *playerClient) writePump() { + ticker := time.NewTicker(pingPeriod) + defer func() { + ticker.Stop() + c.conn.Close() + }() + for { + select { + case message, ok := <-c.s2cMessages: + 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 + } + } + } +} + +type watcherClient struct { + hub *gameHub + conn *websocket.Conn + s2cMessages chan watcherMessageS2C +} + +// Receives messages from the client and sends them to the hub. +func (c *watcherClient) 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 *watcherClient) 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.s2cMessages: + 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/game/game.go b/backend/game/game.go deleted file mode 100644 index 9e63a1e..0000000 --- a/backend/game/game.go +++ /dev/null @@ -1,385 +0,0 @@ -package game - -import ( - "context" - "log" - "time" - - "github.com/gorilla/websocket" - - "github.com/nsfisis/iosdc-2024-albatross/backend/api" - "github.com/nsfisis/iosdc-2024-albatross/backend/db" -) - -type gameState = api.GameState - -const ( - gameStateClosed gameState = api.Closed - gameStateWaitingEntries gameState = api.WaitingEntries - gameStateWaitingStart gameState = api.WaitingStart - gameStatePrepare gameState = api.Prepare - gameStateStarting gameState = api.Starting - gameStateGaming gameState = api.Gaming - gameStateFinished gameState = api.Finished -) - -type game struct { - gameID int - state string - displayName string - durationSeconds int - startedAt *time.Time - problem *problem -} - -type problem struct { - problemID int - title string - description string -} - -// 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) -} -*/ - -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) -} - -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 - } - } - } -} - -// 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 - } - } - } -} - -type GameHubs struct { - hubs map[int]*GameHub -} - -func NewGameHubs() *GameHubs { - return &GameHubs{ - hubs: make(map[int]*GameHub), - } -} - -func (hubs *GameHubs) Close() { - for _, hub := range hubs.hubs { - hub.Close() - } -} - -func (hubs *GameHubs) RestoreFromDB(ctx context.Context, q *db.Queries) error { - games, err := q.ListGames(ctx) - if err != nil { - return err - } - _ = games - return nil -} - -func (hubs *GameHubs) SockHandler() *sockHandler { - return newSockHandler(hubs) -} diff --git a/backend/game/http.go b/backend/game/http.go index a5a7ded..beda46c 100644 --- a/backend/game/http.go +++ b/backend/game/http.go @@ -18,12 +18,13 @@ func newSockHandler(hubs *GameHubs) *sockHandler { } func (h *sockHandler) HandleSockGolfPlay(c echo.Context) error { + // TODO: auth gameId := c.Param("gameId") gameIdInt, err := strconv.Atoi(gameId) if err != nil { return echo.NewHTTPError(http.StatusBadRequest, "Invalid game id") } - var foundHub *GameHub + var foundHub *gameHub for _, hub := range h.hubs.hubs { if hub.game.gameID == gameIdInt { foundHub = hub @@ -37,12 +38,13 @@ func (h *sockHandler) HandleSockGolfPlay(c echo.Context) error { } func (h *sockHandler) HandleSockGolfWatch(c echo.Context) error { + // TODO: auth gameId := c.Param("gameId") gameIdInt, err := strconv.Atoi(gameId) if err != nil { return echo.NewHTTPError(http.StatusBadRequest, "Invalid game id") } - var foundHub *GameHub + var foundHub *gameHub for _, hub := range h.hubs.hubs { if hub.game.gameID == gameIdInt { foundHub = hub diff --git a/backend/game/hub.go b/backend/game/hub.go new file mode 100644 index 0000000..c61f2bb --- /dev/null +++ b/backend/game/hub.go @@ -0,0 +1,273 @@ +package game + +import ( + "context" + "log" + "time" + + "github.com/jackc/pgx/v5/pgtype" + + "github.com/nsfisis/iosdc-2024-albatross/backend/api" + "github.com/nsfisis/iosdc-2024-albatross/backend/db" +) + +type playerClientState int + +const ( + playerClientStateWaitingEntries playerClientState = iota + playerClientStateEntried + playerClientStateReady +) + +type gameHub struct { + ctx context.Context + game *game + q *db.Queries + players map[*playerClient]playerClientState + registerPlayer chan *playerClient + unregisterPlayer chan *playerClient + playerC2SMessages chan *playerMessageC2SWithClient + watchers map[*watcherClient]bool + registerWatcher chan *watcherClient + unregisterWatcher chan *watcherClient +} + +func newGameHub(ctx context.Context, game *game, q *db.Queries) *gameHub { + return &gameHub{ + ctx: ctx, + game: game, + q: q, + players: make(map[*playerClient]playerClientState), + registerPlayer: make(chan *playerClient), + unregisterPlayer: make(chan *playerClient), + playerC2SMessages: make(chan *playerMessageC2SWithClient), + watchers: make(map[*watcherClient]bool), + registerWatcher: make(chan *watcherClient), + unregisterWatcher: make(chan *watcherClient), + } +} + +func (hub *gameHub) run() { + ticker := time.NewTicker(10 * time.Second) + defer func() { + ticker.Stop() + }() + + for { + select { + case player := <-hub.registerPlayer: + hub.players[player] = playerClientStateWaitingEntries + case player := <-hub.unregisterPlayer: + if _, ok := hub.players[player]; ok { + hub.closePlayerClient(player) + } + case watcher := <-hub.registerWatcher: + hub.watchers[watcher] = true + case watcher := <-hub.unregisterWatcher: + if _, ok := hub.watchers[watcher]; ok { + hub.closeWatcherClient(watcher) + } + case message := <-hub.playerC2SMessages: + switch msg := message.message.(type) { + case *playerMessageC2SEntry: + log.Printf("entry: %v", message.message) + // TODO: assert state is waiting_entries + hub.players[message.client] = playerClientStateEntried + entriedPlayerCount := 0 + for _, state := range hub.players { + if playerClientStateEntried <= state { + entriedPlayerCount++ + } + } + if entriedPlayerCount == 2 { + for player := range hub.players { + player.s2cMessages <- &playerMessageS2CPrepare{ + Type: playerMessageTypeS2CPrepare, + Data: playerMessageS2CPreparePayload{ + Problem: api.Problem{ + ProblemId: 1, + Title: "the answer", + Description: "print 42", + }, + }, + } + } + err := hub.q.UpdateGameState(hub.ctx, db.UpdateGameStateParams{ + GameID: int32(hub.game.gameID), + State: string(gameStatePrepare), + }) + if err != nil { + log.Fatalf("failed to set game state: %v", err) + } + hub.game.state = gameStatePrepare + } + case *playerMessageC2SReady: + log.Printf("ready: %v", message.message) + // TODO: assert state is prepare + hub.players[message.client] = playerClientStateReady + readyPlayerCount := 0 + for _, state := range hub.players { + if playerClientStateReady <= state { + readyPlayerCount++ + } + } + if readyPlayerCount == 2 { + startAt := time.Now().Add(11 * time.Second).UTC() + for player := range hub.players { + player.s2cMessages <- &playerMessageS2CStart{ + Type: playerMessageTypeS2CStart, + Data: playerMessageS2CStartPayload{ + StartAt: int(startAt.Unix()), + }, + } + } + err := hub.q.UpdateGameStartedAt(hub.ctx, db.UpdateGameStartedAtParams{ + GameID: int32(hub.game.gameID), + StartedAt: pgtype.Timestamp{ + Time: startAt, + InfinityModifier: pgtype.Finite, + Valid: true, + }, + }) + if err != nil { + log.Fatalf("failed to set game state: %v", err) + } + hub.game.startedAt = &startAt + err = hub.q.UpdateGameState(hub.ctx, db.UpdateGameStateParams{ + GameID: int32(hub.game.gameID), + State: string(gameStateStarting), + }) + if err != nil { + log.Fatalf("failed to set game state: %v", err) + } + hub.game.state = gameStateStarting + } + case *playerMessageC2SCode: + // TODO: assert game state is gaming + log.Printf("code: %v", message.message) + code := msg.Data.Code + score := len(code) + message.client.s2cMessages <- &playerMessageS2CExecResult{ + Type: playerMessageTypeS2CExecResult, + Data: playerMessageS2CExecResultPayload{ + Score: &score, + Status: api.Success, + }, + } + default: + log.Fatalf("unexpected message type: %T", message.message) + } + case <-ticker.C: + if hub.game.state == gameStateStarting { + if time.Now().After(*hub.game.startedAt) { + err := hub.q.UpdateGameState(hub.ctx, db.UpdateGameStateParams{ + GameID: int32(hub.game.gameID), + State: string(gameStateGaming), + }) + if err != nil { + log.Fatalf("failed to set game state: %v", err) + } + hub.game.state = gameStateGaming + } + } else if hub.game.state == gameStateGaming { + if time.Now().After(hub.game.startedAt.Add(time.Duration(hub.game.durationSeconds) * time.Second)) { + err := hub.q.UpdateGameState(hub.ctx, db.UpdateGameStateParams{ + GameID: int32(hub.game.gameID), + State: string(gameStateFinished), + }) + if err != nil { + log.Fatalf("failed to set game state: %v", err) + } + hub.game.state = gameStateFinished + } + hub.close() + return + } + } + } +} + +func (hub *gameHub) close() { + for client := range hub.players { + hub.closePlayerClient(client) + } + close(hub.registerPlayer) + close(hub.unregisterPlayer) + close(hub.playerC2SMessages) + for watcher := range hub.watchers { + hub.closeWatcherClient(watcher) + } + close(hub.registerWatcher) + close(hub.unregisterWatcher) +} + +func (hub *gameHub) closePlayerClient(player *playerClient) { + delete(hub.players, player) + close(player.s2cMessages) +} + +func (hub *gameHub) closeWatcherClient(watcher *watcherClient) { + delete(hub.watchers, watcher) + close(watcher.s2cMessages) +} + +type GameHubs struct { + hubs map[int]*gameHub + q *db.Queries +} + +func NewGameHubs(q *db.Queries) *GameHubs { + return &GameHubs{ + hubs: make(map[int]*gameHub), + q: q, + } +} + +func (hubs *GameHubs) Close() { + for _, hub := range hubs.hubs { + hub.close() + } +} + +func (hubs *GameHubs) RestoreFromDB(ctx context.Context) error { + games, err := hubs.q.ListGames(ctx) + if err != nil { + return err + } + for _, row := range games { + var startedAt *time.Time + if row.StartedAt.Valid { + startedAt = &row.StartedAt.Time + } + var problem_ *problem + if row.ProblemID.Valid { + if !row.Title.Valid || !row.Description.Valid { + panic("inconsistent data") + } + problem_ = &problem{ + problemID: int(row.ProblemID.Int32), + title: row.Title.String, + description: row.Description.String, + } + } + hubs.hubs[int(row.GameID)] = newGameHub(ctx, &game{ + gameID: int(row.GameID), + durationSeconds: int(row.DurationSeconds), + state: gameState(row.State), + displayName: row.DisplayName, + startedAt: startedAt, + problem: problem_, + }, hubs.q) + } + return nil +} + +func (hubs *GameHubs) Run() { + for _, hub := range hubs.hubs { + go hub.run() + } +} + +func (hubs *GameHubs) SockHandler() *sockHandler { + return newSockHandler(hubs) +} diff --git a/backend/game/message.go b/backend/game/message.go index 7d1a166..9116bde 100644 --- a/backend/game/message.go +++ b/backend/game/message.go @@ -3,102 +3,67 @@ package game 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"` -} + "github.com/nsfisis/iosdc-2024-albatross/backend/api" +) -type MessageDataFinish struct { - YourScore *int `json:"yourScore"` - OpponentScore *int `json:"opponentScore"` -} +const ( + playerMessageTypeS2CPrepare = "player:s2c:prepare" + playerMessageTypeS2CStart = "player:s2c:start" + playerMessageTypeS2CExecResult = "player:s2c:execreslut" + playerMessageTypeC2SEntry = "player:c2s:entry" + playerMessageTypeC2SReady = "player:c2s:ready" + playerMessageTypeC2SCode = "player:c2s:code" +) -type MessageDataWatch struct { - Problem string `json:"problem"` - ScoreA *int `json:"scoreA"` - CodeA string `json:"codeA"` - ScoreB *int `json:"scoreB"` - CodeB string `json:"codeB"` +type playerMessageC2SWithClient struct { + client *playerClient + message playerMessageC2S } -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 +type playerMessage = api.GamePlayerMessage + +type playerMessageS2C = interface{} +type playerMessageS2CPrepare = api.GamePlayerMessageS2CPrepare +type playerMessageS2CPreparePayload = api.GamePlayerMessageS2CPreparePayload +type playerMessageS2CStart = api.GamePlayerMessageS2CStart +type playerMessageS2CStartPayload = api.GamePlayerMessageS2CStartPayload +type playerMessageS2CExecResult = api.GamePlayerMessageS2CExecResult +type playerMessageS2CExecResultPayload = api.GamePlayerMessageS2CExecResultPayload + +type playerMessageC2S = interface{} +type playerMessageC2SEntry = api.GamePlayerMessageC2SEntry +type playerMessageC2SReady = api.GamePlayerMessageC2SReady +type playerMessageC2SCode = api.GamePlayerMessageC2SCode +type playerMessageC2SCodePayload = api.GamePlayerMessageC2SCodePayload + +func asPlayerMessageC2S(raw map[string]json.RawMessage) (playerMessageC2S, error) { + var typ string + if err := json.Unmarshal(raw["type"], &typ); err != nil { + return nil, 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 + switch typ { + case playerMessageTypeC2SEntry: + return &playerMessageC2SEntry{ + Type: playerMessageTypeC2SEntry, + }, nil + case playerMessageTypeC2SReady: + return &playerMessageC2SReady{ + Type: playerMessageTypeC2SReady, + }, nil + case playerMessageTypeC2SCode: + var payload playerMessageC2SCodePayload + if err := json.Unmarshal(raw["data"], &payload); err != nil { + return nil, err + } + return &playerMessageC2SCode{ + Type: playerMessageTypeC2SCode, + Data: payload, + }, nil default: - err = fmt.Errorf("unknown message type: %s", m.Type) + return nil, fmt.Errorf("unknown message type: %s", typ) } - - return err } + +type watcherMessageS2C = interface{} diff --git a/backend/game/models.go b/backend/game/models.go new file mode 100644 index 0000000..4e9ee87 --- /dev/null +++ b/backend/game/models.go @@ -0,0 +1,34 @@ +package game + +import ( + "time" + + "github.com/nsfisis/iosdc-2024-albatross/backend/api" +) + +type gameState = api.GameState + +const ( + gameStateClosed gameState = api.Closed + gameStateWaitingEntries gameState = api.WaitingEntries + gameStateWaitingStart gameState = api.WaitingStart + gameStatePrepare gameState = api.Prepare + gameStateStarting gameState = api.Starting + gameStateGaming gameState = api.Gaming + gameStateFinished gameState = api.Finished +) + +type game struct { + gameID int + state gameState + displayName string + durationSeconds int + startedAt *time.Time + problem *problem +} + +type problem struct { + problemID int + title string + description string +} diff --git a/backend/game/ws.go b/backend/game/ws.go index 2ed17af..013db7a 100644 --- a/backend/game/ws.go +++ b/backend/game/ws.go @@ -17,28 +17,40 @@ const ( var upgrader = websocket.Upgrader{ ReadBufferSize: 1024, WriteBufferSize: 1024, + CheckOrigin: func(r *http.Request) bool { + // TODO: insecure! + return true + }, } -func servePlayerWs(hub *GameHub, w http.ResponseWriter, r *http.Request, team string) error { +func servePlayerWs(hub *gameHub, w http.ResponseWriter, r *http.Request, team string) error { conn, err := upgrader.Upgrade(w, r, nil) if err != nil { return err } - client := &GameClient{hub: hub, conn: conn, send: make(chan *Message), team: team} - client.hub.register <- client + player := &playerClient{ + hub: hub, + conn: conn, + s2cMessages: make(chan playerMessageS2C), + } + hub.registerPlayer <- player - go client.writePump() - go client.readPump() + go player.writePump() + go player.readPump() return nil } -func serveWatcherWs(hub *GameHub, w http.ResponseWriter, r *http.Request) error { +func serveWatcherWs(hub *gameHub, w http.ResponseWriter, r *http.Request) error { conn, err := upgrader.Upgrade(w, r, nil) if err != nil { return err } - watcher := &GameWatcher{hub: hub, conn: conn, send: make(chan *Message)} - watcher.hub.registerWatcher <- watcher + watcher := &watcherClient{ + hub: hub, + conn: conn, + s2cMessages: make(chan watcherMessageS2C), + } + hub.registerWatcher <- watcher go watcher.writePump() go watcher.readPump() diff --git a/backend/main.go b/backend/main.go index 379fe8d..91caa73 100644 --- a/backend/main.go +++ b/backend/main.go @@ -6,7 +6,7 @@ import ( "log" "net/http" - "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" oapimiddleware "github.com/oapi-codegen/echo-middleware" @@ -16,6 +16,19 @@ import ( "github.com/nsfisis/iosdc-2024-albatross/backend/game" ) +func connectDB(ctx context.Context, dsn string) (*pgxpool.Pool, error) { + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + return nil, err + } + + if err := pool.Ping(ctx); err != nil { + return nil, err + } + + return pool, nil +} + func main() { var err error config, err := NewConfigFromEnv() @@ -30,13 +43,14 @@ func main() { ctx := context.Background() - conn, err := pgx.Connect(ctx, fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable", config.dbHost, config.dbPort, config.dbUser, config.dbPassword, config.dbName)) + dbDSN := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable", config.dbHost, config.dbPort, config.dbUser, config.dbPassword, config.dbName) + connPool, err := connectDB(ctx, dbDSN) if err != nil { log.Fatalf("Error connecting to db %v", err) } - defer conn.Close(ctx) + defer connPool.Close() - queries := db.New(conn) + queries := db.New(connPool) e := echo.New() @@ -50,20 +64,22 @@ func main() { api.NewJWTMiddleware(), })) - gameHubs := game.NewGameHubs() - err = gameHubs.RestoreFromDB(ctx, queries) + gameHubs := game.NewGameHubs(queries) + err = gameHubs.RestoreFromDB(ctx) if err != nil { log.Fatalf("Error restoring game hubs from db %v", err) } defer gameHubs.Close() sockGroup := e.Group("/sock") sockHandler := gameHubs.SockHandler() - sockGroup.GET("/golf/:gameId/watch", func(c echo.Context) error { - return sockHandler.HandleSockGolfWatch(c) - }) sockGroup.GET("/golf/:gameId/play", func(c echo.Context) error { return sockHandler.HandleSockGolfPlay(c) }) + sockGroup.GET("/golf/:gameId/watch", func(c echo.Context) error { + return sockHandler.HandleSockGolfWatch(c) + }) + + gameHubs.Run() if err := e.Start(":80"); err != http.ErrServerClosed { log.Fatal(err) diff --git a/backend/query.sql b/backend/query.sql index 9b038a5..b2ad2c4 100644 --- a/backend/query.sql +++ b/backend/query.sql @@ -18,3 +18,19 @@ SELECT * FROM games LEFT JOIN problems ON games.problem_id = problems.problem_id JOIN game_players ON games.game_id = game_players.game_id WHERE game_players.user_id = $1; + +-- name: UpdateGameState :exec +UPDATE games +SET state = $2 +WHERE game_id = $1; + +-- name: UpdateGameStartedAt :exec +UPDATE games +SET started_at = $2 +WHERE game_id = $1; + +-- name: GetGameById :one +SELECT * FROM games +LEFT JOIN problems ON games.problem_id = problems.problem_id +WHERE games.game_id = $1 +LIMIT 1; diff --git a/frontend/app/.server/api/schema.d.ts b/frontend/app/.server/api/schema.d.ts index cd87705..40a3347 100644 --- a/frontend/app/.server/api/schema.d.ts +++ b/frontend/app/.server/api/schema.d.ts @@ -118,6 +118,58 @@ export interface paths { patch?: never; trace?: never; }; + [path: `/games/${integer}`]: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get a game */ + get: { + parameters: { + query?: never; + header: { + Authorization: string; + }; + path: { + game_id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description A game */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Game"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example Forbidden operation */ + message: string; + }; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; } export type webhooks = Record<string, never>; export interface components { @@ -138,7 +190,7 @@ export interface components { /** @example 1 */ game_id: number; /** - * @example active + * @example closed * @enum {string} */ state: "closed" | "waiting_entries" | "waiting_start" | "prepare" | "starting" | "gaming" | "finished"; @@ -158,6 +210,57 @@ export interface components { /** @example This is a problem */ description: string; }; + GamePlayerMessage: components["schemas"]["GamePlayerMessageS2C"] | components["schemas"]["GamePlayerMessageC2S"]; + GamePlayerMessageS2C: components["schemas"]["GamePlayerMessageS2CPrepare"] | components["schemas"]["GamePlayerMessageS2CStart"] | components["schemas"]["GamePlayerMessageS2CExecResult"]; + GamePlayerMessageS2CPrepare: { + /** @constant */ + type: "player:s2c:prepare"; + data: components["schemas"]["GamePlayerMessageS2CPreparePayload"]; + }; + GamePlayerMessageS2CPreparePayload: { + problem: components["schemas"]["Problem"]; + }; + GamePlayerMessageS2CStart: { + /** @constant */ + type: "player:s2c:start"; + data: components["schemas"]["GamePlayerMessageS2CStartPayload"]; + }; + GamePlayerMessageS2CStartPayload: { + /** @example 946684800 */ + start_at: number; + }; + GamePlayerMessageS2CExecResult: { + /** @constant */ + type: "player:s2c:execresult"; + data: components["schemas"]["GamePlayerMessageS2CExecResultPayload"]; + }; + GamePlayerMessageS2CExecResultPayload: { + /** + * @example success + * @enum {string} + */ + status: "success"; + /** @example 100 */ + score: number | null; + }; + GamePlayerMessageC2S: components["schemas"]["GamePlayerMessageC2SEntry"] | components["schemas"]["GamePlayerMessageC2SReady"] | components["schemas"]["GamePlayerMessageC2SCode"]; + GamePlayerMessageC2SEntry: { + /** @constant */ + type: "player:c2s:entry"; + }; + GamePlayerMessageC2SReady: { + /** @constant */ + type: "player:c2s:ready"; + }; + GamePlayerMessageC2SCode: { + /** @constant */ + type: "player:c2s:code"; + data: components["schemas"]["GamePlayerMessageC2SCodePayload"]; + }; + GamePlayerMessageC2SCodePayload: { + /** @example print('Hello, world!') */ + code: string; + }; }; responses: never; parameters: never; diff --git a/frontend/app/components/GolfPlayApp.tsx b/frontend/app/components/GolfPlayApp.tsx new file mode 100644 index 0000000..c6c20d4 --- /dev/null +++ b/frontend/app/components/GolfPlayApp.tsx @@ -0,0 +1,146 @@ +import type { components } from "../.server/api/schema"; +import { useState, useEffect } from "react"; +import useWebSocket, { ReadyState } from "react-use-websocket"; +import { useDebouncedCallback } from "use-debounce"; +import GolfPlayAppConnecting from "./GolfPlayApps/GolfPlayAppConnecting"; +import GolfPlayAppWaiting from "./GolfPlayApps/GolfPlayAppWaiting"; +import GolfPlayAppStarting from "./GolfPlayApps/GolfPlayAppStarting"; +import GolfPlayAppGaming from "./GolfPlayApps/GolfPlayAppGaming"; +import GolfPlayAppFinished from "./GolfPlayApps/GolfPlayAppFinished"; + +type WebSocketMessage = components["schemas"]["GamePlayerMessageS2C"]; + +type Game = components["schemas"]["Game"]; +type Problem = components["schemas"]["Problem"]; + +type GameState = "connecting" | "waiting" | "starting" | "gaming" | "finished"; + +export default function GolfPlayApp({ game }: { game: Game }) { + // const socketUrl = `wss://t.nil.ninja/iosdc/2024/sock/golf/${game.game_id}/play`; + const socketUrl = + process.env.NODE_ENV === "development" + ? `ws://localhost:8002/sock/golf/${game.game_id}/play` + : `ws://api-server/sock/golf/${game.game_id}/play`; + + const { sendJsonMessage, lastJsonMessage, readyState } = + useWebSocket<WebSocketMessage>(socketUrl, {}); + + const [gameState, setGameState] = useState<GameState>("connecting"); + + const [problem, setProblem] = useState<Problem | null>(null); + + const [startedAt, setStartedAt] = useState<number | null>(null); + + const [timeLeftSeconds, setTimeLeftSeconds] = useState<number | null>(null); + + useEffect(() => { + if (gameState === "starting" && startedAt !== null) { + const timer1 = setInterval(() => { + setTimeLeftSeconds((prev) => { + if (prev === null) { + return null; + } + if (prev <= 1) { + clearInterval(timer1); + setGameState("gaming"); + return 0; + } + return prev - 1; + }); + }, 1000); + + const timer2 = setInterval(() => { + const nowSec = Math.floor(Date.now() / 1000); + const finishedAt = startedAt + game.duration_seconds; + if (nowSec >= finishedAt) { + clearInterval(timer2); + setGameState("finished"); + } + }, 1000); + + return () => { + clearInterval(timer1); + clearInterval(timer2); + }; + } + }, [gameState, startedAt, game.duration_seconds]); + + const [currentScore, setCurrentScore] = useState<number | null>(null); + void setCurrentScore; + + const onCodeChange = useDebouncedCallback((code: string) => { + console.log("player:c2s:code"); + sendJsonMessage({ + type: "player:c2s:code", + data: { code }, + }); + }, 1000); + + if (readyState === ReadyState.UNINSTANTIATED) { + throw new Error("WebSocket is not connected"); + } + + useEffect(() => { + if (readyState === ReadyState.CLOSING || readyState === ReadyState.CLOSED) { + if (gameState !== "finished") { + setGameState("connecting"); + } + } else if (readyState === ReadyState.CONNECTING) { + setGameState("connecting"); + } else if (readyState === ReadyState.OPEN) { + if (lastJsonMessage !== null) { + console.log(lastJsonMessage.type); + if (lastJsonMessage.type === "player:s2c:prepare") { + const { problem } = lastJsonMessage.data; + setProblem(problem); + console.log("player:c2s:ready"); + sendJsonMessage({ type: "player:c2s:ready" }); + } else if (lastJsonMessage.type === "player:s2c:start") { + if ( + gameState !== "starting" && + gameState !== "gaming" && + gameState !== "finished" + ) { + const { start_at } = lastJsonMessage.data; + setStartedAt(start_at); + const nowSec = Math.floor(Date.now() / 1000); + setTimeLeftSeconds(start_at - nowSec); + setGameState("starting"); + } + } else if (lastJsonMessage.type === "player:s2c:execresult") { + const { score } = lastJsonMessage.data; + if ( + score !== null && + (currentScore === null || score < currentScore) + ) { + setCurrentScore(score); + } + } + } else { + setGameState("waiting"); + console.log("player:c2s:entry"); + sendJsonMessage({ type: "player:c2s:entry" }); + } + } + }, [sendJsonMessage, lastJsonMessage, readyState, gameState, currentScore]); + + if (gameState === "connecting") { + return <GolfPlayAppConnecting />; + } else if (gameState === "waiting") { + return <GolfPlayAppWaiting />; + } else if (gameState === "starting") { + return <GolfPlayAppStarting timeLeft={timeLeftSeconds!} />; + } else if (gameState === "gaming") { + return ( + <GolfPlayAppGaming + problem={problem!.description} + onCodeChange={onCodeChange} + currentScore={currentScore} + /> + ); + } else if (gameState === "finished") { + return <GolfPlayAppFinished />; + } else { + return null; + } +} diff --git a/frontend/app/components/GolfPlayApps/GolfPlayAppConnecting.tsx b/frontend/app/components/GolfPlayApps/GolfPlayAppConnecting.tsx new file mode 100644 index 0000000..e92a8e0 --- /dev/null +++ b/frontend/app/components/GolfPlayApps/GolfPlayAppConnecting.tsx @@ -0,0 +1,3 @@ +export default function GolfPlayAppConnecting() { + return <div>Connecting...</div>; +} diff --git a/frontend/app/components/GolfPlayApps/GolfPlayAppFinished.tsx b/frontend/app/components/GolfPlayApps/GolfPlayAppFinished.tsx new file mode 100644 index 0000000..75ceb71 --- /dev/null +++ b/frontend/app/components/GolfPlayApps/GolfPlayAppFinished.tsx @@ -0,0 +1,3 @@ +export default function GolfPlayAppFinished() { + return <div>Finished</div>; +} diff --git a/frontend/app/components/GolfPlayApps/GolfPlayAppGaming.tsx b/frontend/app/components/GolfPlayApps/GolfPlayAppGaming.tsx new file mode 100644 index 0000000..332cb3c --- /dev/null +++ b/frontend/app/components/GolfPlayApps/GolfPlayAppGaming.tsx @@ -0,0 +1,30 @@ +export default function GolfPlayAppGaming({ + problem, + onCodeChange, + currentScore, +}: { + problem: string; + onCodeChange: (code: string) => void; + currentScore: number | null; +}) { + const handleTextChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => { + onCodeChange(e.target.value); + }; + + return ( + <div style={{ display: "flex" }}> + <div style={{ flex: 1, padding: "10px", borderRight: "1px solid #ccc" }}> + <div>{problem}</div> + <div> + {currentScore == null ? "Score: -" : `Score: ${currentScore}`} + </div> + </div> + <div style={{ flex: 1, padding: "10px" }}> + <textarea + style={{ width: "100%", height: "100%" }} + onChange={handleTextChange} + /> + </div> + </div> + ); +} diff --git a/frontend/app/components/GolfPlayApps/GolfPlayAppStarting.tsx b/frontend/app/components/GolfPlayApps/GolfPlayAppStarting.tsx new file mode 100644 index 0000000..bf45abb --- /dev/null +++ b/frontend/app/components/GolfPlayApps/GolfPlayAppStarting.tsx @@ -0,0 +1,7 @@ +export default function GolfPlayAppStarting({ + timeLeft, +}: { + timeLeft: number; +}) { + return <div>Starting... ({timeLeft} s)</div>; +} diff --git a/frontend/app/components/GolfPlayApps/GolfPlayAppWaiting.tsx b/frontend/app/components/GolfPlayApps/GolfPlayAppWaiting.tsx new file mode 100644 index 0000000..a0751e0 --- /dev/null +++ b/frontend/app/components/GolfPlayApps/GolfPlayAppWaiting.tsx @@ -0,0 +1,3 @@ +export default function GolfPlayAppWaiting() { + return <div>Waiting...</div>; +} diff --git a/frontend/app/routes/golf.$gameId.play.tsx b/frontend/app/routes/golf.$gameId.play.tsx new file mode 100644 index 0000000..bda563f --- /dev/null +++ b/frontend/app/routes/golf.$gameId.play.tsx @@ -0,0 +1,33 @@ +import type { LoaderFunctionArgs } from "@remix-run/node"; +import { isAuthenticated } from "../.server/auth"; +import { apiClient } from "../.server/api/client"; +import { useLoaderData } from "@remix-run/react"; +import GolfPlayApp from "../components/GolfPlayApp"; + +export async function loader({ params, request }: LoaderFunctionArgs) { + const { token } = await isAuthenticated(request, { + failureRedirect: "/login", + }); + const { data, error } = await apiClient.GET("/games/{game_id}", { + params: { + path: { + game_id: Number(params.gameId), + }, + header: { + Authorization: `Bearer ${token}`, + }, + }, + }); + if (error) { + throw new Error(error.message); + } + return { + game: data, + }; +} + +export default function GolfPlay() { + const { game } = useLoaderData<typeof loader>(); + + return <GolfPlayApp game={game} />; +} diff --git a/openapi.yaml b/openapi.yaml index 002b229..d058381 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -87,6 +87,39 @@ paths: example: "Forbidden operation" required: - message + /games/{game_id}: + get: + summary: Get a game + parameters: + - in: path + name: game_id + schema: + type: integer + required: true + - in: header + name: Authorization + schema: + type: string + required: true + responses: + '200': + description: A game + content: + application/json: + schema: + $ref: '#/components/schemas/Game' + '403': + description: Forbidden + content: + application/json: + schema: + type: object + properties: + message: + type: string + example: "Forbidden operation" + required: + - message components: schemas: JwtPayload: @@ -120,7 +153,7 @@ components: example: 1 state: type: string - example: "active" + example: "closed" enum: - closed - waiting_entries @@ -161,3 +194,123 @@ components: - problem_id - title - description + GamePlayerMessage: + oneOf: + - $ref: '#/components/schemas/GamePlayerMessageS2C' + - $ref: '#/components/schemas/GamePlayerMessageC2S' + GamePlayerMessageS2C: + oneOf: + - $ref: '#/components/schemas/GamePlayerMessageS2CPrepare' + - $ref: '#/components/schemas/GamePlayerMessageS2CStart' + - $ref: '#/components/schemas/GamePlayerMessageS2CExecResult' + GamePlayerMessageS2CPrepare: + type: object + properties: + type: + type: string + const: "player:s2c:prepare" + data: + $ref: '#/components/schemas/GamePlayerMessageS2CPreparePayload' + required: + - type + - data + GamePlayerMessageS2CPreparePayload: + type: object + properties: + problem: + $ref: '#/components/schemas/Problem' + required: + - problem + GamePlayerMessageS2CStart: + type: object + properties: + type: + type: string + const: "player:s2c:start" + data: + $ref: '#/components/schemas/GamePlayerMessageS2CStartPayload' + required: + - type + - data + GamePlayerMessageS2CStartPayload: + type: object + properties: + start_at: + type: integer + example: 946684800 + required: + - start_at + GamePlayerMessageS2CExecResult: + type: object + properties: + type: + type: string + const: "player:s2c:execresult" + data: + $ref: '#/components/schemas/GamePlayerMessageS2CExecResultPayload' + required: + - type + - data + GamePlayerMessageS2CExecResultPayload: + type: object + properties: + status: + type: string + example: "success" + enum: + - success + score: + type: integer + nullable: true + example: 100 + required: + - status + - score + GamePlayerMessageC2S: + oneOf: + - $ref: '#/components/schemas/GamePlayerMessageC2SEntry' + - $ref: '#/components/schemas/GamePlayerMessageC2SReady' + - $ref: '#/components/schemas/GamePlayerMessageC2SCode' + GamePlayerMessageC2SEntry: + type: object + properties: + type: + type: string + const: "player:c2s:entry" + required: + - type + GamePlayerMessageC2SReady: + type: object + properties: + type: + type: string + const: "player:c2s:ready" + required: + - type + GamePlayerMessageC2SCode: + type: object + properties: + type: + type: string + const: "player:c2s:code" + data: + $ref: '#/components/schemas/GamePlayerMessageC2SCodePayload' + required: + - type + - data + GamePlayerMessageC2SCodePayload: + type: object + properties: + code: + type: string + example: "print('Hello, world!')" + required: + - code + # GameWatcherMessage: + # oneOf: + # - $ref: '#/components/schemas/GameWatcherMessageS2C' + # - $ref: '#/components/schemas/GameWatcherMessageC2S' + # GameWatcherMessageS2C: + # oneOf: + # GameWatcherMessageC2S: + # oneOf: |
