diff --git a/webui/src/components/Playground.tsx b/webui/src/components/Playground.tsx index 5809558..624a1fa 100644 --- a/webui/src/components/Playground.tsx +++ b/webui/src/components/Playground.tsx @@ -5,6 +5,7 @@ import { Button } from "@/components/ui/Button"; import { Dropdown } from "@/components/ui/Dropdown"; import { Slider } from "@/components/ui/Slider"; import { SidePanel } from "@/components/ui/SidePanel"; +import { AgentSelector } from "@/components/ui/AgentSelector"; import { Editor } from "@/components/Editor"; import { Terminal, TerminalRef } from "@/components/Terminal"; import { @@ -38,11 +39,39 @@ fn step_max(board) { return random_action(board); }`; +const AGENTS = { + // special-cased below + Self: undefined, + + Random: `fn random_action(board) { + let symb = rand_symb(); + let pos = rand_int(0, 10); + let action = Action(symb, pos); + + while !board.can_play(action) { + let symb = rand_symb(); + let pos = rand_int(0, 10); + action = Action(symb, pos); + } + + return action; +} + +fn step_min(board) { + return random_action(board); +} + +fn step_max(board) { + return random_action(board); +}`, +}; + export default function Playground() { const [isScriptRunning, setIsScriptRunning] = useState(false); const [isEditorReady, setIsEditorReady] = useState(false); const [fontSize, setFontSize] = useState(14); const [bulkRounds, setBulkRounds] = useState(1000); + const [selectedAgent, setSelectedAgent] = useState("Random"); const [isHelpOpen, setIsHelpOpen] = useState(false); const editorRef = useRef(null); @@ -104,7 +133,7 @@ export default function Playground() { resultRef.current.value = ""; } - if (runDisabled || !editorRef.current) return; + if (runDisabled) return; setIsScriptRunning(true); @@ -112,8 +141,16 @@ export default function Playground() { terminalRef.current?.clear(); terminalRef.current?.focus(); + const agentCode = AGENTS[selectedAgent as keyof typeof AGENTS]; + const blueScript = + agentCode || (editorRef.current?.getValue() ?? ""); + const redScript = editorRef.current?.getValue() ?? ""; + const opponentName = agentCode ? selectedAgent : "script"; + await startScriptBulk( - editorRef.current.getValue(), + redScript, + blueScript, + opponentName, (line: string) => { if (resultRef.current) { let v = resultRef.current.value + line + "\n"; @@ -145,7 +182,7 @@ export default function Playground() { } setIsScriptRunning(false); - }, [runDisabled, bulkRounds]); + }, [runDisabled, bulkRounds, selectedAgent]); const stopScriptHandler = useCallback(() => { stopScript(); @@ -210,6 +247,15 @@ export default function Playground() { onChange={setBulkRounds} unit="" /> +
+ + +
} /> diff --git a/webui/src/components/Terminal.tsx b/webui/src/components/Terminal.tsx index 4d8468a..2bfda45 100644 --- a/webui/src/components/Terminal.tsx +++ b/webui/src/components/Terminal.tsx @@ -98,7 +98,7 @@ async function init_term( const term = new Terminal({ //"fontFamily": "Fantasque", - rows: 30, + rows: 24, fontSize: fontSize ?? 18, tabStopWidth: 4, cursorBlink: false, diff --git a/webui/src/components/ui/AgentSelector.tsx b/webui/src/components/ui/AgentSelector.tsx new file mode 100644 index 0000000..090bd77 --- /dev/null +++ b/webui/src/components/ui/AgentSelector.tsx @@ -0,0 +1,191 @@ +"use client"; + +import { useState, useRef, useEffect } from "react"; +import { ChevronDown, Search } from "lucide-react"; +import clsx from "clsx"; +import styles from "@/styles/AgentSelector.module.css"; + +interface AgentSelectorProps { + agents: string[]; + selectedAgent: string; + onSelect: (agent: string) => void; + placeholder?: string; +} + +export function AgentSelector({ + agents, + selectedAgent, + onSelect, + placeholder = "Select an agent..." +}: AgentSelectorProps) { + const [isOpen, setIsOpen] = useState(false); + const [searchTerm, setSearchTerm] = useState(""); + const [highlightedIndex, setHighlightedIndex] = useState(0); + const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0, width: 0 }); + const dropdownRef = useRef(null); + const triggerRef = useRef(null); + const searchRef = useRef(null); + + const filteredAgents = agents.filter(agent => + agent.toLowerCase().includes(searchTerm.toLowerCase()) + ); + + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if ( + dropdownRef.current && + !dropdownRef.current.contains(event.target as Node) + ) { + setIsOpen(false); + setSearchTerm(""); + } + } + + if (isOpen) { + document.addEventListener("mousedown", handleClickOutside); + } + + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + }, [isOpen]); + + useEffect(() => { + if (isOpen && searchRef.current) { + searchRef.current.focus(); + } + }, [isOpen]); + + useEffect(() => { + setHighlightedIndex(0); + }, [searchTerm]); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (!isOpen) { + if (e.key === "Enter" || e.key === " " || e.key === "ArrowDown") { + e.preventDefault(); + updateDropdownPosition(); + setIsOpen(true); + } + return; + } + + switch (e.key) { + case "ArrowDown": + e.preventDefault(); + setHighlightedIndex(prev => + prev < filteredAgents.length - 1 ? prev + 1 : 0 + ); + break; + case "ArrowUp": + e.preventDefault(); + setHighlightedIndex(prev => + prev > 0 ? prev - 1 : filteredAgents.length - 1 + ); + break; + case "Enter": + e.preventDefault(); + if (filteredAgents[highlightedIndex]) { + onSelect(filteredAgents[highlightedIndex]); + setIsOpen(false); + setSearchTerm(""); + } + break; + case "Escape": + e.preventDefault(); + setIsOpen(false); + setSearchTerm(""); + break; + } + }; + + const handleSelect = (agent: string) => { + onSelect(agent); + setIsOpen(false); + setSearchTerm(""); + }; + + const updateDropdownPosition = () => { + if (triggerRef.current) { + const rect = triggerRef.current.getBoundingClientRect(); + setDropdownPosition({ + top: rect.bottom + 2, + left: rect.left, + width: rect.width + }); + } + }; + + const handleOpen = () => { + updateDropdownPosition(); + setIsOpen(!isOpen); + }; + + return ( +
+ + + {isOpen && ( +
+
+ + setSearchTerm(e.target.value)} + onKeyDown={handleKeyDown} + /> +
+ +
    + {filteredAgents.length === 0 ? ( +
  • No agents found
  • + ) : ( + filteredAgents.map((agent, index) => ( +
  • handleSelect(agent)} + role="option" + aria-selected={agent === selectedAgent} + > + {agent} +
  • + )) + )} +
+
+ )} +
+ ); +} \ No newline at end of file diff --git a/webui/src/lib/runner.ts b/webui/src/lib/runner.ts index 498742d..46c7ec6 100644 --- a/webui/src/lib/runner.ts +++ b/webui/src/lib/runner.ts @@ -53,7 +53,9 @@ export async function startScript( } export async function startScriptBulk( - script: string, + redScript: string, + blueScript: string, + opponentName: string, appendOutput: (line: string) => void, appendTerminal: (line: string) => void, rounds: number = 1000 @@ -93,7 +95,7 @@ export async function startScriptBulk( reject(error); }; - worker.postMessage({ type: "run", script, rounds }); + worker.postMessage({ type: "run", redScript, blueScript, opponentName, rounds }); }); } diff --git a/webui/src/lib/worker_bulk.ts b/webui/src/lib/worker_bulk.ts index 17f026e..fdd344d 100644 --- a/webui/src/lib/worker_bulk.ts +++ b/webui/src/lib/worker_bulk.ts @@ -56,11 +56,11 @@ self.onmessage = async (event) => { appendTerminal(`============\n\n\r`); currentGame = new MinMaxGame( - event_data.script, + event_data.redScript, () => {}, () => {}, - event_data.script, + event_data.blueScript, () => {}, () => {}, @@ -89,13 +89,19 @@ self.onmessage = async (event) => { } const elapsed = Math.round((performance.now() - start) / 100) / 10; - const r_winrate = Math.round((red_wins / n_rounds) * 1000) / 10 - const b_winrate = Math.round((blue_wins / n_rounds) * 1000) / 10 + const r_winrate = Math.round((red_wins / n_rounds) * 1000) / 10; + const b_winrate = Math.round((blue_wins / n_rounds) * 1000) / 10; + + const opponentName = event_data.opponentName || "Unknown"; appendTerminal("\r\n"); appendTerminal(`Ran ${n_rounds} rounds in ${elapsed}s\r\n`); - appendTerminal(`Red won: ${red_wins} (${r_winrate}%)\r\n`); - appendTerminal(`Blue won: ${blue_wins} (${b_winrate}%)\r\n`); + appendTerminal( + `Red won: ${red_wins} (${r_winrate}%) (script)\r\n` + ); + appendTerminal( + `Blue won: ${blue_wins} (${b_winrate}%) (${opponentName})\r\n` + ); appendTerminal("\r\n"); appendTerminal(`Draws: ${draw_score}\r\n`); appendTerminal(`Invalid: ${draw_invalid}\r\n`); diff --git a/webui/src/styles/AgentSelector.module.css b/webui/src/styles/AgentSelector.module.css new file mode 100644 index 0000000..242a531 --- /dev/null +++ b/webui/src/styles/AgentSelector.module.css @@ -0,0 +1,143 @@ +.agentSelector { + position: relative; + width: 100%; +} + +.trigger { + width: 100%; + padding: 6px 8px; + background: #3c3c3c; + color: #e0e0e0; + border: 1px solid #555; + border-radius: 3px; + font-size: 13px; + outline: none; + cursor: pointer; + display: flex; + align-items: center; + justify-content: space-between; + transition: border-color 0.2s; +} + +.trigger:hover { + border-color: #666; +} + +.trigger:focus, +.triggerOpen { + border-color: #007acc; + box-shadow: 0 0 3px rgba(0, 122, 204, 0.3); +} + +.triggerText { + flex: 1; + text-align: left; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.chevron { + transition: transform 0.2s; + flex-shrink: 0; + margin-left: 8px; +} + +.chevronOpen { + transform: rotate(180deg); +} + +.dropdown { + position: fixed; + z-index: 10000; + background: #2d2d30; + border: 1px solid #555; + border-radius: 3px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + margin-top: 2px; + max-height: 300px; + overflow: hidden; + display: flex; + flex-direction: column; + min-width: 200px; +} + +.searchContainer { + position: relative; + padding: 8px; + border-bottom: 1px solid #555; +} + +.searchIcon { + position: absolute; + left: 16px; + top: 50%; + transform: translateY(-50%); + color: #999; +} + +.searchInput { + width: 100%; + padding: 6px 8px 6px 32px; + background: #3c3c3c; + color: #e0e0e0; + border: 1px solid #555; + border-radius: 3px; + font-size: 13px; + outline: none; +} + +.searchInput:focus { + border-color: #007acc; + box-shadow: 0 0 3px rgba(0, 122, 204, 0.3); +} + +.searchInput::placeholder { + color: #999; +} + +.agentList { + list-style: none; + margin: 0; + padding: 0; + overflow-y: auto; + flex: 1; +} + +.agentOption { + padding: 8px 12px; + cursor: pointer; + font-size: 13px; + color: #e0e0e0; + transition: background-color 0.15s; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.agentOption:hover, +.highlighted { + background: #404040; +} + +.agentOption:active { + background: #4a4a4a; +} + +.selected { + background: #007acc; + color: white; +} + +.selected:hover, +.selected.highlighted { + background: #0088dd; +} + +.noResults { + padding: 12px; + text-align: center; + color: #999; + font-style: italic; + font-size: 13px; +} \ No newline at end of file diff --git a/webui/src/styles/Playground.module.css b/webui/src/styles/Playground.module.css index 53194ac..7ad95a8 100644 --- a/webui/src/styles/Playground.module.css +++ b/webui/src/styles/Playground.module.css @@ -105,6 +105,19 @@ min-width: 250px; } +.configField { + margin-top: 16px; +} + +.configField label { + display: block; + font-size: 13px; + font-weight: 600; + color: #cccccc; + margin-bottom: 6px; +} + + .helpPanel { padding: 16px; width: 300px;