diff --git a/.eslintrc.json b/.eslintrc.json deleted file mode 100644 index 524a633..0000000 --- a/.eslintrc.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "env": { - "browser": true, - "es2021": true - }, - "ignorePatterns": ["**/*.js", "**/*.d.ts"], - "extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended"], - "parser": "@typescript-eslint/parser", - "parserOptions": { - "ecmaVersion": "latest", - "sourceType": "module" - }, - "plugins": ["@typescript-eslint", "eslint-plugin-tsdoc"], - "rules": { - "require-yield": "off", - "@typescript-eslint/explicit-member-accessibility": "error", - "@typescript-eslint/no-explicit-any": "off", - "@typescript-eslint/ban-ts-comment": "off", - "@typescript-eslint/no-non-null-assertion": "off", - "@typescript-eslint/no-namespace": "off", - "tsdoc/syntax": "warn" - } -} diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml index b0705a9..aca44f7 100644 --- a/.github/workflows/deploy-pages.yml +++ b/.github/workflows/deploy-pages.yml @@ -35,8 +35,6 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@v4 - with: - version: 9 - name: Setup Node.js uses: actions/setup-node@v4 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index fac739a..f767b5b 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -30,8 +30,6 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@v4 - with: - version: 9 - name: Setup Node.js uses: actions/setup-node@v4 diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index feeadd7..c3eb02e 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -2,7 +2,7 @@ name: Verify Pull Request on: pull_request: {} - workflow_dispatch: {} + workflow_dispatch: env: HUSKY: 0 @@ -19,6 +19,7 @@ jobs: - uses: actions/setup-node@v4 with: node-version: 22 + cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm exec commitlint --from ${{ github.event.pull_request.base.sha }} --verbose lint: @@ -30,11 +31,11 @@ jobs: - uses: actions/setup-node@v4 with: node-version: 22 + cache: pnpm - run: pnpm install --frozen-lockfile - - run: pnpm --filter @zolver/core build - - run: pnpm --filter @zolvery/server lint - prettier: - name: Code style + - run: pnpm lint + typecheck: + name: Type check runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -42,8 +43,9 @@ jobs: - uses: actions/setup-node@v4 with: node-version: 22 + cache: pnpm - run: pnpm install --frozen-lockfile - - run: pnpm prettier --check . + - run: pnpm typecheck tests: name: Tests runs-on: ubuntu-latest @@ -53,9 +55,9 @@ jobs: - uses: actions/setup-node@v4 with: node-version: 22 + cache: pnpm - run: pnpm install --frozen-lockfile - - run: pnpm --filter @zolver/core test - - run: pnpm --filter @zolvery/server test + - run: pnpm vitest run --passWithNoTests build: name: Build runs-on: ubuntu-latest @@ -65,8 +67,6 @@ jobs: - uses: actions/setup-node@v4 with: node-version: 22 + cache: pnpm - run: pnpm install --frozen-lockfile - - name: Build core - run: pnpm --filter @zolver/core build - - name: Build server - run: pnpm --filter @zolvery/server build + - run: pnpm turbo run build --filter=@aprovan/patchwork-image-boardgameio diff --git a/.prettierrc b/.prettierrc index 517a2b6..1c6452f 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,12 +1 @@ -{ - "plugins": [], - "singleQuote": true, - "printWidth": 80, - "tabWidth": 2, - "trailingComma": "all", - "proseWrap": "always", - "importOrder": ["^components/(.*)$", "^[./]"], - "importOrderSeparation": true, - "importOrderSortSpecifiers": true, - "singleAttributePerLine": true -} +"@aprovan/prettier-config" diff --git a/apps/client/index.html b/apps/client/index.html index 7a22fb0..b6a6da3 100644 --- a/apps/client/index.html +++ b/apps/client/index.html @@ -1,36 +1,18 @@ - + Zolvery - - + + - - - - + + + + @@ -39,15 +21,11 @@ - + diff --git a/apps/client/main.tsx b/apps/client/main.tsx index 5793544..272cc66 100644 --- a/apps/client/main.tsx +++ b/apps/client/main.tsx @@ -1,14 +1,14 @@ -import React, { useState, useCallback, useEffect } from 'react'; -import { createRoot } from 'react-dom/client'; -import { PencilIcon } from '@heroicons/react/24/outline'; -import { EditableWidgetPlayer } from './src/components/editable-widget-player'; -import { useWidgetSource } from './src/hooks/use-widget-source'; -import { useGamesCatalog, type GameEntry } from './src/hooks/use-games-catalog'; -import { GameCatalog } from './src/components/game-catalog'; -import { GameSetup, type GameConfig } from './src/components/game-setup'; -import { GameLobby, type GameLobbyConfig } from './src/components/game-lobby'; - -import './style.css'; +import { PencilIcon } from "@heroicons/react/24/outline"; +import React, { useState, useCallback, useEffect } from "react"; +import { createRoot } from "react-dom/client"; +import { EditableWidgetPlayer } from "./src/components/editable-widget-player"; +import { GameCatalog } from "./src/components/game-catalog"; +import { GameLobby, type GameLobbyConfig } from "./src/components/game-lobby"; +import { GameSetup, type GameConfig } from "./src/components/game-setup"; +import { useGamesCatalog, type GameEntry } from "./src/hooks/use-games-catalog"; +import { useWidgetSource } from "./src/hooks/use-widget-source"; + +import "./style.css"; declare global { interface Window { @@ -27,31 +27,29 @@ const isMobileApp = (): boolean => { // Check for Capacitor's custom schemes or if window.Capacitor exists const protocol = window.location.protocol; return ( - protocol === 'capacitor:' || - protocol === 'ionic:' || - typeof (window as { Capacitor?: unknown }).Capacitor !== 'undefined' + protocol === "capacitor:" || + protocol === "ionic:" || + typeof (window as { Capacitor?: unknown }).Capacitor !== "undefined" ); }; // Share PeerJS settings between the lobby and boardgame transport // On mobile, default to the public PeerJS server if not configured const peerHost = - import.meta.env.VITE_PEER_HOST || - (isMobileApp() ? '0.peerjs.com' : window.location.hostname); -const peerPort = - Number(import.meta.env.VITE_PEER_PORT) || (isMobileApp() ? 443 : 9500); -const peerPath = import.meta.env.VITE_PEER_PATH || '/'; + import.meta.env.VITE_PEER_HOST || (isMobileApp() ? "0.peerjs.com" : window.location.hostname); +const peerPort = Number(import.meta.env.VITE_PEER_PORT) || (isMobileApp() ? 443 : 9500); +const peerPath = import.meta.env.VITE_PEER_PATH || "/"; const peerSecure = - import.meta.env.VITE_PEER_SECURE === 'true' || + import.meta.env.VITE_PEER_SECURE === "true" || (import.meta.env.VITE_PEER_SECURE === undefined && - (isMobileApp() || window.location.protocol === 'https:')); + (isMobileApp() || window.location.protocol === "https:")); const turnUrl = import.meta.env.VITE_PEER_TURN_URL; const turnUsername = import.meta.env.VITE_PEER_TURN_USERNAME; const turnCredential = import.meta.env.VITE_PEER_TURN_CREDENTIAL; const globalIceServers: RTCIceServer[] = [ - { urls: 'stun:stun.l.google.com:19302' }, - { urls: 'stun:stun1.l.google.com:19302' }, + { urls: "stun:stun.l.google.com:19302" }, + { urls: "stun:stun1.l.google.com:19302" }, ]; if (turnUrl) { @@ -71,23 +69,23 @@ window.__peerConfig = { }; type AppState = - | { view: 'catalog' } - | { view: 'setup'; game: GameEntry } + | { view: "catalog" } + | { view: "setup"; game: GameEntry } | { - view: 'lobby'; + view: "lobby"; config: GameConfig; - mode: 'host' | 'join'; + mode: "host" | "join"; joinCode?: string; } - | { view: 'playing'; config: GameConfig; lobbyConfig?: GameLobbyConfig }; + | { view: "playing"; config: GameConfig; lobbyConfig?: GameLobbyConfig }; type ParsedRoute = - | { type: 'catalog' } - | { type: 'game'; gameId: string } - | { type: 'host'; gameId: string; matchCode: string } - | { type: 'join'; gameId: string; matchCode: string } + | { type: "catalog" } + | { type: "game"; gameId: string } + | { type: "host"; gameId: string; matchCode: string } + | { type: "join"; gameId: string; matchCode: string } | { - type: 'play'; + type: "play"; gameId: string; matchCode: string; playerID: string; @@ -98,16 +96,14 @@ function parseHashRoute(): ParsedRoute { const hash = window.location.hash; // Play route (active game): #/apps/{gameId}/play/{code}/{playerID}/{host|client} - const playMatch = hash.match( - /^#\/apps\/(.+)\/play\/([A-Z0-9]+)\/(\d+)\/(host|client)$/i, - ); + const playMatch = hash.match(/^#\/apps\/(.+)\/play\/([A-Z0-9]+)\/(\d+)\/(host|client)$/i); if (playMatch) { return { - type: 'play', + type: "play", gameId: playMatch[1], matchCode: playMatch[2].toUpperCase(), playerID: playMatch[3], - isHost: playMatch[4].toLowerCase() === 'host', + isHost: playMatch[4].toLowerCase() === "host", }; } @@ -115,7 +111,7 @@ function parseHashRoute(): ParsedRoute { const hostMatch = hash.match(/^#\/apps\/(.+)\/host\/([A-Z0-9]+)$/i); if (hostMatch) { return { - type: 'host', + type: "host", gameId: hostMatch[1], matchCode: hostMatch[2].toUpperCase(), }; @@ -125,7 +121,7 @@ function parseHashRoute(): ParsedRoute { const joinMatch = hash.match(/^#\/apps\/(.+)\/join\/([A-Z0-9]+)$/i); if (joinMatch) { return { - type: 'join', + type: "join", gameId: joinMatch[1], matchCode: joinMatch[2].toUpperCase(), }; @@ -134,40 +130,38 @@ function parseHashRoute(): ParsedRoute { // Game route: #/apps/{gameId} const gameMatch = hash.match(/^#\/apps\/(.+?)(?:\/)?$/); if (gameMatch) { - return { type: 'game', gameId: gameMatch[1] }; + return { type: "game", gameId: gameMatch[1] }; } // Fallback to pathname-based route for join (works with server-side routing or 404.html redirect) - const pathMatch = window.location.pathname.match( - /^\/apps\/(.+)\/join\/([A-Z0-9]+)$/i, - ); + const pathMatch = window.location.pathname.match(/^\/apps\/(.+)\/join\/([A-Z0-9]+)$/i); if (pathMatch) { return { - type: 'join', + type: "join", gameId: pathMatch[1], matchCode: pathMatch[2].toUpperCase(), }; } - return { type: 'catalog' }; + return { type: "catalog" }; } function setHashRoute(route: ParsedRoute): void { - let hash = ''; - if (route.type === 'game') { + let hash = ""; + if (route.type === "game") { hash = `#/apps/${route.gameId}`; - } else if (route.type === 'host') { + } else if (route.type === "host") { hash = `#/apps/${route.gameId}/host/${route.matchCode}`; - } else if (route.type === 'join') { + } else if (route.type === "join") { hash = `#/apps/${route.gameId}/join/${route.matchCode}`; - } else if (route.type === 'play') { + } else if (route.type === "play") { hash = `#/apps/${route.gameId}/play/${route.matchCode}/${route.playerID}/${ - route.isHost ? 'host' : 'client' + route.isHost ? "host" : "client" }`; } if (window.location.hash !== hash) { - window.history.pushState({}, '', hash || '/'); + window.history.pushState({}, "", hash || "/"); } } @@ -200,11 +194,10 @@ function clearSessionCredentials(matchCode: string): void { } const IS_LOCALHOST = - window.location.hostname === 'localhost' || - window.location.hostname === '127.0.0.1'; + window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1"; function App() { - const [state, setState] = useState({ view: 'catalog' }); + const [state, setState] = useState({ view: "catalog" }); const [isEditing, setIsEditing] = useState(false); const { categories, isLoading: catalogLoading } = useGamesCatalog(); @@ -217,68 +210,68 @@ function App() { } return undefined; }, - [categories], + [categories] ); // Apply a parsed route to app state const applyRoute = useCallback( (route: ParsedRoute) => { - if (route.type === 'catalog') { - setState({ view: 'catalog' }); - } else if (route.type === 'game') { + if (route.type === "catalog") { + setState({ view: "catalog" }); + } else if (route.type === "game") { const game = findGame(route.gameId); if (game) { - setState({ view: 'setup', game }); + setState({ view: "setup", game }); } else { - setState({ view: 'catalog' }); + setState({ view: "catalog" }); } - } else if (route.type === 'host') { + } else if (route.type === "host") { const game = findGame(route.gameId); if (game) { setState({ - view: 'lobby', + view: "lobby", config: { game, - playMode: 'host', + playMode: "host", playerCount: 2, botCount: 0, settings: {}, }, - mode: 'host', + mode: "host", joinCode: route.matchCode, // Pass the code so lobby can restore it }); } else { - setState({ view: 'catalog' }); + setState({ view: "catalog" }); } - } else if (route.type === 'join') { + } else if (route.type === "join") { const game = findGame(route.gameId); if (game) { setState({ - view: 'lobby', + view: "lobby", config: { game, - playMode: 'join', + playMode: "join", playerCount: 2, botCount: 0, settings: {}, }, - mode: 'join', + mode: "join", joinCode: route.matchCode, }); } else { - setState({ view: 'catalog' }); + setState({ view: "catalog" }); } - } else if (route.type === 'play') { + } else if (route.type === "play") { const game = findGame(route.gameId); if (game) { // Load saved credentials for reconnection const savedCredentials = loadSessionCredentials(route.matchCode); // Reconnect directly to an active game setState({ - view: 'playing', + view: "playing", config: { game, - playMode: route.isHost ? 'host' : 'join', + playMode: route.isHost ? "host" : "join", playerCount: 2, botCount: 0, settings: {}, @@ -286,16 +279,16 @@ function App() { lobbyConfig: { matchID: route.matchCode, playerID: route.playerID, - credentials: savedCredentials || '', // Use saved or let transport regenerate + credentials: savedCredentials || "", // Use saved or let transport regenerate isHost: route.isHost, }, }); } else { - setState({ view: 'catalog' }); + setState({ view: "catalog" }); } } }, - [findGame], + [findGame] ); // Handle route changes (initial load + hashchange) @@ -311,91 +304,91 @@ function App() { handleRouteChange(); // Listen for hash changes (back/forward, manual URL edits) - window.addEventListener('hashchange', handleRouteChange); - window.addEventListener('popstate', handleRouteChange); + window.addEventListener("hashchange", handleRouteChange); + window.addEventListener("popstate", handleRouteChange); return () => { - window.removeEventListener('hashchange', handleRouteChange); - window.removeEventListener('popstate', handleRouteChange); + window.removeEventListener("hashchange", handleRouteChange); + window.removeEventListener("popstate", handleRouteChange); }; }, [categories, applyRoute]); const appId = - state.view === 'setup' + state.view === "setup" ? state.game.appId - : state.view === 'lobby' - ? state.config.game.appId - : state.view === 'playing' - ? state.config.game.appId - : null; + : state.view === "lobby" + ? state.config.game.appId + : state.view === "playing" + ? state.config.game.appId + : null; const { manifest, source, isLoading: sourceLoading } = useWidgetSource(appId); const handleSelectGame = useCallback((game: GameEntry) => { - setHashRoute({ type: 'game', gameId: game.appId }); - setState({ view: 'setup', game }); + setHashRoute({ type: "game", gameId: game.appId }); + setState({ view: "setup", game }); }, []); const handleStartGame = useCallback((config: GameConfig) => { - if (config.playMode === 'host' || config.playMode === 'join') { - setState({ view: 'lobby', config, mode: config.playMode }); + if (config.playMode === "host" || config.playMode === "join") { + setState({ view: "lobby", config, mode: config.playMode }); } else { - setState({ view: 'playing', config }); + setState({ view: "playing", config }); } }, []); const handleLobbyStart = useCallback( (lobbyConfig: GameLobbyConfig) => { - if (state.view !== 'lobby') return; + if (state.view !== "lobby") return; // Save credentials for reconnection if (lobbyConfig.credentials) { saveSessionCredentials(lobbyConfig.matchID, lobbyConfig.credentials); } // Update URL to allow reconnection on refresh setHashRoute({ - type: 'play', + type: "play", gameId: state.config.game.appId, matchCode: lobbyConfig.matchID, playerID: lobbyConfig.playerID, isHost: lobbyConfig.isHost, }); - setState({ view: 'playing', config: state.config, lobbyConfig }); + setState({ view: "playing", config: state.config, lobbyConfig }); }, - [state], + [state] ); const handleBack = useCallback(() => { - if (state.view === 'setup') { - setHashRoute({ type: 'catalog' }); - setState({ view: 'catalog' }); - } else if (state.view === 'lobby') { - setHashRoute({ type: 'game', gameId: state.config.game.appId }); - setState({ view: 'setup', game: state.config.game }); - } else if (state.view === 'playing') { + if (state.view === "setup") { + setHashRoute({ type: "catalog" }); + setState({ view: "catalog" }); + } else if (state.view === "lobby") { + setHashRoute({ type: "game", gameId: state.config.game.appId }); + setState({ view: "setup", game: state.config.game }); + } else if (state.view === "playing") { // Clear saved credentials when intentionally leaving a game if (state.lobbyConfig?.matchID) { clearSessionCredentials(state.lobbyConfig.matchID); } - setHashRoute({ type: 'catalog' }); - setState({ view: 'catalog' }); + setHashRoute({ type: "catalog" }); + setState({ view: "catalog" }); } }, [state]); // Handle when host generates a match code - update URL so refresh preserves it const handleCodeGenerated = useCallback( (code: string) => { - if (state.view !== 'lobby') return; + if (state.view !== "lobby") return; setHashRoute({ - type: 'host', + type: "host", gameId: state.config.game.appId, matchCode: code, }); }, - [state], + [state] ); // Catalog view - if (state.view === 'catalog') { + if (state.view === "catalog") { return (
- +
); } // Lobby view - if (state.view === 'lobby') { + if (state.view === "lobby") { return (
- @@ -497,5 +483,5 @@ function App() { ); } -const root = createRoot(document.getElementById('root')!); +const root = createRoot(document.getElementById("root")!); root.render(); diff --git a/apps/client/public/404.html b/apps/client/public/404.html index 8bf7fc6..60ff12b 100644 --- a/apps/client/public/404.html +++ b/apps/client/public/404.html @@ -1,4 +1,4 @@ - + @@ -11,18 +11,14 @@ var search = window.location.search; // Only redirect if we have a path that looks like a route (not a static file) - if (path && path !== '/' && !path.match(/\.[a-z0-9]+$/i)) { + if (path && path !== "/" && !path.match(/\.[a-z0-9]+$/i)) { // Convert pathname to hash route window.location.replace( - window.location.origin + - '/#' + - path + - search + - (hash ? hash.substring(1) : ''), + window.location.origin + "/#" + path + search + (hash ? hash.substring(1) : "") ); } else { // For other 404s, redirect to home - window.location.replace(window.location.origin + '/'); + window.location.replace(window.location.origin + "/"); } diff --git a/apps/client/public/sw.js b/apps/client/public/sw.js index d1ebbd5..185f616 100644 --- a/apps/client/public/sw.js +++ b/apps/client/public/sw.js @@ -1,31 +1,29 @@ -importScripts( - 'https://storage.googleapis.com/workbox-cdn/releases/7.3.0/workbox-sw.js', -); +importScripts("https://storage.googleapis.com/workbox-cdn/releases/7.3.0/workbox-sw.js"); // This is your Service Worker, you can put any of your custom Service Worker // code in this file, above the `precacheAndRoute` line. // When widget is installed/pinned, push initial state. -self.addEventListener('widgetinstall', (event) => { +self.addEventListener("widgetinstall", (event) => { event.waitUntil(updateWidget(event)); }); // When widget is shown, update content to ensure it is up-to-date. -self.addEventListener('widgetresume', (event) => { +self.addEventListener("widgetresume", (event) => { event.waitUntil(updateWidget(event)); }); // When the user clicks an element with an associated Action.Execute, // handle according to the 'verb' in event.action. -self.addEventListener('widgetclick', (event) => { - if (event.action == 'updateName') { +self.addEventListener("widgetclick", (event) => { + if (event.action == "updateName") { event.waitUntil(updateName(event)); } }); // When the widget is uninstalled/unpinned, clean up any unnecessary // periodic sync or widget-related state. -self.addEventListener('widgetuninstall', (event) => {}); +self.addEventListener("widgetuninstall", (event) => {}); const updateWidget = async (event) => { // The widget definition represents the fields specified in the manifest. @@ -33,9 +31,7 @@ const updateWidget = async (event) => { // Fetch the template and data defined in the manifest to generate the payload. const payload = { - template: JSON.stringify( - await (await fetch(widgetDefinition.msAcTemplate)).json(), - ), + template: JSON.stringify(await (await fetch(widgetDefinition.msAcTemplate)).json()), data: JSON.stringify(await (await fetch(widgetDefinition.data)).json()), }; @@ -51,9 +47,7 @@ const updateName = async (event) => { // Fetch the template and data defined in the manifest to generate the payload. const payload = { - template: JSON.stringify( - await (await fetch(widgetDefinition.msAcTemplate)).json(), - ), + template: JSON.stringify(await (await fetch(widgetDefinition.msAcTemplate)).json()), data: JSON.stringify({ name }), }; diff --git a/apps/client/scripts/embed-examples.js b/apps/client/scripts/embed-examples.js index 52bc577..fc87c8c 100644 --- a/apps/client/scripts/embed-examples.js +++ b/apps/client/scripts/embed-examples.js @@ -5,14 +5,14 @@ * for static deployment (GitHub Pages). */ -import fs from 'fs'; -import path from 'path'; -import { fileURLToPath } from 'url'; +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const CLIENT_DIR = path.resolve(__dirname, '..'); -const EXAMPLES_SRC = path.resolve(CLIENT_DIR, '../../packages/examples/src'); -const PUBLIC_APPS = path.resolve(CLIENT_DIR, 'public/apps'); +const CLIENT_DIR = path.resolve(__dirname, ".."); +const EXAMPLES_SRC = path.resolve(CLIENT_DIR, "../../packages/examples/src"); +const PUBLIC_APPS = path.resolve(CLIENT_DIR, "public/apps"); function copyRecursive(src, dest) { if (!fs.existsSync(src)) { @@ -33,7 +33,7 @@ function copyRecursive(src, dest) { } function main() { - console.log('[embed-examples] Copying examples to public/apps...'); + console.log("[embed-examples] Copying examples to public/apps..."); console.log(` From: ${EXAMPLES_SRC}`); console.log(` To: ${PUBLIC_APPS}`); diff --git a/apps/client/src/components/editable-widget-player.tsx b/apps/client/src/components/editable-widget-player.tsx index c72ff80..690a388 100644 --- a/apps/client/src/components/editable-widget-player.tsx +++ b/apps/client/src/components/editable-widget-player.tsx @@ -1,8 +1,8 @@ -import { useState, useCallback, useEffect } from 'react'; -import type { VirtualProject } from '@aprovan/patchwork-compiler'; -import { WidgetPlayer, type WidgetPlayerProps } from './widget-player'; -import { WidgetEditModal } from './widget-edit-modal'; -import { useWidgetProject } from '../hooks/use-widget-project'; +import { useState, useCallback, useEffect } from "react"; +import { useWidgetProject } from "../hooks/use-widget-project"; +import { WidgetEditModal } from "./widget-edit-modal"; +import { WidgetPlayer, type WidgetPlayerProps } from "./widget-player"; +import type { VirtualProject } from "@aprovan/patchwork-compiler"; export interface EditableWidgetPlayerProps extends WidgetPlayerProps { editable?: boolean; @@ -29,7 +29,7 @@ export function EditableWidgetPlayer({ const isEditing = externalIsEditing ?? internalIsEditing; const setIsEditing = onEditingChange ?? setInternalIsEditing; - const entryFile = project.project?.entry ?? 'client/main.tsx'; + const entryFile = project.project?.entry ?? "client/main.tsx"; const source = project.project?.files.get(entryFile)?.content ?? localSource; useEffect(() => { @@ -47,7 +47,7 @@ export function EditableWidgetPlayer({ onSourceChange?.(finalCode); } }, - [source, onSourceChange, setIsEditing, project, entryFile], + [source, onSourceChange, setIsEditing, project, entryFile] ); const handleSaveProject = useCallback( @@ -59,7 +59,7 @@ export function EditableWidgetPlayer({ })); await project.save(filesToSave); }, - [project], + [project] ); return ( diff --git a/apps/client/src/components/game-catalog.tsx b/apps/client/src/components/game-catalog.tsx index 9e5b800..6bb8c09 100644 --- a/apps/client/src/components/game-catalog.tsx +++ b/apps/client/src/components/game-catalog.tsx @@ -1,6 +1,6 @@ -import React from 'react'; -import type { GameCategory, GameEntry } from '../hooks/use-games-catalog'; -import { GITHUB_REPO_URL } from '../constants'; +import React from "react"; +import { GITHUB_REPO_URL } from "../constants"; +import type { GameCategory, GameEntry } from "../hooks/use-games-catalog"; interface GameCatalogProps { categories: GameCategory[]; @@ -20,29 +20,21 @@ function GameCard({ game, onClick }: { game: GameEntry; onClick: () => void }) { alt="" className="w-16 h-16" onError={(e) => { - e.currentTarget.style.display = 'none'; + e.currentTarget.style.display = "none"; }} />
-

- {game.name ?? game.appId} -

+

{game.name ?? game.appId}

{game.description && ( -

- {game.description} -

+

{game.description}

)}
); } -export function GameCatalog({ - categories, - isLoading, - onSelectGame, -}: GameCatalogProps) { +export function GameCatalog({ categories, isLoading, onSelectGame }: GameCatalogProps) { if (isLoading) { return (
@@ -55,52 +47,32 @@ export function GameCatalog({
- Zolvery Logo - - Zolvery - + Zolvery Logo + Zolvery
{categories.map((category) => ( -
+

{category.label}

{category.games.map((game) => ( - onSelectGame(game)} - /> + onSelectGame(game)} /> ))}
))}
{players.map((player) => ( -
+
{player.name} {player.isHost && ( - - Host - + Host )}
))}
{players.length < 2 && ( -
- Waiting for more players... -
+
Waiting for more players...
)}
); @@ -108,11 +89,7 @@ function ErrorCard({
{title}
{message}
- {detail && ( -
- {detail} -
- )} + {detail &&
{detail}
}
); } @@ -130,32 +107,30 @@ export function GameLobby({ // - If join mode with code: start in waiting (client connected to host) // - Otherwise: show mode selection const [mode, setMode] = useState(() => { - if (initialMode === 'host' && initialCode) return 'host'; // Restore host lobby - if (initialCode) return 'waiting'; // Join with code - if (initialMode === 'join') return 'join'; - if (initialMode === 'host') return 'host'; - return 'choose'; + if (initialMode === "host" && initialCode) return "host"; // Restore host lobby + if (initialCode) return "waiting"; // Join with code + if (initialMode === "join") return "join"; + if (initialMode === "host") return "host"; + return "choose"; }); const [matchID, setMatchID] = useState(() => { // If host mode with initial code, use that code; otherwise generate new - if (initialMode === 'host' && initialCode) return initialCode.toUpperCase(); - if (initialMode === 'host') return generateMatchID(); - return ''; + if (initialMode === "host" && initialCode) return initialCode.toUpperCase(); + if (initialMode === "host") return generateMatchID(); + return ""; }); - const [inputCode, setInputCode] = useState(() => - (initialCode || '').toUpperCase(), - ); + const [inputCode, setInputCode] = useState(() => (initialCode || "").toUpperCase()); const [phraseInputs, setPhraseInputs] = useState( - () => codeToWords(initialCode || '') || ['', '', ''], + () => codeToWords(initialCode || "") || ["", "", ""] ); - const [copied, setCopied] = useState<'code' | 'link' | null>(null); + const [copied, setCopied] = useState<"code" | "link" | null>(null); const [credentials] = useState(() => generateCredentials()); const [gameStarted, setGameStarted] = useState(false); const [focusedInput, setFocusedInput] = useState(null); const blurTimeout = useRef(null); - const isHost = mode === 'host'; - const shouldConnect = mode === 'host' || mode === 'waiting'; + const isHost = mode === "host"; + const shouldConnect = mode === "host" || mode === "waiting"; const handleGameStart = useCallback(() => { if (gameStarted) return; @@ -163,26 +138,25 @@ export function GameLobby({ onStart({ matchID: isHost ? matchID : inputCode.toUpperCase(), - playerID: isHost ? '0' : '1', + playerID: isHost ? "0" : "1", credentials, isHost, }); }, [gameStarted, onStart, isHost, matchID, inputCode, credentials]); - const { isConnecting, isConnected, players, error, startGame, debug } = - useP2PLobby({ - gameId, - matchID: isHost ? matchID : inputCode.toUpperCase(), - isHost, - playerName: isHost ? 'Host' : 'Player 2', - enabled: shouldConnect, - onGameStart: handleGameStart, - }); + const { isConnecting, isConnected, players, error, startGame, debug } = useP2PLobby({ + gameId, + matchID: isHost ? matchID : inputCode.toUpperCase(), + isHost, + playerName: isHost ? "Host" : "Player 2", + enabled: shouldConnect, + onGameStart: handleGameStart, + }); // Auto-connect when entering join code const handleJoinSubmit = () => { if (!/^[A-Z]{6}$/.test(inputCode)) return; - setMode('waiting'); + setMode("waiting"); }; // Notify parent when match code is set (for URL persistence) @@ -206,16 +180,16 @@ export function GameLobby({ () => () => { if (blurTimeout.current) clearTimeout(blurTimeout.current); }, - [], + [] ); const copyCode = async () => { try { await navigator.clipboard.writeText(matchID); - setCopied('code'); + setCopied("code"); setTimeout(() => setCopied(null), 2000); } catch (err) { - console.error('Failed to copy:', err); + console.error("Failed to copy:", err); } }; @@ -224,46 +198,44 @@ export function GameLobby({ const inviteLink = `${window.location.origin}/#/apps/${gameId}/join/${matchID}`; try { await navigator.clipboard.writeText(inviteLink); - setCopied('link'); + setCopied("link"); setTimeout(() => setCopied(null), 2000); } catch (err) { - console.error('Failed to copy:', err); + console.error("Failed to copy:", err); } }; const hostGame = () => { const newMatchID = generateMatchID(); setMatchID(newMatchID); - setMode('host'); + setMode("host"); onCodeGenerated?.(newMatchID); }; const handlePhraseChange = (value: string, index: number) => { - const normalized = value.toLowerCase().replace(/[^a-z]/g, ''); + const normalized = value.toLowerCase().replace(/[^a-z]/g, ""); const next = [...phraseInputs]; next[index] = normalized; setPhraseInputs(next); const code = wordsToCode(next); - setInputCode(code || ''); + setInputCode(code || ""); }; const hostPhrase = codeToWords(matchID); const joinPhrase = codeToWords(inputCode); const phraseSuggestions = phraseInputs.map((word) => fuzzyMatch(word)); const phraseStates = phraseInputs.map((word, i) => { - if (!word) return 'empty'; - if (isValidWord(word)) return 'valid'; - return phraseSuggestions[i].length > 0 ? 'partial' : 'invalid'; + if (!word) return "empty"; + if (isValidWord(word)) return "valid"; + return phraseSuggestions[i].length > 0 ? "partial" : "invalid"; }); - if (mode === 'choose') { + if (mode === "choose") { return (
-

- Multiplayer -

+

Multiplayer

{gameId}

@@ -275,7 +247,7 @@ export function GameLobby({ Host Game
{joinPhrase && ( -
- {joinPhrase.join(' ')} -
+
{joinPhrase.join(" ")}
)}
@@ -563,15 +517,12 @@ export function GameLobby({
Debug
+
Host ID: {debug.hostId}
- Host ID: {debug.hostId} -
-
- Peer ID: {debug.peerId ?? '—'} + Peer ID: {debug.peerId ?? "—"}
- ICE: {debug.lastIceState ?? 'n/a'} | Relay:{' '} - {debug.usingRelayOnly ? 'on' : 'off'} + ICE: {debug.lastIceState ?? "n/a"} | Relay: {debug.usingRelayOnly ? "on" : "off"}
{debug.log.length === 0 ? ( @@ -583,8 +534,8 @@ export function GameLobby({
-

- {game.name ?? game.appId} -

+

{game.name ?? game.appId}

{game.description && ( -

- {game.description} -

+

{game.description}

)}
@@ -152,17 +145,13 @@ export function GameSetup({ game, onStart, onBack }: GameSetupProps) { }} className="w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm text-slate-900 focus:border-slate-400 focus:outline-none" > - {Array.from( - { length: maxPlayers - minPlayers + 1 }, - (_, i) => minPlayers + i, - ).map((n) => ( - - ))} + {Array.from({ length: maxPlayers - minPlayers + 1 }, (_, i) => minPlayers + i).map( + (n) => ( + + ) + )}
)} @@ -178,20 +167,16 @@ export function GameSetup({ game, onStart, onBack }: GameSetupProps) { className="w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm text-slate-900 focus:border-slate-400 focus:outline-none" > {Array.from({ length: playerCount }, (_, i) => i).map((n) => ( - ))}

- {humanPlayers}{' '} - {humanPlayers === 1 ? 'player' : 'players needed'} + {humanPlayers} {humanPlayers === 1 ? "player" : "players needed"} {!localPlayValid && ` This game supports ${maxLocalPlayers} local ${ - maxLocalPlayers === 1 ? 'player' : 'players' + maxLocalPlayers === 1 ? "player" : "players" }.`}

@@ -203,13 +188,8 @@ export function GameSetup({ game, onStart, onBack }: GameSetupProps) { Settings {filteredSettings.map((setting) => ( -
- +
+ {myPlayerIndex === 0 && (
))} - {G.phase === 'results' && ( + {G.phase === "results" && ( )} - {G.phase === 'dealer' && ( -
- Dealer playing... -
+ {G.phase === "dealer" && ( +
Dealer playing...
)} - {G.phase === 'gameOver' && ( + {G.phase === "gameOver" && (
- - Game Over - -

- You ran out of chips! -

+ Game Over +

You ran out of chips!

@@ -224,19 +211,15 @@ export function app({
); - }), + }) )}
@@ -244,15 +227,11 @@ export function app({ {/* Status */} {over && (
- {G.winner === 'draw' ? ( - - Draw - + {G.winner === "draw" ? ( + Draw ) : ( <> - - Winner - + Winner
= { - 0: 'oklch(72.3% 0.219 149.579)', // green - 1: 'oklch(62.3% 0.214 259.815)', // blue + 0: "oklch(72.3% 0.219 149.579)", // green + 1: "oklch(62.3% 0.214 259.815)", // blue }; -const createPits = (): number[] => - Array(PITS_PER_SIDE * 2).fill(INITIAL_STONES); +const createPits = (): number[] => Array(PITS_PER_SIDE * 2).fill(INITIAL_STONES); const getPitOwner = (index: number): Player => (index < PITS_PER_SIDE ? 0 : 1); @@ -38,7 +37,7 @@ const simulateMove = ( pits: number[], homes: [number, number], player: Player, - pitIndex: number, + pitIndex: number ): { pits: number[]; homes: [number, number]; extraTurn: boolean } => { const newPits = [...pits]; const newHomes: [number, number] = [...homes]; @@ -113,7 +112,7 @@ const checkGameOver = (pits: number[]): boolean => { }; export const game = { - name: 'mancala', + name: "mancala", minPlayers: 2, maxPlayers: 2, setup: (): GameState => ({ @@ -142,7 +141,7 @@ export const game = { if (G.homes[0] > G.homes[1]) G.winner = 0; else if (G.homes[1] > G.homes[0]) G.winner = 1; - else G.winner = 'draw'; + else G.winner = "draw"; } else { G.current = result.extraTurn ? G.current : ((1 - G.current) as Player); } @@ -159,7 +158,7 @@ export const game = { G.winner !== null ? [] : getValidMoves(G.pits, G.current).map((pit) => ({ - move: 'sow', + move: "sow", args: [pit], })), }, @@ -176,7 +175,7 @@ export function app({ }: BoardProps) { // Ensure botPlayerIDs is always an array const safeBotPlayerIDs = Array.isArray(botPlayerIDs) ? botPlayerIDs : []; - console.log('[mancala] Props received:', { + console.log("[mancala] Props received:", { botCount, botPlayerIDs, safeBotPlayerIDs, @@ -193,7 +192,7 @@ export function app({ const renderStones = (count: number, compact: boolean = false) => { if (count === 0) return null; - const size = compact ? 'h-1.5 w-1.5' : 'h-2 w-2'; + const size = compact ? "h-1.5 w-1.5" : "h-2 w-2"; return (
{Array.from({ length: Math.min(count, 24) }, (_, i) => ( @@ -202,22 +201,12 @@ export function app({ className={`${size} rounded-full bg-slate-700 transition-all duration-200`} /> ))} - {count > 24 && ( - +{count - 24} - )} + {count > 24 && +{count - 24}}
); }; - const Pit = ({ - index, - stones, - owner, - }: { - index: number; - stones: number; - owner: Player; - }) => { + const Pit = ({ index, stones, owner }: { index: number; stones: number; owner: Player }) => { const isOwnerBot = safeBotPlayerIDs.includes(String(owner)); const isCurrentPlayersTurn = owner === G.current; // In multiplayer, only allow clicking if it's the local player's turn @@ -236,23 +225,13 @@ export function app({ disabled={!isClickable} className={`relative flex aspect-[3/4] w-full flex-col items-center justify-center rounded-xl border-2 transition-all duration-200 ${ - isClickable - ? 'cursor-pointer hover:scale-105 hover:border-slate-400' - : 'cursor-default' - } - ${ - isActive && stones > 0 - ? 'border-slate-300 bg-white' - : 'border-slate-200 bg-slate-50' + isClickable ? "cursor-pointer hover:scale-105 hover:border-slate-400" : "cursor-default" } + ${isActive && stones > 0 ? "border-slate-300 bg-white" : "border-slate-200 bg-slate-50"} `} > -
- {renderStones(stones)} -
- - {stones} - +
{renderStones(stones)}
+ {stones} ); }; @@ -263,17 +242,13 @@ export function app({ return (
-
+
{renderStones(stones, true)}
@@ -288,17 +263,13 @@ export function app({ {/* Header */}
- - Turn - + Turn
{isBotThinking && ( - - Thinking... - + Thinking... )}
); })} @@ -2611,10 +2588,7 @@ export function app({ G, moves }: BoardProps) { {over && (
{G.won ? ( - + You won! ) : ( @@ -2628,11 +2602,7 @@ export function app({ G, moves }: BoardProps) { {/* Keyboard */}
{KEYBOARD_LAYOUT.map((row, rowIndex) => ( -
+
{row.map((key) => { const status = getKeyboardStatus(key); return ( @@ -2643,7 +2613,7 @@ export function app({ G, moves }: BoardProps) { className="flex-1 rounded-lg py-3 text-xs font-semibold text-white transition-all duration-150 active:scale-95 disabled:opacity-50" style={{ backgroundColor: getKeyColor(status), - maxWidth: '10%', + maxWidth: "10%", }} > {key} @@ -2652,18 +2622,15 @@ export function app({ G, moves }: BoardProps) { })}
))} -
+