diff options
Diffstat (limited to 'frontend')
| -rw-r--r-- | frontend/app/components/GolfWatchApp.client.tsx | 295 | ||||
| -rw-r--r-- | frontend/app/components/GolfWatchApps/GolfWatchAppGaming.tsx | 76 | ||||
| -rw-r--r-- | frontend/app/components/GolfWatchApps/GolfWatchAppStarting.tsx | 13 | ||||
| -rw-r--r-- | frontend/app/routes/golf.$gameId.watch.tsx | 162 | ||||
| -rw-r--r-- | frontend/app/states/watch.ts | 247 |
5 files changed, 553 insertions, 240 deletions
diff --git a/frontend/app/components/GolfWatchApp.client.tsx b/frontend/app/components/GolfWatchApp.client.tsx index 9eacb2d..7ea2ced 100644 --- a/frontend/app/components/GolfWatchApp.client.tsx +++ b/frontend/app/components/GolfWatchApp.client.tsx @@ -1,8 +1,21 @@ -import { useEffect, useState } from "react"; +import { useAtomValue, useSetAtom } from "jotai"; +import { useCallback, useEffect } from "react"; +import { useTimer } from "react-use-precision-timer"; import { AudioController } from "../.client/audio/AudioController"; import type { components } from "../.server/api/schema"; import useWebSocket, { ReadyState } from "../hooks/useWebSocket"; -import type { PlayerState } from "../types/PlayerState"; +import { + gameStartAtom, + gameStateKindAtom, + handleWsCodeMessageAtom, + handleWsConnectionClosedAtom, + handleWsExecResultMessageAtom, + handleWsSubmitMessageAtom, + handleWsSubmitResultMessageAtom, + setCurrentTimestampAtom, + setGameStateConnectingAtom, + setGameStateWaitingAtom, +} from "../states/watch"; import GolfWatchAppConnecting from "./GolfWatchApps/GolfWatchAppConnecting"; import GolfWatchAppGaming from "./GolfWatchApps/GolfWatchAppGaming"; import GolfWatchAppStarting from "./GolfWatchApps/GolfWatchAppStarting"; @@ -13,8 +26,6 @@ type GameWatcherMessageC2S = never; type Game = components["schemas"]["Game"]; -type GameState = "connecting" | "waiting" | "starting" | "gaming" | "finished"; - export type Props = { game: Game; sockToken: string; @@ -31,87 +42,47 @@ export default function GolfWatchApp({ ? `ws://localhost:8002/iosdc-japan/2024/code-battle/sock/golf/${game.game_id}/watch?token=${sockToken}` : `wss://t.nil.ninja/iosdc-japan/2024/code-battle/sock/golf/${game.game_id}/watch?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 handleWsCodeMessage = useSetAtom(handleWsCodeMessageAtom); + const handleWsSubmitMessage = useSetAtom(handleWsSubmitMessageAtom); + const handleWsExecResultMessage = useSetAtom(handleWsExecResultMessageAtom); + const handleWsSubmitResultMessage = useSetAtom( + handleWsSubmitResultMessageAtom, + ); + + useTimer({ delay: 1000, startImmediately: true }, setCurrentTimestamp); + const { lastJsonMessage, readyState } = useWebSocket< GameWatcherMessageS2C, GameWatcherMessageC2S >(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"); - audioController.playSoundEffectFinish(); - } else { - setGameState("gaming"); - } - } - return prev - 1; - }); - }, 1000); - - return () => { - clearInterval(timer); - }; - } - }, [gameState, startedAt, game.duration_seconds, audioController]); - const playerA = game.players[0]!; const playerB = game.players[1]!; + const getTargetAtomByPlayerId: <T>( + player_id: number, + atomA: T, + atomB: T, + ) => T = useCallback( + (player_id, atomA, atomB) => + player_id === playerA.user_id ? atomA : atomB, + [playerA.user_id], + ); + const playerProfileA = { displayName: playerA.display_name, iconPath: playerA.icon_path ?? null, }; - const [playerStateA, setPlayerStateA] = useState<PlayerState>({ - score: null, - code: "", - submitResult: { - status: "waiting_submission", - execResults: game.exec_steps.map((r) => ({ - testcase_id: r.testcase_id, - status: "waiting_submission", - label: r.label, - stdout: "", - stderr: "", - })), - }, - }); const playerProfileB = { displayName: playerB.display_name, iconPath: playerB.icon_path ?? null, }; - const [playerStateB, setPlayerStateB] = useState<PlayerState>({ - score: null, - code: "", - submitResult: { - status: "waiting_submission", - execResults: game.exec_steps.map((r) => ({ - testcase_id: r.testcase_id, - status: "waiting_submission", - label: r.label, - stdout: "", - stderr: "", - })), - }, - }); if (readyState === ReadyState.UNINSTANTIATED) { throw new Error("WebSocket is not connected"); @@ -119,133 +90,92 @@ export default function GolfWatchApp({ 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 === "watcher: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 === "watcher:s2c:code") { - const { player_id, code } = lastJsonMessage.data; - const setter = - player_id === playerA.user_id ? setPlayerStateA : setPlayerStateB; - setter((prev) => ({ ...prev, code })); + handleWsCodeMessage( + lastJsonMessage.data, + getTargetAtomByPlayerId, + (player_id, code) => { + const baseKey = `watcherState:${game.game_id}:${player_id}`; + window.localStorage.setItem(`${baseKey}:code`, code); + }, + ); } else if (lastJsonMessage.type === "watcher:s2c:submit") { - const { player_id } = lastJsonMessage.data; - const setter = - player_id === playerA.user_id ? setPlayerStateA : setPlayerStateB; - setter((prev) => ({ - ...prev, - submitResult: { - status: "running", - execResults: prev.submitResult.execResults.map((r) => ({ - ...r, - status: "running", - stdout: "", - stderr: "", - })), + handleWsSubmitMessage( + lastJsonMessage.data, + getTargetAtomByPlayerId, + (player_id, submissionResult) => { + const baseKey = `watcherState:${game.game_id}:${player_id}`; + window.localStorage.setItem( + `${baseKey}:submissionResult`, + JSON.stringify(submissionResult), + ); }, - })); + ); } else if (lastJsonMessage.type === "watcher:s2c:execresult") { - const { player_id, testcase_id, status, stdout, stderr } = - lastJsonMessage.data; - const setter = - player_id === playerA.user_id ? setPlayerStateA : setPlayerStateB; - setter((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, + getTargetAtomByPlayerId, + (player_id, submissionResult) => { + const baseKey = `watcherState:${game.game_id}:${player_id}`; + window.localStorage.setItem( + `${baseKey}:submissionResult`, + JSON.stringify(submissionResult), + ); + }, + ); } else if (lastJsonMessage.type === "watcher:s2c:submitresult") { - const { player_id, status, score } = lastJsonMessage.data; - const setter = - player_id === playerA.user_id ? setPlayerStateA : setPlayerStateB; - setter((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, + getTargetAtomByPlayerId, + (player_id, submissionResult, score) => { + const baseKey = `watcherState:${game.game_id}:${player_id}`; + window.localStorage.setItem( + `${baseKey}:submissionResult`, + JSON.stringify(submissionResult), ); - } - return ret; - }); + window.localStorage.setItem( + `${baseKey}:score`, + score === null ? "" : score.toString(), + ); + }, + ); } } 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.started_at, + game.game_id, lastJsonMessage, readyState, - gameState, - playerA.user_id, - playerB.user_id, + gameStart, + getTargetAtomByPlayerId, + handleWsCodeMessage, + handleWsConnectionClosed, + handleWsExecResultMessage, + handleWsSubmitMessage, + handleWsSubmitResultMessage, + setGameStateConnecting, + setGameStateWaiting, ]); - if (gameState === "connecting") { + if (gameStateKind === "connecting") { return <GolfWatchAppConnecting />; - } else if (gameState === "waiting") { + } else if (gameStateKind === "waiting") { return ( <GolfWatchAppWaiting gameDisplayName={game.display_name} @@ -253,27 +183,14 @@ export default function GolfWatchApp({ playerProfileB={playerProfileB} /> ); - } else if (gameState === "starting") { - return ( - <GolfWatchAppStarting - gameDisplayName={game.display_name} - leftTimeSeconds={leftTimeSeconds!} - /> - ); - } else if (gameState === "gaming" || gameState === "finished") { + } else if (gameStateKind === "starting") { + return <GolfWatchAppStarting gameDisplayName={game.display_name} />; + } else if (gameStateKind === "gaming" || gameStateKind === "finished") { return ( <GolfWatchAppGaming gameDisplayName={game.display_name} - gameDurationSeconds={game.duration_seconds} - leftTimeSeconds={leftTimeSeconds!} - playerInfoA={{ - profile: playerProfileA, - state: playerStateA, - }} - playerInfoB={{ - profile: playerProfileB, - state: playerStateB, - }} + playerProfileA={playerProfileA} + playerProfileB={playerProfileB} problemTitle={game.problem.title} problemDescription={game.problem.description} gameResult={null /* TODO */} diff --git a/frontend/app/components/GolfWatchApps/GolfWatchAppGaming.tsx b/frontend/app/components/GolfWatchApps/GolfWatchAppGaming.tsx index a23a972..2907f5a 100644 --- a/frontend/app/components/GolfWatchApps/GolfWatchAppGaming.tsx +++ b/frontend/app/components/GolfWatchApps/GolfWatchAppGaming.tsx @@ -1,4 +1,14 @@ -import type { PlayerInfo } from "../../types/PlayerInfo"; +import { useAtomValue } from "jotai"; +import { + codeAAtom, + codeBAtom, + gamingLeftTimeSecondsAtom, + scoreAAtom, + scoreBAtom, + submitResultAAtom, + submitResultBAtom, +} from "../../states/watch"; +import type { PlayerProfile } from "../../types/PlayerProfile"; import BorderedContainer from "../BorderedContainer"; import CodeBlock from "../Gaming/CodeBlock"; import ScoreBar from "../Gaming/ScoreBar"; @@ -7,10 +17,8 @@ import UserIcon from "../UserIcon"; type Props = { gameDisplayName: string; - gameDurationSeconds: number; - leftTimeSeconds: number; - playerInfoA: PlayerInfo; - playerInfoB: PlayerInfo; + playerProfileA: PlayerProfile; + playerProfileB: PlayerProfile; problemTitle: string; problemDescription: string; gameResult: "winA" | "winB" | "draw" | null; @@ -18,21 +26,23 @@ type Props = { export default function GolfWatchAppGaming({ gameDisplayName, - gameDurationSeconds, - leftTimeSeconds, - playerInfoA, - playerInfoB, + playerProfileA, + playerProfileB, problemTitle, problemDescription, gameResult, }: Props) { + const leftTimeSeconds = useAtomValue(gamingLeftTimeSecondsAtom)!; + const codeA = useAtomValue(codeAAtom); + const codeB = useAtomValue(codeBAtom); + const scoreA = useAtomValue(scoreAAtom); + const scoreB = useAtomValue(scoreBAtom); + const submitResultA = useAtomValue(submitResultAAtom); + const submitResultB = useAtomValue(submitResultBAtom); + const leftTime = (() => { - const k = gameDurationSeconds + leftTimeSeconds; - if (k <= 0) { - return "00:00"; - } - 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")}`; })(); @@ -49,43 +59,43 @@ export default function GolfWatchAppGaming({ <div className={`text-white ${topBg} grid grid-cols-3 px-4 py-2`}> <div className="font-bold flex justify-between my-auto"> <div className="flex gap-6"> - {playerInfoA.profile.iconPath && ( + {playerProfileA.iconPath && ( <UserIcon - iconPath={playerInfoA.profile.iconPath} - displayName={playerInfoA.profile.displayName} + iconPath={playerProfileA.iconPath} + displayName={playerProfileA.displayName} className="w-12 h-12 my-auto" /> )} <div> <div className="text-gray-100">Player 1</div> - <div className="text-2xl">{playerInfoA.profile.displayName}</div> + <div className="text-2xl">{playerProfileA.displayName}</div> </div> </div> - <div className="text-6xl">{playerInfoA.state.score}</div> + <div className="text-6xl">{scoreA}</div> </div> <div className="font-bold text-center"> <div className="text-gray-100">{gameDisplayName}</div> <div className="text-3xl"> {gameResult ? gameResult === "winA" - ? `勝者 ${playerInfoA.profile.displayName}` + ? `勝者 ${playerProfileA.displayName}` : gameResult === "winB" - ? `勝者 ${playerInfoB.profile.displayName}` + ? `勝者 ${playerProfileB.displayName}` : "引き分け" : leftTime} </div> </div> <div className="font-bold flex justify-between my-auto"> - <div className="text-6xl">{playerInfoB.state.score}</div> + <div className="text-6xl">{scoreB}</div> <div className="flex gap-6 text-end"> <div> <div className="text-gray-100">Player 2</div> - <div className="text-2xl">{playerInfoB.profile.displayName}</div> + <div className="text-2xl">{playerProfileB.displayName}</div> </div> - {playerInfoB.profile.iconPath && ( + {playerProfileB.iconPath && ( <UserIcon - iconPath={playerInfoB.profile.iconPath} - displayName={playerInfoB.profile.displayName} + iconPath={playerProfileB.iconPath} + displayName={playerProfileB.displayName} className="w-12 h-12 my-auto" /> )} @@ -93,17 +103,17 @@ export default function GolfWatchAppGaming({ </div> </div> <ScoreBar - scoreA={playerInfoA.state.score} - scoreB={playerInfoB.state.score} + scoreA={scoreA} + scoreB={scoreB} bgA="bg-orange-400" bgB="bg-purple-400" /> <div className="grow grid grid-cols-3 p-4 gap-4"> - <CodeBlock code={playerInfoA.state.code ?? ""} language="swift" /> + <CodeBlock code={codeA} language="swift" /> <div className="flex flex-col gap-4"> <div className="grid grid-cols-2 gap-4"> - <SubmitResult result={playerInfoA.state.submitResult} /> - <SubmitResult result={playerInfoB.state.submitResult} /> + <SubmitResult result={submitResultA} /> + <SubmitResult result={submitResultB} /> </div> <div> <div className="mb-2 text-center text-xl font-bold"> @@ -112,7 +122,7 @@ export default function GolfWatchAppGaming({ <BorderedContainer>{problemDescription}</BorderedContainer> </div> </div> - <CodeBlock code={playerInfoB.state.code ?? ""} language="swift" /> + <CodeBlock code={codeB} language="swift" /> </div> </div> ); diff --git a/frontend/app/components/GolfWatchApps/GolfWatchAppStarting.tsx b/frontend/app/components/GolfWatchApps/GolfWatchAppStarting.tsx index cd4195d..684d2af 100644 --- a/frontend/app/components/GolfWatchApps/GolfWatchAppStarting.tsx +++ b/frontend/app/components/GolfWatchApps/GolfWatchAppStarting.tsx @@ -1,18 +1,19 @@ +import { useAtomValue } from "jotai"; +import { startingLeftTimeSecondsAtom } from "../../states/watch"; + type Props = { gameDisplayName: string; - leftTimeSeconds: number; }; -export default function GolfWatchAppStarting({ - gameDisplayName, - leftTimeSeconds, -}: Props) { +export default function GolfWatchAppStarting({ 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.watch.tsx b/frontend/app/routes/golf.$gameId.watch.tsx index 7e90b2d..f04f6b0 100644 --- a/frontend/app/routes/golf.$gameId.watch.tsx +++ b/frontend/app/routes/golf.$gameId.watch.tsx @@ -1,10 +1,21 @@ 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 GolfWatchAppWithAudioPlayRequest from "../components/GolfWatchAppWithAudioPlayRequest.client"; import GolfWatchAppConnecting from "../components/GolfWatchApps/GolfWatchAppConnecting"; +import { + codeAAtom, + codeBAtom, + scoreAAtom, + scoreBAtom, + setCurrentTimestampAtom, + setDurationSecondsAtom, + submitResultAAtom, + submitResultBAtom, +} from "../states/watch"; +import { PlayerState } from "../types/PlayerState"; export const meta: MetaFunction<typeof loader> = ({ data }) => [ { @@ -27,23 +38,150 @@ export async function loader({ params, request }: LoaderFunctionArgs) { const [game, sockToken] = await Promise.all([fetchGame(), fetchSockToken()]); if (game.game_type !== "1v1") { - return new Response("Not Found", { status: 404 }); + throw new Response("Not Found", { status: 404 }); } + const playerStateA: 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 playerStateB = structuredClone(playerStateA); + return { game, sockToken, + playerStateA, + playerStateB, }; } +export async function clientLoader({ serverLoader }: ClientLoaderFunctionArgs) { + const data = await serverLoader<typeof loader>(); + + const playerIdA = data.game.players[0]?.user_id; + const playerIdB = data.game.players[1]?.user_id; + + if (playerIdA !== null) { + const baseKeyA = `watcherState:${data.game.game_id}:${playerIdA}`; + + const localCodeA = (() => { + const rawValue = window.localStorage.getItem(`${baseKeyA}:code`); + + if (rawValue === null) { + return null; + } + return rawValue; + })(); + + const localScoreA = (() => { + const rawValue = window.localStorage.getItem(`${baseKeyA}:score`); + if (rawValue === null || rawValue === "") { + return null; + } + return Number(rawValue); + })(); + + const localSubmissionResultA = (() => { + const rawValue = window.localStorage.getItem( + `${baseKeyA}:submissionResult`, + ); + if (rawValue === null) { + return null; + } + const parsed = JSON.parse(rawValue); + if (typeof parsed !== "object") { + return null; + } + return parsed; + })(); + + if (localCodeA !== null) { + data.playerStateA.code = localCodeA; + } + if (localScoreA !== null) { + data.playerStateA.score = localScoreA; + } + if (localSubmissionResultA !== null) { + data.playerStateA.submitResult = localSubmissionResultA; + } + } + + if (playerIdB !== null) { + const baseKeyB = `watcherState:${data.game.game_id}:${playerIdB}`; + + const localCodeB = (() => { + const rawValue = window.localStorage.getItem(`${baseKeyB}:code`); + if (rawValue === null) { + return null; + } + return rawValue; + })(); + + const localScoreB = (() => { + const rawValue = window.localStorage.getItem(`${baseKeyB}:score`); + if (rawValue === null || rawValue === "") { + return null; + } + return Number(rawValue); + })(); + + const localSubmissionResultB = (() => { + const rawValue = window.localStorage.getItem( + `${baseKeyB}:submissionResult`, + ); + if (rawValue === null) { + return null; + } + const parsed = JSON.parse(rawValue); + if (typeof parsed !== "object") { + return null; + } + return parsed; + })(); + + if (localCodeB !== null) { + data.playerStateB.code = localCodeB; + } + if (localScoreB !== null) { + data.playerStateB.score = localScoreB; + } + if (localSubmissionResultB !== null) { + data.playerStateB.submitResult = localSubmissionResultB; + } + } + + return data; +} +clientLoader.hydrate = true; + +export function HydrateFallback() { + return <GolfWatchAppConnecting />; +} + export default function GolfWatch() { - const { game, sockToken } = useLoaderData<typeof loader>(); - - return ( - <ClientOnly fallback={<GolfWatchAppConnecting />}> - {() => ( - <GolfWatchAppWithAudioPlayRequest game={game} sockToken={sockToken} /> - )} - </ClientOnly> - ); + const { game, sockToken, playerStateA, playerStateB } = + useLoaderData<typeof loader>(); + + useHydrateAtoms([ + [setCurrentTimestampAtom, undefined], + [setDurationSecondsAtom, game.duration_seconds], + [codeAAtom, playerStateA.code], + [codeBAtom, playerStateB.code], + [scoreAAtom, playerStateA.score], + [scoreBAtom, playerStateB.score], + [submitResultAAtom, playerStateA.submitResult], + [submitResultBAtom, playerStateB.submitResult], + ]); + + return <GolfWatchAppWithAudioPlayRequest game={game} sockToken={sockToken} />; } diff --git a/frontend/app/states/watch.ts b/frontend/app/states/watch.ts index e69de29..3b80f2f 100644 --- a/frontend/app/states/watch.ts +++ b/frontend/app/states/watch.ts @@ -0,0 +1,247 @@ +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 codeAAtom = atom(""); +export const codeBAtom = atom(""); +export const scoreAAtom = atom<number | null>(null); +export const scoreBAtom = atom<number | null>(null); +export const submitResultAAtom = atom<SubmitResult>({ + status: "waiting_submission", + execResults: [], +}); +export const submitResultBAtom = atom<SubmitResult>({ + status: "waiting_submission", + execResults: [], +}); + +type GameWatcherMessageS2CSubmitPayload = + components["schemas"]["GameWatcherMessageS2CSubmitPayload"]; +type GameWatcherMessageS2CCodePayload = + components["schemas"]["GameWatcherMessageS2CCodePayload"]; +type GameWatcherMessageS2CExecResultPayload = + components["schemas"]["GameWatcherMessageS2CExecResultPayload"]; +type GameWatcherMessageS2CSubmitResultPayload = + components["schemas"]["GameWatcherMessageS2CSubmitResultPayload"]; + +export const handleWsCodeMessageAtom = atom( + null, + ( + _, + set, + data: GameWatcherMessageS2CCodePayload, + getTarget: <T>(player_id: number, atomA: T, atomB: T) => T, + callback: (player_id: number, code: string) => void, + ) => { + const { player_id, code } = data; + const codeAtom = getTarget(player_id, codeAAtom, codeBAtom); + set(codeAtom, code); + callback(player_id, code); + }, +); + +export const handleWsSubmitMessageAtom = atom( + null, + ( + get, + set, + data: GameWatcherMessageS2CSubmitPayload, + getTarget: <T>(player_id: number, atomA: T, atomB: T) => T, + callback: (player_id: number, submissionResult: SubmitResult) => void, + ) => { + const { player_id } = data; + const submitResultAtom = getTarget( + player_id, + submitResultAAtom, + submitResultBAtom, + ); + const prev = get(submitResultAtom); + const newResult = { + status: "running" as const, + execResults: prev.execResults.map((r) => ({ + ...r, + status: "running" as const, + stdout: "", + stderr: "", + })), + }; + set(submitResultAtom, newResult); + callback(player_id, newResult); + }, +); + +export const handleWsExecResultMessageAtom = atom( + null, + ( + get, + set, + data: GameWatcherMessageS2CExecResultPayload, + getTarget: <T>(player_id: number, atomA: T, atomB: T) => T, + callback: (player_id: number, submissionResult: SubmitResult) => void, + ) => { + const { player_id, testcase_id, status, stdout, stderr } = data; + const submitResultAtom = getTarget( + player_id, + submitResultAAtom, + submitResultBAtom, + ); + 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(player_id, newResult); + }, +); + +export const handleWsSubmitResultMessageAtom = atom( + null, + ( + get, + set, + data: GameWatcherMessageS2CSubmitResultPayload, + getTarget: <T>(player_id: number, atomA: T, atomB: T) => T, + callback: ( + player_id: number, + submissionResult: SubmitResult, + score: number | null, + ) => void, + ) => { + const { player_id, status, score } = data; + const submitResultAtom = getTarget( + player_id, + submitResultAAtom, + submitResultBAtom, + ); + const scoreAtom = getTarget(player_id, scoreAAtom, scoreBAtom); + 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(player_id, newResult, score); + }, +); |
