diff --git a/.github/workflows/godot-build.yml b/.github/workflows/godot-build.yml new file mode 100644 index 0000000..dd031f2 --- /dev/null +++ b/.github/workflows/godot-build.yml @@ -0,0 +1,54 @@ +name: Godot Web build + +on: + push: + branches: + - main + pull_request: + +jobs: + build: + permissions: + contents: read + name: Build Godot Web export + runs-on: ubuntu-latest + + steps: + - name: Check out repository + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 + + - name: Set up Godot + uses: chickensoft-games/setup-godot@f166999204a4f2722c6fe042fbaa3b3ea0d9c789 + with: + version: 4.7.0 + use-dotnet: false + include-templates: true + cache: true + + - name: Export Godot Web build + run: make godot-export + + - name: Upload GitHub Pages artifact + uses: actions/upload-pages-artifact@7b1f4a764d45c48632c6b24a0339c27f5614fb0b + with: + path: dist + + deploy: + name: Deploy to GitHub Pages + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + needs: build + runs-on: ubuntu-latest + concurrency: + group: github-pages + cancel-in-progress: false + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + + steps: + - name: Deploy GitHub Pages site + id: deployment + uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e diff --git a/Makefile b/Makefile index 18fc732..81a9a42 100644 --- a/Makefile +++ b/Makefile @@ -18,4 +18,5 @@ godot-export: mkdir -p dist; \ DIST_PATH="$$(pwd)/dist/index.html"; \ echo "Exporting Godot Web build with $$GODOT_BIN to $$DIST_PATH"; \ - "$$GODOT_BIN" --headless --path godot --export-release Web "$$DIST_PATH" + "$$GODOT_BIN" --headless --path godot --export-release Web "$$DIST_PATH"; \ + cp godot/web/stellar_bridge.js "$$(dirname "$$DIST_PATH")/stellar_bridge.js" diff --git a/README.md b/README.md index 0f51e9a..ff4aab4 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # Human-vs-bots +[![Godot Web build](https://github.com/Bitcoindefi/Human-vs-bots/actions/workflows/godot-build.yml/badge.svg)](https://github.com/Bitcoindefi/Human-vs-bots/actions/workflows/godot-build.yml) + Turn-based strategy game with a Web3-ready flow on Stellar. ## What this project is @@ -95,6 +97,23 @@ The command detects `godot4` or `godot`, runs the `Web` export preset in headles mode, and writes the generated entry point to `dist/index.html`. The generated `dist/` directory is intentionally not tracked. +Godot Web exports expose a defensive `WebBridge` autoload for asynchronous +wallet, Stellar commit/reveal, and ZK proof operations. The browser function, +callback, and payload contract is documented in +[`godot/web/README.md`](godot/web/README.md). + +### Godot Web CI/CD + +The [Godot Web build workflow](.github/workflows/godot-build.yml) runs for every +pull request and every push to `main`. Its `build` job installs and caches Godot +4.7.0 plus the matching Web export templates, calls `make godot-export`, and +uploads `dist/` as the GitHub Pages artifact. + +For pushes to `main`, the `deploy` job publishes that artifact to the +`github-pages` environment with `actions/deploy-pages`. Pull requests build and +upload the artifact for validation but never deploy it. The repository's Pages +source must be set to **GitHub Actions** under **Settings → Pages**. + ## Third-party references and licenses - Unciv assets inspiration (visual assets used in demo pipeline) diff --git a/godot/autoloads/WebBridge.gd b/godot/autoloads/WebBridge.gd new file mode 100644 index 0000000..5826805 --- /dev/null +++ b/godot/autoloads/WebBridge.gd @@ -0,0 +1,168 @@ +extends Node + +signal wallet_connected(address: String) +signal wallet_connection_failed(error: String) +signal address_received(address: String) +signal commit_submitted(result) +signal reveal_submitted(result) +signal proof_generated(result) +signal proof_exported(result) +signal bridge_error(action: String, error: String) + +const CALLBACK_NAMESPACE := "HumanVsBotsGodot" +const BRIDGE_INTERFACE := "HumanVsBotsBridge" + +var _bridge = null +var _callback_interface = null +var _callback_references: Array = [] + + +func _ready() -> void: + if not OS.has_feature("web"): + return + + JavaScriptBridge.eval( + "globalThis.%s = globalThis.%s || {};" % [CALLBACK_NAMESPACE, CALLBACK_NAMESPACE] + ) + _callback_interface = JavaScriptBridge.get_interface(CALLBACK_NAMESPACE) + _bridge = JavaScriptBridge.get_interface(BRIDGE_INTERFACE) + + if _callback_interface == null: + return + + _register_callback("walletConnected", _on_wallet_connected) + _register_callback("walletConnectionFailed", _on_wallet_connection_failed) + _register_callback("addressReceived", _on_address_received) + _register_callback("commitSubmitted", _on_commit_submitted) + _register_callback("revealSubmitted", _on_reveal_submitted) + _register_callback("proofGenerated", _on_proof_generated) + _register_callback("proofExported", _on_proof_exported) + _register_callback("bridgeError", _on_bridge_error) + + +func is_available() -> bool: + return OS.has_feature("web") and _bridge != null and _callback_interface != null + + +func connect_wallet() -> void: + if _ensure_available("connect_wallet"): + _bridge.connectWallet() + + +func get_address() -> void: + if _ensure_available("get_address"): + _bridge.getAddress() + + +func commit_action(payload: Dictionary) -> void: + if _ensure_available("commit_action"): + _bridge.commitAction(JSON.stringify(payload)) + + +func reveal_action(payload: Dictionary) -> void: + if _ensure_available("reveal_action"): + _bridge.revealAction(JSON.stringify(payload)) + + +func generate_proof(payload: Dictionary) -> void: + if _ensure_available("generate_proof"): + _bridge.generateProof(JSON.stringify(payload)) + + +func export_proof(payload: Dictionary) -> void: + if _ensure_available("export_proof"): + _bridge.exportProof(JSON.stringify(payload)) + + +func _register_callback(callback_name: String, callable: Callable) -> void: + var callback = JavaScriptBridge.create_callback(callable) + _callback_references.append(callback) + _callback_interface.set(callback_name, callback) + + +func _ensure_available(action: String) -> bool: + if not OS.has_feature("web"): + _report_error(action, "WebBridge is only available in Godot Web exports.") + return false + if _callback_interface == null: + _report_error(action, "Could not register the browser callback interface.") + return false + if _bridge == null: + _report_error( + action, + "window.%s is unavailable; load stellar_bridge.js before Godot starts." % BRIDGE_INTERFACE + ) + return false + return true + + +func _on_wallet_connected(arguments: Array) -> void: + var address := _read_string_argument("connect_wallet", arguments) + if not address.is_empty(): + wallet_connected.emit(address) + + +func _on_wallet_connection_failed(arguments: Array) -> void: + var error := _read_string_argument("connect_wallet", arguments) + if not error.is_empty(): + wallet_connection_failed.emit(error) + + +func _on_address_received(arguments: Array) -> void: + var address := _read_string_argument("get_address", arguments) + if not address.is_empty(): + address_received.emit(address) + + +func _on_commit_submitted(arguments: Array) -> void: + _emit_json_result("commit_action", commit_submitted, arguments) + + +func _on_reveal_submitted(arguments: Array) -> void: + _emit_json_result("reveal_action", reveal_submitted, arguments) + + +func _on_proof_generated(arguments: Array) -> void: + _emit_json_result("generate_proof", proof_generated, arguments) + + +func _on_proof_exported(arguments: Array) -> void: + _emit_json_result("export_proof", proof_exported, arguments) + + +func _on_bridge_error(arguments: Array) -> void: + if arguments.size() < 2: + _report_error("unknown", "Browser bridge returned an incomplete error callback.") + return + bridge_error.emit(str(arguments[0]), str(arguments[1])) + + +func _read_string_argument(action: String, arguments: Array) -> String: + if arguments.is_empty(): + _report_error(action, "Browser bridge callback did not include a value.") + return "" + var value := str(arguments[0]) + if value.is_empty(): + _report_error(action, "Browser bridge callback returned an empty value.") + return value + + +func _emit_json_result(action: String, target_signal: Signal, arguments: Array) -> void: + if arguments.is_empty() or typeof(arguments[0]) != TYPE_STRING: + _report_error(action, "Browser bridge callback must include a JSON string.") + return + + var json := JSON.new() + var parse_error := json.parse(arguments[0]) + if parse_error != OK: + _report_error( + action, + "Browser bridge returned invalid JSON: %s" % json.get_error_message() + ) + return + target_signal.emit(json.data) + + +func _report_error(action: String, error: String) -> void: + push_warning("%s: %s" % [action, error]) + bridge_error.emit(action, error) diff --git a/godot/export_presets.cfg b/godot/export_presets.cfg index 07a6945..ccad80a 100644 --- a/godot/export_presets.cfg +++ b/godot/export_presets.cfg @@ -21,7 +21,7 @@ vram_texture_compression/for_desktop=true vram_texture_compression/for_mobile=true html/export_icon=true html/custom_html_shell="" -html/head_include="" +html/head_include="" html/canvas_resize_policy=2 html/focus_canvas_on_start=true html/experimental_virtual_keyboard=false diff --git a/godot/project.godot b/godot/project.godot index e7777eb..7a31998 100644 --- a/godot/project.godot +++ b/godot/project.godot @@ -12,6 +12,10 @@ config_version=5 config/name="HumanVsBots" run/main_scene="res://scenes/Main.tscn" +[autoload] + +WebBridge="*res://autoloads/WebBridge.gd" + [display] window/size/viewport_width=1280 diff --git a/godot/web/README.md b/godot/web/README.md new file mode 100644 index 0000000..46b4638 --- /dev/null +++ b/godot/web/README.md @@ -0,0 +1,89 @@ +# Godot WebBridge contract + +`WebBridge` is a Godot autoload that exchanges asynchronous Stellar/ZK requests +with browser JavaScript. It is active only in a Godot Web export. Native/editor +calls do not touch `JavaScriptBridge`; they emit `bridge_error` instead. + +## Loading the browser adapter + +The Web export preset adds this tag to the generated page: + +```html + +``` + +`make godot-export` copies `godot/web/stellar_bridge.js` next to the generated +`dist/index.html`. If exporting from the Godot editor instead, copy that file +next to the exported HTML manually. The existing repository-root `index.html` +and demo pages are not part of this integration. + +Before the game makes a request, configure the adapter with the real wallet, +Stellar, and proof implementations: + +```js +window.HumanVsBotsBridge.configure({ + connectWallet: async () => ({ address: "G..." }), + getAddress: async () => "G...", + commitAction: async (payload) => ({ txHash: "..." }), + revealAction: async (payload) => ({ txHash: "..." }), + generateProof: async (payload) => ({ proof: "...", publicInputs: [] }), + exportProof: async (payload) => ({ filename: "proof.json" }), +}); +``` + +Handlers may return a value or a Promise. `commitAction`, `revealAction`, +`generateProof`, and `exportProof` receive the parsed JSON object sent by Godot. +This adapter deliberately does not select a wallet SDK, submit transactions, or +generate proofs itself. + +## JavaScript functions called by Godot + +Godot expects these functions on `window.HumanVsBotsBridge`: + +| Function | Input | Successful handler result | +| --- | --- | --- | +| `connectWallet()` | none | address string or `{ address: "G..." }` | +| `getAddress()` | none | address string or `{ address: "G..." }` | +| `commitAction(payloadJson)` | JSON string | any JSON-serializable value | +| `revealAction(payloadJson)` | JSON string | any JSON-serializable value | +| `generateProof(payloadJson)` | JSON string | any JSON-serializable value | +| `exportProof(payloadJson)` | JSON string | any JSON-serializable value | + +## Callbacks into Godot + +At startup, the autoload creates `window.HumanVsBotsGodot` and registers: + +| Callback | Arguments | Godot signal | +| --- | --- | --- | +| `walletConnected` | `address` string | `wallet_connected(address)` | +| `walletConnectionFailed` | error string | `wallet_connection_failed(error)` | +| `addressReceived` | `address` string | `address_received(address)` | +| `commitSubmitted` | JSON result string | `commit_submitted(result)` | +| `revealSubmitted` | JSON result string | `reveal_submitted(result)` | +| `proofGenerated` | JSON result string | `proof_generated(result)` | +| `proofExported` | JSON result string | `proof_exported(result)` | +| `bridgeError` | action string, error string | `bridge_error(action, error)` | + +The supplied adapter invokes these callbacks after each handler settles. +Integrations that replace the adapter must use the same callback names and must +JSON-stringify action/proof results. Expected action names for `bridgeError` are +`connect_wallet`, `get_address`, `commit_action`, `reveal_action`, +`generate_proof`, and `export_proof`. + +## Godot usage + +```gdscript +func _ready() -> void: + WebBridge.wallet_connected.connect(_on_wallet_connected) + WebBridge.commit_submitted.connect(_on_commit_submitted) + WebBridge.bridge_error.connect(_on_bridge_error) + + WebBridge.connect_wallet() + WebBridge.commit_action({ + "turn": 1, + "commitment": "hex-or-base64-commitment", + }) +``` + +Requests return through signals; wrapper methods do not block or return a +transaction/proof result. diff --git a/godot/web/stellar_bridge.js b/godot/web/stellar_bridge.js new file mode 100644 index 0000000..8f7b218 --- /dev/null +++ b/godot/web/stellar_bridge.js @@ -0,0 +1,122 @@ +/* global HumanVsBotsGodot */ + +/** + * Browser adapter for the Godot WebBridge autoload. + * + * The host application configures Stellar/ZK implementations with + * `window.HumanVsBotsBridge.configure(handlers)`. Every handler may return a + * value or a Promise. Godot sends action payloads as JSON strings and this + * adapter sends result payloads back as JSON strings. + */ +(function installHumanVsBotsBridge(global) { + "use strict"; + + let handlers = global.HumanVsBotsIntegrations || {}; + + function configure(nextHandlers) { + if (!nextHandlers || typeof nextHandlers !== "object") { + throw new TypeError("HumanVsBotsBridge.configure expects an object."); + } + handlers = nextHandlers; + } + + function callback(name) { + const godotCallbacks = global.HumanVsBotsGodot; + return godotCallbacks && typeof godotCallbacks[name] === "function" + ? godotCallbacks[name] + : null; + } + + function notify(name, ...args) { + const target = callback(name); + if (target) { + target(...args); + return; + } + console.error(`Godot bridge callback "${name}" is not registered.`); + } + + function errorMessage(error) { + if (error && typeof error.message === "string") { + return error.message; + } + return String(error); + } + + function parsePayload(payload) { + return typeof payload === "string" ? JSON.parse(payload) : payload; + } + + function serializeResult(result) { + return JSON.stringify(result === undefined ? null : result); + } + + function requireHandler(name) { + if (typeof handlers[name] !== "function") { + throw new Error(`Bridge handler "${name}" is not configured.`); + } + return handlers[name]; + } + + async function run(action, handlerName, successCallback, payload) { + try { + const handler = requireHandler(handlerName); + const result = arguments.length >= 4 + ? await handler(parsePayload(payload)) + : await handler(); + notify(successCallback, serializeResult(result)); + return result; + } catch (error) { + notify("bridgeError", action, errorMessage(error)); + return null; + } + } + + async function connectWallet() { + try { + const result = await requireHandler("connectWallet")(); + const address = typeof result === "string" ? result : result && result.address; + if (typeof address !== "string" || address.length === 0) { + throw new Error("connectWallet must return an address string or { address }."); + } + notify("walletConnected", address); + return result; + } catch (error) { + const message = errorMessage(error); + notify("walletConnectionFailed", message); + notify("bridgeError", "connect_wallet", message); + return null; + } + } + + async function getAddress() { + try { + const result = await requireHandler("getAddress")(); + const address = typeof result === "string" ? result : result && result.address; + if (typeof address !== "string" || address.length === 0) { + throw new Error("getAddress must return an address string or { address }."); + } + notify("addressReceived", address); + return result; + } catch (error) { + notify("bridgeError", "get_address", errorMessage(error)); + return null; + } + } + + const bridge = { + configure, + connectWallet, + getAddress, + commitAction: (payload) => + run("commit_action", "commitAction", "commitSubmitted", payload), + revealAction: (payload) => + run("reveal_action", "revealAction", "revealSubmitted", payload), + generateProof: (payload) => + run("generate_proof", "generateProof", "proofGenerated", payload), + exportProof: (payload) => + run("export_proof", "exportProof", "proofExported", payload), + }; + + global.HumanVsBotsBridge = bridge; +})(globalThis); diff --git a/godot/web/stellar_bridge.test.js b/godot/web/stellar_bridge.test.js new file mode 100644 index 0000000..20c58b7 --- /dev/null +++ b/godot/web/stellar_bridge.test.js @@ -0,0 +1,77 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const test = require("node:test"); +const vm = require("node:vm"); + +function loadBridge() { + const callbacks = []; + const context = { + console, + HumanVsBotsGodot: { + walletConnected: (address) => callbacks.push(["walletConnected", address]), + walletConnectionFailed: (error) => callbacks.push(["walletConnectionFailed", error]), + addressReceived: (address) => callbacks.push(["addressReceived", address]), + commitSubmitted: (result) => callbacks.push(["commitSubmitted", result]), + revealSubmitted: (result) => callbacks.push(["revealSubmitted", result]), + proofGenerated: (result) => callbacks.push(["proofGenerated", result]), + proofExported: (result) => callbacks.push(["proofExported", result]), + bridgeError: (action, error) => callbacks.push(["bridgeError", action, error]), + }, + }; + vm.createContext(context); + const source = fs.readFileSync(path.join(__dirname, "stellar_bridge.js"), "utf8"); + vm.runInContext(source, context); + return { bridge: context.HumanVsBotsBridge, callbacks }; +} + +test("routes wallet and address results to Godot callbacks", async () => { + const { bridge, callbacks } = loadBridge(); + bridge.configure({ + connectWallet: async () => ({ address: "GCONNECTED" }), + getAddress: async () => "GADDRESS", + }); + + await bridge.connectWallet(); + await bridge.getAddress(); + + assert.deepEqual(callbacks, [ + ["walletConnected", "GCONNECTED"], + ["addressReceived", "GADDRESS"], + ]); +}); + +test("parses action payloads and serializes async results", async () => { + const { bridge, callbacks } = loadBridge(); + bridge.configure({ + commitAction: async (payload) => ({ txHash: `commit-${payload.turn}` }), + revealAction: async (payload) => ({ txHash: `reveal-${payload.turn}` }), + generateProof: async (payload) => ({ proof: payload.state }), + exportProof: async (payload) => ({ filename: `${payload.turn}.json` }), + }); + + await bridge.commitAction('{"turn":7}'); + await bridge.revealAction('{"turn":7}'); + await bridge.generateProof('{"state":"state-hash"}'); + await bridge.exportProof('{"turn":7}'); + + assert.deepEqual(callbacks, [ + ["commitSubmitted", '{"txHash":"commit-7"}'], + ["revealSubmitted", '{"txHash":"reveal-7"}'], + ["proofGenerated", '{"proof":"state-hash"}'], + ["proofExported", '{"filename":"7.json"}'], + ]); +}); + +test("reports missing handlers without rejecting into the browser", async () => { + const { bridge, callbacks } = loadBridge(); + + await bridge.connectWallet(); + await bridge.commitAction("{}"); + + assert.deepEqual(callbacks, [ + ["walletConnectionFailed", 'Bridge handler "connectWallet" is not configured.'], + ["bridgeError", "connect_wallet", 'Bridge handler "connectWallet" is not configured.'], + ["bridgeError", "commit_action", 'Bridge handler "commitAction" is not configured.'], + ]); +});