ai-code-battle/cmd/acb-wasm/main.go
jedarden 51edf35d22 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>
2026-04-21 15:28:35 -04:00

326 lines
8.3 KiB
Go
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.

//go:build js && wasm
// Package main is compiled with GOOS=js GOARCH=wasm to produce engine.wasm.
// 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 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 (
"encoding/json"
"math/rand"
"syscall/js"
"time"
"github.com/aicodebattle/acb/engine"
)
// matchSession holds a running match for turn-by-turn access.
type matchSession struct {
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")
}
type initRequest struct {
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 {
return jsErr("parse error: " + 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))
gs := engine.NewGameState(cfg, rng)
bot1 := newBuiltinBot(req.Bot1, rng)
bot2 := newBuiltinBot(req.Bot2, rng)
mr := engine.NewMatchRunner(cfg, engine.WithRNG(rand.New(rand.NewSource(seed))))
mr.AddBot(bot1, req.Bot1)
mr.AddBot(bot2, req.Bot2)
_ = gs
session = &matchSession{
gs: gs,
bots: []engine.BotInterface{bot1, bot2},
}
return map[string]interface{}{"ok": true}
}
// jsStep advances one turn.
// Signature: acbEngine.step(movesJSON: string) => {state, events, result?}
func jsStep(_ js.Value, args []js.Value) interface{} {
if session == nil {
return jsErr("no active session; call loadState first")
}
gs := session.gs
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 {
for _, b := range gs.Bots {
if b.Alive && b.Position == m.Position {
gs.Moves[b.ID] = m
}
}
}
}
result := gs.ExecuteTurn()
stateJSON, _ := json.Marshal(gs)
eventsJSON, _ := json.Marshal(gs.Events)
out := map[string]interface{}{
"state": string(stateJSON),
"events": string(eventsJSON),
"turn": gs.Turn,
}
if result != nil {
resultJSON, _ := json.Marshal(result)
out["result"] = string(resultJSON)
}
return out
}
// 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 {
Config engine.Config `json:"config"`
Bot1 string `json:"bot1"`
Bot2 string `json:"bot2"`
Seed int64 `json:"seed"`
}
var req runRequest
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))
bot1Name := req.Bot1
if bot1Name == "" {
bot1Name = "random"
}
bot2Name := req.Bot2
if bot2Name == "" {
bot2Name = "random"
}
mr := engine.NewMatchRunner(cfg,
engine.WithRNG(rng),
engine.WithTimeout(500*time.Millisecond),
)
mr.AddBot(newBuiltinBot(bot1Name, rand.New(rand.NewSource(seed))), bot1Name)
mr.AddBot(newBuiltinBot(bot2Name, rand.New(rand.NewSource(seed+1))), bot2Name)
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),
}
}
// 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}
}
func main() {
done := make(chan struct{})
js.Global().Set("acbEngine", js.ValueOf(map[string]interface{}{
"loadState": js.FuncOf(func(this js.Value, args []js.Value) interface{} {
return jsLoadState(this, args)
}),
"step": js.FuncOf(func(this js.Value, args []js.Value) interface{} {
return jsStep(this, args)
}),
"runMatch": js.FuncOf(func(this js.Value, args []js.Value) interface{} {
return jsRunMatch(this, args)
}),
"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
}