aboutsummaryrefslogtreecommitdiff
path: root/internal/admin
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-07-10 00:43:29 -0400
committerhistoria <[not public]>2026-07-10 00:43:29 -0400
commit6e7c0b3ec4253bb58a815979b1f4574d08cb1f53 (patch)
treedbff1952bdbf14cd58a6128775e263b53fc64ec1 /internal/admin
parent9836513d287396fabcba85956366e6410d7363ef (diff)
downloadthehouseoficarus-6e7c0b3ec4253bb58a815979b1f4574d08cb1f53.tar.gz
feat(admin): add triggers to rooms, add global triggers
Diffstat (limited to 'internal/admin')
-rw-r--r--internal/admin/api_triggers.go335
-rw-r--r--internal/admin/server.go2
-rw-r--r--internal/admin/static/admin.css4
-rw-r--r--internal/admin/static/cardeditor.js5
-rw-r--r--internal/admin/static/map.js184
-rw-r--r--internal/admin/static/mobeditor.js38
-rw-r--r--internal/admin/static/triggerseditor.js463
-rw-r--r--internal/admin/templates/layout.html2
-rw-r--r--internal/admin/templates/triggers.html19
9 files changed, 902 insertions, 150 deletions
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 += '<button class="panel-tab' + (currentPanelTab === 5 ? ' active' : '') + '" onclick="switchPanelTab(5)">Items</button>';
html += '<button class="panel-tab' + (currentPanelTab === 6 ? ' active' : '') + '" onclick="switchPanelTab(6)">Hazard</button>';
html += '<button class="panel-tab' + (currentPanelTab === 7 ? ' active' : '') + '" onclick="switchPanelTab(7)">Agility</button>';
- html += '<button class="panel-tab' + (currentPanelTab === 8 ? ' active' : '') + '" onclick="switchPanelTab(8)">Enter/Exit</button>';
- html += '<button class="panel-tab' + (currentPanelTab === 9 ? ' active' : '') + '" onclick="switchPanelTab(9)">Triggers</button>';
+ html += '<button class="panel-tab' + (currentPanelTab === 8 ? ' active' : '') + '" onclick="switchPanelTab(8)">Triggers</button>';
html += '</div>';
html += '<div class="panel-tab-content">';
@@ -598,11 +599,14 @@ function renderSidePanel(data) {
}
}
else if (currentPanelTab === 8) html += renderEnterExitTab(r);
- else if (currentPanelTab === 9) html += renderTriggersTab(r);
html += '</div>';
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 = '<h3 style="margin:8px 0 4px">On-Enter Triggers</h3>';
+ var h = '<div style="margin:8px 0"><button class="btn btn-primary" onclick="saveTriggers()">Save Triggers</button></div>';
+ h += '<h3 style="margin:8px 0 4px">On-Enter Triggers</h3>';
h += '<p style="color:#888;font-size:11px;margin:0 0 6px">Fires when a player enters this room. First matching entry wins.</p>';
h += renderTriggerListHTML(r.on_enter, 'on_enter', 'on_enter', false);
- h += '<h3 style="margin:16px 0 4px">On-Exit Triggers</h3>';
+ h += '<hr style="border:none;border-top:1px solid var(--border);margin:12px 0">';
+ h += '<h3 style="margin:8px 0 4px">On-Exit Triggers</h3>';
h += '<p style="color:#888;font-size:11px;margin:0 0 6px">Fires when a player leaves this room. First matching entry wins.</p>';
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 = '<h3 style="margin:8px 0 4px">On-Flag-Change Triggers</h3>';
- h += '<p style="color:#888;font-size:11px;margin:0 0 6px">Fires when a player flag changes. Use the condition\'s "In Room" gate to scope to this room (delete it to make global).</p>';
- h += renderFlagTriggerListHTML(r.on_flag_change, 'on_flag_change', 'on_flag_change', 'player');
- h += '<h3 style="margin:16px 0 4px">On-Global-Flag-Change Triggers</h3>';
- h += '<p style="color:#888;font-size:11px;margin:0 0 6px">Fires when a global flag changes (no player involved).</p>';
- 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 += '<div class="obj-sub-row ie-inter-row" data-idx="' + idx + '">';
- h += '<div class="ie-inter-header">';
- h += '<label class="ie-lock-lbl"><input type="checkbox" class="ie-lock-cb"' + (lockVal ? ' checked' : '') + '> Lock Player</label>';
- if (idx > 0) h += '<button class="btn btn-sm ie-trig-up" onclick="ie_moveRow(\'' + escAttr(secPath) + '\',' + idx + ',' + (idx - 1) + ')" title="Move up">&#9650;</button>';
- if (idx < arr.length - 1) h += '<button class="btn btn-sm ie-trig-dn" onclick="ie_moveRow(\'' + escAttr(secPath) + '\',' + idx + ',' + (idx + 1) + ')" title="Move down">&#9660;</button>';
- h += '<span class="ie-cond-leaf-label">' + esc(kind === 'player' ? 'Player Flag' : 'Global Flag') + '</span>';
- h += '<input class="ie-flag-name" data-ie-flag-key="' + escAttr(flagKey) + '" data-idx="' + idx + '" value="' + escAttr(flagName) + '" placeholder="' + escAttr(kind === 'player' ? 'player flag name' : 'global flag name') + '" style="flex:1;min-width:80px" oninput="_mapSyncFlag(\'' + escAttr(secPath) + '\',' + idx + ',\'' + escAttr(flagKey) + '\')">';
- h += '<span class="ie-cond-eq">=</span>';
- h += '<input class="ie-flag-value" data-idx="' + idx + '" value="' + escAttr(item.value !== undefined ? String(item.value) : '') + '" placeholder="exists" style="width:60px" oninput="_mapSyncFlag(\'' + escAttr(secPath) + '\',' + idx + ',\'' + escAttr(flagKey) + '\')">';
- h += '<span class="ie-inter-header-spacer"></span>';
- if (!condExists) {
- h += '<button class="btn btn-sm ie-add-btn" onclick="ie_addRootConditionGroup(\'' + escAttr(secPath) + '\',' + idx + ',\'entry\')">+ Add Condition</button>';
- }
- h += '<button class="btn btn-sm btn-danger ie-inter-remove" onclick="_mapRemoveTrigger(\'' + escAttr(secPath) + '\',' + idx + ')">Remove Trigger</button>';
- h += '</div>';
- h += '<div class="ie-inter-panel" style="display:' + (condExists ? 'block' : 'none') + '">';
- h += '<div class="ie-inter-panel-body">' + ie_renderCond(item.condition || null, secId, idx, secPath, 'entry') + '</div>';
- h += '</div>';
- h += '<div class="ie-inter-steps">';
- h += ie_renderStepList(steps, secId, idx, secPath);
- h += '</div>';
- h += '</div>';
- });
- h += '<button class="btn btn-sm obj-sub-add" onclick="_mapAddFlagTrigger(\'' + escAttr(secPath) + '\',\'' + escAttr(flagKey) + '\')">+ Add Trigger</button>';
- return h;
+ return '<div class="obj-card" data-card="' + escAttr(secId) + '" style="max-width:100%">' + ie_renderArraySection(ctx) + '</div>';
}
-// _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 += '<div class="ingredients-ingredient"><div class="ingredients-pair">';
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 += '<div class="item' + (triggerID === id ? ' active' : '') + '" onclick="loadTriggerItem(\'' + escAttr(id) + '\')">';
+ html += '<span class="dc-id">' + esc(id) + '</span>';
+ html += '<span class="dc-name" style="color:' + (entry.kind === 'player' ? '#6af' : '#e8c47c') + '">' + esc(badge) + '</span>';
+ html += '</div>';
+ });
+ el.innerHTML = html || '<p style="color:#888;text-align:center;margin-top:20px;font-size:11px">No triggers</p>';
+}
+
+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 = '<p style="color:var(--danger)">Failed to load: ' + esc(e.message) + '</p>';
+ });
+}
+
+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 += '<div style="font-size:11px;color:#888;margin-bottom:8px">' + esc(kindLabel) + ' Trigger</div>';
+
+ html += '<div class="tabs"><button class="tab active" onclick="switchTab(event,\'fields\')">Fields</button>';
+ html += '<button class="tab" onclick="switchTab(event,\'yaml\')">Raw YAML</button></div>';
+
+ html += '<div class="tab-content" id="tabFields">';
+
+ 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 += '<div class="obj-card" style="max-width:100%">';
+ html += '<div class="obj-card-header">';
+ html += '<span class="obj-card-label">Flag Subscription</span>';
+ html += '</div>';
+ html += '<div class="obj-card-body">';
+ html += '<div class="obj-card-grid">';
+ html += '<div class="obj-field obj-wide">';
+ html += '<span>Flag Type</span>';
+ html += '<div style="display:flex;gap:16px;align-items:center">';
+ html += '<label style="font-size:11px;color:var(--text);cursor:pointer"><input type="radio" name="trigFlagType" value="player"' + (isPlayerFlag ? ' checked' : '') + '> Player Flag</label>';
+ html += '<label style="font-size:11px;color:var(--text);cursor:pointer"><input type="radio" name="trigFlagType" value="global"' + (isGlobalFlag ? ' checked' : '') + '> Global Flag</label>';
+ html += '</div>';
+ html += '</div>';
+ html += '<div class="obj-field" style="flex:2">';
+ html += '<span>Flag Name</span>';
+ html += '<input id="trigFlagName" value="' + escAttr(flagName) + '" placeholder="flag name">';
+ html += '</div>';
+ html += '<div class="obj-field" style="flex:1">';
+ html += '<span>Value (optional)</span>';
+ html += '<input id="trigValue" value="' + escAttr(data.value !== undefined ? String(data.value) : '') + '" placeholder="any value">';
+ html += '</div>';
+ html += '<div class="obj-field obj-check">';
+ html += '<input type="checkbox" id="trigLock"' + (data.lock ? ' checked' : '') + '>';
+ html += '<span>Lock Player (atomic sequence)</span>';
+ html += '</div>';
+ html += '</div>';
+ html += '</div>';
+ html += '</div>';
+
+ html += '<div class="obj-card" style="max-width:100%" data-card="condition">';
+ html += '<div class="obj-card-header">';
+ html += '<span class="obj-card-label">Entry Condition</span>';
+ html += '</div>';
+ html += '<div class="obj-card-body">';
+ html += '<div class="ie-cond-root">' + ie_renderCond(data.condition || null, 'condition', 0, '', 'entry') + '</div>';
+ html += '</div>';
+ html += '</div>';
+
+ var steps = Array.isArray(data.steps) ? data.steps : [];
+ html += '<div class="obj-card" style="max-width:100%" data-card="steps">';
+ html += '<div class="obj-card-header">';
+ html += '<span class="obj-card-label">Steps (' + steps.length + ')</span>';
+ html += '</div>';
+ html += '<div class="obj-card-body">';
+ html += renderSteps(steps);
+ html += '<button class="btn btn-sm obj-sub-add" onclick="addStep()">+ Add Step</button>';
+ html += '</div>';
+ html += '</div>';
+
+ html += '</div>';
+
+ html += '<div class="tab-content" id="tabYaml" style="display:none">';
+ html += '<pre>' + esc(data._raw || JSON.stringify(data, null, 2)) + '</pre>';
+ html += '</div>';
+
+ html += '<div class="btn-row">';
+ html += '<button class="btn btn-primary" onclick="saveTrigger()">Save</button>';
+ html += '<button class="btn" style="margin-left:auto" onclick="deleteTrigger()">Delete</button>';
+ html += '</div>';
+
+ $('#editorMain').innerHTML = html;
+ ced_setupSearchFields();
+}
+
+function renderSteps(steps) {
+ var h = '';
+ (steps || []).forEach(function(step, si) {
+ h += '<div class="ie-step-row" data-step="' + si + '">';
+ h += '<div class="ie-step-header">';
+ h += '<span class="ie-step-label">Step ' + (si + 1) + '</span>';
+ h += '<span style="font-size:10px;color:#888">Wait:</span>';
+ h += '<input class="ie-act-input wait-input" type="number" value="' + (step.wait || 0) + '" style="width:60px" min="0">';
+ if (si > 0) h += '<button class="btn btn-sm ie-step-up" onclick="moveStep(' + si + ',' + (si - 1) + ')">&#9650;</button>';
+ if (si < steps.length - 1) h += '<button class="btn btn-sm ie-step-dn" onclick="moveStep(' + si + ',' + (si + 1) + ')">&#9660;</button>';
+ h += '<span class="ie-step-spacer"></span>';
+ h += '<button class="btn btn-sm btn-danger" onclick="removeStep(' + si + ')">Remove</button>';
+ h += '</div>';
+
+ h += '<div class="ie-step-effects">';
+ h += '<div class="ie-act-root">';
+ h += renderStepEffects(step, si);
+ h += '</div>';
+ h += '</div>';
+
+ h += '<div class="ie-step-addbar-bottom">';
+ IE_STEP_KINDS.forEach(function(sk) {
+ h += '<button class="btn btn-sm ie-add-btn" onclick="addStepEffect(' + si + ',\'' + escAttr(sk.key) + '\')">+ ' + esc(sk.label) + '</button>';
+ });
+ h += '</div>';
+ h += '</div>';
+ });
+ return h;
+}
+
+function renderStepEffects(step, si) {
+ var h = '';
+
+ var msgs = Array.isArray(step.messages) ? step.messages : [];
+ msgs.forEach(function(msg, mi) {
+ h += '<div class="ie-act-row">';
+ h += '<span class="ie-act-label">Message</span>';
+ h += '<input class="ie-act-input ie-act-msg trig-msg-input" value="' + escAttr(msg) + '" placeholder="message to player">';
+ h += '</div>';
+ });
+
+ if (step.broadcast !== undefined) {
+ h += '<div class="ie-act-row"><span class="ie-act-label">Broadcast</span>';
+ h += '<input class="ie-act-input ie-act-msg trig-effect-input" data-key="broadcast" value="' + escAttr(String(step.broadcast)) + '" placeholder="%p = player"></div>';
+ }
+ if (step.broadcast_global !== undefined) {
+ h += '<div class="ie-act-row"><span class="ie-act-label">Broadcast Global</span>';
+ h += '<input class="ie-act-input ie-act-msg trig-effect-input" data-key="broadcast_global" value="' + escAttr(String(step.broadcast_global)) + '" placeholder="%p = player"></div>';
+ }
+ if (step.teleport !== undefined) {
+ h += '<div class="ie-act-row"><span class="ie-act-label">Teleport</span>';
+ h += '<input class="ie-act-input trig-effect-input" data-key="teleport" type="number" value="' + (step.teleport || 0) + '" style="width:80px" placeholder="room ID"></div>';
+ }
+ if (step.heal !== undefined) {
+ h += '<div class="ie-act-row"><span class="ie-act-label">Heal</span>';
+ h += '<input class="ie-act-input trig-effect-input" data-key="heal" type="number" value="' + (step.heal || 0) + '" style="width:80px" placeholder="HP"></div>';
+ }
+ if (step.credits !== undefined) {
+ h += '<div class="ie-act-row"><span class="ie-act-label">Credits</span>';
+ h += '<input class="ie-act-input trig-effect-input" data-key="credits" type="number" value="' + (step.credits || 0) + '" style="width:80px" placeholder="amount"></div>';
+ }
+ if (step.spawn_mob && step.spawn_mob.id) {
+ h += '<div class="ie-act-row"><span class="ie-act-label">Spawn Mob</span>';
+ h += '<div class="obj-search" style="position:relative;flex:1;min-width:0">';
+ h += '<input class="search-input trig-spawn-mob" value="' + escAttr(step.spawn_mob.id) + '" placeholder="mob ID" data-search-type="mobs">';
+ h += '<div class="search-results" style="display:none"></div></div></div>';
+ }
+ if (step.despawn_mob !== undefined) {
+ h += '<div class="ie-act-row"><span class="ie-act-label">Despawn Mob</span>';
+ h += '<input class="ie-act-input trig-effect-input" data-key="despawn_mob" value="' + escAttr(String(step.despawn_mob)) + '" placeholder="mob ID"></div>';
+ }
+ if (step.give_item !== undefined) {
+ h += '<div class="ie-act-row"><span class="ie-act-label">Give Item</span>';
+ h += '<input class="ie-act-input trig-effect-input" data-key="give_item" value="' + escAttr(String(step.give_item)) + '" placeholder="item ID"></div>';
+ }
+ if (step.take_item !== undefined) {
+ h += '<div class="ie-act-row"><span class="ie-act-label">Take Item</span>';
+ h += '<input class="ie-act-input trig-effect-input" data-key="take_item" value="' + escAttr(String(step.take_item)) + '" placeholder="item ID"></div>';
+ }
+ if (step.aps_node) {
+ h += '<div class="ie-act-row"><span class="ie-act-label">APS Node</span>';
+ h += '<label style="font-size:11px;color:var(--text)"><input type="checkbox" class="trig-aps-cb" checked> Enabled</label></div>';
+ }
+
+ function renderKVSection(label, key, obj) {
+ var h2 = '<div class="ie-act-kv trig-kv-section" data-kv="' + key + '">';
+ h2 += '<div class="ie-act-kv-header"><span class="ie-act-label">' + label + '</span></div>';
+ h2 += '<div class="ie-kv-list">';
+ Object.keys(obj).forEach(function(k) {
+ h2 += '<div class="ie-kv-row trig-kv-row">';
+ h2 += '<input class="ie-kv-key trig-kv-key" value="' + escAttr(k) + '" placeholder="key">';
+ h2 += '<span class="ie-cond-eq">=</span>';
+ h2 += '<input class="ie-kv-val trig-kv-val" value="' + escAttr(String(obj[k])) + '" placeholder="value">';
+ h2 += '</div>';
+ });
+ h2 += '</div></div>';
+ 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 = '<p style="color:#888;text-align:center;margin-top:60px">Deleted</p>';
+ 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 @@
<a href="/editor/players" class="{{if eq .Page "players"}}active{{end}}">Players</a>
<a href="/editor/dashboard" class="{{if eq .Page "dashboard"}}active{{end}}">Dashboard</a>
<a href="/editor/files" class="{{if eq .Page "files"}}active{{end}}">Files</a>
+ <a href="/editor/triggers" class="{{if eq .Page "triggers"}}active{{end}}">Triggers</a>
<div class="undo-bar">
<span class="undo-label"></span>
<button class="undo-btn" onclick="doUndo()" title="">⟲ Undo</button>
@@ -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}}<p style="color:#888;text-align:center;margin-top:60px">Unknown page: {{.Page}}</p>{{end}}
</body>
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"}}
+<div class="editor-container">
+ <div class="editor-list" id="itemList">
+ <div class="search-bar"><input placeholder="Search triggers..." oninput="filterList(this.value)"></div>
+ <div id="listEntries"></div>
+ <button class="btn btn-primary btn-sm" style="margin-top:8px;width:100%" onclick="createNew()">+ New Trigger</button>
+ </div>
+ <div class="editor-main" id="editorMain">
+ <p style="color:#888;text-align:center;margin-top:60px">Select a trigger to edit</p>
+ </div>
+</div>
+<script src="/static/editor.js"></script>
+<script src="/static/cardeditor.js"></script>
+<script src="/static/intereditor.js"></script>
+<script src="/static/triggerseditor.js"></script>
+<script>
+initTriggerEditor();
+</script>
+{{end}}