diff --git a/package.json b/package.json index 815d1b6dd5..d4c0a10824 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,7 @@ "express-http-proxy": "^2.0.0", "fork-ts-checker-webpack-plugin": "^9.0.0", "globals": "^15.8.0", - "goban": "=8.3.68", + "goban": "=8.3.72", "gulp": "^5.0.0", "gulp-clean-css": "^4.3.0", "gulp-eslint-new": "^2.2.0", diff --git a/src/lib/automatch_manager.tsx b/src/lib/automatch_manager.tsx index a375c1a1bc..e4273be25a 100644 --- a/src/lib/automatch_manager.tsx +++ b/src/lib/automatch_manager.tsx @@ -31,7 +31,11 @@ interface Events { export type AutomatchPreferences = AutomatchPreferencesBase & { uuid: string; - size_speed_options: Array<{ size: Size; speed: Speed }>; + size_speed_options: Array<{ + size: Size; + speed: Speed; + system: "byoyomi" | "fischer"; + }>; }; class AutomatchToast extends React.PureComponent<{}, any> { diff --git a/src/lib/bots.ts b/src/lib/bots.ts index 49a82d8682..c5813ecbfe 100644 --- a/src/lib/bots.ts +++ b/src/lib/bots.ts @@ -17,21 +17,32 @@ import { socket } from "@/lib/sockets"; import { getUserRating } from "@/lib/rank_utils"; -import { User } from "goban"; +import { + BotAllowedClockSettingsV1, + BotAllowedClockSettingsV2, + BotConfig, + Speed, + User, +} from "goban"; import EventEmitter from "eventemitter3"; +import { TimeControlSystem } from "./types"; +import { llm_pgettext } from "./translate"; interface Events { updated: () => void; } +export interface Bot extends User { + config: BotConfig; +} export const bot_event_emitter = new EventEmitter(); -let active_bots: { [id: number]: User } = {}; -let _bots_list: User[] = []; +let active_bots: { [id: number]: Bot } = {}; +let _bots_list: Bot[] = []; export function bots() { return active_bots; } -export function bots_list(): Array { +export function bots_list(): Array { return _bots_list; } export function one_bot() { @@ -44,10 +55,340 @@ export function bot_count() { return Object.keys(active_bots).length; } +interface BotChallengeOptions { + rank: number; + width: number; + height: number; + speed: Speed; + system: TimeControlSystem; + ranked: boolean; + handicap: boolean; + //komi: number; + byoyomi?: { + main_time: number; + period_time: number; + periods: number; + }; + fischer?: { + initial_time: number; + max_time: number; + time_increment: number; + }; + simple?: { + time_per_move: number; + }; + + /* This will be set by getAcceptableTimeSetting() to match the config + * version the bot is using */ + _config_version?: number; +} + +/** Submit a desirable time setting for a bot. This will return null if the + * bot is not compatible with the requested time setting, or a BotChallengeOptions + * object that can be used to create a challenge. This will generally be the same + * object as the one passed in, however the speed may be changed to match an acceptable + * bot speed when the bot can accept the requested time setting in a different speed + * category. (Mostly applicable for bots that don't have the Rapid time setting but + * do accept games in that speed under either the Blitz or Live category.) + * + * When the bot does not have a config set, we will return the original options but + * the _config_version will be set to 0, so the caller can exclude the result or not + * as they see fit. + * + * The second return value is a string that describes the reason or note associated + * with the return value (Why the bot can't work with these options, or a note describing + * the lack of configuration) + * */ + +export function getAcceptableTimeSetting( + _bot: Bot | number, + options: BotChallengeOptions, +): [BotChallengeOptions | null, string | null] { + try { + const bot: Bot = typeof _bot === "number" ? active_bots[_bot] : _bot; + + if (!bot) { + return [ + null, + llm_pgettext("Unable to find a compatible game setting for bot", "Bot not found"), + ]; + } + + if (bot.config._config_version === 0) { + return [ + { ...options, _config_version: 0 }, + llm_pgettext( + "Unable to find a compatible game setting for bot", + "Bot has no configuration", + ), + ]; + } + + /* Check the board size */ + if (bot.config.allowed_board_sizes === "all") { + // whatever is allowed + } else if (bot.config.allowed_board_sizes === "square") { + if (options.width !== options.height) { + return [ + null, + llm_pgettext( + "Unable to find a compatible game setting for bot", + "Bot requires a square board", + ), + ]; + } + } else if (typeof bot.config.allowed_board_sizes === "number") { + if (options.width !== options.height) { + return [ + null, + llm_pgettext( + "Unable to find a compatible game setting for bot", + "Bot requires a square board", + ), + ]; + } + if (options.width !== bot.config.allowed_board_sizes) { + return [ + null, + llm_pgettext( + "Unable to find a compatible game setting for bot", + "Bot is unable to play this board size", + ), + ]; + } + } else if ( + Array.isArray(bot.config.allowed_board_sizes) && + bot.config.allowed_board_sizes.length === 1 && + bot.config.allowed_board_sizes[0] === 0 + ) { + // 0 means any board size too.. + } else { + // If the bot doesn't accept all board sizes, non square are rejected + if (options.width !== options.height) { + return [ + null, + llm_pgettext( + "Unable to find a compatible game setting for bot", + "Bot requires a square board", + ), + ]; + } + + let found = false; + if (Array.isArray(bot.config.allowed_board_sizes)) { + for (const size of bot.config.allowed_board_sizes) { + if (size === options.width) { + found = true; + break; + } + } + } + if (!found) { + return [ + null, + llm_pgettext( + "Unable to find a compatible game setting for bot", + "Bot is unable to play this board size", + ), + ]; + } + } + + /* Check allowed rank */ + function rankNumber(rank: string) { + if (rank.endsWith("p") || rank.endsWith("p")) { + return parseInt(rank) + 45; + } else if (rank.endsWith("d") || rank.endsWith("D")) { + return parseInt(rank) + 30; + } else { + return 30 - parseInt(rank); + } + } + + if ( + bot.config?.allowed_rank_range && + (options.rank < rankNumber(bot.config?.allowed_rank_range[0]) || + options.rank > rankNumber(bot.config?.allowed_rank_range[1])) + ) { + return [ + null, + llm_pgettext( + "Unable to find a compatible game setting for bot", + "Bot has an unsuitable rank restriction", + ), + ]; + } + + /* Check our ranked setting */ + if (options.ranked && !bot.config.allow_ranked) { + return [ + null, + llm_pgettext( + "Unable to find a compatible game setting for bot", + "Bot doesn't accept ranked games", + ), + ]; + } + if (!options.ranked && bot.config.allow_unranked) { + return [ + null, + llm_pgettext( + "Unable to find a compatible game setting for bot", + "Bot doesn't accept unranked games", + ), + ]; + } + + /* Check our handicap setting */ + if (options.handicap && options.ranked && !bot.config.allow_ranked_handicap) { + return [ + null, + llm_pgettext( + "Unable to find a compatible game setting for bot", + "Bot doesn't accept ranked games with handicap", + ), + ]; + } + if (options.handicap && !options.ranked && !bot.config.allow_unranked_handicap) { + return [ + null, + llm_pgettext( + "Unable to find a compatible game setting for bot", + "Bot doesn't accept unranked games with handicap", + ), + ]; + } + + /* Check our komi setting */ + /* + if ( + options.komi < bot.config.allowed_komi_range[0] || + options.komi > bot.config.allowed_komi_range[1] + ) { + return [ + null, + llm_pgettext( + "Unable to find a compatible game setting for bot", + "Komi not accepted", + ), + ]; + } + */ + + /* Check our speed settings */ + + if (!(options as any)[options.system]) { + console.error( + `Caller didn't provide ${options.system} time control system settings`, + options, + ); + return [ + null, + llm_pgettext( + "Unable to find a compatible game setting for bot", + "Time control system not provided", + ), + ]; + } + + function isInRange( + system: TimeControlSystem, + settings?: BotAllowedClockSettingsV1 | BotAllowedClockSettingsV2, + ) { + if (!settings) { + return false; + } + + if (system === "simple") { + return ( + settings.simple && + options.simple!.time_per_move >= settings.simple.per_move_time_range[0] && + options.simple!.time_per_move <= settings.simple.per_move_time_range[1] + ); + } + + if (system === "fischer") { + if (bot.config._config_version === 1) { + // bug in v1 was that max_time_range was used instead of initial_time_range + return ( + settings.fischer && + options.fischer!.initial_time >= settings.fischer.max_time_range[0] && + options.fischer!.initial_time <= settings.fischer.max_time_range[1] && + options.fischer!.time_increment >= + settings.fischer.time_increment_range[0] && + options.fischer!.time_increment <= settings.fischer.time_increment_range[1] + ); + } else if (bot.config._config_version === 2) { + const settingsV2 = settings as BotAllowedClockSettingsV2; + return ( + settingsV2.fischer && + options.fischer!.initial_time >= settingsV2.fischer.initial_time_range[0] && + options.fischer!.initial_time <= settingsV2.fischer.initial_time_range[1] && + options.fischer!.max_time >= settingsV2.fischer.max_time_range[0] && + options.fischer!.max_time <= settingsV2.fischer.max_time_range[1] && + options.fischer!.time_increment >= + settingsV2.fischer.time_increment_range[0] && + options.fischer!.time_increment <= + settingsV2.fischer.time_increment_range[1] + ); + } + } + + if (system === "byoyomi") { + return ( + settings.byoyomi && + options.byoyomi!.main_time >= settings.byoyomi.main_time_range[0] && + options.byoyomi!.main_time <= settings.byoyomi.main_time_range[1] && + options.byoyomi!.period_time >= settings.byoyomi.period_time_range[0] && + options.byoyomi!.period_time <= settings.byoyomi.period_time_range[1] && + options.byoyomi!.periods >= settings.byoyomi.periods_range[0] && + options.byoyomi!.periods <= settings.byoyomi.periods_range[1] + ); + } + + return false; + } + + if (isInRange(options.system, bot.config[`allowed_${options.speed}_settings`])) { + return [{ ...options, _config_version: bot.config._config_version }, null]; + } + + if (bot.config._config_version === 1) { + // v1 bots didn't have rapid settings, but we map that to live settings on the server + for (const speed of ["live"] as Speed[]) { + if (isInRange(options.system, bot.config[`allowed_${speed}_settings`])) { + return [ + { ...options, speed, _config_version: bot.config._config_version }, + null, + ]; + } + } + } + + return [ + null, + llm_pgettext( + "Unable to find a compatible game setting for bot", + "Unable to find a compatible settings", + ), + ]; + } catch (e) { + console.error("Error getting acceptable time setting", e); + return [ + null, + llm_pgettext( + "Unable to find a compatible game setting for bot", + "Unable to find a compatible settings", + ), + ]; + } +} + (window as any)["bots"] = bots; (window as any)["bots_list"] = bots_list; +(window as any)["bot_list"] = bots_list; -socket.on("active-bots", (bots: { [id: number]: User }) => { +socket.on("active-bots", (bots) => { active_bots = bots; _bots_list = []; for (const id in bots) { @@ -55,7 +396,5 @@ socket.on("active-bots", (bots: { [id: number]: User }) => { } _bots_list.sort((a, b) => getUserRating(a).rating - getUserRating(b).rating); - console.log("Active bots: ", _bots_list); - bot_event_emitter.emit("updated"); }); diff --git a/src/lib/preferences.ts b/src/lib/preferences.ts index 956fe6b3bb..be6d596541 100644 --- a/src/lib/preferences.ts +++ b/src/lib/preferences.ts @@ -34,13 +34,23 @@ export const defaults = { "play.tab": "automatch" as "automatch" | "custom", "automatch.size": "9x9" as Size, "automatch.speed": "rapid" as JGOFTimeControlSpeed, - "automatch.game-clock": "flexible" as "exact" | "flexible", + "automatch.game-clock": "flexible" as "exact" | "flexible" | "multiple", "automatch.handicaps": "standard" as "enabled" | "standard" | "disabled", "automatch.time-control": "fischer" as "fischer" | "byoyomi", "automatch.opponent": "human" as "human" | "bot", "automatch.bot": 0, "automatch.lower-rank-diff": 3, "automatch.upper-rank-diff": 3, + "automatch.show-custom-games": false, + "automatch.multiple-sizes": { "9x9": false, "13x13": false, "19x19": false }, + "automatch.multiple-speeds": { + "blitz-fischer": false, + "blitz-byoyomi": false, + "rapid-fischer": false, + "rapid-byoyomi": false, + "live-fischer": false, + "live-byoyomi": false, + }, "board-labeling": "automatic", "chat.show-all-global-channels": true, "chat.show-all-group-channels": true, diff --git a/src/lib/types.ts b/src/lib/types.ts index b9647cf50e..252f1026ca 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -100,21 +100,6 @@ export interface AutomatchPreferencesBase { condition: AutomatchCondition; value: "japanese" | "chinese" | "aga" | "korean" | "nz" | "ing"; }; - time_control: { - condition: AutomatchCondition; - value: { - system: AutomatchTimeControlSystem; - initial_time?: number; - time_increment?: number; - max_time?: number; - main_time?: number; - period_time?: number; - periods?: number; - stones_per_period?: number; - per_move?: number; - pause_on_weekends?: boolean; - }; - }; handicap: { condition: AutomatchCondition; value: "enabled" | "disabled"; diff --git a/src/views/Play/CustomGames.styl b/src/views/Play/CustomGames.styl index 6450686b4f..84d75a4cf0 100644 --- a/src/views/Play/CustomGames.styl +++ b/src/views/Play/CustomGames.styl @@ -22,6 +22,7 @@ align-items: stretch; justify-content: center; box-sizing: border-box; + margin-bottom: 8rem; .header-container { width: 100%; @@ -58,6 +59,17 @@ } } + .create-custom-games-buttons { + display: flex; + gap: 1rem; + justify-content: space-between; + width: 100%; + + button { + height: 3rem; + font-size: 1.1rem; + } + } // This shenanigans is needed to ensure that the canvas of the seek graph // remains rendered, so we don't loose the reference we have to it. diff --git a/src/views/Play/CustomGames.tsx b/src/views/Play/CustomGames.tsx index a31a69525d..bb94641fc1 100644 --- a/src/views/Play/CustomGames.tsx +++ b/src/views/Play/CustomGames.tsx @@ -49,7 +49,7 @@ import { anyChallengesToShow, challenge_sort, time_per_move_challenge_sort } fro import { RengoManagementPane } from "@/components/RengoManagementPane"; import { RengoTeamManagementPane } from "@/components/RengoTeamManagementPane"; import { PlayContext } from "./PlayContext"; -import { ChallengeModalBody } from "@/components/ChallengeModal"; +import { challenge } from "@/components/ChallengeModal"; const CHALLENGE_LIST_FREEZE_PERIOD = 1000; // Freeze challenge list for this period while they move their mouse on it @@ -391,6 +391,12 @@ export function CustomGames(): JSX.Element { }); }, [live_list, cancelOpenChallenge]); + const disable_challenge_buttons = !!( + liveOwnChallengePending() || + live_rengo_challenge_to_show || + automatch_manager.active_live_automatcher + ); + return (
- - {liveOwnChallengePending() ? ( - <> -
{_("Waiting for opponent...")}
-
-
-
-
-
+ {liveOwnChallengePending() ? ( + +
{_("Waiting for opponent...")}
+
+
+
+
-
- -
- - ) : live_rengo_challenge_to_show ? ( - <> - +
+ +
+ + ) : live_rengo_challenge_to_show ? ( + + + - - setPaneLock(live_rengo_challenge_to_show.challenge_id, lock) - } - /> - - - ) : ( -
- void) => { - console.log("on", event, callback); - }, - off: (event: "open" | "close", callback: () => void) => { - console.log("off", event, callback); - }, - }} + lock={(lock: boolean) => + setPaneLock(live_rengo_challenge_to_show.challenge_id, lock) + } /> -
- )} -
+
+ + ) : null}

@@ -505,6 +493,27 @@ export function CustomGames(): JSX.Element { showIcons={true} toggleHandler={toggleFilterHandler} > + +
+ + +

@@ -558,10 +567,8 @@ export function CustomGames(): JSX.Element { .join(",")} - - {m.time_control.condition === "no-preference" - ? pgettext("Automatch: no preference", "No preference") - : timeControlSystemText(m.time_control.value.system)} + + {timeControlSystemText(m.size_speed_options[0].system)} diff --git a/src/views/Play/Play.styl b/src/views/Play/Play.styl index f4f81c5d2a..edf05c50d7 100644 --- a/src/views/Play/Play.styl +++ b/src/views/Play/Play.styl @@ -169,6 +169,18 @@ } } + + .custom-games-toggle-container { + margin-top: 2rem; + text-align: center; + margin-bottom: 8rem; + &.showing-custom-games { + margin-bottom: 1rem; + } + } + .custom-games-toggle { + font-size: 1.3rem; + } } @@ -307,4 +319,5 @@ .fa-times-circle-o { cursor: pointer; } + } diff --git a/src/views/Play/Play.tsx b/src/views/Play/Play.tsx index 8e153ca833..93b8011c94 100644 --- a/src/views/Play/Play.tsx +++ b/src/views/Play/Play.tsx @@ -18,12 +18,18 @@ import * as React from "react"; import * as preferences from "@/lib/preferences"; -import { _, pgettext } from "@/lib/translate"; +//import { _, pgettext } from "@/lib/translate"; +import { _ } from "@/lib/translate"; import { QuickMatch } from "./QuickMatch"; import { CustomGames } from "./CustomGames"; export function Play(): JSX.Element { - const [tab, setTab] = preferences.usePreference("play.tab"); + const [show_custom_games, setShowCustomGames] = preferences.usePreference( + "automatch.show-custom-games", + ); + const toggleCustomGames = React.useCallback(() => { + setShowCustomGames(!show_custom_games); + }, [show_custom_games]); React.useEffect(() => { window.document.title = _("Play"); @@ -31,6 +37,7 @@ export function Play(): JSX.Element { return (
+ {/*

{pgettext("Play page", "Matchmaking")} @@ -51,9 +58,27 @@ export function Play(): JSX.Element {

+ */} + {/* {tab === "automatch" && } {tab === "custom" && } + */} + + +
+
+ +
+ {show_custom_games && } +
); } diff --git a/src/views/Play/QuickMatch.styl b/src/views/Play/QuickMatch.styl index 631e767a7e..138e0eaa30 100644 --- a/src/views/Play/QuickMatch.styl +++ b/src/views/Play/QuickMatch.styl @@ -385,7 +385,7 @@ } .speed-options { - margin-bottom: 2rem; + //margin-bottom: 2rem; } .opponent-options { @@ -452,8 +452,23 @@ } .opponent-rank-range { - display: flex; - justify-content: space-around; + //display: flex; + text-align: center; + //justify-content: space-around; + select { + text-align: center; + margin-left: 0.5rem; + margin-right: 0.5rem; + } + } + + .opponent-rank-range-description { + font-size: 0.9rem; + themed color shade1 + text-align: center; + padding-right: 0.5rem; + padding-left: 0.5rem; + margin-bottom: 0.5rem; } .computer-select { @@ -537,6 +552,49 @@ padding-right: 0.5rem; padding-left: 0.5rem; } + + + + } + + .option-label, .ogs-react-select__single-value { + display: flex !important; + align-items: center; + gap: 0.5rem; + } + + .multiple-options-description { + font-size: 0.9rem; + font-style: italic; + themed color shade1 + padding-right: 0.5rem; + padding-left: 0.5rem; + margin-bottom: 0.5rem; + } + + .computer-select { + .disabled .option-label { + themed color shade2 + } + + .option-label { + display: flex; + justify-content: space-between; + gap: 0.5rem; + + > span { + display: flex; + align-items: center; + gap: 0.5rem; + } + } + + > div { + width: 100%; + } + &.error { + border: 1px solid red; + } } } @@ -547,7 +605,6 @@ flex-direction: column; margin-right: 0rem; margin-left: 0rem; - margin-bottom: 8rem; column-gap: 0rem; margin-top: 0rem; @@ -573,6 +630,7 @@ width: 100%; left: 0; right: 0; + z-index: z.dock; themed background-color bg .play-button { margin: 0; diff --git a/src/views/Play/QuickMatch.tsx b/src/views/Play/QuickMatch.tsx index ee667ab6bc..d6871885b8 100644 --- a/src/views/Play/QuickMatch.tsx +++ b/src/views/Play/QuickMatch.tsx @@ -27,14 +27,14 @@ import { Size, Speed, } from "goban"; -import { _, pgettext } from "@/lib/translate"; +import { _, llm_pgettext, pgettext } from "@/lib/translate"; import { automatch_manager } from "@/lib/automatch_manager"; -import { bot_event_emitter, bots_list } from "@/lib/bots"; +import { Bot, bot_event_emitter, bots_list, getAcceptableTimeSetting } from "@/lib/bots"; import { alert } from "@/lib/swal_config"; import { useRefresh, useUser } from "@/lib/hooks"; //import { Toggle } from "@/components/Toggle"; import { MiniGoban } from "@/components/MiniGoban"; -import { getUserRating, rankString } from "@/lib/rank_utils"; +import { rankString } from "@/lib/rank_utils"; import { errorAlerter, uuid } from "@/lib/misc"; import { LoadingButton } from "@/components/LoadingButton"; import { post } from "@/lib/requests"; @@ -48,9 +48,9 @@ import { notification_manager, NotificationManagerEvents } from "@/components/No import { socket } from "@/lib/sockets"; import { sfx } from "@/lib/sfx"; import { Link } from "react-router-dom"; -import Select from "react-select"; +import Select, { components } from "react-select"; import { SPEED_OPTIONS } from "./SPEED_OPTIONS"; -import { AvailableQuickMatches } from "./AvailableQuickMatches"; +import { PlayerIcon } from "@/components/PlayerIcon"; moment.relativeTimeThreshold("m", 56); export interface SelectOption { @@ -79,6 +79,14 @@ const game_clock_options: OptionWithDescription[] = [ "Prefer one time setting, but accept the other similarly paced time setting", ), }, + { + value: "multiple", + label: _("Multiple"), + description: pgettext( + "Game Clock option description for being able to choose between multiple time settings", + "Pick multiple acceptable time settings", + ), + }, ]; const handicap_options: OptionWithDescription[] = [ @@ -130,6 +138,57 @@ const RenderOptionWithDescription = (props: { ); }; +const RenderBotOption = (props: { + data: Bot & { disabled?: string }; + innerProps: any; + innerRef: any; + isFocused: boolean; + isSelected: boolean; +}) => { + const opt = props.data; + //console.log(opt.username, props.isSelected); + return ( +
+
+ + + {opt.username} ({rankString(opt.ranking || 0)}) + + + + + + +
+
+ {props.data.disabled ? props.data.disabled : ""} +
+
+ ); +}; + +const RenderBotValue = (props: any) => { + const opt = props.data; + return ( + + + {opt.username} ({rankString(opt.ranking || 0)}) + + ); +}; + const select_styles = { menu: ({ ...css }) => ({ ...css, @@ -153,13 +212,19 @@ export function QuickMatch(): JSX.Element { const [upper_rank_diff, setUpperRankDiff] = preferences.usePreference( "automatch.upper-rank-diff", ); - const bot_select = React.useRef(null); const [correspondence_spinner, setCorrespondenceSpinner] = React.useState(false); const [bot_spinner, setBotSpinner] = React.useState(false); const cancel_bot_game = React.useRef<() => void>(() => {}); const [game_clock, setGameClock] = preferences.usePreference("automatch.game-clock"); + const [multiple_sizes, setMultipleSizes] = preferences.usePreference( + "automatch.multiple-sizes", + ); + const [multiple_speeds, setMultipleSpeeds] = preferences.usePreference( + "automatch.multiple-speeds", + ); + React.useEffect(() => { automatch_manager.on("entry", refresh); automatch_manager.on("start", refresh); @@ -193,9 +258,40 @@ export function QuickMatch(): JSX.Element { // Open challenge console.log("findMatch", board_size, game_speed); - const size_speed_options: Array<{ size: Size; speed: Speed }> = [ - { size: board_size, speed: game_speed }, - ]; + const size_speed_options: Array<{ + size: Size; + speed: Speed; + system: "fischer" | "byoyomi"; + }> = []; + + if (game_clock === "exact" || game_clock === "flexible") { + size_speed_options.push({ + size: board_size, + speed: game_speed, + system: time_control_system, + }); + if (game_clock === "flexible" && game_speed !== "correspondence") { + size_speed_options.push({ + size: board_size, + speed: game_speed, + system: time_control_system === "fischer" ? "byoyomi" : "fischer", + }); + } + } else { + for (const size in multiple_sizes) { + if (multiple_sizes[size as keyof typeof multiple_sizes]) { + for (const speed_system in multiple_speeds) { + if (multiple_speeds[speed_system as keyof typeof multiple_speeds]) { + const [speed, system] = speed_system.split("-"); + size_speed_options.push({ size, speed, system } as any); + } + } + } + } + + // shuffle the options so we aren't biasing towards the same settings all the time + size_speed_options.sort(() => Math.random() - 0.5); + } const preferences: AutomatchPreferences = { uuid: uuid(), @@ -206,12 +302,6 @@ export function QuickMatch(): JSX.Element { condition: "required", value: "japanese", }, - time_control: { - condition: game_clock === "flexible" ? "preferred" : "required", - value: { - system: time_control_system, - }, - }, handicap: { condition: handicaps === "standard" ? "preferred" : "required", value: handicaps === "disabled" ? "disabled" : "enabled", @@ -237,9 +327,28 @@ export function QuickMatch(): JSX.Element { refresh, automatch_manager, setCorrespondenceSpinner, + multiple_sizes, + multiple_speeds, ]); const playComputer = React.useCallback(() => { + const settings = { + rank: user.ranking, + width: parseInt(board_size), + height: parseInt(board_size), + ranked: true, + handicap: handicaps === "disabled" ? false : true, + system: time_control_system, + speed: game_speed, + [time_control_system]: SPEED_OPTIONS[board_size][game_speed][time_control_system], + }; + const [options, message] = getAcceptableTimeSetting(selected_bot, settings); + if (!options) { + console.error("Failed to find acceptable time setting", message); + void alert.fire(_("Please select a bot")); + return; + } + const challenge: ChallengeDetails = { initialized: false, min_ranking: -99, @@ -295,7 +404,7 @@ export function QuickMatch(): JSX.Element { }, }; - const bot_id = bot_select.current?.value || selected_bot; + const bot_id = selected_bot; if (!bot_id) { void alert.fire(_("Please select a bot")); return; @@ -406,7 +515,126 @@ export function QuickMatch(): JSX.Element { const search_active = !!automatch_manager.active_live_automatcher || correspondence_spinner || bot_spinner; - // Construction of the pane we need to show... + function isSizeActive(size: Size) { + if (game_clock === "multiple") { + return multiple_sizes[size]; + } else { + return board_size === size; + } + } + + function isSpeedSystemActive(speed: JGOFTimeControlSpeed, system: "fischer" | "byoyomi") { + if (game_clock === "multiple") { + return multiple_speeds[`${speed as "blitz" | "rapid" | "live"}-${system}`]; + } else { + return game_speed === speed && time_control_system === system; + } + } + + function toggleSpeedSystem(speed: JGOFTimeControlSpeed, system: "fischer" | "byoyomi") { + if (game_clock === "multiple") { + const new_speeds = { + ...multiple_speeds, + [`${speed as "blitz" | "rapid" | "live"}-${system}`]: + !multiple_speeds[`${speed as "blitz" | "rapid" | "live"}-${system}`], + }; + delete (new_speeds as any)["correspondence-fischer"]; + delete (new_speeds as any)["correspondence-byoyomi"]; + + if (Object.values(new_speeds).filter((x) => x).length > 0) { + setMultipleSpeeds(new_speeds); + } + } else { + setGameSpeed(speed); + setTimeControlSystem(system); + } + } + + function toggleSize(size: Size) { + if (game_clock === "multiple") { + const new_sizes = { + ...multiple_sizes, + [size]: !multiple_sizes[size], + }; + + if (Object.values(new_sizes).filter((x) => x).length > 0) { + setMultipleSizes(new_sizes); + } + } else { + setBoardSize(size); + } + } + + // nothing selected? Select what we last had selected + if (game_clock === "multiple") { + if (Object.values(multiple_sizes).filter((x) => x).length === 0) { + toggleSize(board_size); + } + if (Object.values(multiple_speeds).filter((x) => x).length === 0) { + if (game_speed !== "correspondence") { + toggleSpeedSystem(game_speed, time_control_system); + } else { + toggleSpeedSystem("rapid", time_control_system); + } + } + } + + const selected_size_count = + game_clock === "multiple" ? Object.values(multiple_sizes).filter((x) => x).length : 1; + + const min_selected_size = multiple_sizes["9x9"] + ? "9x9" + : multiple_sizes["13x13"] + ? "13x13" + : "19x19"; + const max_selected_size = multiple_sizes["19x19"] + ? "19x19" + : multiple_sizes["13x13"] + ? "13x13" + : "9x9"; + + let available_bots: (Bot & { disabled?: string })[] = bots_list().filter((b) => b.id > 0); + + if (game_clock !== "multiple") { + available_bots = available_bots.filter((b) => { + const settings = { + rank: user.ranking, + width: parseInt(board_size), + height: parseInt(board_size), + ranked: true, + handicap: handicaps === "disabled" ? false : true, + system: time_control_system, + speed: game_speed, + [time_control_system]: SPEED_OPTIONS[board_size][game_speed][time_control_system], + }; + const [options, message] = getAcceptableTimeSetting(b, settings); + if (!options) { + b.disabled = message || undefined; + } else if (options && options._config_version && options._config_version === 0) { + b.disabled = llm_pgettext( + "Bot is not configured correctly", + "Bot is not configured correctly", + ); + } else { + b.disabled = undefined; + } + + return true; + }); + } + + available_bots.sort((a, b) => { + if (a.disabled && !b.disabled) { + return 1; + } + if (b.disabled && !a.disabled) { + return -1; + } + return (a.ranking || 0) - (b.ranking || 0); + }); + + const selected_bot_value = available_bots.find((b) => b.id === selected_bot) || undefined; + return ( <>
@@ -419,11 +647,11 @@ export function QuickMatch(): JSX.Element {
{(["9x9", "13x13", "19x19"] as Size[]).map((s) => (
-
+ {game_clock === "multiple" && ( +
+ {_("Select all the settings you are comfortable playing with.")} +
+ )} {( ["blitz", "rapid", "live", "correspondence"] as JGOFTimeControlSpeed[] ).map((speed) => { - const opt = SPEED_OPTIONS[board_size as any][speed]; + const opt = + SPEED_OPTIONS[ + game_clock === "multiple" + ? min_selected_size + : (board_size as any) + ][speed]; + const min_opt = SPEED_OPTIONS[min_selected_size as any][speed]; + const max_opt = SPEED_OPTIONS[max_selected_size as any][speed]; return (
- {opt.time_estimate} - {/* - {opt.fischer.time_estimate || opt.time_estimate} + {selected_size_count > 1 && speed !== "correspondence" + ? `${ + min_opt.time_estimate + } - ${max_opt.time_estimate.replace( + /\u223c/, + "", + )}` + : opt.time_estimate} - {opt.byoyomi?.time_estimate && ( - - {opt.byoyomi.time_estimate} - - )} - */}
{ - setGameSpeed(speed); - setTimeControlSystem("fischer"); + toggleSpeedSystem(speed, "fischer"); }} > - {shortDurationString(opt.fischer.initial_time)} + {selected_size_count > 1 && speed !== "correspondence" + ? `${shortDurationString( + min_opt.fischer.initial_time, + ).replace( + /[^0-9]+/g, + "", + )} - ${shortDurationString( + max_opt.fischer.initial_time, + )}` + : shortDurationString(opt.fischer.initial_time)} {" + "} {shortDurationString(opt.fischer.time_increment)} @@ -534,18 +786,28 @@ export function QuickMatch(): JSX.Element {
+
{_("Rank range")}
{ - if (search_active) { + if (search_active || game_clock === "multiple") { return; } setOpponent("bot"); @@ -634,29 +897,45 @@ export function QuickMatch(): JSX.Element {
{pgettext("Play a computer opponent", "Computer")}
-
- -   - - - +
0 && + opponent === "bot" && + (!selected_bot || + !selected_bot_value || + selected_bot_value.disabled) + ? "error" + : "") + } + > +