From ba15d9d0f6fae2901a5a306376c1d880fa8c2ae4 Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Thu, 16 Apr 2026 09:26:56 +0800 Subject: [PATCH] =?UTF-8?q?docs(config):=20=E6=B7=BB=E5=8A=A0=E6=B8=B8?= =?UTF-8?q?=E6=88=8F=E5=86=85=E5=AE=B9=E9=85=8D=E7=BD=AE=E7=B3=BB=E7=BB=9F?= =?UTF-8?q?=E5=AE=8C=E6=95=B4=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增游戏内容配置系统详细介绍文档 - 包含 YAML 配置源文件和 JSON Schema 结构描述说明 - 提供推荐目录结构和 Schema 示例配置 - 添加官方启动帮助器 GameConfigBootstrap 使用指南 - 包含 Godot 文本配置桥接和运行时读取模板 - 提供 Architecture 推荐接入模板和热重载配置说明 - 添加运行时校验行为和开发期热重载功能说明 - 包含生成器接入约定和 VS Code 工具使用指南 - 新增 JavaScript 配置验证实现和格式校验模式 - 添加字符串格式校验包括 date、email、uuid 等类型 - 实现配置字段可编辑性检测和批量编辑功能支持 --- .../Config/YamlConfigLoaderTests.cs | 3 ++ .../Config/YamlConfigSchemaValidator.cs | 54 ++++++++++++++++++- .../Config/SchemaConfigGeneratorTests.cs | 11 ++-- .../Config/SchemaConfigGenerator.cs | 3 +- docs/zh-CN/game/config-system.md | 7 ++- .../src/configValidation.js | 37 ++++++++++++- .../test/configValidation.test.js | 27 +++++++--- 7 files changed, 123 insertions(+), 19 deletions(-) diff --git a/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs b/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs index 29fce467..e23217e9 100644 --- a/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs +++ b/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs @@ -838,6 +838,7 @@ public class YamlConfigLoaderTests /// 满足该 format 的 YAML 标量值。 [TestCase("date", "2026-04-11")] [TestCase("date-time", "2026-04-11T08:30:00Z")] + [TestCase("duration", "P2DT3H4M5.5S")] [TestCase("email", "boss@example.com")] [TestCase("time", "08:30:00Z")] [TestCase("uri", "https://example.com/loot-table")] @@ -892,6 +893,7 @@ public class YamlConfigLoaderTests /// 不满足该 format 的 YAML 标量值。 [TestCase("date", "2026-02-30")] [TestCase("date-time", "2026-04-11T08:30:00")] + [TestCase("duration", "P1Y")] [TestCase("email", "boss.example.com")] [TestCase("time", "08:30:00")] [TestCase("uri", "/loot-table")] @@ -988,6 +990,7 @@ public class YamlConfigLoaderTests Assert.That(exception.Diagnostic.RawValue, Is.EqualTo("ipv4")); Assert.That(exception.Message, Does.Contain("unsupported string format")); Assert.That(exception.Message, Does.Contain("date-time")); + Assert.That(exception.Message, Does.Contain("duration")); Assert.That(exception.Message, Does.Contain("time")); }); } diff --git a/GFramework.Game/Config/YamlConfigSchemaValidator.cs b/GFramework.Game/Config/YamlConfigSchemaValidator.cs index 2c944d17..473a21a8 100644 --- a/GFramework.Game/Config/YamlConfigSchemaValidator.cs +++ b/GFramework.Game/Config/YamlConfigSchemaValidator.cs @@ -18,7 +18,7 @@ internal static class YamlConfigSchemaValidator // The runtime intentionally uses the same culture-invariant regex semantics as the // JS tooling so grouping and backreferences behave consistently across environments. private const RegexOptions SupportedPatternRegexOptions = RegexOptions.CultureInvariant; - private const string SupportedStringFormatNames = "'date', 'date-time', 'email', 'time', 'uri', 'uuid'"; + private const string SupportedStringFormatNames = "'date', 'date-time', 'duration', 'email', 'time', 'uri', 'uuid'"; private static readonly Regex ExactDecimalPattern = new( @"^(?[+-]?)(?:(?\d+)(?:\.(?\d*))?|\.(?\d+))(?:[eE](?[+-]?\d+))?$", @@ -36,6 +36,10 @@ internal static class YamlConfigSchemaValidator @"^(?\d{4})-(?\d{2})-(?\d{2})T(?\d{2}):(?\d{2}):(?\d{2})(?\.\d+)?(?Z|[+-]\d{2}:\d{2})$", RegexOptions.CultureInvariant | RegexOptions.Compiled); + private static readonly Regex SupportedDurationFormatRegex = new( + @"^P(?:(?\d+)D)?(?:T(?:(?\d+)H)?(?:(?\d+)M)?(?:(?\d+(?:\.\d+)?)S)?)?$", + RegexOptions.CultureInvariant | RegexOptions.Compiled); + private static readonly Regex SupportedTimeFormatRegex = new( @"^(?\d{2}):(?\d{2}):(?\d{2})(?\.\d+)?(?Z|[+-]\d{2}:\d{2})$", RegexOptions.CultureInvariant | RegexOptions.Compiled); @@ -1707,6 +1711,10 @@ internal static class YamlConfigSchemaValidator kind = YamlConfigStringFormatKind.DateTime; return true; + case "duration": + kind = YamlConfigStringFormatKind.Duration; + return true; + case "email": kind = YamlConfigStringFormatKind.Email; return true; @@ -2325,6 +2333,7 @@ internal static class YamlConfigSchemaValidator { YamlConfigStringFormatKind.Date => MatchesSupportedDateFormat(value), YamlConfigStringFormatKind.DateTime => MatchesSupportedDateTimeFormat(value), + YamlConfigStringFormatKind.Duration => MatchesSupportedDurationFormat(value), YamlConfigStringFormatKind.Email => SupportedEmailFormatRegex.IsMatch(value), YamlConfigStringFormatKind.Time => MatchesSupportedTimeFormat(value), YamlConfigStringFormatKind.Uri => MatchesSupportedUriFormat(value), @@ -2371,6 +2380,44 @@ internal static class YamlConfigSchemaValidator out _); } + /// + /// 判断字符串是否满足共享支持的 duration 格式。 + /// 当前共享子集只接受 day-time duration:可声明 D/H/M/S,秒允许小数, + /// 但拒绝 Y / M(月) / W 等依赖日历语义的部分, + /// 避免不同宿主对“一个月/一年到底多长”出现解释漂移。 + /// + /// 待校验的字符串值。 + /// 当前值是否是合法持续时间文本。 + private static bool MatchesSupportedDurationFormat(string value) + { + var match = SupportedDurationFormatRegex.Match(value); + if (!match.Success) + { + return false; + } + + var hasDayComponent = match.Groups["days"].Success; + var hasHourComponent = match.Groups["hours"].Success; + var hasMinuteComponent = match.Groups["minutes"].Success; + var hasSecondComponent = match.Groups["seconds"].Success; + var hasAnyComponent = hasDayComponent || hasHourComponent || hasMinuteComponent || hasSecondComponent; + if (!hasAnyComponent) + { + return false; + } + + var hasTimeSection = value.Contains('T', StringComparison.Ordinal); + if (hasTimeSection && + !hasHourComponent && + !hasMinuteComponent && + !hasSecondComponent) + { + return false; + } + + return true; + } + /// /// 判断字符串是否满足共享支持的 time 格式。 /// 该格式固定要求显式时区偏移,并只接受 24 小时制的合法时分秒文本, @@ -3753,6 +3800,11 @@ internal enum YamlConfigStringFormatKind /// DateTime, + /// + /// 表示 day-time duration 形式的持续时间。 + /// + Duration, + /// /// 表示基础电子邮件地址格式。 /// diff --git a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs index aeb14b6f..45812adc 100644 --- a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs +++ b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs @@ -95,7 +95,7 @@ public class SchemaConfigGeneratorTests /// 验证共享支持的字符串 format 会写入生成 XML 文档。 /// [Test] - public void Run_Should_Write_Supported_Time_Format_Into_Generated_Documentation() + public void Run_Should_Write_Supported_Duration_Format_Into_Generated_Documentation() { const string source = """ namespace TestApp @@ -109,12 +109,12 @@ public class SchemaConfigGeneratorTests const string schema = """ { "type": "object", - "required": ["id", "scheduleTime"], + "required": ["id", "respawnDelay"], "properties": { "id": { "type": "integer" }, - "scheduleTime": { + "respawnDelay": { "type": "string", - "format": "time" + "format": "duration" } } } @@ -133,7 +133,7 @@ public class SchemaConfigGeneratorTests StringComparer.Ordinal); Assert.That(result.Results.Single().Diagnostics, Is.Empty); - Assert.That(generatedSources["MonsterConfig.g.cs"], Does.Contain("Constraints: format = 'time'.")); + Assert.That(generatedSources["MonsterConfig.g.cs"], Does.Contain("Constraints: format = 'duration'.")); } /// @@ -178,6 +178,7 @@ public class SchemaConfigGeneratorTests Assert.That(diagnostic.GetMessage(), Does.Contain("address")); Assert.That(diagnostic.GetMessage(), Does.Contain("ipv4")); Assert.That(diagnostic.GetMessage(), Does.Contain("date-time")); + Assert.That(diagnostic.GetMessage(), Does.Contain("duration")); Assert.That(diagnostic.GetMessage(), Does.Contain("time")); }); } diff --git a/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs b/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs index 39705c0b..4e633775 100644 --- a/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs +++ b/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs @@ -30,7 +30,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator private const string LookupIndexReferencePropertyMessage = "Reference properties are excluded from generated lookup indexes because they already carry cross-table semantics."; - private const string SupportedStringFormatNames = "'date', 'date-time', 'email', 'time', 'uri', and 'uuid'"; + private const string SupportedStringFormatNames = "'date', 'date-time', 'duration', 'email', 'time', 'uri', and 'uuid'"; /// public void Initialize(IncrementalGeneratorInitializationContext context) @@ -707,6 +707,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator { "date" => true, "date-time" => true, + "duration" => true, "email" => true, "time" => true, "uri" => true, diff --git a/docs/zh-CN/game/config-system.md b/docs/zh-CN/game/config-system.md index 2b7ae2dc..5c7f0040 100644 --- a/docs/zh-CN/game/config-system.md +++ b/docs/zh-CN/game/config-system.md @@ -12,7 +12,7 @@ - JSON Schema 作为结构描述 - 一对象一文件的目录组织 - 运行时只读查询 -- Runtime / Generator / Tooling 共享支持 `const`、`minimum`、`maximum`、`exclusiveMinimum`、`exclusiveMaximum`、`multipleOf`、`minLength`、`maxLength`、`pattern`、`format`(当前稳定子集:`date`、`date-time`、`email`、`time`、`uri`、`uuid`)、`minItems`、`maxItems`、`uniqueItems`、`contains`、`minContains`、`maxContains`、`minProperties`、`maxProperties` +- Runtime / Generator / Tooling 共享支持 `const`、`minimum`、`maximum`、`exclusiveMinimum`、`exclusiveMaximum`、`multipleOf`、`minLength`、`maxLength`、`pattern`、`format`(当前稳定子集:`date`、`date-time`、`duration`、`email`、`time`、`uri`、`uuid`)、`minItems`、`maxItems`、`uniqueItems`、`contains`、`minContains`、`maxContains`、`minProperties`、`maxProperties` - Source Generator 生成配置类型、表包装、单表注册/访问辅助,以及项目级聚合注册目录 - VS Code 插件提供配置浏览、raw 编辑、schema 打开、递归轻量校验和嵌套对象表单入口 @@ -773,7 +773,10 @@ if (MonsterConfigBindings.References.TryGetByDisplayPath("dropItems", out var re - `multipleOf`:供运行时校验、VS Code 校验、表单 hint 和生成代码 XML 文档复用;当前优先按运行时与 JS 共用的十进制精确整倍数判定处理常见十进制步进,并在必要时退回浮点容差兜底 - `minLength` / `maxLength`:供运行时校验、VS Code 校验和生成代码 XML 文档复用 - `pattern`:供运行时校验、VS Code 校验、表单提示和生成代码 XML 文档复用;当前按 C# `CultureInvariant` 与 JS Unicode `u` 模式解释,非法模式会在 schema 解析阶段直接报错 -- `format`:当前只支持 Runtime / Generator / Tooling 三端都能稳定对齐的字符串子集 `date`、`date-time`、`email`、`time`、`uri`、`uuid`;其中 `time` 固定要求显式时区偏移(例如 `08:30:00Z` 或 `08:30:00+08:00`),避免不同宿主对 time-only 文本隐式补日期或本地时区;运行时会拒绝不满足格式的值,VS Code 校验与表单 hint 会同步展示该约束,生成代码 XML 文档也会保留 `format = ...` 说明 +- `format`:当前只支持 Runtime / Generator / Tooling 三端都能稳定对齐的字符串子集 `date`、`date-time`、`duration`、`email`、`time`、`uri`、`uuid` +- `duration`:当前只支持稳定的 day-time duration 子集,例如 `P2D`、`PT45M`、`P2DT3H4M5.5S`;为了避免跨宿主对日历语义解释漂移,暂不支持 `Y` / `M(月)` / `W` +- `time`:固定要求显式时区偏移(例如 `08:30:00Z` 或 `08:30:00+08:00`),避免不同宿主对 time-only 文本隐式补日期或本地时区 +- 对上述共享子集,运行时会拒绝不满足格式的值,VS Code 校验与表单 hint 会同步展示该约束,生成代码 XML 文档也会保留 `format = ...` 说明 - `minItems` / `maxItems`:供运行时校验、VS Code 校验、表单提示和生成代码 XML 文档复用 - `uniqueItems`:供运行时校验、VS Code 校验、表单 hint 和生成代码 XML 文档复用;对象数组会按 schema 归一化后的结构比较重复项,而不是依赖 YAML 字段顺序 - `contains` / `minContains` / `maxContains`:供运行时校验、VS Code 校验、表单 hint 和生成代码 XML 文档复用;当前会按同一套递归 schema 规则统计“有多少数组元素匹配 contains 子 schema”,其中仅声明 `contains` 时默认至少需要 1 个匹配元素 diff --git a/tools/gframework-config-tool/src/configValidation.js b/tools/gframework-config-tool/src/configValidation.js index 99caf656..2176f215 100644 --- a/tools/gframework-config-tool/src/configValidation.js +++ b/tools/gframework-config-tool/src/configValidation.js @@ -14,9 +14,11 @@ const UuidFormatPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9 const DateFormatPattern = /^(?\d{4})-(?\d{2})-(?\d{2})$/u; const DateTimeFormatPattern = /^(?\d{4})-(?\d{2})-(?\d{2})T(?\d{2}):(?\d{2}):(?\d{2})(?\.\d+)?(?Z|[+-]\d{2}:\d{2})$/u; +const DurationFormatPattern = + /^P(?:(?\d+)D)?(?:T(?:(?\d+)H)?(?:(?\d+)M)?(?:(?\d+(?:\.\d+)?)S)?)?$/u; const TimeFormatPattern = /^(?\d{2}):(?\d{2}):(?\d{2})(?\.\d+)?(?Z|[+-]\d{2}:\d{2})$/u; -const SupportedStringFormats = new Set(["date", "date-time", "email", "time", "uri", "uuid"]); +const SupportedStringFormats = new Set(["date", "date-time", "duration", "email", "time", "uri", "uuid"]); /** * Compare two strings using the same UTF-16 code-unit ordering as C#'s @@ -484,7 +486,7 @@ function normalizeSchemaStringFormat(value, schemaType, displayPath) { throw new Error( `Schema property '${displayPath}' declares unsupported string format '${value}'. ` + - "Supported formats are 'date', 'date-time', 'email', 'time', 'uri', and 'uuid'."); + "Supported formats are 'date', 'date-time', 'duration', 'email', 'time', 'uri', and 'uuid'."); } /** @@ -628,6 +630,8 @@ function matchesSchemaStringFormat(scalarValue, formatName) { return matchesSchemaDateFormat(scalarValue); case "date-time": return matchesSchemaDateTimeFormat(scalarValue); + case "duration": + return matchesSchemaDurationFormat(scalarValue); case "email": return EmailFormatPattern.test(scalarValue); case "time": @@ -695,6 +699,35 @@ function matchesSchemaDateTimeFormat(scalarValue) { return offsetHour <= 23 && offsetMinute <= 59; } +/** + * Validate one shared day-time duration string. + * + * @param {string} scalarValue Scalar value from YAML. + * @returns {boolean} True when the value stays within the shared day-time subset. + */ +function matchesSchemaDurationFormat(scalarValue) { + const match = DurationFormatPattern.exec(scalarValue); + if (!match || !match.groups) { + return false; + } + + const hasDayComponent = match.groups.days !== undefined; + const hasHourComponent = match.groups.hours !== undefined; + const hasMinuteComponent = match.groups.minutes !== undefined; + const hasSecondComponent = match.groups.seconds !== undefined; + const hasAnyComponent = hasDayComponent || hasHourComponent || hasMinuteComponent || hasSecondComponent; + if (!hasAnyComponent) { + return false; + } + + const hasTimeSection = scalarValue.includes("T"); + if (hasTimeSection && !hasHourComponent && !hasMinuteComponent && !hasSecondComponent) { + return false; + } + + return true; +} + /** * Validate one RFC 3339 full-time string with explicit timezone offset. * diff --git a/tools/gframework-config-tool/test/configValidation.test.js b/tools/gframework-config-tool/test/configValidation.test.js index d34cb747..7c6a8faf 100644 --- a/tools/gframework-config-tool/test/configValidation.test.js +++ b/tools/gframework-config-tool/test/configValidation.test.js @@ -675,6 +675,10 @@ test("validateParsedConfig should enforce supported string formats", () => { "type": "string", "format": "date-time" }, + "respawnDelay": { + "type": "string", + "format": "duration" + }, "contactEmail": { "type": "string", "format": "email" @@ -698,6 +702,7 @@ test("validateParsedConfig should enforce supported string formats", () => { releaseDate: 2026-02-30 ancientReleaseDate: 0000-01-01 publishedAt: 2026-04-11T08:30:00 +respawnDelay: P1Y contactEmail: boss.example.com dailyResetAt: 08:30:00 catalogUri: /loot-table @@ -706,14 +711,15 @@ configId: 123e4567e89b12d3a456426614174000 const diagnostics = validateParsedConfig(schema, yaml); - assert.equal(diagnostics.length, 7); + assert.equal(diagnostics.length, 8); assert.match(diagnostics[0].message, /format 'date'|字符串格式“date”/u); assert.match(diagnostics[1].message, /format 'date'|字符串格式“date”/u); assert.match(diagnostics[2].message, /format 'date-time'|字符串格式“date-time”/u); - assert.match(diagnostics[3].message, /format 'email'|字符串格式“email”/u); - assert.match(diagnostics[4].message, /format 'time'|字符串格式“time”/u); - assert.match(diagnostics[5].message, /format 'uri'|字符串格式“uri”/u); - assert.match(diagnostics[6].message, /format 'uuid'|字符串格式“uuid”/u); + assert.match(diagnostics[3].message, /format 'duration'|字符串格式“duration”/u); + assert.match(diagnostics[4].message, /format 'email'|字符串格式“email”/u); + assert.match(diagnostics[5].message, /format 'time'|字符串格式“time”/u); + assert.match(diagnostics[6].message, /format 'uri'|字符串格式“uri”/u); + assert.match(diagnostics[7].message, /format 'uuid'|字符串格式“uuid”/u); }); test("validateParsedConfig should accept supported string formats", () => { @@ -729,6 +735,10 @@ test("validateParsedConfig should accept supported string formats", () => { "type": "string", "format": "date-time" }, + "respawnDelay": { + "type": "string", + "format": "duration" + }, "contactEmail": { "type": "string", "format": "email" @@ -751,6 +761,7 @@ test("validateParsedConfig should accept supported string formats", () => { const yaml = parseTopLevelYaml(` releaseDate: 2026-04-11 publishedAt: 2026-04-11T08:30:00Z +respawnDelay: P2DT3H4M5.5S contactEmail: boss@example.com dailyResetAt: 08:30:00Z catalogUri: https://example.com/loot-table @@ -1417,7 +1428,7 @@ test("parseSchemaContent should capture supported string format metadata", () => "type": "array", "items": { "type": "string", - "format": "time" + "format": "duration" } } } @@ -1425,7 +1436,7 @@ test("parseSchemaContent should capture supported string format metadata", () => `); assert.equal(schema.properties.contactEmail.format, "email"); - assert.equal(schema.properties.aliases.items.format, "time"); + assert.equal(schema.properties.aliases.items.format, "duration"); }); test("parseSchemaContent should capture multipleOf and uniqueItems metadata", () => { @@ -1643,7 +1654,7 @@ test("parseSchemaContent should reject unsupported string format declarations", } } `), - /unsupported string format 'ipv4'.*'time'/u + /unsupported string format 'ipv4'.*'duration'.*'time'/u ); });