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