diff options
Diffstat (limited to 'frontend/app')
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[]; -}; |
