Compare commits

..

5 Commits

Author SHA1 Message Date
684ae0ecf8 Agent library 2025-11-03 16:46:33 -08:00
0db5b7a8f1 Select bulk opponent 2025-11-03 16:42:03 -08:00
bfbd9d35bc Persist code 2025-11-03 16:42:03 -08:00
07aeda5e07 Add initial webui 2025-11-03 16:42:03 -08:00
19f523d0ed Add Rust: minimax, runner, and codelens highlighter 2025-11-03 16:41:58 -08:00
11 changed files with 835 additions and 78 deletions

169
agents/greed.rhai Normal file
View File

@@ -0,0 +1,169 @@
// Return a random valid action on the given board.
// Used as a last resort.
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
}
/// Returns an array of (idx, f32) for each empty slot in the board.
/// - idx is the index of this slot
/// - f32 is the "influence of" this slot
fn compute_influence(board) {
// Fill all empty slots with fives and compute starting value
let filled = board;
for i in filled.free_spots_idx() {
filled[i] = 5;
}
// Compute the value of the filled board
let base = filled.evaluate();
// Exit early if the board is invalid.
// This is usually caused by zero-division.
if (base == ()) {
return [];
}
// Increase each slot's value by 1
// and record the effect on the expression's total value.
//
// `influence` is an array of (slot_idx, value)
let influence = [];
for i in 0..board.size() {
let slot = board[i];
// Ignore slots that are not empty
if slot != "" {
continue
}
// Don't assign directly to `filled`,
// we want to keep it full of fives.
// Assigning to `b` make a copy of the board.
let b = filled;
b[i] = 6;
influence.push([i, b.evaluate() - base]);
}
// Sort by increasing absolute score
influence.sort(|a, b| {
let a_abs = a[1].abs();
let b_abs = b[1].abs();
// Returns...
// 1 if positive (a_abs > b_abs),
// -1 if negative,
// 0 if equal
return sign(a_abs - b_abs);
});
return influence;
}
fn place_number(board, minimize) {
let numbers = [0,1,2,3,4,5,6,7,8,9];
let available_numbers = numbers.retain(|x| board.contains(x));
let influence = compute_influence(board);
// Stupid edge cases, fall back to random
if influence.len() == 0 || available_numbers.len() == 0 {
return random_action(board);
}
// Get the most influential position
let pos = influence[-1][0];
let val = influence[-1][1];
// Pick the number we should use,
// This is always either the largest
// or the smallest number available to us.
let symbol = 0;
if minimize {
if val > 0 {
symbol = available_numbers[0];
} else {
symbol = available_numbers[-1];
}
} else {
if val > 0 {
symbol = available_numbers[-1];
} else {
symbol = available_numbers[0];
}
}
return Action(symbol, pos);
}
fn place_op(board, minimize) {
let ops = ["+", "-", "*", "/"];
let available_ops = ops.retain(|x| board.contains(x));
// Place operations first,
// they matter much more than numbers
let give_up = 10;
if !available_ops.is_empty() {
let aa = available_ops.rand_shuffle();
let pos = rand_int(0, 10);
let action = Action(aa[0], pos);
while !board.can_play(action) {
let pos = rand_int(0, 10);
action = Action(aa[0], pos);
// In case there are no valid operator moves
give_up -= 1;
if give_up == 0 { break }
}
return action
}
// Could not place an operation
return ();
}
// Main step function (shared between min and max)
fn greed_step(board, minimize) {
let action = place_op(board, minimize);
if action == () {
action = place_number(board, minimize);
}
if board.can_play(action) {
return action;
}
// Prevent invalid moves, random fallback
return random_action(board);
}
// Minimizer step
fn step_min(board) {
greed_step(board, true)
}
// Maximizer step
fn step_max(board) {
greed_step(board, false)
}

21
agents/random.rhai Normal file
View File

@@ -0,0 +1,21 @@
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) {
random_action(board)
}
fn step_max(board) {
random_action(board)
}

View File

@@ -251,6 +251,7 @@ impl MinMaxGame {
(GameState::Red, Some(red_score)) => { (GameState::Red, Some(red_score)) => {
self.board = Board::new(); self.board = Board::new();
self.is_first_print = true; self.is_first_print = true;
self.is_red_turn = false;
self.state = GameState::Blue { red_score } self.state = GameState::Blue { red_score }
} }

View File

@@ -50,13 +50,24 @@ interface EditorProps {
fontSize?: number; fontSize?: number;
} }
const STORAGE_KEY = "minimax-editor-content";
export const Editor = forwardRef<any, EditorProps>(function Editor( export const Editor = forwardRef<any, EditorProps>(function Editor(
{ initialValue = "", onChange, onReady, fontSize = 14 }, { initialValue = "", onChange, onReady, fontSize = 14 },
ref ref
) { ) {
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
const editorRef = useRef<any>(null); const editorRef = useRef<any>(null);
const [content, setContent] = useState(initialValue);
const getInitialContent = () => {
if (typeof window !== "undefined") {
const saved = localStorage.getItem(STORAGE_KEY);
return saved || initialValue;
}
return initialValue;
};
const [content, setContent] = useState(getInitialContent);
useImperativeHandle(ref, () => editorRef.current); useImperativeHandle(ref, () => editorRef.current);
@@ -100,6 +111,11 @@ export const Editor = forwardRef<any, EditorProps>(function Editor(
editor.on("change", (instance: any, changes: any) => { editor.on("change", (instance: any, changes: any) => {
const newContent = instance.getValue(); const newContent = instance.getValue();
setContent(newContent); setContent(newContent);
if (typeof window !== "undefined") {
localStorage.setItem(STORAGE_KEY, newContent);
}
onChange?.(instance, changes); onChange?.(instance, changes);
}); });
@@ -129,7 +145,7 @@ export const Editor = forwardRef<any, EditorProps>(function Editor(
<div className={styles.editorContainer}> <div className={styles.editorContainer}>
<textarea <textarea
ref={textareaRef} ref={textareaRef}
defaultValue={initialValue} defaultValue={content}
placeholder="Code goes here" placeholder="Code goes here"
/> />
</div> </div>

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);
@@ -85,11 +114,14 @@ export default function Playground() {
} }
); );
} catch (ex) { } catch (ex) {
console.error(ex);
if (resultRef.current) { if (resultRef.current) {
resultRef.current.value += `\nScript exited with error:\n${ex}\n`; resultRef.current.value += `\nScript exited with error:\n${ex}\n`;
} }
terminalRef.current?.write( terminalRef.current?.write(
"\r\n\x1B[1;31mScript exited with error:\x1B[0m\n\r" + String(ex).replace("\n", "\n\r") + "\r\n" "\r\n\x1B[1;31mScript exited with error:\x1B[0m\n\r" +
String(ex).replace("\n", "\n\r") +
"\r\n"
); );
} }
@@ -101,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);
@@ -109,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";
@@ -130,16 +170,19 @@ export default function Playground() {
bulkRounds bulkRounds
); );
} catch (ex) { } catch (ex) {
if (resultRef.current) { console.error(ex);
if (resultRef.current) {
resultRef.current.value += `\nScript exited with error:\n${ex}\n`; resultRef.current.value += `\nScript exited with error:\n${ex}\n`;
} }
terminalRef.current?.write( terminalRef.current?.write(
"\r\n\x1B[1;31mScript exited with error:\x1B[0m\n\r" + String(ex).replace("\n", "\n\r") + "\r\n" "\r\n\x1B[1;31mScript exited with error:\x1B[0m\n\r" +
String(ex).replace("\n", "\n\r") +
"\r\n"
); );
} }
setIsScriptRunning(false); setIsScriptRunning(false);
}, [runDisabled, bulkRounds]); }, [runDisabled, bulkRounds, selectedAgent]);
const stopScriptHandler = useCallback(() => { const stopScriptHandler = useCallback(() => {
stopScript(); stopScript();
@@ -204,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>
} }
/> />
@@ -257,100 +309,243 @@ export default function Playground() {
<h2>Game Rules</h2> <h2>Game Rules</h2>
<p> <p>
This game is played in two rounds, This game is played in two rounds, on an empty eleven-space
on an empty eleven-space board. board. The first round is played on {"Red's"} board, the
The first round is played on {"Red's"} board, the second is played on {"Blue's"}. second is played on {"Blue's"}.
</p> </p>
<p> <p>
On {"Red's"} board, {"Red's"} goal is to maximize the value of the expression. On {"Red's"} board, {"Red's"} goal is to maximize the value
{" Blue's"} goal is to minimize it. of the expression.
{" Blue's"} goal is to minimize it. Players take turns
Players take turns placing the fourteen symbols <code>0123456789+-×÷</code> placing the fourteen symbols <code>0123456789+-×÷</code>
on the board, with the maximizing player taking the first move. on the board, with the maximizing player taking the first
move.
</p> </p>
<p> <p>
A {"board's"} syntax must always be valid, and A {"board's"} syntax must always be valid, and the following
the following rules are enforced: rules are enforced:
</p> </p>
<ol> <ol>
<li>Each symbol may only be used once</li> <li>Each symbol may only be used once</li>
<li>The binary operators <code>+-×÷</code> may not be next to one another, and may not be at the end slots.</li> <li>
<li>The unary operator <code>-</code> (negative) must have a number as an argument. Therefore, it cannot be left of an operator (like <code>-×</code>), and it may not be in the rightmost slot.</li> The binary operators <code>+-×÷</code> may not be next
<li>Unary <code>+</code> may not be used.</li> to one another, and may not be at the end slots.
<li> <code>0</code> may not follow <code>÷</code>. </li>
This prevents most cases of zero-division, but {"isn't perfect"}. <li>
<code>÷-0</code> will result in an invalid board (causing a draw), The unary operator <code>-</code> (negative) must have a
and <code>÷0_+</code> is forbidden despite being valid syntax once the empty slot is filled. number as an argument. Therefore, it cannot be left of
This is done to simplyify game logic, and might be improved later. an operator (like <code>-×</code>), and it may not be in
the rightmost slot.
</li>
<li>
Unary <code>+</code> may not be used.
</li>
<li>
{" "}
<code>0</code> may not follow <code>÷</code>. This
prevents most cases of zero-division, but{" "}
{"isn't perfect"}.<code>÷-0</code> will result in an
invalid board (causing a draw), and <code>÷0_+</code> is
forbidden despite being valid syntax once the empty slot
is filled. This is done to simplyify game logic, and
might be improved later.
</li> </li>
<li>Division by zero results in a draw.</li> <li>Division by zero results in a draw.</li>
<li>An incomplete board with no valid moves results in a draw.</li> <li>
An incomplete board with no valid moves results in a
draw.
</li>
</ol> </ol>
<h2>How to Play</h2> <h2>How to Play</h2>
<ol> <ol>
<li>Click <strong>Run</strong> to start a single game. Play against your agent in the terminal. Use your arrow keys (up, down, left, right) to select a symbol. Use enter or space to make a move.</li> <li>
<li>Click <strong>Bulk Run</strong> to collect statistics from a many games.</li> Click <strong>Run</strong> to start a single game. Play
against your agent in the terminal. Use your arrow keys
(up, down, left, right) to select a symbol. Use enter or
space to make a move.
</li>
<li>
Click <strong>Bulk Run</strong> to collect statistics
from a many games.
</li>
</ol> </ol>
<h2>Overview</h2> <h2>Overview</h2>
<ul> <ul>
<li><code>step_min()</code> is called once per turn with the {"board's"} current state. This function must return an <code>Action</code> that aims to minimize the total value of the board.</li> <li>
<li><code>step_max()</code> is just like <code>step_min</code>, but should aim to maximize the value of the board. </li> <code>step_min()</code> is called once per turn with the{" "}
<li>Agent code may not be edited between games. Start a new game to use new code.</li> {"board's"} current state. This function must return an{" "}
<li>If your agent takes more than 5 seconds to compute a move, the script will exit with an error.</li> <code>Action</code> that aims to minimize the total
value of the board.
</li>
<li>
<code>step_max()</code> is just like{" "}
<code>step_min</code>, but should aim to maximize the
value of the board.{" "}
</li>
<li>
Agent code may not be edited between games. Start a new
game to use new code.
</li>
<li>
If your agent takes more than 5 seconds to compute a
move, the script will exit with an error.
</li>
</ul> </ul>
<h2>Rhai basics</h2> <h2>Rhai basics</h2>
<p> <p>
Agents are written in <a href="https://rhai.rs">Rhai</a>, a wonderful embedded scripting language powered by Rust. Agents are written in <a href="https://rhai.rs">Rhai</a>, a
Basic language features are outlined below. wonderful embedded scripting language powered by Rust. Basic
language features are outlined below.
</p> </p>
<ul> <ul>
<li>All statements must be followed by a <code>;</code></li> <li>
<li>Use <code>return</code> to return a value from a function.</li> All statements must be followed by a <code>;</code>
<li><code>print(anything)</code> - Prints to the output panel. Prefer this over <code>debug</code>.</li> </li>
<li><code>debug(anything)</code> - Prints to the output panel. Includes extra debug info.</li> <li>
<li><code>()</code> is the {"\"none\""} type, returned by some methods above.</li> Use <code>return</code> to return a value from a
<li><code>for i in 0..5 {"{}"}</code> will iterate five times, with <code>i = 0, 1, 2, 3, 4</code></li> function.
<li><code>for i in 0..=5 {"{}"}</code> will iterate six times, with <code>i = 0, 1, 2, 3, 4, 5</code></li> </li>
<li><code>let a = [];</code> initializes an empty array.</li> <li>
<li><code>a.push(value)</code> adds a value to the end of an array</li> <code>print(anything)</code> - Prints to the output
<li><code>a.pop()</code> removes a value from the end of an array and returns it</li> panel. Prefer this over <code>debug</code>.
<li><code>a[0]</code> returns the first item of an array</li> </li>
<li><code>a[1]</code> returns the second item of an array</li> <li>
<li>Refer to <a href="https://rhai.rs/book/language/values-and-types.html">the Rhai book</a> for more details.</li> <code>debug(anything)</code> - Prints to the output
panel. Includes extra debug info.
</li>
<li>
<code>()</code> is the {'"none"'} type, returned by some
methods above.
</li>
<li>
<code>for i in 0..5 {"{}"}</code> will iterate five
times, with <code>i = 0, 1, 2, 3, 4</code>
</li>
<li>
<code>for i in 0..=5 {"{}"}</code> will iterate six
times, with <code>i = 0, 1, 2, 3, 4, 5</code>
</li>
<li>
<code>let a = [];</code> initializes an empty array.
</li>
<li>
<code>a.push(value)</code> adds a value to the end of an
array
</li>
<li>
<code>a.pop()</code> removes a value from the end of an
array and returns it
</li>
<li>
<code>a[0]</code> returns the first item of an array
</li>
<li>
<code>a[1]</code> returns the second item of an array
</li>
<li>
Refer to{" "}
<a href="https://rhai.rs/book/language/values-and-types.html">
the Rhai book
</a>{" "}
for more details.
</li>
</ul> </ul>
<h2>Notable Functions</h2> <h2>Notable Functions</h2>
<ul> <ul>
<li><code>Action(symbol, position)</code> - Creates a new action that places <code>symbol</code> at <code>position</code>. Valid symbols are <code>01234567890+-/*</code>. Both <code>0</code> and <code>{"\"0\""}</code> are valid symbols.</li> <li>
<li><code>board.can_play(action)</code> - Checks if an action is valid. Returns a boolean.</li> <code>Action(symbol, position)</code> - Creates a new
<li><code>board.size()</code> - Return the total number of spots on this board.</li> action that places <code>symbol</code> at{" "}
<li><code>board.free_spots()</code> - Count the number of free spots on the board.</li> <code>position</code>. Valid symbols are{" "}
<li><code>board.play(action)</code> - Apply the given action on this board. This mutates the <code>board</code>, but does NOT make the move in the game. The only way to commit to an action is to return it from <code>step_min</code> or <code>step_max</code>. <code>01234567890+-/*</code>. Both <code>0</code> and{" "}
This method lets you compute potential values of a board when used with <code>board.evaluate()</code>. <code>{'"0"'}</code> are valid symbols.
</li>
<li>
<code>board.can_play(action)</code> - Checks if an
action is valid. Returns a boolean.
</li>
<li>
<code>board.size()</code> - Return the total number of
spots on this board.
</li>
<li>
<code>board.free_spots()</code> - Count the number of
free spots on the board.
</li>
<li>
<code>board.play(action)</code> - Apply the given action
on this board. This mutates the <code>board</code>, but
does NOT make the move in the game. The only way to
commit to an action is to return it from{" "}
<code>step_min</code> or <code>step_max</code>. This
method lets you compute potential values of a board when
used with <code>board.evaluate()</code>.
</li>
<li>
<code>board.ith_free_slot(idx)</code> - Returns the
index of the <code>n</code>th free slot on this board.
Returns <code>-1</code> if no such slot exists.
</li>
<li>
<code>board.contains(symbol)</code> - Checks if this
board contains the given symbol. Returns a boolean.
</li>
<li>
<code>board.evaluate()</code> - Return the value of a
board if it can be computed. Returns <code>()</code>{" "}
otherwise.
</li>
<li>
<code>board.free_spots_idx(action)</code> - Checks if an
action is valid. Returns a boolean.
</li>
<li>
<code>for i in board {"{ ... }"}</code> - Iterate over
all slots on this board. Items are returned as strings,
empty slots are the empty string (<code>{'""'}</code>)
</li>
<li>
<code>is_op(symbol)</code> - Returns <code>true</code>{" "}
if <code>symbol</code> is one of <code>+-*/</code>
</li> </li>
<li><code>board.ith_free_slot(idx)</code> - Returns the index of the <code>n</code>th free slot on this board. Returns <code>-1</code> if no such slot exists.</li>
<li><code>board.contains(symbol)</code> - Checks if this board contains the given symbol. Returns a boolean.</li>
<li><code>board.evaluate()</code> - Return the value of a board if it can be computed. Returns <code>()</code> otherwise.</li>
<li><code>board.free_spots_idx(action)</code> - Checks if an action is valid. Returns a boolean.</li>
<li><code>for i in board {"{ ... }"}</code> - Iterate over all slots on this board. Items are returned as strings, empty slots are the empty string (<code>{"\"\""}</code>)</li>
<li><code>is_op(symbol)</code> - Returns <code>true</code> if <code>symbol</code> is one of <code>+-*/</code></li>
<li><code>rand_symb()</code> - Returns a random symbol (number or operation)</li> <li>
<li><code>rand_op()</code> - Returns a random operator symbol (one of <code>+-*/</code>)</li> <code>rand_symb()</code> - Returns a random symbol
<li><code>rand_action()</code> - Returns a random <code>Action</code></li> (number or operation)
<li><code>rand_int(min, max)</code> - Returns a random integer between min and max, including both endpoints.</li> </li>
<li><code>rand_bool(probability)</code> - Return <code>true</code> with the given probability. Otherwise return <code>false</code>.</li> <li>
<li><code>rand_shuffle(array)</code> - Shuffle the given array</li> <code>rand_op()</code> - Returns a random operator
<li><code>for p in permutations(array, 5) {"{}"}</code> - Iterate over all permutations of 5 elements of the given array.</li> symbol (one of <code>+-*/</code>)
</li>
<li>
<code>rand_action()</code> - Returns a random{" "}
<code>Action</code>
</li>
<li>
<code>rand_int(min, max)</code> - Returns a random
integer between min and max, including both endpoints.
</li>
<li>
<code>rand_bool(probability)</code> - Return{" "}
<code>true</code> with the given probability. Otherwise
return <code>false</code>.
</li>
<li>
<code>rand_shuffle(array)</code> - Shuffle the given
array
</li>
<li>
<code>for p in permutations(array, 5) {"{}"}</code> -
Iterate over all permutations of 5 elements of the given
array.
</li>
</ul> </ul>
</SidePanel> </SidePanel>
</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;