diff options
Diffstat (limited to 'frontend/app/components')
5 files changed, 332 insertions, 46 deletions
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> + ); } |
