aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2024-08-22 03:57:05 +0900
committernsfisis <nsfisis@gmail.com>2024-08-22 12:02:01 +0900
commita2b6ed9cd67f1406a6656bce9b3d51b55378ac1e (patch)
treec3f5f686b1043c3704acbcb41eb35c0af6de81b2
parent922bc6a1f52d8f01600e9a61ce31963075ec59a5 (diff)
downloadphperkaigi-2025-albatross-a2b6ed9cd67f1406a6656bce9b3d51b55378ac1e.tar.gz
phperkaigi-2025-albatross-a2b6ed9cd67f1406a6656bce9b3d51b55378ac1e.tar.zst
phperkaigi-2025-albatross-a2b6ed9cd67f1406a6656bce9b3d51b55378ac1e.zip
feat(frontend): jotai for play app
-rw-r--r--frontend/app/components/GolfPlayApp.client.tsx246
-rw-r--r--frontend/app/components/GolfPlayApps/GolfPlayAppFinished.tsx2
-rw-r--r--frontend/app/components/GolfPlayApps/GolfPlayAppGaming.tsx42
-rw-r--r--frontend/app/components/GolfPlayApps/GolfPlayAppStarting.tsx13
-rw-r--r--frontend/app/routes/golf.$gameId.play.tsx97
-rw-r--r--frontend/app/states/play.ts185
-rw-r--r--frontend/app/states/watch.ts0
-rw-r--r--frontend/package-lock.json22
-rw-r--r--frontend/package.json1
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",