Compare commits
4 Commits
0d4fbd030c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ea3663853 | ||
|
|
79ee35db2f | ||
|
|
6608d0a975 | ||
|
|
8ddc3be3a7 |
@@ -133,6 +133,8 @@ A warning about missing `homepage` / `repository` is acceptable until real publi
|
|||||||
## Development rules
|
## Development rules
|
||||||
|
|
||||||
- Keep protocol fields white-listed and explicit.
|
- 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.
|
- Prefer simple data models over implicit behavior.
|
||||||
- Runtime commands must be generic, not game-specific.
|
- Runtime commands must be generic, not game-specific.
|
||||||
- Lua helper aliases are allowed only if normalized before protocol validation.
|
- Lua helper aliases are allowed only if normalized before protocol validation.
|
||||||
|
|||||||
25
README.md
25
README.md
@@ -19,6 +19,9 @@ It is designed for Flutter apps that want to host Lua-authored 2D games or inter
|
|||||||
- Runtime commands for movement, fading, scaling, rotation, sequencing, audio, resources, toast, clipboard, and Spine animation.
|
- Runtime commands for movement, fading, scaling, rotation, sequencing, audio, resources, toast, clipboard, and Spine animation.
|
||||||
- Shared Lua helper modules under `assets/runtime/lua/`.
|
- Shared Lua helper modules under `assets/runtime/lua/`.
|
||||||
- Configurable Runtime Lua asset root via `RuntimeOptions.runtimeLuaRoot`.
|
- 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
|
## Example
|
||||||
|
|
||||||
@@ -59,6 +62,18 @@ LuaGameWidget(
|
|||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
With a shared framework package:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
LuaGameWidget(
|
||||||
|
gameId: 'ludo',
|
||||||
|
runtimeOptions: const RuntimeOptions(
|
||||||
|
runtimeLuaRoot: 'packages/flame_lua_runtime/assets/runtime/lua',
|
||||||
|
basePackages: ['_framework'],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
Your app should provide game package assets such as:
|
Your app should provide game package assets such as:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -82,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
|
## Runtime asset path
|
||||||
|
|
||||||
When used as a published package, configure:
|
When used as a published package, configure:
|
||||||
@@ -105,7 +128,7 @@ For AI agents and maintainers, start with:
|
|||||||
- [`AGENTS.md`](AGENTS.md) — package boundaries, rules, public API, and validation commands.
|
- [`AGENTS.md`](AGENTS.md) — package boundaries, rules, public API, and validation commands.
|
||||||
- [`docs/quick-start.md`](docs/quick-start.md) — host app integration.
|
- [`docs/quick-start.md`](docs/quick-start.md) — host app integration.
|
||||||
- [`docs/architecture.md`](docs/architecture.md) — Dart/Lua/Flame responsibilities.
|
- [`docs/architecture.md`](docs/architecture.md) — Dart/Lua/Flame responsibilities.
|
||||||
- [`docs/lua-package-format.md`](docs/lua-package-format.md) — manifest and Lua package rules.
|
- [`docs/lua-package-format.md`](docs/lua-package-format.md) — manifest, Lua package rules, and multi-package loading.
|
||||||
- [`docs/protocol.md`](docs/protocol.md) — RuntimeEvent, GameDiff, RuntimeNode, RuntimeCommand boundary.
|
- [`docs/protocol.md`](docs/protocol.md) — RuntimeEvent, GameDiff, RuntimeNode, RuntimeCommand boundary.
|
||||||
- [`docs/validation.md`](docs/validation.md) — checks, smoke tests, and release flow.
|
- [`docs/validation.md`](docs/validation.md) — checks, smoke tests, and release flow.
|
||||||
|
|
||||||
|
|||||||
@@ -68,6 +68,28 @@ Package dependency root:
|
|||||||
packages/flame_lua_runtime/assets/runtime/lua
|
packages/flame_lua_runtime/assets/runtime/lua
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Multi-package loading
|
||||||
|
|
||||||
|
The runtime supports loading multiple packages in sequence, with module merging:
|
||||||
|
|
||||||
|
```text
|
||||||
|
RuntimeOptions.basePackages = ['_framework']
|
||||||
|
-> load _framework package (modules: app, diff, ids, net, ...)
|
||||||
|
-> load game package (modules: state, rules, main, ...)
|
||||||
|
-> merge into flat _moduleScripts map
|
||||||
|
-> game modules override framework modules on name collision
|
||||||
|
-> execute game entry script
|
||||||
|
```
|
||||||
|
|
||||||
|
Key classes:
|
||||||
|
|
||||||
|
- `RuntimeOptions.basePackages` — ordered list of framework package IDs.
|
||||||
|
- `PackageActivationController._prepareCandidate()` — loads base packages first, then game package, passes combined list to `ScriptEngine.loadPackages()`.
|
||||||
|
- `LuaDardoScriptEngine.loadPackages()` — iterates all packages, merges `manifest.modules` into `_moduleScripts`, executes entry from last package.
|
||||||
|
- `GamePackageManifest.base` — optional metadata field declaring framework dependency.
|
||||||
|
|
||||||
|
Module resolution is flat: `runtime.import("xxx")` looks up `_moduleScripts[xxx]`. Game modules and framework modules share the same namespace. Later-loaded packages win on collision.
|
||||||
|
|
||||||
## Safety model
|
## Safety model
|
||||||
|
|
||||||
- Lua module loading is manifest-declared.
|
- Lua module loading is manifest-declared.
|
||||||
|
|||||||
@@ -64,6 +64,53 @@ runtime:*.lua
|
|||||||
|
|
||||||
`runtime:` paths must not contain `/`, `..`, or an empty filename.
|
`runtime:` paths must not contain `/`, `..`, or an empty filename.
|
||||||
|
|
||||||
|
## Base packages
|
||||||
|
|
||||||
|
A game manifest can declare a `base` field to indicate it depends on a framework package:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"gameId": "ludo",
|
||||||
|
"base": "_framework",
|
||||||
|
"modules": { ... }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `base` field is metadata. Actual loading is controlled by `RuntimeOptions.basePackages`:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
LuaGameWidget(
|
||||||
|
gameId: 'ludo',
|
||||||
|
runtimeOptions: const RuntimeOptions(
|
||||||
|
basePackages: ['_framework'],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Loading order:
|
||||||
|
|
||||||
|
1. Base packages are loaded first, in `basePackages` order.
|
||||||
|
2. The game package is loaded last.
|
||||||
|
3. All modules are merged into a flat map.
|
||||||
|
4. Later packages override earlier packages on name collision.
|
||||||
|
5. The entry script always comes from the last (game) package.
|
||||||
|
|
||||||
|
This means a game can override any framework module by declaring a module with the same name in its own manifest.
|
||||||
|
|
||||||
|
## Multi-package module resolution
|
||||||
|
|
||||||
|
When Lua code calls `runtime.import("xxx")`:
|
||||||
|
|
||||||
|
1. Look up `xxx` in the merged module map (game modules first, then framework).
|
||||||
|
2. If not found, throw `FormatException: Lua module is not declared in manifest.modules`.
|
||||||
|
|
||||||
|
Framework modules are transparent to game code:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local app = runtime.import("app") -- resolved from framework
|
||||||
|
local state = runtime.import("state") -- resolved from game
|
||||||
|
```
|
||||||
|
|
||||||
## Entry module
|
## Entry module
|
||||||
|
|
||||||
The manifest `entry` module should expose lifecycle/event functions expected by the script engine. Keep game-specific state in Lua modules and return runtime diffs/commands through the approved protocol.
|
The manifest `entry` module should expose lifecycle/event functions expected by the script engine. Keep game-specific state in Lua modules and return runtime diffs/commands through the approved protocol.
|
||||||
@@ -95,6 +142,80 @@ Check without rewriting:
|
|||||||
dart run tool/generate_lua_runtime_defs.dart --check
|
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
|
## Package validation
|
||||||
|
|
||||||
A host repository can validate a game package with:
|
A host repository can validate a game package with:
|
||||||
|
|||||||
@@ -98,3 +98,77 @@ flutter run --dart-define=LUA_GAME_ID=flight
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Multi-package loading (base + game)
|
||||||
|
|
||||||
|
When multiple games share common modules (event router, diff utilities, panel stack, network layer), extract them into a framework package and load it before the game package.
|
||||||
|
|
||||||
|
Framework package structure:
|
||||||
|
|
||||||
|
```text
|
||||||
|
assets/games/_framework/
|
||||||
|
manifest.json
|
||||||
|
scripts/
|
||||||
|
app.lua
|
||||||
|
diff.lua
|
||||||
|
event_router.lua
|
||||||
|
ids.lua
|
||||||
|
net.lua
|
||||||
|
panel_stack.lua
|
||||||
|
```
|
||||||
|
|
||||||
|
Framework manifest:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"gameId": "_framework",
|
||||||
|
"name": "Lua Game Framework",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"runtimeApiVersion": 1,
|
||||||
|
"entry": "scripts/app.lua",
|
||||||
|
"assetsBase": "assets",
|
||||||
|
"modules": {
|
||||||
|
"app": "scripts/app.lua",
|
||||||
|
"diff": "scripts/diff.lua",
|
||||||
|
"event_router": "scripts/event_router.lua",
|
||||||
|
"ids": "scripts/ids.lua",
|
||||||
|
"net": "scripts/net.lua",
|
||||||
|
"panel_stack": "scripts/panel_stack.lua"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Game manifest (no framework modules needed):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"gameId": "ludo",
|
||||||
|
"base": "_framework",
|
||||||
|
"modules": {
|
||||||
|
"state": "scripts/state.lua",
|
||||||
|
"rules": "scripts/rules.lua",
|
||||||
|
"main": "scripts/main.lua"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Game code imports framework modules transparently:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local app = runtime.import("app") -- from framework
|
||||||
|
local state = runtime.import("state") -- from game
|
||||||
|
```
|
||||||
|
|
||||||
|
Embed with base packages:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
LuaGameWidget(
|
||||||
|
gameId: 'ludo',
|
||||||
|
runtimeOptions: const RuntimeOptions(
|
||||||
|
runtimeLuaRoot: 'packages/flame_lua_runtime/assets/runtime/lua',
|
||||||
|
basePackages: ['_framework'],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Module resolution order: game package modules override framework modules with the same name. The entry script always comes from the last package (game package).
|
||||||
|
|||||||
@@ -16,8 +16,11 @@ export 'runtime/host/runtime_host_bridge.dart'
|
|||||||
export 'runtime/packages/game_package_repository.dart'
|
export 'runtime/packages/game_package_repository.dart'
|
||||||
show
|
show
|
||||||
AssetGamePackageRepository,
|
AssetGamePackageRepository,
|
||||||
|
FileGamePackageRepository,
|
||||||
GamePackageRepository,
|
GamePackageRepository,
|
||||||
RemoteGamePackageRepository;
|
RemoteGamePackageRepository;
|
||||||
export 'runtime/scripting/lua_dardo_script_engine.dart'
|
export 'runtime/scripting/lua_dardo_script_engine.dart'
|
||||||
show LuaDardoScriptEngine;
|
show LuaDardoScriptEngine;
|
||||||
export 'runtime/scripting/script_engine.dart' show ScriptEngine;
|
export 'runtime/scripting/script_engine.dart' show ScriptEngine;
|
||||||
|
export 'runtime/storage/runtime_storage_manager.dart'
|
||||||
|
show RuntimeStorageManager;
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import '../display/runtime_viewport.dart';
|
|||||||
import '../resources/game_resource_manager.dart';
|
import '../resources/game_resource_manager.dart';
|
||||||
import '../scripting/runtime_script_services.dart';
|
import '../scripting/runtime_script_services.dart';
|
||||||
import '../scripting/script_engine.dart';
|
import '../scripting/script_engine.dart';
|
||||||
|
import '../storage/runtime_storage_manager.dart';
|
||||||
import 'runtime_locale.dart';
|
import 'runtime_locale.dart';
|
||||||
import 'runtime_options.dart';
|
import 'runtime_options.dart';
|
||||||
|
|
||||||
@@ -73,6 +74,7 @@ class FlameLuaGame extends FlameGame with PanDetector, ScrollDetector {
|
|||||||
late final PositionComponent _viewportRoot;
|
late final PositionComponent _viewportRoot;
|
||||||
RuntimeNetworkManager? _network;
|
RuntimeNetworkManager? _network;
|
||||||
RuntimeHostBridgeManager? _hostBridgeManager;
|
RuntimeHostBridgeManager? _hostBridgeManager;
|
||||||
|
RuntimeStorageManager? _storage;
|
||||||
RuntimeViewportConfig? _viewportConfig;
|
RuntimeViewportConfig? _viewportConfig;
|
||||||
late final CommandExecutor _commands;
|
late final CommandExecutor _commands;
|
||||||
RuntimeSession? _session;
|
RuntimeSession? _session;
|
||||||
@@ -117,6 +119,7 @@ class FlameLuaGame extends FlameGame with PanDetector, ScrollDetector {
|
|||||||
'initialized': true,
|
'initialized': true,
|
||||||
'images': _resources.imagesDebugJson(),
|
'images': _resources.imagesDebugJson(),
|
||||||
'audio': _audio.audioDebugJson(),
|
'audio': _audio.audioDebugJson(),
|
||||||
|
'storage': _storage?.debugJson() ?? const {},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,18 +145,22 @@ class FlameLuaGame extends FlameGame with PanDetector, ScrollDetector {
|
|||||||
diagnostics: diagnostics,
|
diagnostics: diagnostics,
|
||||||
);
|
);
|
||||||
_hostBridgeManager = hostBridgeManager;
|
_hostBridgeManager = hostBridgeManager;
|
||||||
|
final storage = await RuntimeStorageManager.create(gameId: gameId);
|
||||||
|
_storage = storage;
|
||||||
final activation =
|
final activation =
|
||||||
await PackageActivationController(
|
await PackageActivationController(
|
||||||
repository: _packageRepository,
|
repository: _packageRepository,
|
||||||
resources: _createResourceManager(),
|
resources: _createResourceManager(),
|
||||||
scriptEngine: _bootstrapScriptEngine,
|
scriptEngine: _bootstrapScriptEngine,
|
||||||
audio: _createAudioManager(),
|
audio: _createAudioManager(),
|
||||||
|
runtimeOptions: runtimeOptions,
|
||||||
resourceManagerFactory: _createResourceManager,
|
resourceManagerFactory: _createResourceManager,
|
||||||
audioManagerFactory: _createAudioManager,
|
audioManagerFactory: _createAudioManager,
|
||||||
scriptEngineFactory: _scriptEngineFactory,
|
scriptEngineFactory: _scriptEngineFactory,
|
||||||
scriptServices: RuntimeScriptServices(
|
scriptServices: RuntimeScriptServices(
|
||||||
network: network,
|
network: network,
|
||||||
hostBridge: hostBridgeManager,
|
hostBridge: hostBridgeManager,
|
||||||
|
storage: storage,
|
||||||
),
|
),
|
||||||
store: StablePackageStore(runtimeOptions: runtimeOptions),
|
store: StablePackageStore(runtimeOptions: runtimeOptions),
|
||||||
assetFallback: AssetGamePackageRepository(
|
assetFallback: AssetGamePackageRepository(
|
||||||
@@ -174,6 +181,25 @@ class FlameLuaGame extends FlameGame with PanDetector, ScrollDetector {
|
|||||||
_resources = activation.resources;
|
_resources = activation.resources;
|
||||||
_audio = activation.audio ?? _createAudioManager();
|
_audio = activation.audio ?? _createAudioManager();
|
||||||
_scriptEngine = activation.scriptEngine;
|
_scriptEngine = activation.scriptEngine;
|
||||||
|
|
||||||
|
// 合并所有包的翻译字典,框架包先、游戏包后,后加载的覆盖先加载的。
|
||||||
|
final mergedTranslations = <String, Map<String, String>>{};
|
||||||
|
for (final package in activation.packages) {
|
||||||
|
for (final entry in package.manifest.translations.entries) {
|
||||||
|
mergedTranslations.putIfAbsent(entry.key, () => {})
|
||||||
|
.addAll(entry.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final resolvedLocale = RuntimeLocaleResolver.resolve(
|
||||||
|
requested: _localeOverride ?? PlatformDispatcher.instance.locale,
|
||||||
|
defaultLocale: activation.package.manifest.defaultLocale,
|
||||||
|
supportedLocales: activation.package.manifest.supportedLocales,
|
||||||
|
);
|
||||||
|
_scriptEngine.setTranslations(
|
||||||
|
locale: resolvedLocale.resolved,
|
||||||
|
translations: mergedTranslations,
|
||||||
|
);
|
||||||
|
|
||||||
_viewportConfig = activation.package.manifest.display.toViewportConfig();
|
_viewportConfig = activation.package.manifest.display.toViewportConfig();
|
||||||
_viewportRoot = PositionComponent();
|
_viewportRoot = PositionComponent();
|
||||||
add(_viewportRoot);
|
add(_viewportRoot);
|
||||||
|
|||||||
@@ -1,7 +1,31 @@
|
|||||||
class RuntimeOptions {
|
class RuntimeOptions {
|
||||||
const RuntimeOptions({this.runtimeLuaRoot = defaultRuntimeLuaRoot});
|
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';
|
static const defaultRuntimeLuaRoot = 'assets/runtime/lua';
|
||||||
|
|
||||||
final String runtimeLuaRoot;
|
final String runtimeLuaRoot;
|
||||||
|
|
||||||
|
// 框架包 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import '../audio/runtime_audio_manager.dart';
|
import '../audio/runtime_audio_manager.dart';
|
||||||
|
import '../game/runtime_options.dart';
|
||||||
import '../models/game_diff.dart';
|
import '../models/game_diff.dart';
|
||||||
import '../resources/game_resource_manager.dart';
|
import '../resources/game_resource_manager.dart';
|
||||||
import '../scripting/runtime_script_services.dart';
|
import '../scripting/runtime_script_services.dart';
|
||||||
@@ -15,6 +16,7 @@ class PackageActivationController {
|
|||||||
required this.scriptEngine,
|
required this.scriptEngine,
|
||||||
this.audio,
|
this.audio,
|
||||||
this.runtimeApiVersion = 1,
|
this.runtimeApiVersion = 1,
|
||||||
|
this.runtimeOptions = const RuntimeOptions(),
|
||||||
this.store = const StablePackageStore(),
|
this.store = const StablePackageStore(),
|
||||||
this.assetFallback = const AssetGamePackageRepository(),
|
this.assetFallback = const AssetGamePackageRepository(),
|
||||||
this.resourceManagerFactory,
|
this.resourceManagerFactory,
|
||||||
@@ -28,6 +30,7 @@ class PackageActivationController {
|
|||||||
final ScriptEngine scriptEngine;
|
final ScriptEngine scriptEngine;
|
||||||
final RuntimeAudioManager? audio;
|
final RuntimeAudioManager? audio;
|
||||||
final int runtimeApiVersion;
|
final int runtimeApiVersion;
|
||||||
|
final RuntimeOptions runtimeOptions;
|
||||||
final StablePackageStore store;
|
final StablePackageStore store;
|
||||||
final GamePackageRepository assetFallback;
|
final GamePackageRepository assetFallback;
|
||||||
final GameResourceManager Function()? resourceManagerFactory;
|
final GameResourceManager Function()? resourceManagerFactory;
|
||||||
@@ -143,12 +146,31 @@ class PackageActivationController {
|
|||||||
try {
|
try {
|
||||||
await verifier.verify(candidate);
|
await verifier.verify(candidate);
|
||||||
_ensureContinue(shouldContinue);
|
_ensureContinue(shouldContinue);
|
||||||
|
|
||||||
|
// 加载 base packages(框架包),按 runtimeOptions.basePackages 顺序。
|
||||||
|
final basePackages = <GamePackage>[];
|
||||||
|
for (final baseId in runtimeOptions.basePackages) {
|
||||||
|
final baseCandidates = await _candidatePackages(baseId, shouldContinue);
|
||||||
|
for (final baseCandidate in baseCandidates) {
|
||||||
|
try {
|
||||||
|
await verifier.verify(baseCandidate);
|
||||||
|
basePackages.add(baseCandidate);
|
||||||
|
break;
|
||||||
|
} catch (_) {
|
||||||
|
// Try next candidate.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await preparedResources.mount(candidate);
|
await preparedResources.mount(candidate);
|
||||||
_ensureContinue(shouldContinue);
|
_ensureContinue(shouldContinue);
|
||||||
await preparedAudio?.mount(candidate);
|
await preparedAudio?.mount(candidate);
|
||||||
_ensureContinue(shouldContinue);
|
_ensureContinue(shouldContinue);
|
||||||
await preparedScriptEngine.loadPackage(
|
|
||||||
candidate,
|
// 合并 base + game 包,传给脚本引擎。
|
||||||
|
final allPackages = [...basePackages, candidate];
|
||||||
|
await preparedScriptEngine.loadPackages(
|
||||||
|
allPackages,
|
||||||
services: scriptServices,
|
services: scriptServices,
|
||||||
);
|
);
|
||||||
_ensureContinue(shouldContinue);
|
_ensureContinue(shouldContinue);
|
||||||
@@ -164,6 +186,7 @@ class PackageActivationController {
|
|||||||
_ensureContinue(shouldContinue);
|
_ensureContinue(shouldContinue);
|
||||||
return PackageActivationPlan(
|
return PackageActivationPlan(
|
||||||
package: candidate,
|
package: candidate,
|
||||||
|
packages: allPackages,
|
||||||
initialDiff: diff,
|
initialDiff: diff,
|
||||||
resources: preparedResources,
|
resources: preparedResources,
|
||||||
scriptEngine: preparedScriptEngine,
|
scriptEngine: preparedScriptEngine,
|
||||||
@@ -197,6 +220,7 @@ class PackageActivationController {
|
|||||||
class PackageActivationPlan {
|
class PackageActivationPlan {
|
||||||
const PackageActivationPlan({
|
const PackageActivationPlan({
|
||||||
required this.package,
|
required this.package,
|
||||||
|
required this.packages,
|
||||||
required this.initialDiff,
|
required this.initialDiff,
|
||||||
required this.resources,
|
required this.resources,
|
||||||
required this.scriptEngine,
|
required this.scriptEngine,
|
||||||
@@ -204,6 +228,7 @@ class PackageActivationPlan {
|
|||||||
});
|
});
|
||||||
|
|
||||||
final GamePackage package;
|
final GamePackage package;
|
||||||
|
final List<GamePackage> packages;
|
||||||
final GameDiff initialDiff;
|
final GameDiff initialDiff;
|
||||||
final GameResourceManager resources;
|
final GameResourceManager resources;
|
||||||
final ScriptEngine scriptEngine;
|
final ScriptEngine scriptEngine;
|
||||||
@@ -213,6 +238,7 @@ class PackageActivationPlan {
|
|||||||
class PackageActivationResult {
|
class PackageActivationResult {
|
||||||
const PackageActivationResult({
|
const PackageActivationResult({
|
||||||
required this.package,
|
required this.package,
|
||||||
|
required this.packages,
|
||||||
required this.initialDiff,
|
required this.initialDiff,
|
||||||
required this.resources,
|
required this.resources,
|
||||||
required this.scriptEngine,
|
required this.scriptEngine,
|
||||||
@@ -222,6 +248,7 @@ class PackageActivationResult {
|
|||||||
factory PackageActivationResult.fromPlan(PackageActivationPlan plan) {
|
factory PackageActivationResult.fromPlan(PackageActivationPlan plan) {
|
||||||
return PackageActivationResult(
|
return PackageActivationResult(
|
||||||
package: plan.package,
|
package: plan.package,
|
||||||
|
packages: plan.packages,
|
||||||
initialDiff: plan.initialDiff,
|
initialDiff: plan.initialDiff,
|
||||||
resources: plan.resources,
|
resources: plan.resources,
|
||||||
scriptEngine: plan.scriptEngine,
|
scriptEngine: plan.scriptEngine,
|
||||||
@@ -230,6 +257,7 @@ class PackageActivationResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final GamePackage package;
|
final GamePackage package;
|
||||||
|
final List<GamePackage> packages;
|
||||||
final GameDiff initialDiff;
|
final GameDiff initialDiff;
|
||||||
final GameResourceManager resources;
|
final GameResourceManager resources;
|
||||||
final ScriptEngine scriptEngine;
|
final ScriptEngine scriptEngine;
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ class GamePackageManifest {
|
|||||||
this.display = const GameDisplayConfig(),
|
this.display = const GameDisplayConfig(),
|
||||||
this.resources = const {},
|
this.resources = const {},
|
||||||
this.modules = const {},
|
this.modules = const {},
|
||||||
|
this.translations = const {},
|
||||||
|
this.base,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String gameId;
|
final String gameId;
|
||||||
@@ -29,6 +31,13 @@ class GamePackageManifest {
|
|||||||
final Map<String, GameResource> resources;
|
final Map<String, GameResource> resources;
|
||||||
final Map<String, String> modules;
|
final Map<String, String> modules;
|
||||||
|
|
||||||
|
/// 翻译字典:locale → { key → translatedText }。
|
||||||
|
/// Runtime 会按包加载顺序合并,游戏包覆盖框架包。
|
||||||
|
final Map<String, Map<String, String>> translations;
|
||||||
|
|
||||||
|
/// 依赖的框架包 gameId。加载时会先加载框架包,再加载游戏包。
|
||||||
|
final String? base;
|
||||||
|
|
||||||
static GamePackageManifest fromJsonString(String source) {
|
static GamePackageManifest fromJsonString(String source) {
|
||||||
return fromMap(jsonDecode(source) as Map<String, Object?>);
|
return fromMap(jsonDecode(source) as Map<String, Object?>);
|
||||||
}
|
}
|
||||||
@@ -58,6 +67,28 @@ class GamePackageManifest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final base = map['base'] as String?;
|
||||||
|
|
||||||
|
final translationsValue = map['translations'];
|
||||||
|
final translations = <String, Map<String, String>>{};
|
||||||
|
if (translationsValue is Map) {
|
||||||
|
for (final entry in translationsValue.entries) {
|
||||||
|
if (entry.key is! String || entry.value is! Map) {
|
||||||
|
throw const FormatException('manifest.translations must be a map');
|
||||||
|
}
|
||||||
|
final localeDict = <String, String>{};
|
||||||
|
for (final kv in (entry.value as Map).entries) {
|
||||||
|
if (kv.key is! String || kv.value is! String) {
|
||||||
|
throw const FormatException(
|
||||||
|
'manifest.translations values must be string maps',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
localeDict[kv.key as String] = kv.value as String;
|
||||||
|
}
|
||||||
|
translations[entry.key as String] = localeDict;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
final defaultLocale = (map['defaultLocale'] as String?) ?? 'en';
|
final defaultLocale = (map['defaultLocale'] as String?) ?? 'en';
|
||||||
final supportedLocales = _stringList(
|
final supportedLocales = _stringList(
|
||||||
map,
|
map,
|
||||||
@@ -89,6 +120,8 @@ class GamePackageManifest {
|
|||||||
display: display,
|
display: display,
|
||||||
resources: resources,
|
resources: resources,
|
||||||
modules: modules,
|
modules: modules,
|
||||||
|
translations: translations,
|
||||||
|
base: base,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
class RemoteGamePackageRepository implements GamePackageRepository {
|
||||||
RemoteGamePackageRepository({
|
RemoteGamePackageRepository({
|
||||||
required this.baseUri,
|
required this.baseUri,
|
||||||
@@ -103,6 +132,10 @@ class RemoteGamePackageRepository implements GamePackageRepository {
|
|||||||
if (remoteManifest.gameId != gameId) {
|
if (remoteManifest.gameId != gameId) {
|
||||||
throw const FormatException('Remote manifest gameId mismatch');
|
throw const FormatException('Remote manifest gameId mismatch');
|
||||||
}
|
}
|
||||||
|
remoteManifest.compatibility.verify(
|
||||||
|
runtimeApiVersion: runtimeApiVersion,
|
||||||
|
runtimeOptions: runtimeOptions,
|
||||||
|
);
|
||||||
|
|
||||||
final packageRoot = await _downloadAndExtract(
|
final packageRoot = await _downloadAndExtract(
|
||||||
client,
|
client,
|
||||||
@@ -130,7 +163,7 @@ class RemoteGamePackageRepository implements GamePackageRepository {
|
|||||||
http.Client client,
|
http.Client client,
|
||||||
String gameId,
|
String gameId,
|
||||||
) async {
|
) async {
|
||||||
final uri = baseUri.resolve('$gameId/remote_manifest.json');
|
final uri = _remoteManifestUri(gameId);
|
||||||
final response = await client.get(uri);
|
final response = await client.get(uri);
|
||||||
if (response.statusCode != 200) {
|
if (response.statusCode != 200) {
|
||||||
throw HttpException(
|
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(
|
Future<Directory> _downloadAndExtract(
|
||||||
http.Client client,
|
http.Client client,
|
||||||
String gameId,
|
String gameId,
|
||||||
@@ -200,12 +246,14 @@ class RemotePackageManifest {
|
|||||||
required this.version,
|
required this.version,
|
||||||
required this.packageUrl,
|
required this.packageUrl,
|
||||||
required this.sha256,
|
required this.sha256,
|
||||||
|
this.compatibility = const RemotePackageCompatibility(),
|
||||||
});
|
});
|
||||||
|
|
||||||
final String gameId;
|
final String gameId;
|
||||||
final String version;
|
final String version;
|
||||||
final Uri packageUrl;
|
final Uri packageUrl;
|
||||||
final String sha256;
|
final String sha256;
|
||||||
|
final RemotePackageCompatibility compatibility;
|
||||||
|
|
||||||
static RemotePackageManifest fromMap(Map<String, Object?> map) {
|
static RemotePackageManifest fromMap(Map<String, Object?> map) {
|
||||||
return RemotePackageManifest(
|
return RemotePackageManifest(
|
||||||
@@ -213,6 +261,7 @@ class RemotePackageManifest {
|
|||||||
version: _string(map, 'version'),
|
version: _string(map, 'version'),
|
||||||
packageUrl: Uri.parse(_string(map, 'packageUrl')),
|
packageUrl: Uri.parse(_string(map, 'packageUrl')),
|
||||||
sha256: _string(map, 'sha256'),
|
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');
|
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;
|
return;
|
||||||
}
|
}
|
||||||
final marker = await _markerFile(package.manifest.gameId);
|
final marker = await _markerFile(package.manifest.gameId);
|
||||||
marker.createSync(recursive: true);
|
marker.parent.createSync(recursive: true);
|
||||||
final previous = await stablePackage(package.manifest.gameId);
|
final previous = await stablePackage(package.manifest.gameId);
|
||||||
final data = {
|
final data = {
|
||||||
'current': package.rootPath,
|
'current': package.rootPath,
|
||||||
if (previous != null && previous.rootPath != package.rootPath)
|
if (previous != null && previous.rootPath != package.rootPath)
|
||||||
'previous': previous.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 {
|
Future<GamePackage?> stablePackage(String gameId) async {
|
||||||
final marker = await _markerFile(gameId);
|
final data = await _readMarker(gameId);
|
||||||
if (!marker.existsSync()) {
|
return _packageFromPath(data?['current']);
|
||||||
return null;
|
|
||||||
}
|
|
||||||
final data =
|
|
||||||
jsonDecode(await marker.readAsString()) as Map<String, Object?>;
|
|
||||||
return _packageFromPath(data['current']);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<GamePackage?> previousStablePackage(String gameId) async {
|
Future<GamePackage?> previousStablePackage(String gameId) async {
|
||||||
final marker = await _markerFile(gameId);
|
final data = await _readMarker(gameId);
|
||||||
if (!marker.existsSync()) {
|
return _packageFromPath(data?['previous']);
|
||||||
return null;
|
|
||||||
}
|
|
||||||
final data =
|
|
||||||
jsonDecode(await marker.readAsString()) as Map<String, Object?>;
|
|
||||||
return _packageFromPath(data['previous']);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<File> _markerFile(String gameId) async {
|
Future<File> _markerFile(String gameId) async {
|
||||||
@@ -67,6 +64,26 @@ class StablePackageStore {
|
|||||||
return File(p.join(root.path, gameId, 'stable.json'));
|
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) {
|
GamePackage? _packageFromPath(Object? pathValue) {
|
||||||
if (pathValue is! String || pathValue.isEmpty) {
|
if (pathValue is! String || pathValue.isEmpty) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import '../models/game_diff.dart';
|
|||||||
import '../models/runtime_event.dart';
|
import '../models/runtime_event.dart';
|
||||||
import '../network/runtime_network_manager.dart';
|
import '../network/runtime_network_manager.dart';
|
||||||
import '../packages/game_package.dart';
|
import '../packages/game_package.dart';
|
||||||
|
import '../storage/runtime_storage_manager.dart';
|
||||||
import 'runtime_script_services.dart';
|
import 'runtime_script_services.dart';
|
||||||
import 'script_engine.dart';
|
import 'script_engine.dart';
|
||||||
|
|
||||||
@@ -27,15 +28,34 @@ class LuaDardoScriptEngine implements ScriptEngine {
|
|||||||
Future<void> loadPackage(
|
Future<void> loadPackage(
|
||||||
GamePackage package, {
|
GamePackage package, {
|
||||||
RuntimeScriptServices services = const RuntimeScriptServices(),
|
RuntimeScriptServices services = const RuntimeScriptServices(),
|
||||||
|
}) {
|
||||||
|
return loadPackages([package], services: services);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> loadPackages(
|
||||||
|
List<GamePackage> packages, {
|
||||||
|
RuntimeScriptServices services = const RuntimeScriptServices(),
|
||||||
}) async {
|
}) async {
|
||||||
|
if (packages.isEmpty) {
|
||||||
|
throw const FormatException('loadPackages requires at least one package');
|
||||||
|
}
|
||||||
|
|
||||||
_services = services;
|
_services = services;
|
||||||
_networkRequestCounter = 0;
|
_networkRequestCounter = 0;
|
||||||
_hostCallCounter = 0;
|
_hostCallCounter = 0;
|
||||||
final script = await package.readText(package.manifest.entry);
|
|
||||||
_moduleScripts = {};
|
_moduleScripts = {};
|
||||||
for (final entry in package.manifest.modules.entries) {
|
|
||||||
_moduleScripts[entry.key] = await package.readText(entry.value);
|
// 按顺序加载所有包的模块,后加载的同名模块覆盖先加载的。
|
||||||
|
for (final package in packages) {
|
||||||
|
for (final entry in package.manifest.modules.entries) {
|
||||||
|
_moduleScripts[entry.key] = await package.readText(entry.value);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 入口脚本使用最后一个包(游戏包)。
|
||||||
|
final entryPackage = packages.last;
|
||||||
|
final script = await entryPackage.readText(entryPackage.manifest.entry);
|
||||||
_loadingModules.clear();
|
_loadingModules.clear();
|
||||||
|
|
||||||
_lua = LuaState.newState();
|
_lua = LuaState.newState();
|
||||||
@@ -51,6 +71,69 @@ class LuaDardoScriptEngine implements ScriptEngine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void setTranslations({
|
||||||
|
required String locale,
|
||||||
|
required Map<String, Map<String, String>> translations,
|
||||||
|
}) {
|
||||||
|
_lua.getGlobal('runtime');
|
||||||
|
if (_lua.isNil(-1)) {
|
||||||
|
_lua.pop(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_lua.newTable();
|
||||||
|
|
||||||
|
_lua.pushString(locale);
|
||||||
|
_lua.setField(-2, 'locale');
|
||||||
|
|
||||||
|
_lua.pushDartFunction((LuaState lua) {
|
||||||
|
lua.pushString(locale);
|
||||||
|
return 1;
|
||||||
|
});
|
||||||
|
_lua.setField(-2, 'get_locale');
|
||||||
|
|
||||||
|
_lua.pushDartFunction((LuaState lua) {
|
||||||
|
final key = lua.toStr(1);
|
||||||
|
if (key == null || key.isEmpty) {
|
||||||
|
if (lua.getTop() >= 2 && !lua.isNil(2)) {
|
||||||
|
lua.pushValue(2);
|
||||||
|
} else {
|
||||||
|
lua.pushString(key ?? '');
|
||||||
|
}
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
final localeDict = translations[locale];
|
||||||
|
final value = localeDict?[key];
|
||||||
|
if (value != null) {
|
||||||
|
lua.pushString(value);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final entry in translations.entries) {
|
||||||
|
if (entry.key != locale) {
|
||||||
|
final fallbackValue = entry.value[key];
|
||||||
|
if (fallbackValue != null) {
|
||||||
|
lua.pushString(fallbackValue);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lua.getTop() >= 2 && !lua.isNil(2)) {
|
||||||
|
lua.pushValue(2);
|
||||||
|
} else {
|
||||||
|
lua.pushString(key);
|
||||||
|
}
|
||||||
|
return 1;
|
||||||
|
});
|
||||||
|
_lua.setField(-2, 't');
|
||||||
|
|
||||||
|
_lua.setField(-2, 'i18n');
|
||||||
|
_lua.pop(1);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool smokeTest(Map<String, Object?> context) {
|
bool smokeTest(Map<String, Object?> context) {
|
||||||
_lua.getGlobal('smoke_test');
|
_lua.getGlobal('smoke_test');
|
||||||
@@ -147,6 +230,18 @@ class LuaDardoScriptEngine implements ScriptEngine {
|
|||||||
_lua.pushDartFunction(_hostRespond);
|
_lua.pushDartFunction(_hostRespond);
|
||||||
_lua.setField(-2, 'host_respond');
|
_lua.setField(-2, 'host_respond');
|
||||||
|
|
||||||
|
_lua.pushDartFunction(_storageGet);
|
||||||
|
_lua.setField(-2, 'storage_get');
|
||||||
|
|
||||||
|
_lua.pushDartFunction(_storageSet);
|
||||||
|
_lua.setField(-2, 'storage_set');
|
||||||
|
|
||||||
|
_lua.pushDartFunction(_storageRemove);
|
||||||
|
_lua.setField(-2, 'storage_remove');
|
||||||
|
|
||||||
|
_lua.pushDartFunction(_storageClear);
|
||||||
|
_lua.setField(-2, 'storage_clear');
|
||||||
|
|
||||||
_lua.setGlobal('runtime');
|
_lua.setGlobal('runtime');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,6 +348,47 @@ class LuaDardoScriptEngine implements ScriptEngine {
|
|||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int _storageGet(LuaState lua) {
|
||||||
|
final storage = _requireStorage();
|
||||||
|
final key = lua.toStr(1);
|
||||||
|
if (key == null || key.isEmpty) {
|
||||||
|
throw const FormatException(
|
||||||
|
'runtime.storage_get(key, defaultValue) requires key',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final defaultValue = _readValue(2);
|
||||||
|
_pushValue(storage.getValue(key, defaultValue));
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
int _storageSet(LuaState lua) {
|
||||||
|
final storage = _requireStorage();
|
||||||
|
final key = lua.toStr(1);
|
||||||
|
if (key == null || key.isEmpty) {
|
||||||
|
throw const FormatException(
|
||||||
|
'runtime.storage_set(key, value) requires key',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final value = _readValue(2);
|
||||||
|
lua.pushBoolean(storage.setValue(key, value));
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
int _storageRemove(LuaState lua) {
|
||||||
|
final storage = _requireStorage();
|
||||||
|
final key = lua.toStr(1);
|
||||||
|
if (key == null || key.isEmpty) {
|
||||||
|
throw const FormatException('runtime.storage_remove(key) requires key');
|
||||||
|
}
|
||||||
|
lua.pushBoolean(storage.remove(key));
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
int _storageClear(LuaState lua) {
|
||||||
|
lua.pushBoolean(_requireStorage().clear());
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
RuntimeHostBridgeManager _requireHostBridge() {
|
RuntimeHostBridgeManager _requireHostBridge() {
|
||||||
final hostBridge = _services.hostBridge;
|
final hostBridge = _services.hostBridge;
|
||||||
if (hostBridge == null) {
|
if (hostBridge == null) {
|
||||||
@@ -274,6 +410,14 @@ class LuaDardoScriptEngine implements ScriptEngine {
|
|||||||
return network;
|
return network;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
RuntimeStorageManager _requireStorage() {
|
||||||
|
final storage = _services.storage;
|
||||||
|
if (storage == null) {
|
||||||
|
throw StateError('Runtime storage service is not installed');
|
||||||
|
}
|
||||||
|
return storage;
|
||||||
|
}
|
||||||
|
|
||||||
String _nextNetworkRequestId(String prefix) {
|
String _nextNetworkRequestId(String prefix) {
|
||||||
_networkRequestCounter += 1;
|
_networkRequestCounter += 1;
|
||||||
return '$prefix:$_networkRequestCounter';
|
return '$prefix:$_networkRequestCounter';
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import '../host/runtime_host_bridge.dart';
|
import '../host/runtime_host_bridge.dart';
|
||||||
import '../network/runtime_network_manager.dart';
|
import '../network/runtime_network_manager.dart';
|
||||||
|
import '../storage/runtime_storage_manager.dart';
|
||||||
|
|
||||||
class RuntimeScriptServices {
|
class RuntimeScriptServices {
|
||||||
const RuntimeScriptServices({this.network, this.hostBridge});
|
const RuntimeScriptServices({this.network, this.hostBridge, this.storage});
|
||||||
|
|
||||||
final RuntimeNetworkManager? network;
|
final RuntimeNetworkManager? network;
|
||||||
final RuntimeHostBridgeManager? hostBridge;
|
final RuntimeHostBridgeManager? hostBridge;
|
||||||
|
final RuntimeStorageManager? storage;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,14 +4,28 @@ import '../packages/game_package.dart';
|
|||||||
import 'runtime_script_services.dart';
|
import 'runtime_script_services.dart';
|
||||||
|
|
||||||
abstract interface class ScriptEngine {
|
abstract interface class ScriptEngine {
|
||||||
|
// 加载单个包(向后兼容,内部调 loadPackages([package]))。
|
||||||
Future<void> loadPackage(
|
Future<void> loadPackage(
|
||||||
GamePackage package, {
|
GamePackage package, {
|
||||||
RuntimeScriptServices services = const RuntimeScriptServices(),
|
RuntimeScriptServices services = const RuntimeScriptServices(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 加载多个包,按顺序合并模块,后加载的同名模块覆盖先加载的。
|
||||||
|
// 入口脚本使用最后一个包。
|
||||||
|
Future<void> loadPackages(
|
||||||
|
List<GamePackage> packages, {
|
||||||
|
RuntimeScriptServices services = const RuntimeScriptServices(),
|
||||||
|
});
|
||||||
|
|
||||||
bool smokeTest(Map<String, Object?> context);
|
bool smokeTest(Map<String, Object?> context);
|
||||||
|
|
||||||
GameDiff init(Map<String, Object?> context);
|
GameDiff init(Map<String, Object?> context);
|
||||||
|
|
||||||
GameDiff dispatchEvent(RuntimeEvent event);
|
GameDiff dispatchEvent(RuntimeEvent event);
|
||||||
|
|
||||||
|
/// 设置翻译字典和当前语言,Lua 侧可通过 runtime.i18n.t(key, fallback) 查询。
|
||||||
|
void setTranslations({
|
||||||
|
required String locale,
|
||||||
|
required Map<String, Map<String, String>> translations,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
92
lib/runtime/storage/runtime_storage_manager.dart
Normal file
92
lib/runtime/storage/runtime_storage_manager.dart
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
|
||||||
|
class RuntimeStorageManager {
|
||||||
|
RuntimeStorageManager._(this._file, this._values);
|
||||||
|
|
||||||
|
final File _file;
|
||||||
|
final Map<String, Object?> _values;
|
||||||
|
|
||||||
|
static Future<RuntimeStorageManager> create({required String gameId}) async {
|
||||||
|
final root = await getApplicationSupportDirectory();
|
||||||
|
final directory = Directory(p.join(root.path, 'flame_lua_storage'));
|
||||||
|
if (!directory.existsSync()) {
|
||||||
|
directory.createSync(recursive: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
final file = File(p.join(directory.path, '$gameId.json'));
|
||||||
|
if (!file.existsSync()) {
|
||||||
|
return RuntimeStorageManager._(file, <String, Object?>{});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final raw = jsonDecode(file.readAsStringSync());
|
||||||
|
if (raw is Map) {
|
||||||
|
return RuntimeStorageManager._(file, Map<String, Object?>.from(raw));
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Corrupt storage should not prevent a game from loading.
|
||||||
|
}
|
||||||
|
|
||||||
|
return RuntimeStorageManager._(file, <String, Object?>{});
|
||||||
|
}
|
||||||
|
|
||||||
|
Object? getValue(String key, [Object? defaultValue]) {
|
||||||
|
if (!_values.containsKey(key)) {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
return _values[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
bool setValue(String key, Object? value) {
|
||||||
|
_values[key] = _normalize(value);
|
||||||
|
_flush();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool remove(String key) {
|
||||||
|
final removed = _values.remove(key) != null;
|
||||||
|
if (removed) {
|
||||||
|
_flush();
|
||||||
|
}
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool clear() {
|
||||||
|
if (_values.isEmpty) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
_values.clear();
|
||||||
|
_flush();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object?> debugJson() => Map<String, Object?>.from(_values);
|
||||||
|
|
||||||
|
Object? _normalize(Object? value) {
|
||||||
|
if (value == null || value is bool || value is num || value is String) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (value is List) {
|
||||||
|
return value.map(_normalize).toList(growable: false);
|
||||||
|
}
|
||||||
|
if (value is Map) {
|
||||||
|
return {
|
||||||
|
for (final entry in value.entries)
|
||||||
|
entry.key.toString(): _normalize(entry.value),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return value.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _flush() {
|
||||||
|
final parent = _file.parent;
|
||||||
|
if (!parent.existsSync()) {
|
||||||
|
parent.createSync(recursive: true);
|
||||||
|
}
|
||||||
|
_file.writeAsStringSync(jsonEncode(_values));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -138,6 +138,7 @@ void main() {
|
|||||||
expect(script.events.map((event) => event.target), ['bad', 'good']);
|
expect(script.events.map((event) => event.target), ['bad', 'good']);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
RuntimeSession _activeSession() {
|
RuntimeSession _activeSession() {
|
||||||
@@ -156,6 +157,12 @@ class _FakeScriptEngine implements ScriptEngine {
|
|||||||
RuntimeScriptServices services = const RuntimeScriptServices(),
|
RuntimeScriptServices services = const RuntimeScriptServices(),
|
||||||
}) async {}
|
}) async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> loadPackages(
|
||||||
|
List<GamePackage> packages, {
|
||||||
|
RuntimeScriptServices services = const RuntimeScriptServices(),
|
||||||
|
}) async {}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool smokeTest(Map<String, Object?> context) => true;
|
bool smokeTest(Map<String, Object?> context) => true;
|
||||||
|
|
||||||
@@ -171,4 +178,10 @@ class _FakeScriptEngine implements ScriptEngine {
|
|||||||
}
|
}
|
||||||
return GameDiff.empty;
|
return GameDiff.empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void setTranslations({
|
||||||
|
required String locale,
|
||||||
|
required Map<String, Map<String, String>> translations,
|
||||||
|
}) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ void main() {
|
|||||||
await expectLater(game.callLua('host.ready'), throwsA(isA<StateError>()));
|
await expectLater(game.callLua('host.ready'), throwsA(isA<StateError>()));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class _FakeScriptEngine implements ScriptEngine {
|
class _FakeScriptEngine implements ScriptEngine {
|
||||||
@@ -44,6 +45,14 @@ class _FakeScriptEngine implements ScriptEngine {
|
|||||||
throw UnimplementedError();
|
throw UnimplementedError();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> loadPackages(
|
||||||
|
List<GamePackage> packages, {
|
||||||
|
RuntimeScriptServices services = const RuntimeScriptServices(),
|
||||||
|
}) {
|
||||||
|
throw UnimplementedError();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
GameDiff dispatchEvent(RuntimeEvent event) {
|
GameDiff dispatchEvent(RuntimeEvent event) {
|
||||||
throw UnimplementedError();
|
throw UnimplementedError();
|
||||||
@@ -58,6 +67,12 @@ class _FakeScriptEngine implements ScriptEngine {
|
|||||||
bool smokeTest(Map<String, Object?> context) {
|
bool smokeTest(Map<String, Object?> context) {
|
||||||
throw UnimplementedError();
|
throw UnimplementedError();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void setTranslations({
|
||||||
|
required String locale,
|
||||||
|
required Map<String, Map<String, String>> translations,
|
||||||
|
}) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _FakePackageRepository implements GamePackageRepository {
|
class _FakePackageRepository implements GamePackageRepository {
|
||||||
|
|||||||
@@ -217,6 +217,7 @@ void main() {
|
|||||||
expect(store.markedPackages, [fallback.rootPath]);
|
expect(store.markedPackages, [fallback.rootPath]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<String, Object?> _context(GamePackage package) {
|
Map<String, Object?> _context(GamePackage package) {
|
||||||
@@ -366,6 +367,17 @@ class _FakeScriptEngine implements ScriptEngine {
|
|||||||
loadedPackages.add(package.rootPath);
|
loadedPackages.add(package.rootPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> loadPackages(
|
||||||
|
List<GamePackage> packages, {
|
||||||
|
RuntimeScriptServices services = const RuntimeScriptServices(),
|
||||||
|
}) async {
|
||||||
|
for (final package in packages) {
|
||||||
|
_package = package;
|
||||||
|
loadedPackages.add(package.rootPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool smokeTest(Map<String, Object?> context) {
|
bool smokeTest(Map<String, Object?> context) {
|
||||||
return !smokeFailures.contains(_package?.rootPath);
|
return !smokeFailures.contains(_package?.rootPath);
|
||||||
@@ -382,6 +394,12 @@ class _FakeScriptEngine implements ScriptEngine {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
GameDiff dispatchEvent(RuntimeEvent event) => GameDiff.empty;
|
GameDiff dispatchEvent(RuntimeEvent event) => GameDiff.empty;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void setTranslations({
|
||||||
|
required String locale,
|
||||||
|
required Map<String, Map<String, String>> translations,
|
||||||
|
}) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
const _validScript = '''
|
const _validScript = '''
|
||||||
|
|||||||
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() {
|
void main() {
|
||||||
test('public runtime API exposes minimal integration surface', () {
|
test('public runtime API exposes minimal integration surface', () {
|
||||||
const repository = AssetGamePackageRepository();
|
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(
|
const widget = LuaGameWidget(
|
||||||
gameId: 'template',
|
gameId: 'template',
|
||||||
packageRepository: repository,
|
packageRepository: repository,
|
||||||
@@ -14,6 +21,11 @@ void main() {
|
|||||||
expect(widget.gameId, 'template');
|
expect(widget.gameId, 'template');
|
||||||
expect(widget.packageRepository, same(repository));
|
expect(widget.packageRepository, same(repository));
|
||||||
expect(widget.runtimeOptions.runtimeLuaRoot, 'custom/runtime/lua');
|
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(LuaDardoScriptEngine.new, isA<ScriptEngine Function()>());
|
||||||
expect(RuntimeLocaleResolver.localeFromTag('zh-Hans').scriptCode, 'Hans');
|
expect(RuntimeLocaleResolver.localeFromTag('zh-Hans').scriptCode, 'Hans');
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user