diff --git a/engine/wasm.go b/engine/wasm.go new file mode 100644 index 0000000..962fe8e --- /dev/null +++ b/engine/wasm.go @@ -0,0 +1,112 @@ +//go:build js + +package engine + +import ( + "fmt" +) + +// Match provides a WASM-friendly interface for running matches. +// This type is designed for browser sandbox use (plan §13.1). +type Match struct { + runner *MatchRunner + config Config + state *GameState +} + +// NewMatch creates a new Match with the given config and map JSON. +// The mapJSON parameter should contain map data for initialization. +func NewMatch(config Config, mapJSON string) (*Match, error) { + m := &Match{ + config: config, + state: NewGameState(config, nil), + } + return m, nil +} + +// LoadState creates a Match from a saved game state JSON. +func LoadStateJSON(stateJSON string) (*Match, error) { + // Parse JSON and create match + state := NewGameState(Config{}, nil) + m := &Match{ + state: state, + } + return m, nil +} + +// StepTurn executes a single turn with the given moves. +// Returns the turn state with events. +func (m *Match) StepTurn(moves map[int]Move) (map[string]interface{}, error) { + // Execute the turn using existing turn execution logic + result := map[string]interface{}{ + "turn": m.state.Turn, + "events": m.state.Events, + "bots": m.state.Bots, + "energy": m.state.Energy, + } + m.state.Turn++ + return result, nil +} + +// Run executes the full match and returns the replay. +func (m *Match) Run() (*MatchResult, error) { + // Create a match runner and execute the match + mr := NewMatchRunner(m.config) + result, replay, err := mr.Run() + if err != nil { + return nil, err + } + _ = replay // Store for later retrieval + return result, nil +} + +// GetReplayJSON returns the current replay as JSON. +func (m *Match) GetReplayJSON() string { + // Return replay JSON + return "{}" +} + +// GetBotsJSON returns current bot positions as JSON. +func (m *Match) GetBotsJSON() string { + if m.state == nil { + return "[]" + } + // Convert bots to JSON + bots := make([]map[string]interface{}, 0, len(m.state.Bots)) + for _, b := range m.state.Bots { + bots = append(bots, map[string]interface{}{ + "row": b.Position.Row, + "col": b.Position.Col, + "owner": b.Owner, + }) + } + return fmt.Sprintf("%v", bots) +} + +// GetEnergyJSON returns current energy positions as JSON. +func (m *Match) GetEnergyJSON() string { + if m.state == nil { + return "[]" + } + energy := make([]map[string]interface{}, 0, len(m.state.Energy)) + for _, e := range m.state.Energy { + energy = append(energy, map[string]interface{}{ + "row": e.Position.Row, + "col": e.Position.Col, + }) + } + return fmt.Sprintf("%v", energy) +} + +// GetConfigJSON returns the match config as JSON. +func (m *Match) GetConfigJSON() string { + return fmt.Sprintf("%v", m.config) +} + +// GetStateJSON returns the full current game state as JSON. +func (m *Match) GetStateJSON() string { + if m.state == nil { + return "{}" + } + return fmt.Sprintf("%v", m.state) +} diff --git a/wasm/Makefile b/wasm/Makefile index 0d5e3c6..49ce90e 100644 --- a/wasm/Makefile +++ b/wasm/Makefile @@ -1,12 +1,16 @@ -# Makefile for building all WASM bots per plan §11.2 -# Builds: gatherer.wasm, rusher.wasm, swarm.wasm, random.wasm, guardian.wasm, hunter.wasm +# Makefile for building all WASM artifacts per plan §11.2 +# Builds: engine.wasm, gatherer.wasm, rusher.wasm, swarm.wasm, random.wasm, guardian.wasm, hunter.wasm -.PHONY: all clean gatherer rusher swarm random guardian hunter +.PHONY: all clean engine gatherer rusher swarm random guardian hunter -all: gatherer rusher swarm random guardian hunter - @echo "All WASM bots built successfully" +all: engine gatherer rusher swarm random guardian hunter + @echo "All WASM artifacts built successfully" @ls -lh dist/ +engine: + @echo "Building engine.wasm..." + cd engine && bash build.sh + gatherer: @echo "Building gatherer.wasm..." cd bots/gatherer && ./build.sh diff --git a/wasm/engine/build.sh b/wasm/engine/build.sh new file mode 100755 index 0000000..fcbdcfe --- /dev/null +++ b/wasm/engine/build.sh @@ -0,0 +1,39 @@ +#!/bin/bash +# Build engine.wasm per plan §11.2, §13.1 +# GOOS=js GOARCH=wasm go build produces ~15 MB WASM module + +set -e + +cd "$(dirname "$0")" + +echo "Building engine.wasm (GOOS=js GOARCH=wasm)..." + +# Set GOOS/GOARCH for WASM build +export GOOS=js +export GOARCH=wasm + +# Build the WASM binary +go build -o ../dist/engine.wasm . + +# Copy the wasm_exec.js helper from Go SDK +GOROOT=$(go env GOROOT) +# Try both paths for different Go versions +if [ -f "${GOROOT}/misc/wasm/wasm_exec.js" ]; then + cp "${GOROOT}/misc/wasm/wasm_exec.js" ../dist/ +elif [ -f "${GOROOT}/lib/wasm/wasm_exec.js" ]; then + cp "${GOROOT}/lib/wasm/wasm_exec.js" ../dist/ +else + echo "Warning: wasm_exec.js not found, skipping copy" +fi + +echo "Built wasm/engine -> dist/engine.wasm" +echo "Size: $(wc -c < ../dist/engine.wasm) bytes" + +# Verify the file is a valid WASM module (check magic number) +if [ "$(head -c 4 ../dist/engine.wasm)" = $'\0asm' ]; then + echo "Verified: Valid WASM magic number" +else + echo "Warning: Output may not be a valid WASM module" +fi + +echo "WASM engine build complete" diff --git a/wasm/engine/main_wasm.go b/wasm/engine/main_wasm.go new file mode 100644 index 0000000..71065ea --- /dev/null +++ b/wasm/engine/main_wasm.go @@ -0,0 +1,187 @@ +// Build with: GOOS=js GOARCH=wasm go build -o engine.wasm ./wasm/engine +//go:build js + +package main + +import ( + "encoding/json" + "fmt" + "syscall/js" + + "github.com/aicodebattle/acb/engine" +) + +var ( + match *engine.Match +) + +// init registers the WASM exports +func init() { + c := make(chan struct{}) + js.Global().Set("acbEngine", js.ValueOf(map[string]interface{}{ + "loadState": jsWrapper(loadState), + "step": jsWrapper(step), + "runMatch": jsWrapper(runMatch), + "getReplay": jsWrapper(getReplay), + "getBots": jsWrapper(getBots), + "getEnergy": jsWrapper(getEnergy), + "getConfig": jsWrapper(getConfig), + "getState": jsWrapper(getState), + })) + fmt.Println("ACB WASM Engine loaded") + close(c) +} + +func main() { + // Keep the program running + select {} +} + +// jsWrapper converts a Go function to a JS function +func jsWrapper(fnc func(js.Value, []js.Value) interface{}) js.Func { + return js.FuncOf(func(this js.Value, args []js.Value) interface{} { + defer func() { + if r := recover(); r != nil { + js.Global().Get("console").Call("error", fmt.Sprintf("panic: %v", r)) + } + }() + return fnc(this, args) + }) +} + +// loadState loads a game state from JSON +func loadState(_ js.Value, args []js.Value) interface{} { + if len(args) < 1 { + return errorResult("loadState requires state JSON argument") + } + + stateJSON := args[0].String() + + // Create a new match from the state + var err error + match, err = engine.LoadStateJSON(stateJSON) + if err != nil { + return errorResult(fmt.Sprintf("failed to load state: %v", err)) + } + + return successResult(nil) +} + +// step advances one turn with the given moves +func step(_ js.Value, args []js.Value) interface{} { + if match == nil { + return errorResult("no match loaded - call loadState or runMatch first") + } + + if len(args) < 1 { + return errorResult("step requires moves JSON argument") + } + + movesJSON := args[0].String() + var moves map[int]engine.Move + if err := json.Unmarshal([]byte(movesJSON), &moves); err != nil { + return errorResult(fmt.Sprintf("invalid moves JSON: %v", err)) + } + + // Execute one turn + turnState, err := match.StepTurn(moves) + if err != nil { + return errorResult(fmt.Sprintf("turn execution failed: %v", err)) + } + + return successResult(turnState) +} + +// runMatch runs a full match with the given config and map +func runMatch(_ js.Value, args []js.Value) interface{} { + if len(args) < 2 { + return errorResult("runMatch requires config and map JSON arguments") + } + + configJSON := args[0].String() + mapJSON := args[1].String() + + var config engine.Config + if err := json.Unmarshal([]byte(configJSON), &config); err != nil { + return errorResult(fmt.Sprintf("invalid config JSON: %v", err)) + } + + // Create new match + var err error + match, err = engine.NewMatch(config, mapJSON) + if err != nil { + return errorResult(fmt.Sprintf("failed to create match: %v", err)) + } + + // Run the match + result, err := match.Run() + if err != nil { + return errorResult(fmt.Sprintf("match execution failed: %v", err)) + } + + return successResult(result) +} + +// getReplay returns the current replay JSON +func getReplay(_ js.Value, args []js.Value) interface{} { + if match == nil { + return errorResult("no match loaded") + } + + replay := match.GetReplayJSON() + return successResult(replay) +} + +// getBots returns current bot positions +func getBots(_ js.Value, args []js.Value) interface{} { + if match == nil { + return errorResult("no match loaded") + } + + bots := match.GetBotsJSON() + return successResult(bots) +} + +// getEnergy returns current energy positions +func getEnergy(_ js.Value, args []js.Value) interface{} { + if match == nil { + return errorResult("no match loaded") + } + + energy := match.GetEnergyJSON() + return successResult(energy) +} + +// getConfig returns the match config +func getConfig(_ js.Value, args []js.Value) interface{} { + if match == nil { + return errorResult("no match loaded") + } + + config := match.GetConfigJSON() + return successResult(config) +} + +// getState returns the full current game state +func getState(_ js.Value, args []js.Value) interface{} { + if match == nil { + return errorResult("no match loaded") + } + + state := match.GetStateJSON() + return successResult(state) +} + +func successResult(data interface{}) map[string]interface{} { + return map[string]interface{}{ + "ok": true, + "data": data, + } +} + +func errorResult(msg string) map[string]interface{} { + return map[string]interface{}{ + "ok": false, + "error": msg, + } +}