Created
November 22, 2025 02:47
-
-
Save lazerwalker/6da12de58b53e0613010ea00bee8a52d to your computer and use it in GitHub Desktop.
Bot matches implementation for Tenuki - Clean patch
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| diff --git a/src/components/BotChallengeSearchBanner.tsx b/src/components/BotChallengeSearchBanner.tsx | |
| new file mode 100644 | |
| index 0000000..6684fd0 | |
| --- /dev/null | |
| +++ b/src/components/BotChallengeSearchBanner.tsx | |
| @@ -0,0 +1,61 @@ | |
| +import Spinner from "@/components/Spinner" | |
| +import { useResponsiveLayout } from "@/hooks/useResponsiveLayout" | |
| +import { botManager } from "@/ogs/botManager" | |
| +import { useRouter } from "@/navigation/SimpleRouter" | |
| +import clsx from "clsx" | |
| + | |
| +interface BotChallengeSearchBannerProps { | |
| + botName?: string | |
| +} | |
| + | |
| +export default function BotChallengeSearchBanner({ | |
| + botName, | |
| +}: BotChallengeSearchBannerProps) { | |
| + const { textSize } = useResponsiveLayout() | |
| + const { navigate } = useRouter() | |
| + | |
| + const handleCancel = (e: React.MouseEvent) => { | |
| + e.stopPropagation() | |
| + botManager.cancelChallenge() | |
| + } | |
| + | |
| + const handleClick = () => { | |
| + navigate("/new-game/bot") | |
| + } | |
| + | |
| + const baseClasses = clsx( | |
| + "fixed left-4 right-4 z-50 flex items-center gap-3 p-4 border border-secondary-border shadow-lg cursor-pointer transition-colors hover:bg-hover-bg active:bg-active-bg", | |
| + textSize === "xlarge" ? "flex-col" : "flex-row" | |
| + ) | |
| + | |
| + const style = { | |
| + borderRadius: "8px", | |
| + backgroundColor: "rgb(var(--search-banner-bg))", | |
| + bottom: "calc(var(--tab-bar-height) + var(--tab-bar-gap) + env(safe-area-inset-bottom, 0px))", | |
| + } | |
| + | |
| + return ( | |
| + <button className={baseClasses} style={style} onClick={handleClick}> | |
| + {textSize !== "xlarge" && <Spinner />} | |
| + <div className="flex-1"> | |
| + <div className="font-semibold text-primary-text"> | |
| + Waiting for bot... | |
| + </div> | |
| + {textSize === "normal" && ( | |
| + <div className="text-sm text-secondary-text"> | |
| + {botName | |
| + ? `Challenging ${botName}` | |
| + : "Setting up game"} | |
| + </div> | |
| + )} | |
| + </div> | |
| + <button | |
| + className="px-[16px] py-[8px] bg-action-cancel text-primary-text font-semibold transition-colors" | |
| + style={{ borderRadius: "6px" }} | |
| + onClick={handleCancel} | |
| + > | |
| + Cancel | |
| + </button> | |
| + </button> | |
| + ) | |
| +} | |
| diff --git a/src/components/BotListView.tsx b/src/components/BotListView.tsx | |
| new file mode 100644 | |
| index 0000000..c543cbe | |
| --- /dev/null | |
| +++ b/src/components/BotListView.tsx | |
| @@ -0,0 +1,315 @@ | |
| +import { useState, useEffect, useMemo } from "react" | |
| +import { useRouter } from "@/navigation/SimpleRouter" | |
| +import { useSettings } from "@/hooks/useSettings" | |
| +import { botManager } from "@/ogs/botManager" | |
| +import type { Player } from "@/types" | |
| +import type { BotConfig, Size, Speed } from "@/ogs/types/ogsMessageTypes" | |
| +import { SPEED_OPTIONS } from "@/ogs/automatchTimeControls" | |
| +import { | |
| + challengeBot, | |
| + type BotChallengePayload, | |
| +} from "@/ogs/ogsClientREST" | |
| +import { sendChallengeKeepalive, connectToBotGame } from "@/ogs/ogsClientWS" | |
| + | |
| +interface BotWithDisabledReason extends Player { | |
| + disabled?: string | |
| +} | |
| + | |
| +interface BotCategory { | |
| + name: string | |
| + bots: BotWithDisabledReason[] | |
| +} | |
| + | |
| +// Bot difficulty categorization (from reference implementation) | |
| +const categorizeBots = (bots: BotWithDisabledReason[]): BotCategory[] => { | |
| + const beginner: BotWithDisabledReason[] = [] | |
| + const intermediate: BotWithDisabledReason[] = [] | |
| + const advanced: BotWithDisabledReason[] = [] | |
| + | |
| + bots.forEach(bot => { | |
| + const rank = bot.rawRank ?? 0 | |
| + if (rank <= 10) { | |
| + beginner.push(bot) | |
| + } else if (rank <= 25) { | |
| + intermediate.push(bot) | |
| + } else { | |
| + advanced.push(bot) | |
| + } | |
| + }) | |
| + | |
| + const categories: BotCategory[] = [] | |
| + if (beginner.length > 0) { | |
| + categories.push({ name: "Beginner", bots: beginner }) | |
| + } | |
| + if (intermediate.length > 0) { | |
| + categories.push({ name: "Intermediate", bots: intermediate }) | |
| + } | |
| + if (advanced.length > 0) { | |
| + categories.push({ name: "Advanced", bots: advanced }) | |
| + } | |
| + | |
| + return categories | |
| +} | |
| + | |
| +// Check if bot supports the current settings | |
| +const isBotCompatible = ( | |
| + bot: Player, | |
| + size: Size, | |
| + speed: Speed, | |
| + system: "fischer" | "byoyomi" | |
| +): string | undefined => { | |
| + // Type guard to check if bot has config | |
| + const config = (bot as { config?: BotConfig }).config | |
| + if (!config) { | |
| + return "Bot configuration unavailable" | |
| + } | |
| + | |
| + // V0 config means bot is not properly configured | |
| + if (config._config_version === 0) { | |
| + return "Bot not configured" | |
| + } | |
| + | |
| + // Check board size support | |
| + const sizeNum = parseInt(size.split("x")[0]) | |
| + if (Array.isArray(config.allowed_board_sizes)) { | |
| + if (!config.allowed_board_sizes.includes(sizeNum)) { | |
| + return `Does not support ${size} board` | |
| + } | |
| + } | |
| + | |
| + // Check time control system support | |
| + if (!config.allowed_time_control_systems.includes(system)) { | |
| + return `Does not support ${system} time control` | |
| + } | |
| + | |
| + // Check speed-specific settings | |
| + const speedSettings = | |
| + speed === "blitz" | |
| + ? config.allowed_blitz_settings | |
| + : speed === "rapid" | |
| + ? (config as { allowed_rapid_settings?: unknown }) | |
| + .allowed_rapid_settings | |
| + : speed === "live" | |
| + ? config.allowed_live_settings | |
| + : config.allowed_correspondence_settings | |
| + | |
| + if (!speedSettings) { | |
| + return `Does not support ${speed} games` | |
| + } | |
| + | |
| + return undefined | |
| +} | |
| + | |
| +export default function BotListView() { | |
| + const { navigate } = useRouter() | |
| + const { settings } = useSettings() | |
| + const [bots, setBots] = useState<Player[]>(botManager.bots) | |
| + const [selectedBotId, setSelectedBotId] = useState<number | null>(null) | |
| + const [isStarting, setIsStarting] = useState(false) | |
| + | |
| + useEffect(() => { | |
| + const unsubscribe = botManager.onBotsUpdate(() => { | |
| + setBots(botManager.bots) | |
| + }) | |
| + | |
| + return () => { | |
| + unsubscribe() | |
| + } | |
| + }, []) | |
| + | |
| + // Filter and categorize bots based on current settings | |
| + const categorizedBots = useMemo(() => { | |
| + if (!settings) return [] | |
| + | |
| + const size = settings.automatch.size | |
| + const speed = settings.automatch.speed | |
| + const system = settings.automatch.timeControl | |
| + | |
| + // Add disabled reason to each bot | |
| + const botsWithReasons: BotWithDisabledReason[] = bots.map(bot => ({ | |
| + ...bot, | |
| + disabled: isBotCompatible(bot, size, speed, system), | |
| + })) | |
| + | |
| + // Sort: enabled first, then by rank | |
| + botsWithReasons.sort((a, b) => { | |
| + if (a.disabled && !b.disabled) return 1 | |
| + if (!a.disabled && b.disabled) return -1 | |
| + return (a.rawRank ?? 0) - (b.rawRank ?? 0) | |
| + }) | |
| + | |
| + return categorizeBots(botsWithReasons) | |
| + }, [bots, settings]) | |
| + | |
| + // Auto-select first valid bot | |
| + useEffect(() => { | |
| + if (selectedBotId === null && categorizedBots.length > 0) { | |
| + for (const category of categorizedBots) { | |
| + const firstValid = category.bots.find(b => !b.disabled) | |
| + if (firstValid) { | |
| + setSelectedBotId(firstValid.id) | |
| + break | |
| + } | |
| + } | |
| + } | |
| + }, [categorizedBots, selectedBotId]) | |
| + | |
| + const handleStart = async () => { | |
| + if (!selectedBotId || !settings || isStarting) return | |
| + | |
| + const bot = bots.find(b => b.id === selectedBotId) | |
| + if (!bot) return | |
| + | |
| + setIsStarting(true) | |
| + | |
| + try { | |
| + const size = settings.automatch.size | |
| + const speed = settings.automatch.speed | |
| + const system = settings.automatch.timeControl | |
| + const timeControl = SPEED_OPTIONS[size][speed][system] | |
| + | |
| + if (!timeControl) { | |
| + throw new Error("Invalid time control configuration") | |
| + } | |
| + | |
| + const sizeNum = parseInt(size.split("x")[0]) | |
| + | |
| + // Build challenge payload (based on reference implementation) | |
| + const payload: BotChallengePayload = { | |
| + game: { | |
| + name: "Friendly Match", | |
| + width: sizeNum, | |
| + height: sizeNum, | |
| + rules: "japanese", | |
| + ranked: true, | |
| + handicap: 0, | |
| + komi_auto: "automatic", | |
| + time_control: system, | |
| + time_control_parameters: { | |
| + system, | |
| + speed, | |
| + pause_on_weekends: false, | |
| + ...timeControl, | |
| + }, | |
| + disable_analysis: false, | |
| + private: false, | |
| + }, | |
| + min_ranking: -1000, | |
| + max_ranking: 1000, | |
| + challenger_color: "automatic", | |
| + } | |
| + | |
| + const response = await challengeBot(selectedBotId, payload) | |
| + const gameId = typeof response.game === "number" ? response.game : response.game | |
| + | |
| + console.log("[BOT] Challenge created:", response) | |
| + | |
| + // Start tracking this challenge | |
| + botManager.startChallenge(response.challenge, gameId, selectedBotId) | |
| + | |
| + // Connect to the game and start keepalive | |
| + connectToBotGame(gameId) | |
| + botManager.startKeepalive( | |
| + response.challenge, | |
| + gameId, | |
| + sendChallengeKeepalive | |
| + ) | |
| + | |
| + // Pop back to games list | |
| + navigate("/games", { mode: "replace" }) | |
| + } catch (error) { | |
| + console.error("[BOT] Failed to challenge bot:", error) | |
| + setIsStarting(false) | |
| + // TODO: Show error to user | |
| + } | |
| + } | |
| + | |
| + const handleCancel = () => { | |
| + navigate("/games", { mode: "replace" }) | |
| + } | |
| + | |
| + if (!settings) { | |
| + return <div className="p-4">Loading...</div> | |
| + } | |
| + | |
| + return ( | |
| + <div className="h-full flex flex-col"> | |
| + <div className="flex-1 overflow-y-auto p-4"> | |
| + {categorizedBots.length === 0 ? ( | |
| + <div className="text-center text-secondary-text p-8"> | |
| + No bots available | |
| + </div> | |
| + ) : ( | |
| + categorizedBots.map(category => ( | |
| + <div key={category.name} className="mb-6"> | |
| + <h3 className="text-sm font-semibold text-secondary-text mb-2"> | |
| + {category.name} | |
| + </h3> | |
| + <div className="flex flex-col gap-2"> | |
| + {category.bots.map(bot => ( | |
| + <button | |
| + key={bot.id} | |
| + className={`flex items-center gap-3 p-3 rounded-lg border transition-colors ${ | |
| + selectedBotId === bot.id | |
| + ? "bg-action-confirm/20 border-action-confirm" | |
| + : "bg-surface-bg border-secondary-border" | |
| + } ${ | |
| + bot.disabled | |
| + ? "opacity-50 cursor-not-allowed" | |
| + : "hover:bg-surface-bg/80" | |
| + }`} | |
| + onClick={() => | |
| + !bot.disabled && | |
| + setSelectedBotId(bot.id) | |
| + } | |
| + disabled={!!bot.disabled} | |
| + > | |
| + {bot.avatar && ( | |
| + <img | |
| + src={bot.avatar} | |
| + alt={bot.name} | |
| + className="w-10 h-10 rounded-full" | |
| + /> | |
| + )} | |
| + <div className="flex-1 text-left"> | |
| + <div className="font-semibold text-primary-text"> | |
| + {bot.name} | |
| + </div> | |
| + {bot.ranking && ( | |
| + <div className="text-sm text-secondary-text"> | |
| + {bot.ranking} | |
| + </div> | |
| + )} | |
| + {bot.disabled && ( | |
| + <div className="text-xs text-red-500 mt-1"> | |
| + {bot.disabled} | |
| + </div> | |
| + )} | |
| + </div> | |
| + </button> | |
| + ))} | |
| + </div> | |
| + </div> | |
| + )) | |
| + )} | |
| + </div> | |
| + | |
| + <div className="p-4 border-t border-secondary-border flex gap-2"> | |
| + <button | |
| + className="flex-1 h-12 bg-surface-bg text-primary-text font-semibold rounded-lg" | |
| + onClick={handleCancel} | |
| + disabled={isStarting} | |
| + > | |
| + Cancel | |
| + </button> | |
| + <button | |
| + className="flex-1 h-12 bg-action-confirm text-primary-text font-bold rounded-lg disabled:opacity-50 disabled:cursor-not-allowed" | |
| + onClick={handleStart} | |
| + disabled={!selectedBotId || isStarting} | |
| + > | |
| + {isStarting ? "Starting..." : "Start"} | |
| + </button> | |
| + </div> | |
| + </div> | |
| + ) | |
| +} | |
| diff --git a/src/components/GameList.tsx b/src/components/GameList.tsx | |
| index 6f04108..6295197 100644 | |
| --- a/src/components/GameList.tsx | |
| +++ b/src/components/GameList.tsx | |
| @@ -11,7 +11,9 @@ import ChallengeList from "@/components/ChallengeList" | |
| import NewGameButton from "@/components/NewGameButton" | |
| import AutomatchSearchBanner from "@/components/AutomatchSearchBanner" | |
| import CorrespondenceSearchBanner from "@/components/CorrespondenceSearchBanner" | |
| +import BotChallengeSearchBanner from "@/components/BotChallengeSearchBanner" | |
| import { automatchManager } from "@/ogs/automatchManager" | |
| +import { botManager } from "@/ogs/botManager" | |
| const GameListView = (props: { | |
| isMenu?: boolean | |
| @@ -22,6 +24,10 @@ const GameListView = (props: { | |
| const [hasActiveSearch, setHasActiveSearch] = useState( | |
| !!automatchManager.liveSearch | |
| ) | |
| + const [hasActiveBotChallenge, setHasActiveBotChallenge] = useState( | |
| + botManager.hasActiveChallenge | |
| + ) | |
| + const [botName, setBotName] = useState<string>() | |
| useEffect(() => { | |
| const updateSearch = () => { | |
| @@ -44,6 +50,26 @@ const GameListView = (props: { | |
| } | |
| }, [navigate]) | |
| + useEffect(() => { | |
| + const updateBotChallenge = () => { | |
| + setHasActiveBotChallenge(botManager.hasActiveChallenge) | |
| + if (botManager.challenge) { | |
| + const bot = botManager.getBot(botManager.challenge.botId) | |
| + setBotName(bot?.name) | |
| + } else { | |
| + setBotName(undefined) | |
| + } | |
| + } | |
| + | |
| + const unsubStart = botManager.onChallengeStart(updateBotChallenge) | |
| + const unsubCancel = botManager.onChallengeCancel(updateBotChallenge) | |
| + | |
| + return () => { | |
| + unsubStart() | |
| + unsubCancel() | |
| + } | |
| + }, []) | |
| + | |
| const myTurn = _(games || []) | |
| .filter( | |
| g => | |
| @@ -90,7 +116,10 @@ const GameListView = (props: { | |
| className="flex flex-col gap-4 w-full bg-game-view" | |
| style={{ | |
| paddingTop: "4px", | |
| - paddingBottom: hasActiveSearch ? "176px" : "168px", | |
| + paddingBottom: | |
| + hasActiveSearch || hasActiveBotChallenge | |
| + ? "176px" | |
| + : "168px", | |
| }} | |
| > | |
| <ChallengeList /> | |
| @@ -126,7 +155,13 @@ const GameListView = (props: { | |
| })()} | |
| </PullToRefreshContainer> | |
| - {hasActiveSearch ? <AutomatchSearchBanner /> : <NewGameButton />} | |
| + {hasActiveSearch ? ( | |
| + <AutomatchSearchBanner /> | |
| + ) : hasActiveBotChallenge ? ( | |
| + <BotChallengeSearchBanner botName={botName} /> | |
| + ) : ( | |
| + <NewGameButton /> | |
| + )} | |
| </div> | |
| ) | |
| diff --git a/src/components/NewGameButton.tsx b/src/components/NewGameButton.tsx | |
| index 2d6d857..60faf4a 100644 | |
| --- a/src/components/NewGameButton.tsx | |
| +++ b/src/components/NewGameButton.tsx | |
| @@ -4,7 +4,7 @@ export default function NewGameButton() { | |
| const { navigate } = useRouter() | |
| const handleClick = () => { | |
| - navigate("/automatch") | |
| + navigate("/new-game") | |
| } | |
| return ( | |
| diff --git a/src/components/OpponentSelectionView.tsx b/src/components/OpponentSelectionView.tsx | |
| new file mode 100644 | |
| index 0000000..f6980a3 | |
| --- /dev/null | |
| +++ b/src/components/OpponentSelectionView.tsx | |
| @@ -0,0 +1,63 @@ | |
| +import { useRouter } from "@/navigation/SimpleRouter" | |
| +import { botManager } from "@/ogs/botManager" | |
| +import { useState, useEffect } from "react" | |
| + | |
| +export default function OpponentSelectionView() { | |
| + const { navigate } = useRouter() | |
| + const [hasActiveBots, setHasActiveBots] = useState(botManager.bots.length > 0) | |
| + | |
| + useEffect(() => { | |
| + // Update when bots list changes | |
| + const unsubscribe = botManager.onBotsUpdate(() => { | |
| + setHasActiveBots(botManager.bots.length > 0) | |
| + }) | |
| + | |
| + return () => { | |
| + unsubscribe() | |
| + } | |
| + }, []) | |
| + | |
| + const handlePlayHuman = () => { | |
| + navigate("/new-game/human") | |
| + } | |
| + | |
| + const handlePlayBot = () => { | |
| + navigate("/new-game/bot") | |
| + } | |
| + | |
| + return ( | |
| + <div className="h-full flex flex-col items-center justify-center p-4 gap-4"> | |
| + <button | |
| + className="w-full max-w-md min-h-[56px] text-primary-text font-bold text-lg" | |
| + style={{ | |
| + borderRadius: "8px", | |
| + backgroundColor: "rgb(var(--new-game-button-bg))", | |
| + border: "1px solid rgb(var(--primary-border) / 0.3)", | |
| + }} | |
| + onClick={handlePlayHuman} | |
| + > | |
| + Play Human | |
| + </button> | |
| + | |
| + <button | |
| + className="w-full max-w-md min-h-[56px] font-bold text-lg" | |
| + style={{ | |
| + borderRadius: "8px", | |
| + backgroundColor: hasActiveBots | |
| + ? "rgb(var(--new-game-button-bg))" | |
| + : "rgb(var(--surface-bg))", | |
| + border: "1px solid rgb(var(--primary-border) / 0.3)", | |
| + color: hasActiveBots | |
| + ? "rgb(var(--primary-text))" | |
| + : "rgb(var(--secondary-text))", | |
| + opacity: hasActiveBots ? 1 : 0.5, | |
| + cursor: hasActiveBots ? "pointer" : "not-allowed", | |
| + }} | |
| + onClick={handlePlayBot} | |
| + disabled={!hasActiveBots} | |
| + > | |
| + Play Bot | |
| + </button> | |
| + </div> | |
| + ) | |
| +} | |
| diff --git a/src/navigation/routes.tsx b/src/navigation/routes.tsx | |
| index 37f1213..080bc0a 100644 | |
| --- a/src/navigation/routes.tsx | |
| +++ b/src/navigation/routes.tsx | |
| @@ -5,6 +5,9 @@ import StudyListView from "@/components/StudyListView" | |
| import LoginView from "@/components/LoginView" | |
| import GamePage from "@/components/pages/GamePage" | |
| import PuzzlePage from "@/components/pages/PuzzlePage" | |
| +import OpponentSelectionView from "@/components/OpponentSelectionView" | |
| +import BotListView from "@/components/BotListView" | |
| +import AutomatchModal from "@/components/AutomatchModal" | |
| import type { | |
| LeftButtonType, | |
| RightButtonType, | |
| @@ -75,6 +78,39 @@ export const routes: Route[] = [ | |
| rightButton: "menu", | |
| allowedTabs: ["puzzles"], | |
| }, | |
| + { | |
| + path: "/new-game", | |
| + component: () => ( | |
| + <AuthGuard> | |
| + <OpponentSelectionView /> | |
| + </AuthGuard> | |
| + ), | |
| + title: "New Game", | |
| + leftButton: "back", | |
| + allowedTabs: ["games"], | |
| + }, | |
| + { | |
| + path: "/new-game/human", | |
| + component: () => ( | |
| + <AuthGuard> | |
| + <AutomatchModal /> | |
| + </AuthGuard> | |
| + ), | |
| + title: "Find Game", | |
| + leftButton: "back", | |
| + allowedTabs: ["games"], | |
| + }, | |
| + { | |
| + path: "/new-game/bot", | |
| + component: () => ( | |
| + <AuthGuard> | |
| + <BotListView /> | |
| + </AuthGuard> | |
| + ), | |
| + title: "Play Bot", | |
| + leftButton: "back", | |
| + allowedTabs: ["games"], | |
| + }, | |
| ] | |
| export const DefaultRoute = routes.find(r => r.path === "/games")! | |
| diff --git a/src/ogs/botManager.ts b/src/ogs/botManager.ts | |
| new file mode 100644 | |
| index 0000000..c6b830d | |
| --- /dev/null | |
| +++ b/src/ogs/botManager.ts | |
| @@ -0,0 +1,122 @@ | |
| +import type { Player } from "@/types" | |
| + | |
| +type BotUpdateListener = () => void | |
| +type BotChallengeListener = (challenge: ActiveBotChallenge) => void | |
| + | |
| +export interface ActiveBotChallenge { | |
| + challengeId: number | |
| + gameId: number | |
| + botId: number | |
| +} | |
| + | |
| +export class BotManager { | |
| + private activeBots: Map<number, Player> = new Map() | |
| + private activeChallenge?: ActiveBotChallenge | |
| + private keepaliveInterval?: NodeJS.Timeout | |
| + | |
| + // Listener sets for each event type | |
| + private botUpdateListeners = new Set<BotUpdateListener>() | |
| + private challengeStartListeners = new Set<BotChallengeListener>() | |
| + private challengeCancelListeners = new Set<BotChallengeListener>() | |
| + | |
| + // Subscribe to events | |
| + public onBotsUpdate(callback: BotUpdateListener): () => void { | |
| + this.botUpdateListeners.add(callback) | |
| + return () => this.botUpdateListeners.delete(callback) | |
| + } | |
| + | |
| + public onChallengeStart(callback: BotChallengeListener): () => void { | |
| + this.challengeStartListeners.add(callback) | |
| + return () => this.challengeStartListeners.delete(callback) | |
| + } | |
| + | |
| + public onChallengeCancel(callback: BotChallengeListener): () => void { | |
| + this.challengeCancelListeners.add(callback) | |
| + return () => this.challengeCancelListeners.delete(callback) | |
| + } | |
| + | |
| + // Handle active-bots message from WS | |
| + public handleActiveBots(bots: Player[]): void { | |
| + this.activeBots.clear() | |
| + bots.forEach(bot => { | |
| + this.activeBots.set(bot.id, bot) | |
| + }) | |
| + this.botUpdateListeners.forEach(listener => listener()) | |
| + } | |
| + | |
| + // Start a bot challenge | |
| + public startChallenge( | |
| + challengeId: number, | |
| + gameId: number, | |
| + botId: number | |
| + ): void { | |
| + this.activeChallenge = { challengeId, gameId, botId } | |
| + this.challengeStartListeners.forEach(listener => | |
| + listener(this.activeChallenge!) | |
| + ) | |
| + } | |
| + | |
| + // Start sending keepalive messages | |
| + public startKeepalive( | |
| + challengeId: number, | |
| + gameId: number, | |
| + sendKeepaliveFn: (challengeId: number, gameId: number) => void | |
| + ): void { | |
| + // Clear any existing interval | |
| + this.stopKeepalive() | |
| + | |
| + // Send keepalive every 1000ms | |
| + this.keepaliveInterval = setInterval(() => { | |
| + sendKeepaliveFn(challengeId, gameId) | |
| + }, 1000) | |
| + } | |
| + | |
| + // Stop sending keepalive messages | |
| + public stopKeepalive(): void { | |
| + if (this.keepaliveInterval) { | |
| + clearInterval(this.keepaliveInterval) | |
| + this.keepaliveInterval = undefined | |
| + } | |
| + } | |
| + | |
| + // Cancel active challenge | |
| + public cancelChallenge(): void { | |
| + if (this.activeChallenge) { | |
| + const challenge = this.activeChallenge | |
| + this.stopKeepalive() | |
| + this.activeChallenge = undefined | |
| + this.challengeCancelListeners.forEach(listener => | |
| + listener(challenge) | |
| + ) | |
| + } | |
| + } | |
| + | |
| + // Clear state (on disconnect) | |
| + public clearState(): void { | |
| + this.stopKeepalive() | |
| + this.activeChallenge = undefined | |
| + // Don't clear bots - they persist across reconnections | |
| + } | |
| + | |
| + // Getters | |
| + public get bots(): Player[] { | |
| + return Array.from(this.activeBots.values()).sort( | |
| + (a, b) => (a.rawRank ?? 0) - (b.rawRank ?? 0) | |
| + ) | |
| + } | |
| + | |
| + public getBot(botId: number): Player | undefined { | |
| + return this.activeBots.get(botId) | |
| + } | |
| + | |
| + public get challenge(): ActiveBotChallenge | undefined { | |
| + return this.activeChallenge | |
| + } | |
| + | |
| + public get hasActiveChallenge(): boolean { | |
| + return !!this.activeChallenge | |
| + } | |
| +} | |
| + | |
| +// Singleton instance | |
| +export const botManager = new BotManager() | |
| diff --git a/src/ogs/ogsClientREST.ts b/src/ogs/ogsClientREST.ts | |
| index 60d5374..c01de6f 100644 | |
| --- a/src/ogs/ogsClientREST.ts | |
| +++ b/src/ogs/ogsClientREST.ts | |
| @@ -248,6 +248,60 @@ export async function getAutomatchStats( | |
| ) as Promise<AutomatchStats> | |
| } | |
| +export interface BotChallengePayload { | |
| + game: { | |
| + name: string | |
| + width: number | |
| + height: number | |
| + rules: string | |
| + ranked: boolean | |
| + handicap: number | |
| + komi?: number | |
| + komi_auto?: string | |
| + time_control: string | |
| + time_control_parameters: { | |
| + system: string | |
| + speed: string | |
| + pause_on_weekends: boolean | |
| + time_increment?: number | |
| + initial_time?: number | |
| + max_time?: number | |
| + main_time?: number | |
| + period_time?: number | |
| + periods?: number | |
| + per_move_time?: number | |
| + } | |
| + disable_analysis: boolean | |
| + private: boolean | |
| + } | |
| + min_ranking: number | |
| + max_ranking: number | |
| + challenger_color: string | |
| +} | |
| + | |
| +export interface BotChallengeResponse { | |
| + challenge: number | |
| + game: number | { id: number } | |
| +} | |
| + | |
| +export async function challengeBot( | |
| + botId: number, | |
| + payload: BotChallengePayload | |
| +): Promise<BotChallengeResponse> { | |
| + const response = (await makeRequest(`players/${botId}/challenge`, { | |
| + method: "POST", | |
| + data: payload, | |
| + })) as BotChallengeResponse | |
| + | |
| + // Normalize game_id | |
| + const gameId = typeof response.game === "object" ? response.game.id : response.game | |
| + | |
| + return { | |
| + challenge: response.challenge, | |
| + game: gameId, | |
| + } | |
| +} | |
| + | |
| export async function makeTerminationRequest( | |
| endpoint: string, | |
| options?: { | |
| diff --git a/src/ogs/ogsClientWS.ts b/src/ogs/ogsClientWS.ts | |
| index 9eb5794..e74d016 100644 | |
| --- a/src/ogs/ogsClientWS.ts | |
| +++ b/src/ogs/ogsClientWS.ts | |
| @@ -25,6 +25,7 @@ import shouldUseBetaServer from "@/helpers/shouldUseBetaServer" | |
| import { produce } from "immer" | |
| import { removedStonesStringToVertices } from "@/helpers/removedStoneString" | |
| import { automatchManager } from "@/ogs/automatchManager" | |
| +import { botManager } from "@/ogs/botManager" | |
| import type { OGSChatMessage } from "@/ogs/types/ogsMessageTypes" | |
| function getSocketURL(): string { | |
| @@ -87,6 +88,7 @@ function openSocketConnection() { | |
| console.log("close", ev) | |
| setAuthenticated(false) | |
| automatchManager.clearState() | |
| + botManager.clearState() | |
| openSocketConnection() | |
| }) | |
| } | |
| @@ -155,6 +157,21 @@ export function sendAutomatchUnsubscribe(): void { | |
| sendMessage("automatch/available/unsubscribe") | |
| } | |
| +// Bot challenge WebSocket functions | |
| +export function sendChallengeKeepalive( | |
| + challengeId: number, | |
| + gameId: number | |
| +): void { | |
| + sendMessage("challenge/keepalive", { | |
| + challenge_id: challengeId, | |
| + game_id: gameId, | |
| + }) | |
| +} | |
| + | |
| +export function connectToBotGame(gameId: number): void { | |
| + sendMessage("game/connect", { game_id: gameId }) | |
| +} | |
| + | |
| // Chat WebSocket functions | |
| export function joinGameChat(gameId: number): void { | |
| sendMessage("chat/join", { channel: `game-${gameId}` }) | |
| @@ -324,6 +341,15 @@ async function handleCommand(command: [string, unknown]) { | |
| : parsed.payload | |
| handleGameUpdate(gameToUpdate) | |
| + | |
| + // If this is a bot challenge game starting, stop keepalive | |
| + if ( | |
| + botManager.challenge && | |
| + parsed.payload.ogsId === botManager.challenge.gameId | |
| + ) { | |
| + console.log("[BOT] Game started, stopping keepalive") | |
| + botManager.stopKeepalive() | |
| + } | |
| break | |
| } | |
| case "game_move": { | |
| @@ -519,8 +545,9 @@ async function handleCommand(command: [string, unknown]) { | |
| // So we don't actually need to do anything here | |
| break | |
| } | |
| - // case "active-bots": { | |
| - // dispatch({ type: "set_active_bots", payload: parsed[1] }) | |
| - // } | |
| + case "active_bots": { | |
| + botManager.handleActiveBots(parsed.payload) | |
| + break | |
| + } | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment