aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--Makefile5
-rw-r--r--backend/api/generated.go183
-rw-r--r--backend/api/handler.go46
-rw-r--r--backend/db/query.sql.go36
-rw-r--r--backend/game/hub.go153
-rw-r--r--backend/game/message.go14
-rw-r--r--backend/query.sql7
-rw-r--r--frontend/app/.server/api/schema.d.ts41
-rw-r--r--frontend/app/components/ExecStatusIndicatorIcon.tsx45
-rw-r--r--frontend/app/components/GolfWatchApp.client.tsx128
-rw-r--r--frontend/app/components/GolfWatchApps/GolfWatchAppGaming.tsx191
-rw-r--r--frontend/app/components/GolfWatchApps/GolfWatchAppStarting.tsx6
-rw-r--r--frontend/app/components/GolfWatchApps/GolfWatchAppWaiting.tsx8
-rw-r--r--frontend/app/root.tsx2
-rw-r--r--frontend/app/routes/_index.tsx4
-rw-r--r--frontend/package-lock.json50
-rw-r--r--frontend/package.json3
-rw-r--r--openapi.yaml93
18 files changed, 877 insertions, 138 deletions
diff --git a/Makefile b/Makefile
index bf3b34c..b9320bd 100644
--- a/Makefile
+++ b/Makefile
@@ -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{
- "H4sIAAAAAAAC/9xZbW/bthP/KvrzP6AboPkpQdH5XZq1WYeuM+oWe1EEBi2dbWYSqZJUHC/Qdx9I6sGU",
- "KFt2laBYXgS2xLv73d2Pd2fyEQUsThgFKgWaPqIEcxyDBK6/bQCHwBc4lRvGyT9YEkbVc0LRNH+JfERx",
- "DGiKrqxVPuLwNSUcQjSVPAUfiWADMVbicpcoASE5oWuUZT5KsNws1jiGBQlLA+phpb5420ExoRLWwFGm",
- "VHMQCaMCtEOvcfgRvqYgpPoWMCqB6o84SSISaOjDO2G8rPT+wGGFpuj/wypYQ/NWDN9wznJTIYiAk8RE",
- "SdnyeG4s89FbxpckDIE+veXKVOajD0y+ZSkNn97sBya9lTaV+egzLVgDz2DasqZe5xJKoRFS3OYsAS6J",
- "oUIMQuA1qI/wgOMkUsx5R+9xRKq8+Q6uVvT7Uiq5LRey5R0EOuE3mrd1syERSYR3C5q/rWyr9d64adJH",
- "Ycp1tBYCAkZDYcldvBz5DeL7aG8zlUvHrQvN40cENI2VX+N7BSROI0kUWuDKwwqqed3AmXC2jCA+lsVZ",
- "vkylSWIuIVxgaQH95fLly1eXr0ZOz4TE0gIbREyAKgxbTCSh6wVQyVW4qyfaDlIIIcEcUG5Z4dYRMB9W",
- "hBKxgdB2tlR/mApVfaoiWoD17bQ7MtpGoJmO/h8VVxmFP1do+uVwiBui88k1yvwTha4nc5TdupCoN+eD",
- "uZ7M31DJd2ch+gg4PE/ymoVwluA8XcZEtodCK27udCyPFrQ2bTO8ixjWhbTYmgGjqmshsx+nwURMA2X3",
- "GC9zImo0nVhWg9DwK8i9rXZIwgmVP774DaKI+d6W8Sj834ufjiLTirpCMoRpgDkQHdASncLTFYTh3ikg",
- "uJboFUTOxt74ZvR1Y5wwtp+CczaM74F1qmZ+U8Wd5d3m5Jozn1zPdcc6R/LNAwQfQaRRW8Wy1/TDI0vn",
- "cS6JSTCFBwi4wdA7n5xwGp6KgHGbVGM1ctA0ivBSfTW/L9wjSCr2ZxCRBgEINXKsMIlSPWJIEgNLlXdK",
- "lFMcLUDPor7+0UUiKL9vOaPrBaZiWx+1KsWHQ5RD8nOnukapoGhvLMgVdqNANY71n/8akIaDpw6rNUiF",
- "eFc4Zj/3FmatrluQi9m3/xBbIJq7S709YbJvEtqIt6H5C8tgc+ZsbMvq4fjWqfbkHtAQ717IG6KdB9aG",
- "pKsHuNWfzUinugOM3Jr1mpK9Da4HQfQ4Q/j5hurwi7peJ0o5//DocSiH/SWpU5PeT1XPXboDoGal7hp6",
- "/yk6erdurDSEwLlNr5Z1aiiw1ln8OxrnfUrV2n6pvsTTORHf2KDc+jqSrL8WdRjGs/aoWTVg1EK6f3S5",
- "T4NPGyI8IjzsFdNF+yFbp+0giYxqFS9H5TpodE84hmdGk33s6nL6swB+yqHn72xDvV8ZuDwlAaMLfQlg",
- "iQxJjNcghndsQwd3ydopKhY4jIkd3xWORLX5l4xFgPUReSoc5WVy4YqoWtr0QkE5Gs/Cyp6SxrlgibsZ",
- "W6WO0BXTBw4mr+gqWmLJmRBe8QvD28LSu5q9Qz66By7M6fhoMB6MFHqWAMUJQVN0MRgNRshcvOgUDdc4",
- "Nslag94OKn/6jPJdiKboBuSNXuBbV0QtA1G1ZOi8Qspua/cyk9HopEsCm14ldCIhFl2qVVWQEOYc75yn",
- "uaIlC/bVw3sipMdWnpHIfHQ5GrdBKH0e2hcWSujiuNDevY5qJGkcY74rIOT2Mz9P5fAxP5HOjiW1p5z6",
- "R+WsW74n4EC3zDsy3SnRVzrEz5ZhJXF5XKK83rMpcQPSwzlgRYmIrU01TJhwMGHGhHyvl5jggJCvmTnq",
- "PDMfCRZiy3hYm7fzp+PJhatsc1gTIfOrEcn+hlqDfKj9uXR8Y4WmxYbI4buZYV9BZ70yuc3vwd7/47OS",
- "VtKF1nMz0q7SKNp5irJApYJasPZkqls8VPOAZ8ineVg611aQPukF32OX+U/lxdQHsWFc/hyRewg9rM15",
- "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: