Initial flame_lua_runtime package

This commit is contained in:
gem
2026-06-07 22:53:58 +08:00
commit 733b2fb798
262 changed files with 28439 additions and 0 deletions

View File

@@ -0,0 +1,395 @@
import 'dart:io';
import 'package:flame_lua_runtime/runtime/audio/runtime_audio_manager.dart';
import 'package:flame_lua_runtime/runtime/models/game_diff.dart';
import 'package:flame_lua_runtime/runtime/models/runtime_event.dart';
import 'package:flame_lua_runtime/runtime/packages/game_package.dart';
import 'package:flame_lua_runtime/runtime/packages/game_package_activation_controller.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:flame_lua_runtime/runtime/resources/game_resource_manager.dart';
import 'package:flame_lua_runtime/runtime/scripting/script_engine.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('PackageActivationController', () {
test(
'activates repository candidate and marks stable after init',
() async {
final candidate = await _createPackage('candidate');
final store = _FakeStablePackageStore();
final scriptEngine = _FakeScriptEngine();
final result = await PackageActivationController(
repository: _FakeRepository(candidate),
resources: GameResourceManager(),
scriptEngine: scriptEngine,
store: store,
assetFallback: _FakeRepository(await _createPackage('asset')),
).activate(gameId: 'ludo', contextBuilder: _context);
expect(result.package.rootPath, candidate.rootPath);
expect(scriptEngine.loadedPackages, [candidate.rootPath]);
expect(scriptEngine.initPackages, [candidate.rootPath]);
expect(store.markedPackages, [candidate.rootPath]);
},
);
test('falls back to stable when repository candidate is invalid', () async {
final invalidCandidate = await _createPackage('invalid', script: 'bad');
final stable = await _createPackage('stable');
final store = _FakeStablePackageStore(stable: stable);
final scriptEngine = _FakeScriptEngine();
final result = await PackageActivationController(
repository: _FakeRepository(invalidCandidate),
resources: GameResourceManager(),
scriptEngine: scriptEngine,
store: store,
assetFallback: _FakeRepository(await _createPackage('asset')),
).activate(gameId: 'ludo', contextBuilder: _context);
expect(result.package.rootPath, stable.rootPath);
expect(scriptEngine.loadedPackages, [stable.rootPath]);
expect(store.markedPackages, [stable.rootPath]);
});
test(
'falls back to previous stable when current stable is invalid',
() async {
final invalidCandidate = await _createPackage(
'invalid_candidate',
script: 'bad',
);
final invalidStable = await _createPackage(
'invalid_stable',
script: 'bad',
);
final previous = await _createPackage('previous');
final store = _FakeStablePackageStore(
stable: invalidStable,
previous: previous,
);
final scriptEngine = _FakeScriptEngine();
final result = await PackageActivationController(
repository: _FakeRepository(invalidCandidate),
resources: GameResourceManager(),
scriptEngine: scriptEngine,
store: store,
assetFallback: _FakeRepository(await _createPackage('asset')),
).activate(gameId: 'ludo', contextBuilder: _context);
expect(result.package.rootPath, previous.rootPath);
expect(scriptEngine.loadedPackages, [previous.rootPath]);
expect(store.markedPackages, [previous.rootPath]);
},
);
test('falls back to asset when repository load fails', () async {
final failedCandidate = await _createPackage('failed_candidate');
final fallback = await _createPackage('asset');
final store = _FakeStablePackageStore();
final scriptEngine = _FakeScriptEngine();
final result = await PackageActivationController(
repository: _FakeRepository(
failedCandidate,
error: StateError('network failed'),
),
resources: GameResourceManager(),
scriptEngine: scriptEngine,
store: store,
assetFallback: _FakeRepository(fallback),
).activate(gameId: 'ludo', contextBuilder: _context);
expect(result.package.rootPath, fallback.rootPath);
expect(scriptEngine.loadedPackages, [fallback.rootPath]);
expect(store.markedPackages, [fallback.rootPath]);
});
test('stops activation when cancellation guard turns false', () async {
final candidate = await _createPackage('candidate');
final fallback = await _createPackage('asset');
final store = _FakeStablePackageStore();
final scriptEngine = _FakeScriptEngine();
var active = false;
await expectLater(
PackageActivationController(
repository: _FakeRepository(candidate),
resources: GameResourceManager(),
scriptEngine: scriptEngine,
store: store,
assetFallback: _FakeRepository(fallback),
).activate(
gameId: 'ludo',
contextBuilder: _context,
shouldContinue: () => active,
),
throwsStateError,
);
expect(scriptEngine.loadedPackages, isEmpty);
expect(store.markedPackages, isEmpty);
});
test('uses staging resources and script engine before commit', () async {
final candidate = await _createPackage('candidate');
final activeResources = _RecordingResourceManager();
final activeScriptEngine = _FakeScriptEngine();
final stagingResources = <_RecordingResourceManager>[];
final stagingEngines = <_FakeScriptEngine>[];
final result = await PackageActivationController(
repository: _FakeRepository(candidate),
resources: activeResources,
scriptEngine: activeScriptEngine,
store: _FakeStablePackageStore(),
assetFallback: _FakeRepository(await _createPackage('asset')),
resourceManagerFactory: () {
final resources = _RecordingResourceManager();
stagingResources.add(resources);
return resources;
},
scriptEngineFactory: () {
final engine = _FakeScriptEngine();
stagingEngines.add(engine);
return engine;
},
).activate(gameId: 'ludo', contextBuilder: _context);
expect(activeResources.mountedPackages, isEmpty);
expect(activeScriptEngine.loadedPackages, isEmpty);
expect(result.resources, same(stagingResources.single));
expect(result.scriptEngine, same(stagingEngines.single));
expect(stagingResources.single.mountedPackages, [candidate.rootPath]);
expect(stagingEngines.single.loadedPackages, [candidate.rootPath]);
});
test('falls back when required audio preload fails', () async {
final candidate = await _createAudioPackage(
'candidate_bad_audio',
writeAudio: false,
);
final fallback = await _createAudioPackage('asset_audio');
final store = _FakeStablePackageStore();
final scriptEngine = _FakeScriptEngine();
final result = await PackageActivationController(
repository: _FakeRepository(candidate),
resources: GameResourceManager(),
scriptEngine: scriptEngine,
audio: RuntimeAudioManager(),
store: store,
assetFallback: _FakeRepository(fallback),
audioManagerFactory: RuntimeAudioManager.new,
).activate(gameId: 'ludo', contextBuilder: _context);
expect(result.package.rootPath, fallback.rootPath);
expect(result.audio, isNotNull);
expect(store.markedPackages, [fallback.rootPath]);
});
test('does not mark a package when smoke_test fails', () async {
final candidate = await _createPackage('candidate');
final fallback = await _createPackage('asset');
final store = _FakeStablePackageStore();
final scriptEngine = _FakeScriptEngine(
smokeFailures: {candidate.rootPath},
);
final result = await PackageActivationController(
repository: _FakeRepository(candidate),
resources: GameResourceManager(),
scriptEngine: scriptEngine,
store: store,
assetFallback: _FakeRepository(fallback),
).activate(gameId: 'ludo', contextBuilder: _context);
expect(result.package.rootPath, fallback.rootPath);
expect(scriptEngine.loadedPackages, [
candidate.rootPath,
fallback.rootPath,
]);
expect(store.markedPackages, [fallback.rootPath]);
});
});
}
Map<String, Object?> _context(GamePackage package) {
return {
'runtimeApiVersion': 1,
'gameId': package.manifest.gameId,
'gameVersion': package.manifest.version,
};
}
Future<GamePackage> _createPackage(
String name, {
String script = _validScript,
}) async {
final root = await Directory.systemTemp.createTemp('activation_${name}_');
Directory('${root.path}/scripts').createSync(recursive: true);
File('${root.path}/scripts/main.lua').writeAsStringSync(script);
addTearDown(() {
if (root.existsSync()) {
root.deleteSync(recursive: true);
}
});
return GamePackage.file(
rootPath: root.path,
manifest: const GamePackageManifest(
gameId: 'ludo',
name: 'Ludo',
version: '0.1.0',
runtimeApiVersion: 1,
entry: 'scripts/main.lua',
assetsBase: 'assets',
),
);
}
Future<GamePackage> _createAudioPackage(
String name, {
bool writeAudio = true,
}) async {
final root = await Directory.systemTemp.createTemp('activation_${name}_');
Directory('${root.path}/scripts').createSync(recursive: true);
Directory('${root.path}/assets').createSync(recursive: true);
File('${root.path}/scripts/main.lua').writeAsStringSync(_validScript);
if (writeAudio) {
File('${root.path}/assets/dice.wav').writeAsBytesSync(const [1, 2, 3]);
}
addTearDown(() {
if (root.existsSync()) {
root.deleteSync(recursive: true);
}
});
return GamePackage.file(
rootPath: root.path,
manifest: const GamePackageManifest(
gameId: 'ludo',
name: 'Ludo',
version: '0.1.0',
runtimeApiVersion: 1,
entry: 'scripts/main.lua',
assetsBase: 'assets',
resources: {
'dice': GameResource(
type: GameResourceType.audio,
path: 'assets/dice.wav',
preload: GameResourcePreload.required,
),
},
),
);
}
class _FakeRepository implements GamePackageRepository {
const _FakeRepository(this.package, {this.error});
final GamePackage package;
final Object? error;
@override
Future<GamePackage> load(String gameId) async {
final value = error;
if (value != null) {
throw value;
}
return package;
}
}
class _FakeStablePackageStore implements StablePackageStore {
_FakeStablePackageStore({this.stable, this.previous});
final GamePackage? stable;
final GamePackage? previous;
final markedPackages = <String>[];
@override
Future<Directory> cacheRoot() => throw UnimplementedError();
@override
Future<void> markStable(GamePackage package) async {
markedPackages.add(package.rootPath);
}
@override
Future<GamePackage?> previousStablePackage(String gameId) async => previous;
@override
Future<GamePackage?> stablePackage(String gameId) async => stable;
@override
Future<Directory> versionDirectory(String gameId, String version) =>
throw UnimplementedError();
}
class _RecordingResourceManager extends GameResourceManager {
final mountedPackages = <String>[];
var disposed = false;
@override
Future<void> mount(GamePackage package) async {
mountedPackages.add(package.rootPath);
await super.mount(package);
}
@override
void dispose() {
disposed = true;
super.dispose();
}
}
class _FakeScriptEngine implements ScriptEngine {
_FakeScriptEngine({this.smokeFailures = const {}});
final Set<String> smokeFailures;
final loadedPackages = <String>[];
final initPackages = <String>[];
GamePackage? _package;
@override
Future<void> loadPackage(GamePackage package) async {
_package = package;
loadedPackages.add(package.rootPath);
}
@override
bool smokeTest(Map<String, Object?> context) {
return !smokeFailures.contains(_package?.rootPath);
}
@override
GameDiff init(Map<String, Object?> context) {
final package = _package;
if (package != null) {
initPackages.add(package.rootPath);
}
return GameDiff.empty;
}
@override
GameDiff dispatchEvent(RuntimeEvent event) => GameDiff.empty;
}
const _validScript = '''
function smoke_test(ctx)
return true
end
function init(ctx)
return {}
end
function on_event(event)
return {}
end
''';

View File

@@ -0,0 +1,236 @@
import 'package:flame_lua_runtime/runtime/packages/game_package_manifest.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('GamePackageManifest', () {
test('parses manifest with resources', () {
final manifest = GamePackageManifest.fromMap({
'gameId': 'ludo',
'name': 'Ludo',
'version': '0.1.0',
'runtimeApiVersion': 1,
'entry': 'scripts/main.lua',
'assetsBase': 'assets',
'defaultLocale': 'zh-Hans',
'supportedLocales': ['zh-Hans', 'en'],
'display': {
'designWidth': 720,
'designHeight': 1280,
'scaleMode': 'fit',
},
'modules': {
'runtime_ui': 'runtime:runtime_ui.lua',
'theme': 'scripts/theme.lua',
},
'resources': {
'board': {
'type': 'image',
'path': 'assets/board.png',
'preload': 'lazy',
'group': 'board',
},
'roll': {'type': 'audio', 'path': 'assets/roll.mp3'},
'hero': {
'type': 'spine',
'atlas': 'assets/hero.atlas',
'skeleton': 'assets/hero.skel',
'preload': 'lazy',
'group': 'actors',
},
},
});
expect(manifest.gameId, 'ludo');
expect(manifest.name, 'Ludo');
expect(manifest.version, '0.1.0');
expect(manifest.runtimeApiVersion, 1);
expect(manifest.entry, 'scripts/main.lua');
expect(manifest.assetsBase, 'assets');
expect(manifest.defaultLocale, 'zh-Hans');
expect(manifest.supportedLocales, ['zh-Hans', 'en']);
expect(manifest.display.designWidth, 720);
expect(manifest.display.designHeight, 1280);
expect(manifest.display.scaleMode, 'fit');
expect(manifest.resources['board']?.type, 'image');
expect(manifest.resources['board']?.path, 'assets/board.png');
expect(manifest.resources['board']?.preload, GameResourcePreload.lazy);
expect(manifest.resources['board']?.group, 'board');
expect(manifest.resources['roll']?.type, GameResourceType.audio);
expect(manifest.resources['roll']?.preload, GameResourcePreload.required);
expect(manifest.resources['hero']?.type, GameResourceType.spine);
expect(manifest.resources['hero']?.atlas, 'assets/hero.atlas');
expect(manifest.resources['hero']?.skeleton, 'assets/hero.skel');
expect(manifest.resources['hero']?.path, isEmpty);
expect(manifest.modules, {
'runtime_ui': 'runtime:runtime_ui.lua',
'theme': 'scripts/theme.lua',
});
});
test('defaults assetsBase to assets', () {
final manifest = GamePackageManifest.fromMap({
'gameId': 'ludo',
'name': 'Ludo',
'version': '0.1.0',
'runtimeApiVersion': 1,
'entry': 'scripts/main.lua',
});
expect(manifest.assetsBase, 'assets');
expect(manifest.defaultLocale, 'en');
expect(manifest.supportedLocales, ['en']);
expect(manifest.display.designWidth, 720);
expect(manifest.display.designHeight, 720);
expect(manifest.display.scaleMode, 'fit');
expect(manifest.resources, isEmpty);
expect(manifest.modules, isEmpty);
});
test('rejects invalid required fields and resources', () {
expect(
() => GamePackageManifest.fromMap({
'gameId': '',
'name': 'Ludo',
'version': '0.1.0',
'runtimeApiVersion': 1,
'entry': 'scripts/main.lua',
}),
throwsFormatException,
);
expect(
() => GamePackageManifest.fromMap({
'gameId': 'ludo',
'name': 'Ludo',
'version': '0.1.0',
'runtimeApiVersion': '1',
'entry': 'scripts/main.lua',
}),
throwsFormatException,
);
expect(
() => GamePackageManifest.fromMap({
'gameId': 'ludo',
'name': 'Ludo',
'version': '0.1.0',
'runtimeApiVersion': 1,
'entry': 'scripts/main.lua',
'resources': {
'board': {'type': '', 'path': 'assets/board.png'},
},
}),
throwsFormatException,
);
expect(
() => GamePackageManifest.fromMap({
'gameId': 'ludo',
'name': 'Ludo',
'version': '0.1.0',
'runtimeApiVersion': 1,
'entry': 'scripts/main.lua',
'resources': {
'roll': {'type': 'sound', 'path': 'assets/roll.mp3'},
},
}),
throwsFormatException,
);
expect(
() => GamePackageManifest.fromMap({
'gameId': 'ludo',
'name': 'Ludo',
'version': '0.1.0',
'runtimeApiVersion': 1,
'entry': 'scripts/main.lua',
'modules': {'theme': 1},
}),
throwsFormatException,
);
expect(
() => GamePackageManifest.fromMap({
'gameId': 'ludo',
'name': 'Ludo',
'version': '0.1.0',
'runtimeApiVersion': 1,
'entry': 'scripts/main.lua',
'resources': {
'board': {
'type': 'image',
'path': 'assets/board.png',
'preload': 'eager',
},
},
}),
throwsFormatException,
);
expect(
() => GamePackageManifest.fromMap({
'gameId': 'ludo',
'name': 'Ludo',
'version': '0.1.0',
'runtimeApiVersion': 1,
'entry': 'scripts/main.lua',
'resources': {
'board': {'type': 'image', 'path': 'assets/board.png', 'group': ''},
},
}),
throwsFormatException,
);
expect(
() => GamePackageManifest.fromMap({
'gameId': 'ludo',
'name': 'Ludo',
'version': '0.1.0',
'runtimeApiVersion': 1,
'entry': 'scripts/main.lua',
'resources': {
'hero': {'type': 'spine', 'atlas': 'assets/hero.atlas'},
},
}),
throwsFormatException,
);
expect(
() => GamePackageManifest.fromMap({
'gameId': 'ludo',
'name': 'Ludo',
'version': '0.1.0',
'runtimeApiVersion': 1,
'entry': 'scripts/main.lua',
'defaultLocale': 'zh-Hans',
'supportedLocales': ['en'],
}),
throwsFormatException,
);
expect(
() => GamePackageManifest.fromMap({
'gameId': 'ludo',
'name': 'Ludo',
'version': '0.1.0',
'runtimeApiVersion': 1,
'entry': 'scripts/main.lua',
'display': {'designWidth': 0},
}),
throwsFormatException,
);
expect(
() => GamePackageManifest.fromMap({
'gameId': 'ludo',
'name': 'Ludo',
'version': '0.1.0',
'runtimeApiVersion': 1,
'entry': 'scripts/main.lua',
'display': {'scaleMode': 'zoom'},
}),
throwsFormatException,
);
});
});
}

View File

@@ -0,0 +1,84 @@
import 'dart:io';
import 'package:flame_lua_runtime/runtime/packages/game_package.dart';
import 'package:flame_lua_runtime/runtime/packages/game_package_manifest.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('GamePackage', () {
test('resolves manifest resource keys', () {
final package = _package();
expect(
package.resolveResourcePath('board'),
'example/assets/games/ludo/assets/board.png',
);
});
test('resolves package-relative paths and assetsBase fallback', () {
final package = _package();
expect(
package.resolveResourcePath('scripts/main.lua'),
'example/assets/games/ludo/scripts/main.lua',
);
expect(
package.resolveResourcePath('unknown.png'),
'example/assets/games/ludo/assets/unknown.png',
);
expect(
package.resolveResourcePath(
'example/assets/games/ludo/assets/board.png',
),
'example/assets/games/ludo/assets/board.png',
);
});
test('keeps runtime Lua root configurable per package', () {
final package = GamePackage.asset(
rootPath: 'example/assets/games/ludo',
manifest: _manifest(),
runtimeLuaRoot: 'packages/flame_lua_runtime/assets/runtime/lua',
);
expect(
package.runtimeLuaRoot,
'packages/flame_lua_runtime/assets/runtime/lua',
);
});
test('reads file package text and bytes', () async {
final root = await Directory.systemTemp.createTemp('game_package_test_');
addTearDown(() => root.deleteSync(recursive: true));
Directory('${root.path}/scripts').createSync(recursive: true);
File('${root.path}/scripts/main.lua').writeAsStringSync('return true');
final package = GamePackage.file(
rootPath: root.path,
manifest: _manifest(),
);
expect(await package.readText('scripts/main.lua'), 'return true');
expect((await package.readBytes('scripts/main.lua')).lengthInBytes, 11);
});
});
}
GamePackage _package() {
return GamePackage.asset(
rootPath: 'example/assets/games/ludo',
manifest: _manifest(),
);
}
GamePackageManifest _manifest() {
return const GamePackageManifest(
gameId: 'ludo',
name: 'Ludo',
version: '0.1.0',
runtimeApiVersion: 1,
entry: 'scripts/main.lua',
assetsBase: 'assets',
resources: {'board': GameResource(type: 'image', path: 'assets/board.png')},
);
}

View File

@@ -0,0 +1,144 @@
import 'dart:io';
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/package_verifier.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('PackageVerifier', () {
test('accepts a valid file package', () async {
final package = await _createPackage();
await expectLater(
const PackageVerifier(runtimeApiVersion: 1).verify(package),
completes,
);
});
test('accepts runtime framework module paths', () async {
final package = await _createPackage(
modules: {'runtime_ui': 'runtime:runtime_ui.lua'},
);
await expectLater(
const PackageVerifier(runtimeApiVersion: 1).verify(package),
completes,
);
});
test('rejects unsupported runtimeApiVersion', () async {
final package = await _createPackage(runtimeApiVersion: 2);
await expectLater(
const PackageVerifier(runtimeApiVersion: 1).verify(package),
throwsFormatException,
);
});
test('rejects missing Lua entry functions', () async {
final package = await _createPackage(script: 'function init(ctx) end');
await expectLater(
const PackageVerifier(runtimeApiVersion: 1).verify(package),
throwsFormatException,
);
});
test('rejects unsafe declared modules', () async {
final package = await _createPackage(
modules: {'../theme': 'scripts/theme.lua'},
);
await expectLater(
const PackageVerifier(runtimeApiVersion: 1).verify(package),
throwsFormatException,
);
});
test('rejects module paths outside scripts directory', () async {
final package = await _createPackage(
modules: {'theme': 'assets/theme.lua'},
);
await expectLater(
const PackageVerifier(runtimeApiVersion: 1).verify(package),
throwsFormatException,
);
});
test('rejects missing declared resources', () async {
final package = await _createPackage(writeResource: false);
await expectLater(
const PackageVerifier(runtimeApiVersion: 1).verify(package),
throwsFormatException,
);
});
test('rejects resource paths that escape package root', () async {
final package = await _createPackage(resourcePath: '../outside.png');
await expectLater(
const PackageVerifier(runtimeApiVersion: 1).verify(package),
throwsFormatException,
);
});
});
}
Future<GamePackage> _createPackage({
int runtimeApiVersion = 1,
String script = _validScript,
String resourcePath = 'assets/board.png',
bool writeResource = true,
Map<String, String> modules = const {'theme': 'scripts/theme.lua'},
}) async {
final root = await Directory.systemTemp.createTemp('package_verifier_test_');
Directory('${root.path}/scripts').createSync(recursive: true);
Directory('${root.path}/assets').createSync(recursive: true);
File('${root.path}/scripts/main.lua').writeAsStringSync(script);
File('${root.path}/scripts/theme.lua').writeAsStringSync('return {}');
if (writeResource && !resourcePath.contains('..')) {
File('${root.path}/$resourcePath')
..createSync(recursive: true)
..writeAsBytesSync([1, 2, 3]);
}
final package = GamePackage.file(
rootPath: root.path,
manifest: GamePackageManifest(
gameId: 'ludo',
name: 'Ludo',
version: '0.1.0',
runtimeApiVersion: runtimeApiVersion,
entry: 'scripts/main.lua',
assetsBase: 'assets',
resources: {'board': GameResource(type: 'image', path: resourcePath)},
modules: modules,
),
);
addTearDown(() {
if (root.existsSync()) {
root.deleteSync(recursive: true);
}
});
return package;
}
const _validScript = '''
function smoke_test(ctx)
return true
end
function init(ctx)
return {}
end
function on_event(event)
return {}
end
''';