From 6e7c0b3ec4253bb58a815979b1f4574d08cb1f53 Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Fri, 10 Jul 2026 00:43:29 -0400 Subject: feat(admin): add triggers to rooms, add global triggers --- internal/admin/api_triggers.go | 335 +++++++++++++++++++++++ internal/admin/server.go | 2 + internal/admin/static/admin.css | 4 +- internal/admin/static/cardeditor.js | 5 +- internal/admin/static/map.js | 184 ++++--------- internal/admin/static/mobeditor.js | 38 +-- internal/admin/static/triggerseditor.js | 463 ++++++++++++++++++++++++++++++++ internal/admin/templates/layout.html | 2 + internal/admin/templates/triggers.html | 19 ++ 9 files changed, 902 insertions(+), 150 deletions(-) create mode 100644 internal/admin/api_triggers.go create mode 100644 internal/admin/static/triggerseditor.js create mode 100644 internal/admin/templates/triggers.html (limited to 'internal/admin') diff --git a/internal/admin/api_triggers.go b/internal/admin/api_triggers.go new file mode 100644 index 0000000..b2fecbc --- /dev/null +++ b/internal/admin/api_triggers.go @@ -0,0 +1,335 @@ +package admin + +import ( + "encoding/json" + "fmt" + "net/http" + "os" + "path/filepath" + "strings" + + "gopkg.in/yaml.v3" + + "thehouseoficarus/internal/behavior" +) + +type triggerEntry struct { + ID string `json:"id"` + Kind string `json:"kind"` +} + +func (s *AdminServer) handleTriggers(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + entries, err := listTriggerEntries(s.dataDir) + if err != nil { + writeJSON(w, map[string]any{"error": err.Error()}) + return + } + if entries == nil { + entries = []triggerEntry{} + } + writeJSON(w, entries) + case http.MethodPost: + var m map[string]any + if err := readJSON(r, &m); err != nil { + writeJSON(w, map[string]any{"error": "invalid json"}) + return + } + id, ok := m["id"].(string) + if !ok || strings.TrimSpace(id) == "" { + writeJSON(w, map[string]any{"error": "missing id"}) + return + } + delete(m, "id") + + kind := triggerKind(m) + subdir := kindToSubdir(kind) + path := filepath.Join(s.dataDir, "triggers", subdir, id+".yaml") + os.MkdirAll(filepath.Dir(path), 0755) + if _, err := os.Stat(path); err == nil { + writeJSON(w, map[string]any{"error": "trigger already exists"}) + return + } + + newContent, err := writeMapAsYAML(path, m) + if err != nil { + writeJSON(w, map[string]any{"error": "write error: " + err.Error()}) + return + } + s.undoStack.Push(ChangeDesc{ + Description: "Created trigger " + id, + FilePath: path, + NewContent: newContent, + IsCreate: true, + }) + writeJSON(w, map[string]any{"ok": true, "id": id, "kind": kind}) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +func (s *AdminServer) handleTriggerByID(w http.ResponseWriter, r *http.Request) { + id := strings.TrimPrefix(r.URL.Path, "/api/triggers/") + if id == "" { + http.Error(w, `{"error":"missing id"}`, http.StatusBadRequest) + return + } + + switch r.Method { + case http.MethodGet: + path, _, err := findTriggerFile(s.dataDir, id) + if err != nil { + writeJSON(w, map[string]any{"error": "not found: " + id}) + return + } + data, err := os.ReadFile(path) + if err != nil { + writeJSON(w, map[string]any{"error": "not found: " + id}) + return + } + raw, err := yamlToMap(data) + if err != nil { + writeJSON(w, map[string]any{"error": "parse error: " + err.Error()}) + return + } + raw["id"] = id + raw["_path"] = path + raw["_raw"] = string(data) + raw["_kind"] = triggerKind(raw) + writeJSON(w, raw) + + case http.MethodPut: + var body map[string]any + if err := readJSON(r, &body); err != nil { + writeJSON(w, map[string]any{"error": "invalid JSON: " + err.Error()}) + return + } + delete(body, "id") + delete(body, "_path") + delete(body, "_raw") + + oldPath, oldDir, findErr := findTriggerFile(s.dataDir, id) + newKind := triggerKind(body) + newSubdir := kindToSubdir(newKind) + newPath := filepath.Join(s.dataDir, "triggers", newSubdir, id+".yaml") + + var oldContent []byte + if existing, err := snapshotFile(oldPath); err == nil { + oldContent = existing + } + + // If the kind changed, ensure the target directory exists and delete + // the old path after writing the new one. + if findErr == nil && newSubdir != oldDir { + os.MkdirAll(filepath.Dir(newPath), 0755) + } + + triggerData, err := triggerMapToYAML(body) + if err != nil { + writeJSON(w, map[string]any{"error": "serialize error: " + err.Error()}) + return + } + + if err := os.WriteFile(newPath, triggerData, 0644); err != nil { + writeJSON(w, map[string]any{"error": "write error: " + err.Error()}) + return + } + + if findErr == nil && newSubdir != oldDir { + os.Remove(oldPath) + } + + s.undoStack.Push(ChangeDesc{ + Description: "Update trigger " + id, + FilePath: newPath, + OldContent: oldContent, + NewContent: triggerData, + }) + s.rebuildTriggerIndex() + writeJSON(w, map[string]any{"ok": true, "id": id, "kind": newKind}) + + case http.MethodDelete: + path, _, err := findTriggerFile(s.dataDir, id) + if err != nil { + writeJSON(w, map[string]any{"error": "not found: " + id}) + return + } + oldContent, err := snapshotFile(path) + if err != nil { + writeJSON(w, map[string]any{"error": "not found: " + id}) + return + } + if err := os.Remove(path); err != nil { + writeJSON(w, map[string]any{"error": "delete error: " + err.Error()}) + return + } + s.undoStack.Push(ChangeDesc{ + Description: "Delete trigger " + id, + FilePath: path, + OldContent: oldContent, + }) + s.rebuildTriggerIndex() + writeJSON(w, map[string]any{"ok": true}) + + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +// triggerKind returns "player", "global", or "" based on the map content. +func triggerKind(m map[string]any) string { + if _, ok := m["on_player_flag"]; ok { + if s, is := m["on_player_flag"].(string); is && s != "" { + return "player" + } + } + if _, ok := m["on_global_flag"]; ok { + if s, is := m["on_global_flag"].(string); is && s != "" { + return "global" + } + } + // Fallback: if neither is present, return empty. + return "" +} + +func kindToSubdir(kind string) string { + if kind == "global" { + return "global_flag" + } + return "player_flag" +} + +func listTriggerEntries(dataDir string) ([]triggerEntry, error) { + root := filepath.Join(dataDir, "triggers") + if _, err := os.Stat(root); os.IsNotExist(err) { + return []triggerEntry{}, nil + } + var entries []triggerEntry + var walk func(p string) error + walk = func(p string) error { + dirs, err := os.ReadDir(p) + if err != nil { + return err + } + for _, entry := range dirs { + full := filepath.Join(p, entry.Name()) + if entry.IsDir() { + if err := walk(full); err != nil { + return err + } + continue + } + ext := filepath.Ext(entry.Name()) + if ext != ".yaml" && ext != ".yml" { + continue + } + id := strings.TrimSuffix(entry.Name(), ext) + // Determine kind from the parent directory name. + parent := filepath.Base(filepath.Dir(full)) + kind := "" + if parent == "global_flag" { + kind = "global" + } else if parent == "player_flag" { + kind = "player" + } + entries = append(entries, triggerEntry{ID: id, Kind: kind}) + } + return nil + } + if err := walk(root); err != nil { + return nil, err + } + return entries, nil +} + +// findTriggerFile searches for a trigger file in the triggers directory tree +// and returns its full path and the parent directory name (relative to the +// triggers root). Searches player_flag/ then global_flag/ then root. +func findTriggerFile(dataDir, id string) (path string, dir string, err error) { + root := filepath.Join(dataDir, "triggers") + for _, sub := range []string{"player_flag", "global_flag", ""} { + var p string + if sub == "" { + p = filepath.Join(root, id+".yaml") + } else { + p = filepath.Join(root, sub, id+".yaml") + } + if _, statErr := os.Stat(p); statErr == nil { + return p, sub, nil + } + p2 := filepath.Join(root, sub, id+".yml") + if _, statErr := os.Stat(p2); statErr == nil { + return p2, sub, nil + } + } + return "", "", fmt.Errorf("trigger %q not found", id) +} + +// triggerMapToYAML serializes a flat map into a Trigger YAML document. +// The map contains keys like on_player_flag, on_global_flag, value, +// lock, condition, steps, etc. +func triggerMapToYAML(m map[string]any) ([]byte, error) { + var t behavior.Trigger + + // Handle steps specially — need to convert from []any to []behavior.Step. + if rawSteps, ok := m["steps"]; ok { + delete(m, "steps") + stepsJSON, err := json.Marshal(rawSteps) + if err != nil { + return nil, fmt.Errorf("marshal steps: %w", err) + } + var steps []behavior.Step + if err := json.Unmarshal(stepsJSON, &steps); err != nil { + return nil, fmt.Errorf("unmarshal steps: %w", err) + } + t.Steps = steps + } else { + t.Steps = []behavior.Step{} + } + + // Handle condition. + if rawCond, ok := m["condition"]; ok { + delete(m, "condition") + condJSON, err := json.Marshal(rawCond) + if err == nil { + var cond behavior.Condition + if err := json.Unmarshal(condJSON, &cond); err == nil { + t.Condition = &cond + } + } + } + + // Handle simple fields. + if v, ok := m["on_player_flag"]; ok { + if s, ok := v.(string); ok { + t.OnPlayerFlag = s + } + delete(m, "on_player_flag") + } + if v, ok := m["on_global_flag"]; ok { + if s, ok := v.(string); ok { + t.OnGlobalFlag = s + } + delete(m, "on_global_flag") + } + if v, ok := m["lock"]; ok { + if b, ok := v.(bool); ok { + t.Lock = b + } + delete(m, "lock") + } + if v, ok := m["value"]; ok { + t.Value = v + delete(m, "value") + } + + // Serialize to YAML. + yamlBytes, err := yaml.Marshal(t) + if err != nil { + return nil, fmt.Errorf("yaml marshal: %w", err) + } + return yamlBytes, nil +} + +func (s *AdminServer) rebuildTriggerIndex() {} diff --git a/internal/admin/server.go b/internal/admin/server.go index 64c5592..f07bdd4 100644 --- a/internal/admin/server.go +++ b/internal/admin/server.go @@ -179,6 +179,8 @@ func NewServer(cfg *config.Config, useTLS bool, accountStore *player.AccountStor apiMux.HandleFunc("/api/files", s.handleFiles) apiMux.HandleFunc("/api/duplicate-rooms", s.handleDuplicateRooms) apiMux.HandleFunc("/api/rooms/resolve-duplicate", s.handleResolveDuplicate) + apiMux.HandleFunc("/api/triggers", s.handleTriggers) + apiMux.HandleFunc("/api/triggers/", s.handleTriggerByID) mux.Handle("/api/", s.authMiddleware(apiMux)) diff --git a/internal/admin/static/admin.css b/internal/admin/static/admin.css index a5c3497..896ea1a 100644 --- a/internal/admin/static/admin.css +++ b/internal/admin/static/admin.css @@ -457,8 +457,8 @@ g.ud-hover:hover text{font-weight:bold} .ie-step-messages{margin:2px 0} .ie-step-addbar{display:none;flex-wrap:wrap;gap:3px;margin-top:2px} .ie-step-row.show-addbar .ie-step-addbar{display:flex} -.ie-step-addbar-bottom{display:grid;grid-template-columns:repeat(5,1fr);gap:4px;margin-top:6px} -.ie-step-addbar-bottom .btn{width:100%;box-sizing:border-box;text-align:center;padding:4px 10px;font-size:11px} +.ie-step-addbar-bottom{display:flex;flex-wrap:wrap;gap:4px;margin-top:6px} +.ie-step-addbar-bottom .btn{flex:0 0 auto;padding:4px 10px;font-size:11px} /* ── Message rows within a step ── */ .ie-msg-row{display:flex;align-items:center;gap:4px} diff --git a/internal/admin/static/cardeditor.js b/internal/admin/static/cardeditor.js index e430e1d..1e8d259 100644 --- a/internal/admin/static/cardeditor.js +++ b/internal/admin/static/cardeditor.js @@ -92,8 +92,9 @@ function ced_addKVRow(cardID, subKey) { // ---- Search autocomplete ---- -function ced_setupSearchFields() { - document.querySelectorAll('.search-input').forEach(function(input) { +function ced_setupSearchFields(root) { + root = root || document; + root.querySelectorAll('.search-input').forEach(function(input) { if (input._searchSetup) return; input._searchSetup = true; var entityType = input.getAttribute('data-search-type') || 'items'; diff --git a/internal/admin/static/map.js b/internal/admin/static/map.js index ab20a86..7c47b4e 100644 --- a/internal/admin/static/map.js +++ b/internal/admin/static/map.js @@ -26,6 +26,7 @@ var oppositeDir = { up:'down', down:'up' }; var saveTimer = null; +var _triggersDirty = false; var upTarget = {}, downTarget = {}; var _mapMouseCleanup = false; @@ -530,6 +531,7 @@ function updateView() { function selectRoom(id) { if (saveTimer) { clearTimeout(saveTimer); saveTimer = null; } + _triggersDirty = false; var prev = selectedRoom; selectedRoom = id; multiSelectedIds.clear(); @@ -575,8 +577,7 @@ function renderSidePanel(data) { html += ''; html += ''; html += ''; - html += ''; - html += ''; + html += ''; html += ''; html += '
'; @@ -598,11 +599,14 @@ function renderSidePanel(data) { } } else if (currentPanelTab === 8) html += renderEnterExitTab(r); - else if (currentPanelTab === 9) html += renderTriggersTab(r); html += '
'; panel.innerHTML = html; setTimeout(function() { + if (currentPanelTab >= 8) { + var content = panel.querySelector('.panel-tab-content'); + if (content) { bindTriggerInputSave(content); ced_setupSearchFields(content); } + } var cf = document.querySelector('#panelContent .color-field'); if (cf) { bindColorField(cf); @@ -1406,9 +1410,9 @@ function switchPanelTab(idx) { } } else if (idx === 8) html = renderEnterExitTab(r); - else if (idx === 9) html = renderTriggersTab(r); var content = document.querySelector('.panel-tab-content'); if (content) content.innerHTML = html; + if (idx >= 8) { setTimeout(function() { bindTriggerInputSave(content); ced_setupSearchFields(content); }, 10); } var tabs = document.querySelectorAll('.panel-tab'); tabs.forEach(function(t, i) { t.classList.toggle('active', i === idx); }); if (idx === 0) { @@ -2076,6 +2080,43 @@ function autoSave() { saveTimer = setTimeout(function() { doSavePanel(); }, 600); } +function bindTriggerInputSave(container) { + var inputs = container.querySelectorAll('input:not([type="checkbox"]):not([type="radio"]), textarea'); + inputs.forEach(function(el) { + el.removeEventListener('input', _triggerInputHandler); + el.addEventListener('input', _triggerInputHandler); + }); + var checks = container.querySelectorAll('input[type="checkbox"], input[type="radio"]'); + checks.forEach(function(el) { + el.removeEventListener('change', _triggerInputHandler); + el.addEventListener('change', _triggerInputHandler); + }); +} + +function _syncTriggersToData() { + if (!window._editorData || !window._panelTriggerSecs) return; + ced_syncDOMToData(window._editorData); + if (typeof ie_syncAllInteractions === 'function') { + ie_syncAllInteractions(window._editorData, window._panelTriggerSecs); + } + if (currentRoomData && currentRoomData.room) { + window._panelTriggerSecs.forEach(function(sec) { + currentRoomData.room[sec.path] = window._editorData[sec.path]; + }); + } +} + +function _triggerInputHandler() { + _syncTriggersToData(); + _triggersDirty = true; +} + +function saveTriggers() { + _syncTriggersToData(); + _triggersDirty = false; + saveNow(); +} + function saveNow() { if (window._colorPickerActive) return; if (saveTimer) clearTimeout(saveTimer); @@ -2104,8 +2145,6 @@ async function doSavePanel() { r.hazard = currentRoomData.room.hazard || ''; r.on_enter = currentRoomData.room.on_enter; r.on_exit = currentRoomData.room.on_exit; - r.on_flag_change = currentRoomData.room.on_flag_change; - r.on_global_flag_change = currentRoomData.room.on_global_flag_change; if (r.block_transport === undefined) r.block_transport = currentRoomData.room.block_transport; } @@ -2126,6 +2165,7 @@ async function doSavePanel() { await API.put('/api/rooms/' + id, r); notify('Room #' + id + ' saved', 'success'); + _triggersDirty = false; updateUndoBar(); await loadMap(); selectRoom(id); @@ -3382,51 +3422,26 @@ function renderEnterExitTab(r) { currentRoomData.room.on_exit = window._editorData.on_exit; } switchPanelTab(8); - autoSave(); + _triggersDirty = true; }; window._ieRenderOnly = window._ieRenderCurrent; window._ieSyncAll = ie_syncAllInteractions; + window._panelTriggerSecs = [ + {id: 'on_enter', path: 'on_enter', isArray: true, interactionStyle: true, itemIDAllowed: false, label: 'Trigger'}, + {id: 'on_exit', path: 'on_exit', isArray: true, interactionStyle: true, itemIDAllowed: false, label: 'Trigger'} + ]; - var h = '

On-Enter Triggers

'; + var h = '
'; + h += '

On-Enter Triggers

'; h += '

Fires when a player enters this room. First matching entry wins.

'; h += renderTriggerListHTML(r.on_enter, 'on_enter', 'on_enter', false); - h += '

On-Exit Triggers

'; + h += '
'; + h += '

On-Exit Triggers

'; h += '

Fires when a player leaves this room. First matching entry wins.

'; h += renderTriggerListHTML(r.on_exit, 'on_exit', 'on_exit', false); return h; } -// ── Triggers tab (on_flag_change / on_global_flag_change) ── - -function renderTriggersTab(r) { - var fc = Array.isArray(r.on_flag_change) ? r.on_flag_change : []; - var gfc = Array.isArray(r.on_global_flag_change) ? r.on_global_flag_change : []; - window._editorData = { on_flag_change: fc, on_global_flag_change: gfc }; - - window._ieRenderCurrent = function() { - if (window._ieSyncAll) window._ieSyncAll(window._editorData, [ - {id: 'on_flag_change', path: 'on_flag_change', isArray: true, interactionStyle: true, itemIDAllowed: false, label: 'Trigger'}, - {id: 'on_global_flag_change', path: 'on_global_flag_change', isArray: true, interactionStyle: true, itemIDAllowed: false, label: 'Trigger'} - ]); - if (currentRoomData && currentRoomData.room) { - currentRoomData.room.on_flag_change = window._editorData.on_flag_change; - currentRoomData.room.on_global_flag_change = window._editorData.on_global_flag_change; - } - switchPanelTab(9); - autoSave(); - }; - window._ieRenderOnly = window._ieRenderCurrent; - window._ieSyncAll = ie_syncAllInteractions; - - var h = '

On-Flag-Change Triggers

'; - h += '

Fires when a player flag changes. Use the condition\'s "In Room" gate to scope to this room (delete it to make global).

'; - h += renderFlagTriggerListHTML(r.on_flag_change, 'on_flag_change', 'on_flag_change', 'player'); - h += '

On-Global-Flag-Change Triggers

'; - h += '

Fires when a global flag changes (no player involved).

'; - h += renderFlagTriggerListHTML(r.on_global_flag_change, 'on_global_flag_change', 'on_global_flag_change', 'global'); - return h; -} - // renderTriggerListHTML emits the HTML for a []Trigger block using the shared // ie_renderArraySection. It's a thin shim that builds the ctx the card // editors construct. @@ -3469,61 +3484,10 @@ function renderTriggerListHTML(arr, secId, secPath, allowItem) { return '_mapAddTrigger(\'' + secPath + '\')'; } }; - return ie_renderArraySection(ctx); -} - -// renderFlagTriggerListHTML is like renderTriggerListHTML but adds the -// on_player_flag / on_global_flag subscription header per entry. -function renderFlagTriggerListHTML(arr, secId, secPath, kind) { - var visMap = {}; - arr = Array.isArray(arr) ? arr : []; - var sec = { - id: secId, - path: secPath, - isArray: true, - interactionStyle: true, - itemIDAllowed: false, - label: 'Trigger', - fields: [] - }; - var flagKey = kind === 'player' ? 'on_player_flag' : 'on_global_flag'; - - var h = ''; - arr.forEach(function(item, idx) { - visMap[idx] = visMap[idx] || {}; - var condExists = !!(item.condition && typeof item.condition === 'object'); - var lockVal = !!item.lock; - var steps = item.steps || []; - var flagName = item[flagKey] || ''; - - h += '
'; - h += '
'; - h += ''; - if (idx > 0) h += ''; - if (idx < arr.length - 1) h += ''; - h += '' + esc(kind === 'player' ? 'Player Flag' : 'Global Flag') + ''; - h += ''; - h += '='; - h += ''; - h += ''; - if (!condExists) { - h += ''; - } - h += ''; - h += '
'; - h += '
'; - h += '
' + ie_renderCond(item.condition || null, secId, idx, secPath, 'entry') + '
'; - h += '
'; - h += '
'; - h += ie_renderStepList(steps, secId, idx, secPath); - h += '
'; - h += '
'; - }); - h += ''; - return h; + return '
' + ie_renderArraySection(ctx) + '
'; } -// _mapAddTrigger / _mapRemoveTrigger / _mapAddFlagTrigger: mutation helpers +// _mapAddTrigger / _mapRemoveTrigger: mutation helpers // called from the inline onclick handlers. They sync the DOM, splice the // _editorData array, and re-render. function _mapAddTrigger(secPath) { @@ -3537,18 +3501,6 @@ function _mapAddTrigger(secPath) { window._ieRenderCurrent(); } -function _mapAddFlagTrigger(secPath, flagKey) { - if (!window._editorData) return; - if (!window._editorData[secPath]) window._editorData[secPath] = []; - var entry = {steps: []}; - entry[flagKey] = ''; - window._editorData[secPath].push(entry); - if (currentRoomData && currentRoomData.room) { - currentRoomData.room[secPath] = window._editorData[secPath]; - } - window._ieRenderCurrent(); -} - function _mapRemoveTrigger(secPath, idx) { if (!window._editorData || !window._editorData[secPath]) return; window._editorData[secPath].splice(idx, 1); @@ -3557,25 +3509,3 @@ function _mapRemoveTrigger(secPath, idx) { } window._ieRenderCurrent(); } - -function _mapSyncFlag(secPath, idx, flagKey) { - if (!window._editorData || !window._editorData[secPath]) return; - var row = document.querySelector('.ie-inter-row[data-idx="' + idx + '"]'); - if (!row) return; - var nameEl = row.querySelector('.ie-flag-name'); - var valEl = row.querySelector('.ie-flag-value'); - if (nameEl) { - window._editorData[secPath][idx][flagKey] = nameEl.value.trim(); - } - if (valEl) { - var v = valEl.value.trim(); - if (v === '') delete window._editorData[secPath][idx].value; - else { - try { window._editorData[secPath][idx].value = JSON.parse(v); } catch(e) { window._editorData[secPath][idx].value = v; } - } - } - if (currentRoomData && currentRoomData.room) { - currentRoomData.room[secPath] = window._editorData[secPath]; - } - autoSave(); -} diff --git a/internal/admin/static/mobeditor.js b/internal/admin/static/mobeditor.js index 463af0a..183aef9 100644 --- a/internal/admin/static/mobeditor.js +++ b/internal/admin/static/mobeditor.js @@ -129,10 +129,10 @@ var MOB_SECTIONS = [ }, ]; -function mobCardPath(sec) { return ced_cardPath(sec); } -function mobGetVal(data, sec, key) { return ced_getVal(data, sec, key); } -function mobFieldID(sec, key, idx) { return ced_fieldID(sec, key, idx); } -function mobFieldIDSub(sec, subKey, idx, fieldKey) { return ced_fieldIDSub(sec, subKey, idx, fieldKey); } +function cardPath(sec) { return ced_cardPath(sec); } +function getVal(data, sec, key) { return ced_getVal(data, sec, key); } +function fieldID(sec, key, idx) { return ced_fieldID(sec, key, idx); } +function fieldIDSub(sec, subKey, idx, fieldKey) { return ced_fieldIDSub(sec, subKey, idx, fieldKey); } function initMobEditor() { window._ieRenderCurrent = renderCurrent; @@ -418,7 +418,7 @@ function renderMobCombatCard(data) { function setupMobProtectedToggle() { var sec = MOB_SECTIONS.find(function(s) { return s.id === 'core'; }); if (!sec) return; - var protEl = document.getElementById(mobFieldID(sec, 'protected', -1)); + var protEl = document.getElementById(fieldID(sec, 'protected', -1)); if (!protEl) return; function apply() { var combatCard = document.querySelector('.obj-card[data-card="combat"]'); @@ -434,7 +434,7 @@ function setupMobProtectedToggle() { } function renderMobField(sec, f, data, idx, subPath, optStyle) { - return ced_renderField(sec, f, data, idx, subPath, optStyle, mobCardPath, mobFieldID, mobGetVal); + return ced_renderField(sec, f, data, idx, subPath, optStyle, cardPath, fieldID, getVal); } // ---- Interaction-style array rendering (On Kill) ---- @@ -519,14 +519,14 @@ function mobHideInterField(secId, idx, key) { // ---- Subtable rendering ---- function renderMobSubField(sec, stKey, idx, f, val) { - var fid = mobFieldIDSub(sec, stKey, idx, f[0]); + var fid = fieldIDSub(sec, stKey, idx, f[0]); val = val === undefined || val === null ? '' : val; var extraCls = ''; if (f[4] === 'narrow') extraCls = ' obj-narrow'; else if (f[4] === 'grow') extraCls = ' obj-grow'; var defaultPh = ''; if (f[3]) defaultPh = ' placeholder="' + escAttr(f[3]) + '"'; - var dp = mobCardPath(sec) + '.' + stKey + '[' + idx + '].' + f[0]; + var dp = cardPath(sec) + '.' + stKey + '[' + idx + '].' + f[0]; var dpAttr = ' data-field-path="' + escAttr(dp) + '"'; if (f[2] === 'search') { var entity = f[6] || 'items'; @@ -540,7 +540,7 @@ function renderMobSubField(sec, stKey, idx, f, val) { } function renderMobSubtable(sec, st, data) { - var p = mobCardPath(sec); + var p = cardPath(sec); var sectionRoot = p ? (mobData[p] || {}) : mobData; var raw = sectionRoot[st.key]; var arr; @@ -630,9 +630,9 @@ function renderMobTableSubtable(sec, st, arr) { arr.forEach(function(item, idx) { h += '
'; st.fields.forEach(function(f) { - var fid = mobFieldIDSub(sec, st.key, idx, f[0]); + var fid = fieldIDSub(sec, st.key, idx, f[0]); var val = item[f[0]]; - var dp = mobCardPath(sec) + '.' + st.key + '[' + idx + '].' + f[0]; + var dp = cardPath(sec) + '.' + st.key + '[' + idx + '].' + f[0]; if (f[2] === 'search') { var entity = f[6] || 'items'; var sval = (val === undefined || val === null) ? '' : String(val); @@ -734,7 +734,7 @@ function mobAddSection() { function addMobSubRow(cardID, subKey) { var sec = MOB_SECTIONS.find(function(s) { return s.id === cardID; }); if (!sec) return; - ced_addSubRow(sec, subKey, mobData, mobCardPath); + ced_addSubRow(sec, subKey, mobData, cardPath); renderCurrent(); } @@ -742,14 +742,14 @@ function removeMobSubRow(cardID, subKey, idx) { var sec = MOB_SECTIONS.find(function(s) { return s.id === cardID; }); if (!sec) return; ced_syncDOMToData(mobData); - ced_removeSubRow(sec, subKey, idx, mobData, mobCardPath); + ced_removeSubRow(sec, subKey, idx, mobData, cardPath); renderMobEditor(mobID, mobData); } function addMobLootRow(cardID, subKey, kind) { var sec = MOB_SECTIONS.find(function(s) { return s.id === cardID; }); if (!sec) return; - var p = mobCardPath(sec); + var p = cardPath(sec); var root = p ? (mobData[p] || (mobData[p] = {})) : mobData; if (!Array.isArray(root[subKey])) root[subKey] = []; if (kind === 'table') { @@ -779,7 +779,7 @@ function unflattenObj(obj) { // ---- Save ---- function saveMob() { - if (!ced_validateFields(MOB_SECTIONS, mobFieldID, mobFieldIDSub)) return; + if (!ced_validateFields(MOB_SECTIONS, fieldID, fieldIDSub)) return; ie_syncAllInteractions(mobData, MOB_SECTIONS); @@ -800,7 +800,7 @@ function saveMob() { var item = {}; sec.fields.forEach(function(f) { if (f[2] === 'inter_cond' || f[2] === 'inter_act') return; - var fid = mobFieldID(sec, f[0], idx); + var fid = fieldID(sec, f[0], idx); var el = document.getElementById(fid); if (el) item[f[0]] = ced_collectFieldValue(el, f[2]); }); @@ -826,7 +826,7 @@ function saveMob() { var hasValues = sec.always; if (sec.fields) { - hasValues = ced_saveSectionFields(sec, sectionObj, mobCardPath, mobFieldID) || hasValues; + hasValues = ced_saveSectionFields(sec, sectionObj, cardPath, fieldID) || hasValues; } if (sec.id === 'combat' && document.querySelector('input[name="mob_atktype_melee"]')) { @@ -836,14 +836,14 @@ function saveMob() { if (sec.subtables) { sec.subtables.forEach(function(st) { - if (ced_saveSubtable(sec, st, sectionObj, mobCardPath, mobFieldIDSub)) hasValues = true; + if (ced_saveSubtable(sec, st, sectionObj, cardPath, fieldIDSub)) hasValues = true; }); } if (sec.inline) { sec.inline.forEach(function(il) { if (il.type === 'tags') { - var fid = mobFieldID(sec, il.key); + var fid = fieldID(sec, il.key); var el = document.getElementById(fid); if (el && el.value.trim()) { sectionObj[il.key] = el.value.split(',').map(function(s) { return s.trim(); }).filter(function(s) { return s; }); diff --git a/internal/admin/static/triggerseditor.js b/internal/admin/static/triggerseditor.js new file mode 100644 index 0000000..29759eb --- /dev/null +++ b/internal/admin/static/triggerseditor.js @@ -0,0 +1,463 @@ +var triggerData = null; +var triggerID = null; +var triggerKind = ''; + +function initTriggerEditor() { + editorType = 'triggers'; + editorFields = []; + loadTriggerList(); + if (window.location.hash) loadTriggerItem(window.location.hash.substring(1)); +} + +function loadTriggerList() { + API.get('/api/triggers').then(function(data) { + allIDs = (data || []).map(function(e) { return e.id || e; }); + window._triggerEntries = data || []; + renderTriggerList(''); + }).catch(function(e) { + notify('Failed to load list: ' + e.message, 'error'); + }); +} + +function renderTriggerList(filter) { + var el = $('#listEntries'); + var f = (filter || '').toLowerCase(); + var entries = window._triggerEntries || []; + var html = ''; + entries.forEach(function(entry) { + var id = entry.id || entry; + if (f && id.toLowerCase().indexOf(f) === -1) return; + var badge = entry.kind === 'player' ? ' [Player]' : (entry.kind === 'global' ? ' [Global]' : ''); + var badgeClass = entry.kind === 'player' ? 'dc-item' : (entry.kind === 'global' ? 'dc-item' : ''); + html += '
'; + html += '' + esc(id) + ''; + html += '' + esc(badge) + ''; + html += '
'; + }); + el.innerHTML = html || '

No triggers

'; +} + +function loadTriggerItem(id) { + loadTrigger(id); +} + +function loadTrigger(id) { + triggerID = id; + currentID = id; + window.location.hash = id; + renderTriggerList(''); + API.get('/api/triggers/' + encodeURIComponent(id)).then(function(data) { + triggerData = data; + triggerKind = data._kind || ''; + if (!Array.isArray(triggerData.steps)) triggerData.steps = []; + window._editorData = triggerData; + window._ieRenderCurrent = renderCurrent; + window._ieRenderOnly = function() { if (triggerID && triggerData) renderTriggerEditor(triggerID, triggerData); }; + window._ieSyncAll = function(ed) { if (ed) syncCondDOM(ed); }; + renderTriggerEditor(id, data); + }).catch(function(e) { + $('#editorMain').innerHTML = '

Failed to load: ' + esc(e.message) + '

'; + }); +} + +function syncCondDOM(ed) { + if (!ed) return; + var condRoot = document.querySelector('.obj-card[data-card="condition"] .ie-cond-root'); + if (condRoot) { + var cond = ie_collectCond(condRoot); + if (cond) ed.condition = cond; + else delete ed.condition; + } +} + +function renderCurrent() { + if (!triggerData || !triggerID) return; + syncCondDOM(triggerData); + var flagTypeEl = document.querySelector('input[name="trigFlagType"]:checked'); + if (flagTypeEl) { + delete triggerData.on_player_flag; + delete triggerData.on_global_flag; + if (flagTypeEl.value === 'player') { + var n = $('#trigFlagName'); triggerData.on_player_flag = n ? n.value.trim() : ''; + } else { + var n = $('#trigFlagName'); triggerData.on_global_flag = n ? n.value.trim() : ''; + } + } + var valEl = $('#trigValue'); + if (valEl) { + var v = valEl.value.trim(); + if (v === '') delete triggerData.value; + else { try { triggerData.value = JSON.parse(v); } catch(e) { triggerData.value = v; } } + } + var lockEl = $('#trigLock'); + if (lockEl) { if (lockEl.checked) triggerData.lock = true; else delete triggerData.lock; } + var stepRows = document.querySelectorAll('.obj-card[data-card="steps"] .ie-step-row'); + if (!Array.isArray(triggerData.steps)) triggerData.steps = []; + stepRows.forEach(function(stepRow) { + var si = parseInt(stepRow.getAttribute('data-step'), 10); + if (isNaN(si)) return; + if (!triggerData.steps[si]) triggerData.steps[si] = {}; + var waitEl = stepRow.querySelector('.wait-input'); + if (waitEl) triggerData.steps[si].wait = parseInt(waitEl.value) || 0; + syncStepEffects(stepRow, si); + }); + if (triggerData.steps.length > stepRows.length) triggerData.steps.length = stepRows.length; + renderTriggerEditor(triggerID, triggerData); +} + +function syncStepEffects(row, si) { + var effectsEl = row.querySelector('.ie-act-root'); + if (!effectsEl) return; + var step = triggerData.steps[si] || {}; + ['messages','broadcast','broadcast_global','teleport','heal','credits', + 'give_item','take_item','despawn_mob','aps_node'].forEach(function(k) { delete step[k]; }); + delete step.set_global_flags; delete step.set_player_flags; delete step.spawn_mob; + + var msgInputs = effectsEl.querySelectorAll('.trig-msg-input'); + var msgs = []; + msgInputs.forEach(function(inp) { if (inp.value !== '') msgs.push(inp.value); }); + if (msgs.length > 0) step.messages = msgs; + + effectsEl.querySelectorAll('.trig-effect-input').forEach(function(inp) { + var key = inp.getAttribute('data-key'); + var v = inp.value.trim(); + if (v !== '') { if (key === 'teleport' || key === 'heal' || key === 'credits') step[key] = parseInt(v)||0; else step[key] = v; } + }); + + var cb = effectsEl.querySelector('.trig-aps-cb'); + if (cb && cb.checked) step.aps_node = true; + + effectsEl.querySelectorAll('.trig-kv-section').forEach(function(kvSec) { + var kvKey = kvSec.getAttribute('data-kv'); + var kvObj = {}; + kvSec.querySelectorAll('.trig-kv-row').forEach(function(kvRow) { + var kEl = kvRow.querySelector('.trig-kv-key'); + var vEl = kvRow.querySelector('.trig-kv-val'); + if (kEl && kEl.value) { + var v = vEl ? vEl.value : ''; + try { kvObj[kEl.value] = JSON.parse(v); } catch(e) { kvObj[kEl.value] = v; } + } + }); + if (Object.keys(kvObj).length > 0) step[kvKey] = kvObj; + }); + + var smInput = effectsEl.querySelector('.trig-spawn-mob'); + if (smInput && smInput.value.trim()) { + if (!step.spawn_mob) step.spawn_mob = {}; + step.spawn_mob.id = smInput.value.trim(); + } + + triggerData.steps[si] = step; +} + +function renderTriggerEditor(id, data) { + var html = renderRenameableHeader('Trigger', id); + + var kindLabel = data._kind === 'global' ? 'Global Flag' : (data._kind === 'player' ? 'Player Flag' : ''); + if (kindLabel) html += '
' + esc(kindLabel) + ' Trigger
'; + + html += '
'; + html += '
'; + + html += '
'; + + var isPlayerFlag = data.on_player_flag !== undefined; + var isGlobalFlag = data.on_global_flag !== undefined; + if (!isPlayerFlag && !isGlobalFlag) isPlayerFlag = true; + var flagName = isPlayerFlag ? (data.on_player_flag || '') : (data.on_global_flag || ''); + + html += '
'; + html += '
'; + html += 'Flag Subscription'; + html += '
'; + html += '
'; + html += '
'; + html += '
'; + html += 'Flag Type'; + html += '
'; + html += ''; + html += ''; + html += '
'; + html += '
'; + html += '
'; + html += 'Flag Name'; + html += ''; + html += '
'; + html += '
'; + html += 'Value (optional)'; + html += ''; + html += '
'; + html += '
'; + html += ''; + html += 'Lock Player (atomic sequence)'; + html += '
'; + html += '
'; + html += '
'; + html += '
'; + + html += '
'; + html += '
'; + html += 'Entry Condition'; + html += '
'; + html += '
'; + html += '
' + ie_renderCond(data.condition || null, 'condition', 0, '', 'entry') + '
'; + html += '
'; + html += '
'; + + var steps = Array.isArray(data.steps) ? data.steps : []; + html += '
'; + html += '
'; + html += 'Steps (' + steps.length + ')'; + html += '
'; + html += '
'; + html += renderSteps(steps); + html += ''; + html += '
'; + html += '
'; + + html += '
'; + + html += ''; + + html += '
'; + html += ''; + html += ''; + html += '
'; + + $('#editorMain').innerHTML = html; + ced_setupSearchFields(); +} + +function renderSteps(steps) { + var h = ''; + (steps || []).forEach(function(step, si) { + h += '
'; + h += '
'; + h += 'Step ' + (si + 1) + ''; + h += 'Wait:'; + h += ''; + if (si > 0) h += ''; + if (si < steps.length - 1) h += ''; + h += ''; + h += ''; + h += '
'; + + h += '
'; + h += '
'; + h += renderStepEffects(step, si); + h += '
'; + h += '
'; + + h += '
'; + IE_STEP_KINDS.forEach(function(sk) { + h += ''; + }); + h += '
'; + h += '
'; + }); + return h; +} + +function renderStepEffects(step, si) { + var h = ''; + + var msgs = Array.isArray(step.messages) ? step.messages : []; + msgs.forEach(function(msg, mi) { + h += '
'; + h += 'Message'; + h += ''; + h += '
'; + }); + + if (step.broadcast !== undefined) { + h += '
Broadcast'; + h += '
'; + } + if (step.broadcast_global !== undefined) { + h += '
Broadcast Global'; + h += '
'; + } + if (step.teleport !== undefined) { + h += '
Teleport'; + h += '
'; + } + if (step.heal !== undefined) { + h += '
Heal'; + h += '
'; + } + if (step.credits !== undefined) { + h += '
Credits'; + h += '
'; + } + if (step.spawn_mob && step.spawn_mob.id) { + h += '
Spawn Mob'; + h += '
'; + } + if (step.despawn_mob !== undefined) { + h += '
Despawn Mob'; + h += '
'; + } + if (step.give_item !== undefined) { + h += '
Give Item'; + h += '
'; + } + if (step.take_item !== undefined) { + h += '
Take Item'; + h += '
'; + } + if (step.aps_node) { + h += '
APS Node'; + h += '
'; + } + + function renderKVSection(label, key, obj) { + var h2 = '
'; + h2 += '
' + label + '
'; + h2 += '
'; + Object.keys(obj).forEach(function(k) { + h2 += '
'; + h2 += ''; + h2 += '='; + h2 += ''; + h2 += '
'; + }); + h2 += '
'; + return h2; + } + + if (step.set_global_flags && typeof step.set_global_flags === 'object' && Object.keys(step.set_global_flags).length > 0) { + h += renderKVSection('Set Global Flags', 'set_global_flags', step.set_global_flags); + } + if (step.set_player_flags && typeof step.set_player_flags === 'object' && Object.keys(step.set_player_flags).length > 0) { + h += renderKVSection('Set Player Flags', 'set_player_flags', step.set_player_flags); + } + + return h; +} + +function addStep() { + if (!triggerData) return; + if (!Array.isArray(triggerData.steps)) triggerData.steps = []; + triggerData.steps.push({wait: 0}); + renderCurrent(); +} + +function removeStep(si) { + if (!triggerData || !Array.isArray(triggerData.steps)) return; + triggerData.steps.splice(si, 1); + renderCurrent(); +} + +function moveStep(from, to) { + if (!triggerData || !Array.isArray(triggerData.steps)) return; + var s = triggerData.steps[from]; + triggerData.steps.splice(from, 1); + triggerData.steps.splice(to, 0, s); + renderCurrent(); +} + +function addStepEffect(si, kind) { + if (!triggerData) return; + syncCondDOM(triggerData); + syncAllStepEffects(); + if (!Array.isArray(triggerData.steps)) triggerData.steps = []; + if (!triggerData.steps[si]) triggerData.steps[si] = {}; + var step = triggerData.steps[si]; + + if (kind === 'messages') { + if (!Array.isArray(step.messages)) step.messages = []; + step.messages.push(''); + } else if (kind === 'wait') { + step.wait = (step.wait || 0) + 1; + } else { + var at = IE_ACT_TYPES.find(function(a) { return a.key === kind; }); + if (!at) return; + if (at.kind === 'kv') step[kind] = {}; + else if (at.kind === 'checkbox') step[kind] = true; + else if (at.kind === 'number') step[kind] = 0; + else if (at.kind === 'search') step[kind] = ''; + else step[kind] = ''; + } + renderCurrent(); +} + +function syncAllStepEffects() { + var stepRows = document.querySelectorAll('.obj-card[data-card="steps"] .ie-step-row'); + if (!Array.isArray(triggerData.steps)) triggerData.steps = []; + stepRows.forEach(function(stepRow) { + var si = parseInt(stepRow.getAttribute('data-step'), 10); + if (isNaN(si)) return; + if (!triggerData.steps[si]) triggerData.steps[si] = {}; + var waitEl = stepRow.querySelector('.wait-input'); + if (waitEl) triggerData.steps[si].wait = parseInt(waitEl.value) || 0; + syncStepEffects(stepRow, si); + }); +} + +function saveTrigger() { + syncCondDOM(triggerData); + syncAllStepEffects(); + if (!triggerData || !triggerID) return; + var flagTypeEl = document.querySelector('input[name="trigFlagType"]:checked'); + if (flagTypeEl) { + delete triggerData.on_player_flag; + delete triggerData.on_global_flag; + var n = $('#trigFlagName'); + if (flagTypeEl.value === 'player') triggerData.on_player_flag = n ? n.value.trim() : ''; + else triggerData.on_global_flag = n ? n.value.trim() : ''; + } + var valEl = $('#trigValue'); + if (valEl) { + var v = valEl.value.trim(); + if (v === '') delete triggerData.value; + else { try { triggerData.value = JSON.parse(v); } catch(e) { triggerData.value = v; } } + } + var lockEl = $('#trigLock'); + if (lockEl) { if (lockEl.checked) triggerData.lock = true; else delete triggerData.lock; } + + var out = JSON.parse(JSON.stringify(triggerData)); + delete out.id; + delete out._path; + delete out._raw; + delete out._kind; + ced_cleanOutput(out); + API.put('/api/triggers/' + encodeURIComponent(triggerID), out).then(function(resp) { + notify('Trigger ' + triggerID + ' saved', 'success'); + updateUndoBar(); + if (resp) { + if (resp._raw) triggerData._raw = resp._raw; + if (resp.kind) triggerKind = resp.kind; + } + loadTriggerList(); + }).catch(function(e) { notify('Save failed: ' + e.message, 'error'); }); +} + +function deleteTrigger() { + if (!confirm('Delete trigger "' + triggerID + '"?')) return; + API.del('/api/triggers/' + encodeURIComponent(triggerID)).then(function() { + notify('Trigger ' + triggerID + ' deleted', 'success'); + updateUndoBar(); + triggerID = null; + currentID = null; + triggerData = null; + triggerKind = ''; + $('#editorMain').innerHTML = '

Deleted

'; + loadTriggerList(); + }).catch(function(e) { notify('Delete failed: ' + e.message, 'error'); }); +} + +function createNew() { + var baseID = prompt('Trigger ID (filename without .yaml):'); + if (!baseID || !baseID.trim()) return; + var id = baseID.trim(); + API.post('/api/triggers', {id: id, on_player_flag: '', steps: []}).then(function(resp) { + notify('Trigger ' + id + ' created', 'success'); + updateUndoBar(); + loadTriggerList(); + loadTrigger(id); + }).catch(function(e) { notify('Create failed: ' + e.message, 'error'); }); +} diff --git a/internal/admin/templates/layout.html b/internal/admin/templates/layout.html index 009e1f7..fe4e0f9 100644 --- a/internal/admin/templates/layout.html +++ b/internal/admin/templates/layout.html @@ -21,6 +21,7 @@ Players Dashboard Files + Triggers
@@ -40,6 +41,7 @@ {{else if eq .Page "players"}}{{template "body-players" .}} {{else if eq .Page "dashboard"}}{{template "body-dashboard" .}} {{else if eq .Page "files"}}{{template "body-files" .}} +{{else if eq .Page "triggers"}}{{template "body-triggers" .}} {{else if eq .Page "duplicate-rooms"}}{{template "body-duplicate-rooms" .}} {{else}}

Unknown page: {{.Page}}

{{end}} diff --git a/internal/admin/templates/triggers.html b/internal/admin/templates/triggers.html new file mode 100644 index 0000000..3b32765 --- /dev/null +++ b/internal/admin/templates/triggers.html @@ -0,0 +1,19 @@ +{{define "body-triggers"}} +
+
+ +
+ +
+
+

Select a trigger to edit

+
+
+ + + + + +{{end}} -- cgit v1.2.3