649 lines
17 KiB
Dart
649 lines
17 KiB
Dart
import 'dart:async' as async;
|
|
|
|
import 'package:lua_dardo_plus/lua.dart';
|
|
|
|
import '../diagnostics/runtime_diagnostics.dart';
|
|
import '../host/runtime_host_bridge.dart';
|
|
import '../models/game_diff.dart';
|
|
import '../models/runtime_event.dart';
|
|
import '../network/runtime_network_manager.dart';
|
|
import '../packages/game_package.dart';
|
|
import '../storage/runtime_storage_manager.dart';
|
|
import 'runtime_script_services.dart';
|
|
import 'script_engine.dart';
|
|
|
|
class LuaDardoScriptEngine implements ScriptEngine {
|
|
LuaDardoScriptEngine({RuntimeDiagnostics? diagnostics})
|
|
: _diagnostics = diagnostics;
|
|
|
|
final RuntimeDiagnostics? _diagnostics;
|
|
late final LuaState _lua;
|
|
late final Map<String, String> _moduleScripts;
|
|
RuntimeScriptServices _services = const RuntimeScriptServices();
|
|
int _networkRequestCounter = 0;
|
|
int _hostCallCounter = 0;
|
|
final Set<String> _loadingModules = {};
|
|
|
|
@override
|
|
Future<void> loadPackage(
|
|
GamePackage package, {
|
|
RuntimeScriptServices services = const RuntimeScriptServices(),
|
|
}) {
|
|
return loadPackages([package], services: services);
|
|
}
|
|
|
|
@override
|
|
Future<void> loadPackages(
|
|
List<GamePackage> packages, {
|
|
RuntimeScriptServices services = const RuntimeScriptServices(),
|
|
}) async {
|
|
if (packages.isEmpty) {
|
|
throw const FormatException('loadPackages requires at least one package');
|
|
}
|
|
|
|
_services = services;
|
|
_networkRequestCounter = 0;
|
|
_hostCallCounter = 0;
|
|
_moduleScripts = {};
|
|
|
|
// 按顺序加载所有包的模块,后加载的同名模块覆盖先加载的。
|
|
for (final package in packages) {
|
|
for (final entry in package.manifest.modules.entries) {
|
|
_moduleScripts[entry.key] = await package.readText(entry.value);
|
|
}
|
|
}
|
|
|
|
// 入口脚本使用最后一个包(游戏包)。
|
|
final entryPackage = packages.last;
|
|
final script = await entryPackage.readText(entryPackage.manifest.entry);
|
|
_loadingModules.clear();
|
|
|
|
_lua = LuaState.newState();
|
|
_lua.openLibs();
|
|
_disableUnsafeGlobals();
|
|
_installRuntimeApi();
|
|
|
|
final ok = _lua.doString(script);
|
|
if (!ok) {
|
|
final error = _lua.toStr(-1) ?? 'unknown Lua load error';
|
|
_lua.pop(1);
|
|
throw StateError(error);
|
|
}
|
|
}
|
|
|
|
@override
|
|
bool smokeTest(Map<String, Object?> context) {
|
|
_lua.getGlobal('smoke_test');
|
|
if (!_lua.isFunction(-1)) {
|
|
_lua.pop(1);
|
|
return true;
|
|
}
|
|
|
|
_pushValue(context);
|
|
final status = _lua.pCall(1, 1, 0);
|
|
if (status != ThreadStatus.luaOk) {
|
|
final error = _lua.toStr(-1) ?? 'unknown Lua smoke_test error';
|
|
_lua.pop(1);
|
|
throw StateError(error);
|
|
}
|
|
final result = _lua.toBoolean(-1);
|
|
_lua.pop(1);
|
|
return result;
|
|
}
|
|
|
|
@override
|
|
GameDiff init(Map<String, Object?> context) {
|
|
return _callDiffFunction('init', context);
|
|
}
|
|
|
|
@override
|
|
GameDiff dispatchEvent(RuntimeEvent event) {
|
|
return _callDiffFunction('on_event', event.toMap());
|
|
}
|
|
|
|
GameDiff _callDiffFunction(String name, Map<String, Object?> argument) {
|
|
_lua.getGlobal(name);
|
|
if (!_lua.isFunction(-1)) {
|
|
_lua.pop(1);
|
|
return GameDiff.empty;
|
|
}
|
|
|
|
_pushValue(argument);
|
|
final status = _lua.pCall(1, 1, 0);
|
|
if (status != ThreadStatus.luaOk) {
|
|
final error = _lua.toStr(-1) ?? 'unknown Lua runtime error';
|
|
_lua.pop(1);
|
|
throw StateError(error);
|
|
}
|
|
|
|
if (_lua.isNil(-1)) {
|
|
_lua.pop(1);
|
|
return GameDiff.empty;
|
|
}
|
|
if (!_lua.isTable(-1)) {
|
|
_lua.pop(1);
|
|
throw StateError('Lua function $name must return a table or nil');
|
|
}
|
|
|
|
final value = _readValue(-1);
|
|
_lua.pop(1);
|
|
|
|
if (value is! Map) {
|
|
throw StateError('Lua function $name returned a non-map value');
|
|
}
|
|
return GameDiff.fromMap(Map<String, Object?>.from(value));
|
|
}
|
|
|
|
void _installRuntimeApi() {
|
|
_lua.newTable();
|
|
|
|
_lua.newTable();
|
|
_lua.setField(-2, '__modules');
|
|
|
|
_lua.pushDartFunction(_importModule);
|
|
_lua.setField(-2, 'import');
|
|
|
|
_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.pushDartFunction(_hostCall);
|
|
_lua.setField(-2, 'host_call');
|
|
|
|
_lua.pushDartFunction(_hostNotify);
|
|
_lua.setField(-2, 'host_notify');
|
|
|
|
_lua.pushDartFunction(_hostRespond);
|
|
_lua.setField(-2, 'host_respond');
|
|
|
|
_lua.pushDartFunction(_storageGet);
|
|
_lua.setField(-2, 'storage_get');
|
|
|
|
_lua.pushDartFunction(_storageSet);
|
|
_lua.setField(-2, 'storage_set');
|
|
|
|
_lua.pushDartFunction(_storageRemove);
|
|
_lua.setField(-2, 'storage_remove');
|
|
|
|
_lua.pushDartFunction(_storageClear);
|
|
_lua.setField(-2, 'storage_clear');
|
|
|
|
_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;
|
|
}
|
|
|
|
int _hostCall(LuaState lua) {
|
|
final host = _requireHostBridge();
|
|
final options = _requiredMapArgument(1, 'runtime.host_call(options)');
|
|
final method = _requiredString(options, 'method');
|
|
final id = _optionalString(options, 'id') ?? _nextHostCallId();
|
|
async.unawaited(
|
|
host.callHost(
|
|
RuntimeHostCall(id: id, method: method, data: options['data']),
|
|
),
|
|
);
|
|
lua.pushString(id);
|
|
return 1;
|
|
}
|
|
|
|
int _hostNotify(LuaState lua) {
|
|
final host = _requireHostBridge();
|
|
final options = _requiredMapArgument(1, 'runtime.host_notify(options)');
|
|
final method = _requiredString(options, 'method');
|
|
lua.pushBoolean(
|
|
host.notifyHost(
|
|
RuntimeHostNotification(method: method, data: options['data']),
|
|
),
|
|
);
|
|
return 1;
|
|
}
|
|
|
|
int _hostRespond(LuaState lua) {
|
|
final host = _requireHostBridge();
|
|
final options = _requiredMapArgument(1, 'runtime.host_respond(options)');
|
|
final id = _requiredString(options, 'id');
|
|
final error = _optionalString(options, 'error');
|
|
lua.pushBoolean(
|
|
host.completeLuaCall(id, result: options['result'], error: error),
|
|
);
|
|
return 1;
|
|
}
|
|
|
|
int _storageGet(LuaState lua) {
|
|
final storage = _requireStorage();
|
|
final key = lua.toStr(1);
|
|
if (key == null || key.isEmpty) {
|
|
throw const FormatException('runtime.storage_get(key, defaultValue) requires key');
|
|
}
|
|
final defaultValue = _readValue(2);
|
|
_pushValue(storage.getValue(key, defaultValue));
|
|
return 1;
|
|
}
|
|
|
|
int _storageSet(LuaState lua) {
|
|
final storage = _requireStorage();
|
|
final key = lua.toStr(1);
|
|
if (key == null || key.isEmpty) {
|
|
throw const FormatException('runtime.storage_set(key, value) requires key');
|
|
}
|
|
final value = _readValue(2);
|
|
lua.pushBoolean(storage.setValue(key, value));
|
|
return 1;
|
|
}
|
|
|
|
int _storageRemove(LuaState lua) {
|
|
final storage = _requireStorage();
|
|
final key = lua.toStr(1);
|
|
if (key == null || key.isEmpty) {
|
|
throw const FormatException('runtime.storage_remove(key) requires key');
|
|
}
|
|
lua.pushBoolean(storage.remove(key));
|
|
return 1;
|
|
}
|
|
|
|
int _storageClear(LuaState lua) {
|
|
lua.pushBoolean(_requireStorage().clear());
|
|
return 1;
|
|
}
|
|
|
|
RuntimeHostBridgeManager _requireHostBridge() {
|
|
final hostBridge = _services.hostBridge;
|
|
if (hostBridge == null) {
|
|
throw StateError('Runtime host bridge service is not installed');
|
|
}
|
|
return hostBridge;
|
|
}
|
|
|
|
String _nextHostCallId() {
|
|
_hostCallCounter += 1;
|
|
return 'lua:$_hostCallCounter';
|
|
}
|
|
|
|
RuntimeNetworkManager _requireNetwork() {
|
|
final network = _services.network;
|
|
if (network == null) {
|
|
throw StateError('Runtime network service is not installed');
|
|
}
|
|
return network;
|
|
}
|
|
|
|
RuntimeStorageManager _requireStorage() {
|
|
final storage = _services.storage;
|
|
if (storage == null) {
|
|
throw StateError('Runtime storage service is not installed');
|
|
}
|
|
return storage;
|
|
}
|
|
|
|
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>[];
|
|
for (var index = 1; index <= argumentCount; index++) {
|
|
messageParts.add(_formatLuaLogValue(lua, index));
|
|
}
|
|
final message = messageParts.join(' ');
|
|
|
|
_diagnostics?.record(
|
|
type: RuntimeDiagnosticType.luaLog,
|
|
message: message,
|
|
context: {'argumentCount': argumentCount},
|
|
);
|
|
return 0;
|
|
}
|
|
|
|
int _importModule(LuaState lua) {
|
|
final moduleName = lua.toStr(1);
|
|
if (moduleName == null || moduleName.isEmpty) {
|
|
throw const FormatException(
|
|
'runtime.import(moduleName) requires a module name',
|
|
);
|
|
}
|
|
if (!_isSafeModuleName(moduleName)) {
|
|
throw FormatException('Unsafe Lua module name: $moduleName');
|
|
}
|
|
|
|
final source = _moduleScripts[moduleName];
|
|
if (source == null) {
|
|
throw FormatException(
|
|
'Lua module is not declared in manifest.modules: $moduleName',
|
|
);
|
|
}
|
|
|
|
lua.getGlobal('runtime');
|
|
lua.getField(-1, '__modules');
|
|
final modulesIndex = lua.absIndex(-1);
|
|
|
|
lua.getField(modulesIndex, moduleName);
|
|
if (!lua.isNil(-1)) {
|
|
lua.remove(-2);
|
|
lua.remove(-2);
|
|
return 1;
|
|
}
|
|
lua.pop(1);
|
|
|
|
if (_loadingModules.contains(moduleName)) {
|
|
lua.pop(2);
|
|
throw FormatException('Circular Lua module import: $moduleName');
|
|
}
|
|
|
|
_loadingModules.add(moduleName);
|
|
try {
|
|
final loadStatus = lua.loadString(source);
|
|
if (loadStatus != ThreadStatus.luaOk) {
|
|
final error = lua.toStr(-1) ?? 'unknown Lua module load error';
|
|
lua.pop(3);
|
|
throw StateError('Failed to load Lua module $moduleName: $error');
|
|
}
|
|
|
|
final callStatus = lua.pCall(0, 1, 0);
|
|
if (callStatus != ThreadStatus.luaOk) {
|
|
final error = lua.toStr(-1) ?? 'unknown Lua module runtime error';
|
|
lua.pop(3);
|
|
throw StateError('Failed to run Lua module $moduleName: $error');
|
|
}
|
|
|
|
if (lua.isNil(-1)) {
|
|
lua.pop(1);
|
|
lua.pushBoolean(true);
|
|
}
|
|
if (!lua.isTable(-1) && !lua.isBoolean(-1)) {
|
|
lua.pop(3);
|
|
throw StateError(
|
|
'Lua module $moduleName must return a table, true, or nil',
|
|
);
|
|
}
|
|
|
|
lua.pushValue(-1);
|
|
lua.setField(modulesIndex, moduleName);
|
|
lua.remove(-2);
|
|
lua.remove(-2);
|
|
return 1;
|
|
} finally {
|
|
_loadingModules.remove(moduleName);
|
|
}
|
|
}
|
|
|
|
String _formatLuaLogValue(LuaState lua, int index) {
|
|
if (lua.isNil(index) || lua.isNone(index)) {
|
|
return 'nil';
|
|
}
|
|
if (lua.isBoolean(index)) {
|
|
return lua.toBoolean(index).toString();
|
|
}
|
|
if (lua.isInteger(index)) {
|
|
return lua.toInteger(index).toString();
|
|
}
|
|
if (lua.isNumber(index)) {
|
|
return lua.toNumber(index).toString();
|
|
}
|
|
if (lua.isString(index)) {
|
|
return lua.toStr(index) ?? '';
|
|
}
|
|
return lua.typeName2(index);
|
|
}
|
|
|
|
bool _isSafeModuleName(String value) {
|
|
return RegExp(r'^[A-Za-z0-9_.-]+$').hasMatch(value) &&
|
|
!value.contains('..') &&
|
|
!value.startsWith('.') &&
|
|
!value.endsWith('.');
|
|
}
|
|
|
|
void _disableUnsafeGlobals() {
|
|
for (final name in const [
|
|
'os',
|
|
'package',
|
|
'dofile',
|
|
'loadfile',
|
|
'require',
|
|
]) {
|
|
_lua.pushNil();
|
|
_lua.setGlobal(name);
|
|
}
|
|
}
|
|
|
|
void _pushValue(Object? value) {
|
|
switch (value) {
|
|
case null:
|
|
_lua.pushNil();
|
|
case bool v:
|
|
_lua.pushBoolean(v);
|
|
case int v:
|
|
_lua.pushInteger(v);
|
|
case double v:
|
|
_lua.pushNumber(v);
|
|
case String v:
|
|
_lua.pushString(v);
|
|
case List<Object?> v:
|
|
_pushList(v);
|
|
case Map<String, Object?> v:
|
|
_pushMap(v);
|
|
default:
|
|
throw UnsupportedError('Unsupported value for Lua: $value');
|
|
}
|
|
}
|
|
|
|
void _pushList(List<Object?> values) {
|
|
_lua.newTable();
|
|
for (var i = 0; i < values.length; i++) {
|
|
_pushValue(values[i]);
|
|
_lua.setI(-2, i + 1);
|
|
}
|
|
}
|
|
|
|
void _pushMap(Map<String, Object?> values) {
|
|
_lua.newTable();
|
|
for (final entry in values.entries) {
|
|
_pushValue(entry.value);
|
|
_lua.setField(-2, entry.key);
|
|
}
|
|
}
|
|
|
|
Object? _readValue(int index) {
|
|
if (_lua.isNil(index) || _lua.isNone(index)) {
|
|
return null;
|
|
}
|
|
if (_lua.isBoolean(index)) {
|
|
return _lua.toBoolean(index);
|
|
}
|
|
if (_lua.isInteger(index)) {
|
|
return _lua.toInteger(index);
|
|
}
|
|
if (_lua.isNumber(index)) {
|
|
return _lua.toNumber(index);
|
|
}
|
|
if (_lua.isString(index)) {
|
|
return _lua.toStr(index);
|
|
}
|
|
if (_lua.isTable(index)) {
|
|
return _readTable(index);
|
|
}
|
|
throw UnsupportedError('Unsupported Lua type: ${_lua.typeName2(index)}');
|
|
}
|
|
|
|
Object _readTable(int index) {
|
|
final tableIndex = _lua.absIndex(index);
|
|
final length = _lua.rawLen(tableIndex);
|
|
final list = <Object?>[];
|
|
var hasOnlyArrayKeys = length > 0;
|
|
final map = <String, Object?>{};
|
|
|
|
_lua.pushNil();
|
|
while (_lua.next(tableIndex)) {
|
|
final value = _readValue(-1);
|
|
final key = _readValue(-2);
|
|
_lua.pop(1);
|
|
|
|
if (key is int && key >= 1 && key <= length) {
|
|
while (list.length < key) {
|
|
list.add(null);
|
|
}
|
|
list[key - 1] = value;
|
|
} else {
|
|
hasOnlyArrayKeys = false;
|
|
map[key.toString()] = value;
|
|
}
|
|
}
|
|
|
|
if (hasOnlyArrayKeys && map.isEmpty) {
|
|
return list;
|
|
}
|
|
|
|
for (var i = 0; i < list.length; i++) {
|
|
if (list[i] != null) {
|
|
map[(i + 1).toString()] = list[i];
|
|
}
|
|
}
|
|
return map;
|
|
}
|
|
}
|