实战教程:GameRewind v3 插件
通过可构建的 GameRewind 示例学习发现、文件策略与命令能力,所有操作通过宿主服务执行。
GameRewind 是可独立构建的 API 3.6 示例:识别用户提供且包含 save.dat 的根目录,提出配置草稿、排除缓存,并通过 Host 发起备份或执行无副作用的 ECHO。
完整源码
using System.Text.Json;
using FolderRewind.Plugin.Abstractions;
namespace GameRewind;
public sealed class Plugin : IFolderRewindPlugin, IDiscoveryCapability,
IDiscoveryDefinitionCatalog, IFilePolicyCapability, IPluginCommandCapability,
IKnotLinkIntegrationCapability
{
private static readonly PluginId Id = new("com.example.gamerewind");
public ConfigKindRef Kind { get; } = new(new OwnerId(Id.Value), "game-saves");
public DiscoveryProviderId ProviderId { get; } = new("com.example.gamerewind.discovery");
private bool _discover;
public ValueTask<PluginActivationResult> ActivateAsync(
IPluginActivationContext context, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
_discover = context.Settings.Values.TryGetValue("DiscoverSaves", out var value)
&& value.GetBoolean();
context.RegisterCapability<IDiscoveryCapability>(this);
// Register this multi-interface instance once. Runtime recognizes all its contracts.
return ValueTask.FromResult(PluginActivationResult.Empty);
}
public ValueTask DeactivateAsync(CancellationToken cancellationToken)
{
_discover = false;
return ValueTask.CompletedTask;
}
public IReadOnlyList<DiscoveryDefinitionDescriptor> Definitions { get; } =
[new("example-game", "Example game", [], new Dictionary<string, string>())];
public string? ResolveDefinitionId(DiscoveryCandidate candidate)
=> candidate.ConfigDrafts.Any(draft => draft.Kind == Kind) ? "example-game" : null;
public ValueTask<DiscoveryResult> DiscoverAsync(
DiscoveryRequest request, PluginInvocationContext context)
{
var candidates = new List<DiscoveryCandidate>();
var diagnostics = new List<PluginDiagnostic>();
if (!_discover) return ValueTask.FromResult(new DiscoveryResult(candidates, diagnostics));
foreach (var suppliedRoot in request.UserRoots)
{
context.OperationCancellation.ThrowIfCancellationRequested();
context.PluginLifetime.ThrowIfCancellationRequested();
try
{
var root = Path.GetFullPath(suppliedRoot);
if (!File.Exists(Path.Combine(root, "save.dat"))) continue;
var folder = new FolderDraft(root, Path.GetFileName(root),
new Dictionary<StateOwnerId, ProviderStateDraft>());
var draft = new ConfigDraft(Kind, "Example game", [folder],
new Dictionary<StateOwnerId, ProviderStateDraft>());
candidates.Add(new DiscoveryCandidate(root, folder.DisplayName, [draft]));
}
catch (Exception error) when (error is IOException or UnauthorizedAccessException or ArgumentException)
{
diagnostics.Add(new("example.discovery_root_failed", DiagnosticSeverity.Warning,
"Discovery", Id.Value, new Dictionary<string, string> { ["message"] = error.Message }));
}
}
return ValueTask.FromResult(new DiscoveryResult(candidates, diagnostics));
}
public ValueTask<FilePolicyResult> ResolveAsync(
FilePolicyRequest request, PluginInvocationContext context)
{
context.OperationCancellation.ThrowIfCancellationRequested();
return ValueTask.FromResult(new FilePolicyResult(["*.tmp", "cache/**"], [], []));
}
public IReadOnlyList<PluginCommandDescriptor> Commands { get; } =
[new(new PluginCommandId(Id, "backup"), "Back up a reviewed example config",
JsonSerializer.SerializeToElement(new {
type = "object", properties = new { configId = new { type = "string" } },
required = new[] { "configId" }, additionalProperties = false }))];
public async ValueTask<PluginCommandResult> ExecuteAsync(
PluginCommandRequest request, PluginInvocationContext context)
{
if (request.Id != Commands[0].Id || !request.Arguments.TryGetValue("configId", out var id)
|| id.ValueKind != JsonValueKind.String || string.IsNullOrWhiteSpace(id.GetString()))
return Result(OperationOutcome.Blocked);
context.HostServices.Logger.Log(DiagnosticSeverity.Information, "Example backup requested.");
var outcome = await context.HostServices.Backups.RequestAsync(id.GetString()!, null,
new BackupRequestOptions { Comment = "GameRewind example" }, context.OperationCancellation);
return Result(outcome);
}
IReadOnlyList<KnotLinkCommandDescriptor> IKnotLinkIntegrationCapability.Commands { get; } =
[new("EXAMPLE_ECHO", "Echo text without changing data") {
Arguments = [new("text", "Text to return")], Returns = ["data"] }];
public ValueTask<PluginCommandResult> ExecuteAsync(string command,
IReadOnlyDictionary<string, string> arguments, PluginInvocationContext context)
{
context.OperationCancellation.ThrowIfCancellationRequested();
return ValueTask.FromResult(command == "EXAMPLE_ECHO"
? new PluginCommandResult(OperationOutcome.Success,
new Dictionary<string, JsonElement> {
["data"] = JsonSerializer.SerializeToElement(arguments.GetValueOrDefault("text", "")) }, [])
: Result(OperationOutcome.Blocked));
}
private static PluginCommandResult Result(OperationOutcome outcome)
=> new(outcome, new Dictionary<string, JsonElement>(), []);
}
{
"manifestVersion": 3,
"pluginId": "com.example.gamerewind",
"version": "1.0.0",
"name": "GameRewind",
"description": "API 3.6 documentation example; use test data.",
"author": "FolderRewind documentation",
"pluginApi": {
"major": 3,
"minor": 6
},
"entryAssembly": "GameRewind.dll",
"entryType": "GameRewind.Plugin",
"settingsSchema": "settings.schema.json",
"architectures": [
"any"
],
"configKinds": [
{
"ownerId": "com.example.gamerewind",
"kindId": "game-saves",
"displayName": "Game saves example",
"description": "User-reviewed local save directories.",
"icon": "folder",
"backupFallback": "block",
"restoreCoordination": "none"
}
],
"requestedHostServices": [
"backupRequest",
"logging"
],
"capabilities": [
"discovery",
"filePolicy",
"pluginCommand",
"knotLinkIntegration"
],
"artifactFormats": [],
"artifactTransformers": [],
"restoreStrategies": [],
"hasBackupCompletionObserver": false
}
发现与配置所有权
只检查用户明确给出的根,返回 ConfigDraft 和 FolderDraft,不写 config.json。Host 分配受管来源身份并按用户创建策略提交。不存在、不可读或无标记的目录不会被强行加入。示例没有递归扫描磁盘。
DiscoveryDefinitionCatalog 给出稳定游戏 DefinitionId、别名及外部 ID,以参与游戏发现和定向预设。它不是额外 capability。实际插件应使用稳定、可对账的候选身份;不要把显示名称作为身份。
文件策略与命令
FilePolicy 返回必要排除规则;来源范围和 Host 过滤仍然约束保护边界。备份命令需要明确 configId,并通过 Backups 服务执行,绝不自己调用归档引擎。ECHO 声明自己的命令与参数,不接管 Host BACKUP。
热备份与还原扩展的边界
示例没有实现游戏一致性或玩家保留。真实热备份需要 IBackupConsistencyCapability 提供有效租约和稳定来源;直接复制游戏写入中的目录不能证明一致性。真实还原由 IRestoreCoordinatorCapability 协调外部环境、由 Host 执行一次性 mutation。
普通 Restore 的额外保留通过 IRestoreStagingPreparationCapability 返回相对文件提案;不能在还原后写 live path。Checkout 和 Merge 不调用玩家保留。配置修改则由 IConfigReconciliationCapability 提交绑定 revision 的提案,过期 revision 必须重新对账。
构建和测试
node scripts/pack-plugin.mjs GameRewind
建立测试目录并放置 save.dat。安装包、检查声明、启用、执行发现并审阅草稿,再备份与还原测试内容。测试失败和取消时检查诊断及资源释放。首次用于真实游戏前停止所有写入者。
已知位置与手选范围
GameRewind 示例仅检查 UserRoots 中含 save.dat 的目录,不自行提供已知位置列表。真实发现提供器可在 IncludeKnownLocations=true 时合并只读机器线索;false 时保持手选范围。不要扫描全盘、写启动器配置或把候选直接保存为配置。