aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--frontend/app/.client/audio/AudioController.ts11
-rw-r--r--frontend/app/components/Gaming/ExecStatusIndicatorIcon.tsx2
-rw-r--r--frontend/app/components/Gaming/SubmitResult.tsx2
-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/components/GolfPlayApps/GolfPlayAppWaiting.tsx595
-rw-r--r--frontend/app/components/GolfWatchApp.client.tsx319
-rw-r--r--frontend/app/components/GolfWatchAppWithAudioPlayRequest.client.tsx38
-rw-r--r--frontend/app/components/GolfWatchApps/GolfWatchAppGaming.tsx76
-rw-r--r--frontend/app/components/GolfWatchApps/GolfWatchAppStarting.tsx13
-rw-r--r--frontend/app/components/GolfWatchApps/GolfWatchAppWaiting.tsx18
-rw-r--r--frontend/app/components/PlayerNameAndIcon.tsx25
-rw-r--r--frontend/app/components/PlayerProfile.tsx27
-rw-r--r--frontend/app/components/SubmitStatusLabel.tsx2
-rw-r--r--frontend/app/routes/golf.$gameId.play.tsx97
-rw-r--r--frontend/app/routes/golf.$gameId.watch.tsx162
-rw-r--r--frontend/app/states/play.ts185
-rw-r--r--frontend/app/states/watch.ts250
-rw-r--r--frontend/app/types/ExecResult.ts (renamed from frontend/app/models/ExecResult.ts)0
-rw-r--r--frontend/app/types/PlayerInfo.ts7
-rw-r--r--frontend/app/types/PlayerProfile.ts4
-rw-r--r--frontend/app/types/PlayerState.ts (renamed from frontend/app/models/PlayerInfo.ts)6
-rw-r--r--frontend/app/types/SubmitResult.ts (renamed from frontend/app/models/SubmitResult.ts)0
-rw-r--r--frontend/package-lock.json125
-rw-r--r--frontend/package.json4
27 files changed, 1112 insertions, 1159 deletions
diff --git a/frontend/app/.client/audio/AudioController.ts b/frontend/app/.client/audio/AudioController.ts
index 6ed6180..296f685 100644
--- a/frontend/app/.client/audio/AudioController.ts
+++ b/frontend/app/.client/audio/AudioController.ts
@@ -51,6 +51,17 @@ export class AudioController {
});
}
+ async playDummySoundEffect(): Promise<void> {
+ const audio = this.audioElements["good_1"];
+ if (!audio) {
+ return;
+ }
+ audio.muted = true;
+ audio.currentTime = 0;
+ await audio.play();
+ audio.muted = false;
+ }
+
async playSoundEffect(soundEffect: SoundEffect): Promise<void> {
const audio = this.audioElements[soundEffect];
if (!audio) {
diff --git a/frontend/app/components/Gaming/ExecStatusIndicatorIcon.tsx b/frontend/app/components/Gaming/ExecStatusIndicatorIcon.tsx
index b611c5d..a717a48 100644
--- a/frontend/app/components/Gaming/ExecStatusIndicatorIcon.tsx
+++ b/frontend/app/components/Gaming/ExecStatusIndicatorIcon.tsx
@@ -6,7 +6,7 @@ import {
faRotate,
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
-import type { ExecResultStatus } from "../../models/ExecResult";
+import type { ExecResultStatus } from "../../types/ExecResult";
type Props = {
status: ExecResultStatus;
diff --git a/frontend/app/components/Gaming/SubmitResult.tsx b/frontend/app/components/Gaming/SubmitResult.tsx
index 93e08a7..c626910 100644
--- a/frontend/app/components/Gaming/SubmitResult.tsx
+++ b/frontend/app/components/Gaming/SubmitResult.tsx
@@ -1,5 +1,5 @@
import React from "react";
-import type { SubmitResult } from "../../models/SubmitResult";
+import type { SubmitResult } from "../../types/SubmitResult";
import BorderedContainer from "../BorderedContainer";
import SubmitStatusLabel from "../SubmitStatusLabel";
import ExecStatusIndicatorIcon from "./ExecStatusIndicatorIcon";
diff --git a/frontend/app/components/GolfPlayApp.client.tsx b/frontend/app/components/GolfPlayApp.client.tsx
index 42f0250..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 { PlayerInfo } from "../models/PlayerInfo";
+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,78 +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 [playerInfo, setPlayerInfo] = useState<Omit<PlayerInfo, "code">>({
+ const playerProfile = {
displayName: player.display_name,
iconPath: player.icon_path ?? null,
- 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");
@@ -94,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) => {
@@ -105,18 +88,7 @@ export default function GolfPlayApp({
type: "player:c2s:submit",
data: { code },
});
- setPlayerInfo((prev) => ({
- ...prev,
- submitResult: {
- status: "running",
- execResults: prev.submitResult.execResults.map((r) => ({
- ...r,
- status: "running",
- stdout: "",
- stderr: "",
- })),
- },
- }));
+ handleSubmitCode();
}, 1000);
if (readyState === ReadyState.UNINSTANTIATED) {
@@ -125,133 +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;
- setPlayerInfo((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;
- setPlayerInfo((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),
);
- }
- 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.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}
- playerInfo={playerInfo}
- />
- );
- } else if (gameState === "starting") {
- return (
- <GolfPlayAppStarting
- gameDisplayName={game.display_name}
- leftTimeSeconds={leftTimeSeconds!}
+ playerProfile={playerProfile}
/>
);
- } 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={playerInfo}
+ 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 e6cb7e9..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 "../../models/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: Omit<PlayerInfo, "code">;
+ 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.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.displayName}</div>
+ <div className="text-2xl">{playerProfile.displayName}</div>
</div>
- {playerInfo.iconPath && (
+ {playerProfile.iconPath && (
<UserIcon
- iconPath={playerInfo.iconPath}
- displayName={playerInfo.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.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/components/GolfPlayApps/GolfPlayAppWaiting.tsx b/frontend/app/components/GolfPlayApps/GolfPlayAppWaiting.tsx
index bbef43e..706dc8f 100644
--- a/frontend/app/components/GolfPlayApps/GolfPlayAppWaiting.tsx
+++ b/frontend/app/components/GolfPlayApps/GolfPlayAppWaiting.tsx
@@ -1,598 +1,23 @@
-import { PlayerInfo } from "../../models/PlayerInfo";
-import PlayerProfile from "../PlayerProfile";
+import type { PlayerProfile } from "../../types/PlayerProfile";
+import PlayerNameAndIcon from "../PlayerNameAndIcon";
type Props = {
gameDisplayName: string;
- playerInfo: Omit<PlayerInfo, "code">;
+ playerProfile: PlayerProfile;
};
export default function GolfPlayAppWaiting({
gameDisplayName,
- playerInfo,
+ playerProfile,
}: Props) {
return (
- <>
- <div className="min-h-screen bg-gray-100 flex flex-col font-bold text-center">
- <div className="text-white bg-iosdc-japan p-10">
- <div className="text-4xl">{gameDisplayName}</div>
- </div>
- <div className="grow grid mx-auto text-black">
- <PlayerProfile playerInfo={playerInfo} label="You" />
- </div>
+ <div className="min-h-screen bg-gray-100 flex flex-col font-bold text-center">
+ <div className="text-white bg-iosdc-japan p-10">
+ <div className="text-4xl">{gameDisplayName}</div>
</div>
- <style>
- {`
- @keyframes changeHeight {
- 0% { height: 20%; }
- 50% { height: 100%; }
- 100% { height: 20%; }
- }
- `}
- </style>
- <div
- style={{
- position: "fixed",
- bottom: 0,
- width: "100%",
- display: "flex",
- justifyContent: "center",
- alignItems: "flex-end",
- height: "100px",
- margin: "0 2px",
- }}
- >
- <div
- style={{
- width: "2%",
- margin: "0 2px",
- background:
- "linear-gradient(345deg, rgb(230, 36, 136) 0%, rgb(240, 184, 106) 100%)",
- display: "inline-block",
- animation: "changeHeight 1s infinite ease-in-out",
- animationDelay: "2.0s",
- }}
- ></div>
- <div
- style={{
- width: "2%",
- margin: "0 2px",
- background:
- "linear-gradient(345deg, rgb(230, 36, 136) 0%, rgb(240, 184, 106) 100%)",
- display: "inline-block",
- animation: "changeHeight 1s infinite ease-in-out",
- animationDelay: "1.9s",
- }}
- ></div>
- <div
- style={{
- width: "2%",
- margin: "0 2px",
- background:
- "linear-gradient(345deg, rgb(230, 36, 136) 0%, rgb(240, 184, 106) 100%)",
- display: "inline-block",
- animation: "changeHeight 1s infinite ease-in-out",
- animationDelay: "1.8s",
- }}
- ></div>
- <div
- style={{
- width: "2%",
- margin: "0 2px",
- background:
- "linear-gradient(345deg, rgb(230, 36, 136) 0%, rgb(240, 184, 106) 100%)",
- display: "inline-block",
- animation: "changeHeight 1s infinite ease-in-out",
- animationDelay: "1.7s",
- }}
- ></div>
- <div
- style={{
- width: "2%",
- margin: "0 2px",
- background:
- "linear-gradient(345deg, rgb(230, 36, 136) 0%, rgb(240, 184, 106) 100%)",
- display: "inline-block",
- animation: "changeHeight 1s infinite ease-in-out",
- animationDelay: "1.6s",
- }}
- ></div>
- <div
- style={{
- width: "2%",
- margin: "0 2px",
- background:
- "linear-gradient(345deg, rgb(230, 36, 136) 0%, rgb(240, 184, 106) 100%)",
- display: "inline-block",
- animation: "changeHeight 1s infinite ease-in-out",
- animationDelay: "1.5s",
- }}
- ></div>
- <div
- style={{
- width: "2%",
- margin: "0 2px",
- background:
- "linear-gradient(345deg, rgb(230, 36, 136) 0%, rgb(240, 184, 106) 100%)",
- display: "inline-block",
- animation: "changeHeight 1s infinite ease-in-out",
- animationDelay: "1.4s",
- }}
- ></div>
- <div
- style={{
- width: "2%",
- margin: "0 2px",
- background:
- "linear-gradient(345deg, rgb(230, 36, 136) 0%, rgb(240, 184, 106) 100%)",
- display: "inline-block",
- animation: "changeHeight 1s infinite ease-in-out",
- animationDelay: "1.3s",
- }}
- ></div>
- <div
- style={{
- width: "2%",
- margin: "0 2px",
- background:
- "linear-gradient(345deg, rgb(230, 36, 136) 0%, rgb(240, 184, 106) 100%)",
- display: "inline-block",
- animation: "changeHeight 1s infinite ease-in-out",
- animationDelay: "1.2s",
- }}
- ></div>
- <div
- style={{
- width: "2%",
- margin: "0 2px",
- background:
- "linear-gradient(345deg, rgb(230, 36, 136) 0%, rgb(240, 184, 106) 100%)",
- display: "inline-block",
- animation: "changeHeight 1s infinite ease-in-out",
- animationDelay: "1.1s",
- }}
- ></div>
- <div
- style={{
- width: "2%",
- margin: "0 2px",
- background:
- "linear-gradient(345deg, rgb(230, 36, 136) 0%, rgb(240, 184, 106) 100%)",
- display: "inline-block",
- animation: "changeHeight 1s infinite ease-in-out",
- animationDelay: "1.0s",
- }}
- ></div>
- <div
- style={{
- width: "2%",
- margin: "0 2px",
- background:
- "linear-gradient(345deg, rgb(230, 36, 136) 0%, rgb(240, 184, 106) 100%)",
- display: "inline-block",
- animation: "changeHeight 1s infinite ease-in-out",
- animationDelay: "0.9s",
- }}
- ></div>
- <div
- style={{
- width: "2%",
- margin: "0 2px",
- background:
- "linear-gradient(345deg, rgb(230, 36, 136) 0%, rgb(240, 184, 106) 100%)",
- display: "inline-block",
- animation: "changeHeight 1s infinite ease-in-out",
- animationDelay: "0.8s",
- }}
- ></div>
- <div
- style={{
- width: "2%",
- margin: "0 2px",
- background:
- "linear-gradient(345deg, rgb(230, 36, 136) 0%, rgb(240, 184, 106) 100%)",
- display: "inline-block",
- animation: "changeHeight 1s infinite ease-in-out",
- animationDelay: "0.7s",
- }}
- ></div>
- <div
- style={{
- width: "2%",
- margin: "0 2px",
- background:
- "linear-gradient(345deg, rgb(230, 36, 136) 0%, rgb(240, 184, 106) 100%)",
- display: "inline-block",
- animation: "changeHeight 1s infinite ease-in-out",
- animationDelay: "0.6s",
- }}
- ></div>
- <div
- style={{
- width: "2%",
- margin: "0 2px",
- background:
- "linear-gradient(345deg, rgb(230, 36, 136) 0%, rgb(240, 184, 106) 100%)",
- display: "inline-block",
- animation: "changeHeight 1s infinite ease-in-out",
- animationDelay: "0.5s",
- }}
- ></div>
- <div
- style={{
- width: "2%",
- margin: "0 2px",
- background:
- "linear-gradient(345deg, rgb(230, 36, 136) 0%, rgb(240, 184, 106) 100%)",
- display: "inline-block",
- animation: "changeHeight 1s infinite ease-in-out",
- animationDelay: "0.4s",
- }}
- ></div>
- <div
- style={{
- width: "2%",
- margin: "0 2px",
- background:
- "linear-gradient(345deg, rgb(230, 36, 136) 0%, rgb(240, 184, 106) 100%)",
- display: "inline-block",
- animation: "changeHeight 1s infinite ease-in-out",
- animationDelay: "0.3s",
- }}
- ></div>
- <div
- style={{
- width: "2%",
- margin: "0 2px",
- background:
- "linear-gradient(345deg, rgb(230, 36, 136) 0%, rgb(240, 184, 106) 100%)",
- display: "inline-block",
- animation: "changeHeight 1s infinite ease-in-out",
- animationDelay: "0.2s",
- }}
- ></div>
- <div
- style={{
- width: "2%",
- margin: "0 2px",
- background:
- "linear-gradient(345deg, rgb(230, 36, 136) 0%, rgb(240, 184, 106) 100%)",
- display: "inline-block",
- animation: "changeHeight 1s infinite ease-in-out",
- animationDelay: "0.1s",
- }}
- ></div>
- <div
- style={{
- width: "2%",
- margin: "0 2px",
- background:
- "linear-gradient(345deg, rgb(230, 36, 136) 0%, rgb(240, 184, 106) 100%)",
- display: "inline-block",
- animation: "changeHeight 1s infinite ease-in-out",
- animationDelay: "0.5s",
- }}
- ></div>
- <div
- style={{
- width: "2%",
- margin: "0 2px",
- background:
- "linear-gradient(345deg, rgb(230, 36, 136) 0%, rgb(240, 184, 106) 100%)",
- display: "inline-block",
- animation: "changeHeight 1s infinite ease-in-out",
- animationDelay: "0.4s",
- }}
- ></div>
- <div
- style={{
- width: "2%",
- margin: "0 2px",
- background:
- "linear-gradient(345deg, rgb(230, 36, 136) 0%, rgb(240, 184, 106) 100%)",
- display: "inline-block",
- animation: "changeHeight 1s infinite ease-in-out",
- animationDelay: "0.3s",
- }}
- ></div>
- <div
- style={{
- width: "2%",
- margin: "0 2px",
- background:
- "linear-gradient(345deg, rgb(230, 36, 136) 0%, rgb(240, 184, 106) 100%)",
- display: "inline-block",
- animation: "changeHeight 1s infinite ease-in-out",
- animationDelay: "0.2s",
- }}
- ></div>
- <div
- style={{
- width: "2%",
- margin: "0 2px",
- background:
- "linear-gradient(345deg, rgb(230, 36, 136) 0%, rgb(240, 184, 106) 100%)",
- display: "inline-block",
- animation: "changeHeight 1s infinite ease-in-out",
- animationDelay: "0.1s",
- }}
- ></div>
-
- <div
- style={{
- width: "2%",
- margin: "0 2px",
- background:
- "linear-gradient(345deg, rgb(230, 36, 136) 0%, rgb(240, 184, 106) 100%)",
- display: "inline-block",
- animation: "changeHeight 1s infinite ease-in-out",
- animationDelay: "0.1s",
- }}
- ></div>
- <div
- style={{
- width: "2%",
- margin: "0 2px",
- background:
- "linear-gradient(345deg, rgb(230, 36, 136) 0%, rgb(240, 184, 106) 100%)",
- display: "inline-block",
- animation: "changeHeight 1s infinite ease-in-out",
- animationDelay: "0.2s",
- }}
- ></div>
- <div
- style={{
- width: "2%",
- margin: "0 2px",
- background:
- "linear-gradient(345deg, rgb(230, 36, 136) 0%, rgb(240, 184, 106) 100%)",
- display: "inline-block",
- animation: "changeHeight 1s infinite ease-in-out",
- animationDelay: "0.3s",
- }}
- ></div>
- <div
- style={{
- width: "2%",
- margin: "0 2px",
- background:
- "linear-gradient(345deg, rgb(230, 36, 136) 0%, rgb(240, 184, 106) 100%)",
- display: "inline-block",
- animation: "changeHeight 1s infinite ease-in-out",
- animationDelay: "0.4s",
- }}
- ></div>
- <div
- style={{
- width: "2%",
- margin: "0 2px",
- background:
- "linear-gradient(345deg, rgb(230, 36, 136) 0%, rgb(240, 184, 106) 100%)",
- display: "inline-block",
- animation: "changeHeight 1s infinite ease-in-out",
- animationDelay: "0.5s",
- }}
- ></div>
- <div
- style={{
- width: "2%",
- margin: "0 2px",
- background:
- "linear-gradient(345deg, rgb(230, 36, 136) 0%, rgb(240, 184, 106) 100%)",
- display: "inline-block",
- animation: "changeHeight 1s infinite ease-in-out",
- animationDelay: "0.1s",
- }}
- ></div>
- <div
- style={{
- width: "2%",
- margin: "0 2px",
- background:
- "linear-gradient(345deg, rgb(230, 36, 136) 0%, rgb(240, 184, 106) 100%)",
- display: "inline-block",
- animation: "changeHeight 1s infinite ease-in-out",
- animationDelay: "0.2s",
- }}
- ></div>
- <div
- style={{
- width: "2%",
- margin: "0 2px",
- background:
- "linear-gradient(345deg, rgb(230, 36, 136) 0%, rgb(240, 184, 106) 100%)",
- display: "inline-block",
- animation: "changeHeight 1s infinite ease-in-out",
- animationDelay: "0.3s",
- }}
- ></div>
- <div
- style={{
- width: "2%",
- margin: "0 2px",
- background:
- "linear-gradient(345deg, rgb(230, 36, 136) 0%, rgb(240, 184, 106) 100%)",
- display: "inline-block",
- animation: "changeHeight 1s infinite ease-in-out",
- animationDelay: "0.4s",
- }}
- ></div>
- <div
- style={{
- width: "2%",
- margin: "0 2px",
- background:
- "linear-gradient(345deg, rgb(230, 36, 136) 0%, rgb(240, 184, 106) 100%)",
- display: "inline-block",
- animation: "changeHeight 1s infinite ease-in-out",
- animationDelay: "0.5s",
- }}
- ></div>
- <div
- style={{
- width: "2%",
- margin: "0 2px",
- background:
- "linear-gradient(345deg, rgb(230, 36, 136) 0%, rgb(240, 184, 106) 100%)",
- display: "inline-block",
- animation: "changeHeight 1s infinite ease-in-out",
- animationDelay: "0.6s",
- }}
- ></div>
- <div
- style={{
- width: "2%",
- margin: "0 2px",
- background:
- "linear-gradient(345deg, rgb(230, 36, 136) 0%, rgb(240, 184, 106) 100%)",
- display: "inline-block",
- animation: "changeHeight 1s infinite ease-in-out",
- animationDelay: "0.7s",
- }}
- ></div>
- <div
- style={{
- width: "2%",
- margin: "0 2px",
- background:
- "linear-gradient(345deg, rgb(230, 36, 136) 0%, rgb(240, 184, 106) 100%)",
- display: "inline-block",
- animation: "changeHeight 1s infinite ease-in-out",
- animationDelay: "0.8s",
- }}
- ></div>
- <div
- style={{
- width: "2%",
- margin: "0 2px",
- background:
- "linear-gradient(345deg, rgb(230, 36, 136) 0%, rgb(240, 184, 106) 100%)",
- display: "inline-block",
- animation: "changeHeight 1s infinite ease-in-out",
- animationDelay: "0.9s",
- }}
- ></div>
- <div
- style={{
- width: "2%",
- margin: "0 2px",
- background:
- "linear-gradient(345deg, rgb(230, 36, 136) 0%, rgb(240, 184, 106) 100%)",
- display: "inline-block",
- animation: "changeHeight 1s infinite ease-in-out",
- animationDelay: "1.0s",
- }}
- ></div>
- <div
- style={{
- width: "2%",
- margin: "0 2px",
- background:
- "linear-gradient(345deg, rgb(230, 36, 136) 0%, rgb(240, 184, 106) 100%)",
- display: "inline-block",
- animation: "changeHeight 1s infinite ease-in-out",
- animationDelay: "1.1s",
- }}
- ></div>
- <div
- style={{
- width: "2%",
- margin: "0 2px",
- background:
- "linear-gradient(345deg, rgb(230, 36, 136) 0%, rgb(240, 184, 106) 100%)",
- display: "inline-block",
- animation: "changeHeight 1s infinite ease-in-out",
- animationDelay: "1.2s",
- }}
- ></div>
- <div
- style={{
- width: "2%",
- margin: "0 2px",
- background:
- "linear-gradient(345deg, rgb(230, 36, 136) 0%, rgb(240, 184, 106) 100%)",
- display: "inline-block",
- animation: "changeHeight 1s infinite ease-in-out",
- animationDelay: "1.3s",
- }}
- ></div>
- <div
- style={{
- width: "2%",
- margin: "0 2px",
- background:
- "linear-gradient(345deg, rgb(230, 36, 136) 0%, rgb(240, 184, 106) 100%)",
- display: "inline-block",
- animation: "changeHeight 1s infinite ease-in-out",
- animationDelay: "1.4s",
- }}
- ></div>
- <div
- style={{
- width: "2%",
- margin: "0 2px",
- background:
- "linear-gradient(345deg, rgb(230, 36, 136) 0%, rgb(240, 184, 106) 100%)",
- display: "inline-block",
- animation: "changeHeight 1s infinite ease-in-out",
- animationDelay: "1.5s",
- }}
- ></div>
- <div
- style={{
- width: "2%",
- margin: "0 2px",
- background:
- "linear-gradient(345deg, rgb(230, 36, 136) 0%, rgb(240, 184, 106) 100%)",
- display: "inline-block",
- animation: "changeHeight 1s infinite ease-in-out",
- animationDelay: "1.6s",
- }}
- ></div>
- <div
- style={{
- width: "2%",
- margin: "0 2px",
- background:
- "linear-gradient(345deg, rgb(230, 36, 136) 0%, rgb(240, 184, 106) 100%)",
- display: "inline-block",
- animation: "changeHeight 1s infinite ease-in-out",
- animationDelay: "1.7s",
- }}
- ></div>
- <div
- style={{
- width: "2%",
- margin: "0 2px",
- background:
- "linear-gradient(345deg, rgb(230, 36, 136) 0%, rgb(240, 184, 106) 100%)",
- display: "inline-block",
- animation: "changeHeight 1s infinite ease-in-out",
- animationDelay: "1.8s",
- }}
- ></div>
- <div
- style={{
- width: "2%",
- margin: "0 2px",
- background:
- "linear-gradient(345deg, rgb(230, 36, 136) 0%, rgb(240, 184, 106) 100%)",
- display: "inline-block",
- animation: "changeHeight 1s infinite ease-in-out",
- animationDelay: "1.9s",
- }}
- ></div>
- <div
- style={{
- width: "2%",
- margin: "0 2px",
- background:
- "linear-gradient(345deg, rgb(230, 36, 136) 0%, rgb(240, 184, 106) 100%)",
- display: "inline-block",
- animation: "changeHeight 1s infinite ease-in-out",
- animationDelay: "2.0s",
- }}
- ></div>
+ <div className="grow grid mx-auto text-black">
+ <PlayerNameAndIcon label="You" profile={playerProfile} />
</div>
- </>
+ </div>
);
}
diff --git a/frontend/app/components/GolfWatchApp.client.tsx b/frontend/app/components/GolfWatchApp.client.tsx
index d09a4ae..c8b1d53 100644
--- a/frontend/app/components/GolfWatchApp.client.tsx
+++ b/frontend/app/components/GolfWatchApp.client.tsx
@@ -1,8 +1,20 @@
-import { useEffect, useState } from "react";
-import { AudioController } from "../.client/audio/AudioController";
+import { useAtomValue, useSetAtom } from "jotai";
+import { useCallback, useEffect } from "react";
+import { useTimer } from "react-use-precision-timer";
import type { components } from "../.server/api/schema";
import useWebSocket, { ReadyState } from "../hooks/useWebSocket";
-import type { PlayerInfo } from "../models/PlayerInfo";
+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,101 +25,58 @@ type GameWatcherMessageC2S = never;
type Game = components["schemas"]["Game"];
-type GameState = "connecting" | "waiting" | "starting" | "gaming" | "finished";
-
export type Props = {
game: Game;
sockToken: string;
- audioController: AudioController;
};
-export default function GolfWatchApp({
- game,
- sockToken,
- audioController,
-}: Props) {
+export default function GolfWatchApp({ game, sockToken }: Props) {
const socketUrl =
process.env.NODE_ENV === "development"
? `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 [playerInfoA, setPlayerInfoA] = useState<PlayerInfo>({
- displayName: playerA?.display_name ?? null,
- iconPath: playerA?.icon_path ?? null,
- 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 [playerInfoB, setPlayerInfoB] = useState<PlayerInfo>({
- displayName: playerB?.display_name ?? null,
- iconPath: playerB?.icon_path ?? null,
- 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 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 playerProfileB = {
+ displayName: playerB.display_name,
+ iconPath: playerB.icon_path ?? null,
+ };
if (readyState === ReadyState.UNINSTANTIATED) {
throw new Error("WebSocket is not connected");
@@ -115,155 +84,107 @@ 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 ? setPlayerInfoA : setPlayerInfoB;
- 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 ? setPlayerInfoA : setPlayerInfoB;
- 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 ? setPlayerInfoA : setPlayerInfoB;
- 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 ? setPlayerInfoA : setPlayerInfoB;
- 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),
+ );
+ 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.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}
- playerInfoA={playerInfoA}
- playerInfoB={playerInfoB}
- />
- );
- } else if (gameState === "starting") {
- return (
- <GolfWatchAppStarting
- gameDisplayName={game.display_name}
- leftTimeSeconds={leftTimeSeconds!}
+ playerProfileA={playerProfileA}
+ playerProfileB={playerProfileB}
/>
);
- } 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={playerInfoA}
- playerInfoB={playerInfoB}
+ playerProfileA={playerProfileA}
+ playerProfileB={playerProfileB}
problemTitle={game.problem.title}
problemDescription={game.problem.description}
gameResult={null /* TODO */}
diff --git a/frontend/app/components/GolfWatchAppWithAudioPlayRequest.client.tsx b/frontend/app/components/GolfWatchAppWithAudioPlayRequest.client.tsx
index e299f4b..ce5a59c 100644
--- a/frontend/app/components/GolfWatchAppWithAudioPlayRequest.client.tsx
+++ b/frontend/app/components/GolfWatchAppWithAudioPlayRequest.client.tsx
@@ -1,35 +1,33 @@
-import { useState } from "react";
+import { useAtom } from "jotai";
import { AudioController } from "../.client/audio/AudioController";
+import { audioControllerAtom } from "../states/watch";
import GolfWatchApp, { type Props } from "./GolfWatchApp.client";
+import SubmitButton from "./SubmitButton";
export default function GolfWatchAppWithAudioPlayRequest({
game,
sockToken,
}: Omit<Props, "audioController">) {
- const [audioController, setAudioController] =
- useState<AudioController | null>(null);
+ const [audioController, setAudioController] = useAtom(audioControllerAtom);
const audioPlayPermitted = audioController !== null;
if (audioPlayPermitted) {
- return (
- <GolfWatchApp
- game={game}
- sockToken={sockToken}
- audioController={audioController}
- />
- );
+ return <GolfWatchApp game={game} sockToken={sockToken} />;
} else {
return (
- <div>
- <button
- onClick={async () => {
- const audioController = new AudioController();
- await audioController.loadAll();
- setAudioController(audioController);
- }}
- >
- Enable Audio Play
- </button>
+ <div className="min-h-screen bg-gray-100 flex items-center justify-center">
+ <div className="text-center">
+ <SubmitButton
+ onClick={async () => {
+ const audioController = new AudioController();
+ await audioController.loadAll();
+ await audioController.playDummySoundEffect();
+ setAudioController(audioController);
+ }}
+ >
+ 開始
+ </SubmitButton>
+ </div>
</div>
);
}
diff --git a/frontend/app/components/GolfWatchApps/GolfWatchAppGaming.tsx b/frontend/app/components/GolfWatchApps/GolfWatchAppGaming.tsx
index 28babff..2907f5a 100644
--- a/frontend/app/components/GolfWatchApps/GolfWatchAppGaming.tsx
+++ b/frontend/app/components/GolfWatchApps/GolfWatchAppGaming.tsx
@@ -1,4 +1,14 @@
-import { PlayerInfo } from "../../models/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.iconPath && (
+ {playerProfileA.iconPath && (
<UserIcon
- iconPath={playerInfoA.iconPath}
- displayName={playerInfoA.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.displayName}</div>
+ <div className="text-2xl">{playerProfileA.displayName}</div>
</div>
</div>
- <div className="text-6xl">{playerInfoA.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.displayName}`
+ ? `勝者 ${playerProfileA.displayName}`
: gameResult === "winB"
- ? `勝者 ${playerInfoB.displayName}`
+ ? `勝者 ${playerProfileB.displayName}`
: "引き分け"
: leftTime}
</div>
</div>
<div className="font-bold flex justify-between my-auto">
- <div className="text-6xl">{playerInfoB.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.displayName}</div>
+ <div className="text-2xl">{playerProfileB.displayName}</div>
</div>
- {playerInfoB.iconPath && (
+ {playerProfileB.iconPath && (
<UserIcon
- iconPath={playerInfoB.iconPath}
- displayName={playerInfoB.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.score}
- scoreB={playerInfoB.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.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.submitResult} />
- <SubmitResult result={playerInfoB.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.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/components/GolfWatchApps/GolfWatchAppWaiting.tsx b/frontend/app/components/GolfWatchApps/GolfWatchAppWaiting.tsx
index faa9485..0e964e3 100644
--- a/frontend/app/components/GolfWatchApps/GolfWatchAppWaiting.tsx
+++ b/frontend/app/components/GolfWatchApps/GolfWatchAppWaiting.tsx
@@ -1,18 +1,16 @@
-import { PlayerInfo as FullPlayerInfo } from "../../models/PlayerInfo";
-import PlayerProfile from "../PlayerProfile";
-
-type PlayerInfo = Pick<FullPlayerInfo, "displayName" | "iconPath">;
+import type { PlayerProfile } from "../../types/PlayerProfile";
+import PlayerNameAndIcon from "../PlayerNameAndIcon";
type Props = {
gameDisplayName: string;
- playerInfoA: PlayerInfo;
- playerInfoB: PlayerInfo;
+ playerProfileA: PlayerProfile;
+ playerProfileB: PlayerProfile;
};
export default function GolfWatchAppWaiting({
gameDisplayName,
- playerInfoA,
- playerInfoB,
+ playerProfileA,
+ playerProfileB,
}: Props) {
return (
<div className="min-h-screen bg-gray-100 flex flex-col font-bold text-center">
@@ -20,9 +18,9 @@ export default function GolfWatchAppWaiting({
<div className="text-4xl">{gameDisplayName}</div>
</div>
<div className="grow grid grid-cols-3 gap-10 mx-auto text-black">
- <PlayerProfile playerInfo={playerInfoA} label="Player 1" />
+ <PlayerNameAndIcon label="Player 1" profile={playerProfileA} />
<div className="text-8xl my-auto">vs.</div>
- <PlayerProfile playerInfo={playerInfoB} label="Player 2" />
+ <PlayerNameAndIcon label="Player 2" profile={playerProfileB} />
</div>
</div>
);
diff --git a/frontend/app/components/PlayerNameAndIcon.tsx b/frontend/app/components/PlayerNameAndIcon.tsx
new file mode 100644
index 0000000..e9536e3
--- /dev/null
+++ b/frontend/app/components/PlayerNameAndIcon.tsx
@@ -0,0 +1,25 @@
+import { PlayerProfile } from "../types/PlayerProfile";
+import UserIcon from "./UserIcon";
+
+type Props = {
+ label: string;
+ profile: PlayerProfile;
+};
+
+export default function PlayerNameAndIcon({ label, profile }: Props) {
+ return (
+ <div className="flex flex-col gap-6 my-auto">
+ <div className="flex flex-col gap-2">
+ <div className="text-4xl">{label}</div>
+ <div className="text-6xl">{profile.displayName}</div>
+ </div>
+ {profile.iconPath && (
+ <UserIcon
+ iconPath={profile.iconPath}
+ displayName={profile.displayName}
+ className="w-48 h-48"
+ />
+ )}
+ </div>
+ );
+}
diff --git a/frontend/app/components/PlayerProfile.tsx b/frontend/app/components/PlayerProfile.tsx
deleted file mode 100644
index 675d77b..0000000
--- a/frontend/app/components/PlayerProfile.tsx
+++ /dev/null
@@ -1,27 +0,0 @@
-import { PlayerInfo as FullPlayerInfo } from "../models/PlayerInfo";
-import UserIcon from "./UserIcon";
-
-type PlayerInfo = Pick<FullPlayerInfo, "displayName" | "iconPath">;
-
-type Props = {
- playerInfo: PlayerInfo;
- label: string;
-};
-
-export default function PlayerProfile({ playerInfo, label }: Props) {
- return (
- <div className="flex flex-col gap-6 my-auto">
- <div className="flex flex-col gap-2">
- <div className="text-4xl">{label}</div>
- <div className="text-6xl">{playerInfo.displayName}</div>
- </div>
- {playerInfo.iconPath && (
- <UserIcon
- iconPath={playerInfo.iconPath}
- displayName={playerInfo.displayName!}
- className="w-48 h-48"
- />
- )}
- </div>
- );
-}
diff --git a/frontend/app/components/SubmitStatusLabel.tsx b/frontend/app/components/SubmitStatusLabel.tsx
index 0e13c1e..d1dc89c 100644
--- a/frontend/app/components/SubmitStatusLabel.tsx
+++ b/frontend/app/components/SubmitStatusLabel.tsx
@@ -1,4 +1,4 @@
-import type { SubmitResultStatus } from "../models/SubmitResult";
+import type { SubmitResultStatus } from "../types/SubmitResult";
type Props = {
status: SubmitResultStatus;
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/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/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..ba3dd2a
--- /dev/null
+++ b/frontend/app/states/watch.ts
@@ -0,0 +1,250 @@
+import { atom } from "jotai";
+import { AudioController } from "../.client/audio/AudioController";
+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);
+ },
+);
+
+export const audioControllerAtom = atom<AudioController | null>(null);
diff --git a/frontend/app/models/ExecResult.ts b/frontend/app/types/ExecResult.ts
index e0b6bb4..e0b6bb4 100644
--- a/frontend/app/models/ExecResult.ts
+++ b/frontend/app/types/ExecResult.ts
diff --git a/frontend/app/types/PlayerInfo.ts b/frontend/app/types/PlayerInfo.ts
new file mode 100644
index 0000000..e282ba9
--- /dev/null
+++ b/frontend/app/types/PlayerInfo.ts
@@ -0,0 +1,7 @@
+import type { PlayerProfile } from "./PlayerProfile";
+import type { PlayerState } from "./PlayerState";
+
+export type PlayerInfo = {
+ profile: PlayerProfile;
+ state: PlayerState;
+};
diff --git a/frontend/app/types/PlayerProfile.ts b/frontend/app/types/PlayerProfile.ts
new file mode 100644
index 0000000..42bdcb8
--- /dev/null
+++ b/frontend/app/types/PlayerProfile.ts
@@ -0,0 +1,4 @@
+export type PlayerProfile = {
+ displayName: string;
+ iconPath: string | null;
+};
diff --git a/frontend/app/models/PlayerInfo.ts b/frontend/app/types/PlayerState.ts
index 8092ab3..e2a2da9 100644
--- a/frontend/app/models/PlayerInfo.ts
+++ b/frontend/app/types/PlayerState.ts
@@ -1,9 +1,7 @@
import type { SubmitResult } from "./SubmitResult";
-export type PlayerInfo = {
- displayName: string | null;
- iconPath: string | null;
+export type PlayerState = {
score: number | null;
- code: string | null;
+ code: string;
submitResult: SubmitResult;
};
diff --git a/frontend/app/models/SubmitResult.ts b/frontend/app/types/SubmitResult.ts
index 6df00b6..6df00b6 100644
--- a/frontend/app/models/SubmitResult.ts
+++ b/frontend/app/types/SubmitResult.ts
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index ad96c48..6ba0394 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -14,15 +14,17 @@
"@remix-run/serve": "^2.10.3",
"cookie": "^0.6.0",
"isbot": "^5.1.13",
+ "jotai": "^2.9.3",
+ "jotai-effect": "^1.0.0",
"jwt-decode": "^4.0.0",
"openapi-fetch": "^0.10.2",
"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",
- "remix-utils": "^7.6.0",
"use-debounce": "^10.0.1"
},
"devDependencies": {
@@ -2300,13 +2302,13 @@
"version": "15.7.12",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.12.tgz",
"integrity": "sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==",
- "dev": true
+ "devOptional": true
},
"node_modules/@types/react": {
"version": "18.3.3",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.3.tgz",
"integrity": "sha512-hti/R0pS0q1/xx+TsI73XIqk26eBsISZ2R0wUijXIngRK9R/e7Xw/cXVxQK7R5JjW+SV4zGcn5hXjudkN/pLIw==",
- "dev": true,
+ "devOptional": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.0.2"
@@ -3682,7 +3684,7 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
- "dev": true
+ "devOptional": true
},
"node_modules/damerau-levenshtein": {
"version": "1.0.8",
@@ -6503,6 +6505,34 @@
"jiti": "bin/jiti.js"
}
},
+ "node_modules/jotai": {
+ "version": "2.9.3",
+ "resolved": "https://registry.npmjs.org/jotai/-/jotai-2.9.3.tgz",
+ "integrity": "sha512-IqMWKoXuEzWSShjd9UhalNsRGbdju5G2FrqNLQJT+Ih6p41VNYe2sav5hnwQx4HJr25jq9wRqvGSWGviGG6Gjw==",
+ "engines": {
+ "node": ">=12.20.0"
+ },
+ "peerDependencies": {
+ "@types/react": ">=17.0.0",
+ "react": ">=17.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/jotai-effect": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/jotai-effect/-/jotai-effect-1.0.0.tgz",
+ "integrity": "sha512-eCgKKG4BACDzuJGYTu0xZRk1C1MEOvbAhC3L8w7YufQ2lSLORwNX/WFnCuZxLFX0sDLkTUeoUzOYaw8wnXY+UQ==",
+ "peerDependencies": {
+ "jotai": ">=2.5.0"
+ }
+ },
"node_modules/js-levenshtein": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz",
@@ -9202,6 +9232,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",
@@ -9397,72 +9448,6 @@
"remix-auth": "^3.6.0"
}
},
- "node_modules/remix-utils": {
- "version": "7.6.0",
- "resolved": "https://registry.npmjs.org/remix-utils/-/remix-utils-7.6.0.tgz",
- "integrity": "sha512-BPhCUEy+nwrhDDDg2v3+LFSszV6tluMbeSkbffj2o4tqZxt5Kn69Y9sNpGxYLAj8gjqeYDuxjv55of+gYnnykA==",
- "dependencies": {
- "type-fest": "^4.3.3"
- },
- "engines": {
- "node": ">=18.0.0"
- },
- "peerDependencies": {
- "@remix-run/cloudflare": "^2.0.0",
- "@remix-run/deno": "^2.0.0",
- "@remix-run/node": "^2.0.0",
- "@remix-run/react": "^2.0.0",
- "@remix-run/router": "^1.7.2",
- "crypto-js": "^4.1.1",
- "intl-parse-accept-language": "^1.0.0",
- "is-ip": "^5.0.1",
- "react": "^18.0.0",
- "zod": "^3.22.4"
- },
- "peerDependenciesMeta": {
- "@remix-run/cloudflare": {
- "optional": true
- },
- "@remix-run/deno": {
- "optional": true
- },
- "@remix-run/node": {
- "optional": true
- },
- "@remix-run/react": {
- "optional": true
- },
- "@remix-run/router": {
- "optional": true
- },
- "crypto-js": {
- "optional": true
- },
- "intl-parse-accept-language": {
- "optional": true
- },
- "is-ip": {
- "optional": true
- },
- "react": {
- "optional": true
- },
- "zod": {
- "optional": true
- }
- }
- },
- "node_modules/remix-utils/node_modules/type-fest": {
- "version": "4.23.0",
- "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.23.0.tgz",
- "integrity": "sha512-ZiBujro2ohr5+Z/hZWHESLz3g08BBdrdLMieYFULJO+tWc437sn8kQsWLJoZErY8alNhxre9K4p3GURAG11n+w==",
- "engines": {
- "node": ">=16"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 5022494..ee06aea 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -22,15 +22,17 @@
"@remix-run/serve": "^2.10.3",
"cookie": "^0.6.0",
"isbot": "^5.1.13",
+ "jotai": "^2.9.3",
+ "jotai-effect": "^1.0.0",
"jwt-decode": "^4.0.0",
"openapi-fetch": "^0.10.2",
"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",
- "remix-utils": "^7.6.0",
"use-debounce": "^10.0.1"
},
"devDependencies": {