From 1912b3a76f511cc4ce0696d595e5bc2d36898dff Mon Sep 17 00:00:00 2001 From: josedario87 Date: Thu, 7 Aug 2025 17:10:08 -0600 Subject: [PATCH] docs: add contributor and Colyseus guides feat(client): parametrize WS/API via VITE_WS_URL and VITE_API_URL; add .env.example and README env section --- AGENTS.md | 42 ++++++++++++ COLYSEUS-GUIA.md | 117 ++++++++++++++++++++++++++++++++ README.md | 13 +++- client/.env.example | 10 +++ client/src/services/colyseus.ts | 28 +++++--- 5 files changed, 200 insertions(+), 10 deletions(-) create mode 100644 AGENTS.md create mode 100644 COLYSEUS-GUIA.md create mode 100644 client/.env.example diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..5252986 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,42 @@ +# Repository Guidelines + +## Project Structure & Module Organization +- `server/`: Colyseus + Express TypeScript backend (`src/index.ts`, rooms, schemas, utils, `adminApi.ts`). +- `client/`: Vue 3 + Vite TypeScript app (`src/views`, `services/colyseus.ts`, `router`, `main.ts`). +- `shared/`: Cross-cutting TypeScript types (`shared/types.ts`). + +## Build, Test, and Development Commands +- From repo root: + - `npm run install:all`: Install client and server dependencies. + - `npm run dev`: Run server and client in watch mode. + - `npm run build`: Type-check and build server and client. +- Per package: + - Server: `cd server && npm run dev | build | start`. + - Client: `cd client && npm run dev | build | preview`. + +## Coding Style & Naming Conventions +- Language: TypeScript (server/client). Indentation: 2 spaces. +- Quotes/Semicolons: double quotes and trailing semicolons (match existing code). +- Naming: `PascalCase` for classes and Vue SFC files (e.g., `Game.vue`), `camelCase` for variables/functions, `kebab-case` for non-component file names when appropriate. +- Keep server state changes server-authoritative; avoid mutating Colyseus state on client. +- No linter is configured; keep changes consistent with surrounding files. + +## Testing Guidelines +- No tests yet. Prefer adding: + - Client: Vitest + Vue Test Utils for views/services. + - Server: Jest for room logic and schema helpers. +- Conventions: mirror source path and name tests `*.spec.ts` (e.g., `server/src/rooms/GameRoom.spec.ts`). +- CI-friendly: tests must run headless and not require a live server. + +## Commit & Pull Request Guidelines +- Commits: follow Conventional Commits (`feat:`, `fix:`, `docs:`) as in history. +- PRs include: + - Clear description and rationale; link issues (e.g., `Fixes #123`). + - Screenshots/GIFs for UI changes (`client/src/views/*`). + - Steps to reproduce and test notes. + - Scope limited to one logical change; update README/QUICKSTART if behavior changes. + +## Security & Configuration Tips +- Server port via `PORT` (default 3000). Monitor at `/colyseus`, REST at `/api`. +- Client WebSocket URL is set in `client/src/services/colyseus.ts`; consider env (`VITE_WS_URL`) for deployments. +- CORS is enabled; restrict origins in production. diff --git a/COLYSEUS-GUIA.md b/COLYSEUS-GUIA.md new file mode 100644 index 0000000..13f0c7a --- /dev/null +++ b/COLYSEUS-GUIA.md @@ -0,0 +1,117 @@ +# Colyseus: Guía de Integración para Snatchgame + +Esta guía resume las APIs de Colyseus necesarias para: un lobby unificado, salas de juego (máx. 2 jugadores) y un dashboard para inspección/acciones por sala y globales. + +## Conceptos Clave +- Rooms: unidades de ejecución con ciclo de vida (`onCreate`, `onJoin`, `onLeave`, `onDispose`). +- Schema: sincroniza estado autoritativo del servidor a clientes con `@type`. +- Mensajería: `room.onMessage(type, handler)`, `client.send(type, payload)`, `room.broadcast(type, payload)`. +- Matchmaker: descubrimiento/control de salas (`matchMaker.query`, `remoteRoomCall`, `stats`). +- Monitor: panel listo para usar en Express (`@colyseus/monitor`). + +## Lobby Unificado +- Definir una sala pública única `lobby` que gestione nombres, matchmaking y listado. +- API útil: `gameServer.define("lobby", LobbyRoom)`, `setPrivate(false)`, `enableRealtimeListing()` en salas de juego para listar. +- Mensajes recomendados: `setName`, `quickPlay`, `joinRoom`. + +Ejemplo (server/src/rooms/LobbyRoom.ts): +```ts +export class LobbyRoom extends Room { + onCreate() { + this.setState(new LobbyState()); + this.onMessage("setName", (client, name) => {/* validar + asignar */}); + this.onMessage("quickPlay", async (client) => { + // Crear o elegir una sala "game" con cupo + const rooms = await matchMaker.query({ name: "game", locked: false }); + const target = rooms.find(r => r.clients < r.maxClients) || + await matchMaker.createRoom("game", { maxClients: 2 }); + client.send("gameJoined", { roomId: target.roomId || target.roomId }); + }); + } +} +``` + +## Game Rooms (2 jugadores) +- Definición: `gameServer.define("game", GameRoom).filterBy(["maxClients"]).enableRealtimeListing()`. +- Capacidad: `maxClients = 2;` en la clase de la sala. +- Re-conexión: `await this.allowReconnection(client, 30);` para tolerancia a fallos. +- Ticks: usar `this.setSimulationInterval((deltaTime) => { /* actualizar timer */ }, 1000);`. + +Ejemplo (server/src/rooms/GameRoom.ts): +```ts +export class GameRoom extends Room { + maxClients = 2; + onCreate() { + this.setState(new GameState()); + this.onMessage("click", (client) => {/* sumar clicks si PLAYING */}); + } + onJoin(client, { playerName }) { + this.state.addPlayer(client.sessionId, playerName); + if (this.state.players.size === 2) this.state.startGame(); + } + onLeave(client) { + this.state.players.get(client.sessionId)!.connected = false; + this.allowReconnection(client, 30); + } +} +``` + +## Estado Autoritativo (Schema) +- Definir modelos con `@type` y colecciones `MapSchema`/`ArraySchema`. +- Patrón: solo el servidor muta estado; clientes envían mensajes de intención. + +Ejemplo: +```ts +export class GameState extends Schema { + @type({ map: Player }) players = new MapSchema(); + @type("string") gameStatus: GameStatus = GameStatus.WAITING; + @type("number") timeRemaining = 600; +} +``` + +## Dashboard y Control Administrativo +- Monitor integrado: `app.use("/colyseus", monitor());` para ver salas/CCU. +- REST/Admin personalizado vía `matchMaker`: + - Listar: `await matchMaker.query({ name: "game" });` + - Llamar métodos remotos: `await matchMaker.remoteRoomCall(roomId, "getState");` + - Acciones por sala: `await matchMaker.remoteRoomCall(roomId, "broadcast", ["admin:pause"]);` + +Ejemplos (server/src/adminApi.ts): +```ts +router.get("/rooms", async (_, res) => { + const rooms = await matchMaker.query({}); + res.json(rooms); +}); +router.post("/rooms/:roomId/restart", async (req, res) => { + await matchMaker.remoteRoomCall(req.params.roomId, "broadcast", ["admin:restart"]); + res.json({ success: true }); +}); +``` + +Acciones globales (todas las salas "game"): +```ts +const rooms = await matchMaker.query({ name: "game" }); +await Promise.all(rooms.map(r => matchMaker.remoteRoomCall(r.roomId, "broadcast", ["admin:pause"]))); +``` + +## Buenas Prácticas +- Validar inputs en `onMessage` y nunca confiar en el cliente. +- Usar `metadata`/`filterBy` para matchmaking por atributos. +- Mantener `maxClients` y reglas de inicio/pausa dentro del servidor. +- Exponer solo métodos necesarios para `remoteRoomCall` (p. ej., `getState`). + +Referencias: documentación oficial Colyseus (Rooms, Schema, Matchmaker, Monitor). + +## Configuración de WebSocket (Cliente) +- Variable de entorno en Vite: `VITE_WS_URL`. +- Si no se define, el cliente usa `ws(s)://:3000` según el protocolo actual. +- Ejemplos: + - Desarrollo local: `VITE_WS_URL=ws://localhost:3000` + - Producción (TLS): `VITE_WS_URL=wss://api.midominio.com` + +## Configuración de REST API (Cliente) +- Variable de entorno en Vite: `VITE_API_URL` con la base completa del API (incluye `/api`). +- Si no se define, el cliente usa `http(s)://:3000/api` según el protocolo actual. +- Ejemplos: + - Desarrollo local: `VITE_API_URL=http://localhost:3000/api` + - Producción (TLS): `VITE_API_URL=https://api.midominio.com/api` diff --git a/README.md b/README.md index b913ad5..1346461 100644 --- a/README.md +++ b/README.md @@ -188,6 +188,17 @@ This will start: - Server on http://localhost:3000 - Client on http://localhost:5173 +### Environment Variables (Client) + +Create `client/.env` (or copy `client/.env.example`) to configure endpoints: + +``` +VITE_WS_URL=ws://localhost:3000 +VITE_API_URL=http://localhost:3000/api +``` + +For production over HTTPS, use `wss://` for `VITE_WS_URL` and an HTTPS API base for `VITE_API_URL`. + ## URLs - **Game**: http://localhost:5173 @@ -247,4 +258,4 @@ The admin dashboard provides: - `POST /api/rooms/:roomId/resume` - Resume a game - `POST /api/rooms/:roomId/restart` - Restart a game - `POST /api/rooms/:roomId/kick/:playerId` - Kick a player -- `GET /api/stats` - Get global server statistics \ No newline at end of file +- `GET /api/stats` - Get global server statistics diff --git a/client/.env.example b/client/.env.example new file mode 100644 index 0000000..f0e0f2d --- /dev/null +++ b/client/.env.example @@ -0,0 +1,10 @@ +# Client environment variables (copy to client/.env) + +# WebSocket endpoint for Colyseus server +# e.g., ws://localhost:3000 (dev) or wss://api.my-domain.com (prod) +VITE_WS_URL=ws://localhost:3000 + +# REST API base (must include /api) +# e.g., http://localhost:3000/api (dev) or https://api.my-domain.com/api (prod) +VITE_API_URL=http://localhost:3000/api + diff --git a/client/src/services/colyseus.ts b/client/src/services/colyseus.ts index f1cf656..ddd4a95 100644 --- a/client/src/services/colyseus.ts +++ b/client/src/services/colyseus.ts @@ -23,6 +23,7 @@ export interface AvailableRoom { class ColyseusService { private client: Client; private currentRoom: Room | null = null; + private apiBase: string; public lobbyRoom: Ref = ref(null); public gameRoom: Ref = ref(null); @@ -30,7 +31,16 @@ class ColyseusService { public sessionId: Ref = ref(""); constructor() { - this.client = new Client("ws://localhost:3000"); + const defaultHost = typeof window !== "undefined" ? window.location.hostname : "localhost"; + const defaultProtocol = typeof window !== "undefined" && window.location.protocol === "https:" ? "wss" : "ws"; + const defaultPort = 3000; + const fallbackUrl = `${defaultProtocol}://${defaultHost}:${defaultPort}`; + const url = import.meta.env.VITE_WS_URL || fallbackUrl; + this.client = new Client(url); + + const httpProtocol = typeof window !== "undefined" && window.location.protocol === "https:" ? "https" : "http"; + const apiFallback = `${httpProtocol}://${defaultHost}:${defaultPort}/api`; + this.apiBase = (import.meta.env as any).VITE_API_URL || apiFallback; } async joinLobby(): Promise { @@ -170,7 +180,7 @@ class ColyseusService { async fetchRooms(): Promise { try { - const response = await fetch("http://localhost:3000/api/rooms"); + const response = await fetch(`${this.apiBase}/rooms`); return await response.json(); } catch (error) { console.error("Failed to fetch rooms:", error); @@ -180,7 +190,7 @@ class ColyseusService { async fetchRoomStats(roomId: string): Promise { try { - const response = await fetch(`http://localhost:3000/api/rooms/${roomId}/stats`); + const response = await fetch(`${this.apiBase}/rooms/${roomId}/stats`); return await response.json(); } catch (error) { console.error("Failed to fetch room stats:", error); @@ -189,24 +199,24 @@ class ColyseusService { } async pauseRoom(roomId: string): Promise { - await fetch(`http://localhost:3000/api/rooms/${roomId}/pause`, { method: "POST" }); + await fetch(`${this.apiBase}/rooms/${roomId}/pause`, { method: "POST" }); } async resumeRoom(roomId: string): Promise { - await fetch(`http://localhost:3000/api/rooms/${roomId}/resume`, { method: "POST" }); + await fetch(`${this.apiBase}/rooms/${roomId}/resume`, { method: "POST" }); } async restartRoom(roomId: string): Promise { - await fetch(`http://localhost:3000/api/rooms/${roomId}/restart`, { method: "POST" }); + await fetch(`${this.apiBase}/rooms/${roomId}/restart`, { method: "POST" }); } async kickPlayer(roomId: string, playerId: string): Promise { - await fetch(`http://localhost:3000/api/rooms/${roomId}/kick/${playerId}`, { method: "POST" }); + await fetch(`${this.apiBase}/rooms/${roomId}/kick/${playerId}`, { method: "POST" }); } async fetchGlobalStats(): Promise { try { - const response = await fetch("http://localhost:3000/api/stats"); + const response = await fetch(`${this.apiBase}/stats`); return await response.json(); } catch (error) { console.error("Failed to fetch global stats:", error); @@ -215,4 +225,4 @@ class ColyseusService { } } -export const colyseusService = new ColyseusService(); \ No newline at end of file +export const colyseusService = new ColyseusService();