diff --git a/.prettierrc.json b/.prettierrc.json index 650cb88..dc6958f 100644 --- a/.prettierrc.json +++ b/.prettierrc.json @@ -1,4 +1,4 @@ { - "singleQuote": true, + "singleQuote": false, "semi": true } diff --git a/common.tsx b/common.tsx index 2e20f84..c63c1e1 100644 --- a/common.tsx +++ b/common.tsx @@ -2,37 +2,51 @@ import Link from "next/link"; import { Game } from "./convex/search"; import { Id } from "./convex/_generated/dataModel"; -function getProfileLink(className: string, name: string, id: string | Id<"users"> | null) { +function getProfileLink( + className: string, + name: string, + id: string | Id<"users"> | null +) { if (!id) { return <>; } if (typeof id == "string") { - return {name} + return {name}; } else { - return {name} + return ( + + {name} + + ); } } export function gameTitle(state: Game) { - const player1Span = getProfileLink("whitePlayer", state.player1Name, state.player1); - const player2Span = getProfileLink("blackPlayer", state.player2Name, state.player2); - const context = state.resultContext; - if (state.player1Name && state.player2Name) { - return ( + const player1Span = getProfileLink( + "whitePlayer", + state.player1Name, + state.player1 + ); + const player2Span = getProfileLink( + "blackPlayer", + state.player2Name, + state.player2 + ); + const context = state.resultContext; + if (state.player1Name && state.player2Name) { + return ( +
-
{player1Span} vs {player2Span}
- {context && ( -
- {context} -
- )} + {player1Span} vs {player2Span}
- ) - } else if (state.player1Name) { - return player1Span; - } else if (state.player2Name) { - return player2Span; - } else { - return
; - } + {context &&
{context}
} +
+ ); + } else if (state.player1Name) { + return player1Span; + } else if (state.player2Name) { + return player2Span; + } else { + return
; + } } diff --git a/convex/auth.config.js b/convex/auth.config.js index a62b513..54bbc45 100644 --- a/convex/auth.config.js +++ b/convex/auth.config.js @@ -1,8 +1,8 @@ - export default { - providers: [ +export default { + providers: [ { - "domain": "https://convexdev.us.auth0.com/", - "applicationID": "tPilWp4HDDF4yoi3D995XMrVIJhflD4V" - } + domain: "https://convexdev.us.auth0.com/", + applicationID: "tPilWp4HDDF4yoi3D995XMrVIJhflD4V", + }, ], - }; \ No newline at end of file +}; diff --git a/convex/engine.ts b/convex/engine.ts index be93ace..f8f4616 100644 --- a/convex/engine.ts +++ b/convex/engine.ts @@ -1,45 +1,45 @@ -'use node' +"use node"; -import { api, internal } from './_generated/api' -import { Id } from './_generated/dataModel' -import { internalAction } from './_generated/server' -const jsChessEngine = require('js-chess-engine') -import { Chess } from 'chess.js' +import { api, internal } from "./_generated/api"; +import { Id } from "./_generated/dataModel"; +import { internalAction } from "./_generated/server"; +const jsChessEngine = require("js-chess-engine"); +import { Chess } from "chess.js"; export const maybeMakeComputerMove = internalAction( - async (ctx, { id }: { id: Id<'games'> }) => { - const { runQuery, runMutation } = ctx + async (ctx, { id }: { id: Id<"games"> }) => { + const { runQuery, runMutation } = ctx; const state = await runQuery(api.games.internalGetPgnForComputerMove, { id, - }) + }); if (state === null) { - return + return; } - const [pgn, strategy] = state - const gameState = new Chess() - gameState.loadPgn(pgn) - const moveNumber = gameState.history().length - const game = new jsChessEngine.Game(gameState.fen()) - let level = 1 - if (strategy === 'hard') { - level = 2 - } else if (strategy === 'tricky') { + const [pgn, strategy] = state; + const gameState = new Chess(); + gameState.loadPgn(pgn); + const moveNumber = gameState.history().length; + const game = new jsChessEngine.Game(gameState.fen()); + let level = 1; + if (strategy === "hard") { + level = 2; + } else if (strategy === "tricky") { if (moveNumber > 6) { - level = 2 + level = 2; if (moveNumber % 3 === 0) { - level = 3 + level = 3; } } } - const aiMove = game.aiMove(level) + const aiMove = game.aiMove(level); // aiMove has format {moveFrom: moveTo} - let moveFrom = Object.keys(aiMove)[0] - let moveTo = aiMove[moveFrom] - console.log(`move at level ${level}: ${moveFrom}->${moveTo}`) + let moveFrom = Object.keys(aiMove)[0]; + let moveTo = aiMove[moveFrom]; + console.log(`move at level ${level}: ${moveFrom}->${moveTo}`); await runMutation(internal.games.internalMakeComputerMove, { id, moveFrom: moveFrom.toLowerCase(), moveTo: moveTo.toLowerCase(), - }) + }); } -) +); diff --git a/convex/games.ts b/convex/games.ts index ebd31b2..4456119 100644 --- a/convex/games.ts +++ b/convex/games.ts @@ -1,4 +1,4 @@ -import { api, internal } from './_generated/api'; +import { api, internal } from "./_generated/api"; import { query, mutation, @@ -7,32 +7,32 @@ import { internalMutation, internalAction, internalQuery, -} from './_generated/server'; -import { Id, Doc } from './_generated/dataModel'; +} from "./_generated/server"; +import { Id, Doc } from "./_generated/dataModel"; import { getCurrentPlayer, validateMove, PlayerId, getNextPlayer, -} from './utils'; +} from "./utils"; -import { Chess, Move } from 'chess.js'; -import { getOrCreateUser } from './users'; -import { Scheduler } from 'convex/server'; -import { ConvexError, v } from 'convex/values'; -import { chatCompletion } from './lib/openai'; +import { Chess, Move } from "chess.js"; +import { getOrCreateUser } from "./users"; +import { Scheduler } from "convex/server"; +import { ConvexError, v } from "convex/values"; +import { chatCompletion } from "./lib/openai"; async function playerName( db: DatabaseReader, - playerId: 'Computer' | Id<'users'> | null + playerId: "Computer" | Id<"users"> | null ) { if (playerId === null) { - return ''; - } else if (playerId == 'Computer') { + return ""; + } else if (playerId == "Computer") { return playerId; } else { - const user = await db.get(playerId as Id<'users'>); + const user = await db.get(playerId as Id<"users">); if (user === null) { throw new Error(`Missing player id ${playerId}`); } @@ -42,7 +42,7 @@ async function playerName( export async function denormalizePlayerNames( db: DatabaseReader, - game: Doc<'games'> + game: Doc<"games"> ) { return { ...game, @@ -51,7 +51,7 @@ export async function denormalizePlayerNames( }; } -export const get = query(async ({ db }, { id }: { id: Id<'games'> }) => { +export const get = query(async ({ db }, { id }: { id: Id<"games"> }) => { const game = await db.get(id); if (!game) { throw new Error(`Invalid game ${id}`); @@ -61,9 +61,9 @@ export const get = query(async ({ db }, { id }: { id: Id<'games'> }) => { export const ongoingGames = query(async ({ db }) => { const games = await db - .query('games') - .withIndex('finished', (q) => q.eq('finished', false)) - .order('desc') + .query("games") + .withIndex("finished", (q) => q.eq("finished", false)) + .order("desc") .take(50); const result = []; for (let game of games) { @@ -79,13 +79,13 @@ export const newGame = mutation( player1, player2, }: { - player1: null | 'Computer' | 'Me'; - player2: null | 'Computer' | 'Me'; + player1: null | "Computer" | "Me"; + player2: null | "Computer" | "Me"; } ) => { const userId = await getOrCreateUser(db, auth); let player1Id: PlayerId; - if (player1 === 'Me') { + if (player1 === "Me") { if (!userId) { throw new Error("Can't play as unauthenticated user"); } @@ -94,7 +94,7 @@ export const newGame = mutation( player1Id = player1; } let player2Id: PlayerId; - if (player2 === 'Me') { + if (player2 === "Me") { if (!userId) { throw new Error("Can't play as unauthenticated user"); } @@ -104,7 +104,7 @@ export const newGame = mutation( } const game = new Chess(); - let id: Id<'games'> = await db.insert('games', { + let id: Id<"games"> = await db.insert("games", { pgn: game.pgn(), player1: player1Id, player2: player2Id, @@ -118,10 +118,10 @@ export const newGame = mutation( ); export const joinGame = mutation( - async ({ db, auth }, { id }: { id: Id<'games'> }) => { + async ({ db, auth }, { id }: { id: Id<"games"> }) => { const user = await getOrCreateUser(db, auth); if (!user) { - throw new Error('Trying to join game with unauthenticated user'); + throw new Error("Trying to join game with unauthenticated user"); } let state = await db.get(id); if (state == null) { @@ -144,7 +144,7 @@ async function _performMove( db: DatabaseWriter, player: PlayerId, scheduler: Scheduler, - state: Doc<'games'>, + state: Doc<"games">, from: string, to: string ) { @@ -190,13 +190,13 @@ async function _performMove( const boardView = (chess: Chess): string => { const rows = []; for (const row of chess.board()) { - let rowView = ''; + let rowView = ""; for (const square of row) { if (square === null) { - rowView += '.'; + rowView += "."; } else { let piece = square.type as string; - if (square.color === 'w') { + if (square.color === "w") { piece = piece.toUpperCase(); } rowView += piece; @@ -204,12 +204,12 @@ const boardView = (chess: Chess): string => { } rows.push(rowView); } - return rows.join('\n'); + return rows.join("\n"); }; export const analyzeMove = internalAction({ args: { - gameId: v.id('games'), + gameId: v.id("games"), moveIndex: v.number(), previousPGN: v.string(), move: v.string(), @@ -224,12 +224,12 @@ export const analyzeMove = internalAction({ const response = await chatCompletion({ messages: [ { - role: 'user', + role: "user", content: prompt, }, ], }); - let responseText = ''; + let responseText = ""; for await (const chunk of response.content.read()) { responseText += chunk; @@ -245,15 +245,15 @@ export const analyzeMove = internalAction({ export const saveAnalysis = internalMutation({ args: { - gameId: v.id('games'), + gameId: v.id("games"), moveIndex: v.number(), analysis: v.string(), }, handler: async (ctx, { gameId, moveIndex, analysis }) => { const analysisDoc = await ctx.db - .query('analysis') - .withIndex('by_game_index', (q) => - q.eq('game', gameId).eq('moveIndex', moveIndex) + .query("analysis") + .withIndex("by_game_index", (q) => + q.eq("game", gameId).eq("moveIndex", moveIndex) ) .unique(); if (analysisDoc) { @@ -261,7 +261,7 @@ export const saveAnalysis = internalMutation({ analysis, }); } else { - await ctx.db.insert('analysis', { + await ctx.db.insert("analysis", { game: gameId, moveIndex, analysis, @@ -271,26 +271,26 @@ export const saveAnalysis = internalMutation({ }); export const getAnalysis = query({ - args: { gameId: v.id('games'), moveIndex: v.optional(v.number()) }, + args: { gameId: v.id("games"), moveIndex: v.optional(v.number()) }, handler: async (ctx, { gameId, moveIndex }) => { const state = await ctx.db.get(gameId); if (state === null) { - throw new Error('Invalid Game ID'); + throw new Error("Invalid Game ID"); } let analysis; if (moveIndex !== undefined) { analysis = await ctx.db - .query('analysis') - .withIndex('by_game_index', (q) => - q.eq('game', gameId).eq('moveIndex', moveIndex) + .query("analysis") + .withIndex("by_game_index", (q) => + q.eq("game", gameId).eq("moveIndex", moveIndex) ) .unique(); } else { analysis = await ctx.db - .query('analysis') - .withIndex('by_game_index', (q) => q.eq('game', gameId)) - .order('desc') + .query("analysis") + .withIndex("by_game_index", (q) => q.eq("game", gameId)) + .order("desc") .first(); } @@ -314,11 +314,11 @@ export const getAnalysis = query({ export const move = mutation( async ( { db, auth, scheduler }, - { gameId, from, to }: { gameId: Id<'games'>; from: string; to: string } + { gameId, from, to }: { gameId: Id<"games">; from: string; to: string } ) => { const userId = await getOrCreateUser(db, auth); if (!userId) { - throw new Error('Trying to perform a move with unauthenticated user'); + throw new Error("Trying to perform a move with unauthenticated user"); } // Load the game. @@ -332,13 +332,13 @@ export const move = mutation( ); export const internalGetPgnForComputerMove = query( - async ({ db }, { id }: { id: Id<'games'> }) => { + async ({ db }, { id }: { id: Id<"games"> }) => { let state = await db.get(id); if (state == null) { throw new Error(`Invalid game ${id}`); } - if (getCurrentPlayer(state) !== 'Computer') { + if (getCurrentPlayer(state) !== "Computer") { console.log("it's not the computer's turn"); return null; } @@ -348,20 +348,20 @@ export const internalGetPgnForComputerMove = query( const possibleMoves = game.moves({ verbose: true }); if (game.isGameOver() || game.isDraw() || possibleMoves.length === 0) { - console.log('no moves'); + console.log("no moves"); return null; } const opponent = getNextPlayer(state); - let strategy = 'default'; - if (opponent !== 'Computer') { - const opponentPlayer = await db.get(opponent as Id<'users'>); + let strategy = "default"; + if (opponent !== "Computer") { + const opponentPlayer = await db.get(opponent as Id<"users">); const name = opponentPlayer!.name.toLowerCase(); - if (name.includes('nipunn')) { - strategy = 'tricky'; - } else if (name.includes('preslav')) { - strategy = 'hard'; + if (name.includes("nipunn")) { + strategy = "tricky"; + } else if (name.includes("preslav")) { + strategy = "hard"; } } @@ -377,7 +377,7 @@ export const internalMakeComputerMove = internalMutation( moveFrom, moveTo, }: { - id: Id<'games'>; + id: Id<"games">; moveFrom: string; moveTo: string; } @@ -386,9 +386,9 @@ export const internalMakeComputerMove = internalMutation( if (state == null) { throw new Error(`Invalid game ${id}`); } - if (getCurrentPlayer(state) !== 'Computer') { + if (getCurrentPlayer(state) !== "Computer") { return; } - await _performMove(db, 'Computer', scheduler, state, moveFrom, moveTo); + await _performMove(db, "Computer", scheduler, state, moveFrom, moveTo); } ); diff --git a/convex/lib/openai.ts b/convex/lib/openai.ts index d13840f..6cdf7ef 100644 --- a/convex/lib/openai.ts +++ b/convex/lib/openai.ts @@ -1,25 +1,29 @@ // That's right! No imports and no dependencies 🤯 export async function chatCompletion( - body: Omit & { - model?: CreateChatCompletionRequest['model']; - }, + body: Omit & { + model?: CreateChatCompletionRequest["model"]; + } ) { checkForAPIKey(); - body.model = body.model ?? 'gpt-3.5-turbo-16k'; + body.model = body.model ?? "gpt-3.5-turbo-16k"; body.stream = true; - const stopWords = body.stop ? (typeof body.stop === 'string' ? [body.stop] : body.stop) : []; + const stopWords = body.stop + ? typeof body.stop === "string" + ? [body.stop] + : body.stop + : []; const { result: resultStream, retries, ms, } = await retryWithBackoff(async () => { - const result = await fetch('https://api.openai.com/v1/chat/completions', { - method: 'POST', + const result = await fetch("https://api.openai.com/v1/chat/completions", { + method: "POST", headers: { - 'Content-Type': 'application/json', - Authorization: 'Bearer ' + process.env.OPENAI_API_KEY, + "Content-Type": "application/json", + Authorization: "Bearer " + process.env.OPENAI_API_KEY, }, body: JSON.stringify(body), @@ -28,7 +32,9 @@ export async function chatCompletion( throw { retry: result.status === 429 || result.status >= 500, error: new Error( - `Chat completion failed with code ${result.status}: ${await result.text()}`, + `Chat completion failed with code ${ + result.status + }: ${await result.text()}` ), }; } @@ -48,29 +54,31 @@ export async function fetchEmbeddingBatch(texts: string[]) { retries, ms, } = await retryWithBackoff(async () => { - const result = await fetch('https://api.openai.com/v1/embeddings', { - method: 'POST', + const result = await fetch("https://api.openai.com/v1/embeddings", { + method: "POST", headers: { - 'Content-Type': 'application/json', - Authorization: 'Bearer ' + process.env.OPENAI_API_KEY, + "Content-Type": "application/json", + Authorization: "Bearer " + process.env.OPENAI_API_KEY, }, body: JSON.stringify({ - model: 'text-embedding-ada-002', - input: texts.map((text) => text.replace(/\n/g, ' ')), + model: "text-embedding-ada-002", + input: texts.map((text) => text.replace(/\n/g, " ")), }), }); if (!result.ok) { throw { retry: result.status === 429 || result.status >= 500, - error: new Error(`Embedding failed with code ${result.status}: ${await result.text()}`), + error: new Error( + `Embedding failed with code ${result.status}: ${await result.text()}` + ), }; } return (await result.json()) as CreateEmbeddingResponse; }); if (json.data.length !== texts.length) { console.error(json); - throw new Error('Unexpected number of embeddings'); + throw new Error("Unexpected number of embeddings"); } const allembeddings = json.data; allembeddings.sort((a, b) => b.index - a.index); @@ -90,11 +98,11 @@ export async function fetchEmbedding(text: string) { export async function fetchModeration(content: string) { checkForAPIKey(); const { result: flagged } = await retryWithBackoff(async () => { - const result = await fetch('https://api.openai.com/v1/moderations', { - method: 'POST', + const result = await fetch("https://api.openai.com/v1/moderations", { + method: "POST", headers: { - 'Content-Type': 'application/json', - Authorization: 'Bearer ' + process.env.OPENAI_API_KEY, + "Content-Type": "application/json", + Authorization: "Bearer " + process.env.OPENAI_API_KEY, }, body: JSON.stringify({ @@ -104,7 +112,9 @@ export async function fetchModeration(content: string) { if (!result.ok) { throw { retry: result.status === 429 || result.status >= 500, - error: new Error(`Embedding failed with code ${result.status}: ${await result.text()}`), + error: new Error( + `Embedding failed with code ${result.status}: ${await result.text()}` + ), }; } return (await result.json()) as { results: { flagged: boolean }[] }; @@ -115,9 +125,9 @@ export async function fetchModeration(content: string) { const checkForAPIKey = () => { if (!process.env.OPENAI_API_KEY) { throw new Error( - 'Missing OPENAI_API_KEY in environment variables.\n' + - 'Set it in the project settings in the Convex dashboard:\n' + - ' npx convex dashboard\n or https://dashboard.convex.dev', + "Missing OPENAI_API_KEY in environment variables.\n" + + "Set it in the project settings in the Convex dashboard:\n" + + " npx convex dashboard\n or https://dashboard.convex.dev" ); } }; @@ -128,7 +138,7 @@ const RETRY_JITTER = 100; // In ms type RetryError = { retry: boolean; error: any }; export async function retryWithBackoff( - fn: () => Promise, + fn: () => Promise ): Promise<{ retries: number; result: T; ms: number }> { let i = 0; for (; i <= RETRY_BACKOFF.length; i++) { @@ -142,11 +152,13 @@ export async function retryWithBackoff( if (i < RETRY_BACKOFF.length) { if (retryError.retry) { console.log( - `Attempt ${i + 1} failed, waiting ${RETRY_BACKOFF[i]}ms to retry...`, - Date.now(), + `Attempt ${i + 1} failed, waiting ${ + RETRY_BACKOFF[i] + }ms to retry...`, + Date.now() ); await new Promise((resolve) => - setTimeout(resolve, RETRY_BACKOFF[i] + RETRY_JITTER * Math.random()), + setTimeout(resolve, RETRY_BACKOFF[i] + RETRY_JITTER * Math.random()) ); continue; } @@ -155,7 +167,7 @@ export async function retryWithBackoff( else throw e; } } - throw new Error('Unreachable'); + throw new Error("Unreachable"); } // Lifted from openai's package @@ -170,7 +182,7 @@ export interface LLMMessage { * The role of the messages author. One of `system`, `user`, `assistant`, or * `function`. */ - role: 'system' | 'user' | 'assistant' | 'function'; + role: "system" | "user" | "assistant" | "function"; /** * The name of the author of this message. `name` is required if role is @@ -217,14 +229,14 @@ export interface CreateChatCompletionRequest { * @memberof CreateChatCompletionRequest */ model: - | 'gpt-4' - | 'gpt-4-0613' - | 'gpt-4-32k' - | 'gpt-4-32k-0613' - | 'gpt-3.5-turbo' - | 'gpt-3.5-turbo-0613' - | 'gpt-3.5-turbo-16k' // <- our default - | 'gpt-3.5-turbo-16k-0613'; + | "gpt-4" + | "gpt-4-0613" + | "gpt-4-32k" + | "gpt-4-32k-0613" + | "gpt-3.5-turbo" + | "gpt-3.5-turbo-0613" + | "gpt-3.5-turbo-16k" // <- our default + | "gpt-3.5-turbo-16k-0613"; /** * The messages to generate chat completions for, in the chat format: * https://platform.openai.com/docs/guides/chat/introduction @@ -340,7 +352,7 @@ export interface CreateChatCompletionRequest { * - "none" is the default when no functions are present. * - "auto" is the default if functions are present. */ - function_call?: 'none' | 'auto' | { name: string }; + function_call?: "none" | "auto" | { name: string }; } // Checks whether a suffix of s1 is a prefix of s2. For example, @@ -368,9 +380,9 @@ export class ChatCompletionContent { async *readInner() { for await (const data of this.splitStream(this.body)) { - if (data.startsWith('data: ')) { + if (data.startsWith("data: ")) { try { - const json = JSON.parse(data.substring('data: '.length)) as { + const json = JSON.parse(data.substring("data: ".length)) as { choices: { delta: { content?: string } }[]; }; if (json.choices[0].delta.content) { @@ -386,7 +398,7 @@ export class ChatCompletionContent { // stop words in OpenAI api don't always work. // So we have to truncate on our side. async *read() { - let lastFragment = ''; + let lastFragment = ""; for await (const data of this.readInner()) { lastFragment += data; let hasOverlap = false; @@ -402,13 +414,13 @@ export class ChatCompletionContent { } if (hasOverlap) continue; yield lastFragment; - lastFragment = ''; + lastFragment = ""; } yield lastFragment; } async readAll() { - let allContent = ''; + let allContent = ""; for await (const chunk of this.read()) { allContent += chunk; } @@ -417,26 +429,26 @@ export class ChatCompletionContent { async *splitStream(stream: ReadableStream) { const reader = stream.getReader(); - let lastFragment = ''; + let lastFragment = ""; try { while (true) { const { value, done } = await reader.read(); if (done) { // Flush the last fragment now that we're done if (lastFragment !== "") { - yield lastFragment + yield lastFragment; } - break + break; } const data = new TextDecoder().decode(value); lastFragment += data; - const parts = lastFragment.split("\n\n") + const parts = lastFragment.split("\n\n"); // Yield all except for the last part for (let i = 0; i < parts.length - 1; i += 1) { - yield parts[i] + yield parts[i]; } // Save the last part as the new last fragment - lastFragment = parts[parts.length - 1] + lastFragment = parts[parts.length - 1]; } } finally { reader.releaseLock(); diff --git a/convex/schema.ts b/convex/schema.ts index 323a3c4..d308915 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -1,27 +1,27 @@ -import { defineSchema, defineTable } from 'convex/server' -import { v } from 'convex/values' +import { defineSchema, defineTable } from "convex/server"; +import { v } from "convex/values"; export default defineSchema({ games: defineTable({ pgn: v.string(), - player1: v.union(v.id('users'), v.literal('Computer'), v.null()), - player2: v.union(v.id('users'), v.literal('Computer'), v.null()), + player1: v.union(v.id("users"), v.literal("Computer"), v.null()), + player2: v.union(v.id("users"), v.literal("Computer"), v.null()), finished: v.boolean(), }) - .index('finished', ['finished']) - .searchIndex('search_pgn', { searchField: 'pgn' }), + .index("finished", ["finished"]) + .searchIndex("search_pgn", { searchField: "pgn" }), analysis: defineTable({ game: v.id("games"), moveIndex: v.number(), analysis: v.string(), }) - .index('by_game_index', ['game', 'moveIndex']) - .searchIndex('search_analysis', { searchField: 'analysis' }), + .index("by_game_index", ["game", "moveIndex"]) + .searchIndex("search_analysis", { searchField: "analysis" }), users: defineTable({ name: v.string(), tokenIdentifier: v.string(), profilePic: v.optional(v.union(v.string(), v.null())), }) - .index('by_token', ['tokenIdentifier']) - .searchIndex('search_name', { searchField: 'name' }), -}) + .index("by_token", ["tokenIdentifier"]) + .searchIndex("search_name", { searchField: "name" }), +}); diff --git a/convex/search.ts b/convex/search.ts index ff2e5b9..b7c1672 100644 --- a/convex/search.ts +++ b/convex/search.ts @@ -1,8 +1,8 @@ -import { denormalizePlayerNames } from './games'; -import { Doc } from './_generated/dataModel'; -import { query } from './_generated/server'; +import { denormalizePlayerNames } from "./games"; +import { Doc } from "./_generated/dataModel"; +import { query } from "./_generated/server"; -export interface Game extends Doc<'games'> { +export interface Game extends Doc<"games"> { player1Name: string; player2Name: string; moveIndex?: number; @@ -10,22 +10,22 @@ export interface Game extends Doc<'games'> { } type SearchResult = { - users: Doc<'users'>[]; + users: Doc<"users">[]; games: Game[]; }; export default query(async ({ db }, { query }: { query: string }) => { const users = await db - .query('users') - .withSearchIndex('search_name', (q) => q.search('name', query)) + .query("users") + .withSearchIndex("search_name", (q) => q.search("name", query)) .collect(); const games = await db - .query('games') - .withSearchIndex('search_pgn', (q) => q.search('pgn', query)) + .query("games") + .withSearchIndex("search_pgn", (q) => q.search("pgn", query)) .take(5); const analyses = await db - .query('analysis') - .withSearchIndex('search_analysis', (q) => q.search('analysis', query)) + .query("analysis") + .withSearchIndex("search_analysis", (q) => q.search("analysis", query)) .take(5); let denormalizedGames = []; for (const game of games) { diff --git a/convex/users.ts b/convex/users.ts index be2ef81..d216e6b 100644 --- a/convex/users.ts +++ b/convex/users.ts @@ -1,96 +1,92 @@ -import { v } from 'convex/values' -import { - DatabaseWriter, - mutation, - query, -} from './_generated/server' +import { v } from "convex/values"; +import { DatabaseWriter, mutation, query } from "./_generated/server"; export const getOrCreateUser = async (db: DatabaseWriter, auth: any) => { - const identity = await auth.getUserIdentity() + const identity = await auth.getUserIdentity(); if (!identity) { - return null + return null; } // Check if we've already stored this identity before. const user = await db - .query('users') - .withIndex('by_token', (q) => - q.eq('tokenIdentifier', identity.tokenIdentifier) + .query("users") + .withIndex("by_token", (q) => + q.eq("tokenIdentifier", identity.tokenIdentifier) ) - .unique() + .unique(); if (user !== null) { // If we've seen this identity before but the name has changed, patch the value. if (user.name != identity.name) { - await db.patch(user._id, { name: identity.name }) + await db.patch(user._id, { name: identity.name }); } - return user._id + return user._id; } // If it's a new identity, create a new `User`. - return db.insert('users', { + return db.insert("users", { name: identity.name, tokenIdentifier: identity.tokenIdentifier, profilePic: null, - }) -} + }); +}; export const getMyUser = query(async ({ db, auth }) => { - const identity = await auth.getUserIdentity() + const identity = await auth.getUserIdentity(); if (!identity) { - return null + return null; } const user = await db - .query('users') - .withIndex('by_token', (q) => - q.eq('tokenIdentifier', identity.tokenIdentifier) + .query("users") + .withIndex("by_token", (q) => + q.eq("tokenIdentifier", identity.tokenIdentifier) ) - .unique() + .unique(); - return user?._id -}) + return user?._id; +}); export const get = query({ args: { - id: v.id('users'), + id: v.id("users"), }, handler: async ({ db, storage }, { id }) => { - const user = await db.get(id) - let profilePicUrl = null + const user = await db.get(id); + let profilePicUrl = null; if (user?.profilePic) { - profilePicUrl = await storage.getUrl(user.profilePic) + profilePicUrl = await storage.getUrl(user.profilePic); } return { ...user, profilePicUrl, - } + }; }, -}) +}); // Generate a short-lived upload URL. export const generateUploadUrl = mutation(async ({ storage }) => { - return await storage.generateUploadUrl() -}) + return await storage.generateUploadUrl(); +}); // Save the storage ID within a message. export const setProfilePic = mutation( async ({ db, auth }, { storageId }: { storageId: string }) => { - const identity = await auth.getUserIdentity() + const identity = await auth.getUserIdentity(); if (!identity) { - return null + return null; } const user = await db - .query('users') - .withIndex('by_token', (q) => - q.eq('tokenIdentifier', identity.tokenIdentifier) + .query("users") + .withIndex("by_token", (q) => + q.eq("tokenIdentifier", identity.tokenIdentifier) ) - .unique() + .unique(); if (user === null) { - throw new Error('Updating profile pic for missing user') + throw new Error("Updating profile pic for missing user"); } - db.patch(user._id, { profilePic: storageId }) + db.patch(user._id, { profilePic: storageId }); } -) +); diff --git a/convex/utils.ts b/convex/utils.ts index a6097d4..c48b380 100644 --- a/convex/utils.ts +++ b/convex/utils.ts @@ -1,87 +1,87 @@ -import { Chess } from 'chess.js' -import { Doc, Id } from '../convex/_generated/dataModel' -import { DatabaseReader } from './_generated/server' +import { Chess } from "chess.js"; +import { Doc, Id } from "../convex/_generated/dataModel"; +import { DatabaseReader } from "./_generated/server"; -export type PlayerId = Id<'users'> | 'Computer' | null +export type PlayerId = Id<"users"> | "Computer" | null; export async function playerName( db: DatabaseReader, player: PlayerId ): Promise { - if (player === 'Computer') { - return 'Computer' + if (player === "Computer") { + return "Computer"; } if (player === null) { - return 'nobody' + return "nobody"; } - const p = await db.get(player) + const p = await db.get(player); if (!p) { - return 'invalid-player-id' + return "invalid-player-id"; } - return p.name + return p.name; } export function playerEquals(player1: PlayerId, player2: PlayerId) { if (!player1) { // null is not equal to null. - return false + return false; } - return typeof player1 == 'string' ? player1 == player2 : player1 === player2 + return typeof player1 == "string" ? player1 == player2 : player1 === player2; } -export function isOpen(state: Doc<'games'>): boolean { - return !state.player1 || !state.player2 +export function isOpen(state: Doc<"games">): boolean { + return !state.player1 || !state.player2; } -export function hasPlayer(state: Doc<'games'>, player: PlayerId): boolean { +export function hasPlayer(state: Doc<"games">, player: PlayerId): boolean { if (!player) { - return false + return false; } return ( playerEquals(state.player1 as any, player) || playerEquals(state.player2 as any, player) - ) + ); } -export function getCurrentPlayer(state: Doc<'games'>): PlayerId { - const game = new Chess() - game.loadPgn(state.pgn) - let result = game.turn() == 'w' ? state.player1 : state.player2 - return result as any +export function getCurrentPlayer(state: Doc<"games">): PlayerId { + const game = new Chess(); + game.loadPgn(state.pgn); + let result = game.turn() == "w" ? state.player1 : state.player2; + return result as any; } -export function getNextPlayer(state: Doc<'games'>): PlayerId { - const game = new Chess() - game.loadPgn(state.pgn) - let result = game.turn() == 'w' ? state.player2 : state.player1 - return result as any +export function getNextPlayer(state: Doc<"games">): PlayerId { + const game = new Chess(); + game.loadPgn(state.pgn); + let result = game.turn() == "w" ? state.player2 : state.player1; + return result as any; } export function validateMove( - state: Doc<'games'>, + state: Doc<"games">, player: PlayerId, from: string, to: string ): Chess | null { if (!playerEquals(getCurrentPlayer(state), player)) { // Wrong player. - return null + return null; } - const game = new Chess() - game.loadPgn(state.pgn) - let valid = null + const game = new Chess(); + game.loadPgn(state.pgn); + let valid = null; try { - valid = game.move({ from, to }) + valid = game.move({ from, to }); } catch { // This is lame but try promoting. try { - valid = game.move({ from, to, promotion: 'q' }) - console.log('promoted a pawn') + valid = game.move({ from, to, promotion: "q" }); + console.log("promoted a pawn"); } catch { - console.log(`invalid move ${from}->${to}`) - valid = null + console.log(`invalid move ${from}->${to}`); + valid = null; } } - return valid ? game : null + return valid ? game : null; } diff --git a/pages/_app.tsx b/pages/_app.tsx index 1fefee8..8eb4206 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -1,15 +1,15 @@ import { api } from "../convex/_generated/api"; -import '../styles/globals.css' -import type { AppProps } from 'next/app' +import "../styles/globals.css"; +import type { AppProps } from "next/app"; -import { ConvexReactClient, useQuery } from 'convex/react' -import { ConvexProviderWithAuth0 } from 'convex/react-auth0' +import { ConvexReactClient, useQuery } from "convex/react"; +import { ConvexProviderWithAuth0 } from "convex/react-auth0"; -import { useAuth0, Auth0Provider } from '@auth0/auth0-react'; -import { useState } from 'react'; -import Link from 'next/link'; -import { gameTitle } from '../common'; -import { Game } from '../convex/search'; +import { useAuth0, Auth0Provider } from "@auth0/auth0-react"; +import { useState } from "react"; +import Link from "next/link"; +import { gameTitle } from "../common"; +import { Game } from "../convex/search"; const address = process.env.NEXT_PUBLIC_CONVEX_URL; if (!address) { @@ -24,19 +24,18 @@ function App(props: AppProps) { domain={process.env.NEXT_PUBLIC_AUTH0_DOMAIN!} clientId={process.env.NEXT_PUBLIC_CLIENT_ID!} authorizationParams={{ - redirect_uri: typeof window !== "undefined" ? window.location.origin : "", + redirect_uri: + typeof window !== "undefined" ? window.location.origin : "", }} useRefreshTokens={true} cacheLocation="localstorage" > - + - ) + ); } function MyApp({ Component, pageProps }: AppProps) { @@ -51,53 +50,75 @@ function MyApp({ Component, pageProps }: AppProps) { setSearchInput(e.target.value); }; - const searchResults = useQuery(api.search.default, { query: searchInput }) || {users: [], games: []}; + const searchResults = useQuery(api.search.default, { + query: searchInput, + }) || { users: [], games: [] }; return (
- + + +
-
- - - {searchResults.users.map((result) => - - - - )} - {searchResults.games.map((result) => - - - - )} - + value={searchInput} + /> + +
- { - {(result as any).name} - } -
- {gameTitle(result)} -
+ + {searchResults.users.map((result) => ( + + + + ))} + {searchResults.games.map((result) => ( + + + + ))} +
+ { + + {(result as any).name} + + } +
+ + {gameTitle(result)} + +

Convex Chess

- { - user ? -
- { user.name } - -
- : - } + {user ? ( +
+ + {user.name} + + +
+ ) : ( + + )}
- ) + ); } -export default App +export default App; diff --git a/pages/index.tsx b/pages/index.tsx index 3b68c0d..68128a5 100644 --- a/pages/index.tsx +++ b/pages/index.tsx @@ -1,14 +1,14 @@ import { api } from "../convex/_generated/api"; -import { FormEvent } from 'react' +import { FormEvent } from "react"; -import { useMutation, useQuery } from 'convex/react' -import { isOpen, hasPlayer } from "../convex/utils" -import { gameTitle } from "../common" +import { useMutation, useQuery } from "convex/react"; +import { isOpen, hasPlayer } from "../convex/utils"; +import { gameTitle } from "../common"; -import { useRouter } from 'next/router' -import { useAuth0 } from '@auth0/auth0-react'; +import { useRouter } from "next/router"; +import { useAuth0 } from "@auth0/auth0-react"; -export default function() { +export default function () { const router = useRouter(); const { user } = useAuth0(); @@ -20,8 +20,8 @@ export default function() { async function newGame(event: FormEvent) { const value = (event.nativeEvent as any).submitter.defaultValue ?? ""; const white = Boolean(Math.round(Math.random())); - let player1 : "Me" | "Computer" | null = null; - let player2 : "Me" | "Computer" | null = null; + let player1: "Me" | "Computer" | null = null; + let player2: "Me" | "Computer" | null = null; switch (value) { case "Play vs another Player": if (white) { @@ -45,72 +45,73 @@ export default function() { break; } event.preventDefault(); - const id = await startNewGame({player1, player2}); - router.push({ pathname: `/play/${id}`}); + const id = await startNewGame({ player1, player2 }); + router.push({ pathname: `/play/${id}` }); } async function join(event: FormEvent) { event.preventDefault(); const gameId = (event.nativeEvent as any).submitter.id ?? ""; - router.push({ pathname: `/play/${gameId}`}); + router.push({ pathname: `/play/${gameId}` }); } return (
- - - -
+ onSubmit={newGame} + className="control-form d-flex justify-content-center" + > + + + + Ongoing Games - { - ongoingGames.map((game, i) => ( - - - - - )) - } + {ongoingGames.map((game, i) => ( + + + + + ))}
{gameTitle(game)} -
- -
-
{gameTitle(game)} +
+ +
+
- ) + ); } diff --git a/pages/play/[id].tsx b/pages/play/[id].tsx index 742c70b..831303d 100644 --- a/pages/play/[id].tsx +++ b/pages/play/[id].tsx @@ -1,17 +1,17 @@ -import { api } from '../../convex/_generated/api'; -import { useRouter } from 'next/router'; -import { Chess, Move } from 'chess.js'; -import { Chessboard } from 'react-chessboard'; +import { api } from "../../convex/_generated/api"; +import { useRouter } from "next/router"; +import { Chess, Move } from "chess.js"; +import { Chessboard } from "react-chessboard"; -import { useMutation, useQuery } from 'convex/react'; -import { Id } from '../../convex/_generated/dataModel'; -import { validateMove, isOpen, playerEquals } from '../../convex/utils'; -import { gameTitle } from '../../common'; -import { useEffect, useState } from 'react'; +import { useMutation, useQuery } from "convex/react"; +import { Id } from "../../convex/_generated/dataModel"; +import { validateMove, isOpen, playerEquals } from "../../convex/utils"; +import { gameTitle } from "../../common"; +import { useEffect, useState } from "react"; export default function () { const router = useRouter(); - const gameId = router.query.id as Id<'games'>; + const gameId = router.query.id as Id<"games">; const moveIdx = router.query.moveIndex ? Number(router.query.moveIndex) : undefined; @@ -29,7 +29,7 @@ export default function () { const { analysis, moveIndex, move } = useQuery( api.games.getAnalysis, - gameState ? { gameId: gameState._id, moveIndex: selectedMove } : 'skip' + gameState ? { gameId: gameState._id, moveIndex: selectedMove } : "skip" ) ?? {}; const performMove = useMutation(api.games.move).withOptimisticUpdate( @@ -41,7 +41,7 @@ export default function () { game.move({ from, to }); const newState = { ...state }; newState.pgn = game.pgn(); - console.log('nextState', game.history(), gameId); + console.log("nextState", game.history(), gameId); localStore.setQuery(api.games.get, { id: gameId }, newState); } } @@ -78,7 +78,7 @@ export default function () { await performMove({ gameId, from: sourceSquare, to: targetSquare }); setSelectedMove(undefined); } else { - setMainStyle({ backgroundColor: 'red' }); + setMainStyle({ backgroundColor: "red" }); setTimeout(() => setMainStyle({}), 50); try { await tryPerformMove({ gameId, from: sourceSquare, to: targetSquare }); @@ -95,16 +95,16 @@ export default function () { blackMove: string; }; let turns: Turn[] = []; - let history = game.history().length > 0 ? game.history() : ['']; + let history = game.history().length > 0 ? game.history() : [""]; while (history.length > 0) { const whiteMove = history.shift() as string; - const blackMove = (history.shift() as string) ?? ''; + const blackMove = (history.shift() as string) ?? ""; turns.push({ num: turns.length + 1, whiteMove, blackMove }); } const boardOrientation = playerEquals(userId, gameState.player2 as any) - ? 'black' - : 'white'; + ? "black" + : "white"; return (
@@ -143,7 +143,7 @@ export default function () { {Math.floor(moveIndex / 2) + 1} - {moveIndex % 2 ? 'b' : 'a'}. {move} + {moveIndex % 2 ? "b" : "a"}. {move} diff --git a/pages/user/[id].tsx b/pages/user/[id].tsx index a2cf22a..63969cf 100644 --- a/pages/user/[id].tsx +++ b/pages/user/[id].tsx @@ -1,41 +1,41 @@ -import { useRouter } from 'next/router' +import { useRouter } from "next/router"; -import { useQuery, useMutation } from 'convex/react' -import { useRef, useState } from 'react' -import { Id } from '../../convex/_generated/dataModel' -import { api } from '../../convex/_generated/api' +import { useQuery, useMutation } from "convex/react"; +import { useRef, useState } from "react"; +import { Id } from "../../convex/_generated/dataModel"; +import { api } from "../../convex/_generated/api"; export default function () { - const router = useRouter() - const userId = router.query.id as Id<'users'> - const user = useQuery(api.users.get, { id: userId }) || null - const myUserId = useQuery(api.users.getMyUser) ?? null + const router = useRouter(); + const userId = router.query.id as Id<"users">; + const user = useQuery(api.users.get, { id: userId }) || null; + const myUserId = useQuery(api.users.getMyUser) ?? null; - const imageInput = useRef(null) - const [selectedImage, setSelectedImage] = useState(null) + const imageInput = useRef(null); + const [selectedImage, setSelectedImage] = useState(null); - const generateUploadUrl = useMutation(api.users.generateUploadUrl) - const setProfilePic = useMutation(api.users.setProfilePic) + const generateUploadUrl = useMutation(api.users.generateUploadUrl); + const setProfilePic = useMutation(api.users.setProfilePic); async function handleSetProfilePic(event: any) { - event.preventDefault() - setSelectedImage(null) - ;(imageInput.current as any).value = '' + event.preventDefault(); + setSelectedImage(null); + (imageInput.current as any).value = ""; // Step 1: Get a short-lived upload URL - const postUrl = await generateUploadUrl() + const postUrl = await generateUploadUrl(); // Step 2: POST the file to the URL const result = await fetch(postUrl, { - method: 'POST', - headers: { 'Content-Type': (selectedImage as any).type }, + method: "POST", + headers: { "Content-Type": (selectedImage as any).type }, body: selectedImage, - }) - const { storageId } = await result.json() + }); + const { storageId } = await result.json(); // Step 3: Save the newly allocated storage id to the messages table - await setProfilePic(storageId) + await setProfilePic(storageId); } - console.log() + console.log(); return (
@@ -44,9 +44,9 @@ export default function () { ) : (
{user?.name - ?.split(' ') + ?.split(" ") .map((w) => w.slice(0, 1).toUpperCase()) - .join('')} + .join("")}
)}
{user?.name}
@@ -72,5 +72,5 @@ export default function () {
)}
- ) + ); } diff --git a/styles/globals.css b/styles/globals.css index 22f2aa9..3648b58 100644 --- a/styles/globals.css +++ b/styles/globals.css @@ -7,7 +7,7 @@ } body { - font-family: system-ui, 'Segoe UI', Roboto, 'Helvetica Neue', helvetica, + font-family: system-ui, "Segoe UI", Roboto, "Helvetica Neue", helvetica, sans-serif; } @@ -116,7 +116,7 @@ input:not([type]) { font-size: 16px; } -input[type='submit'], +input[type="submit"], button { margin-left: 4px; background: lightblue; @@ -127,12 +127,12 @@ button { background-color: rgb(49, 108, 244); } -input[type='submit']:hover, +input[type="submit"]:hover, button:hover { background-color: rgb(41, 93, 207); } -input[type='submit']:disabled, +input[type="submit"]:disabled, button:disabled { background-color: rgb(122, 160, 248); }