diff --git a/cmd/acb-wasm/main.go b/cmd/acb-wasm/main.go index 10bb182..a56ea12 100644 --- a/cmd/acb-wasm/main.go +++ b/cmd/acb-wasm/main.go @@ -1,18 +1,15 @@ //go:build js && wasm // Package main is compiled with GOOS=js GOARCH=wasm to produce engine.wasm. -// It exposes three functions on the global acbEngine object: +// It exposes functions on the global acbEngine object: // -// acbEngine.loadState(stateJSON) – load a serialised GameState -// acbEngine.step(movesJSON) – advance one turn; returns {state,result} -// acbEngine.runMatch(configJSON) – run a full match; returns {replay,result} -// -// Example (JavaScript): -// -// const go = new Go(); -// WebAssembly.instantiateStreaming(fetch('/wasm/engine.wasm'), go.importObject) -// .then(({instance}) => { go.run(instance); }); -// // acbEngine is now available +// acbEngine.loadState(stateJSON) – load a serialised GameState +// acbEngine.step(movesJSON) – advance one turn; returns {state,result} +// acbEngine.runMatch(configJSON) – run a full 2-player match with built-in bots +// acbEngine.addPlayer(name, fn) – register a player with a JS callback strategy +// acbEngine.clearPlayers() – clear all registered players +// acbEngine.runMatchMulti(configJSON) – run a match with all registered players +// acbEngine.version – version string package main import ( @@ -26,25 +23,55 @@ import ( // matchSession holds a running match for turn-by-turn access. type matchSession struct { - gs *engine.GameState - bots []engine.BotInterface + gs *engine.GameState + bots []engine.BotInterface recorder *engine.ReplayWriter } var session *matchSession +// jsPlayers holds JS callback strategies registered via addPlayer. +var jsPlayers []jsPlayerEntry + +type jsPlayerEntry struct { + name string + fn js.Value // JS function: (stateJSON: string) => string (moves JSON) +} + +// jsBot wraps a JS callback as an engine.BotInterface. +type jsBot struct { + fn js.Value +} + +func (b *jsBot) GetMoves(state *engine.VisibleState) ([]engine.Move, error) { + stateJSON, err := json.Marshal(state) + if err != nil { + return nil, err + } + + result := b.fn.Invoke(string(stateJSON)) + if result.IsUndefined() || result.IsNull() { + return []engine.Move{}, nil + } + + var moves []engine.Move + if err := json.Unmarshal([]byte(result.String()), &moves); err != nil { + return []engine.Move{}, nil + } + return moves, nil +} + // jsLoadState parses a serialised GameState JSON and stores it as the active session. // Signature: acbEngine.loadState(stateJSON: string) => {ok:bool, error?:string} func jsLoadState(_ js.Value, args []js.Value) interface{} { if len(args) < 1 { return jsErr("stateJSON argument required") } - // For now, we expect an initialisation config rather than a full state dump. type initRequest struct { - Config engine.Config `json:"config"` - Seed int64 `json:"seed"` - Bot1 string `json:"bot1"` // strategy name - Bot2 string `json:"bot2"` // strategy name + Config engine.Config `json:"config"` + Seed int64 `json:"seed"` + Bot1 string `json:"bot1"` + Bot2 string `json:"bot2"` } var req initRequest if err := json.Unmarshal([]byte(args[0].String()), &req); err != nil { @@ -54,7 +81,6 @@ func jsLoadState(_ js.Value, args []js.Value) interface{} { cfg := req.Config if cfg.Rows == 0 { cfg = engine.DefaultConfig() - // Smaller default for in-browser matches cfg.Rows = 30 cfg.Cols = 30 cfg.MaxTurns = 200 @@ -75,7 +101,7 @@ func jsLoadState(_ js.Value, args []js.Value) interface{} { mr.AddBot(bot1, req.Bot1) mr.AddBot(bot2, req.Bot2) - _ = gs // session setup done via match runner below + _ = gs session = &matchSession{ gs: gs, @@ -93,14 +119,12 @@ func jsStep(_ js.Value, args []js.Value) interface{} { } gs := session.gs - // Parse moves from caller (if provided) if len(args) > 0 && args[0].String() != "" { var moves []engine.Move if err := json.Unmarshal([]byte(args[0].String()), &moves); err != nil { return jsErr("parse moves: " + err.Error()) } for _, m := range moves { - // Find bot at position and submit move for _, b := range gs.Bots { if b.Alive && b.Position == m.Position { gs.Moves[b.ID] = m @@ -126,7 +150,7 @@ func jsStep(_ js.Value, args []js.Value) interface{} { return out } -// jsRunMatch executes a complete match and returns the replay. +// jsRunMatch executes a complete 2-player match with built-in bots. // Signature: acbEngine.runMatch(configJSON: string) => {replay, result} func jsRunMatch(_ js.Value, args []js.Value) interface{} { type runRequest struct { @@ -186,6 +210,89 @@ func jsRunMatch(_ js.Value, args []js.Value) interface{} { } } +// jsAddPlayer registers a player with a JS callback strategy. +// Signature: acbEngine.addPlayer(name: string, fn: (stateJSON: string) => string) => {ok:bool} +func jsAddPlayer(_ js.Value, args []js.Value) interface{} { + if len(args) < 2 { + return jsErr("addPlayer requires (name, fn) arguments") + } + name := args[0].String() + fn := args[1] + + jsPlayers = append(jsPlayers, jsPlayerEntry{ + name: name, + fn: fn, + }) + + return map[string]interface{}{ + "ok": true, + "index": len(jsPlayers) - 1, + } +} + +// jsClearPlayers clears all registered players. +// Signature: acbEngine.clearPlayers() => {ok:bool} +func jsClearPlayers(_ js.Value, _ []js.Value) interface{} { + jsPlayers = jsPlayers[:0] + return map[string]interface{}{"ok": true} +} + +// jsRunMatchMulti runs a match with all registered JS callback players. +// Signature: acbEngine.runMatchMulti(configJSON: string) => {replay, result} +func jsRunMatchMulti(_ js.Value, args []js.Value) interface{} { + if len(jsPlayers) < 2 { + return jsErr("need at least 2 registered players; use addPlayer() first") + } + + type configRequest struct { + Config engine.Config `json:"config"` + Seed int64 `json:"seed"` + } + + var req configRequest + if len(args) > 0 && args[0].String() != "" { + if err := json.Unmarshal([]byte(args[0].String()), &req); err != nil { + return jsErr("parse config: " + err.Error()) + } + } + + cfg := req.Config + if cfg.Rows == 0 { + cfg = engine.DefaultConfig() + cfg.Rows = 30 + cfg.Cols = 30 + cfg.MaxTurns = 200 + } + + seed := req.Seed + if seed == 0 { + seed = time.Now().UnixNano() + } + rng := rand.New(rand.NewSource(seed)) + + mr := engine.NewMatchRunner(cfg, + engine.WithRNG(rng), + engine.WithTimeout(2*time.Second), + ) + + for i, p := range jsPlayers { + mr.AddBot(&jsBot{fn: p.fn}, p.name) + _ = i + } + + result, replay, err := mr.Run() + if err != nil { + return jsErr("run match: " + err.Error()) + } + + replayJSON, _ := json.Marshal(replay) + resultJSON, _ := json.Marshal(result) + return map[string]interface{}{ + "replay": string(replayJSON), + "result": string(resultJSON), + } +} + func jsErr(msg string) map[string]interface{} { return map[string]interface{}{"ok": false, "error": msg} } @@ -203,7 +310,16 @@ func main() { "runMatch": js.FuncOf(func(this js.Value, args []js.Value) interface{} { return jsRunMatch(this, args) }), - "version": "1.0.0", + "addPlayer": js.FuncOf(func(this js.Value, args []js.Value) interface{} { + return jsAddPlayer(this, args) + }), + "clearPlayers": js.FuncOf(func(this js.Value, args []js.Value) interface{} { + return jsClearPlayers(this, args) + }), + "runMatchMulti": js.FuncOf(func(this js.Value, args []js.Value) interface{} { + return jsRunMatchMulti(this, args) + }), + "version": "2.0.0", })) <-done diff --git a/web/public/wasm/engine.wasm b/web/public/wasm/engine.wasm index ed0dca1..274ec37 100755 Binary files a/web/public/wasm/engine.wasm and b/web/public/wasm/engine.wasm differ diff --git a/web/src/pages/sandbox.ts b/web/src/pages/sandbox.ts index bc5fb47..f314a4d 100644 --- a/web/src/pages/sandbox.ts +++ b/web/src/pages/sandbox.ts @@ -1,7 +1,15 @@ -// In-browser bot sandbox: Monaco editor + TS game engine + WASM upload + replay viewer -import { runMatch, defaultConfig, type Config, type BotStrategy, type VisibleState, type Move } from '../engine'; +// In-browser WASM game sandbox per §13.1 +// Two match engines: +// - Go WASM engine (production-accurate, loaded from /wasm/engine.wasm) +// - TypeScript engine (instant, always available as fallback) +// Two user modes: +// - Quick-start: Monaco editor with JS/TS starter → eval → run match +// - Full: Upload compiled .wasm file → validate interface → run match +// Multi-opponent: Select 1–3 AI opponents for 2–4 player matches +// Replay viewer: Canvas-based with fog-of-war toggle, view modes +import { runMultiMatch, defaultConfig, BUILTIN_STRATEGIES, type Config, type BotStrategy, type VisibleState, type Move, type Replay } from '../engine'; import { ReplayViewer } from '../replay-viewer'; -import type { Replay } from '../types'; +import type { ViewMode } from '../types'; const WASM_BOT_SPEC = `// ACB WASM Bot Interface Spec (v1.0) // ───────────────────────────────────────────────────────────────────────────── @@ -73,21 +81,125 @@ function dist2(a, b, cfg) { } `; +// ─── Console logger ──────────────────────────────────────────────────────── + +interface ConsoleEntry { + turn: number; + level: 'log' | 'warn' | 'error'; + message: string; +} + +// ─── Mobile detection ────────────────────────────────────────────────────── + +function isMobile(): boolean { + return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) + || (window.innerWidth < 900 && 'ontouchstart' in window); +} + +// ─── Go WASM Engine Loader ───────────────────────────────────────────────── + +type GoEngine = { + loadState(s: string): { ok: boolean; error?: string }; + step(m: string): { state: string; events: string; turn: number; result?: string }; + runMatch(c: string): { replay: string; result: string; error?: string }; + addPlayer(name: string, fn: (stateJSON: string) => string): { ok: boolean; index: number; error?: string }; + clearPlayers(): { ok: boolean }; + runMatchMulti(c: string): { replay: string; result: string; error?: string }; + version: string; +}; + +let goEngine: GoEngine | null = null; +let goEngineLoading = false; +let goEngineLoadPromise: Promise | null = null; + +async function loadGoEngine(): Promise { + if (goEngine) return goEngine; + if (goEngineLoading && goEngineLoadPromise) return goEngineLoadPromise; + + goEngineLoading = true; + goEngineLoadPromise = (async () => { + try { + // Load wasm_exec.js if not already loaded + if (!(globalThis as any).Go) { + await new Promise((resolve, reject) => { + const script = document.createElement('script'); + script.src = '/wasm/wasm_exec.js'; + script.onload = () => resolve(); + script.onerror = () => reject(new Error('Failed to load wasm_exec.js')); + document.head.appendChild(script); + }); + } + + const go = new (globalThis as any).Go(); + const response = await fetch('/wasm/engine.wasm'); + const buffer = await response.arrayBuffer(); + const { instance } = await WebAssembly.instantiate(buffer, go.importObject); + go.run(instance); + + goEngine = (globalThis as any).acbEngine as GoEngine; + return goEngine; + } catch (err) { + console.warn('Go WASM engine load failed, using TS engine fallback:', err); + return null; + } finally { + goEngineLoading = false; + } + })(); + + return goEngineLoadPromise; +} + export function renderSandboxPage(_params: Record): void { const app = document.getElementById('app'); if (!app) return; - app.innerHTML = buildHTML(); + if (isMobile()) { + app.innerHTML = buildMobileHTML(); + return; + } - // Defer heavy init to avoid blocking render + app.innerHTML = buildHTML(); requestAnimationFrame(() => initSandbox()); } +function buildMobileHTML(): string { + return ` +
+

Bot Sandbox

+
+
🖥️
+

Desktop Required

+

The Bot Sandbox requires a desktop browser for the code editor and replay viewer.

+

+ Scan this page's QR code on your phone to open it on your desktop, or visit + aicodebattle.com/#/compete/sandbox on a computer. +

+ +
+
+ + `; +} + function buildHTML(): string { return `

Bot Sandbox

-

Write JavaScript bot logic, pick an opponent, and run an in-browser match instantly — no server required.

+

Write bot logic, pick opponents, and run in-browser matches instantly — no server required.

@@ -101,11 +213,18 @@ function buildHTML(): string {
-
+
+
Console + +
+
+
+ +
WASM Bot Interface Spec
@@ -118,21 +237,20 @@ function buildHTML(): string {
Match Settings
- - + + + +
Engine: loading…
+
@@ -140,12 +258,26 @@ function buildHTML(): string { + + + + - - 100ms +
+ + 100ms +
+ +
+
Opponents + +
+
+
+
@@ -155,15 +287,29 @@ function buildHTML(): string {
- +