feat(game): 添加游戏内容配置系统及YAML Schema校验器

- 实现AI-First配表方案,支持怪物、物品、技能等静态内容管理
- 集成YAML配置源文件与JSON Schema结构描述功能
- 提供一对象一文件的目录组织方式和运行时只读查询能力
- 实现Source Generator生成配置类型和表包装类
- 集成VS Code插件提供配置浏览、raw编辑和递归校验功能
- 开发YamlConfigSchemaValidator实现JSON Schema子集校验
- 支持嵌套对象、对象数组、标量数组与深层enum引用约束校验
- 实现跨表引用检测和热重载时依赖表联动校验机制
This commit is contained in:
GeWuYou 2026-04-01 21:02:25 +08:00
parent 965f20059f
commit 03580d6836
12 changed files with 2545 additions and 1152 deletions

0
.codex Normal file
View File

View File

@ -428,6 +428,194 @@ public class YamlConfigLoaderTests
});
}
/// <summary>
/// 验证嵌套对象中的必填字段同样会按 schema 在运行时生效。
/// </summary>
[Test]
public void LoadAsync_Should_Throw_When_Nested_Object_Is_Missing_Required_Property()
{
CreateConfigFile(
"monster/slime.yaml",
"""
id: 1
name: Slime
reward:
gold: 10
""");
CreateSchemaFile(
"schemas/monster.schema.json",
"""
{
"type": "object",
"required": ["id", "name", "reward"],
"properties": {
"id": { "type": "integer" },
"name": { "type": "string" },
"reward": {
"type": "object",
"required": ["gold", "currency"],
"properties": {
"gold": { "type": "integer" },
"currency": { "type": "string" }
}
}
}
}
""");
var loader = new YamlConfigLoader(_rootPath)
.RegisterTable<int, MonsterNestedConfigStub>("monster", "monster", "schemas/monster.schema.json",
static config => config.Id);
var registry = new ConfigRegistry();
var exception = Assert.ThrowsAsync<InvalidOperationException>(async () => await loader.LoadAsync(registry));
Assert.Multiple(() =>
{
Assert.That(exception, Is.Not.Null);
Assert.That(exception!.Message, Does.Contain("reward.currency"));
Assert.That(registry.Count, Is.EqualTo(0));
});
}
/// <summary>
/// 验证对象数组中的嵌套字段也会按 schema 递归校验。
/// </summary>
[Test]
public void LoadAsync_Should_Throw_When_Object_Array_Item_Contains_Unknown_Property()
{
CreateConfigFile(
"monster/slime.yaml",
"""
id: 1
name: Slime
phases:
-
wave: 1
monsterId: slime
hpScale: 1.5
""");
CreateSchemaFile(
"schemas/monster.schema.json",
"""
{
"type": "object",
"required": ["id", "name", "phases"],
"properties": {
"id": { "type": "integer" },
"name": { "type": "string" },
"phases": {
"type": "array",
"items": {
"type": "object",
"required": ["wave", "monsterId"],
"properties": {
"wave": { "type": "integer" },
"monsterId": { "type": "string" }
}
}
}
}
}
""");
var loader = new YamlConfigLoader(_rootPath)
.RegisterTable<int, MonsterPhaseArrayConfigStub>("monster", "monster", "schemas/monster.schema.json",
static config => config.Id);
var registry = new ConfigRegistry();
var exception = Assert.ThrowsAsync<InvalidOperationException>(async () => await loader.LoadAsync(registry));
Assert.Multiple(() =>
{
Assert.That(exception, Is.Not.Null);
Assert.That(exception!.Message, Does.Contain("phases[0].hpScale"));
Assert.That(registry.Count, Is.EqualTo(0));
});
}
/// <summary>
/// 验证深层对象数组中的跨表引用也会参与整批加载校验。
/// </summary>
[Test]
public void LoadAsync_Should_Throw_When_Nested_Object_Array_Reference_Target_Is_Missing()
{
CreateConfigFile(
"item/potion.yaml",
"""
id: potion
name: Potion
""");
CreateConfigFile(
"monster/slime.yaml",
"""
id: 1
name: Slime
phases:
-
wave: 1
dropItemId: potion
-
wave: 2
dropItemId: bomb
""");
CreateSchemaFile(
"schemas/item.schema.json",
"""
{
"type": "object",
"required": ["id", "name"],
"properties": {
"id": { "type": "string" },
"name": { "type": "string" }
}
}
""");
CreateSchemaFile(
"schemas/monster.schema.json",
"""
{
"type": "object",
"required": ["id", "name", "phases"],
"properties": {
"id": { "type": "integer" },
"name": { "type": "string" },
"phases": {
"type": "array",
"items": {
"type": "object",
"required": ["wave", "dropItemId"],
"properties": {
"wave": { "type": "integer" },
"dropItemId": {
"type": "string",
"x-gframework-ref-table": "item"
}
}
}
}
}
}
""");
var loader = new YamlConfigLoader(_rootPath)
.RegisterTable<string, ItemConfigStub>("item", "item", "schemas/item.schema.json",
static config => config.Id)
.RegisterTable<int, MonsterPhaseDropConfigStub>("monster", "monster", "schemas/monster.schema.json",
static config => config.Id);
var registry = new ConfigRegistry();
var exception = Assert.ThrowsAsync<InvalidOperationException>(async () => await loader.LoadAsync(registry));
Assert.Multiple(() =>
{
Assert.That(exception, Is.Not.Null);
Assert.That(exception!.Message, Does.Contain("phases[1].dropItemId"));
Assert.That(exception!.Message, Does.Contain("bomb"));
Assert.That(registry.Count, Is.EqualTo(0));
});
}
/// <summary>
/// 验证绑定跨表引用 schema 时,存在的目标行可以通过加载校验。
/// </summary>
@ -949,6 +1137,117 @@ public class YamlConfigLoaderTests
public IReadOnlyList<int> DropRates { get; set; } = Array.Empty<int>();
}
/// <summary>
/// 用于嵌套对象 schema 校验测试的最小怪物配置类型。
/// </summary>
private sealed class MonsterNestedConfigStub
{
/// <summary>
/// 获取或设置主键。
/// </summary>
public int Id { get; set; }
/// <summary>
/// 获取或设置名称。
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// 获取或设置奖励对象。
/// </summary>
public RewardConfigStub Reward { get; set; } = new();
}
/// <summary>
/// 表示嵌套奖励对象的测试桩类型。
/// </summary>
private sealed class RewardConfigStub
{
/// <summary>
/// 获取或设置金币数量。
/// </summary>
public int Gold { get; set; }
/// <summary>
/// 获取或设置货币类型。
/// </summary>
public string Currency { get; set; } = string.Empty;
}
/// <summary>
/// 用于对象数组 schema 校验测试的怪物配置类型。
/// </summary>
private sealed class MonsterPhaseArrayConfigStub
{
/// <summary>
/// 获取或设置主键。
/// </summary>
public int Id { get; set; }
/// <summary>
/// 获取或设置名称。
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// 获取或设置阶段数组。
/// </summary>
public IReadOnlyList<PhaseConfigStub> Phases { get; set; } = Array.Empty<PhaseConfigStub>();
}
/// <summary>
/// 表示对象数组中的阶段元素。
/// </summary>
private sealed class PhaseConfigStub
{
/// <summary>
/// 获取或设置波次编号。
/// </summary>
public int Wave { get; set; }
/// <summary>
/// 获取或设置怪物主键。
/// </summary>
public string MonsterId { get; set; } = string.Empty;
}
/// <summary>
/// 用于深层跨表引用测试的怪物配置类型。
/// </summary>
private sealed class MonsterPhaseDropConfigStub
{
/// <summary>
/// 获取或设置主键。
/// </summary>
public int Id { get; set; }
/// <summary>
/// 获取或设置名称。
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// 获取或设置阶段数组。
/// </summary>
public List<PhaseDropConfigStub> Phases { get; set; } = new();
}
/// <summary>
/// 表示带有掉落引用的阶段元素。
/// </summary>
private sealed class PhaseDropConfigStub
{
/// <summary>
/// 获取或设置波次编号。
/// </summary>
public int Wave { get; set; }
/// <summary>
/// 获取或设置掉落物品主键。
/// </summary>
public string DropItemId { get; set; } = string.Empty;
}
/// <summary>
/// 用于跨表引用测试的最小物品配置类型。
/// </summary>

File diff suppressed because it is too large Load Diff

View File

@ -43,7 +43,7 @@ public class SchemaConfigGeneratorSnapshotTests
"title": "Monster Config",
"description": "Represents one monster entry generated from schema metadata.",
"type": "object",
"required": ["id", "name"],
"required": ["id", "name", "reward", "phases"],
"properties": {
"id": {
"type": "integer",
@ -69,6 +69,38 @@ public class SchemaConfigGeneratorSnapshotTests
},
"default": ["potion"],
"x-gframework-ref-table": "item"
},
"reward": {
"type": "object",
"description": "Reward payload.",
"required": ["gold", "currency"],
"properties": {
"gold": {
"type": "integer",
"default": 10
},
"currency": {
"type": "string",
"enum": ["coin", "gem"]
}
}
},
"phases": {
"type": "array",
"description": "Encounter phases.",
"items": {
"type": "object",
"required": ["wave", "monsterId"],
"properties": {
"wave": {
"type": "integer"
},
"monsterId": {
"type": "string",
"description": "Monster reference id."
}
}
}
}
}
}

View File

@ -45,4 +45,50 @@ public class SchemaConfigGeneratorTests
Assert.That(diagnostic.GetMessage(), Does.Contain("monster.schema.json"));
});
}
/// <summary>
/// 验证深层不支持的数组嵌套会带着完整字段路径产生命名明确的诊断。
/// </summary>
[Test]
public void Run_Should_Report_Diagnostic_When_Nested_Array_Type_Is_Not_Supported()
{
const string source = """
namespace TestApp
{
public sealed class Dummy
{
}
}
""";
const string schema = """
{
"type": "object",
"required": ["id"],
"properties": {
"id": { "type": "integer" },
"waves": {
"type": "array",
"items": {
"type": "array",
"items": { "type": "integer" }
}
}
}
}
""";
var result = SchemaGeneratorTestDriver.Run(
source,
("monster.schema.json", schema));
var diagnostic = result.Results.Single().Diagnostics.Single();
Assert.Multiple(() =>
{
Assert.That(diagnostic.Id, Is.EqualTo("GF_ConfigSchema_004"));
Assert.That(diagnostic.GetMessage(), Does.Contain("waves"));
Assert.That(diagnostic.GetMessage(), Does.Contain("array<array>"));
});
}
}

View File

@ -13,7 +13,7 @@ public sealed partial class MonsterConfig
/// Unique monster identifier.
/// </summary>
/// <remarks>
/// Schema property: 'id'.
/// Schema property path: 'id'.
/// </remarks>
public int Id { get; set; }
@ -21,7 +21,7 @@ public sealed partial class MonsterConfig
/// Localized monster display name.
/// </summary>
/// <remarks>
/// Schema property: 'name'.
/// Schema property path: 'name'.
/// Display title: 'Monster Name'.
/// Allowed values: Slime, Goblin.
/// Generated default initializer: = "Slime";
@ -29,10 +29,10 @@ public sealed partial class MonsterConfig
public string Name { get; set; } = "Slime";
/// <summary>
/// Gets or sets the value mapped from schema property 'hp'.
/// Gets or sets the value mapped from schema property path 'hp'.
/// </summary>
/// <remarks>
/// Schema property: 'hp'.
/// Schema property path: 'hp'.
/// Generated default initializer: = 10;
/// </remarks>
public int? Hp { get; set; } = 10;
@ -41,11 +41,80 @@ public sealed partial class MonsterConfig
/// Referenced drop ids.
/// </summary>
/// <remarks>
/// Schema property: 'dropItems'.
/// Schema property path: 'dropItems'.
/// Allowed values: potion, slime_gel.
/// References config table: 'item'.
/// Generated default initializer: = new string[] { "potion" };
/// </remarks>
public global::System.Collections.Generic.IReadOnlyList<string> DropItems { get; set; } = new string[] { "potion" };
/// <summary>
/// Reward payload.
/// </summary>
/// <remarks>
/// Schema property path: 'reward'.
/// Generated default initializer: = new();
/// </remarks>
public RewardConfig Reward { get; set; } = new();
/// <summary>
/// Encounter phases.
/// </summary>
/// <remarks>
/// Schema property path: 'phases'.
/// Generated default initializer: = global::System.Array.Empty&lt;PhasesItemConfig&gt;();
/// </remarks>
public global::System.Collections.Generic.IReadOnlyList<PhasesItemConfig> Phases { get; set; } = global::System.Array.Empty<PhasesItemConfig>();
/// <summary>
/// Auto-generated nested config type for schema property path 'reward'.
/// Reward payload.
/// </summary>
public sealed partial class RewardConfig
{
/// <summary>
/// Gets or sets the value mapped from schema property path 'reward.gold'.
/// </summary>
/// <remarks>
/// Schema property path: 'reward.gold'.
/// Generated default initializer: = 10;
/// </remarks>
public int Gold { get; set; } = 10;
/// <summary>
/// Gets or sets the value mapped from schema property path 'reward.currency'.
/// </summary>
/// <remarks>
/// Schema property path: 'reward.currency'.
/// Allowed values: coin, gem.
/// Generated default initializer: = string.Empty;
/// </remarks>
public string Currency { get; set; } = string.Empty;
}
/// <summary>
/// Auto-generated nested config type for schema property path 'phases[]'.
/// This nested type is generated so object-valued schema fields remain strongly typed in consumer code.
/// </summary>
public sealed partial class PhasesItemConfig
{
/// <summary>
/// Gets or sets the value mapped from schema property path 'phases[].wave'.
/// </summary>
/// <remarks>
/// Schema property path: 'phases[].wave'.
/// </remarks>
public int Wave { get; set; }
/// <summary>
/// Monster reference id.
/// </summary>
/// <remarks>
/// Schema property path: 'phases[].monsterId'.
/// Generated default initializer: = string.Empty;
/// </remarks>
public string MonsterId { get; set; } = string.Empty;
}
}

File diff suppressed because it is too large Load Diff

View File

@ -13,7 +13,7 @@
- 一对象一文件的目录组织
- 运行时只读查询
- Source Generator 生成配置类型和表包装
- VS Code 插件提供配置浏览、raw 编辑、schema 打开和轻量校验入口
- VS Code 插件提供配置浏览、raw 编辑、schema 打开、递归轻量校验和嵌套对象表单入口
## 推荐目录结构
@ -113,6 +113,8 @@ var slime = monsterTable.Get(1);
- 未在 schema 中声明的未知字段
- 标量类型不匹配
- 数组元素类型不匹配
- 嵌套对象字段类型不匹配
- 对象数组元素结构不匹配
- 标量 `enum` 不匹配
- 标量数组元素 `enum` 不匹配
- 通过 `x-gframework-ref-table` 声明的跨表引用缺失目标行
@ -198,19 +200,22 @@ var hotReload = loader.EnableHotReload(
- 浏览 `config/` 目录
- 打开 raw YAML 文件
- 打开匹配的 schema 文件
- 对必填字段、未知顶层字段、基础标量类型和标量数组元素做轻量校验
- 对顶层标量字段和顶层标量数组提供轻量表单入口
- 对嵌套对象中的必填字段、未知字段、基础标量类型、标量数组和对象数组元素做轻量校验
- 对嵌套对象字段、顶层标量字段和顶层标量数组提供轻量表单入口
- 对同一配置域内的多份 YAML 文件执行批量字段更新
- 在表单和批量编辑入口中显示 `title / description / default / enum / ref-table` 元数据
当前批量编辑入口适合对同域文件统一改动顶层标量字段和顶层标量数组;复杂数组、嵌套对象仍建议放在 raw YAML 中完成。
当前表单入口适合编辑嵌套对象中的标量字段和标量数组;对象数组仍建议放在 raw YAML 中完成。
当前批量编辑入口仍刻意限制在“同域文件统一改动顶层标量字段和顶层标量数组”,避免复杂结构批量写回时破坏人工维护的 YAML 排版。
## 当前限制
以下能力尚未完全完成:
- 更完整的 JSON Schema 支持
- 更强的 VS Code 嵌套对象与复杂数组编辑器
- VS Code 中对象数组的安全表单编辑器
- 更强的复杂数组与更深 schema 关键字支持
因此,现阶段更适合作为你游戏项目的“受控试点配表系统”,而不是完全无约束的大规模内容生产平台。

View File

@ -7,8 +7,9 @@ Minimal VS Code extension scaffold for the GFramework AI-First config workflow.
- Browse config files from the workspace `config/` directory
- Open raw YAML files
- Open matching schema files from `schemas/`
- Run lightweight schema validation for required fields, unknown top-level fields, scalar types, and scalar array items
- Open a lightweight form preview for top-level scalar fields and top-level scalar arrays
- Run lightweight schema validation for nested required fields, unknown nested fields, scalar types, scalar arrays, and
arrays of objects
- Open a lightweight form preview for nested object fields, top-level scalar fields, and scalar arrays
- Batch edit one config domain across multiple files for top-level scalar and scalar-array fields
- Surface schema metadata such as `title`, `description`, `default`, `enum`, and `x-gframework-ref-table` in the
lightweight editors
@ -17,13 +18,14 @@ Minimal VS Code extension scaffold for the GFramework AI-First config workflow.
The extension currently validates the repository's minimal config-schema subset:
- required top-level properties
- unknown top-level properties
- required properties in nested objects
- unknown properties in nested objects
- scalar compatibility for `integer`, `number`, `boolean`, and `string`
- top-level scalar arrays with scalar item type checks
- scalar arrays with scalar item type checks
- arrays of objects whose items use the same supported subset recursively
- scalar `enum` constraints and scalar-array item `enum` constraints
Nested objects and complex arrays should still be reviewed in raw YAML.
Object-array editing should still be reviewed in raw YAML.
## Local Testing
@ -36,8 +38,8 @@ node --test ./test/*.test.js
- Multi-root workspaces use the first workspace folder
- Validation only covers a minimal subset of JSON Schema
- Form and batch editing currently support top-level scalar fields and top-level scalar arrays
- Nested objects and complex arrays should still be edited in raw YAML
- Form preview supports nested objects and scalar arrays, but object arrays remain raw-YAML-only for edits
- Batch editing remains limited to top-level scalar fields and top-level scalar arrays
## Workspace Settings

File diff suppressed because it is too large Load Diff

View File

@ -252,9 +252,9 @@ async function openSchemaFile(item) {
}
/**
* Open a lightweight form preview for top-level scalar fields and scalar
* arrays. Nested objects and more complex array shapes still use raw YAML as
* the escape hatch.
* Open a lightweight form preview for schema-bound config fields.
* The preview now walks nested object structures recursively, while complex
* object-array editing still falls back to raw YAML for safety.
*
* @param {ConfigTreeItem | { resourceUri?: vscode.Uri }} item Tree item.
* @param {vscode.DiagnosticCollection} diagnostics Diagnostic collection.
@ -284,7 +284,8 @@ async function openFormPreview(item, diagnostics) {
panel.webview.onDidReceiveMessage(async (message) => {
if (message.type === "save") {
const updatedYaml = applyFormUpdates(yamlText, {
const latestYamlText = await fs.promises.readFile(configUri.fsPath, "utf8");
const updatedYaml = applyFormUpdates(latestYamlText, {
scalars: message.scalars || {},
arrays: parseArrayFieldPayload(message.arrays || {})
});
@ -544,6 +545,7 @@ async function loadSchemaInfoForConfig(configUri, workspaceRoot) {
return {
exists: true,
schemaPath,
type: parsed.type,
required: parsed.required,
properties: parsed.properties
};
@ -561,86 +563,67 @@ async function loadSchemaInfoForConfig(configUri, workspaceRoot) {
* Render the form-preview webview HTML.
*
* @param {string} fileName File name.
* @param {{exists: boolean, schemaPath: string, required: string[], properties: Record<string, {
* type: string,
* itemType?: string,
* title?: string,
* description?: string,
* defaultValue?: string,
* enumValues?: string[],
* itemEnumValues?: string[],
* refTable?: string
* }>}} schemaInfo Schema info.
* @param {{entries: Map<string, {kind: string, value?: string, items?: Array<{raw: string, isComplex: boolean}>}>, keys: Set<string>}} parsedYaml Parsed YAML data.
* @param {{exists: boolean, schemaPath: string, required: string[], properties: Record<string, unknown>, type?: string}} schemaInfo Schema info.
* @param {unknown} parsedYaml Parsed YAML data.
* @returns {string} HTML string.
*/
function renderFormHtml(fileName, schemaInfo, parsedYaml) {
const scalarFields = Array.from(parsedYaml.entries.entries())
.filter(([, entry]) => entry.kind === "scalar")
.map(([key, entry]) => {
const propertySchema = schemaInfo.properties[key] || {};
const displayName = propertySchema.title || key;
const escapedKey = escapeHtml(key);
const escapedDisplayName = escapeHtml(displayName);
const escapedValue = escapeHtml(unquoteScalar(entry.value || ""));
const required = schemaInfo.required.includes(key) ? "<span class=\"badge\">required</span>" : "";
const metadataHint = renderFieldHint(propertySchema, false);
const enumValues = Array.isArray(propertySchema.enumValues) ? propertySchema.enumValues : [];
const formModel = buildFormModel(schemaInfo, parsedYaml);
const renderedFields = formModel.fields
.map((field) => {
if (field.kind === "section") {
return `
<div class="section depth-${field.depth}">
<div class="section-title">${escapeHtml(field.label)} ${field.required ? "<span class=\"badge\">required</span>" : ""}</div>
<div class="meta-key">${escapeHtml(field.path)}</div>
${field.description ? `<span class="hint">${escapeHtml(field.description)}</span>` : ""}
</div>
`;
}
if (field.kind === "array") {
const itemType = field.itemType
? `array<${escapeHtml(field.itemType)}>`
: "array";
return `
<label class="field depth-${field.depth}">
<span class="label">${escapeHtml(field.label)} ${field.required ? "<span class=\"badge\">required</span>" : ""}</span>
<span class="meta-key">${escapeHtml(field.path)}</span>
<span class="hint">One item per line. Expected type: ${itemType}</span>
${renderFieldHint(field.schema, true)}
<textarea data-array-path="${escapeHtml(field.path)}" rows="5">${escapeHtml(field.value.join("\n"))}</textarea>
</label>
`;
}
const enumValues = Array.isArray(field.schema.enumValues) ? field.schema.enumValues : [];
const inputControl = enumValues.length > 0
? `
<select data-key="${escapedKey}">
<select data-path="${escapeHtml(field.path)}">
${enumValues.map((value) => {
const escapedOption = escapeHtml(value);
const selected = value === unquoteScalar(entry.value || "") ? " selected" : "";
const selected = value === field.value ? " selected" : "";
return `<option value="${escapedOption}"${selected}>${escapedOption}</option>`;
}).join("\n")}
</select>
`
: `<input data-key="${escapedKey}" value="${escapedValue}" />`;
: `<input data-path="${escapeHtml(field.path)}" value="${escapeHtml(field.value)}" />`;
return `
<label class="field">
<span class="label">${escapedDisplayName} ${required}</span>
<span class="meta-key">${escapedKey}</span>
${metadataHint}
<label class="field depth-${field.depth}">
<span class="label">${escapeHtml(field.label)} ${field.required ? "<span class=\"badge\">required</span>" : ""}</span>
<span class="meta-key">${escapeHtml(field.path)}</span>
${renderFieldHint(field.schema, false)}
${inputControl}
</label>
`;
})
.join("\n");
const arrayFields = Array.from(parsedYaml.entries.entries())
.filter(([, entry]) => entry.kind === "array")
.map(([key, entry]) => {
const propertySchema = schemaInfo.properties[key] || {};
const displayName = propertySchema.title || key;
const escapedKey = escapeHtml(key);
const escapedDisplayName = escapeHtml(displayName);
const escapedValue = escapeHtml((entry.items || [])
.map((item) => unquoteScalar(item.raw))
.join("\n"));
const required = schemaInfo.required.includes(key) ? "<span class=\"badge\">required</span>" : "";
const itemType = propertySchema.itemType
? `array<${escapeHtml(propertySchema.itemType)}>`
: "array";
const metadataHint = renderFieldHint(propertySchema, true);
return `
<label class="field">
<span class="label">${escapedDisplayName} ${required}</span>
<span class="meta-key">${escapedKey}</span>
<span class="hint">One item per line. Expected type: ${itemType}</span>
${metadataHint}
<textarea data-array-key="${escapedKey}" rows="5">${escapedValue}</textarea>
</label>
`;
})
.join("\n");
const unsupportedFields = Array.from(parsedYaml.entries.entries())
.filter(([, entry]) => entry.kind !== "scalar" && entry.kind !== "array")
.map(([key, entry]) => `
const unsupportedFields = formModel.unsupported
.map((field) => `
<div class="unsupported">
<strong>${escapeHtml(key)}</strong>: ${escapeHtml(entry.kind)} fields are currently raw-YAML-only.
<strong>${escapeHtml(field.path)}</strong>: ${escapeHtml(field.message)}
</div>
`)
.join("\n");
@ -649,13 +632,13 @@ function renderFormHtml(fileName, schemaInfo, parsedYaml) {
? `Schema: ${escapeHtml(schemaInfo.schemaPath)}`
: `Schema missing: ${escapeHtml(schemaInfo.schemaPath)}`;
const editableContent = [scalarFields, arrayFields].filter((content) => content.length > 0).join("\n");
const editableContent = renderedFields;
const unsupportedSection = unsupportedFields.length > 0
? `<div class="unsupported-list">${unsupportedFields}</div>`
: "";
const emptyState = editableContent.length > 0
? `${editableContent}${unsupportedSection}`
: "<p>No editable top-level scalar or scalar-array fields were detected. Use raw YAML for nested objects or complex arrays.</p>";
: "<p>No editable schema-bound fields were detected. Use raw YAML for unsupported shapes.</p>";
return `<!DOCTYPE html>
<html lang="en">
@ -689,6 +672,15 @@ function renderFormHtml(fileName, schemaInfo, parsedYaml) {
display: block;
margin-bottom: 12px;
}
.section {
margin: 18px 0 8px;
padding-top: 12px;
border-top: 1px solid var(--vscode-panel-border, transparent);
}
.section-title {
font-weight: 700;
margin-bottom: 4px;
}
.meta-key {
display: inline-block;
margin-bottom: 6px;
@ -742,6 +734,15 @@ function renderFormHtml(fileName, schemaInfo, parsedYaml) {
margin-bottom: 10px;
color: var(--vscode-descriptionForeground);
}
.depth-1 {
margin-left: 12px;
}
.depth-2 {
margin-left: 24px;
}
.depth-3 {
margin-left: 36px;
}
</style>
</head>
<body>
@ -759,11 +760,11 @@ function renderFormHtml(fileName, schemaInfo, parsedYaml) {
document.getElementById("save").addEventListener("click", () => {
const scalars = {};
const arrays = {};
for (const control of document.querySelectorAll("[data-key]")) {
scalars[control.dataset.key] = control.value;
for (const control of document.querySelectorAll("[data-path]")) {
scalars[control.dataset.path] = control.value;
}
for (const textarea of document.querySelectorAll("textarea[data-array-key]")) {
arrays[textarea.dataset.arrayKey] = textarea.value;
for (const textarea of document.querySelectorAll("textarea[data-array-path]")) {
arrays[textarea.dataset.arrayPath] = textarea.value;
}
vscode.postMessage({ type: "save", scalars, arrays });
});
@ -775,10 +776,145 @@ function renderFormHtml(fileName, schemaInfo, parsedYaml) {
</html>`;
}
/**
* Build a recursive form model from schema and parsed YAML.
*
* @param {{exists: boolean, schemaPath: string, required: string[], properties: Record<string, unknown>, type?: string}} schemaInfo Schema info.
* @param {unknown} parsedYaml Parsed YAML data.
* @returns {{fields: Array<Record<string, unknown>>, unsupported: Array<{path: string, message: string}>}} Form model.
*/
function buildFormModel(schemaInfo, parsedYaml) {
if (!schemaInfo || schemaInfo.type !== "object") {
return {fields: [], unsupported: []};
}
const fields = [];
const unsupported = [];
collectFormFields(schemaInfo, parsedYaml, "", 0, fields, unsupported);
return {fields, unsupported};
}
/**
* Recursively collect form-editable fields.
*
* @param {{type: string, required?: string[], properties?: Record<string, unknown>, title?: string, description?: string}} schemaNode Schema node.
* @param {unknown} yamlNode YAML node.
* @param {string} currentPath Current logical path.
* @param {number} depth Current depth.
* @param {Array<Record<string, unknown>>} fields Field sink.
* @param {Array<{path: string, message: string}>} unsupported Unsupported sink.
*/
function collectFormFields(schemaNode, yamlNode, currentPath, depth, fields, unsupported) {
if (!schemaNode || schemaNode.type !== "object") {
return;
}
const yamlMap = getYamlObjectMap(yamlNode);
const requiredSet = new Set(Array.isArray(schemaNode.required) ? schemaNode.required : []);
for (const [key, propertySchema] of Object.entries(schemaNode.properties || {})) {
const propertyPath = currentPath ? `${currentPath}.${key}` : key;
const label = propertySchema.title || key;
const propertyValue = yamlMap.get(key);
if (propertySchema.type === "object") {
fields.push({
kind: "section",
path: propertyPath,
label,
description: propertySchema.description,
required: requiredSet.has(key),
depth
});
collectFormFields(propertySchema, propertyValue, propertyPath, depth + 1, fields, unsupported);
continue;
}
if (propertySchema.type === "array" &&
propertySchema.items &&
["string", "integer", "number", "boolean"].includes(propertySchema.items.type)) {
fields.push({
kind: "array",
path: propertyPath,
label,
required: requiredSet.has(key),
depth,
itemType: propertySchema.items.type,
value: getScalarArrayValue(propertyValue),
schema: propertySchema
});
continue;
}
if (["string", "integer", "number", "boolean"].includes(propertySchema.type)) {
fields.push({
kind: "scalar",
path: propertyPath,
label,
required: requiredSet.has(key),
depth,
value: getScalarFieldValue(propertyValue, propertySchema.defaultValue),
schema: propertySchema
});
continue;
}
unsupported.push({
path: propertyPath,
message: propertySchema.type === "array"
? "Object-array fields are currently view-only in the form preview. Use raw YAML for edits."
: `${propertySchema.type} fields are currently raw-YAML-only.`
});
}
}
/**
* Get the mapping lookup for one parsed YAML object node.
*
* @param {unknown} yamlNode YAML node.
* @returns {Map<string, unknown>} Mapping lookup.
*/
function getYamlObjectMap(yamlNode) {
return yamlNode && yamlNode.kind === "object" && yamlNode.map instanceof Map
? yamlNode.map
: new Map();
}
/**
* Extract a scalar field value from a parsed YAML node.
*
* @param {unknown} yamlNode YAML node.
* @param {string | undefined} defaultValue Default value from schema metadata.
* @returns {string} Scalar display value.
*/
function getScalarFieldValue(yamlNode, defaultValue) {
if (yamlNode && yamlNode.kind === "scalar") {
return unquoteScalar(yamlNode.value || "");
}
return defaultValue || "";
}
/**
* Extract a scalar-array value list from a parsed YAML node.
*
* @param {unknown} yamlNode YAML node.
* @returns {string[]} Scalar array value list.
*/
function getScalarArrayValue(yamlNode) {
if (!yamlNode || yamlNode.kind !== "array") {
return [];
}
return yamlNode.items
.filter((item) => item && item.kind === "scalar")
.map((item) => unquoteScalar(item.value || ""));
}
/**
* Render human-facing metadata hints for one schema field.
*
* @param {{description?: string, defaultValue?: string, enumValues?: string[], itemEnumValues?: string[], refTable?: string}} propertySchema Property schema metadata.
* @param {{description?: string, defaultValue?: string, enumValues?: string[], items?: {enumValues?: string[]}, refTable?: string}} propertySchema Property schema metadata.
* @param {boolean} isArrayField Whether the field is an array.
* @returns {string} HTML fragment.
*/
@ -793,7 +929,11 @@ function renderFieldHint(propertySchema, isArrayField) {
hints.push(`Default: ${escapeHtml(propertySchema.defaultValue)}`);
}
const enumValues = isArrayField ? propertySchema.itemEnumValues : propertySchema.enumValues;
const enumValues = isArrayField
? propertySchema.items && Array.isArray(propertySchema.items.enumValues)
? propertySchema.items.enumValues
: []
: propertySchema.enumValues;
if (Array.isArray(enumValues) && enumValues.length > 0) {
hints.push(`Allowed: ${escapeHtml(enumValues.join(", "))}`);
}

View File

@ -10,11 +10,11 @@ const {
validateParsedConfig
} = require("../src/configValidation");
test("parseSchemaContent should capture scalar and array property metadata", () => {
test("parseSchemaContent should capture nested objects and object-array metadata", () => {
const schema = parseSchemaContent(`
{
"type": "object",
"required": ["id", "name"],
"required": ["id", "reward", "phases"],
"properties": {
"id": {
"type": "integer",
@ -22,169 +22,192 @@ test("parseSchemaContent should capture scalar and array property metadata", ()
"description": "Primary monster key.",
"default": 1
},
"name": {
"type": "string",
"enum": ["Slime", "Goblin"]
"reward": {
"type": "object",
"required": ["gold"],
"properties": {
"gold": {
"type": "integer",
"default": 10
},
"currency": {
"type": "string",
"enum": ["coin", "gem"]
}
}
},
"dropRates": {
"phases": {
"type": "array",
"description": "Drop rate list.",
"description": "Encounter phases.",
"items": {
"type": "integer",
"enum": [1, 2, 3]
"type": "object",
"required": ["wave"],
"properties": {
"wave": { "type": "integer" },
"monsterId": { "type": "string" }
}
}
}
}
}
`);
assert.deepEqual(schema.required, ["id", "name"]);
assert.deepEqual(schema.properties, {
id: {
type: "integer",
title: "Monster Id",
description: "Primary monster key.",
defaultValue: "1",
enumValues: undefined,
refTable: undefined
},
name: {
type: "string",
title: undefined,
description: undefined,
defaultValue: undefined,
enumValues: ["Slime", "Goblin"],
refTable: undefined
},
dropRates: {
type: "array",
itemType: "integer",
title: undefined,
description: "Drop rate list.",
defaultValue: undefined,
refTable: undefined,
itemEnumValues: ["1", "2", "3"]
}
});
assert.equal(schema.type, "object");
assert.deepEqual(schema.required, ["id", "reward", "phases"]);
assert.equal(schema.properties.id.defaultValue, "1");
assert.equal(schema.properties.reward.type, "object");
assert.deepEqual(schema.properties.reward.required, ["gold"]);
assert.equal(schema.properties.reward.properties.currency.enumValues[1], "gem");
assert.equal(schema.properties.phases.type, "array");
assert.equal(schema.properties.phases.items.type, "object");
assert.equal(schema.properties.phases.items.properties.wave.type, "integer");
});
test("validateParsedConfig should report missing and unknown properties", () => {
test("parseTopLevelYaml should parse nested mappings and object arrays", () => {
const yaml = parseTopLevelYaml(`
id: 1
reward:
gold: 10
currency: coin
phases:
-
wave: 1
monsterId: slime
`);
assert.equal(yaml.kind, "object");
assert.equal(yaml.map.get("reward").kind, "object");
assert.equal(yaml.map.get("reward").map.get("currency").value, "coin");
assert.equal(yaml.map.get("phases").kind, "array");
assert.equal(yaml.map.get("phases").items[0].kind, "object");
assert.equal(yaml.map.get("phases").items[0].map.get("wave").value, "1");
});
test("validateParsedConfig should report missing and unknown nested properties", () => {
const schema = parseSchemaContent(`
{
"type": "object",
"required": ["id", "name"],
"required": ["reward"],
"properties": {
"id": { "type": "integer" },
"name": { "type": "string" }
"reward": {
"type": "object",
"required": ["gold", "currency"],
"properties": {
"gold": { "type": "integer" },
"currency": { "type": "string" }
}
}
}
}
`);
const yaml = parseTopLevelYaml(`
id: 1
title: Slime
reward:
gold: 10
rarity: epic
`);
const diagnostics = validateParsedConfig(schema, yaml);
assert.equal(diagnostics.length, 2);
assert.equal(diagnostics[0].severity, "error");
assert.match(diagnostics[0].message, /name/u);
assert.equal(diagnostics[1].severity, "error");
assert.match(diagnostics[1].message, /title/u);
assert.match(diagnostics[0].message, /reward\.currency/u);
assert.match(diagnostics[1].message, /reward\.rarity/u);
});
test("validateParsedConfig should report array item type mismatches", () => {
test("validateParsedConfig should report object-array item issues", () => {
const schema = parseSchemaContent(`
{
"type": "object",
"properties": {
"dropRates": {
"type": "array",
"items": { "type": "integer" }
}
}
}
`);
const yaml = parseTopLevelYaml(`
dropRates:
- 1
- potion
`);
const diagnostics = validateParsedConfig(schema, yaml);
assert.equal(diagnostics.length, 1);
assert.equal(diagnostics[0].severity, "error");
assert.match(diagnostics[0].message, /dropRates/u);
});
test("validateParsedConfig should report scalar enum mismatches", () => {
const schema = parseSchemaContent(`
{
"type": "object",
"properties": {
"rarity": {
"type": "string",
"enum": ["common", "rare"]
}
}
}
`);
const yaml = parseTopLevelYaml(`
rarity: epic
`);
const diagnostics = validateParsedConfig(schema, yaml);
assert.equal(diagnostics.length, 1);
assert.match(diagnostics[0].message, /common, rare/u);
});
test("validateParsedConfig should report array item enum mismatches", () => {
const schema = parseSchemaContent(`
{
"type": "object",
"properties": {
"tags": {
"phases": {
"type": "array",
"items": {
"type": "string",
"enum": ["fire", "ice"]
"type": "object",
"required": ["wave", "monsterId"],
"properties": {
"wave": { "type": "integer" },
"monsterId": { "type": "string" }
}
}
}
}
}
`);
const yaml = parseTopLevelYaml(`
tags:
- fire
- poison
phases:
-
wave: 1
hpScale: 1.5
`);
const diagnostics = validateParsedConfig(schema, yaml);
assert.equal(diagnostics.length, 2);
assert.match(diagnostics[0].message, /phases\[0\]\.monsterId/u);
assert.match(diagnostics[1].message, /phases\[0\]\.hpScale/u);
});
test("validateParsedConfig should report deep enum mismatches", () => {
const schema = parseSchemaContent(`
{
"type": "object",
"properties": {
"reward": {
"type": "object",
"properties": {
"currency": {
"type": "string",
"enum": ["coin", "gem"]
}
}
}
}
}
`);
const yaml = parseTopLevelYaml(`
reward:
currency: ticket
`);
const diagnostics = validateParsedConfig(schema, yaml);
assert.equal(diagnostics.length, 1);
assert.match(diagnostics[0].message, /fire, ice/u);
assert.match(diagnostics[0].message, /coin, gem/u);
});
test("parseTopLevelYaml should classify nested mappings as object entries", () => {
const yaml = parseTopLevelYaml(`
reward:
gold: 10
name: Slime
`);
test("applyFormUpdates should update nested scalar and scalar-array paths", () => {
const updated = applyFormUpdates(
[
"id: 1",
"reward:",
" gold: 10",
"phases:",
" -",
" wave: 1"
].join("\n"),
{
scalars: {
"reward.currency": "coin",
name: "Slime"
},
arrays: {
dropItems: ["potion", "hi potion"]
}
});
assert.equal(yaml.entries.get("reward").kind, "object");
assert.equal(yaml.entries.get("name").kind, "scalar");
assert.match(updated, /^name: Slime$/mu);
assert.match(updated, /^reward:$/mu);
assert.match(updated, /^ currency: coin$/mu);
assert.match(updated, /^dropItems:$/mu);
assert.match(updated, /^ - potion$/mu);
assert.match(updated, /^ - hi potion$/mu);
assert.match(updated, /^phases:$/mu);
});
test("applyScalarUpdates should update top-level scalars and append new keys", () => {
test("applyScalarUpdates should preserve the scalar-only compatibility wrapper", () => {
const updated = applyScalarUpdates(
[
"id: 1",
"name: Slime",
"dropRates:",
" - 1"
"name: Slime"
].join("\n"),
{
name: "Goblin",
@ -193,38 +216,9 @@ test("applyScalarUpdates should update top-level scalars and append new keys", (
assert.match(updated, /^name: Goblin$/mu);
assert.match(updated, /^hp: 25$/mu);
assert.match(updated, /^ - 1$/mu);
});
test("applyFormUpdates should replace top-level scalar arrays and preserve unrelated content", () => {
const updated = applyFormUpdates(
[
"id: 1",
"name: Slime",
"dropItems:",
" - potion",
" - slime_gel",
"reward:",
" gold: 10"
].join("\n"),
{
scalars: {
name: "Goblin"
},
arrays: {
dropItems: ["bomb", "hi potion"]
}
});
assert.match(updated, /^name: Goblin$/mu);
assert.match(updated, /^dropItems:$/mu);
assert.match(updated, /^ - bomb$/mu);
assert.match(updated, /^ - hi potion$/mu);
assert.match(updated, /^reward:$/mu);
assert.match(updated, /^ gold: 10$/mu);
});
test("getEditableSchemaFields should expose only scalar and scalar-array properties", () => {
test("getEditableSchemaFields should keep batch editing limited to top-level scalar and scalar-array properties", () => {
const schema = parseSchemaContent(`
{
"type": "object",
@ -236,7 +230,12 @@ test("getEditableSchemaFields should expose only scalar and scalar-array propert
"title": "Monster Name",
"description": "Display name."
},
"reward": { "type": "object" },
"reward": {
"type": "object",
"properties": {
"gold": { "type": "integer" }
}
},
"dropItems": {
"type": "array",
"description": "Drop ids.",
@ -247,7 +246,12 @@ test("getEditableSchemaFields should expose only scalar and scalar-array propert
},
"waypoints": {
"type": "array",
"items": { "type": "object" }
"items": {
"type": "object",
"properties": {
"x": { "type": "integer" }
}
}
}
}
}
@ -256,6 +260,7 @@ test("getEditableSchemaFields should expose only scalar and scalar-array propert
assert.deepEqual(getEditableSchemaFields(schema), [
{
key: "dropItems",
path: "dropItems",
type: "array",
itemType: "string",
title: undefined,
@ -268,6 +273,7 @@ test("getEditableSchemaFields should expose only scalar and scalar-array propert
},
{
key: "id",
path: "id",
type: "integer",
title: undefined,
description: undefined,
@ -279,6 +285,7 @@ test("getEditableSchemaFields should expose only scalar and scalar-array propert
},
{
key: "name",
path: "name",
type: "string",
title: "Monster Name",
description: "Display name.",
@ -291,11 +298,6 @@ test("getEditableSchemaFields should expose only scalar and scalar-array propert
]);
});
test("parseBatchArrayValue should split comma-separated items and drop empty segments", () => {
assert.deepEqual(parseBatchArrayValue(" potion, hi potion , ,bomb "), [
"potion",
"hi potion",
"bomb"
]);
assert.deepEqual(parseBatchArrayValue(""), []);
test("parseBatchArrayValue should keep comma-separated batch editing behavior", () => {
assert.deepEqual(parseBatchArrayValue(" potion, bomb , ,elixir "), ["potion", "bomb", "elixir"]);
});