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 ++ internal/game/cmd_inspect.go | 12 - internal/game/sys_triggers.go | 103 +++---- internal/validate/checks.go | 8 - internal/world/room.go | 2 - internal/world/room_migrate.go | 22 +- internal/world/room_migrate_test.go | 18 +- 15 files changed, 947 insertions(+), 270 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') 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 += '
Fires when a player enters this room. First matching entry wins.
'; h += renderTriggerListHTML(r.on_enter, 'on_enter', 'on_enter', false); - 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 = '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 += '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 += '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 += '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