diff --git a/CHANGELOG.md b/CHANGELOG.md index a240ef6..a6f8401 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unreleased - Added TexturePacker frame, manual source-region, and nine-slice image rendering fields for image-capable nodes. +- Added Runtime host bridge APIs for bidirectional Flutter host calls and Lua notifications. - Added Runtime Lua networking APIs for async HTTP/HTTPS requests and WS/WSS connections. - Fixed nine-slice image seams by overlapping destination slices and using inset source sampling during runtime rendering. - Fixed Runtime alpha inheritance so parent fade commands apply to the full child subtree. diff --git a/docs/protocol.md b/docs/protocol.md index 6dec8b5..3f82adb 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -92,6 +92,22 @@ Network results are delivered back to Lua through `on_event(event)`: - `network_ws_error`: WebSocket connection or stream error. - `network_ws_close`: WebSocket connection closed. +## Runtime host bridge + +Flutter host apps may register a `RuntimeHostBridge` when creating `LuaGameWidget` or `FlameLuaGame`. + +Lua-to-Flutter calls: + +- `runtime.host_call({ id?, method, data? })`: async request. Result is delivered to Lua as `host_call_result` with `id`, `method`, `ok`, and either `result` or `error`. +- `runtime.host_notify({ method, data? })`: fire-and-forget notification to Flutter host code. + +Flutter-to-Lua calls: + +- `FlameLuaGame.notifyLua(method, data?)`: emits a `host_notify` event into Lua. +- `FlameLuaGame.callLua(method, data?, timeout?)`: emits a `host_call` event into Lua and waits for Lua to call `runtime.host_respond({ id, result?, error? })`. + +Host bridge payloads must be JSON-like values: null, bool, number, string, list, or string-keyed map. Unsupported Dart objects are converted to strings. + ## RuntimeCommand Runtime commands request generic side effects owned by Dart/Flame. diff --git a/example/assets/games/flight/scripts/runtime_defs.lua b/example/assets/games/flight/scripts/runtime_defs.lua index 93e8b8b..daf3916 100644 --- a/example/assets/games/flight/scripts/runtime_defs.lua +++ b/example/assets/games/flight/scripts/runtime_defs.lua @@ -69,6 +69,9 @@ ---| 'network_ws_message' ---| 'network_ws_error' ---| 'network_ws_close' +---| 'host_notify' +---| 'host_call' +---| 'host_call_result' ---@alias RuntimeScaleMode ---| 'fit' @@ -598,6 +601,20 @@ ---@field url string ws/wss URL. ---@field protocols? string[] +---@class RuntimeHostCallOptions +---@field id? string +---@field method string Host method name registered by Flutter. +---@field data? any + +---@class RuntimeHostNotifyOptions +---@field method string Host notification name registered by Flutter. +---@field data? any + +---@class RuntimeHostRespondOptions +---@field id string Host-to-Lua call id from host_call event. +---@field result? any +---@field error? string + ---@class RuntimeImportApi ---@field import fun(moduleName: string): table ---@field log fun(...: any) @@ -605,6 +622,9 @@ ---@field ws_connect fun(options: RuntimeWsConnectOptions): string Opens a WebSocket and returns connection id. Event types: network_ws_open/network_ws_message/network_ws_error/network_ws_close. ---@field ws_send fun(id: string, message: string): boolean ---@field ws_close fun(id: string): boolean +---@field host_call fun(options: RuntimeHostCallOptions): string Starts an async Lua-to-Flutter host call. Result event type: host_call_result. +---@field host_notify fun(options: RuntimeHostNotifyOptions): boolean Sends a fire-and-forget notification to Flutter host code. +---@field host_respond fun(options: RuntimeHostRespondOptions): boolean Completes a Flutter-to-Lua host_call event. ---@type RuntimeImportApi runtime = runtime diff --git a/example/assets/games/ludo/scripts/runtime_defs.lua b/example/assets/games/ludo/scripts/runtime_defs.lua index 509bd20..374663e 100644 --- a/example/assets/games/ludo/scripts/runtime_defs.lua +++ b/example/assets/games/ludo/scripts/runtime_defs.lua @@ -69,6 +69,9 @@ ---| 'network_ws_message' ---| 'network_ws_error' ---| 'network_ws_close' +---| 'host_notify' +---| 'host_call' +---| 'host_call_result' ---@alias RuntimeScaleMode ---| 'fit' @@ -598,6 +601,20 @@ ---@field url string ws/wss URL. ---@field protocols? string[] +---@class RuntimeHostCallOptions +---@field id? string +---@field method string Host method name registered by Flutter. +---@field data? any + +---@class RuntimeHostNotifyOptions +---@field method string Host notification name registered by Flutter. +---@field data? any + +---@class RuntimeHostRespondOptions +---@field id string Host-to-Lua call id from host_call event. +---@field result? any +---@field error? string + ---@class RuntimeImportApi ---@field import fun(moduleName: string): table ---@field log fun(...: any) @@ -605,6 +622,9 @@ ---@field ws_connect fun(options: RuntimeWsConnectOptions): string Opens a WebSocket and returns connection id. Event types: network_ws_open/network_ws_message/network_ws_error/network_ws_close. ---@field ws_send fun(id: string, message: string): boolean ---@field ws_close fun(id: string): boolean +---@field host_call fun(options: RuntimeHostCallOptions): string Starts an async Lua-to-Flutter host call. Result event type: host_call_result. +---@field host_notify fun(options: RuntimeHostNotifyOptions): boolean Sends a fire-and-forget notification to Flutter host code. +---@field host_respond fun(options: RuntimeHostRespondOptions): boolean Completes a Flutter-to-Lua host_call event. ---@type RuntimeImportApi runtime = runtime diff --git a/example/assets/games/showcase/scripts/runtime_defs.lua b/example/assets/games/showcase/scripts/runtime_defs.lua index 351a9f7..4b539e6 100644 --- a/example/assets/games/showcase/scripts/runtime_defs.lua +++ b/example/assets/games/showcase/scripts/runtime_defs.lua @@ -69,6 +69,9 @@ ---| 'network_ws_message' ---| 'network_ws_error' ---| 'network_ws_close' +---| 'host_notify' +---| 'host_call' +---| 'host_call_result' ---@alias RuntimeScaleMode ---| 'fit' @@ -598,6 +601,20 @@ ---@field url string ws/wss URL. ---@field protocols? string[] +---@class RuntimeHostCallOptions +---@field id? string +---@field method string Host method name registered by Flutter. +---@field data? any + +---@class RuntimeHostNotifyOptions +---@field method string Host notification name registered by Flutter. +---@field data? any + +---@class RuntimeHostRespondOptions +---@field id string Host-to-Lua call id from host_call event. +---@field result? any +---@field error? string + ---@class RuntimeImportApi ---@field import fun(moduleName: string): table ---@field log fun(...: any) @@ -605,6 +622,9 @@ ---@field ws_connect fun(options: RuntimeWsConnectOptions): string Opens a WebSocket and returns connection id. Event types: network_ws_open/network_ws_message/network_ws_error/network_ws_close. ---@field ws_send fun(id: string, message: string): boolean ---@field ws_close fun(id: string): boolean +---@field host_call fun(options: RuntimeHostCallOptions): string Starts an async Lua-to-Flutter host call. Result event type: host_call_result. +---@field host_notify fun(options: RuntimeHostNotifyOptions): boolean Sends a fire-and-forget notification to Flutter host code. +---@field host_respond fun(options: RuntimeHostRespondOptions): boolean Completes a Flutter-to-Lua host_call event. ---@type RuntimeImportApi runtime = runtime diff --git a/example/assets/games/template/scripts/runtime_defs.lua b/example/assets/games/template/scripts/runtime_defs.lua index 2a92569..47da77d 100644 --- a/example/assets/games/template/scripts/runtime_defs.lua +++ b/example/assets/games/template/scripts/runtime_defs.lua @@ -69,6 +69,9 @@ ---| 'network_ws_message' ---| 'network_ws_error' ---| 'network_ws_close' +---| 'host_notify' +---| 'host_call' +---| 'host_call_result' ---@alias RuntimeScaleMode ---| 'fit' @@ -598,6 +601,20 @@ ---@field url string ws/wss URL. ---@field protocols? string[] +---@class RuntimeHostCallOptions +---@field id? string +---@field method string Host method name registered by Flutter. +---@field data? any + +---@class RuntimeHostNotifyOptions +---@field method string Host notification name registered by Flutter. +---@field data? any + +---@class RuntimeHostRespondOptions +---@field id string Host-to-Lua call id from host_call event. +---@field result? any +---@field error? string + ---@class RuntimeImportApi ---@field import fun(moduleName: string): table ---@field log fun(...: any) @@ -605,6 +622,9 @@ ---@field ws_connect fun(options: RuntimeWsConnectOptions): string Opens a WebSocket and returns connection id. Event types: network_ws_open/network_ws_message/network_ws_error/network_ws_close. ---@field ws_send fun(id: string, message: string): boolean ---@field ws_close fun(id: string): boolean +---@field host_call fun(options: RuntimeHostCallOptions): string Starts an async Lua-to-Flutter host call. Result event type: host_call_result. +---@field host_notify fun(options: RuntimeHostNotifyOptions): boolean Sends a fire-and-forget notification to Flutter host code. +---@field host_respond fun(options: RuntimeHostRespondOptions): boolean Completes a Flutter-to-Lua host_call event. ---@type RuntimeImportApi runtime = runtime diff --git a/lib/flame_lua_runtime.dart b/lib/flame_lua_runtime.dart index 65e20df..fb32c6b 100644 --- a/lib/flame_lua_runtime.dart +++ b/lib/flame_lua_runtime.dart @@ -4,6 +4,15 @@ export 'runtime/game/flame_lua_game.dart' show FlameLuaGame; export 'runtime/game/lua_game_widget.dart' show LuaGameWidget; export 'runtime/game/runtime_locale.dart' show RuntimeLocaleResolver; export 'runtime/game/runtime_options.dart' show RuntimeOptions; +export 'runtime/host/runtime_host_bridge.dart' + show + RuntimeHostBridge, + RuntimeHostBridgeManager, + RuntimeHostCall, + RuntimeHostCallHandler, + RuntimeHostEventType, + RuntimeHostNotification, + RuntimeHostNotifyHandler; export 'runtime/packages/game_package_repository.dart' show AssetGamePackageRepository, diff --git a/lib/runtime/diagnostics/runtime_diagnostics.dart b/lib/runtime/diagnostics/runtime_diagnostics.dart index 7f78a57..a6748eb 100644 --- a/lib/runtime/diagnostics/runtime_diagnostics.dart +++ b/lib/runtime/diagnostics/runtime_diagnostics.dart @@ -137,4 +137,5 @@ enum RuntimeDiagnosticType { resourceLoadError, commandError, networkError, + hostBridgeError, } diff --git a/lib/runtime/game/flame_lua_game.dart b/lib/runtime/game/flame_lua_game.dart index 4b7c3e9..57bf56d 100644 --- a/lib/runtime/game/flame_lua_game.dart +++ b/lib/runtime/game/flame_lua_game.dart @@ -10,6 +10,7 @@ import '../commands/command_executor.dart'; import '../diagnostics/runtime_diagnostics.dart'; import '../events/runtime_event_dispatcher.dart'; import '../lifecycle/runtime_session.dart'; +import '../host/runtime_host_bridge.dart'; import '../models/game_diff.dart'; import '../models/runtime_event.dart'; import '../network/runtime_network_manager.dart'; @@ -41,6 +42,7 @@ class FlameLuaGame extends FlameGame with PanDetector, ScrollDetector { this.audioMaxConcurrentLoads = 4, this.audioSfxPoolSize = 8, this.runtimeOptions = const RuntimeOptions(), + this.hostBridge = const RuntimeHostBridge(), Locale? localeOverride, }) : _bootstrapScriptEngine = scriptEngine, _localeOverride = localeOverride, @@ -62,6 +64,7 @@ class FlameLuaGame extends FlameGame with PanDetector, ScrollDetector { final int audioMaxConcurrentLoads; final int audioSfxPoolSize; final RuntimeOptions runtimeOptions; + final RuntimeHostBridge hostBridge; final Locale? _localeOverride; late final GameResourceManager _resources; @@ -69,6 +72,7 @@ class FlameLuaGame extends FlameGame with PanDetector, ScrollDetector { late final RenderTreeController _renderTree; late final PositionComponent _viewportRoot; RuntimeNetworkManager? _network; + RuntimeHostBridgeManager? _hostBridgeManager; RuntimeViewportConfig? _viewportConfig; late final CommandExecutor _commands; RuntimeSession? _session; @@ -83,6 +87,28 @@ class FlameLuaGame extends FlameGame with PanDetector, ScrollDetector { String diagnosticsDumpText() => diagnostics.dumpText(); + Future callLua( + String method, { + Object? data, + Duration timeout = const Duration(seconds: 15), + }) { + final hostBridgeManager = _hostBridgeManager; + if (!_runtimeInitialized || hostBridgeManager == null) { + return Future.error( + StateError('Lua runtime is not initialized'), + ); + } + return hostBridgeManager.callLua(method, data: data, timeout: timeout); + } + + bool notifyLua(String method, {Object? data}) { + final hostBridgeManager = _hostBridgeManager; + if (!_runtimeInitialized || hostBridgeManager == null) { + return false; + } + return hostBridgeManager.notifyLua(method, data: data); + } + Map resourcesDebugJson() { if (!_runtimeInitialized) { return {'initialized': false}; @@ -110,6 +136,12 @@ class FlameLuaGame extends FlameGame with PanDetector, ScrollDetector { diagnostics: diagnostics, ); _network = network; + final hostBridgeManager = RuntimeHostBridgeManager( + bridge: hostBridge, + eventSink: _emitEvent, + diagnostics: diagnostics, + ); + _hostBridgeManager = hostBridgeManager; final activation = await PackageActivationController( repository: _packageRepository, @@ -119,7 +151,10 @@ class FlameLuaGame extends FlameGame with PanDetector, ScrollDetector { resourceManagerFactory: _createResourceManager, audioManagerFactory: _createAudioManager, scriptEngineFactory: _scriptEngineFactory, - scriptServices: RuntimeScriptServices(network: network), + scriptServices: RuntimeScriptServices( + network: network, + hostBridge: hostBridgeManager, + ), store: StablePackageStore(runtimeOptions: runtimeOptions), assetFallback: AssetGamePackageRepository( runtimeOptions: runtimeOptions, @@ -168,6 +203,8 @@ class FlameLuaGame extends FlameGame with PanDetector, ScrollDetector { _runtimeInitialized = true; _applyDiff(activation.initialDiff); } catch (error) { + _hostBridgeManager?.dispose(); + _hostBridgeManager = null; _network?.dispose(); _network = null; session.dispose(); @@ -347,6 +384,8 @@ class FlameLuaGame extends FlameGame with PanDetector, ScrollDetector { _events?.dispose(); if (_runtimeInitialized) { _commands.dispose(); + _hostBridgeManager?.dispose(); + _hostBridgeManager = null; _network?.dispose(); _network = null; _renderTree.clear(); diff --git a/lib/runtime/game/lua_game_widget.dart b/lib/runtime/game/lua_game_widget.dart index e59315a..12a87a6 100644 --- a/lib/runtime/game/lua_game_widget.dart +++ b/lib/runtime/game/lua_game_widget.dart @@ -2,6 +2,7 @@ import 'package:flame/game.dart'; import 'package:flutter/widgets.dart'; import '../diagnostics/runtime_diagnostics.dart'; +import '../host/runtime_host_bridge.dart'; import '../packages/game_package_repository.dart'; import '../scripting/lua_dardo_script_engine.dart'; import 'flame_lua_game.dart'; @@ -14,6 +15,7 @@ class LuaGameWidget extends StatelessWidget { this.serverUrl, this.localeOverride, this.runtimeOptions = const RuntimeOptions(), + this.hostBridge = const RuntimeHostBridge(), super.key, }); @@ -22,6 +24,7 @@ class LuaGameWidget extends StatelessWidget { final Uri? serverUrl; final Locale? localeOverride; final RuntimeOptions runtimeOptions; + final RuntimeHostBridge hostBridge; @override Widget build(BuildContext context) { @@ -42,6 +45,7 @@ class LuaGameWidget extends StatelessWidget { )), gameId: gameId, runtimeOptions: runtimeOptions, + hostBridge: hostBridge, localeOverride: localeOverride, ), ); diff --git a/lib/runtime/host/runtime_host_bridge.dart b/lib/runtime/host/runtime_host_bridge.dart new file mode 100644 index 0000000..b4dcd61 --- /dev/null +++ b/lib/runtime/host/runtime_host_bridge.dart @@ -0,0 +1,228 @@ +import 'dart:async' as async; + +import '../diagnostics/runtime_diagnostics.dart'; +import '../models/runtime_event.dart'; + +typedef RuntimeHostCallHandler = + async.FutureOr Function(RuntimeHostCall call); +typedef RuntimeHostNotifyHandler = + void Function(RuntimeHostNotification notification); + +class RuntimeHostBridge { + const RuntimeHostBridge({this.handlers = const {}, this.onNotify}); + + final Map handlers; + final RuntimeHostNotifyHandler? onNotify; +} + +class RuntimeHostCall { + const RuntimeHostCall({required this.id, required this.method, this.data}); + + final String id; + final String method; + final Object? data; +} + +class RuntimeHostNotification { + const RuntimeHostNotification({required this.method, this.data}); + + final String method; + final Object? data; +} + +class RuntimeHostBridgeManager { + RuntimeHostBridgeManager({ + required RuntimeHostBridge bridge, + required void Function(RuntimeEvent event) eventSink, + RuntimeDiagnostics? diagnostics, + }) : _bridge = bridge, + _eventSink = eventSink, + _diagnostics = diagnostics; + + final RuntimeHostBridge _bridge; + final void Function(RuntimeEvent event) _eventSink; + final RuntimeDiagnostics? _diagnostics; + final Map> _pendingLuaCalls = {}; + var _nextCallId = 0; + bool _disposed = false; + + Future callHost(RuntimeHostCall call) async { + if (_disposed) { + return; + } + final handler = _bridge.handlers[call.method]; + if (handler == null) { + _emitHostCallResult( + id: call.id, + method: call.method, + ok: false, + error: 'No host handler registered for ${call.method}', + ); + return; + } + + try { + final result = await handler(call); + _emitHostCallResult( + id: call.id, + method: call.method, + ok: true, + result: result, + ); + } catch (error) { + _diagnostics?.record( + type: RuntimeDiagnosticType.hostBridgeError, + message: 'Runtime host call failed', + error: error, + context: {'id': call.id, 'method': call.method}, + ); + _emitHostCallResult( + id: call.id, + method: call.method, + ok: false, + error: error.toString(), + ); + } + } + + bool notifyHost(RuntimeHostNotification notification) { + if (_disposed) { + return false; + } + final handler = _bridge.onNotify; + if (handler == null) { + return false; + } + try { + handler(notification); + return true; + } catch (error) { + _diagnostics?.record( + type: RuntimeDiagnosticType.hostBridgeError, + message: 'Runtime host notification failed', + error: error, + context: {'method': notification.method}, + ); + return false; + } + } + + Future callLua( + String method, { + Object? data, + Duration timeout = const Duration(seconds: 15), + }) { + if (_disposed) { + return Future.error(StateError('Runtime host bridge disposed')); + } + final id = 'host:${++_nextCallId}'; + final completer = async.Completer(); + _pendingLuaCalls[id] = completer; + _emit( + RuntimeEvent( + type: RuntimeHostEventType.call, + data: { + 'id': id, + 'method': method, + if (data != null) 'data': _runtimeValue(data), + }, + ), + ); + return completer.future.timeout( + timeout, + onTimeout: () { + _pendingLuaCalls.remove(id); + throw async.TimeoutException( + 'Lua host call timed out: $method', + timeout, + ); + }, + ); + } + + bool notifyLua(String method, {Object? data}) { + if (_disposed) { + return false; + } + _emit( + RuntimeEvent( + type: RuntimeHostEventType.notify, + data: {'method': method, if (data != null) 'data': _runtimeValue(data)}, + ), + ); + return true; + } + + bool completeLuaCall(String id, {Object? result, String? error}) { + final completer = _pendingLuaCalls.remove(id); + if (completer == null || completer.isCompleted) { + return false; + } + if (error != null) { + completer.completeError(StateError(error)); + } else { + completer.complete(_runtimeValue(result)); + } + return true; + } + + void dispose() { + _disposed = true; + for (final completer in _pendingLuaCalls.values) { + if (!completer.isCompleted) { + completer.completeError(StateError('Runtime host bridge disposed')); + } + } + _pendingLuaCalls.clear(); + } + + void _emitHostCallResult({ + required String id, + required String method, + required bool ok, + Object? result, + String? error, + }) { + _emit( + RuntimeEvent( + type: RuntimeHostEventType.callResult, + data: { + 'id': id, + 'method': method, + 'ok': ok, + if (ok) 'result': _runtimeValue(result), + if (!ok && error != null) 'error': error, + }, + ), + ); + } + + void _emit(RuntimeEvent event) { + if (_disposed) { + return; + } + _eventSink(event); + } + + Object? _runtimeValue(Object? value) { + if (value == null || value is String || value is num || value is bool) { + return value; + } + if (value is Iterable) { + return value.map(_runtimeValue).toList(growable: false); + } + if (value is Map) { + return { + for (final entry in value.entries) + entry.key.toString(): _runtimeValue(entry.value), + }; + } + return value.toString(); + } +} + +abstract final class RuntimeHostEventType { + static const notify = 'host_notify'; + static const call = 'host_call'; + static const callResult = 'host_call_result'; +} diff --git a/lib/runtime/scripting/lua_dardo_script_engine.dart b/lib/runtime/scripting/lua_dardo_script_engine.dart index 47b0df6..a3fd433 100644 --- a/lib/runtime/scripting/lua_dardo_script_engine.dart +++ b/lib/runtime/scripting/lua_dardo_script_engine.dart @@ -3,6 +3,7 @@ import 'dart:async' as async; import 'package:lua_dardo_plus/lua.dart'; import '../diagnostics/runtime_diagnostics.dart'; +import '../host/runtime_host_bridge.dart'; import '../models/game_diff.dart'; import '../models/runtime_event.dart'; import '../network/runtime_network_manager.dart'; @@ -19,6 +20,7 @@ class LuaDardoScriptEngine implements ScriptEngine { late final Map _moduleScripts; RuntimeScriptServices _services = const RuntimeScriptServices(); int _networkRequestCounter = 0; + int _hostCallCounter = 0; final Set _loadingModules = {}; @override @@ -28,6 +30,7 @@ class LuaDardoScriptEngine implements ScriptEngine { }) async { _services = services; _networkRequestCounter = 0; + _hostCallCounter = 0; final script = await package.readText(package.manifest.entry); _moduleScripts = {}; for (final entry in package.manifest.modules.entries) { @@ -135,6 +138,15 @@ class LuaDardoScriptEngine implements ScriptEngine { _lua.pushDartFunction(_wsClose); _lua.setField(-2, 'ws_close'); + _lua.pushDartFunction(_hostCall); + _lua.setField(-2, 'host_call'); + + _lua.pushDartFunction(_hostNotify); + _lua.setField(-2, 'host_notify'); + + _lua.pushDartFunction(_hostRespond); + _lua.setField(-2, 'host_respond'); + _lua.setGlobal('runtime'); } @@ -204,6 +216,56 @@ class LuaDardoScriptEngine implements ScriptEngine { return 1; } + int _hostCall(LuaState lua) { + final host = _requireHostBridge(); + final options = _requiredMapArgument(1, 'runtime.host_call(options)'); + final method = _requiredString(options, 'method'); + final id = _optionalString(options, 'id') ?? _nextHostCallId(); + async.unawaited( + host.callHost( + RuntimeHostCall(id: id, method: method, data: options['data']), + ), + ); + lua.pushString(id); + return 1; + } + + int _hostNotify(LuaState lua) { + final host = _requireHostBridge(); + final options = _requiredMapArgument(1, 'runtime.host_notify(options)'); + final method = _requiredString(options, 'method'); + lua.pushBoolean( + host.notifyHost( + RuntimeHostNotification(method: method, data: options['data']), + ), + ); + return 1; + } + + int _hostRespond(LuaState lua) { + final host = _requireHostBridge(); + final options = _requiredMapArgument(1, 'runtime.host_respond(options)'); + final id = _requiredString(options, 'id'); + final error = _optionalString(options, 'error'); + lua.pushBoolean( + host.completeLuaCall(id, result: options['result'], error: error), + ); + return 1; + } + + RuntimeHostBridgeManager _requireHostBridge() { + final hostBridge = _services.hostBridge; + if (hostBridge == null) { + throw StateError('Runtime host bridge service is not installed'); + } + return hostBridge; + } + + String _nextHostCallId() { + _hostCallCounter += 1; + return 'lua:$_hostCallCounter'; + } + RuntimeNetworkManager _requireNetwork() { final network = _services.network; if (network == null) { diff --git a/lib/runtime/scripting/runtime_script_services.dart b/lib/runtime/scripting/runtime_script_services.dart index dcacdb5..b683c81 100644 --- a/lib/runtime/scripting/runtime_script_services.dart +++ b/lib/runtime/scripting/runtime_script_services.dart @@ -1,7 +1,9 @@ +import '../host/runtime_host_bridge.dart'; import '../network/runtime_network_manager.dart'; class RuntimeScriptServices { - const RuntimeScriptServices({this.network}); + const RuntimeScriptServices({this.network, this.hostBridge}); final RuntimeNetworkManager? network; + final RuntimeHostBridgeManager? hostBridge; } diff --git a/test/runtime/game/flame_lua_game_test.dart b/test/runtime/game/flame_lua_game_test.dart index cd7514d..1e0767a 100644 --- a/test/runtime/game/flame_lua_game_test.dart +++ b/test/runtime/game/flame_lua_game_test.dart @@ -11,7 +11,7 @@ import 'package:flutter_test/flutter_test.dart'; void main() { group('FlameLuaGame diagnostics debug access', () { - test('exposes diagnostics entries, dump text and debug json', () { + test('exposes diagnostics entries, dump text and debug json', () async { final diagnostics = RuntimeDiagnostics() ..record( type: RuntimeDiagnosticType.commandError, @@ -29,6 +29,8 @@ void main() { expect(game.diagnosticsDumpText(), contains('command failed')); expect(game.diagnosticsDebugJson()['count'], 1); expect(game.resourcesDebugJson(), {'initialized': false}); + expect(game.notifyLua('host.ready'), isFalse); + await expectLater(game.callLua('host.ready'), throwsA(isA())); }); }); } diff --git a/test/runtime/host/runtime_host_bridge_test.dart b/test/runtime/host/runtime_host_bridge_test.dart new file mode 100644 index 0000000..4c3b526 --- /dev/null +++ b/test/runtime/host/runtime_host_bridge_test.dart @@ -0,0 +1,93 @@ +import 'package:flame_lua_runtime/runtime/diagnostics/runtime_diagnostics.dart'; +import 'package:flame_lua_runtime/runtime/host/runtime_host_bridge.dart'; +import 'package:flame_lua_runtime/runtime/models/runtime_event.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('RuntimeHostBridgeManager', () { + test('calls registered host handler and emits result event', () async { + final events = []; + final manager = RuntimeHostBridgeManager( + bridge: RuntimeHostBridge( + handlers: { + 'user.profile': (call) => {'name': 'Lua', 'id': call.data}, + }, + ), + eventSink: events.add, + ); + + await manager.callHost( + const RuntimeHostCall(id: 'call_1', method: 'user.profile', data: 7), + ); + + expect(events.single.type, RuntimeHostEventType.callResult); + expect(events.single.data['ok'], isTrue); + expect(events.single.data['result'], {'name': 'Lua', 'id': 7}); + }); + + test( + 'emits failed result and diagnostics when host handler throws', + () async { + final diagnostics = RuntimeDiagnostics(); + final events = []; + final manager = RuntimeHostBridgeManager( + bridge: RuntimeHostBridge( + handlers: {'boom': (_) => throw StateError('boom')}, + ), + eventSink: events.add, + diagnostics: diagnostics, + ); + + await manager.callHost( + const RuntimeHostCall(id: 'call_1', method: 'boom'), + ); + + expect(events.single.data['ok'], isFalse); + expect(events.single.data['error'], contains('boom')); + expect( + diagnostics.entries.single.type, + RuntimeDiagnosticType.hostBridgeError, + ); + }, + ); + + test('notifies host and emits Lua calls', () async { + RuntimeHostNotification? notification; + final events = []; + final manager = RuntimeHostBridgeManager( + bridge: RuntimeHostBridge(onNotify: (value) => notification = value), + eventSink: events.add, + ); + + expect( + manager.notifyHost( + const RuntimeHostNotification( + method: 'analytics', + data: {'level': 2}, + ), + ), + isTrue, + ); + expect(notification?.method, 'analytics'); + expect(manager.notifyLua('pause', data: {'reason': 'host'}), isTrue); + + expect(events.single.type, RuntimeHostEventType.notify); + expect(events.single.data['method'], 'pause'); + }); + + test('completes Flutter-to-Lua call through host response', () async { + final events = []; + final manager = RuntimeHostBridgeManager( + bridge: const RuntimeHostBridge(), + eventSink: events.add, + ); + + final future = manager.callLua('select_avatar', data: {'current': 1}); + final id = events.single.data['id']! as String; + expect(events.single.type, RuntimeHostEventType.call); + expect(manager.completeLuaCall(id, result: {'selected': 3}), isTrue); + + expect(await future, {'selected': 3}); + }); + }); +} diff --git a/test/runtime/scripting/lua_dardo_script_engine_test.dart b/test/runtime/scripting/lua_dardo_script_engine_test.dart index b90aae6..5bcc45d 100644 --- a/test/runtime/scripting/lua_dardo_script_engine_test.dart +++ b/test/runtime/scripting/lua_dardo_script_engine_test.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:flame_lua_runtime/runtime/diagnostics/runtime_diagnostics.dart'; import 'package:flame_lua_runtime/runtime/packages/game_package.dart'; import 'package:flame_lua_runtime/runtime/packages/game_package_manifest.dart'; +import 'package:flame_lua_runtime/runtime/host/runtime_host_bridge.dart'; import 'package:flame_lua_runtime/runtime/models/runtime_event.dart'; import 'package:flame_lua_runtime/runtime/network/runtime_network_manager.dart'; import 'package:flame_lua_runtime/runtime/protocol/runtime_protocol.dart'; @@ -1021,6 +1022,57 @@ function on_event(event) return {} end expect(network.closedWebSockets, ['chat']); }); + test('exposes host bridge runtime API to Lua', () async { + final package = await _createPackage( + mainScript: ''' +function smoke_test(ctx) return true end +function init(ctx) + local id = runtime.host_call({ + id = "profile", + method = "user.profile", + data = { userId = 9 }, + }) + local notified = runtime.host_notify({ + method = "analytics", + data = { event = "open" }, + }) + return { + commands = { + { type = "toast", text = id .. ":" .. tostring(notified) }, + }, + } +end +function on_event(event) + if event.type == "host_call" then + runtime.host_respond({ id = event.data.id, result = { handled = event.data.method } }) + end + return {} +end +''', + ); + final hostBridge = _RecordingHostBridgeManager(); + final engine = LuaDardoScriptEngine(); + + await engine.loadPackage( + package, + services: RuntimeScriptServices(hostBridge: hostBridge), + ); + final diff = engine.init({'runtimeApiVersion': 1}); + + expect(diff.commands.single.payload['text'], 'profile:true'); + expect(hostBridge.calls.single.method, 'user.profile'); + expect(hostBridge.calls.single.data, {'userId': 9}); + expect(hostBridge.notifications.single.method, 'analytics'); + expect(hostBridge.notifications.single.data, {'event': 'open'}); + + final callFuture = hostBridge.callLua('flutter.request', data: {'id': 2}); + final event = _RecordingHostBridgeManager.events.singleWhere( + (item) => item.type == RuntimeHostEventType.call, + ); + engine.dispatchEvent(event); + expect(await callFuture, {'handled': 'flutter.request'}); + }); + test('rejects undeclared module imports', () async { final package = await _createPackage( mainScript: ''' @@ -1052,6 +1104,26 @@ function on_event(event) return {} end }); } +class _RecordingHostBridgeManager extends RuntimeHostBridgeManager { + _RecordingHostBridgeManager() + : super(bridge: const RuntimeHostBridge(), eventSink: events.add); + + static final events = []; + final calls = []; + final notifications = []; + + @override + Future callHost(RuntimeHostCall call) async { + calls.add(call); + } + + @override + bool notifyHost(RuntimeHostNotification notification) { + notifications.add(notification); + return true; + } +} + class _RecordingNetworkManager extends RuntimeNetworkManager { _RecordingNetworkManager() : super( diff --git a/tool/lua_runtime_defs_common.lua b/tool/lua_runtime_defs_common.lua index 2a92569..47da77d 100644 --- a/tool/lua_runtime_defs_common.lua +++ b/tool/lua_runtime_defs_common.lua @@ -69,6 +69,9 @@ ---| 'network_ws_message' ---| 'network_ws_error' ---| 'network_ws_close' +---| 'host_notify' +---| 'host_call' +---| 'host_call_result' ---@alias RuntimeScaleMode ---| 'fit' @@ -598,6 +601,20 @@ ---@field url string ws/wss URL. ---@field protocols? string[] +---@class RuntimeHostCallOptions +---@field id? string +---@field method string Host method name registered by Flutter. +---@field data? any + +---@class RuntimeHostNotifyOptions +---@field method string Host notification name registered by Flutter. +---@field data? any + +---@class RuntimeHostRespondOptions +---@field id string Host-to-Lua call id from host_call event. +---@field result? any +---@field error? string + ---@class RuntimeImportApi ---@field import fun(moduleName: string): table ---@field log fun(...: any) @@ -605,6 +622,9 @@ ---@field ws_connect fun(options: RuntimeWsConnectOptions): string Opens a WebSocket and returns connection id. Event types: network_ws_open/network_ws_message/network_ws_error/network_ws_close. ---@field ws_send fun(id: string, message: string): boolean ---@field ws_close fun(id: string): boolean +---@field host_call fun(options: RuntimeHostCallOptions): string Starts an async Lua-to-Flutter host call. Result event type: host_call_result. +---@field host_notify fun(options: RuntimeHostNotifyOptions): boolean Sends a fire-and-forget notification to Flutter host code. +---@field host_respond fun(options: RuntimeHostRespondOptions): boolean Completes a Flutter-to-Lua host_call event. ---@type RuntimeImportApi runtime = runtime