From 7b3c5cb0f52d8134529d9799e07e3721266e6214 Mon Sep 17 00:00:00 2001 From: gem Date: Tue, 9 Jun 2026 16:09:19 +0800 Subject: [PATCH] Add runtime networking APIs --- CHANGELOG.md | 1 + docs/protocol.md | 19 ++ .../games/flight/scripts/runtime_defs.lua | 22 ++ .../games/ludo/scripts/runtime_defs.lua | 22 ++ .../games/showcase/scripts/runtime_defs.lua | 22 ++ .../games/template/scripts/runtime_defs.lua | 22 ++ .../diagnostics/runtime_diagnostics.dart | 1 + lib/runtime/game/flame_lua_game.dart | 13 + .../network/runtime_network_manager.dart | 273 ++++++++++++++++++ .../game_package_activation_controller.dart | 8 +- .../scripting/lua_dardo_script_engine.dart | 172 ++++++++++- .../scripting/runtime_script_services.dart | 7 + lib/runtime/scripting/script_engine.dart | 6 +- pubspec.yaml | 2 + .../events/runtime_event_dispatcher_test.dart | 6 +- test/runtime/game/flame_lua_game_test.dart | 6 +- .../network/runtime_network_manager_test.dart | 195 +++++++++++++ ...me_package_activation_controller_test.dart | 6 +- .../lua_dardo_script_engine_test.dart | 117 ++++++++ tool/lua_runtime_defs_common.lua | 22 ++ 20 files changed, 936 insertions(+), 6 deletions(-) create mode 100644 lib/runtime/network/runtime_network_manager.dart create mode 100644 lib/runtime/scripting/runtime_script_services.dart create mode 100644 test/runtime/network/runtime_network_manager_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index deaafb4..a240ef6 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 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. - Added Runtime text shadow fields for text-capable nodes. diff --git a/docs/protocol.md b/docs/protocol.md index 89844eb..6dec8b5 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -73,6 +73,25 @@ Image-capable nodes may also use nine-slice scaling with source-pixel insets: Nine-slice keeps corners unscaled, stretches edges on one axis, and stretches the center on both axes. Insets are clamped to the selected source region and destination size. +## Runtime network API + +Lua may use runtime-owned async networking without blocking script execution: + +- `runtime.http_request({ id?, method?, url, headers?, body?, timeout? })` +- `runtime.ws_connect({ id?, url, protocols? })` +- `runtime.ws_send(id, message)` +- `runtime.ws_close(id)` + +HTTP requests support `http` and `https` URLs. WebSocket connections support `ws` and `wss` URLs. + +Network results are delivered back to Lua through `on_event(event)`: + +- `network_http`: HTTP request completed or failed. `event.data` includes `id`, `url`, `method`, `ok`, and either `status`/`headers`/`body` or `error`. +- `network_ws_open`: WebSocket connection opened. +- `network_ws_message`: WebSocket message received. +- `network_ws_error`: WebSocket connection or stream error. +- `network_ws_close`: WebSocket connection closed. + ## 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 1b56637..93e8b8b 100644 --- a/example/assets/games/flight/scripts/runtime_defs.lua +++ b/example/assets/games/flight/scripts/runtime_defs.lua @@ -64,6 +64,11 @@ ---| 'animation_done' ---| 'resize' ---| 'scroll' +---| 'network_http' +---| 'network_ws_open' +---| 'network_ws_message' +---| 'network_ws_error' +---| 'network_ws_close' ---@alias RuntimeScaleMode ---| 'fit' @@ -580,9 +585,26 @@ ---@field cancel_group fun(group: string): RuntimeCommand ---@field cancel_scope fun(scope: string): RuntimeCommand +---@class RuntimeHttpRequestOptions +---@field id? string +---@field method? string HTTP method. Defaults to GET. +---@field url string http/https URL. +---@field headers? table +---@field body? string +---@field timeout? number Timeout in seconds. Defaults to 15. + +---@class RuntimeWsConnectOptions +---@field id? string +---@field url string ws/wss URL. +---@field protocols? string[] + ---@class RuntimeImportApi ---@field import fun(moduleName: string): table ---@field log fun(...: any) +---@field http_request fun(options: RuntimeHttpRequestOptions): string Starts an async HTTP request and returns request id. Result event type: network_http. +---@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 ---@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 d6847be..509bd20 100644 --- a/example/assets/games/ludo/scripts/runtime_defs.lua +++ b/example/assets/games/ludo/scripts/runtime_defs.lua @@ -64,6 +64,11 @@ ---| 'animation_done' ---| 'resize' ---| 'scroll' +---| 'network_http' +---| 'network_ws_open' +---| 'network_ws_message' +---| 'network_ws_error' +---| 'network_ws_close' ---@alias RuntimeScaleMode ---| 'fit' @@ -580,9 +585,26 @@ ---@field cancel_group fun(group: string): RuntimeCommand ---@field cancel_scope fun(scope: string): RuntimeCommand +---@class RuntimeHttpRequestOptions +---@field id? string +---@field method? string HTTP method. Defaults to GET. +---@field url string http/https URL. +---@field headers? table +---@field body? string +---@field timeout? number Timeout in seconds. Defaults to 15. + +---@class RuntimeWsConnectOptions +---@field id? string +---@field url string ws/wss URL. +---@field protocols? string[] + ---@class RuntimeImportApi ---@field import fun(moduleName: string): table ---@field log fun(...: any) +---@field http_request fun(options: RuntimeHttpRequestOptions): string Starts an async HTTP request and returns request id. Result event type: network_http. +---@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 ---@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 92fc29d..351a9f7 100644 --- a/example/assets/games/showcase/scripts/runtime_defs.lua +++ b/example/assets/games/showcase/scripts/runtime_defs.lua @@ -64,6 +64,11 @@ ---| 'animation_done' ---| 'resize' ---| 'scroll' +---| 'network_http' +---| 'network_ws_open' +---| 'network_ws_message' +---| 'network_ws_error' +---| 'network_ws_close' ---@alias RuntimeScaleMode ---| 'fit' @@ -580,9 +585,26 @@ ---@field cancel_group fun(group: string): RuntimeCommand ---@field cancel_scope fun(scope: string): RuntimeCommand +---@class RuntimeHttpRequestOptions +---@field id? string +---@field method? string HTTP method. Defaults to GET. +---@field url string http/https URL. +---@field headers? table +---@field body? string +---@field timeout? number Timeout in seconds. Defaults to 15. + +---@class RuntimeWsConnectOptions +---@field id? string +---@field url string ws/wss URL. +---@field protocols? string[] + ---@class RuntimeImportApi ---@field import fun(moduleName: string): table ---@field log fun(...: any) +---@field http_request fun(options: RuntimeHttpRequestOptions): string Starts an async HTTP request and returns request id. Result event type: network_http. +---@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 ---@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 d227e95..2a92569 100644 --- a/example/assets/games/template/scripts/runtime_defs.lua +++ b/example/assets/games/template/scripts/runtime_defs.lua @@ -64,6 +64,11 @@ ---| 'animation_done' ---| 'resize' ---| 'scroll' +---| 'network_http' +---| 'network_ws_open' +---| 'network_ws_message' +---| 'network_ws_error' +---| 'network_ws_close' ---@alias RuntimeScaleMode ---| 'fit' @@ -580,9 +585,26 @@ ---@field cancel_group fun(group: string): RuntimeCommand ---@field cancel_scope fun(scope: string): RuntimeCommand +---@class RuntimeHttpRequestOptions +---@field id? string +---@field method? string HTTP method. Defaults to GET. +---@field url string http/https URL. +---@field headers? table +---@field body? string +---@field timeout? number Timeout in seconds. Defaults to 15. + +---@class RuntimeWsConnectOptions +---@field id? string +---@field url string ws/wss URL. +---@field protocols? string[] + ---@class RuntimeImportApi ---@field import fun(moduleName: string): table ---@field log fun(...: any) +---@field http_request fun(options: RuntimeHttpRequestOptions): string Starts an async HTTP request and returns request id. Result event type: network_http. +---@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 ---@type RuntimeImportApi runtime = runtime diff --git a/lib/runtime/diagnostics/runtime_diagnostics.dart b/lib/runtime/diagnostics/runtime_diagnostics.dart index fce29b7..7f78a57 100644 --- a/lib/runtime/diagnostics/runtime_diagnostics.dart +++ b/lib/runtime/diagnostics/runtime_diagnostics.dart @@ -136,4 +136,5 @@ enum RuntimeDiagnosticType { packageActivationError, resourceLoadError, commandError, + networkError, } diff --git a/lib/runtime/game/flame_lua_game.dart b/lib/runtime/game/flame_lua_game.dart index 13f227d..4b7c3e9 100644 --- a/lib/runtime/game/flame_lua_game.dart +++ b/lib/runtime/game/flame_lua_game.dart @@ -12,6 +12,7 @@ import '../events/runtime_event_dispatcher.dart'; import '../lifecycle/runtime_session.dart'; import '../models/game_diff.dart'; import '../models/runtime_event.dart'; +import '../network/runtime_network_manager.dart'; import '../packages/game_package.dart'; import '../packages/game_package_activation_controller.dart'; import '../packages/game_package_repository.dart'; @@ -20,6 +21,7 @@ import '../protocol/runtime_protocol.dart'; import '../rendering/render_tree_controller.dart'; import '../display/runtime_viewport.dart'; import '../resources/game_resource_manager.dart'; +import '../scripting/runtime_script_services.dart'; import '../scripting/script_engine.dart'; import 'runtime_locale.dart'; import 'runtime_options.dart'; @@ -66,6 +68,7 @@ class FlameLuaGame extends FlameGame with PanDetector, ScrollDetector { late final RuntimeAudioManager _audio; late final RenderTreeController _renderTree; late final PositionComponent _viewportRoot; + RuntimeNetworkManager? _network; RuntimeViewportConfig? _viewportConfig; late final CommandExecutor _commands; RuntimeSession? _session; @@ -102,6 +105,11 @@ class FlameLuaGame extends FlameGame with PanDetector, ScrollDetector { _session = session; try { + final network = RuntimeNetworkManager( + eventSink: _emitEvent, + diagnostics: diagnostics, + ); + _network = network; final activation = await PackageActivationController( repository: _packageRepository, @@ -111,6 +119,7 @@ class FlameLuaGame extends FlameGame with PanDetector, ScrollDetector { resourceManagerFactory: _createResourceManager, audioManagerFactory: _createAudioManager, scriptEngineFactory: _scriptEngineFactory, + scriptServices: RuntimeScriptServices(network: network), store: StablePackageStore(runtimeOptions: runtimeOptions), assetFallback: AssetGamePackageRepository( runtimeOptions: runtimeOptions, @@ -159,6 +168,8 @@ class FlameLuaGame extends FlameGame with PanDetector, ScrollDetector { _runtimeInitialized = true; _applyDiff(activation.initialDiff); } catch (error) { + _network?.dispose(); + _network = null; session.dispose(); loadError = error.toString(); diagnostics.record( @@ -336,6 +347,8 @@ class FlameLuaGame extends FlameGame with PanDetector, ScrollDetector { _events?.dispose(); if (_runtimeInitialized) { _commands.dispose(); + _network?.dispose(); + _network = null; _renderTree.clear(); _audio.dispose(); _resources.dispose(); diff --git a/lib/runtime/network/runtime_network_manager.dart b/lib/runtime/network/runtime_network_manager.dart new file mode 100644 index 0000000..f0d8438 --- /dev/null +++ b/lib/runtime/network/runtime_network_manager.dart @@ -0,0 +1,273 @@ +import 'dart:async' as async; +import 'dart:convert'; + +import 'package:http/http.dart' as http; +import 'package:web_socket_channel/web_socket_channel.dart'; + +import '../diagnostics/runtime_diagnostics.dart'; +import '../models/runtime_event.dart'; + +class RuntimeNetworkManager { + RuntimeNetworkManager({ + required void Function(RuntimeEvent event) eventSink, + RuntimeDiagnostics? diagnostics, + http.Client? httpClient, + WebSocketChannel Function(Uri uri, {Iterable? protocols})? + webSocketFactory, + }) : _eventSink = eventSink, + _diagnostics = diagnostics, + _httpClient = httpClient ?? http.Client(), + _ownsHttpClient = httpClient == null, + _webSocketFactory = + webSocketFactory ?? + ((uri, {protocols}) => + WebSocketChannel.connect(uri, protocols: protocols)); + + final void Function(RuntimeEvent event) _eventSink; + final RuntimeDiagnostics? _diagnostics; + final http.Client _httpClient; + final bool _ownsHttpClient; + final WebSocketChannel Function(Uri uri, {Iterable? protocols}) + _webSocketFactory; + final Map _webSockets = {}; + bool _disposed = false; + + Future httpRequest(RuntimeHttpRequest request) async { + if (_disposed) { + return; + } + try { + _ensureScheme(request.uri, const {'http', 'https'}, 'HTTP request'); + final response = await _httpClient + .send( + http.Request(request.method, request.uri) + ..headers.addAll(request.headers) + ..body = request.body ?? '', + ) + .timeout(request.timeout); + final body = await response.stream.bytesToString(); + _emit( + RuntimeEvent( + type: RuntimeNetworkEventType.http, + data: { + 'id': request.id, + 'url': request.uri.toString(), + 'method': request.method, + 'ok': response.statusCode >= 200 && response.statusCode < 300, + 'status': response.statusCode, + 'headers': response.headers, + 'body': body, + }, + ), + ); + } catch (error) { + _diagnostics?.record( + type: RuntimeDiagnosticType.networkError, + message: 'Runtime HTTP request failed', + error: error, + context: {'id': request.id, 'url': request.uri.toString()}, + ); + _emit( + RuntimeEvent( + type: RuntimeNetworkEventType.http, + data: { + 'id': request.id, + 'url': request.uri.toString(), + 'method': request.method, + 'ok': false, + 'error': error.toString(), + }, + ), + ); + } + } + + void wsConnect(RuntimeWebSocketConnectRequest request) { + if (_disposed) { + return; + } + try { + _ensureScheme(request.uri, const {'ws', 'wss'}, 'WebSocket connect'); + closeWebSocket(request.id, emitClose: false); + final channel = _webSocketFactory( + request.uri, + protocols: request.protocols.isEmpty ? null : request.protocols, + ); + final subscription = channel.stream.listen( + (message) { + _emit( + RuntimeEvent( + type: RuntimeNetworkEventType.wsMessage, + data: { + 'id': request.id, + 'url': request.uri.toString(), + 'message': _webSocketMessageToString(message), + }, + ), + ); + }, + onError: (Object error) { + _diagnostics?.record( + type: RuntimeDiagnosticType.networkError, + message: 'Runtime WebSocket error', + error: error, + context: {'id': request.id, 'url': request.uri.toString()}, + ); + _emit( + RuntimeEvent( + type: RuntimeNetworkEventType.wsError, + data: { + 'id': request.id, + 'url': request.uri.toString(), + 'error': error.toString(), + }, + ), + ); + }, + onDone: () { + _webSockets.remove(request.id); + _emit( + RuntimeEvent( + type: RuntimeNetworkEventType.wsClose, + data: {'id': request.id, 'url': request.uri.toString()}, + ), + ); + }, + ); + _webSockets[request.id] = _RuntimeWebSocketConnection( + channel: channel, + subscription: subscription, + ); + _emit( + RuntimeEvent( + type: RuntimeNetworkEventType.wsOpen, + data: {'id': request.id, 'url': request.uri.toString()}, + ), + ); + } catch (error) { + _diagnostics?.record( + type: RuntimeDiagnosticType.networkError, + message: 'Runtime WebSocket connect failed', + error: error, + context: {'id': request.id, 'url': request.uri.toString()}, + ); + _emit( + RuntimeEvent( + type: RuntimeNetworkEventType.wsError, + data: { + 'id': request.id, + 'url': request.uri.toString(), + 'error': error.toString(), + }, + ), + ); + } + } + + bool wsSend(String id, Object? message) { + final connection = _webSockets[id]; + if (_disposed || connection == null) { + return false; + } + connection.channel.sink.add(message?.toString() ?? ''); + return true; + } + + bool closeWebSocket(String id, {bool emitClose = true}) { + final connection = _webSockets.remove(id); + if (connection == null) { + return false; + } + connection.subscription.cancel(); + connection.channel.sink.close(); + if (emitClose) { + _emit( + RuntimeEvent(type: RuntimeNetworkEventType.wsClose, data: {'id': id}), + ); + } + return true; + } + + void dispose() { + _disposed = true; + for (final id in _webSockets.keys.toList(growable: false)) { + closeWebSocket(id, emitClose: false); + } + if (_ownsHttpClient) { + _httpClient.close(); + } + } + + void _emit(RuntimeEvent event) { + if (_disposed) { + return; + } + _eventSink(event); + } + + void _ensureScheme(Uri uri, Set allowed, String label) { + if (!allowed.contains(uri.scheme)) { + throw FormatException( + '$label only supports ${allowed.join('/')} URLs: $uri', + ); + } + } + + String _webSocketMessageToString(Object? message) { + if (message is String) { + return message; + } + if (message is List) { + return base64Encode(message); + } + return message?.toString() ?? ''; + } +} + +class RuntimeHttpRequest { + const RuntimeHttpRequest({ + required this.id, + required this.method, + required this.uri, + this.headers = const {}, + this.body, + this.timeout = const Duration(seconds: 15), + }); + + final String id; + final String method; + final Uri uri; + final Map headers; + final String? body; + final Duration timeout; +} + +class RuntimeWebSocketConnectRequest { + const RuntimeWebSocketConnectRequest({ + required this.id, + required this.uri, + this.protocols = const [], + }); + + final String id; + final Uri uri; + final List protocols; +} + +abstract final class RuntimeNetworkEventType { + static const http = 'network_http'; + static const wsOpen = 'network_ws_open'; + static const wsMessage = 'network_ws_message'; + static const wsError = 'network_ws_error'; + static const wsClose = 'network_ws_close'; +} + +class _RuntimeWebSocketConnection { + const _RuntimeWebSocketConnection({ + required this.channel, + required this.subscription, + }); + + final WebSocketChannel channel; + final async.StreamSubscription subscription; +} diff --git a/lib/runtime/packages/game_package_activation_controller.dart b/lib/runtime/packages/game_package_activation_controller.dart index 2f6e32e..8aa8701 100644 --- a/lib/runtime/packages/game_package_activation_controller.dart +++ b/lib/runtime/packages/game_package_activation_controller.dart @@ -1,6 +1,7 @@ import '../audio/runtime_audio_manager.dart'; import '../models/game_diff.dart'; import '../resources/game_resource_manager.dart'; +import '../scripting/runtime_script_services.dart'; import '../scripting/script_engine.dart'; import 'game_package.dart'; import 'game_package_repository.dart'; @@ -19,6 +20,7 @@ class PackageActivationController { this.resourceManagerFactory, this.audioManagerFactory, this.scriptEngineFactory, + this.scriptServices = const RuntimeScriptServices(), }); final GamePackageRepository repository; @@ -31,6 +33,7 @@ class PackageActivationController { final GameResourceManager Function()? resourceManagerFactory; final RuntimeAudioManager Function()? audioManagerFactory; final ScriptEngine Function()? scriptEngineFactory; + final RuntimeScriptServices scriptServices; Future activate({ required String gameId, @@ -144,7 +147,10 @@ class PackageActivationController { _ensureContinue(shouldContinue); await preparedAudio?.mount(candidate); _ensureContinue(shouldContinue); - await preparedScriptEngine.loadPackage(candidate); + await preparedScriptEngine.loadPackage( + candidate, + services: scriptServices, + ); _ensureContinue(shouldContinue); final context = contextBuilder(candidate); diff --git a/lib/runtime/scripting/lua_dardo_script_engine.dart b/lib/runtime/scripting/lua_dardo_script_engine.dart index 07a41c2..47b0df6 100644 --- a/lib/runtime/scripting/lua_dardo_script_engine.dart +++ b/lib/runtime/scripting/lua_dardo_script_engine.dart @@ -1,9 +1,13 @@ +import 'dart:async' as async; + import 'package:lua_dardo_plus/lua.dart'; import '../diagnostics/runtime_diagnostics.dart'; import '../models/game_diff.dart'; import '../models/runtime_event.dart'; +import '../network/runtime_network_manager.dart'; import '../packages/game_package.dart'; +import 'runtime_script_services.dart'; import 'script_engine.dart'; class LuaDardoScriptEngine implements ScriptEngine { @@ -13,10 +17,17 @@ class LuaDardoScriptEngine implements ScriptEngine { final RuntimeDiagnostics? _diagnostics; late final LuaState _lua; late final Map _moduleScripts; + RuntimeScriptServices _services = const RuntimeScriptServices(); + int _networkRequestCounter = 0; final Set _loadingModules = {}; @override - Future loadPackage(GamePackage package) async { + Future loadPackage( + GamePackage package, { + RuntimeScriptServices services = const RuntimeScriptServices(), + }) async { + _services = services; + _networkRequestCounter = 0; final script = await package.readText(package.manifest.entry); _moduleScripts = {}; for (final entry in package.manifest.modules.entries) { @@ -112,9 +123,168 @@ class LuaDardoScriptEngine implements ScriptEngine { _lua.pushDartFunction(_log); _lua.setField(-2, 'log'); + _lua.pushDartFunction(_httpRequest); + _lua.setField(-2, 'http_request'); + + _lua.pushDartFunction(_wsConnect); + _lua.setField(-2, 'ws_connect'); + + _lua.pushDartFunction(_wsSend); + _lua.setField(-2, 'ws_send'); + + _lua.pushDartFunction(_wsClose); + _lua.setField(-2, 'ws_close'); + _lua.setGlobal('runtime'); } + int _httpRequest(LuaState lua) { + final network = _requireNetwork(); + final options = _requiredMapArgument(1, 'runtime.http_request(options)'); + final url = _requiredString(options, 'url'); + final uri = Uri.parse(url); + final id = _optionalString(options, 'id') ?? _nextNetworkRequestId('http'); + final method = (_optionalString(options, 'method') ?? 'GET').toUpperCase(); + final headers = _optionalStringMap(options['headers'], 'headers'); + final body = _optionalString(options, 'body'); + final timeout = Duration( + milliseconds: ((_optionalNumber(options, 'timeout') ?? 15) * 1000) + .round(), + ); + async.unawaited( + network.httpRequest( + RuntimeHttpRequest( + id: id, + method: method, + uri: uri, + headers: headers, + body: body, + timeout: timeout, + ), + ), + ); + lua.pushString(id); + return 1; + } + + int _wsConnect(LuaState lua) { + final network = _requireNetwork(); + final options = _requiredMapArgument(1, 'runtime.ws_connect(options)'); + final url = _requiredString(options, 'url'); + final id = _optionalString(options, 'id') ?? _nextNetworkRequestId('ws'); + network.wsConnect( + RuntimeWebSocketConnectRequest( + id: id, + uri: Uri.parse(url), + protocols: _optionalStringList(options['protocols'], 'protocols'), + ), + ); + lua.pushString(id); + return 1; + } + + int _wsSend(LuaState lua) { + final network = _requireNetwork(); + final id = lua.toStr(1); + if (id == null || id.isEmpty) { + throw const FormatException('runtime.ws_send(id, message) requires id'); + } + final message = _formatLuaLogValue(lua, 2); + lua.pushBoolean(network.wsSend(id, message)); + return 1; + } + + int _wsClose(LuaState lua) { + final network = _requireNetwork(); + final id = lua.toStr(1); + if (id == null || id.isEmpty) { + throw const FormatException('runtime.ws_close(id) requires id'); + } + lua.pushBoolean(network.closeWebSocket(id)); + return 1; + } + + RuntimeNetworkManager _requireNetwork() { + final network = _services.network; + if (network == null) { + throw StateError('Runtime network service is not installed'); + } + return network; + } + + String _nextNetworkRequestId(String prefix) { + _networkRequestCounter += 1; + return '$prefix:$_networkRequestCounter'; + } + + Map _requiredMapArgument(int index, String label) { + final value = _readValue(index); + if (value is Map) { + return Map.from(value); + } + throw FormatException('$label requires a table'); + } + + String _requiredString(Map map, String key) { + final value = _optionalString(map, key); + if (value == null) { + throw FormatException('$key must be a non-empty string'); + } + return value; + } + + String? _optionalString(Map map, String key) { + final value = map[key]; + if (value == null) { + return null; + } + if (value is String && value.isNotEmpty) { + return value; + } + throw FormatException('$key must be a non-empty string'); + } + + double? _optionalNumber(Map map, String key) { + final value = map[key]; + if (value == null) { + return null; + } + if (value is num) { + return value.toDouble(); + } + throw FormatException('$key must be a number'); + } + + Map _optionalStringMap(Object? value, String key) { + if (value == null) { + return const {}; + } + if (value is! Map) { + throw FormatException('$key must be a string map'); + } + return { + for (final entry in value.entries) + entry.key.toString(): entry.value?.toString() ?? '', + }; + } + + List _optionalStringList(Object? value, String key) { + if (value == null) { + return const []; + } + if (value is List) { + return value.map((item) => item.toString()).toList(growable: false); + } + if (value is Map) { + final entries = value.entries.toList() + ..sort((a, b) => a.key.toString().compareTo(b.key.toString())); + return entries + .map((entry) => entry.value.toString()) + .toList(growable: false); + } + throw FormatException('$key must be a string list'); + } + int _log(LuaState lua) { final argumentCount = lua.getTop(); final messageParts = []; diff --git a/lib/runtime/scripting/runtime_script_services.dart b/lib/runtime/scripting/runtime_script_services.dart new file mode 100644 index 0000000..dcacdb5 --- /dev/null +++ b/lib/runtime/scripting/runtime_script_services.dart @@ -0,0 +1,7 @@ +import '../network/runtime_network_manager.dart'; + +class RuntimeScriptServices { + const RuntimeScriptServices({this.network}); + + final RuntimeNetworkManager? network; +} diff --git a/lib/runtime/scripting/script_engine.dart b/lib/runtime/scripting/script_engine.dart index 2fba010..27fe34f 100644 --- a/lib/runtime/scripting/script_engine.dart +++ b/lib/runtime/scripting/script_engine.dart @@ -1,9 +1,13 @@ import '../models/game_diff.dart'; import '../models/runtime_event.dart'; import '../packages/game_package.dart'; +import 'runtime_script_services.dart'; abstract interface class ScriptEngine { - Future loadPackage(GamePackage package); + Future loadPackage( + GamePackage package, { + RuntimeScriptServices services = const RuntimeScriptServices(), + }); bool smokeTest(Map context); diff --git a/pubspec.yaml b/pubspec.yaml index eb470c8..123728c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -15,6 +15,7 @@ dependencies: archive: ^4.0.9 crypto: ^3.0.7 http: ^1.6.0 + web_socket_channel: ^3.0.3 path_provider: ^2.1.5 path: ^1.9.1 audioplayers: ^6.7.1 @@ -23,6 +24,7 @@ dev_dependencies: flutter_test: sdk: flutter flutter_lints: ^5.0.0 + stream_channel: ^2.1.4 flutter: assets: diff --git a/test/runtime/events/runtime_event_dispatcher_test.dart b/test/runtime/events/runtime_event_dispatcher_test.dart index b523e02..fb8ab6f 100644 --- a/test/runtime/events/runtime_event_dispatcher_test.dart +++ b/test/runtime/events/runtime_event_dispatcher_test.dart @@ -4,6 +4,7 @@ import 'package:flame_lua_runtime/runtime/lifecycle/runtime_session.dart'; import 'package:flame_lua_runtime/runtime/models/game_diff.dart'; import 'package:flame_lua_runtime/runtime/models/runtime_event.dart'; import 'package:flame_lua_runtime/runtime/packages/game_package.dart'; +import 'package:flame_lua_runtime/runtime/scripting/runtime_script_services.dart'; import 'package:flame_lua_runtime/runtime/scripting/script_engine.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -150,7 +151,10 @@ class _FakeScriptEngine implements ScriptEngine { bool failNext = false; @override - Future loadPackage(GamePackage package) async {} + Future loadPackage( + GamePackage package, { + RuntimeScriptServices services = const RuntimeScriptServices(), + }) async {} @override bool smokeTest(Map context) => true; diff --git a/test/runtime/game/flame_lua_game_test.dart b/test/runtime/game/flame_lua_game_test.dart index 3370eee..cd7514d 100644 --- a/test/runtime/game/flame_lua_game_test.dart +++ b/test/runtime/game/flame_lua_game_test.dart @@ -5,6 +5,7 @@ import 'package:flame_lua_runtime/runtime/models/runtime_event.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/packages/game_package_repository.dart'; +import 'package:flame_lua_runtime/runtime/scripting/runtime_script_services.dart'; import 'package:flame_lua_runtime/runtime/scripting/script_engine.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -34,7 +35,10 @@ void main() { class _FakeScriptEngine implements ScriptEngine { @override - Future loadPackage(GamePackage package) { + Future loadPackage( + GamePackage package, { + RuntimeScriptServices services = const RuntimeScriptServices(), + }) { throw UnimplementedError(); } diff --git a/test/runtime/network/runtime_network_manager_test.dart b/test/runtime/network/runtime_network_manager_test.dart new file mode 100644 index 0000000..3547ce1 --- /dev/null +++ b/test/runtime/network/runtime_network_manager_test.dart @@ -0,0 +1,195 @@ +import 'dart:async'; + +import 'package:flame_lua_runtime/runtime/diagnostics/runtime_diagnostics.dart'; +import 'package:flame_lua_runtime/runtime/models/runtime_event.dart'; +import 'package:flame_lua_runtime/runtime/network/runtime_network_manager.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:stream_channel/stream_channel.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; + +void main() { + group('RuntimeNetworkManager', () { + test('supports http and https requests', () async { + final events = []; + final manager = RuntimeNetworkManager( + eventSink: events.add, + httpClient: MockClient((request) async { + return http.Response( + 'ok', + 201, + headers: {'content-type': 'text/plain'}, + ); + }), + ); + + await manager.httpRequest( + RuntimeHttpRequest( + id: 'http_1', + method: 'GET', + uri: Uri.parse('http://example.com/ping'), + ), + ); + await manager.httpRequest( + RuntimeHttpRequest( + id: 'https_1', + method: 'POST', + uri: Uri.parse('https://example.com/ping'), + body: 'body', + ), + ); + + expect(events.map((event) => event.type), [ + RuntimeNetworkEventType.http, + RuntimeNetworkEventType.http, + ]); + expect(events.first.data['ok'], isTrue); + expect(events.first.data['status'], 201); + expect(events.first.data['body'], 'ok'); + expect(events.last.data['id'], 'https_1'); + }); + + test('rejects unsupported HTTP schemes with error event', () async { + final diagnostics = RuntimeDiagnostics(); + final events = []; + final manager = RuntimeNetworkManager( + eventSink: events.add, + diagnostics: diagnostics, + httpClient: MockClient((_) async => http.Response('no', 500)), + ); + + await manager.httpRequest( + RuntimeHttpRequest( + id: 'bad', + method: 'GET', + uri: Uri.parse('ftp://example.com/file'), + ), + ); + + expect(events.single.type, RuntimeNetworkEventType.http); + expect(events.single.data['ok'], isFalse); + expect(events.single.data['error'], contains('http/https')); + expect( + diagnostics.entries.single.type, + RuntimeDiagnosticType.networkError, + ); + }); + + test('supports ws and wss connections plus send and close', () async { + final events = []; + final controllers = >[]; + addTearDown(() { + for (final controller in controllers) { + controller.local.sink.close(); + } + }); + final manager = RuntimeNetworkManager( + eventSink: events.add, + webSocketFactory: (_, {protocols}) { + final controller = StreamChannelController(); + controllers.add(controller); + return _FakeWebSocketChannel(controller.foreign); + }, + ); + + manager.wsConnect( + RuntimeWebSocketConnectRequest( + id: 'ws_1', + uri: Uri.parse('ws://example.com/socket'), + ), + ); + manager.wsConnect( + RuntimeWebSocketConnectRequest( + id: 'wss_1', + uri: Uri.parse('wss://example.com/socket'), + protocols: const ['game.v1'], + ), + ); + expect(manager.wsSend('wss_1', 'hello'), isTrue); + controllers.last.local.sink.add('server'); + await Future.delayed(Duration.zero); + expect(manager.closeWebSocket('wss_1'), isTrue); + + expect( + events.map((event) => event.type), + contains(RuntimeNetworkEventType.wsOpen), + ); + expect( + events.where((event) => event.type == RuntimeNetworkEventType.wsOpen), + hasLength(2), + ); + expect( + events + .where((event) => event.type == RuntimeNetworkEventType.wsMessage) + .single + .data['message'], + 'server', + ); + expect(events.last.type, RuntimeNetworkEventType.wsClose); + }); + + test('rejects unsupported WebSocket schemes with error event', () { + final events = []; + final manager = RuntimeNetworkManager(eventSink: events.add); + + manager.wsConnect( + RuntimeWebSocketConnectRequest( + id: 'bad', + uri: Uri.parse('http://example.com/socket'), + ), + ); + + expect(events.single.type, RuntimeNetworkEventType.wsError); + expect(events.single.data['error'], contains('ws/wss')); + }); + }); +} + +class _FakeWebSocketChannel extends StreamChannelMixin + implements WebSocketChannel { + _FakeWebSocketChannel(StreamChannel channel) : _channel = channel; + + final StreamChannel _channel; + + @override + Stream get stream => _channel.stream; + + @override + WebSocketSink get sink => _FakeWebSocketSink(_channel.sink); + + @override + int? get closeCode => null; + + @override + String? get closeReason => null; + + @override + String? get protocol => null; + + @override + Future get ready => Future.value(); +} + +class _FakeWebSocketSink implements WebSocketSink { + _FakeWebSocketSink(this._sink); + + final StreamSink _sink; + + @override + void add(Object? event) => _sink.add(event); + + @override + void addError(Object error, [StackTrace? stackTrace]) { + _sink.addError(error, stackTrace); + } + + @override + Future addStream(Stream stream) => _sink.addStream(stream); + + @override + Future close([int? closeCode, String? closeReason]) => _sink.close(); + + @override + Future get done => _sink.done; +} diff --git a/test/runtime/packages/game_package_activation_controller_test.dart b/test/runtime/packages/game_package_activation_controller_test.dart index d9524a7..4a2364f 100644 --- a/test/runtime/packages/game_package_activation_controller_test.dart +++ b/test/runtime/packages/game_package_activation_controller_test.dart @@ -9,6 +9,7 @@ import 'package:flame_lua_runtime/runtime/packages/game_package_manifest.dart'; import 'package:flame_lua_runtime/runtime/packages/game_package_repository.dart'; import 'package:flame_lua_runtime/runtime/packages/stable_package_store.dart'; import 'package:flame_lua_runtime/runtime/resources/game_resource_manager.dart'; +import 'package:flame_lua_runtime/runtime/scripting/runtime_script_services.dart'; import 'package:flame_lua_runtime/runtime/scripting/script_engine.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -357,7 +358,10 @@ class _FakeScriptEngine implements ScriptEngine { GamePackage? _package; @override - Future loadPackage(GamePackage package) async { + Future loadPackage( + GamePackage package, { + RuntimeScriptServices services = const RuntimeScriptServices(), + }) async { _package = package; loadedPackages.add(package.rootPath); } diff --git a/test/runtime/scripting/lua_dardo_script_engine_test.dart b/test/runtime/scripting/lua_dardo_script_engine_test.dart index fff645b..b90aae6 100644 --- a/test/runtime/scripting/lua_dardo_script_engine_test.dart +++ b/test/runtime/scripting/lua_dardo_script_engine_test.dart @@ -4,8 +4,10 @@ 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/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'; import 'package:flame_lua_runtime/runtime/scripting/lua_dardo_script_engine.dart'; +import 'package:flame_lua_runtime/runtime/scripting/runtime_script_services.dart'; import 'package:flutter_test/flutter_test.dart'; Future _loadExamplePackage(String gameId) async { @@ -941,6 +943,84 @@ end expect(diagnostics.entries.first.context, {'argumentCount': 2}); }); + test('exposes async HTTP runtime API to Lua', () async { + final package = await _createPackage( + mainScript: ''' +function smoke_test(ctx) return true end +function init(ctx) + local id = runtime.http_request({ + id = "login", + method = "post", + url = "https://example.com/login", + headers = { Authorization = "Bearer token" }, + body = "{}", + timeout = 2, + }) + return { commands = { { type = "toast", text = id } } } +end +function on_event(event) return {} end +''', + ); + final network = _RecordingNetworkManager(); + final engine = LuaDardoScriptEngine(); + + await engine.loadPackage( + package, + services: RuntimeScriptServices(network: network), + ); + final diff = engine.init({'runtimeApiVersion': 1}); + + expect(diff.commands.single.payload['text'], 'login'); + expect(network.httpRequests, hasLength(1)); + expect(network.httpRequests.single.id, 'login'); + expect(network.httpRequests.single.method, 'POST'); + expect(network.httpRequests.single.uri.scheme, 'https'); + expect( + network.httpRequests.single.headers['Authorization'], + 'Bearer token', + ); + expect(network.httpRequests.single.body, '{}'); + expect(network.httpRequests.single.timeout, const Duration(seconds: 2)); + }); + + test('exposes WebSocket runtime API to Lua', () async { + final package = await _createPackage( + mainScript: ''' +function smoke_test(ctx) return true end +function init(ctx) + local id = runtime.ws_connect({ + id = "chat", + url = "wss://example.com/socket", + protocols = { "game.v1" }, + }) + local sent = runtime.ws_send(id, "hello") + local closed = runtime.ws_close(id) + return { + commands = { + { type = "toast", text = id .. ":" .. tostring(sent) .. ":" .. tostring(closed) }, + }, + } +end +function on_event(event) return {} end +''', + ); + final network = _RecordingNetworkManager(); + final engine = LuaDardoScriptEngine(); + + await engine.loadPackage( + package, + services: RuntimeScriptServices(network: network), + ); + final diff = engine.init({'runtimeApiVersion': 1}); + + expect(diff.commands.single.payload['text'], 'chat:true:true'); + expect(network.wsConnectRequests.single.id, 'chat'); + expect(network.wsConnectRequests.single.uri.scheme, 'wss'); + expect(network.wsConnectRequests.single.protocols, ['game.v1']); + expect(network.wsMessages, {'chat': 'hello'}); + expect(network.closedWebSockets, ['chat']); + }); + test('rejects undeclared module imports', () async { final package = await _createPackage( mainScript: ''' @@ -972,6 +1052,43 @@ function on_event(event) return {} end }); } +class _RecordingNetworkManager extends RuntimeNetworkManager { + _RecordingNetworkManager() + : super( + eventSink: (event) { + events.add(event); + }, + ); + + static final events = []; + final httpRequests = []; + final wsConnectRequests = []; + final wsMessages = {}; + final closedWebSockets = []; + + @override + Future httpRequest(RuntimeHttpRequest request) async { + httpRequests.add(request); + } + + @override + void wsConnect(RuntimeWebSocketConnectRequest request) { + wsConnectRequests.add(request); + } + + @override + bool wsSend(String id, Object? message) { + wsMessages[id] = message; + return true; + } + + @override + bool closeWebSocket(String id, {bool emitClose = true}) { + closedWebSockets.add(id); + return true; + } +} + Future _createPackage({ required String mainScript, Map modules = const {}, diff --git a/tool/lua_runtime_defs_common.lua b/tool/lua_runtime_defs_common.lua index d227e95..2a92569 100644 --- a/tool/lua_runtime_defs_common.lua +++ b/tool/lua_runtime_defs_common.lua @@ -64,6 +64,11 @@ ---| 'animation_done' ---| 'resize' ---| 'scroll' +---| 'network_http' +---| 'network_ws_open' +---| 'network_ws_message' +---| 'network_ws_error' +---| 'network_ws_close' ---@alias RuntimeScaleMode ---| 'fit' @@ -580,9 +585,26 @@ ---@field cancel_group fun(group: string): RuntimeCommand ---@field cancel_scope fun(scope: string): RuntimeCommand +---@class RuntimeHttpRequestOptions +---@field id? string +---@field method? string HTTP method. Defaults to GET. +---@field url string http/https URL. +---@field headers? table +---@field body? string +---@field timeout? number Timeout in seconds. Defaults to 15. + +---@class RuntimeWsConnectOptions +---@field id? string +---@field url string ws/wss URL. +---@field protocols? string[] + ---@class RuntimeImportApi ---@field import fun(moduleName: string): table ---@field log fun(...: any) +---@field http_request fun(options: RuntimeHttpRequestOptions): string Starts an async HTTP request and returns request id. Result event type: network_http. +---@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 ---@type RuntimeImportApi runtime = runtime