Add runtime networking APIs
This commit is contained in:
@@ -136,4 +136,5 @@ enum RuntimeDiagnosticType {
|
||||
packageActivationError,
|
||||
resourceLoadError,
|
||||
commandError,
|
||||
networkError,
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
273
lib/runtime/network/runtime_network_manager.dart
Normal file
273
lib/runtime/network/runtime_network_manager.dart
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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>[];
|
||||
|
||||
7
lib/runtime/scripting/runtime_script_services.dart
Normal file
7
lib/runtime/scripting/runtime_script_services.dart
Normal file
@@ -0,0 +1,7 @@
|
||||
import '../network/runtime_network_manager.dart';
|
||||
|
||||
class RuntimeScriptServices {
|
||||
const RuntimeScriptServices({this.network});
|
||||
|
||||
final RuntimeNetworkManager? network;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user