Select bulk opponent
This commit is contained in:
@@ -5,6 +5,7 @@ import { Button } from "@/components/ui/Button";
|
|||||||
import { Dropdown } from "@/components/ui/Dropdown";
|
import { Dropdown } from "@/components/ui/Dropdown";
|
||||||
import { Slider } from "@/components/ui/Slider";
|
import { Slider } from "@/components/ui/Slider";
|
||||||
import { SidePanel } from "@/components/ui/SidePanel";
|
import { SidePanel } from "@/components/ui/SidePanel";
|
||||||
|
import { AgentSelector } from "@/components/ui/AgentSelector";
|
||||||
import { Editor } from "@/components/Editor";
|
import { Editor } from "@/components/Editor";
|
||||||
import { Terminal, TerminalRef } from "@/components/Terminal";
|
import { Terminal, TerminalRef } from "@/components/Terminal";
|
||||||
import {
|
import {
|
||||||
@@ -38,11 +39,39 @@ fn step_max(board) {
|
|||||||
return random_action(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() {
|
export default function Playground() {
|
||||||
const [isScriptRunning, setIsScriptRunning] = useState(false);
|
const [isScriptRunning, setIsScriptRunning] = useState(false);
|
||||||
const [isEditorReady, setIsEditorReady] = useState(false);
|
const [isEditorReady, setIsEditorReady] = useState(false);
|
||||||
const [fontSize, setFontSize] = useState(14);
|
const [fontSize, setFontSize] = useState(14);
|
||||||
const [bulkRounds, setBulkRounds] = useState(1000);
|
const [bulkRounds, setBulkRounds] = useState(1000);
|
||||||
|
const [selectedAgent, setSelectedAgent] = useState("Random");
|
||||||
const [isHelpOpen, setIsHelpOpen] = useState(false);
|
const [isHelpOpen, setIsHelpOpen] = useState(false);
|
||||||
|
|
||||||
const editorRef = useRef<any>(null);
|
const editorRef = useRef<any>(null);
|
||||||
@@ -104,7 +133,7 @@ export default function Playground() {
|
|||||||
resultRef.current.value = "";
|
resultRef.current.value = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (runDisabled || !editorRef.current) return;
|
if (runDisabled) return;
|
||||||
|
|
||||||
setIsScriptRunning(true);
|
setIsScriptRunning(true);
|
||||||
|
|
||||||
@@ -112,8 +141,16 @@ export default function Playground() {
|
|||||||
terminalRef.current?.clear();
|
terminalRef.current?.clear();
|
||||||
terminalRef.current?.focus();
|
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(
|
await startScriptBulk(
|
||||||
editorRef.current.getValue(),
|
redScript,
|
||||||
|
blueScript,
|
||||||
|
opponentName,
|
||||||
(line: string) => {
|
(line: string) => {
|
||||||
if (resultRef.current) {
|
if (resultRef.current) {
|
||||||
let v = resultRef.current.value + line + "\n";
|
let v = resultRef.current.value + line + "\n";
|
||||||
@@ -145,7 +182,7 @@ export default function Playground() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setIsScriptRunning(false);
|
setIsScriptRunning(false);
|
||||||
}, [runDisabled, bulkRounds]);
|
}, [runDisabled, bulkRounds, selectedAgent]);
|
||||||
|
|
||||||
const stopScriptHandler = useCallback(() => {
|
const stopScriptHandler = useCallback(() => {
|
||||||
stopScript();
|
stopScript();
|
||||||
@@ -210,6 +247,15 @@ export default function Playground() {
|
|||||||
onChange={setBulkRounds}
|
onChange={setBulkRounds}
|
||||||
unit=""
|
unit=""
|
||||||
/>
|
/>
|
||||||
|
<div className={styles.configField}>
|
||||||
|
<label>Bulk opponent</label>
|
||||||
|
<AgentSelector
|
||||||
|
agents={Object.keys(AGENTS)}
|
||||||
|
selectedAgent={selectedAgent}
|
||||||
|
onSelect={setSelectedAgent}
|
||||||
|
placeholder="Select an agent..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ async function init_term(
|
|||||||
|
|
||||||
const term = new Terminal({
|
const term = new Terminal({
|
||||||
//"fontFamily": "Fantasque",
|
//"fontFamily": "Fantasque",
|
||||||
rows: 30,
|
rows: 24,
|
||||||
fontSize: fontSize ?? 18,
|
fontSize: fontSize ?? 18,
|
||||||
tabStopWidth: 4,
|
tabStopWidth: 4,
|
||||||
cursorBlink: false,
|
cursorBlink: false,
|
||||||
|
|||||||
191
webui/src/components/ui/AgentSelector.tsx
Normal file
191
webui/src/components/ui/AgentSelector.tsx
Normal file
@@ -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<HTMLDivElement>(null);
|
||||||
|
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const searchRef = useRef<HTMLInputElement>(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 (
|
||||||
|
<div className={styles.agentSelector} ref={dropdownRef}>
|
||||||
|
<button
|
||||||
|
ref={triggerRef}
|
||||||
|
className={clsx(styles.trigger, isOpen && styles.triggerOpen)}
|
||||||
|
type="button"
|
||||||
|
onClick={handleOpen}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
aria-haspopup="listbox"
|
||||||
|
>
|
||||||
|
<span className={styles.triggerText}>
|
||||||
|
{selectedAgent || placeholder}
|
||||||
|
</span>
|
||||||
|
<ChevronDown
|
||||||
|
size={16}
|
||||||
|
className={clsx(styles.chevron, isOpen && styles.chevronOpen)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div
|
||||||
|
className={styles.dropdown}
|
||||||
|
style={{
|
||||||
|
top: dropdownPosition.top,
|
||||||
|
left: dropdownPosition.left,
|
||||||
|
width: Math.max(dropdownPosition.width, 200)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className={styles.searchContainer}>
|
||||||
|
<Search size={16} className={styles.searchIcon} />
|
||||||
|
<input
|
||||||
|
ref={searchRef}
|
||||||
|
type="text"
|
||||||
|
className={styles.searchInput}
|
||||||
|
placeholder="Search agents..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className={styles.agentList} role="listbox">
|
||||||
|
{filteredAgents.length === 0 ? (
|
||||||
|
<li className={styles.noResults}>No agents found</li>
|
||||||
|
) : (
|
||||||
|
filteredAgents.map((agent, index) => (
|
||||||
|
<li
|
||||||
|
key={agent}
|
||||||
|
className={clsx(
|
||||||
|
styles.agentOption,
|
||||||
|
agent === selectedAgent && styles.selected,
|
||||||
|
index === highlightedIndex && styles.highlighted
|
||||||
|
)}
|
||||||
|
onClick={() => handleSelect(agent)}
|
||||||
|
role="option"
|
||||||
|
aria-selected={agent === selectedAgent}
|
||||||
|
>
|
||||||
|
{agent}
|
||||||
|
</li>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -53,7 +53,9 @@ export async function startScript(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function startScriptBulk(
|
export async function startScriptBulk(
|
||||||
script: string,
|
redScript: string,
|
||||||
|
blueScript: string,
|
||||||
|
opponentName: string,
|
||||||
appendOutput: (line: string) => void,
|
appendOutput: (line: string) => void,
|
||||||
appendTerminal: (line: string) => void,
|
appendTerminal: (line: string) => void,
|
||||||
rounds: number = 1000
|
rounds: number = 1000
|
||||||
@@ -93,7 +95,7 @@ export async function startScriptBulk(
|
|||||||
reject(error);
|
reject(error);
|
||||||
};
|
};
|
||||||
|
|
||||||
worker.postMessage({ type: "run", script, rounds });
|
worker.postMessage({ type: "run", redScript, blueScript, opponentName, rounds });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -56,11 +56,11 @@ self.onmessage = async (event) => {
|
|||||||
appendTerminal(`============\n\n\r`);
|
appendTerminal(`============\n\n\r`);
|
||||||
|
|
||||||
currentGame = new MinMaxGame(
|
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 elapsed = Math.round((performance.now() - start) / 100) / 10;
|
||||||
const r_winrate = Math.round((red_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 b_winrate = Math.round((blue_wins / n_rounds) * 1000) / 10;
|
||||||
|
|
||||||
|
const opponentName = event_data.opponentName || "Unknown";
|
||||||
|
|
||||||
appendTerminal("\r\n");
|
appendTerminal("\r\n");
|
||||||
appendTerminal(`Ran ${n_rounds} rounds in ${elapsed}s\r\n`);
|
appendTerminal(`Ran ${n_rounds} rounds in ${elapsed}s\r\n`);
|
||||||
appendTerminal(`Red won: ${red_wins} (${r_winrate}%)\r\n`);
|
appendTerminal(
|
||||||
appendTerminal(`Blue won: ${blue_wins} (${b_winrate}%)\r\n`);
|
`Red won: ${red_wins} (${r_winrate}%) (script)\r\n`
|
||||||
|
);
|
||||||
|
appendTerminal(
|
||||||
|
`Blue won: ${blue_wins} (${b_winrate}%) (${opponentName})\r\n`
|
||||||
|
);
|
||||||
appendTerminal("\r\n");
|
appendTerminal("\r\n");
|
||||||
appendTerminal(`Draws: ${draw_score}\r\n`);
|
appendTerminal(`Draws: ${draw_score}\r\n`);
|
||||||
appendTerminal(`Invalid: ${draw_invalid}\r\n`);
|
appendTerminal(`Invalid: ${draw_invalid}\r\n`);
|
||||||
|
|||||||
143
webui/src/styles/AgentSelector.module.css
Normal file
143
webui/src/styles/AgentSelector.module.css
Normal file
@@ -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;
|
||||||
|
}
|
||||||
@@ -105,6 +105,19 @@
|
|||||||
min-width: 250px;
|
min-width: 250px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.configField {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.configField label {
|
||||||
|
display: block;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #cccccc;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
.helpPanel {
|
.helpPanel {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
width: 300px;
|
width: 300px;
|
||||||
|
|||||||
Reference in New Issue
Block a user