aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2024-07-29 02:59:07 +0900
committernsfisis <nsfisis@gmail.com>2024-07-29 02:59:07 +0900
commitd1c8aa42aec32c8b042ae32d249df9c3c969453d (patch)
tree2f248715213d12d6649f32b5ddcbb0d9a0281ed8
parent22ddf340f0b0c8d0cd04c34d9fa1481a1fbf422f (diff)
parent161d82bee9f9e65680516a9cfd392e0cf297eadf (diff)
downloadiosdc-japan-2024-albatross-d1c8aa42aec32c8b042ae32d249df9c3c969453d.tar.gz
iosdc-japan-2024-albatross-d1c8aa42aec32c8b042ae32d249df9c3c969453d.tar.zst
iosdc-japan-2024-albatross-d1c8aa42aec32c8b042ae32d249df9c3c969453d.zip
Merge branch 'game-playing'
-rw-r--r--backend/Dockerfile3
-rw-r--r--backend/api/generated.go455
-rw-r--r--backend/api/handlers.go35
-rw-r--r--backend/db/query.sql.go70
-rw-r--r--backend/game/client.go113
-rw-r--r--backend/game/game.go385
-rw-r--r--backend/game/http.go6
-rw-r--r--backend/game/hub.go273
-rw-r--r--backend/game/message.go143
-rw-r--r--backend/game/models.go34
-rw-r--r--backend/game/ws.go28
-rw-r--r--backend/main.go34
-rw-r--r--backend/query.sql16
-rw-r--r--frontend/app/.server/api/schema.d.ts105
-rw-r--r--frontend/app/components/GolfPlayApp.tsx146
-rw-r--r--frontend/app/components/GolfPlayApps/GolfPlayAppConnecting.tsx3
-rw-r--r--frontend/app/components/GolfPlayApps/GolfPlayAppFinished.tsx3
-rw-r--r--frontend/app/components/GolfPlayApps/GolfPlayAppGaming.tsx30
-rw-r--r--frontend/app/components/GolfPlayApps/GolfPlayAppStarting.tsx7
-rw-r--r--frontend/app/components/GolfPlayApps/GolfPlayAppWaiting.tsx3
-rw-r--r--frontend/app/routes/golf.$gameId.play.tsx33
-rw-r--r--openapi.yaml155
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: