feat(docs): 添加游戏内容配置系统文档和VSCode插件功能

- 新增游戏内容配置系统完整文档,介绍AI-First配表方案
- 实现YAML配置源文件和JSON Schema结构描述功能
- 添加运行时只读查询和Source Generator类型生成能力
- 集成VSCode插件提供配置浏览、校验和表单编辑功能
- 支持开发期热重载和跨表引用校验机制
- 提供批量编辑和嵌套对象安全表单入口
This commit is contained in:
GeWuYou 2026-04-01 21:35:53 +08:00
parent 65a6e2c257
commit 38bd934779
5 changed files with 509 additions and 64 deletions

View File

@ -201,11 +201,20 @@ var hotReload = loader.EnableHotReload(
- 打开 raw YAML 文件
- 打开匹配的 schema 文件
- 对嵌套对象中的必填字段、未知字段、基础标量类型、标量数组和对象数组元素做轻量校验
- 对嵌套对象字段、顶层标量字段和顶层标量数组提供轻量表单入口
- 对嵌套对象字段、对象数组、顶层标量字段和顶层标量数组提供轻量表单入口
- 对同一配置域内的多份 YAML 文件执行批量字段更新
- 在表单和批量编辑入口中显示 `title / description / default / enum / ref-table` 元数据
当前表单入口适合编辑嵌套对象中的标量字段和标量数组;对象数组仍建议放在 raw YAML 中完成。
当前表单入口适合编辑嵌套对象中的标量字段、标量数组,以及对象数组中的对象项。
对象数组编辑器当前支持:
- 新增和删除对象项
- 编辑对象项中的标量字段
- 编辑对象项中的标量数组
- 编辑对象项中的嵌套对象字段
如果对象数组项内部继续包含对象数组,当前仍建议回退到 raw YAML 完成。
当前批量编辑入口仍刻意限制在“同域文件统一改动顶层标量字段和顶层标量数组”,避免复杂结构批量写回时破坏人工维护的 YAML 排版。
@ -214,7 +223,7 @@ var hotReload = loader.EnableHotReload(
以下能力尚未完全完成:
- 更完整的 JSON Schema 支持
- VS Code 中对象数组的安全表单编辑器
- VS Code 中更深层对象数组嵌套的安全表单编辑器
- 更强的复杂数组与更深 schema 关键字支持
因此,现阶段更适合作为你游戏项目的“受控试点配表系统”,而不是完全无约束的大规模内容生产平台。

View File

@ -9,7 +9,7 @@ Minimal VS Code extension scaffold for the GFramework AI-First config workflow.
- Open matching schema files from `schemas/`
- 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
- Open a lightweight form preview for nested object fields, object arrays, 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
@ -25,8 +25,6 @@ The extension currently validates the repository's minimal config-schema subset:
- arrays of objects whose items use the same supported subset recursively
- scalar `enum` constraints and scalar-array item `enum` constraints
Object-array editing should still be reviewed in raw YAML.
## Local Testing
```bash
@ -38,7 +36,7 @@ node --test ./test/*.test.js
- Multi-root workspaces use the first workspace folder
- Validation only covers a minimal subset of JSON Schema
- Form preview supports nested objects and scalar arrays, but object arrays remain raw-YAML-only for edits
- Form preview supports object-array editing, but nested object arrays inside array items still fall back to raw YAML
- Batch editing remains limited to top-level scalar fields and top-level scalar arrays
## Workspace Settings

View File

@ -150,13 +150,14 @@ function isScalarCompatible(expectedType, scalarValue) {
* from the parsed structure so nested object edits can be saved safely.
*
* @param {string} originalYaml Original YAML content.
* @param {{scalars?: Record<string, string>, arrays?: Record<string, string[]>}} updates Updated form values.
* @param {{scalars?: Record<string, string>, arrays?: Record<string, string[]>, objectArrays?: Record<string, Array<Record<string, unknown>>>}} updates Updated form values.
* @returns {string} Updated YAML content.
*/
function applyFormUpdates(originalYaml, updates) {
const root = normalizeRootNode(parseTopLevelYaml(originalYaml));
const scalarUpdates = updates.scalars || {};
const arrayUpdates = updates.arrays || {};
const objectArrayUpdates = updates.objectArrays || {};
for (const [path, value] of Object.entries(scalarUpdates)) {
setNodeAtPath(root, path.split("."), createScalarNode(String(value)));
@ -167,6 +168,11 @@ function applyFormUpdates(originalYaml, updates) {
(values || []).map((item) => createScalarNode(String(item)))));
}
for (const [path, items] of Object.entries(objectArrayUpdates)) {
setNodeAtPath(root, path.split("."), createArrayNode(
(items || []).map((item) => createNodeFromFormValue(item))));
}
return renderYaml(root).join("\n");
}
@ -687,6 +693,11 @@ function renderObjectNode(node, indent) {
continue;
}
if (entry.node.kind === "array" && entry.node.items.length === 0) {
lines.push(`${" ".repeat(indent)}${entry.key}: []`);
continue;
}
lines.push(`${" ".repeat(indent)}${entry.key}:`);
lines.push(...renderYaml(entry.node, indent + 2));
}
@ -736,6 +747,32 @@ function createArrayNode(items) {
return {kind: "array", items};
}
/**
* Convert one structured form value back into a YAML node tree.
* Object-array editors submit plain JavaScript objects so the writer can
* rebuild the full array deterministically instead of patching item paths
* one by one.
*
* @param {unknown} value Structured form value.
* @returns {YamlNode} YAML node.
*/
function createNodeFromFormValue(value) {
if (Array.isArray(value)) {
return createArrayNode(value.map((item) => createNodeFromFormValue(item)));
}
if (value && typeof value === "object") {
const objectNode = createObjectNode();
for (const [key, childValue] of Object.entries(value)) {
setObjectEntry(objectNode, key, createNodeFromFormValue(childValue));
}
return objectNode;
}
return createScalarNode(String(value ?? ""));
}
/**
* Create an object node.
*

View File

@ -13,8 +13,8 @@ const {
/**
* Activate the GFramework config extension.
* The initial MVP focuses on workspace file navigation, lightweight validation,
* and a small form-preview entry for top-level scalar values.
* The current tool focuses on workspace file navigation, lightweight
* validation, and a schema-aware form preview for common editing workflows.
*
* @param {vscode.ExtensionContext} context Extension context.
*/
@ -253,8 +253,8 @@ async function openSchemaFile(item) {
/**
* 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.
* The preview walks nested object structures recursively and now supports
* object-array editing for the repository's supported schema subset.
*
* @param {ConfigTreeItem | { resourceUri?: vscode.Uri }} item Tree item.
* @param {vscode.DiagnosticCollection} diagnostics Diagnostic collection.
@ -287,7 +287,8 @@ async function openFormPreview(item, diagnostics) {
const latestYamlText = await fs.promises.readFile(configUri.fsPath, "utf8");
const updatedYaml = applyFormUpdates(latestYamlText, {
scalars: message.scalars || {},
arrays: parseArrayFieldPayload(message.arrays || {})
arrays: parseArrayFieldPayload(message.arrays || {}),
objectArrays: message.objectArrays || {}
});
await fs.promises.writeFile(configUri.fsPath, updatedYaml, "utf8");
const document = await vscode.workspace.openTextDocument(configUri);
@ -570,54 +571,7 @@ async function loadSchemaInfoForConfig(configUri, workspaceRoot) {
function renderFormHtml(fileName, schemaInfo, parsedYaml) {
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-path="${escapeHtml(field.path)}">
${enumValues.map((value) => {
const escapedOption = escapeHtml(value);
const selected = value === field.value ? " selected" : "";
return `<option value="${escapedOption}"${selected}>${escapedOption}</option>`;
}).join("\n")}
</select>
`
: `<input data-path="${escapeHtml(field.path)}" value="${escapeHtml(field.value)}" />`;
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>
${renderFieldHint(field.schema, false)}
${inputControl}
</label>
`;
})
.map((field) => renderFormField(field))
.join("\n");
const unsupportedFields = formModel.unsupported
@ -664,6 +618,10 @@ function renderFormHtml(fileName, schemaInfo, parsedYaml) {
padding: 8px 12px;
cursor: pointer;
}
.secondary-button {
background: transparent;
color: var(--vscode-button-foreground);
}
.meta {
margin-bottom: 16px;
color: var(--vscode-descriptionForeground);
@ -734,6 +692,34 @@ function renderFormHtml(fileName, schemaInfo, parsedYaml) {
margin-bottom: 10px;
color: var(--vscode-descriptionForeground);
}
.object-array {
margin-bottom: 18px;
padding: 12px;
border: 1px solid var(--vscode-panel-border, transparent);
border-radius: 6px;
}
.object-array-items {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 12px;
}
.object-array-item {
padding: 12px;
border: 1px solid var(--vscode-input-border, transparent);
border-radius: 6px;
background: color-mix(in srgb, var(--vscode-editor-background) 88%, var(--vscode-panel-border, transparent));
}
.object-array-item-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
margin-bottom: 8px;
}
.object-array-item-title {
font-weight: 700;
}
.depth-1 {
margin-left: 12px;
}
@ -743,6 +729,12 @@ function renderFormHtml(fileName, schemaInfo, parsedYaml) {
.depth-3 {
margin-left: 36px;
}
.depth-4 {
margin-left: 48px;
}
.depth-5 {
margin-left: 60px;
}
</style>
</head>
<body>
@ -757,16 +749,96 @@ function renderFormHtml(fileName, schemaInfo, parsedYaml) {
<div id="fields">${emptyState}</div>
<script>
const vscode = acquireVsCodeApi();
function parseArrayEditorValue(value) {
return String(value)
.split(/\\r?\\n/u)
.map((item) => item.trim())
.filter((item) => item.length > 0);
}
function setNestedObjectValue(target, path, value) {
const segments = path.split(".").filter((segment) => segment.length > 0);
if (segments.length === 0) {
return;
}
let current = target;
for (let index = 0; index < segments.length; index += 1) {
const segment = segments[index];
if (index === segments.length - 1) {
current[segment] = value;
return;
}
if (!current[segment] || typeof current[segment] !== "object" || Array.isArray(current[segment])) {
current[segment] = {};
}
current = current[segment];
}
}
function renumberObjectArrayItems(editor) {
const items = editor.querySelectorAll("[data-object-array-item]");
items.forEach((item, index) => {
const title = item.querySelector(".object-array-item-title");
if (title) {
title.textContent = "Item " + (index + 1);
}
});
}
document.addEventListener("click", (event) => {
const addButton = event.target.closest("[data-add-object-array-item]");
if (addButton) {
const editor = addButton.closest("[data-object-array-editor]");
const itemsHost = editor.querySelector("[data-object-array-items]");
const template = editor.querySelector("template[data-object-array-template]");
if (itemsHost && template) {
itemsHost.appendChild(template.content.cloneNode(true));
renumberObjectArrayItems(editor);
}
return;
}
const removeButton = event.target.closest("[data-remove-object-array-item]");
if (removeButton) {
const item = removeButton.closest("[data-object-array-item]");
const editor = removeButton.closest("[data-object-array-editor]");
if (item) {
item.remove();
}
if (editor) {
renumberObjectArrayItems(editor);
}
}
});
document.getElementById("save").addEventListener("click", () => {
const scalars = {};
const arrays = {};
const objectArrays = {};
for (const control of document.querySelectorAll("[data-path]")) {
scalars[control.dataset.path] = control.value;
}
for (const textarea of document.querySelectorAll("textarea[data-array-path]")) {
arrays[textarea.dataset.arrayPath] = textarea.value;
}
vscode.postMessage({ type: "save", scalars, arrays });
for (const editor of document.querySelectorAll("[data-object-array-editor]")) {
const path = editor.dataset.objectArrayPath;
const items = [];
for (const item of editor.querySelectorAll("[data-object-array-items] > [data-object-array-item]")) {
const itemValue = {};
for (const control of item.querySelectorAll("[data-item-local-path]")) {
setNestedObjectValue(itemValue, control.dataset.itemLocalPath, control.value);
}
for (const textarea of item.querySelectorAll("textarea[data-item-array-path]")) {
setNestedObjectValue(
itemValue,
textarea.dataset.itemArrayPath,
parseArrayEditorValue(textarea.value));
}
items.push(itemValue);
}
objectArrays[path] = items;
}
vscode.postMessage({ type: "save", scalars, arrays, objectArrays });
});
document.getElementById("openRaw").addEventListener("click", () => {
vscode.postMessage({ type: "openRaw" });
@ -776,6 +848,106 @@ function renderFormHtml(fileName, schemaInfo, parsedYaml) {
</html>`;
}
/**
* Render one form field.
*
* @param {Record<string, unknown>} field Form field descriptor.
* @returns {string} HTML fragment.
*/
function renderFormField(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.displayPath || field.path)}</div>
${field.description ? `<span class="hint">${escapeHtml(field.description)}</span>` : ""}
</div>
`;
}
if (field.kind === "objectArray") {
const renderedItems = field.items
.map((item) => renderObjectArrayItem(item))
.join("\n");
const renderedTemplate = renderObjectArrayItem({
title: "Item",
fields: field.templateFields
});
return `
<div class="object-array depth-${field.depth}" data-object-array-editor data-object-array-path="${escapeHtml(field.path)}">
<div class="label">${escapeHtml(field.label)} ${field.required ? "<span class=\"badge\">required</span>" : ""}</div>
<div class="meta-key">${escapeHtml(field.displayPath || field.path)}</div>
<span class="hint">Each item uses the object schema below.</span>
${renderFieldHint(field.schema, true)}
<div class="object-array-items" data-object-array-items>${renderedItems}</div>
<template data-object-array-template>${renderedTemplate}</template>
<button type="button" class="secondary-button" data-add-object-array-item>Add Item</button>
</div>
`;
}
if (field.kind === "array") {
const itemType = field.itemType
? `array<${escapeHtml(field.itemType)}>`
: "array";
const dataAttribute = field.itemMode
? `data-item-array-path="${escapeHtml(field.path)}"`
: `data-array-path="${escapeHtml(field.path)}"`;
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.displayPath || field.path)}</span>
<span class="hint">One item per line. Expected type: ${itemType}</span>
${renderFieldHint(field.schema, true)}
<textarea ${dataAttribute} rows="5">${escapeHtml(field.value.join("\n"))}</textarea>
</label>
`;
}
const enumValues = Array.isArray(field.schema.enumValues) ? field.schema.enumValues : [];
const dataAttribute = field.itemMode
? `data-item-local-path="${escapeHtml(field.path)}"`
: `data-path="${escapeHtml(field.path)}"`;
const inputControl = enumValues.length > 0
? `
<select ${dataAttribute}>
${enumValues.map((value) => {
const escapedOption = escapeHtml(value);
const selected = value === field.value ? " selected" : "";
return `<option value="${escapedOption}"${selected}>${escapedOption}</option>`;
}).join("\n")}
</select>
`
: `<input ${dataAttribute} value="${escapeHtml(field.value)}" />`;
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.displayPath || field.path)}</span>
${renderFieldHint(field.schema, false)}
${inputControl}
</label>
`;
}
/**
* Render one object-array item editor block.
*
* @param {{title: string, fields: Array<Record<string, unknown>>}} item Item model.
* @returns {string} HTML fragment.
*/
function renderObjectArrayItem(item) {
return `
<div class="object-array-item" data-object-array-item>
<div class="object-array-item-header">
<span class="object-array-item-title">${escapeHtml(item.title)}</span>
<button type="button" class="secondary-button" data-remove-object-array-item>Remove</button>
</div>
${item.fields.map((field) => renderFormField(field)).join("\n")}
</div>
`;
}
/**
* Build a recursive form model from schema and parsed YAML.
*
@ -795,7 +967,7 @@ function buildFormModel(schemaInfo, parsedYaml) {
}
/**
* Recursively collect form-editable fields.
* Recursively collect top-level form-editable fields.
*
* @param {{type: string, required?: string[], properties?: Record<string, unknown>, title?: string, description?: string}} schemaNode Schema node.
* @param {unknown} yamlNode YAML node.
@ -836,6 +1008,7 @@ function collectFormFields(schemaNode, yamlNode, currentPath, depth, fields, uns
fields.push({
kind: "array",
path: propertyPath,
displayPath: propertyPath,
label,
required: requiredSet.has(key),
depth,
@ -846,10 +1019,37 @@ function collectFormFields(schemaNode, yamlNode, currentPath, depth, fields, uns
continue;
}
if (propertySchema.type === "array" &&
propertySchema.items &&
propertySchema.items.type === "object") {
const itemFieldsTemplate = [];
collectObjectArrayItemFields(
propertySchema.items,
undefined,
"",
`${propertyPath}[]`,
depth + 1,
itemFieldsTemplate,
unsupported);
fields.push({
kind: "objectArray",
path: propertyPath,
displayPath: propertyPath,
label,
required: requiredSet.has(key),
depth,
schema: propertySchema,
items: buildObjectArrayItemModels(propertySchema.items, propertyValue, propertyPath, depth + 1, unsupported),
templateFields: itemFieldsTemplate
});
continue;
}
if (["string", "integer", "number", "boolean"].includes(propertySchema.type)) {
fields.push({
kind: "scalar",
path: propertyPath,
displayPath: propertyPath,
label,
required: requiredSet.has(key),
depth,
@ -862,7 +1062,142 @@ function collectFormFields(schemaNode, yamlNode, currentPath, depth, fields, uns
unsupported.push({
path: propertyPath,
message: propertySchema.type === "array"
? "Object-array fields are currently view-only in the form preview. Use raw YAML for edits."
? "Unsupported array shapes are currently raw-YAML-only in the form preview."
: `${propertySchema.type} fields are currently raw-YAML-only.`
});
}
}
/**
* Build object-array item models from the current YAML array value.
*
* @param {{type: string, required?: string[], properties?: Record<string, unknown>}} itemSchema Array item schema.
* @param {unknown} yamlNode YAML node.
* @param {string} propertyPath Top-level object-array path.
* @param {number} depth Current depth.
* @param {Array<{path: string, message: string}>} unsupported Unsupported sink.
* @returns {Array<{title: string, fields: Array<Record<string, unknown>>}>} Item models.
*/
function buildObjectArrayItemModels(itemSchema, yamlNode, propertyPath, depth, unsupported) {
if (!yamlNode || yamlNode.kind !== "array") {
return [];
}
const items = [];
for (let index = 0; index < yamlNode.items.length; index += 1) {
const itemNode = yamlNode.items[index];
const itemPath = `${propertyPath}[${index}]`;
if (!itemNode || itemNode.kind !== "object") {
unsupported.push({
path: itemPath,
message: "Object-array items must be mappings. Use raw YAML if the current file mixes scalar and object items."
});
continue;
}
const fields = [];
collectObjectArrayItemFields(
itemSchema,
itemNode,
"",
itemPath,
depth,
fields,
unsupported);
items.push({
title: `Item ${index + 1}`,
fields
});
}
return items;
}
/**
* Recursively collect editable fields inside one object-array item.
* Nested objects remain editable, while nested object arrays still fall back
* to raw YAML until a deeper editor model is added.
*
* @param {{type: string, required?: string[], properties?: Record<string, unknown>, title?: string, description?: string}} schemaNode Schema node.
* @param {unknown} yamlNode YAML node.
* @param {string} localPath Path inside the current array item.
* @param {string} displayPath Full logical path for UI display.
* @param {number} depth Current depth.
* @param {Array<Record<string, unknown>>} fields Field sink.
* @param {Array<{path: string, message: string}>} unsupported Unsupported sink.
*/
function collectObjectArrayItemFields(schemaNode, yamlNode, localPath, displayPath, 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 itemLocalPath = localPath ? `${localPath}.${key}` : key;
const itemDisplayPath = `${displayPath}.${key}`;
const label = propertySchema.title || key;
const propertyValue = yamlMap.get(key);
if (propertySchema.type === "object") {
fields.push({
kind: "section",
path: itemLocalPath,
displayPath: itemDisplayPath,
label,
description: propertySchema.description,
required: requiredSet.has(key),
depth
});
collectObjectArrayItemFields(
propertySchema,
propertyValue,
itemLocalPath,
itemDisplayPath,
depth + 1,
fields,
unsupported);
continue;
}
if (propertySchema.type === "array" &&
propertySchema.items &&
["string", "integer", "number", "boolean"].includes(propertySchema.items.type)) {
fields.push({
kind: "array",
path: itemLocalPath,
displayPath: itemDisplayPath,
label,
required: requiredSet.has(key),
depth,
itemType: propertySchema.items.type,
value: getScalarArrayValue(propertyValue),
schema: propertySchema,
itemMode: true
});
continue;
}
if (["string", "integer", "number", "boolean"].includes(propertySchema.type)) {
fields.push({
kind: "scalar",
path: itemLocalPath,
displayPath: itemDisplayPath,
label,
required: requiredSet.has(key),
depth,
value: getScalarFieldValue(propertyValue, propertySchema.defaultValue),
schema: propertySchema,
itemMode: true
});
continue;
}
unsupported.push({
path: itemDisplayPath,
message: propertySchema.type === "array"
? "Nested object-array fields are currently raw-YAML-only inside the object-array editor."
: `${propertySchema.type} fields are currently raw-YAML-only.`
});
}

View File

@ -203,6 +203,72 @@ test("applyFormUpdates should update nested scalar and scalar-array paths", () =
assert.match(updated, /^phases:$/mu);
});
test("applyFormUpdates should rewrite object-array items from structured form payloads", () => {
const updated = applyFormUpdates(
[
"id: 1",
"name: Slime",
"phases:",
" -",
" wave: 1",
" monsterId: slime"
].join("\n"),
{
objectArrays: {
phases: [
{
wave: "1",
monsterId: "slime",
tags: ["starter", "melee"],
reward: {
gold: "10",
currency: "coin"
}
},
{
wave: "2",
monsterId: "goblin"
}
]
}
});
assert.match(updated, /^id: 1$/mu);
assert.match(updated, /^name: Slime$/mu);
assert.match(updated, /^phases:$/mu);
assert.match(updated, /^ -$/mu);
assert.match(updated, /^ wave: 1$/mu);
assert.match(updated, /^ monsterId: slime$/mu);
assert.match(updated, /^ tags:$/mu);
assert.match(updated, /^ - starter$/mu);
assert.match(updated, /^ - melee$/mu);
assert.match(updated, /^ reward:$/mu);
assert.match(updated, /^ gold: 10$/mu);
assert.match(updated, /^ currency: coin$/mu);
assert.match(updated, /^ monsterId: goblin$/mu);
});
test("applyFormUpdates should clear object arrays when the form removes all items", () => {
const updated = applyFormUpdates(
[
"id: 1",
"phases:",
" -",
" wave: 1",
" monsterId: slime"
].join("\n"),
{
objectArrays: {
phases: []
}
});
assert.equal(updated, [
"id: 1",
"phases: []"
].join("\n"));
});
test("applyScalarUpdates should preserve the scalar-only compatibility wrapper", () => {
const updated = applyScalarUpdates(
[