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..8cb4421 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, @@ -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..7f53b9a 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,16 +9,20 @@ import { type AdminStatsResponse, type AdminTerminateSessionResponse, type CreateSandboxPositionResponse, + type CreateAccountBotRequest, type CreateSessionResponse, DEFAULT_LOBBY_OPTIONS, type LobbyOptions, type ServerSettings, + type UpdateAccountBotRequest, zAdminBroadcastMessageRequest, zAdminScheduleShutdownRequest, zAdminUpdateServerSettingsRequest, + zCreateAccountBotRequest, zCreateSandboxPositionRequest, zLobbyVisibility, zSandboxPositionId, + zUpdateAccountBotRequest, zUpdateAccountPreferencesRequest, zUpdateAccountProfileRequest, } from '@ih3t/shared'; @@ -28,6 +34,7 @@ import { AdminStatsService } from '../../admin/adminStatsService'; import { ServerSettingsService } from '../../admin/serverSettingsService'; import { ServerShutdownService } from '../../admin/serverShutdownService'; import { type AccountUserProfile, AuthRepository } 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 +86,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 +99,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, @@ -115,6 +126,19 @@ 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) { @@ -164,6 +188,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) { @@ -369,24 +461,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 +500,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 +511,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 +528,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..fc9d018 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 }; } @@ -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..6024d5f 100644 --- a/packages/frontend/src/components/AccountPreferencesScreen.tsx +++ b/packages/frontend/src/components/AccountPreferencesScreen.tsx @@ -1,9 +1,9 @@ -import type { AccountPreferences, AccountProfile } from '@ih3t/shared'; +import type { AccountBot, AccountPreferences, AccountProfile } from '@ih3t/shared'; import { useState } from 'react'; import React from 'react'; import { toast } from 'react-toastify'; -import { updateAccountPreferences } from '../query/accountClient'; +import { createAccountBot, deleteAccountBot, updateAccountBot, updateAccountPreferences } from '../query/accountClient'; import { signInWithDiscord } from '../query/authClient'; import PageCorpus from './PageCorpus'; @@ -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 @@ -82,11 +305,11 @@ function PreferenceSwitchCard({ className={`relative inline-flex h-8 w-14 shrink-0 items-center rounded-full border transition ${checked ? `border-sky-300/50 bg-sky-400/80` : `border-white/10 bg-slate-800/90` - } ${disabled ? `cursor-wait opacity-70` : `cursor-pointer`}`} + } ${disabled ? `cursor-wait opacity-70` : `cursor-pointer`}`} > @@ -97,10 +320,13 @@ function PreferenceSwitchCard({ function AccountPreferencesScreen({ account, preferences, + bots, isLoading, isPreferencesLoading, + isBotsLoading, errorMessage, preferencesErrorMessage, + botsErrorMessage, }: Readonly) { const [savingPreferenceKey, setSavingPreferenceKey] = useState(null); @@ -239,6 +465,12 @@ function AccountPreferencesScreen({
{isSavingPreference ? `Saving your latest preference change...` : `Changes save automatically.`}
+ + )} 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/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/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/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/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..4fb7262 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), @@ -858,6 +870,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(), 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) => [