From a2b6ed9cd67f1406a6656bce9b3d51b55378ac1e Mon Sep 17 00:00:00 2001
From: nsfisis <nsfisis@gmail.com>
Date: Thu, 22 Aug 2024 03:57:05 +0900
Subject: feat(frontend): jotai for play app

---
 frontend/app/components/GolfPlayApp.client.tsx | 246 +++++++++----------------
 1 file changed, 84 insertions(+), 162 deletions(-)

(limited to 'frontend/app/components/GolfPlayApp.client.tsx')

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;
-- 
cgit v1.3.1

