diff options
Diffstat (limited to 'frontend')
| -rw-r--r-- | frontend/app/components/GolfPlayApp.client.tsx | 246 | ||||
| -rw-r--r-- | frontend/app/components/GolfPlayApps/GolfPlayAppFinished.tsx | 2 | ||||
| -rw-r--r-- | frontend/app/components/GolfPlayApps/GolfPlayAppGaming.tsx | 42 | ||||
| -rw-r--r-- | frontend/app/components/GolfPlayApps/GolfPlayAppStarting.tsx | 13 | ||||
| -rw-r--r-- | frontend/app/routes/golf.$gameId.play.tsx | 97 | ||||
| -rw-r--r-- | frontend/app/states/play.ts | 185 | ||||
| -rw-r--r-- | frontend/app/states/watch.ts | 0 | ||||
| -rw-r--r-- | frontend/package-lock.json | 22 | ||||
| -rw-r--r-- | frontend/package.json | 1 |
9 files changed, 416 insertions, 192 deletions
diff --git a/frontend/app/components/GolfPlayApp.client.tsx b/frontend/app/components/GolfPlayApp.client.tsx index 80cfc40..0230426 100644 --- a/frontend/app/components/GolfPlayApp.client.tsx +++ b/frontend/app/components/GolfPlayApp.client.tsx @@ -1,8 +1,20 @@ -import { useEffect, useState } from "react"; +import { useAtomValue, useSetAtom } from "jotai"; +import { useEffect } from "react"; +import { useTimer } from "react-use-precision-timer"; import { useDebouncedCallback } from "use-debounce"; import type { components } from "../.server/api/schema"; import useWebSocket, { ReadyState } from "../hooks/useWebSocket"; -import type { PlayerState } from "../types/PlayerState"; +import { + gameStartAtom, + gameStateKindAtom, + handleSubmitCodeAtom, + handleWsConnectionClosedAtom, + handleWsExecResultMessageAtom, + handleWsSubmitResultMessageAtom, + setCurrentTimestampAtom, + setGameStateConnectingAtom, + setGameStateWaitingAtom, +} from "../states/play"; import GolfPlayAppConnecting from "./GolfPlayApps/GolfPlayAppConnecting"; import GolfPlayAppFinished from "./GolfPlayApps/GolfPlayAppFinished"; import GolfPlayAppGaming from "./GolfPlayApps/GolfPlayAppGaming"; @@ -15,81 +27,47 @@ type GamePlayerMessageC2S = components["schemas"]["GamePlayerMessageC2S"]; type Game = components["schemas"]["Game"]; type User = components["schemas"]["User"]; -type GameState = "connecting" | "waiting" | "starting" | "gaming" | "finished"; +type Props = { + game: Game; + player: User; + initialCode: string; + sockToken: string; +}; export default function GolfPlayApp({ game, player, + initialCode, sockToken, -}: { - game: Game; - player: User; - sockToken: string; -}) { +}: Props) { const socketUrl = process.env.NODE_ENV === "development" ? `ws://localhost:8002/iosdc-japan/2024/code-battle/sock/golf/${game.game_id}/play?token=${sockToken}` : `wss://t.nil.ninja/iosdc-japan/2024/code-battle/sock/golf/${game.game_id}/play?token=${sockToken}`; + const gameStateKind = useAtomValue(gameStateKindAtom); + const setCurrentTimestamp = useSetAtom(setCurrentTimestampAtom); + const gameStart = useSetAtom(gameStartAtom); + const setGameStateConnecting = useSetAtom(setGameStateConnectingAtom); + const setGameStateWaiting = useSetAtom(setGameStateWaitingAtom); + const handleWsConnectionClosed = useSetAtom(handleWsConnectionClosedAtom); + const handleWsExecResultMessage = useSetAtom(handleWsExecResultMessageAtom); + const handleWsSubmitResultMessage = useSetAtom( + handleWsSubmitResultMessageAtom, + ); + const handleSubmitCode = useSetAtom(handleSubmitCodeAtom); + + useTimer({ delay: 1000, startImmediately: true }, setCurrentTimestamp); + const { sendJsonMessage, lastJsonMessage, readyState } = useWebSocket< GamePlayerMessageS2C, GamePlayerMessageC2S >(socketUrl); - const [gameState, setGameState] = useState<GameState>("connecting"); - - const [startedAt, setStartedAt] = useState<number | null>(null); - - const [leftTimeSeconds, setLeftTimeSeconds] = useState<number | null>(null); - - useEffect(() => { - if ( - (gameState === "starting" || gameState === "gaming") && - startedAt !== null - ) { - const timer = setInterval(() => { - setLeftTimeSeconds((prev) => { - if (prev === null) { - return null; - } - if (prev <= 1) { - const nowSec = Math.floor(Date.now() / 1000); - const finishedAt = startedAt + game.duration_seconds; - if (nowSec >= finishedAt) { - clearInterval(timer); - setGameState("finished"); - } else { - setGameState("gaming"); - } - } - return prev - 1; - }); - }, 1000); - - return () => { - clearInterval(timer); - }; - } - }, [gameState, startedAt, game.duration_seconds]); - const playerProfile = { displayName: player.display_name, iconPath: player.icon_path ?? null, }; - const [playerState, setPlayerState] = useState<PlayerState>({ - code: "", - score: null, - submitResult: { - status: "waiting_submission", - execResults: game.exec_steps.map((r) => ({ - testcase_id: r.testcase_id, - status: "waiting_submission", - label: r.label, - stdout: "", - stderr: "", - })), - }, - }); const onCodeChange = useDebouncedCallback((code: string) => { console.log("player:c2s:code"); @@ -97,6 +75,8 @@ export default function GolfPlayApp({ type: "player:c2s:code", data: { code }, }); + const baseKey = `playerState:${game.game_id}:${player.user_id}`; + window.localStorage.setItem(`${baseKey}:code`, code); }, 1000); const onCodeSubmit = useDebouncedCallback((code: string) => { @@ -108,18 +88,7 @@ export default function GolfPlayApp({ type: "player:c2s:submit", data: { code }, }); - setPlayerState((prev) => ({ - ...prev, - submitResult: { - status: "running", - execResults: prev.submitResult.execResults.map((r) => ({ - ...r, - status: "running", - stdout: "", - stderr: "", - })), - }, - })); + handleSubmitCode(); }, 1000); if (readyState === ReadyState.UNINSTANTIATED) { @@ -128,136 +97,89 @@ export default function GolfPlayApp({ useEffect(() => { if (readyState === ReadyState.CLOSING || readyState === ReadyState.CLOSED) { - if (gameState !== "finished") { - setGameState("connecting"); - } + handleWsConnectionClosed(); } else if (readyState === ReadyState.CONNECTING) { - setGameState("connecting"); + setGameStateConnecting(); } else if (readyState === ReadyState.OPEN) { if (lastJsonMessage !== null) { console.log(lastJsonMessage.type); if (lastJsonMessage.type === "player:s2c:start") { - if ( - gameState !== "starting" && - gameState !== "gaming" && - gameState !== "finished" - ) { - const { start_at } = lastJsonMessage.data; - setStartedAt(start_at); - const nowSec = Math.floor(Date.now() / 1000); - setLeftTimeSeconds(start_at - nowSec); - setGameState("starting"); - } + const { start_at } = lastJsonMessage.data; + gameStart(start_at); } else if (lastJsonMessage.type === "player:s2c:execresult") { - const { testcase_id, status, stdout, stderr } = lastJsonMessage.data; - setPlayerState((prev) => { - const ret = { ...prev }; - ret.submitResult = { - ...prev.submitResult, - execResults: prev.submitResult.execResults.map((r) => - r.testcase_id === testcase_id && r.status === "running" - ? { - ...r, - status, - stdout, - stderr, - } - : r, - ), - }; - return ret; - }); + handleWsExecResultMessage( + lastJsonMessage.data, + (submissionResult) => { + const baseKey = `playerState:${game.game_id}:${player.user_id}`; + window.localStorage.setItem( + `${baseKey}:submissionResult`, + JSON.stringify(submissionResult), + ); + }, + ); } else if (lastJsonMessage.type === "player:s2c:submitresult") { - const { status, score } = lastJsonMessage.data; - setPlayerState((prev) => { - const ret = { ...prev }; - ret.submitResult = { - ...prev.submitResult, - status, - }; - if (status === "success") { - if (score) { - if (ret.score === null || score < ret.score) { - ret.score = score; - } - } - } else { - ret.submitResult.execResults = prev.submitResult.execResults.map( - (r) => - r.status === "running" ? { ...r, status: "canceled" } : r, + handleWsSubmitResultMessage( + lastJsonMessage.data, + (submissionResult, score) => { + const baseKey = `playerState:${game.game_id}:${player.user_id}`; + window.localStorage.setItem( + `${baseKey}:submissionResult`, + JSON.stringify(submissionResult), + ); + window.localStorage.setItem( + `${baseKey}:score`, + score === null ? "" : score.toString(), ); - } - return ret; - }); + }, + ); } } else { if (game.started_at) { - const nowSec = Math.floor(Date.now() / 1000); - if (game.started_at <= nowSec) { - // The game has already started. - if (gameState !== "gaming" && gameState !== "finished") { - setStartedAt(game.started_at); - setLeftTimeSeconds(game.started_at - nowSec); - setGameState("gaming"); - } - } else { - // The game is starting. - if ( - gameState !== "starting" && - gameState !== "gaming" && - gameState !== "finished" - ) { - setStartedAt(game.started_at); - setLeftTimeSeconds(game.started_at - nowSec); - setGameState("starting"); - } - } + gameStart(game.started_at); } else { - setGameState("waiting"); + setGameStateWaiting(); } } } }, [ + game.game_id, game.started_at, + player.user_id, sendJsonMessage, lastJsonMessage, readyState, - gameState, + gameStart, + handleWsConnectionClosed, + handleWsExecResultMessage, + handleWsSubmitResultMessage, + setGameStateConnecting, + setGameStateWaiting, ]); - if (gameState === "connecting") { + if (gameStateKind === "connecting") { return <GolfPlayAppConnecting />; - } else if (gameState === "waiting") { + } else if (gameStateKind === "waiting") { return ( <GolfPlayAppWaiting gameDisplayName={game.display_name} playerProfile={playerProfile} /> ); - } else if (gameState === "starting") { - return ( - <GolfPlayAppStarting - gameDisplayName={game.display_name} - leftTimeSeconds={leftTimeSeconds!} - /> - ); - } else if (gameState === "gaming") { + } else if (gameStateKind === "starting") { + return <GolfPlayAppStarting gameDisplayName={game.display_name} />; + } else if (gameStateKind === "gaming") { return ( <GolfPlayAppGaming gameDisplayName={game.display_name} - gameDurationSeconds={game.duration_seconds} - leftTimeSeconds={leftTimeSeconds!} - playerInfo={{ - profile: playerProfile, - state: playerState, - }} + playerProfile={playerProfile} problemTitle={game.problem.title} problemDescription={game.problem.description} + initialCode={initialCode} onCodeChange={onCodeChange} onCodeSubmit={onCodeSubmit} /> ); - } else if (gameState === "finished") { + } else if (gameStateKind === "finished") { return <GolfPlayAppFinished />; } else { return null; diff --git a/frontend/app/components/GolfPlayApps/GolfPlayAppFinished.tsx b/frontend/app/components/GolfPlayApps/GolfPlayAppFinished.tsx index c3ef2d4..c218414 100644 --- a/frontend/app/components/GolfPlayApps/GolfPlayAppFinished.tsx +++ b/frontend/app/components/GolfPlayApps/GolfPlayAppFinished.tsx @@ -2,7 +2,7 @@ export default function GolfPlayAppFinished() { 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">Finished</h1> + <div className="text-6xl font-bold text-black">終了</div> </div> </div> ); diff --git a/frontend/app/components/GolfPlayApps/GolfPlayAppGaming.tsx b/frontend/app/components/GolfPlayApps/GolfPlayAppGaming.tsx index 38516bc..0aa6b3d 100644 --- a/frontend/app/components/GolfPlayApps/GolfPlayAppGaming.tsx +++ b/frontend/app/components/GolfPlayApps/GolfPlayAppGaming.tsx @@ -1,32 +1,40 @@ import { Link } from "@remix-run/react"; +import { useAtomValue } from "jotai"; import React, { useRef } from "react"; import SubmitButton from "../../components/SubmitButton"; -import type { PlayerInfo } from "../../types/PlayerInfo"; +import { + gamingLeftTimeSecondsAtom, + scoreAtom, + submitResultAtom, +} from "../../states/play"; +import type { PlayerProfile } from "../../types/PlayerProfile"; import BorderedContainer from "../BorderedContainer"; import SubmitResult from "../Gaming/SubmitResult"; import UserIcon from "../UserIcon"; type Props = { gameDisplayName: string; - gameDurationSeconds: number; - leftTimeSeconds: number; - playerInfo: PlayerInfo; + playerProfile: PlayerProfile; problemTitle: string; problemDescription: string; + initialCode: string; onCodeChange: (code: string) => void; onCodeSubmit: (code: string) => void; }; export default function GolfPlayAppGaming({ gameDisplayName, - gameDurationSeconds, - leftTimeSeconds, - playerInfo, + playerProfile, problemTitle, problemDescription, + initialCode, onCodeChange, onCodeSubmit, }: Props) { + const leftTimeSeconds = useAtomValue(gamingLeftTimeSecondsAtom)!; + const score = useAtomValue(scoreAtom); + const submitResult = useAtomValue(submitResultAtom); + const textareaRef = useRef<HTMLTextAreaElement>(null); const handleTextChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => { @@ -40,9 +48,8 @@ export default function GolfPlayAppGaming({ }; const leftTime = (() => { - const k = gameDurationSeconds + leftTimeSeconds; - const m = Math.floor(k / 60); - const s = k % 60; + const m = Math.floor(leftTimeSeconds / 60); + const s = leftTimeSeconds % 60; return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`; })(); @@ -55,15 +62,15 @@ export default function GolfPlayAppGaming({ </div> <Link to={"/dashboard"}> <div className="flex gap-4 my-auto font-bold"> - <div className="text-6xl">{playerInfo.state.score}</div> + <div className="text-6xl">{score}</div> <div className="text-end"> <div className="text-gray-100">Player 1</div> - <div className="text-2xl">{playerInfo.profile.displayName}</div> + <div className="text-2xl">{playerProfile.displayName}</div> </div> - {playerInfo.profile.iconPath && ( + {playerProfile.iconPath && ( <UserIcon - iconPath={playerInfo.profile.iconPath} - displayName={playerInfo.profile.displayName} + iconPath={playerProfile.iconPath} + displayName={playerProfile.displayName} className="w-12 h-12 my-auto" /> )} @@ -82,13 +89,14 @@ export default function GolfPlayAppGaming({ <div className="p-4"> <textarea ref={textareaRef} + defaultValue={initialCode} onChange={handleTextChange} className="resize-none h-full w-full rounded-lg border border-gray-300 p-2 focus:outline-none focus:ring-2 focus:ring-gray-400 transition duration-300" - ></textarea> + /> </div> <div className="p-4"> <SubmitResult - result={playerInfo.state.submitResult} + result={submitResult} submitButton={ <SubmitButton onClick={handleSubmitButtonClick}> 提出 diff --git a/frontend/app/components/GolfPlayApps/GolfPlayAppStarting.tsx b/frontend/app/components/GolfPlayApps/GolfPlayAppStarting.tsx index a42c883..b41dfed 100644 --- a/frontend/app/components/GolfPlayApps/GolfPlayAppStarting.tsx +++ b/frontend/app/components/GolfPlayApps/GolfPlayAppStarting.tsx @@ -1,18 +1,19 @@ +import { useAtomValue } from "jotai"; +import { startingLeftTimeSecondsAtom } from "../../states/play"; + type Props = { gameDisplayName: string; - leftTimeSeconds: number; }; -export default function GolfPlayAppStarting({ - gameDisplayName, - leftTimeSeconds, -}: Props) { +export default function GolfPlayAppStarting({ gameDisplayName }: Props) { + const leftTimeSeconds = useAtomValue(startingLeftTimeSecondsAtom)!; + return ( <div className="min-h-screen bg-gray-100 flex flex-col"> <div className="text-white bg-iosdc-japan p-10 text-center"> <div className="text-4xl font-bold">{gameDisplayName}</div> </div> - <div className="text-center text-black font-black text-10xl animate-ping"> + <div className="text-center text-black font-black text-10xl"> {leftTimeSeconds} </div> </div> diff --git a/frontend/app/routes/golf.$gameId.play.tsx b/frontend/app/routes/golf.$gameId.play.tsx index ea1b8fd..a2860dd 100644 --- a/frontend/app/routes/golf.$gameId.play.tsx +++ b/frontend/app/routes/golf.$gameId.play.tsx @@ -1,10 +1,17 @@ import type { LoaderFunctionArgs, MetaFunction } from "@remix-run/node"; -import { useLoaderData } from "@remix-run/react"; -import { ClientOnly } from "remix-utils/client-only"; +import { ClientLoaderFunctionArgs, useLoaderData } from "@remix-run/react"; +import { useHydrateAtoms } from "jotai/utils"; import { apiGetGame, apiGetToken } from "../.server/api/client"; import { ensureUserLoggedIn } from "../.server/auth"; import GolfPlayApp from "../components/GolfPlayApp.client"; import GolfPlayAppConnecting from "../components/GolfPlayApps/GolfPlayAppConnecting"; +import { + scoreAtom, + setCurrentTimestampAtom, + setDurationSecondsAtom, + submitResultAtom, +} from "../states/play"; +import { PlayerState } from "../types/PlayerState"; export const meta: MetaFunction<typeof loader> = ({ data }) => [ { @@ -25,19 +32,97 @@ export async function loader({ params, request }: LoaderFunctionArgs) { }; const [game, sockToken] = await Promise.all([fetchGame(), fetchSockToken()]); + + const playerState: PlayerState = { + code: "", + score: null, + submitResult: { + status: "waiting_submission", + execResults: game.exec_steps.map((r) => ({ + testcase_id: r.testcase_id, + status: "waiting_submission", + label: r.label, + stdout: "", + stderr: "", + })), + }, + }; + return { game, player: user, sockToken, + playerState, }; } +export async function clientLoader({ serverLoader }: ClientLoaderFunctionArgs) { + const data = await serverLoader<typeof loader>(); + const baseKey = `playerState:${data.game.game_id}:${data.player.user_id}`; + + const localCode = (() => { + const rawValue = window.localStorage.getItem(`${baseKey}:code`); + if (rawValue === null) { + return null; + } + return rawValue; + })(); + + const localScore = (() => { + const rawValue = window.localStorage.getItem(`${baseKey}:score`); + if (rawValue === null || rawValue === "") { + return null; + } + return Number(rawValue); + })(); + + const localSubmissionResult = (() => { + const rawValue = window.localStorage.getItem(`${baseKey}:submissionResult`); + if (rawValue === null) { + return null; + } + const parsed = JSON.parse(rawValue); + if (typeof parsed !== "object") { + return null; + } + return parsed; + })(); + + if (localCode !== null) { + data.playerState.code = localCode; + } + if (localScore !== null) { + data.playerState.score = localScore; + } + if (localSubmissionResult !== null) { + data.playerState.submitResult = localSubmissionResult; + } + + return data; +} +clientLoader.hydrate = true; + +export function HydrateFallback() { + return <GolfPlayAppConnecting />; +} + export default function GolfPlay() { - const { game, player, sockToken } = useLoaderData<typeof loader>(); + const { game, player, sockToken, playerState } = + useLoaderData<typeof loader>(); + + useHydrateAtoms([ + [setCurrentTimestampAtom, undefined], + [setDurationSecondsAtom, game.duration_seconds], + [scoreAtom, playerState.score], + [submitResultAtom, playerState.submitResult], + ]); return ( - <ClientOnly fallback={<GolfPlayAppConnecting />}> - {() => <GolfPlayApp game={game} player={player} sockToken={sockToken} />} - </ClientOnly> + <GolfPlayApp + game={game} + player={player} + initialCode={playerState.code} + sockToken={sockToken} + /> ); } diff --git a/frontend/app/states/play.ts b/frontend/app/states/play.ts new file mode 100644 index 0000000..13bd39f --- /dev/null +++ b/frontend/app/states/play.ts @@ -0,0 +1,185 @@ +import { atom } from "jotai"; +import type { components } from "../.server/api/schema"; +import type { SubmitResult } from "../types/SubmitResult"; + +type RawGameState = + | { + kind: "connecting"; + startedAtTimestamp: null; + } + | { + kind: "waiting"; + startedAtTimestamp: null; + } + | { + kind: "starting"; + startedAtTimestamp: number; + }; + +const rawGameStateAtom = atom<RawGameState>({ + kind: "connecting", + startedAtTimestamp: null, +}); + +export type GameStateKind = + | "connecting" + | "waiting" + | "starting" + | "gaming" + | "finished"; + +export const gameStateKindAtom = atom<GameStateKind>((get) => { + const { kind: rawKind, startedAtTimestamp } = get(rawGameStateAtom); + if (rawKind === "connecting" || rawKind === "waiting") { + return rawKind; + } else { + const durationSeconds = get(rawDurationSecondsAtom); + const finishedAtTimestamp = startedAtTimestamp + durationSeconds; + const currentTimestamp = get(rawCurrentTimestampAtom); + if (currentTimestamp < startedAtTimestamp) { + return "starting"; + } else if (currentTimestamp < finishedAtTimestamp) { + return "gaming"; + } else { + return "finished"; + } + } +}); + +export const gameStartAtom = atom(null, (get, set, value: number) => { + const { kind } = get(rawGameStateAtom); + if (kind === "starting") { + return; + } + set(rawGameStateAtom, { + kind: "starting", + startedAtTimestamp: value, + }); +}); +export const setGameStateConnectingAtom = atom(null, (_, set) => + set(rawGameStateAtom, { kind: "connecting", startedAtTimestamp: null }), +); +export const setGameStateWaitingAtom = atom(null, (_, set) => + set(rawGameStateAtom, { kind: "waiting", startedAtTimestamp: null }), +); + +const rawCurrentTimestampAtom = atom(0); +export const setCurrentTimestampAtom = atom(null, (_, set) => + set(rawCurrentTimestampAtom, Math.floor(Date.now() / 1000)), +); + +const rawDurationSecondsAtom = atom<number>(0); +export const setDurationSecondsAtom = atom(null, (_, set, value: number) => + set(rawDurationSecondsAtom, value), +); + +export const startingLeftTimeSecondsAtom = atom<number | null>((get) => { + const { startedAtTimestamp } = get(rawGameStateAtom); + if (startedAtTimestamp === null) { + return null; + } + const currentTimestamp = get(rawCurrentTimestampAtom); + return Math.max(0, startedAtTimestamp - currentTimestamp); +}); + +export const gamingLeftTimeSecondsAtom = atom<number | null>((get) => { + const { startedAtTimestamp } = get(rawGameStateAtom); + if (startedAtTimestamp === null) { + return null; + } + const durationSeconds = get(rawDurationSecondsAtom); + const finishedAtTimestamp = startedAtTimestamp + durationSeconds; + const currentTimestamp = get(rawCurrentTimestampAtom); + return Math.min( + durationSeconds, + Math.max(0, finishedAtTimestamp - currentTimestamp), + ); +}); + +export const handleWsConnectionClosedAtom = atom(null, (get, set) => { + const kind = get(gameStateKindAtom); + if (kind !== "finished") { + set(setGameStateConnectingAtom); + } +}); + +export const scoreAtom = atom<number | null>(null); +export const submitResultAtom = atom<SubmitResult>({ + status: "waiting_submission", + execResults: [], +}); + +export const handleSubmitCodeAtom = atom(null, (_, set) => { + set(submitResultAtom, (prev) => ({ + status: "running", + execResults: prev.execResults.map((r) => ({ + ...r, + status: "running", + stdout: "", + stderr: "", + })), + })); +}); + +type GamePlayerMessageS2CExecResultPayload = + components["schemas"]["GamePlayerMessageS2CExecResultPayload"]; +type GamePlayerMessageS2CSubmitResultPayload = + components["schemas"]["GamePlayerMessageS2CSubmitResultPayload"]; + +export const handleWsExecResultMessageAtom = atom( + null, + ( + get, + set, + data: GamePlayerMessageS2CExecResultPayload, + callback: (submissionResult: SubmitResult) => void, + ) => { + const { testcase_id, status, stdout, stderr } = data; + const prev = get(submitResultAtom); + const newResult = { + ...prev, + execResults: prev.execResults.map((r) => + r.testcase_id === testcase_id && r.status === "running" + ? { + ...r, + status, + stdout, + stderr, + } + : r, + ), + }; + set(submitResultAtom, newResult); + callback(newResult); + }, +); + +export const handleWsSubmitResultMessageAtom = atom( + null, + ( + get, + set, + data: GamePlayerMessageS2CSubmitResultPayload, + callback: (submissionResult: SubmitResult, score: number | null) => void, + ) => { + const { status, score } = data; + const prev = get(submitResultAtom); + const newResult = { + ...prev, + status, + }; + if (status !== "success") { + newResult.execResults = prev.execResults.map((r) => + r.status === "running" ? { ...r, status: "canceled" } : r, + ); + } + set(submitResultAtom, newResult); + if (status === "success" && score !== null) { + const currentScore = get(scoreAtom); + if (currentScore === null || score < currentScore) { + set(scoreAtom, score); + } + } + callback(newResult, score); + }, +); diff --git a/frontend/app/states/watch.ts b/frontend/app/states/watch.ts new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/frontend/app/states/watch.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 6953cef..538ceeb 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -21,6 +21,7 @@ "prismjs": "^1.29.0", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-use-precision-timer": "^3.5.5", "react-use-websocket": "^4.8.1", "remix-auth": "^3.7.0", "remix-auth-form": "^1.5.0", @@ -9232,6 +9233,27 @@ "react-dom": ">=16.8" } }, + "node_modules/react-sub-unsub": { + "version": "2.2.7", + "resolved": "https://registry.npmjs.org/react-sub-unsub/-/react-sub-unsub-2.2.7.tgz", + "integrity": "sha512-b2o0mIW8G4Yb3aaKxFB9iiCCHxCDGmogy+493oQpEJHjBy/hl6uf+6RhAinqKWRwi1fvO6mGIMVGsf2XYLL38g==", + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^20.0.0 || ^21.0.0 || ^22.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^20.0.0 || ^21.0.0 || ^22.0.0" + } + }, + "node_modules/react-use-precision-timer": { + "version": "3.5.5", + "resolved": "https://registry.npmjs.org/react-use-precision-timer/-/react-use-precision-timer-3.5.5.tgz", + "integrity": "sha512-fPf9d1fAb4CCJrJCnErvvB/GFVDm+bzb07WilkiW3hcJUjqS3ep6pCLKUguT76gpPvyOuKp9KSD8z06uM3LzAA==", + "dependencies": { + "react-sub-unsub": "^2.2.2" + }, + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^20.0.0 || ^21.0.0 || ^22.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^20.0.0 || ^21.0.0 || ^22.0.0" + } + }, "node_modules/react-use-websocket": { "version": "4.8.1", "resolved": "https://registry.npmjs.org/react-use-websocket/-/react-use-websocket-4.8.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 1238349..bb11725 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -29,6 +29,7 @@ "prismjs": "^1.29.0", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-use-precision-timer": "^3.5.5", "react-use-websocket": "^4.8.1", "remix-auth": "^3.7.0", "remix-auth-form": "^1.5.0", |
