Files
flutter_lua_runtime/lib/runtime/scripting/lua_dardo_script_engine.dart
2026-06-09 16:09:19 +08:00

510 lines
13 KiB
Dart

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 {
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;
final Set<String> _loadingModules = {};
@override
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) {
_moduleScripts[entry.key] = await package.readText(entry.value);
}
_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.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>[];
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;
}
}