Compare commits

..

9 Commits

Author SHA1 Message Date
48a45b5447 Fixes 2025-11-06 10:21:44 -08:00
5bd6331ad9 Better greed 2025-11-06 10:21:42 -08:00
eb084e1f07 Deploy 2025-11-03 18:42:04 -08:00
30649488bb Save and load scripts 2025-11-03 16:55:46 -08:00
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
24 changed files with 1603 additions and 106 deletions

8
.env_dist Normal file
View File

@@ -0,0 +1,8 @@
# Script saving configuration
ENABLE_SAVE=true
SAVE_SECRET=save
SAVE_DIRECTORY=./data/scripts
MAX_FILENAME_LENGTH=32
# Next.js environment
NODE_ENV=production

2
.gitignore vendored
View File

@@ -4,3 +4,5 @@ node_modules
target target
.DS_Store .DS_Store
webui/src/wasm webui/src/wasm
webui/data
.env

30
Dockerfile Normal file
View File

@@ -0,0 +1,30 @@
FROM rust:1.91 AS rust-builder
WORKDIR /app
RUN cargo install wasm-pack
COPY rust/ ./rust/
WORKDIR /app/rust/rhai-codemirror
RUN wasm-pack build --target web --out-dir "../../webui/src/wasm/rhai-codemirror"
WORKDIR /app/rust/runner
RUN wasm-pack build --target web --out-dir "../../webui/src/wasm/runner"
FROM node:24-alpine AS app
WORKDIR /app
RUN npm install -g bun
COPY webui/package.json webui/bun.lock* ./webui/
WORKDIR /app/webui
RUN bun install --frozen-lockfile
COPY webui/ ./
COPY --from=rust-builder /app/webui/src/wasm/ ./src/wasm/
RUN bun run build
RUN mkdir -p ../data/scripts
EXPOSE 3000
CMD ["bun", "start"]

161
agents/greed-v1.rhai Normal file
View File

@@ -0,0 +1,161 @@
// 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);
// We could not place an op, so place a number
if action == () {
action = place_number(board, minimize);
}
// Prevent invalid moves, random fallback
if board.can_play(action) { return action; }
return random_action(board);
}
// Minimizer step
fn step_min(board) {
greed_step(board, true)
}
// Maximizer step
fn step_max(board) {
greed_step(board, false)
}

254
agents/greed-v2.rhai Normal file
View File

@@ -0,0 +1,254 @@
// SECRET
// 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 op_value(board) {
print(board);
let actions = [];
for o in ["+", "-", "*", "/"] {
if board.contains(o) {
continue;
}
for p in 0..=10 {
let action = Action(o, p);
if board.can_play(action) {
actions.push(action);
}
}
}
// No other operators can be placed, return value of fives
if actions.is_empty() {
let filled = board;
for i in filled.free_spots_idx() {
filled[i] = 5;
}
let v = filled.evaluate();
if v == () {
return ();
} else {
return [v, v];
}
}
let max = ();
let min = ();
for a in actions {
let tmp = board;
tmp.play(a);
let vals = op_value(tmp);
if vals != () {
for v in vals {
if max == () || min == () {
max = v;
min = v;
}
if v > max {
max = v;
} else if v < min {
min = v;
}
}
}
}
if min == () || max == () {
return ();
}
return [min, max];
}
fn place_op(board, minimize) {
let ops = ["+", "-", "*", "/"];
let available_ops = ops.retain(|x| board.contains(x));
// Performance optimization if board is empty.
// This is the move we would pick, hard-coded.
if available_ops.len() == 4 {
if minimize {
let act = Action("+", 3);
if board.can_play(act) {return act}
} else {
let act = Action("/", 9);
if board.can_play(act) {return act}
}
}
// All possible operator actions
let actions = [];
for o in ["+", "-", "*", "/"] {
for p in 0..=10 {
let action = Action(o, p);
if board.can_play(action) {
let tmp = board;
tmp.play(action);
let v = op_value(tmp);
if v != () {
actions.push([action, v]);
}
}
}
}
if actions.is_empty() {
return ();
}
let action = ();
if minimize {
// Sort by increasing minimum score
actions.sort(|a, b| sign(a[1][0] - b[1][0]));
action = actions[0][0];
} else {
// Sort by increasing maximum score
actions.sort(|a, b| sign(a[1][1] - b[1][1]));
action = actions[-1][0];
}
debug(action);
return action;
}
// 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)
}

10
docker-compose.yml Normal file
View File

@@ -0,0 +1,10 @@
services:
webui:
image: minimax
ports:
- "4000:3000"
volumes:
- ./data:/app/data
env_file:
- .env
restart: unless-stopped

View File

@@ -80,7 +80,6 @@ pub struct RhaiAgent<R: Rng + 'static> {
engine: Engine, engine: Engine,
script: AST, script: AST,
scope: Scope<'static>,
print_callback: Arc<dyn Fn(&str) + 'static>, print_callback: Arc<dyn Fn(&str) + 'static>,
} }
@@ -118,7 +117,6 @@ impl<R: Rng + 'static> RhaiAgent<R> {
// Do not use FULL, rand_* functions are not pure // Do not use FULL, rand_* functions are not pure
engine.set_optimization_level(OptimizationLevel::Simple); engine.set_optimization_level(OptimizationLevel::Simple);
engine.disable_symbol("eval"); engine.disable_symbol("eval");
engine.set_max_expr_depths(100, 100); engine.set_max_expr_depths(100, 100);
engine.set_max_strings_interned(1024); engine.set_max_strings_interned(1024);
@@ -201,13 +199,11 @@ impl<R: Rng + 'static> RhaiAgent<R> {
}; };
let script = engine.compile(script)?; let script = engine.compile(script)?;
let scope = Scope::new(); // Not used
Ok(Self { Ok(Self {
rng, rng,
engine, engine,
script, script,
scope,
print_callback, print_callback,
}) })
} }
@@ -227,7 +223,7 @@ impl<R: Rng + 'static> Agent for RhaiAgent<R> {
fn step_min(&mut self, board: &Board) -> Result<PlayerAction, Self::ErrorType> { fn step_min(&mut self, board: &Board) -> Result<PlayerAction, Self::ErrorType> {
let res = self.engine.call_fn_with_options::<PlayerAction>( let res = self.engine.call_fn_with_options::<PlayerAction>(
CallFnOptions::new().eval_ast(false), CallFnOptions::new().eval_ast(false),
&mut self.scope, &mut Scope::new(),
&self.script, &self.script,
"step_min", "step_min",
(board.clone(),), (board.clone(),),
@@ -242,7 +238,7 @@ impl<R: Rng + 'static> Agent for RhaiAgent<R> {
fn step_max(&mut self, board: &Board) -> Result<PlayerAction, Self::ErrorType> { fn step_max(&mut self, board: &Board) -> Result<PlayerAction, Self::ErrorType> {
let res = self.engine.call_fn_with_options::<PlayerAction>( let res = self.engine.call_fn_with_options::<PlayerAction>(
CallFnOptions::new().eval_ast(false), CallFnOptions::new().eval_ast(false),
&mut self.scope, &mut Scope::new(),
&self.script, &self.script,
"step_max", "step_max",
(board.clone(),), (board.clone(),),

View File

@@ -1,8 +1,8 @@
use wasm_bindgen::prelude::*; use wasm_bindgen::prelude::*;
mod ansi; mod ansi;
mod gamestate; mod minmaxgame;
mod gamestatehuman; mod minmaxgamehuman;
mod terminput; mod terminput;
#[global_allocator] #[global_allocator]

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

@@ -9,7 +9,7 @@ use wasm_bindgen::prelude::*;
use crate::{ansi, terminput::TermInput}; use crate::{ansi, terminput::TermInput};
#[wasm_bindgen] #[wasm_bindgen]
pub struct GameStateHuman { pub struct MinMaxGameHuman {
/// Red player /// Red player
human: TermInput, human: TermInput,
@@ -26,7 +26,7 @@ pub struct GameStateHuman {
} }
#[wasm_bindgen] #[wasm_bindgen]
impl GameStateHuman { impl MinMaxGameHuman {
#[wasm_bindgen(constructor)] #[wasm_bindgen(constructor)]
pub fn new( pub fn new(
max_script: &str, max_script: &str,
@@ -34,7 +34,7 @@ impl GameStateHuman {
max_debug_callback: js_sys::Function, max_debug_callback: js_sys::Function,
game_state_callback: js_sys::Function, game_state_callback: js_sys::Function,
) -> Result<GameStateHuman, String> { ) -> Result<MinMaxGameHuman, String> {
Self::new_native( Self::new_native(
max_script, max_script,
move |s| { move |s| {

View File

@@ -0,0 +1,55 @@
import { NextRequest, NextResponse } from "next/server";
import { readFile } from "fs/promises";
import { join } from "path";
import { existsSync } from "fs";
import { CONFIG } from "@/lib/config";
// Force dynamic rendering for this API route
export const dynamic = "force-dynamic";
export async function GET(request: NextRequest) {
try {
const { searchParams } = request.nextUrl;
const name = searchParams.get("name");
if (!name) {
return NextResponse.json(
{ error: "Script name is required" },
{ status: 400 }
);
}
// Validate filename (same validation as save)
if (!CONFIG.FILENAME_REGEX.test(name)) {
return NextResponse.json(
{ error: "Invalid script name" },
{ status: 400 }
);
}
const saveDir = CONFIG.SAVE_DIRECTORY;
const filename = `${name}.rhai`;
const filepath = join(saveDir, filename);
if (!existsSync(filepath)) {
return NextResponse.json(
{ error: `Script "${name}" not found` },
{ status: 404 }
);
}
let content = await readFile(filepath, "utf8");
return NextResponse.json({
name,
filename,
content,
});
} catch (error) {
console.error("Get script error:", error);
return NextResponse.json(
{ error: "Failed to read script" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,40 @@
import { NextRequest, NextResponse } from "next/server";
import { readdir } from "fs/promises";
import { existsSync } from "fs";
import { CONFIG } from "@/lib/config";
// Force dynamic rendering for this API route
export const dynamic = "force-dynamic";
const headers = {
"Cache-Control": "no-store, no-cache, must-revalidate, proxy-revalidate",
Pragma: "no-cache",
Expires: "0",
};
export async function GET(_request: NextRequest) {
try {
const saveDir = CONFIG.SAVE_DIRECTORY;
// If save directory doesn't exist, return empty array
if (!existsSync(saveDir)) {
return NextResponse.json({ scripts: [] }, { headers });
}
// Read directory and filter for .rhai files
const files = await readdir(saveDir);
const scripts = files
.filter((file) => file.endsWith(".rhai"))
.map((file) => file.replace(".rhai", ""))
.sort(); // Sort alphabetically
return NextResponse.json({ scripts }, { headers });
} catch (error) {
console.error("List scripts error:", error);
return NextResponse.json(
{ error: "Failed to list scripts" },
{ status: 500, headers }
);
}
}

View File

@@ -0,0 +1,92 @@
import { NextRequest, NextResponse } from "next/server";
import { writeFile, mkdir } from "fs/promises";
import { join } from "path";
import { existsSync } from "fs";
import { CONFIG } from "@/lib/config";
export async function POST(request: NextRequest) {
try {
// Check if saving is enabled
if (!CONFIG.ENABLE_SAVE) {
return NextResponse.json(
{ error: "Script saving is disabled" },
{ status: 403 }
);
}
const { name, content, secret } = await request.json();
if (secret !== CONFIG.SAVE_SECRET) {
return NextResponse.json(
{ error: "Invalid secret" },
{ status: 401 }
);
}
if (!name || !content) {
return NextResponse.json(
{ error: "Name and content are required" },
{ status: 400 }
);
}
if (name.length > CONFIG.MAX_FILENAME_LENGTH) {
return NextResponse.json(
{
error: `Filename must be ${CONFIG.MAX_FILENAME_LENGTH} characters or less`,
},
{ status: 400 }
);
}
if (content.length > CONFIG.MAX_FILE_SIZE) {
return NextResponse.json(
{
error: `File is too large`,
},
{ status: 400 }
);
}
if (!CONFIG.FILENAME_REGEX.test(name)) {
return NextResponse.json(
{
error: "Filename can only contain alphanumerics, underscores, spaces, and hyphens",
},
{ status: 400 }
);
}
// Ensure save directory exists
const saveDir = CONFIG.SAVE_DIRECTORY;
if (!existsSync(saveDir)) {
await mkdir(saveDir, { recursive: true });
}
// Check if file already exists
const filename = `${name}.rhai`;
const filepath = join(saveDir, filename);
if (existsSync(filepath)) {
return NextResponse.json(
{ error: `A script named "${name}" already exists` },
{ status: 409 }
);
}
// Save the file
await writeFile(filepath, content, "utf8");
return NextResponse.json({
success: true,
message: `Script saved as ${filename}`,
filename,
});
} catch (error) {
console.error("Save script error:", error);
return NextResponse.json(
{ error: "Failed to save script" },
{ status: 500 }
);
}
}

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);
}); });
@@ -112,7 +128,8 @@ export const Editor = forwardRef<any, EditorProps>(function Editor(
editorRef.current = null; editorRef.current = null;
} }
}; };
}, []); // DO NOT FILL ARRAY // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // DO NOT FILL ARRAY - intentionally empty to prevent re-initialization
// Update font size when it changes // Update font size when it changes
useEffect(() => { useEffect(() => {
@@ -129,7 +146,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 {
@@ -15,8 +16,7 @@ import {
} from "@/lib/runner"; } from "@/lib/runner";
import styles from "@/styles/Playground.module.css"; import styles from "@/styles/Playground.module.css";
const initialCode = ` const initialCode = `fn random_action(board) {
fn random_action(board) {
let symb = rand_symb(); let symb = rand_symb();
let pos = rand_int(0, 10); let pos = rand_int(0, 10);
let action = Action(symb, pos); let action = Action(symb, pos);
@@ -37,15 +37,45 @@ fn step_min(board) {
fn step_max(board) { fn step_max(board) {
return random_action(board); return random_action(board);
} }`;
`;
const AGENTS = {
// special-cased below
Self: undefined,
};
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(() => {
const [bulkRounds, setBulkRounds] = useState(1000); if (typeof window !== "undefined") {
const saved = localStorage.getItem("playground-fontSize");
return saved ? parseInt(saved, 10) : 14;
}
return 14;
});
const [bulkRounds, setBulkRounds] = useState(() => {
if (typeof window !== "undefined") {
const saved = localStorage.getItem("playground-bulkRounds");
return saved ? parseInt(saved, 10) : 1000;
}
return 1000;
});
const [selectedAgent, setSelectedAgent] = useState(() => {
if (typeof window !== "undefined") {
const saved = localStorage.getItem("playground-selectedAgent");
return saved || "Self";
}
return "Self";
});
const [isHelpOpen, setIsHelpOpen] = useState(false); const [isHelpOpen, setIsHelpOpen] = useState(false);
const [scriptName, setScriptName] = useState("");
const [saveSecret, setSaveSecret] = useState("");
const [isSaving, setIsSaving] = useState(false);
const [availableAgents, setAvailableAgents] = useState<string[]>([]);
const [savedScripts, setSavedScripts] = useState<Record<string, string>>(
{}
);
const editorRef = useRef<any>(null); const editorRef = useRef<any>(null);
const resultRef = useRef<HTMLTextAreaElement>(null); const resultRef = useRef<HTMLTextAreaElement>(null);
@@ -54,6 +84,86 @@ export default function Playground() {
const runDisabled = isScriptRunning || !isEditorReady; const runDisabled = isScriptRunning || !isEditorReady;
const stopDisabled = !isScriptRunning; const stopDisabled = !isScriptRunning;
// Fetch saved scripts and update available agents
const loadSavedScripts = useCallback(async () => {
try {
const response = await fetch("/api/list-scripts");
const data = await response.json();
if (response.ok) {
const scripts = data.scripts || [];
const scriptContents: Record<string, string> = {};
// Fetch content for each saved script
await Promise.all(
scripts.map(async (scriptName: string) => {
try {
const scriptResponse = await fetch(
`/api/get-script?name=${encodeURIComponent(
scriptName
)}`
);
const scriptData = await scriptResponse.json();
if (scriptResponse.ok) {
scriptContents[scriptName] = scriptData.content;
}
} catch (error) {
console.error(
`Failed to load script ${scriptName}:`,
error
);
}
})
);
setSavedScripts(scriptContents);
// Combine hardcoded agents with saved scripts, ensuring Self and Random are first
const combinedAgents = [
"Self",
...Object.keys(AGENTS).filter((key) => key !== "Self"),
...scripts,
];
setAvailableAgents(combinedAgents);
}
} catch (error) {
console.error("Failed to load saved scripts:", error);
// Fallback to hardcoded agents only
setAvailableAgents(Object.keys(AGENTS));
}
}, []);
// Load saved scripts on component mount
useEffect(() => {
loadSavedScripts();
}, [loadSavedScripts]);
// Save font size to localStorage
useEffect(() => {
if (typeof window !== "undefined") {
localStorage.setItem("playground-fontSize", fontSize.toString());
}
}, [fontSize]);
// Save bulk rounds to localStorage
useEffect(() => {
if (typeof window !== "undefined") {
localStorage.setItem(
"playground-bulkRounds",
bulkRounds.toString()
);
}
}, [bulkRounds]);
// Save selected agent to localStorage
useEffect(() => {
if (typeof window !== "undefined") {
localStorage.setItem("playground-selectedAgent", selectedAgent);
}
}, [selectedAgent]);
const runHuman = useCallback(async () => { const runHuman = useCallback(async () => {
if (resultRef.current) { if (resultRef.current) {
resultRef.current.value = ""; resultRef.current.value = "";
@@ -87,11 +197,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"
); );
} }
@@ -103,7 +216,7 @@ export default function Playground() {
resultRef.current.value = ""; resultRef.current.value = "";
} }
if (runDisabled || !editorRef.current) return; if (runDisabled) return;
setIsScriptRunning(true); setIsScriptRunning(true);
@@ -111,8 +224,20 @@ export default function Playground() {
terminalRef.current?.clear(); terminalRef.current?.clear();
terminalRef.current?.focus(); terminalRef.current?.focus();
// Get script content from either hardcoded agents or saved scripts
const hardcodedAgent = AGENTS[selectedAgent as keyof typeof AGENTS];
const savedScript = savedScripts[selectedAgent];
const agentScript = hardcodedAgent || savedScript;
const blueScript =
agentScript || (editorRef.current?.getValue() ?? "");
const redScript = editorRef.current?.getValue() ?? "";
const opponentName = agentScript ? 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";
@@ -132,21 +257,104 @@ 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, savedScripts]);
const stopScriptHandler = useCallback(() => { const stopScriptHandler = useCallback(() => {
stopScript(); stopScript();
}, []); }, []);
const saveScript = useCallback(async () => {
if (!scriptName.trim()) {
alert("Please enter a script name");
return;
}
if (!saveSecret.trim()) {
alert("Please enter a secret");
return;
}
if (!editorRef.current) {
alert("No script content to save");
return;
}
setIsSaving(true);
try {
const response = await fetch("/api/save-script", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
name: scriptName.trim(),
content: editorRef.current.getValue(),
secret: saveSecret.trim(),
}),
});
const result = await response.json();
if (response.ok) {
alert(result.message);
setScriptName("");
// Reload saved scripts to include the new one
loadSavedScripts();
} else {
alert(result.error || "Failed to save script");
}
} catch (error) {
console.error("Save error:", error);
alert("Network error: Failed to save script");
}
setIsSaving(false);
}, [scriptName, saveSecret, loadSavedScripts]);
const copyScriptToEditor = useCallback(() => {
if (!selectedAgent || selectedAgent === "Self") {
alert("Please select a script to copy to the editor");
return;
}
// Get the script content
const hardcodedAgent = AGENTS[selectedAgent as keyof typeof AGENTS];
const savedScript = savedScripts[selectedAgent];
const scriptContent = hardcodedAgent || savedScript;
if (!scriptContent) {
alert("No script content available for the selected agent");
return;
}
if (scriptContent.trim().startsWith("// SECRET")) {
alert("This script is hidden :)");
return;
}
// Warn user about losing current content
const confirmed = confirm(
`This will replace your current script with "${selectedAgent}". Your current work will be lost. Continue?`
);
if (confirmed && editorRef.current) {
editorRef.current.setValue(scriptContent);
}
}, [selectedAgent, savedScripts]);
return ( return (
<div className={styles.playgroundRoot}> <div className={styles.playgroundRoot}>
<header className={styles.header}> <header className={styles.header}>
@@ -206,6 +414,72 @@ export default function Playground() {
onChange={setBulkRounds} onChange={setBulkRounds}
unit="" unit=""
/> />
<div className={styles.configField}>
<label>Bulk opponent</label>
<AgentSelector
agents={availableAgents}
selectedAgent={selectedAgent}
onSelect={setSelectedAgent}
placeholder="Select an agent..."
/>
</div>
<div className={styles.configField}>
<Button
variant="info"
onClick={copyScriptToEditor}
disabled={
!selectedAgent ||
selectedAgent === "Self"
}
>
Copy to Editor
</Button>
</div>
<div className={styles.saveSection}>
<div className={styles.configField}>
<label>Script name</label>
<input
type="text"
className={styles.saveInput}
placeholder="Enter script name..."
value={scriptName}
onChange={(e) =>
setScriptName(
e.target.value
)
}
maxLength={32}
/>
</div>
<div className={styles.configField}>
<label>Secret</label>
<input
type="password"
className={styles.saveInput}
placeholder="Enter secret..."
value={saveSecret}
onChange={(e) =>
setSaveSecret(
e.target.value
)
}
/>
</div>
<div className={styles.configField}>
<Button
variant="primary"
onClick={saveScript}
loading={isSaving}
disabled={isSaving}
>
Save Script
</Button>
</div>
</div>
</div> </div>
} }
/> />
@@ -259,100 +533,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>
);
}

9
webui/src/lib/config.ts Normal file
View File

@@ -0,0 +1,9 @@
export const CONFIG = {
ENABLE_SAVE: process.env.ENABLE_SAVE === "true" || true,
SAVE_SECRET: process.env.SAVE_SECRET || "save",
SAVE_DIRECTORY: process.env.SAVE_DIRECTORY || "./data/scripts",
MAX_FILENAME_LENGTH: parseInt(process.env.MAX_FILENAME_LENGTH || "32"),
MAX_FILE_SIZE: parseInt(process.env.MAX_FILE_SIZE || "1048576"),
FILENAME_REGEX: /^[a-zA-Z0-9_\s-]+$/,
} as const;

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

@@ -1,8 +1,8 @@
import init, { GameState, GameStateHuman } from "../wasm/runner"; import init, { MinMaxGameHuman } from "../wasm/runner";
let wasmReady = false; let wasmReady = false;
let wasmInitPromise: Promise<void> | null = null; let wasmInitPromise: Promise<void> | null = null;
let currentGame: GameStateHuman | null = null; let currentGame: MinMaxGameHuman | null = null;
async function initWasm(): Promise<void> { async function initWasm(): Promise<void> {
if (wasmReady) return; if (wasmReady) return;
@@ -24,14 +24,18 @@ self.onmessage = async (event) => {
if (type === "data") { if (type === "data") {
if (currentGame !== null) { if (currentGame !== null) {
currentGame.take_input(event_data.data); try {
currentGame.take_input(event_data.data);
if (currentGame.is_error()) { if (currentGame.is_error()) {
currentGame = null; currentGame = null;
self.postMessage({ type: "complete" }); self.postMessage({ type: "complete" });
} else if (currentGame.is_done()) { } else if (currentGame.is_done()) {
currentGame = null; currentGame = null;
self.postMessage({ type: "complete" }); self.postMessage({ type: "complete" });
}
} catch (error) {
self.postMessage({ type: "error", error: String(error) });
} }
} }
} else if (type === "init") { } else if (type === "init") {
@@ -53,7 +57,7 @@ self.onmessage = async (event) => {
self.postMessage({ type: "terminal", line }); self.postMessage({ type: "terminal", line });
}; };
currentGame = new GameStateHuman( currentGame = new MinMaxGameHuman(
event_data.script, event_data.script,
appendOutput, appendOutput,
appendOutput, appendOutput,

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,44 @@
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;
}
.saveInput {
width: 100%;
padding: 6px 8px;
background: #3c3c3c;
color: #e0e0e0;
border: 1px solid #555;
border-radius: 3px;
font-size: 13px;
outline: none;
}
.saveInput:focus {
border-color: #007acc;
box-shadow: 0 0 3px rgba(0, 122, 204, 0.3);
}
.saveInput::placeholder {
color: #999;
}
.saveSection {
margin-top: 24px;
padding-top: 16px;
border-top: 1px solid #444;
}
.helpPanel { .helpPanel {
padding: 16px; padding: 16px;
width: 300px; width: 300px;