1531 lines
45 KiB
JavaScript
1531 lines
45 KiB
JavaScript
|
|
const state = {
|
|
definitions: [],
|
|
rules: [],
|
|
activeType: null,
|
|
activeFile: null,
|
|
viewMode: "raw",
|
|
currentContent: null,
|
|
activeEntryIndex: 0,
|
|
};
|
|
|
|
const elements = {
|
|
refreshButton: document.getElementById("refresh-button"),
|
|
filterInput: document.getElementById("filter-input"),
|
|
definitionsList: document.getElementById("definitions-list"),
|
|
rulesList: document.getElementById("rules-list"),
|
|
definitionsCount: document.getElementById("definitions-count"),
|
|
rulesCount: document.getElementById("rules-count"),
|
|
editorTitle: document.getElementById("editor-title"),
|
|
editorSubtitle: document.getElementById("editor-subtitle"),
|
|
editorTextarea: document.getElementById("editor-textarea"),
|
|
rawEditor: document.getElementById("raw-editor"),
|
|
structuredEditor: document.getElementById("structured-editor"),
|
|
structuredForm: document.getElementById("structured-form"),
|
|
entrySelect: document.getElementById("entry-select"),
|
|
addEntryButton: document.getElementById("add-entry"),
|
|
deleteEntryButton: document.getElementById("delete-entry"),
|
|
schemaSummary: document.getElementById("schema-summary"),
|
|
formatButton: document.getElementById("format-button"),
|
|
saveButton: document.getElementById("save-button"),
|
|
statusMessage: document.getElementById("status-message"),
|
|
newRuleInput: document.getElementById("new-rule-id"),
|
|
createRuleButton: document.getElementById("create-rule"),
|
|
buildButton: document.getElementById("build-button"),
|
|
buildOutput: document.getElementById("build-output"),
|
|
guideTitle: document.getElementById("guide-title"),
|
|
guideDesc: document.getElementById("guide-desc"),
|
|
guideFields: document.getElementById("guide-fields"),
|
|
insertTemplateButton: document.getElementById("insert-template"),
|
|
validateFileButton: document.getElementById("validate-file"),
|
|
validateAllButton: document.getElementById("validate-all"),
|
|
validationOutput: document.getElementById("validation-output"),
|
|
viewRawButton: document.getElementById("view-raw"),
|
|
viewStructuredButton: document.getElementById("view-structured"),
|
|
};
|
|
|
|
const fetchJson = async (url, options = {}) => {
|
|
const response = await fetch(url, {
|
|
headers: { "Content-Type": "application/json" },
|
|
...options,
|
|
});
|
|
if (!response.ok) {
|
|
let detail = "Request failed.";
|
|
try {
|
|
const body = await response.json();
|
|
detail = body.detail || detail;
|
|
} catch (err) {
|
|
detail = response.statusText || detail;
|
|
}
|
|
throw new Error(detail);
|
|
}
|
|
return response.json();
|
|
};
|
|
|
|
const setStatus = (message, isError = false) => {
|
|
elements.statusMessage.textContent = message;
|
|
elements.statusMessage.classList.toggle("error", isError);
|
|
};
|
|
|
|
const clearSchema = () => {
|
|
elements.schemaSummary.innerHTML = "";
|
|
elements.schemaSummary.classList.add("hidden");
|
|
};
|
|
|
|
const renderSchema = (schema) => {
|
|
if (!schema || schema.type !== "object") {
|
|
clearSchema();
|
|
return;
|
|
}
|
|
const fields = Object.keys(schema.fields || {}).slice(0, 12);
|
|
elements.schemaSummary.innerHTML = "";
|
|
fields.forEach((field) => {
|
|
const chip = document.createElement("span");
|
|
chip.textContent = field;
|
|
elements.schemaSummary.appendChild(chip);
|
|
});
|
|
elements.schemaSummary.classList.toggle("hidden", fields.length === 0);
|
|
};
|
|
|
|
const localizedTemplate = () => ({ Key: "", Fallback: "" });
|
|
const modifiersTemplate = () => ({
|
|
AttributeModifiers: [],
|
|
StatusModifiers: [],
|
|
ResourceModifiers: [],
|
|
RuleIds: [],
|
|
});
|
|
|
|
const definitionGuides = {
|
|
"disciplines.json": {
|
|
title: "Disciplines",
|
|
description: "Academic disciplines with buffs, role pools, and item pools.",
|
|
fields: ["Header", "Buff", "RolePoolIds", "ItemPoolIds", "TaskKeywordIds"],
|
|
templateType: "array",
|
|
template: () => ({
|
|
Header: {
|
|
Id: "core:discipline_new",
|
|
Name: localizedTemplate(),
|
|
Description: localizedTemplate(),
|
|
IconPath: "",
|
|
Tags: ["discipline"],
|
|
},
|
|
Buff: {
|
|
Name: localizedTemplate(),
|
|
Description: localizedTemplate(),
|
|
Modifiers: modifiersTemplate(),
|
|
},
|
|
RolePoolIds: [],
|
|
ItemPoolIds: [],
|
|
TaskKeywordIds: [],
|
|
}),
|
|
},
|
|
"roles.json": {
|
|
title: "Roles",
|
|
description: "Role definitions with tiered modifiers and discipline limits.",
|
|
fields: ["Header", "Tiers", "AllowedDisciplineIds"],
|
|
templateType: "array",
|
|
template: () => ({
|
|
Header: {
|
|
Id: "core:role_new",
|
|
Name: localizedTemplate(),
|
|
Description: localizedTemplate(),
|
|
IconPath: "",
|
|
Tags: ["role"],
|
|
},
|
|
Tiers: [
|
|
{
|
|
RequiredCount: 1,
|
|
Modifiers: modifiersTemplate(),
|
|
},
|
|
],
|
|
AllowedDisciplineIds: [],
|
|
}),
|
|
},
|
|
"archetypes.json": {
|
|
title: "Archetypes",
|
|
description: "Synergy archetypes with stacked tiers.",
|
|
fields: ["Header", "Tiers"],
|
|
templateType: "array",
|
|
template: () => ({
|
|
Header: {
|
|
Id: "core:archetype_new",
|
|
Name: localizedTemplate(),
|
|
Description: localizedTemplate(),
|
|
IconPath: "",
|
|
Tags: ["archetype"],
|
|
},
|
|
Tiers: [
|
|
{
|
|
RequiredCount: 1,
|
|
Modifiers: modifiersTemplate(),
|
|
},
|
|
],
|
|
}),
|
|
},
|
|
"traits.json": {
|
|
title: "Traits",
|
|
description: "Individual traits with modifier bundles and rules.",
|
|
fields: ["Header", "Modifiers"],
|
|
templateType: "array",
|
|
template: () => ({
|
|
Header: {
|
|
Id: "core:trait_new",
|
|
Name: localizedTemplate(),
|
|
Description: localizedTemplate(),
|
|
IconPath: "",
|
|
Tags: ["trait"],
|
|
},
|
|
Modifiers: modifiersTemplate(),
|
|
}),
|
|
},
|
|
"campus_behavior.json": {
|
|
title: "Campus behavior",
|
|
description: "Global thresholds and action configs for campus AI.",
|
|
fields: [
|
|
"CriticalSanityThreshold",
|
|
"CriticalStaminaThreshold",
|
|
"CriticalStressThreshold",
|
|
"HungerThreshold",
|
|
"EnergyThreshold",
|
|
"SocialThreshold",
|
|
"LowMoodThreshold",
|
|
"HungerDecayPerSecond",
|
|
"EnergyDecayPerSecond",
|
|
"StaminaDecayPerSecond",
|
|
"StressGrowthPerSecond",
|
|
"SocialDecayPerSecond",
|
|
"DecisionIntervalSeconds",
|
|
"ActionDurationVariance",
|
|
"MinPlannedActionSeconds",
|
|
"ActionConfigs",
|
|
],
|
|
templateType: "object",
|
|
template: () => ({
|
|
CriticalSanityThreshold: 0,
|
|
CriticalStaminaThreshold: 0,
|
|
CriticalStressThreshold: 0,
|
|
HungerThreshold: 0,
|
|
EnergyThreshold: 0,
|
|
SocialThreshold: 0,
|
|
LowMoodThreshold: 0,
|
|
HungerDecayPerSecond: 0.0,
|
|
EnergyDecayPerSecond: 0.0,
|
|
StaminaDecayPerSecond: 0.0,
|
|
StressGrowthPerSecond: 0.0,
|
|
SocialDecayPerSecond: 0.0,
|
|
DecisionIntervalSeconds: 0.0,
|
|
ActionDurationVariance: 0.0,
|
|
MinPlannedActionSeconds: 0.0,
|
|
ActionConfigs: [],
|
|
}),
|
|
},
|
|
};
|
|
|
|
const tresGuide = {
|
|
title: "Discipline resource",
|
|
description: "Godot resource-based discipline definition.",
|
|
fields: [
|
|
"Id",
|
|
"NameKey",
|
|
"NameFallback",
|
|
"DescriptionKey",
|
|
"DescriptionFallback",
|
|
"IconPath",
|
|
"Tags",
|
|
"BuffNameKey",
|
|
"BuffNameFallback",
|
|
"BuffDescriptionKey",
|
|
"BuffDescriptionFallback",
|
|
"BuffRuleIds",
|
|
"RolePoolIds",
|
|
"ItemPoolIds",
|
|
"TaskKeywordIds",
|
|
],
|
|
templateType: "object",
|
|
template: () => ({
|
|
Id: "core:discipline_new_tres",
|
|
NameKey: "",
|
|
NameFallback: "",
|
|
DescriptionKey: "",
|
|
DescriptionFallback: "",
|
|
IconPath: "",
|
|
Tags: ["discipline"],
|
|
BuffNameKey: "",
|
|
BuffNameFallback: "",
|
|
BuffDescriptionKey: "",
|
|
BuffDescriptionFallback: "",
|
|
BuffRuleIds: [],
|
|
RolePoolIds: [],
|
|
ItemPoolIds: [],
|
|
TaskKeywordIds: [],
|
|
}),
|
|
};
|
|
|
|
const idPattern = /^[a-z0-9_]+:[a-z0-9_]+$/;
|
|
|
|
const getGuideForFile = (filename) => {
|
|
if (filename.endsWith(".tres")) {
|
|
return tresGuide;
|
|
}
|
|
return definitionGuides[filename] || null;
|
|
};
|
|
|
|
const renderGuide = (guide, filename, content) => {
|
|
if (!guide) {
|
|
elements.guideTitle.textContent = "No definition selected";
|
|
elements.guideDesc.textContent = "Select a definition file to see expected fields.";
|
|
elements.guideFields.innerHTML = "";
|
|
return;
|
|
}
|
|
const entryLabel = Array.isArray(content)
|
|
? `${content.length} entries`
|
|
: "single object";
|
|
elements.guideTitle.textContent = `${guide.title} (${filename})`;
|
|
elements.guideDesc.textContent = `${guide.description} (${entryLabel}).`;
|
|
elements.guideFields.innerHTML = "";
|
|
guide.fields.forEach((field) => {
|
|
const chip = document.createElement("span");
|
|
chip.className = "guide-field";
|
|
chip.textContent = field;
|
|
elements.guideFields.appendChild(chip);
|
|
});
|
|
};
|
|
|
|
const setGuideButtons = ({ enabled, hasTemplate }) => {
|
|
elements.insertTemplateButton.disabled = !enabled || !hasTemplate;
|
|
elements.validateFileButton.disabled = !enabled;
|
|
elements.validateAllButton.disabled = !state.definitions.length;
|
|
};
|
|
|
|
const clearValidation = () => {
|
|
elements.validationOutput.textContent = "";
|
|
elements.validationOutput.classList.add("hidden");
|
|
};
|
|
|
|
const renderValidation = (issues) => {
|
|
elements.validationOutput.classList.remove("hidden");
|
|
if (!issues.length) {
|
|
elements.validationOutput.textContent = "No issues found.";
|
|
return;
|
|
}
|
|
elements.validationOutput.textContent = issues
|
|
.map((issue) => `[${issue.level.toUpperCase()}] ${issue.file}: ${issue.message}`)
|
|
.join("\n");
|
|
};
|
|
|
|
const createNavItem = (file, isActive, onClick) => {
|
|
const button = document.createElement("button");
|
|
button.className = "nav-item";
|
|
if (isActive) {
|
|
button.classList.add("active");
|
|
}
|
|
const label = document.createElement("span");
|
|
label.textContent = file;
|
|
button.appendChild(label);
|
|
button.addEventListener("click", () => onClick(file));
|
|
return button;
|
|
};
|
|
|
|
const renderLists = () => {
|
|
const filterValue = elements.filterInput.value.trim().toLowerCase();
|
|
const defFiles = state.definitions.filter((file) =>
|
|
file.toLowerCase().includes(filterValue),
|
|
);
|
|
const ruleFiles = state.rules.filter((file) =>
|
|
file.toLowerCase().includes(filterValue),
|
|
);
|
|
|
|
elements.definitionsList.innerHTML = "";
|
|
elements.rulesList.innerHTML = "";
|
|
defFiles.forEach((file) => {
|
|
elements.definitionsList.appendChild(
|
|
createNavItem(
|
|
file,
|
|
state.activeType === "definition" && state.activeFile === file,
|
|
openDefinition,
|
|
),
|
|
);
|
|
});
|
|
ruleFiles.forEach((file) => {
|
|
elements.rulesList.appendChild(
|
|
createNavItem(
|
|
file,
|
|
state.activeType === "rule" && state.activeFile === file,
|
|
openRule,
|
|
),
|
|
);
|
|
});
|
|
|
|
elements.definitionsCount.textContent = String(defFiles.length);
|
|
elements.rulesCount.textContent = String(ruleFiles.length);
|
|
setGuideButtons({
|
|
enabled: state.activeType === "definition" && !!state.activeFile,
|
|
hasTemplate: !!getGuideForFile(state.activeFile || "")?.template,
|
|
});
|
|
};
|
|
|
|
const setEditorState = ({ title, subtitle, content, mode }) => {
|
|
elements.editorTitle.textContent = title;
|
|
elements.editorSubtitle.textContent = subtitle;
|
|
elements.editorTextarea.value = content;
|
|
elements.formatButton.classList.toggle(
|
|
"hidden",
|
|
mode !== "definition" || state.viewMode !== "raw",
|
|
);
|
|
};
|
|
|
|
const setGuideState = ({ mode, filename, content }) => {
|
|
clearValidation();
|
|
if (mode !== "definition" || !filename) {
|
|
renderGuide(null, "", null);
|
|
setGuideButtons({ enabled: false, hasTemplate: false });
|
|
return;
|
|
}
|
|
const guide = getGuideForFile(filename);
|
|
renderGuide(guide, filename, content);
|
|
setGuideButtons({ enabled: true, hasTemplate: !!guide?.template });
|
|
};
|
|
|
|
const setViewMode = (mode, { skipRender } = { skipRender: false }) => {
|
|
state.viewMode = mode;
|
|
elements.rawEditor.classList.toggle("hidden", mode === "structured");
|
|
elements.structuredEditor.classList.toggle("hidden", mode !== "structured");
|
|
elements.viewRawButton.classList.toggle("active", mode === "raw");
|
|
elements.viewStructuredButton.classList.toggle("active", mode === "structured");
|
|
elements.formatButton.classList.toggle(
|
|
"hidden",
|
|
state.activeType !== "definition" || mode !== "raw",
|
|
);
|
|
|
|
if (mode === "structured" && !skipRender) {
|
|
const ok = syncContentFromRaw();
|
|
if (!ok) {
|
|
setStatus("Invalid JSON. Fix errors before opening structured editor.", true);
|
|
setViewMode("raw", { skipRender: true });
|
|
return;
|
|
}
|
|
renderStructuredEditor();
|
|
}
|
|
};
|
|
|
|
const updateViewButtons = () => {
|
|
const allowStructured = state.activeType === "definition";
|
|
elements.viewStructuredButton.disabled = !allowStructured;
|
|
if (!allowStructured) {
|
|
setViewMode("raw", { skipRender: true });
|
|
}
|
|
};
|
|
|
|
const syncContentFromRaw = () => {
|
|
if (state.activeType !== "definition") {
|
|
return false;
|
|
}
|
|
try {
|
|
state.currentContent = JSON.parse(elements.editorTextarea.value);
|
|
return true;
|
|
} catch (err) {
|
|
return false;
|
|
}
|
|
};
|
|
|
|
const syncRawFromContent = () => {
|
|
if (state.activeType !== "definition") {
|
|
return;
|
|
}
|
|
elements.editorTextarea.value = JSON.stringify(state.currentContent ?? {}, null, 2);
|
|
};
|
|
|
|
const createSection = (title) => {
|
|
const section = document.createElement("div");
|
|
section.className = "form-section";
|
|
const heading = document.createElement("h3");
|
|
heading.textContent = title;
|
|
section.appendChild(heading);
|
|
return section;
|
|
};
|
|
|
|
const createRow = (label, input) => {
|
|
const row = document.createElement("div");
|
|
row.className = "form-row";
|
|
const labelEl = document.createElement("div");
|
|
labelEl.className = "form-label";
|
|
labelEl.textContent = label;
|
|
row.appendChild(labelEl);
|
|
row.appendChild(input);
|
|
return row;
|
|
};
|
|
|
|
const createTextInput = (value, onChange) => {
|
|
const input = document.createElement("input");
|
|
input.className = "form-input";
|
|
input.type = "text";
|
|
input.value = value ?? "";
|
|
input.addEventListener("input", () => onChange(input.value));
|
|
return input;
|
|
};
|
|
|
|
const createNumberInput = (value, onChange) => {
|
|
const input = document.createElement("input");
|
|
input.className = "form-input";
|
|
input.type = "number";
|
|
input.step = "any";
|
|
input.value = value ?? 0;
|
|
input.addEventListener("input", () => {
|
|
const parsed = Number(input.value);
|
|
onChange(Number.isNaN(parsed) ? 0 : parsed);
|
|
});
|
|
return input;
|
|
};
|
|
|
|
const createCheckboxInput = (value, onChange) => {
|
|
const input = document.createElement("input");
|
|
input.className = "form-input";
|
|
input.type = "checkbox";
|
|
input.checked = Boolean(value);
|
|
input.addEventListener("change", () => onChange(input.checked));
|
|
return input;
|
|
};
|
|
|
|
const createArrayInput = (value, onChange) => {
|
|
const input = document.createElement("input");
|
|
input.className = "form-input";
|
|
input.type = "text";
|
|
input.value = Array.isArray(value) ? value.join(", ") : "";
|
|
input.addEventListener("input", () => {
|
|
const parts = input.value
|
|
.split(",")
|
|
.map((item) => item.trim())
|
|
.filter((item) => item.length > 0);
|
|
onChange(parts);
|
|
});
|
|
return input;
|
|
};
|
|
|
|
const ensureObject = (target, key, fallback) => {
|
|
if (!target[key] || typeof target[key] !== "object" || Array.isArray(target[key])) {
|
|
target[key] = fallback;
|
|
}
|
|
return target[key];
|
|
};
|
|
|
|
const ensureLocalized = (target, key) => {
|
|
return ensureObject(target, key, localizedTemplate());
|
|
};
|
|
|
|
const ensureModifiers = (target, key) => {
|
|
return ensureObject(target, key, modifiersTemplate());
|
|
};
|
|
|
|
const renderArrayEditor = (options) => {
|
|
const { title, items, fields, onChange } = options;
|
|
const section = createSection(title);
|
|
const listContainer = document.createElement("div");
|
|
|
|
const rebuild = () => {
|
|
listContainer.innerHTML = "";
|
|
items.forEach((item, index) => {
|
|
const row = document.createElement("div");
|
|
row.className = "array-row";
|
|
fields.forEach((field) => {
|
|
const input = field.type === "number"
|
|
? createNumberInput(item[field.key], (value) => {
|
|
item[field.key] = value;
|
|
onChange();
|
|
})
|
|
: createTextInput(item[field.key], (value) => {
|
|
item[field.key] = value;
|
|
onChange();
|
|
});
|
|
input.placeholder = field.label;
|
|
row.appendChild(input);
|
|
});
|
|
const remove = document.createElement("button");
|
|
remove.className = "ghost";
|
|
remove.textContent = "Remove";
|
|
remove.addEventListener("click", () => {
|
|
items.splice(index, 1);
|
|
onChange(true);
|
|
});
|
|
row.appendChild(remove);
|
|
listContainer.appendChild(row);
|
|
});
|
|
};
|
|
|
|
const addButton = document.createElement("button");
|
|
addButton.className = "ghost";
|
|
addButton.textContent = `Add ${title}`;
|
|
addButton.addEventListener("click", () => {
|
|
const newItem = {};
|
|
fields.forEach((field) => {
|
|
newItem[field.key] = field.defaultValue ?? (field.type === "number" ? 0 : "");
|
|
});
|
|
items.push(newItem);
|
|
onChange(true);
|
|
});
|
|
|
|
rebuild();
|
|
section.appendChild(listContainer);
|
|
section.appendChild(addButton);
|
|
return section;
|
|
};
|
|
|
|
const renderModifiersSection = (modifiers, onChange) => {
|
|
const section = createSection("Modifiers");
|
|
const attributeSection = renderArrayEditor({
|
|
title: "AttributeModifiers",
|
|
items: modifiers.AttributeModifiers || [],
|
|
fields: [
|
|
{ key: "Type", label: "Type" },
|
|
{ key: "Add", label: "Add", type: "number", defaultValue: 0 },
|
|
{ key: "Multiplier", label: "Multiplier", type: "number", defaultValue: 1.0 },
|
|
],
|
|
onChange,
|
|
});
|
|
const statusSection = renderArrayEditor({
|
|
title: "StatusModifiers",
|
|
items: modifiers.StatusModifiers || [],
|
|
fields: [
|
|
{ key: "Type", label: "Type" },
|
|
{ key: "Add", label: "Add", type: "number", defaultValue: 0 },
|
|
{ key: "Multiplier", label: "Multiplier", type: "number", defaultValue: 1.0 },
|
|
],
|
|
onChange,
|
|
});
|
|
const resourceSection = renderArrayEditor({
|
|
title: "ResourceModifiers",
|
|
items: modifiers.ResourceModifiers || [],
|
|
fields: [
|
|
{ key: "Type", label: "Type" },
|
|
{ key: "Add", label: "Add", type: "number", defaultValue: 0 },
|
|
{ key: "Multiplier", label: "Multiplier", type: "number", defaultValue: 1.0 },
|
|
],
|
|
onChange,
|
|
});
|
|
const ruleIdsRow = createRow(
|
|
"RuleIds",
|
|
createArrayInput(modifiers.RuleIds || [], (value) => {
|
|
modifiers.RuleIds = value;
|
|
onChange();
|
|
}),
|
|
);
|
|
|
|
section.appendChild(attributeSection);
|
|
section.appendChild(statusSection);
|
|
section.appendChild(resourceSection);
|
|
section.appendChild(ruleIdsRow);
|
|
return section;
|
|
};
|
|
|
|
const renderHeaderSection = (entry, onChange) => {
|
|
const header = ensureObject(entry, "Header", {});
|
|
const name = ensureLocalized(header, "Name");
|
|
const description = ensureLocalized(header, "Description");
|
|
|
|
const section = createSection("Header");
|
|
section.appendChild(
|
|
createRow(
|
|
"Id",
|
|
createTextInput(header.Id, (value) => {
|
|
header.Id = value;
|
|
onChange();
|
|
}),
|
|
),
|
|
);
|
|
section.appendChild(
|
|
createRow(
|
|
"Name.Key",
|
|
createTextInput(name.Key, (value) => {
|
|
name.Key = value;
|
|
onChange();
|
|
}),
|
|
),
|
|
);
|
|
section.appendChild(
|
|
createRow(
|
|
"Name.Fallback",
|
|
createTextInput(name.Fallback, (value) => {
|
|
name.Fallback = value;
|
|
onChange();
|
|
}),
|
|
),
|
|
);
|
|
section.appendChild(
|
|
createRow(
|
|
"Description.Key",
|
|
createTextInput(description.Key, (value) => {
|
|
description.Key = value;
|
|
onChange();
|
|
}),
|
|
),
|
|
);
|
|
section.appendChild(
|
|
createRow(
|
|
"Description.Fallback",
|
|
createTextInput(description.Fallback, (value) => {
|
|
description.Fallback = value;
|
|
onChange();
|
|
}),
|
|
),
|
|
);
|
|
section.appendChild(
|
|
createRow(
|
|
"IconPath",
|
|
createTextInput(header.IconPath, (value) => {
|
|
header.IconPath = value;
|
|
onChange();
|
|
}),
|
|
),
|
|
);
|
|
section.appendChild(
|
|
createRow(
|
|
"Tags",
|
|
createArrayInput(header.Tags || [], (value) => {
|
|
header.Tags = value;
|
|
onChange();
|
|
}),
|
|
),
|
|
);
|
|
return section;
|
|
};
|
|
|
|
const renderBuffSection = (entry, onChange) => {
|
|
const buff = ensureObject(entry, "Buff", {});
|
|
const name = ensureLocalized(buff, "Name");
|
|
const description = ensureLocalized(buff, "Description");
|
|
const modifiers = ensureModifiers(buff, "Modifiers");
|
|
|
|
const section = createSection("Buff");
|
|
section.appendChild(
|
|
createRow(
|
|
"Name.Key",
|
|
createTextInput(name.Key, (value) => {
|
|
name.Key = value;
|
|
onChange();
|
|
}),
|
|
),
|
|
);
|
|
section.appendChild(
|
|
createRow(
|
|
"Name.Fallback",
|
|
createTextInput(name.Fallback, (value) => {
|
|
name.Fallback = value;
|
|
onChange();
|
|
}),
|
|
),
|
|
);
|
|
section.appendChild(
|
|
createRow(
|
|
"Description.Key",
|
|
createTextInput(description.Key, (value) => {
|
|
description.Key = value;
|
|
onChange();
|
|
}),
|
|
),
|
|
);
|
|
section.appendChild(
|
|
createRow(
|
|
"Description.Fallback",
|
|
createTextInput(description.Fallback, (value) => {
|
|
description.Fallback = value;
|
|
onChange();
|
|
}),
|
|
),
|
|
);
|
|
section.appendChild(renderModifiersSection(modifiers, onChange));
|
|
return section;
|
|
};
|
|
|
|
const renderTierSection = (tiers, onChange) => {
|
|
const section = createSection("Tiers");
|
|
const tierList = document.createElement("div");
|
|
|
|
const rebuild = () => {
|
|
tierList.innerHTML = "";
|
|
tiers.forEach((tier, index) => {
|
|
const tierSection = document.createElement("div");
|
|
tierSection.className = "form-section";
|
|
const heading = document.createElement("h3");
|
|
heading.textContent = `Tier ${index + 1}`;
|
|
tierSection.appendChild(heading);
|
|
tierSection.appendChild(
|
|
createRow(
|
|
"RequiredCount",
|
|
createNumberInput(tier.RequiredCount, (value) => {
|
|
tier.RequiredCount = value;
|
|
onChange();
|
|
}),
|
|
),
|
|
);
|
|
const modifiers = ensureModifiers(tier, "Modifiers");
|
|
tierSection.appendChild(renderModifiersSection(modifiers, onChange));
|
|
const remove = document.createElement("button");
|
|
remove.className = "ghost";
|
|
remove.textContent = "Remove tier";
|
|
remove.addEventListener("click", () => {
|
|
tiers.splice(index, 1);
|
|
onChange(true);
|
|
});
|
|
tierSection.appendChild(remove);
|
|
tierList.appendChild(tierSection);
|
|
});
|
|
};
|
|
|
|
const addButton = document.createElement("button");
|
|
addButton.className = "ghost";
|
|
addButton.textContent = "Add tier";
|
|
addButton.addEventListener("click", () => {
|
|
tiers.push({ RequiredCount: 1, Modifiers: modifiersTemplate() });
|
|
onChange(true);
|
|
});
|
|
|
|
rebuild();
|
|
section.appendChild(tierList);
|
|
section.appendChild(addButton);
|
|
return section;
|
|
};
|
|
|
|
const renderDefinitionEntryForm = (filename, entry) => {
|
|
elements.structuredForm.innerHTML = "";
|
|
const onChange = (rerender) => {
|
|
syncRawFromContent();
|
|
if (rerender) {
|
|
renderStructuredEditor();
|
|
}
|
|
};
|
|
|
|
elements.structuredForm.appendChild(renderHeaderSection(entry, onChange));
|
|
|
|
if (filename === "disciplines.json") {
|
|
elements.structuredForm.appendChild(renderBuffSection(entry, onChange));
|
|
elements.structuredForm.appendChild(
|
|
createRow(
|
|
"RolePoolIds",
|
|
createArrayInput(entry.RolePoolIds || [], (value) => {
|
|
entry.RolePoolIds = value;
|
|
onChange();
|
|
}),
|
|
),
|
|
);
|
|
elements.structuredForm.appendChild(
|
|
createRow(
|
|
"ItemPoolIds",
|
|
createArrayInput(entry.ItemPoolIds || [], (value) => {
|
|
entry.ItemPoolIds = value;
|
|
onChange();
|
|
}),
|
|
),
|
|
);
|
|
elements.structuredForm.appendChild(
|
|
createRow(
|
|
"TaskKeywordIds",
|
|
createArrayInput(entry.TaskKeywordIds || [], (value) => {
|
|
entry.TaskKeywordIds = value;
|
|
onChange();
|
|
}),
|
|
),
|
|
);
|
|
}
|
|
|
|
if (filename === "roles.json" || filename === "archetypes.json") {
|
|
entry.Tiers = Array.isArray(entry.Tiers) ? entry.Tiers : [];
|
|
elements.structuredForm.appendChild(renderTierSection(entry.Tiers, onChange));
|
|
}
|
|
|
|
if (filename === "roles.json") {
|
|
elements.structuredForm.appendChild(
|
|
createRow(
|
|
"AllowedDisciplineIds",
|
|
createArrayInput(entry.AllowedDisciplineIds || [], (value) => {
|
|
entry.AllowedDisciplineIds = value;
|
|
onChange();
|
|
}),
|
|
),
|
|
);
|
|
}
|
|
|
|
if (filename === "traits.json") {
|
|
const modifiers = ensureModifiers(entry, "Modifiers");
|
|
elements.structuredForm.appendChild(renderModifiersSection(modifiers, onChange));
|
|
}
|
|
};
|
|
|
|
const renderCampusBehaviorForm = (content) => {
|
|
elements.structuredForm.innerHTML = "";
|
|
const onChange = (rerender) => {
|
|
syncRawFromContent();
|
|
if (rerender) {
|
|
renderStructuredEditor();
|
|
}
|
|
};
|
|
const fields = definitionGuides["campus_behavior.json"].fields.filter(
|
|
(field) => field !== "ActionConfigs",
|
|
);
|
|
const section = createSection("Global settings");
|
|
fields.forEach((field) => {
|
|
section.appendChild(
|
|
createRow(
|
|
field,
|
|
createNumberInput(content[field], (value) => {
|
|
content[field] = value;
|
|
onChange();
|
|
}),
|
|
),
|
|
);
|
|
});
|
|
elements.structuredForm.appendChild(section);
|
|
|
|
content.ActionConfigs = Array.isArray(content.ActionConfigs) ? content.ActionConfigs : [];
|
|
const actionSection = renderArrayEditor({
|
|
title: "ActionConfigs",
|
|
items: content.ActionConfigs,
|
|
fields: [
|
|
{ key: "ActionId", label: "ActionId" },
|
|
{ key: "LocationId", label: "LocationId" },
|
|
{ key: "DurationSeconds", label: "DurationSeconds", type: "number", defaultValue: 0 },
|
|
{ key: "HungerDelta", label: "HungerDelta", type: "number", defaultValue: 0 },
|
|
{ key: "EnergyDelta", label: "EnergyDelta", type: "number", defaultValue: 0 },
|
|
{ key: "StaminaDelta", label: "StaminaDelta", type: "number", defaultValue: 0 },
|
|
{ key: "StressDelta", label: "StressDelta", type: "number", defaultValue: 0 },
|
|
{ key: "MoodDelta", label: "MoodDelta", type: "number", defaultValue: 0 },
|
|
{ key: "SocialDelta", label: "SocialDelta", type: "number", defaultValue: 0 },
|
|
{ key: "SanityDelta", label: "SanityDelta", type: "number", defaultValue: 0 },
|
|
{ key: "HealthDelta", label: "HealthDelta", type: "number", defaultValue: 0 },
|
|
],
|
|
onChange,
|
|
});
|
|
elements.structuredForm.appendChild(actionSection);
|
|
};
|
|
|
|
const renderTresForm = (content) => {
|
|
elements.structuredForm.innerHTML = "";
|
|
const onChange = () => {
|
|
syncRawFromContent();
|
|
};
|
|
const section = createSection("Resource fields");
|
|
tresGuide.fields.forEach((field) => {
|
|
let input;
|
|
const value = content[field];
|
|
if (Array.isArray(value)) {
|
|
input = createArrayInput(value, (list) => {
|
|
content[field] = list;
|
|
onChange();
|
|
});
|
|
} else if (typeof value === "number") {
|
|
input = createNumberInput(value, (next) => {
|
|
content[field] = next;
|
|
onChange();
|
|
});
|
|
} else if (typeof value === "boolean") {
|
|
input = createCheckboxInput(value, (next) => {
|
|
content[field] = next;
|
|
onChange();
|
|
});
|
|
} else {
|
|
input = createTextInput(value ?? "", (next) => {
|
|
content[field] = next;
|
|
onChange();
|
|
});
|
|
}
|
|
section.appendChild(createRow(field, input));
|
|
});
|
|
elements.structuredForm.appendChild(section);
|
|
};
|
|
|
|
const renderStructuredEditor = () => {
|
|
if (state.activeType !== "definition" || !state.activeFile) {
|
|
return;
|
|
}
|
|
const filename = state.activeFile;
|
|
const content = state.currentContent;
|
|
if (!content) {
|
|
return;
|
|
}
|
|
|
|
if (filename === "campus_behavior.json") {
|
|
elements.entrySelect.classList.add("hidden");
|
|
elements.addEntryButton.classList.add("hidden");
|
|
elements.deleteEntryButton.classList.add("hidden");
|
|
renderCampusBehaviorForm(content);
|
|
return;
|
|
}
|
|
|
|
if (filename.endsWith(".tres")) {
|
|
elements.entrySelect.classList.add("hidden");
|
|
elements.addEntryButton.classList.add("hidden");
|
|
elements.deleteEntryButton.classList.add("hidden");
|
|
renderTresForm(content);
|
|
return;
|
|
}
|
|
|
|
if (!Array.isArray(content)) {
|
|
elements.structuredForm.innerHTML = "";
|
|
return;
|
|
}
|
|
|
|
elements.entrySelect.classList.remove("hidden");
|
|
elements.addEntryButton.classList.remove("hidden");
|
|
elements.deleteEntryButton.classList.remove("hidden");
|
|
|
|
elements.entrySelect.innerHTML = "";
|
|
content.forEach((entry, index) => {
|
|
const option = document.createElement("option");
|
|
const entryId = entry?.Header?.Id || `Entry ${index + 1}`;
|
|
option.value = String(index);
|
|
option.textContent = entryId;
|
|
elements.entrySelect.appendChild(option);
|
|
});
|
|
if (state.activeEntryIndex >= content.length) {
|
|
state.activeEntryIndex = 0;
|
|
}
|
|
elements.entrySelect.value = String(state.activeEntryIndex);
|
|
const entry = content[state.activeEntryIndex];
|
|
if (entry) {
|
|
renderDefinitionEntryForm(filename, entry);
|
|
}
|
|
};
|
|
const openDefinition = async (filename) => {
|
|
try {
|
|
setStatus("Loading definition...");
|
|
const [contentResponse, schemaResponse] = await Promise.all([
|
|
fetchJson(`/api/files/content?filename=${encodeURIComponent(filename)}`),
|
|
fetchJson(`/api/files/schema?filename=${encodeURIComponent(filename)}`),
|
|
]);
|
|
state.activeType = "definition";
|
|
state.activeFile = filename;
|
|
state.currentContent = contentResponse.content || {};
|
|
state.activeEntryIndex = 0;
|
|
renderLists();
|
|
const rawContent = JSON.stringify(contentResponse.content || {}, null, 2);
|
|
setEditorState({
|
|
title: filename,
|
|
subtitle: filename.endsWith(".tres") ? "Godot resource (tres)" : "JSON definition",
|
|
content: rawContent,
|
|
mode: "definition",
|
|
});
|
|
renderSchema(schemaResponse.schema);
|
|
setGuideState({
|
|
mode: "definition",
|
|
filename,
|
|
content: contentResponse.content,
|
|
});
|
|
updateViewButtons();
|
|
if (state.viewMode === "structured") {
|
|
renderStructuredEditor();
|
|
}
|
|
setStatus("Definition loaded.");
|
|
} catch (err) {
|
|
setStatus(err.message || "Failed to load definition.", true);
|
|
}
|
|
};
|
|
|
|
const openRule = async (filename) => {
|
|
try {
|
|
setStatus("Loading rule...");
|
|
const response = await fetchJson(
|
|
`/api/rules/content?filename=${encodeURIComponent(filename)}`,
|
|
);
|
|
state.activeType = "rule";
|
|
state.activeFile = filename;
|
|
state.currentContent = null;
|
|
renderLists();
|
|
setEditorState({
|
|
title: filename,
|
|
subtitle: "C# rule source",
|
|
content: response.content || "",
|
|
mode: "rule",
|
|
});
|
|
clearSchema();
|
|
setGuideState({ mode: "rule", filename: null, content: null });
|
|
updateViewButtons();
|
|
setViewMode("raw", { skipRender: true });
|
|
setStatus("Rule loaded.");
|
|
} catch (err) {
|
|
setStatus(err.message || "Failed to load rule.", true);
|
|
}
|
|
};
|
|
|
|
const refreshAll = async () => {
|
|
try {
|
|
setStatus("Refreshing lists...");
|
|
const [definitions, rules] = await Promise.all([
|
|
fetchJson("/api/files/definitions"),
|
|
fetchJson("/api/rules/list"),
|
|
]);
|
|
state.definitions = definitions.files || [];
|
|
state.rules = rules.files || [];
|
|
renderLists();
|
|
setStatus("Lists refreshed.");
|
|
} catch (err) {
|
|
setStatus(err.message || "Failed to refresh.", true);
|
|
}
|
|
};
|
|
|
|
const saveCurrent = async () => {
|
|
if (!state.activeType || !state.activeFile) {
|
|
setStatus("Select a file before saving.", true);
|
|
return;
|
|
}
|
|
if (state.activeType === "definition") {
|
|
let parsed;
|
|
try {
|
|
parsed = JSON.parse(elements.editorTextarea.value);
|
|
} catch (err) {
|
|
setStatus("Invalid JSON. Fix errors before saving.", true);
|
|
return;
|
|
}
|
|
try {
|
|
await fetchJson("/api/files/content", {
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
filename: state.activeFile,
|
|
content: parsed,
|
|
}),
|
|
});
|
|
setStatus("Definition saved.");
|
|
} catch (err) {
|
|
setStatus(err.message || "Failed to save definition.", true);
|
|
}
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await fetchJson("/api/rules/content", {
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
filename: state.activeFile,
|
|
content: elements.editorTextarea.value,
|
|
}),
|
|
});
|
|
setStatus("Rule saved.");
|
|
} catch (err) {
|
|
setStatus(err.message || "Failed to save rule.", true);
|
|
}
|
|
};
|
|
|
|
const formatJson = () => {
|
|
if (state.activeType !== "definition") {
|
|
return;
|
|
}
|
|
try {
|
|
const parsed = JSON.parse(elements.editorTextarea.value);
|
|
elements.editorTextarea.value = JSON.stringify(parsed, null, 2);
|
|
setStatus("Formatted JSON.");
|
|
} catch (err) {
|
|
setStatus("Invalid JSON. Cannot format.", true);
|
|
}
|
|
};
|
|
|
|
const mergeMissing = (target, template) => {
|
|
const output = Array.isArray(target) ? [...target] : { ...target };
|
|
Object.keys(template).forEach((key) => {
|
|
const value = template[key];
|
|
if (!(key in output)) {
|
|
output[key] = value;
|
|
return;
|
|
}
|
|
const current = output[key];
|
|
if (
|
|
value &&
|
|
typeof value === "object" &&
|
|
!Array.isArray(value) &&
|
|
current &&
|
|
typeof current === "object" &&
|
|
!Array.isArray(current)
|
|
) {
|
|
output[key] = mergeMissing(current, value);
|
|
}
|
|
});
|
|
return output;
|
|
};
|
|
|
|
const insertTemplate = () => {
|
|
if (state.activeType !== "definition" || !state.activeFile) {
|
|
setStatus("Select a definition file first.", true);
|
|
return;
|
|
}
|
|
const guide = getGuideForFile(state.activeFile);
|
|
if (!guide || !guide.template) {
|
|
setStatus("No template available for this file.", true);
|
|
return;
|
|
}
|
|
let parsed;
|
|
try {
|
|
parsed = JSON.parse(elements.editorTextarea.value);
|
|
} catch (err) {
|
|
setStatus("Invalid JSON. Fix errors before inserting template.", true);
|
|
return;
|
|
}
|
|
const templateValue = guide.template();
|
|
if (guide.templateType === "array") {
|
|
if (!Array.isArray(parsed)) {
|
|
setStatus("Expected a JSON array for this definition file.", true);
|
|
return;
|
|
}
|
|
parsed.push(templateValue);
|
|
} else {
|
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
setStatus("Expected a JSON object for this definition file.", true);
|
|
return;
|
|
}
|
|
parsed = mergeMissing(parsed, templateValue);
|
|
}
|
|
elements.editorTextarea.value = JSON.stringify(parsed, null, 2);
|
|
state.currentContent = parsed;
|
|
if (state.viewMode === "structured") {
|
|
renderStructuredEditor();
|
|
}
|
|
setStatus("Template inserted.");
|
|
};
|
|
|
|
const validateLocalizedText = (value, path, issues) => {
|
|
if (!value || typeof value !== "object") {
|
|
issues.push({ level: "error", file: path, message: "LocalizedText missing." });
|
|
return;
|
|
}
|
|
if (!value.Fallback) {
|
|
issues.push({ level: "warn", file: path, message: "Fallback is empty." });
|
|
}
|
|
if (!value.Key) {
|
|
issues.push({ level: "warn", file: path, message: "Key is empty." });
|
|
}
|
|
};
|
|
|
|
const validateModifiers = (modifiers, path, issues) => {
|
|
if (!modifiers || typeof modifiers !== "object") {
|
|
issues.push({ level: "warn", file: path, message: "Modifiers missing." });
|
|
return;
|
|
}
|
|
["AttributeModifiers", "StatusModifiers", "ResourceModifiers", "RuleIds"].forEach(
|
|
(key) => {
|
|
if (key in modifiers && !Array.isArray(modifiers[key])) {
|
|
issues.push({
|
|
level: "error",
|
|
file: path,
|
|
message: `${key} should be an array.`,
|
|
});
|
|
}
|
|
},
|
|
);
|
|
};
|
|
|
|
const validateDefinitionArray = (filename, content) => {
|
|
const issues = [];
|
|
if (!Array.isArray(content)) {
|
|
issues.push({ level: "error", file: filename, message: "Expected an array." });
|
|
return issues;
|
|
}
|
|
const requiredFields = definitionGuides[filename]?.fields || [];
|
|
const ids = new Set();
|
|
content.forEach((entry, index) => {
|
|
const entryPath = `${filename}[${index}]`;
|
|
if (!entry || typeof entry !== "object") {
|
|
issues.push({ level: "error", file: entryPath, message: "Entry is not an object." });
|
|
return;
|
|
}
|
|
requiredFields.forEach((field) => {
|
|
if (!(field in entry)) {
|
|
issues.push({ level: "error", file: entryPath, message: `${field} missing.` });
|
|
}
|
|
});
|
|
const header = entry.Header;
|
|
if (!header || typeof header !== "object") {
|
|
issues.push({ level: "error", file: entryPath, message: "Header missing." });
|
|
} else {
|
|
const id = header.Id;
|
|
if (!id) {
|
|
issues.push({ level: "error", file: entryPath, message: "Header.Id missing." });
|
|
} else {
|
|
if (!idPattern.test(id)) {
|
|
issues.push({
|
|
level: "warn",
|
|
file: entryPath,
|
|
message: `Header.Id format is unusual (${id}).`,
|
|
});
|
|
}
|
|
if (ids.has(id)) {
|
|
issues.push({
|
|
level: "error",
|
|
file: entryPath,
|
|
message: `Duplicate Header.Id ${id}.`,
|
|
});
|
|
}
|
|
ids.add(id);
|
|
}
|
|
validateLocalizedText(header.Name, `${entryPath}.Header.Name`, issues);
|
|
validateLocalizedText(header.Description, `${entryPath}.Header.Description`, issues);
|
|
if (header.Tags && !Array.isArray(header.Tags)) {
|
|
issues.push({
|
|
level: "error",
|
|
file: entryPath,
|
|
message: "Header.Tags should be an array.",
|
|
});
|
|
}
|
|
}
|
|
|
|
if (entry.Tiers) {
|
|
if (!Array.isArray(entry.Tiers)) {
|
|
issues.push({ level: "error", file: entryPath, message: "Tiers should be an array." });
|
|
} else {
|
|
entry.Tiers.forEach((tier, tierIndex) => {
|
|
const tierPath = `${entryPath}.Tiers[${tierIndex}]`;
|
|
if (tier.RequiredCount == null) {
|
|
issues.push({ level: "warn", file: tierPath, message: "RequiredCount missing." });
|
|
}
|
|
validateModifiers(tier.Modifiers, `${tierPath}.Modifiers`, issues);
|
|
});
|
|
}
|
|
}
|
|
|
|
if (entry.Modifiers) {
|
|
validateModifiers(entry.Modifiers, `${entryPath}.Modifiers`, issues);
|
|
}
|
|
|
|
if (entry.Buff) {
|
|
validateLocalizedText(entry.Buff.Name, `${entryPath}.Buff.Name`, issues);
|
|
validateLocalizedText(entry.Buff.Description, `${entryPath}.Buff.Description`, issues);
|
|
validateModifiers(entry.Buff.Modifiers, `${entryPath}.Buff.Modifiers`, issues);
|
|
}
|
|
|
|
["RolePoolIds", "ItemPoolIds", "TaskKeywordIds", "AllowedDisciplineIds"].forEach(
|
|
(field) => {
|
|
if (field in entry && !Array.isArray(entry[field])) {
|
|
issues.push({
|
|
level: "error",
|
|
file: entryPath,
|
|
message: `${field} should be an array.`,
|
|
});
|
|
}
|
|
},
|
|
);
|
|
});
|
|
return issues;
|
|
};
|
|
|
|
const validateCampusBehavior = (filename, content) => {
|
|
const issues = [];
|
|
if (!content || typeof content !== "object" || Array.isArray(content)) {
|
|
issues.push({ level: "error", file: filename, message: "Expected an object." });
|
|
return issues;
|
|
}
|
|
const requiredFields = definitionGuides[filename]?.fields || [];
|
|
requiredFields.forEach((field) => {
|
|
if (!(field in content)) {
|
|
issues.push({ level: "error", file: filename, message: `${field} missing.` });
|
|
}
|
|
});
|
|
if (content.ActionConfigs && !Array.isArray(content.ActionConfigs)) {
|
|
issues.push({ level: "error", file: filename, message: "ActionConfigs should be an array." });
|
|
}
|
|
(content.ActionConfigs || []).forEach((action, index) => {
|
|
const actionPath = `${filename}.ActionConfigs[${index}]`;
|
|
["ActionId", "LocationId", "DurationSeconds"].forEach((field) => {
|
|
if (!(field in action)) {
|
|
issues.push({ level: "error", file: actionPath, message: `${field} missing.` });
|
|
}
|
|
});
|
|
[
|
|
"HungerDelta",
|
|
"EnergyDelta",
|
|
"StaminaDelta",
|
|
"StressDelta",
|
|
"MoodDelta",
|
|
"SocialDelta",
|
|
"SanityDelta",
|
|
"HealthDelta",
|
|
].forEach((field) => {
|
|
if (!(field in action)) {
|
|
issues.push({ level: "warn", file: actionPath, message: `${field} missing.` });
|
|
}
|
|
});
|
|
});
|
|
return issues;
|
|
};
|
|
|
|
const validateTres = (filename, content) => {
|
|
const issues = [];
|
|
if (!content || typeof content !== "object" || Array.isArray(content)) {
|
|
issues.push({ level: "error", file: filename, message: "Expected an object." });
|
|
return issues;
|
|
}
|
|
tresGuide.fields.forEach((field) => {
|
|
if (!(field in content)) {
|
|
issues.push({ level: "warn", file: filename, message: `${field} missing.` });
|
|
}
|
|
});
|
|
if (content.Id && !idPattern.test(content.Id)) {
|
|
issues.push({
|
|
level: "warn",
|
|
file: filename,
|
|
message: `Id format is unusual (${content.Id}).`,
|
|
});
|
|
}
|
|
if (content.Tags && !Array.isArray(content.Tags)) {
|
|
issues.push({ level: "error", file: filename, message: "Tags should be an array." });
|
|
}
|
|
return issues;
|
|
};
|
|
|
|
const validateDefinitionContent = (filename, content) => {
|
|
if (filename === "campus_behavior.json") {
|
|
return validateCampusBehavior(filename, content);
|
|
}
|
|
if (filename.endsWith(".tres")) {
|
|
return validateTres(filename, content);
|
|
}
|
|
return validateDefinitionArray(filename, content);
|
|
};
|
|
|
|
const extractDefinitionIds = (filename, content) => {
|
|
if (filename.endsWith(".tres") && content && typeof content === "object") {
|
|
return content.Id ? [content.Id] : [];
|
|
}
|
|
if (!Array.isArray(content)) {
|
|
return [];
|
|
}
|
|
return content
|
|
.map((entry) => entry?.Header?.Id)
|
|
.filter((value) => typeof value === "string" && value.length > 0);
|
|
};
|
|
|
|
const validateFile = () => {
|
|
if (state.activeType !== "definition" || !state.activeFile) {
|
|
setStatus("Select a definition file to validate.", true);
|
|
return;
|
|
}
|
|
let parsed;
|
|
try {
|
|
parsed = JSON.parse(elements.editorTextarea.value);
|
|
} catch (err) {
|
|
setStatus("Invalid JSON. Fix errors before validating.", true);
|
|
return;
|
|
}
|
|
const issues = validateDefinitionContent(state.activeFile, parsed);
|
|
renderValidation(issues);
|
|
setStatus(issues.length ? "Validation complete with issues." : "Validation passed.");
|
|
};
|
|
|
|
const validateAll = async () => {
|
|
if (!state.definitions.length) {
|
|
await refreshAll();
|
|
}
|
|
const issues = [];
|
|
const idIndex = new Map();
|
|
for (const file of state.definitions) {
|
|
try {
|
|
const response = await fetchJson(
|
|
`/api/files/content?filename=${encodeURIComponent(file)}`,
|
|
);
|
|
const content = response.content;
|
|
validateDefinitionContent(file, content).forEach((issue) => issues.push(issue));
|
|
extractDefinitionIds(file, content).forEach((id) => {
|
|
if (!idIndex.has(id)) {
|
|
idIndex.set(id, []);
|
|
}
|
|
idIndex.get(id).push(file);
|
|
});
|
|
} catch (err) {
|
|
issues.push({ level: "error", file, message: err.message || "Failed to load file." });
|
|
}
|
|
}
|
|
idIndex.forEach((files, id) => {
|
|
if (files.length > 1) {
|
|
issues.push({
|
|
level: "error",
|
|
file: files.join(", "),
|
|
message: `Duplicate Id across files: ${id}`,
|
|
});
|
|
}
|
|
});
|
|
renderValidation(issues);
|
|
setStatus(issues.length ? "Validation complete with issues." : "Validation passed.");
|
|
};
|
|
|
|
const createRule = async () => {
|
|
const ruleId = elements.newRuleInput.value.trim();
|
|
if (!ruleId) {
|
|
setStatus("Enter a rule id first.", true);
|
|
return;
|
|
}
|
|
try {
|
|
const response = await fetchJson("/api/rules/create", {
|
|
method: "POST",
|
|
body: JSON.stringify({ rule_id: ruleId }),
|
|
});
|
|
elements.newRuleInput.value = "";
|
|
await refreshAll();
|
|
if (response.filename) {
|
|
await openRule(response.filename);
|
|
}
|
|
setStatus("Rule created.");
|
|
} catch (err) {
|
|
setStatus(err.message || "Failed to create rule.", true);
|
|
}
|
|
};
|
|
|
|
const buildGame = async () => {
|
|
try {
|
|
elements.buildOutput.textContent = "Running build...";
|
|
const response = await fetchJson("/api/build", { method: "POST" });
|
|
const output = [response.stdout, response.stderr].filter(Boolean).join("\n");
|
|
elements.buildOutput.textContent = output || "Build finished.";
|
|
setStatus(response.success ? "Build complete." : "Build completed with errors.");
|
|
} catch (err) {
|
|
elements.buildOutput.textContent = "Build failed to start.";
|
|
setStatus(err.message || "Build failed.", true);
|
|
}
|
|
};
|
|
|
|
const changeEntrySelection = () => {
|
|
const index = Number(elements.entrySelect.value);
|
|
if (Number.isNaN(index)) {
|
|
return;
|
|
}
|
|
state.activeEntryIndex = index;
|
|
renderStructuredEditor();
|
|
};
|
|
|
|
const addEntry = () => {
|
|
if (!Array.isArray(state.currentContent)) {
|
|
return;
|
|
}
|
|
const guide = getGuideForFile(state.activeFile || "");
|
|
const template = guide?.template ? guide.template() : { Header: { Id: "" } };
|
|
state.currentContent.push(template);
|
|
state.activeEntryIndex = state.currentContent.length - 1;
|
|
syncRawFromContent();
|
|
renderStructuredEditor();
|
|
};
|
|
|
|
const deleteEntry = () => {
|
|
if (!Array.isArray(state.currentContent)) {
|
|
return;
|
|
}
|
|
state.currentContent.splice(state.activeEntryIndex, 1);
|
|
state.activeEntryIndex = Math.max(0, state.activeEntryIndex - 1);
|
|
syncRawFromContent();
|
|
renderStructuredEditor();
|
|
};
|
|
|
|
const init = () => {
|
|
elements.refreshButton.addEventListener("click", refreshAll);
|
|
elements.filterInput.addEventListener("input", renderLists);
|
|
elements.saveButton.addEventListener("click", saveCurrent);
|
|
elements.formatButton.addEventListener("click", formatJson);
|
|
elements.createRuleButton.addEventListener("click", createRule);
|
|
elements.buildButton.addEventListener("click", buildGame);
|
|
elements.insertTemplateButton.addEventListener("click", insertTemplate);
|
|
elements.validateFileButton.addEventListener("click", validateFile);
|
|
elements.validateAllButton.addEventListener("click", validateAll);
|
|
elements.viewRawButton.addEventListener("click", () => setViewMode("raw"));
|
|
elements.viewStructuredButton.addEventListener("click", () => setViewMode("structured"));
|
|
elements.entrySelect.addEventListener("change", changeEntrySelection);
|
|
elements.addEntryButton.addEventListener("click", addEntry);
|
|
elements.deleteEntryButton.addEventListener("click", deleteEntry);
|
|
refreshAll();
|
|
setGuideState({ mode: null, filename: null, content: null });
|
|
setViewMode("raw", { skipRender: true });
|
|
setStatus("Ready.");
|
|
};
|
|
|
|
init();
|