Select bulk opponent

This commit is contained in:
2025-11-03 16:40:59 -08:00
parent bfbd9d35bc
commit 0db5b7a8f1
7 changed files with 413 additions and 12 deletions

View File

@@ -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>
} }
/> />

View File

@@ -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,

View 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>
);
}

View File

@@ -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 });
}); });
} }

View File

@@ -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`);

View 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;
}

View File

@@ -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;