diff --git a/packages/backend/package.json b/packages/backend/package.json index b93885c..96bc9cb 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -11,12 +11,14 @@ "clean": "rm -rf dist" }, "dependencies": { + "@auth/core": "0.41.1", "@auth/express": "^0.12.1", "@auth/mongodb-adapter": "^3.11.1", "@ih3t/shared": "workspace:*", "@tanstack/react-query": "^5.91.2", "@types/cors": "^2.8.19", "async-mutex": "^0.5.0", + "bcryptjs": "^3.0.3", "cors": "^2.8.6", "cron": "^4.4.0", "dotenv": "^17.3.1", diff --git a/packages/backend/src/auth/authRepository.ts b/packages/backend/src/auth/authRepository.ts index 3a658c8..e775223 100644 --- a/packages/backend/src/auth/authRepository.ts +++ b/packages/backend/src/auth/authRepository.ts @@ -4,13 +4,15 @@ import type { AdapterSession, AdapterUser, } from '@auth/express/adapters'; +import { compare } from 'bcryptjs'; import { type AccountPreferences, DEFAULT_ACCOUNT_PREFERENCES, + type RegisterCredentialsRequest, type UserRole, zAccountPreferences, } from '@ih3t/shared'; -import { Collection, ObjectId } from 'mongodb'; +import { Collection, MongoServerError, ObjectId } from 'mongodb'; import type { Logger } from 'pino'; import { inject, injectable } from 'tsyringe'; @@ -39,9 +41,10 @@ type AuthUserDocument = { type AuthAccountDocument = { _id: ObjectId; userId: ObjectId; - type: AdapterAccount[`type`]; + type: AdapterAccount[`type`] | `credentials`; provider: string; providerAccountId: string; + passwordHash?: string; refresh_token?: string; access_token?: string; expires_at?: number; @@ -72,12 +75,23 @@ type StoredAdapterUser = AdapterUser & { }; const DEFAULT_PLAYER_ELO = 1000; +const CREDENTIALS_PROVIDER_ID = `credentials`; export type AdminUserWindowStats = { newUsers: number; activeUsers: number; }; +export class AuthRepositoryError extends Error { + constructor( + readonly code: `username_taken`, + message: string, + ) { + super(message); + this.name = `AuthRepositoryError`; + } +} + export type AccountUserProfile = { id: string; username: string; @@ -346,26 +360,149 @@ export class AuthRepository implements Adapter { return document ? this.mapAccountUserProfile(this.mapUserDocument(document)) : null; } - async updateUsername(userId: string, username: string): Promise { + async getAuthenticatedUserProfileById(userId: string): Promise { const collection = await this.getUsersCollection(); const objectId = this.parseObjectId(userId); if (!objectId) { return null; } - await collection.updateOne( + const document = await collection.findOne({ _id: objectId }); + if (!document) { + return null; + } + + const user = await this.touchUserLastActive(this.mapUserDocument(document)); + return this.mapAccountUserProfile(user); + } + + async updateUsername(userId: string, username: string): Promise { + const objectId = this.parseObjectId(userId); + if (!objectId) { + return null; + } + + const [usersCollection, accountsCollection] = await Promise.all([ + this.getUsersCollection(), + this.getAccountsCollection(), + ]); + const credentialsAccount = await accountsCollection.findOne({ + userId: objectId, + provider: CREDENTIALS_PROVIDER_ID, + }); + + if (credentialsAccount) { + try { + await accountsCollection.updateOne( + { _id: credentialsAccount._id }, + { + $set: { + providerAccountId: this.normalizeCredentialsUsernameKey(username), + }, + }, + ); + } catch (error: unknown) { + if (error instanceof MongoServerError && error.code === 11000) { + throw new AuthRepositoryError(`username_taken`, `That username is already in use.`); + } + + throw error; + } + } + + await usersCollection.updateOne( { _id: objectId }, { $set: { - displayName: username, + name: username, }, }, ); - const document = await collection.findOne({ _id: objectId }); + const document = await usersCollection.findOne({ _id: objectId }); return document ? this.mapAccountUserProfile(this.mapUserDocument(document)) : null; } + async createCredentialsUser( + registration: RegisterCredentialsRequest, + passwordHash: string, + ): Promise { + const [usersCollection, accountsCollection] = await Promise.all([ + this.getUsersCollection(), + this.getAccountsCollection(), + ]); + let createdUserId: ObjectId | null = null; + + try { + const now = Date.now(); + const userDocument: AuthUserDocument = { + _id: new ObjectId(), + name: registration.username, + role: `user`, + elo: DEFAULT_PLAYER_ELO, + preferences: { + ...DEFAULT_ACCOUNT_PREFERENCES, + changelogReadAt: Date.now(), + }, + registeredAt: now, + lastActiveAt: now, + }; + createdUserId = userDocument._id; + + await usersCollection.insertOne(userDocument); + + await accountsCollection.insertOne({ + _id: new ObjectId(), + userId: createdUserId, + type: CREDENTIALS_PROVIDER_ID, + provider: CREDENTIALS_PROVIDER_ID, + providerAccountId: this.normalizeCredentialsUsernameKey(registration.username), + passwordHash, + }); + + return this.mapAccountUserProfile(this.mapUserDocument(userDocument)); + } catch (error: unknown) { + if (createdUserId) { + await usersCollection.deleteOne({ _id: createdUserId }).catch((cleanupError: unknown) => { + this.logger.warn({ + err: cleanupError, + event: `auth.credentials.cleanup.failed`, + userId: createdUserId?.toHexString(), + }, `Failed to clean up partially created credentials user`); + }); + } + + if (error instanceof MongoServerError && error.code === 11000) { + throw new AuthRepositoryError(`username_taken`, `That username is already in use.`); + } + + throw error; + } + } + + async verifyCredentialsUser(username: string, password: string): Promise { + const [accountsCollection, usersCollection] = await Promise.all([ + this.getAccountsCollection(), + this.getUsersCollection(), + ]); + const accountDocument = await accountsCollection.findOne({ + provider: CREDENTIALS_PROVIDER_ID, + providerAccountId: this.normalizeCredentialsUsernameKey(username), + }); + + if (!accountDocument?.passwordHash) { + return null; + } + + const passwordMatches = await compare(password, accountDocument.passwordHash); + if (!passwordMatches) { + return null; + } + + const userDocument = await usersCollection.findOne({ _id: accountDocument.userId }); + return userDocument ? this.mapUserDocument(userDocument) : null; + } + async getAccountPreferences(userId: string): Promise { const collection = await this.getUsersCollection(); const objectId = this.parseObjectId(userId); @@ -615,6 +752,11 @@ export class AuthRepository implements Adapter { return Math.max(0, Math.floor(value)); } + private normalizeCredentialsUsernameKey(username: string): string { + return username.trim().replace(/\s+/g, ` `) + .toLowerCase(); + } + private toStoredAdapterUser(user: AdapterUser & { role?: UserRole; registeredAt?: number; lastActiveAt?: number }): StoredAdapterUser { const registeredAt = this.normalizeTimestamp(user.registeredAt) ?? Date.now(); diff --git a/packages/backend/src/auth/authService.ts b/packages/backend/src/auth/authService.ts index b4f25c2..1801ad2 100644 --- a/packages/backend/src/auth/authService.ts +++ b/packages/backend/src/auth/authService.ts @@ -1,17 +1,20 @@ import { ExpressAuth, type ExpressAuthConfig } from '@auth/express'; +import _Credentials from '@auth/express/providers/credentials'; import _Discord, { type DiscordProfile } from '@auth/express/providers/discord'; +import { getToken, type JWT } from '@auth/core/jwt'; import { type AccountPreferences, type ClientToServerEvents, DEFAULT_ACCOUNT_PREFERENCES, type ServerToClientEvents } from '@ih3t/shared'; import type { Request } from 'express'; import type { Socket } from 'socket.io'; import { inject, injectable } from 'tsyringe'; import { ServerConfig } from '../config/serverConfig'; -import { getCookieValue } from '../network/clientInfo'; import { CorsConfiguration } from '../network/cors'; import { type AccountUserProfile, AuthRepository } from './authRepository'; /* eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access */ const Discord: typeof _Discord = (_Discord as any).default ?? _Discord; +/* eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access */ +const Credentials: typeof _Credentials = (_Credentials as any).default ?? _Credentials; type SessionUserShape = { id?: string; @@ -25,22 +28,28 @@ export class AuthService { readonly config: ExpressAuthConfig; readonly handler: ReturnType; readonly sessionCookieName = `ih3t.session-token`; + private readonly authSecret: string; + private readonly useSecureCookies: boolean; constructor( @inject(ServerConfig) serverConfig: ServerConfig, @inject(CorsConfiguration) corsConfiguration: CorsConfiguration, @inject(AuthRepository) private readonly authRepository: AuthRepository, ) { - const useSecureCookies = process.env.NODE_ENV === `production`; + this.authSecret = serverConfig.authSecret; + this.useSecureCookies = process.env.NODE_ENV === `production`; this.config = { trustHost: true, secret: serverConfig.authSecret, adapter: authRepository, + pages: { + signIn: `/login`, + }, session: { - strategy: `database`, + strategy: `jwt`, }, - useSecureCookies, + useSecureCookies: this.useSecureCookies, cookies: { sessionToken: { name: this.sessionCookieName, @@ -48,11 +57,34 @@ export class AuthService { httpOnly: true, sameSite: `lax`, path: `/`, - secure: useSecureCookies, + secure: this.useSecureCookies, }, }, }, providers: [ + Credentials({ + name: `Username and Password`, + credentials: { + username: { + label: `Username`, + type: `text`, + }, + password: { + label: `Password`, + type: `password`, + }, + }, + authorize: async (credentials) => { + if (!credentials?.username || !credentials.password) { + return null; + } + + return await this.authRepository.verifyCredentialsUser( + String(credentials.username), + String(credentials.password), + ); + }, + }), Discord({ clientId: serverConfig.discordClientId, clientSecret: serverConfig.discordClientSecret, @@ -67,7 +99,11 @@ export class AuthService { }), ], callbacks: { - async signIn({ profile }) { + async signIn({ account, profile }) { + if (account?.provider !== `discord`) { + return true; + } + if (typeof profile?.email === `string` && profile.email.trim().length > 0) { return true; } @@ -90,12 +126,22 @@ export class AuthService { return baseUrl; }, - async session({ session, user }) { + async jwt({ token, user }) { + if (user) { + token.sub = user.id; + token.name = user.name; + token.email = user.email; + token.picture = user.image; + } + + return token; + }, + async session({ session, token }) { const sessionUser = session.user as typeof session.user & SessionUserShape; - sessionUser.id = user.id; - sessionUser.name = user.name; - sessionUser.email = user.email; - sessionUser.image = user.image; + sessionUser.id = typeof token.sub === `string` ? token.sub : ``; + sessionUser.name = typeof token.name === `string` ? token.name : null; + sessionUser.email = typeof token.email === `string` ? token.email : ``; + sessionUser.image = typeof token.picture === `string` ? token.picture : null; return session; }, }, @@ -105,29 +151,54 @@ export class AuthService { } async getUserFromRequest(request: Request): Promise { - const sessionToken = getCookieValue(request.get(`cookie`), this.sessionCookieName); - if (!sessionToken) { - return null; - } - - return this.authRepository.getUserProfileBySessionToken(sessionToken); + const token = await this.getSessionJwt({ + cookie: request.get(`cookie`) ?? null, + authorization: request.get(`authorization`) ?? null, + }); + return typeof token?.sub === `string` + ? await this.authRepository.getAuthenticatedUserProfileById(token.sub) + : null; } async getUserFromSocket(socket: Socket): Promise { - const sessionToken = getCookieValue( - typeof socket.handshake.headers.cookie === `string` ? socket.handshake.headers.cookie : null, - this.sessionCookieName, - ); + const token = await this.getSessionJwt({ + cookie: typeof socket.handshake.headers.cookie === `string` ? socket.handshake.headers.cookie : null, + authorization: typeof socket.handshake.headers.authorization === `string` ? socket.handshake.headers.authorization : null, + }); + return typeof token?.sub === `string` + ? await this.authRepository.getAuthenticatedUserProfileById(token.sub) + : null; + } + + async getUserPreferences(userId: string): Promise { + return await this.authRepository.getAccountPreferences(userId) ?? DEFAULT_ACCOUNT_PREFERENCES; + } - if (!sessionToken) { + private async getSessionJwt(headers: { + cookie: string | null + authorization: string | null + }): Promise { + if (!headers.cookie && !headers.authorization) { return null; } - return this.authRepository.getUserProfileBySessionToken(sessionToken); - } + const requestHeaders = new Headers(); + if (headers.cookie) { + requestHeaders.set(`cookie`, headers.cookie); + } - async getUserPreferences(userId: string): Promise { - return await this.authRepository.getAccountPreferences(userId) ?? DEFAULT_ACCOUNT_PREFERENCES; + if (headers.authorization) { + requestHeaders.set(`authorization`, headers.authorization); + } + + return await getToken({ + req: { + headers: requestHeaders, + }, + secret: this.authSecret, + secureCookie: this.useSecureCookies, + cookieName: this.sessionCookieName, + }); } } diff --git a/packages/backend/src/bots/accountBotService.ts b/packages/backend/src/bots/accountBotService.ts new file mode 100644 index 0000000..6af5224 --- /dev/null +++ b/packages/backend/src/bots/accountBotService.ts @@ -0,0 +1,311 @@ +import { randomInt } from 'node:crypto'; + +import type { + AccountBot, + AccountBotCapabilities, + CreateAccountBotRequest, + UpdateAccountBotRequest, +} from '@ih3t/shared'; +import { zAccountBotEndpoint } from '@ih3t/shared'; +import type { Logger } from 'pino'; +import { inject, injectable } from 'tsyringe'; +import { z } from 'zod'; + +import { ServerConfig } from '../config/serverConfig'; +import { ROOT_LOGGER } from '../logger'; +import { AccountBotRepository } from '../persistence/accountBotRepository'; + +const SHORT_ID_ALPHABET = `abcdefghijklmnopqrstuvwxyz0123456789`; +const SHORT_ID_LENGTH = 7; +const MAX_SHORT_ID_ATTEMPTS = 10; + +const zCapabilitiesResponse = z.object({ + meta: z.object({ + name: z.string().trim() + .min(1) + .optional(), + description: z.string().trim() + .min(1) + .optional(), + author: z.string().trim() + .min(1) + .optional(), + version: z.string().trim() + .min(1) + .optional(), + }).partial() + .optional(), + stateless: z.object({ + versions: z.object({ + 'v1-alpha': z.object({ + api_root: z.string().trim() + .min(1) + .optional(), + move_time_limit: z.boolean().optional(), + }).partial(), + }).partial(), + }).partial() + .optional(), +}); + +const zStatelessTurnResponse = z.object({ + move: z.object({ + pieces: z.array(z.object({ + q: z.number().int(), + r: z.number().int(), + })).length(2), + }), +}); + +export type BotMoveRequest = { + toMove: `x` | `o`; + cells: Array<{ + x: number; + y: number; + piece: `x` | `o`; + }>; + timeLimitSeconds?: number; +}; + +export type BotMoveResponse = { + pieces: [ + { x: number; y: number }, + { x: number; y: number }, + ]; +}; + +export class AccountBotError extends Error { + constructor(message: string) { + super(message); + this.name = `AccountBotError`; + } +} + +@injectable() +export class AccountBotService { + static readonly MAX_BOTS_PER_ACCOUNT = 20; + private readonly logger: Logger; + + constructor( + @inject(ROOT_LOGGER) rootLogger: Logger, + @inject(ServerConfig) private readonly serverConfig: ServerConfig, + @inject(AccountBotRepository) private readonly accountBotRepository: AccountBotRepository, + ) { + this.logger = rootLogger.child({ component: `account-bot-service` }); + } + + async listBots(ownerProfileId: string): Promise { + return await this.accountBotRepository.listByOwnerProfileId(ownerProfileId); + } + + async getOwnedBot(ownerProfileId: string, botId: string): Promise { + return await this.accountBotRepository.getByOwnerProfileIdAndId(ownerProfileId, botId); + } + + async getBotById(botId: string): Promise { + return await this.accountBotRepository.getById(botId); + } + + async requireOwnedBots(ownerProfileId: string, botIds: string[]): Promise { + const normalizedBotIds = Array.from(new Set(botIds.map((botId) => botId.trim()).filter(Boolean))); + if (normalizedBotIds.length !== botIds.length) { + throw new AccountBotError(`Duplicate bot selections are not allowed.`); + } + + const bots = await Promise.all(normalizedBotIds.map((botId) => this.getOwnedBot(ownerProfileId, botId))); + if (bots.some((bot) => bot === null)) { + throw new AccountBotError(`One or more selected bots were not found in your account.`); + } + + return bots.filter((bot): bot is AccountBot => bot !== null); + } + + async createBot(ownerProfileId: string, request: CreateAccountBotRequest): Promise { + const existingCount = await this.accountBotRepository.countByOwnerProfileId(ownerProfileId); + if (existingCount >= AccountBotService.MAX_BOTS_PER_ACCOUNT) { + throw new AccountBotError(`You can save up to ${AccountBotService.MAX_BOTS_PER_ACCOUNT} bots per account.`); + } + + const normalizedEndpoint = normalizeEndpoint(request.bot.endpoint); + const capabilities = await this.discoverCapabilities(normalizedEndpoint); + const now = Date.now(); + + for (let attempt = 0; attempt < MAX_SHORT_ID_ATTEMPTS; attempt += 1) { + try { + return await this.accountBotRepository.createBot({ + id: this.generateShortId(), + ownerProfileId, + name: request.bot.name, + endpoint: normalizedEndpoint, + createdAt: now, + updatedAt: now, + capabilities, + }); + } catch (error: unknown) { + if (isMongoDuplicateKeyError(error)) { + continue; + } + + throw error; + } + } + + throw new AccountBotError(`Failed to generate a bot id.`); + } + + async updateBot(ownerProfileId: string, botId: string, request: UpdateAccountBotRequest): Promise { + const normalizedEndpoint = normalizeEndpoint(request.bot.endpoint); + const capabilities = await this.discoverCapabilities(normalizedEndpoint); + return await this.accountBotRepository.updateBot(ownerProfileId, botId, { + name: request.bot.name, + endpoint: normalizedEndpoint, + updatedAt: Date.now(), + capabilities, + }); + } + + async deleteBot(ownerProfileId: string, botId: string): Promise { + return await this.accountBotRepository.deleteBot(ownerProfileId, botId); + } + + async requestMove(bot: AccountBot, request: BotMoveRequest): Promise { + const endpoint = resolveStatelessTurnUrl(bot); + const response = await this.fetchJson(endpoint, { + method: `POST`, + headers: { + 'Content-Type': `application/json`, + }, + body: JSON.stringify({ + board: { + to_move: request.toMove, + cells: request.cells.map((cell) => ({ + q: cell.x, + r: cell.y, + p: cell.piece, + })), + }, + ...(bot.capabilities.moveTimeLimit && typeof request.timeLimitSeconds === `number` + ? { time_limit: request.timeLimitSeconds } + : {}), + }), + }); + const parsed = zStatelessTurnResponse.safeParse(response); + if (!parsed.success) { + throw new AccountBotError(`Bot "${bot.name}" returned an invalid move response.`); + } + + return { + pieces: [ + { + x: parsed.data.move.pieces[0].q, + y: parsed.data.move.pieces[0].r, + }, + { + x: parsed.data.move.pieces[1].q, + y: parsed.data.move.pieces[1].r, + }, + ], + }; + } + + private async discoverCapabilities(endpoint: string): Promise { + const capabilityUrl = new URL(`capabilities.json`, toDirectoryUrl(endpoint)).toString(); + const response = await this.fetchJson(capabilityUrl, { + method: `GET`, + }); + const parsed = zCapabilitiesResponse.safeParse(response); + if (!parsed.success) { + throw new AccountBotError(`Bot capabilities response is invalid.`); + } + + const statelessVersion = parsed.data.stateless?.versions?.[`v1-alpha`]; + if (!statelessVersion) { + throw new AccountBotError(`Only bots with stateless v1-alpha support can be added right now.`); + } + + return { + statelessApiRoot: resolveApiRoot(endpoint, statelessVersion.api_root ?? `stateless/v1-alpha`), + moveTimeLimit: statelessVersion.move_time_limit ?? false, + discoveredAt: Date.now(), + meta: { + name: parsed.data.meta?.name ?? null, + description: parsed.data.meta?.description ?? null, + author: parsed.data.meta?.author ?? null, + version: parsed.data.meta?.version ?? null, + }, + }; + } + + private async fetchJson(url: string, init: RequestInit): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), this.serverConfig.botHttpTimeoutMs); + + try { + const response = await fetch(url, { + ...init, + signal: controller.signal, + }); + if (!response.ok) { + throw new AccountBotError(`Bot request failed with ${response.status} ${response.statusText}.`); + } + + return await response.json(); + } catch (error: unknown) { + if (error instanceof AccountBotError) { + throw error; + } + + if (error instanceof Error && error.name === `AbortError`) { + throw new AccountBotError(`Bot request timed out after ${this.serverConfig.botHttpTimeoutMs}ms.`); + } + + this.logger.warn({ err: error, url }, `Bot request failed`); + throw new AccountBotError(error instanceof Error ? error.message : `Bot request failed.`); + } finally { + clearTimeout(timeout); + } + } + + private generateShortId(): string { + let id = ``; + for (let characterIndex = 0; characterIndex < SHORT_ID_LENGTH; characterIndex += 1) { + const alphabetIndex = randomInt(0, SHORT_ID_ALPHABET.length); + id += SHORT_ID_ALPHABET[alphabetIndex]; + } + + return id; + } +} + +function normalizeEndpoint(endpoint: string): string { + const normalized = zAccountBotEndpoint.parse(endpoint); + const url = new URL(normalized); + url.search = ``; + url.hash = ``; + + if (url.pathname.length > 1) { + url.pathname = url.pathname.replace(/\/+$/, ``); + } + + return url.toString().replace(/\/$/, url.pathname === `/` ? `/` : ``); +} + +function toDirectoryUrl(endpoint: string): string { + return endpoint.endsWith(`/`) ? endpoint : `${endpoint}/`; +} + +function resolveApiRoot(endpoint: string, apiRoot: string): string { + return new URL(apiRoot, toDirectoryUrl(endpoint)).toString(); +} + +function resolveStatelessTurnUrl(bot: AccountBot): string { + return new URL(`turn`, toDirectoryUrl(bot.capabilities.statelessApiRoot)).toString(); +} + +function isMongoDuplicateKeyError(error: unknown): error is { code: number } { + return typeof error === `object` + && error !== null + && `code` in error + && typeof (error as { code?: unknown }).code === `number` + && (error as { code: number }).code === 11000; +} diff --git a/packages/backend/src/config/serverConfig.ts b/packages/backend/src/config/serverConfig.ts index 36f5f41..91124c5 100644 --- a/packages/backend/src/config/serverConfig.ts +++ b/packages/backend/src/config/serverConfig.ts @@ -21,6 +21,7 @@ export class ServerConfig { // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing readonly logLevel = process.env.LOG_LEVEL?.trim() || (process.env.NODE_ENV === `production` ? `info` : `debug`); readonly prettyLogs = this.parseBoolean(process.env.LOG_PRETTY) ?? process.env.NODE_ENV !== `production`; + readonly botHttpTimeoutMs = this.parseIntegerEnv(`BOT_HTTP_TIMEOUT_MS`) ?? 15_000; toLogObject() { return { @@ -32,6 +33,7 @@ export class ServerConfig { discordClientConfigured: true, logLevel: this.logLevel, prettyLogs: this.prettyLogs, + botHttpTimeoutMs: this.botHttpTimeoutMs, }; } @@ -80,4 +82,14 @@ export class ServerConfig { return null; } + + private parseIntegerEnv(name: string): number | null { + const value = process.env[name]?.trim(); + if (!value) { + return null; + } + + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) ? parsed : null; + } } diff --git a/packages/backend/src/di/createAppContainer.ts b/packages/backend/src/di/createAppContainer.ts index e770d79..000cdd8 100644 --- a/packages/backend/src/di/createAppContainer.ts +++ b/packages/backend/src/di/createAppContainer.ts @@ -5,6 +5,7 @@ import { ServerSettingsService } from '../admin/serverSettingsService'; import { ServerShutdownService } from '../admin/serverShutdownService'; import { AuthRepository } from '../auth/authRepository'; import { AuthService } from '../auth/authService'; +import { AccountBotService } from '../bots/accountBotService'; import { ServerConfig } from '../config/serverConfig'; import { EloHandler } from '../elo/eloHandler'; import { EloRepository } from '../elo/eloRepository'; @@ -16,6 +17,7 @@ import { HttpApplication } from '../network/createHttpApp'; import { SocketServerGateway } from '../network/createSocketServer'; import { ApiQueryService } from '../network/rest/apiQueryService'; import { ApiRouter } from '../network/rest/createApiRouter'; +import { AccountBotRepository } from '../persistence/accountBotRepository'; import { DatabaseMigrationRunner } from '../persistence/databaseMigrationRunner'; import { GameHistoryRepository } from '../persistence/gameHistoryRepository'; import { MetricsRepository } from '../persistence/metricsRepository'; @@ -43,6 +45,8 @@ export function createAppContainer(): DependencyContainer { appContainer.registerSingleton(DatabaseMigrationRunner); appContainer.registerSingleton(AuthRepository); appContainer.registerSingleton(AuthService); + appContainer.registerSingleton(AccountBotRepository); + appContainer.registerSingleton(AccountBotService); appContainer.registerSingleton(EloRepository); appContainer.registerSingleton(EloHandler); appContainer.registerSingleton(ServerSettingsRepository); diff --git a/packages/backend/src/network/createHttpApp.ts b/packages/backend/src/network/createHttpApp.ts index 13e70ce..c08163d 100644 --- a/packages/backend/src/network/createHttpApp.ts +++ b/packages/backend/src/network/createHttpApp.ts @@ -19,7 +19,7 @@ import { ApiRouter } from './rest/createApiRouter'; export class HttpApplication { readonly app: express.Application; private readonly frontendDistPath: string; - private readonly frontendSsrRenderer: FrontendSsrRenderer; + private readonly frontendSsrRenderer: FrontendSsrRenderer | null; constructor( @inject(ROOT_LOGGER) rootLogger: Logger, @@ -33,10 +33,12 @@ export class HttpApplication { const logger = rootLogger.child({ component: `http-application` }); const corsOptions = corsConfiguration.options; this.frontendDistPath = `${serverConfig.frontendDistPath}/client`; - this.frontendSsrRenderer = new FrontendSsrRenderer({ - apiQueryService, - ssrDistPath: serverConfig.frontendDistPath, - }); + this.frontendSsrRenderer = existsSync(this.frontendDistPath) + ? new FrontendSsrRenderer({ + apiQueryService, + ssrDistPath: serverConfig.frontendDistPath, + }) + : null; app.set(`trust proxy`, true); @@ -90,7 +92,8 @@ export class HttpApplication { }); }); - if (existsSync(this.frontendDistPath)) { + if (this.frontendSsrRenderer) { + const frontendSsrRenderer = this.frontendSsrRenderer; app.use(express.static(this.frontendDistPath, { index: false })); app.get(/^(?!\/api(?:\/|$)|\/socket\.io(?:\/|$)).*/, async (req, res) => { const joinRedirectUrl = this.resolveJoinRedirectUrl(req); @@ -105,9 +108,14 @@ export class HttpApplication { return; } - const html = await this.frontendSsrRenderer.render(req); + const html = await frontendSsrRenderer.render(req); res.type(`html`).send(html); }); + } else { + logger.warn({ + event: `frontend.dist.missing`, + frontendDistPath: this.frontendDistPath, + }, `Frontend dist is missing; SSR routes are disabled until the frontend is built`); } this.app = app; diff --git a/packages/backend/src/network/rest/apiQueryService.ts b/packages/backend/src/network/rest/apiQueryService.ts index 168e1e3..9fc3b24 100644 --- a/packages/backend/src/network/rest/apiQueryService.ts +++ b/packages/backend/src/network/rest/apiQueryService.ts @@ -1,4 +1,5 @@ import type { + AccountBotsResponse, AccountPreferencesResponse, AccountResponse, FinishedGameRecord, @@ -16,6 +17,7 @@ import type express from 'express'; import { inject, injectable } from 'tsyringe'; import { type AccountUserProfile, AuthRepository } from '../../auth/authRepository'; +import { AccountBotService } from '../../bots/accountBotService'; import { AuthService } from '../../auth/authService'; import { EloRepository } from '../../elo/eloRepository'; import { LeaderboardService } from '../../leaderboard/leaderboardService'; @@ -45,6 +47,7 @@ export class ApiQueryService { constructor( @inject(AuthService) private readonly authService: AuthService, @inject(AuthRepository) private readonly authRepository: AuthRepository, + @inject(AccountBotService) private readonly accountBotService: AccountBotService, @inject(EloRepository) private readonly eloRepository: EloRepository, @inject(LeaderboardService) private readonly leaderboardService: LeaderboardService, @inject(GameHistoryRepository) private readonly gameHistoryRepository: GameHistoryRepository, @@ -61,7 +64,7 @@ export class ApiQueryService { async getAccountPreferences(req: express.Request): Promise { const user = await this.authService.getUserFromRequest(req); if (!user) { - throw new ApiRequestError(401, `Sign in with Discord to view your account preferences.`); + throw new ApiRequestError(401, `Sign in to view your account preferences.`); } const preferences = await this.authRepository.getAccountPreferences(user.id); @@ -72,6 +75,17 @@ export class ApiQueryService { return { preferences }; } + async getAccountBots(req: express.Request): Promise { + const user = await this.authService.getUserFromRequest(req); + if (!user) { + throw new ApiRequestError(401, `Sign in with Discord to manage your bots.`); + } + + return { + bots: await this.accountBotService.listBots(user.id), + }; + } + async getProfile(profileId: string): Promise { const user = await this.authRepository.getUserProfileById(profileId); if (!user) { diff --git a/packages/backend/src/network/rest/createApiRouter.ts b/packages/backend/src/network/rest/createApiRouter.ts index 2425ace..4c2b5a2 100644 --- a/packages/backend/src/network/rest/createApiRouter.ts +++ b/packages/backend/src/network/rest/createApiRouter.ts @@ -1,4 +1,6 @@ import { + type AccountBotResponse, + type AccountBotsResponse, type AccountPreferencesResponse, type AccountResponse, type AdminBroadcastMessageResponse, @@ -7,19 +9,25 @@ import { type AdminStatsResponse, type AdminTerminateSessionResponse, type CreateSandboxPositionResponse, + type CreateAccountBotRequest, type CreateSessionResponse, DEFAULT_LOBBY_OPTIONS, type LobbyOptions, + zRegisterCredentialsRequest, type ServerSettings, + type UpdateAccountBotRequest, zAdminBroadcastMessageRequest, zAdminScheduleShutdownRequest, zAdminUpdateServerSettingsRequest, + zCreateAccountBotRequest, zCreateSandboxPositionRequest, zLobbyVisibility, zSandboxPositionId, + zUpdateAccountBotRequest, zUpdateAccountPreferencesRequest, zUpdateAccountProfileRequest, } from '@ih3t/shared'; +import { hash } from 'bcryptjs'; import express from 'express'; import { inject, injectable } from 'tsyringe'; import { z } from 'zod'; @@ -27,7 +35,8 @@ import { z } from 'zod'; import { AdminStatsService } from '../../admin/adminStatsService'; import { ServerSettingsService } from '../../admin/serverSettingsService'; import { ServerShutdownService } from '../../admin/serverShutdownService'; -import { type AccountUserProfile, AuthRepository } from '../../auth/authRepository'; +import { type AccountUserProfile, AuthRepository, AuthRepositoryError } from '../../auth/authRepository'; +import { AccountBotError, AccountBotService } from '../../bots/accountBotService'; import { AuthService } from '../../auth/authService'; import { SandboxPositionService } from '../../sandbox/sandboxPositionService'; import { SessionError, SessionManager } from '../../session/sessionManager'; @@ -79,6 +88,9 @@ const zCreateSessionRequestInput = z.object({ timeControl: zGameTimeControlInput.optional(), rated: z.coerce.boolean().optional(), }).optional(), + botPlayerIds: z.array(z.string().trim().min(1)) + .max(2) + .optional(), }); @injectable() @@ -89,6 +101,7 @@ export class ApiRouter { @inject(ApiQueryService) private readonly apiQueryService: ApiQueryService, @inject(AuthService) private readonly authService: AuthService, @inject(AuthRepository) private readonly authRepository: AuthRepository, + @inject(AccountBotService) private readonly accountBotService: AccountBotService, @inject(ServerSettingsService) private readonly serverSettingsService: ServerSettingsService, @inject(ServerShutdownService) private readonly serverShutdownService: ServerShutdownService, @inject(AdminStatsService) private readonly adminStatsService: AdminStatsService, @@ -98,6 +111,28 @@ export class ApiRouter { ) { const router = express.Router(); + router.post(`/auth/register`, express.json(), async (req, res) => { + try { + const registration = zRegisterCredentialsRequest.parse(req.body ?? {}); + const passwordHash = await hash(registration.password, 12); + const user = await this.authRepository.createCredentialsUser(registration, passwordHash); + const response: AccountResponse = { user }; + res.status(201).json(response); + } catch (error: unknown) { + if (error instanceof AuthRepositoryError) { + res.status(409).json({ error: error.message }); + return; + } + + if (error instanceof z.ZodError) { + res.status(400).json({ error: error.issues[0]?.message ?? `Invalid registration request.` }); + return; + } + + throw error; + } + }); + router.get(`/account`, async (req, res) => { res.json(await this.apiQueryService.getAccount(req)); }); @@ -115,10 +150,23 @@ export class ApiRouter { } }); + router.get(`/account/bots`, async (req, res) => { + try { + res.json(await this.apiQueryService.getAccountBots(req)); + } catch (error: unknown) { + if (error instanceof ApiRequestError) { + res.status(error.statusCode).json({ error: error.message }); + return; + } + + throw error; + } + }); + router.patch(`/account`, express.json(), async (req, res) => { const user = await this.authService.getUserFromRequest(req); if (!user) { - res.status(401).json({ error: `Sign in with Discord to update your account.` }); + res.status(401).json({ error: `Sign in to update your account.` }); return; } @@ -135,6 +183,11 @@ export class ApiRouter { }; res.json(response); } catch (error: unknown) { + if (error instanceof AuthRepositoryError) { + res.status(409).json({ error: error.message }); + return; + } + if (error instanceof SessionError) { res.status(400).json({ error: error.message }); return; @@ -147,7 +200,7 @@ export class ApiRouter { router.patch(`/account/preferences`, express.json(), async (req, res) => { const user = await this.authService.getUserFromRequest(req); if (!user) { - res.status(401).json({ error: `Sign in with Discord to update your account preferences.` }); + res.status(401).json({ error: `Sign in to update your account preferences.` }); return; } @@ -164,6 +217,74 @@ export class ApiRouter { res.json(response); }); + router.post(`/account/bots`, express.json(), async (req, res) => { + const user = await this.authService.getUserFromRequest(req); + if (!user) { + res.status(401).json({ error: `Sign in to create bots.` }); + return; + } + + try { + const request = this.parseCreateAccountBotRequest(req.body); + const bot = await this.accountBotService.createBot(user.id, request); + const response: AccountBotResponse = { bot }; + res.json(response); + } catch (error: unknown) { + if (error instanceof AccountBotError) { + res.status(400).json({ error: error.message }); + return; + } + + throw error; + } + }); + + router.put(`/account/bots/:botId`, express.json(), async (req, res) => { + const user = await this.authService.getUserFromRequest(req); + if (!user) { + res.status(401).json({ error: `Sign in to update bots.` }); + return; + } + + try { + const request = this.parseUpdateAccountBotRequest(req.body); + const bot = await this.accountBotService.updateBot(user.id, String(req.params.botId ?? ``).trim(), request); + if (!bot) { + res.status(404).json({ error: `Bot not found.` }); + return; + } + + const response: AccountBotResponse = { bot }; + res.json(response); + } catch (error: unknown) { + if (error instanceof AccountBotError) { + res.status(400).json({ error: error.message }); + return; + } + + throw error; + } + }); + + router.delete(`/account/bots/:botId`, async (req, res) => { + const user = await this.authService.getUserFromRequest(req); + if (!user) { + res.status(401).json({ error: `Sign in to delete bots.` }); + return; + } + + const deleted = await this.accountBotService.deleteBot(user.id, String(req.params.botId ?? ``).trim()); + if (!deleted) { + res.status(404).json({ error: `Bot not found.` }); + return; + } + + const response: AccountBotsResponse = { + bots: await this.accountBotService.listBots(user.id), + }; + res.json(response); + }); + router.get(`/profiles/:profileId`, async (req, res) => { const response = await this.apiQueryService.getProfile(req.params.profileId); if (!response) { @@ -244,7 +365,7 @@ export class ApiRouter { router.post(`/sandbox-positions`, express.json(), async (req, res) => { const user = await this.authService.getUserFromRequest(req); if (!user) { - res.status(401).json({ error: `Sign in with Discord to share sandbox positions.` }); + res.status(401).json({ error: `Sign in to share sandbox positions.` }); return; } @@ -369,24 +490,34 @@ export class ApiRouter { router.post(`/sessions`, express.json(), async (req, res) => { try { - const lobbyOptions = this.parseLobbyOptions(req.body); - const currentUser = lobbyOptions.rated + const createSessionRequest = this.parseCreateSessionRequest(req.body); + const currentUser = (createSessionRequest.lobbyOptions.rated || createSessionRequest.botPlayerIds.length > 0) ? await this.authService.getUserFromRequest(req) : null; - if (lobbyOptions.rated && !currentUser) { - res.status(401).json({ error: `Sign in with Discord to create rated lobbies.` }); + if (createSessionRequest.lobbyOptions.rated && !currentUser) { + res.status(401).json({ error: `Sign in to create rated lobbies.` }); return; } + if (createSessionRequest.botPlayerIds.length > 0 && !currentUser) { + res.status(401).json({ error: `Sign in to seat your bots in a lobby.` }); + return; + } + + const bots = currentUser + ? await this.accountBotService.requireOwnedBots(currentUser.id, createSessionRequest.botPlayerIds) + : []; + const response: CreateSessionResponse = this.sessionManager.createSession({ client: getRequestClientInfo(req), - lobbyOptions, + lobbyOptions: createSessionRequest.lobbyOptions, + bots, }); res.json(response); } catch (error: unknown) { - if (error instanceof SessionError) { + if (error instanceof SessionError || error instanceof AccountBotError) { res.status(409).json({ error: error.message }); return; } @@ -398,7 +529,10 @@ export class ApiRouter { this.router = router; } - private parseLobbyOptions(body: unknown): LobbyOptions { + private parseCreateSessionRequest(body: unknown): { + lobbyOptions: LobbyOptions; + botPlayerIds: string[]; + } { const request = zCreateSessionRequestInput.parse(body ?? {}); const visibility = request.lobbyOptions?.visibility; @@ -406,9 +540,12 @@ export class ApiRouter { const rated = request.lobbyOptions?.rated ?? DEFAULT_LOBBY_OPTIONS.rated; return { - visibility: visibility ?? DEFAULT_LOBBY_OPTIONS.visibility, - timeControl, - rated, + lobbyOptions: { + visibility: visibility ?? DEFAULT_LOBBY_OPTIONS.visibility, + timeControl, + rated, + }, + botPlayerIds: request.botPlayerIds ?? [], }; } @@ -420,6 +557,14 @@ export class ApiRouter { return zUpdateAccountPreferencesRequest.parse(body ?? {}).preferences; } + private parseCreateAccountBotRequest(body: unknown): CreateAccountBotRequest { + return zCreateAccountBotRequest.parse(body ?? {}); + } + + private parseUpdateAccountBotRequest(body: unknown): UpdateAccountBotRequest { + return zUpdateAccountBotRequest.parse(body ?? {}); + } + private parseAdminServerSettingsUpdate(body: unknown): ServerSettings { return zAdminUpdateServerSettingsRequest.parse(body ?? {}).settings; } diff --git a/packages/backend/src/persistence/accountBotRepository.ts b/packages/backend/src/persistence/accountBotRepository.ts new file mode 100644 index 0000000..59f6f46 --- /dev/null +++ b/packages/backend/src/persistence/accountBotRepository.ts @@ -0,0 +1,137 @@ +import type { AccountBot, AccountBotCapabilities, AccountBotName } from '@ih3t/shared'; +import { zAccountBot, zAccountBotCapabilities, zAccountBotName } from '@ih3t/shared'; +import type { Collection, Document } from 'mongodb'; +import type { Logger } from 'pino'; +import { inject, injectable } from 'tsyringe'; +import { z } from 'zod'; + +import { ROOT_LOGGER } from '../logger'; +import { MongoDatabase } from './mongoClient'; +import { ACCOUNT_BOTS_COLLECTION_NAME } from './mongoCollections'; + +const zAccountBotDocument = z.object({ + id: z.string().trim() + .min(1), + ownerProfileId: z.string().trim() + .min(1), + name: zAccountBotName, + endpoint: z.string().trim() + .min(1), + createdAt: z.number().int() + .nonnegative(), + updatedAt: z.number().int() + .nonnegative(), + capabilities: zAccountBotCapabilities, +}); + +type AccountBotDocument = z.infer & Document; + +type CreateAccountBotParams = { + id: string; + ownerProfileId: string; + name: AccountBotName; + endpoint: string; + createdAt: number; + updatedAt: number; + capabilities: AccountBotCapabilities; +}; + +type UpdateAccountBotParams = { + name: AccountBotName; + endpoint: string; + updatedAt: number; + capabilities: AccountBotCapabilities; +}; + +@injectable() +export class AccountBotRepository { + private collectionPromise: Promise> | null = null; + private readonly logger: Logger; + + constructor( + @inject(ROOT_LOGGER) rootLogger: Logger, + @inject(MongoDatabase) private readonly mongoDatabase: MongoDatabase, + ) { + this.logger = rootLogger.child({ component: `account-bot-repository` }); + } + + async countByOwnerProfileId(ownerProfileId: string): Promise { + const collection = await this.getCollection(); + return await collection.countDocuments({ ownerProfileId }); + } + + async listByOwnerProfileId(ownerProfileId: string): Promise { + const collection = await this.getCollection(); + const documents = await collection.find({ ownerProfileId }) + .sort({ updatedAt: -1, id: 1 }) + .toArray(); + + return documents.map((document) => zAccountBot.parse(document)); + } + + async getById(id: string): Promise { + const collection = await this.getCollection(); + const document = await collection.findOne({ id }); + return document ? zAccountBot.parse(document) : null; + } + + async getByOwnerProfileIdAndId(ownerProfileId: string, id: string): Promise { + const collection = await this.getCollection(); + const document = await collection.findOne({ ownerProfileId, id }); + return document ? zAccountBot.parse(document) : null; + } + + async createBot(params: CreateAccountBotParams): Promise { + const collection = await this.getCollection(); + const document = zAccountBotDocument.parse(params); + await collection.insertOne(document); + return zAccountBot.parse(document); + } + + async updateBot(ownerProfileId: string, id: string, params: UpdateAccountBotParams): Promise { + const collection = await this.getCollection(); + const update = z.object({ + name: zAccountBotName, + endpoint: z.string().trim() + .min(1), + updatedAt: z.number().int() + .nonnegative(), + capabilities: zAccountBotCapabilities, + }).parse(params); + + const document = await collection.findOneAndUpdate( + { ownerProfileId, id }, + { + $set: update, + }, + { + returnDocument: `after`, + }, + ); + + return document ? zAccountBot.parse(document) : null; + } + + async deleteBot(ownerProfileId: string, id: string): Promise { + const collection = await this.getCollection(); + const result = await collection.deleteOne({ ownerProfileId, id }); + return result.deletedCount > 0; + } + + private async getCollection(): Promise> { + if (this.collectionPromise !== null) { + return this.collectionPromise; + } + + this.collectionPromise = (async () => { + const database = await this.mongoDatabase.getDatabase(); + return database.collection(ACCOUNT_BOTS_COLLECTION_NAME); + })().catch((error: unknown) => { + this.collectionPromise = null; + this.logger.error({ err: error, event: `account-bots.init.failed` }, `Failed to initialize account bots collection`); + throw error; + }); + + return this.collectionPromise; + } +} diff --git a/packages/backend/src/persistence/migrations/008-account-bots.ts b/packages/backend/src/persistence/migrations/008-account-bots.ts new file mode 100644 index 0000000..0e34193 --- /dev/null +++ b/packages/backend/src/persistence/migrations/008-account-bots.ts @@ -0,0 +1,20 @@ +import type { Document } from 'mongodb'; + +import { ACCOUNT_BOTS_COLLECTION_NAME } from '../mongoCollections'; +import type { DatabaseMigration } from './types'; + +type AccountBotDocument = { + id: string; + ownerProfileId: string; + updatedAt: number; +} & Document; + +export const accountBotsMigration: DatabaseMigration = { + id: `008-account-bots`, + description: `Create account bot indexes`, + async up({ database }) { + const collection = database.collection(ACCOUNT_BOTS_COLLECTION_NAME); + await collection.createIndex({ id: 1 }, { unique: true }); + await collection.createIndex({ ownerProfileId: 1, updatedAt: -1 }); + }, +}; diff --git a/packages/backend/src/persistence/migrations/index.ts b/packages/backend/src/persistence/migrations/index.ts index a1e7982..5ee4519 100644 --- a/packages/backend/src/persistence/migrations/index.ts +++ b/packages/backend/src/persistence/migrations/index.ts @@ -5,6 +5,7 @@ import { metricsMigration } from './004-metrics'; import { sandboxPositionsMigration } from './005-sandbox-positions'; import { serverSettingsMigration } from './006-server-settings'; import k007 from "./007-fix-elo-new-users"; +import { accountBotsMigration } from './008-account-bots'; import type { DatabaseMigration } from './types'; export const databaseMigrations: readonly DatabaseMigration[] = [ @@ -15,4 +16,5 @@ export const databaseMigrations: readonly DatabaseMigration[] = [ sandboxPositionsMigration, serverSettingsMigration, k007, + accountBotsMigration, ]; diff --git a/packages/backend/src/persistence/mongoCollections.ts b/packages/backend/src/persistence/mongoCollections.ts index 29e809d..354053e 100644 --- a/packages/backend/src/persistence/mongoCollections.ts +++ b/packages/backend/src/persistence/mongoCollections.ts @@ -7,6 +7,7 @@ export const AUTH_VERIFICATION_TOKENS_COLLECTION_NAME export const GAME_HISTORY_COLLECTION_NAME = process.env.MONGODB_GAME_HISTORY_COLLECTION ?? `gameHistory`; export const METRICS_COLLECTION_NAME = process.env.MONGODB_METRICS_COLLECTION ?? `metrics`; export const SANDBOX_POSITIONS_COLLECTION_NAME = process.env.MONGODB_SANDBOX_POSITIONS_COLLECTION ?? `sandboxPositions`; +export const ACCOUNT_BOTS_COLLECTION_NAME = process.env.MONGODB_ACCOUNT_BOTS_COLLECTION ?? `accountBots`; export const SERVER_SETTINGS_COLLECTION_NAME = `serverSettings`; export const DATABASE_MIGRATIONS_COLLECTION_NAME = process.env.MONGODB_MIGRATIONS_COLLECTION ?? `databaseMigrations`; diff --git a/packages/backend/src/session/sessionManager.ts b/packages/backend/src/session/sessionManager.ts index 542d9cb..5cfb16f 100644 --- a/packages/backend/src/session/sessionManager.ts +++ b/packages/backend/src/session/sessionManager.ts @@ -1,6 +1,7 @@ import assert from 'node:assert'; import type { + AccountBot, BoardCell, CreateSessionResponse, GameState, @@ -21,6 +22,7 @@ import { inject, injectable } from 'tsyringe'; import { ServerSettingsService } from '../admin/serverSettingsService'; import { ServerShutdownService, type ShutdownHook } from '../admin/serverShutdownService'; +import { AccountBotService } from '../bots/accountBotService'; import { EloHandler } from '../elo/eloHandler'; import { ROOT_LOGGER } from '../logger'; import { MetricsTracker } from '../metrics/metricsTracker'; @@ -88,6 +90,7 @@ export class SessionManager { private eventHandlers: SessionManagerEventHandlers = {}; private readonly logger: Logger; private readonly sessions = new Map(); + private readonly activeBotTurns = new Set(); private readonly shutdownHook: ShutdownHook; constructor( @@ -96,6 +99,7 @@ export class SessionManager { @inject(GameSimulation) private readonly simulation: GameSimulation, @inject(GameTimeControlManager) private readonly timeControl: GameTimeControlManager, @inject(EloHandler) private readonly eloHandler: EloHandler, + @inject(AccountBotService) private readonly accountBotService: AccountBotService, @inject(GameHistoryRepository) private readonly gameHistoryRepository: GameHistoryRepository, @inject(MetricsTracker) private readonly metricsTracker: MetricsTracker, @inject(ServerSettingsService) private readonly serverSettingsService: ServerSettingsService, @@ -183,9 +187,18 @@ export class SessionManager { createSession(params: CreateSessionParams): CreateSessionResponse { this.assertNewGameCreationAllowed(`lobby`); + if (params.lobbyOptions.rated && params.bots && params.bots.length > 0) { + throw new SessionError(`Bots can only be used in casual games.`); + } + const sessionId = this.createSessionId(); const session = createGameSession(sessionId, params.lobbyOptions); + if (params.bots?.length) { + session.players = params.bots.map((bot) => this.createBotParticipant(session, bot)); + session.hadPlayers = session.players.length > 0; + } + this.sessions.set(session.id, session); /* @@ -209,6 +222,11 @@ export class SessionManager { client: params.client, }); + if (session.players.length > 0) { + this.emitLobbyUpdated(session); + void this.tickSession(session); + } + return { sessionId }; } @@ -225,7 +243,7 @@ export class SessionManager { const profileId = params.profile?.id ?? null; if (session.gameOptions.rated && !profileId) { - throw new SessionError(`Sign in with Discord to join rated games.`); + throw new SessionError(`Sign in to join rated games.`); } @@ -266,6 +284,9 @@ export class SessionManager { deviceId: params.deviceId, profileId: params.profile?.id ?? null, displayName, + isBot: false, + botId: null, + botOwnerProfileId: null, rating: playerRating, ratingAdjustment: null, @@ -286,6 +307,9 @@ export class SessionManager { deviceId: params.deviceId, profileId: params.profile?.id ?? null, displayName: params.displayName, + isBot: false, + botId: null, + botOwnerProfileId: null, rating: playerRating, ratingAdjustment: null, @@ -352,6 +376,7 @@ export class SessionManager { async placeCell(session: ServerGameSession, playerId: string, x: number, y: number) { await session.lock.runExclusive(async () => await this.placeCellLocked(session, playerId, x, y)); + this.triggerBotTurnIfNeeded(session.id); } private async placeCellLocked(session: ServerGameSession, playerId: string, x: number, y: number) { @@ -457,6 +482,11 @@ export class SessionManager { if (!session.rematchAcceptedPlayerIds.includes(participantId)) { session.rematchAcceptedPlayerIds = [...session.rematchAcceptedPlayerIds, participantId]; } + for (const botPlayer of session.players.filter((player) => player.isBot)) { + if (!session.rematchAcceptedPlayerIds.includes(botPlayer.id)) { + session.rematchAcceptedPlayerIds = [...session.rematchAcceptedPlayerIds, botPlayer.id]; + } + } this.emitSessionUpdated(session, [`state`]); return { @@ -500,7 +530,9 @@ export class SessionManager { id: newParticipantId, deviceId: player.deviceId, - connection: { status: `disconnected`, timestamp: Date.now() }, + connection: player.isBot + ? ({ status: `connected`, socketId: this.getBotSocketId(player.botId ?? newParticipantId) } satisfies ServerParticipantConnection) + : ({ status: `disconnected`, timestamp: Date.now() } satisfies ServerParticipantConnection), displayName: player.displayName, rating: player.ratingAdjusted ?? player.rating, @@ -508,6 +540,9 @@ export class SessionManager { ratingAdjusted: null, profileId: player.profileId, + isBot: player.isBot, + botId: player.botId, + botOwnerProfileId: player.botOwnerProfileId, }; }); rematchSession.players.reverse(); @@ -528,6 +563,9 @@ export class SessionManager { ratingAdjusted: null, profileId: player.profileId, + isBot: player.isBot, + botId: player.botId, + botOwnerProfileId: player.botOwnerProfileId, }; }); @@ -638,6 +676,7 @@ export class SessionManager { private async tickSession(session: ServerGameSession) { await session.lock.runExclusive(async () => this.tickSessionLocked(session)); + this.triggerBotTurnIfNeeded(session.id); } private deleteSession(session: ServerGameSession, reason: string) { @@ -655,6 +694,7 @@ export class SessionManager { ); this.timeControl.clearSession(session.id); + this.activeBotTurns.delete(session.id); this.sessions.delete(session.id); this.eventHandlers.lobbyRemoved?.({ id: session.id }); this.shutdownHook.tryShutdown(); @@ -815,6 +855,7 @@ export class SessionManager { }); this.timeControl.clearSession(session.id); + this.activeBotTurns.delete(session.id); /* finished sessions are removed from the list */ this.eventHandlers.lobbyRemoved?.({ id: session.id }); @@ -1206,6 +1247,220 @@ export class SessionManager { return participantId; } + private createBotParticipant(session: ServerGameSession, bot: AccountBot): ServerSessionParticipant { + return { + id: this.createParticipantId(session), + deviceId: `bot:${bot.id}`, + profileId: null, + displayName: bot.name, + isBot: true, + botId: bot.id, + botOwnerProfileId: bot.ownerProfileId, + rating: { + eloScore: 0, + gameCount: 0, + }, + ratingAdjustment: null, + ratingAdjusted: null, + connection: { + status: `connected`, + socketId: this.getBotSocketId(bot.id), + }, + }; + } + + private getBotSocketId(botId: string): string { + return `bot:${botId}`; + } + + private triggerBotTurnIfNeeded(sessionId: string): void { + const session = this.sessions.get(sessionId); + if (!session || session.state !== `in-game` || this.activeBotTurns.has(sessionId)) { + return; + } + + const currentPlayer = session.players.find((player) => player.id === session.gameState.currentTurnPlayerId); + if (!currentPlayer?.isBot) { + return; + } + + this.activeBotTurns.add(sessionId); + void this.runBotTurnLoop(sessionId) + .catch((error: unknown) => { + this.logger.error({ err: error, sessionId, event: `bot-turn.failed` }, `Bot turn loop failed`); + }) + .finally(() => { + this.activeBotTurns.delete(sessionId); + + const activeSession = this.sessions.get(sessionId); + const activeBotPlayer = activeSession?.players.find((player) => player.id === activeSession.gameState.currentTurnPlayerId); + if (activeSession?.state === `in-game` && activeBotPlayer?.isBot) { + queueMicrotask(() => this.triggerBotTurnIfNeeded(sessionId)); + } + }); + } + + private async runBotTurnLoop(sessionId: string): Promise { + while (true) { + const session = this.sessions.get(sessionId); + if (!session) { + return; + } + + const pendingTurn = await session.lock.runExclusive(async () => this.getPendingBotTurnLocked(session)); + if (!pendingTurn) { + return; + } + + if (pendingTurn.kind === `opening-origin`) { + await this.placeCell(session, pendingTurn.playerId, 0, 0); + continue; + } + + const bot = await this.accountBotService.getBotById(pendingTurn.botId); + if (!bot) { + await this.forfeitBotTurn(session, pendingTurn.playerId, `Bot configuration no longer exists.`); + return; + } + + let move; + try { + move = await this.accountBotService.requestMove(bot, pendingTurn.request); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : `Bot request failed.`; + await this.forfeitBotTurn(session, pendingTurn.playerId, message); + return; + } + + const applyResult = await session.lock.runExclusive(async () => { + if (session.state !== `in-game` || session.gameState.currentTurnPlayerId !== pendingTurn.playerId) { + return { status: `stale` } as const; + } + + const currentPlayer = session.players.find((player) => player.id === pendingTurn.playerId); + if (!currentPlayer?.isBot || currentPlayer.botId !== pendingTurn.botId) { + return { status: `stale` } as const; + } + + if (move.pieces[0].x === move.pieces[1].x && move.pieces[0].y === move.pieces[1].y) { + return { status: `invalid`, message: `Bot returned the same placement twice.` } as const; + } + + try { + await this.placeCellLocked(session, pendingTurn.playerId, move.pieces[0].x, move.pieces[0].y); + if (session.state !== `in-game`) { + return { status: `done` } as const; + } + + await this.placeCellLocked(session, pendingTurn.playerId, move.pieces[1].x, move.pieces[1].y); + return { status: `applied` } as const; + } catch (error: unknown) { + if (error instanceof SessionError) { + return { status: `invalid`, message: error.message } as const; + } + + throw error; + } + }); + + if (applyResult.status === `stale` || applyResult.status === `done`) { + return; + } + + if (applyResult.status === `invalid`) { + await this.forfeitBotTurn(session, pendingTurn.playerId, applyResult.message); + return; + } + } + } + + private getPendingBotTurnLocked(session: ServerGameSession): + | { + kind: `opening-origin`; + playerId: string; + } + | { + kind: `stateless-turn`; + playerId: string; + botId: string; + request: Parameters[1]; + } + | null { + if (session.state !== `in-game`) { + return null; + } + + const currentPlayerId = session.gameState.currentTurnPlayerId; + if (!currentPlayerId) { + return null; + } + + const currentPlayer = session.players.find((player) => player.id === currentPlayerId); + if (!currentPlayer?.isBot || !currentPlayer.botId) { + return null; + } + + if (session.gameState.cells.length === 0 && session.gameState.placementsRemaining === 1) { + return { + kind: `opening-origin`, + playerId: currentPlayerId, + }; + } + + const playerOneId = session.players[0]?.id; + const playerTwoId = session.players[1]?.id; + if (!playerOneId || !playerTwoId) { + return null; + } + + const currentPlayerIndex = session.players.findIndex((player) => player.id === currentPlayerId); + if (currentPlayerIndex === -1) { + return null; + } + + const timeLimitSeconds = session.gameState.currentTurnExpiresAt + ? Math.max(0.1, (session.gameState.currentTurnExpiresAt - Date.now()) / 1000) + : undefined; + + return { + kind: `stateless-turn`, + playerId: currentPlayerId, + botId: currentPlayer.botId, + request: { + toMove: currentPlayerIndex === 0 ? `x` : `o`, + cells: session.gameState.cells.map((cell) => ({ + x: cell.x, + y: cell.y, + piece: cell.occupiedBy === playerOneId ? `x` : `o`, + })), + timeLimitSeconds, + }, + }; + } + + private async forfeitBotTurn(session: ServerGameSession, playerId: string, reason: string): Promise { + this.logger.warn({ + event: `bot-turn.forfeit`, + sessionId: session.id, + playerId, + reason, + }, `Bot forfeited the game`); + + await session.lock.runExclusive(async () => { + if (session.state !== `in-game`) { + return; + } + + const botPlayer = session.players.find((player) => player.id === playerId); + if (!botPlayer?.isBot) { + return; + } + + const winningPlayerId = session.players.find((player) => player.id !== playerId)?.id ?? null; + await this.finishSessionLocked(session, `surrender`, winningPlayerId); + }); + } + private toSessionInfo(session: ServerGameSession): SessionInfo { let state: SessionState; switch (session.state) { @@ -1260,6 +1515,9 @@ export class SessionManager { players: session.players.map((player) => ({ displayName: player.displayName, profileId: player.profileId, + isBot: player.isBot, + botId: player.botId, + botOwnerProfileId: player.botOwnerProfileId, elo: player.rating.eloScore, })), @@ -1290,7 +1548,10 @@ export class SessionManager { return session.players.map((player, playerIndex) => ({ playerId: player.id, displayName: player.displayName || `Player ${playerIndex + 1}`, - profileId: player.profileId ?? player.id, + profileId: player.profileId, + isBot: player.isBot, + botId: player.botId, + botOwnerProfileId: player.botOwnerProfileId, elo: player.rating?.eloScore ?? null, eloChange: null, })); diff --git a/packages/backend/src/session/types.ts b/packages/backend/src/session/types.ts index 0f0d868..15bac15 100644 --- a/packages/backend/src/session/types.ts +++ b/packages/backend/src/session/types.ts @@ -1,4 +1,5 @@ import { + AccountBot, cloneGameState, createEmptyGameState, EventLobbyRemoved, @@ -83,6 +84,7 @@ export type JoinSessionParams = { export type CreateSessionParams = { client: RequestClientInfo; lobbyOptions: LobbyOptions; + bots?: AccountBot[]; }; export type ParticipantLeftEvent = { @@ -153,6 +155,9 @@ export function cloneSessionParticipant(participant: ServerSessionParticipant): displayName: participant.displayName, profileId: participant.profileId, + isBot: participant.isBot, + botId: participant.botId, + botOwnerProfileId: participant.botOwnerProfileId, rating: participant.rating, ratingAdjustment: participant.ratingAdjustment, diff --git a/packages/frontend/src/components/AccountPreferencesScreen.tsx b/packages/frontend/src/components/AccountPreferencesScreen.tsx index 2c011d8..2112bb0 100644 --- a/packages/frontend/src/components/AccountPreferencesScreen.tsx +++ b/packages/frontend/src/components/AccountPreferencesScreen.tsx @@ -1,10 +1,10 @@ -import type { AccountPreferences, AccountProfile } from '@ih3t/shared'; +import type { AccountBot, AccountPreferences, AccountProfile } from '@ih3t/shared'; import { useState } from 'react'; import React from 'react'; +import { Link } from 'react-router'; import { toast } from 'react-toastify'; -import { updateAccountPreferences } from '../query/accountClient'; -import { signInWithDiscord } from '../query/authClient'; +import { createAccountBot, deleteAccountBot, updateAccountBot, updateAccountPreferences } from '../query/accountClient'; import PageCorpus from './PageCorpus'; function showErrorToast(message: string) { @@ -16,10 +16,13 @@ function showErrorToast(message: string) { type AccountPreferencesScreenProps = { account: AccountProfile | null preferences: AccountPreferences | null + bots: AccountBot[] isLoading: boolean isPreferencesLoading: boolean + isBotsLoading: boolean errorMessage: string | null preferencesErrorMessage: string | null + botsErrorMessage: string | null }; function PreferencesLoadingState() { @@ -38,6 +41,226 @@ function PreferencesErrorState({ message }: Readonly<{ message: string }>) { ); } +type BotManagerProps = { + bots: AccountBot[] + isLoading: boolean + errorMessage: string | null +}; + +function BotManager({ bots, isLoading, errorMessage }: Readonly) { + const [editingBotId, setEditingBotId] = useState(null); + const [name, setName] = useState(``); + const [endpoint, setEndpoint] = useState(``); + const [isSaving, setIsSaving] = useState(false); + const [isDeletingBotId, setIsDeletingBotId] = useState(null); + + const resetForm = () => { + setEditingBotId(null); + setName(``); + setEndpoint(``); + }; + + const handleEdit = (bot: AccountBot) => { + setEditingBotId(bot.id); + setName(bot.name); + setEndpoint(bot.endpoint); + }; + + const handleSave = async () => { + setIsSaving(true); + + try { + if (editingBotId) { + await updateAccountBot(editingBotId, { name, endpoint }); + } else { + await createAccountBot({ name, endpoint }); + } + + resetForm(); + } catch (error) { + console.error(`Failed to save bot:`, error); + showErrorToast(error instanceof Error ? error.message : `Failed to save bot.`); + } finally { + setIsSaving(false); + } + }; + + const handleDelete = async (botId: string) => { + setIsDeletingBotId(botId); + + try { + await deleteAccountBot(botId); + if (editingBotId === botId) { + resetForm(); + } + } catch (error) { + console.error(`Failed to delete bot:`, error); + showErrorToast(error instanceof Error ? error.message : `Failed to delete bot.`); + } finally { + setIsDeletingBotId(null); + } + }; + + return ( +
+
+
+

+ Bot Players +

+ +

+ Save up to 20 stateless HTTTX bots tied to your account. Saved bots can be seated directly in new casual lobbies. +

+
+ +
+ {bots.length} + /20 saved +
+
+ + {isLoading ? ( +
+ Loading your bots... +
+ ) : errorMessage ? ( +
+ {errorMessage} +
+ ) : ( + +
+
+
+ {editingBotId ? `Edit Bot` : `Add Bot`} +
+ +
+ + + + +
+ The server verifies `GET /capabilities.json` and currently requires stateless `v1-alpha` support before a bot can be saved. +
+
+ +
+ + + {(editingBotId || name || endpoint) && ( + + )} +
+
+ +
+
+ Saved Bots +
+ + {bots.length === 0 ? ( +
+ No bots saved yet. +
+ ) : ( +
+ {bots.map((bot) => ( +
+
+
+
+ {bot.name} +
+ +
+ {bot.endpoint} +
+
+ +
+ stateless +
+
+ + {(bot.capabilities.meta.author || bot.capabilities.meta.version) && ( +
+ {[bot.capabilities.meta.author, bot.capabilities.meta.version].filter(Boolean).join(` • `)} +
+ )} + +
+ + + +
+
+ ))} +
+ )} +
+
+
+ )} +
+ ); +} + type PreferenceSwitchCardProps = { label: string description: string @@ -97,22 +320,16 @@ function PreferenceSwitchCard({ function AccountPreferencesScreen({ account, preferences, + bots, isLoading, isPreferencesLoading, + isBotsLoading, errorMessage, preferencesErrorMessage, + botsErrorMessage, }: Readonly) { const [savingPreferenceKey, setSavingPreferenceKey] = useState(null); - const handleSignIn = async () => { - try { - await signInWithDiscord(); - } catch (error) { - console.error(`Failed to start Discord sign in:`, error); - showErrorToast(error instanceof Error ? error.message : `Failed to start Discord sign in.`); - } - }; - async function handlePreferenceToggle( key: PreferenceKey, nextValue: AccountPreferences[PreferenceKey], @@ -167,15 +384,15 @@ function AccountPreferencesScreen({

- Sign in with Discord to manage your account preferences. + Sign in to manage your account preferences.

- + Sign In + ) : ( @@ -239,6 +456,12 @@ function AccountPreferencesScreen({
{isSavingPreference ? `Saving your latest preference change...` : `Changes save automatically.`}
+ + )} diff --git a/packages/frontend/src/components/AuthScreen.tsx b/packages/frontend/src/components/AuthScreen.tsx new file mode 100644 index 0000000..2754384 --- /dev/null +++ b/packages/frontend/src/components/AuthScreen.tsx @@ -0,0 +1,258 @@ +import type { AccountProfile } from '@ih3t/shared'; +import { useState } from 'react'; +import { Link, Navigate, useSearchParams } from 'react-router'; + +import { getSignInErrorMessage, registerCredentialsAccount, signInWithCredentials, signInWithDiscord } from '../query/authClient'; +import PageCorpus from './PageCorpus'; + +type AuthScreenProps = { + account: AccountProfile | null + isLoading: boolean +}; + +function resolveCallbackPath(callbackUrl: string | null): string { + try { + const fallbackOrigin = typeof window === `undefined` ? `http://localhost:3000` : window.location.origin; + const target = new URL(callbackUrl ?? `/`, fallbackOrigin); + if (typeof window !== `undefined` && target.origin !== window.location.origin) { + return `/`; + } + + return `${target.pathname}${target.search}${target.hash}` || `/`; + } catch { + return `/`; + } +} + +function AuthScreen({ account, isLoading }: Readonly) { + const [searchParams] = useSearchParams(); + const callbackPath = resolveCallbackPath(searchParams.get(`callbackUrl`)); + const callbackUrl = typeof window === `undefined` + ? callbackPath + : new URL(callbackPath, window.location.origin).toString(); + + const [signInUsername, setSignInUsername] = useState(``); + const [signInPassword, setSignInPassword] = useState(``); + const [registerUsername, setRegisterUsername] = useState(``); + const [registerPassword, setRegisterPassword] = useState(``); + const [errorMessage, setErrorMessage] = useState(getSignInErrorMessage(searchParams.get(`error`))); + const [activeAction, setActiveAction] = useState<`discord` | `sign-in` | `register` | null>(null); + + if (!isLoading && account) { + return ; + } + + const isBusy = activeAction !== null; + + async function handleCredentialsSignIn() { + setActiveAction(`sign-in`); + setErrorMessage(null); + + try { + const result = await signInWithCredentials({ + username: signInUsername, + password: signInPassword, + }, callbackUrl); + + if (result.errorMessage) { + setErrorMessage(result.errorMessage); + return; + } + + window.location.assign(result.redirectUrl); + } catch (error) { + setErrorMessage(error instanceof Error ? error.message : `Unable to sign in right now.`); + } finally { + setActiveAction(current => current === `sign-in` ? null : current); + } + } + + async function handleRegister() { + setActiveAction(`register`); + setErrorMessage(null); + + try { + await registerCredentialsAccount({ + username: registerUsername, + password: registerPassword, + }); + + const result = await signInWithCredentials({ + username: registerUsername, + password: registerPassword, + }, callbackUrl); + + if (result.errorMessage) { + setErrorMessage(result.errorMessage); + return; + } + + window.location.assign(result.redirectUrl); + } catch (error) { + setErrorMessage(error instanceof Error ? error.message : `Unable to create your account right now.`); + } finally { + setActiveAction(current => current === `register` ? null : current); + } + } + + async function handleDiscordSignIn() { + setActiveAction(`discord`); + setErrorMessage(null); + + try { + await signInWithDiscord(callbackUrl); + } catch (error) { + setErrorMessage(error instanceof Error ? error.message : `Unable to start Discord sign-in.`); + setActiveAction(null); + } + } + + return ( + +
+
+
+
+ Returning Player +
+ +

+ Username Login +

+ +
+ + + +
+ + + +
+
+ Or +
+
+ + +
+ +
+
+ New Account +
+ +

+ Register +

+ +

+ Create a local account for browser-based sign-in. You can change your username later from account settings. +

+ +
+ + + +
+ + + +

+ Passwords must be 8 to 72 characters long. If you already have an account, use the sign-in panel instead. +

+
+
+ + {errorMessage && ( +
+ {errorMessage} +
+ )} + + {isLoading && ( +
+ Checking your current session... +
+ )} + +
+ By continuing, you agree to use this account for normal play only. See the game rules if you need a refresher. +
+
+
+ ); +} + +export default AuthScreen; diff --git a/packages/frontend/src/components/CommonPageLayout.tsx b/packages/frontend/src/components/CommonPageLayout.tsx index d6dce1a..ab5a57d 100644 --- a/packages/frontend/src/components/CommonPageLayout.tsx +++ b/packages/frontend/src/components/CommonPageLayout.tsx @@ -3,7 +3,7 @@ import { NavLink, Outlet, useLocation } from 'react-router'; import { toast } from 'react-toastify'; import { useQueryAccount } from '../query/accountClient'; -import { signInWithDiscord, signOutAccount } from '../query/authClient'; +import { signOutAccount } from '../query/authClient'; import AccountPicture from './AccountPicture'; import AppErrorBoundary from './AppErrorBoundary'; @@ -115,15 +115,6 @@ function CommonPageLayout({ limitWidth }: { limitWidth: boolean }) { return () => document.removeEventListener(`keydown`, handleKeyDown); }, []); - const handleSignIn = async () => { - try { - await signInWithDiscord(); - } catch (error) { - console.error(`Failed to start Discord sign in:`, error); - showErrorToast(error instanceof Error ? error.message : `Failed to start Discord sign in.`); - } - }; - const handleSignOut = async () => { try { await signOutAccount(); @@ -249,24 +240,13 @@ function CommonPageLayout({ limitWidth }: { limitWidth: boolean }) { )} ) : ( - + Sign In + )} diff --git a/packages/frontend/src/components/CreateLobbyDialog.spec.tsx b/packages/frontend/src/components/CreateLobbyDialog.spec.tsx index fa1d8b0..855b141 100644 --- a/packages/frontend/src/components/CreateLobbyDialog.spec.tsx +++ b/packages/frontend/src/components/CreateLobbyDialog.spec.tsx @@ -30,6 +30,7 @@ test('submits casual match defaults for guests', async ({ mount }) => { closeCount += 1 }} account={null} + accountBots={[]} onCreateLobby={(request) => { createRequest = request }} @@ -51,6 +52,7 @@ test('submits casual match defaults for guests', async ({ mount }) => { }, rated: false, }, + botPlayerIds: [], }) await component.getByRole('button', { name: /^Cancel$/i }).click() @@ -65,6 +67,7 @@ test('submits a rated private turn-based lobby for authenticated players', async isOpen onClose={() => { }} account={authenticatedAccount} + accountBots={[]} onCreateLobby={(request) => { createRequest = request }} @@ -91,6 +94,7 @@ test('submits a rated private turn-based lobby for authenticated players', async }, rated: true, }, + botPlayerIds: [], }) }) @@ -100,6 +104,7 @@ test('matches the authenticated lobby dialog screenshot', async ({ mount }) => { isOpen onClose={() => { }} account={authenticatedAccount} + accountBots={[]} onCreateLobby={() => { }} /> ) diff --git a/packages/frontend/src/components/CreateLobbyDialog.tsx b/packages/frontend/src/components/CreateLobbyDialog.tsx index 686030b..e4ed5b7 100644 --- a/packages/frontend/src/components/CreateLobbyDialog.tsx +++ b/packages/frontend/src/components/CreateLobbyDialog.tsx @@ -1,4 +1,4 @@ -import type { AccountProfile, CreateSessionRequest, GameTimeControl, LobbyVisibility } from '@ih3t/shared'; +import type { AccountBot, AccountProfile, CreateSessionRequest, GameTimeControl, LobbyVisibility } from '@ih3t/shared'; import { useEffect, useMemo, useState } from 'react'; import { formatGameTimeSeconds } from '../utils/gameTimeControl'; @@ -7,6 +7,7 @@ type CreateLobbyDialogProps = { isOpen: boolean onClose: () => void account: AccountProfile | null + accountBots: AccountBot[] onCreateLobby: (request: CreateSessionRequest) => void }; @@ -93,6 +94,7 @@ function CreateLobbyDialog({ isOpen, onClose, account, + accountBots, onCreateLobby, }: Readonly) { const canCreateRatedLobby = Boolean(account); @@ -102,11 +104,22 @@ function CreateLobbyDialog({ const [turnTimeStepIndex, setTurnTimeStepIndex] = useState(TURN_TIME_STEP_SECONDS.indexOf(TURN_TIME_DEFAULT)); const [matchTimeStepIndex, setMatchTimeStepIndex] = useState(MATCH_TIME_STEP_MINUTES.indexOf(MATCH_TIME_DEFAULT)); const [incrementStepIndex, setIncrementStepIndex] = useState(INCREMENT_STEP_SECONDS.indexOf(INCREMENT_DEFAULT)); + const [selectedBotIds, setSelectedBotIds] = useState([]); useEffect(() => { setRated(canCreateRatedLobby); }, [canCreateRatedLobby]); + useEffect(() => { + if (selectedBotIds.length > 0) { + setRated(false); + } + }, [selectedBotIds.length]); + + useEffect(() => { + setSelectedBotIds((currentBotIds) => currentBotIds.filter((botId) => accountBots.some((bot) => bot.id === botId))); + }, [accountBots]); + const turnTimeSeconds = TURN_TIME_STEP_SECONDS[turnTimeStepIndex]; const matchTimeMinutes = MATCH_TIME_STEP_MINUTES[matchTimeStepIndex]; const incrementSeconds = INCREMENT_STEP_SECONDS[incrementStepIndex]; @@ -145,6 +158,21 @@ function CreateLobbyDialog({ timeControl: selectedTimeControl, rated, }, + botPlayerIds: selectedBotIds, + }); + }; + + const toggleBot = (botId: string) => { + setSelectedBotIds((currentBotIds) => { + if (currentBotIds.includes(botId)) { + return currentBotIds.filter((currentBotId) => currentBotId !== botId); + } + + if (currentBotIds.length >= 2) { + return currentBotIds; + } + + return [...currentBotIds, botId]; }); }; @@ -197,12 +225,12 @@ function CreateLobbyDialog({ { - if (canCreateRatedLobby) { + if (canCreateRatedLobby && selectedBotIds.length === 0) { setRated(true); } }} selected={rated} - disabled={!canCreateRatedLobby} + disabled={!canCreateRatedLobby || selectedBotIds.length > 0} title="Rated" description="Rated game with ELO" /> @@ -213,6 +241,12 @@ function CreateLobbyDialog({ Rated lobbies are for authenticated players only. )} + + {selectedBotIds.length > 0 && ( +
+ Bot-seated lobbies are always casual. +
+ )}
@@ -381,6 +415,51 @@ function CreateLobbyDialog({
+ +
+
+
+
+ Bot Seats +
+
+ +
+ {selectedBotIds.length} + /2 selected +
+
+ + {!account ? ( +
+ Sign in with Discord to seat bots in a lobby. +
+ ) : accountBots.length === 0 ? ( +
+ No bots saved yet. Add bots from your account preferences page, then come back here to seat them. +
+ ) : ( +
+ {accountBots.map((bot) => { + const selected = selectedBotIds.includes(bot.id); + const disabled = !selected && selectedBotIds.length >= 2; + + return ( + toggleBot(bot.id)} + selected={selected} + disabled={disabled} + title={bot.name} + description={bot.capabilities.meta.name + ? `${bot.capabilities.meta.name} • ${bot.endpoint}` + : bot.endpoint} + /> + ); + })} +
+ )} +
diff --git a/packages/frontend/src/components/FinishedGamesScreen.tsx b/packages/frontend/src/components/FinishedGamesScreen.tsx index 6486394..7419a77 100644 --- a/packages/frontend/src/components/FinishedGamesScreen.tsx +++ b/packages/frontend/src/components/FinishedGamesScreen.tsx @@ -124,7 +124,7 @@ function FinishedGamesScreen({
- Sign in with Discord to unlock your own match history. + Sign in to unlock your own match history.
)} diff --git a/packages/frontend/src/components/GameScreen.tsx b/packages/frontend/src/components/GameScreen.tsx index 5378600..efefdf9 100644 --- a/packages/frontend/src/components/GameScreen.tsx +++ b/packages/frontend/src/components/GameScreen.tsx @@ -67,6 +67,7 @@ function GameScreen({ return players.map(player => ({ playerId: player.id, profileId: player.profileId, + isBot: player.isBot ?? false, displayName: player.displayName, displayColor: getPlayerTileColor(gameState.playerTiles, player.id), diff --git a/packages/frontend/src/components/LobbyScreen.spec.tsx b/packages/frontend/src/components/LobbyScreen.spec.tsx index 5441a8c..15177e5 100644 --- a/packages/frontend/src/components/LobbyScreen.spec.tsx +++ b/packages/frontend/src/components/LobbyScreen.spec.tsx @@ -151,6 +151,7 @@ function createLobbyScreenProps(overrides: Partial = {}) { isConnected: true, shutdown: null, account: signedInAccount, + accountBots: [], isAccountLoading: false, liveSessions: [openLobby, ratedLobby, activeLobby], unreadChangelogEntries: 2, diff --git a/packages/frontend/src/components/LobbyScreen.tsx b/packages/frontend/src/components/LobbyScreen.tsx index 8d818ad..39d9f69 100644 --- a/packages/frontend/src/components/LobbyScreen.tsx +++ b/packages/frontend/src/components/LobbyScreen.tsx @@ -1,4 +1,4 @@ -import type { AccountProfile, CreateSessionRequest, LobbyInfo, ShutdownState } from '@ih3t/shared'; +import type { AccountBot, AccountProfile, CreateSessionRequest, LobbyInfo, ShutdownState } from '@ih3t/shared'; import { useEffect, useState } from 'react'; import { useSsrCompatibleNow } from '../ssrState'; @@ -12,6 +12,7 @@ type LobbyScreenProps = { isConnected: boolean shutdown: ShutdownState | null account: AccountProfile | null + accountBots: AccountBot[] isAccountLoading: boolean liveSessions: LobbyInfo[] unreadChangelogEntries: number @@ -37,6 +38,7 @@ function LobbyScreen({ isConnected, shutdown, account, + accountBots, isAccountLoading, liveSessions, unreadChangelogEntries, @@ -65,6 +67,7 @@ function LobbyScreen({ isOpen={isCreateLobbyDialogOpen} onClose={() => setIsCreateLobbyDialogOpen(false)} account={account} + accountBots={accountBots} onCreateLobby={onHostGame} /> diff --git a/packages/frontend/src/components/ProfileScreen.spec.tsx b/packages/frontend/src/components/ProfileScreen.spec.tsx index 5b39ae7..8a058cc 100644 --- a/packages/frontend/src/components/ProfileScreen.spec.tsx +++ b/packages/frontend/src/components/ProfileScreen.spec.tsx @@ -172,56 +172,9 @@ async function setRenderTimestamp(page: { addInitScript: (callback: (value: numb }, renderTimestamp) } -test('starts the Discord sign-in flow for private account access', async ({ mount, page }) => { +test('links private account access to the login screen', async ({ mount, page }) => { await setRenderTimestamp(page) - await page.route('**/auth/csrf', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - csrfToken: 'csrf-token-123', - }), - }) - }) - - await page.evaluate(() => { - const originalSubmit = HTMLFormElement.prototype.submit - - ; (window as typeof window & { - __profileSignInSubmission: { - action: string - method: string - values: Record - } | null - __restoreProfileFormSubmit?: () => void - }).__profileSignInSubmission = null - - HTMLFormElement.prototype.submit = function submit() { - ; (window as typeof window & { - __profileSignInSubmission: { - action: string - method: string - values: Record - } | null - }).__profileSignInSubmission = { - action: this.action, - method: this.method, - values: Object.fromEntries( - Array.from(this.elements) - .filter((element): element is HTMLInputElement => element instanceof HTMLInputElement) - .map((input) => [input.name, input.value]) - ), - } - } - - ; (window as typeof window & { - __restoreProfileFormSubmit?: () => void - }).__restoreProfileFormSubmit = () => { - HTMLFormElement.prototype.submit = originalSubmit - } - }) - const component = await mount( { - return await page.evaluate(() => { - return (window as typeof window & { - __profileSignInSubmission: { - action: string - method: string - values: Record - } | null - }).__profileSignInSubmission - }) - }).not.toBeNull() - - const submission = await page.evaluate(() => { - return (window as typeof window & { - __profileSignInSubmission: { - action: string - method: string - values: Record - } | null - }).__profileSignInSubmission - }) - - expect(submission?.action).toMatch(/\/auth\/signin\/discord$/) - expect(submission?.method).toBe('post') - expect(submission?.values.csrfToken).toBe('csrf-token-123') - expect(submission?.values.callbackUrl).toBe(page.url()) - - await page.evaluate(() => { - ; (window as typeof window & { - __restoreProfileFormSubmit?: () => void - }).__restoreProfileFormSubmit?.() - }) + await expect(component.getByRole('link', { name: 'Sign In' })).toHaveAttribute('href', '/login') }) test('matches the full profile statistics screen', async ({ mount, page }) => { diff --git a/packages/frontend/src/components/ProfileScreen.tsx b/packages/frontend/src/components/ProfileScreen.tsx index f88941b..082d73c 100644 --- a/packages/frontend/src/components/ProfileScreen.tsx +++ b/packages/frontend/src/components/ProfileScreen.tsx @@ -2,7 +2,6 @@ import type { AccountEloHistory, AccountStatistics, FinishedGamesPage, FinishedG import { type ReactNode, useMemo } from 'react'; import React from 'react'; import { Link } from 'react-router'; -import { toast } from 'react-toastify'; import { CartesianGrid, Line, @@ -13,7 +12,6 @@ import { YAxis, } from 'recharts'; -import { signInWithDiscord } from '../query/authClient'; import { buildFinishedGamePath, buildSessionPath } from '../routes/archiveRouteState'; import { useSsrCompatibleNow } from '../ssrState'; import { @@ -38,12 +36,6 @@ import PageCorpus from './PageCorpus'; const thirtyDaysMs = 30 * 24 * 60 * 60 * 1000; -function showErrorToast(message: string) { - toast.error(message, { - toastId: `error:${message}`, - }); -} - type ProfileScreenProps = { account: PublicAccountProfile | null statistics: AccountStatistics | null @@ -585,15 +577,6 @@ function ProfileScreen({ const intlFormatProvider = useIntlFormatProvider(); const now = useSsrCompatibleNow(); - const handleSignIn = async () => { - try { - await signInWithDiscord(); - } catch (error) { - console.error(`Failed to start Discord sign in:`, error); - showErrorToast(error instanceof Error ? error.message : `Failed to start Discord sign in.`); - } - }; - const isMissingPublicProfile = isPublicView && errorMessage === `Profile not found.`; const memberSinceLabel = account ? formatCalendarDate(intlFormatProvider, account.registeredAt) : null; const lastSeenLabel = account ? formatRelativeTimeFrom(intlFormatProvider, account.lastActiveAt, now) : null; @@ -660,15 +643,15 @@ function ProfileScreen({

- Sign in with Discord to view your account details and competitive standing. + Sign in to view your account details and competitive standing.

- + Sign In + ) @@ -689,7 +672,7 @@ function ProfileScreen({ - Discord Account + Account diff --git a/packages/frontend/src/components/PublicMatchesList.tsx b/packages/frontend/src/components/PublicMatchesList.tsx index 7d3b124..438b240 100644 --- a/packages/frontend/src/components/PublicMatchesList.tsx +++ b/packages/frontend/src/components/PublicMatchesList.tsx @@ -98,7 +98,8 @@ function formatPlayerLabel(player: LobbyInfo[`players`][number] | undefined, rat return null; } - return rated ? `${player.displayName} (${player.elo})` : player.displayName; + const baseLabel = rated ? `${player.displayName} (${player.elo})` : player.displayName; + return player.isBot ? `${baseLabel} [Bot]` : baseLabel; } function formatSessionStatusLabel(session: LobbyInfo, now: number) { diff --git a/packages/frontend/src/components/game-screen/GameScreenHud.spec.tsx b/packages/frontend/src/components/game-screen/GameScreenHud.spec.tsx index 1de7f44..5cee15c 100644 --- a/packages/frontend/src/components/game-screen/GameScreenHud.spec.tsx +++ b/packages/frontend/src/components/game-screen/GameScreenHud.spec.tsx @@ -22,6 +22,7 @@ function createProps(overrides: Partial = {}): GameScreenHud { playerId: 'player-1', profileId: null, + isBot: false, displayColor: '#38bdf8', displayName: 'Alpha', isConnected: true, @@ -30,6 +31,7 @@ function createProps(overrides: Partial = {}): GameScreenHud { playerId: 'player-2', profileId: null, + isBot: false, displayColor: '#f97316', displayName: 'Bravo', isConnected: true, diff --git a/packages/frontend/src/components/game-screen/GameScreenHud.tsx b/packages/frontend/src/components/game-screen/GameScreenHud.tsx index 6bdeb64..eefcd4c 100644 --- a/packages/frontend/src/components/game-screen/GameScreenHud.tsx +++ b/packages/frontend/src/components/game-screen/GameScreenHud.tsx @@ -11,6 +11,7 @@ import { ShutdownTimer } from './ShutdownTimer'; export type HudPlayerInfo = { playerId: string, profileId: string | null, + isBot: boolean, displayColor: string, displayName: string, @@ -200,7 +201,7 @@ function GameScreenHud({ - {players.map(({ playerId, profileId, displayColor, displayName, isConnected, rankingEloScore }) => { + {players.map(({ playerId, profileId, isBot, displayColor, displayName, isConnected, rankingEloScore }) => { let formattedName; if (gameOptions.rated && !hideEloInHud) { formattedName = `${displayName} (${rankingEloScore})`; @@ -244,6 +245,12 @@ function GameScreenHud({ You )} + + {isBot && ( + + Bot + + )} ); })} diff --git a/packages/frontend/src/query/accountClient.ts b/packages/frontend/src/query/accountClient.ts index 4f765f8..653f676 100644 --- a/packages/frontend/src/query/accountClient.ts +++ b/packages/frontend/src/query/accountClient.ts @@ -1,9 +1,13 @@ import type { + AccountBotResponse, + AccountBotsResponse, AccountPreferences, AccountPreferencesResponse, AccountResponse, + CreateAccountBotRequest, ProfileResponse, ProfileStatisticsResponse, + UpdateAccountBotRequest, UpdateAccountPreferencesRequest, UpdateAccountProfileRequest, } from '@ih3t/shared'; @@ -25,6 +29,10 @@ async function fetchAccountPreferences() { return await fetchJson(`/api/account/preferences`); } +async function fetchAccountBots() { + return await fetchJson(`/api/account/bots`); +} + async function fetchProfileStatistics(profileId: string) { return await fetchJson(`/api/profiles/${encodeURIComponent(profileId)}/statistics`); } @@ -69,6 +77,45 @@ export async function updateAccountPreferences(preferences: AccountPreferences) } } +export async function createAccountBot(bot: CreateAccountBotRequest[`bot`]) { + const response = await fetchJson(`/api/account/bots`, { + method: `POST`, + headers: { + 'Content-Type': `application/json`, + }, + body: JSON.stringify({ bot } satisfies CreateAccountBotRequest), + }); + + queryClient.setQueryData(queryKeys.accountBots, (previous) => ({ + bots: [response.bot, ...(previous?.bots ?? [])], + })); + return response; +} + +export async function updateAccountBot(botId: string, bot: UpdateAccountBotRequest[`bot`]) { + const response = await fetchJson(`/api/account/bots/${encodeURIComponent(botId)}`, { + method: `PUT`, + headers: { + 'Content-Type': `application/json`, + }, + body: JSON.stringify({ bot } satisfies UpdateAccountBotRequest), + }); + + queryClient.setQueryData(queryKeys.accountBots, (previous) => ({ + bots: (previous?.bots ?? []).map((existingBot) => existingBot.id === response.bot.id ? response.bot : existingBot), + })); + return response; +} + +export async function deleteAccountBot(botId: string) { + const response = await fetchJson(`/api/account/bots/${encodeURIComponent(botId)}`, { + method: `DELETE`, + }); + + queryClient.setQueryData(queryKeys.accountBots, response); + return response; +} + export function useQueryAccount(options?: { enabled?: boolean }) { return useQuery({ queryKey: queryKeys.account, @@ -87,6 +134,15 @@ export function useQueryAccountPreferences(options?: { enabled?: boolean }) { }); } +export function useQueryAccountBots(options?: { enabled?: boolean }) { + return useQuery({ + queryKey: queryKeys.accountBots, + queryFn: fetchAccountBots, + enabled: options?.enabled, + staleTime: 10 * 60 * 1000, + }); +} + export function useQueryProfile(profileId: string | null, options?: { enabled?: boolean }) { return useQuery({ queryKey: queryKeys.profile(profileId), diff --git a/packages/frontend/src/query/authClient.ts b/packages/frontend/src/query/authClient.ts index 4abdcf4..37574c8 100644 --- a/packages/frontend/src/query/authClient.ts +++ b/packages/frontend/src/query/authClient.ts @@ -1,4 +1,6 @@ -import { getApiBaseUrl } from './apiClient'; +import type { AccountResponse, RegisterCredentialsRequest } from '@ih3t/shared'; + +import { fetchJson, getApiBaseUrl } from './apiClient'; type CsrfResponse = { csrfToken: string @@ -21,6 +23,10 @@ async function fetchCsrfToken() { return data.csrfToken; } +type AuthRedirectResponse = { + url?: string +}; + function submitAuthForm(path: string, values: Record) { const form = document.createElement(`form`); form.method = `POST`; @@ -40,11 +46,72 @@ function submitAuthForm(path: string, values: Record) { form.remove(); } -export async function signInWithDiscord() { +async function postAuthForm(path: string, values: Record) { + const response = await fetch(`${getApiBaseUrl()}${path}`, { + method: `POST`, + credentials: `include`, + headers: { + 'Content-Type': `application/x-www-form-urlencoded`, + 'X-Auth-Return-Redirect': `1`, + }, + body: new URLSearchParams(values), + }); + + const data = await response.json().catch((): AuthRedirectResponse => ({})); + if (!response.ok) { + throw new Error(`Authentication request failed.`); + } + + return data; +} + +export function getSignInErrorMessage(errorCode: string | null): string | null { + switch (errorCode) { + case `CredentialsSignin`: + return `The username or password you entered is incorrect.`; + case `AccessDenied`: + return `Sign-in was denied.`; + case `Configuration`: + return `Authentication is not configured correctly on the server.`; + case null: + return null; + default: + return `Unable to sign in right now.`; + } +} + +export async function signInWithDiscord(callbackUrl = window.location.href) { const csrfToken = await fetchCsrfToken(); submitAuthForm(`/auth/signin/discord`, { csrfToken, - callbackUrl: window.location.href, + callbackUrl, + }); +} + +export async function signInWithCredentials(credentials: RegisterCredentialsRequest, callbackUrl: string) { + const csrfToken = await fetchCsrfToken(); + const response = await postAuthForm(`/auth/callback/credentials`, { + csrfToken, + username: credentials.username, + password: credentials.password, + callbackUrl, + }); + const redirectUrl = typeof response.url === `string` ? response.url : callbackUrl; + const errorCode = new URL(redirectUrl, window.location.origin).searchParams.get(`error`); + + return { + redirectUrl, + errorMessage: getSignInErrorMessage(errorCode), + }; +} + +export async function registerCredentialsAccount(credentials: RegisterCredentialsRequest) { + return await fetchJson(`/api/auth/register`, { + method: `POST`, + headers: { + 'Content-Type': `application/json`, + }, + body: JSON.stringify(credentials), }); } diff --git a/packages/frontend/src/router.tsx b/packages/frontend/src/router.tsx index eb599a2..937d0ec 100644 --- a/packages/frontend/src/router.tsx +++ b/packages/frontend/src/router.tsx @@ -7,6 +7,7 @@ import RouteErrorScreen from './components/RouteErrorScreen'; import AccountPreferencesRoute from './routes/AccountPreferencesRoute'; import AdminControlsRoute from './routes/AdminControlsRoute'; import AdminRoute from './routes/AdminRoute'; +import AuthRoute from './routes/AuthRoute'; import ChangelogRoute from './routes/ChangelogRoute'; import FinishedGameRoute from './routes/FinishedGameRoute'; import FinishedGamesRoute from './routes/FinishedGamesRoute'; @@ -48,6 +49,7 @@ export function createAppRoutes() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/packages/frontend/src/routes/AccountPreferencesRoute.tsx b/packages/frontend/src/routes/AccountPreferencesRoute.tsx index 39bc5d2..2738ed7 100644 --- a/packages/frontend/src/routes/AccountPreferencesRoute.tsx +++ b/packages/frontend/src/routes/AccountPreferencesRoute.tsx @@ -1,12 +1,15 @@ import AccountPreferencesScreen from '../components/AccountPreferencesScreen'; import PageMetadata, { DEFAULT_PAGE_TITLE } from '../components/PageMetadata'; -import { useQueryAccount, useQueryAccountPreferences } from '../query/accountClient'; +import { useQueryAccount, useQueryAccountBots, useQueryAccountPreferences } from '../query/accountClient'; function AccountPreferencesRoute() { const accountQuery = useQueryAccount({ enabled: true }); const accountPreferencesQuery = useQueryAccountPreferences({ enabled: !accountQuery.isLoading && Boolean(accountQuery.data?.user), }); + const accountBotsQuery = useQueryAccountBots({ + enabled: !accountQuery.isLoading && Boolean(accountQuery.data?.user), + }); return ( <> @@ -19,10 +22,13 @@ function AccountPreferencesRoute() { ); diff --git a/packages/frontend/src/routes/AuthRoute.tsx b/packages/frontend/src/routes/AuthRoute.tsx new file mode 100644 index 0000000..9cac858 --- /dev/null +++ b/packages/frontend/src/routes/AuthRoute.tsx @@ -0,0 +1,24 @@ +import AuthScreen from '../components/AuthScreen'; +import PageMetadata, { DEFAULT_PAGE_TITLE } from '../components/PageMetadata'; +import { useQueryAccount } from '../query/accountClient'; + +function AuthRoute() { + const accountQuery = useQueryAccount({ enabled: true }); + + return ( + <> + + + + + ); +} + +export default AuthRoute; diff --git a/packages/frontend/src/routes/LobbyRoute.tsx b/packages/frontend/src/routes/LobbyRoute.tsx index 3f1ac75..ce2b226 100644 --- a/packages/frontend/src/routes/LobbyRoute.tsx +++ b/packages/frontend/src/routes/LobbyRoute.tsx @@ -6,7 +6,7 @@ import LobbyScreen from '../components/LobbyScreen'; import PageMetadata, { DEFAULT_PAGE_TITLE } from '../components/PageMetadata'; import { joinSession } from '../liveGameClient'; import { useLiveGameStore } from '../liveGameStore'; -import { useQueryAccount, useQueryAccountPreferences } from '../query/accountClient'; +import { useQueryAccount, useQueryAccountBots, useQueryAccountPreferences } from '../query/accountClient'; import { useQueryServerShutdown } from '../query/serverClient'; import { hostGame } from '../query/sessionClient'; import { useQueryAvailableSessions } from '../query/sessionClient'; @@ -21,6 +21,9 @@ function LobbyRoute() { const accountPreferencesQuery = useQueryAccountPreferences({ enabled: !accountQuery.isLoading && Boolean(accountQuery.data?.user), }); + const accountBotsQuery = useQueryAccountBots({ + enabled: !accountQuery.isLoading && Boolean(accountQuery.data?.user), + }); const availableSessionsQuery = useQueryAvailableSessions({ enabled: true }); const unreadChangelogEntries = accountQuery.data?.user && accountPreferencesQuery.data?.preferences ? countUnreadChangelogEntries(CHANGELOG_DAYS, accountPreferencesQuery.data.preferences.changelogReadAt) @@ -61,6 +64,7 @@ function LobbyRoute() { isConnected={connection.isConnected} shutdown={shutdown} account={accountQuery.data?.user ?? null} + accountBots={accountBotsQuery.data?.bots ?? []} isAccountLoading={accountQuery.isLoading} liveSessions={availableSessionsQuery.data ?? []} onHostGame={createLobby} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 40c2cda..cef39b7 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -511,6 +511,9 @@ export const zSessionParticipant = z.object({ displayName: z.string(), profileId: zIdentifier.nullable(), + isBot: z.boolean().optional(), + botId: zIdentifier.nullable().optional(), + botOwnerProfileId: zIdentifier.nullable().optional(), rating: zPlayerRating, ratingAdjustment: zPlayerRatingAdjustment.nullable().default(null), @@ -520,6 +523,9 @@ export type SessionParticipant = z.infer; export const zLobbyListParticipant = z.object({ displayName: z.string(), profileId: zIdentifier.nullable(), + isBot: z.boolean().optional(), + botId: zIdentifier.nullable().optional(), + botOwnerProfileId: zIdentifier.nullable().optional(), elo: z.number().int(), }); export type LobbyListParticipant = z.infer; @@ -538,6 +544,9 @@ export type LobbyInfo = z.infer; export const zCreateSessionRequest = z.object({ lobbyOptions: zLobbyOptions.optional(), + botPlayerIds: z.array(zIdentifier) + .max(2) + .optional(), }); export type CreateSessionRequest = z.infer; @@ -611,7 +620,10 @@ export type GameMove = z.infer; export const zDatabaseGamePlayer = z.object({ playerId: zIdentifier, displayName: z.string(), - profileId: zIdentifier, + profileId: zIdentifier.nullable(), + isBot: z.boolean().optional(), + botId: zIdentifier.nullable().optional(), + botOwnerProfileId: zIdentifier.nullable().optional(), elo: z.number().int() .nullable() .default(null), @@ -797,6 +809,18 @@ const zNormalizedUsername = z.string() message: `Your username contains unsupported characters.`, }); +export const zCredentialsPassword = z.string() + .min(8, { + message: `Your password must be at least 8 characters long.`, + }) + .max(72, { + message: `Your password must be at most 72 characters long.`, + }) + .refine((password) => !/[\p{C}]/u.test(password), { + message: `Your password contains unsupported characters.`, + }); +export type CredentialsPassword = z.infer; + export const zAccountEloHistoryPoint = z.object({ timestamp: zTimestamp, elo: z.number().int() @@ -858,6 +882,90 @@ export type AccountPreferences = z.infer; export const DEFAULT_ACCOUNT_PREFERENCES: AccountPreferences = zAccountPreferences.parse({}); +export const zAccountBotName = z.string().trim() + .min(1) + .max(48); +export type AccountBotName = z.infer; + +export const zAccountBotEndpoint = z.string().trim() + .url() + .refine((value) => { + try { + const url = new URL(value); + return url.protocol === `http:` || url.protocol === `https:`; + } catch { + return false; + } + }, { + message: `Bot endpoint must use http or https.`, + }); +export type AccountBotEndpoint = z.infer; + +export const zAccountBotCapabilities = z.object({ + statelessApiRoot: z.string().trim() + .min(1), + moveTimeLimit: z.boolean().default(false), + discoveredAt: zTimestamp, + meta: z.object({ + name: z.string().trim() + .min(1) + .nullable() + .default(null), + description: z.string().trim() + .min(1) + .nullable() + .default(null), + author: z.string().trim() + .min(1) + .nullable() + .default(null), + version: z.string().trim() + .min(1) + .nullable() + .default(null), + }), +}); +export type AccountBotCapabilities = z.infer; + +export const zAccountBot = z.object({ + id: zIdentifier, + ownerProfileId: zIdentifier, + name: zAccountBotName, + endpoint: zAccountBotEndpoint, + createdAt: zTimestamp, + updatedAt: zTimestamp, + capabilities: zAccountBotCapabilities, +}); +export type AccountBot = z.infer; + +export const zCreateAccountBotRequest = z.object({ + bot: z.object({ + name: zAccountBotName, + endpoint: z.string().trim() + .min(1), + }), +}); +export type CreateAccountBotRequest = z.infer; + +export const zUpdateAccountBotRequest = z.object({ + bot: z.object({ + name: zAccountBotName, + endpoint: z.string().trim() + .min(1), + }), +}); +export type UpdateAccountBotRequest = z.infer; + +export const zAccountBotsResponse = z.object({ + bots: z.array(zAccountBot), +}); +export type AccountBotsResponse = z.infer; + +export const zAccountBotResponse = z.object({ + bot: zAccountBot, +}); +export type AccountBotResponse = z.infer; + export const zAccountProfile = z.object({ id: zIdentifier, username: z.string(), @@ -1023,6 +1131,12 @@ export const zUpdateAccountProfileRequest = z.object({ }); export type UpdateAccountProfileRequest = z.infer; +export const zRegisterCredentialsRequest = z.object({ + username: zNormalizedUsername, + password: zCredentialsPassword, +}); +export type RegisterCredentialsRequest = z.infer; + export const zUpdateAccountPreferencesRequest = z.object({ preferences: zAccountPreferences, }); diff --git a/packages/shared/src/queryKeys.ts b/packages/shared/src/queryKeys.ts index 5d70494..aee36d9 100644 --- a/packages/shared/src/queryKeys.ts +++ b/packages/shared/src/queryKeys.ts @@ -4,6 +4,7 @@ export type FinishedGamesArchiveView = `all` | `mine`; export const queryKeys = { account: [`account`] as const, accountPreferences: [`account`, `preferences`] as const, + accountBots: [`account`, `bots`] as const, profile: (profileId: string | null) => [`profile`, profileId ?? `unknown`] as const, profileRecentGames: (profileId: string | null) => [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dd05a9b..a814072 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -50,6 +50,9 @@ importers: packages/backend: dependencies: + '@auth/core': + specifier: 0.41.1 + version: 0.41.1 '@auth/express': specifier: ^0.12.1 version: 0.12.1(express@5.2.1) @@ -68,6 +71,9 @@ importers: async-mutex: specifier: ^0.5.0 version: 0.5.0 + bcryptjs: + specifier: ^3.0.3 + version: 3.0.3 cors: specifier: ^2.8.6 version: 2.8.6 @@ -1554,6 +1560,10 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + bcryptjs@3.0.3: + resolution: {integrity: sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==} + hasBin: true + body-parser@2.2.2: resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} engines: {node: '>=18'} @@ -4619,6 +4629,8 @@ snapshots: baseline-browser-mapping@2.10.8: {} + bcryptjs@3.0.3: {} + body-parser@2.2.2: dependencies: bytes: 3.1.2