supervisor-simulator/tools/ndbs/server/web/app.js
2026-01-17 20:59:45 +08:00

281 lines
8.5 KiB
JavaScript

const state = {
definitions: [],
rules: [],
activeType: null,
activeFile: null,
};
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"),
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"),
};
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 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);
};
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");
};
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;
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);
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;
renderLists();
setEditorState({
title: filename,
subtitle: "C# rule source",
content: response.content || "",
mode: "rule",
});
clearSchema();
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 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 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);
refreshAll();
setStatus("Ready.");
};
init();