diff --git a/docker-compose.yml b/docker-compose.yml index 9b1bf73..bc31188 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,7 +2,7 @@ services: webui: image: minimax ports: - - "3000:3000" + - "4000:3000" volumes: - ./data:/app/data env_file: diff --git a/rust/minimax/src/agents/rhai.rs b/rust/minimax/src/agents/rhai.rs index ed68aac..a5bae6e 100644 --- a/rust/minimax/src/agents/rhai.rs +++ b/rust/minimax/src/agents/rhai.rs @@ -80,7 +80,6 @@ pub struct RhaiAgent { engine: Engine, script: AST, - scope: Scope<'static>, print_callback: Arc, } @@ -118,7 +117,6 @@ impl RhaiAgent { // Do not use FULL, rand_* functions are not pure engine.set_optimization_level(OptimizationLevel::Simple); - engine.disable_symbol("eval"); engine.set_max_expr_depths(100, 100); engine.set_max_strings_interned(1024); @@ -201,13 +199,11 @@ impl RhaiAgent { }; let script = engine.compile(script)?; - let scope = Scope::new(); // Not used Ok(Self { rng, engine, script, - scope, print_callback, }) } @@ -227,7 +223,7 @@ impl Agent for RhaiAgent { fn step_min(&mut self, board: &Board) -> Result { let res = self.engine.call_fn_with_options::( CallFnOptions::new().eval_ast(false), - &mut self.scope, + &mut Scope::new(), &self.script, "step_min", (board.clone(),), @@ -242,7 +238,7 @@ impl Agent for RhaiAgent { fn step_max(&mut self, board: &Board) -> Result { let res = self.engine.call_fn_with_options::( CallFnOptions::new().eval_ast(false), - &mut self.scope, + &mut Scope::new(), &self.script, "step_max", (board.clone(),), diff --git a/webui/src/app/api/get-script/route.ts b/webui/src/app/api/get-script/route.ts index a494a46..75488e2 100644 --- a/webui/src/app/api/get-script/route.ts +++ b/webui/src/app/api/get-script/route.ts @@ -2,11 +2,14 @@ import { NextRequest, NextResponse } from "next/server"; import { readFile } from "fs/promises"; import { join } from "path"; import { existsSync } from "fs"; -import { SAVE_CONFIG } from "@/lib/saveConfig"; +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 } = new URL(request.url); + const { searchParams } = request.nextUrl; const name = searchParams.get("name"); if (!name) { @@ -17,18 +20,17 @@ export async function GET(request: NextRequest) { } // Validate filename (same validation as save) - if (!SAVE_CONFIG.FILENAME_REGEX.test(name)) { + if (!CONFIG.FILENAME_REGEX.test(name)) { return NextResponse.json( { error: "Invalid script name" }, { status: 400 } ); } - const saveDir = SAVE_CONFIG.SAVE_DIRECTORY; + const saveDir = CONFIG.SAVE_DIRECTORY; const filename = `${name}.rhai`; const filepath = join(saveDir, filename); - // Check if file exists if (!existsSync(filepath)) { return NextResponse.json( { error: `Script "${name}" not found` }, @@ -36,8 +38,7 @@ export async function GET(request: NextRequest) { ); } - // Read and return file content - const content = await readFile(filepath, "utf8"); + let content = await readFile(filepath, "utf8"); return NextResponse.json({ name, diff --git a/webui/src/app/api/list-scripts/route.ts b/webui/src/app/api/list-scripts/route.ts index 97f3274..ed68c31 100644 --- a/webui/src/app/api/list-scripts/route.ts +++ b/webui/src/app/api/list-scripts/route.ts @@ -1,31 +1,40 @@ -import { NextResponse } from "next/server"; +import { NextRequest, NextResponse } from "next/server"; import { readdir } from "fs/promises"; -import { join } from "path"; import { existsSync } from "fs"; -import { SAVE_CONFIG } from "@/lib/saveConfig"; +import { CONFIG } from "@/lib/config"; -export async function GET() { +// 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 = SAVE_CONFIG.SAVE_DIRECTORY; + const saveDir = CONFIG.SAVE_DIRECTORY; // If save directory doesn't exist, return empty array if (!existsSync(saveDir)) { - return NextResponse.json({ scripts: [] }); + 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 }); + return NextResponse.json({ scripts }, { headers }); } catch (error) { console.error("List scripts error:", error); return NextResponse.json( { error: "Failed to list scripts" }, - { status: 500 } + { status: 500, headers } ); } } diff --git a/webui/src/app/api/save-script/route.ts b/webui/src/app/api/save-script/route.ts index 61280dc..6459bb3 100644 --- a/webui/src/app/api/save-script/route.ts +++ b/webui/src/app/api/save-script/route.ts @@ -2,12 +2,12 @@ import { NextRequest, NextResponse } from "next/server"; import { writeFile, mkdir } from "fs/promises"; import { join } from "path"; import { existsSync } from "fs"; -import { SAVE_CONFIG } from "@/lib/saveConfig"; +import { CONFIG } from "@/lib/config"; export async function POST(request: NextRequest) { try { // Check if saving is enabled - if (!SAVE_CONFIG.ENABLE_SAVE) { + if (!CONFIG.ENABLE_SAVE) { return NextResponse.json( { error: "Script saving is disabled" }, { status: 403 } @@ -16,15 +16,13 @@ export async function POST(request: NextRequest) { const { name, content, secret } = await request.json(); - // Validate secret - if (secret !== SAVE_CONFIG.SAVE_SECRET) { + if (secret !== CONFIG.SAVE_SECRET) { return NextResponse.json( - { error: "Invalid save secret" }, + { error: "Invalid secret" }, { status: 401 } ); } - // Validate required fields if (!name || !content) { return NextResponse.json( { error: "Name and content are required" }, @@ -32,17 +30,25 @@ export async function POST(request: NextRequest) { ); } - // Validate filename - if (name.length > SAVE_CONFIG.MAX_FILENAME_LENGTH) { + if (name.length > CONFIG.MAX_FILENAME_LENGTH) { return NextResponse.json( { - error: `Filename must be ${SAVE_CONFIG.MAX_FILENAME_LENGTH} characters or less`, + error: `Filename must be ${CONFIG.MAX_FILENAME_LENGTH} characters or less`, }, { status: 400 } ); } - if (!SAVE_CONFIG.FILENAME_REGEX.test(name)) { + 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", @@ -52,7 +58,7 @@ export async function POST(request: NextRequest) { } // Ensure save directory exists - const saveDir = SAVE_CONFIG.SAVE_DIRECTORY; + const saveDir = CONFIG.SAVE_DIRECTORY; if (!existsSync(saveDir)) { await mkdir(saveDir, { recursive: true }); } diff --git a/webui/src/components/Editor.tsx b/webui/src/components/Editor.tsx index d190e3c..7bb2696 100644 --- a/webui/src/components/Editor.tsx +++ b/webui/src/components/Editor.tsx @@ -128,7 +128,8 @@ export const Editor = forwardRef(function Editor( 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 useEffect(() => { diff --git a/webui/src/components/Playground.tsx b/webui/src/components/Playground.tsx index c7f550b..1350ff9 100644 --- a/webui/src/components/Playground.tsx +++ b/webui/src/components/Playground.tsx @@ -42,36 +42,32 @@ fn step_max(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 [fontSize, setFontSize] = useState(() => { + 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 [scriptName, setScriptName] = useState(""); const [saveSecret, setSaveSecret] = useState(""); @@ -126,10 +122,7 @@ export default function Playground() { // 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" - ), + ...Object.keys(AGENTS).filter((key) => key !== "Self"), ...scripts, ]; @@ -147,6 +140,30 @@ export default function Playground() { 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 () => { if (resultRef.current) { resultRef.current.value = ""; @@ -265,7 +282,7 @@ export default function Playground() { } if (!saveSecret.trim()) { - alert("Please enter the save secret"); + alert("Please enter a secret"); return; } @@ -323,6 +340,11 @@ export default function Playground() { 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?` @@ -433,11 +455,11 @@ export default function Playground() {
- + setSaveSecret( diff --git a/webui/src/lib/saveConfig.ts b/webui/src/lib/config.ts similarity index 61% rename from webui/src/lib/saveConfig.ts rename to webui/src/lib/config.ts index ac51840..fe726ea 100644 --- a/webui/src/lib/saveConfig.ts +++ b/webui/src/lib/config.ts @@ -1,8 +1,9 @@ -export const SAVE_CONFIG = { - ENABLE_SAVE: process.env.ENABLE_SAVE === 'true' || true, +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; diff --git a/webui/src/lib/worker_human.ts b/webui/src/lib/worker_human.ts index 342637c..f88203b 100644 --- a/webui/src/lib/worker_human.ts +++ b/webui/src/lib/worker_human.ts @@ -24,14 +24,18 @@ self.onmessage = async (event) => { if (type === "data") { if (currentGame !== null) { - currentGame.take_input(event_data.data); + try { + currentGame.take_input(event_data.data); - if (currentGame.is_error()) { - currentGame = null; - self.postMessage({ type: "complete" }); - } else if (currentGame.is_done()) { - currentGame = null; - self.postMessage({ type: "complete" }); + if (currentGame.is_error()) { + currentGame = null; + self.postMessage({ type: "complete" }); + } else if (currentGame.is_done()) { + currentGame = null; + self.postMessage({ type: "complete" }); + } + } catch (error) { + self.postMessage({ type: "error", error: String(error) }); } } } else if (type === "init") {