feat: add package source compatibility controls
This commit is contained in:
@@ -133,6 +133,8 @@ A warning about missing `homepage` / `repository` is acceptable until real publi
|
||||
## Development rules
|
||||
|
||||
- Keep protocol fields white-listed and explicit.
|
||||
- Write code that ordinary maintainers can safely modify: keep control flow obvious, names explicit, abstractions shallow, and avoid clever or hidden behavior.
|
||||
- Remove deprecated, unused, or superseded code promptly when replacing behavior; do not leave parallel old paths unless a documented compatibility window requires them.
|
||||
- Prefer simple data models over implicit behavior.
|
||||
- Runtime commands must be generic, not game-specific.
|
||||
- Lua helper aliases are allowed only if normalized before protocol validation.
|
||||
|
||||
10
README.md
10
README.md
@@ -20,6 +20,8 @@ It is designed for Flutter apps that want to host Lua-authored 2D games or inter
|
||||
- Shared Lua helper modules under `assets/runtime/lua/`.
|
||||
- Configurable Runtime Lua asset root via `RuntimeOptions.runtimeLuaRoot`.
|
||||
- Multi-package loading: shared framework packages loaded once, game packages loaded on top.
|
||||
- Asset, local file, and remote package repositories for bundled, development, and hot-update workflows.
|
||||
- Remote package compatibility checks for Runtime version, host build, platform, and release channel.
|
||||
|
||||
## Example
|
||||
|
||||
@@ -95,6 +97,14 @@ The game manifest declares package-local scripts and shared Runtime Lua modules:
|
||||
}
|
||||
```
|
||||
|
||||
## Package loading modes
|
||||
|
||||
- `AssetGamePackageRepository`: bundled app assets and fallback packages.
|
||||
- `FileGamePackageRepository`: local development directory, useful when large images should not be bundled into the app during iteration.
|
||||
- `RemoteGamePackageRepository`: remote zip packages with sha256 verification, compatibility checks, and stable cache fallback.
|
||||
|
||||
Remote compatibility is configured with `RuntimeOptions.runtimeVersion`, `hostBuild`, `platform`, and `channel`.
|
||||
|
||||
## Runtime asset path
|
||||
|
||||
When used as a published package, configure:
|
||||
|
||||
@@ -142,6 +142,80 @@ Check without rewriting:
|
||||
dart run tool/generate_lua_runtime_defs.dart --check
|
||||
```
|
||||
|
||||
## Package sources
|
||||
|
||||
Host apps can load packages from three common sources:
|
||||
|
||||
```dart
|
||||
// Bundled app assets.
|
||||
AssetGamePackageRepository(runtimeOptions: runtimeOptions)
|
||||
|
||||
// Local development directory, useful when images should not be bundled into app assets.
|
||||
FileGamePackageRepository(
|
||||
baseDirectory: 'E:/lua_packages',
|
||||
runtimeOptions: runtimeOptions,
|
||||
)
|
||||
|
||||
// Remote update server.
|
||||
RemoteGamePackageRepository(
|
||||
baseUri: Uri.parse('https://example.com/lua-packages/'),
|
||||
runtimeOptions: runtimeOptions,
|
||||
)
|
||||
```
|
||||
|
||||
A local development directory uses the same package layout as a downloaded remote zip:
|
||||
|
||||
```text
|
||||
E:/lua_packages/gomoku/
|
||||
manifest.json
|
||||
scripts/
|
||||
assets/
|
||||
```
|
||||
|
||||
## Remote compatibility
|
||||
|
||||
Remote manifests may include a `compat` block. The server should use request query values to return the newest compatible package, and the client validates the returned manifest again before download.
|
||||
|
||||
```json
|
||||
{
|
||||
"gameId": "gomoku",
|
||||
"version": "0.3.0",
|
||||
"packageUrl": "https://example.com/packages/gomoku-0.3.0.zip",
|
||||
"sha256": "...",
|
||||
"compat": {
|
||||
"runtimeApiVersion": 1,
|
||||
"minRuntimeVersion": "0.4.0",
|
||||
"maxRuntimeVersion": "0.4.9",
|
||||
"minHostBuild": 120,
|
||||
"platforms": ["windows", "android"],
|
||||
"channels": ["dev", "prod"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`RemoteGamePackageRepository` sends these query parameters when fetching `remote_manifest.json`:
|
||||
|
||||
```text
|
||||
runtimeApiVersion
|
||||
runtimeVersion
|
||||
hostBuild
|
||||
platform
|
||||
channel
|
||||
```
|
||||
|
||||
Configure them through `RuntimeOptions`:
|
||||
|
||||
```dart
|
||||
RuntimeOptions(
|
||||
runtimeVersion: '0.4.0',
|
||||
hostBuild: 120,
|
||||
platform: 'windows',
|
||||
channel: 'dev',
|
||||
)
|
||||
```
|
||||
|
||||
If compatibility fails, the remote package is not downloaded. The repository falls back to stable cache, previous stable cache, then bundled assets.
|
||||
|
||||
## Package validation
|
||||
|
||||
A host repository can validate a game package with:
|
||||
|
||||
@@ -16,9 +16,11 @@ export 'runtime/host/runtime_host_bridge.dart'
|
||||
export 'runtime/packages/game_package_repository.dart'
|
||||
show
|
||||
AssetGamePackageRepository,
|
||||
FileGamePackageRepository,
|
||||
GamePackageRepository,
|
||||
RemoteGamePackageRepository;
|
||||
export 'runtime/scripting/lua_dardo_script_engine.dart'
|
||||
show LuaDardoScriptEngine;
|
||||
export 'runtime/scripting/script_engine.dart' show ScriptEngine;
|
||||
export 'runtime/storage/runtime_storage_manager.dart' show RuntimeStorageManager;
|
||||
export 'runtime/storage/runtime_storage_manager.dart'
|
||||
show RuntimeStorageManager;
|
||||
|
||||
@@ -2,6 +2,10 @@ class RuntimeOptions {
|
||||
const RuntimeOptions({
|
||||
this.runtimeLuaRoot = defaultRuntimeLuaRoot,
|
||||
this.basePackages = const [],
|
||||
this.runtimeVersion = '0.0.0',
|
||||
this.hostBuild = 0,
|
||||
this.channel = 'dev',
|
||||
this.platform,
|
||||
});
|
||||
|
||||
static const defaultRuntimeLuaRoot = 'assets/runtime/lua';
|
||||
@@ -11,4 +15,17 @@ class RuntimeOptions {
|
||||
// 框架包 gameId 列表,按顺序先于游戏包加载。
|
||||
// 后加载的同名模块覆盖先加载的。
|
||||
final List<String> basePackages;
|
||||
|
||||
// 宿主 Flutter App 当前集成的 Lua Runtime 版本。
|
||||
// 远程包可通过 minRuntimeVersion/maxRuntimeVersion 限制兼容范围。
|
||||
final String runtimeVersion;
|
||||
|
||||
// 宿主 App 构建号。远程包可通过 minHostBuild/maxHostBuild 避免旧 App 拉取新包。
|
||||
final int hostBuild;
|
||||
|
||||
// dev/staging/prod 等发布通道,用于服务器和客户端双重筛选远程包。
|
||||
final String channel;
|
||||
|
||||
// 平台名,例如 windows/android/ios。为空时 Runtime 会尝试自动识别。
|
||||
final String? platform;
|
||||
}
|
||||
|
||||
@@ -150,10 +150,7 @@ class PackageActivationController {
|
||||
// 加载 base packages(框架包),按 runtimeOptions.basePackages 顺序。
|
||||
final basePackages = <GamePackage>[];
|
||||
for (final baseId in runtimeOptions.basePackages) {
|
||||
final baseCandidates = await _candidatePackages(
|
||||
baseId,
|
||||
shouldContinue,
|
||||
);
|
||||
final baseCandidates = await _candidatePackages(baseId, shouldContinue);
|
||||
for (final baseCandidate in baseCandidates) {
|
||||
try {
|
||||
await verifier.verify(baseCandidate);
|
||||
|
||||
@@ -38,6 +38,35 @@ class AssetGamePackageRepository implements GamePackageRepository {
|
||||
}
|
||||
}
|
||||
|
||||
class FileGamePackageRepository implements GamePackageRepository {
|
||||
const FileGamePackageRepository({
|
||||
required this.baseDirectory,
|
||||
this.runtimeOptions = const RuntimeOptions(),
|
||||
});
|
||||
|
||||
final String baseDirectory;
|
||||
final RuntimeOptions runtimeOptions;
|
||||
|
||||
@override
|
||||
Future<GamePackage> load(String gameId) async {
|
||||
final root = Directory(p.join(baseDirectory, gameId));
|
||||
final manifestFile = File(p.join(root.path, 'manifest.json'));
|
||||
if (!manifestFile.existsSync()) {
|
||||
throw FileSystemException(
|
||||
'Game package manifest not found',
|
||||
manifestFile.path,
|
||||
);
|
||||
}
|
||||
return GamePackage.file(
|
||||
rootPath: root.path,
|
||||
manifest: GamePackageManifest.fromJsonString(
|
||||
await manifestFile.readAsString(),
|
||||
),
|
||||
runtimeLuaRoot: runtimeOptions.runtimeLuaRoot,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class RemoteGamePackageRepository implements GamePackageRepository {
|
||||
RemoteGamePackageRepository({
|
||||
required this.baseUri,
|
||||
@@ -103,6 +132,10 @@ class RemoteGamePackageRepository implements GamePackageRepository {
|
||||
if (remoteManifest.gameId != gameId) {
|
||||
throw const FormatException('Remote manifest gameId mismatch');
|
||||
}
|
||||
remoteManifest.compatibility.verify(
|
||||
runtimeApiVersion: runtimeApiVersion,
|
||||
runtimeOptions: runtimeOptions,
|
||||
);
|
||||
|
||||
final packageRoot = await _downloadAndExtract(
|
||||
client,
|
||||
@@ -130,7 +163,7 @@ class RemoteGamePackageRepository implements GamePackageRepository {
|
||||
http.Client client,
|
||||
String gameId,
|
||||
) async {
|
||||
final uri = baseUri.resolve('$gameId/remote_manifest.json');
|
||||
final uri = _remoteManifestUri(gameId);
|
||||
final response = await client.get(uri);
|
||||
if (response.statusCode != 200) {
|
||||
throw HttpException(
|
||||
@@ -143,6 +176,19 @@ class RemoteGamePackageRepository implements GamePackageRepository {
|
||||
);
|
||||
}
|
||||
|
||||
Uri _remoteManifestUri(String gameId) {
|
||||
final uri = baseUri.resolve('$gameId/remote_manifest.json');
|
||||
final query = Map<String, String>.from(uri.queryParameters)
|
||||
..addAll({
|
||||
'runtimeApiVersion': runtimeApiVersion.toString(),
|
||||
'runtimeVersion': runtimeOptions.runtimeVersion,
|
||||
'hostBuild': runtimeOptions.hostBuild.toString(),
|
||||
'platform': _platformName(runtimeOptions.platform),
|
||||
'channel': runtimeOptions.channel,
|
||||
});
|
||||
return uri.replace(queryParameters: query);
|
||||
}
|
||||
|
||||
Future<Directory> _downloadAndExtract(
|
||||
http.Client client,
|
||||
String gameId,
|
||||
@@ -200,12 +246,14 @@ class RemotePackageManifest {
|
||||
required this.version,
|
||||
required this.packageUrl,
|
||||
required this.sha256,
|
||||
this.compatibility = const RemotePackageCompatibility(),
|
||||
});
|
||||
|
||||
final String gameId;
|
||||
final String version;
|
||||
final Uri packageUrl;
|
||||
final String sha256;
|
||||
final RemotePackageCompatibility compatibility;
|
||||
|
||||
static RemotePackageManifest fromMap(Map<String, Object?> map) {
|
||||
return RemotePackageManifest(
|
||||
@@ -213,6 +261,7 @@ class RemotePackageManifest {
|
||||
version: _string(map, 'version'),
|
||||
packageUrl: Uri.parse(_string(map, 'packageUrl')),
|
||||
sha256: _string(map, 'sha256'),
|
||||
compatibility: RemotePackageCompatibility.fromMap(map['compat']),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -224,3 +273,182 @@ class RemotePackageManifest {
|
||||
throw FormatException('remote_manifest.$key must be a non-empty string');
|
||||
}
|
||||
}
|
||||
|
||||
class RemotePackageCompatibility {
|
||||
const RemotePackageCompatibility({
|
||||
this.runtimeApiVersion,
|
||||
this.minRuntimeVersion,
|
||||
this.maxRuntimeVersion,
|
||||
this.minHostBuild,
|
||||
this.maxHostBuild,
|
||||
this.platforms = const [],
|
||||
this.channels = const [],
|
||||
});
|
||||
|
||||
final int? runtimeApiVersion;
|
||||
final String? minRuntimeVersion;
|
||||
final String? maxRuntimeVersion;
|
||||
final int? minHostBuild;
|
||||
final int? maxHostBuild;
|
||||
final List<String> platforms;
|
||||
final List<String> channels;
|
||||
|
||||
static RemotePackageCompatibility fromMap(Object? value) {
|
||||
if (value == null) {
|
||||
return const RemotePackageCompatibility();
|
||||
}
|
||||
if (value is! Map) {
|
||||
throw const FormatException('remote_manifest.compat must be a map');
|
||||
}
|
||||
final map = Map<String, Object?>.from(value);
|
||||
return RemotePackageCompatibility(
|
||||
runtimeApiVersion: _optionalInt(map, 'runtimeApiVersion'),
|
||||
minRuntimeVersion: _optionalString(map, 'minRuntimeVersion'),
|
||||
maxRuntimeVersion: _optionalString(map, 'maxRuntimeVersion'),
|
||||
minHostBuild: _optionalInt(map, 'minHostBuild'),
|
||||
maxHostBuild: _optionalInt(map, 'maxHostBuild'),
|
||||
platforms: _optionalStringList(map, 'platforms'),
|
||||
channels: _optionalStringList(map, 'channels'),
|
||||
);
|
||||
}
|
||||
|
||||
void verify({
|
||||
required int runtimeApiVersion,
|
||||
required RuntimeOptions runtimeOptions,
|
||||
}) {
|
||||
if (this.runtimeApiVersion != null &&
|
||||
this.runtimeApiVersion != runtimeApiVersion) {
|
||||
throw const FormatException('Remote package runtimeApiVersion mismatch');
|
||||
}
|
||||
if (minRuntimeVersion != null &&
|
||||
_compareVersions(runtimeOptions.runtimeVersion, minRuntimeVersion!) <
|
||||
0) {
|
||||
throw const FormatException('Remote package requires newer runtime');
|
||||
}
|
||||
if (maxRuntimeVersion != null &&
|
||||
_compareVersions(runtimeOptions.runtimeVersion, maxRuntimeVersion!) >
|
||||
0) {
|
||||
throw const FormatException(
|
||||
'Remote package does not support this runtime',
|
||||
);
|
||||
}
|
||||
if (minHostBuild != null && runtimeOptions.hostBuild < minHostBuild!) {
|
||||
throw const FormatException('Remote package requires newer host build');
|
||||
}
|
||||
if (maxHostBuild != null && runtimeOptions.hostBuild > maxHostBuild!) {
|
||||
throw const FormatException(
|
||||
'Remote package does not support this host build',
|
||||
);
|
||||
}
|
||||
final platform = _platformName(runtimeOptions.platform);
|
||||
if (platforms.isNotEmpty && !platforms.contains(platform)) {
|
||||
throw const FormatException(
|
||||
'Remote package does not support this platform',
|
||||
);
|
||||
}
|
||||
if (channels.isNotEmpty && !channels.contains(runtimeOptions.channel)) {
|
||||
throw const FormatException(
|
||||
'Remote package does not support this channel',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
static 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('remote_manifest.compat.$key must be a string');
|
||||
}
|
||||
|
||||
static int? _optionalInt(Map<String, Object?> map, String key) {
|
||||
final value = map[key];
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
if (value is num) {
|
||||
return value.toInt();
|
||||
}
|
||||
throw FormatException('remote_manifest.compat.$key must be an integer');
|
||||
}
|
||||
|
||||
static List<String> _optionalStringList(
|
||||
Map<String, Object?> map,
|
||||
String key,
|
||||
) {
|
||||
final value = map[key];
|
||||
if (value == null) {
|
||||
return const [];
|
||||
}
|
||||
if (value is! List) {
|
||||
throw FormatException(
|
||||
'remote_manifest.compat.$key must be a string list',
|
||||
);
|
||||
}
|
||||
return value
|
||||
.map((item) {
|
||||
if (item is String && item.isNotEmpty) {
|
||||
return item;
|
||||
}
|
||||
throw FormatException(
|
||||
'remote_manifest.compat.$key must be a string list',
|
||||
);
|
||||
})
|
||||
.toList(growable: false);
|
||||
}
|
||||
}
|
||||
|
||||
String _platformName(String? explicitPlatform) {
|
||||
if (explicitPlatform != null && explicitPlatform.isNotEmpty) {
|
||||
return explicitPlatform;
|
||||
}
|
||||
if (Platform.isAndroid) {
|
||||
return 'android';
|
||||
}
|
||||
if (Platform.isIOS) {
|
||||
return 'ios';
|
||||
}
|
||||
if (Platform.isMacOS) {
|
||||
return 'macos';
|
||||
}
|
||||
if (Platform.isWindows) {
|
||||
return 'windows';
|
||||
}
|
||||
if (Platform.isLinux) {
|
||||
return 'linux';
|
||||
}
|
||||
if (Platform.isFuchsia) {
|
||||
return 'fuchsia';
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
int _compareVersions(String left, String right) {
|
||||
final leftParts = _versionParts(left);
|
||||
final rightParts = _versionParts(right);
|
||||
final count = leftParts.length > rightParts.length
|
||||
? leftParts.length
|
||||
: rightParts.length;
|
||||
for (var index = 0; index < count; index++) {
|
||||
final leftValue = index < leftParts.length ? leftParts[index] : 0;
|
||||
final rightValue = index < rightParts.length ? rightParts[index] : 0;
|
||||
if (leftValue != rightValue) {
|
||||
return leftValue.compareTo(rightValue);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
List<int> _versionParts(String value) {
|
||||
final normalized = value.split('-').first;
|
||||
return normalized
|
||||
.split('.')
|
||||
.map((part) {
|
||||
final digits = RegExp(r'^\d+').firstMatch(part)?.group(0);
|
||||
return int.tryParse(digits ?? '') ?? 0;
|
||||
})
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
@@ -32,34 +32,31 @@ class StablePackageStore {
|
||||
return;
|
||||
}
|
||||
final marker = await _markerFile(package.manifest.gameId);
|
||||
marker.createSync(recursive: true);
|
||||
marker.parent.createSync(recursive: true);
|
||||
final previous = await stablePackage(package.manifest.gameId);
|
||||
final data = {
|
||||
'current': package.rootPath,
|
||||
if (previous != null && previous.rootPath != package.rootPath)
|
||||
'previous': previous.rootPath,
|
||||
};
|
||||
marker.writeAsStringSync(const JsonEncoder.withIndent(' ').convert(data));
|
||||
final temporary = File('${marker.path}.tmp');
|
||||
temporary.writeAsStringSync(
|
||||
const JsonEncoder.withIndent(' ').convert(data),
|
||||
);
|
||||
if (marker.existsSync()) {
|
||||
marker.deleteSync();
|
||||
}
|
||||
temporary.renameSync(marker.path);
|
||||
}
|
||||
|
||||
Future<GamePackage?> stablePackage(String gameId) async {
|
||||
final marker = await _markerFile(gameId);
|
||||
if (!marker.existsSync()) {
|
||||
return null;
|
||||
}
|
||||
final data =
|
||||
jsonDecode(await marker.readAsString()) as Map<String, Object?>;
|
||||
return _packageFromPath(data['current']);
|
||||
final data = await _readMarker(gameId);
|
||||
return _packageFromPath(data?['current']);
|
||||
}
|
||||
|
||||
Future<GamePackage?> previousStablePackage(String gameId) async {
|
||||
final marker = await _markerFile(gameId);
|
||||
if (!marker.existsSync()) {
|
||||
return null;
|
||||
}
|
||||
final data =
|
||||
jsonDecode(await marker.readAsString()) as Map<String, Object?>;
|
||||
return _packageFromPath(data['previous']);
|
||||
final data = await _readMarker(gameId);
|
||||
return _packageFromPath(data?['previous']);
|
||||
}
|
||||
|
||||
Future<File> _markerFile(String gameId) async {
|
||||
@@ -67,6 +64,26 @@ class StablePackageStore {
|
||||
return File(p.join(root.path, gameId, 'stable.json'));
|
||||
}
|
||||
|
||||
Future<Map<String, Object?>?> _readMarker(String gameId) async {
|
||||
final marker = await _markerFile(gameId);
|
||||
if (!marker.existsSync()) {
|
||||
return null;
|
||||
}
|
||||
final source = await marker.readAsString();
|
||||
if (source.trim().isEmpty) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
final value = jsonDecode(source);
|
||||
if (value is Map) {
|
||||
return Map<String, Object?>.from(value);
|
||||
}
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
GamePackage? _packageFromPath(Object? pathValue) {
|
||||
if (pathValue is! String || pathValue.isEmpty) {
|
||||
return null;
|
||||
|
||||
@@ -289,7 +289,9 @@ class LuaDardoScriptEngine implements ScriptEngine {
|
||||
final storage = _requireStorage();
|
||||
final key = lua.toStr(1);
|
||||
if (key == null || key.isEmpty) {
|
||||
throw const FormatException('runtime.storage_get(key, defaultValue) requires key');
|
||||
throw const FormatException(
|
||||
'runtime.storage_get(key, defaultValue) requires key',
|
||||
);
|
||||
}
|
||||
final defaultValue = _readValue(2);
|
||||
_pushValue(storage.getValue(key, defaultValue));
|
||||
@@ -300,7 +302,9 @@ class LuaDardoScriptEngine implements ScriptEngine {
|
||||
final storage = _requireStorage();
|
||||
final key = lua.toStr(1);
|
||||
if (key == null || key.isEmpty) {
|
||||
throw const FormatException('runtime.storage_set(key, value) requires key');
|
||||
throw const FormatException(
|
||||
'runtime.storage_set(key, value) requires key',
|
||||
);
|
||||
}
|
||||
final value = _readValue(2);
|
||||
lua.pushBoolean(storage.setValue(key, value));
|
||||
|
||||
@@ -25,10 +25,7 @@ class RuntimeStorageManager {
|
||||
try {
|
||||
final raw = jsonDecode(file.readAsStringSync());
|
||||
if (raw is Map) {
|
||||
return RuntimeStorageManager._(
|
||||
file,
|
||||
Map<String, Object?>.from(raw),
|
||||
);
|
||||
return RuntimeStorageManager._(file, Map<String, Object?>.from(raw));
|
||||
}
|
||||
} catch (_) {
|
||||
// Corrupt storage should not prevent a game from loading.
|
||||
@@ -78,7 +75,8 @@ class RuntimeStorageManager {
|
||||
}
|
||||
if (value is Map) {
|
||||
return {
|
||||
for (final entry in value.entries) entry.key.toString(): _normalize(entry.value),
|
||||
for (final entry in value.entries)
|
||||
entry.key.toString(): _normalize(entry.value),
|
||||
};
|
||||
}
|
||||
return value.toString();
|
||||
|
||||
220
test/runtime/packages/game_package_repository_test.dart
Normal file
220
test/runtime/packages/game_package_repository_test.dart
Normal file
@@ -0,0 +1,220 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flame_lua_runtime/runtime/game/runtime_options.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/packages/stable_package_store.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
|
||||
void main() {
|
||||
group('FileGamePackageRepository', () {
|
||||
test('loads a package from a local development directory', () async {
|
||||
final root = await Directory.systemTemp.createTemp('local_packages_');
|
||||
addTearDown(() => root.deleteSync(recursive: true));
|
||||
await _writePackage(root.path, 'gomoku', version: '0.2.0');
|
||||
|
||||
final package = await FileGamePackageRepository(
|
||||
baseDirectory: root.path,
|
||||
runtimeOptions: const RuntimeOptions(runtimeLuaRoot: 'runtime/lua'),
|
||||
).load('gomoku');
|
||||
|
||||
expect(package.source, GamePackageSource.file);
|
||||
expect(package.manifest.gameId, 'gomoku');
|
||||
expect(package.manifest.version, '0.2.0');
|
||||
expect(package.runtimeLuaRoot, 'runtime/lua');
|
||||
expect(await package.readText('scripts/main.lua'), contains('init'));
|
||||
});
|
||||
});
|
||||
|
||||
group('RemoteGamePackageRepository compatibility', () {
|
||||
test(
|
||||
'sends host compatibility query and falls back when incompatible',
|
||||
() async {
|
||||
late Uri requestedUri;
|
||||
var downloadedPackage = false;
|
||||
final fallback = await _createPackage('fallback');
|
||||
final client = MockClient((request) async {
|
||||
requestedUri = request.url;
|
||||
if (request.url.path.endsWith('/remote_manifest.json')) {
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'gameId': 'gomoku',
|
||||
'version': '2.0.0',
|
||||
'packageUrl': 'http://example.test/packages/gomoku.zip',
|
||||
'sha256': 'unused',
|
||||
'compat': {
|
||||
'runtimeApiVersion': 1,
|
||||
'minRuntimeVersion': '1.2.0',
|
||||
'minHostBuild': 200,
|
||||
'platforms': ['windows'],
|
||||
'channels': ['prod'],
|
||||
},
|
||||
}),
|
||||
200,
|
||||
);
|
||||
}
|
||||
downloadedPackage = true;
|
||||
return http.Response('not used', 500);
|
||||
});
|
||||
|
||||
final package = await RemoteGamePackageRepository(
|
||||
baseUri: Uri.parse('http://example.test/'),
|
||||
client: client,
|
||||
fallback: _SinglePackageRepository(fallback),
|
||||
store: _EmptyStablePackageStore(),
|
||||
runtimeOptions: const RuntimeOptions(
|
||||
runtimeVersion: '1.1.0',
|
||||
hostBuild: 100,
|
||||
channel: 'prod',
|
||||
platform: 'windows',
|
||||
),
|
||||
).load('gomoku');
|
||||
|
||||
expect(package.rootPath, fallback.rootPath);
|
||||
expect(requestedUri.queryParameters['runtimeApiVersion'], '1');
|
||||
expect(requestedUri.queryParameters['runtimeVersion'], '1.1.0');
|
||||
expect(requestedUri.queryParameters['hostBuild'], '100');
|
||||
expect(requestedUri.queryParameters['platform'], 'windows');
|
||||
expect(requestedUri.queryParameters['channel'], 'prod');
|
||||
expect(downloadedPackage, isFalse);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('StablePackageStore', () {
|
||||
const channel = MethodChannel('plugins.flutter.io/path_provider');
|
||||
|
||||
setUp(() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(channel, null);
|
||||
});
|
||||
|
||||
test('ignores empty or malformed stable marker files', () async {
|
||||
final support = await Directory.systemTemp.createTemp('support_');
|
||||
addTearDown(() => support.deleteSync(recursive: true));
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(channel, (call) async {
|
||||
if (call.method == 'getApplicationSupportDirectory') {
|
||||
return support.path;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
final store = const StablePackageStore();
|
||||
final gameDir = Directory('${support.path}/flame_lua_packages/gomoku')
|
||||
..createSync(recursive: true);
|
||||
final marker = File('${gameDir.path}/stable.json');
|
||||
|
||||
marker.writeAsStringSync('');
|
||||
expect(await store.stablePackage('gomoku'), isNull);
|
||||
expect(await store.previousStablePackage('gomoku'), isNull);
|
||||
|
||||
marker.writeAsStringSync('{bad json');
|
||||
expect(await store.stablePackage('gomoku'), isNull);
|
||||
expect(await store.previousStablePackage('gomoku'), isNull);
|
||||
});
|
||||
|
||||
test('writes stable marker atomically and reads current package', () async {
|
||||
final support = await Directory.systemTemp.createTemp('support_');
|
||||
addTearDown(() => support.deleteSync(recursive: true));
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(channel, (call) async {
|
||||
if (call.method == 'getApplicationSupportDirectory') {
|
||||
return support.path;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
final package = await _createPackage('stable');
|
||||
final store = const StablePackageStore();
|
||||
|
||||
await store.markStable(package);
|
||||
|
||||
final marker = File(
|
||||
'${support.path}/flame_lua_packages/gomoku/stable.json',
|
||||
);
|
||||
expect(marker.existsSync(), isTrue);
|
||||
expect(File('${marker.path}.tmp').existsSync(), isFalse);
|
||||
final stable = await store.stablePackage('gomoku');
|
||||
expect(stable?.rootPath, package.rootPath);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Future<GamePackage> _createPackage(String name) async {
|
||||
final root = await Directory.systemTemp.createTemp('package_${name}_');
|
||||
addTearDown(() => root.deleteSync(recursive: true));
|
||||
await _writePackageRoot(root.path, gameId: 'gomoku', version: '1.0.0');
|
||||
return GamePackage.file(
|
||||
rootPath: root.path,
|
||||
manifest: GamePackageManifest.fromJsonString(
|
||||
await File('${root.path}/manifest.json').readAsString(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _writePackage(
|
||||
String baseDirectory,
|
||||
String gameId, {
|
||||
required String version,
|
||||
}) async {
|
||||
final root = Directory('$baseDirectory/$gameId')..createSync(recursive: true);
|
||||
await _writePackageRoot(root.path, gameId: gameId, version: version);
|
||||
}
|
||||
|
||||
Future<void> _writePackageRoot(
|
||||
String root, {
|
||||
required String gameId,
|
||||
required String version,
|
||||
}) async {
|
||||
Directory('$root/scripts').createSync(recursive: true);
|
||||
await File('$root/scripts/main.lua').writeAsString('''
|
||||
function smoke_test(ctx) return true end
|
||||
function init(ctx) return {} end
|
||||
function on_event(event) return {} end
|
||||
''');
|
||||
await File('$root/manifest.json').writeAsString(
|
||||
jsonEncode({
|
||||
'gameId': gameId,
|
||||
'name': gameId,
|
||||
'version': version,
|
||||
'runtimeApiVersion': 1,
|
||||
'entry': 'scripts/main.lua',
|
||||
'assetsBase': 'assets',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
class _SinglePackageRepository implements GamePackageRepository {
|
||||
const _SinglePackageRepository(this.package);
|
||||
|
||||
final GamePackage package;
|
||||
|
||||
@override
|
||||
Future<GamePackage> load(String gameId) async => package;
|
||||
}
|
||||
|
||||
class _EmptyStablePackageStore implements StablePackageStore {
|
||||
@override
|
||||
Future<Directory> cacheRoot() => throw UnimplementedError();
|
||||
|
||||
@override
|
||||
Future<void> markStable(GamePackage package) async {}
|
||||
|
||||
@override
|
||||
Future<GamePackage?> previousStablePackage(String gameId) async => null;
|
||||
|
||||
@override
|
||||
Future<GamePackage?> stablePackage(String gameId) async => null;
|
||||
|
||||
@override
|
||||
Future<Directory> versionDirectory(String gameId, String version) =>
|
||||
throw UnimplementedError();
|
||||
}
|
||||
@@ -4,7 +4,14 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
void main() {
|
||||
test('public runtime API exposes minimal integration surface', () {
|
||||
const repository = AssetGamePackageRepository();
|
||||
const options = RuntimeOptions(runtimeLuaRoot: 'custom/runtime/lua');
|
||||
const fileRepository = FileGamePackageRepository(baseDirectory: 'packages');
|
||||
const options = RuntimeOptions(
|
||||
runtimeLuaRoot: 'custom/runtime/lua',
|
||||
runtimeVersion: '1.2.0',
|
||||
hostBuild: 12,
|
||||
platform: 'windows',
|
||||
channel: 'dev',
|
||||
);
|
||||
const widget = LuaGameWidget(
|
||||
gameId: 'template',
|
||||
packageRepository: repository,
|
||||
@@ -14,6 +21,11 @@ void main() {
|
||||
expect(widget.gameId, 'template');
|
||||
expect(widget.packageRepository, same(repository));
|
||||
expect(widget.runtimeOptions.runtimeLuaRoot, 'custom/runtime/lua');
|
||||
expect(widget.runtimeOptions.runtimeVersion, '1.2.0');
|
||||
expect(widget.runtimeOptions.hostBuild, 12);
|
||||
expect(widget.runtimeOptions.platform, 'windows');
|
||||
expect(widget.runtimeOptions.channel, 'dev');
|
||||
expect(fileRepository.baseDirectory, 'packages');
|
||||
expect(LuaDardoScriptEngine.new, isA<ScriptEngine Function()>());
|
||||
expect(RuntimeLocaleResolver.localeFromTag('zh-Hans').scriptCode, 'Hans');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user