From 9477788709127ffd5611caed0fa7ee191326ce00 Mon Sep 17 00:00:00 2001 From: nsfisis Date: Sat, 10 Aug 2024 13:31:52 +0900 Subject: feat(frontend): partially implement watch page --- frontend/app/components/GolfWatchApp.client.tsx | 66 ++++++++++++++++++------- 1 file changed, 47 insertions(+), 19 deletions(-) (limited to 'frontend/app/components/GolfWatchApp.client.tsx') diff --git a/frontend/app/components/GolfWatchApp.client.tsx b/frontend/app/components/GolfWatchApp.client.tsx index 829f709..355f7e3 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(null); - const [timeLeftSeconds, setTimeLeftSeconds] = useState(null); + const [leftTimeSeconds, setLeftTimeSeconds] = useState(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(null); - const [scoreB, setScoreB] = useState(null); - const [codeA, setCodeA] = useState(""); - const [codeB, setCodeB] = useState(""); + const playerA = game.players[0]; + const playerB = game.players[1]; + + const [playerInfoA, setPlayerInfoA] = useState({ + displayName: playerA?.display_name ?? null, + iconPath: playerA?.icon_path ?? null, + score: null, + code: "", + submissionResult: undefined, + }); + const [playerInfoB, setPlayerInfoB] = useState({ + 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,51 @@ 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); - } else if (lastJsonMessage.type === "watcher:s2c:execresult") { - const { score } = lastJsonMessage.data; - if (score !== null && (scoreA === null || score < scoreA)) { - setScoreA(score); + if (player_id === playerA?.user_id) { + setPlayerInfoA((prev) => ({ ...prev, code })); + } else if (player_id === playerB?.user_id) { + setPlayerInfoB((prev) => ({ ...prev, code })); + } else { + throw new Error("Unknown player_id"); } + } else if (lastJsonMessage.type === "watcher:s2c:execresult") { + // const { score } = lastJsonMessage.data; + // if (score !== null && (scoreA === null || score < scoreA)) { + // setScoreA(score); + // } } } else { setGameState("waiting"); } } - }, [lastJsonMessage, readyState, gameState, scoreA]); + }, [ + lastJsonMessage, + readyState, + gameState, + playerInfoA, + playerInfoB, + playerA?.user_id, + playerB?.user_id, + ]); if (gameState === "connecting") { return ; } else if (gameState === "waiting") { return ; } else if (gameState === "starting") { - return ; + return ; } else if (gameState === "gaming") { return ( ); } else if (gameState === "finished") { -- cgit v1.2.3-70-g09d2 From b4ab693aa438f3f1a335369568aabe7849fc1370 Mon Sep 17 00:00:00 2001 From: nsfisis Date: Sat, 10 Aug 2024 20:25:59 +0900 Subject: feat: implement watch page --- backend/game/hub.go | 153 +++++++++++++++++---- backend/game/message.go | 14 +- frontend/app/components/GolfWatchApp.client.tsx | 88 ++++++++++-- .../GolfWatchApps/GolfWatchAppGaming.tsx | 82 ++++++++--- frontend/app/root.tsx | 2 +- 5 files changed, 283 insertions(+), 56 deletions(-) (limited to 'frontend/app/components/GolfWatchApp.client.tsx') 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/frontend/app/components/GolfWatchApp.client.tsx b/frontend/app/components/GolfWatchApp.client.tsx index 355f7e3..a9c9989 100644 --- a/frontend/app/components/GolfWatchApp.client.tsx +++ b/frontend/app/components/GolfWatchApp.client.tsx @@ -116,29 +116,91 @@ export default function GolfWatchApp({ } } else if (lastJsonMessage.type === "watcher:s2c:code") { const { player_id, code } = lastJsonMessage.data; - if (player_id === playerA?.user_id) { - setPlayerInfoA((prev) => ({ ...prev, code })); - } else if (player_id === playerB?.user_id) { - setPlayerInfoB((prev) => ({ ...prev, code })); - } else { - throw new Error("Unknown player_id"); - } + 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"); } } }, [ + game.verification_steps, lastJsonMessage, readyState, gameState, - playerInfoA, - playerInfoB, playerA?.user_id, playerB?.user_id, ]); diff --git a/frontend/app/components/GolfWatchApps/GolfWatchAppGaming.tsx b/frontend/app/components/GolfWatchApps/GolfWatchAppGaming.tsx index 470a00c..992ce7a 100644 --- a/frontend/app/components/GolfWatchApps/GolfWatchAppGaming.tsx +++ b/frontend/app/components/GolfWatchApps/GolfWatchAppGaming.tsx @@ -14,17 +14,57 @@ export type PlayerInfo = { }; type SubmissionResult = { - status: string; - nextScore: number; - executionResults: ExecutionResult[]; + status: + | "running" + | "success" + | "wrong_answer" + | "timeout" + | "compile_error" + | "runtime_error" + | "internal_error"; + preliminaryScore: number; + verificationResults: VerificationResult[]; }; -type ExecutionResult = { - status: string; +type VerificationResult = { + testcase_id: number | null; + status: + | "running" + | "success" + | "wrong_answer" + | "timeout" + | "compile_error" + | "runtime_error" + | "internal_error" + | "canceled"; label: string; - output: 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, playerInfoA, @@ -40,7 +80,7 @@ export default function GolfWatchAppGaming({ const scoreA = playerInfoA.score ?? 0; const scoreB = playerInfoB.score ?? 0; const totalScore = scoreA + scoreB; - return totalScore === 0 ? 50 : (scoreA / totalScore) * 100; + return totalScore === 0 ? 50 : (scoreB / totalScore) * 100; })(); return ( @@ -68,7 +108,7 @@ export default function GolfWatchAppGaming({ {playerInfoB.score ?? "-"} -
+
 						{playerInfoA.code}
@@ -76,19 +116,24 @@ export default function GolfWatchAppGaming({
 				
- {playerInfoA.submissionResult?.status}( - {playerInfoA.submissionResult?.nextScore}) + {submissionResultStatusToLabel( + playerInfoA.submissionResult?.status ?? null, + )}{" "} + ({playerInfoA.submissionResult?.preliminaryScore})
    - {playerInfoA.submissionResult?.executionResults.map( + {playerInfoA.submissionResult?.verificationResults.map( (result, idx) => (
  1. {result.status} {result.label}
    -
    {result.output}
    +
    + {result.stdout} + {result.stderr} +
  2. ), @@ -103,19 +148,24 @@ export default function GolfWatchAppGaming({
- {playerInfoB.submissionResult?.status}( - {playerInfoB.submissionResult?.nextScore}) + {submissionResultStatusToLabel( + playerInfoB.submissionResult?.status ?? null, + )}{" "} + ({playerInfoB.submissionResult?.preliminaryScore ?? "-"})
    - {playerInfoB.submissionResult?.executionResults.map( + {playerInfoB.submissionResult?.verificationResults.map( (result, idx) => (
  1. {result.status} {result.label}
    -
    {result.output}
    +
    + {result.stdout} + {result.stderr} +
  2. ), 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 }) { - + {children} -- cgit v1.2.3-70-g09d2