feat(sandbox): integrate Go WASM engine with multi-player support per §13.1
Enhance the WASM game sandbox with production-accurate Go engine: - Add multi-player support (2-4 players) to Go WASM engine via JS callbacks - New acbEngine.addPlayer/clearPlayers/runMatchMulti API for N-player matches - Sandbox auto-loads Go WASM engine in background, falls back to TS engine - Engine selector: Auto (Go WASM → TS fallback), Go WASM only, or TS only - Engine status indicator shows which engine is active - Performance panel reports which engine was used Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
347ae4f1df
commit
51edf35d22
3 changed files with 637 additions and 109 deletions
|
|
@ -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
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -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<GoEngine | null> | null = null;
|
||||
|
||||
async function loadGoEngine(): Promise<GoEngine | null> {
|
||||
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<void>((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<string, string>): 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 `
|
||||
<div class="sandbox-page">
|
||||
<h1 class="page-title">Bot Sandbox</h1>
|
||||
<div class="mobile-notice">
|
||||
<div class="mobile-icon">🖥️</div>
|
||||
<h2>Desktop Required</h2>
|
||||
<p>The Bot Sandbox requires a desktop browser for the code editor and replay viewer.</p>
|
||||
<p style="margin-top:16px;color:var(--text-muted);font-size:0.875rem;">
|
||||
Scan this page's QR code on your phone to open it on your desktop, or visit
|
||||
<strong>aicodebattle.com/#/compete/sandbox</strong> on a computer.
|
||||
</p>
|
||||
<div style="margin-top:24px;">
|
||||
<a href="#/compete" class="btn primary">Back to Compete</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<style>
|
||||
.mobile-notice {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 12px;
|
||||
max-width: 400px;
|
||||
margin: 40px auto;
|
||||
}
|
||||
.mobile-icon { font-size: 3rem; margin-bottom: 16px; }
|
||||
.mobile-notice h2 { color: var(--text-primary); margin-bottom: 8px; }
|
||||
.mobile-notice p { color: var(--text-muted); }
|
||||
</style>
|
||||
`;
|
||||
}
|
||||
|
||||
function buildHTML(): string {
|
||||
return `
|
||||
<div class="sandbox-page">
|
||||
<h1 class="page-title">Bot Sandbox</h1>
|
||||
<p class="sandbox-intro">Write JavaScript bot logic, pick an opponent, and run an in-browser match instantly — no server required.</p>
|
||||
<p class="sandbox-intro">Write bot logic, pick opponents, and run in-browser matches instantly — no server required.</p>
|
||||
|
||||
<div class="sandbox-layout">
|
||||
<!-- Left: editor -->
|
||||
|
|
@ -101,11 +213,18 @@ function buildHTML(): string {
|
|||
<button id="reset-code-btn" class="btn small secondary">Reset</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="monaco-container" style="height:420px;border-radius:6px;overflow:hidden;"></div>
|
||||
<div id="monaco-container" style="height:400px;border-radius:6px;overflow:hidden;"></div>
|
||||
<div id="wasm-status" class="wasm-status hidden"></div>
|
||||
</div>
|
||||
|
||||
<div class="sandbox-panel">
|
||||
<div class="panel-header"><span>Console</span>
|
||||
<button id="clear-console-btn" class="btn small secondary">Clear</button>
|
||||
</div>
|
||||
<div id="console-output" class="console-output"></div>
|
||||
</div>
|
||||
|
||||
<div class="sandbox-panel collapsible">
|
||||
<div class="panel-header"><span>WASM Bot Interface Spec</span>
|
||||
<button id="toggle-spec-btn" class="btn small secondary">Show</button>
|
||||
</div>
|
||||
|
|
@ -118,21 +237,20 @@ function buildHTML(): string {
|
|||
<div class="sandbox-panel">
|
||||
<div class="panel-header"><span>Match Settings</span></div>
|
||||
<div class="settings-grid">
|
||||
<label>Opponent Strategy</label>
|
||||
<select id="opponent-select">
|
||||
<option value="random">Random</option>
|
||||
<option value="gatherer" selected>Gatherer</option>
|
||||
<option value="rusher">Rusher</option>
|
||||
<option value="guardian">Guardian</option>
|
||||
<option value="swarm">Swarm</option>
|
||||
<option value="hunter">Hunter</option>
|
||||
<label>Engine</label>
|
||||
<select id="engine-select">
|
||||
<option value="auto" selected>Auto (Go WASM → TS fallback)</option>
|
||||
<option value="wasm">Go WASM Engine</option>
|
||||
<option value="ts">TypeScript Engine (instant)</option>
|
||||
</select>
|
||||
<div id="engine-status" class="engine-status">Engine: loading…</div>
|
||||
<div></div>
|
||||
|
||||
<label>Grid Size</label>
|
||||
<select id="grid-size-select">
|
||||
<option value="20">Small (20×20)</option>
|
||||
<option value="30" selected>Medium (30×30)</option>
|
||||
<option value="40">Large (40×40)</option>
|
||||
<option value="20">Small (20x20)</option>
|
||||
<option value="30" selected>Medium (30x30)</option>
|
||||
<option value="40">Large (40x40)</option>
|
||||
</select>
|
||||
|
||||
<label>Max Turns</label>
|
||||
|
|
@ -140,12 +258,26 @@ function buildHTML(): string {
|
|||
<option value="100">100</option>
|
||||
<option value="200" selected>200</option>
|
||||
<option value="300">300</option>
|
||||
<option value="500">500</option>
|
||||
</select>
|
||||
|
||||
<label>Map Seed</label>
|
||||
<input type="number" id="seed-input" placeholder="Random" class="seed-input">
|
||||
|
||||
<label>Playback Speed</label>
|
||||
<input type="range" id="speed-slider" min="20" max="500" value="100" class="speed-slider">
|
||||
<span id="speed-label" class="speed-label">100ms</span>
|
||||
<div class="speed-row">
|
||||
<input type="range" id="speed-slider" min="20" max="500" value="100" class="speed-slider">
|
||||
<span id="speed-label" class="speed-label">100ms</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="opponents-section">
|
||||
<div class="panel-header" style="margin-bottom:8px"><span>Opponents</span>
|
||||
<button id="add-opponent-btn" class="btn small secondary">+ Add</button>
|
||||
</div>
|
||||
<div id="opponents-list"></div>
|
||||
</div>
|
||||
|
||||
<button id="run-btn" class="btn primary run-btn">Run Match</button>
|
||||
</div>
|
||||
|
||||
|
|
@ -155,15 +287,29 @@ function buildHTML(): string {
|
|||
</div>
|
||||
|
||||
<div class="sandbox-panel" id="performance-panel" style="display:none">
|
||||
<div class="panel-header"><span>Performance Stats</span></div>
|
||||
<div class="panel-header"><span>Performance</span></div>
|
||||
<div id="perf-stats" class="perf-stats"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Replay viewer below -->
|
||||
<!-- Replay viewer -->
|
||||
<div id="replay-section" class="replay-section" style="display:none">
|
||||
<h2 class="section-title">Replay</h2>
|
||||
<div class="replay-header">
|
||||
<h2 class="section-title">Replay</h2>
|
||||
<div class="replay-controls-top">
|
||||
<label class="fog-toggle">
|
||||
<input type="checkbox" id="fog-toggle"> Fog of War (Player <select id="fog-player-select"><option value="0">0</option></select>)
|
||||
</label>
|
||||
<select id="view-mode-select" class="btn small secondary">
|
||||
<option value="standard">Standard</option>
|
||||
<option value="dots">Dots</option>
|
||||
<option value="influence">Influence</option>
|
||||
<option value="voronoi">Voronoi</option>
|
||||
</select>
|
||||
<button id="download-replay-btn" class="btn small secondary">Download Replay</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="replay-layout-sandbox">
|
||||
<div class="canvas-wrapper">
|
||||
<canvas id="sandbox-canvas"></canvas>
|
||||
|
|
@ -174,7 +320,6 @@ function buildHTML(): string {
|
|||
<button id="sb-prev-btn" class="btn">Prev</button>
|
||||
<button id="sb-next-btn" class="btn">Next</button>
|
||||
<button id="sb-reset-btn" class="btn">Reset</button>
|
||||
<button id="download-replay-btn" class="btn secondary">Download Replay</button>
|
||||
</div>
|
||||
<div class="slider-group">
|
||||
<label>Turn: <span id="sb-turn-display">0</span> / <span id="sb-total-turns">0</span></label>
|
||||
|
|
@ -189,12 +334,68 @@ function buildHTML(): string {
|
|||
`;
|
||||
}
|
||||
|
||||
// ─── Opponent slots ────────────────────────────────────────────────────────
|
||||
|
||||
const MAX_OPPONENTS = 3;
|
||||
const STRATEGY_OPTIONS = [
|
||||
{ value: 'random', label: 'Random' },
|
||||
{ value: 'gatherer', label: 'Gatherer' },
|
||||
{ value: 'rusher', label: 'Rusher' },
|
||||
{ value: 'guardian', label: 'Guardian' },
|
||||
{ value: 'swarm', label: 'Swarm' },
|
||||
{ value: 'hunter', label: 'Hunter' },
|
||||
];
|
||||
|
||||
function buildOpponentRow(index: number, defaultStrategy: string): string {
|
||||
const opts = STRATEGY_OPTIONS.map(s =>
|
||||
`<option value="${s.value}"${s.value === defaultStrategy ? ' selected' : ''}>${s.label}</option>`
|
||||
).join('');
|
||||
return `
|
||||
<div class="opponent-row" data-idx="${index}">
|
||||
<span class="opponent-label">Opponent ${index + 1}</span>
|
||||
<select class="opponent-select">${opts}</select>
|
||||
<button class="btn small secondary remove-opponent-btn" title="Remove">✕</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ─── Init ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function initSandbox(): void {
|
||||
let monacoEditor: any = null;
|
||||
let currentCode = STARTER_CODE;
|
||||
let wasmStrategy: BotStrategy | null = null;
|
||||
let lastReplay: any = null;
|
||||
let viewer: ReplayViewer | null = null;
|
||||
const consoleEntries: ConsoleEntry[] = [];
|
||||
|
||||
const consoleDiv = document.getElementById('console-output')!;
|
||||
const opponentsList = document.getElementById('opponents-list')!;
|
||||
const engineStatusDiv = document.getElementById('engine-status')!;
|
||||
|
||||
// Start with one opponent (gatherer)
|
||||
opponentsList.innerHTML = buildOpponentRow(0, 'gatherer');
|
||||
|
||||
// Start loading Go WASM engine in background
|
||||
loadGoEngine().then(engine => {
|
||||
if (engine) {
|
||||
engineStatusDiv.textContent = `Engine: Go WASM v${engine.version}`;
|
||||
engineStatusDiv.style.color = 'var(--success)';
|
||||
} else {
|
||||
engineStatusDiv.textContent = 'Engine: TypeScript (WASM unavailable)';
|
||||
engineStatusDiv.style.color = 'var(--text-muted)';
|
||||
}
|
||||
});
|
||||
|
||||
function logToConsole(level: ConsoleEntry['level'], message: string, turn = -1): void {
|
||||
consoleEntries.push({ turn, level, message });
|
||||
if (consoleEntries.length > 500) consoleEntries.shift();
|
||||
const colors: Record<string, string> = { log: 'var(--text-muted)', warn: 'var(--warning)', error: 'var(--error)' };
|
||||
const prefix = turn >= 0 ? `[T${turn}] ` : '';
|
||||
consoleDiv.innerHTML += `<div class="console-line" style="color:${colors[level]}">${prefix}${escapeHtml(message)}</div>`;
|
||||
consoleDiv.scrollTop = consoleDiv.scrollHeight;
|
||||
}
|
||||
|
||||
logToConsole('log', 'Sandbox ready. Write code or upload a WASM bot, then click Run Match.');
|
||||
|
||||
// ── Monaco editor ───────────────────────────────────────────────────────
|
||||
loadMonaco().then(monaco => {
|
||||
|
|
@ -216,7 +417,6 @@ function initSandbox(): void {
|
|||
currentCode = monacoEditor.getValue();
|
||||
});
|
||||
}).catch(() => {
|
||||
// Monaco unavailable – use plain textarea fallback
|
||||
const container = document.getElementById('monaco-container')!;
|
||||
container.innerHTML = `<textarea id="code-textarea" style="width:100%;height:100%;background:#1e1e1e;color:#d4d4d4;font-family:monospace;font-size:13px;border:none;padding:10px;resize:none;">${escapeHtml(STARTER_CODE)}</textarea>`;
|
||||
const ta = document.getElementById('code-textarea') as HTMLTextAreaElement;
|
||||
|
|
@ -234,15 +434,19 @@ function initSandbox(): void {
|
|||
const status = document.getElementById('wasm-status')!;
|
||||
status.textContent = `Loading ${file.name}…`;
|
||||
status.className = 'wasm-status';
|
||||
logToConsole('log', `Loading WASM file: ${file.name} (${(file.size / 1024).toFixed(1)} KB)`);
|
||||
|
||||
try {
|
||||
wasmStrategy = await loadWasmBot(file);
|
||||
status.textContent = `WASM bot loaded: ${file.name}`;
|
||||
status.className = 'wasm-status ok';
|
||||
logToConsole('log', `WASM bot interface validated: init() + compute_moves() found.`);
|
||||
(document.getElementById('run-btn') as HTMLButtonElement).textContent = 'Run Match (WASM)';
|
||||
} catch (err) {
|
||||
status.textContent = `Failed to load WASM: ${err}`;
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
status.textContent = `Failed: ${msg}`;
|
||||
status.className = 'wasm-status error';
|
||||
logToConsole('error', `WASM load failed: ${msg}`);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -262,8 +466,48 @@ function initSandbox(): void {
|
|||
const status = document.getElementById('wasm-status')!;
|
||||
status.className = 'wasm-status hidden';
|
||||
(document.getElementById('run-btn') as HTMLButtonElement).textContent = 'Run Match';
|
||||
logToConsole('log', 'Code reset to starter template.');
|
||||
});
|
||||
|
||||
// ── Console clear ───────────────────────────────────────────────────────
|
||||
document.getElementById('clear-console-btn')!.addEventListener('click', () => {
|
||||
consoleEntries.length = 0;
|
||||
consoleDiv.innerHTML = '';
|
||||
});
|
||||
|
||||
// ── Opponent management ─────────────────────────────────────────────────
|
||||
function getOpponentCount(): number {
|
||||
return opponentsList.querySelectorAll('.opponent-row').length;
|
||||
}
|
||||
|
||||
document.getElementById('add-opponent-btn')!.addEventListener('click', () => {
|
||||
const count = getOpponentCount();
|
||||
if (count >= MAX_OPPONENTS) return;
|
||||
const defaults = ['gatherer', 'rusher', 'swarm'];
|
||||
opponentsList.insertAdjacentHTML('beforeend', buildOpponentRow(count, defaults[count] || 'random'));
|
||||
updateAddButton();
|
||||
});
|
||||
|
||||
opponentsList.addEventListener('click', (e) => {
|
||||
const btn = (e.target as HTMLElement).closest('.remove-opponent-btn');
|
||||
if (!btn) return;
|
||||
if (getOpponentCount() <= 1) return;
|
||||
const row = btn.closest('.opponent-row')!;
|
||||
row.remove();
|
||||
opponentsList.querySelectorAll('.opponent-row').forEach((row, i) => {
|
||||
row.querySelector('.opponent-label')!.textContent = `Opponent ${i + 1}`;
|
||||
(row as HTMLElement).dataset.idx = String(i);
|
||||
});
|
||||
updateAddButton();
|
||||
});
|
||||
|
||||
function updateAddButton(): void {
|
||||
const btn = document.getElementById('add-opponent-btn') as HTMLButtonElement;
|
||||
btn.disabled = getOpponentCount() >= MAX_OPPONENTS;
|
||||
btn.style.opacity = getOpponentCount() >= MAX_OPPONENTS ? '0.5' : '1';
|
||||
}
|
||||
updateAddButton();
|
||||
|
||||
// ── Speed slider ────────────────────────────────────────────────────────
|
||||
document.getElementById('speed-slider')!.addEventListener('input', (e) => {
|
||||
const val = (e.target as HTMLInputElement).value;
|
||||
|
|
@ -276,11 +520,14 @@ function initSandbox(): void {
|
|||
const btn = document.getElementById('run-btn') as HTMLButtonElement;
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Running…';
|
||||
logToConsole('log', 'Starting match…');
|
||||
|
||||
try {
|
||||
const opponent = (document.getElementById('opponent-select') as HTMLSelectElement).value;
|
||||
const gridSize = Number((document.getElementById('grid-size-select') as HTMLSelectElement).value);
|
||||
const maxTurns = Number((document.getElementById('max-turns-select') as HTMLSelectElement).value);
|
||||
const seedStr = (document.getElementById('seed-input') as HTMLInputElement).value;
|
||||
const seed = seedStr ? Number(seedStr) : undefined;
|
||||
const enginePref = (document.getElementById('engine-select') as HTMLSelectElement).value;
|
||||
|
||||
const cfg: Config = {
|
||||
...defaultConfig(),
|
||||
|
|
@ -289,36 +536,110 @@ function initSandbox(): void {
|
|||
max_turns: maxTurns,
|
||||
};
|
||||
|
||||
// Build user strategy from code or WASM
|
||||
const userStrategy: BotStrategy = wasmStrategy ?? buildUserStrategy(currentCode);
|
||||
// Collect strategies: user + opponents
|
||||
const userStrategy: BotStrategy = wasmStrategy ?? buildUserStrategy(currentCode, (msg, turn) => logToConsole('error', msg, turn));
|
||||
const opponentSelects = opponentsList.querySelectorAll<HTMLSelectElement>('.opponent-select');
|
||||
const strategies: (BotStrategy | string)[] = [userStrategy];
|
||||
for (const sel of opponentSelects) {
|
||||
strategies.push(sel.value);
|
||||
}
|
||||
|
||||
const numPlayers = strategies.length;
|
||||
logToConsole('log', `Match config: ${gridSize}x${gridSize}, ${maxTurns} turns, ${numPlayers} players${seed !== undefined ? `, seed=${seed}` : ''}`);
|
||||
|
||||
// Determine which engine to use
|
||||
const useGoWasm = enginePref === 'wasm' || (enginePref === 'auto' && goEngine !== null);
|
||||
let replay: any;
|
||||
let result: any;
|
||||
let engineUsed: string;
|
||||
|
||||
const t0 = performance.now();
|
||||
const { replay, result } = runMatch(cfg, userStrategy, opponent);
|
||||
const elapsed = performance.now() - t0;
|
||||
|
||||
if (useGoWasm && goEngine) {
|
||||
// Use Go WASM engine with JS callback strategies
|
||||
logToConsole('log', `Using Go WASM engine v${goEngine.version}`);
|
||||
engineUsed = 'Go WASM';
|
||||
|
||||
goEngine.clearPlayers();
|
||||
for (let i = 0; i < strategies.length; i++) {
|
||||
const strategy = strategies[i];
|
||||
const name = i === 0
|
||||
? (wasmStrategy ? 'Uploaded Bot' : 'Your Bot')
|
||||
: typeof strategy === 'string' ? strategy : `Bot ${i}`;
|
||||
const fn = typeof strategy === 'string'
|
||||
? (stateJSON: string): string => {
|
||||
try {
|
||||
const state = JSON.parse(stateJSON) as VisibleState;
|
||||
const builtinFn = BUILTIN_STRATEGIES[strategy];
|
||||
if (!builtinFn) return '[]';
|
||||
const moves = builtinFn(state);
|
||||
return JSON.stringify(moves);
|
||||
} catch { return '[]'; }
|
||||
}
|
||||
: (stateJSON: string): string => {
|
||||
try {
|
||||
const state = JSON.parse(stateJSON) as VisibleState;
|
||||
const moves = strategy(state);
|
||||
return JSON.stringify(moves);
|
||||
} catch { return '[]'; }
|
||||
};
|
||||
goEngine.addPlayer(name, fn);
|
||||
}
|
||||
|
||||
const configJSON = JSON.stringify({
|
||||
config: { ...cfg, cores_per_player: 1 },
|
||||
seed: seed ?? 0,
|
||||
});
|
||||
|
||||
const matchResult = goEngine.runMatchMulti(configJSON);
|
||||
if (matchResult.error) {
|
||||
throw new Error(`Go WASM engine error: ${matchResult.error}`);
|
||||
}
|
||||
|
||||
replay = JSON.parse(matchResult.replay);
|
||||
result = JSON.parse(matchResult.result);
|
||||
} else {
|
||||
// Use TypeScript engine
|
||||
if (enginePref === 'wasm') {
|
||||
logToConsole('warn', 'Go WASM engine not available, falling back to TypeScript engine');
|
||||
}
|
||||
engineUsed = 'TypeScript';
|
||||
const matchOutput = runMultiMatch(cfg, strategies, seed);
|
||||
replay = matchOutput.replay;
|
||||
result = matchOutput.result;
|
||||
}
|
||||
|
||||
const elapsed = performance.now() - t0;
|
||||
lastReplay = replay;
|
||||
|
||||
logToConsole('log', `Match complete in ${elapsed.toFixed(1)}ms (${engineUsed}) — ${result.turns} turns, winner: ${result.winner >= 0 ? replay.players[result.winner].name : 'Draw'} (${result.reason})`);
|
||||
|
||||
// Show result
|
||||
const resultPanel = document.getElementById('result-panel')!;
|
||||
resultPanel.style.display = '';
|
||||
document.getElementById('match-result')!.innerHTML = formatResult(result, replay);
|
||||
document.getElementById('match-result')!.innerHTML = formatResult(result, replay, numPlayers);
|
||||
|
||||
// Performance panel
|
||||
const perfPanel = document.getElementById('performance-panel')!;
|
||||
perfPanel.style.display = '';
|
||||
document.getElementById('perf-stats')!.innerHTML = `
|
||||
<div class="perf-row"><span>Match duration (JS)</span><span>${elapsed.toFixed(1)} ms</span></div>
|
||||
<div class="perf-row"><span>Turns played</span><span>${result.turns}</span></div>
|
||||
<div class="perf-row"><span>Your bots alive</span><span>${result.bots_alive[0]}</span></div>
|
||||
<div class="perf-row"><span>Opponent bots alive</span><span>${result.bots_alive[1]}</span></div>
|
||||
`;
|
||||
const perfRows = [
|
||||
`<div class="perf-row"><span>Engine</span><span>${engineUsed}</span></div>`,
|
||||
`<div class="perf-row"><span>Match duration</span><span>${elapsed.toFixed(1)} ms</span></div>`,
|
||||
`<div class="perf-row"><span>Turns played</span><span>${result.turns}</span></div>`,
|
||||
];
|
||||
for (let i = 0; i < numPlayers; i++) {
|
||||
perfRows.push(`<div class="perf-row"><span>${replay.players[i].name}</span><span>${result.scores[i]} pts, ${result.bots_alive[i]} bots</span></div>`);
|
||||
}
|
||||
document.getElementById('perf-stats')!.innerHTML = perfRows.join('');
|
||||
|
||||
// Show replay
|
||||
document.getElementById('replay-section')!.style.display = '';
|
||||
initReplayViewer(replay as any);
|
||||
initReplayViewer(replay);
|
||||
|
||||
} catch (err) {
|
||||
alert('Error running match: ' + err);
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
logToConsole('error', `Match error: ${msg}`);
|
||||
console.error('Sandbox match error:', err);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = wasmStrategy ? 'Run Match (WASM)' : 'Run Match';
|
||||
|
|
@ -337,13 +658,21 @@ function initSandbox(): void {
|
|||
URL.revokeObjectURL(url);
|
||||
});
|
||||
|
||||
// ── Replay viewer setup ─────────────────────────────────────────────────
|
||||
function initReplayViewer(replay: Replay): void {
|
||||
const canvas = document.getElementById('sandbox-canvas') as HTMLCanvasElement;
|
||||
const speed = Number((document.getElementById('speed-slider') as HTMLInputElement).value);
|
||||
|
||||
if (viewer) viewer.destroy();
|
||||
viewer = new ReplayViewer(canvas, { cellSize: 12 });
|
||||
viewer.loadReplay(replay);
|
||||
viewer.loadReplay(replay as any);
|
||||
viewer.setSpeed(speed);
|
||||
|
||||
const fogPlayerSelect = document.getElementById('fog-player-select') as HTMLSelectElement;
|
||||
fogPlayerSelect.innerHTML = replay.players.map((p, i) =>
|
||||
`<option value="${i}">${i} (${p.name})</option>`
|
||||
).join('');
|
||||
|
||||
const turnDisplay = document.getElementById('sb-turn-display')!;
|
||||
const totalTurns = document.getElementById('sb-total-turns')!;
|
||||
const slider = document.getElementById('sb-turn-slider') as HTMLInputElement;
|
||||
|
|
@ -358,79 +687,108 @@ function initSandbox(): void {
|
|||
const events = viewer!.getTurnEvents();
|
||||
eventsDiv.innerHTML = events.length === 0
|
||||
? '<div class="no-events">No events</div>'
|
||||
: events.map(ev => `<div class="event"><span style="color:#fbbf24">${ev.type.replace(/_/g,' ')}</span></div>`).join('');
|
||||
: events.map(ev => `<div class="event"><span style="color:#fbbf24">${ev.type.replace(/_/g, ' ')}</span></div>`).join('');
|
||||
};
|
||||
|
||||
document.getElementById('sb-play-btn')!.addEventListener('click', () => viewer!.togglePlay());
|
||||
document.getElementById('sb-prev-btn')!.addEventListener('click', () => { viewer!.setTurn(viewer!.getTurn() - 1); });
|
||||
document.getElementById('sb-next-btn')!.addEventListener('click', () => { viewer!.setTurn(viewer!.getTurn() + 1); });
|
||||
document.getElementById('sb-reset-btn')!.addEventListener('click', () => { viewer!.pause(); viewer!.setTurn(0); });
|
||||
document.getElementById('sb-play-btn')!.onclick = () => viewer!.togglePlay();
|
||||
document.getElementById('sb-prev-btn')!.onclick = () => { viewer!.setTurn(viewer!.getTurn() - 1); };
|
||||
document.getElementById('sb-next-btn')!.onclick = () => { viewer!.setTurn(viewer!.getTurn() + 1); };
|
||||
document.getElementById('sb-reset-btn')!.onclick = () => { viewer!.pause(); viewer!.setTurn(0); };
|
||||
slider.oninput = () => viewer!.setTurn(parseInt(slider.value, 10));
|
||||
|
||||
slider.addEventListener('input', () => viewer!.setTurn(parseInt(slider.value, 10)));
|
||||
(document.getElementById('fog-toggle') as HTMLInputElement).onchange = (e) => {
|
||||
const checked = (e.target as HTMLInputElement).checked;
|
||||
if (checked) {
|
||||
const pid = parseInt(fogPlayerSelect.value);
|
||||
viewer!.setFogOfWar(pid);
|
||||
} else {
|
||||
viewer!.setFogOfWar(null);
|
||||
}
|
||||
};
|
||||
fogPlayerSelect.onchange = () => {
|
||||
if ((document.getElementById('fog-toggle') as HTMLInputElement).checked) {
|
||||
viewer!.setFogOfWar(parseInt(fogPlayerSelect.value));
|
||||
}
|
||||
};
|
||||
|
||||
(document.getElementById('view-mode-select') as HTMLSelectElement).onchange = (e) => {
|
||||
viewer!.setViewMode((e.target as HTMLSelectElement).value as ViewMode);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// User strategy builder (sandboxed eval)
|
||||
// User strategy builder (sandboxed eval with error reporting)
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function buildUserStrategy(code: string): BotStrategy {
|
||||
// Wrap the user's computeMoves function; catch errors gracefully
|
||||
function buildUserStrategy(code: string, onError: (msg: string, turn: number) => void): BotStrategy {
|
||||
let turnCounter = 0;
|
||||
return (state: VisibleState): Move[] => {
|
||||
turnCounter++;
|
||||
try {
|
||||
// Create a sandboxed function using the user code
|
||||
const fn = new Function('state', `
|
||||
${code}
|
||||
if (typeof computeMoves !== 'function') {
|
||||
throw new Error('computeMoves function not found');
|
||||
throw new Error('computeMoves function not found — define a computeMoves(state) function');
|
||||
}
|
||||
return computeMoves(state);
|
||||
`);
|
||||
const result = fn(state);
|
||||
if (!Array.isArray(result)) return [];
|
||||
if (!Array.isArray(result)) {
|
||||
onError(`computeMoves returned ${typeof result} instead of array`, turnCounter);
|
||||
return [];
|
||||
}
|
||||
return result as Move[];
|
||||
} catch (err) {
|
||||
console.warn('User strategy error:', err);
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
onError(`Turn ${turnCounter}: ${msg}`, turnCounter);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// WASM bot loader
|
||||
// WASM bot loader (supports both standard WASM and Go WASM)
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function loadWasmBot(file: File): Promise<BotStrategy> {
|
||||
const buffer = await file.arrayBuffer();
|
||||
|
||||
// Try to instantiate the WASM module
|
||||
let acbBotExport: { init: (c: string) => void; compute_moves: (s: string) => string } | null = null;
|
||||
|
||||
try {
|
||||
// Standard WASM (non-Go)
|
||||
// Standard WASM (Rust, C, AssemblyScript) — exports compute_moves directly
|
||||
const { instance } = await WebAssembly.instantiate(buffer, {
|
||||
env: { memory: new WebAssembly.Memory({ initial: 256 }) },
|
||||
});
|
||||
acbBotExport = {
|
||||
init: (instance.exports.init as (c: string) => void) ?? (() => {}),
|
||||
compute_moves: instance.exports.compute_moves as (s: string) => string,
|
||||
};
|
||||
} catch {
|
||||
// Likely a Go WASM – requires wasm_exec.js runtime
|
||||
// Check if Go runtime is available
|
||||
const computeFn = instance.exports.compute_moves as ((ptr: number, len: number) => number) | undefined;
|
||||
|
||||
if (computeFn) {
|
||||
const memory = instance.exports.memory as WebAssembly.Memory | undefined;
|
||||
if (memory) {
|
||||
acbBotExport = createPointerBasedBridge(instance, memory);
|
||||
}
|
||||
}
|
||||
|
||||
if (!acbBotExport) {
|
||||
throw new Error('WASM module does not export acbBot.compute_moves or compatible functions');
|
||||
}
|
||||
} catch (primaryErr) {
|
||||
// Likely a Go WASM — requires wasm_exec.js runtime
|
||||
if (typeof (globalThis as any).Go !== 'undefined') {
|
||||
const go = new (globalThis as any).Go();
|
||||
const { instance } = await WebAssembly.instantiate(buffer, go.importObject);
|
||||
go.run(instance);
|
||||
// After go.run, acbBot global should be set
|
||||
acbBotExport = (globalThis as any).acbBot;
|
||||
if (!acbBotExport) {
|
||||
throw new Error('Go WASM ran but did not set global acbBot — check your bot builds with cmd/acb-wasm/botmain template');
|
||||
}
|
||||
} else {
|
||||
throw new Error('Go WASM runtime not loaded. Add <script src="/wasm/wasm_exec.js"> to the page.');
|
||||
throw new Error('Not a standard WASM bot and Go WASM runtime (wasm_exec.js) not loaded.');
|
||||
}
|
||||
}
|
||||
|
||||
if (!acbBotExport?.compute_moves) {
|
||||
throw new Error('WASM module does not export acbBot.compute_moves');
|
||||
throw new Error('WASM module does not export a valid acbBot.compute_moves function');
|
||||
}
|
||||
|
||||
return (state: VisibleState): Move[] => {
|
||||
|
|
@ -443,15 +801,53 @@ async function loadWasmBot(file: File): Promise<BotStrategy> {
|
|||
};
|
||||
}
|
||||
|
||||
function createPointerBasedBridge(
|
||||
instance: WebAssembly.Instance,
|
||||
memory: WebAssembly.Memory,
|
||||
): { init: (c: string) => void; compute_moves: (s: string) => string } {
|
||||
const encoder = new TextEncoder();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
function writeString(str: string): [number, number] {
|
||||
const bytes = encoder.encode(str);
|
||||
const ptr = (instance.exports.allocate as (len: number) => number)?.(bytes.length)
|
||||
?? (instance.exports.malloc as (len: number) => number)?.(bytes.length);
|
||||
if (!ptr) throw new Error('WASM does not export allocate or malloc');
|
||||
const buf = new Uint8Array(memory.buffer);
|
||||
buf.set(bytes, ptr);
|
||||
return [ptr, bytes.length];
|
||||
}
|
||||
|
||||
function readString(ptr: number): string {
|
||||
const buf = new Uint8Array(memory.buffer);
|
||||
let end = ptr;
|
||||
while (end < buf.length && buf[end] !== 0) end++;
|
||||
return decoder.decode(buf.slice(ptr, end));
|
||||
}
|
||||
|
||||
return {
|
||||
init: (configJSON: string) => {
|
||||
const [ptr, len] = writeString(configJSON);
|
||||
(instance.exports.init as (p: number, l: number) => void)(ptr, len);
|
||||
},
|
||||
compute_moves: (stateJSON: string): string => {
|
||||
const [ptr, len] = writeString(stateJSON);
|
||||
const resultPtr = (instance.exports.compute_moves as (p: number, l: number) => number)(ptr, len);
|
||||
const result = readString(resultPtr);
|
||||
(instance.exports.free_result as (p: number) => void)?.(resultPtr);
|
||||
return result;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Monaco loader (CDN)
|
||||
// Monaco loader (CDN, ~4MB lazy-loaded)
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function loadMonaco(): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if ((globalThis as any).monaco) { resolve((globalThis as any).monaco); return; }
|
||||
|
||||
// Load AMD loader then monaco
|
||||
const loaderScript = document.createElement('script');
|
||||
loaderScript.src = 'https://cdn.jsdelivr.net/npm/monaco-editor@0.45.0/min/vs/loader.js';
|
||||
loaderScript.onload = () => {
|
||||
|
|
@ -471,27 +867,27 @@ function loadMonaco(): Promise<any> {
|
|||
// Helpers
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function formatResult(result: any, replay: any): string {
|
||||
const p0Name = replay.players[0]?.name ?? 'Your Bot';
|
||||
const p1Name = replay.players[1]?.name ?? 'Opponent';
|
||||
const winnerName = result.winner === 0 ? p0Name : result.winner === 1 ? p1Name : 'Draw';
|
||||
const winnerClass = result.winner === 0 ? 'win' : result.winner === 1 ? 'loss' : 'draw';
|
||||
function formatResult(result: any, replay: any, _numPlayers: number): string {
|
||||
const winnerName = result.winner >= 0 ? replay.players[result.winner].name : 'Draw';
|
||||
const winnerClass = result.winner === 0 ? 'win' : result.winner >= 0 ? 'loss' : 'draw';
|
||||
const scoreRows = replay.players.map((p: any, i: number) =>
|
||||
`<div class="result-player"><span class="player-dot" style="background:${PLAYER_COLORS[i % PLAYER_COLORS.length]}"></span>${p.name}: ${result.scores[i]} pts, ${result.bots_alive[i]} bots alive</div>`
|
||||
).join('');
|
||||
return `
|
||||
<div class="result-banner ${winnerClass}">
|
||||
<strong>${result.winner >= 0 ? winnerName + ' wins!' : 'Draw'}</strong>
|
||||
<span>${result.reason}</span>
|
||||
</div>
|
||||
<div class="result-stats">
|
||||
<div>${p0Name}: ${result.scores[0]} pts, ${result.bots_alive[0]} bots alive</div>
|
||||
<div>${p1Name}: ${result.scores[1]} pts, ${result.bots_alive[1]} bots alive</div>
|
||||
</div>
|
||||
<div class="result-stats">${scoreRows}</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
const PLAYER_COLORS = ['#3b82f6', '#ef4444', '#22c55e', '#f59e0b'];
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Styles
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
|
@ -501,30 +897,45 @@ const SANDBOX_STYLES = `
|
|||
.sandbox-intro { color: var(--text-muted); margin-bottom: 24px; }
|
||||
.sandbox-layout { display: flex; gap: 20px; }
|
||||
.sandbox-editor-col { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 16px; }
|
||||
.sandbox-controls-col { width: 300px; flex-shrink: 0; display: flex; flex-direction: column; gap: 16px; }
|
||||
.sandbox-controls-col { width: 320px; flex-shrink: 0; display: flex; flex-direction: column; gap: 16px; }
|
||||
.sandbox-panel { background: var(--bg-secondary); border-radius: 8px; padding: 16px; }
|
||||
.panel-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; font-weight: 600; color: var(--text-primary); }
|
||||
.panel-actions { display: flex; gap: 8px; }
|
||||
.settings-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px 12px; align-items: center; font-size: 0.875rem; color: var(--text-muted); margin-bottom: 16px; }
|
||||
.settings-grid select, .settings-grid input { background: var(--bg-primary); border: 1px solid var(--border); color: var(--text-primary); padding: 6px; border-radius: 4px; font-size: 0.875rem; }
|
||||
.speed-slider { width: 100%; }
|
||||
.speed-label { color: var(--text-muted); font-size: 0.75rem; }
|
||||
.engine-status { font-size: 0.75rem; color: var(--text-muted); padding: 2px 0; }
|
||||
.seed-input { width: 100%; }
|
||||
.speed-row { display: flex; align-items: center; gap: 8px; }
|
||||
.speed-slider { flex: 1; }
|
||||
.speed-label { color: var(--text-muted); font-size: 0.75rem; min-width: 40px; }
|
||||
.opponents-section { margin-bottom: 16px; }
|
||||
.opponent-row { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
|
||||
.opponent-label { font-size: 0.8rem; color: var(--text-muted); min-width: 80px; }
|
||||
.opponent-select { flex: 1; background: var(--bg-primary); border: 1px solid var(--border); color: var(--text-primary); padding: 4px 8px; border-radius: 4px; font-size: 0.8rem; }
|
||||
.run-btn { width: 100%; padding: 12px; font-size: 1rem; }
|
||||
.wasm-status { font-size: 0.8rem; padding: 8px; border-radius: 4px; margin-top: 8px; }
|
||||
.wasm-status.hidden { display: none; }
|
||||
.wasm-status.ok { background: rgba(34,197,94,0.15); color: var(--success); }
|
||||
.wasm-status.error { background: rgba(239,68,68,0.15); color: var(--error); }
|
||||
.code-block { background: var(--bg-primary); padding: 12px; border-radius: 6px; font-size: 0.75rem; font-family: monospace; white-space: pre; overflow-x: auto; color: var(--text-muted); }
|
||||
.code-block { background: var(--bg-primary); padding: 12px; border-radius: 6px; font-size: 0.75rem; font-family: monospace; white-space: pre; overflow-x: auto; color: var(--text-muted); max-height: 300px; overflow-y: auto; }
|
||||
.code-block.hidden { display: none; }
|
||||
.console-output { background: #0d1117; border-radius: 6px; padding: 8px 12px; font-family: 'Menlo', 'Monaco', 'Courier New', monospace; font-size: 0.75rem; line-height: 1.6; max-height: 160px; overflow-y: auto; min-height: 60px; }
|
||||
.console-line { padding: 1px 0; word-break: break-all; }
|
||||
.match-result .result-banner { padding: 12px 16px; border-radius: 6px; margin-bottom: 10px; display: flex; justify-content: space-between; align-items: center; }
|
||||
.result-banner.win { background: rgba(34,197,94,0.15); color: var(--success); }
|
||||
.result-banner.loss { background: rgba(239,68,68,0.15); color: var(--error); }
|
||||
.result-banner.draw { background: rgba(245,158,11,0.15); color: var(--warning); }
|
||||
.result-stats { font-size: 0.875rem; color: var(--text-muted); display: flex; flex-direction: column; gap: 4px; }
|
||||
.result-player { display: flex; align-items: center; gap: 8px; }
|
||||
.player-dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; }
|
||||
.perf-stats .perf-row { display: flex; justify-content: space-between; font-size: 0.875rem; color: var(--text-muted); padding: 4px 0; border-bottom: 1px solid var(--bg-tertiary); }
|
||||
.replay-header { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 12px; }
|
||||
.section-title { font-size: 1.25rem; color: var(--text-primary); margin: 24px 0 16px; }
|
||||
.replay-section { margin-top: 8px; }
|
||||
.replay-layout-sandbox { display: flex; gap: 20px; }
|
||||
.replay-controls-top { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-top: 16px; }
|
||||
.fog-toggle { display: flex; align-items: center; gap: 6px; font-size: 0.8rem; color: var(--text-muted); cursor: pointer; }
|
||||
.fog-toggle select { background: var(--bg-primary); border: 1px solid var(--border); color: var(--text-primary); padding: 2px 4px; border-radius: 3px; font-size: 0.75rem; }
|
||||
.replay-layout-sandbox { display: flex; gap: 20px; margin-top: 16px; }
|
||||
.canvas-wrapper { background: var(--bg-secondary); border-radius: 8px; padding: 10px; overflow: auto; flex: 1; }
|
||||
.sandbox-replay-controls { width: 260px; flex-shrink: 0; background: var(--bg-secondary); border-radius: 8px; padding: 16px; display: flex; flex-direction: column; gap: 12px; }
|
||||
.playback-controls { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
|
|
@ -538,6 +949,7 @@ const SANDBOX_STYLES = `
|
|||
.sandbox-controls-col { width: 100%; }
|
||||
.replay-layout-sandbox { flex-direction: column; }
|
||||
.sandbox-replay-controls { width: 100%; }
|
||||
.replay-header { flex-direction: column; align-items: flex-start; }
|
||||
}
|
||||
</style>
|
||||
`;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue