supervisor-simulator/tools/ndbs/server/web/app.js
2026-01-18 12:38:03 +08:00

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();