diff --git a/apps/backend/package.json b/apps/backend/package.json index db81889..0ec2b4b 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -5,7 +5,9 @@ "scripts": { "dev": "wrangler dev", "deploy": "wrangler deploy --minify", - "cf-typegen": "wrangler types --env-interface CloudflareBindings" + "cf-typegen": "wrangler types --env-interface CloudflareBindings", + "db:generate": "drizzle-kit generate", + "db:push": "drizzle-kit push" }, "dependencies": { "drizzle-orm": "^0.44.5", diff --git a/apps/backend/src/db/schema.ts b/apps/backend/src/db/schema.ts index 7810949..b23cfb1 100644 --- a/apps/backend/src/db/schema.ts +++ b/apps/backend/src/db/schema.ts @@ -18,6 +18,7 @@ export const sessions = pgTable("Session", { export const rooms = pgTable("Room", { id: text("id").primaryKey(), name: text("name").notNull(), + gameTitle: text("gameType").notNull(), createdAt: timestamp("createdAt", { withTimezone: true }).defaultNow(), users: text("users").array().notNull(), hostId: text("hostId") diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index 39af179..6e077a4 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -17,8 +17,11 @@ import { type Rule, } from "./magic"; +import { Memory } from "./memory"; + type Bindings = { MAGIC: DurableObjectNamespace; + MEMORY: DurableObjectNamespace; DATABASE_URL: string; ENV: string; }; @@ -30,6 +33,8 @@ type Variables = { user: User; }; +type GameTitles = "magic-square" | "memory-optimization"; + // TODO: 環境変数にする const secret = "hoge"; @@ -151,9 +156,14 @@ const apiApp = new Hono<{ const db = c.get("db"); const user = c.get("user"); - const { name } = await c.req.json<{ name: string }>(); - if (!name) { - throw new HTTPException(400, { message: "Room name is required" }); + const { name, gameTitle } = await c.req.json<{ + name: string; + gameTitle: GameTitles; + }>(); + if (!name || !gameTitle) { + throw new HTTPException(400, { + message: "Room name and game title are required", + }); } const roomSecret = Math.floor(100000 + Math.random() * 900000).toString(); @@ -164,6 +174,7 @@ const apiApp = new Hono<{ .values({ id: roomId, name, + gameTitle: gameTitle, hostId: user.id, users: [user.id], }) @@ -300,18 +311,34 @@ const apiApp = new Hono<{ throw new HTTPException(403, { message: "Unauthorized" }); } - const id = c.env.MAGIC.idFromName(roomId); - const stub = c.env.MAGIC.get(id); + switch (room.gameTitle) { + case "magic-square": { + const id = c.env.MAGIC.idFromName(roomId); + const stub = c.env.MAGIC.get(id); - const url = new URL(c.req.url); - url.searchParams.set("playerId", user.id); + const url = new URL(c.req.url); + url.searchParams.set("playerId", user.id); - const request = new Request(url.toString(), c.req.raw); - return stub.fetch(request); + const request = new Request(url.toString(), c.req.raw); + return stub.fetch(request); + } + case "memory-optimization": { + const id = c.env.MEMORY.idFromName(roomId); + const stub = c.env.MEMORY.get(id); + + const url = new URL(c.req.url); + url.searchParams.set("playerId", user.id); + + const request = new Request(url.toString(), c.req.raw); + return stub.fetch(request); + } + default: + throw new HTTPException(400, { message: "Invalid game title" }); + } }); export type AppType = typeof apiApp; export default apiApp; export type { GameState, MoveAction, MessageType, Rule, Operation }; -export { Magic }; +export { Magic, Memory }; diff --git a/apps/backend/src/memory.ts b/apps/backend/src/memory.ts new file mode 100644 index 0000000..228c6bd --- /dev/null +++ b/apps/backend/src/memory.ts @@ -0,0 +1,557 @@ +import type { Env } from "hono/types"; +import { type Mission, missions } from "./mission"; +import { RoomMatch, type RoomState } from "./room"; + +// --- Game-specific Types --- + +let timeout: ReturnType; + +export type Operation = "add" | "sub"; + +export type MoveAction = { + x: number; + y: number; + operation: Operation; + num: number; + numIndex: number; +}; + +export type Rule = + | { rule: "negativeDisabled"; state: boolean } + | { rule: "boardSize"; state: number } + | { rule: "timeLimit"; state: number }; + +// GameState extends RoomState to include game-specific properties +export type GameState = RoomState & { + round: number; + turn: number; + board: (number | null)[][]; + winners: string[] | null; + winnersAry: { [playerId: string]: (true | false)[][] }; + gameId: string; + hands: { [playerId: string]: number[] }; + missions: { [playerId: string]: { id: string; mission: Mission } }; + timeLimitUnix: number; +}; + +// Combined message types for both room and game actions +export type MessageType = + | { type: "makeMove"; payload: MoveAction } + | { type: "setReady"; payload?: undefined } + | { type: "cancelReady"; payload?: undefined } + | { type: "changeRule"; payload: Rule } + | { type: "pass"; payload?: undefined } + | { type: "backToLobby"; payload?: undefined } + | { type: "removePlayer"; payload?: undefined }; + +const DEFAULT_BOARD_SIZE = 3; +const DEFAULT_TIME_LIMIT_MS = 10000; + +export class Memory extends RoomMatch { + constructor(ctx: DurableObjectState, env: Env) { + super(ctx, env); + this.state = undefined; // Initialize state, will be loaded in super's constructor + } + + async wsMessageListener( + ws: WebSocket, + message: MessageEvent, + playerId: string, + ) { + try { + const { type, payload } = JSON.parse( + message.data as string, + ) as MessageType; + switch (type) { + // Game actions + case "makeMove": + await this.makeMove( + playerId, + payload.x, + payload.y, + payload.num, + payload.operation, + payload.numIndex, + ); + break; + case "pass": + await this.pass(); + break; + // Room actions (from base class) + case "setReady": + await this.setReady(playerId); + break; + case "cancelReady": + await this.cancelReady(playerId); + break; + case "changeRule": + await this.changeRule(payload); + break; + case "removePlayer": + await this.removePlayer(playerId); + break; + case "backToLobby": + await this.backToLobby(playerId); + break; + default: + throw new Error(`Unhandled message type: ${type}`); + } + } catch { + ws.send(JSON.stringify({ error: "Invalid message" })); + } + } + + // called once when the Durable Object is first created + async initialize() { + this.state = { + // RoomState properties + status: "preparing", + players: [], + playerStatus: {}, + names: {}, + rules: { + negativeDisabled: false, + boardSize: DEFAULT_BOARD_SIZE, + timeLimit: DEFAULT_TIME_LIMIT_MS / 1000, + }, + // GameState specific properties + round: 0, + turn: 0, + board: [], + winners: null, + winnersAry: {}, + gameId: this.ctx.id.toString(), + hands: {}, + missions: {}, + timeLimitUnix: Date.now() + DEFAULT_TIME_LIMIT_MS, + }; + await this.ctx.storage.put("gameState", this.state); + this.broadcast({ type: "state", payload: this.state }); + } + + async changeRule(payload: Rule) { + if (!this.state || this.state.status !== "preparing") return; + + if (payload.rule === "negativeDisabled") { + this.state.rules.negativeDisabled = payload.state; + } else if (payload.rule === "boardSize") { + this.state.rules.boardSize = payload.state; + this.state.board = Array(payload.state) + .fill(null) + .map(() => Array(payload.state).fill(null)); + } else if (payload.rule === "timeLimit") { + this.state.rules.timeLimit = payload.state; + } + await this.ctx.storage.put("gameState", this.state); + this.broadcast({ type: "state", payload: this.state }); + } + + override async startGame(): Promise { + if (!this.state || this.state.status !== "preparing") return; + // The original implementation called a method to clear parts of the state. + // We will replicate that behavior by resetting the game-specific state here. + const size = this.state.rules.boardSize; + this.state.board = Array(size) + .fill(null) + .map(() => Array(size).fill(null)); + this.state.round = 0; + this.state.turn = 0; + this.state.winners = null; + this.state.winnersAry = {}; + this.state.hands = {}; + this.state.missions = {}; + + for (const playerId of this.state.players) { + if (this.state.playerStatus[playerId] !== "ready") { + console.error("one of the players not ready:", playerId); + return; + } + this.state.playerStatus[playerId] = "playing"; + + if (this.state.hands[playerId]) { + console.error("player already has a hand:", playerId); + return; + } + this.state.hands[playerId] = this.drawInitialHand(); + + if (this.state.missions[playerId]) { + console.error("player already has a mission:", playerId); + return; + } + this.state.missions[playerId] = this.getRandomMission(); + } + this.state.status = "playing"; + this.state.timeLimitUnix = Date.now() + this.state.rules.timeLimit * 1000; + clearTimeout(timeout); + timeout = setTimeout(() => { + this.pass(); + }, this.state.rules.timeLimit * 1000); + + await this.ctx.storage.put("gameState", this.state); + this.broadcast({ type: "state", payload: this.state }); + } + + // --- Game-specific Methods --- + + drawInitialHand() { + if (!this.state) return []; + const hand = new Array(3); // TODO: 変更可能にする + for (let i = 0; i < hand.length; i++) { + hand[i] = this.drawCard(); + } + return hand; + } + + // TODO: 調整可能にする + drawCard() { + const rand = Math.random(); + if (rand < 0.4) { + return 1; + } else if (rand < 0.6) { + return 2; + } else if (rand < 0.8) { + return 3; + } else { + return 4; + } + } + + // TODO: ミッションの重複を避ける + getRandomMission() { + const missionKeys = Object.keys(missions); + const randomKey = + missionKeys[Math.floor(Math.random() * missionKeys.length)]; + return { id: randomKey, mission: missions[randomKey] }; + } + + advanceTurnAndRound() { + if (!this.state) return; + + const players = this.state.players; + const playerStatuses = this.state.playerStatus; + const currentTurn = this.state.turn; + + const activePlayerIds = players.filter( + (p) => playerStatuses[p] === "playing", + ); + if (activePlayerIds.length === 0) { + this.state.status = "paused"; + return; // No one to advance turn to. + } + + const currentPlayerId = players[currentTurn]; + + // Find the index of the current player in the list of *active* players. + // If the current player is not active (e.g., a watcher), this will be -1. + const currentPlayerActiveIndex = activePlayerIds.indexOf(currentPlayerId); + + let nextPlayerId: string | null = null; + + if (currentPlayerActiveIndex === -1) { + // The turn was on an inactive player. Find the first active player after the current one. + let nextTurn = currentTurn; + for (let i = 0; i < players.length; i++) { + nextTurn = (nextTurn + 1) % players.length; + if (playerStatuses[players[nextTurn]] === "playing") { + nextPlayerId = players[nextTurn]; + break; + } + } + if (!nextPlayerId) { + // Should be unreachable due to activePlayerIds.length check + this.state.status = "paused"; + return; + } + } else { + // The current player is active. Find the next one in the active list. + const nextPlayerActiveIndex = + (currentPlayerActiveIndex + 1) % activePlayerIds.length; + nextPlayerId = activePlayerIds[nextPlayerActiveIndex]; + + // If we wrapped around the active players list, increment the round. + if (nextPlayerActiveIndex === 0) { + this.state.round += 1; + } + } + + if (nextPlayerId) { + this.state.turn = players.indexOf(nextPlayerId); + } + } + + async makeMove( + player: string, + x: number, + y: number, + num: number, + operation: Operation, + numIndex: number, + ) { + if (!this.state || this.state.winners) return; + + if (!this.isValidMove(player, x, y, num)) { + console.error("Invalid move attempted:", player, x, y, num); + return; + } + + console.log("Making move:", player, x, y, num); + + this.state.board[y][x] = this.computeCellResult(x, y, num, operation); + + this.advanceTurnAndRound(); + + const prevHand = this.state.hands[player]; + + this.state.hands[player] = prevHand.toSpliced(numIndex, 1, this.drawCard()); + + for (const id of this.state.players) { + if (this.state.missions[id]) { + const winary = this.isVictory(this.state.missions[id].mission); + if (winary.some((row) => row.includes(true))) { + if (!this.state) throw new Error("Game state is not initialized"); + this.state.winnersAry[id] = winary; + if (!this.state.winners) { + this.state.winners = [id]; + } else if (!this.state.winners.includes(id)) { + this.state.winners.push(id); + } + console.log("winary", winary); + console.log("this.state.winnersAry", this.state.winnersAry); + } + } + } + + if (this.state.winners) { + this.state.status = "preparing"; + Object.keys(this.state.playerStatus).forEach((playerId) => { + if (!this.state) throw new Error("Game state is not initialized"); + this.state.playerStatus[playerId] = "finished"; + }); + } + this.state.timeLimitUnix = Date.now() + this.state.rules.timeLimit * 1000; + clearTimeout(timeout); + if (!this.state.winners) + timeout = setTimeout(() => { + this.pass(); + }, this.state.rules.timeLimit * 1000); + await this.ctx.storage.put("gameState", this.state); + this.broadcast({ type: "state", payload: this.state }); + } + + async pass() { + if (!this.state) return; + this.advanceTurnAndRound(); + this.state.timeLimitUnix = Date.now() + this.state.rules.timeLimit * 1000; + clearTimeout(timeout); + timeout = setTimeout(() => { + this.pass(); + }, this.state.rules.timeLimit * 1000); + await this.ctx.storage.put("gameState", this.state); + this.broadcast({ type: "state", payload: this.state }); + } + + isValidMove(player: string, x: number, y: number, num: number) { + if (!this.state) throw new Error("Game state is not initialized"); + + // TODO: 調整可能にする + if (!Number.isInteger(num) || num < 1 || num > 4) { + console.error("Invalid number:", num); + return false; + } + + const currentPlayer = this.state.players[this.state.turn]; + if (currentPlayer !== player) { + console.error("Not your turn:", player); + return false; + } + + const currentHand = this.state.hands[currentPlayer]; + if (!currentHand || currentHand.length === 0) { + console.error("Invalid hand:", currentPlayer); + return false; + } + if (!currentHand.includes(num)) { + console.error("Card not in hand:", num); + return false; + } + + if (this.state.board[y][x] === undefined) { + console.error("Invalid board position:", x, y); + return false; + } + + return true; + } + + computeCellResult( + x: number, + y: number, + num: number, + operation: Operation, + ): number { + if (!this.state) throw new Error("Game state is not initialized"); + + const prev = this.state.board[y][x] ?? 0; + + switch (operation) { + case "add": + return prev + num; + case "sub": + return num > prev && this.state.rules.negativeDisabled + ? num - prev + : prev - num; + default: + return operation satisfies never; + } + } + + isVictory(mission: Mission) { + if (!this.state) throw new Error("Game state is not initialized"); + const matrix = Array.from({ length: this.state.rules.boardSize }, () => + Array(this.state?.rules.boardSize).fill(false), + ); + if (mission.target === "column" || mission.target === "allDirection") { + for (let i = 0; i < this.state.rules.boardSize; i++) { + const columnary = this.state.board[i].filter((value) => value !== null); + if (this.isWinner(columnary, mission)) { + matrix[i] = Array(this.state.rules.boardSize).fill(true); + } + } + } + + if (mission.target === "row" || mission.target === "allDirection") { + for (let i = 0; i < this.state.rules.boardSize; i++) { + const nullinary = []; + for (let j = 0; j < this.state.rules.boardSize; j++) { + nullinary.push(this.state.board[j][i]); + } + const rowary = nullinary.filter((value) => value !== null); + if (this.isWinner(rowary, mission)) { + for (let j = 0; j < this.state.rules.boardSize; j++) { + matrix[j][i] = true; + } + } + } + } + + if (mission.target === "diagonal" || mission.target === "allDirection") { + for (let i = 0; i < 2; i++) { + const nullinary = []; + for (let j = 0; j < this.state.rules.boardSize; j++) { + if (i === 0) { + nullinary.push( + this.state.board[j][this.state.rules.boardSize - j - 1], + ); + } else { + nullinary.push( + this.state.board[this.state.rules.boardSize - j - 1][j], + ); + } + } + const diaary = nullinary.filter((value) => value !== null); + if (this.isWinner(diaary, mission)) { + for (let j = 0; j < this.state.rules.boardSize; j++) { + if (i === 0) { + matrix[j][this.state.rules.boardSize - j - 1] = true; + } else { + matrix[this.state.rules.boardSize - j - 1][j] = true; + } + } + } + } + } + + if (mission.target === "allCell") { + const nullinary = []; + for (let i = 0; i < this.state.rules.boardSize; i++) { + for (let j = 0; j < this.state.rules.boardSize; j++) { + nullinary.push(this.state.board[i][j]); + } + } + const boardary = nullinary.filter((value) => value !== null); + if (mission.type === "multipile") { + let hikaku = 0; + for (let j = 0; j < boardary.length; j++) { + if (boardary[j] % mission.number === 0) { + hikaku += 1; + } + } + if (hikaku > 3) { + for (let i = 0; i < nullinary.length; i++) { + matrix[Math.floor(i / mission.number)][i % mission.number] = + this.multi(nullinary[i], mission.number); + } + } + } + if (mission.type === "prime") { + let hikaku = 0; + for (let j = 0; j < boardary.length; j++) { + if (this.isPrime(boardary[j])) { + hikaku += 1; + } + } + if (hikaku > 3) { + for (let i = 0; i < nullinary.length; i++) { + matrix[Math.floor(i / mission.number)][i % mission.number] = + this.isPrime(nullinary[i]); + } + } + } + } + return matrix; + } + + multi(devidedNumber: number | null, devideNumber: number) { + if (devidedNumber === null) { + return false; + } else { + return devidedNumber % devideNumber === 0; + } + } + + isWinner(obary: number[], mission: Mission) { + if (!this.state) throw new Error("Game state is not initialized"); + if (obary.length !== this.state.rules.boardSize) { + return false; + } + + switch (mission.type) { + case "sum": + return obary.reduce((acc, val) => acc + val, 0) === mission.number; + case "multipile": + return obary.every((val) => val % mission.number === 0); + case "arithmetic": + return obary + .toSorted((a, b) => a - b) + .slice(1) + .every((val, i) => val - obary[i] === mission.number); + case "geometric": + return obary + .toSorted((a, b) => a - b) + .slice(1) + .every((val, i) => val === obary[i] * mission.number); + case "prime": + return obary.every((val) => this.isPrime(val)); + default: + return false; + } + } + + isPrime(number: number | null) { + if (number === null) { + return false; + } + const primeNumber = [ + 2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47, 53, 59, 61, 67, + 71, 73, 79, 83, 89, 97, + ]; + for (let z = 0; z < primeNumber.length; z++) { + if (number === primeNumber[z]) { + return true; + } else if (number < primeNumber[z]) { + return false; + } + } + return false; + } +} diff --git a/apps/backend/wrangler.jsonc b/apps/backend/wrangler.jsonc index 1f6ce5a..7ad3468 100644 --- a/apps/backend/wrangler.jsonc +++ b/apps/backend/wrangler.jsonc @@ -9,6 +9,10 @@ { "name": "MAGIC", "class_name": "Magic" + }, + { + "name": "MEMORY", + "class_name": "Memory" } ] }, @@ -16,6 +20,10 @@ { "tag": "v1", "new_sqlite_classes": ["Magic"] + }, + { + "tag": "v2", + "new_sqlite_classes": ["Memory"] } ] } diff --git a/apps/frontend/app/routes.ts b/apps/frontend/app/routes.ts index 3b154c8..d02d18c 100644 --- a/apps/frontend/app/routes.ts +++ b/apps/frontend/app/routes.ts @@ -11,4 +11,11 @@ export default [ route("magic-square", "routes/magic-square/home.tsx"), route("magic-square/room/:roomId", "routes/magic-square/room.$roomId.tsx"), ]), + layout("routes/memory-optimization/layout.tsx", [ + route("memory-optimization", "routes/memory-optimization/home.tsx"), + route( + "memory-optimization/room/:roomId", + "routes/memory-optimization/room.$roomId.tsx", + ), + ]), ] satisfies RouteConfig; diff --git a/apps/frontend/app/routes/home.tsx b/apps/frontend/app/routes/home.tsx index eb605e0..f27db01 100644 --- a/apps/frontend/app/routes/home.tsx +++ b/apps/frontend/app/routes/home.tsx @@ -4,8 +4,8 @@ export default function Home() {

ゲームポータル

-
-
+
+

魔法陣

魔法陣を模したゲームです。

@@ -14,6 +14,15 @@ export default function Home() {
+
+

メモリ最適化

+

メモリ最適化をテーマにしたゲームです。

+ +
diff --git a/apps/frontend/app/routes/magic-square/home.tsx b/apps/frontend/app/routes/magic-square/home.tsx index 701af42..d00c3b5 100644 --- a/apps/frontend/app/routes/magic-square/home.tsx +++ b/apps/frontend/app/routes/magic-square/home.tsx @@ -76,7 +76,7 @@ export default function Lobby() { const roomName = newRoomName || `room-${Math.floor(Math.random() * 100000)}`; const res = await client.rooms.create.$post({ - json: { name: roomName }, + json: { name: roomName, gameTitle: "magic-square" }, }); if (res.ok) { const newRoom = await res.json(); diff --git a/apps/frontend/app/routes/memory-optimization/home.tsx b/apps/frontend/app/routes/memory-optimization/home.tsx new file mode 100644 index 0000000..1aefac9 --- /dev/null +++ b/apps/frontend/app/routes/memory-optimization/home.tsx @@ -0,0 +1,324 @@ +// TODO: Componentにして再利用 + +import type { User } from "@apps/backend"; +import { useEffect, useState } from "react"; +import { useLoaderData, useNavigate } from "react-router"; +import { client } from "~/lib/client"; +import { IS_DEV } from "~/lib/env"; + +type Room = { + id: string; + name: string; + users: string[]; +}; + +export async function clientLoader() { + 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 }; + } catch (e) { + console.error(e); + return null; + } +} + +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); + + const instructions = [ + "盤上に数字を置いていき、自分のミッションを誰よりも早く達成することを狙うゲームです。", + "自分の番になったら、手札から数字を選び、次に「+」(加算)、「-」(減算)のいずれかを選びます。(パスも可)", + "盤上のマス目を選択すると、選んだカードの数字が加算/減算され、ターンが終了します。", + "制限時間を過ぎると強制的にパスになるので注意!", + ]; + + useEffect(() => { + if (!IS_DEV) return; + const fetchRooms = async () => { + const res = await client.rooms.$get(); + if (res.ok) { + const data = await res.json(); + setRooms(data); + } + }; + 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}`); + } + }; + + const handleChangeName = async () => { + if (!newUserName) return; + setUserName(newUserName); + try { + const res = await client.users.me.$patch({ + json: { newName: newUserName }, + }); + if (res.ok) { + const data = await res.json(); + const createdAt = data.createdAt ? new Date(data.createdAt) : null; + setUser({ ...data, createdAt }); + } + } 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}`); + } + } 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"); + } + } catch (e) { + console.error(e); + } + }; + + if (!user) return null; + + return ( +
+

Lobby

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

Welcome, {userName}!

+ +
+ )} + +
+
+
+

Create a Room

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

Join a Room

+
+
{ + e.preventDefault(); + handleJoinWithSecret(); + }} + > + {joinError && ( +
+
+ {joinError} +
+
+ )} + setJoinRoomSecret(e.target.value)} + required + /> +
+ +
+
+
+
+
+ + +
+
+ +
+

+ How to play ({step + 1} / {instructions.length}) +

+ {`How +
+

{instructions[step]}

+
+
+ + {step < instructions.length - 1 ? ( + + ) : ( +
+ +
+ )} +
+
+
+ {IS_DEV && ( +
+

Available Rooms (Debug)

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

{room.name}

+

{room.users.length} players

+
+ +
+
+
+ ))} +
+
+ )} +
+
+ ); +} diff --git a/apps/frontend/app/routes/memory-optimization/layout.tsx b/apps/frontend/app/routes/memory-optimization/layout.tsx new file mode 100644 index 0000000..8c8ca40 --- /dev/null +++ b/apps/frontend/app/routes/memory-optimization/layout.tsx @@ -0,0 +1,13 @@ +import { Outlet } from "react-router"; + +export function HydrateFallback() { + return ( +
+
+
+ ); +} + +export default function LogicPuzzleLayout() { + return ; +} diff --git a/apps/frontend/app/routes/memory-optimization/room.$roomId.tsx b/apps/frontend/app/routes/memory-optimization/room.$roomId.tsx new file mode 100644 index 0000000..c02499f --- /dev/null +++ b/apps/frontend/app/routes/memory-optimization/room.$roomId.tsx @@ -0,0 +1,873 @@ +/** 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, Operation, Rule } from "@apps/backend"; +import { useEffect, useRef, useState } from "react"; +import { + type ClientLoaderFunctionArgs, + redirect, + useLoaderData, + useNavigate, + useParams, +} from "react-router"; +import { client } from "~/lib/client"; + +export async function clientLoader({ params }: ClientLoaderFunctionArgs) { + const roomId = params.roomId; + if (!roomId) throw new Error("Room ID is required"); + + const [userRes, roomRes, roomSecretRes] = await Promise.all([ + client.users.me.$get(), + client.rooms[":roomId"].$get({ param: { roomId } }), + client.rooms[":roomId"].secret.$get({ + param: { roomId }, + }), + ]); + + if (!userRes.ok || !roomRes.ok || !roomSecretRes.ok) { + return redirect("/magic-square"); + } + + const user = await userRes.json(); + const roomData = await roomRes.json(); + const roomSecretData = await roomSecretRes.json(); + + const createdAt = user.createdAt ? new Date(user.createdAt) : null; + + if (!roomData.users.includes(user.id)) { + return redirect("/magic-square"); + } + + return { + user: { ...user, createdAt }, + secret: roomSecretData.secret, + hostId: roomData.hostId, + }; +} + +// --- Game Components --- + +function GameBoard({ + board, + onCellClick, +}: { + board: (number | null)[][]; + onCellClick: (x: number, y: number) => void; +}) { + return ( +
+ {board.map((row, y) => + row.map((cell, x) => ( +
onCellClick(x, y)} + > + {cell} +
+ )), + )} +
+ ); +} + +function FinalGameBoard({ + board, + winnerary, +}: { + board: (number | null)[][]; + winnerary: (true | false)[][]; +}) { + return ( +
+ {board.map((row, y) => + row.map((cell, x) => + winnerary[y][x] === true ? ( +
+ {cell} +
+ ) : ( +
+ {cell} +
+ ), + ), + )} +
+ ); +} + +function Hand({ + cards, + onCardClick, + selectedNumIndex, +}: { + cards: number[]; + onCardClick: (i: number) => void; + selectedNumIndex: number | null; +}) { + return ( +
+
+ {cards.map((card, i) => ( +
onCardClick(i)} + > + {card} +
+ ))} +
+
+ ); +} + +function Operations({ + onOperationClick, + selectedOperation, +}: { + onOperationClick: (name: Operation) => void; + selectedOperation: Operation; +}) { + return ( +
+
+
onOperationClick("add")} + > + + +
+
onOperationClick("sub")} + > + - +
+
+
+ ); +} + +function Mission({ + title, + description, +}: { + title: string; + description: string; +}) { + return ( + +
+

{title}

+

{description}

+
+
+ ); +} + +// --- Main Page Component --- + +function TurnDisplay({ + round, + currentPlayerId, + currentPlayerName, + myId, + remainingTime, +}: { + round: number; + currentPlayerId: string; + currentPlayerName: string; + myId: string; + remainingTime: number; +}) { + const isMyTurn = currentPlayerId === myId; + + return ( +
+
+

Round

+

{round + 1}

+
+
+ {isMyTurn ? "Your Turn" : `${currentPlayerName}'s Turn`} +
+
+

Time

+

+ {remainingTime >= 500 ? "∞" : remainingTime} +

+
+
+ ); +} + +export default function RoomPage() { + const { + user, + secret: roomSecret, + hostId: roomHost, + } = useLoaderData(); + + const { roomId } = useParams(); + const navigate = useNavigate(); + + const [gameState, setGameState] = useState(null); + const myStatus = user?.id + ? (gameState?.playerStatus[user?.id] ?? null) + : null; + const ws = useRef(null); + + const activePlayerIds = user + ? (gameState?.players.filter( + (p) => gameState?.playerStatus[p] === "playing", + ) ?? null) + : null; + const opponentIds = user + ? (activePlayerIds?.filter((p) => p !== user.id) ?? null) + : null; + const currentPlayerId = gameState?.players[gameState.turn] ?? null; + + const [selectedNumIndex, setSelectedNumIndex] = useState(null); + const [selectedOperation, setSelectedOperation] = useState("add"); + + const [winnerDisplay, setWinnerDisplay] = useState(0); + const [remainingTime, setRemainingTime] = useState(0); + const [spectatedPlayerId, setSpectatedPlayerId] = useState( + null, + ); + + // WebSocket connection effect + useEffect(() => { + if (!roomId || !user?.id || !user?.name) return; + + const proto = window.location.protocol === "https:" ? "wss:" : "ws:"; + + const host = window.location.hostname; + const port = window.location.port; + const fullHost = + host === "localhost" && port === "5173" + ? "localhost:8787" + : port + ? `${host}:${port}` + : host; + const prefix = host === "localhost" && port === "5173" ? "" : "/api"; + + const wsUrl = `${proto}//${fullHost}${prefix}/games/${roomId}/ws?playerId=${user.id}&playerName=${user.name}`; + + const socket = new WebSocket(wsUrl); + ws.current = socket; + + socket.onopen = () => { + console.log("[WS] Connected to server."); + }; + socket.onclose = () => { + console.log("[WS] Disconnected from server."); + }; + socket.onerror = (err) => console.error("[WS] WebSocket error:", err); + + socket.onmessage = (event) => { + console.log("[WS] Message from server:", event.data); + const message = JSON.parse(event.data); + if (message.type === "state") { + setGameState(message.payload); + console.log(message.payload); + } + if (message.error) { + console.error("[WS] Server error:", message.error); + } + }; + + return () => socket.close(); + }, [roomId, user.id, user.name]); + + useEffect(() => { + if (gameState?.timeLimitUnix) { + const interval = setInterval(() => { + const remaining = gameState.timeLimitUnix - Date.now(); + setRemainingTime(remaining > 0 ? remaining : 0); + }, 1000); + // Set initial time + const remaining = gameState.timeLimitUnix - Date.now(); + setRemainingTime(remaining > 0 ? remaining : 0); + return () => clearInterval(interval); + } + }, [gameState?.timeLimitUnix]); + + function sendWsMessage({ type, payload }: MessageType): void { + if (ws.current?.readyState === WebSocket.OPEN) { + const message = JSON.stringify({ type, payload }); + console.log("[WS] Sending message:", message); + ws.current.send(message); + } + } + const handleCellClick = (x: number, y: number) => { + if (!gameState || !user || !user.id || selectedNumIndex === null) return; + sendWsMessage({ + type: "makeMove", + payload: { + x, + y, + operation: selectedOperation, + num: gameState.hands[user.id][selectedNumIndex], + numIndex: selectedNumIndex, + }, + }); + setSelectedNumIndex(null); + setSelectedOperation("add"); + }; + + const handleWinnersPlusClick = () => { + setWinnerDisplay(winnerDisplay + 1); + }; + + const handleWinnersMinusClick = () => { + setWinnerDisplay(winnerDisplay - 1); + }; + + const handleReadyClick = () => { + if (myStatus === "ready") { + sendWsMessage({ type: "cancelReady" }); + } else { + sendWsMessage({ type: "setReady" }); + } + }; + + const handleRuleChange = (rule: Rule) => { + sendWsMessage({ + type: "changeRule", + payload: rule, + }); + }; + + const handleBackToLobby = () => { + sendWsMessage({ type: "backToLobby" }); + }; + + const handleLeaveRoom = async () => { + sendWsMessage({ type: "removePlayer" }); + if (roomId) { + await client.rooms[":roomId"].leave.$post({ param: { roomId } }); + } + navigate("/magic-square"); + }; + + // --- Render Logic --- + + if (!user || !roomId) { + return ( +
+

Error: Room ID not found.

+

Please ensure you are accessing a valid room.

+
+ ); + } + + if (!gameState) { + console.log( + "Loading or waiting for game state...", + gameState, + currentPlayerId, + ); + return ( +
+
+
+ ); + } + + if (myStatus === "watching") { + if (!currentPlayerId) { + throw new Error("Current player ID is missing"); + } + + const playingPlayers = gameState.players.filter( + (p) => gameState.playerStatus[p] === "playing", + ); + + const spectatedPlayer = spectatedPlayerId + ? { + id: spectatedPlayerId, + name: gameState.names[spectatedPlayerId], + hand: gameState.hands[spectatedPlayerId], + mission: gameState.missions[spectatedPlayerId], + } + : null; + + return ( +
+

Watching Game

+ + {/* Player perspective switcher */} +
+ + {playingPlayers.map((pId) => ( + + ))} +
+ + {/* Opponents' Missions */} +
+ {spectatedPlayer + ? // Single player perspective + playingPlayers + .filter((pId) => pId !== spectatedPlayer.id) + .map((opponentId) => + gameState.missions[opponentId] ? ( + + ) : null, + ) + : null} +
+ + {/* Game Board */} +
+ + {}} /> +
+ + {/* Player's Info (Hand and Mission) */} +
+ {spectatedPlayer ? ( + <> + + {}} + selectedNumIndex={null} + /> + + ) : ( +
+ { + // Overview perspective + playingPlayers.map((playerId) => + gameState.missions[playerId] ? ( +
+
+ +
+
+ {}} + selectedNumIndex={null} + /> +
+
+ ) : null, + ) + } +
+

You are watching

+

Select a player above to see their perspective.

+
+
+ )} +
+
+ ); + } + if (myStatus === "preparing" || myStatus === "ready") { + return ( +
+

+ Waiting for players to be ready... +

+
+ Password +
+ {roomSecret} +
+
+
    + {gameState.players.map((playerId) => ( +
  • + + {gameState.names[playerId]} + {playerId === roomHost && ( + + Host + + )} + + + {gameState.playerStatus[playerId] === "ready" + ? "Ready!" + : gameState.playerStatus[playerId] === "error" + ? "Error" + : "Preparing..."} + +
  • + ))} +
+
+ +
+
+ +
+
+ +
+
+ {myStatus === "ready" ? "READY!!" : "ready?"} +
+
+ Leave Room +
+
+ ); + } + + if (myStatus === "finished") { + if (!gameState.winners || gameState.winners.length === 0) { + throw new Error("Winners data is missing"); + } + if (winnerDisplay === 0) { + return ( +
+
+

GAME SET

+
+ {gameState.winners && ( +
+ {gameState.winners.map((winnersId) => ( +

+ {gameState.names[winnersId]} +

+ ))} +

WIN!!

+
+ )} +
+ + Array(gameState?.rules.boardSize).fill(false), + )} + /> +
+
+ +
+
+ ); + } + if (winnerDisplay === gameState.winners.length) { + return ( +
+
+

+ Result {winnerDisplay}/{gameState.winners.length} +

+
+
+ +
+
+ +
+
+ + +
+
+ ); + } + return ( +
+
+

+ Result {winnerDisplay}/{gameState.winners.length} +

+
+
+ +
+
+ +
+
+ + +
+
+ ); + } + + if (myStatus === "playing") { + if (!currentPlayerId) { + throw new Error("Current player ID is missing"); + } + return ( +
+
Password:{roomSecret}
+ {/* Opponent's Info */} + {opponentIds && ( +
+ {opponentIds.map((opponentId) => ( + + ))} +
+ )} + {/* Game Board */} +
+ + +
+ {/* Player's Info */} +
+ {gameState.missions[user.id] && ( + + )} +
+ {gameState.hands[user.id] && ( + + )} +
+ + +
+
+ {gameState.status === "paused" && ( +
+

Connection lost

+

+ Someone else has lost connection. Please wait while we try to + reconnect… +

+
+ )} +
+ ); + } + + if (myStatus === "error") { + return ( +
+

Error

+

+ An error occurred in the game. Please try again later. +

+ + Go back + +
+ ); + } + + if (myStatus === null && !(user.id in gameState.players)) { + console.log("leaved"); + return ( +
+
+
+ ); + } + + throw new Error(`Unknown player status: ${myStatus}`); +} diff --git a/package.json b/package.json index 1b67119..b4f0b75 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "dev": "bun run --filter='@apps/{frontend,backend}' dev", "dev:frontend": "cd ./apps/frontend && bun run dev", "dev:backend": "cd ./apps/backend && bun run dev", - "db": "cd ./apps/backend && bun run db", + "db:push": "cd ./apps/backend && bun run db:push", "fix": "bunx --bun biome check --write .", "fix:format": "bunx --bun biome format --write .", "fix:lint": "bunx --bun biome lint --write .",