diff options
| -rw-r--r-- | Makefile | 5 | ||||
| -rw-r--r-- | backend/api/generated.go | 183 | ||||
| -rw-r--r-- | backend/api/handler.go | 46 | ||||
| -rw-r--r-- | backend/db/query.sql.go | 36 | ||||
| -rw-r--r-- | backend/game/hub.go | 153 | ||||
| -rw-r--r-- | backend/game/message.go | 14 | ||||
| -rw-r--r-- | backend/query.sql | 7 | ||||
| -rw-r--r-- | frontend/app/.server/api/schema.d.ts | 41 | ||||
| -rw-r--r-- | frontend/app/components/ExecStatusIndicatorIcon.tsx | 45 | ||||
| -rw-r--r-- | frontend/app/components/GolfWatchApp.client.tsx | 128 | ||||
| -rw-r--r-- | frontend/app/components/GolfWatchApps/GolfWatchAppGaming.tsx | 191 | ||||
| -rw-r--r-- | frontend/app/components/GolfWatchApps/GolfWatchAppStarting.tsx | 6 | ||||
| -rw-r--r-- | frontend/app/components/GolfWatchApps/GolfWatchAppWaiting.tsx | 8 | ||||
| -rw-r--r-- | frontend/app/root.tsx | 2 | ||||
| -rw-r--r-- | frontend/app/routes/_index.tsx | 4 | ||||
| -rw-r--r-- | frontend/package-lock.json | 50 | ||||
| -rw-r--r-- | frontend/package.json | 3 | ||||
| -rw-r--r-- | openapi.yaml | 93 |
18 files changed, 877 insertions, 138 deletions
@@ -1,5 +1,10 @@ DOCKER_COMPOSE := docker compose -f compose.local.yaml +all: down build reset up + +reset: + echo "UPDATE games SET state = 'waiting_entries', started_at = NULL WHERE game_id = 1;" | make psql-query + .PHONY: build build: ${DOCKER_COMPOSE} build diff --git a/backend/api/generated.go b/backend/api/generated.go index 33f1a78..f7fd7e4 100644 --- a/backend/api/generated.go +++ b/backend/api/generated.go @@ -51,7 +51,22 @@ const ( // Defines values for GameWatcherMessageS2CExecResultPayloadStatus. const ( - GameWatcherMessageS2CExecResultPayloadStatusSuccess GameWatcherMessageS2CExecResultPayloadStatus = "success" + GameWatcherMessageS2CExecResultPayloadStatusCompileError GameWatcherMessageS2CExecResultPayloadStatus = "compile_error" + GameWatcherMessageS2CExecResultPayloadStatusInternalError GameWatcherMessageS2CExecResultPayloadStatus = "internal_error" + GameWatcherMessageS2CExecResultPayloadStatusRuntimeError GameWatcherMessageS2CExecResultPayloadStatus = "runtime_error" + GameWatcherMessageS2CExecResultPayloadStatusSuccess GameWatcherMessageS2CExecResultPayloadStatus = "success" + GameWatcherMessageS2CExecResultPayloadStatusTimeout GameWatcherMessageS2CExecResultPayloadStatus = "timeout" + GameWatcherMessageS2CExecResultPayloadStatusWrongAnswer GameWatcherMessageS2CExecResultPayloadStatus = "wrong_answer" +) + +// Defines values for GameWatcherMessageS2CSubmitResultPayloadStatus. +const ( + CompileError GameWatcherMessageS2CSubmitResultPayloadStatus = "compile_error" + InternalError GameWatcherMessageS2CSubmitResultPayloadStatus = "internal_error" + RuntimeError GameWatcherMessageS2CSubmitResultPayloadStatus = "runtime_error" + Success GameWatcherMessageS2CSubmitResultPayloadStatus = "success" + Timeout GameWatcherMessageS2CSubmitResultPayloadStatus = "timeout" + WrongAnswer GameWatcherMessageS2CSubmitResultPayloadStatus = "wrong_answer" ) // Error defines model for Error. @@ -61,13 +76,15 @@ type Error struct { // Game defines model for Game. type Game struct { - DisplayName string `json:"display_name"` - DurationSeconds int `json:"duration_seconds"` - GameID int `json:"game_id"` - GameType GameGameType `json:"game_type"` - Problem *Problem `json:"problem,omitempty"` - StartedAt *int `json:"started_at,omitempty"` - State GameState `json:"state"` + DisplayName string `json:"display_name"` + DurationSeconds int `json:"duration_seconds"` + GameID int `json:"game_id"` + GameType GameGameType `json:"game_type"` + Players []User `json:"players"` + Problem *Problem `json:"problem,omitempty"` + StartedAt *int `json:"started_at,omitempty"` + State GameState `json:"state"` + VerificationSteps []VerificationStep `json:"verification_steps"` } // GameGameType defines model for Game.GameType. @@ -190,11 +207,11 @@ type GameWatcherMessageS2CExecResult struct { // GameWatcherMessageS2CExecResultPayload defines model for GameWatcherMessageS2CExecResultPayload. type GameWatcherMessageS2CExecResultPayload struct { - PlayerID int `json:"player_id"` - Score nullable.Nullable[int] `json:"score"` - Status GameWatcherMessageS2CExecResultPayloadStatus `json:"status"` - Stderr string `json:"stderr"` - Stdout string `json:"stdout"` + PlayerID int `json:"player_id"` + Status GameWatcherMessageS2CExecResultPayloadStatus `json:"status"` + Stderr string `json:"stderr"` + Stdout string `json:"stdout"` + TestcaseID nullable.Nullable[int] `json:"testcase_id"` } // GameWatcherMessageS2CExecResultPayloadStatus defines model for GameWatcherMessageS2CExecResultPayload.Status. @@ -211,6 +228,33 @@ type GameWatcherMessageS2CStartPayload struct { StartAt int `json:"start_at"` } +// GameWatcherMessageS2CSubmit defines model for GameWatcherMessageS2CSubmit. +type GameWatcherMessageS2CSubmit struct { + Data GameWatcherMessageS2CSubmitPayload `json:"data"` + Type string `json:"type"` +} + +// GameWatcherMessageS2CSubmitPayload defines model for GameWatcherMessageS2CSubmitPayload. +type GameWatcherMessageS2CSubmitPayload struct { + PlayerID int `json:"player_id"` + PreliminaryScore int `json:"preliminary_score"` +} + +// GameWatcherMessageS2CSubmitResult defines model for GameWatcherMessageS2CSubmitResult. +type GameWatcherMessageS2CSubmitResult struct { + Data GameWatcherMessageS2CSubmitResultPayload `json:"data"` + Type string `json:"type"` +} + +// GameWatcherMessageS2CSubmitResultPayload defines model for GameWatcherMessageS2CSubmitResultPayload. +type GameWatcherMessageS2CSubmitResultPayload struct { + PlayerID int `json:"player_id"` + Status GameWatcherMessageS2CSubmitResultPayloadStatus `json:"status"` +} + +// GameWatcherMessageS2CSubmitResultPayloadStatus defines model for GameWatcherMessageS2CSubmitResultPayload.Status. +type GameWatcherMessageS2CSubmitResultPayloadStatus string + // Problem defines model for Problem. type Problem struct { Description string `json:"description"` @@ -227,6 +271,12 @@ type User struct { Username string `json:"username"` } +// VerificationStep defines model for VerificationStep. +type VerificationStep struct { + Label string `json:"label"` + TestcaseID nullable.Nullable[int] `json:"testcase_id"` +} + // HeaderAuthorization defines model for header_authorization. type HeaderAuthorization = string @@ -622,6 +672,32 @@ func (t *GameWatcherMessageS2C) MergeGameWatcherMessageS2CCode(v GameWatcherMess return err } +// AsGameWatcherMessageS2CSubmit returns the union data inside the GameWatcherMessageS2C as a GameWatcherMessageS2CSubmit +func (t GameWatcherMessageS2C) AsGameWatcherMessageS2CSubmit() (GameWatcherMessageS2CSubmit, error) { + var body GameWatcherMessageS2CSubmit + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromGameWatcherMessageS2CSubmit overwrites any union data inside the GameWatcherMessageS2C as the provided GameWatcherMessageS2CSubmit +func (t *GameWatcherMessageS2C) FromGameWatcherMessageS2CSubmit(v GameWatcherMessageS2CSubmit) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeGameWatcherMessageS2CSubmit performs a merge with any union data inside the GameWatcherMessageS2C, using the provided GameWatcherMessageS2CSubmit +func (t *GameWatcherMessageS2C) MergeGameWatcherMessageS2CSubmit(v GameWatcherMessageS2CSubmit) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + // AsGameWatcherMessageS2CExecResult returns the union data inside the GameWatcherMessageS2C as a GameWatcherMessageS2CExecResult func (t GameWatcherMessageS2C) AsGameWatcherMessageS2CExecResult() (GameWatcherMessageS2CExecResult, error) { var body GameWatcherMessageS2CExecResult @@ -648,6 +724,32 @@ func (t *GameWatcherMessageS2C) MergeGameWatcherMessageS2CExecResult(v GameWatch return err } +// AsGameWatcherMessageS2CSubmitResult returns the union data inside the GameWatcherMessageS2C as a GameWatcherMessageS2CSubmitResult +func (t GameWatcherMessageS2C) AsGameWatcherMessageS2CSubmitResult() (GameWatcherMessageS2CSubmitResult, error) { + var body GameWatcherMessageS2CSubmitResult + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromGameWatcherMessageS2CSubmitResult overwrites any union data inside the GameWatcherMessageS2C as the provided GameWatcherMessageS2CSubmitResult +func (t *GameWatcherMessageS2C) FromGameWatcherMessageS2CSubmitResult(v GameWatcherMessageS2CSubmitResult) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeGameWatcherMessageS2CSubmitResult performs a merge with any union data inside the GameWatcherMessageS2C, using the provided GameWatcherMessageS2CSubmitResult +func (t *GameWatcherMessageS2C) MergeGameWatcherMessageS2CSubmitResult(v GameWatcherMessageS2CSubmitResult) 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 GameWatcherMessageS2C) MarshalJSON() ([]byte, error) { b, err := t.union.MarshalJSON() return b, err @@ -1107,32 +1209,35 @@ func (sh *strictHandler) GetToken(ctx echo.Context, params GetTokenParams) error // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "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", - "BmCWZdm/AQAA//8u6d2yGyIAAA==", + "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", + "AA==", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/backend/api/handler.go b/backend/api/handler.go index 23c3cfe..134a4f9 100644 --- a/backend/api/handler.go +++ b/backend/api/handler.go @@ -3,11 +3,13 @@ package api import ( "context" "errors" + "fmt" "log" "net/http" "github.com/jackc/pgx/v5" "github.com/labstack/echo/v4" + "github.com/oapi-codegen/nullable" "github.com/nsfisis/iosdc-japan-2024-albatross/backend/auth" "github.com/nsfisis/iosdc-japan-2024-albatross/backend/db" @@ -135,14 +137,44 @@ func (h *Handler) GetGame(ctx context.Context, request GetGameRequestObject, use } } } + playerRows, err := h.q.ListGamePlayers(ctx, int32(gameID)) + if err != nil { + return nil, echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + players := make([]User, len(playerRows)) + for i, playerRow := range playerRows { + players[i] = User{ + UserID: int(playerRow.UserID), + Username: playerRow.Username, + DisplayName: playerRow.DisplayName, + IconPath: playerRow.IconPath, + IsAdmin: playerRow.IsAdmin, + } + } + testcaseIDs, err := h.q.ListTestcaseIDsByGameID(ctx, int32(gameID)) + if err != nil { + return nil, echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + verificationSteps := make([]VerificationStep, len(testcaseIDs)+1) + verificationSteps[0] = VerificationStep{ + Label: "Compile", + } + for i, testcaseID := range testcaseIDs { + verificationSteps[i+1] = VerificationStep{ + TestcaseID: nullable.NewNullableWithValue(int(testcaseID)), + Label: fmt.Sprintf("Testcase %d", i+1), + } + } game := Game{ - GameID: int(row.GameID), - GameType: GameGameType(row.GameType), - State: GameState(row.State), - DisplayName: row.DisplayName, - DurationSeconds: int(row.DurationSeconds), - StartedAt: startedAt, - Problem: problem, + GameID: int(row.GameID), + GameType: GameGameType(row.GameType), + State: GameState(row.State), + DisplayName: row.DisplayName, + DurationSeconds: int(row.DurationSeconds), + StartedAt: startedAt, + Problem: problem, + Players: players, + VerificationSteps: verificationSteps, } return GetGame200JSONResponse{ Game: game, diff --git a/backend/db/query.sql.go b/backend/db/query.sql.go index 47140fc..cbef51d 100644 --- a/backend/db/query.sql.go +++ b/backend/db/query.sql.go @@ -248,7 +248,7 @@ func (q *Queries) IsRegistrationTokenValid(ctx context.Context, token string) (b const listGamePlayers = `-- name: ListGamePlayers :many SELECT game_id, game_players.user_id, users.user_id, username, display_name, icon_path, is_admin, created_at FROM game_players -LEFT JOIN users ON game_players.user_id = users.user_id +JOIN users ON game_players.user_id = users.user_id WHERE game_players.game_id = $1 ORDER BY game_players.user_id ` @@ -256,11 +256,11 @@ ORDER BY game_players.user_id type ListGamePlayersRow struct { GameID int32 UserID int32 - UserID_2 *int32 - Username *string - DisplayName *string + UserID_2 int32 + Username string + DisplayName string IconPath *string - IsAdmin *bool + IsAdmin bool CreatedAt pgtype.Timestamp } @@ -403,6 +403,32 @@ func (q *Queries) ListGamesForPlayer(ctx context.Context, userID int32) ([]ListG return items, nil } +const listTestcaseIDsByGameID = `-- name: ListTestcaseIDsByGameID :many +SELECT testcases.testcase_id FROM testcases +WHERE testcases.problem_id = (SELECT problem_id FROM games WHERE game_id = $1) +ORDER BY testcases.testcase_id +` + +func (q *Queries) ListTestcaseIDsByGameID(ctx context.Context, gameID int32) ([]int32, error) { + rows, err := q.db.Query(ctx, listTestcaseIDsByGameID, gameID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []int32 + for rows.Next() { + var testcase_id int32 + if err := rows.Scan(&testcase_id); err != nil { + return nil, err + } + items = append(items, testcase_id) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const listTestcasesByGameID = `-- name: ListTestcasesByGameID :many SELECT testcase_id, problem_id, stdin, stdout FROM testcases WHERE testcases.problem_id = (SELECT problem_id FROM games WHERE game_id = $1) diff --git a/backend/game/hub.go b/backend/game/hub.go index aa1b9f2..54c559c 100644 --- a/backend/game/hub.go +++ b/backend/game/hub.go @@ -10,6 +10,7 @@ import ( "time" "github.com/jackc/pgx/v5/pgtype" + "github.com/oapi-codegen/nullable" "github.com/nsfisis/iosdc-japan-2024-albatross/backend/api" "github.com/nsfisis/iosdc-japan-2024-albatross/backend/db" @@ -118,14 +119,12 @@ func (hub *gameHub) run() { }, } } - for watcher := range hub.watchers { - watcher.s2cMessages <- &watcherMessageS2CStart{ - Type: watcherMessageTypeS2CStart, - Data: watcherMessageS2CStartPayload{ - StartAt: int(startAt.Unix()), - }, - } - } + hub.broadcastToWatchers(&watcherMessageS2CStart{ + Type: watcherMessageTypeS2CStart, + Data: watcherMessageS2CStartPayload{ + StartAt: int(startAt.Unix()), + }, + }) err := hub.q.UpdateGameStartedAt(hub.ctx, db.UpdateGameStartedAtParams{ GameID: int32(hub.game.gameID), StartedAt: pgtype.Timestamp{ @@ -151,15 +150,13 @@ func (hub *gameHub) run() { // TODO: assert game state is gaming log.Printf("code: %v", message.message) code := msg.Data.Code - for watcher := range hub.watchers { - watcher.s2cMessages <- &watcherMessageS2CCode{ - Type: watcherMessageTypeS2CCode, - Data: watcherMessageS2CCodePayload{ - PlayerID: message.client.playerID, - Code: code, - }, - } - } + hub.broadcastToWatchers(&watcherMessageS2CCode{ + Type: watcherMessageTypeS2CCode, + Data: watcherMessageS2CCodePayload{ + PlayerID: message.client.playerID, + Code: code, + }, + }) case *playerMessageC2SSubmit: // TODO: assert game state is gaming log.Printf("submit: %v", message.message) @@ -176,6 +173,13 @@ func (hub *gameHub) run() { // TODO: notify failure to player log.Fatalf("failed to enqueue task: %v", err) } + hub.broadcastToWatchers(&watcherMessageS2CSubmit{ + Type: watcherMessageTypeS2CSubmit, + Data: watcherMessageS2CSubmitPayload{ + PlayerID: message.client.playerID, + PreliminaryScore: codeSize, + }, + }) default: log.Printf("unexpected message type: %T", message.message) } @@ -209,6 +213,12 @@ func (hub *gameHub) run() { } } +func (hub *gameHub) broadcastToWatchers(msg watcherMessageS2C) { + for watcher := range hub.watchers { + watcher.s2cMessages <- msg + } +} + type codeSubmissionError struct { Status string Stdout string @@ -237,7 +247,13 @@ func (hub *gameHub) processTaskResults() { }, } } - // TODO: broadcast to watchers + hub.broadcastToWatchers(&watcherMessageS2CSubmitResult{ + Type: watcherMessageTypeS2CSubmitResult, + Data: watcherMessageS2CSubmitResultPayload{ + PlayerID: taskResult.TaskPayload.UserID(), + Status: api.GameWatcherMessageS2CSubmitResultPayloadStatus(err.Status), + }, + }) } case *taskqueue.TaskResultCompileSwiftToWasm: err := hub.processTaskResultCompileSwiftToWasm(taskResult) @@ -254,7 +270,22 @@ func (hub *gameHub) processTaskResults() { }, } } - // TODO: broadcast to watchers + hub.broadcastToWatchers(&watcherMessageS2CExecResult{ + Type: watcherMessageTypeS2CExecResult, + Data: watcherMessageS2CExecResultPayload{ + PlayerID: taskResult.TaskPayload.UserID(), + Status: api.GameWatcherMessageS2CExecResultPayloadStatus(err.Status), + Stdout: err.Stdout, + Stderr: err.Stderr, + }, + }) + hub.broadcastToWatchers(&watcherMessageS2CSubmitResult{ + Type: watcherMessageTypeS2CSubmitResult, + Data: watcherMessageS2CSubmitResultPayload{ + PlayerID: taskResult.TaskPayload.UserID(), + Status: api.GameWatcherMessageS2CSubmitResultPayloadStatus(err.Status), + }, + }) } case *taskqueue.TaskResultCompileWasmToNativeExecutable: err := hub.processTaskResultCompileWasmToNativeExecutable(taskResult) @@ -271,11 +302,38 @@ func (hub *gameHub) processTaskResults() { }, } } - // TODO: broadcast to watchers + hub.broadcastToWatchers(&watcherMessageS2CExecResult{ + Type: watcherMessageTypeS2CExecResult, + Data: watcherMessageS2CExecResultPayload{ + PlayerID: taskResult.TaskPayload.UserID(), + Status: api.GameWatcherMessageS2CExecResultPayloadStatus(err.Status), + Stdout: err.Stdout, + Stderr: err.Stderr, + }, + }) + hub.broadcastToWatchers(&watcherMessageS2CSubmitResult{ + Type: watcherMessageTypeS2CSubmitResult, + Data: watcherMessageS2CSubmitResultPayload{ + PlayerID: taskResult.TaskPayload.UserID(), + Status: api.GameWatcherMessageS2CSubmitResultPayloadStatus(err.Status), + }, + }) + } else { + hub.broadcastToWatchers(&watcherMessageS2CExecResult{ + Type: watcherMessageTypeS2CExecResult, + Data: watcherMessageS2CExecResultPayload{ + PlayerID: taskResult.TaskPayload.UserID(), + Status: api.GameWatcherMessageS2CExecResultPayloadStatus("success"), + // TODO: inherit the command stdout/stderr. + Stdout: "Successfully compiled", + Stderr: "", + }, + }) } case *taskqueue.TaskResultRunTestcase: + // FIXME: error handling var err error - err = hub.processTaskResultRunTestcase(taskResult) + err1 := hub.processTaskResultRunTestcase(taskResult) _ = err // TODO: handle err? aggregatedStatus, err := hub.q.AggregateTestcaseResults(hub.ctx, int32(taskResult.TaskPayload.SubmissionID)) _ = err // TODO: handle err? @@ -298,7 +356,24 @@ func (hub *gameHub) processTaskResults() { }, } } - // TODO: broadcast to watchers + hub.broadcastToWatchers(&watcherMessageS2CExecResult{ + Type: watcherMessageTypeS2CExecResult, + Data: watcherMessageS2CExecResultPayload{ + PlayerID: taskResult.TaskPayload.UserID(), + TestcaseID: nullable.NewNullableWithValue(int(taskResult.TaskPayload.TestcaseID)), + Status: api.GameWatcherMessageS2CExecResultPayloadStatus("internal_error"), + // TODO: inherit the command stdout/stderr? + Stdout: "", + Stderr: "internal error", + }, + }) + hub.broadcastToWatchers(&watcherMessageS2CSubmitResult{ + Type: watcherMessageTypeS2CSubmitResult, + Data: watcherMessageS2CSubmitResultPayload{ + PlayerID: taskResult.TaskPayload.UserID(), + Status: api.GameWatcherMessageS2CSubmitResultPayloadStatus("internal_error"), + }, + }) continue } for player := range hub.players { @@ -312,7 +387,39 @@ func (hub *gameHub) processTaskResults() { Status: api.GamePlayerMessageS2CExecResultPayloadStatus(aggregatedStatus), }, } - // TODO: broadcast to watchers + } + if err1 != nil { + hub.broadcastToWatchers(&watcherMessageS2CExecResult{ + Type: watcherMessageTypeS2CExecResult, + Data: watcherMessageS2CExecResultPayload{ + PlayerID: taskResult.TaskPayload.UserID(), + TestcaseID: nullable.NewNullableWithValue(int(taskResult.TaskPayload.TestcaseID)), + Status: api.GameWatcherMessageS2CExecResultPayloadStatus(err1.Status), + Stdout: err1.Stdout, + Stderr: err1.Stderr, + }, + }) + } else { + hub.broadcastToWatchers(&watcherMessageS2CExecResult{ + Type: watcherMessageTypeS2CExecResult, + Data: watcherMessageS2CExecResultPayload{ + PlayerID: taskResult.TaskPayload.UserID(), + TestcaseID: nullable.NewNullableWithValue(int(taskResult.TaskPayload.TestcaseID)), + Status: api.GameWatcherMessageS2CExecResultPayloadStatus("success"), + // TODO: inherit the command stdout/stderr? + Stdout: "Testcase passed", + Stderr: "", + }, + }) + } + if aggregatedStatus != "running" { + hub.broadcastToWatchers(&watcherMessageS2CSubmitResult{ + Type: watcherMessageTypeS2CSubmitResult, + Data: watcherMessageS2CSubmitResultPayload{ + PlayerID: taskResult.TaskPayload.UserID(), + Status: api.GameWatcherMessageS2CSubmitResultPayloadStatus(aggregatedStatus), + }, + }) } default: panic("unexpected task result type") diff --git a/backend/game/message.go b/backend/game/message.go index 031222d..1fb30cb 100644 --- a/backend/game/message.go +++ b/backend/game/message.go @@ -77,9 +77,11 @@ func asPlayerMessageC2S(raw map[string]json.RawMessage) (playerMessageC2S, error } const ( - watcherMessageTypeS2CStart = "watcher:s2c:start" - watcherMessageTypeS2CExecResult = "watcher:s2c:execresult" - watcherMessageTypeS2CCode = "watcher:s2c:code" + watcherMessageTypeS2CStart = "watcher:s2c:start" + watcherMessageTypeS2CCode = "watcher:s2c:code" + watcherMessageTypeS2CSubmit = "watcher:s2c:submit" + watcherMessageTypeS2CExecResult = "watcher:s2c:execresult" + watcherMessageTypeS2CSubmitResult = "watcher:s2c:submitresult" ) type watcherMessageS2C = interface{} @@ -87,3 +89,9 @@ type watcherMessageS2CStart = api.GameWatcherMessageS2CStart type watcherMessageS2CStartPayload = api.GameWatcherMessageS2CStartPayload type watcherMessageS2CCode = api.GameWatcherMessageS2CCode type watcherMessageS2CCodePayload = api.GameWatcherMessageS2CCodePayload +type watcherMessageS2CSubmit = api.GameWatcherMessageS2CSubmit +type watcherMessageS2CSubmitPayload = api.GameWatcherMessageS2CSubmitPayload +type watcherMessageS2CExecResult = api.GameWatcherMessageS2CExecResult +type watcherMessageS2CExecResultPayload = api.GameWatcherMessageS2CExecResultPayload +type watcherMessageS2CSubmitResult = api.GameWatcherMessageS2CSubmitResult +type watcherMessageS2CSubmitResultPayload = api.GameWatcherMessageS2CSubmitResultPayload diff --git a/backend/query.sql b/backend/query.sql index 13bbbe6..bcbee12 100644 --- a/backend/query.sql +++ b/backend/query.sql @@ -58,7 +58,7 @@ LIMIT 1; -- name: ListGamePlayers :many SELECT * FROM game_players -LEFT JOIN users ON game_players.user_id = users.user_id +JOIN users ON game_players.user_id = users.user_id WHERE game_players.game_id = $1 ORDER BY game_players.user_id; @@ -83,6 +83,11 @@ SELECT * FROM testcases WHERE testcases.problem_id = (SELECT problem_id FROM games WHERE game_id = $1) ORDER BY testcases.testcase_id; +-- name: ListTestcaseIDsByGameID :many +SELECT testcases.testcase_id FROM testcases +WHERE testcases.problem_id = (SELECT problem_id FROM games WHERE game_id = $1) +ORDER BY testcases.testcase_id; + -- name: CreateSubmissionResult :exec INSERT INTO submission_results (submission_id, status, stdout, stderr) VALUES ($1, $2, $3, $4); diff --git a/frontend/app/.server/api/schema.d.ts b/frontend/app/.server/api/schema.d.ts index 9a96f19..719babb 100644 --- a/frontend/app/.server/api/schema.d.ts +++ b/frontend/app/.server/api/schema.d.ts @@ -112,6 +112,14 @@ export interface components { /** @example 946684800 */ started_at?: number; problem?: components["schemas"]["Problem"]; + players: components["schemas"]["User"][]; + verification_steps: components["schemas"]["VerificationStep"][]; + }; + VerificationStep: { + /** @example 1 */ + testcase_id: number | null; + /** @example Test case 1 */ + label: string; }; Problem: { /** @example 1 */ @@ -182,7 +190,7 @@ export interface components { code: string; }; GameWatcherMessage: components["schemas"]["GameWatcherMessageS2C"]; - GameWatcherMessageS2C: components["schemas"]["GameWatcherMessageS2CStart"] | components["schemas"]["GameWatcherMessageS2CCode"] | components["schemas"]["GameWatcherMessageS2CExecResult"]; + GameWatcherMessageS2C: components["schemas"]["GameWatcherMessageS2CStart"] | components["schemas"]["GameWatcherMessageS2CCode"] | components["schemas"]["GameWatcherMessageS2CSubmit"] | components["schemas"]["GameWatcherMessageS2CExecResult"] | components["schemas"]["GameWatcherMessageS2CSubmitResult"]; GameWatcherMessageS2CStart: { /** @constant */ type: "watcher:s2c:start"; @@ -203,6 +211,17 @@ export interface components { /** @example print('Hello, world!') */ code: string; }; + GameWatcherMessageS2CSubmit: { + /** @constant */ + type: "watcher:s2c:submit"; + data: components["schemas"]["GameWatcherMessageS2CSubmitPayload"]; + }; + GameWatcherMessageS2CSubmitPayload: { + /** @example 1 */ + player_id: number; + /** @example 100 */ + preliminary_score: number; + }; GameWatcherMessageS2CExecResult: { /** @constant */ type: "watcher:s2c:execresult"; @@ -211,18 +230,32 @@ export interface components { GameWatcherMessageS2CExecResultPayload: { /** @example 1 */ player_id: number; + /** @example 1 */ + testcase_id: number | null; /** * @example success * @enum {string} */ - status: "success"; - /** @example 100 */ - score: number | null; + status: "success" | "wrong_answer" | "timeout" | "runtime_error" | "internal_error" | "compile_error"; /** @example Hello, world! */ stdout: string; /** @example */ stderr: string; }; + GameWatcherMessageS2CSubmitResult: { + /** @constant */ + type: "watcher:s2c:submitresult"; + data: components["schemas"]["GameWatcherMessageS2CSubmitResultPayload"]; + }; + GameWatcherMessageS2CSubmitResultPayload: { + /** @example 1 */ + player_id: number; + /** + * @example success + * @enum {string} + */ + status: "success" | "wrong_answer" | "timeout" | "runtime_error" | "internal_error" | "compile_error"; + }; }; responses: { /** @description Bad request */ diff --git a/frontend/app/components/ExecStatusIndicatorIcon.tsx b/frontend/app/components/ExecStatusIndicatorIcon.tsx new file mode 100644 index 0000000..a76e957 --- /dev/null +++ b/frontend/app/components/ExecStatusIndicatorIcon.tsx @@ -0,0 +1,45 @@ +import { + faBan, + faCircleCheck, + faCircleExclamation, + faRotate, +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +type Props = { + status: string; +}; + +export default function ExecStatusIndicatorIcon({ status }: Props) { + switch (status) { + case "running": + return ( + <FontAwesomeIcon + icon={faRotate} + spin + fixedWidth + className="text-gray-700" + /> + ); + case "success": + return ( + <FontAwesomeIcon + icon={faCircleCheck} + fixedWidth + className="text-green-500" + /> + ); + case "canceled": + return ( + <FontAwesomeIcon icon={faBan} fixedWidth className="text-gray-400" /> + ); + default: + return ( + <FontAwesomeIcon + icon={faCircleExclamation} + fixedWidth + className="text-red-500" + /> + ); + } +} diff --git a/frontend/app/components/GolfWatchApp.client.tsx b/frontend/app/components/GolfWatchApp.client.tsx index 829f709..a9c9989 100644 --- a/frontend/app/components/GolfWatchApp.client.tsx +++ b/frontend/app/components/GolfWatchApp.client.tsx @@ -3,7 +3,9 @@ import useWebSocket, { ReadyState } from "react-use-websocket"; import type { components } from "../.server/api/schema"; import GolfWatchAppConnecting from "./GolfWatchApps/GolfWatchAppConnecting"; import GolfWatchAppFinished from "./GolfWatchApps/GolfWatchAppFinished"; -import GolfWatchAppGaming from "./GolfWatchApps/GolfWatchAppGaming"; +import GolfWatchAppGaming, { + PlayerInfo, +} from "./GolfWatchApps/GolfWatchAppGaming"; import GolfWatchAppStarting from "./GolfWatchApps/GolfWatchAppStarting"; import GolfWatchAppWaiting from "./GolfWatchApps/GolfWatchAppWaiting"; @@ -34,12 +36,12 @@ export default function GolfWatchApp({ const [startedAt, setStartedAt] = useState<number | null>(null); - const [timeLeftSeconds, setTimeLeftSeconds] = useState<number | null>(null); + const [leftTimeSeconds, setLeftTimeSeconds] = useState<number | null>(null); useEffect(() => { if (gameState === "starting" && startedAt !== null) { const timer1 = setInterval(() => { - setTimeLeftSeconds((prev) => { + setLeftTimeSeconds((prev) => { if (prev === null) { return null; } @@ -68,10 +70,23 @@ export default function GolfWatchApp({ } }, [gameState, startedAt, game.duration_seconds]); - const [scoreA, setScoreA] = useState<number | null>(null); - const [scoreB, setScoreB] = useState<number | null>(null); - const [codeA, setCodeA] = useState<string>(""); - const [codeB, setCodeB] = useState<string>(""); + const playerA = game.players[0]; + const playerB = game.players[1]; + + const [playerInfoA, setPlayerInfoA] = useState<PlayerInfo>({ + displayName: playerA?.display_name ?? null, + iconPath: playerA?.icon_path ?? null, + score: null, + code: "", + submissionResult: undefined, + }); + const [playerInfoB, setPlayerInfoB] = useState<PlayerInfo>({ + displayName: playerB?.display_name ?? null, + iconPath: playerB?.icon_path ?? null, + score: null, + code: "", + submissionResult: undefined, + }); if (readyState === ReadyState.UNINSTANTIATED) { throw new Error("WebSocket is not connected"); @@ -96,38 +111,113 @@ export default function GolfWatchApp({ const { start_at } = lastJsonMessage.data; setStartedAt(start_at); const nowSec = Math.floor(Date.now() / 1000); - setTimeLeftSeconds(start_at - nowSec); + setLeftTimeSeconds(start_at - nowSec); setGameState("starting"); } } else if (lastJsonMessage.type === "watcher:s2c:code") { const { player_id, code } = lastJsonMessage.data; - setCodeA(code); + const setter = + player_id === playerA?.user_id ? setPlayerInfoA : setPlayerInfoB; + setter((prev) => ({ ...prev, code })); + } else if (lastJsonMessage.type === "watcher:s2c:submit") { + const { player_id, preliminary_score } = lastJsonMessage.data; + const setter = + player_id === playerA?.user_id ? setPlayerInfoA : setPlayerInfoB; + setter((prev) => ({ + ...prev, + submissionResult: { + status: "running", + preliminaryScore: preliminary_score, + verificationResults: game.verification_steps.map((v) => ({ + testcase_id: v.testcase_id, + status: "running", + label: v.label, + stdout: "", + stderr: "", + })), + }, + })); } else if (lastJsonMessage.type === "watcher:s2c:execresult") { - const { score } = lastJsonMessage.data; - if (score !== null && (scoreA === null || score < scoreA)) { - setScoreA(score); - } + const { player_id, testcase_id, status, stdout, stderr } = + lastJsonMessage.data; + const setter = + player_id === playerA?.user_id ? setPlayerInfoA : setPlayerInfoB; + setter((prev) => { + const ret = { ...prev }; + if (ret.submissionResult === undefined) { + return ret; + } + ret.submissionResult = { + ...ret.submissionResult, + verificationResults: ret.submissionResult.verificationResults.map( + (v) => + v.testcase_id === testcase_id && v.status === "running" + ? { + ...v, + status, + stdout, + stderr, + } + : v, + ), + }; + return ret; + }); + } else if (lastJsonMessage.type === "watcher:s2c:submitresult") { + const { player_id, status } = lastJsonMessage.data; + const setter = + player_id === playerA?.user_id ? setPlayerInfoA : setPlayerInfoB; + setter((prev) => { + const ret = { ...prev }; + if (ret.submissionResult === undefined) { + return ret; + } + ret.submissionResult = { + ...ret.submissionResult, + status, + }; + if (status === "success") { + if ( + ret.score === null || + ret.submissionResult.preliminaryScore < ret.score + ) { + ret.score = ret.submissionResult.preliminaryScore; + } + } else { + ret.submissionResult.verificationResults = + ret.submissionResult.verificationResults.map((v) => + v.status === "running" ? { ...v, status: "canceled" } : v, + ); + } + return ret; + }); } } else { setGameState("waiting"); } } - }, [lastJsonMessage, readyState, gameState, scoreA]); + }, [ + game.verification_steps, + lastJsonMessage, + readyState, + gameState, + playerA?.user_id, + playerB?.user_id, + ]); if (gameState === "connecting") { return <GolfWatchAppConnecting />; } else if (gameState === "waiting") { return <GolfWatchAppWaiting />; } else if (gameState === "starting") { - return <GolfWatchAppStarting timeLeft={timeLeftSeconds!} />; + return <GolfWatchAppStarting leftTimeSeconds={leftTimeSeconds!} />; } else if (gameState === "gaming") { return ( <GolfWatchAppGaming problem={game.problem!.description} - codeA={codeA} - scoreA={scoreA} - codeB={codeB} - scoreB={scoreB} + playerInfoA={playerInfoA} + playerInfoB={playerInfoB} + leftTimeSeconds={leftTimeSeconds!} /> ); } else if (gameState === "finished") { diff --git a/frontend/app/components/GolfWatchApps/GolfWatchAppGaming.tsx b/frontend/app/components/GolfWatchApps/GolfWatchAppGaming.tsx index 22277f8..53d5bce 100644 --- a/frontend/app/components/GolfWatchApps/GolfWatchAppGaming.tsx +++ b/frontend/app/components/GolfWatchApps/GolfWatchAppGaming.tsx @@ -1,41 +1,184 @@ +import ExecStatusIndicatorIcon from "../ExecStatusIndicatorIcon"; + type Props = { problem: string; - codeA: string; - scoreA: number | null; - codeB: string; - scoreB: number | null; + playerInfoA: PlayerInfo; + playerInfoB: PlayerInfo; + leftTimeSeconds: number; +}; + +export type PlayerInfo = { + displayName: string | null; + iconPath: string | null; + score: number | null; + code: string | null; + submissionResult?: SubmissionResult; +}; + +type SubmissionResult = { + status: + | "running" + | "success" + | "wrong_answer" + | "timeout" + | "compile_error" + | "runtime_error" + | "internal_error"; + preliminaryScore: number; + verificationResults: VerificationResult[]; }; +type VerificationResult = { + testcase_id: number | null; + status: + | "running" + | "success" + | "wrong_answer" + | "timeout" + | "compile_error" + | "runtime_error" + | "internal_error" + | "canceled"; + label: string; + stdout: string; + stderr: string; +}; + +function submissionResultStatusToLabel( + status: SubmissionResult["status"] | null, +) { + switch (status) { + case null: + return "-"; + case "running": + return "Running..."; + case "success": + return "Accepted"; + case "wrong_answer": + return "Wrong Answer"; + case "timeout": + return "Time Limit Exceeded"; + case "compile_error": + return "Compile Error"; + case "runtime_error": + return "Runtime Error"; + case "internal_error": + return "Internal Error"; + } +} + export default function GolfWatchAppGaming({ problem, - codeA, - scoreA, - codeB, - scoreB, + playerInfoA, + playerInfoB, + leftTimeSeconds, }: Props) { + const leftTime = (() => { + const m = Math.floor(leftTimeSeconds / 60); + const s = leftTimeSeconds % 60; + return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`; + })(); + const scoreRatio = (() => { + const scoreA = playerInfoA.score ?? 0; + const scoreB = playerInfoB.score ?? 0; + const totalScore = scoreA + scoreB; + return totalScore === 0 ? 50 : (scoreB / totalScore) * 100; + })(); + return ( - <div style={{ display: "flex", flexDirection: "column" }}> - <div style={{ display: "flex", flex: 1, justifyContent: "center" }}> - <div>{problem}</div> + <div className="grid h-full w-full grid-rows-[auto_auto_1fr_auto]"> + <div className="grid grid-cols-[1fr_auto_1fr]"> + <div className="grid justify-start bg-red-500 p-2 text-white"> + {playerInfoA.displayName} + </div> + <div className="grid justify-center p-2">{leftTime}</div> + <div className="grid justify-end bg-blue-500 p-2 text-white"> + {playerInfoB.displayName} + </div> + </div> + <div className="grid grid-cols-[auto_1fr_auto]"> + <div className="grid justify-start bg-red-500 p-2 text-lg font-bold text-white"> + {playerInfoA.score ?? "-"} + </div> + <div className="w-full bg-blue-500"> + <div + className="h-full bg-red-500" + style={{ width: `${scoreRatio}%` }} + ></div> + </div> + <div className="grid justify-end bg-blue-500 p-2 text-lg font-bold text-white"> + {playerInfoB.score ?? "-"} + </div> </div> - <div style={{ display: "flex", flex: 3 }}> - <div style={{ display: "flex", flex: 3, flexDirection: "column" }}> - <div style={{ flex: 1, justifyContent: "center" }}>{scoreA}</div> - <div style={{ flex: 3 }}> - <pre> - <code>{codeA}</code> - </pre> + <div className="grid grid-cols-[3fr_2fr_3fr_2fr] p-2"> + <div> + <pre> + <code>{playerInfoA.code}</code> + </pre> + </div> + <div> + <div> + {submissionResultStatusToLabel( + playerInfoA.submissionResult?.status ?? null, + )}{" "} + ({playerInfoA.submissionResult?.preliminaryScore}) + </div> + <div> + <ol> + {playerInfoA.submissionResult?.verificationResults.map( + (result, idx) => ( + <li key={idx}> + <div> + <div> + <ExecStatusIndicatorIcon status={result.status} />{" "} + {result.label} + </div> + <div> + {result.stdout} + {result.stderr} + </div> + </div> + </li> + ), + )} + </ol> </div> </div> - <div style={{ display: "flex", flex: 3, flexDirection: "column" }}> - <div style={{ flex: 1, justifyContent: "center" }}>{scoreB}</div> - <div style={{ flex: 3 }}> - <pre> - <code>{codeB}</code> - </pre> + <div> + <pre> + <code>{playerInfoB.code}</code> + </pre> + </div> + <div> + <div> + {submissionResultStatusToLabel( + playerInfoB.submissionResult?.status ?? null, + )}{" "} + ({playerInfoB.submissionResult?.preliminaryScore ?? "-"}) + </div> + <div> + <ol> + {playerInfoB.submissionResult?.verificationResults.map( + (result, idx) => ( + <li key={idx}> + <div> + <div> + <ExecStatusIndicatorIcon status={result.status} />{" "} + {result.label} + </div> + <div> + {result.stdout} + {result.stderr} + </div> + </div> + </li> + ), + )} + </ol> </div> </div> </div> + <div className="grid justify-center p-2 bg-slate-300">{problem}</div> </div> ); } diff --git a/frontend/app/components/GolfWatchApps/GolfWatchAppStarting.tsx b/frontend/app/components/GolfWatchApps/GolfWatchAppStarting.tsx index ef72cec..8282fb4 100644 --- a/frontend/app/components/GolfWatchApps/GolfWatchAppStarting.tsx +++ b/frontend/app/components/GolfWatchApps/GolfWatchAppStarting.tsx @@ -1,8 +1,10 @@ type Props = { - timeLeft: number; + leftTimeSeconds: number; }; -export default function GolfWatchAppStarting({ timeLeft }: Props) { +export default function GolfWatchAppStarting({ + leftTimeSeconds: timeLeft, +}: Props) { return ( <div className="min-h-screen bg-gray-100 flex items-center justify-center"> <div className="text-center"> diff --git a/frontend/app/components/GolfWatchApps/GolfWatchAppWaiting.tsx b/frontend/app/components/GolfWatchApps/GolfWatchAppWaiting.tsx index d58ec19..17ef2b9 100644 --- a/frontend/app/components/GolfWatchApps/GolfWatchAppWaiting.tsx +++ b/frontend/app/components/GolfWatchApps/GolfWatchAppWaiting.tsx @@ -1,3 +1,9 @@ export default function GolfWatchAppWaiting() { - return <div>Waiting...</div>; + return ( + <div className="min-h-screen bg-gray-100 flex items-center justify-center"> + <div className="text-center"> + <h1 className="text-4xl font-bold text-black-600 mb-4">Waiting...</h1> + </div> + </div> + ); } diff --git a/frontend/app/root.tsx b/frontend/app/root.tsx index 054474a..4d7a661 100644 --- a/frontend/app/root.tsx +++ b/frontend/app/root.tsx @@ -21,7 +21,7 @@ export function Layout({ children }: { children: React.ReactNode }) { <Meta /> <Links /> </head> - <body> + <body className="h-screen"> {children} <ScrollRestoration /> <Scripts /> diff --git a/frontend/app/routes/_index.tsx b/frontend/app/routes/_index.tsx index 2fcf1f2..25b9c81 100644 --- a/frontend/app/routes/_index.tsx +++ b/frontend/app/routes/_index.tsx @@ -1,7 +1,11 @@ +import { config } from "@fortawesome/fontawesome-svg-core"; import type { LoaderFunctionArgs, MetaFunction } from "@remix-run/node"; import { Link } from "@remix-run/react"; +import "@fortawesome/fontawesome-svg-core/styles.css"; import { ensureUserNotLoggedIn } from "../.server/auth"; +config.autoAddCss = false; + export const meta: MetaFunction = () => [ { title: "iOSDC Japan 2024 Albatross.swift" }, ]; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d5c12fe..b706788 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -6,6 +6,9 @@ "": { "name": "iosdc-japan-2024-albatross-frontend", "dependencies": { + "@fortawesome/fontawesome-svg-core": "^6.6.0", + "@fortawesome/free-solid-svg-icons": "^6.6.0", + "@fortawesome/react-fontawesome": "^0.2.2", "@remix-run/node": "^2.10.3", "@remix-run/react": "^2.10.3", "@remix-run/serve": "^2.10.3", @@ -1306,6 +1309,48 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@fortawesome/fontawesome-common-types": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.6.0.tgz", + "integrity": "sha512-xyX0X9mc0kyz9plIyryrRbl7ngsA9jz77mCZJsUkLl+ZKs0KWObgaEBoSgQiYWAsSmjz/yjl0F++Got0Mdp4Rw==", + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/fontawesome-svg-core": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.6.0.tgz", + "integrity": "sha512-KHwPkCk6oRT4HADE7smhfsKudt9N/9lm6EJ5BVg0tD1yPA5hht837fB87F8pn15D8JfTqQOjhKTktwmLMiD7Kg==", + "dependencies": { + "@fortawesome/fontawesome-common-types": "6.6.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/free-solid-svg-icons": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.6.0.tgz", + "integrity": "sha512-IYv/2skhEDFc2WGUcqvFJkeK39Q+HyPf5GHUrT/l2pKbtgEIv1al1TKd6qStR5OIwQdN1GZP54ci3y4mroJWjA==", + "dependencies": { + "@fortawesome/fontawesome-common-types": "6.6.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/react-fontawesome": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@fortawesome/react-fontawesome/-/react-fontawesome-0.2.2.tgz", + "integrity": "sha512-EnkrprPNqI6SXJl//m29hpaNzOp1bruISWaOiRtkMi/xSvHJlzc2j2JAYS7egxt/EbjSNV/k6Xy0AQI6vB2+1g==", + "dependencies": { + "prop-types": "^15.8.1" + }, + "peerDependencies": { + "@fortawesome/fontawesome-svg-core": "~1 || ~6", + "react": ">=16.3" + } + }, "node_modules/@humanwhocodes/config-array": { "version": "0.11.14", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", @@ -8087,7 +8132,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -8953,7 +8997,6 @@ "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dev": true, "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", @@ -9102,8 +9145,7 @@ "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, "node_modules/react-refresh": { "version": "0.14.2", diff --git a/frontend/package.json b/frontend/package.json index 44af089..4c630a0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,6 +14,9 @@ "start": "remix-serve ./build/server/index.js" }, "dependencies": { + "@fortawesome/fontawesome-svg-core": "^6.6.0", + "@fortawesome/free-solid-svg-icons": "^6.6.0", + "@fortawesome/react-fontawesome": "^0.2.2", "@remix-run/node": "^2.10.3", "@remix-run/react": "^2.10.3", "@remix-run/serve": "^2.10.3", diff --git a/openapi.yaml b/openapi.yaml index ebad2f0..54f9f3c 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -216,12 +216,35 @@ components: example: 946684800 problem: $ref: '#/components/schemas/Problem' + players: + type: array + items: + $ref: '#/components/schemas/User' + verification_steps: + type: array + items: + $ref: '#/components/schemas/VerificationStep' required: - game_id - game_type - state - display_name - duration_seconds + - players + - verification_steps + VerificationStep: + type: object + properties: + testcase_id: + type: integer + nullable: true + example: 1 + label: + type: string + example: "Test case 1" + required: + - testcase_id + - label Problem: type: object properties: @@ -383,7 +406,9 @@ components: oneOf: - $ref: '#/components/schemas/GameWatcherMessageS2CStart' - $ref: '#/components/schemas/GameWatcherMessageS2CCode' + - $ref: '#/components/schemas/GameWatcherMessageS2CSubmit' - $ref: '#/components/schemas/GameWatcherMessageS2CExecResult' + - $ref: '#/components/schemas/GameWatcherMessageS2CSubmitResult' GameWatcherMessageS2CStart: type: object properties: @@ -426,6 +451,29 @@ components: required: - player_id - code + GameWatcherMessageS2CSubmit: + type: object + properties: + type: + type: string + const: "watcher:s2c:submit" + data: + $ref: '#/components/schemas/GameWatcherMessageS2CSubmitPayload' + required: + - type + - data + GameWatcherMessageS2CSubmitPayload: + type: object + properties: + player_id: + type: integer + example: 1 + preliminary_score: + type: integer + example: 100 + required: + - player_id + - preliminary_score GameWatcherMessageS2CExecResult: type: object properties: @@ -443,15 +491,20 @@ components: player_id: type: integer example: 1 + testcase_id: + type: integer + nullable: true + example: 1 status: type: string example: "success" enum: - success - score: - type: integer - nullable: true - example: 100 + - wrong_answer + - timeout + - runtime_error + - internal_error + - compile_error stdout: type: string example: "Hello, world!" @@ -460,9 +513,39 @@ components: example: "" required: - player_id + - testcase_id - status - - score - stdout - stderr + GameWatcherMessageS2CSubmitResult: + type: object + properties: + type: + type: string + const: "watcher:s2c:submitresult" + data: + $ref: '#/components/schemas/GameWatcherMessageS2CSubmitResultPayload' + required: + - type + - data + GameWatcherMessageS2CSubmitResultPayload: + type: object + properties: + player_id: + type: integer + example: 1 + status: + type: string + example: "success" + enum: + - success + - wrong_answer + - timeout + - runtime_error + - internal_error + - compile_error + required: + - player_id + - status # GameWatcherMessageC2S: # oneOf: |
