aboutsummaryrefslogtreecommitdiffhomepage
path: root/frontend
diff options
context:
space:
mode:
Diffstat (limited to 'frontend')
-rw-r--r--frontend/app/components/GolfWatchApp.client.tsx295
-rw-r--r--frontend/app/components/GolfWatchApps/GolfWatchAppGaming.tsx76
-rw-r--r--frontend/app/components/GolfWatchApps/GolfWatchAppStarting.tsx13
-rw-r--r--frontend/app/routes/golf.$gameId.watch.tsx162
-rw-r--r--frontend/app/states/watch.ts247
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);
+ },
+);