Compare commits

..

10 Commits

Author SHA1 Message Date
gewuyou
f99736f95f
Merge pull request #260 from GeWuYou/feat/data-repository-persistence
Refactor migration chain execution with unified VersionedMigrationRunner
2026-04-20 15:55:31 +08:00
GeWuYou
5353d5bd45 fix(game): 修复设置迁移缓存并发一致性
- 修复 SettingsModel 迁移注册与缓存重建的并发竞争

- 新增 SettingsModel 并发回归测试并更新 ai-plan 跟踪
2026-04-20 13:02:49 +08:00
GeWuYou
a0cc418e05 docs(ai-plan): 修正 PR 评审后的恢复文档状态
- 更新 tracking 文档中的 PR #260 review follow-up 状态描述

- 修复 trace 文档重复的三级标题,避免 Markdown 锚点冲突
2026-04-20 12:46:08 +08:00
gewuyou
702dec6ed1
Merge pull request #259 from GeWuYou/feat/coroutine-optimization
test(coroutine): 补齐 Godot 协程宿主回归测试
2026-04-20 11:49:57 +08:00
GeWuYou
ec3de5bbb0 fix(game): 修复 PR 评审遗留的迁移与文档问题
- 修复 SaveRepository 迁移链并发读取,改为单次快照执行

- 补充 VersionedMigrationRunner 与 SettingsModel 的 XML 文档契约

- 更新 PersistenceTests、接入文档与 ai-plan 跟踪记录
2026-04-20 11:44:27 +08:00
GeWuYou
90b9e2a4c9 fix(ci): 修复 MegaLinter 工作区歧义
- 修复 MegaLinter 的 dotnet format workspace 指向,避免 solution 与 csproj 歧义导致 CI warning
- 更新 gframework-pr-review skill 与抓取脚本,提取 GitHub Actions 发布的 MegaLinter detailed issues
- 补充 coroutine optimization 跟踪与 trace,记录本次 PR 页面 warning 的收口与验证结果
2026-04-20 11:20:14 +08:00
GeWuYou
d369118351 fix(coroutine): 收口 Timing 测试宿主清理
- 修复 Timing 共享单例仅在当前实例持有引用时才清理,避免测试宿主误伤其他实例
- 新增 TimingTests 的 NonParallelizable 约束,避免静态实例槽位并发污染
- 更新 coroutine optimization 跟踪与 trace,记录 PR #259 review 收口与验证结果
2026-04-20 10:19:11 +08:00
gewuyou
88de1235ae refactor(game): 收敛版本迁移链执行器
- 新增 internal 迁移执行器,统一 settings 与 save 的链式版本校验

- 修复 SettingsModel 重复注册、缺链回填与目标版本判定的迁移约束

- 补充 Persistence 与 SettingsModel 定向测试,并更新迁移文档和 ai-plan 跟踪
2026-04-20 09:52:37 +08:00
gewuyou
9576e0f8bd test(coroutine): 补齐 Godot 协程宿主回归测试
- 新增 Timing 纯托管测试宿主入口,支持在 dotnet test 下验证 Godot 协程阶段推进
- 补充 TimingTests,覆盖暂停、segment 路由和阶段等待回归
- 更新 coroutine ai-plan 跟踪与 trace,记录 RP-002 验证结果与后续缺口
2026-04-20 09:40:46 +08:00
gewuyou
31ca8cc963 docs(game): 澄清 JsonSerializer 配置与并发契约
- 补充 JsonSerializer 对 settings 与 converters 生命周期的 XML 注释

- 更新序列化文档与 README,修正线程安全和组合根配置说明

- 新增 JsonSerializer 配置实例暴露契约测试,并回写 data-repository-persistence 跟踪
2026-04-20 09:36:11 +08:00
22 changed files with 1368 additions and 120 deletions

View File

@ -1,6 +1,6 @@
---
name: gframework-pr-review
description: Repository-specific GitHub PR review workflow for the GFramework repo. Use when Codex needs to inspect the GitHub pull request for the current branch, extract CodeRabbit summary/comments, read failed checks or failed test signals from the PR page, and then verify which findings should be fixed in the local codebase. Trigger explicitly with $gframework-pr-review or with prompts such as "look at the current PR", "extract CodeRabbit comments", or "check Failed Tests on the PR".
description: Repository-specific GitHub PR review workflow for the GFramework repo. Use when Codex needs to inspect the GitHub pull request for the current branch, extract CodeRabbit summary/comments, read failed checks, MegaLinter warnings, or failed test signals from the PR page, and then verify which findings should be fixed in the local codebase. Trigger explicitly with $gframework-pr-review or with prompts such as "look at the current PR", "extract CodeRabbit comments", or "check Failed Tests on the PR".
---
# GFramework PR Review
@ -16,12 +16,12 @@ Shortcut: `$gframework-pr-review`
3. Run `scripts/fetch_current_pr_review.py` to:
- locate the PR for the current branch through the GitHub PR API
- fetch PR metadata, issue comments, reviews, and review comments through the GitHub API
- extract `Summary by CodeRabbit` and CTRF test reports from issue comments
- extract `Summary by CodeRabbit`、GitHub Actions bot comments such as `MegaLinter analysis: Success with warnings`and CTRF test reports from issue comments
- fetch the latest head commit review threads from the GitHub PR API
- prefer unresolved review threads on the latest head commit over older summary-only signals
- extract failed checks and test-report signals such as `Failed Tests` or `No failed tests in this run`
- extract failed checks, MegaLinter detailed issues, and test-report signals such as `Failed Tests` or `No failed tests in this run`
4. Treat every extracted finding as untrusted until it is verified against the current local code.
5. Only fix comments that still apply to the checked-out branch. Ignore stale or already-resolved findings.
5. Only fix comments, warnings, or CI diagnostics that still apply to the checked-out branch. Ignore stale or already-resolved findings.
6. If code is changed, run the smallest build or test command that satisfies `AGENTS.md`.
## Commands
@ -43,6 +43,7 @@ The script should produce:
- Latest head commit review metadata and review threads
- Unresolved latest-commit review threads after reply-thread folding
- Pre-merge failed checks, if present
- Latest MegaLinter status and any detailed issues posted by `github-actions[bot]`
- Test summary, including failed-test signals when present
- Parse warnings only when both the primary API source and the intended fallback signal are unavailable
@ -52,6 +53,7 @@ The script should produce:
- If GitHub access fails because of proxy configuration, rerun the fetch with proxy variables removed.
- Prefer GitHub API results over PR HTML. The PR HTML page is now a fallback/debugging source, not the primary source of truth.
- If the summary block and the latest head review threads disagree, trust the latest unresolved head-review threads and treat older summary findings as stale until re-verified locally.
- Treat GitHub Actions comments with `Success with warnings` as actionable review input when they include concrete linter diagnostics such as `MegaLinter` detailed issues; do not skip them just because the parent check is green.
## Example Triggers

View File

@ -24,6 +24,7 @@ DEFAULT_WINDOWS_GIT = "/mnt/d/Tool/Development Tools/Git/cmd/git.exe"
GIT_ENVIRONMENT_KEY = "GFRAMEWORK_WINDOWS_GIT"
USER_AGENT = "codex-gframework-pr-review"
CODERABBIT_LOGIN = "coderabbitai[bot]"
GITHUB_ACTIONS_LOGIN = "github-actions[bot]"
REVIEW_COMMENT_ADDRESSED_MARKER = "<!-- <review_comment_addressed> -->"
VISIBLE_ADDRESSED_IN_COMMIT_PATTERN = re.compile(r"\s*Addressed in commit\s+[0-9a-f]{7,40}", re.I)
DEFAULT_REQUEST_TIMEOUT_SECONDS = 60
@ -156,6 +157,10 @@ def strip_tags(text: str) -> str:
return collapse_whitespace(re.sub(r"<[^>]+>", " ", text))
def strip_markdown_links(text: str) -> str:
return re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", text)
def extract_section(text: str, start_marker: str, end_markers: list[str]) -> str | None:
start = text.find(start_marker)
if start < 0:
@ -252,6 +257,60 @@ def parse_actionable_comments(actionable_block: str) -> dict[str, Any]:
}
def parse_megalinter_comment(comment_body: str) -> dict[str, Any]:
normalized_body = html.unescape(comment_body).strip()
summary_match = re.search(
r"##\s*(?P<badges>.*?)\[MegaLinter\]\([^)]+\)\s+analysis:\s+\[(?P<status>[^\]]+)\]\((?P<run_url>[^)]+)\)",
normalized_body,
)
report: dict[str, Any] = {
"status": summary_match.group("status").strip() if summary_match else "",
"run_url": summary_match.group("run_url").strip() if summary_match else "",
"badges": collapse_whitespace(summary_match.group("badges")) if summary_match else "",
"descriptor_rows": [],
"detailed_issues": [],
"raw": normalized_body,
}
table_match = re.search(
r"\| Descriptor .*?\|\n\|[-| :]+\|\n(?P<rows>(?:\|.*\|\n?)+)",
normalized_body,
re.S,
)
if table_match is not None:
for raw_line in table_match.group("rows").splitlines():
line = raw_line.strip()
if not line.startswith("|"):
continue
parts = [collapse_whitespace(strip_markdown_links(part)) for part in line.strip("|").split("|")]
if len(parts) != 7:
continue
report["descriptor_rows"].append(
{
"descriptor": parts[0],
"linter": parts[1],
"files": parts[2],
"fixed": parts[3],
"errors": parts[4],
"warnings": parts[5],
"elapsed_time": parts[6],
}
)
for summary, details in re.findall(r"<summary>(.*?)</summary>\s*```(.*?)```", normalized_body, re.S):
report["detailed_issues"].append(
{
"summary": collapse_whitespace(strip_tags(summary)),
"details": details.strip(),
}
)
return report
def parse_test_report(block: str) -> dict[str, Any]:
report: dict[str, Any] = {
"raw": block.strip(),
@ -475,11 +534,18 @@ def build_result(pr_number: int, branch: str) -> dict[str, Any]:
issue_comments,
lambda body: "CTRF PR COMMENT TAG:" in body or "### Test Results" in body,
)
megalinter_block = select_latest_comment_body(
issue_comments,
lambda body: "MegaLinter" in body and "Detailed Issues" in body,
required_user=GITHUB_ACTIONS_LOGIN,
)
if not summary_block:
warnings.append("CodeRabbit summary block was not found in issue comments.")
if not test_blocks:
warnings.append("PR test-report block was not found in issue comments.")
if not megalinter_block:
warnings.append("MegaLinter report block was not found in issue comments.")
latest_commit_review: dict[str, Any] = {}
try:
@ -506,6 +572,7 @@ def build_result(pr_number: int, branch: str) -> dict[str, Any]:
},
"coderabbit_comments": parse_actionable_comments(actionable_block) if actionable_block else {},
"latest_commit_review": latest_commit_review,
"megalinter_report": parse_megalinter_comment(megalinter_block) if megalinter_block else {},
"test_reports": [parse_test_report(block) for block in test_blocks],
"parse_warnings": warnings,
}
@ -569,6 +636,31 @@ def format_text(result: dict[str, Any]) -> str:
" Note: thread is still open; treat the visible 'Addressed in commit ...' text as unverified until local code matches."
)
megalinter_report = result.get("megalinter_report", {})
if megalinter_report:
lines.append("")
lines.append(
"MegaLinter: "
f"{megalinter_report.get('status', 'unknown')}"
+ (
f" ({megalinter_report.get('run_url', '')})"
if megalinter_report.get("run_url")
else ""
)
)
descriptor_rows = megalinter_report.get("descriptor_rows", [])
for descriptor_row in descriptor_rows:
lines.append(
"- "
f"{descriptor_row['descriptor']} / {descriptor_row['linter']}: "
f"errors={descriptor_row['errors']} warnings={descriptor_row['warnings']} files={descriptor_row['files']}"
)
for issue in megalinter_report.get("detailed_issues", []):
lines.append(f"- Detailed issue: {issue['summary']}")
lines.append(f" {collapse_whitespace(issue['details'])}")
lines.append("")
lines.append(f"Test reports: {len(result['test_reports'])}")
for index, report in enumerate(result["test_reports"], start=1):

View File

@ -45,6 +45,9 @@ ENABLE_LINTERS:
# 设置 C# 代码风格检查的参数和验证级别
# ========================
CSHARP_DOTNET_FORMAT_ARGUMENTS:
# 仓库根目录同时存在 GFramework.sln 与 GFramework.csproj
# 显式指定 workspace避免 dotnet format 在 CI 中因自动探测歧义直接异常退出。
- "GFramework.sln"
- "--severity"
- "info"
- "--verify-no-changes"
@ -83,4 +86,3 @@ GITHUB_COMMENT_REPORTER: true
PARALLEL: true
SHOW_ELAPSED_TIME: true
VALIDATE_ALL_CODEBASE: false

View File

@ -1,4 +1,5 @@
using System.IO;
using System.Threading;
using GFramework.Core.Abstractions.Events;
using GFramework.Core.Abstractions.Rule;
using GFramework.Core.Abstractions.Storage;
@ -188,6 +189,100 @@ public class PersistenceTests
Assert.That(exception!.Message, Does.Contain("from version 2"));
}
/// <summary>
/// 验证迁移器声明的目标版本必须与返回数据上的实际版本一致,避免错误迁移结果被静默接受。
/// </summary>
/// <returns>表示异步测试完成的任务。</returns>
/// <exception cref="InvalidOperationException">当迁移器返回的版本与声明目标版本不一致时抛出。</exception>
[Test]
public async Task SaveRepository_LoadAsync_Should_Throw_When_Migration_Result_Version_Does_Not_Match_Declaration()
{
var root = CreateTempRoot();
using var storage = new FileStorage(root, new JsonSerializer());
var config = new SaveConfiguration
{
SaveRoot = "saves",
SaveSlotPrefix = "slot_",
SaveFileName = "save"
};
var writer = new SaveRepository<TestVersionedSaveData>(storage, config);
await writer.SaveAsync(1, new TestVersionedSaveData
{
Name = "legacy",
Level = 3,
Experience = 0,
Version = 1
});
var repository = new SaveRepository<TestVersionedSaveData>(storage, config)
.RegisterMigration(new TestSaveMigrationV1ToV2ReturningV3());
var exception = Assert.ThrowsAsync<InvalidOperationException>(async () => await repository.LoadAsync(1));
var persisted = await storage.ReadAsync<TestVersionedSaveData>("saves/slot_1/save");
Assert.Multiple(() =>
{
Assert.That(exception!.Message, Does.Contain("declared target version 2"));
Assert.That(persisted.Version, Is.EqualTo(1));
Assert.That(persisted.Name, Is.EqualTo("legacy"));
Assert.That(persisted.Level, Is.EqualTo(3));
Assert.That(persisted.Experience, Is.EqualTo(0));
});
}
/// <summary>
/// 验证加载流程会在开始迁移前固定迁移表快照,避免并发注册让同一次加载看到变化中的链路。
/// </summary>
/// <returns>表示异步测试完成的任务。</returns>
/// <exception cref="InvalidOperationException">当快照中缺少后续迁移链时抛出。</exception>
[Test]
public async Task SaveRepository_LoadAsync_Should_Use_Migration_Snapshot_When_Registrations_Change_Concurrently()
{
var root = CreateTempRoot();
using var storage = new FileStorage(root, new JsonSerializer());
var config = new SaveConfiguration
{
SaveRoot = "saves",
SaveSlotPrefix = "slot_",
SaveFileName = "save"
};
var writer = new SaveRepository<TestVersionedSaveData>(storage, config);
await writer.SaveAsync(1, new TestVersionedSaveData
{
Name = "legacy",
Level = 3,
Experience = 0,
Version = 1
});
using var migrationStarted = new ManualResetEventSlim(false);
using var continueMigration = new ManualResetEventSlim(false);
var repository = new SaveRepository<TestVersionedSaveData>(storage, config)
.RegisterMigration(new BlockingSaveMigrationV1ToV2(migrationStarted, continueMigration));
var loadTask = repository.LoadAsync(1);
Assert.That(migrationStarted.Wait(TimeSpan.FromSeconds(5)), Is.True, "First migration step did not start in time.");
repository.RegisterMigration(new TestSaveMigrationV2ToV3());
continueMigration.Set();
var exception = Assert.ThrowsAsync<InvalidOperationException>(async () => await loadTask);
var persisted = await storage.ReadAsync<TestVersionedSaveData>("saves/slot_1/save");
Assert.Multiple(() =>
{
Assert.That(exception!.Message, Does.Contain("from version 2"));
Assert.That(persisted.Version, Is.EqualTo(1));
Assert.That(persisted.Name, Is.EqualTo("legacy"));
Assert.That(persisted.Level, Is.EqualTo(3));
Assert.That(persisted.Experience, Is.EqualTo(0));
});
}
/// <summary>
/// 验证统一设置仓库能够保存、重新加载并批量读取已注册的设置数据。
/// </summary>
@ -669,6 +764,34 @@ public class PersistenceTests
}
}
private sealed class BlockingSaveMigrationV1ToV2(
ManualResetEventSlim migrationStarted,
ManualResetEventSlim continueMigration) : ISaveMigration<TestVersionedSaveData>
{
public int FromVersion => 1;
public int ToVersion => 2;
public TestVersionedSaveData Migrate(TestVersionedSaveData oldData)
{
migrationStarted.Set();
if (!continueMigration.Wait(TimeSpan.FromSeconds(5)))
{
throw new InvalidOperationException("Timed out while waiting to continue the save migration test.");
}
return new TestVersionedSaveData
{
Name = $"{oldData.Name}-v2",
Level = oldData.Level,
Experience = oldData.Level * 100,
Version = 2,
LastModified = oldData.LastModified
};
}
}
private sealed class TestSaveMigrationV2ToV3 : ISaveMigration<TestVersionedSaveData>
{
public int FromVersion => 2;
@ -707,6 +830,25 @@ public class PersistenceTests
}
}
private sealed class TestSaveMigrationV1ToV2ReturningV3 : ISaveMigration<TestVersionedSaveData>
{
public int FromVersion => 1;
public int ToVersion => 2;
public TestVersionedSaveData Migrate(TestVersionedSaveData oldData)
{
return new TestVersionedSaveData
{
Name = oldData.Name,
Level = oldData.Level,
Experience = oldData.Experience,
Version = 3,
LastModified = oldData.LastModified
};
}
}
private sealed class TestNonVersionedMigration : ISaveMigration<TestSaveData>
{
public int FromVersion => 1;

View File

@ -49,6 +49,19 @@ public sealed class JsonSerializerTests
});
}
[Test]
public void Settings_And_Converters_Should_Expose_Live_Configuration_Instance()
{
var settings = new JsonSerializerSettings();
var serializer = new GameJsonSerializer(settings);
Assert.Multiple(() =>
{
Assert.That(serializer.Settings, Is.SameAs(settings));
Assert.That(serializer.Converters, Is.SameAs(settings.Converters));
});
}
[Test]
public void Converters_Should_Be_Used_For_Serialization_And_Deserialization()
{
@ -174,4 +187,4 @@ public sealed class JsonSerializerTests
};
}
}
}
}

View File

@ -1,3 +1,5 @@
using System.Reflection;
using System.Threading;
using GFramework.Core.Abstractions.Architectures;
using GFramework.Core.Abstractions.Lifecycle;
using GFramework.Core.Abstractions.Rule;
@ -47,6 +49,7 @@ public sealed class SettingsModelTests
await model.InitializeAsync();
Assert.That(model.GetData<TestSettingsData>().Version, Is.EqualTo(1));
model.GetData<TestSettingsData>().Version = 2;
model.RegisterMigration(new TestSettingsMigration());
repository.Stored["TestSettingsData"] = new TestSettingsData
@ -65,6 +68,109 @@ public sealed class SettingsModelTests
});
}
[Test]
public void RegisterMigration_Should_Reject_Duplicate_FromVersion_For_Same_SettingsType()
{
var locationProvider = new TestDataLocationProvider();
var repository = new FakeSettingsDataRepository();
var model = new SettingsModel<FakeSettingsDataRepository>(locationProvider, repository);
model.RegisterMigration(new TestSettingsMigration());
var exception = Assert.Throws<InvalidOperationException>(() => model.RegisterMigration(new TestSettingsMigration()));
Assert.That(exception!.Message, Does.Contain("Duplicate settings migration registration"));
}
[Test]
public async Task InitializeAsync_Should_Keep_Current_Instance_When_Migration_Chain_Is_Incomplete()
{
var locationProvider = new TestDataLocationProvider();
var repository = new FakeSettingsDataRepository();
var model = new SettingsModel<FakeSettingsDataRepository>(locationProvider, repository);
((IContextAware)model).SetContext(new Mock<IArchitectureContext>(MockBehavior.Loose).Object);
_ = model.GetData<TestLatestSettingsData>();
((IInitializable)model).Initialize();
repository.Stored["TestLatestSettingsData"] = new TestLatestSettingsData
{
Version = 1,
Value = "legacy"
};
model.RegisterMigration(new TestLatestSettingsMigrationV1ToV2());
await model.InitializeAsync();
var current = model.GetData<TestLatestSettingsData>();
Assert.Multiple(() =>
{
Assert.That(current.Version, Is.EqualTo(3));
Assert.That(current.Value, Is.EqualTo("default-v3"));
});
}
[Test]
public async Task RegisterMigration_During_Cache_Rebuild_Should_Not_Leave_Stale_Type_Cache()
{
var locationProvider = new TestDataLocationProvider();
var repository = new FakeSettingsDataRepository();
var model = new SettingsModel<FakeSettingsDataRepository>(locationProvider, repository);
((IContextAware)model).SetContext(new Mock<IArchitectureContext>(MockBehavior.Loose).Object);
_ = model.GetData<TestLatestSettingsData>();
((IInitializable)model).Initialize();
model.RegisterMigration(new TestLatestSettingsMigrationV1ToV2());
repository.Stored["TestLatestSettingsData"] = new TestLatestSettingsData
{
Version = 1,
Value = "legacy"
};
var lockField = typeof(SettingsModel<FakeSettingsDataRepository>)
.GetField("_migrationMapLock", BindingFlags.Instance | BindingFlags.NonPublic);
Assert.That(lockField, Is.Not.Null);
var migrationMapLock = lockField!.GetValue(model);
Assert.That(migrationMapLock, Is.Not.Null);
Task initializeTask;
Task registerTask;
lock (migrationMapLock!)
{
initializeTask = Task.Run(() => model.InitializeAsync());
registerTask = Task.Run(() => model.RegisterMigration(new TestLatestSettingsMigrationV2ToV3()));
Thread.Sleep(50);
Assert.Multiple(() =>
{
Assert.That(initializeTask.IsCompleted, Is.False);
Assert.That(registerTask.IsCompleted, Is.False);
});
}
await Task.WhenAll(initializeTask, registerTask);
repository.Stored["TestLatestSettingsData"] = new TestLatestSettingsData
{
Version = 1,
Value = "legacy"
};
await model.InitializeAsync();
var current = model.GetData<TestLatestSettingsData>();
Assert.Multiple(() =>
{
Assert.That(current.Version, Is.EqualTo(3));
Assert.That(current.Value, Is.EqualTo("legacy-migrated-v3"));
});
}
private sealed class TestSettingsData : ISettingsData
{
public string Value { get; set; } = "default";
@ -110,6 +216,70 @@ public sealed class SettingsModelTests
}
}
private sealed class TestLatestSettingsData : ISettingsData
{
public string Value { get; set; } = "default-v3";
public int Version { get; set; } = 3;
public DateTime LastModified { get; } = DateTime.UtcNow;
public void Reset()
{
Value = "default-v3";
Version = 3;
}
public void LoadFrom(ISettingsData source)
{
if (source is not TestLatestSettingsData data)
{
return;
}
Value = data.Value;
Version = data.Version;
}
}
private sealed class TestLatestSettingsMigrationV1ToV2 : ISettingsMigration
{
public Type SettingsType => typeof(TestLatestSettingsData);
public int FromVersion => 1;
public int ToVersion => 2;
public ISettingsSection Migrate(ISettingsSection oldData)
{
var data = (TestLatestSettingsData)oldData;
return new TestLatestSettingsData
{
Version = 2,
Value = $"{data.Value}-migrated"
};
}
}
private sealed class TestLatestSettingsMigrationV2ToV3 : ISettingsMigration
{
public Type SettingsType => typeof(TestLatestSettingsData);
public int FromVersion => 2;
public int ToVersion => 3;
public ISettingsSection Migrate(ISettingsSection oldData)
{
var data = (TestLatestSettingsData)oldData;
return new TestLatestSettingsData
{
Version = 3,
Value = $"{data.Value}-v3"
};
}
}
private sealed class FakeSettingsDataRepository : ISettingsDataRepository
{
public Dictionary<string, Type> RegisteredTypes { get; } = new(StringComparer.Ordinal);
@ -178,4 +348,4 @@ public sealed class SettingsModelTests
public IReadOnlyDictionary<string, string>? Metadata => null;
}
}
}

View File

@ -19,6 +19,7 @@ using System.Threading.Tasks;
using GFramework.Core.Abstractions.Storage;
using GFramework.Core.Utility;
using GFramework.Game.Abstractions.Data;
using GFramework.Game.Internal;
using GFramework.Game.Storage;
namespace GFramework.Game.Data;
@ -61,20 +62,20 @@ public class SaveRepository<TSaveData> : AbstractContextUtility, ISaveRepository
/// </exception>
/// <exception cref="ArgumentException">迁移器的目标版本不大于源版本。</exception>
/// <remarks>
/// 迁移注册表是可变共享状态。注册与加载可以并发发生,因此所有访问都通过 <see cref="_migrationsLock" />
/// 串行化,避免读写竞争和“部分可见”的迁移链
/// 迁移注册表是可变共享状态。注册路径通过 <see cref="_migrationsLock" /> 串行化;
/// 加载路径会在同一把锁下复制一次快照,保证单次加载始终使用同一个迁移链视图
/// </remarks>
public ISaveRepository<TSaveData> RegisterMigration(ISaveMigration<TSaveData> migration)
{
ArgumentNullException.ThrowIfNull(migration);
EnsureVersionedSaveType();
if (migration.ToVersion <= migration.FromVersion)
{
throw new ArgumentException(
$"Migration for {typeof(TSaveData).Name} must advance the version number.",
nameof(migration));
}
VersionedMigrationRunner.ValidateForwardOnlyRegistration(
typeof(TSaveData).Name,
"Save migration",
migration.FromVersion,
migration.ToVersion,
nameof(migration));
lock (_migrationsLock)
{
@ -227,57 +228,23 @@ public class SaveRepository<TSaveData> : AbstractContextUtility, ISaveRepository
EnsureVersionedSaveType();
var migrated = data;
Dictionary<int, ISaveMigration<TSaveData>> migrationsSnapshot;
lock (_migrationsLock)
{
migrationsSnapshot = new Dictionary<int, ISaveMigration<TSaveData>>(_migrations);
}
// 迁移链按“当前版本 -> 下一个已注册迁移器”推进;任何缺口都表示运行时无法安全解释旧存档。
// 读取迁移表时使用同一把锁,保证并发注册不会让加载线程看到不一致的链路状态。
while (currentVersion < targetVersion)
{
ISaveMigration<TSaveData>? migration;
lock (_migrationsLock)
{
_migrations.TryGetValue(currentVersion, out migration);
}
if (migration is null)
{
throw new InvalidOperationException(
$"No save migration is registered for {typeof(TSaveData).Name} from version {currentVersion}.");
}
migrated = migration.Migrate(migrated) ??
throw new InvalidOperationException(
$"Save migration for {typeof(TSaveData).Name} from version {currentVersion} returned null.");
if (migrated is not IVersionedData migratedVersionedData)
{
throw new InvalidOperationException(
$"Save migration for {typeof(TSaveData).Name} must return data implementing {nameof(IVersionedData)}.");
}
// 显式校验迁移器声明与实际结果,避免版本号不前进导致死循环或把旧数据错误回写为“已升级”。
if (migration.ToVersion != migratedVersionedData.Version)
{
throw new InvalidOperationException(
$"Save migration for {typeof(TSaveData).Name} declared target version {migration.ToVersion} " +
$"but returned version {migratedVersionedData.Version}.");
}
if (migratedVersionedData.Version <= currentVersion)
{
throw new InvalidOperationException(
$"Save migration for {typeof(TSaveData).Name} must advance beyond version {currentVersion}.");
}
if (migratedVersionedData.Version > targetVersion)
{
throw new InvalidOperationException(
$"Save migration for {typeof(TSaveData).Name} produced version {migratedVersionedData.Version}, " +
$"which exceeds the current runtime version {targetVersion}.");
}
currentVersion = migratedVersionedData.Version;
}
// 这里先对迁移表拍快照,避免并发注册让同一次加载在不同步骤看到不同版本的链路。
var migrated = VersionedMigrationRunner.MigrateToTargetVersion(
data,
targetVersion,
static saveData => ((IVersionedData)saveData).Version,
fromVersion => migrationsSnapshot.TryGetValue(fromVersion, out var migration) ? migration : null,
static migration => migration.ToVersion,
static (migration, currentData) => migration.Migrate(currentData),
$"{typeof(TSaveData).Name} in slot {slot}",
"save migration");
await storage.WriteAsync(_config.SaveFileName, migrated);
return migrated;

View File

@ -0,0 +1,149 @@
// Copyright (c) 2026 GeWuYou
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
namespace GFramework.Game.Internal;
/// <summary>
/// 提供版本化数据迁移链的共享执行逻辑。
/// </summary>
/// <remarks>
/// 该运行器只负责“按版本号推进”的公共约束,包括:
/// 前向注册校验、缺失链路失败、声明目标版本与实际结果版本一致性,以及避免版本回退或死循环。
/// 它不关心具体存储、日志、回写或异常吞吐策略;这些由调用方负责。
/// </remarks>
internal static class VersionedMigrationRunner
{
/// <summary>
/// 校验迁移注册是否表示一次有效的前向升级。
/// </summary>
/// <param name="subjectName">迁移所作用的主体名称,例如设置类型或存档类型。</param>
/// <param name="migrationKind">用于异常消息的迁移类别名称。</param>
/// <param name="fromVersion">源版本。</param>
/// <param name="toVersion">目标版本。</param>
/// <param name="paramName">异常中要使用的参数名。</param>
/// <exception cref="ArgumentException">目标版本不大于源版本时抛出。</exception>
internal static void ValidateForwardOnlyRegistration(
string subjectName,
string migrationKind,
int fromVersion,
int toVersion,
string paramName)
{
if (toVersion <= fromVersion)
{
throw new ArgumentException(
$"{migrationKind} for {subjectName} must advance the version number.",
paramName);
}
}
/// <summary>
/// 按目标运行时版本执行连续迁移。
/// </summary>
/// <typeparam name="TData">迁移数据类型。</typeparam>
/// <typeparam name="TMigration">迁移描述类型。</typeparam>
/// <param name="data">原始加载的数据。</param>
/// <param name="targetVersion">当前运行时支持的目标版本。</param>
/// <param name="getVersion">从数据对象提取版本号的委托。</param>
/// <param name="resolveMigration">根据当前版本查找下一步迁移器的委托。</param>
/// <param name="getToVersion">从迁移器提取声明目标版本的委托。</param>
/// <param name="applyMigration">执行单步迁移的委托。</param>
/// <param name="subjectName">迁移主体名称,用于异常消息。</param>
/// <param name="migrationKind">迁移类别名称,用于异常消息。</param>
/// <returns>迁移到目标版本后的数据;如果已经是最新版本,则返回原对象。</returns>
/// <exception cref="ArgumentNullException">
/// <paramref name="data" />、<paramref name="getVersion" />、<paramref name="resolveMigration" />、
/// <paramref name="getToVersion" /> 或 <paramref name="applyMigration" /> 为 <see langword="null" /> 时抛出。
/// </exception>
/// <exception cref="ArgumentException">
/// <paramref name="subjectName" /> 或 <paramref name="migrationKind" /> 为空白时抛出。
/// </exception>
/// <exception cref="InvalidOperationException">
/// 数据版本高于当前运行时、迁移链缺失、迁移器返回 <see langword="null" />、
/// 迁移结果版本与声明不一致、版本未前进或超出目标版本时抛出。
/// </exception>
internal static TData MigrateToTargetVersion<TData, TMigration>(
TData data,
int targetVersion,
Func<TData, int> getVersion,
Func<int, TMigration?> resolveMigration,
Func<TMigration, int> getToVersion,
Func<TMigration, TData, TData> applyMigration,
string subjectName,
string migrationKind)
where TData : class
{
ArgumentNullException.ThrowIfNull(data);
ArgumentNullException.ThrowIfNull(getVersion);
ArgumentNullException.ThrowIfNull(resolveMigration);
ArgumentNullException.ThrowIfNull(getToVersion);
ArgumentNullException.ThrowIfNull(applyMigration);
ArgumentException.ThrowIfNullOrWhiteSpace(subjectName);
ArgumentException.ThrowIfNullOrWhiteSpace(migrationKind);
var currentVersion = getVersion(data);
if (currentVersion > targetVersion)
{
throw new InvalidOperationException(
$"{subjectName} is version {currentVersion}, which is newer than the current runtime version {targetVersion}.");
}
if (currentVersion == targetVersion)
{
return data;
}
var current = data;
while (currentVersion < targetVersion)
{
var migration = resolveMigration(currentVersion);
if (migration is null)
{
throw new InvalidOperationException(
$"No {migrationKind} is registered for {subjectName} from version {currentVersion}.");
}
current = applyMigration(migration, current)
?? throw new InvalidOperationException(
$"{migrationKind} for {subjectName} from version {currentVersion} returned null.");
var migratedVersion = getVersion(current);
var declaredTargetVersion = getToVersion(migration);
if (declaredTargetVersion != migratedVersion)
{
throw new InvalidOperationException(
$"{migrationKind} for {subjectName} declared target version {declaredTargetVersion} " +
$"but returned version {migratedVersion}.");
}
if (migratedVersion <= currentVersion)
{
throw new InvalidOperationException(
$"{migrationKind} for {subjectName} must advance beyond version {currentVersion}.");
}
if (migratedVersion > targetVersion)
{
throw new InvalidOperationException(
$"{migrationKind} for {subjectName} produced version {migratedVersion}, " +
$"which exceeds the current runtime version {targetVersion}.");
}
currentVersion = migratedVersion;
}
return current;
}
}

View File

@ -180,12 +180,14 @@ using GFramework.Core.Abstractions.Storage;
using GFramework.Game.Serializer;
using GFramework.Game.Storage;
ISerializer serializer = new JsonSerializer();
var serializer = new JsonSerializer();
IStorage storage = new FileStorage("GameData", serializer);
await storage.WriteAsync("player/profile", new { Name = "Alice", Level = 3 });
```
这里的 `JsonSerializer` 建议在组合根只创建并配置一次;如果需要自定义 `JsonSerializerSettings` 或 converters请在把它注册给 `IStorage``DataRepository` 或架构 utility 之前完成。
如果你需要逻辑隔离,再包一层 `ScopedStorage`
```csharp

View File

@ -4,8 +4,12 @@ using Newtonsoft.Json;
namespace GFramework.Game.Serializer;
/// <summary>
/// JSON序列化器实现类用于将对象序列化为JSON字符串或将JSON字符串反序列化为对象
/// 基于 Newtonsoft.Json 的运行时 JSON 序列化器。
/// </summary>
/// <remarks>
/// 该类型会直接持有并复用外部提供的 <see cref="JsonSerializerSettings" /> 实例及其转换器集合,而不会在构造时复制配置。
/// 请在组合根或启动阶段完成全部配置,并在注册给其他组件后将这些配置视为只读;否则在并发调用期间同时修改设置或转换器集合可能导致不可预测行为。
/// </remarks>
public sealed class JsonSerializer
: IRuntimeTypeSerializer
{
@ -14,7 +18,10 @@ public sealed class JsonSerializer
/// <summary>
/// 初始化 JSON 序列化器。
/// </summary>
/// <param name="settings">可选的 Newtonsoft.Json 配置;不提供时使用默认配置。</param>
/// <param name="settings">
/// 可选的 Newtonsoft.Json 配置实例;不提供时使用默认配置。
/// 传入的实例会被当前序列化器直接复用,后续对该实例的修改会影响所有后续序列化与反序列化调用。
/// </param>
public JsonSerializer(JsonSerializerSettings? settings = null)
{
_settings = settings ?? new JsonSerializerSettings();
@ -23,11 +30,19 @@ public sealed class JsonSerializer
/// <summary>
/// 获取当前序列化器使用的 Newtonsoft.Json 配置实例。
/// </summary>
/// <remarks>
/// 返回的是当前序列化器持有的活动配置实例,适合在启动阶段补充 contract resolver、格式化策略或 converter。
/// 一旦该序列化器被共享给其他组件,应避免再修改返回值,以免破坏调用方对并发读行为的假设。
/// </remarks>
public JsonSerializerSettings Settings => _settings;
/// <summary>
/// 获取当前序列化器使用的自定义转换器集合。
/// </summary>
/// <remarks>
/// 该集合与 <see cref="Settings" /> 的 <see cref="JsonSerializerSettings.Converters" /> 引用相同。
/// 请在注册序列化器前完成 converter 配置,并避免在序列化器已经发布后继续增删转换器。
/// </remarks>
public IList<JsonConverter> Converters => _settings.Converters;
/// <summary>
@ -115,4 +130,4 @@ public sealed class JsonSerializer
return result;
}
}
}

View File

@ -4,6 +4,7 @@ using GFramework.Core.Logging;
using GFramework.Core.Model;
using GFramework.Game.Abstractions.Data;
using GFramework.Game.Abstractions.Setting;
using GFramework.Game.Internal;
using GFramework.Game.Setting.Events;
namespace GFramework.Game.Setting;
@ -28,6 +29,7 @@ public class SettingsModel<TRepository>(IDataLocationProvider? locationProvider,
private readonly ConcurrentDictionary<Type, ISettingsData> _data = new();
private readonly ConcurrentDictionary<Type, Dictionary<int, ISettingsMigration>> _migrationCache = new();
private readonly object _migrationMapLock = new();
private readonly ConcurrentDictionary<(Type type, int from), ISettingsMigration> _migrations = new();
private volatile bool _initialized;
@ -114,10 +116,41 @@ public class SettingsModel<TRepository>(IDataLocationProvider? locationProvider,
/// <returns>
/// 返回当前 ISettingsModel 实例,支持链式调用。
/// </returns>
/// <exception cref="ArgumentNullException">
/// <paramref name="migration" /> 为 <see langword="null" /> 时抛出。
/// </exception>
/// <exception cref="ArgumentException">
/// 迁移声明的目标版本不大于源版本时抛出。
/// </exception>
/// <exception cref="InvalidOperationException">
/// 同一设置类型与源版本已经注册过迁移器时抛出。
/// </exception>
/// <remarks>
/// 迁移注册表与按类型缓存的版本映射需要保持一致;因此注册与 cache miss 时的缓存重建
/// 统一通过 <see cref="_migrationMapLock" /> 串行化,避免并发加载把旧快照重新写回缓存。
/// </remarks>
public ISettingsModel RegisterMigration(ISettingsMigration migration)
{
_migrations[(migration.SettingsType, migration.FromVersion)] = migration;
_migrationCache.TryRemove(migration.SettingsType, out _);
ArgumentNullException.ThrowIfNull(migration);
VersionedMigrationRunner.ValidateForwardOnlyRegistration(
migration.SettingsType.Name,
"Settings migration",
migration.FromVersion,
migration.ToVersion,
nameof(migration));
lock (_migrationMapLock)
{
if (!_migrations.TryAdd((migration.SettingsType, migration.FromVersion), migration))
{
throw new InvalidOperationException(
$"Duplicate settings migration registration for {migration.SettingsType.Name} from version {migration.FromVersion}.");
}
_migrationCache.TryRemove(migration.SettingsType, out _);
}
return this;
}
@ -156,7 +189,7 @@ public class SettingsModel<TRepository>(IDataLocationProvider? locationProvider,
if (raw is not ISettingsData loaded)
continue;
var migrated = MigrateIfNeeded(loaded);
var migrated = MigrateIfNeeded(loaded, data);
// 回填(不替换实例)
data.LoadFrom(migrated);
@ -277,29 +310,75 @@ public class SettingsModel<TRepository>(IDataLocationProvider? locationProvider,
_repository.RegisterDataType(location, type);
}
private ISettingsData MigrateIfNeeded(ISettingsData data)
/// <summary>
/// 将已加载的设置数据迁移到当前运行时实例声明的目标版本。
/// </summary>
/// <param name="data">从仓库读取的设置数据。</param>
/// <param name="latestData">当前内存中的设置实例,其 <c>Version</c> 值代表目标版本。</param>
/// <returns>迁移后的设置数据;如果无需迁移则返回原对象。</returns>
/// <remarks>
/// 该方法按设置类型缓存迁移表,并始终以 <paramref name="latestData" /> 的版本作为目标运行时版本,
/// 避免把旧文件中的版本号误当成当前版本。具体的缺链、版本一致性与前进性校验都委托给
/// <see cref="VersionedMigrationRunner" /> 统一处理。缓存重建与迁移注册共用
/// <see cref="_migrationMapLock" />,确保运行中的初始化不会把过期迁移快照写回缓存。
/// </remarks>
private ISettingsData MigrateIfNeeded(ISettingsData data, ISettingsData latestData)
{
if (data is not IVersionedData versioned)
return data;
var type = data.GetType();
var current = data;
if (!_migrationCache.TryGetValue(type, out var versionMap))
Dictionary<int, ISettingsMigration> versionMap;
lock (_migrationMapLock)
{
versionMap = _migrations
.Where(kv => kv.Key.type == type)
.ToDictionary(kv => kv.Key.from, kv => kv.Value);
if (!_migrationCache.TryGetValue(type, out var cachedVersionMap))
{
// cache miss 与 RegisterMigration 共用同一把锁,避免注册新迁移后又被旧快照覆盖回缓存。
versionMap = _migrations
.Where(kv => kv.Key.type == type)
.ToDictionary(kv => kv.Key.from, kv => kv.Value);
_migrationCache[type] = versionMap;
_migrationCache[type] = versionMap;
}
else
{
versionMap = cachedVersionMap;
}
}
while (versionMap.TryGetValue(versioned.Version, out var migration))
return VersionedMigrationRunner.MigrateToTargetVersion(
data,
latestData.Version,
static settings => settings.Version,
fromVersion => versionMap.TryGetValue(fromVersion, out var migration) ? migration : null,
static migration => migration.ToVersion,
static (migration, current) => ApplySettingsMigration(migration, current),
$"{type.Name} settings",
"settings migration");
}
/// <summary>
/// 执行单步设置迁移,并验证迁移结果仍然属于已注册的设置类型。
/// </summary>
/// <param name="migration">要执行的迁移器。</param>
/// <param name="currentData">当前版本的数据。</param>
/// <returns>迁移后的设置数据。</returns>
/// <exception cref="InvalidOperationException">
/// 迁移结果不实现 <see cref="ISettingsData" />,或返回了与声明设置类型不兼容的数据时抛出。
/// </exception>
private static ISettingsData ApplySettingsMigration(ISettingsMigration migration, ISettingsData currentData)
{
var fromVersion = currentData.Version;
var migrated = migration.Migrate(currentData);
if (migrated is not ISettingsData migratedData)
{
current = (ISettingsData)migration.Migrate(current);
versioned = current;
throw new InvalidOperationException(
$"Settings migration for {migration.SettingsType.Name} from version {fromVersion} must return {nameof(ISettingsData)}.");
}
return current;
if (!migration.SettingsType.IsInstanceOfType(migratedData))
{
throw new InvalidOperationException(
$"Settings migration for {migration.SettingsType.Name} from version {fromVersion} returned incompatible data type {migratedData.GetType().Name}.");
}
return migratedData;
}
}

View File

@ -0,0 +1,197 @@
using GFramework.Core.Abstractions.Coroutine;
using GFramework.Core.Coroutine;
using GFramework.Core.Coroutine.Instructions;
using GFramework.Godot.Coroutine;
using System.Runtime.CompilerServices;
namespace GFramework.Godot.Tests.Coroutine;
/// <summary>
/// 验证 <see cref="Timing" /> 在纯托管测试宿主下仍保持与真实 Godot 生命周期一致的阶段语义。
/// </summary>
[TestFixture]
[NonParallelizable]
public sealed class TimingTests
{
private Timing _timing = null!;
/// <summary>
/// 为每个测试准备独立的 Timing 宿主,避免静态实例槽位相互污染。
/// </summary>
[SetUp]
public void SetUp()
{
// Timing 继承自 Godot.Node在纯 dotnet test 宿主中直接运行原生构造函数会触发测试进程崩溃。
// 这里仅为调度语义测试创建未初始化对象,再由 InitializeForTests 补齐纯托管字段与调度器状态。
_timing = (Timing)RuntimeHelpers.GetUninitializedObject(typeof(Timing));
_timing.InitializeForTests();
}
/// <summary>
/// 清理测试宿主注册的调度器与实例槽位。
/// </summary>
[TearDown]
public void TearDown()
{
_timing.DisposeForTests();
}
/// <summary>
/// 验证暂停场景时只会冻结普通 Process 协程,忽略暂停段仍会继续推进。
/// </summary>
[Test]
public void AdvanceProcessFrameForTests_Should_Freeze_Process_Segment_But_Keep_IgnorePause_Segment_Running()
{
var executedSegments = new List<string>();
var processHandle = _timing.RunCoroutineOnInstance(
CompleteAfterOneFrame(() => executedSegments.Add("process")),
Segment.Process);
var ignorePauseHandle = _timing.RunCoroutineOnInstance(
CompleteAfterOneFrame(() => executedSegments.Add("ignore-pause")),
Segment.ProcessIgnorePause);
_timing.AdvanceProcessFrameForTests(paused: true);
Assert.Multiple(() =>
{
Assert.That(executedSegments, Is.EqualTo(new[] { "ignore-pause" }));
Assert.That(_timing.ProcessCoroutines, Is.EqualTo(1));
Assert.That(_timing.ProcessIgnorePauseCoroutines, Is.EqualTo(0));
Assert.That(_timing.GetSchedulerForTests(Segment.Process).IsCoroutineAlive(processHandle), Is.True);
Assert.That(
_timing.GetSchedulerForTests(Segment.ProcessIgnorePause)
.TryGetCompletionStatus(ignorePauseHandle, out var status),
Is.True);
Assert.That(status, Is.EqualTo(CoroutineCompletionStatus.Completed));
});
}
/// <summary>
/// 验证 Physics 帧只会推进 Physics 段,不会提前消费普通 Process 段的等待。
/// </summary>
[Test]
public void AdvancePhysicsFrameForTests_Should_Only_Advance_Physics_Segment()
{
var executedSegments = new List<string>();
var processHandle = _timing.RunCoroutineOnInstance(
CompleteAfterOneFrame(() => executedSegments.Add("process")),
Segment.Process);
var physicsHandle = _timing.RunCoroutineOnInstance(
CompleteAfterOneFrame(() => executedSegments.Add("physics")),
Segment.PhysicsProcess);
_timing.AdvancePhysicsFrameForTests();
Assert.Multiple(() =>
{
Assert.That(executedSegments, Is.EqualTo(new[] { "physics" }));
Assert.That(_timing.GetSchedulerForTests(Segment.Process).IsCoroutineAlive(processHandle), Is.True);
Assert.That(_timing.GetSchedulerForTests(Segment.PhysicsProcess).IsCoroutineAlive(physicsHandle), Is.False);
});
}
/// <summary>
/// 验证帧尾段会在 Process 段之后执行,保持与生产宿主 `_Process -> CallDeferred` 的顺序一致。
/// </summary>
[Test]
public void AdvanceProcessFrameForTests_Should_Run_Deferred_Segment_After_Process_Segment()
{
var executionOrder = new List<string>();
_timing.RunCoroutineOnInstance(
CompleteAfterOneFrame(() => executionOrder.Add("process")),
Segment.Process);
_timing.RunCoroutineOnInstance(
CompleteAfterOneFrame(() => executionOrder.Add("deferred")),
Segment.DeferredProcess);
_timing.AdvanceProcessFrameForTests(paused: false);
Assert.That(executionOrder, Is.EqualTo(new[] { "process", "deferred" }));
}
/// <summary>
/// 验证 <see cref="WaitForFixedUpdate" /> 只会在 Physics 段完成,避免阶段型等待被错误地提前消费。
/// </summary>
[Test]
public void WaitForFixedUpdate_Should_Only_Complete_On_Physics_Segment()
{
var processCompletions = 0;
var physicsCompletions = 0;
var processHandle = _timing.RunCoroutineOnInstance(
CompleteAfterInstruction(new WaitForFixedUpdate(), () => processCompletions++),
Segment.Process);
var physicsHandle = _timing.RunCoroutineOnInstance(
CompleteAfterInstruction(new WaitForFixedUpdate(), () => physicsCompletions++),
Segment.PhysicsProcess);
_timing.AdvanceProcessFrameForTests(paused: false);
_timing.AdvancePhysicsFrameForTests();
Assert.Multiple(() =>
{
Assert.That(processCompletions, Is.EqualTo(0));
Assert.That(physicsCompletions, Is.EqualTo(1));
Assert.That(_timing.GetSchedulerForTests(Segment.Process).IsCoroutineAlive(processHandle), Is.True);
Assert.That(_timing.GetSchedulerForTests(Segment.PhysicsProcess).IsCoroutineAlive(physicsHandle), Is.False);
});
}
/// <summary>
/// 验证 <see cref="WaitForEndOfFrame" /> 只会在 Deferred 段完成,避免提前穿透到普通 Process 段。
/// </summary>
[Test]
public void WaitForEndOfFrame_Should_Only_Complete_On_Deferred_Segment()
{
var processCompletions = 0;
var deferredCompletions = 0;
var processHandle = _timing.RunCoroutineOnInstance(
CompleteAfterInstruction(new WaitForEndOfFrame(), () => processCompletions++),
Segment.Process);
var deferredHandle = _timing.RunCoroutineOnInstance(
CompleteAfterInstruction(new WaitForEndOfFrame(), () => deferredCompletions++),
Segment.DeferredProcess);
_timing.AdvanceProcessFrameForTests(paused: false);
Assert.Multiple(() =>
{
Assert.That(processCompletions, Is.EqualTo(0));
Assert.That(deferredCompletions, Is.EqualTo(1));
Assert.That(_timing.GetSchedulerForTests(Segment.Process).IsCoroutineAlive(processHandle), Is.True);
Assert.That(_timing.GetSchedulerForTests(Segment.DeferredProcess).IsCoroutineAlive(deferredHandle), Is.False);
});
}
/// <summary>
/// 构造一个在单帧等待后执行回调的测试协程。
/// </summary>
/// <param name="onCompleted">等待完成后执行的回调。</param>
/// <returns>供 Timing 运行的协程枚举器。</returns>
private static IEnumerator<IYieldInstruction> CompleteAfterOneFrame(Action onCompleted)
{
ArgumentNullException.ThrowIfNull(onCompleted);
yield return new WaitOneFrame();
onCompleted();
}
/// <summary>
/// 构造一个在指定等待指令完成后执行回调的测试协程。
/// </summary>
/// <param name="instruction">要验证的等待指令。</param>
/// <param name="onCompleted">等待完成后执行的回调。</param>
/// <returns>供 Timing 运行的协程枚举器。</returns>
private static IEnumerator<IYieldInstruction> CompleteAfterInstruction(
IYieldInstruction instruction,
Action onCompleted)
{
ArgumentNullException.ThrowIfNull(instruction);
ArgumentNullException.ThrowIfNull(onCompleted);
yield return instruction;
onCompleted();
}
}

View File

@ -0,0 +1,160 @@
using System;
using System.Collections.Generic;
using GFramework.Core.Abstractions.Coroutine;
using GFramework.Core.Coroutine;
namespace GFramework.Godot.Coroutine;
public partial class Timing
{
/// <summary>
/// 使用可控时间源初始化当前 <see cref="Timing" /> 实例,供纯托管测试验证宿主阶段语义。
/// </summary>
/// <param name="processDeltaProvider">`Process` 段的增量提供器。</param>
/// <param name="physicsDeltaProvider">`PhysicsProcess` 段的增量提供器。</param>
/// <param name="deferredDeltaProvider">`DeferredProcess` 段的增量提供器。</param>
/// <remarks>
/// 该入口只用于测试宿主驱动顺序,不会挂接真实场景树,也不会暴露给运行时调用方。
/// 由于协程句柄包含实例槽位前缀,这里仍会注册实例槽位,便于沿用生产代码的查询与控制路径。
/// </remarks>
internal void InitializeForTests(
Func<double>? processDeltaProvider = null,
Func<double>? physicsDeltaProvider = null,
Func<double>? deferredDeltaProvider = null)
{
_instanceId = 1;
_ownedCoroutineRegistrations ??= new Dictionary<CoroutineHandle, OwnedCoroutineRegistration>();
_ownedCoroutinesByNode ??= new Dictionary<ulong, HashSet<CoroutineHandle>>();
RegisterInstance();
_processTimeSource = new GodotTimeSource(processDeltaProvider ?? DefaultDeltaProvider);
_processRealtimeTimeSource = new GodotTimeSource(processDeltaProvider ?? DefaultDeltaProvider);
_processIgnorePauseTimeSource = new GodotTimeSource(processDeltaProvider ?? DefaultDeltaProvider);
_processIgnorePauseRealtimeTimeSource = new GodotTimeSource(processDeltaProvider ?? DefaultDeltaProvider);
_physicsTimeSource = new GodotTimeSource(physicsDeltaProvider ?? DefaultDeltaProvider);
_physicsRealtimeTimeSource = new GodotTimeSource(physicsDeltaProvider ?? DefaultDeltaProvider);
_deferredTimeSource = new GodotTimeSource(deferredDeltaProvider ?? processDeltaProvider ?? DefaultDeltaProvider);
_deferredRealtimeTimeSource =
new GodotTimeSource(deferredDeltaProvider ?? processDeltaProvider ?? DefaultDeltaProvider);
_processScheduler = new CoroutineScheduler(
_processTimeSource,
_instanceId,
256,
false,
_processRealtimeTimeSource,
CoroutineExecutionStage.Update);
_processIgnorePauseScheduler = new CoroutineScheduler(
_processIgnorePauseTimeSource,
_instanceId,
256,
false,
_processIgnorePauseRealtimeTimeSource,
CoroutineExecutionStage.Update);
_physicsScheduler = new CoroutineScheduler(
_physicsTimeSource,
_instanceId,
128,
false,
_physicsRealtimeTimeSource,
CoroutineExecutionStage.FixedUpdate);
_deferredScheduler = new CoroutineScheduler(
_deferredTimeSource,
_instanceId,
64,
false,
_deferredRealtimeTimeSource,
CoroutineExecutionStage.EndOfFrame);
AttachSchedulerLifecycleHandlers(ProcessScheduler);
AttachSchedulerLifecycleHandlers(ProcessIgnorePauseScheduler);
AttachSchedulerLifecycleHandlers(PhysicsScheduler);
AttachSchedulerLifecycleHandlers(DeferredScheduler);
}
/// <summary>
/// 以测试宿主的方式推进一次 Process 帧。
/// </summary>
/// <param name="paused">
/// 指示当前帧是否视为场景暂停。
/// 暂停时仅推进 `ProcessIgnorePause` 段,并跳过 `DeferredProcess`,以匹配生产宿主逻辑。
/// </param>
internal void AdvanceProcessFrameForTests(bool paused)
{
if (!paused)
{
_processScheduler?.Update();
}
_processIgnorePauseScheduler?.Update();
_frameCounter++;
if (!paused)
{
_deferredScheduler?.Update();
}
}
/// <summary>
/// 以测试宿主的方式推进一次 Physics 帧。
/// </summary>
internal void AdvancePhysicsFrameForTests()
{
_physicsScheduler?.Update();
}
/// <summary>
/// 获取指定分段对应的调度器,供测试读取完成状态与快照。
/// </summary>
/// <param name="segment">目标分段。</param>
/// <returns>对应分段的调度器实例。</returns>
internal CoroutineScheduler GetSchedulerForTests(Segment segment)
{
return GetScheduler(segment);
}
/// <summary>
/// 清理测试初始化留下的实例槽位与调度器状态,避免跨测试污染静态单例表。
/// 仅当当前测试宿主仍持有共享单例引用时才会清理 `_instance`,以免误伤同进程内的其他宿主。
/// </summary>
internal void DisposeForTests()
{
DetachAllOwnedRegistrations();
ClearOnInstance();
if (_instanceId < ActiveInstances.Length)
{
ActiveInstances[_instanceId] = null;
}
CleanupInstanceIfNecessary(this);
_processScheduler = null;
_processIgnorePauseScheduler = null;
_physicsScheduler = null;
_deferredScheduler = null;
_processTimeSource = null;
_processRealtimeTimeSource = null;
_processIgnorePauseTimeSource = null;
_processIgnorePauseRealtimeTimeSource = null;
_physicsTimeSource = null;
_physicsRealtimeTimeSource = null;
_deferredTimeSource = null;
_deferredRealtimeTimeSource = null;
_frameCounter = 0;
_instanceId = 1;
}
/// <summary>
/// 提供测试默认使用的稳定帧增量。
/// </summary>
/// <returns>固定的 60 FPS 增量。</returns>
private static double DefaultDeltaProvider()
{
return 1.0 / 60.0;
}
}

View File

@ -20,8 +20,8 @@ public partial class Timing : Node
private static readonly Timing?[] ActiveInstances = new Timing?[16];
private static Timing? _instance;
private readonly Dictionary<CoroutineHandle, OwnedCoroutineRegistration> _ownedCoroutineRegistrations = new();
private readonly Dictionary<ulong, HashSet<CoroutineHandle>> _ownedCoroutinesByNode = new();
private Dictionary<CoroutineHandle, OwnedCoroutineRegistration> _ownedCoroutineRegistrations = new();
private Dictionary<ulong, HashSet<CoroutineHandle>> _ownedCoroutinesByNode = new();
private GodotTimeSource? _deferredRealtimeTimeSource;
private CoroutineScheduler? _deferredScheduler;
private GodotTimeSource? _deferredTimeSource;
@ -185,15 +185,19 @@ public partial class Timing : Node
ActiveInstances[_instanceId] = null;
}
CleanupInstanceIfNecessary();
CleanupInstanceIfNecessary(this);
}
/// <summary>
/// 清理单例引用
/// 仅在当前实例仍持有共享单例引用时清理它,避免多宿主场景误清其他实例
/// </summary>
private static void CleanupInstanceIfNecessary()
/// <param name="instance">正在退出生命周期的实例。</param>
private static void CleanupInstanceIfNecessary(Timing instance)
{
_instance = null;
if (ReferenceEquals(_instance, instance))
{
_instance = null;
}
}
/// <summary>
@ -901,4 +905,4 @@ public partial class Timing : Node
}
#endregion
}
}

View File

@ -7,32 +7,47 @@
## 当前恢复点
- 恢复点编号:`COROUTINE-OPTIMIZATION-RP-001`
- 当前阶段:`Phase 1`
- 恢复点编号:`COROUTINE-OPTIMIZATION-RP-002`
- 当前阶段:`Phase 4`
- 当前焦点:
- 已将 worktree-root 遗留的 `local-plan/` 迁入 `ai-plan/public/coroutine-optimization/`active 入口只保留当前恢复信息
- 基于早期计划中已经完成的第一轮实现,重新收敛后续切入点,避免把语义命名、宿主集成、测试扩面和文档清理混成一次大任务
- 明确记录“旧计划没有 durable trace只有 todo 基线”,后续恢复时先读 active 入口,再按需展开 archive
- 已为 `Timing` 补齐纯托管测试宿主入口,允许在 `dotnet test` 下验证 Godot 协程宿主阶段语义,而不依赖原生 `Node` 构造
- 已补充 `GFramework.Godot.Tests/Coroutine/TimingTests.cs`锁定暂停、segment 路由和阶段型等待指令的回归覆盖
- 已根据 PR #259 的最新 CodeRabbit review 收口测试宿主清理对称性,并将 `TimingTests` 固定为非并行执行
- 已根据 PR #259`MegaLinter analysis: Success with warnings` 结果修复 `dotnet format` workspace 歧义,并增强 PR review skill 以提取此类 CI warning
- 下一轮优先补“仍需真实场景树参与”的归属协程 / 退树语义或转入文档迁移收口不再回到“Godot 宿主没有自动化回归”的旧状态
## 当前状态摘要
- Core 协程第一轮语义收拢已完成,包括真实时间源、执行阶段与阶段型等待的基础行为调整
- 调度器第一版控制与可观测能力已落地,包括完成状态、等待完成、快照查询和完成事件
- Godot 宿主第一版接入已落地,包括分段时间源、节点归属协程入口与退树终止语义
- Core 与 Godot 两侧已经具备一轮基础测试与文档更新,但更贴近运行时的集成验证、兼容性说明和迁移对照仍未收口
- Core 与 Godot 两侧已经具备一轮基础测试与文档更新;其中 Godot 侧现已补齐 `Timing` 的 pause / segment / stage-wait 自动化回归
- 更贴近真实场景树的节点归属、退树与 `queue_free` 集成验证,以及迁移对照文档仍未收口
## 当前活跃事实
- 本主题的详细历史不是从已有 trace 迁入,而是由旧 `local-plan/todos/coroutine/*.md` 整合出的计划基线
- `RP-001` 的详细工作流拆分、验收标准和缺失 trace 说明已归档到主题内 `archive/`
- 当前工作树分支 `feat/coroutine-optimization` 已在 `ai-plan/public/README.md` 建立 topic 映射
- `RP-002` 已在 `GFramework.Godot` 内新增仅供测试使用的 `Timing` 纯托管宿主入口,不改公开 API
- `RP-002` 已新增 `TimingTests`,覆盖:
- 暂停时 `Process` / `ProcessIgnorePause` 的差异
- `Process` / `PhysicsProcess` / `DeferredProcess` 的推进边界
- `WaitForFixedUpdate``WaitForEndOfFrame` 的阶段型等待语义
- 针对 PR #259 的最新未解决 review 线程,已补充两项收口:
- `TimingTests` 已添加 `[NonParallelizable]`,避免共享静态实例槽位在 NUnit 并行执行时互相污染
- `Timing` 的测试清理与运行时退树清理现仅在当前实例持有共享 `_instance` 引用时才会清空单例状态
- 针对 PR #259`MegaLinter` warning已补充两项收口
- `.mega-linter.yml` 现为 `CSHARP_DOTNET_FORMAT_ARGUMENTS` 显式指定 `GFramework.sln`,避免仓库根目录同时存在 `*.sln``*.csproj` 时触发 workspace 歧义
- `.codex/skills/gframework-pr-review/` 现会抓取并解析 `github-actions[bot]` 发布的 `MegaLinter analysis: Success with warnings` comment默认把其中的 detailed issues 视为待验证输入
- `dotnet test GFramework.Godot.Tests/GFramework.Godot.Tests.csproj -c Release --no-restore` 当前通过,合计 `58` 个测试
## 当前风险
- 语义兼容性风险:`Delay``WaitForSecondsScaled``WaitForNextFrame``WaitOneFrame` 等命名与行为若继续调整,可能影响既有调用认知
- 缓解措施:下一轮只先挑一个语义面收敛,并同步补足迁移说明与宿主前提文档
- 宿主验证缺口风险Godot 节点归属、退树、暂停与各 segment 差异仍缺少更贴近运行时的自动化回归
- 缓解措施:优先规划 Godot 集成测试宿主,再决定是否扩展更多运行时诊断 API
- 宿主验证缺口风险Godot 节点归属、退树、`queue_free` 与真实场景树回调仍缺少更贴近运行时的自动化回归
- 缓解措施:下一轮仅补真实场景树相关宿主验证;已完成的 `Timing` 纯托管语义测试不再重复规划
- 历史信息稀疏风险:旧计划没有同步保留当时的执行 trace 与完整验证记录
- 缓解措施active 文档只保留当前结论;需要历史语义时回看 archive并明确哪些内容是从早期 todo 推导出的基线
@ -45,9 +60,21 @@
- 旧 `local-plan` 的五份 coroutine todo 已整合进主题内历史归档,不再作为 worktree-root durable recovery 入口保留
- active 跟踪文件只保留当前恢复点、活跃事实、风险与下一步,避免把更早期计划直接平移成新的追加式日志
- `dotnet test GFramework.Godot.Tests/GFramework.Godot.Tests.csproj -c Release --filter "FullyQualifiedName~TimingTests" --no-restore`
- 结果:通过
- 备注:新增 `TimingTests``5` 个测试全部通过
- `dotnet test GFramework.Godot.Tests/GFramework.Godot.Tests.csproj -c Release --no-restore`
- 结果:通过
- 备注Godot 测试项目共 `58` 个测试全部通过
- `dotnet test GFramework.Godot.Tests/GFramework.Godot.Tests.csproj -c Release --filter "FullyQualifiedName~TimingTests" --no-restore`
- 结果:通过
- 备注:针对 PR #259 review 修复后的 `TimingTests``5` 个测试全部通过
- `dotnet build GFramework.Godot.Tests/GFramework.Godot.Tests.csproj -c Release --no-restore`
- 结果:通过
- 备注CI/MegaLinter 配置与 PR review skill 更新后,目标测试项目仍保持 `0 warning / 0 error`
## 下一步
1. 若继续该主题,先在 `Core Semantics``Control And Observability``Godot Runtime Integration``Tests And Regressions``Docs And Migration` 中只选一个切入点推进
2. 若优先补验证,先规划 Godot 集成测试宿主与节点归属/退树/暂停场景,再扩运行时诊断 API
3. 若优先补文档与迁移说明,先清理其余 `StartCoroutine()/StopCoroutine()` 残留,再为阶段等待和新入口补统一对照说明
1. 若继续补验证,优先只做真实场景树相关的节点归属 / 退树 / `queue_free` 回归,不再重新设计 `Timing` 纯托管宿主
2. 当前 PR 合并前可直接回到 GitHub 确认最新 push 是否已消除 `MegaLinter analysis` warning并顺手处理 review 线程的回复与 resolve
3. 若转入文档收口,优先清理其余 `StartCoroutine()/StopCoroutine()` 残留,并补 Godot 新入口与阶段等待的迁移对照

View File

@ -32,3 +32,39 @@
1. 后续若继续 coroutine 主题,只从 `ai-plan/public/coroutine-optimization/` 进入,不再恢复 `local-plan/`
2. 下一轮只选择一个主切入点推进,避免语义、宿主、测试和文档扩面同时发生
3. 若 active 入口后续积累多轮已完成且已验证阶段,再按同一模式迁入该主题自己的 `archive/`
## 2026-04-20
### 阶段Godot 宿主回归覆盖补齐RP-002
- 选择只推进 `Tests And Regressions` 切面,不同时改动协程语义与迁移文档
- 新增 `GFramework.Godot/Coroutine/Timing.Testing.cs`,为 `Timing` 提供仅供测试使用的纯托管初始化与帧推进入口
- 新增 `GFramework.Godot.Tests/Coroutine/TimingTests.cs`,覆盖:
- 暂停时 `Process``ProcessIgnorePause` 的推进差异
- `Process` / `PhysicsProcess` / `DeferredProcess` 的执行边界与顺序
- `WaitForFixedUpdate``WaitForEndOfFrame` 的阶段型等待语义
- 关键决策:在 `dotnet test` 宿主中不直接运行 `Timing : Node` 的原生构造,而是使用未初始化对象配合测试入口补齐纯托管字段
- 原因:直接构造 `Godot.Node` 派生类型会导致 VSTest test host 崩溃,无法作为稳定回归路径
- 约束:当前测试覆盖的是宿主调度语义,不覆盖真实场景树信号、节点归属与退树回调
- 为支持上述测试入口,将 `Timing` 的节点归属字典从只读字段调整为可在测试初始化阶段重建的私有字段,未改动任何公共 API
- 完成验证:
- `dotnet test GFramework.Godot.Tests/GFramework.Godot.Tests.csproj -c Release --filter "FullyQualifiedName~TimingTests" --no-restore`
- `dotnet test GFramework.Godot.Tests/GFramework.Godot.Tests.csproj -c Release --no-restore`
- 同日根据 PR #259 的最新 unresolved CodeRabbit review 继续收口:
- 在 `GFramework.Godot.Tests/Coroutine/TimingTests.cs` 为 fixture 添加 `[NonParallelizable]`,避免静态实例槽位在 NUnit 并行执行时互相污染
- 将 `Timing``_instance` 清理改为“仅当当前实例仍持有共享单例引用时才执行”,同时覆盖运行时 `_ExitTree()` 与测试入口 `DisposeForTests()`
- 额外完成验证:
- `dotnet test GFramework.Godot.Tests/GFramework.Godot.Tests.csproj -c Release --filter "FullyQualifiedName~TimingTests" --no-restore`
- 同日继续收口 PR #259 页面上的 `MegaLinter analysis: Success with warnings`
- 确认 detailed issue 实际不是格式差异,而是 `dotnet format` 在仓库根目录同时发现 `GFramework.sln``GFramework.csproj` 后因未指定 workspace 直接抛异常
- 更新 `.mega-linter.yml`,为 `CSHARP_DOTNET_FORMAT_ARGUMENTS` 显式指定 `GFramework.sln`
- 更新 `.codex/skills/gframework-pr-review/SKILL.md``scripts/fetch_current_pr_review.py`,使 skill 默认抓取并输出 `github-actions[bot]` 的 MegaLinter comments 和 detailed issues
- 额外完成验证:
- `python3 -c "from pathlib import Path; compile(Path('.codex/skills/gframework-pr-review/scripts/fetch_current_pr_review.py').read_text(encoding='utf-8'), '.codex/skills/gframework-pr-review/scripts/fetch_current_pr_review.py', 'exec'); print('syntax-ok')"`
- `python3 .codex/skills/gframework-pr-review/scripts/fetch_current_pr_review.py --branch feat/coroutine-optimization --format json`
- `dotnet build GFramework.Godot.Tests/GFramework.Godot.Tests.csproj -c Release --no-restore`
### 下一步
1. 若继续补验证,优先规划真实场景树参与的节点归属 / 退树 / `queue_free` 测试宿主
2. 若转入文档收口,优先清理仍引用 `StartCoroutine()/StopCoroutine()` 的教程残留,并补迁移对照

View File

@ -13,26 +13,37 @@
- 已将根目录 legacy `local-plan/settings-persistence-serialization-tracking.md` 迁入
`ai-plan/public/data-repository-persistence/`
- 第一轮 settings / persistence / serialization 修复、测试与文档同步已完成,并收入主题内 `archive/`
- 下一轮需要继续评估 `JsonSerializer` 配置说明、迁移模型统一抽象与 codec / persistence pipeline 边界
- 已完成 `SettingsModel` / `SaveRepository<T>` 共享迁移执行器收敛与契约补强
- 已完成 PR #260 的追加 review follow-up`SettingsModel` 迁移缓存并发一致性
- 下一轮需要继续评估 codec / persistence pipeline 边界
## 当前状态摘要
- 高优先级实现、测试与文档对齐已在本主题历史阶段完成,当前 active 入口主要保留后续 design/backlog 恢复点
- 当前分支 `feat/data-repository-persistence` 已在 `ai-plan/public/README.md` 建立 topic 映射
- 旧单文件不再同时承担 todo 与 trace 角色,后续恢复统一从本 topic 的 active tracking / trace 进入
- `SettingsModel``SaveRepository<T>` 的版本迁移链现在共用同一个 internal runner继续沿这条线扩展时应优先复用而不是再复制链式迁移逻辑
## 当前活跃事实
- 原 `local-plan` 只有一份混合 tracking 文件,没有独立的 `todos/``traces/`
- 详细历史已拆分迁入主题内 `archive/`active tracking / trace 只保留当前恢复点、风险与下一步
- 历史已验证结果包括 `GFramework.Game.Tests` 的定向与全量通过,以及 `docs/zh-CN/game/*` 的同步更新
- `GFramework.Game.Serializer.JsonSerializer` 当前直接暴露活动中的 `JsonSerializerSettings` 与 converters 集合,配置不会被复制
- `GFramework.Game.Internal.VersionedMigrationRunner` 已统一前向迁移注册校验、缺链失败、声明版本一致性与非递增防护
- `SettingsModel` 现在以当前内存设置实例的 `Version` 作为目标运行时版本;若迁移失败则保留当前实例并记录错误日志
- `SaveRepository<T>` 继续在 `LoadAsync(slot)` 期间迁移并回写,但其核心链式校验已与设置迁移共用同一实现
- PR #260 review follow-up 已完成:`VersionedMigrationRunner` / `SettingsModel` 的 XML 异常契约已补齐,
`SaveRepository<T>` 单次加载已切换为迁移表快照,避免并发注册期间读取变化中的迁移链
- `SettingsModel` 现已通过 `_migrationMapLock` 串行化迁移注册与 cache miss 时的按类型缓存重建,
避免并发注册把旧快照重新写回 `_migrationCache`
- `docs/zh-CN/game/index.md` 当前仍承担最低接入示例,因此其中的 `JsonSerializer` 配置必须避免鼓励对
用户可篡改存档启用不受限的多态反序列化
## 当前风险
- 只读配置 / 线程安全说明缺口:`JsonSerializer` 新增 settings 与 converter 扩展后,若不补充约束说明,后续容易被误用
- 缓解措施:下一轮先核对源码与文档,必要时补 XML docs 或采用文档
- 迁移模型分叉风险:`SettingsModel``DataRepository``SaveRepository<T>` 的版本演进机制仍可能继续分叉
- 缓解措施:在新增更多 persistence feature 前,先评估能否抽出统一的 migration abstraction
- codec / persistence pipeline 边界风险:压缩、加密、元数据与备份策略还散落在仓库与存储语义之间
- 缓解措施:下一轮先梳理现有 `Serializer` / `Storage` / `Repository` 的责任边界,再决定是否需要新的 pipeline abstraction
- Active 入口回膨胀风险:若后续把实现细节继续堆回 active 文档,会重新退化成旧 `local-plan`
- 缓解措施:后续阶段完成并验证后,继续迁入本 topic 的 `archive/`
@ -45,9 +56,20 @@
- 旧混合 `local-plan` 已拆分迁入主题内 archive
- active 跟踪文件已按 `ai-plan` 治理规则精简为当前恢复入口
- 已补充 `JsonSerializer` XML docs、文档示例与最小契约测试
- `dotnet test GFramework.Game.Tests/GFramework.Game.Tests.csproj -c Release --filter "FullyQualifiedName~JsonSerializerTests"` 已通过9/9
- 已完成 `VersionedMigrationRunner` 抽取,并让 `SettingsModel` / `SaveRepository<T>` 共用链式迁移校验
- `dotnet test GFramework.Game.Tests/GFramework.Game.Tests.csproj -c Release --filter "FullyQualifiedName~SettingsModelTests|FullyQualifiedName~PersistenceTests"` 已通过20/20
- 已完成 PR #260 follow-up并新增定向回归测试锁定迁移快照与失败不污染持久化数据的约束
- `dotnet test GFramework.Game.Tests/GFramework.Game.Tests.csproj -c Release --filter "FullyQualifiedName~SettingsModelTests|FullyQualifiedName~PersistenceTests" -m:1 -nodeReuse:false`
已通过21/21
- 已新增 `SettingsModelTests` 并发回归测试,锁定迁移注册与 cache miss 重建不会留下 stale cache
- `dotnet test GFramework.Game.Tests/GFramework.Game.Tests.csproj -c Release --filter "FullyQualifiedName~SettingsModelTests" -m:1 -nodeReuse:false`
已通过5/5
- 本次定向验证过程中出现的 analyzer warning 来自仓库既有代码,不属于本轮新增问题
## 下一步
1. 先评估 `JsonSerializer` 的只读配置、线程安全与实例级 converter 使用说明是否需要补足
2. 再评估设置 / 通用仓库 / 存档仓库的迁移模型是否要统一抽象
3. 最后评估压缩 / 加密 / 元数据策略是否应落入更明确的 codec / persistence pipeline
1. 评估压缩 / 加密 / 元数据策略是否应落入更明确的 codec / persistence pipeline
2. 梳理 `Serializer``Storage``DataRepositoryOptions` 与统一文件仓库之间的扩展点重叠
3. 若进入下一轮实现,先确定是否需要新的 dedicated recovery point 以避免 RP-001 active 入口继续膨胀

View File

@ -24,7 +24,107 @@
- 历史 trace 归档:
- `ai-plan/public/data-repository-persistence/archive/traces/data-repository-persistence-history-pre-rp001.md`
### 下一步
### 下一步JsonSerializer 配置契约补充
1. 后续继续该主题时,只从 `ai-plan/public/data-repository-persistence/` 进入,不再恢复 `local-plan/`
2. 若 active 入口再次积累多轮已完成且已验证阶段,继续按同一模式迁入该主题自己的 `archive/`
## 2026-04-20
### 阶段JsonSerializer 配置契约补充RP-001
- 复核 `GFramework.Game/Serializer/JsonSerializer.cs` 后确认:当前实现直接复用传入的 `JsonSerializerSettings`,并通过 `Settings` / `Converters` 暴露活动配置对象
- 复核 `docs/zh-CN/game/serialization.md` 后确认:现有 FAQ 把 `JsonSerializer` 写成“本身线程安全”,与当前可变配置契约不一致
- 决定本轮只补齐契约说明而不改变运行时行为:
- 在源码 XML docs 中说明 settings / converters 的生命周期与并发约束
- 在定向单测中固定“序列化器暴露活动配置实例”的当前契约
- 在 `docs/zh-CN/game/serialization.md``docs/zh-CN/game/index.md``GFramework.Game/README.md` 中同步修正接入建议
### 下一步JsonSerializer 配置契约补充
1. `dotnet test GFramework.Game.Tests/GFramework.Game.Tests.csproj -c Release --filter "FullyQualifiedName~JsonSerializerTests"` 已通过9/9
2. 验证过程中出现的 analyzer warning 为仓库既有 warning未在本轮扩大
3. 下一步回到 migration abstraction 与 codec / persistence pipeline 的后续评估
### 阶段迁移执行器统一收敛RP-001
- 对 `SettingsModel``DataRepository``UnifiedSettingsDataRepository``SaveRepository<T>` 的实现进行并排核对后确认:
- `DataRepository``UnifiedSettingsDataRepository` 不直接承担按版本号推进的迁移链
- 实际重复点只在 `SettingsModel``SaveRepository<T>` 的“版本迁移链执行与校验”逻辑
- 决定不新增 public migration abstraction而是抽出 internal `VersionedMigrationRunner`
- 统一前向注册校验
- 统一缺链失败
- 统一声明目标版本与实际结果版本一致性校验
- 统一非递增 / 超目标版本防护
- `SettingsModel` 本轮额外补强:
- 拒绝同一设置类型同一 `FromVersion` 的重复注册
- 以当前内存设置实例的 `Version` 作为目标运行时版本
- 迁移失败时保持当前实例不被旧数据覆盖,并继续记录错误日志
- `SaveRepository<T>` 改为复用同一个 internal runner但保留“加载成功后自动回写升级结果”的现有仓库语义
- 同步更新 `docs/zh-CN/game/setting.md``docs/zh-CN/game/data.md`,补迁移链约束说明
- 新增 / 更新测试:
- `SettingsModelTests`:重复注册拒绝、不完整链路保持当前实例、缓存失效场景
- `PersistenceTests`:迁移结果版本与声明版本不一致时显式失败
### 验证:迁移执行器统一收敛
1. `dotnet test GFramework.Game.Tests/GFramework.Game.Tests.csproj -c Release --filter "FullyQualifiedName~SettingsModelTests|FullyQualifiedName~PersistenceTests"` 已通过20/20
2. 过程中出现的 analyzer warning 来自仓库既有项,未在本轮扩大
### 下一步:迁移执行器统一收敛
1. 进入 codec / persistence pipeline 边界评估
2. 重点查看压缩、加密、元数据、备份是否仍然跨越 `Serializer` / `Storage` / `Repository` 多层分散
### 阶段PR #260 review follow-upRP-001
- 复核当前 PR review 后确认两条未解决 inline 线程仍成立:
- `SaveRepository<T>.MigrateIfNeededAsync` 在每一步迁移时都现查 `_migrations`,会让并发 `RegisterMigration`
把同一次加载暴露给变化中的迁移链
- `VersionedMigrationRunner.MigrateToTargetVersion` 的 XML docs 仍缺少参数校验异常契约
- 同步接受两条 outside-diff / nitpick 中仍然成立且低成本的 follow-up
- `SettingsModel.RegisterMigration``MigrateIfNeeded` 需要补齐 XML 文档,和当前迁移约束保持一致
- `PersistenceTests` 需要锁定“迁移失败后不会污染已持久化存档”的行为
- 额外复核 `docs/zh-CN/game/index.md` 后确认:最低接入示例仍把 `TypeNameHandling.Auto` 用在用户可编辑的存档场景,
这与当前仓库安全约束不一致,因此一并改为默认安全配置并补充白名单说明
- 本轮实现计划:
- `SaveRepository<T>` 在加载前复制迁移表快照,再把 resolver 切换到快照读取
- 新增并发回归测试,证明加载过程不会在迁移途中读到后续注册的链路
- 补齐 `VersionedMigrationRunner` / `SettingsModel` XML docs
- 更新 `docs/zh-CN/game/index.md` 示例与 active tracking
- 实际落地结果:
- `SaveRepository<T>` 已切换为在加载前复制 `_migrations` 快照,并在同一次迁移链执行中只读取快照
- `VersionedMigrationRunner``SettingsModel.RegisterMigration``SettingsModel.MigrateIfNeeded` 已补齐缺失 XML docs
- `PersistenceTests` 已新增“迁移失败不污染持久化数据”断言,以及并发注册下固定迁移快照的回归测试
- `docs/zh-CN/game/index.md``JsonSerializer` 接入示例已改为 `TypeNameHandling.None`,并补充白名单 binder 说明
### 验证PR #260 review follow-up
1. `dotnet test GFramework.Game.Tests/GFramework.Game.Tests.csproj -c Release --filter "FullyQualifiedName~SettingsModelTests|FullyQualifiedName~PersistenceTests" -m:1 -nodeReuse:false` 已通过21/21
2. 本次验证未再出现本轮新增的 XML doc warning输出中的 analyzer warning 仍为仓库既有项
### 下一步PR #260 review follow-up
1. 回到 codec / persistence pipeline 边界评估
2. 继续判断压缩、加密、元数据与备份策略是否需要新的 dedicated pipeline abstraction
### 阶段SettingsModel 迁移缓存并发修复RP-001
- 重新使用 `$gframework-pr-review` 复核 PR #260 后确认:此前遗漏了一条仍然 open 的 `SettingsModel.cs` major comment
问题点不是迁移执行本身,而是 `_migrations``_migrationCache` 在并发注册和 cache miss 重建交错时,可能把旧快照写回缓存
- 确认该 comment 来自 `2026-04-20T04:23:09Z` 的 CodeRabbit review run当前修复策略采用同一把私有锁串行化
- `RegisterMigration` 中的 `_migrations.TryAdd(...)``_migrationCache.TryRemove(...)`
- `MigrateIfNeeded` 在 cache miss 时按类型重建 `versionMap` 并写回 `_migrationCache`
- 同步补充源码注释与 XML remarks明确运行时注册与缓存重建共享同一并发语义
- 计划新增 `SettingsModelTests` 回归测试,验证 cache rebuild 与运行时注册在同一把锁前排队后,后续初始化能看到新增迁移而不会留下 stale cache
### 验证SettingsModel 迁移缓存并发修复
1. `dotnet test GFramework.Game.Tests/GFramework.Game.Tests.csproj -c Release --filter "FullyQualifiedName~SettingsModelTests" -m:1 -nodeReuse:false` 已通过5/5
2. 本轮验证中未再出现 `SettingsModel` 新增的 nullable warning输出中的 analyzer warning 仍为仓库既有项加上新的 `MA0158`
建议,后者来自本轮新增对象锁
### 下一步SettingsModel 迁移缓存并发修复
1. 若继续收口 analyzer 反馈,可评估是否将 `_migrationMapLock` 升级为 `System.Threading.Lock`,同时保留可验证的并发回归测试策略
2. 否则恢复到 codec / persistence pipeline 边界评估

View File

@ -299,7 +299,9 @@ public partial class AutoSaveController : IController
- `TSaveData` 需要实现 `IVersionedData`
- 仓库以 `new TSaveData().Version` 作为当前运行时目标版本
- 每个迁移器负责一个 `FromVersion -> ToVersion` 跳转
- 同一个 `FromVersion` 只能注册一个迁移器,且 `ToVersion` 必须严格大于 `FromVersion`
- 加载时仓库会按链路连续执行迁移,并在成功后自动回写升级后的存档
- 迁移器返回数据上的 `Version` 必须与声明的 `ToVersion` 一致
- 如果缺少中间迁移器,或者读到了比当前运行时更高的版本,`LoadAsync` 会抛出异常,避免静默加载错误数据
```csharp

View File

@ -683,12 +683,14 @@ public class GameDataSerializer
public GameDataSerializer()
{
// 在构造阶段完成全部 JsonSerializerSettings / Converter 配置,
// 后续把 _serializer 视为共享只读实例。
_serializer = new JsonSerializer(new JsonSerializerSettings
{
Formatting = Formatting.Indented,
NullValueHandling = NullValueHandling.Ignore,
DefaultValueHandling = DefaultValueHandling.Populate,
TypeNameHandling = TypeNameHandling.Auto
TypeNameHandling = TypeNameHandling.None
});
// 自定义转换器
@ -727,6 +729,9 @@ public class GameDataSerializer
}
```
对于玩家可直接编辑的存档文件,默认应保持 `TypeNameHandling.None`。只有确实需要多态反序列化时,才应配合
白名单 `SerializationBinder` 显式限制允许的类型集合。
### 自定义 JSON 转换器
```csharp

View File

@ -55,7 +55,8 @@ public interface IRuntimeTypeSerializer : ISerializer
### JSON 序列化器
`JsonSerializer` 是基于 Newtonsoft.Json 的实现:
`JsonSerializer` 是基于 Newtonsoft.Json 的实现。它会直接复用构造时提供的
`JsonSerializerSettings``Converters` 集合,因此推荐在组合根统一完成配置,再把同一个实例注册到架构或仓库中复用:
```csharp
public sealed class JsonSerializer : IRuntimeTypeSerializer
@ -81,8 +82,10 @@ public class GameArchitecture : Architecture
{
protected override void Init()
{
// 注册 JSON 序列化器
// 在启动阶段一次性完成配置,后续将该实例视为只读
var jsonSerializer = new JsonSerializer();
jsonSerializer.Converters.Add(new PlayerDataJsonConverter());
RegisterUtility<ISerializer>(jsonSerializer);
RegisterUtility<IRuntimeTypeSerializer>(jsonSerializer);
}
@ -166,6 +169,40 @@ public void SerializeRuntimeType()
}
```
### 配置生命周期约束
`JsonSerializer` 不会复制 `JsonSerializerSettings`。这意味着:
- 传给构造函数的 settings 会被原样保留
- `serializer.Settings``serializer.Converters` 返回的都是活动配置对象
- 一旦序列化器实例已经注册给其他模块或开始被并发调用,就不应继续修改这些配置
推荐模式:
```csharp
var settings = new JsonSerializerSettings
{
Formatting = Formatting.Indented,
NullValueHandling = NullValueHandling.Ignore
};
settings.Converters.Add(new Vector2JsonConverter());
var serializer = new JsonSerializer(settings);
architecture.RegisterUtility<ISerializer>(serializer);
architecture.RegisterUtility<IRuntimeTypeSerializer>(serializer);
```
不推荐模式:
```csharp
var serializer = architecture.GetUtility<IRuntimeTypeSerializer>();
// 已经共享给运行时后再修改配置,容易让并发调用得到不稳定行为
((JsonSerializer)serializer).Converters.Add(new LateBoundConverter());
```
## 高级用法
### 与存储系统集成
@ -463,10 +500,11 @@ public PlayerDataV2 LoadWithMigration(string json)
## 最佳实践
1. **使用接口而非具体类型**:依赖 `ISerializer` 接口
1. **优先依赖接口,在组合根统一实例化**:业务代码依赖 `ISerializer`,具体 `JsonSerializer` 在启动阶段配置一次即可
```csharp
✓ var serializer = this.GetUtility<ISerializer>();
✗ var serializer = new JsonSerializer(); // 避免直接实例化
✓ var serializer = new JsonSerializer(settings); // 仅在组合根/启动阶段配置
✗ var serializer = new JsonSerializer(); // 避免在业务逻辑中临时创建
```
2. **为数据类提供默认值**:确保反序列化的健壮性
@ -731,10 +769,26 @@ public async Task<GameData> LoadEncrypted(string key)
### 问题:序列化器是线程安全的吗?
**解答**
`JsonSerializer` 本身是线程安全的,但建议通过架构的 Utility 系统访问:
`JsonSerializer` 的并发读行为只在“配置不再变化”这个前提下才安全。当前实现会暴露活动中的
`JsonSerializerSettings``Converters` 集合,因此:
- 可以在启动阶段创建并配置一个共享实例
- 可以在配置冻结后把该实例注册为 Utility 或注入到仓库
- 不应在序列化器已经被多个调用方使用时继续修改 settings、contract resolver 或 converters
推荐按下面的方式在启动阶段完成配置,然后只做读操作:
```csharp
// 线程安全的访问方式
// 启动阶段完成全部配置
var serializer = new JsonSerializer(new JsonSerializerSettings
{
NullValueHandling = NullValueHandling.Ignore
});
serializer.Converters.Add(new GameDataJsonConverter());
architecture.RegisterUtility<ISerializer>(serializer);
// 运行阶段只复用,不再修改配置
public async Task ParallelSave()
{
var tasks = Enumerable.Range(0, 10).Select(async i =>

View File

@ -181,6 +181,14 @@ public interface ISettingsMigration
`InitializeAsync()` 读取到旧版本设置时,会按已注册迁移链逐步升级,再通过 `LoadFrom` 回填到当前实例。
迁移规则如下:
- 同一个设置类型的同一个 `FromVersion` 只能注册一个迁移器
- `ToVersion` 必须严格大于 `FromVersion`
- `InitializeAsync()` 会以当前运行时代码里该设置实例的 `Version` 作为目标版本
- 如果迁移链缺口、迁移结果类型不兼容、迁移结果版本与声明不一致,或者读取到比当前运行时更高的版本,当前设置节不会覆盖内存中的最新实例,并会记录错误日志
- 与 `SaveRepository<TSaveData>` 不同,设置初始化阶段会跳过失败的设置节并继续处理其他设置节,而不是把异常继续向外抛出
## 依赖项
要让设置系统完整工作,通常需要准备: