Add runtime networking APIs

This commit is contained in:
gem
2026-06-09 16:09:19 +08:00
parent 4f36d68b74
commit 7b3c5cb0f5
20 changed files with 936 additions and 6 deletions

View File

@@ -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.

View File

@@ -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.

View File

@@ -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<string, string>
---@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

View File

@@ -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<string, string>
---@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

View File

@@ -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<string, string>
---@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

View File

@@ -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<string, string>
---@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

View File

@@ -136,4 +136,5 @@ enum RuntimeDiagnosticType {
packageActivationError,
resourceLoadError,
commandError,
networkError,
}

View File

@@ -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();

View File

@@ -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<String>? 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<String>? protocols})
_webSocketFactory;
final Map<String, _RuntimeWebSocketConnection> _webSockets = {};
bool _disposed = false;
Future<void> 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<String> 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<int>) {
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<String, String> 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<String> 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<dynamic> subscription;
}

View File

@@ -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<PackageActivationResult> 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);

View File

@@ -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<String, String> _moduleScripts;
RuntimeScriptServices _services = const RuntimeScriptServices();
int _networkRequestCounter = 0;
final Set<String> _loadingModules = {};
@override
Future<void> loadPackage(GamePackage package) async {
Future<void> 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<String, Object?> _requiredMapArgument(int index, String label) {
final value = _readValue(index);
if (value is Map) {
return Map<String, Object?>.from(value);
}
throw FormatException('$label requires a table');
}
String _requiredString(Map<String, Object?> 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<String, Object?> 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<String, Object?> 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<String, String> _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<String> _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 = <String>[];

View File

@@ -0,0 +1,7 @@
import '../network/runtime_network_manager.dart';
class RuntimeScriptServices {
const RuntimeScriptServices({this.network});
final RuntimeNetworkManager? network;
}

View File

@@ -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<void> loadPackage(GamePackage package);
Future<void> loadPackage(
GamePackage package, {
RuntimeScriptServices services = const RuntimeScriptServices(),
});
bool smokeTest(Map<String, Object?> context);

View File

@@ -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:

View File

@@ -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<void> loadPackage(GamePackage package) async {}
Future<void> loadPackage(
GamePackage package, {
RuntimeScriptServices services = const RuntimeScriptServices(),
}) async {}
@override
bool smokeTest(Map<String, Object?> context) => true;

View File

@@ -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<void> loadPackage(GamePackage package) {
Future<void> loadPackage(
GamePackage package, {
RuntimeScriptServices services = const RuntimeScriptServices(),
}) {
throw UnimplementedError();
}

View File

@@ -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 = <RuntimeEvent>[];
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 = <RuntimeEvent>[];
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 = <RuntimeEvent>[];
final controllers = <StreamChannelController<Object?>>[];
addTearDown(() {
for (final controller in controllers) {
controller.local.sink.close();
}
});
final manager = RuntimeNetworkManager(
eventSink: events.add,
webSocketFactory: (_, {protocols}) {
final controller = StreamChannelController<Object?>();
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<void>.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 = <RuntimeEvent>[];
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<Object?> channel) : _channel = channel;
final StreamChannel<Object?> _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<void> get ready => Future<void>.value();
}
class _FakeWebSocketSink implements WebSocketSink {
_FakeWebSocketSink(this._sink);
final StreamSink<Object?> _sink;
@override
void add(Object? event) => _sink.add(event);
@override
void addError(Object error, [StackTrace? stackTrace]) {
_sink.addError(error, stackTrace);
}
@override
Future<void> addStream(Stream stream) => _sink.addStream(stream);
@override
Future<void> close([int? closeCode, String? closeReason]) => _sink.close();
@override
Future<void> get done => _sink.done;
}

View File

@@ -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<void> loadPackage(GamePackage package) async {
Future<void> loadPackage(
GamePackage package, {
RuntimeScriptServices services = const RuntimeScriptServices(),
}) async {
_package = package;
loadedPackages.add(package.rootPath);
}

View File

@@ -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<GamePackage> _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 = <RuntimeEvent>[];
final httpRequests = <RuntimeHttpRequest>[];
final wsConnectRequests = <RuntimeWebSocketConnectRequest>[];
final wsMessages = <String, Object?>{};
final closedWebSockets = <String>[];
@override
Future<void> 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<GamePackage> _createPackage({
required String mainScript,
Map<String, String> modules = const {},

View File

@@ -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<string, string>
---@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