diff --git a/apps/backend/drizzle/migrations/0000_bouncy_reaper.sql b/apps/backend/drizzle/migrations/0000_bouncy_reaper.sql deleted file mode 100644 index 18c19b2..0000000 --- a/apps/backend/drizzle/migrations/0000_bouncy_reaper.sql +++ /dev/null @@ -1,30 +0,0 @@ -CREATE TABLE "RoomSecret" ( - "roomId" text PRIMARY KEY NOT NULL, - "secret" text NOT NULL, - CONSTRAINT "RoomSecret_secret_unique" UNIQUE("secret") -); ---> statement-breakpoint -CREATE TABLE "Room" ( - "id" text PRIMARY KEY NOT NULL, - "name" text NOT NULL, - "createdAt" timestamp with time zone DEFAULT now(), - "users" text[] NOT NULL, - "hostId" text NOT NULL -); ---> statement-breakpoint -CREATE TABLE "Session" ( - "id" text PRIMARY KEY NOT NULL, - "userId" text NOT NULL, - "sessionToken" text NOT NULL, - CONSTRAINT "Session_sessionToken_unique" UNIQUE("sessionToken") -); ---> statement-breakpoint -CREATE TABLE "User" ( - "id" text PRIMARY KEY NOT NULL, - "createdAt" timestamp with time zone DEFAULT now(), - "name" text NOT NULL -); ---> statement-breakpoint -ALTER TABLE "RoomSecret" ADD CONSTRAINT "RoomSecret_roomId_Room_id_fk" FOREIGN KEY ("roomId") REFERENCES "public"."Room"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "Room" ADD CONSTRAINT "Room_hostId_User_id_fk" FOREIGN KEY ("hostId") REFERENCES "public"."User"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_User_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE cascade ON UPDATE no action; \ No newline at end of file diff --git a/apps/backend/drizzle/migrations/meta/0000_snapshot.json b/apps/backend/drizzle/migrations/meta/0000_snapshot.json deleted file mode 100644 index c2221b3..0000000 --- a/apps/backend/drizzle/migrations/meta/0000_snapshot.json +++ /dev/null @@ -1,193 +0,0 @@ -{ - "id": "b05bb5ae-e798-4dbf-b97e-efee1a014a4b", - "prevId": "00000000-0000-0000-0000-000000000000", - "version": "7", - "dialect": "postgresql", - "tables": { - "public.RoomSecret": { - "name": "RoomSecret", - "schema": "", - "columns": { - "roomId": { - "name": "roomId", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "secret": { - "name": "secret", - "type": "text", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": { - "RoomSecret_roomId_Room_id_fk": { - "name": "RoomSecret_roomId_Room_id_fk", - "tableFrom": "RoomSecret", - "tableTo": "Room", - "columnsFrom": ["roomId"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "RoomSecret_secret_unique": { - "name": "RoomSecret_secret_unique", - "nullsNotDistinct": false, - "columns": ["secret"] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.Room": { - "name": "Room", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "createdAt": { - "name": "createdAt", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false, - "default": "now()" - }, - "users": { - "name": "users", - "type": "text[]", - "primaryKey": false, - "notNull": true - }, - "hostId": { - "name": "hostId", - "type": "text", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": { - "Room_hostId_User_id_fk": { - "name": "Room_hostId_User_id_fk", - "tableFrom": "Room", - "tableTo": "User", - "columnsFrom": ["hostId"], - "columnsTo": ["id"], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.Session": { - "name": "Session", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "userId": { - "name": "userId", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "sessionToken": { - "name": "sessionToken", - "type": "text", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": { - "Session_userId_User_id_fk": { - "name": "Session_userId_User_id_fk", - "tableFrom": "Session", - "tableTo": "User", - "columnsFrom": ["userId"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "Session_sessionToken_unique": { - "name": "Session_sessionToken_unique", - "nullsNotDistinct": false, - "columns": ["sessionToken"] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.User": { - "name": "User", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "createdAt": { - "name": "createdAt", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false, - "default": "now()" - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - } - }, - "enums": {}, - "schemas": {}, - "sequences": {}, - "roles": {}, - "policies": {}, - "views": {}, - "_meta": { - "columns": {}, - "schemas": {}, - "tables": {} - } -} diff --git a/apps/backend/drizzle/migrations/meta/_journal.json b/apps/backend/drizzle/migrations/meta/_journal.json deleted file mode 100644 index 66a35a0..0000000 --- a/apps/backend/drizzle/migrations/meta/_journal.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "version": "7", - "dialect": "postgresql", - "entries": [ - { - "idx": 0, - "version": "7", - "when": 1758695334972, - "tag": "0000_bouncy_reaper", - "breakpoints": true - }, - { - "idx": 1, - "version": "7", - "when": 1761641407204, - "tag": "0001_true_meggan", - "breakpoints": true - } - ] -} \ No newline at end of file diff --git a/apps/backend/src/room.ts b/apps/backend/src/room.ts index 305982f..01322b1 100644 --- a/apps/backend/src/room.ts +++ b/apps/backend/src/room.ts @@ -57,7 +57,7 @@ export abstract class RoomMatch extends DurableObject { } const { 0: client, 1: server } = new WebSocketPair(); - await this.handleSession(server, playerId, playerName); + await this.handleSession(server, playerId, playerName, server); return new Response(null, { status: 101, @@ -65,25 +65,37 @@ export abstract class RoomMatch extends DurableObject { }); } - async handleSession(ws: WebSocket, playerId: string, playerName: string) { + async handleSession( + ws: WebSocket, + playerId: string, + playerName: string, + server: WebSocket, + ) { const session: Session = { ws, playerId }; this.sessions.push(session); - ws.accept(); + this.ctx.acceptWebSocket(server); await this.addPlayer(playerId, playerName); - ws.addEventListener("message", async (msg) => { - this.wsMessageListener(ws, msg, playerId); - }); + ws.send(JSON.stringify({ type: "state", payload: this.state })); + } - const closeOrErrorHandler = () => { - this.sessions = this.sessions.filter((s) => s !== session); - this.updateDisconnectedPlayer(playerId); - }; - ws.addEventListener("close", closeOrErrorHandler); - ws.addEventListener("error", closeOrErrorHandler); + async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer) { + const playerId = this.sessions.find((s) => s.ws === ws)?.playerId; + if (!playerId) { + console.error("[WS] No playerId found for WebSocket"); + return; + } + const messageEvent = new MessageEvent("message", { data: message }); + await this.wsMessageListener(ws, messageEvent, playerId); + } - ws.send(JSON.stringify({ type: "state", payload: this.state })); + async webSocketClose(ws: WebSocket) { + const session = this.sessions.find((s) => s.ws === ws); + if (session) { + this.sessions = this.sessions.filter((s) => s !== session); + this.updateDisconnectedPlayer(session.playerId); + } } broadcast(message: unknown) { diff --git a/apps/frontend/app/routes/magic-square/home.tsx b/apps/frontend/app/routes/magic-square/home.tsx index d00c3b5..747ca9c 100644 --- a/apps/frontend/app/routes/magic-square/home.tsx +++ b/apps/frontend/app/routes/magic-square/home.tsx @@ -1,6 +1,14 @@ import type { User } from "@apps/backend"; import { useEffect, useState } from "react"; -import { useLoaderData, useNavigate } from "react-router"; +import { + type ClientActionFunctionArgs, + Form, + redirect, + useActionData, + useLoaderData, + useNavigation, + useSubmit, +} from "react-router"; import { client } from "~/lib/client"; import { IS_DEV } from "~/lib/env"; @@ -11,229 +19,264 @@ type Room = { }; export async function clientLoader() { + let user: (User & { createdAt: Date | null }) | null = null; + let rooms: Room[] = []; + try { const res = await client.users.me.$get({}); - - if (!res.ok) throw new Error("Failed to fetch user.", { cause: res }); - const user = await res.json(); - const createdAt = user.createdAt ? new Date(user.createdAt) : null; - - return { ...user, createdAt }; + if (res.ok) { + const data = await res.json(); + user = { + ...data, + createdAt: data.createdAt ? new Date(data.createdAt) : null, + }; + } else { + const newUserName = `player-${Math.floor(Math.random() * 100000)}`; + const createRes = await client.users.create.$post({ + json: { name: newUserName }, + }); + const data = await createRes.json(); + user = { + ...data, + createdAt: data.createdAt ? new Date(data.createdAt) : null, + }; + } } catch (e) { - console.error(e); + console.error("User fetch/create failed", e); return null; } + + if (IS_DEV) { + try { + const res = await client.rooms.$get(); + if (res.ok) { + rooms = await res.json(); + } + } catch (e) { + console.error("Rooms fetch failed", e); + } + } + + return { user, rooms }; } -export default function Lobby() { - const me = useLoaderData(); - const [user, setUser] = useState(me ?? null); - const [rooms, setRooms] = useState([]); - const [userName, setUserName] = useState(user?.name ?? ""); - const [newUserName, setNewUserName] = useState(""); - const [newRoomName, setNewRoomName] = useState(""); - const [joinRoomSecret, setJoinRoomSecret] = useState(""); - const [joinError, setJoinError] = useState(null); - const navigate = useNavigate(); - const [step, setStep] = useState(0); - const [isEditingName, setIsEditingName] = useState(false); +export async function clientAction({ request }: ClientActionFunctionArgs) { + const formData = await request.formData(); + const intent = formData.get("intent"); - const instructions = [ - "盤上に数字を置いていき、自分のミッションを誰よりも早く達成することを狙うゲームです。", - "自分の番になったら、手札から数字を選び、次に「+」(加算)、「-」(減算)のいずれかを選びます。(パスも可)", - "盤上のマス目を選択すると、選んだカードの数字が加算/減算され、ターンが終了します。", - "制限時間を過ぎると強制的にパスになるので注意!", - ]; + try { + switch (intent) { + case "create-room": { + const nameInput = formData.get("roomName") as string; + const roomName = + nameInput || `room-${Math.floor(Math.random() * 100000)}`; - useEffect(() => { - if (!IS_DEV) return; - const fetchRooms = async () => { - const res = await client.rooms.$get(); - if (res.ok) { - const data = await res.json(); - setRooms(data); + const res = await client.rooms.create.$post({ + json: { name: roomName, gameTitle: "magic-square" }, + }); + + if (!res.ok) return { error: "Failed to create room" }; + const newRoom = await res.json(); + return redirect(`/magic-square/room/${newRoom.id}`); } - }; - fetchRooms(); - }, []); - useEffect(() => { - if (user) return; - const newUserName = `player-${Math.floor(Math.random() * 100000)}`; - const handleCreateUser = async () => { - const res = await client.users.create.$post({ - json: { name: newUserName }, - }); - const data = await res.json(); - const createdAt = data.createdAt ? new Date(data.createdAt) : null; - setUser({ ...data, createdAt }); - setUserName(data.name); - }; - handleCreateUser(); - }, [user]); - - const handleCreateRoom = async () => { - const roomName = - newRoomName || `room-${Math.floor(Math.random() * 100000)}`; - const res = await client.rooms.create.$post({ - json: { name: roomName, gameTitle: "magic-square" }, - }); - if (res.ok) { - const newRoom = await res.json(); - navigate(`/magic-square/room/${newRoom.id}`); - } - }; + case "join-room-secret": { + const secret = formData.get("secret") as string; + if (!secret) return { error: "Secret is required" }; - const handleChangeName = async () => { - if (!newUserName) return; - setUserName(newUserName); - try { - const res = await client.users.me.$patch({ - json: { newName: newUserName }, - }); - if (res.ok) { + const res = await client.rooms.join.$post({ + json: { secret }, + }); + + if (!res.ok) return { error: "Failed to join room (Invalid secret?)" }; const data = await res.json(); - const createdAt = data.createdAt ? new Date(data.createdAt) : null; - setUser({ ...data, createdAt }); + return redirect(`/magic-square/room/${data.id}`); } - } catch (e) { - console.error(e); - } - }; - const handleJoinRoom = async (roomId: string) => { - try { - const res = await client.rooms[":roomId"].join.$post({ - param: { roomId }, - }); - if (res.ok) { - navigate(`/magic-square/room/${roomId}`); + case "dev-join-room-id": { + const roomId = formData.get("roomId") as string; + const res = await client.rooms[":roomId"].join.$post({ + param: { roomId }, + }); + if (res.ok) return redirect(`/magic-square/room/${roomId}`); + return { error: "Failed to join room" }; } - } catch (e) { - console.error(e); - } - }; - const handleJoinWithSecret = async () => { - if (!joinRoomSecret) return; - setJoinError(null); - try { - const res = await client.rooms.join.$post({ - json: { secret: joinRoomSecret }, - }); - if (res.ok) { - const data = await res.json(); - navigate(`/magic-square/room/${data.id}`); - } else { - setJoinError("Failed to join room"); + case "change-name": { + const newName = formData.get("newName") as string; + if (!newName) return { error: "Name is required" }; + + const res = await client.users.me.$patch({ + json: { newName }, + }); + if (!res.ok) return { error: "Failed to update name" }; + + return { success: true }; } - } catch (e) { - console.error(e); + + default: + return { error: "Unknown action" }; } + } catch (e) { + console.error(e); + return { error: "Unexpected error occurred" }; + } +} + +export default function Lobby() { + const loaderData = useLoaderData(); + const actionData = useActionData(); + const submit = useSubmit(); + const navigation = useNavigation(); + + const [step, setStep] = useState(0); + const [isEditingName, setIsEditingName] = useState(false); + const [pendingName, setPendingName] = useState(null); + + useEffect(() => { + const userName = loaderData?.user?.name; + if (userName && pendingName && userName === pendingName) { + setPendingName(null); + } + }, [loaderData?.user, pendingName]); + + if (!loaderData) return
Failed to load user data.
; + const { user, rooms } = loaderData; + + const handleNameChange = (formData: FormData) => { + const newName = formData.get("newName") as string; + setPendingName(newName); + submit(formData, { method: "post" }); + setIsEditingName(false); }; - if (!user) return null; + const displayName = pendingName ?? user.name; + + const isCreatingRoom = + (navigation.state === "submitting" || navigation.state === "loading") && + navigation.formData?.get("intent") === "create-room"; + const isJoiningRoom = + (navigation.state === "submitting" || navigation.state === "loading") && + navigation.formData?.get("intent") === "join-room-secret"; + const isJoiningRoomById = + (navigation.state === "submitting" || navigation.state === "loading") && + navigation.formData?.get("intent") === "dev-join-room-id"; + + const instructions = [ + "盤上に数字を置いていき、自分のミッションを誰よりも早く達成することを狙うゲームです。", + "自分の番になったら、手札から数字を選び、次に「+」(加算)、「-」(減算)のいずれかを選びます。(パスも可)", + "盤上のマス目を選択すると、選んだカードの数字が加算/減算され、ターンが終了します。", + "制限時間を過ぎると強制的にパスになるので注意!", + ]; return (

Lobby

- {isEditingName ? ( -
{ - e.preventDefault(); - handleChangeName(); - setIsEditingName(false); - }} - > - setNewUserName(e.target.value)} - required - /> - -
- ) : ( -
-

Welcome, {userName}!

- -
- )} + + + + + + ) : ( +
+

Welcome, {displayName}!

+ +
+ )} +

Create a Room

-
{ - e.preventDefault(); - handleCreateRoom(); - }} - > + + setNewRoomName(e.target.value)} />
-
-
+
+

Join a Room

-
{ - e.preventDefault(); - handleJoinWithSecret(); - }} - > - {joinError && ( + + + {actionData?.error && (
-
- {joinError} -
+ {actionData.error}
)} setJoinRoomSecret(e.target.value)} required />
-
-
+
+ +
@@ -292,27 +336,44 @@ export default function Lobby() {
+ {IS_DEV && (

Available Rooms (Debug)

- {rooms.map((room) => ( -
-
-

{room.name}

-

{room.users.length} players

-
- + {rooms.map((room) => { + const isJoiningThisRoom = + isJoiningRoomById && + navigation.formData?.get("roomId") === room.id; + + return ( +
+
+

{room.name}

+

{room.users.length} players

+
+ + + + + +
-
- ))} + ); + })}
)} diff --git a/apps/frontend/app/routes/magic-square/room.$roomId.tsx b/apps/frontend/app/routes/magic-square/room.$roomId.tsx index f9041e5..3e6316d 100644 --- a/apps/frontend/app/routes/magic-square/room.$roomId.tsx +++ b/apps/frontend/app/routes/magic-square/room.$roomId.tsx @@ -1,6 +1,3 @@ -/** biome-ignore-all lint/a11y/noStaticElementInteractions: TODO */ -/** biome-ignore-all lint/suspicious/noArrayIndexKey: TODO */ -/** biome-ignore-all lint/a11y/useKeyWithClickEvents: TODO */ import type { GameState, MessageType, @@ -60,22 +57,25 @@ function GameBoard({ board: (number | null)[][]; onCellClick: (x: number, y: number) => void; }) { + const cells = board.flatMap((row, y) => + row.map((cell, x) => ({ cell, x, y })), + ); + return (
- {board.map((row, y) => - row.map((cell, x) => ( -
onCellClick(x, y)} - > - {cell} -
- )), - )} + {cells.map(({ cell, x, y }) => ( + + ))}
); } @@ -87,28 +87,30 @@ function FinalGameBoard({ board: (number | null)[][]; winnerary: (true | false)[][]; }) { + const cells = board.flatMap((row, y) => + row.map((cell, x) => ({ cell, x, y })), + ); + return (
- {board.map((row, y) => - row.map((cell, x) => - winnerary[y][x] === true ? ( -
- {cell} -
- ) : ( -
- {cell} -
- ), + {cells.map(({ cell, x, y }) => + winnerary[y][x] === true ? ( +
+ {cell} +
+ ) : ( +
+ {cell} +
), )}
@@ -124,17 +126,20 @@ function Hand({ onCardClick: (i: number) => void; selectedNumIndex: number | null; }) { + const cardItems = cards.map((card, i) => ({ card, index: i })); + return (
- {cards.map((card, i) => ( -
onCardClick(i)} + {cardItems.map(({ card, index }) => ( +
+ ))}
@@ -151,18 +156,20 @@ function Operations({ return (
-
onOperationClick("add")} > + -
-
+
+
); @@ -279,6 +286,7 @@ export default function RoomPage() { const [spectatedPlayerId, setSpectatedPlayerId] = useState( null, ); + const [isLeavingRoom, setIsLeavingRoom] = useState(false); // WebSocket connection effect useEffect(() => { @@ -388,6 +396,7 @@ export default function RoomPage() { }; const handleLeaveRoom = async () => { + setIsLeavingRoom(true); sendWsMessage({ type: "removePlayer" }); if (roomId) { await client.rooms[":roomId"].leave.$post({ param: { roomId } }); @@ -691,21 +700,35 @@ export default function RoomPage() {
-
{myStatus === "ready" ? "READY!!" : "ready?"} -
-
+
-
- Leave Room -
+ +
); } diff --git a/apps/frontend/app/routes/memory-optimization/home.tsx b/apps/frontend/app/routes/memory-optimization/home.tsx index 0a19a26..e5a5961 100644 --- a/apps/frontend/app/routes/memory-optimization/home.tsx +++ b/apps/frontend/app/routes/memory-optimization/home.tsx @@ -2,7 +2,15 @@ import type { User } from "@apps/backend"; import { useEffect, useState } from "react"; -import { useLoaderData, useNavigate } from "react-router"; +import { + type ClientActionFunctionArgs, + Form, + redirect, + useActionData, + useLoaderData, + useNavigation, + useSubmit, +} from "react-router"; import { client } from "~/lib/client"; import { IS_DEV } from "~/lib/env"; @@ -13,242 +21,264 @@ type Room = { }; export async function clientLoader() { + let user: (User & { createdAt: Date | null }) | null = null; + let rooms: Room[] = []; + try { const res = await client.users.me.$get({}); - - if (!res.ok) throw new Error("Failed to fetch user.", { cause: res }); - const user = await res.json(); - const createdAt = user.createdAt ? new Date(user.createdAt) : null; - - return { ...user, createdAt }; + if (res.ok) { + const data = await res.json(); + user = { + ...data, + createdAt: data.createdAt ? new Date(data.createdAt) : null, + }; + } else { + const newUserName = `player-${Math.floor(Math.random() * 100000)}`; + const createRes = await client.users.create.$post({ + json: { name: newUserName }, + }); + const data = await createRes.json(); + user = { + ...data, + createdAt: data.createdAt ? new Date(data.createdAt) : null, + }; + } } catch (e) { - console.error(e); + console.error("User fetch/create failed", e); return null; } + + if (IS_DEV) { + try { + const res = await client.rooms.$get(); + if (res.ok) { + rooms = await res.json(); + } + } catch (e) { + console.error("Rooms fetch failed", e); + } + } + + return { user, rooms }; } -export default function Lobby() { - const me = useLoaderData(); - const [user, setUser] = useState(me ?? null); - const [rooms, setRooms] = useState([]); - const [userName, setUserName] = useState(user?.name ?? ""); - const [newUserName, setNewUserName] = useState(""); - const [newRoomName, setNewRoomName] = useState(""); - const [joinRoomSecret, setJoinRoomSecret] = useState(""); - const [joinError, setJoinError] = useState(null); - const navigate = useNavigate(); - const [step, setStep] = useState(0); - const [isEditingName, setIsEditingName] = useState(false); +export async function clientAction({ request }: ClientActionFunctionArgs) { + const formData = await request.formData(); + const intent = formData.get("intent"); - const instructions = [ - "盤上に数字を置いていき、自分のミッションを誰よりも早く達成することを狙うゲームです。", - "自分の番になったら、手札から数字を選び、次に「+」(加算)、「-」(減算)のいずれかを選びます。(パスも可)", - "盤上のマス目を選択すると、選んだカードの数字が加算/減算され、ターンが終了します。", - "制限時間を過ぎると強制的にパスになるので注意!", - ]; + try { + switch (intent) { + case "create-room": { + const nameInput = formData.get("roomName") as string; + const roomName = + nameInput || `room-${Math.floor(Math.random() * 100000)}`; - useEffect(() => { - if (!IS_DEV) return; - const fetchRooms = async () => { - const res = await client.rooms.$get(); - if (res.ok) { - const data = await res.json(); - setRooms(data); + const res = await client.rooms.create.$post({ + json: { name: roomName, gameTitle: "memory-optimization" }, + }); + + if (!res.ok) return { error: "Failed to create room" }; + const newRoom = await res.json(); + return redirect(`/memory-optimization/room/${newRoom.id}`); } - }; - fetchRooms(); - }, []); - useEffect(() => { - if (user) return; - const newUserName = `player-${Math.floor(Math.random() * 100000)}`; - const handleCreateUser = async () => { - const res = await client.users.create.$post({ - json: { name: newUserName }, - }); - const data = await res.json(); - const createdAt = data.createdAt ? new Date(data.createdAt) : null; - setUser({ ...data, createdAt }); - setUserName(data.name); - }; - handleCreateUser(); - }, [user]); - - const handleCreateRoom = async () => { - const roomName = - newRoomName || `room-${Math.floor(Math.random() * 100000)}`; - const res = await client.rooms.create.$post({ - json: { name: roomName, gameTitle: "memory-optimization" }, - }); - if (res.ok) { - const newRoom = await res.json(); - navigate(`/memory-optimization/room/${newRoom.id}`); - } - }; + case "join-room-secret": { + const secret = formData.get("secret") as string; + if (!secret) return { error: "Secret is required" }; - const handleChangeName = async () => { - if (!newUserName) return; - setUserName(newUserName); - try { - const res = await client.users.me.$patch({ - json: { newName: newUserName }, - }); - if (res.ok) { + const res = await client.rooms.join.$post({ + json: { secret }, + }); + + if (!res.ok) return { error: "Failed to join room (Invalid secret?)" }; const data = await res.json(); - const createdAt = data.createdAt ? new Date(data.createdAt) : null; - setUser({ ...data, createdAt }); + return redirect(`/memory-optimization/room/${data.id}`); } - } catch (e) { - console.error(e); - } - }; - const handleJoinRoom = async (roomId: string) => { - setJoinError(null); - try { - const res = await client.rooms[":roomId"].join.$post({ - param: { roomId }, - }); - if (res.ok) { - navigate(`/memory-optimization/room/${roomId}`); - } else { - const errorData = - ((await res.json()) as unknown as { message: string }).message || - "Failed to join room"; - setJoinError(errorData); - alert(errorData); + case "dev-join-room-id": { + const roomId = formData.get("roomId") as string; + const res = await client.rooms[":roomId"].join.$post({ + param: { roomId }, + }); + if (res.ok) return redirect(`/memory-optimization/room/${roomId}`); + return { error: "Failed to join room" }; } - } catch (e) { - console.error(e); - alert("An unexpected error occurred."); - } - }; - const handleJoinWithSecret = async () => { - if (!joinRoomSecret) return; - setJoinError(null); - try { - const res = await client.rooms.join.$post({ - json: { secret: joinRoomSecret }, - }); - if (res.ok) { - const data = await res.json(); - navigate(`/memory-optimization/room/${data.id}`); - } else { - const errorData = - ((await res.json()) as unknown as { message: string }).message || - "Failed to join room"; - setJoinError(errorData); - alert(errorData); + case "change-name": { + const newName = formData.get("newName") as string; + if (!newName) return { error: "Name is required" }; + + const res = await client.users.me.$patch({ + json: { newName }, + }); + if (!res.ok) return { error: "Failed to update name" }; + + return { success: true }; } - } catch (e) { - console.error(e); - setJoinError("An unexpected error occurred."); + + default: + return { error: "Unknown action" }; } + } catch (e) { + console.error(e); + return { error: "Unexpected error occurred" }; + } +} + +export default function Lobby() { + const loaderData = useLoaderData(); + const actionData = useActionData(); + const submit = useSubmit(); + const navigation = useNavigation(); + + const [step, setStep] = useState(0); + const [isEditingName, setIsEditingName] = useState(false); + const [pendingName, setPendingName] = useState(null); + + useEffect(() => { + const userName = loaderData?.user?.name; + if (userName && pendingName && userName === pendingName) { + setPendingName(null); + } + }, [loaderData?.user, pendingName]); + + if (!loaderData) return
Failed to load user data.
; + const { user, rooms } = loaderData; + + const handleNameChange = (formData: FormData) => { + const newName = formData.get("newName") as string; + setPendingName(newName); + submit(formData, { method: "post" }); + setIsEditingName(false); }; - if (!user) return null; + const displayName = pendingName ?? user.name; + + const isCreatingRoom = + (navigation.state === "submitting" || navigation.state === "loading") && + navigation.formData?.get("intent") === "create-room"; + const isJoiningRoom = + (navigation.state === "submitting" || navigation.state === "loading") && + navigation.formData?.get("intent") === "join-room-secret"; + const isJoiningRoomById = + (navigation.state === "submitting" || navigation.state === "loading") && + navigation.formData?.get("intent") === "dev-join-room-id"; + + const instructions = [ + "盤上に数字を置いていき、自分のミッションを誰よりも早く達成することを狙うゲームです。", + "自分の番になったら、手札から数字を選び、次に「+」(加算)、「-」(減算)のいずれかを選びます。(パスも可)", + "盤上のマス目を選択すると、選んだカードの数字が加算/減算され、ターンが終了します。", + "制限時間を過ぎると強制的にパスになるので注意!", + ]; return (

Lobby

- {isEditingName ? ( -
{ - e.preventDefault(); - handleChangeName(); - setIsEditingName(false); - }} - > - setNewUserName(e.target.value)} - required - /> - -
- ) : ( -
-

Welcome, {userName}!

- -
- )} + + + + + + ) : ( +
+

Welcome, {displayName}!

+ +
+ )} +

Create a Room

-
{ - e.preventDefault(); - handleCreateRoom(); - }} - > + + setNewRoomName(e.target.value)} />
-
-
+
+

Join a Room

-
{ - e.preventDefault(); - handleJoinWithSecret(); - }} - > - {joinError && ( + + + {actionData?.error && (
-
- {joinError} -
+ {actionData.error}
)} setJoinRoomSecret(e.target.value)} required />
-
-
+
+ +
@@ -311,23 +342,39 @@ export default function Lobby() {

Available Rooms (Debug)

- {rooms.map((room) => ( -
-
-

{room.name}

-

{room.users.length} players

-
- + {rooms.map((room) => { + const isJoiningThisRoom = + isJoiningRoomById && + navigation.formData?.get("roomId") === room.id; + + return ( +
+
+

{room.name}

+

{room.users.length} players

+
+ + + + + +
-
- ))} + ); + })}
)} diff --git a/apps/frontend/app/routes/memory-optimization/room.$roomId.tsx b/apps/frontend/app/routes/memory-optimization/room.$roomId.tsx index 323681a..3b00d28 100644 --- a/apps/frontend/app/routes/memory-optimization/room.$roomId.tsx +++ b/apps/frontend/app/routes/memory-optimization/room.$roomId.tsx @@ -1,6 +1,3 @@ -/** biome-ignore-all lint/a11y/noStaticElementInteractions: TODO */ -/** biome-ignore-all lint/suspicious/noArrayIndexKey: TODO */ -/** biome-ignore-all lint/a11y/useKeyWithClickEvents: TODO */ import type { CellState, EventCard, @@ -80,8 +77,9 @@ function GameBoard({ > {board.map((row, y) => row.map((cell, x) => ( -
+ /> )} -
+ )), )}
@@ -230,9 +228,10 @@ function Hand({ return (
- {Object.keys(cards).map((id, i) => ( -
( +
+ + ))}
@@ -267,8 +266,9 @@ function Missions({ {Object.keys(cards).map((id) => { const card = cards[id]; return ( -
{ if (onFuncClick) onFuncClick(id); @@ -276,7 +276,7 @@ function Missions({ > {card.cost} -
+ ); })}
@@ -302,8 +302,9 @@ function EventCards({ {Object.keys(cards).map((id) => { const card = cards[id]; return ( -
{ if (onEventClick) onEventClick(id); @@ -312,7 +313,7 @@ function EventCards({
{card.description}
-
+ ); })}
@@ -391,6 +392,7 @@ export default function RoomPage() { // const [winnerDisplay, setWinnerDisplay] = useState(0); const [remainingTime, setRemainingTime] = useState(0); + const [isLeavingRoom, setIsLeavingRoom] = useState(false); // const [spectatedPlayerId, setSpectatedPlayerId] = useState( // null // ); @@ -528,6 +530,7 @@ export default function RoomPage() { // }; const handleLeaveRoom = async () => { + setIsLeavingRoom(true); sendWsMessage({ type: "removePlayer" }); if (roomId) { await client.rooms[":roomId"].leave.$post({ param: { roomId } }); @@ -790,15 +793,28 @@ export default function RoomPage() { -
{myStatus === "ready" ? "READY!!" : "ready?"} -
-
- Leave Room -
+ + ); } diff --git a/biome.json b/biome.json index 4991391..4ea23a4 100644 --- a/biome.json +++ b/biome.json @@ -15,7 +15,10 @@ "linter": { "enabled": true, "rules": { - "recommended": true + "recommended": true, + "suspicious": { + "noArrayIndexKey": "off" + } } }, "overrides": [