diff --git a/app/api/game/[room]/route.ts b/app/api/game/[room]/route.ts new file mode 100644 index 0000000..97bb3e3 --- /dev/null +++ b/app/api/game/[room]/route.ts @@ -0,0 +1,140 @@ +import type { Card, Discussion, Player, State } from "@/types/game"; + +import { NextResponse } from "next/server"; + +const rooms = new Map(); + +export async function GET( + _request: Request, + { params }: { params: { room: string } }, +) { + const state = rooms.get(params.room); + + if (!state) { + return NextResponse.json({ error: "Room not found" }, { status: 404 }); + } + + return NextResponse.json(state); +} + +export async function POST( + request: Request, + { params }: { params: { room: string } }, +) { + const body = await request.json(); + const room = params.room; + let state = rooms.get(room); + + switch (body.type) { + case "createRoom": { + if (!state) { + state = { + deck: body.game.deck as Card[], + throws: body.game.throws as Card[], + players: body.game.players as Player[], + isGameStarted: false, + turn: body.game.players[0] as Player, + openDiscussions: [], + }; + rooms.set(room, state); + } + break; + } + case "updatePlayerName": { + if (!state) break; + const idx = state.players.findIndex((p) => p.id === body.player.id); + + if (idx === -1) { + state.players.push(body.player as Player); + } else { + state.players[idx] = { ...state.players[idx], ...body.player }; + } + break; + } + case "startGame": { + if (!state) break; + state.isGameStarted = true; + state.players.forEach((p) => { + p.coins = state.players.length === 2 ? 1 : 2; + p.cards = state.deck.splice(0, 2); + }); + break; + } + case "playTurn": { + if (!state) break; + const player = state.players.find((p) => p.id === body.player.id); + + if (!player) break; + // Very basic actions implementation + switch (body.action) { + case "Income": + player.coins += 1; + break; + case "Foreign aid": + player.coins += 2; + break; + case "Tax": + player.coins += 3; + break; + case "Coup": { + const target = state.players.find( + (p: Player) => p.id === body.target?.id, + ); + + if (player.coins >= 7 && target && target.cards.length > 0) { + player.coins -= 7; + target.cards.pop(); + } + break; + } + case "Assassinate": { + const target = state.players.find( + (p: Player) => p.id === body.target?.id, + ); + + if (player.coins >= 3 && target && target.cards.length > 0) { + player.coins -= 3; + target.cards.pop(); + } + break; + } + case "Steal": { + const target = state.players.find( + (p: Player) => p.id === body.target?.id, + ); + + if (target) { + const stolen = Math.min(2, target.coins); + + target.coins -= stolen; + player.coins += stolen; + } + break; + } + case "Exchange": { + const newCards = state.deck.splice(0, 2); + + state.deck.push(...player.cards.splice(0, player.cards.length)); + player.cards.push(...newCards.slice(0, 2)); + break; + } + case "Lose card": { + if (typeof body.cardToThrow === "number") { + player.cards.splice(body.cardToThrow, 1); + state.throws.push({}); + } + break; + } + } + // advance turn + const currentIndex = state.players.findIndex((p) => p.id === player.id); + + state.turn = state.players[(currentIndex + 1) % state.players.length]; + break; + } + } + + if (state) rooms.set(room, state); + + return NextResponse.json(state ?? {}); +} diff --git a/app/game/[...slug]/components/RunningGame/RunningGame.tsx b/app/game/[...slug]/components/RunningGame/RunningGame.tsx index 9c756de..beb73ff 100644 --- a/app/game/[...slug]/components/RunningGame/RunningGame.tsx +++ b/app/game/[...slug]/components/RunningGame/RunningGame.tsx @@ -21,7 +21,6 @@ import GameOver from "./components/GameOver"; import { cardsImg, discussionMeta, icons } from "./RunningGame.utils"; import CounterActionModal from "./components/CounteractionModal/CounterActionModal"; -import { useWebSocket } from "@/app/providers"; import { Action, Card, Discussion, Player } from "@/types/game"; import { subtitle } from "@/shared/primitives"; @@ -79,7 +78,6 @@ const RunningGame: React.FC = ({ ); const [globalActiveDiscussion, setGlobalActiveDiscussion] = useState(null); - const { socket } = useWebSocket(); const handleActionsModal = () => { onOpenActionMenu(); }; @@ -106,8 +104,8 @@ const RunningGame: React.FC = ({ return players.find((p) => p.id == selectedTargetId); }; - const sendAction = (action: Action, extraData: any = {}) => { - let currentStep = globalActiveDiscussion?.step || 0; + const sendAction = async (action: Action, extraData: any = {}) => { + const currentStep = globalActiveDiscussion?.step || 0; const payload = { type: "playTurn", room, @@ -124,7 +122,11 @@ const RunningGame: React.FC = ({ ...extraData, }; - socket?.send(JSON.stringify(payload)); + await fetch(`/api/game/${room}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); }; const sendSelectedAction = ( expicit_action: Action = "None", diff --git a/app/game/[...slug]/page.tsx b/app/game/[...slug]/page.tsx index cbe2100..aa74f28 100644 --- a/app/game/[...slug]/page.tsx +++ b/app/game/[...slug]/page.tsx @@ -8,8 +8,6 @@ import { createDeck, createThrows } from "./page.utils"; import PlayersComponent from "./components/AllPlayers"; import RunningGame from "./components/RunningGame"; -import { useWebSocket } from "@/app/providers"; -import WebSocketComponent from "@/shared/websocket"; import { Card, Discussion, Player } from "@/types/game"; import { title, subtitle } from "@/shared/primitives"; @@ -28,22 +26,28 @@ export default function Game() { const [room, setRoom] = useState("none"); const [deck, setDeck] = useState([]); const [throws, setThrows] = useState([]); - const { socket } = useWebSocket(); - const handleNamingButton = () => { + const apiCall = async (payload: any) => { + if (room === "none") return; + await fetch(`/api/game/${room}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + }; + + const handleNamingButton = async () => { if (!player) return; player.name = name; setPlayer(player); localStorage.setItem("player", JSON.stringify(player)); - socket?.send( - JSON.stringify({ type: "updatePlayerName", player: player, room: room }), - ); + await apiCall({ type: "updatePlayerName", player }); }; const gameStarts = () => { setIsGameStarted(true); }; - const sendStartGameSignal = () => { - socket?.send(JSON.stringify({ type: "startGame", room: room })); + const sendStartGameSignal = async () => { + await apiCall({ type: "startGame" }); }; useEffect(() => { @@ -75,81 +79,101 @@ export default function Game() { setRoom(routeUuid); setRouteUuid(routeUuid); + + const createRoom = async () => { + await fetch(`/api/game/${routeUuid}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + type: "createRoom", + game: { deck, throws, players }, + }), + }); + }; + + createRoom(); + + const fetchStatus = async () => { + const res = await fetch(`/api/game/${routeUuid}`); + + if (!res.ok) return; + const data = await res.json(); + + setDeck(data.deck); + setThrows(data.throws); + setPlayers(data.players); + setPlayer( + data.players.find((p: Player) => p.id === newPlayer.id) as Player, + ); + setTurn(data.turn); + setDiscussions(data.openDiscussions || []); + if (data.isGameStarted) gameStarts(); + }; + + fetchStatus(); + const interval = setInterval(fetchStatus, 1000); + + return () => clearInterval(interval); }, []); if (room !== "none") return ( - -
- {player && !isGameStarted && ( -
-

Coup room

-

- Invite your friends to this URL link and play together. -

- - {players.length > 1 && player?.isHost && ( - - )} - -

Insert your nickname:

-
{ - e.preventDefault(); - handleNamingButton(); - }} +
+ {player && !isGameStarted && ( +
+

Coup room

+

+ Invite your friends to this URL link and play together. +

+ + {players.length > 1 && player?.isHost && ( + - - -

Players in the room:

- -
- )} - {isGameStarted && ( - - )} -
- + Start Game + + )} + +

Insert your nickname:

+
{ + e.preventDefault(); + handleNamingButton(); + }} + > + setName(e.target.value)} + /> + +
+ +

Players in the room:

+ +
+ )} + {isGameStarted && ( + + )} +
); else return null; } diff --git a/app/providers.tsx b/app/providers.tsx index b6c4896..1c3cbf3 100644 --- a/app/providers.tsx +++ b/app/providers.tsx @@ -6,8 +6,8 @@ import * as React from "react"; import { HeroUIProvider } from "@heroui/system"; import { useRouter } from "next/navigation"; import { ThemeProvider as NextThemesProvider } from "next-themes"; -import { createContext } from "react"; import { ToastProvider } from "@heroui/toast"; + export interface ProvidersProps { children: React.ReactNode; themeProps?: ThemeProviderProps; @@ -21,38 +21,15 @@ declare module "@react-types/shared" { } } -/* Websockets context provider */ - -type WebSocketContextType = { - socket: WebSocket | null; -}; - -const ws = new WebSocket( - process.env.NEXT_PUBLIC_WS_URL || "ws://localhost:8000", -); -const WebSocketContext = createContext({ - socket: ws, -}); - -export const useWebSocket = () => { - const context = React.useContext(WebSocketContext); - - if (!context) { - throw new Error("useWebSocket must be used within a WebSocketProvider"); - } - - return context; -}; - export function Providers({ children, themeProps }: ProvidersProps) { const router = useRouter(); return ( - + <> {children} - + ); } diff --git a/package.json b/package.json index 6112e26..fa2bc61 100644 --- a/package.json +++ b/package.json @@ -7,8 +7,7 @@ "build": "next build", "start": "next start", "lint": "eslint . --ext .ts,.tsx -c .eslintrc.json --fix", - "test": "vitest", - "prepare": "next-ws patch" + "test": "vitest" }, "dependencies": { "@heroui/button": "2.2.9", @@ -29,16 +28,11 @@ "clsx": "2.1.1", "framer-motion": "11.13.1", "intl-messageformat": "^10.5.0", - "ioredis": "^5.5.0", "next": "15.0.4", "next-themes": "^0.4.4", - "next-ws": "^2.0.0", "react": "18.3.1", "react-dom": "18.3.1", - "socket.io": "^4.8.1", - "socket.io-client": "^4.8.1", - "uuid": "^11.0.5", - "ws": "^8.18.0" + "uuid": "^11.0.5" }, "devDependencies": { "@next/eslint-plugin-next": "15.0.4", @@ -48,7 +42,6 @@ "@types/node": "20.5.7", "@types/react": "18.3.3", "@types/react-dom": "18.3.0", - "@types/ws": "^8.5.14", "@typescript-eslint/eslint-plugin": "8.11.0", "@typescript-eslint/parser": "8.11.0", "autoprefixer": "10.4.19", diff --git a/shared/websocket.tsx b/shared/websocket.tsx deleted file mode 100644 index b8e12c5..0000000 --- a/shared/websocket.tsx +++ /dev/null @@ -1,88 +0,0 @@ -"use client"; -import React, { - Dispatch, - ReactElement, - SetStateAction, - useEffect, -} from "react"; - -import { useWebSocket } from "@/app/providers"; -import { Card, Discussion, Player } from "@/types/game"; - -type WebSocketComponentProps = { - room: string; - children: ReactElement; - deck: Card[]; - throws: Card[]; - player: Player; - players: Player[]; - setDeck: Dispatch>; - setThrows: Dispatch>; - setPlayers: Dispatch>; - setPlayer: Dispatch>; - setTurn: Dispatch>; - setDiscussions: Dispatch>; - gameStarts: () => void; -}; - -const WebSocketComponent = ({ - room, - children, - deck, - throws, - player, - players, - setDiscussions, - setDeck, - setTurn, - setPlayers, - setPlayer, - setThrows, - gameStarts, -}: WebSocketComponentProps) => { - const { socket } = useWebSocket(); - - useEffect(() => { - if (!socket) return; - - socket.onopen = () => { - socket.send( - JSON.stringify({ - type: "createRoom", - room: room, - game: { deck, throws, players }, - }), - ); - }; - - socket.onmessage = (event) => { - const msg = JSON.parse(event.data); - - switch (msg.type) { - case "status": - setDeck(msg.data.deck); - setThrows(msg.data.throws); - setPlayers(msg.data.players); - setPlayer( - msg.data.players.find((p: Player) => p.id === player.id) as Player, - ); - setTurn(msg.data.turn as Player); - setDiscussions(msg.data.openDiscussions as Discussion[]); - msg.data.isGameStarted && gameStarts(); - break; - case "startGame": - gameStarts(); - default: - break; - } - }; - - return () => { - //socket.close(); - }; - }, []); - - return
{children}
; -}; - -export default WebSocketComponent;