Files
minimax/webui/src/components/Playground.tsx
2025-11-03 16:55:46 -08:00

755 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState, useRef, useCallback, useEffect } from "react";
import { Button } from "@/components/ui/Button";
import { Dropdown } from "@/components/ui/Dropdown";
import { Slider } from "@/components/ui/Slider";
import { SidePanel } from "@/components/ui/SidePanel";
import { AgentSelector } from "@/components/ui/AgentSelector";
import { Editor } from "@/components/Editor";
import { Terminal, TerminalRef } from "@/components/Terminal";
import {
sendDataToScript,
startScript,
startScriptBulk,
stopScript,
} from "@/lib/runner";
import styles from "@/styles/Playground.module.css";
const initialCode = `fn random_action(board) {
let symb = rand_symb();
let pos = rand_int(0, 10);
let action = Action(symb, pos);
// If this action is invalid, randomly select a new one.
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);
}`;
const AGENTS = {
// special-cased below
Self: undefined,
Random: `fn random_action(board) {
let symb = rand_symb();
let pos = rand_int(0, 10);
let action = Action(symb, pos);
while !board.can_play(action) {
let symb = rand_symb();
let pos = rand_int(0, 10);
action = Action(symb, pos);
}
return action;
}
fn step_min(board) {
return random_action(board);
}
fn step_max(board) {
return random_action(board);
}`,
};
export default function Playground() {
const [isScriptRunning, setIsScriptRunning] = useState(false);
const [isEditorReady, setIsEditorReady] = useState(false);
const [fontSize, setFontSize] = useState(14);
const [bulkRounds, setBulkRounds] = useState(1000);
const [selectedAgent, setSelectedAgent] = useState("Random");
const [isHelpOpen, setIsHelpOpen] = useState(false);
const [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 resultRef = useRef<HTMLTextAreaElement>(null);
const terminalRef = useRef<TerminalRef>(null);
const runDisabled = isScriptRunning || !isEditorReady;
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",
"Random",
...Object.keys(AGENTS).filter(
(key) => key !== "Self" && key !== "Random"
),
...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]);
const runHuman = useCallback(async () => {
if (resultRef.current) {
resultRef.current.value = "";
}
if (runDisabled || !editorRef.current) return;
setIsScriptRunning(true);
try {
terminalRef.current?.clear();
terminalRef.current?.focus();
await startScript(
editorRef.current.getValue(),
(line: string) => {
if (resultRef.current) {
let v = resultRef.current.value + line + "\n";
if (v.length > 10000) {
v = v.substring(v.length - 10000);
}
resultRef.current.value = v;
resultRef.current.scrollTop =
resultRef.current.scrollHeight -
resultRef.current.clientHeight;
}
},
(line: string) => {
terminalRef.current?.write(line);
}
);
} catch (ex) {
console.error(ex);
if (resultRef.current) {
resultRef.current.value += `\nScript exited with error:\n${ex}\n`;
}
terminalRef.current?.write(
"\r\n\x1B[1;31mScript exited with error:\x1B[0m\n\r" +
String(ex).replace("\n", "\n\r") +
"\r\n"
);
}
setIsScriptRunning(false);
}, [runDisabled]);
const runBulk = useCallback(async () => {
if (resultRef.current) {
resultRef.current.value = "";
}
if (runDisabled) return;
setIsScriptRunning(true);
try {
terminalRef.current?.clear();
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(
redScript,
blueScript,
opponentName,
(line: string) => {
if (resultRef.current) {
let v = resultRef.current.value + line + "\n";
if (v.length > 10000) {
v = v.substring(v.length - 10000);
}
resultRef.current.value = v;
resultRef.current.scrollTop =
resultRef.current.scrollHeight -
resultRef.current.clientHeight;
}
},
(line: string) => {
terminalRef.current?.write(line);
},
bulkRounds
);
} catch (ex) {
console.error(ex);
if (resultRef.current) {
resultRef.current.value += `\nScript exited with error:\n${ex}\n`;
}
terminalRef.current?.write(
"\r\n\x1B[1;31mScript exited with error:\x1B[0m\n\r" +
String(ex).replace("\n", "\n\r") +
"\r\n"
);
}
setIsScriptRunning(false);
}, [runDisabled, bulkRounds, selectedAgent, savedScripts]);
const stopScriptHandler = useCallback(() => {
stopScript();
}, []);
const saveScript = useCallback(async () => {
if (!scriptName.trim()) {
alert("Please enter a script name");
return;
}
if (!saveSecret.trim()) {
alert("Please enter the save 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;
}
// 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 (
<div className={styles.playgroundRoot}>
<header className={styles.header}>
<div className={styles.headerField}>
<div className={styles.buttonGroup}>
<Button
variant="success"
iconLeft="play"
onClick={runHuman}
loading={isScriptRunning}
disabled={runDisabled}
>
Run
</Button>
<Button
variant="success"
iconLeft="play"
onClick={runBulk}
loading={isScriptRunning}
disabled={runDisabled}
>
Bulk Run
</Button>
<Button
variant="danger"
iconLeft="stop"
onClick={stopScriptHandler}
disabled={stopDisabled}
>
Stop
</Button>
</div>
<div className={styles.buttonGroup}>
<Dropdown
trigger="Config"
align="right"
customContent={
<div className={styles.configPanel}>
<Slider
label="Font Size"
value={fontSize}
min={10}
max={24}
step={1}
onChange={setFontSize}
unit="px"
/>
<Slider
label="Bulk Rounds"
value={bulkRounds}
min={100}
max={10000}
step={100}
onChange={setBulkRounds}
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>Save secret</label>
<input
type="password"
className={styles.saveInput}
placeholder="Enter save 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>
}
/>
<Button
iconLeft="help-circle"
onClick={() => setIsHelpOpen(true)}
>
Help
</Button>
</div>
</div>
</header>
<div className={styles.mainContent}>
<div className={styles.leftPanel}>
<Editor
ref={editorRef}
initialValue={initialCode}
onChange={() => {}}
onReady={() => setIsEditorReady(true)}
fontSize={fontSize}
/>
</div>
<div className={styles.rightPanel}>
<div className={styles.terminalPanel}>
<div className={styles.panelHeader}>Terminal</div>
<div className={styles.terminalContainer}>
<Terminal
ref={terminalRef}
onData={sendDataToScript}
fontSize={fontSize}
/>
</div>
</div>
<div className={styles.outputPanel}>
<div className={styles.panelHeader}>Output</div>
<textarea
ref={resultRef}
className={styles.result}
readOnly
autoComplete="off"
placeholder="Use print() to produce output"
style={{ fontSize: `${fontSize}px` }}
/>
</div>
</div>
</div>
<SidePanel isOpen={isHelpOpen} onClose={() => setIsHelpOpen(false)}>
<h2>Game Rules</h2>
<p>
This game is played in two rounds, on an empty eleven-space
board. The first round is played on {"Red's"} board, the
second is played on {"Blue's"}.
</p>
<p>
On {"Red's"} board, {"Red's"} goal is to maximize the value
of the expression.
{" Blue's"} goal is to minimize it. Players take turns
placing the fourteen symbols <code>0123456789+-×÷</code>
on the board, with the maximizing player taking the first
move.
</p>
<p>
A {"board's"} syntax must always be valid, and the following
rules are enforced:
</p>
<ol>
<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>
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>
<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>Division by zero results in a draw.</li>
<li>
An incomplete board with no valid moves results in a
draw.
</li>
</ol>
<h2>How to Play</h2>
<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>
Click <strong>Bulk Run</strong> to collect statistics
from a many games.
</li>
</ol>
<h2>Overview</h2>
<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>
<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>
<h2>Rhai basics</h2>
<p>
Agents are written in <a href="https://rhai.rs">Rhai</a>, a
wonderful embedded scripting language powered by Rust. Basic
language features are outlined below.
</p>
<ul>
<li>
All statements must be followed by a <code>;</code>
</li>
<li>
Use <code>return</code> to return a value from a
function.
</li>
<li>
<code>print(anything)</code> - Prints to the output
panel. Prefer this over <code>debug</code>.
</li>
<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>
<h2>Notable Functions</h2>
<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>
<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>
<code>rand_symb()</code> - Returns a random symbol
(number or operation)
</li>
<li>
<code>rand_op()</code> - Returns a random operator
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>
</SidePanel>
</div>
);
}