├── .prettierignore ├── .env ├── .prettierrc.json ├── public ├── favicon.ico ├── convex.svg └── convex-chess.svg ├── next.config.mjs ├── convex ├── auth.config.js ├── http.ts ├── convex.config.ts ├── index.ts ├── cronFunctions.ts ├── auth.ts ├── crons.ts ├── _generated │ ├── api.js │ ├── dataModel.d.ts │ ├── server.js │ ├── api.d.ts │ └── server.d.ts ├── tsconfig.json ├── schema.ts ├── users.ts ├── engine.ts ├── search.ts ├── testing.ts ├── utils.ts ├── README.md ├── games.ts └── lib │ └── openai.ts ├── jest.config.js ├── app ├── page.tsx ├── ConvexClientProvider.tsx ├── layout.tsx ├── user │ └── [id] │ │ └── page.tsx └── play │ └── [id] │ └── page.tsx ├── README.md ├── .gitignore ├── tsconfig.json ├── components ├── SignInButtons.tsx ├── JoinButton.tsx ├── UserBadge.tsx ├── GameList.tsx ├── SearchBar.tsx └── Topbar.tsx ├── middleware.ts ├── package.json ├── common.tsx ├── Justfile ├── backendHarness.js ├── styles └── globals.css ├── games.test.ts └── LICENSE.txt /.prettierignore: -------------------------------------------------------------------------------- 1 | .next 2 | convex/_generated 3 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | NEXT_PUBLIC_CONVEX_URL="https://wandering-fish-513.convex.cloud" -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": false, 3 | "semi": true 4 | } 5 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/get-convex/convex-chess/main/public/favicon.ico -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = {}; 3 | 4 | export default nextConfig; 5 | -------------------------------------------------------------------------------- /convex/auth.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | providers: [ 3 | { 4 | domain: process.env.CONVEX_SITE_URL, 5 | applicationID: "convex", 6 | }, 7 | ], 8 | }; 9 | -------------------------------------------------------------------------------- /convex/http.ts: -------------------------------------------------------------------------------- 1 | import { httpRouter } from "convex/server"; 2 | import { auth } from "./auth"; 3 | 4 | const http = httpRouter(); 5 | 6 | auth.addHttpRoutes(http); 7 | 8 | export default http; 9 | -------------------------------------------------------------------------------- /convex/convex.config.ts: -------------------------------------------------------------------------------- 1 | import { defineApp } from "convex/server"; 2 | import aggregate from "@convex-dev/aggregate/convex.config.js"; 3 | 4 | const app = defineApp(); 5 | app.use(aggregate); 6 | export default app; 7 | -------------------------------------------------------------------------------- /convex/index.ts: -------------------------------------------------------------------------------- 1 | import { components } from "./_generated/api"; 2 | import { DirectAggregate } from "@convex-dev/aggregate"; 3 | 4 | export const aggregate = new DirectAggregate( 5 | components.aggregate 6 | ); 7 | -------------------------------------------------------------------------------- /convex/cronFunctions.ts: -------------------------------------------------------------------------------- 1 | import { internalMutation } from "./_generated/server"; 2 | 3 | export const logMessage = internalMutation({ 4 | handler: async () => { 5 | console.log("Cron job running every 15 seconds"); 6 | }, 7 | }); -------------------------------------------------------------------------------- /convex/auth.ts: -------------------------------------------------------------------------------- 1 | import { convexAuth } from "@convex-dev/auth/server"; 2 | import Google from "@auth/core/providers/google"; 3 | 4 | export const { auth, signIn, signOut, store, isAuthenticated } = convexAuth({ 5 | providers: [Google], 6 | }); 7 | -------------------------------------------------------------------------------- /convex/crons.ts: -------------------------------------------------------------------------------- 1 | import { cronJobs } from "convex/server"; 2 | import { internal } from "./_generated/api"; 3 | 4 | const crons = cronJobs(); 5 | 6 | crons.interval( 7 | "logEvery15Seconds", 8 | { seconds: 15 }, 9 | internal.cronFunctions.logMessage 10 | ); 11 | 12 | export default crons; -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ 2 | module.exports = { 3 | preset: "ts-jest/presets/js-with-ts", 4 | testEnvironment: "node", 5 | transformIgnorePatterns: ['/node_modules/(?!(convex-helpers)/)'], 6 | 7 | // Only run one suite at a time because all of our tests are running against 8 | // the same backend and we don't want to leak state. 9 | maxWorkers: 1, 10 | }; -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | import { preloadQuery } from "convex/nextjs"; 2 | import { GameList } from "../components/GameList"; 3 | import { Topbar } from "../components/Topbar"; 4 | import { api } from "../convex/_generated/api"; 5 | 6 | export default async function Home() { 7 | const preloadedGames = await preloadQuery(api.games.ongoingGames); 8 | return ( 9 | <> 10 | 11 | 12 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Convex 2 | 3 | This example demonstrates how to build a fully functional chat app using React, Next.js and Convex. 4 | 5 | The app was build within few hours. The computer just does random valid moves for now. Will improve later. 6 | 7 | ## Run locally 8 | 9 | ```bash 10 | npm install 11 | rm -rf convex.json 12 | npx convex init 13 | npm run dev 14 | ``` 15 | 16 | ## Deploy your own 17 | 18 | Deploy the example using [Vercel](https://docs.convex.dev/using/hosting/vercel): 19 | -------------------------------------------------------------------------------- /app/ConvexClientProvider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { ConvexAuthNextjsProvider } from "@convex-dev/auth/nextjs"; 4 | import { ConvexReactClient } from "convex/react"; 5 | import { ReactNode } from "react"; 6 | 7 | const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!); 8 | 9 | export function ConvexClientProvider({ children }: { children: ReactNode }) { 10 | return ( 11 | 12 | {children} 13 | 14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | .pnpm-debug.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /convex/_generated/api.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /** 3 | * Generated `api` utility. 4 | * 5 | * THIS CODE IS AUTOMATICALLY GENERATED. 6 | * 7 | * To regenerate, run `npx convex dev`. 8 | * @module 9 | */ 10 | 11 | import { anyApi, componentsGeneric } from "convex/server"; 12 | 13 | /** 14 | * A utility for referencing Convex functions in your app's API. 15 | * 16 | * Usage: 17 | * ```js 18 | * const myFunctionReference = api.myModule.myFunction; 19 | * ``` 20 | */ 21 | export const api = anyApi; 22 | export const internal = anyApi; 23 | export const components = componentsGeneric(); 24 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "Bundler", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true, 17 | "plugins": [ 18 | { 19 | "name": "next" 20 | } 21 | ] 22 | }, 23 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 24 | "exclude": ["node_modules"] 25 | } 26 | -------------------------------------------------------------------------------- /components/SignInButtons.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { useAuthActions } from "@convex-dev/auth/react"; 3 | import { useConvexAuth, useQuery } from "convex/react"; 4 | 5 | export function SignInButtons() { 6 | const { signIn, signOut } = useAuthActions(); 7 | const convexAuthState = useConvexAuth(); 8 | 9 | const isAuthenticated = convexAuthState.isAuthenticated; 10 | const isUnauthenticated = 11 | !convexAuthState.isLoading && !convexAuthState.isAuthenticated; 12 | 13 | return ( 14 |
15 | {isUnauthenticated ? ( 16 | 17 | ) : isAuthenticated ? ( 18 | 19 | ) : ( 20 | 21 | )} 22 |
23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /convex/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | /* This TypeScript project config describes the environment that 3 | * Convex functions run in and is used to typecheck them. 4 | * You can modify it, but some settings required to use Convex. 5 | */ 6 | "compilerOptions": { 7 | /* These settings are not required by Convex and can be modified. */ 8 | "skipLibCheck": true, 9 | "allowJs": true, 10 | "strict": true, 11 | 12 | /* These compiler options are required by Convex */ 13 | "target": "ESNext", 14 | "lib": ["ES2021", "dom"], 15 | "forceConsistentCasingInFileNames": true, 16 | "allowSyntheticDefaultImports": true, 17 | "module": "ESNext", 18 | "moduleResolution": "Bundler", 19 | "isolatedModules": true, 20 | "noEmit": true 21 | }, 22 | "include": ["./**/*"], 23 | "exclude": ["./_generated"] 24 | } 25 | -------------------------------------------------------------------------------- /components/JoinButton.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { FormEvent } from "react"; 4 | import { useRouter } from "next/navigation"; 5 | 6 | export function JoinButton({ 7 | text, 8 | gameId, 9 | disabled, 10 | }: { 11 | text: string; 12 | gameId: string; 13 | disabled: boolean; 14 | }) { 15 | const router = useRouter(); 16 | 17 | async function join(event: FormEvent) { 18 | event.preventDefault(); 19 | const gameId = (event.nativeEvent as any).submitter.id ?? ""; 20 | router.push(`/play/${gameId}`); 21 | } 22 | 23 | return ( 24 |
25 | 34 |
35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /middleware.ts: -------------------------------------------------------------------------------- 1 | import { 2 | convexAuthNextjsMiddleware, 3 | createRouteMatcher, 4 | nextjsMiddlewareRedirect, 5 | } from "@convex-dev/auth/nextjs/server"; 6 | 7 | const isSignInPage = createRouteMatcher(["/signin"]); 8 | const isProtectedRoute = createRouteMatcher(["/product(.*)"]); 9 | 10 | export default convexAuthNextjsMiddleware( 11 | async (request, { convexAuth }) => { 12 | const isAuthenticated = await convexAuth.isAuthenticated(); 13 | if (isSignInPage(request) && isAuthenticated) { 14 | return nextjsMiddlewareRedirect(request, "/product"); 15 | } 16 | if (isProtectedRoute(request) && !isAuthenticated) { 17 | return nextjsMiddlewareRedirect(request, "/signin"); 18 | } 19 | }, 20 | { verbose: true } 21 | ); 22 | 23 | export const config = { 24 | // The following matcher runs middleware on all routes 25 | // except static assets. 26 | matcher: ["/((?!.*\\..*|_next).*)", "/", "/(api|trpc)(.*)"], 27 | }; 28 | -------------------------------------------------------------------------------- /components/UserBadge.tsx: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { api } from "../convex/_generated/api"; 4 | import Link from "next/link"; 5 | import { fetchQuery } from "convex/nextjs"; 6 | import { convexAuthNextjsToken } from "@convex-dev/auth/nextjs/server"; 7 | import { SignInButtons } from "./SignInButtons"; 8 | 9 | export async function UserBadge() { 10 | const token = await convexAuthNextjsToken(); 11 | const user = await fetchQuery(api.users.getMyUser, {}, { token }); 12 | 13 | return ( 14 |
15 | {user === null ? ( 16 | 17 | ) : ( 18 |
25 | 26 | {user.name} 27 | 28 | 29 |
30 | )} 31 |
32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "dev": "npm-run-all --parallel dev:backend dev:frontend", 5 | "predev": "convex dev --until-success", 6 | "build": "tsc && next build", 7 | "dev:backend": "convex dev", 8 | "dev:frontend": "next dev", 9 | "testFunctionsExistingBackend": "just convex deploy && just convex env set IS_TEST true && jest", 10 | "testFunctions": "node backendHarness.js 'npm run testFunctionsExistingBackend'" 11 | }, 12 | "dependencies": { 13 | "@auth/core": "^0.37.0", 14 | "@convex-dev/aggregate": "^0.1.12", 15 | "@convex-dev/auth": "^0.0.81-alpha.0", 16 | "chess.js": "^1.0.0-beta.3", 17 | "convex": "^1.19.4", 18 | "convex-helpers": "^0.1.25", 19 | "js-chess-engine": "^1.0.2", 20 | "next": "^14.2.13", 21 | "next-themes": "^0.3.0", 22 | "react": "18.2", 23 | "react-chessboard": "^4.6.0", 24 | "react-dom": "18.2", 25 | "react-router-dom": "^6.9.0" 26 | }, 27 | "devDependencies": { 28 | "@types/jest": "^29.5.12", 29 | "@types/node": "~18.15.3", 30 | "@types/react": "18.2", 31 | "@types/react-dom": "18.2", 32 | "jest": "^29.7.0", 33 | "npm-run-all": "^4.1.5", 34 | "prettier": "^2.8.4", 35 | "ts-jest": "^29.1.2", 36 | "typescript": "^5.0.2" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /common.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import { Game } from "./convex/search"; 3 | import { Id } from "./convex/_generated/dataModel"; 4 | 5 | function getProfileLink( 6 | className: string, 7 | name: string, 8 | id: string | Id<"users"> | null 9 | ) { 10 | if (!id) { 11 | return <>; 12 | } 13 | if (typeof id == "string") { 14 | return {name}; 15 | } else { 16 | return ( 17 | 18 | {name} 19 | 20 | ); 21 | } 22 | } 23 | 24 | export function gameTitle(state: Game) { 25 | const player1Span = getProfileLink( 26 | "whitePlayer", 27 | state.player1Name, 28 | state.player1 29 | ); 30 | const player2Span = getProfileLink( 31 | "blackPlayer", 32 | state.player2Name, 33 | state.player2 34 | ); 35 | const context = state.resultContext; 36 | if (state.player1Name && state.player2Name) { 37 | return ( 38 |
39 |
40 | {player1Span} vs {player2Span} 41 |
42 | {context &&
{context}
} 43 |
44 | ); 45 | } else if (state.player1Name) { 46 | return player1Span; 47 | } else if (state.player2Name) { 48 | return player2Span; 49 | } else { 50 | return
; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /convex/schema.ts: -------------------------------------------------------------------------------- 1 | import { authTables } from "@convex-dev/auth/server"; 2 | import { defineSchema, defineTable } from "convex/server"; 3 | import { v } from "convex/values"; 4 | 5 | export default defineSchema({ 6 | ...authTables, 7 | games: defineTable({ 8 | pgn: v.string(), 9 | player1: v.union(v.id("users"), v.literal("Computer"), v.null()), 10 | player2: v.union(v.id("users"), v.literal("Computer"), v.null()), 11 | finished: v.boolean(), 12 | }) 13 | .index("finished", ["finished"]) 14 | .searchIndex("search_pgn", { searchField: "pgn" }), 15 | analysis: defineTable({ 16 | game: v.id("games"), 17 | moveIndex: v.number(), 18 | analysis: v.string(), 19 | }) 20 | .index("by_game_index", ["game", "moveIndex"]) 21 | .searchIndex("search_analysis", { searchField: "analysis" }), 22 | users: defineTable({ 23 | name: v.optional(v.string()), 24 | email: v.optional(v.string()), 25 | image: v.optional(v.string()), 26 | emailVerificationTime: v.optional(v.number()), 27 | phone: v.optional(v.string()), 28 | phoneVerificationTime: v.optional(v.number()), 29 | isAnonymous: v.optional(v.boolean()), 30 | // Additional fields 31 | profilePic: v.optional(v.union(v.string(), v.null())), 32 | // For old Auth0 accounts 33 | tokenIdentifier: v.optional(v.string()), 34 | }) 35 | .index("email", ["email"]) 36 | .searchIndex("search_name", { searchField: "name" }), 37 | }); 38 | -------------------------------------------------------------------------------- /convex/users.ts: -------------------------------------------------------------------------------- 1 | import { v } from "convex/values"; 2 | import { mutation, query } from "./_generated/server"; 3 | import { getAuthUserId } from "@convex-dev/auth/server"; 4 | 5 | export const getMyUser = query(async ({ db, auth }) => { 6 | const userId = await getAuthUserId({ auth }); 7 | if (!userId) { 8 | return null; 9 | } 10 | 11 | const user = await db.get(userId); 12 | 13 | return user; 14 | }); 15 | 16 | export const get = query({ 17 | args: { 18 | id: v.id("users"), 19 | }, 20 | handler: async ({ db, storage }, { id }) => { 21 | const user = await db.get(id); 22 | let profilePicUrl = null; 23 | if (user?.profilePic) { 24 | profilePicUrl = await storage.getUrl(user.profilePic); 25 | } 26 | return { 27 | ...user, 28 | profilePicUrl, 29 | }; 30 | }, 31 | }); 32 | 33 | // Generate a short-lived upload URL. 34 | export const generateUploadUrl = mutation(async ({ storage }) => { 35 | return await storage.generateUploadUrl(); 36 | }); 37 | 38 | // Save the storage ID within a message. 39 | export const setProfilePic = mutation( 40 | async ({ db, auth }, { storageId }: { storageId: string }) => { 41 | const identity = await auth.getUserIdentity(); 42 | const userId = await getAuthUserId({ auth }); 43 | if (!userId) { 44 | return null; 45 | } 46 | 47 | const user = await db.get(userId); 48 | 49 | if (user === null) { 50 | throw new Error("Updating profile pic for missing user"); 51 | } 52 | 53 | db.patch(user._id, { profilePic: storageId }); 54 | } 55 | ); 56 | -------------------------------------------------------------------------------- /Justfile: -------------------------------------------------------------------------------- 1 | set fallback := true 2 | set shell := ["bash", "-uc"] 3 | set windows-shell := ["sh", "-uc"] 4 | 5 | # `just --list` (or just `just`) will print all the recipes in 6 | # the current Justfile. `just RECIPE` will run the macro/job. 7 | # 8 | # In several places there are recipes for running common scripts or commands. 9 | # Instead of `Makefile`s, Convex uses Justfiles, which are similar, but avoid 10 | # several footguns associated with Makefiles, since using make as a macro runner 11 | # can sometimes conflict with Makefiles desire to have some rudimentary 12 | # understanding of build artifacts and associated dependencies. 13 | # 14 | # Read up on just here: https://github.com/casey/just 15 | 16 | _default: 17 | @just --list 18 | 19 | set positional-arguments 20 | 21 | reset-local-backend: 22 | cd $CONVEX_LOCAL_BACKEND_PATH; rm -rf convex_local_storage && rm -f convex_local_backend.sqlite3 23 | 24 | # (*) Run the open source convex backend on port 3210 25 | run-local-backend *ARGS: 26 | cd $CONVEX_LOCAL_BACKEND_PATH && just run-local-backend --port 3210 27 | 28 | # Taken from https://github.com/get-convex/convex-backend/blob/main/Justfile 29 | # (*) Run convex CLI commands like `convex dev` against local backend from `just run-local-backend`. 30 | # This uses the default admin key for local backends, which is safe as long as the backend is 31 | # running locally. 32 | convex *ARGS: 33 | npx convex "$@" --admin-key 0135d8598650f8f5cb0f30c34ec2e2bb62793bc28717c8eb6fb577996d50be5f4281b59181095065c5d0f86a2c31ddbe9b597ec62b47ded69782cd --url "http://127.0.0.1:3210" 34 | 35 | -------------------------------------------------------------------------------- /components/GameList.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Preloaded, usePreloadedQuery, useQuery } from "convex/react"; 4 | import { api } from "../convex/_generated/api"; 5 | import { gameTitle } from "../common"; 6 | import { hasPlayer, isOpen } from "../convex/utils"; 7 | import { JoinButton } from "./JoinButton"; 8 | 9 | export function GameList({ 10 | preloadedGames, 11 | }: { 12 | preloadedGames: Preloaded; 13 | }) { 14 | const ongoingGames = usePreloadedQuery(preloadedGames) || []; 15 | const user = useQuery(api.users.getMyUser) ?? null; 16 | 17 | return ( 18 |
27 | Ongoing Games 28 | 29 | 30 | {ongoingGames.map((game, i) => ( 31 | 32 | 33 | 46 | 47 | ))} 48 | 49 |
{gameTitle(game)} 34 | 45 |
50 |
51 | ); 52 | } 53 | -------------------------------------------------------------------------------- /convex/engine.ts: -------------------------------------------------------------------------------- 1 | "use node"; 2 | 3 | import { api, internal } from "./_generated/api"; 4 | import { Id } from "./_generated/dataModel"; 5 | import { internalAction } from "./_generated/server"; 6 | const jsChessEngine = require("js-chess-engine"); 7 | import { Chess } from "chess.js"; 8 | 9 | export const maybeMakeComputerMove = internalAction( 10 | async (ctx, { id }: { id: Id<"games"> }) => { 11 | const { runQuery, runMutation } = ctx; 12 | const state = await runQuery(api.games.internalGetPgnForComputerMove, { 13 | id, 14 | }); 15 | if (state === null) { 16 | return; 17 | } 18 | const [pgn, strategy] = state; 19 | const gameState = new Chess(); 20 | gameState.loadPgn(pgn); 21 | const moveNumber = gameState.history().length; 22 | const game = new jsChessEngine.Game(gameState.fen()); 23 | let level = 1; 24 | if (strategy === "hard") { 25 | level = 2; 26 | } else if (strategy === "tricky") { 27 | if (moveNumber > 6) { 28 | level = 2; 29 | if (moveNumber % 3 === 0) { 30 | level = 3; 31 | } 32 | } 33 | } 34 | const aiMove = game.aiMove(level); 35 | // aiMove has format {moveFrom: moveTo} 36 | let moveFrom = Object.keys(aiMove)[0]; 37 | let moveTo = aiMove[moveFrom]; 38 | console.log(`move at level ${level}: ${moveFrom}->${moveTo}`); 39 | await runMutation(internal.games.internalMakeComputerMove, { 40 | id, 41 | moveFrom: moveFrom.toLowerCase(), 42 | moveTo: moveTo.toLowerCase(), 43 | finalPiece: "q", // js-chess-engine only knows how to promote queen 44 | }); 45 | } 46 | ); 47 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { Inter } from "next/font/google"; 3 | import "../styles/globals.css"; 4 | import { ConvexAuthNextjsServerProvider } from "@convex-dev/auth/nextjs/server"; 5 | import { ConvexClientProvider } from "./ConvexClientProvider"; 6 | 7 | import { SearchBar } from "../components/SearchBar"; 8 | import { UserBadge } from "../components/UserBadge"; 9 | 10 | const inter = Inter({ subsets: ["latin"] }); 11 | 12 | export const metadata: Metadata = { 13 | title: "Convex Chess", 14 | description: "Chess game powered by Convex", 15 | icons: { 16 | icon: "/convex-chess.svg", 17 | }, 18 | }; 19 | 20 | export default function RootLayout({ 21 | children, 22 | }: Readonly<{ 23 | children: React.ReactNode; 24 | }>) { 25 | return ( 26 | 27 | {/* `suppressHydrationWarning` only affects the html tag, 28 | and is needed by `ThemeProvider` which sets the theme 29 | class attribute on it */} 30 | 31 | 32 |
40 | 41 | 42 |

Convex Chess

43 | 44 | {children} 45 |
46 |
47 | 48 | 49 |
50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /convex/search.ts: -------------------------------------------------------------------------------- 1 | import { denormalizePlayerNames } from "./games"; 2 | import { Doc } from "./_generated/dataModel"; 3 | import { query } from "./_generated/server"; 4 | 5 | export interface Game extends Doc<"games"> { 6 | player1Name: string; 7 | player2Name: string; 8 | moveIndex?: number; 9 | resultContext?: string; 10 | } 11 | 12 | type SearchResult = { 13 | users: Doc<"users">[]; 14 | games: Game[]; 15 | }; 16 | 17 | export default query(async ({ db }, { query }: { query: string }) => { 18 | const users = await db 19 | .query("users") 20 | .withSearchIndex("search_name", (q) => q.search("name", query)) 21 | .collect(); 22 | const games = await db 23 | .query("games") 24 | .withSearchIndex("search_pgn", (q) => q.search("pgn", query)) 25 | .take(5); 26 | const analyses = await db 27 | .query("analysis") 28 | .withSearchIndex("search_analysis", (q) => q.search("analysis", query)) 29 | .take(5); 30 | let denormalizedGames = []; 31 | for (const game of games) { 32 | const denormalizedGame = { 33 | ...(await denormalizePlayerNames(db, game)), 34 | // Would love snippeting here 35 | resultContext: game.pgn, 36 | }; 37 | denormalizedGames.push(denormalizedGame); 38 | } 39 | for (const analysis of analyses) { 40 | const game = await db.get(analysis.game); 41 | const denormalizedGame = { 42 | ...(await denormalizePlayerNames(db, game!)), 43 | moveIndex: analysis.moveIndex, 44 | // Would love snippeting here 45 | resultContext: analysis.analysis, 46 | }; 47 | denormalizedGames.push(denormalizedGame); 48 | } 49 | return { users, games: denormalizedGames as Game[] }; 50 | }); 51 | -------------------------------------------------------------------------------- /convex/_generated/dataModel.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /** 3 | * Generated data model types. 4 | * 5 | * THIS CODE IS AUTOMATICALLY GENERATED. 6 | * 7 | * To regenerate, run `npx convex dev`. 8 | * @module 9 | */ 10 | 11 | import type { 12 | DataModelFromSchemaDefinition, 13 | DocumentByName, 14 | TableNamesInDataModel, 15 | SystemTableNames, 16 | } from "convex/server"; 17 | import type { GenericId } from "convex/values"; 18 | import schema from "../schema.js"; 19 | 20 | /** 21 | * The names of all of your Convex tables. 22 | */ 23 | export type TableNames = TableNamesInDataModel; 24 | 25 | /** 26 | * The type of a document stored in Convex. 27 | * 28 | * @typeParam TableName - A string literal type of the table name (like "users"). 29 | */ 30 | export type Doc = DocumentByName< 31 | DataModel, 32 | TableName 33 | >; 34 | 35 | /** 36 | * An identifier for a document in Convex. 37 | * 38 | * Convex documents are uniquely identified by their `Id`, which is accessible 39 | * on the `_id` field. To learn more, see [Document IDs](https://docs.convex.dev/using/document-ids). 40 | * 41 | * Documents can be loaded using `db.get(id)` in query and mutation functions. 42 | * 43 | * IDs are just strings at runtime, but this type can be used to distinguish them from other 44 | * strings when type checking. 45 | * 46 | * @typeParam TableName - A string literal type of the table name (like "users"). 47 | */ 48 | export type Id = 49 | GenericId; 50 | 51 | /** 52 | * A type describing your Convex data model. 53 | * 54 | * This type includes information about what tables you have, the type of 55 | * documents stored in those tables, and the indexes defined on them. 56 | * 57 | * This type is used to parameterize methods like `queryGeneric` and 58 | * `mutationGeneric` to make them type-safe. 59 | */ 60 | export type DataModel = DataModelFromSchemaDefinition; 61 | -------------------------------------------------------------------------------- /components/SearchBar.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { api } from "../convex/_generated/api"; 4 | import "../styles/globals.css"; 5 | 6 | import { useQuery } from "convex/react"; 7 | 8 | import { useState } from "react"; 9 | import Link from "next/link"; 10 | import { gameTitle } from "../common"; 11 | 12 | export function SearchBar() { 13 | const [searchInput, setSearchInput] = useState(""); 14 | 15 | const handleChange = (e: any) => { 16 | e.preventDefault(); 17 | setSearchInput(e.target.value); 18 | }; 19 | 20 | const searchResults = useQuery( 21 | api.search.default, 22 | searchInput === "" 23 | ? "skip" 24 | : { 25 | query: searchInput, 26 | } 27 | ) || { users: [], games: [] }; 28 | 29 | const count = useQuery(api.games.getMoveCount, searchInput === "" ? "skip" : { move: searchInput }); 30 | 31 | return ( 32 |
33 |
34 | 35 | 36 | 37 |
38 | 44 |
45 | 46 | 47 | {searchResults.users.map((result) => ( 48 | 49 | 56 | 57 | ))} 58 | {searchResults.games.map((result) => ( 59 | 60 | 69 | 70 | ))} 71 | {count !== undefined && } 72 | 73 |
50 | { 51 | 52 | {(result as any).name} 53 | 54 | } 55 |
61 | 66 | {gameTitle(result)} 67 | 68 |
Count: {count}
74 |
75 |
76 | ); 77 | } 78 | -------------------------------------------------------------------------------- /components/Topbar.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useMutation, useQuery } from "convex/react"; 4 | import { useRouter } from "next/navigation"; 5 | import { api } from "../convex/_generated/api"; 6 | import { FormEvent } from "react"; 7 | 8 | export function Topbar() { 9 | const router = useRouter(); 10 | const user = useQuery(api.users.getMyUser) ?? null; 11 | 12 | const startNewGame = useMutation(api.games.newGame); 13 | 14 | async function newGame(event: FormEvent) { 15 | const value = (event.nativeEvent as any).submitter.defaultValue ?? ""; 16 | const white = Boolean(Math.round(Math.random())); 17 | let player1: "Me" | "Computer" | null = null; 18 | let player2: "Me" | "Computer" | null = null; 19 | switch (value) { 20 | case "Play vs another Player": 21 | if (white) { 22 | player1 = "Me"; 23 | } else { 24 | player2 = "Me"; 25 | } 26 | break; 27 | case "Play vs Computer": 28 | if (white) { 29 | player1 = "Me"; 30 | player2 = "Computer"; 31 | } else { 32 | player1 = "Computer"; 33 | player2 = "Me"; 34 | } 35 | break; 36 | case "Computer vs Computer": 37 | player1 = "Computer"; 38 | player2 = "Computer"; 39 | break; 40 | } 41 | event.preventDefault(); 42 | const id = await startNewGame({ player1, player2 }); 43 | router.push(`/play/${id}`); 44 | } 45 | 46 | return ( 47 |
51 | 59 | 67 | 72 |
73 | ); 74 | } 75 | -------------------------------------------------------------------------------- /convex/testing.ts: -------------------------------------------------------------------------------- 1 | import { 2 | customAction, 3 | customMutation, 4 | customQuery, 5 | } from "convex-helpers/server/customFunctions"; 6 | import { action, mutation, query } from "./_generated/server"; 7 | import schema from "./schema"; 8 | import { WithoutSystemFields } from "convex/server"; 9 | import { Doc } from "./_generated/dataModel"; 10 | 11 | // Wrappers to use for function that should only be called from tests 12 | export const testingQuery = customQuery(query, { 13 | args: {}, 14 | input: async (_ctx, _args) => { 15 | if (process.env.IS_TEST === undefined) { 16 | throw new Error( 17 | "Calling a test only function in an unexpected environment" 18 | ); 19 | } 20 | return { ctx: {}, args: {} }; 21 | }, 22 | }); 23 | 24 | export const testingMutation = customMutation(mutation, { 25 | args: {}, 26 | input: async (_ctx, _args) => { 27 | if (process.env.IS_TEST === undefined) { 28 | throw new Error( 29 | "Calling a test only function in an unexpected environment" 30 | ); 31 | } 32 | return { ctx: {}, args: {} }; 33 | }, 34 | }); 35 | 36 | export const testingAction = customAction(action, { 37 | args: {}, 38 | input: async (_ctx, _args) => { 39 | if (process.env.IS_TEST === undefined) { 40 | throw new Error( 41 | "Calling a test only function in an unexpected environment" 42 | ); 43 | } 44 | return { ctx: {}, args: {} }; 45 | }, 46 | }); 47 | 48 | export const clearAll = testingMutation(async ({ db, scheduler, storage }) => { 49 | for (const table of Object.keys(schema.tables)) { 50 | const docs = await db.query(table as any).collect(); 51 | await Promise.all(docs.map((doc) => db.delete(doc._id))); 52 | } 53 | const scheduled = await db.system.query("_scheduled_functions").collect(); 54 | await Promise.all(scheduled.map((s) => scheduler.cancel(s._id))); 55 | const storedFiles = await db.system.query("_storage").collect(); 56 | await Promise.all(storedFiles.map((s) => storage.delete(s._id))); 57 | }); 58 | 59 | export const setupGame = testingMutation( 60 | async ({ db }, args: WithoutSystemFields>) => { 61 | return db.insert("games", args); 62 | } 63 | ); 64 | 65 | export const setupUser = testingMutation(async ({ db, auth }) => { 66 | throw new Error("TODO SARAH FIX"); 67 | // return getOrCreateUser(db, auth); 68 | }); 69 | -------------------------------------------------------------------------------- /convex/utils.ts: -------------------------------------------------------------------------------- 1 | import { Chess } from "chess.js"; 2 | import { Doc, Id } from "../convex/_generated/dataModel"; 3 | import { DatabaseReader } from "./_generated/server"; 4 | 5 | export type PlayerId = Id<"users"> | "Computer" | null; 6 | 7 | export async function playerName( 8 | db: DatabaseReader, 9 | player: PlayerId 10 | ): Promise { 11 | if (player === "Computer") { 12 | return "Computer"; 13 | } 14 | if (player === null) { 15 | return "nobody"; 16 | } 17 | const p = await db.get(player); 18 | if (!p) { 19 | return "invalid-player-id"; 20 | } 21 | return p.name ?? "Unknown"; 22 | } 23 | 24 | export function playerEquals(player1: PlayerId, player2: PlayerId) { 25 | if (!player1) { 26 | // null is not equal to null. 27 | return false; 28 | } 29 | return typeof player1 == "string" ? player1 == player2 : player1 === player2; 30 | } 31 | 32 | export function isOpen(state: Doc<"games">): boolean { 33 | return !state.player1 || !state.player2; 34 | } 35 | 36 | export function hasPlayer(state: Doc<"games">, player: PlayerId): boolean { 37 | if (!player) { 38 | return false; 39 | } 40 | return ( 41 | playerEquals(state.player1 as any, player) || 42 | playerEquals(state.player2 as any, player) 43 | ); 44 | } 45 | 46 | export function getCurrentPlayer(state: Doc<"games">): PlayerId { 47 | const game = new Chess(); 48 | game.loadPgn(state.pgn); 49 | let result = game.turn() == "w" ? state.player1 : state.player2; 50 | return result as any; 51 | } 52 | 53 | export function getNextPlayer(state: Doc<"games">): PlayerId { 54 | const game = new Chess(); 55 | game.loadPgn(state.pgn); 56 | let result = game.turn() == "w" ? state.player2 : state.player1; 57 | return result as any; 58 | } 59 | 60 | export function validateMove( 61 | state: Doc<"games">, 62 | player: PlayerId, 63 | from: string, 64 | to: string, 65 | finalPiece: string 66 | ): Chess | null { 67 | if (!playerEquals(getCurrentPlayer(state), player)) { 68 | // Wrong player. 69 | return null; 70 | } 71 | const game = new Chess(); 72 | game.loadPgn(state.pgn); 73 | let valid = null; 74 | try { 75 | valid = game.move({ from, to }); 76 | } catch { 77 | // This is lame but try promoting. 78 | try { 79 | valid = game.move({ from, to, promotion: finalPiece }); 80 | console.log(`promoted a pawn to ${finalPiece}`); 81 | } catch { 82 | console.log(`invalid move ${from}->${to}`); 83 | valid = null; 84 | } 85 | } 86 | 87 | return valid ? game : null; 88 | } 89 | -------------------------------------------------------------------------------- /app/user/[id]/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { useQuery, useMutation } from "convex/react"; 3 | import { useRef, useState } from "react"; 4 | import { Id } from "../../../convex/_generated/dataModel"; 5 | import { api } from "../../../convex/_generated/api"; 6 | 7 | export default function Profile({ params }: { params: { id: string } }) { 8 | const userId = params.id as Id<"users">; 9 | const user = useQuery(api.users.get, { id: userId }) ?? null; 10 | const myUser = useQuery(api.users.getMyUser) ?? null; 11 | 12 | const imageInput = useRef(null); 13 | const [selectedImage, setSelectedImage] = useState(null); 14 | 15 | const generateUploadUrl = useMutation(api.users.generateUploadUrl); 16 | const setProfilePic = useMutation(api.users.setProfilePic); 17 | 18 | async function handleSetProfilePic(event: any) { 19 | event.preventDefault(); 20 | setSelectedImage(null); 21 | (imageInput.current as any).value = ""; 22 | 23 | // Step 1: Get a short-lived upload URL 24 | const postUrl = await generateUploadUrl(); 25 | // Step 2: POST the file to the URL 26 | const result = await fetch(postUrl, { 27 | method: "POST", 28 | headers: { "Content-Type": (selectedImage as any).type }, 29 | body: selectedImage, 30 | }); 31 | const { storageId } = await result.json(); 32 | // Step 3: Save the newly allocated storage id to the messages table 33 | await setProfilePic(storageId); 34 | } 35 | 36 | console.log(); 37 | 38 | return ( 39 |
40 | {user?.profilePicUrl ? ( 41 | 42 | ) : ( 43 |
44 | {user?.name 45 | ?.split(" ") 46 | .map((w) => w.slice(0, 1).toUpperCase()) 47 | .join("")} 48 |
49 | )} 50 |
{user?.name}
51 | {user && user._id === myUser?._id ? ( 52 |
53 | 58 | setSelectedImage((event.target.files as any)[0]) 59 | } 60 | className="ms-2 btn btn-primary" 61 | disabled={selectedImage !== null} 62 | /> 63 | 68 |
69 | ) : ( 70 |
71 | )} 72 |
73 | ); 74 | } 75 | -------------------------------------------------------------------------------- /convex/README.md: -------------------------------------------------------------------------------- 1 | # Welcome to your Convex functions directory! 2 | 3 | Write your Convex functions here. See 4 | https://docs.convex.dev/using/writing-convex-functions for more. 5 | 6 | A query function that takes two arguments looks like: 7 | 8 | ```javascript 9 | // myQueryFunction.js 10 | import { query } from "./_generated/server"; 11 | 12 | export default query(async ({ db }, first, second) => { 13 | // Validate arguments here. 14 | if (typeof first !== "number" || first < 0) { 15 | throw new Error("First argument is not a non-negative number."); 16 | } 17 | if (typeof second !== "string" || second.length > 1000) { 18 | throw new Error("Second argument is not a string of length 1000 or less."); 19 | } 20 | 21 | // Query the database as many times as you need here. 22 | // See https://docs.convex.dev/using/database-queries to learn how to write queries. 23 | const documents = await db.query("tablename").collect(); 24 | 25 | // Write arbitrary JavaScript here: filter, aggregate, build derived data, 26 | // remove non-public properties, or create new objects. 27 | return documents; 28 | }); 29 | ``` 30 | 31 | Using this query function in a React component looks like: 32 | 33 | ```javascript 34 | const data = useQuery("myQueryFunction", 10, "hello"); 35 | ``` 36 | 37 | A mutation function looks like: 38 | 39 | ```javascript 40 | // myMutationFunction.js 41 | import { mutation } from "./_generated/server"; 42 | 43 | export default mutation(async ({ db }, first, second) => { 44 | // Validate arguments here. 45 | if (typeof first !== "string" || typeof second !== "string") { 46 | throw new Error("Both arguments must be strings"); 47 | } 48 | 49 | // Insert or modify documents in the database here. 50 | // Mutations can also read from the database like queries. 51 | const message = { body: first, author: second }; 52 | const id = await db.insert("messages", message); 53 | 54 | // Optionally, return a value from your mutation. 55 | return await db.get(id); 56 | }); 57 | ``` 58 | 59 | Using this mutation function in a React component looks like: 60 | 61 | ```javascript 62 | const mutation = useMutation("myMutationFunction"); 63 | function handleButtonPress() { 64 | // fire and forget, the most common way to use mutations 65 | mutation("Hello!", "me"); 66 | // OR 67 | // use the result once the mutation has completed 68 | mutation("Hello!", "me").then((result) => console.log(result)); 69 | } 70 | ``` 71 | 72 | The Convex CLI is your friend. See everything it can do by running 73 | `npx convex -h` in your project root directory. To learn more, launch the docs 74 | with `npx convex docs`. 75 | -------------------------------------------------------------------------------- /backendHarness.js: -------------------------------------------------------------------------------- 1 | // Taken from https://github.com/get-convex/convex-helpers/blob/main/backendHarness.js 2 | 3 | const http = require("http"); 4 | const { spawn, exec, execSync } = require("child_process"); 5 | 6 | // Run a command against a fresh local backend, handling setting up and tearing down the backend. 7 | 8 | // Checks for a local backend running on port 3210. 9 | const parsedUrl = new URL("http://127.0.0.1:3210"); 10 | 11 | async function isBackendRunning(backendUrl) { 12 | return new Promise ((resolve) => { 13 | http 14 | .request( 15 | { 16 | hostname: backendUrl.hostname, 17 | port: backendUrl.port, 18 | path: "/version", 19 | method: "GET", 20 | }, 21 | (res) => { 22 | resolve(res.statusCode === 200) 23 | } 24 | ) 25 | .on("error", () => { resolve(false) }) 26 | .end(); 27 | }) 28 | } 29 | 30 | function sleep(ms) { 31 | return new Promise((resolve) => setTimeout(resolve, ms)); 32 | } 33 | 34 | const waitForLocalBackendRunning = async (backendUrl) => { 35 | let isRunning = await isBackendRunning(backendUrl); 36 | let i = 0 37 | while (!isRunning) { 38 | if (i % 10 === 0) { 39 | // Progress messages every ~5 seconds 40 | console.log("Waiting for backend to be running...") 41 | } 42 | await sleep(500); 43 | isRunning = await isBackendRunning(backendUrl); 44 | i += 1 45 | } 46 | return 47 | 48 | } 49 | 50 | let backendProcess = null 51 | 52 | function cleanup() { 53 | if (backendProcess !== null) { 54 | console.log("Cleaning up running backend") 55 | backendProcess.kill("SIGTERM") 56 | execSync("just reset-local-backend") 57 | } 58 | } 59 | 60 | async function runWithLocalBackend(command, backendUrl) { 61 | if (process.env.CONVEX_LOCAL_BACKEND_PATH === undefined) { 62 | console.error("Please set environment variable CONVEX_LOCAL_BACKEND_PATH first") 63 | process.exit(1) 64 | } 65 | const isRunning = await isBackendRunning(backendUrl); 66 | if (isRunning) { 67 | console.error("Looks like local backend is already running. Cancel it and restart this command.") 68 | process.exit(1) 69 | } 70 | execSync("just reset-local-backend") 71 | backendProcess = exec("CONVEX_TRACE_FILE=1 just run-local-backend") 72 | await waitForLocalBackendRunning(backendUrl) 73 | console.log("Backend running! Logs can be found in $CONVEX_LOCAL_BACKEND_PATH/convex-local-backend.log") 74 | const innerCommand = new Promise((resolve) => { 75 | const c = spawn(command, { shell: true, stdio: "pipe", env: {...process.env, FORCE_COLOR: true } }) 76 | c.stdout.on('data', (data) => { 77 | process.stdout.write(data); 78 | }) 79 | 80 | c.stderr.on('data', (data) => { 81 | process.stderr.write(data); 82 | }) 83 | 84 | c.on('exit', (code) => { 85 | console.log('inner command exited with code ' + code.toString()) 86 | resolve(code) 87 | }) 88 | }); 89 | return innerCommand; 90 | } 91 | 92 | runWithLocalBackend(process.argv[2], parsedUrl).then((code) => { 93 | cleanup() 94 | process.exit(code) 95 | }).catch(() => { 96 | cleanup() 97 | process.exit(1) 98 | }) -------------------------------------------------------------------------------- /styles/globals.css: -------------------------------------------------------------------------------- 1 | /* reset */ 2 | * { 3 | margin: 0; 4 | padding: 0; 5 | border: 0; 6 | line-height: 1.5; 7 | } 8 | 9 | body { 10 | font-family: system-ui, "Segoe UI", Roboto, "Helvetica Neue", helvetica, 11 | sans-serif; 12 | } 13 | 14 | main { 15 | padding-top: 1em; 16 | padding-bottom: 1em; 17 | width: min(800px, 95vw); 18 | margin: 0 auto; 19 | display: flex; 20 | flex-direction: column; 21 | align-items: center; 22 | } 23 | 24 | h1 { 25 | text-align: center; 26 | margin-bottom: 8px; 27 | font-size: 1.8em; 28 | font-weight: 500; 29 | } 30 | 31 | .badge { 32 | text-align: center; 33 | margin-bottom: 16px; 34 | } 35 | .badge span { 36 | background-color: #279929; 37 | border-radius: 6px; 38 | font-weight: bold; 39 | padding: 4px 8px 4px 8px; 40 | font-size: 0.75em; 41 | } 42 | .badge a { 43 | color: #212529; 44 | background-color: #279929; 45 | border-radius: 6px; 46 | font-weight: bold; 47 | padding: 4px 8px 4px 8px; 48 | font-size: 0.75em; 49 | } 50 | .whitePlayer { 51 | background-color: #f0f0f0; 52 | color: #212529; 53 | border-radius: 6px; 54 | font-weight: bold; 55 | padding: 4px 8px 4px 8px; 56 | font-size: 0.75em; 57 | } 58 | .blackPlayer { 59 | background-color: #000000; 60 | color: #ffffff; 61 | border-radius: 6px; 62 | font-weight: bold; 63 | padding: 4px 8px 4px 8px; 64 | font-size: 0.75em; 65 | } 66 | 67 | ul { 68 | margin: 8px; 69 | border-radius: 8px; 70 | border: solid 1px lightgray; 71 | box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075); 72 | } 73 | 74 | ul:empty { 75 | display: none; 76 | } 77 | 78 | li { 79 | display: flex; 80 | justify-content: flex-start; 81 | padding: 8px 16px 8px 16px; 82 | border-bottom: solid 1px lightgray; 83 | font-size: 16px; 84 | } 85 | 86 | li:last-child { 87 | border: 0; 88 | } 89 | 90 | li span:nth-child(1) { 91 | font-weight: bold; 92 | margin-right: 4px; 93 | white-space: nowrap; 94 | } 95 | li span:nth-child(2) { 96 | margin-right: 4px; 97 | word-break: break-word; 98 | } 99 | li span:nth-child(3) { 100 | color: #6c757d; 101 | margin-left: auto; 102 | white-space: nowrap; 103 | } 104 | 105 | .control-form { 106 | display: flex; 107 | justify-content: center; 108 | padding-bottom: 20px; 109 | } 110 | 111 | input:not([type]) { 112 | padding: 6px 12px 6px 12px; 113 | color: rgb(33, 37, 41); 114 | border: solid 1px rgb(206, 212, 218); 115 | border-radius: 8px; 116 | font-size: 16px; 117 | } 118 | 119 | input[type="submit"], 120 | button { 121 | margin-left: 4px; 122 | background: lightblue; 123 | color: white; 124 | padding: 6px 12px 6px 12px; 125 | border-radius: 8px; 126 | font-size: 16px; 127 | background-color: rgb(49, 108, 244); 128 | } 129 | 130 | input[type="submit"]:hover, 131 | button:hover { 132 | background-color: rgb(41, 93, 207); 133 | } 134 | 135 | input[type="submit"]:disabled, 136 | button:disabled { 137 | background-color: rgb(122, 160, 248); 138 | } 139 | 140 | .game { 141 | display: flex; 142 | } 143 | 144 | .moves { 145 | padding-left: 10px; 146 | min-width: 50px; 147 | } 148 | 149 | .moves table { 150 | border: 1px solid; 151 | } 152 | 153 | .moveNumber { 154 | min-width: 20px; 155 | } 156 | 157 | .moveSquare { 158 | min-width: 50px; 159 | } 160 | 161 | .analysis { 162 | width: 130px; 163 | } 164 | 165 | .convexImage { 166 | margin-top: 20px; 167 | margin-left: 20px; 168 | } 169 | 170 | .profileImage { 171 | width: 150px; 172 | height: 150px; 173 | border-radius: 50%; 174 | font-size: 35px; 175 | color: #fff; 176 | background-color: #279929; 177 | text-align: center; 178 | line-height: 150px; 179 | margin: 20px 40px; 180 | border: 4px solid #000; 181 | } 182 | -------------------------------------------------------------------------------- /convex/_generated/server.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /** 3 | * Generated utilities for implementing server-side Convex query and mutation functions. 4 | * 5 | * THIS CODE IS AUTOMATICALLY GENERATED. 6 | * 7 | * To regenerate, run `npx convex dev`. 8 | * @module 9 | */ 10 | 11 | import { 12 | actionGeneric, 13 | httpActionGeneric, 14 | queryGeneric, 15 | mutationGeneric, 16 | internalActionGeneric, 17 | internalMutationGeneric, 18 | internalQueryGeneric, 19 | componentsGeneric, 20 | } from "convex/server"; 21 | 22 | /** 23 | * Define a query in this Convex app's public API. 24 | * 25 | * This function will be allowed to read your Convex database and will be accessible from the client. 26 | * 27 | * @param func - The query function. It receives a {@link QueryCtx} as its first argument. 28 | * @returns The wrapped query. Include this as an `export` to name it and make it accessible. 29 | */ 30 | export const query = queryGeneric; 31 | 32 | /** 33 | * Define a query that is only accessible from other Convex functions (but not from the client). 34 | * 35 | * This function will be allowed to read from your Convex database. It will not be accessible from the client. 36 | * 37 | * @param func - The query function. It receives a {@link QueryCtx} as its first argument. 38 | * @returns The wrapped query. Include this as an `export` to name it and make it accessible. 39 | */ 40 | export const internalQuery = internalQueryGeneric; 41 | 42 | /** 43 | * Define a mutation in this Convex app's public API. 44 | * 45 | * This function will be allowed to modify your Convex database and will be accessible from the client. 46 | * 47 | * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. 48 | * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. 49 | */ 50 | export const mutation = mutationGeneric; 51 | 52 | /** 53 | * Define a mutation that is only accessible from other Convex functions (but not from the client). 54 | * 55 | * This function will be allowed to modify your Convex database. It will not be accessible from the client. 56 | * 57 | * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. 58 | * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. 59 | */ 60 | export const internalMutation = internalMutationGeneric; 61 | 62 | /** 63 | * Define an action in this Convex app's public API. 64 | * 65 | * An action is a function which can execute any JavaScript code, including non-deterministic 66 | * code and code with side-effects, like calling third-party services. 67 | * They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive. 68 | * They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}. 69 | * 70 | * @param func - The action. It receives an {@link ActionCtx} as its first argument. 71 | * @returns The wrapped action. Include this as an `export` to name it and make it accessible. 72 | */ 73 | export const action = actionGeneric; 74 | 75 | /** 76 | * Define an action that is only accessible from other Convex functions (but not from the client). 77 | * 78 | * @param func - The function. It receives an {@link ActionCtx} as its first argument. 79 | * @returns The wrapped function. Include this as an `export` to name it and make it accessible. 80 | */ 81 | export const internalAction = internalActionGeneric; 82 | 83 | /** 84 | * Define a Convex HTTP action. 85 | * 86 | * @param func - The function. It receives an {@link ActionCtx} as its first argument, and a `Request` object 87 | * as its second. 88 | * @returns The wrapped endpoint function. Route a URL path to this function in `convex/http.js`. 89 | */ 90 | export const httpAction = httpActionGeneric; 91 | -------------------------------------------------------------------------------- /games.test.ts: -------------------------------------------------------------------------------- 1 | import { api } from "./convex/_generated/api"; 2 | import { ConvexTestingHelper } from "convex-helpers/testing"; 3 | 4 | describe("games", () => { 5 | let t: ConvexTestingHelper; 6 | 7 | beforeEach(() => { 8 | t = new ConvexTestingHelper(); 9 | }); 10 | 11 | afterEach(async () => { 12 | await t.mutation(api.testing.clearAll, {}); 13 | await t.close(); 14 | }); 15 | 16 | test("two players can join game", async () => { 17 | const sarahIdentity = t.newIdentity({ name: "Sarah" }); 18 | const asSarah = t.withIdentity(sarahIdentity); 19 | 20 | const leeIdentity = t.newIdentity({ name: "Lee" }); 21 | const asLee = t.withIdentity(leeIdentity); 22 | 23 | const gameId = await asSarah.mutation(api.games.newGame, { 24 | player1: "Me", 25 | player2: null, 26 | }); 27 | 28 | let game = await t.query(api.games.get, { id: gameId }); 29 | expect(game.player1Name).toEqual("Sarah"); 30 | 31 | await asLee.mutation(api.games.joinGame, { id: gameId }); 32 | game = await t.query(api.games.get, { id: gameId }); 33 | expect(game.player2Name).toEqual("Lee"); 34 | 35 | await asSarah.mutation(api.games.move, { 36 | gameId, 37 | from: "c2", 38 | to: "c3", 39 | finalPiece: "p", 40 | }); 41 | game = await t.query(api.games.get, { id: gameId }); 42 | expect(game.pgn).toEqual("1. c3"); 43 | 44 | // Invalid move -- out of turn 45 | expect(() => 46 | asSarah.mutation(api.games.move, { 47 | gameId, 48 | from: "d2", 49 | to: "d3", 50 | finalPiece: "p", 51 | }) 52 | ).rejects.toThrow(/invalid move d2-d3/); 53 | game = await t.query(api.games.get, { id: gameId }); 54 | expect(game.pgn).toEqual("1. c3"); 55 | }); 56 | 57 | test("game finishes", async () => { 58 | // Set up data using test only functions 59 | const sarahIdentity = t.newIdentity({ name: "Sarah" }); 60 | const asSarah = t.withIdentity(sarahIdentity); 61 | const sarahId = await asSarah.mutation(api.testing.setupUser, {}); 62 | 63 | const leeIdentity = t.newIdentity({ name: "Lee" }); 64 | const asLee = t.withIdentity(leeIdentity); 65 | const leeId = await asLee.mutation(api.testing.setupUser, {}); 66 | 67 | // Two moves before the end of the game 68 | const gameAlmostFinishedPgn = 69 | "1. Nf3 Nf6 2. d4 Nc6 3. e4 Nxe4 4. Bd3 Nf6 5. Nc3 Nxd4 6. Nxd4 b6 7. O-O Bb7 8. g3 Qb8 9. Be3 c5 10. Nf5 a6 11. f3 b5 12. Bxc5 d6 13. Bd4 b4 14. Bxf6 gxf6 15. Ne2 e6 16. Nfd4 e5 17. Nf5 Qc8 18. Ne3 d5 19. Re1 d4 20. Nf1 Bh6 21. Nxd4 O-O 22. Ne2 f5 23. a3 bxa3 24. bxa3 Re8 25. Qb1 e4 26. fxe4 fxe4 27. Bxe4 Rxe4 28. Ne3 Rxe3 29. c3 Be4 30. Qb2 Bg7 31. g4 Bxc3 32. Nxc3 Rxc3 33. Rxe4 Kg7 34. g5 Kg6 35. Re7 Rc7 36. Qf6+ Kh5 37. Re5 Rb8 38. Rae1 Rc6 39. Qxf7+ Kg4 40. Qxh7 Rb7 41. Qd3 Rbc7 42. Rd1 Rc3 43. Qd4+ Kh5 44. a4 R7c4 45. g6+ Kxg6 46. Qd6+ Kf7 47. Re7+ Kf8 48. Rc7+"; 70 | 71 | const gameId = await t.mutation(api.testing.setupGame, { 72 | player1: sarahId, 73 | player2: leeId, 74 | pgn: gameAlmostFinishedPgn, 75 | finished: false, 76 | }); 77 | 78 | // Test that winning the game marks the game as finished 79 | await asLee.mutation(api.games.move, { 80 | gameId, 81 | from: "f8", 82 | to: "e8", 83 | finalPiece: "k", 84 | }); 85 | let game = await t.query(api.games.get, { id: gameId }); 86 | let ongoingGames = await t.query(api.games.ongoingGames, {}); 87 | expect(game.finished).toBe(false); 88 | expect(ongoingGames.length).toStrictEqual(1); 89 | 90 | await asSarah.mutation(api.games.move, { 91 | gameId, 92 | from: "d6", 93 | to: "e7", 94 | finalPiece: "q", 95 | }); 96 | game = await t.query(api.games.get, { id: gameId }); 97 | ongoingGames = await t.query(api.games.ongoingGames, {}); 98 | expect(game.finished).toBe(true); 99 | expect(ongoingGames.length).toStrictEqual(0); 100 | }); 101 | }); 102 | 103 | -------------------------------------------------------------------------------- /public/convex.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /public/convex-chess.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /convex/_generated/api.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /** 3 | * Generated `api` utility. 4 | * 5 | * THIS CODE IS AUTOMATICALLY GENERATED. 6 | * 7 | * To regenerate, run `npx convex dev`. 8 | * @module 9 | */ 10 | 11 | import type * as auth from "../auth.js"; 12 | import type * as cronFunctions from "../cronFunctions.js"; 13 | import type * as crons from "../crons.js"; 14 | import type * as engine from "../engine.js"; 15 | import type * as games from "../games.js"; 16 | import type * as http from "../http.js"; 17 | import type * as index from "../index.js"; 18 | import type * as lib_openai from "../lib/openai.js"; 19 | import type * as search from "../search.js"; 20 | import type * as testing from "../testing.js"; 21 | import type * as users from "../users.js"; 22 | import type * as utils from "../utils.js"; 23 | 24 | import type { 25 | ApiFromModules, 26 | FilterApi, 27 | FunctionReference, 28 | } from "convex/server"; 29 | /** 30 | * A utility for referencing Convex functions in your app's API. 31 | * 32 | * Usage: 33 | * ```js 34 | * const myFunctionReference = api.myModule.myFunction; 35 | * ``` 36 | */ 37 | declare const fullApi: ApiFromModules<{ 38 | auth: typeof auth; 39 | cronFunctions: typeof cronFunctions; 40 | crons: typeof crons; 41 | engine: typeof engine; 42 | games: typeof games; 43 | http: typeof http; 44 | index: typeof index; 45 | "lib/openai": typeof lib_openai; 46 | search: typeof search; 47 | testing: typeof testing; 48 | users: typeof users; 49 | utils: typeof utils; 50 | }>; 51 | declare const fullApiWithMounts: typeof fullApi; 52 | 53 | export declare const api: FilterApi< 54 | typeof fullApiWithMounts, 55 | FunctionReference 56 | >; 57 | export declare const internal: FilterApi< 58 | typeof fullApiWithMounts, 59 | FunctionReference 60 | >; 61 | 62 | export declare const components: { 63 | aggregate: { 64 | btree: { 65 | aggregateBetween: FunctionReference< 66 | "query", 67 | "internal", 68 | { k1?: any; k2?: any }, 69 | { count: number; sum: number } 70 | >; 71 | atNegativeOffset: FunctionReference< 72 | "query", 73 | "internal", 74 | { k1?: any; k2?: any; offset: number }, 75 | { k: any; s: number; v: any } 76 | >; 77 | atOffset: FunctionReference< 78 | "query", 79 | "internal", 80 | { k1?: any; k2?: any; offset: number }, 81 | { k: any; s: number; v: any } 82 | >; 83 | count: FunctionReference<"query", "internal", {}, any>; 84 | get: FunctionReference< 85 | "query", 86 | "internal", 87 | { key: any }, 88 | null | { k: any; s: number; v: any } 89 | >; 90 | offset: FunctionReference< 91 | "query", 92 | "internal", 93 | { k1?: any; key: any }, 94 | number 95 | >; 96 | offsetUntil: FunctionReference< 97 | "query", 98 | "internal", 99 | { k2?: any; key: any }, 100 | number 101 | >; 102 | paginate: FunctionReference< 103 | "query", 104 | "internal", 105 | { 106 | cursor?: string; 107 | k1?: any; 108 | k2?: any; 109 | limit: number; 110 | order: "asc" | "desc"; 111 | }, 112 | { 113 | cursor: string; 114 | isDone: boolean; 115 | page: Array<{ k: any; s: number; v: any }>; 116 | } 117 | >; 118 | sum: FunctionReference<"query", "internal", {}, number>; 119 | validate: FunctionReference<"query", "internal", {}, any>; 120 | }; 121 | inspect: { 122 | display: FunctionReference<"query", "internal", {}, any>; 123 | dump: FunctionReference<"query", "internal", {}, string>; 124 | inspectNode: FunctionReference< 125 | "query", 126 | "internal", 127 | { node?: string }, 128 | null 129 | >; 130 | }; 131 | public: { 132 | clear: FunctionReference< 133 | "mutation", 134 | "internal", 135 | { maxNodeSize?: number; rootLazy?: boolean }, 136 | null 137 | >; 138 | deleteIfExists: FunctionReference< 139 | "mutation", 140 | "internal", 141 | { key: any }, 142 | any 143 | >; 144 | delete_: FunctionReference<"mutation", "internal", { key: any }, null>; 145 | init: FunctionReference< 146 | "mutation", 147 | "internal", 148 | { maxNodeSize?: number; rootLazy?: boolean }, 149 | null 150 | >; 151 | insert: FunctionReference< 152 | "mutation", 153 | "internal", 154 | { key: any; summand?: number; value: any }, 155 | null 156 | >; 157 | makeRootLazy: FunctionReference<"mutation", "internal", {}, null>; 158 | replace: FunctionReference< 159 | "mutation", 160 | "internal", 161 | { currentKey: any; newKey: any; summand?: number; value: any }, 162 | null 163 | >; 164 | replaceOrInsert: FunctionReference< 165 | "mutation", 166 | "internal", 167 | { currentKey: any; newKey: any; summand?: number; value: any }, 168 | any 169 | >; 170 | }; 171 | }; 172 | }; 173 | -------------------------------------------------------------------------------- /convex/_generated/server.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /** 3 | * Generated utilities for implementing server-side Convex query and mutation functions. 4 | * 5 | * THIS CODE IS AUTOMATICALLY GENERATED. 6 | * 7 | * To regenerate, run `npx convex dev`. 8 | * @module 9 | */ 10 | 11 | import { 12 | ActionBuilder, 13 | AnyComponents, 14 | HttpActionBuilder, 15 | MutationBuilder, 16 | QueryBuilder, 17 | GenericActionCtx, 18 | GenericMutationCtx, 19 | GenericQueryCtx, 20 | GenericDatabaseReader, 21 | GenericDatabaseWriter, 22 | FunctionReference, 23 | } from "convex/server"; 24 | import type { DataModel } from "./dataModel.js"; 25 | 26 | type GenericCtx = 27 | | GenericActionCtx 28 | | GenericMutationCtx 29 | | GenericQueryCtx; 30 | 31 | /** 32 | * Define a query in this Convex app's public API. 33 | * 34 | * This function will be allowed to read your Convex database and will be accessible from the client. 35 | * 36 | * @param func - The query function. It receives a {@link QueryCtx} as its first argument. 37 | * @returns The wrapped query. Include this as an `export` to name it and make it accessible. 38 | */ 39 | export declare const query: QueryBuilder; 40 | 41 | /** 42 | * Define a query that is only accessible from other Convex functions (but not from the client). 43 | * 44 | * This function will be allowed to read from your Convex database. It will not be accessible from the client. 45 | * 46 | * @param func - The query function. It receives a {@link QueryCtx} as its first argument. 47 | * @returns The wrapped query. Include this as an `export` to name it and make it accessible. 48 | */ 49 | export declare const internalQuery: QueryBuilder; 50 | 51 | /** 52 | * Define a mutation in this Convex app's public API. 53 | * 54 | * This function will be allowed to modify your Convex database and will be accessible from the client. 55 | * 56 | * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. 57 | * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. 58 | */ 59 | export declare const mutation: MutationBuilder; 60 | 61 | /** 62 | * Define a mutation that is only accessible from other Convex functions (but not from the client). 63 | * 64 | * This function will be allowed to modify your Convex database. It will not be accessible from the client. 65 | * 66 | * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. 67 | * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. 68 | */ 69 | export declare const internalMutation: MutationBuilder; 70 | 71 | /** 72 | * Define an action in this Convex app's public API. 73 | * 74 | * An action is a function which can execute any JavaScript code, including non-deterministic 75 | * code and code with side-effects, like calling third-party services. 76 | * They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive. 77 | * They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}. 78 | * 79 | * @param func - The action. It receives an {@link ActionCtx} as its first argument. 80 | * @returns The wrapped action. Include this as an `export` to name it and make it accessible. 81 | */ 82 | export declare const action: ActionBuilder; 83 | 84 | /** 85 | * Define an action that is only accessible from other Convex functions (but not from the client). 86 | * 87 | * @param func - The function. It receives an {@link ActionCtx} as its first argument. 88 | * @returns The wrapped function. Include this as an `export` to name it and make it accessible. 89 | */ 90 | export declare const internalAction: ActionBuilder; 91 | 92 | /** 93 | * Define an HTTP action. 94 | * 95 | * This function will be used to respond to HTTP requests received by a Convex 96 | * deployment if the requests matches the path and method where this action 97 | * is routed. Be sure to route your action in `convex/http.js`. 98 | * 99 | * @param func - The function. It receives an {@link ActionCtx} as its first argument. 100 | * @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up. 101 | */ 102 | export declare const httpAction: HttpActionBuilder; 103 | 104 | /** 105 | * A set of services for use within Convex query functions. 106 | * 107 | * The query context is passed as the first argument to any Convex query 108 | * function run on the server. 109 | * 110 | * This differs from the {@link MutationCtx} because all of the services are 111 | * read-only. 112 | */ 113 | export type QueryCtx = GenericQueryCtx; 114 | 115 | /** 116 | * A set of services for use within Convex mutation functions. 117 | * 118 | * The mutation context is passed as the first argument to any Convex mutation 119 | * function run on the server. 120 | */ 121 | export type MutationCtx = GenericMutationCtx; 122 | 123 | /** 124 | * A set of services for use within Convex action functions. 125 | * 126 | * The action context is passed as the first argument to any Convex action 127 | * function run on the server. 128 | */ 129 | export type ActionCtx = GenericActionCtx; 130 | 131 | /** 132 | * An interface to read from the database within Convex query functions. 133 | * 134 | * The two entry points are {@link DatabaseReader.get}, which fetches a single 135 | * document by its {@link Id}, or {@link DatabaseReader.query}, which starts 136 | * building a query. 137 | */ 138 | export type DatabaseReader = GenericDatabaseReader; 139 | 140 | /** 141 | * An interface to read from and write to the database within Convex mutation 142 | * functions. 143 | * 144 | * Convex guarantees that all writes within a single mutation are 145 | * executed atomically, so you never have to worry about partial writes leaving 146 | * your data in an inconsistent state. See [the Convex Guide](https://docs.convex.dev/understanding/convex-fundamentals/functions#atomicity-and-optimistic-concurrency-control) 147 | * for the guarantees Convex provides your functions. 148 | */ 149 | export type DatabaseWriter = GenericDatabaseWriter; 150 | -------------------------------------------------------------------------------- /app/play/[id]/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { api } from "../../../convex/_generated/api"; 3 | import { useRouter, useSearchParams } from "next/navigation"; 4 | import { Chess, Move, Square } from "chess.js"; 5 | import { Chessboard } from "react-chessboard"; 6 | 7 | import { useMutation, useQuery } from "convex/react"; 8 | import { Id } from "../../../convex/_generated/dataModel"; 9 | import { validateMove, isOpen, playerEquals } from "../../../convex/utils"; 10 | import { gameTitle } from "../../../common"; 11 | import { useEffect, useState } from "react"; 12 | import { Piece } from "react-chessboard/dist/chessboard/types"; 13 | 14 | export default function Game({ params }: { params: { id: string } }) { 15 | const gameId = params.id as Id<"games">; 16 | const searchParams = useSearchParams(); 17 | const moveIdx = 18 | searchParams.get("moveIndex") !== null 19 | ? Number(searchParams.get("moveIndex")) 20 | : undefined; 21 | 22 | const gameState = useQuery(api.games.get, { id: gameId }); 23 | const user = useQuery(api.users.getMyUser) ?? null; 24 | const [selectedMove, setSelectedMove] = useState(moveIdx); 25 | const [mainStyle, setMainStyle] = useState<{ backgroundColor?: string }>({}); 26 | 27 | useEffect(() => { 28 | if (moveIdx !== undefined && moveIdx !== selectedMove) 29 | setSelectedMove(moveIdx); 30 | }, [moveIdx]); 31 | 32 | const { analysis, moveIndex, move } = 33 | useQuery( 34 | api.games.getAnalysis, 35 | gameState ? { gameId: gameState._id, moveIndex: selectedMove } : "skip" 36 | ) ?? {}; 37 | 38 | const performMove = useMutation(api.games.move).withOptimisticUpdate( 39 | (localStore, { gameId, from, to, finalPiece }) => { 40 | const state = localStore.getQuery(api.games.get, { id: gameId }); 41 | if (state) { 42 | const game = new Chess(); 43 | game.loadPgn(state.pgn); 44 | // This is lame but try promoting. 45 | try { 46 | game.move({ from, to }); 47 | } catch { 48 | game.move({ from, to, promotion: finalPiece }); 49 | } 50 | const newState = { ...state }; 51 | newState.pgn = game.pgn(); 52 | console.log("nextState", game.history(), gameId); 53 | localStore.setQuery(api.games.get, { id: gameId }, newState); 54 | } 55 | } 56 | ); 57 | const joinGame = useMutation(api.games.joinGame); 58 | const tryPerformMove = useMutation(api.games.move); 59 | 60 | if (!gameState) { 61 | return <>; 62 | } 63 | 64 | if (isOpen(gameState)) { 65 | joinGame({ id: gameId }); 66 | } 67 | 68 | const game = new Chess(); 69 | game.loadPgn(gameState.pgn); 70 | 71 | const clickWhiteMove = (i: number) => { 72 | setSelectedMove(i * 2); 73 | }; 74 | const clickBlackMove = (i: number) => { 75 | setSelectedMove(i * 2 + 1); 76 | }; 77 | 78 | async function onDrop( 79 | sourceSquare: Square, 80 | targetSquare: Square, 81 | piece: Piece 82 | ) { 83 | const finalPiece = piece[1].toLowerCase(); 84 | let nextState = validateMove( 85 | gameState!, 86 | user?._id ?? null, 87 | sourceSquare, 88 | targetSquare, 89 | finalPiece 90 | ); 91 | if (nextState) { 92 | await performMove({ 93 | gameId, 94 | from: sourceSquare, 95 | to: targetSquare, 96 | finalPiece, 97 | }); 98 | setSelectedMove(undefined); 99 | } else { 100 | setMainStyle({ backgroundColor: "red" }); 101 | setTimeout(() => setMainStyle({}), 50); 102 | try { 103 | await tryPerformMove({ 104 | gameId, 105 | from: sourceSquare, 106 | to: targetSquare, 107 | finalPiece, 108 | }); 109 | } catch (error) { 110 | console.log(error); 111 | } 112 | } 113 | return nextState != null; 114 | } 115 | 116 | type Turn = { 117 | num: number; 118 | whiteMove: string; 119 | blackMove: string; 120 | }; 121 | let turns: Turn[] = []; 122 | let history = game.history().length > 0 ? game.history() : [""]; 123 | while (history.length > 0) { 124 | const whiteMove = history.shift() as string; 125 | const blackMove = (history.shift() as string) ?? ""; 126 | turns.push({ num: turns.length + 1, whiteMove, blackMove }); 127 | } 128 | 129 | const boardOrientation = playerEquals( 130 | user?._id ?? null, 131 | gameState.player2 as any 132 | ) 133 | ? "black" 134 | : "white"; 135 | 136 | return ( 137 |
138 |
{gameTitle(gameState)}
139 |
140 |
141 | 145 | onDrop(source, target, piece) as unknown as boolean 146 | } 147 | boardOrientation={boardOrientation} 148 | showPromotionDialog 149 | /> 150 |
151 |
152 | Moves 153 | 154 | 155 | {turns.map((turn, i) => ( 156 | 157 | 158 | 161 | 164 | 165 | ))} 166 | 167 |
{turn.num}. clickWhiteMove(i)}> 159 | {turn.whiteMove} 160 | clickBlackMove(i)}> 162 | {turn.blackMove} 163 |
168 | {moveIndex !== undefined && ( 169 | 170 | 171 | 172 | 178 | 179 | 180 | 181 | 182 | 183 |
173 | 174 | {Math.floor(moveIndex / 2) + 1} 175 | {moveIndex % 2 ? "b" : "a"}. {move} 176 | 177 |
{analysis}
184 | )} 185 |
186 |
187 |
188 | ); 189 | } 190 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright 2024 Convex, Inc. 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /convex/games.ts: -------------------------------------------------------------------------------- 1 | import { api, internal } from "./_generated/api"; 2 | import { 3 | query, 4 | mutation, 5 | DatabaseWriter, 6 | DatabaseReader, 7 | internalMutation, 8 | internalAction, 9 | internalQuery, 10 | MutationCtx, 11 | } from "./_generated/server"; 12 | import { Id, Doc } from "./_generated/dataModel"; 13 | 14 | import { 15 | getCurrentPlayer, 16 | validateMove, 17 | PlayerId, 18 | getNextPlayer, 19 | } from "./utils"; 20 | 21 | import { Chess } from "chess.js"; 22 | import { Scheduler } from "convex/server"; 23 | import { ConvexError, v } from "convex/values"; 24 | import { chatCompletion } from "./lib/openai"; 25 | import { getAuthUserId } from "@convex-dev/auth/server"; 26 | import { aggregate } from "."; 27 | 28 | async function playerName( 29 | db: DatabaseReader, 30 | playerId: "Computer" | Id<"users"> | null 31 | ) { 32 | if (playerId === null) { 33 | return ""; 34 | } else if (playerId == "Computer") { 35 | return playerId; 36 | } else { 37 | const user = await db.get(playerId as Id<"users">); 38 | if (user === null) { 39 | throw new Error(`Missing player id ${playerId}`); 40 | } 41 | return user.name ?? "Unknown"; 42 | } 43 | } 44 | 45 | export async function denormalizePlayerNames( 46 | db: DatabaseReader, 47 | game: Doc<"games"> 48 | ) { 49 | return { 50 | ...game, 51 | player1Name: await playerName(db, game.player1), 52 | player2Name: await playerName(db, game.player2), 53 | }; 54 | } 55 | 56 | export const get = query(async ({ db }, { id }: { id: Id<"games"> }) => { 57 | const game = await db.get(id); 58 | if (!game) { 59 | throw new Error(`Invalid game ${id}.`); 60 | } 61 | return await denormalizePlayerNames(db, game); 62 | }); 63 | 64 | export const ongoingGames = query(async ({ db }) => { 65 | const games = await db 66 | .query("games") 67 | .withIndex("finished", (q) => q.eq("finished", false)) 68 | .order("desc") 69 | .take(50); 70 | const result = []; 71 | for (let game of games) { 72 | result.push(await denormalizePlayerNames(db, game)); 73 | } 74 | return result; 75 | }); 76 | 77 | export const newGame = mutation( 78 | async ( 79 | { db, auth, scheduler }, 80 | { 81 | player1, 82 | player2, 83 | }: { 84 | player1: null | "Computer" | "Me"; 85 | player2: null | "Computer" | "Me"; 86 | } 87 | ) => { 88 | const userId = await getAuthUserId({ auth }); 89 | let player1Id: PlayerId; 90 | if (player1 === "Me") { 91 | if (!userId) { 92 | throw new Error("Can't play as unauthenticated user"); 93 | } 94 | player1Id = userId; 95 | } else { 96 | player1Id = player1; 97 | } 98 | let player2Id: PlayerId; 99 | if (player2 === "Me") { 100 | if (!userId) { 101 | throw new Error("Can't play as unauthenticated user"); 102 | } 103 | player2Id = userId; 104 | } else { 105 | player2Id = player2; 106 | } 107 | 108 | const game = new Chess(); 109 | let id: Id<"games"> = await db.insert("games", { 110 | pgn: game.pgn(), 111 | player1: player1Id, 112 | player2: player2Id, 113 | finished: false, 114 | }); 115 | 116 | scheduler.runAfter(1000, internal.engine.maybeMakeComputerMove, { id }); 117 | 118 | return id; 119 | } 120 | ); 121 | 122 | export const joinGame = mutation( 123 | async ({ db, auth }, { id }: { id: Id<"games"> }) => { 124 | const userId = await getAuthUserId({ auth }); 125 | if (!userId) { 126 | throw new Error("Trying to join game with unauthenticated user"); 127 | } 128 | let state = await db.get(id); 129 | if (state == null) { 130 | throw new Error(`Invalid game ${id}`); 131 | } 132 | 133 | if (!state.player1 && userId !== state.player2) { 134 | await db.patch(id, { 135 | player1: userId, 136 | }); 137 | } else if (!state.player2 && userId !== state.player1) { 138 | await db.patch(id, { 139 | player2: userId, 140 | }); 141 | } 142 | } 143 | ); 144 | 145 | async function _performMove( 146 | ctx: MutationCtx, 147 | player: PlayerId, 148 | state: Doc<"games">, 149 | from: string, 150 | to: string, 151 | finalPiece: string 152 | ) { 153 | const currentPGN = state.pgn; 154 | let nextState = validateMove(state, player, from, to, finalPiece); 155 | if (!nextState) { 156 | // Invalid move. 157 | throw new ConvexError(`invalid move ${from}-${to}`); 158 | } 159 | 160 | if (nextState.isGameOver()) { 161 | const currentPlayer = await playerName(ctx.db, getCurrentPlayer(state)); 162 | const nextPlayer = await playerName(ctx.db, getNextPlayer(state)); 163 | 164 | if (nextState.isCheckmate()) { 165 | console.log(`Checkmate! ${currentPlayer} beat ${nextPlayer}`); 166 | } 167 | if (nextState.isDraw()) { 168 | console.log( 169 | `Draw! ${currentPlayer} and ${nextPlayer} are evenly matched` 170 | ); 171 | } 172 | } 173 | 174 | await ctx.db.patch(state._id, { 175 | pgn: nextState.pgn(), 176 | finished: nextState.isGameOver(), 177 | }); 178 | const history = nextState.history(); 179 | const move = history[history.length - 1]; 180 | await ctx.scheduler.runAfter(0, internal.games.analyzeMove, { 181 | gameId: state._id, 182 | moveIndex: history.length - 1, 183 | previousPGN: currentPGN, 184 | move, 185 | }); 186 | 187 | await aggregate.insert(ctx, move, `${state._id}:${history.length - 1}`, 1); 188 | 189 | await ctx.scheduler.runAfter(1000, internal.engine.maybeMakeComputerMove, { 190 | id: state._id, 191 | }); 192 | } 193 | 194 | const boardView = (chess: Chess): string => { 195 | const rows = []; 196 | for (const row of chess.board()) { 197 | let rowView = ""; 198 | for (const square of row) { 199 | if (square === null) { 200 | rowView += "."; 201 | } else { 202 | let piece = square.type as string; 203 | if (square.color === "w") { 204 | piece = piece.toUpperCase(); 205 | } 206 | rowView += piece; 207 | } 208 | } 209 | rows.push(rowView); 210 | } 211 | return rows.join("\n"); 212 | }; 213 | 214 | export const analyzeMove = internalAction({ 215 | args: { 216 | gameId: v.id("games"), 217 | moveIndex: v.number(), 218 | previousPGN: v.string(), 219 | move: v.string(), 220 | }, 221 | handler: async (ctx, { gameId, moveIndex, previousPGN, move }) => { 222 | const game = new Chess(); 223 | game.loadPgn(previousPGN); 224 | const boardState = boardView(game); 225 | const _boardState = game.fen(); 226 | const oldPrompt = `Analyze just the move at index ${moveIndex} in this chess game. Only tell me about the effect of that move.: ${game.history()}.`; 227 | const prompt = `You are a chess expert. I am playing a chess game. The board looks like this:\n${boardState}\n\nAnalyze the effect of playing the move ${move}. Please analyze concisely, with less than 20 words. Then conclude with an over-the-top sentence describing sarcastic, flippant, or humorous feelings about the move.`; 228 | const response = await chatCompletion({ 229 | messages: [ 230 | { 231 | role: "user", 232 | content: prompt, 233 | }, 234 | ], 235 | }); 236 | let responseText = ""; 237 | for await (const chunk of response.content.read()) { 238 | responseText += chunk; 239 | 240 | await ctx.runMutation(internal.games.saveAnalysis, { 241 | gameId: gameId, 242 | moveIndex, 243 | analysis: responseText, 244 | }); 245 | } 246 | console.log(`PROMPT '${prompt}' GOT RESPONSE '${responseText}'`); 247 | }, 248 | }); 249 | 250 | export const saveAnalysis = internalMutation({ 251 | args: { 252 | gameId: v.id("games"), 253 | moveIndex: v.number(), 254 | analysis: v.string(), 255 | }, 256 | handler: async (ctx, { gameId, moveIndex, analysis }) => { 257 | const analysisDoc = await ctx.db 258 | .query("analysis") 259 | .withIndex("by_game_index", (q) => 260 | q.eq("game", gameId).eq("moveIndex", moveIndex) 261 | ) 262 | .unique(); 263 | if (analysisDoc) { 264 | await ctx.db.patch(analysisDoc._id, { 265 | analysis, 266 | }); 267 | } else { 268 | await ctx.db.insert("analysis", { 269 | game: gameId, 270 | moveIndex, 271 | analysis, 272 | }); 273 | } 274 | }, 275 | }); 276 | 277 | export const getAnalysis = query({ 278 | args: { gameId: v.id("games"), moveIndex: v.optional(v.number()) }, 279 | handler: async (ctx, { gameId, moveIndex }) => { 280 | const state = await ctx.db.get(gameId); 281 | if (state === null) { 282 | throw new Error("Invalid Game ID"); 283 | } 284 | 285 | let analysis; 286 | if (moveIndex !== undefined) { 287 | analysis = await ctx.db 288 | .query("analysis") 289 | .withIndex("by_game_index", (q) => 290 | q.eq("game", gameId).eq("moveIndex", moveIndex) 291 | ) 292 | .unique(); 293 | } else { 294 | analysis = await ctx.db 295 | .query("analysis") 296 | .withIndex("by_game_index", (q) => q.eq("game", gameId)) 297 | .order("desc") 298 | .first(); 299 | } 300 | 301 | if (analysis === null) { 302 | return null; 303 | } 304 | 305 | const currentPGN = state.pgn; 306 | const game = new Chess(); 307 | game.loadPgn(currentPGN); 308 | const move = game.history()[analysis.moveIndex]; 309 | 310 | return { 311 | analysis: analysis.analysis, 312 | moveIndex: analysis.moveIndex, 313 | move, 314 | }; 315 | }, 316 | }); 317 | 318 | export const move = mutation({ 319 | args: { 320 | gameId: v.id("games"), 321 | from: v.string(), 322 | to: v.string(), 323 | finalPiece: v.string(), 324 | }, 325 | handler: async (ctx, { gameId, from, to, finalPiece }) => { 326 | const userId = await getAuthUserId(ctx); 327 | if (!userId) { 328 | throw new Error("Trying to perform a move with unauthenticated user"); 329 | } 330 | 331 | // Load the game. 332 | let state = await ctx.db.get(gameId); 333 | if (state == null) { 334 | throw new Error(`Invalid game ${gameId}`); 335 | } 336 | await _performMove(ctx, userId, state, from, to, finalPiece); 337 | }, 338 | }); 339 | 340 | export const internalGetPgnForComputerMove = query( 341 | async ({ db }, { id }: { id: Id<"games"> }) => { 342 | let state = await db.get(id); 343 | if (state == null) { 344 | throw new Error(`Invalid game ${id}`); 345 | } 346 | 347 | if (getCurrentPlayer(state) !== "Computer") { 348 | console.log("it's not the computer's turn"); 349 | return null; 350 | } 351 | 352 | const game = new Chess(); 353 | game.loadPgn(state.pgn); 354 | 355 | const possibleMoves = game.moves({ verbose: true }); 356 | if (game.isGameOver() || game.isDraw() || possibleMoves.length === 0) { 357 | console.log("no moves"); 358 | return null; 359 | } 360 | 361 | const opponent = getNextPlayer(state); 362 | 363 | let strategy = "default"; 364 | if (opponent !== "Computer") { 365 | const opponentPlayer = await db.get(opponent as Id<"users">); 366 | const name = opponentPlayer?.name?.toLowerCase(); 367 | if (name?.includes("nipunn")) { 368 | strategy = "tricky"; 369 | } else if (name?.includes("preslav")) { 370 | strategy = "hard"; 371 | } 372 | } 373 | 374 | return [state.pgn, strategy]; 375 | } 376 | ); 377 | 378 | export const internalMakeComputerMove = internalMutation({ 379 | args: { 380 | id: v.id("games"), 381 | moveFrom: v.string(), 382 | moveTo: v.string(), 383 | finalPiece: v.string(), 384 | }, 385 | handler: async (ctx, { id, moveFrom, moveTo, finalPiece }) => { 386 | let state = await ctx.db.get(id); 387 | if (state == null) { 388 | throw new Error(`Invalid game ${id}`); 389 | } 390 | if (getCurrentPlayer(state) !== "Computer") { 391 | return; 392 | } 393 | await _performMove(ctx, "Computer", state, moveFrom, moveTo, finalPiece); 394 | }, 395 | }); 396 | 397 | export const getMoveCount = query({ 398 | args: { move: v.string() }, 399 | returns: v.number(), 400 | handler: async (ctx, { move }) => { 401 | const count = await aggregate.sum(ctx, { 402 | lower: { key: move, inclusive: true }, 403 | upper: { key: move, inclusive: true }, 404 | }); 405 | return count; 406 | }, 407 | }); 408 | -------------------------------------------------------------------------------- /convex/lib/openai.ts: -------------------------------------------------------------------------------- 1 | // That's right! No imports and no dependencies 🤯 2 | 3 | export async function chatCompletion( 4 | body: Omit & { 5 | model?: CreateChatCompletionRequest["model"]; 6 | } 7 | ) { 8 | checkForAPIKey(); 9 | 10 | body.model = body.model ?? "gpt-3.5-turbo"; 11 | body.stream = true; 12 | const stopWords = body.stop 13 | ? typeof body.stop === "string" 14 | ? [body.stop] 15 | : body.stop 16 | : []; 17 | const { 18 | result: resultStream, 19 | retries, 20 | ms, 21 | } = await retryWithBackoff(async () => { 22 | const result = await fetch("https://api.openai.com/v1/chat/completions", { 23 | method: "POST", 24 | headers: { 25 | "Content-Type": "application/json", 26 | Authorization: "Bearer " + process.env.OPENAI_API_KEY, 27 | }, 28 | 29 | body: JSON.stringify(body), 30 | }); 31 | if (!result.ok) { 32 | throw { 33 | retry: result.status === 429 || result.status >= 500, 34 | error: new Error( 35 | `Chat completion failed with code ${ 36 | result.status 37 | }: ${await result.text()}` 38 | ), 39 | }; 40 | } 41 | return result.body!; 42 | }); 43 | return { 44 | content: new ChatCompletionContent(resultStream, stopWords), 45 | retries, 46 | ms, 47 | }; 48 | } 49 | 50 | export async function fetchEmbeddingBatch(texts: string[]) { 51 | checkForAPIKey(); 52 | const { 53 | result: json, 54 | retries, 55 | ms, 56 | } = await retryWithBackoff(async () => { 57 | const result = await fetch("https://api.openai.com/v1/embeddings", { 58 | method: "POST", 59 | headers: { 60 | "Content-Type": "application/json", 61 | Authorization: "Bearer " + process.env.OPENAI_API_KEY, 62 | }, 63 | 64 | body: JSON.stringify({ 65 | model: "text-embedding-ada-002", 66 | input: texts.map((text) => text.replace(/\n/g, " ")), 67 | }), 68 | }); 69 | if (!result.ok) { 70 | throw { 71 | retry: result.status === 429 || result.status >= 500, 72 | error: new Error( 73 | `Embedding failed with code ${result.status}: ${await result.text()}` 74 | ), 75 | }; 76 | } 77 | return (await result.json()) as CreateEmbeddingResponse; 78 | }); 79 | if (json.data.length !== texts.length) { 80 | console.error(json); 81 | throw new Error("Unexpected number of embeddings"); 82 | } 83 | const allembeddings = json.data; 84 | allembeddings.sort((a, b) => b.index - a.index); 85 | return { 86 | embeddings: allembeddings.map(({ embedding }) => embedding), 87 | usage: json.usage.total_tokens, 88 | retries, 89 | ms, 90 | }; 91 | } 92 | 93 | export async function fetchEmbedding(text: string) { 94 | const { embeddings, ...stats } = await fetchEmbeddingBatch([text]); 95 | return { embedding: embeddings[0], ...stats }; 96 | } 97 | 98 | export async function fetchModeration(content: string) { 99 | checkForAPIKey(); 100 | const { result: flagged } = await retryWithBackoff(async () => { 101 | const result = await fetch("https://api.openai.com/v1/moderations", { 102 | method: "POST", 103 | headers: { 104 | "Content-Type": "application/json", 105 | Authorization: "Bearer " + process.env.OPENAI_API_KEY, 106 | }, 107 | 108 | body: JSON.stringify({ 109 | input: content, 110 | }), 111 | }); 112 | if (!result.ok) { 113 | throw { 114 | retry: result.status === 429 || result.status >= 500, 115 | error: new Error( 116 | `Embedding failed with code ${result.status}: ${await result.text()}` 117 | ), 118 | }; 119 | } 120 | return (await result.json()) as { results: { flagged: boolean }[] }; 121 | }); 122 | return flagged; 123 | } 124 | 125 | const checkForAPIKey = () => { 126 | if (!process.env.OPENAI_API_KEY) { 127 | throw new Error( 128 | "Missing OPENAI_API_KEY in environment variables.\n" + 129 | "Set it in the project settings in the Convex dashboard:\n" + 130 | " npx convex dashboard\n or https://dashboard.convex.dev" 131 | ); 132 | } 133 | }; 134 | 135 | // Retry after this much time, based on the retry number. 136 | const RETRY_BACKOFF = [1000, 10_000, 20_000]; // In ms 137 | const RETRY_JITTER = 100; // In ms 138 | type RetryError = { retry: boolean; error: any }; 139 | 140 | export async function retryWithBackoff( 141 | fn: () => Promise 142 | ): Promise<{ retries: number; result: T; ms: number }> { 143 | let i = 0; 144 | for (; i <= RETRY_BACKOFF.length; i++) { 145 | try { 146 | const start = Date.now(); 147 | const result = await fn(); 148 | const ms = Date.now() - start; 149 | return { result, retries: i, ms }; 150 | } catch (e) { 151 | const retryError = e as RetryError; 152 | if (i < RETRY_BACKOFF.length) { 153 | if (retryError.retry) { 154 | console.log( 155 | `Attempt ${i + 1} failed, waiting ${ 156 | RETRY_BACKOFF[i] 157 | }ms to retry...`, 158 | Date.now() 159 | ); 160 | await new Promise((resolve) => 161 | setTimeout(resolve, RETRY_BACKOFF[i] + RETRY_JITTER * Math.random()) 162 | ); 163 | continue; 164 | } 165 | } 166 | if (retryError.error) throw retryError.error; 167 | else throw e; 168 | } 169 | } 170 | throw new Error("Unreachable"); 171 | } 172 | 173 | // Lifted from openai's package 174 | export interface LLMMessage { 175 | /** 176 | * The contents of the message. `content` is required for all messages, and may be 177 | * null for assistant messages with function calls. 178 | */ 179 | content: string | null; 180 | 181 | /** 182 | * The role of the messages author. One of `system`, `user`, `assistant`, or 183 | * `function`. 184 | */ 185 | role: "system" | "user" | "assistant" | "function"; 186 | 187 | /** 188 | * The name of the author of this message. `name` is required if role is 189 | * `function`, and it should be the name of the function whose response is in the 190 | * `content`. May contain a-z, A-Z, 0-9, and underscores, with a maximum length of 191 | * 64 characters. 192 | */ 193 | name?: string; 194 | 195 | /** 196 | * The name and arguments of a function that should be called, as generated by the model. 197 | */ 198 | function_call?: { 199 | // The name of the function to call. 200 | name: string; 201 | /** 202 | * The arguments to call the function with, as generated by the model in 203 | * JSON format. Note that the model does not always generate valid JSON, 204 | * and may hallucinate parameters not defined by your function schema. 205 | * Validate the arguments in your code before calling your function. 206 | */ 207 | arguments: string; 208 | }; 209 | } 210 | 211 | interface CreateEmbeddingResponse { 212 | data: { 213 | index: number; 214 | object: string; 215 | embedding: number[]; 216 | }[]; 217 | model: string; 218 | object: string; 219 | usage: { 220 | prompt_tokens: number; 221 | total_tokens: number; 222 | }; 223 | } 224 | 225 | export interface CreateChatCompletionRequest { 226 | /** 227 | * ID of the model to use. 228 | * @type {string} 229 | * @memberof CreateChatCompletionRequest 230 | */ 231 | model: 232 | | "gpt-4" 233 | | "gpt-4-0613" 234 | | "gpt-4-32k" 235 | | "gpt-4-32k-0613" 236 | | "gpt-3.5-turbo" // <- our default 237 | | "gpt-3.5-turbo-0613" 238 | | "gpt-3.5-turbo-16k" 239 | | "gpt-3.5-turbo-16k-0613"; 240 | /** 241 | * The messages to generate chat completions for, in the chat format: 242 | * https://platform.openai.com/docs/guides/chat/introduction 243 | * @type {Array} 244 | * @memberof CreateChatCompletionRequest 245 | */ 246 | messages: LLMMessage[]; 247 | /** 248 | * What sampling temperature to use, between 0 and 2. Higher values like 0.8 will make the output more random, while lower values like 0.2 will make it more focused and deterministic. We generally recommend altering this or `top_p` but not both. 249 | * @type {number} 250 | * @memberof CreateChatCompletionRequest 251 | */ 252 | temperature?: number | null; 253 | /** 254 | * An alternative to sampling with temperature, called nucleus sampling, where the model considers the results of the tokens with top_p probability mass. So 0.1 means only the tokens comprising the top 10% probability mass are considered. We generally recommend altering this or `temperature` but not both. 255 | * @type {number} 256 | * @memberof CreateChatCompletionRequest 257 | */ 258 | top_p?: number | null; 259 | /** 260 | * How many chat completion choices to generate for each input message. 261 | * @type {number} 262 | * @memberof CreateChatCompletionRequest 263 | */ 264 | n?: number | null; 265 | /** 266 | * If set, partial message deltas will be sent, like in ChatGPT. Tokens will be sent as data-only [server-sent events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events#Event_stream_format) as they become available, with the stream terminated by a `data: [DONE]` message. 267 | * @type {boolean} 268 | * @memberof CreateChatCompletionRequest 269 | */ 270 | stream?: boolean | null; 271 | /** 272 | * 273 | * @type {CreateChatCompletionRequestStop} 274 | * @memberof CreateChatCompletionRequest 275 | */ 276 | stop?: Array | string; 277 | /** 278 | * The maximum number of tokens allowed for the generated answer. By default, 279 | * the number of tokens the model can return will be (4096 - prompt tokens). 280 | * @type {number} 281 | * @memberof CreateChatCompletionRequest 282 | */ 283 | max_tokens?: number; 284 | /** 285 | * Number between -2.0 and 2.0. Positive values penalize new tokens based on 286 | * whether they appear in the text so far, increasing the model\'s likelihood 287 | * to talk about new topics. See more information about frequency and 288 | * presence penalties: 289 | * https://platform.openai.com/docs/api-reference/parameter-details 290 | * @type {number} 291 | * @memberof CreateChatCompletionRequest 292 | */ 293 | presence_penalty?: number | null; 294 | /** 295 | * Number between -2.0 and 2.0. Positive values penalize new tokens based on 296 | * their existing frequency in the text so far, decreasing the model\'s 297 | * likelihood to repeat the same line verbatim. See more information about 298 | * presence penalties: 299 | * https://platform.openai.com/docs/api-reference/parameter-details 300 | * @type {number} 301 | * @memberof CreateChatCompletionRequest 302 | */ 303 | frequency_penalty?: number | null; 304 | /** 305 | * Modify the likelihood of specified tokens appearing in the completion. 306 | * Accepts a json object that maps tokens (specified by their token ID in the 307 | * tokenizer) to an associated bias value from -100 to 100. Mathematically, 308 | * the bias is added to the logits generated by the model prior to sampling. 309 | * The exact effect will vary per model, but values between -1 and 1 should 310 | * decrease or increase likelihood of selection; values like -100 or 100 311 | * should result in a ban or exclusive selection of the relevant token. 312 | * @type {object} 313 | * @memberof CreateChatCompletionRequest 314 | */ 315 | logit_bias?: object | null; 316 | /** 317 | * A unique identifier representing your end-user, which can help OpenAI to 318 | * monitor and detect abuse. Learn more: 319 | * https://platform.openai.com/docs/guides/safety-best-practices/end-user-ids 320 | * @type {string} 321 | * @memberof CreateChatCompletionRequest 322 | */ 323 | user?: string; 324 | functions?: { 325 | /** 326 | * The name of the function to be called. Must be a-z, A-Z, 0-9, or 327 | * contain underscores and dashes, with a maximum length of 64. 328 | */ 329 | name: string; 330 | /** 331 | * A description of what the function does, used by the model to choose 332 | * when and how to call the function. 333 | */ 334 | description?: string; 335 | /** 336 | * The parameters the functions accepts, described as a JSON Schema 337 | * object. See the guide[1] for examples, and the JSON Schema reference[2] 338 | * for documentation about the format. 339 | * [1]: https://platform.openai.com/docs/guides/gpt/function-calling 340 | * [2]: https://json-schema.org/understanding-json-schema/ 341 | * To describe a function that accepts no parameters, provide the value 342 | * {"type": "object", "properties": {}}. 343 | */ 344 | parameters: object; 345 | }[]; 346 | /** 347 | * Controls how the model responds to function calls. "none" means the model 348 | * does not call a function, and responds to the end-user. "auto" means the 349 | * model can pick between an end-user or calling a function. Specifying a 350 | * particular function via {"name":\ "my_function"} forces the model to call 351 | * that function. 352 | * - "none" is the default when no functions are present. 353 | * - "auto" is the default if functions are present. 354 | */ 355 | function_call?: "none" | "auto" | { name: string }; 356 | } 357 | 358 | // Checks whether a suffix of s1 is a prefix of s2. For example, 359 | // ('Hello', 'Kira:') -> false 360 | // ('Hello Kira', 'Kira:') -> true 361 | const suffixOverlapsPrefix = (s1: string, s2: string) => { 362 | for (let i = 1; i <= Math.min(s1.length, s2.length); i++) { 363 | const suffix = s1.substring(s1.length - i); 364 | const prefix = s2.substring(0, i); 365 | if (suffix === prefix) { 366 | return true; 367 | } 368 | } 369 | return false; 370 | }; 371 | 372 | export class ChatCompletionContent { 373 | private readonly body: ReadableStream; 374 | private readonly stopWords: string[]; 375 | 376 | constructor(body: ReadableStream, stopWords: string[]) { 377 | this.body = body; 378 | this.stopWords = stopWords; 379 | } 380 | 381 | async *readInner() { 382 | for await (const data of this.splitStream(this.body)) { 383 | if (data.startsWith("data: ")) { 384 | try { 385 | const json = JSON.parse(data.substring("data: ".length)) as { 386 | choices: { delta: { content?: string } }[]; 387 | }; 388 | if (json.choices[0].delta.content) { 389 | yield json.choices[0].delta.content; 390 | } 391 | } catch (e) { 392 | // e.g. the last chunk is [DONE] which is not valid JSON. 393 | } 394 | } 395 | } 396 | } 397 | 398 | // stop words in OpenAI api don't always work. 399 | // So we have to truncate on our side. 400 | async *read() { 401 | let lastFragment = ""; 402 | for await (const data of this.readInner()) { 403 | lastFragment += data; 404 | let hasOverlap = false; 405 | for (const stopWord of this.stopWords) { 406 | const idx = lastFragment.indexOf(stopWord); 407 | if (idx >= 0) { 408 | yield lastFragment.substring(0, idx); 409 | return; 410 | } 411 | if (suffixOverlapsPrefix(lastFragment, stopWord)) { 412 | hasOverlap = true; 413 | } 414 | } 415 | if (hasOverlap) continue; 416 | yield lastFragment; 417 | lastFragment = ""; 418 | } 419 | yield lastFragment; 420 | } 421 | 422 | async readAll() { 423 | let allContent = ""; 424 | for await (const chunk of this.read()) { 425 | allContent += chunk; 426 | } 427 | return allContent; 428 | } 429 | 430 | async *splitStream(stream: ReadableStream) { 431 | const reader = stream.getReader(); 432 | let lastFragment = ""; 433 | try { 434 | while (true) { 435 | const { value, done } = await reader.read(); 436 | if (done) { 437 | // Flush the last fragment now that we're done 438 | if (lastFragment !== "") { 439 | yield lastFragment; 440 | } 441 | break; 442 | } 443 | const data = new TextDecoder().decode(value); 444 | lastFragment += data; 445 | const parts = lastFragment.split("\n\n"); 446 | // Yield all except for the last part 447 | for (let i = 0; i < parts.length - 1; i += 1) { 448 | yield parts[i]; 449 | } 450 | // Save the last part as the new last fragment 451 | lastFragment = parts[parts.length - 1]; 452 | } 453 | } finally { 454 | reader.releaseLock(); 455 | } 456 | } 457 | } 458 | --------------------------------------------------------------------------------