From 79ee35db2fcb5132d2a33cf94a5aaa450f8c5b9c Mon Sep 17 00:00:00 2001 From: gem Date: Wed, 10 Jun 2026 17:54:12 +0800 Subject: [PATCH] feat: add package source compatibility controls --- AGENTS.md | 2 + README.md | 10 + docs/lua-package-format.md | 74 ++++++ lib/flame_lua_runtime.dart | 4 +- lib/runtime/game/runtime_options.dart | 17 ++ .../game_package_activation_controller.dart | 5 +- .../packages/game_package_repository.dart | 230 +++++++++++++++++- .../packages/stable_package_store.dart | 49 ++-- .../scripting/lua_dardo_script_engine.dart | 8 +- .../storage/runtime_storage_manager.dart | 8 +- .../game_package_repository_test.dart | 220 +++++++++++++++++ test/runtime/public_api_test.dart | 14 +- 12 files changed, 611 insertions(+), 30 deletions(-) create mode 100644 test/runtime/packages/game_package_repository_test.dart diff --git a/AGENTS.md b/AGENTS.md index 5c21579..ec7b869 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/README.md b/README.md index 7436a9a..de054e1 100644 --- a/README.md +++ b/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: diff --git a/docs/lua-package-format.md b/docs/lua-package-format.md index a71d324..6a15103 100644 --- a/docs/lua-package-format.md +++ b/docs/lua-package-format.md @@ -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: diff --git a/lib/flame_lua_runtime.dart b/lib/flame_lua_runtime.dart index 3d8f0bd..67a59e4 100644 --- a/lib/flame_lua_runtime.dart +++ b/lib/flame_lua_runtime.dart @@ -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; diff --git a/lib/runtime/game/runtime_options.dart b/lib/runtime/game/runtime_options.dart index d527121..8e38c02 100644 --- a/lib/runtime/game/runtime_options.dart +++ b/lib/runtime/game/runtime_options.dart @@ -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 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; } diff --git a/lib/runtime/packages/game_package_activation_controller.dart b/lib/runtime/packages/game_package_activation_controller.dart index 9741bd0..8bcc4b5 100644 --- a/lib/runtime/packages/game_package_activation_controller.dart +++ b/lib/runtime/packages/game_package_activation_controller.dart @@ -150,10 +150,7 @@ class PackageActivationController { // 加载 base packages(框架包),按 runtimeOptions.basePackages 顺序。 final basePackages = []; 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); diff --git a/lib/runtime/packages/game_package_repository.dart b/lib/runtime/packages/game_package_repository.dart index cc6b5c6..d73f8eb 100644 --- a/lib/runtime/packages/game_package_repository.dart +++ b/lib/runtime/packages/game_package_repository.dart @@ -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 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.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 _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 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 platforms; + final List 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.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 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 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 _optionalStringList( + Map 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 _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); +} diff --git a/lib/runtime/packages/stable_package_store.dart b/lib/runtime/packages/stable_package_store.dart index cd578ea..4eea0bd 100644 --- a/lib/runtime/packages/stable_package_store.dart +++ b/lib/runtime/packages/stable_package_store.dart @@ -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 stablePackage(String gameId) async { - final marker = await _markerFile(gameId); - if (!marker.existsSync()) { - return null; - } - final data = - jsonDecode(await marker.readAsString()) as Map; - return _packageFromPath(data['current']); + final data = await _readMarker(gameId); + return _packageFromPath(data?['current']); } Future previousStablePackage(String gameId) async { - final marker = await _markerFile(gameId); - if (!marker.existsSync()) { - return null; - } - final data = - jsonDecode(await marker.readAsString()) as Map; - return _packageFromPath(data['previous']); + final data = await _readMarker(gameId); + return _packageFromPath(data?['previous']); } Future _markerFile(String gameId) async { @@ -67,6 +64,26 @@ class StablePackageStore { return File(p.join(root.path, gameId, 'stable.json')); } + Future?> _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.from(value); + } + } catch (_) { + return null; + } + return null; + } + GamePackage? _packageFromPath(Object? pathValue) { if (pathValue is! String || pathValue.isEmpty) { return null; diff --git a/lib/runtime/scripting/lua_dardo_script_engine.dart b/lib/runtime/scripting/lua_dardo_script_engine.dart index 0781b0b..65ee1cb 100644 --- a/lib/runtime/scripting/lua_dardo_script_engine.dart +++ b/lib/runtime/scripting/lua_dardo_script_engine.dart @@ -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)); diff --git a/lib/runtime/storage/runtime_storage_manager.dart b/lib/runtime/storage/runtime_storage_manager.dart index 2c67d92..d959f8f 100644 --- a/lib/runtime/storage/runtime_storage_manager.dart +++ b/lib/runtime/storage/runtime_storage_manager.dart @@ -25,10 +25,7 @@ class RuntimeStorageManager { try { final raw = jsonDecode(file.readAsStringSync()); if (raw is Map) { - return RuntimeStorageManager._( - file, - Map.from(raw), - ); + return RuntimeStorageManager._(file, Map.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(); diff --git a/test/runtime/packages/game_package_repository_test.dart b/test/runtime/packages/game_package_repository_test.dart new file mode 100644 index 0000000..cb7e774 --- /dev/null +++ b/test/runtime/packages/game_package_repository_test.dart @@ -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 _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 _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 _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 load(String gameId) async => package; +} + +class _EmptyStablePackageStore implements StablePackageStore { + @override + Future cacheRoot() => throw UnimplementedError(); + + @override + Future markStable(GamePackage package) async {} + + @override + Future previousStablePackage(String gameId) async => null; + + @override + Future stablePackage(String gameId) async => null; + + @override + Future versionDirectory(String gameId, String version) => + throw UnimplementedError(); +} diff --git a/test/runtime/public_api_test.dart b/test/runtime/public_api_test.dart index 7d94e54..c89a144 100644 --- a/test/runtime/public_api_test.dart +++ b/test/runtime/public_api_test.dart @@ -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()); expect(RuntimeLocaleResolver.localeFromTag('zh-Hans').scriptCode, 'Hans'); });