aboutsummaryrefslogtreecommitdiffhomepage
path: root/frontend/app
diff options
context:
space:
mode:
Diffstat (limited to 'frontend/app')
-rw-r--r--frontend/app/.server/api/client.ts51
-rw-r--r--frontend/app/.server/api/schema.d.ts858
-rw-r--r--frontend/app/.server/auth.ts4
-rw-r--r--frontend/app/api/client.ts120
-rw-r--r--frontend/app/api/schema.d.ts499
-rw-r--r--frontend/app/components/Gaming/CodeBlock.tsx31
-rw-r--r--frontend/app/components/Gaming/ExecStatusIndicatorIcon.tsx11
-rw-r--r--frontend/app/components/Gaming/SubmitResult.tsx34
-rw-r--r--frontend/app/components/GolfPlayApp.client.tsx188
-rw-r--r--frontend/app/components/GolfPlayApp.tsx141
-rw-r--r--frontend/app/components/GolfPlayApps/GolfPlayAppConnecting.tsx9
-rw-r--r--frontend/app/components/GolfPlayApps/GolfPlayAppGaming.tsx17
-rw-r--r--frontend/app/components/GolfWatchApp.client.tsx197
-rw-r--r--frontend/app/components/GolfWatchApp.tsx127
-rw-r--r--frontend/app/components/GolfWatchApps/GolfWatchAppConnecting.tsx9
-rw-r--r--frontend/app/components/GolfWatchApps/GolfWatchAppGaming.tsx31
-rw-r--r--frontend/app/components/SubmitStatusLabel.tsx8
-rw-r--r--frontend/app/hooks/useWebSocket.ts14
-rw-r--r--frontend/app/routes/dashboard.tsx19
-rw-r--r--frontend/app/routes/golf.$gameId.play.tsx110
-rw-r--r--frontend/app/routes/golf.$gameId.watch.tsx185
-rw-r--r--frontend/app/states/play.ts210
-rw-r--r--frontend/app/states/watch.ts265
-rw-r--r--frontend/app/types/ExecResult.ts18
-rw-r--r--frontend/app/types/PlayerInfo.ts7
-rw-r--r--frontend/app/types/PlayerProfile.ts1
-rw-r--r--frontend/app/types/PlayerState.ts7
-rw-r--r--frontend/app/types/SubmitResult.ts16
28 files changed, 1565 insertions, 1622 deletions
diff --git a/frontend/app/.server/api/client.ts b/frontend/app/.server/api/client.ts
deleted file mode 100644
index fef1508..0000000
--- a/frontend/app/.server/api/client.ts
+++ /dev/null
@@ -1,51 +0,0 @@
-import createClient from "openapi-fetch";
-import type { paths } from "./schema";
-
-const apiClient = createClient<paths>({
- baseUrl:
- process.env.NODE_ENV === "development"
- ? "http://localhost:8003/phperkaigi/2025/code-battle/api/"
- : "http://api-server/phperkaigi/2025/code-battle/api/",
-});
-
-export async function apiPostLogin(username: string, password: string) {
- const { data, error } = await apiClient.POST("/login", {
- body: {
- username,
- password,
- },
- });
- if (error) throw new Error(error.message);
- return data;
-}
-
-export async function apiGetGames(token: string) {
- const { data, error } = await apiClient.GET("/games", {
- params: {
- header: { Authorization: `Bearer ${token}` },
- },
- });
- if (error) throw new Error(error.message);
- return data;
-}
-
-export async function apiGetGame(token: string, gameId: number) {
- const { data, error } = await apiClient.GET("/games/{game_id}", {
- params: {
- header: { Authorization: `Bearer ${token}` },
- path: { game_id: gameId },
- },
- });
- if (error) throw new Error(error.message);
- return data;
-}
-
-export async function apiGetToken(token: string) {
- const { data, error } = await apiClient.GET("/token", {
- params: {
- header: { Authorization: `Bearer ${token}` },
- },
- });
- if (error) throw new Error(error.message);
- return data;
-}
diff --git a/frontend/app/.server/api/schema.d.ts b/frontend/app/.server/api/schema.d.ts
index 1afed69..0736e43 100644
--- a/frontend/app/.server/api/schema.d.ts
+++ b/frontend/app/.server/api/schema.d.ts
@@ -4,421 +4,457 @@
*/
export interface paths {
- "/login": {
- parameters: {
- query?: never;
- header?: never;
- path?: never;
- cookie?: never;
- };
- get?: never;
- put?: never;
- /** User login */
- post: operations["postLogin"];
- delete?: never;
- options?: never;
- head?: never;
- patch?: never;
- trace?: never;
- };
- "/token": {
- parameters: {
- query?: never;
- header?: never;
- path?: never;
- cookie?: never;
- };
- /** Get a short-lived access token */
- get: operations["getToken"];
- put?: never;
- post?: never;
- delete?: never;
- options?: never;
- head?: never;
- patch?: never;
- trace?: never;
- };
- "/games": {
- parameters: {
- query?: never;
- header?: never;
- path?: never;
- cookie?: never;
- };
- /** List games */
- get: operations["getGames"];
- put?: never;
- post?: never;
- delete?: never;
- options?: never;
- head?: never;
- patch?: never;
- trace?: never;
- };
- "/games/{game_id}": {
- parameters: {
- query?: never;
- header?: never;
- path?: never;
- cookie?: never;
- };
- /** Get a game */
- get: operations["getGame"];
- put?: never;
- post?: never;
- delete?: never;
- options?: never;
- head?: never;
- patch?: never;
- trace?: never;
- };
+ "/login": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /** User login */
+ post: operations["postLogin"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/token": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** Get a short-lived access token */
+ get: operations["getToken"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/games": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** List games */
+ get: operations["getGames"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/games/{game_id}": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** Get a game */
+ get: operations["getGame"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
}
export type webhooks = Record<string, never>;
export interface components {
- schemas: {
- Error: {
- /** @example Invalid request */
- message: string;
- };
- User: {
- /** @example 123 */
- user_id: number;
- /** @example john */
- username: string;
- /** @example John Doe */
- display_name: string;
- /** @example /images/john.jpg */
- icon_path?: string;
- /** @example false */
- is_admin: boolean;
- };
- Game: {
- /** @example 1 */
- game_id: number;
- /**
- * @example 1v1
- * @enum {string}
- */
- game_type: "1v1" | "multiplayer";
- /**
- * @example closed
- * @enum {string}
- */
- state: "closed" | "waiting" | "starting" | "gaming" | "finished";
- /** @example Game 1 */
- display_name: string;
- /** @example 360 */
- duration_seconds: number;
- /** @example 946684800 */
- started_at?: number;
- problem: components["schemas"]["Problem"];
- players: components["schemas"]["User"][];
- exec_steps: components["schemas"]["ExecStep"][];
- };
- ExecStep: {
- /** @example 1 */
- testcase_id: number | null;
- /** @example Test case 1 */
- label: string;
- };
- Problem: {
- /** @example 1 */
- problem_id: number;
- /** @example Problem 1 */
- title: string;
- /** @example This is a problem */
- description: string;
- };
- GamePlayerMessage: components["schemas"]["GamePlayerMessageS2C"] | components["schemas"]["GamePlayerMessageC2S"];
- GamePlayerMessageS2C: components["schemas"]["GamePlayerMessageS2CStart"] | components["schemas"]["GamePlayerMessageS2CExecResult"] | components["schemas"]["GamePlayerMessageS2CSubmitResult"];
- GamePlayerMessageS2CStart: {
- /** @constant */
- type: "player:s2c:start";
- data: components["schemas"]["GamePlayerMessageS2CStartPayload"];
- };
- GamePlayerMessageS2CStartPayload: {
- /** @example 946684800 */
- start_at: number;
- };
- GamePlayerMessageS2CExecResult: {
- /** @constant */
- type: "player:s2c:execresult";
- data: components["schemas"]["GamePlayerMessageS2CExecResultPayload"];
- };
- GamePlayerMessageS2CExecResultPayload: {
- /** @example 1 */
- testcase_id: number | null;
- /**
- * @example success
- * @enum {string}
- */
- status: "success" | "wrong_answer" | "timeout" | "runtime_error" | "internal_error" | "compile_error";
- /** @example Hello, world! */
- stdout: string;
- /** @example */
- stderr: string;
- };
- GamePlayerMessageS2CSubmitResult: {
- /** @constant */
- type: "player:s2c:submitresult";
- data: components["schemas"]["GamePlayerMessageS2CSubmitResultPayload"];
- };
- GamePlayerMessageS2CSubmitResultPayload: {
- /**
- * @example success
- * @enum {string}
- */
- status: "success" | "wrong_answer" | "timeout" | "runtime_error" | "internal_error" | "compile_error";
- /** @example 100 */
- score: number | null;
- };
- GamePlayerMessageC2S: components["schemas"]["GamePlayerMessageC2SCode"] | components["schemas"]["GamePlayerMessageC2SSubmit"];
- GamePlayerMessageC2SCode: {
- /** @constant */
- type: "player:c2s:code";
- data: components["schemas"]["GamePlayerMessageC2SCodePayload"];
- };
- GamePlayerMessageC2SCodePayload: {
- /** @example print('Hello, world!') */
- code: string;
- };
- GamePlayerMessageC2SSubmit: {
- /** @constant */
- type: "player:c2s:submit";
- data: components["schemas"]["GamePlayerMessageC2SSubmitPayload"];
- };
- GamePlayerMessageC2SSubmitPayload: {
- /** @example print('Hello, world!') */
- code: string;
- };
- GameWatcherMessage: components["schemas"]["GameWatcherMessageS2C"];
- GameWatcherMessageS2C: components["schemas"]["GameWatcherMessageS2CStart"] | components["schemas"]["GameWatcherMessageS2CCode"] | components["schemas"]["GameWatcherMessageS2CSubmit"] | components["schemas"]["GameWatcherMessageS2CExecResult"] | components["schemas"]["GameWatcherMessageS2CSubmitResult"];
- GameWatcherMessageS2CStart: {
- /** @constant */
- type: "watcher:s2c:start";
- data: components["schemas"]["GameWatcherMessageS2CStartPayload"];
- };
- GameWatcherMessageS2CStartPayload: {
- /** @example 946684800 */
- start_at: number;
- };
- GameWatcherMessageS2CCode: {
- /** @constant */
- type: "watcher:s2c:code";
- data: components["schemas"]["GameWatcherMessageS2CCodePayload"];
- };
- GameWatcherMessageS2CCodePayload: {
- /** @example 1 */
- player_id: number;
- /** @example print('Hello, world!') */
- code: string;
- };
- GameWatcherMessageS2CSubmit: {
- /** @constant */
- type: "watcher:s2c:submit";
- data: components["schemas"]["GameWatcherMessageS2CSubmitPayload"];
- };
- GameWatcherMessageS2CSubmitPayload: {
- /** @example 1 */
- player_id: number;
- };
- GameWatcherMessageS2CExecResult: {
- /** @constant */
- type: "watcher:s2c:execresult";
- data: components["schemas"]["GameWatcherMessageS2CExecResultPayload"];
- };
- GameWatcherMessageS2CExecResultPayload: {
- /** @example 1 */
- player_id: number;
- /** @example 1 */
- testcase_id: number | null;
- /**
- * @example success
- * @enum {string}
- */
- status: "success" | "wrong_answer" | "timeout" | "runtime_error" | "internal_error" | "compile_error";
- /** @example Hello, world! */
- stdout: string;
- /** @example */
- stderr: string;
- };
- GameWatcherMessageS2CSubmitResult: {
- /** @constant */
- type: "watcher:s2c:submitresult";
- data: components["schemas"]["GameWatcherMessageS2CSubmitResultPayload"];
- };
- GameWatcherMessageS2CSubmitResultPayload: {
- /** @example 1 */
- player_id: number;
- /**
- * @example success
- * @enum {string}
- */
- status: "success" | "wrong_answer" | "timeout" | "runtime_error" | "internal_error" | "compile_error";
- /** @example 100 */
- score: number | null;
- };
- };
- responses: {
- /** @description Bad request */
- BadRequest: {
- headers: {
- [name: string]: unknown;
- };
- content: {
- "application/json": components["schemas"]["Error"];
- };
- };
- /** @description Unauthorized */
- Unauthorized: {
- headers: {
- [name: string]: unknown;
- };
- content: {
- "application/json": components["schemas"]["Error"];
- };
- };
- /** @description Forbidden */
- Forbidden: {
- headers: {
- [name: string]: unknown;
- };
- content: {
- "application/json": components["schemas"]["Error"];
- };
- };
- /** @description Not found */
- NotFound: {
- headers: {
- [name: string]: unknown;
- };
- content: {
- "application/json": components["schemas"]["Error"];
- };
- };
- };
- parameters: {
- header_authorization: string;
- path_game_id: number;
- };
- requestBodies: never;
- headers: never;
- pathItems: never;
+ schemas: {
+ Error: {
+ /** @example Invalid request */
+ message: string;
+ };
+ User: {
+ /** @example 123 */
+ user_id: number;
+ /** @example john */
+ username: string;
+ /** @example John Doe */
+ display_name: string;
+ /** @example /images/john.jpg */
+ icon_path?: string;
+ /** @example false */
+ is_admin: boolean;
+ };
+ Game: {
+ /** @example 1 */
+ game_id: number;
+ /**
+ * @example 1v1
+ * @enum {string}
+ */
+ game_type: "1v1" | "multiplayer";
+ /**
+ * @example closed
+ * @enum {string}
+ */
+ state: "closed" | "waiting" | "starting" | "gaming" | "finished";
+ /** @example Game 1 */
+ display_name: string;
+ /** @example 360 */
+ duration_seconds: number;
+ /** @example 946684800 */
+ started_at?: number;
+ problem: components["schemas"]["Problem"];
+ players: components["schemas"]["User"][];
+ exec_steps: components["schemas"]["ExecStep"][];
+ };
+ ExecStep: {
+ /** @example 1 */
+ testcase_id: number | null;
+ /** @example Test case 1 */
+ label: string;
+ };
+ Problem: {
+ /** @example 1 */
+ problem_id: number;
+ /** @example Problem 1 */
+ title: string;
+ /** @example This is a problem */
+ description: string;
+ };
+ GamePlayerMessage:
+ | components["schemas"]["GamePlayerMessageS2C"]
+ | components["schemas"]["GamePlayerMessageC2S"];
+ GamePlayerMessageS2C:
+ | components["schemas"]["GamePlayerMessageS2CStart"]
+ | components["schemas"]["GamePlayerMessageS2CExecResult"]
+ | components["schemas"]["GamePlayerMessageS2CSubmitResult"];
+ GamePlayerMessageS2CStart: {
+ /** @constant */
+ type: "player:s2c:start";
+ data: components["schemas"]["GamePlayerMessageS2CStartPayload"];
+ };
+ GamePlayerMessageS2CStartPayload: {
+ /** @example 946684800 */
+ start_at: number;
+ };
+ GamePlayerMessageS2CExecResult: {
+ /** @constant */
+ type: "player:s2c:execresult";
+ data: components["schemas"]["GamePlayerMessageS2CExecResultPayload"];
+ };
+ GamePlayerMessageS2CExecResultPayload: {
+ /** @example 1 */
+ testcase_id: number | null;
+ /**
+ * @example success
+ * @enum {string}
+ */
+ status:
+ | "success"
+ | "wrong_answer"
+ | "timeout"
+ | "runtime_error"
+ | "internal_error"
+ | "compile_error";
+ /** @example Hello, world! */
+ stdout: string;
+ /** @example */
+ stderr: string;
+ };
+ GamePlayerMessageS2CSubmitResult: {
+ /** @constant */
+ type: "player:s2c:submitresult";
+ data: components["schemas"]["GamePlayerMessageS2CSubmitResultPayload"];
+ };
+ GamePlayerMessageS2CSubmitResultPayload: {
+ /**
+ * @example success
+ * @enum {string}
+ */
+ status:
+ | "success"
+ | "wrong_answer"
+ | "timeout"
+ | "runtime_error"
+ | "internal_error"
+ | "compile_error";
+ /** @example 100 */
+ score: number | null;
+ };
+ GamePlayerMessageC2S:
+ | components["schemas"]["GamePlayerMessageC2SCode"]
+ | components["schemas"]["GamePlayerMessageC2SSubmit"];
+ GamePlayerMessageC2SCode: {
+ /** @constant */
+ type: "player:c2s:code";
+ data: components["schemas"]["GamePlayerMessageC2SCodePayload"];
+ };
+ GamePlayerMessageC2SCodePayload: {
+ /** @example print('Hello, world!') */
+ code: string;
+ };
+ GamePlayerMessageC2SSubmit: {
+ /** @constant */
+ type: "player:c2s:submit";
+ data: components["schemas"]["GamePlayerMessageC2SSubmitPayload"];
+ };
+ GamePlayerMessageC2SSubmitPayload: {
+ /** @example print('Hello, world!') */
+ code: string;
+ };
+ GameWatcherMessage: components["schemas"]["GameWatcherMessageS2C"];
+ GameWatcherMessageS2C:
+ | components["schemas"]["GameWatcherMessageS2CStart"]
+ | components["schemas"]["GameWatcherMessageS2CCode"]
+ | components["schemas"]["GameWatcherMessageS2CSubmit"]
+ | components["schemas"]["GameWatcherMessageS2CExecResult"]
+ | components["schemas"]["GameWatcherMessageS2CSubmitResult"];
+ GameWatcherMessageS2CStart: {
+ /** @constant */
+ type: "watcher:s2c:start";
+ data: components["schemas"]["GameWatcherMessageS2CStartPayload"];
+ };
+ GameWatcherMessageS2CStartPayload: {
+ /** @example 946684800 */
+ start_at: number;
+ };
+ GameWatcherMessageS2CCode: {
+ /** @constant */
+ type: "watcher:s2c:code";
+ data: components["schemas"]["GameWatcherMessageS2CCodePayload"];
+ };
+ GameWatcherMessageS2CCodePayload: {
+ /** @example 1 */
+ player_id: number;
+ /** @example print('Hello, world!') */
+ code: string;
+ };
+ GameWatcherMessageS2CSubmit: {
+ /** @constant */
+ type: "watcher:s2c:submit";
+ data: components["schemas"]["GameWatcherMessageS2CSubmitPayload"];
+ };
+ GameWatcherMessageS2CSubmitPayload: {
+ /** @example 1 */
+ player_id: number;
+ };
+ GameWatcherMessageS2CExecResult: {
+ /** @constant */
+ type: "watcher:s2c:execresult";
+ data: components["schemas"]["GameWatcherMessageS2CExecResultPayload"];
+ };
+ GameWatcherMessageS2CExecResultPayload: {
+ /** @example 1 */
+ player_id: number;
+ /** @example 1 */
+ testcase_id: number | null;
+ /**
+ * @example success
+ * @enum {string}
+ */
+ status:
+ | "success"
+ | "wrong_answer"
+ | "timeout"
+ | "runtime_error"
+ | "internal_error"
+ | "compile_error";
+ /** @example Hello, world! */
+ stdout: string;
+ /** @example */
+ stderr: string;
+ };
+ GameWatcherMessageS2CSubmitResult: {
+ /** @constant */
+ type: "watcher:s2c:submitresult";
+ data: components["schemas"]["GameWatcherMessageS2CSubmitResultPayload"];
+ };
+ GameWatcherMessageS2CSubmitResultPayload: {
+ /** @example 1 */
+ player_id: number;
+ /**
+ * @example success
+ * @enum {string}
+ */
+ status:
+ | "success"
+ | "wrong_answer"
+ | "timeout"
+ | "runtime_error"
+ | "internal_error"
+ | "compile_error";
+ /** @example 100 */
+ score: number | null;
+ };
+ };
+ responses: {
+ /** @description Bad request */
+ BadRequest: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description Unauthorized */
+ Unauthorized: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description Forbidden */
+ Forbidden: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description Not found */
+ NotFound: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ };
+ parameters: {
+ header_authorization: string;
+ path_game_id: number;
+ };
+ requestBodies: never;
+ headers: never;
+ pathItems: never;
}
export type $defs = Record<string, never>;
export interface operations {
- postLogin: {
- parameters: {
- query?: never;
- header?: never;
- path?: never;
- cookie?: never;
- };
- requestBody: {
- content: {
- "application/json": {
- /** @example john */
- username: string;
- /** @example password123 */
- password: string;
- };
- };
- };
- responses: {
- /** @description Successfully authenticated */
- 200: {
- headers: {
- [name: string]: unknown;
- };
- content: {
- "application/json": {
- /** @example xxxxx.xxxxx.xxxxx */
- token: string;
- };
- };
- };
- 401: components["responses"]["Unauthorized"];
- };
- };
- getToken: {
- parameters: {
- query?: never;
- header: {
- Authorization: components["parameters"]["header_authorization"];
- };
- path?: never;
- cookie?: never;
- };
- requestBody?: never;
- responses: {
- /** @description Successfully authenticated */
- 200: {
- headers: {
- [name: string]: unknown;
- };
- content: {
- "application/json": {
- /** @example xxxxx.xxxxx.xxxxx */
- token: string;
- };
- };
- };
- 401: components["responses"]["Unauthorized"];
- };
- };
- getGames: {
- parameters: {
- query?: never;
- header: {
- Authorization: components["parameters"]["header_authorization"];
- };
- path?: never;
- cookie?: never;
- };
- requestBody?: never;
- responses: {
- /** @description List of games */
- 200: {
- headers: {
- [name: string]: unknown;
- };
- content: {
- "application/json": {
- games: components["schemas"]["Game"][];
- };
- };
- };
- 401: components["responses"]["Unauthorized"];
- 403: components["responses"]["Forbidden"];
- };
- };
- getGame: {
- parameters: {
- query?: never;
- header: {
- Authorization: components["parameters"]["header_authorization"];
- };
- path: {
- game_id: components["parameters"]["path_game_id"];
- };
- cookie?: never;
- };
- requestBody?: never;
- responses: {
- /** @description A game */
- 200: {
- headers: {
- [name: string]: unknown;
- };
- content: {
- "application/json": {
- game: components["schemas"]["Game"];
- };
- };
- };
- 401: components["responses"]["Unauthorized"];
- 403: components["responses"]["Forbidden"];
- 404: components["responses"]["NotFound"];
- };
- };
+ postLogin: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": {
+ /** @example john */
+ username: string;
+ /** @example password123 */
+ password: string;
+ };
+ };
+ };
+ responses: {
+ /** @description Successfully authenticated */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ /** @example xxxxx.xxxxx.xxxxx */
+ token: string;
+ };
+ };
+ };
+ 401: components["responses"]["Unauthorized"];
+ };
+ };
+ getToken: {
+ parameters: {
+ query?: never;
+ header: {
+ Authorization: components["parameters"]["header_authorization"];
+ };
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successfully authenticated */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ /** @example xxxxx.xxxxx.xxxxx */
+ token: string;
+ };
+ };
+ };
+ 401: components["responses"]["Unauthorized"];
+ };
+ };
+ getGames: {
+ parameters: {
+ query?: never;
+ header: {
+ Authorization: components["parameters"]["header_authorization"];
+ };
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description List of games */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ games: components["schemas"]["Game"][];
+ };
+ };
+ };
+ 401: components["responses"]["Unauthorized"];
+ 403: components["responses"]["Forbidden"];
+ };
+ };
+ getGame: {
+ parameters: {
+ query?: never;
+ header: {
+ Authorization: components["parameters"]["header_authorization"];
+ };
+ path: {
+ game_id: components["parameters"]["path_game_id"];
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description A game */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ game: components["schemas"]["Game"];
+ };
+ };
+ };
+ 401: components["responses"]["Unauthorized"];
+ 403: components["responses"]["Forbidden"];
+ 404: components["responses"]["NotFound"];
+ };
+ };
}
diff --git a/frontend/app/.server/auth.ts b/frontend/app/.server/auth.ts
index ac8afe8..386eb70 100644
--- a/frontend/app/.server/auth.ts
+++ b/frontend/app/.server/auth.ts
@@ -3,8 +3,8 @@ import type { Session } from "@remix-run/server-runtime";
import { jwtDecode } from "jwt-decode";
import { Authenticator } from "remix-auth";
import { FormStrategy } from "remix-auth-form";
-import { apiPostLogin } from "./api/client";
-import { components } from "./api/schema";
+import { apiPostLogin } from "../api/client";
+import { components } from "../api/schema";
import { createUnstructuredCookie } from "./cookie";
import { cookieOptions, sessionStorage } from "./session";
diff --git a/frontend/app/api/client.ts b/frontend/app/api/client.ts
new file mode 100644
index 0000000..3d8b5dd
--- /dev/null
+++ b/frontend/app/api/client.ts
@@ -0,0 +1,120 @@
+import createClient from "openapi-fetch";
+import { createContext } from "react";
+import type { paths } from "./schema";
+
+const apiClient = createClient<paths>({
+ baseUrl:
+ process.env.NODE_ENV === "development"
+ ? "http://localhost:8003/phperkaigi/2025/code-battle/api/"
+ : "http://api-server/phperkaigi/2025/code-battle/api/",
+});
+
+export async function apiPostLogin(username: string, password: string) {
+ const { data, error } = await apiClient.POST("/login", {
+ body: {
+ username,
+ password,
+ },
+ });
+ if (error) throw new Error(error.message);
+ return data;
+}
+
+export async function apiGetGames(token: string) {
+ const { data, error } = await apiClient.GET("/games", {
+ params: {
+ header: { Authorization: `Bearer ${token}` },
+ },
+ });
+ if (error) throw new Error(error.message);
+ return data;
+}
+
+export async function apiGetGame(token: string, gameId: number) {
+ const { data, error } = await apiClient.GET("/games/{game_id}", {
+ params: {
+ header: { Authorization: `Bearer ${token}` },
+ path: { game_id: gameId },
+ },
+ });
+ if (error) throw new Error(error.message);
+ return data;
+}
+
+export async function apiGetGamePlayLatestState(token: string, gameId: number) {
+ const { data, error } = await apiClient.GET(
+ "/games/{game_id}/play/latest_state",
+ {
+ params: {
+ header: { Authorization: `Bearer ${token}` },
+ path: { game_id: gameId },
+ },
+ },
+ );
+ if (error) throw new Error(error.message);
+ return data;
+}
+
+export async function apiPostGamePlayCode(
+ token: string,
+ gameId: number,
+ code: string,
+) {
+ const { error } = await apiClient.POST("/games/{game_id}/play/code", {
+ params: {
+ header: { Authorization: `Bearer ${token}` },
+ path: { game_id: gameId },
+ },
+ body: { code },
+ });
+ if (error) throw new Error(error.message);
+}
+
+export async function apiPostGamePlaySubmit(
+ token: string,
+ gameId: number,
+ code: string,
+) {
+ const { data, error } = await apiClient.POST("/games/{game_id}/play/submit", {
+ params: {
+ header: { Authorization: `Bearer ${token}` },
+ path: { game_id: gameId },
+ },
+ body: { code },
+ });
+ if (error) throw new Error(error.message);
+ return data;
+}
+
+export async function apiGetGameWatchRanking(token: string, gameId: number) {
+ const { data, error } = await apiClient.GET(
+ "/games/{game_id}/watch/ranking",
+ {
+ params: {
+ header: { Authorization: `Bearer ${token}` },
+ path: { game_id: gameId },
+ },
+ },
+ );
+ if (error) throw new Error(error.message);
+ return data;
+}
+
+export async function apiGetGameWatchLatestStates(
+ token: string,
+ gameId: number,
+) {
+ const { data, error } = await apiClient.GET(
+ "/games/{game_id}/watch/latest_states",
+ {
+ params: {
+ header: { Authorization: `Bearer ${token}` },
+ path: { game_id: gameId },
+ },
+ },
+ );
+ if (error) throw new Error(error.message);
+ return data;
+}
+
+export const ApiAuthTokenContext = createContext<string>("");
diff --git a/frontend/app/api/schema.d.ts b/frontend/app/api/schema.d.ts
new file mode 100644
index 0000000..cec5661
--- /dev/null
+++ b/frontend/app/api/schema.d.ts
@@ -0,0 +1,499 @@
+/**
+ * This file was auto-generated by openapi-typescript.
+ * Do not make direct changes to the file.
+ */
+
+export interface paths {
+ "/login": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /** User login */
+ post: operations["postLogin"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/games": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** List games */
+ get: operations["getGames"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/games/{game_id}": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** Get a game */
+ get: operations["getGame"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/games/{game_id}/play/latest_state": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** Get the latest execution result for player */
+ get: operations["getGamePlayLatestState"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/games/{game_id}/play/code": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /** Post the latest code */
+ post: operations["postGamePlayCode"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/games/{game_id}/play/submit": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /** Submit the answer */
+ post: operations["postGamePlaySubmit"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/games/{game_id}/watch/ranking": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** Get the latest player ranking */
+ get: operations["getGameWatchRanking"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/games/{game_id}/watch/latest_states": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** Get all the latest game states of the main players */
+ get: operations["getGameWatchLatestStates"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+}
+export type webhooks = Record<string, never>;
+export interface components {
+ schemas: {
+ Error: {
+ /** @example Invalid request */
+ message: string;
+ };
+ User: {
+ /** @example 123 */
+ user_id: number;
+ /** @example john */
+ username: string;
+ /** @example John Doe */
+ display_name: string;
+ /** @example /images/john.jpg */
+ icon_path?: string;
+ /** @example false */
+ is_admin: boolean;
+ };
+ Game: {
+ /** @example 1 */
+ game_id: number;
+ /**
+ * @example 1v1
+ * @enum {string}
+ */
+ game_type: "1v1" | "multiplayer";
+ /** @example true */
+ is_public: boolean;
+ /** @example Game 1 */
+ display_name: string;
+ /** @example 360 */
+ duration_seconds: number;
+ /** @example 946684800 */
+ started_at?: number;
+ problem: components["schemas"]["Problem"];
+ main_players: components["schemas"]["User"][];
+ };
+ Problem: {
+ /** @example 1 */
+ problem_id: number;
+ /** @example Problem 1 */
+ title: string;
+ /** @example This is a problem */
+ description: string;
+ /** @example echo 'hello world'; */
+ sample_code: string;
+ };
+ /**
+ * @example success
+ * @enum {string}
+ */
+ ExecutionStatus: "none" | "running" | "success" | "wrong_answer" | "timeout" | "runtime_error" | "internal_error";
+ LatestGameState: {
+ /** @example echo 'hello world'; */
+ code: string;
+ /** @example 100 */
+ score: number | null;
+ status: components["schemas"]["ExecutionStatus"];
+ };
+ RankingEntry: {
+ player: components["schemas"]["User"];
+ /** @example 100 */
+ score: number;
+ };
+ };
+ responses: {
+ /** @description Bad request */
+ BadRequest: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description Unauthorized */
+ Unauthorized: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description Forbidden */
+ Forbidden: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description Not found */
+ NotFound: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ };
+ parameters: {
+ header_authorization: string;
+ path_game_id: number;
+ };
+ requestBodies: never;
+ headers: never;
+ pathItems: never;
+}
+export type $defs = Record<string, never>;
+export interface operations {
+ postLogin: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": {
+ /** @example john */
+ username: string;
+ /** @example password123 */
+ password: string;
+ };
+ };
+ };
+ responses: {
+ /** @description Successfully authenticated */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ /** @example xxxxx.xxxxx.xxxxx */
+ token: string;
+ };
+ };
+ };
+ 401: components["responses"]["Unauthorized"];
+ };
+ };
+ getGames: {
+ parameters: {
+ query?: never;
+ header: {
+ Authorization: components["parameters"]["header_authorization"];
+ };
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description List of games */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ games: components["schemas"]["Game"][];
+ };
+ };
+ };
+ 401: components["responses"]["Unauthorized"];
+ 403: components["responses"]["Forbidden"];
+ };
+ };
+ getGame: {
+ parameters: {
+ query?: never;
+ header: {
+ Authorization: components["parameters"]["header_authorization"];
+ };
+ path: {
+ game_id: components["parameters"]["path_game_id"];
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description A game */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ game: components["schemas"]["Game"];
+ };
+ };
+ };
+ 401: components["responses"]["Unauthorized"];
+ 403: components["responses"]["Forbidden"];
+ 404: components["responses"]["NotFound"];
+ };
+ };
+ getGamePlayLatestState: {
+ parameters: {
+ query?: never;
+ header: {
+ Authorization: components["parameters"]["header_authorization"];
+ };
+ path: {
+ game_id: components["parameters"]["path_game_id"];
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Your latest game state */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ state: components["schemas"]["LatestGameState"];
+ };
+ };
+ };
+ 401: components["responses"]["Unauthorized"];
+ 403: components["responses"]["Forbidden"];
+ 404: components["responses"]["NotFound"];
+ };
+ };
+ postGamePlayCode: {
+ parameters: {
+ query?: never;
+ header: {
+ Authorization: components["parameters"]["header_authorization"];
+ };
+ path: {
+ game_id: components["parameters"]["path_game_id"];
+ };
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": {
+ /** @example echo 'hello world'; */
+ code: string;
+ };
+ };
+ };
+ responses: {
+ /** @description Successfully updated */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ 401: components["responses"]["Unauthorized"];
+ 403: components["responses"]["Forbidden"];
+ 404: components["responses"]["NotFound"];
+ };
+ };
+ postGamePlaySubmit: {
+ parameters: {
+ query?: never;
+ header: {
+ Authorization: components["parameters"]["header_authorization"];
+ };
+ path: {
+ game_id: components["parameters"]["path_game_id"];
+ };
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": {
+ /** @example echo 'hello world'; */
+ code: string;
+ };
+ };
+ };
+ responses: {
+ /** @description Successfully submitted */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ 401: components["responses"]["Unauthorized"];
+ 403: components["responses"]["Forbidden"];
+ 404: components["responses"]["NotFound"];
+ };
+ };
+ getGameWatchRanking: {
+ parameters: {
+ query?: never;
+ header: {
+ Authorization: components["parameters"]["header_authorization"];
+ };
+ path: {
+ game_id: components["parameters"]["path_game_id"];
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Player ranking */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ ranking: components["schemas"]["RankingEntry"][];
+ };
+ };
+ };
+ 401: components["responses"]["Unauthorized"];
+ 403: components["responses"]["Forbidden"];
+ 404: components["responses"]["NotFound"];
+ };
+ };
+ getGameWatchLatestStates: {
+ parameters: {
+ query?: never;
+ header: {
+ Authorization: components["parameters"]["header_authorization"];
+ };
+ path: {
+ game_id: components["parameters"]["path_game_id"];
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description All the latest game states of the main players */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ states: {
+ [key: string]: components["schemas"]["LatestGameState"];
+ };
+ };
+ };
+ };
+ 401: components["responses"]["Unauthorized"];
+ 403: components["responses"]["Forbidden"];
+ 404: components["responses"]["NotFound"];
+ };
+ };
+}
diff --git a/frontend/app/components/Gaming/CodeBlock.tsx b/frontend/app/components/Gaming/CodeBlock.tsx
index b7d45c0..0a9a2e5 100644
--- a/frontend/app/components/Gaming/CodeBlock.tsx
+++ b/frontend/app/components/Gaming/CodeBlock.tsx
@@ -1,8 +1,5 @@
-import Prism, { highlight, languages } from "prismjs";
-import "prismjs/components/prism-swift";
-import "prismjs/themes/prism.min.css";
-
-Prism.manual = true;
+import { useEffect, useState } from "react";
+import { codeToHtml } from "shiki";
type Props = {
code: string;
@@ -10,11 +7,31 @@ type Props = {
};
export default function CodeBlock({ code, language }: Props) {
- const highlighted = highlight(code, languages[language]!, language);
+ const [highlightedCode, setHighlightedCode] = useState<string | null>(null);
+
+ useEffect(() => {
+ let isMounted = true;
+
+ (async () => {
+ const highlighted = await codeToHtml(code, {
+ lang: language,
+ theme: "github-light",
+ });
+ if (isMounted) {
+ setHighlightedCode(highlighted);
+ }
+ })();
+
+ return () => {
+ isMounted = false;
+ };
+ }, [code, language]);
return (
<pre className="h-full w-full p-2 bg-gray-50 rounded-lg border border-gray-300 whitespace-pre-wrap break-words">
- <code dangerouslySetInnerHTML={{ __html: highlighted }} />
+ {highlightedCode === null ? null : (
+ <code dangerouslySetInnerHTML={{ __html: highlightedCode }} />
+ )}
</pre>
);
}
diff --git a/frontend/app/components/Gaming/ExecStatusIndicatorIcon.tsx b/frontend/app/components/Gaming/ExecStatusIndicatorIcon.tsx
index a717a48..44d28ad 100644
--- a/frontend/app/components/Gaming/ExecStatusIndicatorIcon.tsx
+++ b/frontend/app/components/Gaming/ExecStatusIndicatorIcon.tsx
@@ -1,20 +1,19 @@
import {
- faBan,
faCircle,
faCircleCheck,
faCircleExclamation,
faRotate,
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
-import type { ExecResultStatus } from "../../types/ExecResult";
+import type { components } from "../../api/schema";
type Props = {
- status: ExecResultStatus;
+ status: components["schemas"]["ExecutionStatus"];
};
export default function ExecStatusIndicatorIcon({ status }: Props) {
switch (status) {
- case "waiting_submission":
+ case "none":
return (
<FontAwesomeIcon icon={faCircle} fixedWidth className="text-gray-400" />
);
@@ -35,10 +34,6 @@ export default function ExecStatusIndicatorIcon({ status }: Props) {
className="text-sky-500"
/>
);
- case "canceled":
- return (
- <FontAwesomeIcon icon={faBan} fixedWidth className="text-gray-400" />
- );
default:
return (
<FontAwesomeIcon
diff --git a/frontend/app/components/Gaming/SubmitResult.tsx b/frontend/app/components/Gaming/SubmitResult.tsx
index c626910..a78c79e 100644
--- a/frontend/app/components/Gaming/SubmitResult.tsx
+++ b/frontend/app/components/Gaming/SubmitResult.tsx
@@ -1,47 +1,21 @@
import React from "react";
-import type { SubmitResult } from "../../types/SubmitResult";
-import BorderedContainer from "../BorderedContainer";
+import type { components } from "../../api/schema";
import SubmitStatusLabel from "../SubmitStatusLabel";
-import ExecStatusIndicatorIcon from "./ExecStatusIndicatorIcon";
type Props = {
- result: SubmitResult;
+ status: components["schemas"]["ExecutionStatus"];
submitButton?: React.ReactNode;
};
-export default function SubmitResult({ result, submitButton }: Props) {
+export default function SubmitResult({ status, submitButton }: Props) {
return (
<div className="flex flex-col gap-2">
<div className="flex">
{submitButton}
<div className="grow font-bold text-xl text-center">
- <SubmitStatusLabel status={result.status} />
+ <SubmitStatusLabel status={status} />
</div>
</div>
- <ul className="flex flex-col gap-4">
- {result.execResults.map((r) => (
- <li key={r.testcase_id ?? -1}>
- <BorderedContainer>
- <div className="flex flex-col gap-2">
- <div className="flex gap-2">
- <div className="my-auto">
- <ExecStatusIndicatorIcon status={r.status} />
- </div>
- <div className="font-semibold">{r.label}</div>
- </div>
- {r.stdout + r.stderr && (
- <pre className="overflow-y-hidden max-h-96 p-2 bg-gray-50 rounded-lg border border-gray-300 whitespace-pre-wrap break-words">
- <code>
- {r.stdout}
- {r.stderr}
- </code>
- </pre>
- )}
- </div>
- </BorderedContainer>
- </li>
- ))}
- </ul>
</div>
);
}
diff --git a/frontend/app/components/GolfPlayApp.client.tsx b/frontend/app/components/GolfPlayApp.client.tsx
deleted file mode 100644
index c81fe7e..0000000
--- a/frontend/app/components/GolfPlayApp.client.tsx
+++ /dev/null
@@ -1,188 +0,0 @@
-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 {
- 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";
-import GolfPlayAppStarting from "./GolfPlayApps/GolfPlayAppStarting";
-import GolfPlayAppWaiting from "./GolfPlayApps/GolfPlayAppWaiting";
-
-type GamePlayerMessageS2C = components["schemas"]["GamePlayerMessageS2C"];
-type GamePlayerMessageC2S = components["schemas"]["GamePlayerMessageC2S"];
-
-type Game = components["schemas"]["Game"];
-type User = components["schemas"]["User"];
-
-type Props = {
- game: Game;
- player: User;
- initialCode: string;
- sockToken: string;
-};
-
-export default function GolfPlayApp({
- game,
- player,
- initialCode,
- sockToken,
-}: Props) {
- const socketUrl =
- process.env.NODE_ENV === "development"
- ? `ws://localhost:8003/phperkaigi/2025/code-battle/sock/golf/${game.game_id}/play?token=${sockToken}`
- : `wss://t.nil.ninja/phperkaigi/2025/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 playerProfile = {
- displayName: player.display_name,
- iconPath: player.icon_path ?? null,
- };
-
- const onCodeChange = useDebouncedCallback((code: string) => {
- console.log("player:c2s:code");
- sendJsonMessage({
- 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) => {
- if (code === "") {
- return;
- }
- console.log("player:c2s:submit");
- sendJsonMessage({
- type: "player:c2s:submit",
- data: { code },
- });
- handleSubmitCode();
- }, 1000);
-
- if (readyState === ReadyState.UNINSTANTIATED) {
- throw new Error("WebSocket is not connected");
- }
-
- useEffect(() => {
- if (readyState === ReadyState.CLOSING || readyState === ReadyState.CLOSED) {
- handleWsConnectionClosed();
- } else if (readyState === ReadyState.CONNECTING) {
- setGameStateConnecting();
- } else if (readyState === ReadyState.OPEN) {
- if (lastJsonMessage !== null) {
- console.log(lastJsonMessage.type);
- console.log(lastJsonMessage.data);
- if (lastJsonMessage.type === "player:s2c:start") {
- const { start_at } = lastJsonMessage.data;
- gameStart(start_at);
- } else if (lastJsonMessage.type === "player:s2c:execresult") {
- 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") {
- handleWsSubmitResultMessage(
- lastJsonMessage.data,
- (submissionResult, score) => {
- const baseKey = `playerState:${game.game_id}:${player.user_id}`;
- window.localStorage.setItem(
- `${baseKey}:submissionResult`,
- JSON.stringify(submissionResult),
- );
- window.localStorage.setItem(
- `${baseKey}:score`,
- score === null ? "" : score.toString(),
- );
- },
- );
- }
- } else {
- if (game.started_at) {
- gameStart(game.started_at);
- } else {
- setGameStateWaiting();
- }
- }
- }
- }, [
- game.game_id,
- game.started_at,
- player.user_id,
- sendJsonMessage,
- lastJsonMessage,
- readyState,
- gameStart,
- handleWsConnectionClosed,
- handleWsExecResultMessage,
- handleWsSubmitResultMessage,
- setGameStateConnecting,
- setGameStateWaiting,
- ]);
-
- if (gameStateKind === "connecting") {
- return <GolfPlayAppConnecting />;
- } else if (gameStateKind === "waiting") {
- return (
- <GolfPlayAppWaiting
- gameDisplayName={game.display_name}
- playerProfile={playerProfile}
- />
- );
- } else if (gameStateKind === "starting") {
- return <GolfPlayAppStarting gameDisplayName={game.display_name} />;
- } else if (gameStateKind === "gaming") {
- return (
- <GolfPlayAppGaming
- gameDisplayName={game.display_name}
- playerProfile={playerProfile}
- problemTitle={game.problem.title}
- problemDescription={game.problem.description}
- initialCode={initialCode}
- onCodeChange={onCodeChange}
- onCodeSubmit={onCodeSubmit}
- />
- );
- } else if (gameStateKind === "finished") {
- return <GolfPlayAppFinished />;
- } else {
- return null;
- }
-}
diff --git a/frontend/app/components/GolfPlayApp.tsx b/frontend/app/components/GolfPlayApp.tsx
new file mode 100644
index 0000000..e8fafbd
--- /dev/null
+++ b/frontend/app/components/GolfPlayApp.tsx
@@ -0,0 +1,141 @@
+import { useAtomValue, useSetAtom } from "jotai";
+import { useContext, useEffect, useState } from "react";
+import { useTimer } from "react-use-precision-timer";
+import { useDebouncedCallback } from "use-debounce";
+import {
+ ApiAuthTokenContext,
+ apiGetGame,
+ apiGetGamePlayLatestState,
+ apiPostGamePlayCode,
+ apiPostGamePlaySubmit,
+} from "../api/client";
+import type { components } from "../api/schema";
+import {
+ gameStateKindAtom,
+ handleSubmitCodePostAtom,
+ handleSubmitCodePreAtom,
+ setCurrentTimestampAtom,
+ setGameStartedAtAtom,
+ setLatestGameStateAtom,
+} from "../states/play";
+import GolfPlayAppFinished from "./GolfPlayApps/GolfPlayAppFinished";
+import GolfPlayAppGaming from "./GolfPlayApps/GolfPlayAppGaming";
+import GolfPlayAppStarting from "./GolfPlayApps/GolfPlayAppStarting";
+import GolfPlayAppWaiting from "./GolfPlayApps/GolfPlayAppWaiting";
+
+type Game = components["schemas"]["Game"];
+type User = components["schemas"]["User"];
+
+type Props = {
+ game: Game;
+ player: User;
+ initialCode: string;
+};
+
+export default function GolfPlayApp({ game, player, initialCode }: Props) {
+ const apiAuthToken = useContext(ApiAuthTokenContext);
+
+ const gameStateKind = useAtomValue(gameStateKindAtom);
+ const setGameStartedAt = useSetAtom(setGameStartedAtAtom);
+ const setCurrentTimestamp = useSetAtom(setCurrentTimestampAtom);
+ const handleSubmitCodePre = useSetAtom(handleSubmitCodePreAtom);
+ const handleSubmitCodePost = useSetAtom(handleSubmitCodePostAtom);
+ const setLatestGameState = useSetAtom(setLatestGameStateAtom);
+
+ useTimer({ delay: 1000, startImmediately: true }, setCurrentTimestamp);
+
+ const playerProfile = {
+ id: player.user_id,
+ displayName: player.display_name,
+ iconPath: player.icon_path ?? null,
+ };
+
+ const onCodeChange = useDebouncedCallback(async (code: string) => {
+ console.log("player:c2s:code");
+ if (game.game_type === "1v1") {
+ await apiPostGamePlayCode(apiAuthToken, game.game_id, code);
+ }
+ }, 1000);
+
+ const onCodeSubmit = useDebouncedCallback(async (code: string) => {
+ if (code === "") {
+ return;
+ }
+ console.log("player:c2s:submit");
+ handleSubmitCodePre();
+ await apiPostGamePlaySubmit(apiAuthToken, game.game_id, code);
+ handleSubmitCodePost();
+ }, 1000);
+
+ const [isDataPolling, setIsDataPolling] = useState(false);
+
+ useEffect(() => {
+ if (isDataPolling) {
+ return;
+ }
+ const timerId = setInterval(async () => {
+ if (isDataPolling) {
+ return;
+ }
+ setIsDataPolling(true);
+
+ try {
+ if (gameStateKind === "waiting") {
+ const { game: g } = await apiGetGame(apiAuthToken, game.game_id);
+ if (g.started_at != null) {
+ setGameStartedAt(g.started_at);
+ }
+ } else if (gameStateKind === "gaming") {
+ const { state } = await apiGetGamePlayLatestState(
+ apiAuthToken,
+ game.game_id,
+ );
+ setLatestGameState(state);
+ }
+ } catch (error) {
+ console.error(error);
+ } finally {
+ setIsDataPolling(false);
+ }
+ }, 1000);
+
+ return () => {
+ clearInterval(timerId);
+ };
+ }, [
+ isDataPolling,
+ apiAuthToken,
+ game.game_id,
+ gameStateKind,
+ setGameStartedAt,
+ setLatestGameState,
+ ]);
+
+ if (gameStateKind === "waiting") {
+ return (
+ <GolfPlayAppWaiting
+ gameDisplayName={game.display_name}
+ playerProfile={playerProfile}
+ />
+ );
+ } else if (gameStateKind === "starting") {
+ return <GolfPlayAppStarting gameDisplayName={game.display_name} />;
+ } else if (gameStateKind === "gaming") {
+ return (
+ <GolfPlayAppGaming
+ gameDisplayName={game.display_name}
+ playerProfile={playerProfile}
+ problemTitle={game.problem.title}
+ problemDescription={game.problem.description}
+ sampleCode={game.problem.sample_code}
+ initialCode={initialCode}
+ onCodeChange={onCodeChange}
+ onCodeSubmit={onCodeSubmit}
+ />
+ );
+ } else if (gameStateKind === "finished") {
+ return <GolfPlayAppFinished />;
+ } else {
+ return null;
+ }
+}
diff --git a/frontend/app/components/GolfPlayApps/GolfPlayAppConnecting.tsx b/frontend/app/components/GolfPlayApps/GolfPlayAppConnecting.tsx
deleted file mode 100644
index 4b80f8f..0000000
--- a/frontend/app/components/GolfPlayApps/GolfPlayAppConnecting.tsx
+++ /dev/null
@@ -1,9 +0,0 @@
-export default function GolfPlayAppConnecting() {
- return (
- <div className="min-h-screen bg-gray-100 flex items-center justify-center">
- <div className="text-center">
- <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 d4a059f..bc205fb 100644
--- a/frontend/app/components/GolfPlayApps/GolfPlayAppGaming.tsx
+++ b/frontend/app/components/GolfPlayApps/GolfPlayAppGaming.tsx
@@ -5,10 +5,11 @@ import SubmitButton from "../../components/SubmitButton";
import {
gamingLeftTimeSecondsAtom,
scoreAtom,
- submitResultAtom,
+ statusAtom,
} from "../../states/play";
import type { PlayerProfile } from "../../types/PlayerProfile";
import BorderedContainer from "../BorderedContainer";
+import CodeBlock from "../Gaming/CodeBlock";
import SubmitResult from "../Gaming/SubmitResult";
import UserIcon from "../UserIcon";
@@ -17,6 +18,7 @@ type Props = {
playerProfile: PlayerProfile;
problemTitle: string;
problemDescription: string;
+ sampleCode: string;
initialCode: string;
onCodeChange: (code: string) => void;
onCodeSubmit: (code: string) => void;
@@ -27,13 +29,14 @@ export default function GolfPlayAppGaming({
playerProfile,
problemTitle,
problemDescription,
+ sampleCode,
initialCode,
onCodeChange,
onCodeSubmit,
}: Props) {
const leftTimeSeconds = useAtomValue(gamingLeftTimeSecondsAtom)!;
const score = useAtomValue(scoreAtom);
- const submitResult = useAtomValue(submitResultAtom);
+ const status = useAtomValue(statusAtom);
const textareaRef = useRef<HTMLTextAreaElement>(null);
@@ -80,10 +83,16 @@ export default function GolfPlayAppGaming({
<div className="grow grid grid-cols-3 divide-x divide-gray-300">
<div className="p-4">
<div className="mb-2 text-xl font-bold">{problemTitle}</div>
- <div className="p-2">
+ <div className="p-2 grid gap-4">
<BorderedContainer>
<div className="text-gray-700">{problemDescription}</div>
</BorderedContainer>
+ <BorderedContainer>
+ <div>
+ <h2>サンプルコード</h2>
+ <CodeBlock code={sampleCode} language="php" />
+ </div>
+ </BorderedContainer>
</div>
</div>
<div className="p-4">
@@ -96,7 +105,7 @@ export default function GolfPlayAppGaming({
</div>
<div className="p-4">
<SubmitResult
- result={submitResult}
+ status={status}
submitButton={
<SubmitButton onClick={handleSubmitButtonClick}>
提出
diff --git a/frontend/app/components/GolfWatchApp.client.tsx b/frontend/app/components/GolfWatchApp.client.tsx
deleted file mode 100644
index e80a009..0000000
--- a/frontend/app/components/GolfWatchApp.client.tsx
+++ /dev/null
@@ -1,197 +0,0 @@
-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 {
- 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";
-import GolfWatchAppWaiting from "./GolfWatchApps/GolfWatchAppWaiting";
-
-type GameWatcherMessageS2C = components["schemas"]["GameWatcherMessageS2C"];
-type GameWatcherMessageC2S = never;
-
-type Game = components["schemas"]["Game"];
-
-export type Props = {
- game: Game;
- sockToken: string;
-};
-
-export default function GolfWatchApp({ game, sockToken }: Props) {
- const socketUrl =
- process.env.NODE_ENV === "development"
- ? `ws://localhost:8003/phperkaigi/2025/code-battle/sock/golf/${game.game_id}/watch?token=${sockToken}`
- : `wss://t.nil.ninja/phperkaigi/2025/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 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");
- }
-
- useEffect(() => {
- if (readyState === ReadyState.CLOSING || readyState === ReadyState.CLOSED) {
- handleWsConnectionClosed();
- } else if (readyState === ReadyState.CONNECTING) {
- setGameStateConnecting();
- } else if (readyState === ReadyState.OPEN) {
- if (lastJsonMessage !== null) {
- console.log(lastJsonMessage.type);
- console.log(lastJsonMessage.data);
- if (lastJsonMessage.type === "watcher:s2c:start") {
- const { start_at } = lastJsonMessage.data;
- gameStart(start_at);
- } else if (lastJsonMessage.type === "watcher:s2c: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") {
- 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") {
- 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") {
- 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(),
- );
- },
- );
- }
- } else {
- if (game.started_at) {
- gameStart(game.started_at);
- } else {
- setGameStateWaiting();
- }
- }
- }
- }, [
- game.started_at,
- game.game_id,
- lastJsonMessage,
- readyState,
- gameStart,
- getTargetAtomByPlayerId,
- handleWsCodeMessage,
- handleWsConnectionClosed,
- handleWsExecResultMessage,
- handleWsSubmitMessage,
- handleWsSubmitResultMessage,
- setGameStateConnecting,
- setGameStateWaiting,
- ]);
-
- if (gameStateKind === "connecting") {
- return <GolfWatchAppConnecting />;
- } else if (gameStateKind === "waiting") {
- return (
- <GolfWatchAppWaiting
- gameDisplayName={game.display_name}
- playerProfileA={playerProfileA}
- playerProfileB={playerProfileB}
- />
- );
- } else if (gameStateKind === "starting") {
- return <GolfWatchAppStarting gameDisplayName={game.display_name} />;
- } else if (gameStateKind === "gaming" || gameStateKind === "finished") {
- return (
- <GolfWatchAppGaming
- gameDisplayName={game.display_name}
- playerProfileA={playerProfileA}
- playerProfileB={playerProfileB}
- problemTitle={game.problem.title}
- problemDescription={game.problem.description}
- gameResult={null /* TODO */}
- />
- );
- } else {
- return null;
- }
-}
diff --git a/frontend/app/components/GolfWatchApp.tsx b/frontend/app/components/GolfWatchApp.tsx
new file mode 100644
index 0000000..fe71932
--- /dev/null
+++ b/frontend/app/components/GolfWatchApp.tsx
@@ -0,0 +1,127 @@
+import { useAtomValue, useSetAtom } from "jotai";
+import { useContext, useEffect, useState } from "react";
+import { useTimer } from "react-use-precision-timer";
+import {
+ ApiAuthTokenContext,
+ apiGetGame,
+ apiGetGameWatchLatestStates,
+ apiGetGameWatchRanking,
+} from "../api/client";
+import type { components } from "../api/schema";
+import {
+ gameStateKindAtom,
+ setCurrentTimestampAtom,
+ setGameStartedAtAtom,
+ setLatestGameStatesAtom,
+ setRankingAtom,
+} from "../states/watch";
+import GolfWatchAppGaming from "./GolfWatchApps/GolfWatchAppGaming";
+import GolfWatchAppStarting from "./GolfWatchApps/GolfWatchAppStarting";
+import GolfWatchAppWaiting from "./GolfWatchApps/GolfWatchAppWaiting";
+
+type Game = components["schemas"]["Game"];
+
+export type Props = {
+ game: Game;
+};
+
+export default function GolfWatchApp({ game }: Props) {
+ const apiAuthToken = useContext(ApiAuthTokenContext);
+
+ const gameStateKind = useAtomValue(gameStateKindAtom);
+ const setGameStartedAt = useSetAtom(setGameStartedAtAtom);
+ const setCurrentTimestamp = useSetAtom(setCurrentTimestampAtom);
+ const setLatestGameStates = useSetAtom(setLatestGameStatesAtom);
+ const setRanking = useSetAtom(setRankingAtom);
+
+ useTimer({ delay: 1000, startImmediately: true }, setCurrentTimestamp);
+
+ const playerA = game.main_players[0]!;
+ const playerB = game.main_players[1]!;
+
+ const playerProfileA = {
+ id: playerA.user_id,
+ displayName: playerA.display_name,
+ iconPath: playerA.icon_path ?? null,
+ };
+ const playerProfileB = {
+ id: playerB.user_id,
+ displayName: playerB.display_name,
+ iconPath: playerB.icon_path ?? null,
+ };
+
+ const [isDataPolling, setIsDataPolling] = useState(false);
+
+ useEffect(() => {
+ if (isDataPolling) {
+ return;
+ }
+ const timerId = setInterval(async () => {
+ if (isDataPolling) {
+ return;
+ }
+ setIsDataPolling(true);
+
+ try {
+ if (gameStateKind === "waiting") {
+ const { game: g } = await apiGetGame(apiAuthToken, game.game_id);
+ if (g.started_at != null) {
+ setGameStartedAt(g.started_at);
+ }
+ } else if (gameStateKind === "gaming") {
+ const { states } = await apiGetGameWatchLatestStates(
+ apiAuthToken,
+ game.game_id,
+ );
+ setLatestGameStates(states);
+ const { ranking } = await apiGetGameWatchRanking(
+ apiAuthToken,
+ game.game_id,
+ );
+ setRanking(ranking);
+ }
+ } catch (error) {
+ console.error(error);
+ } finally {
+ setIsDataPolling(false);
+ }
+ }, 1000);
+
+ return () => {
+ clearInterval(timerId);
+ };
+ }, [
+ isDataPolling,
+ apiAuthToken,
+ game.game_id,
+ gameStateKind,
+ setGameStartedAt,
+ setLatestGameStates,
+ setRanking,
+ ]);
+
+ if (gameStateKind === "waiting") {
+ return (
+ <GolfWatchAppWaiting
+ gameDisplayName={game.display_name}
+ playerProfileA={playerProfileA}
+ playerProfileB={playerProfileB}
+ />
+ );
+ } else if (gameStateKind === "starting") {
+ return <GolfWatchAppStarting gameDisplayName={game.display_name} />;
+ } else if (gameStateKind === "gaming" || gameStateKind === "finished") {
+ return (
+ <GolfWatchAppGaming
+ gameDisplayName={game.display_name}
+ playerProfileA={playerProfileA}
+ playerProfileB={playerProfileB}
+ problemTitle={game.problem.title}
+ problemDescription={game.problem.description}
+ gameResult={null /* TODO */}
+ />
+ );
+ } else {
+ return null;
+ }
+}
diff --git a/frontend/app/components/GolfWatchApps/GolfWatchAppConnecting.tsx b/frontend/app/components/GolfWatchApps/GolfWatchAppConnecting.tsx
deleted file mode 100644
index 07a1be8..0000000
--- a/frontend/app/components/GolfWatchApps/GolfWatchAppConnecting.tsx
+++ /dev/null
@@ -1,9 +0,0 @@
-export default function GolfWatchAppConnecting() {
- return (
- <div className="min-h-screen bg-gray-100 flex items-center justify-center">
- <div className="text-center">
- <div className="text-6xl font-bold text-black">接続中...</div>
- </div>
- </div>
- );
-}
diff --git a/frontend/app/components/GolfWatchApps/GolfWatchAppGaming.tsx b/frontend/app/components/GolfWatchApps/GolfWatchAppGaming.tsx
index 7cfbc86..afb8bfe 100644
--- a/frontend/app/components/GolfWatchApps/GolfWatchAppGaming.tsx
+++ b/frontend/app/components/GolfWatchApps/GolfWatchAppGaming.tsx
@@ -1,12 +1,7 @@
import { useAtomValue } from "jotai";
import {
- codeAAtom,
- codeBAtom,
gamingLeftTimeSecondsAtom,
- scoreAAtom,
- scoreBAtom,
- submitResultAAtom,
- submitResultBAtom,
+ latestGameStatesAtom,
} from "../../states/watch";
import type { PlayerProfile } from "../../types/PlayerProfile";
import BorderedContainer from "../BorderedContainer";
@@ -33,12 +28,16 @@ export default function GolfWatchAppGaming({
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 latestGameStates = useAtomValue(latestGameStatesAtom);
+
+ const stateA = latestGameStates[playerProfileA.id]!;
+ const codeA = stateA.code;
+ const scoreA = stateA.score;
+ const statusA = stateA.status;
+ const stateB = latestGameStates[playerProfileB.id]!;
+ const codeB = stateB.code;
+ const scoreB = stateB.score;
+ const statusB = stateB.status;
const leftTime = (() => {
const m = Math.floor(leftTimeSeconds / 60);
@@ -109,11 +108,11 @@ export default function GolfWatchAppGaming({
bgB="bg-purple-400"
/>
<div className="grow grid grid-cols-3 p-4 gap-4">
- <CodeBlock code={codeA} language="swift" />
+ <CodeBlock code={codeA} language="php" />
<div className="flex flex-col gap-4">
<div className="grid grid-cols-2 gap-4">
- <SubmitResult result={submitResultA} />
- <SubmitResult result={submitResultB} />
+ <SubmitResult status={statusA} />
+ <SubmitResult status={statusB} />
</div>
<div>
<div className="mb-2 text-center text-xl font-bold">
@@ -122,7 +121,7 @@ export default function GolfWatchAppGaming({
<BorderedContainer>{problemDescription}</BorderedContainer>
</div>
</div>
- <CodeBlock code={codeB} language="swift" />
+ <CodeBlock code={codeB} language="php" />
</div>
</div>
);
diff --git a/frontend/app/components/SubmitStatusLabel.tsx b/frontend/app/components/SubmitStatusLabel.tsx
index d1dc89c..8384e95 100644
--- a/frontend/app/components/SubmitStatusLabel.tsx
+++ b/frontend/app/components/SubmitStatusLabel.tsx
@@ -1,12 +1,12 @@
-import type { SubmitResultStatus } from "../types/SubmitResult";
+import type { components } from "../api/schema";
type Props = {
- status: SubmitResultStatus;
+ status: components["schemas"]["ExecutionStatus"];
};
export default function SubmitStatusLabel({ status }: Props) {
switch (status) {
- case "waiting_submission":
+ case "none":
return "提出待ち";
case "running":
return "実行中...";
@@ -16,8 +16,6 @@ export default function SubmitStatusLabel({ status }: Props) {
return "テスト失敗";
case "timeout":
return "時間切れ";
- case "compile_error":
- return "コンパイルエラー";
case "runtime_error":
return "実行時エラー";
case "internal_error":
diff --git a/frontend/app/hooks/useWebSocket.ts b/frontend/app/hooks/useWebSocket.ts
deleted file mode 100644
index 8fe688f..0000000
--- a/frontend/app/hooks/useWebSocket.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-import useWebSocketOriginal, { ReadyState } from "react-use-websocket";
-
-export { ReadyState };
-
-// Typed version of useWebSocket() hook.
-export default function useWebSocket<ReceiveMessage, SendMessage>(
- url: string,
-): {
- sendJsonMessage: (message: SendMessage) => void;
- lastJsonMessage: ReceiveMessage;
- readyState: ReadyState;
-} {
- return useWebSocketOriginal(url);
-}
diff --git a/frontend/app/routes/dashboard.tsx b/frontend/app/routes/dashboard.tsx
index cf5453c..08461a5 100644
--- a/frontend/app/routes/dashboard.tsx
+++ b/frontend/app/routes/dashboard.tsx
@@ -1,7 +1,7 @@
import type { LoaderFunctionArgs, MetaFunction } from "@remix-run/node";
import { Form, useLoaderData } from "@remix-run/react";
-import { apiGetGames } from "../.server/api/client";
import { ensureUserLoggedIn } from "../.server/auth";
+import { apiGetGames } from "../api/client";
import BorderedContainer from "../components/BorderedContainer";
import NavigateLink from "../components/NavigateLink";
import UserIcon from "../components/UserIcon";
@@ -39,7 +39,7 @@ export default function Dashboard() {
<BorderedContainer>
<div className="px-4">
{games.length === 0 ? (
- <p>エントリーしている試合はありません</p>
+ <p>エントリーできる試合はありません</p>
) : (
<ul className="divide-y">
{games.map((game) => (
@@ -58,15 +58,12 @@ export default function Dashboard() {
</span>
</div>
<span>
- {game.state === "closed" || game.state === "finished" ? (
- <span className="text-lg text-gray-400 bg-gray-200 px-4 py-2 rounded">
- 入室
- </span>
- ) : (
- <NavigateLink to={`/golf/${game.game_id}/play`}>
- 入室
- </NavigateLink>
- )}
+ <NavigateLink to={`/golf/${game.game_id}/play`}>
+ 対戦
+ </NavigateLink>
+ <NavigateLink to={`/golf/${game.game_id}/watch`}>
+ 観戦
+ </NavigateLink>
</span>
</li>
))}
diff --git a/frontend/app/routes/golf.$gameId.play.tsx b/frontend/app/routes/golf.$gameId.play.tsx
index 91a2b8c..e523187 100644
--- a/frontend/app/routes/golf.$gameId.play.tsx
+++ b/frontend/app/routes/golf.$gameId.play.tsx
@@ -1,17 +1,19 @@
import type { LoaderFunctionArgs, MetaFunction } from "@remix-run/node";
-import { ClientLoaderFunctionArgs, useLoaderData } from "@remix-run/react";
+import { 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,
+ ApiAuthTokenContext,
+ apiGetGame,
+ apiGetGamePlayLatestState,
+} from "../api/client";
+import GolfPlayApp from "../components/GolfPlayApp";
+import {
setCurrentTimestampAtom,
setDurationSecondsAtom,
- submitResultAtom,
+ setGameStartedAtAtom,
+ setLatestGameStateAtom,
} from "../states/play";
-import { PlayerState } from "../types/PlayerState";
export const meta: MetaFunction<typeof loader> = ({ data }) => [
{
@@ -24,105 +26,39 @@ export const meta: MetaFunction<typeof loader> = ({ data }) => [
export async function loader({ params, request }: LoaderFunctionArgs) {
const { token, user } = await ensureUserLoggedIn(request);
+ const gameId = Number(params.gameId);
+
const fetchGame = async () => {
- return (await apiGetGame(token, Number(params.gameId))).game;
+ return (await apiGetGame(token, gameId)).game;
};
- const fetchSockToken = async () => {
- return (await apiGetToken(token)).token;
+ const fetchGameState = async () => {
+ return (await apiGetGamePlayLatestState(token, gameId)).state;
};
- 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: "",
- })),
- },
- };
+ const [game, state] = await Promise.all([fetchGame(), fetchGameState()]);
return {
+ apiAuthToken: token,
game,
player: user,
- sockToken,
- playerState,
+ gameState: state,
};
}
-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, playerState } =
+ const { apiAuthToken, game, player, gameState } =
useLoaderData<typeof loader>();
useHydrateAtoms([
[setCurrentTimestampAtom, undefined],
[setDurationSecondsAtom, game.duration_seconds],
- [scoreAtom, playerState.score],
- [submitResultAtom, playerState.submitResult],
+ [setGameStartedAtAtom, game.started_at ?? null],
+ [setLatestGameStateAtom, gameState],
]);
return (
- <GolfPlayApp
- game={game}
- player={player}
- initialCode={playerState.code}
- sockToken={sockToken}
- />
+ <ApiAuthTokenContext.Provider value={apiAuthToken}>
+ <GolfPlayApp game={game} player={player} initialCode={gameState.code} />
+ </ApiAuthTokenContext.Provider>
);
}
diff --git a/frontend/app/routes/golf.$gameId.watch.tsx b/frontend/app/routes/golf.$gameId.watch.tsx
index 5a41de5..0c07633 100644
--- a/frontend/app/routes/golf.$gameId.watch.tsx
+++ b/frontend/app/routes/golf.$gameId.watch.tsx
@@ -1,21 +1,21 @@
import type { LoaderFunctionArgs, MetaFunction } from "@remix-run/node";
-import { ClientLoaderFunctionArgs, useLoaderData } from "@remix-run/react";
+import { useLoaderData } from "@remix-run/react";
import { useHydrateAtoms } from "jotai/utils";
-import { apiGetGame, apiGetToken } from "../.server/api/client";
import { ensureUserLoggedIn } from "../.server/auth";
-import GolfWatchApp from "../components/GolfWatchApp.client";
-import GolfWatchAppConnecting from "../components/GolfWatchApps/GolfWatchAppConnecting";
import {
- codeAAtom,
- codeBAtom,
- scoreAAtom,
- scoreBAtom,
+ ApiAuthTokenContext,
+ apiGetGame,
+ apiGetGameWatchLatestStates,
+ apiGetGameWatchRanking,
+} from "../api/client";
+import GolfWatchApp from "../components/GolfWatchApp";
+import {
setCurrentTimestampAtom,
setDurationSecondsAtom,
- submitResultAAtom,
- submitResultBAtom,
+ setGameStartedAtAtom,
+ setLatestGameStatesAtom,
+ setRankingAtom,
} from "../states/watch";
-import { PlayerState } from "../types/PlayerState";
export const meta: MetaFunction<typeof loader> = ({ data }) => [
{
@@ -28,160 +28,47 @@ export const meta: MetaFunction<typeof loader> = ({ data }) => [
export async function loader({ params, request }: LoaderFunctionArgs) {
const { token } = await ensureUserLoggedIn(request);
+ const gameId = Number(params.gameId);
+
const fetchGame = async () => {
- return (await apiGetGame(token, Number(params.gameId))).game;
+ return (await apiGetGame(token, gameId)).game;
};
- const fetchSockToken = async () => {
- return (await apiGetToken(token)).token;
+ const fetchRanking = async () => {
+ return (await apiGetGameWatchRanking(token, gameId)).ranking;
};
-
- const [game, sockToken] = await Promise.all([fetchGame(), fetchSockToken()]);
-
- if (game.game_type !== "1v1") {
- 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 fetchGameStates = async () => {
+ return (await apiGetGameWatchLatestStates(token, gameId)).states;
};
- const playerStateB = structuredClone(playerStateA);
+
+ const [game, ranking, gameStates] = await Promise.all([
+ fetchGame(),
+ fetchRanking(),
+ fetchGameStates(),
+ ]);
return {
+ apiAuthToken: token,
game,
- sockToken,
- playerStateA,
- playerStateB,
+ ranking,
+ gameStates,
};
}
-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, playerStateA, playerStateB } =
+ const { apiAuthToken, game, ranking, gameStates } =
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],
+ [setGameStartedAtAtom, game.started_at ?? null],
+ [setRankingAtom, ranking],
+ [setLatestGameStatesAtom, gameStates],
]);
- return <GolfWatchApp game={game} sockToken={sockToken} />;
+ return (
+ <ApiAuthTokenContext.Provider value={apiAuthToken}>
+ <GolfWatchApp game={game} />
+ </ApiAuthTokenContext.Provider>
+ );
}
diff --git a/frontend/app/states/play.ts b/frontend/app/states/play.ts
index 1684367..e7774cb 100644
--- a/frontend/app/states/play.ts
+++ b/frontend/app/states/play.ts
@@ -1,190 +1,90 @@
import { atom } from "jotai";
-import type { components } from "../.server/api/schema";
-import type { SubmitResult } from "../types/SubmitResult";
+import type { components } from "../api/schema";
-type RawGameState =
- | {
- kind: "connecting";
- startedAtTimestamp: null;
- }
- | {
- kind: "waiting";
- startedAtTimestamp: null;
- }
- | {
- kind: "starting";
- startedAtTimestamp: number;
- };
-
-const rawGameStateAtom = atom<RawGameState>({
- kind: "connecting",
- startedAtTimestamp: null,
-});
+const gameStartedAtAtom = atom<number | null>(null);
+export const setGameStartedAtAtom = atom(null, (_, set, value: number | null) =>
+ set(gameStartedAtAtom, value),
+);
-export type GameStateKind =
- | "connecting"
- | "waiting"
- | "starting"
- | "gaming"
- | "finished";
+export type GameStateKind = "waiting" | "starting" | "gaming" | "finished";
+type ExecutionStatus = components["schemas"]["ExecutionStatus"];
+type LatestGameState = components["schemas"]["LatestGameState"];
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";
- }
+ const startedAt = get(gameStartedAtAtom);
+ if (!startedAt) {
+ return "waiting";
}
-});
-export const gameStartAtom = atom(null, (get, set, value: number) => {
- const { kind } = get(rawGameStateAtom);
- if (kind === "starting") {
- return;
+ const durationSeconds = get(durationSecondsAtom);
+ const finishedAt = startedAt + durationSeconds;
+ const now = get(currentTimestampAtom);
+ if (now < startedAt) {
+ return "starting";
+ } else if (now < finishedAt) {
+ return "gaming";
+ } else {
+ return "finished";
}
- 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);
+const currentTimestampAtom = atom(0);
export const setCurrentTimestampAtom = atom(null, (_, set) =>
- set(rawCurrentTimestampAtom, Math.floor(Date.now() / 1000)),
+ set(currentTimestampAtom, Math.floor(Date.now() / 1000)),
);
-const rawDurationSecondsAtom = atom<number>(0);
+const durationSecondsAtom = atom<number>(0);
export const setDurationSecondsAtom = atom(null, (_, set, value: number) =>
- set(rawDurationSecondsAtom, value),
+ set(durationSecondsAtom, value),
);
export const startingLeftTimeSecondsAtom = atom<number | null>((get) => {
- const { startedAtTimestamp } = get(rawGameStateAtom);
- if (startedAtTimestamp === null) {
+ const startedAt = get(gameStartedAtAtom);
+ if (startedAt === null) {
return null;
}
- const currentTimestamp = get(rawCurrentTimestampAtom);
- return Math.max(0, startedAtTimestamp - currentTimestamp);
+ const currentTimestamp = get(currentTimestampAtom);
+ return Math.max(0, startedAt - currentTimestamp);
});
export const gamingLeftTimeSecondsAtom = atom<number | null>((get) => {
- const { startedAtTimestamp } = get(rawGameStateAtom);
- if (startedAtTimestamp === null) {
+ const startedAt = get(gameStartedAtAtom);
+ if (startedAt === null) {
return null;
}
- const durationSeconds = get(rawDurationSecondsAtom);
- const finishedAtTimestamp = startedAtTimestamp + durationSeconds;
- const currentTimestamp = get(rawCurrentTimestampAtom);
- return Math.min(
- durationSeconds,
- Math.max(0, finishedAtTimestamp - currentTimestamp),
- );
+ const durationSeconds = get(durationSecondsAtom);
+ const finishedAt = startedAt + durationSeconds;
+ const currentTimestamp = get(currentTimestampAtom);
+ return Math.min(durationSeconds, Math.max(0, finishedAt - currentTimestamp));
});
-export const handleWsConnectionClosedAtom = atom(null, (get, set) => {
- const kind = get(gameStateKindAtom);
- if (kind !== "finished") {
- set(setGameStateConnectingAtom);
+const rawStatusAtom = atom<ExecutionStatus>("none");
+const rawScoreAtom = atom<number | null>(null);
+export const statusAtom = atom<ExecutionStatus>((get) => {
+ const isSubmittingCode = get(isSubmittingCodeAtom);
+ if (isSubmittingCode) {
+ return "running";
+ } else {
+ return get(rawStatusAtom);
}
});
-
-export const scoreAtom = atom<number | null>(null);
-export const submitResultAtom = atom<SubmitResult>({
- status: "waiting_submission",
- execResults: [],
+export const scoreAtom = atom<number | null>((get) => {
+ return get(rawScoreAtom);
});
-export const handleSubmitCodeAtom = atom(null, (_, set) => {
- set(submitResultAtom, (prev) => ({
- status: "running",
- execResults: prev.execResults.map((r) => ({
- ...r,
- status: "running",
- stdout: "",
- stderr: "",
- })),
- }));
+const rawIsSubmittingCodeAtom = atom(false);
+export const isSubmittingCodeAtom = atom((get) => get(rawIsSubmittingCodeAtom));
+export const handleSubmitCodePreAtom = atom(null, (_, set) => {
+ set(rawIsSubmittingCodeAtom, true);
+});
+export const handleSubmitCodePostAtom = atom(null, (_, set) => {
+ set(rawIsSubmittingCodeAtom, false);
});
-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(
+export const setLatestGameStateAtom = 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,
- );
- } else {
- newResult.execResults = prev.execResults.map((r) => ({
- ...r,
- status: "success",
- }));
- }
- set(submitResultAtom, newResult);
- if (status === "success" && score !== null) {
- const currentScore = get(scoreAtom);
- if (currentScore === null || score < currentScore) {
- set(scoreAtom, score);
- }
- }
- callback(newResult, score);
+ (_, set, value: LatestGameState) => {
+ set(rawStatusAtom, value.status);
+ set(rawScoreAtom, value.score);
},
);
diff --git a/frontend/app/states/watch.ts b/frontend/app/states/watch.ts
index cb719eb..ad95e0a 100644
--- a/frontend/app/states/watch.ts
+++ b/frontend/app/states/watch.ts
@@ -1,252 +1,75 @@
import { atom } from "jotai";
-import type { components } from "../.server/api/schema";
-import type { SubmitResult } from "../types/SubmitResult";
+import type { components } from "../api/schema";
-type RawGameState =
- | {
- kind: "connecting";
- startedAtTimestamp: null;
- }
- | {
- kind: "waiting";
- startedAtTimestamp: null;
- }
- | {
- kind: "starting";
- startedAtTimestamp: number;
- };
-
-const rawGameStateAtom = atom<RawGameState>({
- kind: "connecting",
- startedAtTimestamp: null,
-});
+const gameStartedAtAtom = atom<number | null>(null);
+export const setGameStartedAtAtom = atom(null, (_, set, value: number | null) =>
+ set(gameStartedAtAtom, value),
+);
-export type GameStateKind =
- | "connecting"
- | "waiting"
- | "starting"
- | "gaming"
- | "finished";
+export type GameStateKind = "waiting" | "starting" | "gaming" | "finished";
+type LatestGameState = components["schemas"]["LatestGameState"];
+type RankingEntry = components["schemas"]["RankingEntry"];
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";
- }
+ const startedAt = get(gameStartedAtAtom);
+ if (!startedAt) {
+ return "waiting";
}
-});
-export const gameStartAtom = atom(null, (get, set, value: number) => {
- const { kind } = get(rawGameStateAtom);
- if (kind === "starting") {
- return;
+ const durationSeconds = get(durationSecondsAtom);
+ const finishedAt = startedAt + durationSeconds;
+ const now = get(currentTimestampAtom);
+ if (now < startedAt) {
+ return "starting";
+ } else if (now < finishedAt) {
+ return "gaming";
+ } else {
+ return "finished";
}
- 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);
+const currentTimestampAtom = atom(0);
export const setCurrentTimestampAtom = atom(null, (_, set) =>
- set(rawCurrentTimestampAtom, Math.floor(Date.now() / 1000)),
+ set(currentTimestampAtom, Math.floor(Date.now() / 1000)),
);
-const rawDurationSecondsAtom = atom<number>(0);
+const durationSecondsAtom = atom<number>(0);
export const setDurationSecondsAtom = atom(null, (_, set, value: number) =>
- set(rawDurationSecondsAtom, value),
+ set(durationSecondsAtom, value),
);
export const startingLeftTimeSecondsAtom = atom<number | null>((get) => {
- const { startedAtTimestamp } = get(rawGameStateAtom);
- if (startedAtTimestamp === null) {
+ const startedAt = get(gameStartedAtAtom);
+ if (startedAt === null) {
return null;
}
- const currentTimestamp = get(rawCurrentTimestampAtom);
- return Math.max(0, startedAtTimestamp - currentTimestamp);
+ const currentTimestamp = get(currentTimestampAtom);
+ return Math.max(0, startedAt - currentTimestamp);
});
export const gamingLeftTimeSecondsAtom = atom<number | null>((get) => {
- const { startedAtTimestamp } = get(rawGameStateAtom);
- if (startedAtTimestamp === null) {
+ const startedAt = get(gameStartedAtAtom);
+ if (startedAt === 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);
- }
+ const durationSeconds = get(durationSecondsAtom);
+ const finishedAt = startedAt + durationSeconds;
+ const currentTimestamp = get(currentTimestampAtom);
+ return Math.min(durationSeconds, Math.max(0, finishedAt - currentTimestamp));
});
-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: [],
+const rankingAtom = atom<RankingEntry[]>([]);
+export const setRankingAtom = atom(null, (_, set, value: RankingEntry[]) => {
+ set(rankingAtom, value);
});
-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(
+const rawLatestGameStatesAtom = atom<{
+ [key: string]: LatestGameState | undefined;
+}>({});
+export const latestGameStatesAtom = atom((get) => get(rawLatestGameStatesAtom));
+export const setLatestGameStatesAtom = 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,
- );
- } else {
- newResult.execResults = prev.execResults.map((r) => ({
- ...r,
- status: "success",
- }));
- }
- 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);
+ (_, set, value: { [key: string]: LatestGameState | undefined }) => {
+ set(rawLatestGameStatesAtom, value);
},
);
diff --git a/frontend/app/types/ExecResult.ts b/frontend/app/types/ExecResult.ts
deleted file mode 100644
index e0b6bb4..0000000
--- a/frontend/app/types/ExecResult.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-export type ExecResultStatus =
- | "waiting_submission"
- | "running"
- | "success"
- | "wrong_answer"
- | "timeout"
- | "compile_error"
- | "runtime_error"
- | "internal_error"
- | "canceled";
-
-export type ExecResult = {
- testcase_id: number | null;
- status: ExecResultStatus;
- label: string;
- stdout: string;
- stderr: string;
-};
diff --git a/frontend/app/types/PlayerInfo.ts b/frontend/app/types/PlayerInfo.ts
deleted file mode 100644
index e282ba9..0000000
--- a/frontend/app/types/PlayerInfo.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-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
index 42bdcb8..2e9c16a 100644
--- a/frontend/app/types/PlayerProfile.ts
+++ b/frontend/app/types/PlayerProfile.ts
@@ -1,4 +1,5 @@
export type PlayerProfile = {
+ id: number;
displayName: string;
iconPath: string | null;
};
diff --git a/frontend/app/types/PlayerState.ts b/frontend/app/types/PlayerState.ts
deleted file mode 100644
index e2a2da9..0000000
--- a/frontend/app/types/PlayerState.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-import type { SubmitResult } from "./SubmitResult";
-
-export type PlayerState = {
- score: number | null;
- code: string;
- submitResult: SubmitResult;
-};
diff --git a/frontend/app/types/SubmitResult.ts b/frontend/app/types/SubmitResult.ts
deleted file mode 100644
index 6df00b6..0000000
--- a/frontend/app/types/SubmitResult.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-import type { ExecResult } from "./ExecResult";
-
-export type SubmitResultStatus =
- | "waiting_submission"
- | "running"
- | "success"
- | "wrong_answer"
- | "timeout"
- | "compile_error"
- | "runtime_error"
- | "internal_error";
-
-export type SubmitResult = {
- status: SubmitResultStatus;
- execResults: ExecResult[];
-};