From ecba7f726f70b37126d852c38c7e3eec7b04d730 Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Thu, 9 Jul 2026 22:15:54 -0400 Subject: feat: old standalone trigger systems completely unified into trigger->condition->action system --- internal/admin/api_map.go | 38 +- internal/admin/api_room_courses.go | 24 +- internal/admin/api_room_insert_remove_test.go | 12 +- internal/admin/api_rooms_bulk.go | 10 +- internal/admin/server.go | 2 +- internal/admin/static/admin.css | 36 +- internal/admin/static/intereditor.js | 1074 ++++++++++++++----------- internal/admin/static/map.js | 229 ++++++ internal/admin/static/mobeditor.js | 9 +- internal/admin/static/objecteditor.js | 41 +- internal/admin/templates/map.html | 2 + internal/behavior/behavior.go | 189 +++-- internal/behavior/types.go | 59 +- internal/game/act.go | 47 +- internal/game/act_agility.go | 2 +- internal/game/act_effects.go | 203 ++--- internal/game/act_interaction_seq.go | 97 --- internal/game/act_interaction_seq_test.go | 216 ----- internal/game/act_room.go | 178 +--- internal/game/act_state.go | 2 - internal/game/act_steal.go | 6 +- internal/game/act_talk.go | 4 +- internal/game/cmd_dig.go | 10 +- internal/game/cmd_goto.go | 5 +- internal/game/cmd_inspect.go | 26 +- internal/game/cmd_move.go | 45 +- internal/game/cmd_registry.go | 21 +- internal/game/cmd_reload.go | 6 +- internal/game/cmd_room_insert.go | 5 +- internal/game/cmd_room_remove_test.go | 9 +- internal/game/cmd_summon.go | 5 +- internal/game/cmd_use.go | 30 +- internal/game/cmd_verbs.go | 6 +- internal/game/combat_mob.go | 2 +- internal/game/combat_mob_test.go | 2 +- internal/game/core_course.go | 2 +- internal/game/core_flags.go | 12 +- internal/game/core_login_char.go | 17 +- internal/game/exit_migrate.go | 6 +- internal/game/exit_migrate_test.go | 8 +- internal/game/game.go | 33 +- internal/game/look_target.go | 2 +- internal/game/sys_triggers.go | 608 +++++++++++--- internal/item/item.go | 6 +- internal/object/object.go | 26 +- internal/player/player.go | 28 +- internal/validate/checks.go | 291 ++++--- internal/validate/validate.go | 1 - internal/world/grid.go | 10 +- internal/world/insert_remove_test.go | 2 + internal/world/mob.go | 22 +- internal/world/room.go | 64 +- internal/world/room_migrate.go | 162 +++- internal/world/room_migrate_test.go | 104 ++- internal/world/trigger.go | 16 - internal/world/trigger_store.go | 330 -------- 56 files changed, 2277 insertions(+), 2125 deletions(-) delete mode 100644 internal/game/act_interaction_seq.go delete mode 100644 internal/game/act_interaction_seq_test.go delete mode 100644 internal/world/trigger.go delete mode 100644 internal/world/trigger_store.go (limited to 'internal') diff --git a/internal/admin/api_map.go b/internal/admin/api_map.go index 6e62d9e..5b0464d 100644 --- a/internal/admin/api_map.go +++ b/internal/admin/api_map.go @@ -280,27 +280,27 @@ func (s *AdminServer) handleMap(w http.ResponseWriter, r *http.Request) { continue } - bidirectional := false - if targetRoom, ok := roomData[target]; ok { - if oppExit, ok := targetRoom.Exits[world.OppositeExit[dir]]; ok && oppExit.Room == rid && !oppExit.Hidden && !oppExit.AlwaysBlocked { - bidirectional = true + bidirectional := false + if targetRoom, ok := roomData[target]; ok { + if oppExit, ok := targetRoom.Exits[world.OppositeExit[dir]]; ok && oppExit.Room == rid && !oppExit.Hidden && !oppExit.AlwaysBlocked { + bidirectional = true + } } - } - key := linkKey(rid, target) - if bidirectional && seenLinks[key] { - continue - } - seenLinks[key] = true - - links = append(links, LinkEntry{ - From: rid, - To: target, - Dir: string(dir), - Bidirectional: bidirectional, - Hidden: exit.Hidden, - AlwaysBlocked: exit.AlwaysBlocked, - }) + key := linkKey(rid, target) + if bidirectional && seenLinks[key] { + continue + } + seenLinks[key] = true + + links = append(links, LinkEntry{ + From: rid, + To: target, + Dir: string(dir), + Bidirectional: bidirectional, + Hidden: exit.Hidden, + AlwaysBlocked: exit.AlwaysBlocked, + }) } } diff --git a/internal/admin/api_room_courses.go b/internal/admin/api_room_courses.go index e4cf849..097b9e8 100644 --- a/internal/admin/api_room_courses.go +++ b/internal/admin/api_room_courses.go @@ -290,15 +290,15 @@ func (s *AdminServer) handleRoomCourse(w http.ResponseWriter, r *http.Request, r m["id"] = id delete(m, "_raw") writeJSON(w, map[string]any{ - "course": m, - "course_id": id, - "course_name": m["name"], - "obstacle_index": idx, - "total_obstacles": len(obstacles), - "obstacle": obstacles[idx], - "obstacles": obstacles, - "_raw": string(raw), - "room_id": roomID, + "course": m, + "course_id": id, + "course_name": m["name"], + "obstacle_index": idx, + "total_obstacles": len(obstacles), + "obstacle": obstacles[idx], + "obstacles": obstacles, + "_raw": string(raw), + "room_id": roomID, }) return } @@ -565,8 +565,6 @@ func exitIsAlwaysBlocked(v any) bool { return false } - - // isHorizontalDir returns whether d is one of the 8 horizontal directions. func isHorizontalDir(d string) bool { for _, h := range horizontalExitDirs { @@ -577,8 +575,6 @@ func isHorizontalDir(d string) bool { return false } - - // reconcileExitOnRoom validates that roomID has an exit in dir. // If expectedTarget > 0, also checks the exit points to that room. // Returns an error string or empty string on success. @@ -627,4 +623,4 @@ func (s *AdminServer) reconcileCourseExits(m map[string]any) []string { } return warnings -} \ No newline at end of file +} diff --git a/internal/admin/api_room_insert_remove_test.go b/internal/admin/api_room_insert_remove_test.go index 6d80595..f613313 100644 --- a/internal/admin/api_room_insert_remove_test.go +++ b/internal/admin/api_room_insert_remove_test.go @@ -31,9 +31,9 @@ func newTestAdminServer(t *testing.T, rooms map[int]string) *AdminServer { } } return &AdminServer{ - world: world.New(dir), - mobStore: world.NewMobStore(dir), - dataDir: dir, + world: world.New(dir), + mobStore: world.NewMobStore(dir), + dataDir: dir, undoStack: NewUndoStack(dir), } } @@ -428,9 +428,9 @@ func TestNextRoomIDNoCrossSubdirCollision(t *testing.T) { t.Fatal(err) } s := &AdminServer{ - world: world.New(dir), - mobStore: world.NewMobStore(dir), - dataDir: dir, + world: world.New(dir), + mobStore: world.NewMobStore(dir), + dataDir: dir, undoStack: NewUndoStack(dir), } // Allocating near room 1 (root) must skip 2 (which lives in zone/). diff --git a/internal/admin/api_rooms_bulk.go b/internal/admin/api_rooms_bulk.go index 34ca209..6283067 100644 --- a/internal/admin/api_rooms_bulk.go +++ b/internal/admin/api_rooms_bulk.go @@ -64,11 +64,11 @@ func (s *AdminServer) handleBulkEdit(w http.ResponseWriter, r *http.Request) { } type roomWrite struct { - oldContent []byte - newContent []byte - oldPath string - newPath string - wasMoved bool + oldContent []byte + newContent []byte + oldPath string + newPath string + wasMoved bool } writes := make([]roomWrite, 0, len(req.IDs)) diff --git a/internal/admin/server.go b/internal/admin/server.go index 67de0ab..64c5592 100644 --- a/internal/admin/server.go +++ b/internal/admin/server.go @@ -19,8 +19,8 @@ import ( "math/big" "net" "net/http" - "path/filepath" "os" + "path/filepath" "strconv" "strings" "time" diff --git a/internal/admin/static/admin.css b/internal/admin/static/admin.css index b35d5e2..7c8f0a5 100644 --- a/internal/admin/static/admin.css +++ b/internal/admin/static/admin.css @@ -420,8 +420,7 @@ g.ud-hover:hover text{font-weight:bold} .ie-kv-row{display:flex;gap:4px;align-items:center} .ie-kv-key,.ie-kv-val{font-size:11px;padding:2px 4px;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:3px;font-family:monospace;width:120px} .ie-kv-val{width:80px} -.ie-act-msg-delay,.ie-act-msg{font-size:11px;padding:3px 6px;background:var(--input-bg);color:var(--text);border:1px solid var(--input-border);border-radius:3px;font-family:monospace} -.ie-act-msg-delay{width:60px} +.ie-act-msg{font-size:11px;padding:3px 6px;background:var(--input-bg);color:var(--text);border:1px solid var(--input-border);border-radius:3px;font-family:monospace} .ie-act-msg{flex:1} /* Interaction row layout: column. The single flex header row holds the @@ -439,3 +438,36 @@ g.ud-hover:hover text{font-weight:bold} .ie-inter-panel-row{display:flex;gap:6px;align-items:flex-start} .ie-inter-panel-body{flex:1;min-width:0} .ie-inter-panel-x{margin-top:4px;flex-shrink:0} + +/* ── Step list: boxed steps with right-justified move/remove controls ── */ +.ie-inter-steps{margin-top:4px} +.ie-step-list{display:flex;flex-direction:column;gap:4px} +.ie-step-row{background:rgba(100,160,255,.03);border:1px solid rgba(100,160,255,.1);border-radius:6px;padding:8px;display:flex;flex-direction:column;gap:4px} +.ie-step-header{display:flex;align-items:center;gap:6px} +.ie-step-label{font-size:10px;color:#6af;text-transform:uppercase;letter-spacing:.3px;font-weight:bold;white-space:nowrap} +.ie-step-addmore{color:#6af;border:1px solid rgba(100,160,255,.25);background:transparent;font-size:10px;padding:1px 6px;border-radius:3px;cursor:pointer;font-family:monospace;line-height:1} +.ie-step-addmore:hover{background:rgba(100,160,255,.1)} +.ie-step-spacer{flex:1} +.ie-step-up,.ie-step-dn{padding:2px 6px;font-size:9px} +.ie-step-cond-panel{margin:2px 0} +.ie-step-effects{margin:2px 0} +.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:flex;flex-wrap:wrap;gap:4px;margin-top:6px} +.ie-step-addbar-bottom .btn{padding:4px 10px;font-size:11px} + +/* ── Message rows within a step ── */ +.ie-msg-row{display:flex;align-items:center;gap:4px} +.ie-msg-up,.ie-msg-dn{padding:1px 4px;font-size:9px;line-height:1} + +/* ── Lock player checkbox ── */ +.ie-lock-lbl{font-size:10px;color:var(--text);display:inline-flex;align-items:center;gap:3px;cursor:pointer;white-space:nowrap;margin-right:6px} +.ie-lock-cb{accent-color:var(--accent);margin:0} + +/* ── Trigger row move up/down ── */ +.ie-trig-up,.ie-trig-dn{padding:2px 6px;font-size:9px} + +/* ── Empty condition group placeholder ── */ +.ie-cond-empty{margin-bottom:2px} +.ie-cond-empty-bar{display:flex;align-items:center;gap:6px;flex-wrap:wrap;padding-bottom:2px} diff --git a/internal/admin/static/intereditor.js b/internal/admin/static/intereditor.js index 5cf39a1..653bccf 100644 --- a/internal/admin/static/intereditor.js +++ b/internal/admin/static/intereditor.js @@ -1,38 +1,172 @@ -// intereditor.js — Structured condition & action editors for on_use/on_look/on_kill. -// Replaces free-form JSON textareas with button-driven pickers. -// Shared by objecteditor.js and mobeditor.js. +// intereditor.js — Structured condition & step editors for trigger blocks. +// Drives the unified Trigger model: +// Trigger = { lock, item_id?, condition?, steps[], on_player_flag?, on_global_flag?, value? } +// Step = { condition?, wait, messages[], broadcast, broadcast_global, +// spawn_mob, despawn_mob, set_global_flags, set_player_flags, +// give_item, take_item, teleport, heal, credits, aps_node } +// Shared by objecteditor.js, mobeditor.js, and map.js. // ── Condition leaf types ── var IE_COND_TYPES = [ {type: 'global_flag', label: 'Global Flag', hint: 'global flag name'}, {type: 'player_flag', label: 'Player Flag', hint: 'player flag name'}, + {type: 'room', label: 'In Room', hint: 'room ID'}, {type: 'has_item', label: 'Has Item', hint: 'item ID'}, {type: 'min_credits', label: 'Min Credits', hint: 'amount'}, ]; +// ── Effect field types ── +// +// Every block is sequence-scoped now: broadcast / broadcast_global / +// spawn_mob / despawn_mob are available everywhere. Messages and condition +// are handled separately (plain-string message rows; per-step condition). + var IE_ACT_TYPES = [ - {key: 'set_global_flags', label: 'Set Global Flags', kind: 'kv'}, - {key: 'set_player_flags', label: 'Set Player Flags', kind: 'kv'}, - {key: 'give_item', label: 'Give Item', kind: 'search', entity: 'items'}, - {key: 'take_item', label: 'Take Item', kind: 'search', entity: 'items'}, - {key: 'teleport', label: 'Teleport', kind: 'number', hint: 'room ID'}, - {key: 'heal', label: 'Heal', kind: 'number', hint: 'HP to restore'}, - {key: 'credits', label: 'Credits', kind: 'number', hint: 'amount (negative = cost)'}, - {key: 'aps_node', label: 'APS Node', kind: 'checkbox'}, + {key: 'wait', label: 'Wait', kind: 'number', hint: 'ticks before this step'}, + {key: 'broadcast', label: 'Broadcast', kind: 'text', hint: '%p = player'}, + {key: 'broadcast_global', label: 'Broadcast Global', kind: 'text', hint: '%p = player'}, + {key: 'spawn_mob', label: 'Spawn Mob', kind: 'search', entity: 'mobs'}, + {key: 'despawn_mob', label: 'Despawn Mob', kind: 'search', entity: 'mobs'}, + {key: 'set_global_flags', label: 'Set Global Flags', kind: 'kv'}, + {key: 'set_player_flags', label: 'Set Player Flags', kind: 'kv'}, + {key: 'give_item', label: 'Give Item', kind: 'search', entity: 'items'}, + {key: 'take_item', label: 'Take Item', kind: 'search', entity: 'items'}, + {key: 'teleport', label: 'Teleport', kind: 'number', hint: 'room ID'}, + {key: 'heal', label: 'Heal', kind: 'number', hint: 'HP to restore'}, + {key: 'credits', label: 'Credits', kind: 'number', hint: 'amount (negative = cost)'}, + {key: 'aps_node', label: 'APS Node', kind: 'checkbox'}, ]; -// Sequence-only effects (only shown for scope === 'seq'). -var IE_ACT_TYPES_SEQ = [ - {key: 'broadcast', label: 'Broadcast', kind: 'text', hint: '%p = player'}, - {key: 'broadcast_global', label: 'Broadcast Global', kind: 'text', hint: '%p = player'}, - {key: 'spawn_mob', label: 'Spawn Mob', kind: 'search', entity: 'mobs'}, - {key: 'despawn_mob', label: 'Despawn Mob', kind: 'search', entity: 'mobs'}, +// ── Step-kind buttons for the bottom add-bar (each click creates a new +// step initialised with that single action kind) and the per-step +// "+" toggle bar. messages is handled separately from IE_ACT_TYPES. ── + +var IE_STEP_KINDS = [ + {key: 'messages', label: 'Message', kind: 'messages'}, ]; +IE_ACT_TYPES.forEach(function(at) { + IE_STEP_KINDS.push(at); +}); + +// ── Rendering helpers ── + +// ie_renderStepAddbar: the per-step action add bar revealed by the "+" toggle. +// Only shows kinds not already present on this step. +function ie_renderStepAddbar(step, secId, idx, si, secPath) { + var h = ''; + IE_STEP_KINDS.forEach(function(sk) { + if (sk.key === 'messages') { + if (step && Array.isArray(step.messages)) return; + } else { + if (step && Object.prototype.hasOwnProperty.call(step, sk.key)) { + if (sk.kind === 'checkbox' && step[sk.key] !== true) { /* re-addable */ } + else return; + } + } + if (sk.key === 'messages') { + h += ''; + } else { + h += ''; + } + }); + return h; +} + +// ie_renderBottomAddbar: the always-visible bar at the foot of the step list. +// Each button creates a new step initialised with that single action kind. +function ie_renderBottomAddbar(secId, secPath, idx) { + var h = ''; + IE_STEP_KINDS.forEach(function(sk) { + h += ''; + }); + return h; +} + +// ie_addStepKind creates a new step initialised with a single action kind. +function ie_addStepKind(secId, secPath, idx, kind) { + var ed = window._editorData; + if (!ed) return; + ced_syncDOMToData(ed); + if (window._ieSyncAll) window._ieSyncAll(ed); + if (!ed[secPath]) ed[secPath] = []; + if (!ed[secPath][idx]) ed[secPath][idx] = {}; + if (!ed[secPath][idx].steps || !Array.isArray(ed[secPath][idx].steps)) ed[secPath][idx].steps = []; + var step = {}; + if (kind === 'messages') { + step.messages = ['']; + } else if (kind === 'wait') { + step.wait = 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 step[kind] = ''; + } + ed[secPath][idx].steps.push(step); + window._ieRenderCurrent(); +} // Sentinel child-index for a root-level single leaf (no parent group). var IE_ROOT_LEAF = -1; +// ── Condition lens helpers (entry vs step) ── +// +// kind is the string-encoded condition anchor: 'entry' for the trigger's own +// condition, or 'step-' for the nth step's condition. Internally we parse +// it to the numeric step index (-1 = entry). + +function _ie_kindStep(kind) { + if (kind === undefined || kind === null || kind === 'entry') return -1; + if (typeof kind === 'string' && kind.indexOf('step-') === 0) { + var n = parseInt(kind.slice(5), 10); + return isNaN(n) ? -1 : n; + } + return -1; +} + +function _ie_getCond(ed, secPath, idx, kind) { + var si = _ie_kindStep(kind); + if (!ed[secPath] || !ed[secPath][idx]) return null; + if (si < 0) return ed[secPath][idx].condition || null; + if (!ed[secPath][idx].steps || !ed[secPath][idx].steps[si]) return null; + return ed[secPath][idx].steps[si].condition || null; +} + +function _ie_setCond(ed, secPath, idx, kind, newCond) { + var si = _ie_kindStep(kind); + if (!ed[secPath]) ed[secPath] = []; + if (!ed[secPath][idx]) ed[secPath][idx] = {}; + if (si < 0) { + if (newCond) ed[secPath][idx].condition = newCond; + else delete ed[secPath][idx].condition; + } else { + if (!ed[secPath][idx].steps) ed[secPath][idx].steps = []; + if (!ed[secPath][idx].steps[si]) ed[secPath][idx].steps[si] = {}; + if (newCond) ed[secPath][idx].steps[si].condition = newCond; + else delete ed[secPath][idx].steps[si].condition; + } +} + +function _ie_syncCond(ed, secId, secPath, idx, kind) { + var card = document.querySelector('.obj-card[data-card="' + secId + '"]'); + if (!card) return; + var si = _ie_kindStep(kind); + var container; + if (si < 0) { + container = card.querySelector('.obj-sub-row.ie-inter-row[data-idx="' + idx + '"]'); + } else { + container = card.querySelector('.obj-sub-row.ie-inter-row[data-idx="' + idx + '"] .ie-step-row[data-step="' + si + '"]'); + } + if (!container) return; + var condRoot = container.querySelector('.ie-cond-root'); + if (!condRoot) return; + var cond = ie_collectCond(condRoot); + _ie_setCond(ed, secPath, idx, kind, cond); +} + // ── Navigation in condition tree ── function ie_condAt(cond, path) { @@ -48,7 +182,6 @@ function ie_condAt(cond, path) { return cur; } -// Replaces the condition at path within the overall tree. Returns new root. function ie_condReplace(cond, path, newVal) { if (!path) return newVal; if (!cond) return cond; @@ -72,13 +205,7 @@ function ie_condReplace(cond, path, newVal) { } // ── Clean condition tree for save ── -// -// The live editor tree intentionally holds empty leaves/groups while the user -// is building up a condition (so clicking a second leaf button adds a sibling -// rather than replacing the first). This walker strips those in-progress -// artifacts before save: drops leaves whose value is missing/empty, drops -// 0-child groups, and collapses 1-child non-NOT groups to their child. min_credits -// keeps the value 0 (falsy in JS but a valid threshold). + function ie_cleanCondition(cond) { if (!cond || typeof cond !== 'object') return null; if (cond.all_of || cond.any_of) { @@ -91,7 +218,6 @@ function ie_cleanCondition(cond) { r[mode] = cleaned; return r; } - // Leaf var not = !!cond.not; var leaf = null; if (cond.global_flag !== undefined) { @@ -104,6 +230,8 @@ function ie_cleanCondition(cond) { leaf = { player_flag: cond.player_flag }; if (cond.value !== undefined) leaf.value = cond.value; } + } else if (cond.room !== undefined) { + if (cond.room) leaf = { room: parseInt(cond.room, 10) || 0 }; } else if (cond.has_item !== undefined) { if (cond.has_item) leaf = { has_item: cond.has_item }; } else if (cond.min_credits !== undefined) { @@ -120,12 +248,6 @@ function ie_cleanCondition(cond) { function ie_collectCond(rootEl) { if (!rootEl) return null; - // A .ie-cond-root wraps either a single group (.ie-cond) or a single bare - // leaf (.ie-cond-leaf). Unwrap it so sync is the pure inverse of render: - // cond-root > .ie-cond(group) -> ie_collectCond(.ie-cond) - // cond-root > .ie-cond-leaf -> ie_collectLeaf(leafEl) - // The old version recursed into the .ie-cond child and then wrapped the - // result, producing {all_of:[{all_of:[...]}]} for any group condition. if (rootEl.classList.contains('ie-cond-root')) { var groupEl = rootEl.querySelector(':scope > .ie-cond'); if (groupEl) return ie_collectCond(groupEl); @@ -134,7 +256,6 @@ function ie_collectCond(rootEl) { return null; } - // rootEl is a .ie-cond group box. var mode = 'all_of'; var modeSel = rootEl.querySelector(':scope > .ie-cond-modebar .ie-cond-modesel'); if (modeSel) mode = modeSel.value; @@ -155,13 +276,6 @@ function ie_collectCond(rootEl) { }); } - // Preserve empty groups (no modebar rendered for <2 children, so empty groups - // always serialize as {all_of:[]}). Without this, "+ Group" would vanish on - // the next sync before the user could add their first leaf, and the - // subsequent "+ leaf" would turn it into a bare leaf — silently dropping - // the user's group intent. Empty {all_of:[]} is harmless game-side - // (checkCondition returns true, equivalent to no condition) and - // ced_cleanOutput strips it from saved YAML. var result = {}; if (not) result.not = true; result[mode] = items; @@ -178,11 +292,6 @@ function ie_collectLeaf(el) { var valInput = el.querySelector('.ie-cond-val'); var val = valInput ? valInput.value.trim() : ''; - // Always return the leaf (even when val is empty) so the in-memory - // condition tree keeps "in-progress" leaves the user just added. Without - // this, clicking a second leaf button would sync, see null, and wipe the - // first leaf — breaking multi-add and nested-group building. ie_cleanCondition - // strips empty leaves at save time. switch (leafType) { case 'global_flag': result.global_flag = val; @@ -200,6 +309,9 @@ function ie_collectLeaf(el) { try { result.value = JSON.parse(sv); } catch(e) { result.value = sv; } } break; + case 'room': + result.room = parseInt(val) || 0; + break; case 'has_item': result.has_item = val; break; @@ -212,123 +324,30 @@ function ie_collectLeaf(el) { return result; } -// ── Collect action from DOM ── - -function ie_collectAct(rootEl) { - if (!rootEl) return null; - var r = {}; - - // Messages — collected from the extendable delay+message rows. - var msgsKv = rootEl.querySelector('.ie-act-kv[data-ie-act-key="messages"]'); - if (msgsKv) { - var list = []; - msgsKv.querySelectorAll('.ie-kv-row').forEach(function(row) { - var delayEl = row.querySelector('.ie-act-msg-delay'); - var msgEl = row.querySelector('.ie-act-msg'); - var d = delayEl ? parseInt(delayEl.value) || 0 : 0; - var m = msgEl ? msgEl.value.trim() : ''; - list.push({ message: m, delay: d }); - }); - r.messages = list; - } - - // KV-type effects — include the map even when empty (as long as the - // .ie-act-kv element exists in the DOM). This lets a just-added section - // (e.g. "+ Set Player Flags" → no rows yet) survive the next sync instead - // of being dropped on the next add-effect call. ced_cleanOutput strips - // empty maps at save time. - rootEl.querySelectorAll('.ie-act-kv').forEach(function(kvEl) { - var key = kvEl.getAttribute('data-ie-act-key'); - if (!key) return; - if (key === 'messages') return; - var map = {}; - kvEl.querySelectorAll('.ie-kv-row').forEach(function(row) { - var kEl = row.querySelector('.ie-kv-key'); - var vEl = row.querySelector('.ie-kv-val'); - if (kEl && kEl.value.trim() && vEl) { - var v = vEl.value.trim(); - var num = parseFloat(v); - map[kEl.value.trim()] = v === 'true' ? true : (v === 'false' ? false : (!isNaN(num) ? num : v)); - } - }); - r[key] = map; - }); - - // Scalar effects — preserve empty strings so a present-but-unfilled input - // round-trips on every sync. - rootEl.querySelectorAll('.ie-act-input').forEach(function(el) { - var key = el.getAttribute('data-ie-act-key'); - if (!key) return; - var kind = el.getAttribute('data-ie-act-kind'); - if (kind === 'checkbox') { - r[key] = el.checked; - } else if (kind === 'number') { - var n = parseFloat(el.value); - if (isNaN(n)) return; - r[key] = n; - } else { - r[key] = el.value.trim(); - } - }); - - if (Object.keys(r).length === 0) return null; - return r; -} - -// ── Sync all interaction DOM back to data ── - -function ie_syncAllInteractions(data, secs) { - if (!data || !secs) return; - secs.forEach(function(sec) { - if (!sec.interactionStyle || !sec.isArray) return; - var p = sec.path; - if (!data[p]) return; - var card = document.querySelector('.obj-card[data-card="' + sec.id + '"]'); - if (!card) return; - var rows = card.querySelectorAll('.obj-sub-row'); - rows.forEach(function(row, idx) { - if (!data[p][idx]) data[p][idx] = {}; - var condRoot = row.querySelector('.ie-cond-root'); - if (condRoot) { - var cond = ie_collectCond(condRoot); - if (cond) data[p][idx].condition = cond; - else delete data[p][idx].condition; - } - var actRoot = row.querySelector('.ie-act-root'); - if (actRoot) { - var act = ie_collectAct(actRoot); - if (act) data[p][idx].action = act; - else delete data[p][idx].action; - } - }); - }); -} - // ── Render condition ── -function ie_renderCond(condObj, secId, idx, secPath) { +function ie_renderCond(condObj, secId, idx, secPath, kind) { + kind = kind || 'entry'; var h = ''; var isGroup = condObj && (condObj.all_of || condObj.any_of); if (condObj && typeof condObj === 'object') { h += '
'; - h += ie_renderGroup(condObj, secId, secPath, idx, ''); + h += ie_renderGroup(condObj, secId, secPath, idx, '', kind); h += '
'; } - // For a group root, the group's own addbar (rendered inside _ie_renderGroupNode) - // already covers appending — don't emit a duplicate top-level addbar. var showAddbar = !isGroup; if (showAddbar) { h += '
'; IE_COND_TYPES.forEach(function(ct) { - h += ''; + h += ''; }); - h += ''; + h += ''; h += '
'; } return h; } -function ie_renderGroup(cond, secId, secPath, idx, gpath) { +function ie_renderGroup(cond, secId, secPath, idx, gpath, kind) { var mode = null, children = null; if (cond.all_of) { mode = 'all_of'; children = cond.all_of; } else if (cond.any_of) { mode = 'any_of'; children = cond.any_of; } @@ -336,74 +355,74 @@ function ie_renderGroup(cond, secId, secPath, idx, gpath) { var not = !!cond.not; if (mode && children) { - return _ie_renderGroupNode(mode, children, not, secId, secPath, idx, gpath); + return _ie_renderGroupNode(mode, children, not, secId, secPath, idx, gpath, kind); } - // Leaf - if (cond.global_flag !== undefined) return _ie_renderLeaf('global_flag', cond.global_flag, cond.value, not, secId, secPath, idx, gpath, IE_ROOT_LEAF); - if (cond.player_flag !== undefined) return _ie_renderLeaf('player_flag', cond.player_flag, cond.value, not, secId, secPath, idx, gpath, IE_ROOT_LEAF); - if (cond.has_item !== undefined) return _ie_renderLeaf('has_item', cond.has_item, '', not, secId, secPath, idx, gpath, IE_ROOT_LEAF); - if (cond.min_credits !== undefined) return _ie_renderLeaf('min_credits', cond.min_credits, '', not, secId, secPath, idx, gpath, IE_ROOT_LEAF); + if (cond.global_flag !== undefined) return _ie_renderLeaf('global_flag', cond.global_flag, cond.value, not, secId, secPath, idx, gpath, IE_ROOT_LEAF, kind); + if (cond.player_flag !== undefined) return _ie_renderLeaf('player_flag', cond.player_flag, cond.value, not, secId, secPath, idx, gpath, IE_ROOT_LEAF, kind); + if (cond.room !== undefined) return _ie_renderLeaf('room', cond.room, '', not, secId, secPath, idx, gpath, IE_ROOT_LEAF, kind); + if (cond.has_item !== undefined) return _ie_renderLeaf('has_item', cond.has_item, '', not, secId, secPath, idx, gpath, IE_ROOT_LEAF, kind); + if (cond.min_credits !== undefined) return _ie_renderLeaf('min_credits', cond.min_credits, '', not, secId, secPath, idx, gpath, IE_ROOT_LEAF, kind); return ''; } -function _ie_renderGroupNode(mode, children, not, secId, secPath, idx, gpath) { +function _ie_renderGroupNode(mode, children, not, secId, secPath, idx, gpath, kind) { var h = ''; if (children.length === 0) { - // Empty group: single line — addbar buttons left, X right. h += '
'; h += '
'; IE_COND_TYPES.forEach(function(ct) { - h += ''; + h += ''; }); - h += ''; - h += ''; + h += ''; + h += ''; h += '
'; h += '
'; return h; } - // Non-empty group: modebar (no X) + children + addbar below. h += '
'; h += '
'; if (children.length >= 2) { - h += ''; h += ''; h += ''; h += ''; } if (children.length >= 2 || not) { - h += ''; + h += ''; } h += '
'; h += '
'; children.forEach(function(child, ci) { var cpath = gpath ? gpath + '-' + ci : String(ci); if (child.all_of || child.any_of) { - h += ie_renderGroup(child, secId, secPath, idx, cpath); + h += ie_renderGroup(child, secId, secPath, idx, cpath, kind); } else { var cval = '', csub = '', ctype = '', cnot = !!child.not; if (child.global_flag !== undefined) { ctype = 'global_flag'; cval = child.global_flag; csub = child.value; } else if (child.player_flag !== undefined) { ctype = 'player_flag'; cval = child.player_flag; csub = child.value; } + else if (child.room !== undefined) { ctype = 'room'; cval = child.room; } else if (child.has_item !== undefined) { ctype = 'has_item'; cval = child.has_item; } else if (child.min_credits !== undefined) { ctype = 'min_credits'; cval = child.min_credits; } - if (ctype) h += _ie_renderLeaf(ctype, cval, csub, cnot, secId, secPath, idx, gpath, ci); + if (ctype) h += _ie_renderLeaf(ctype, cval, csub, cnot, secId, secPath, idx, gpath, ci, kind); } }); h += '
'; h += '
'; IE_COND_TYPES.forEach(function(ct) { - h += ''; + h += ''; }); - h += ''; + h += ''; h += '
'; h += '
'; return h; } -function _ie_renderLeaf(leafType, leafVal, leafSub, not, secId, secPath, idx, gpath, ci) { +function _ie_renderLeaf(leafType, leafVal, leafSub, not, secId, secPath, idx, gpath, ci, kind) { + kind = kind || 'entry'; var label = ''; IE_COND_TYPES.forEach(function(ct) { if (ct.type === leafType) label = ct.label; }); var hint = ''; @@ -420,7 +439,7 @@ function _ie_renderLeaf(leafType, leafVal, leafSub, not, secId, secPath, idx, gp h += ''; h += ''; h += ''; - } else if (leafType === 'min_credits') { + } else if (leafType === 'min_credits' || leafType === 'room') { h += ''; } else { h += ''; @@ -429,64 +448,33 @@ function _ie_renderLeaf(leafType, leafVal, leafSub, not, secId, secPath, idx, gp h += '='; h += ''; } - h += ''; - h += ''; + h += ''; + h += ''; h += ''; return h; } -// ── Render action ── +// ── Render step effects (everything except messages and condition). +// The add-bar is rendered separately via ie_renderStepAddbar. ── -function ie_renderAct(actionObj, scope, secId, idx, secPath) { +function ie_renderAct(stepObj, secId, idx, si, secPath) { var hasAny = false; var rows = ''; - // Messages — extendable list of {delay, message} entries. Mirrors the KV-section - // pattern: when the 'messages' key is present we render the section with rows - // and an internal + Add; when it goes away (last row removed) the section - // disappears and the addbar + Add Message button reappears. There is no - // section-level X. - var msgsPresent = actionObj && Object.prototype.hasOwnProperty.call(actionObj, 'messages') && Array.isArray(actionObj.messages); - if (msgsPresent) { - var msgs = actionObj.messages; - hasAny = true; - var rmFn = 'ie_removeMessage(this,\'' + escAttr(secId) + '\',\'' + escAttr(secPath) + '\',' + idx + ')'; - rows += '
'; - rows += '
Messages
'; - rows += '
'; - if (msgs.length === 0) { - rows += '
'; - rows += ''; - rows += '
'; - } else { - msgs.forEach(function(m, mi) { - rows += '
'; - rows += ''; - rows += '
'; - }); - } - rows += '
'; - rows += ''; - rows += '
'; - } - - // Standard effects IE_ACT_TYPES.forEach(function(at) { - if (!actionObj || !Object.prototype.hasOwnProperty.call(actionObj, at.key)) return; - var val = actionObj[at.key]; + if (!stepObj || !Object.prototype.hasOwnProperty.call(stepObj, at.key)) return; + var val = stepObj[at.key]; if (at.kind === 'checkbox' && val !== true) return; hasAny = true; if (at.kind === 'kv') { var kvMap = (val && typeof val === 'object') ? val : {}; var kvKeys = Object.keys(kvMap); - var kvRmAttr = 'ie_removeKVRow(this,\'' + escAttr(secId) + '\',\'' + escAttr(secPath) + '\',' + idx + ',\'' + escAttr(at.key) + '\')'; + var kvRmAttr = 'ie_removeKVRow(this,\'' + escAttr(secId) + '\',\'' + escAttr(secPath) + '\',' + idx + ',' + si + ',\'' + escAttr(at.key) + '\')'; rows += '
'; rows += '
' + esc(at.label) + '
'; rows += '
'; if (kvKeys.length === 0) { - // Start with a blank row so there are input boxes immediately. The - // row's X acts as the section's only remove path (no header X). rows += '
'; rows += ''; rows += '
'; @@ -498,92 +486,239 @@ function ie_renderAct(actionObj, scope, secId, idx, secPath) { }); } rows += '
'; - rows += ''; + rows += ''; rows += '
'; } else if (at.kind === 'checkbox') { rows += '
' + esc(at.label) + ''; rows += ''; - rows += ''; + rows += ''; rows += '
'; } else if (at.kind === 'search') { rows += '
' + esc(at.label) + ''; rows += ''; - rows += ''; + rows += ''; rows += '
'; } else { rows += '
' + esc(at.label) + ''; rows += ''; - rows += ''; + rows += ''; rows += '
'; } }); - // Seq-only effects - if (scope === 'seq') { - IE_ACT_TYPES_SEQ.forEach(function(at) { - if (!actionObj || !Object.prototype.hasOwnProperty.call(actionObj, at.key)) return; - var val = actionObj[at.key]; - if (val === undefined || val === null) return; - hasAny = true; - - if (at.kind === 'search') { - rows += '
' + esc(at.label) + ''; - rows += ''; - rows += ''; - rows += '
'; - } else { - rows += '
' + esc(at.label) + ''; - rows += ''; - rows += ''; - rows += '
'; - } - }); + if (hasAny) { + return '
' + rows + '
'; } + return ''; +} + +// ── Render messages sub-list (plain strings) ── - // Add effect buttons - var addbar = '
'; - if (!msgsPresent) { - addbar += ''; +function ie_renderMessages(step, secId, idx, si, secPath) { + var present = step && Array.isArray(step.messages); + if (!present) { + return ''; } - IE_ACT_TYPES.forEach(function(at) { - if (actionObj && Object.prototype.hasOwnProperty.call(actionObj, at.key)) { - if (at.kind === 'checkbox') { - if (actionObj[at.key] === true) return; - } else { - return; + var msgs = step.messages; + var rmFn = 'ie_removeMessage(this,\'' + escAttr(secId) + '\',\'' + escAttr(secPath) + '\',' + idx + ',' + si + ')'; + var h = '
'; + h += '
Messages
'; + h += '
'; + msgs.forEach(function(m, mi) { + var upBtn = mi > 0 + ? '' + : ''; + var dnBtn = mi < msgs.length - 1 + ? '' + : ''; + h += '
' + upBtn + dnBtn + '
'; + }); + h += '
'; + h += ''; + h += '
'; + return h; +} + +// ── Render steps list of a trigger entry ── + +function ie_renderStepList(steps, secId, idx, secPath) { + steps = steps || []; + var h = '
'; + steps.forEach(function(step, si) { + var stepCond = step && step.condition && typeof step.condition === 'object'; + h += '
'; + h += '
'; + h += 'Step ' + (si + 1) + ''; + h += ''; + h += ''; + if (si > 0) h += ''; + if (si < steps.length - 1) h += ''; + h += ''; + h += '
'; + + h += '
'; + if (stepCond) { + h += ie_renderCond(step.condition || null, secId, idx, secPath, 'step-' + si); + } else { + h += ''; + } + h += '
'; + + h += '
' + ie_renderAct(step, secId, idx, si, secPath) + '
'; + h += '
' + ie_renderStepAddbar(step, secId, idx, si, secPath) + '
'; + h += '
' + ie_renderMessages(step, secId, idx, si, secPath) + '
'; + + h += '
'; + }); + h += '
' + ie_renderBottomAddbar(secId, secPath, idx) + '
'; + h += '
'; + return h; +} + +// ── Collect effects (everything except messages and condition) ── + +function ie_collectAct(container) { + if (!container) return null; + var r = {}; + + container.querySelectorAll('.ie-act-kv').forEach(function(kvEl) { + var key = kvEl.getAttribute('data-ie-act-key'); + if (!key) return; + if (key === 'messages') return; + var map = {}; + kvEl.querySelectorAll('.ie-kv-row').forEach(function(row) { + var kEl = row.querySelector('.ie-kv-key'); + var vEl = row.querySelector('.ie-kv-val'); + if (kEl && kEl.value.trim() && vEl) { + var v = vEl.value.trim(); + var num = parseFloat(v); + map[kEl.value.trim()] = v === 'true' ? true : (v === 'false' ? false : (!isNaN(num) ? num : v)); } + }); + r[key] = map; + }); + + container.querySelectorAll('.ie-act-input').forEach(function(el) { + var key = el.getAttribute('data-ie-act-key'); + if (!key) return; + var kind = el.getAttribute('data-ie-act-kind'); + if (kind === 'checkbox') { + r[key] = el.checked; + } else if (kind === 'number') { + var n = parseFloat(el.value); + if (isNaN(n)) return; + r[key] = n; + } else { + r[key] = el.value.trim(); } - addbar += ''; }); - if (scope === 'seq') { - IE_ACT_TYPES_SEQ.forEach(function(at) { - if (actionObj && Object.prototype.hasOwnProperty.call(actionObj, at.key)) return; - addbar += ''; + + if (Object.keys(r).length === 0) return null; + return r; +} + +// ── Collect messages (plain strings) ── + +function ie_collectMessages(container) { + if (!container) return null; + var msgsKv = container.querySelector('.ie-act-kv[data-ie-act-key="messages"]'); + if (!msgsKv) return null; + var list = []; + msgsKv.querySelectorAll('.ie-kv-row .ie-act-msg').forEach(function(el) { + list.push(el.value.trim()); + }); + return list; +} + +// ── Sync a single step's DOM back into data ── + +function ie_syncStep(ed, secId, secPath, idx, si) { + var card = document.querySelector('.obj-card[data-card="' + secId + '"]'); + if (!card) return; + var stepRow = card.querySelector('.obj-sub-row.ie-inter-row[data-idx="' + idx + '"] .ie-step-row[data-step="' + si + '"]'); + if (!stepRow) return; + if (!ed[secPath] || !ed[secPath][idx] || !ed[secPath][idx].steps || !ed[secPath][idx].steps[si]) return; + var step = ed[secPath][idx].steps[si]; + + var condRoot = stepRow.querySelector(':scope > .ie-step-cond-panel .ie-cond-root'); + if (condRoot) { + var cond = ie_collectCond(condRoot); + if (cond) step.condition = cond; else delete step.condition; + } + + var effContainer = stepRow.querySelector(':scope > .ie-step-effects'); + if (effContainer) { + var act = ie_collectAct(effContainer); + if (act) { + Object.keys(act).forEach(function(k) { step[k] = act[k]; }); + } + // Drop keys the user X'd out (no longer present in the DOM among the rendered IE_ACT_TYPES set). + IE_ACT_TYPES.forEach(function(at) { + if (at.key === 'messages') return; + if (!Object.prototype.hasOwnProperty.call(act || {}, at.key)) { + if (Object.prototype.hasOwnProperty.call(step, at.key)) delete step[at.key]; + } }); } - addbar += '
'; - // Only wrap the content + addbar in .ie-act-root when there's actual content. - // An empty action just emits the addbar (mirroring ie_renderCond, which - // emits an unwrapped addbar for an empty condition). This keeps the empty - // condition and empty action panels visually symmetric — one box, not two. - if (hasAny) { - return '
' + rows + addbar + '
'; + var msgsContainer = stepRow.querySelector(':scope > .ie-step-messages'); + if (msgsContainer) { + var list = ie_collectMessages(msgsContainer); + if (list !== null) step.messages = list; + else delete step.messages; + if (Array.isArray(step.messages) && step.messages.length === 0) { + // keep empty array — survives next render, ced_cleanOutput will strip + // it on save. + } } - return addbar; +} + +// ── Sync all interaction DOM back to data ── + +function ie_syncAllInteractions(data, secs) { + if (!data || !secs) return; + secs.forEach(function(sec) { + if (!sec.interactionStyle || !sec.isArray) return; + var p = sec.path; + if (!data[p]) return; + var card = document.querySelector('.obj-card[data-card="' + sec.id + '"]'); + if (!card) return; + var rows = card.querySelectorAll('.obj-sub-row.ie-inter-row'); + rows.forEach(function(row, idx) { + if (!data[p][idx]) data[p][idx] = {}; + var lockCb = row.querySelector(':scope > .ie-inter-header .ie-lock-cb'); + if (lockCb) { + if (lockCb.checked) data[p][idx].lock = true; + else delete data[p][idx].lock; + } + var entryCondRoot = row.querySelector(':scope > .ie-inter-panel .ie-cond-root'); + if (entryCondRoot) { + var cond = ie_collectCond(entryCondRoot); + if (cond) data[p][idx].condition = cond; + else delete data[p][idx].condition; + } + if (!data[p][idx].steps) data[p][idx].steps = []; + var stepRows = row.querySelectorAll(':scope > .ie-inter-steps .ie-step-row'); + stepRows.forEach(function(stepRow) { + var si = parseInt(stepRow.getAttribute('data-step'), 10); + if (isNaN(si)) return; + ie_syncStep(data, sec.id, p, idx, si); + }); + }); + }); } // ── Add/Remove/Toggle: Condition ── -function ie_addLeaf(secId, secPath, idx, groupPath, leafType) { +function ie_addLeaf(secId, secPath, idx, kind, groupPath, leafType) { var ed = window._editorData; if (!ed) return; - ie_syncCondEntry(ed, secId, secPath, idx); - var cond = (ed[secPath] && ed[secPath][idx]) ? ed[secPath][idx].condition : null; + _ie_syncCond(ed, secId, secPath, idx, kind); + var cond = _ie_getCond(ed, secPath, idx, kind); var group = ie_condAt(cond, groupPath); var leaf = {}; - leaf[leafType] = leafType === 'min_credits' ? 0 : ''; + leaf[leafType] = (leafType === 'min_credits' || leafType === 'room') ? 0 : ''; var newGroup; if (!group) { @@ -599,28 +734,21 @@ function ie_addLeaf(secId, secPath, idx, groupPath, leafType) { } var newCond = ie_condReplace(cond, groupPath, newGroup); - if (!ed[secPath][idx]) ed[secPath][idx] = {}; - if (newCond) ed[secPath][idx].condition = newCond; - else delete ed[secPath][idx].condition; + _ie_setCond(ed, secPath, idx, kind, newCond); ced_syncDOMToData(ed); window._ieRenderOnly(); } -function ie_removeCond(secId, secPath, idx, groupPath, childIdx) { +function ie_removeCond(secId, secPath, idx, kind, groupPath, childIdx) { var ed = window._editorData; if (!ed) return; - ie_syncCondEntry(ed, secId, secPath, idx); - var cond = (ed[secPath] && ed[secPath][idx]) ? ed[secPath][idx].condition : null; + _ie_syncCond(ed, secId, secPath, idx, kind); + var cond = _ie_getCond(ed, secPath, idx, kind); - // Removing the single root leaf. Note: children of a *root-level group* - // are also rendered with groupPath='' (and a real child index), so the - // test must be childIdx, not groupPath, otherwise removing any leaf from - // a root group wipes the entire condition. if (childIdx === IE_ROOT_LEAF) { - if (!ed[secPath][idx]) ed[secPath][idx] = {}; - delete ed[secPath][idx].condition; + _ie_setCond(ed, secPath, idx, kind, null); ced_syncDOMToData(ed); - window._ieRenderOnly(); + window._ieRenderOnly(); return; } @@ -635,8 +763,6 @@ function ie_removeCond(secId, secPath, idx, groupPath, childIdx) { if (children.length === 0) { newGroup = null; } else if (children.length === 1 && !group.not) { - // Collapse a single-child non-NOT group to the child itself so removal - // doesn't leave a one-child all_of/any_of wrapper in the live tree. newGroup = children[0]; } else { newGroup = {}; @@ -644,36 +770,28 @@ function ie_removeCond(secId, secPath, idx, groupPath, childIdx) { newGroup[mode] = children; } var newCond = ie_condReplace(cond, groupPath, newGroup); - if (!ed[secPath][idx]) ed[secPath][idx] = {}; - if (newCond) ed[secPath][idx].condition = newCond; - else delete ed[secPath][idx].condition; + _ie_setCond(ed, secPath, idx, kind, newCond); } else { var nc = ie_condReplace(cond, groupPath, null); - if (!ed[secPath][idx]) ed[secPath][idx] = {}; - if (nc) ed[secPath][idx].condition = nc; - else delete ed[secPath][idx].condition; + _ie_setCond(ed, secPath, idx, kind, nc); } ced_syncDOMToData(ed); window._ieRenderOnly(); } -function ie_removeGroup(secId, secPath, idx, groupPath) { +function ie_removeGroup(secId, secPath, idx, kind, groupPath) { var ed = window._editorData; if (!ed) return; - ie_syncCondEntry(ed, secId, secPath, idx); - if (!ed[secPath] || !ed[secPath][idx]) return; - var cond = ed[secPath][idx].condition; + _ie_syncCond(ed, secId, secPath, idx, kind); + var cond = _ie_getCond(ed, secPath, idx, kind); - // Root group: removing the entire condition. if (!groupPath) { - delete ed[secPath][idx].condition; + _ie_setCond(ed, secPath, idx, kind, null); ced_syncDOMToData(ed); - window._ieRenderOnly(); + window._ieRenderOnly(); return; } - // Non-root group: gpath is the group's own path. Parent path = gpath minus - // last segment; childIdx = last segment. var parts = groupPath.split('-').map(Number); var childIdx = parts[parts.length - 1]; var parentPath = parts.slice(0, -1).join('-'); @@ -696,41 +814,41 @@ function ie_removeGroup(secId, secPath, idx, groupPath) { } var newCond = ie_condReplace(cond, parentPath, newParent); - if (newCond) ed[secPath][idx].condition = newCond; - else delete ed[secPath][idx].condition; + _ie_setCond(ed, secPath, idx, kind, newCond); ced_syncDOMToData(ed); window._ieRenderOnly(); } -function ie_addRootConditionGroup(secPath, idx) { +function ie_addRootConditionGroup(secPath, idx, kind) { var ed = window._editorData; if (!ed) return; if (!ed[secPath]) ed[secPath] = []; if (!ed[secPath][idx]) ed[secPath][idx] = {}; - if (!ed[secPath][idx].condition || typeof ed[secPath][idx].condition !== 'object') { - ed[secPath][idx].condition = {all_of: []}; + var si = _ie_kindStep(kind); + if (si < 0) { + if (!ed[secPath][idx].condition || typeof ed[secPath][idx].condition !== 'object') { + ed[secPath][idx].condition = {all_of: []}; + } + } else { + if (!ed[secPath][idx].steps) ed[secPath][idx].steps = []; + if (!ed[secPath][idx].steps[si]) ed[secPath][idx].steps[si] = {}; + if (!ed[secPath][idx].steps[si].condition || typeof ed[secPath][idx].steps[si].condition !== 'object') { + ed[secPath][idx].steps[si].condition = {all_of: []}; + } } ced_syncDOMToData(ed); window._ieRenderOnly(); } -function ie_addEffectToInteraction(secPath, idx) { - var ed = window._editorData; - if (!ed) return; - if (!ed[secPath]) ed[secPath] = []; - if (!ed[secPath][idx]) ed[secPath][idx] = {}; - if (!ed[secPath][idx].action || typeof ed[secPath][idx].action !== 'object') { - ed[secPath][idx].action = {}; - } - ced_syncDOMToData(ed); - window._ieRenderOnly(); +function ie_addStepCond(secId, secPath, idx, si) { + ie_addRootConditionGroup(secPath, idx, 'step-' + si); } -function ie_addGroup(secId, secPath, idx, groupPath) { +function ie_addGroup(secId, secPath, idx, kind, groupPath) { var ed = window._editorData; if (!ed) return; - ie_syncCondEntry(ed, secId, secPath, idx); - var cond = (ed[secPath] && ed[secPath][idx]) ? ed[secPath][idx].condition : null; + _ie_syncCond(ed, secId, secPath, idx, kind); + var cond = _ie_getCond(ed, secPath, idx, kind); var group = ie_condAt(cond, groupPath); var newSub = {all_of: []}; @@ -749,17 +867,16 @@ function ie_addGroup(secId, secPath, idx, groupPath) { } var newCond = ie_condReplace(cond, groupPath, newGroup); - if (!ed[secPath][idx]) ed[secPath][idx] = {}; - ed[secPath][idx].condition = newCond; + _ie_setCond(ed, secPath, idx, kind, newCond); ced_syncDOMToData(ed); window._ieRenderOnly(); } -function ie_toggleCondMode(secId, secPath, idx, groupPath) { +function ie_toggleCondMode(secId, secPath, idx, kind, groupPath) { var ed = window._editorData; if (!ed) return; - ie_syncCondEntry(ed, secId, secPath, idx); - var cond = (ed[secPath] && ed[secPath][idx]) ? ed[secPath][idx].condition : null; + _ie_syncCond(ed, secId, secPath, idx, kind); + var cond = _ie_getCond(ed, secPath, idx, kind); var group = ie_condAt(cond, groupPath); if (!group || !group.all_of && !group.any_of) return; @@ -770,291 +887,257 @@ function ie_toggleCondMode(secId, secPath, idx, groupPath) { newGroup[oldMode === 'all_of' ? 'any_of' : 'all_of'] = children; var newCond = ie_condReplace(cond, groupPath, newGroup); - ed[secPath][idx].condition = newCond; + _ie_setCond(ed, secPath, idx, kind, newCond); ced_syncDOMToData(ed); window._ieRenderOnly(); } -function ie_toggleCondNot(secId, secPath, idx, groupPath) { +function ie_toggleCondNot(secId, secPath, idx, kind, groupPath) { + ced_syncDOMToData(window._editorData); + window._ieRenderOnly(); +} + +function ie_toggleNotLeaf(secId, secPath, idx, kind, groupPath, ci) { + ced_syncDOMToData(window._editorData); + window._ieRenderOnly(); +} + +// ── Add/Remove: Steps ── + +function ie_removeStep(secId, secPath, idx, si) { var ed = window._editorData; if (!ed) return; - ie_syncCondEntry(ed, secId, secPath, idx); ced_syncDOMToData(ed); - window._ieRenderOnly(); + if (window._ieSyncAll) window._ieSyncAll(ed); + if (!ed[secPath] || !ed[secPath][idx] || !ed[secPath][idx].steps) return; + ed[secPath][idx].steps.splice(si, 1); + window._ieRenderCurrent(); } -function ie_toggleNotLeaf(secId, secPath, idx, groupPath, ci) { +function ie_moveStep(secId, secPath, idx, fromSi, toSi) { var ed = window._editorData; if (!ed) return; - ie_syncCondEntry(ed, secId, secPath, idx); ced_syncDOMToData(ed); + if (window._ieSyncAll) window._ieSyncAll(ed); + var steps = ed[secPath] && ed[secPath][idx] && ed[secPath][idx].steps; + if (!steps || fromSi < 0 || fromSi >= steps.length || toSi < 0 || toSi >= steps.length) return; + var m = steps.splice(fromSi, 1)[0]; + steps.splice(toSi, 0, m); + window._ieRenderCurrent(); +} + +function ie_moveRow(secPath, fromIdx, toIdx) { + var ed = window._editorData; + if (!ed) return; + ced_syncDOMToData(ed); + if (window._ieSyncAll) window._ieSyncAll(ed); + var arr = ed[secPath]; + if (!arr || fromIdx < 0 || fromIdx >= arr.length || toIdx < 0 || toIdx >= arr.length) return; + var m = arr.splice(fromIdx, 1)[0]; + arr.splice(toIdx, 0, m); + window._ieRenderCurrent(); +} + +function ie_moveMessage(secId, secPath, idx, si, fromMi, toMi) { + var ed = window._editorData; + if (!ed) return; + ced_syncDOMToData(ed); + ie_syncStep(ed, secId, secPath, idx, si); + var step = ed[secPath] && ed[secPath][idx] && ed[secPath][idx].steps && ed[secPath][idx].steps[si]; + if (!step || !Array.isArray(step.messages)) return; + if (fromMi < 0 || fromMi >= step.messages.length || toMi < 0 || toMi >= step.messages.length) return; + var m = step.messages.splice(fromMi, 1)[0]; + step.messages.splice(toMi, 0, m); window._ieRenderOnly(); } -// ── Add/Remove: Action ── +// ── Add/Remove: Action effects ── -function ie_addActEffect(secId, secPath, idx, effectKey, scope) { +function ie_addActEffect(secId, secPath, idx, si, effectKey) { var ed = window._editorData; if (!ed) return; - ie_syncActEntry(ed, secId, secPath, idx); + ie_syncStep(ed, secId, secPath, idx, si); if (!ed[secPath]) ed[secPath] = []; if (!ed[secPath][idx]) ed[secPath][idx] = {}; - if (!ed[secPath][idx].action) ed[secPath][idx].action = {}; - - var act = ed[secPath][idx].action; - if (effectKey === 'messages') { - act.messages = []; + if (!ed[secPath][idx].steps) ed[secPath][idx].steps = []; + if (!ed[secPath][idx].steps[si]) ed[secPath][idx].steps[si] = {}; + var step = ed[secPath][idx].steps[si]; + + var at = IE_ACT_TYPES.find(function(a) { return a.key === effectKey; }); + if (!at) return; + if (at.kind === 'kv') { + step[effectKey] = {}; + } else if (at.kind === 'checkbox') { + step[effectKey] = true; + } else if (at.kind === 'number') { + step[effectKey] = 0; } else { - var at = IE_ACT_TYPES.find(function(a) { return a.key === effectKey; }); - var at2 = IE_ACT_TYPES_SEQ.find(function(a) { return a.key === effectKey; }); - var info = at || at2; - if (!info) return; - if (info.kind === 'kv') { - act[effectKey] = {}; - } else if (info.kind === 'checkbox') { - act[effectKey] = true; - } else if (info.kind === 'number') { - act[effectKey] = 0; - } else { - act[effectKey] = ''; - } + step[effectKey] = ''; } ced_syncDOMToData(ed); window._ieRenderOnly(); } -function ie_removeActEffect(secId, secPath, idx, effectKey) { +function ie_removeActEffect(secId, secPath, idx, si, effectKey) { var ed = window._editorData; if (!ed) return; - ie_syncActEntry(ed, secId, secPath, idx); - if (!ed[secPath] || !ed[secPath][idx] || !ed[secPath][idx].action) return; - delete ed[secPath][idx].action[effectKey]; - if (Object.keys(ed[secPath][idx].action).length === 0) { - delete ed[secPath][idx].action; - } + ie_syncStep(ed, secId, secPath, idx, si); + var step = ed[secPath] && ed[secPath][idx] && ed[secPath][idx].steps && ed[secPath][idx].steps[si]; + if (!step) return; + delete step[effectKey]; ced_syncDOMToData(ed); window._ieRenderOnly(); } -// ── Add/Remove: KV action row ── -// -// KV rows are added/removed through these handlers rather than inline DOM -// manipulation so removal can drop the whole effect when the last row goes. -// "+ Add" appends a blank row to the DOM (no data change yet — it joins the -// map on the next sync, the same way typing into an existing row does). -// Removal deletes the row's key from the data map; if the map empties out the -// whole action key is dropped so the section "disappears when all flags are -// removed" (per the spec) and re-renders with a single starter row only when -// the user clicks the addbar "+ Set ... Flags" again. -function ie_addKVRow(secId, secPath, idx, effectKey) { +// ── Add/Remove: KV row ── + +function ie_addKVRow(secId, secPath, idx, si, effectKey) { var ed = window._editorData; if (!ed) return; - // Sync first so any in-progress typing in the existing rows is captured. - ie_syncActEntry(ed, secId, secPath, idx); - // Append a blank row directly to the DOM (no data change yet). + ie_syncStep(ed, secId, secPath, idx, si); var card = document.querySelector('.obj-card[data-card="' + secId + '"]'); if (!card) return; - var rows = card.querySelectorAll('.obj-sub-row'); - var row = rows[idx]; - if (!row) return; - var list = row.querySelector('.ie-act-kv[data-ie-act-key="' + effectKey + '"] .ie-kv-list'); + var stepRow = card.querySelector('.obj-sub-row.ie-inter-row[data-idx="' + idx + '"] .ie-step-row[data-step="' + si + '"]'); + if (!stepRow) return; + var list = stepRow.querySelector('.ie-act-kv[data-ie-act-key="' + effectKey + '"] .ie-kv-list'); if (!list) return; var rowEl = document.createElement('div'); rowEl.className = 'ie-kv-row'; - rowEl.innerHTML = ''; + rowEl.innerHTML = ''; list.appendChild(rowEl); rowEl.querySelector('.ie-kv-key').focus(); } -function ie_removeKVRow(rowEl, secId, secPath, idx, effectKey) { +function ie_removeKVRow(rowEl, secId, secPath, idx, si, effectKey) { var ed = window._editorData; if (!ed) return; - // rowEl may be the X button itself (callers use `this` in inline onclick) — - // walk up to the actual .ie-kv-row so the key input below is found. if (rowEl && rowEl.closest) { var kvRow = rowEl.closest('.ie-kv-row'); if (kvRow) rowEl = kvRow; } - ie_syncActEntry(ed, secId, secPath, idx); - if (!ed[secPath] || !ed[secPath][idx] || !ed[secPath][idx].action) return; - var act = ed[secPath][idx].action; - if (!act.hasOwnProperty(effectKey)) return; + ie_syncStep(ed, secId, secPath, idx, si); + var step = ed[secPath] && ed[secPath][idx] && ed[secPath][idx].steps && ed[secPath][idx].steps[si]; + if (!step || !step.hasOwnProperty(effectKey)) return; var keyEl = rowEl.querySelector('.ie-kv-key'); var k = keyEl ? keyEl.value.trim() : ''; - if (k && act[effectKey].hasOwnProperty(k)) delete act[effectKey][k]; - // Empty-key rows are not in the data map at all; nothing to delete there. - if (Object.keys(act[effectKey]).length === 0) { - delete act[effectKey]; - if (Object.keys(act).length === 0) delete ed[secPath][idx].action; + if (k && step[effectKey].hasOwnProperty(k)) delete step[effectKey][k]; + if (Object.keys(step[effectKey]).length === 0) { + delete step[effectKey]; } ced_syncDOMToData(ed); window._ieRenderOnly(); } -// ── Add/Remove: Messages list row ── +// ── Add/Remove: Messages ── + +function ie_addMessages(secId, secPath, idx, si) { + var ed = window._editorData; + if (!ed) return; + ie_syncStep(ed, secId, secPath, idx, si); + if (!ed[secPath] || !ed[secPath][idx] || !ed[secPath][idx].steps || !ed[secPath][idx].steps[si]) return; + ed[secPath][idx].steps[si].messages = ['']; + ced_syncDOMToData(ed); + window._ieRenderOnly(); +} -// ie_addMessage appends a blank delay+message row to the DOM (no data change -// yet — it joins the list on the next sync, the same way typing into an -// existing row does). The Messages section must already be present (created -// via + Add Message in the addbar). Focuses the new row's message input. -function ie_addMessage(secId, secPath, idx) { +function ie_addMessage(secId, secPath, idx, si) { var ed = window._editorData; if (!ed) return; - ie_syncActEntry(ed, secId, secPath, idx); + ie_syncStep(ed, secId, secPath, idx, si); var card = document.querySelector('.obj-card[data-card="' + secId + '"]'); if (!card) return; - var rows = card.querySelectorAll('.obj-sub-row'); - var row = rows[idx]; - if (!row) return; - var list = row.querySelector('.ie-act-kv[data-ie-act-key="messages"] .ie-kv-list'); + var stepRow = card.querySelector('.obj-sub-row.ie-inter-row[data-idx="' + idx + '"] .ie-step-row[data-step="' + si + '"]'); + if (!stepRow) return; + var list = stepRow.querySelector('.ie-act-kv[data-ie-act-key="messages"] .ie-kv-list'); if (!list) return; var rowEl = document.createElement('div'); - rowEl.className = 'ie-kv-row'; - var rmFn = 'ie_removeMessage(this,\'' + escAttr(secId) + '\',\'' + escAttr(secPath) + '\',' + idx + ')'; - rowEl.innerHTML = ''; + rowEl.className = 'ie-kv-row ie-msg-row'; + var rmFn = 'ie_removeMessage(this,\'' + escAttr(secId) + '\',\'' + escAttr(secPath) + '\',' + idx + ',' + si + ')'; + rowEl.innerHTML = ''; list.appendChild(rowEl); rowEl.querySelector('.ie-act-msg').focus(); } -// ie_removeMessage removes a message entry from the action's messages list. If -// the list becomes empty the entire messages key is dropped (and the section -// disappears on re-render). rowEl may be the X button itself. -function ie_removeMessage(rowEl, secId, secPath, idx) { +function ie_removeMessage(rowEl, secId, secPath, idx, si) { var ed = window._editorData; if (!ed) return; if (rowEl && rowEl.closest) { var kvRow = rowEl.closest('.ie-kv-row'); if (kvRow) rowEl = kvRow; } - ie_syncActEntry(ed, secId, secPath, idx); - if (!ed[secPath] || !ed[secPath][idx] || !ed[secPath][idx].action) return; - var act = ed[secPath][idx].action; - if (!act.hasOwnProperty('messages') || !Array.isArray(act.messages)) return; + ie_syncStep(ed, secId, secPath, idx, si); + var step = ed[secPath] && ed[secPath][idx] && ed[secPath][idx].steps && ed[secPath][idx].steps[si]; + if (!step || !Array.isArray(step.messages)) return; - // Find which row index to remove by locating our row in the list. var card = document.querySelector('.obj-card[data-card="' + secId + '"]'); - var subRows = card ? card.querySelectorAll('.obj-sub-row') : []; - var row = subRows[idx]; - if (row) { - var kvRows = row.querySelectorAll('.ie-act-kv[data-ie-act-key="messages"] .ie-kv-row'); + var stepRow = card.querySelector('.obj-sub-row.ie-inter-row[data-idx="' + idx + '"] .ie-step-row[data-step="' + si + '"]'); + if (stepRow) { + var kvRows = stepRow.querySelectorAll('.ie-act-kv[data-ie-act-key="messages"] .ie-kv-row'); for (var ri = 0; ri < kvRows.length; ri++) { if (kvRows[ri] === rowEl || kvRows[ri].contains(rowEl)) { - act.messages.splice(ri, 1); + step.messages.splice(ri, 1); break; } } } - if (act.messages.length === 0) { - delete act.messages; - if (Object.keys(act).length === 0) delete ed[secPath][idx].action; + if (step.messages.length === 0) { + delete step.messages; } ced_syncDOMToData(ed); window._ieRenderOnly(); } -// ── Pre-render sync helpers ── +// ── Shared trigger-style array-section renderer ── -function ie_syncCondEntry(ed, secId, secPath, idx) { - var card = document.querySelector('.obj-card[data-card="' + secId + '"]'); - if (!card) return; - var rows = card.querySelectorAll('.obj-sub-row'); - var row = rows[idx]; - if (!row) return; - var condRoot = row.querySelector('.ie-cond-root'); - if (!condRoot) return; - var cond = ie_collectCond(condRoot); - if (!ed[secPath]) ed[secPath] = []; - if (!ed[secPath][idx]) ed[secPath][idx] = {}; - if (cond) ed[secPath][idx].condition = cond; - else delete ed[secPath][idx].condition; -} - -function ie_syncActEntry(ed, secId, secPath, idx) { - var card = document.querySelector('.obj-card[data-card="' + secId + '"]'); - if (!card) return; - var rows = card.querySelectorAll('.obj-sub-row'); - var row = rows[idx]; - if (!row) return; - var actRoot = row.querySelector('.ie-act-root'); - if (!actRoot) return; - var act = ie_collectAct(actRoot); - if (!ed[secPath]) ed[secPath] = []; - if (!ed[secPath][idx]) ed[secPath][idx] = {}; - if (act) { - // Preserve any action keys that aren't rendered in the DOM (e.g. legacy - // seq-only effects on an inline section, or unknown future keys). Without - // this, the first sync after any edit would silently drop them. - var existing = (ed[secPath][idx].action && typeof ed[secPath][idx].action === 'object') ? ed[secPath][idx].action : null; - if (existing) { - Object.keys(existing).forEach(function(k) { - if (!Object.prototype.hasOwnProperty.call(act, k)) act[k] = existing[k]; - }); - } - ed[secPath][idx].action = act; - } else { - delete ed[secPath][idx].action; - } -} - -// ── Shared interaction-style array-section renderer ── -// -// Replaces the duplicated `renderArraySection` (objecteditor.js) and -// `renderMobArraySection` (mobeditor.js). -// -// ctx: { -// sec: section def (.id, .path, .isArray, .interactionStyle, .interScope, .fields, .label), -// data: editor's data root, -// visMap: per-editor vis object (e.g. window._interVis[sec.id]), -// showRemove(idx) => onclick string for "Remove Interaction", -// showField(secId, idx, key) => onclick string for "+ Add Required Item/...", -// hideField(secId, idx, key) => onclick string for the panel "X", -// renderField(sec, f, data, idx, optStyle) => HTML for the item_id field, -// onAdd() => onclick string for the bottom "+ Add ...", -// } function ie_renderArraySection(ctx) { var sec = ctx.sec; var data = ctx.data; var visMap = ctx.visMap; var arr = data[sec.path] || []; var h = ''; + var allowItem = sec.itemIDAllowed !== false; arr.forEach(function(item, idx) { visMap[idx] = visMap[idx] || {}; var vis = visMap[idx]; - var showItem = vis.item_id || (item.item_id && item.item_id !== ''); + var showItem = allowItem && (vis.item_id || (item.item_id && item.item_id !== '')); var condExists = !!(item.condition && typeof item.condition === 'object'); - var actExists = !!(item.action && typeof item.action === 'object'); + var lockVal = !!item.lock; + var steps = item.steps || []; h += '
'; h += '
'; - if (!showItem) { - h += ''; - } else { - h += ctx.renderField(sec, sec.fields[0], data, idx, 'flex:1;min-width:0'); - h += ''; + h += ''; + if (idx > 0) h += ''; + if (idx < arr.length - 1) h += ''; + + if (allowItem) { + if (!showItem) { + h += ''; + } else { + h += ctx.renderField(sec, sec.fields[0], data, idx, 'flex:1;min-width:0'); + h += ''; + } } if (!condExists) { - h += ''; - } - if (!actExists) { - h += ''; + h += ''; } h += ''; - h += ''; + h += ''; h += '
'; h += '
'; - h += '
' + ie_renderCond(item.condition || null, sec.id, idx, sec.path) + '
'; + h += '
' + ie_renderCond(item.condition || null, sec.id, idx, sec.path, 'entry') + '
'; h += '
'; - h += '
'; - h += '
' + ie_renderAct(item.action || null, sec.interScope || 'inline', sec.id, idx, sec.path) + '
'; + h += '
'; + h += ie_renderStepList(steps, sec.id, idx, sec.path); h += '
'; h += '
'; }); - h += ''; + h += ''; return h; } @@ -1072,12 +1155,37 @@ function ie_hideField(visMap, secId, idx, key) { visMap[secId][idx][key] = false; } +// ── Step-cleaning helper used by save paths ── + +function ie_cleanStep(step) { + var s = {}; + if (step && step.condition) { + var c = ie_cleanCondition(step.condition); + if (c) s.condition = c; + } + if (step && step.wait !== undefined && step.wait !== null && step.wait !== 0) s.wait = step.wait; + if (step && Array.isArray(step.messages) && step.messages.filter(function(m){return m;}).length > 0) { + s.messages = step.messages.filter(function(m){return m;}); + } + IE_ACT_TYPES.forEach(function(at) { + if (at.key === 'wait' || at.key === 'messages') return; + if (!step || !Object.prototype.hasOwnProperty.call(step, at.key)) return; + var v = step[at.key]; + if (v === undefined || v === null || v === '') return; + if (typeof v === 'object') { + if (Object.keys(v).length === 0) return; + s[at.key] = v; + } else if (typeof v === 'boolean') { + if (!v) return; + s[at.key] = v; + } else { + s[at.key] = v; + } + }); + return s; +} + // ── Bindings (set by each editor) ── -// _ieRenderCurrent: full sync + render. Used by chrome toggles -// (show/hide panels, add row, remove row, add section, etc.). -// _ieRenderOnly: render only, no sync. Used by ie_* mutation handlers -// that have already synced + mutated the data and must not re-read the -// stale DOM before the next render. window._ieRenderCurrent = function() {}; -window._ieRenderOnly = function() {}; +window._ieRenderOnly = function() {}; \ No newline at end of file diff --git a/internal/admin/static/map.js b/internal/admin/static/map.js index 00230a4..ab20a86 100644 --- a/internal/admin/static/map.js +++ b/internal/admin/static/map.js @@ -575,6 +575,8 @@ function renderSidePanel(data) { html += ''; html += ''; html += ''; + html += ''; + html += ''; html += '
'; html += '
'; @@ -595,6 +597,8 @@ function renderSidePanel(data) { html += renderCourseTab(); } } + else if (currentPanelTab === 8) html += renderEnterExitTab(r); + else if (currentPanelTab === 9) html += renderTriggersTab(r); html += '
'; panel.innerHTML = html; @@ -1401,6 +1405,8 @@ function switchPanelTab(idx) { html = renderCourseTab(); } } + 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; var tabs = document.querySelectorAll('.panel-tab'); @@ -2096,6 +2102,10 @@ async function doSavePanel() { r.mobs = currentRoomData.room.mobs; r.exits = currentRoomData.room.exits; 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; } @@ -3350,3 +3360,222 @@ document.addEventListener('DOMContentLoaded', function() { loadRoomDirs(function window.addEventListener('keydown', function(e) { if (isCtrlKey(e)) { ctrlHeld = true; if (dragging) refreshDragMode(true); } }); window.addEventListener('keyup', function(e) { if (isCtrlKey(e)) { ctrlHeld = false; if (dragging) refreshDragMode(false); } }); }); + +// ── Enter/Exit tab ── +// Renders on_enter and on_exit trigger lists, wiring the shared ie_* helpers +// from intereditor.js so the same condition/step/message editors used by the +// object/mob editors are reused here. Edits mutate currentRoomData.room.* +// and the debounced autoSave() persists them. + +function renderEnterExitTab(r) { + var onEnter = Array.isArray(r.on_enter) ? r.on_enter : []; + var onExit = Array.isArray(r.on_exit) ? r.on_exit : []; + window._editorData = { on_enter: onEnter, on_exit: onExit }; + + window._ieRenderCurrent = function() { + if (window._ieSyncAll) window._ieSyncAll(window._editorData, [ + {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'} + ]); + if (currentRoomData && currentRoomData.room) { + currentRoomData.room.on_enter = window._editorData.on_enter; + currentRoomData.room.on_exit = window._editorData.on_exit; + } + switchPanelTab(8); + autoSave(); + }; + window._ieRenderOnly = window._ieRenderCurrent; + window._ieSyncAll = ie_syncAllInteractions; + + var 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 += '

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. +function renderTriggerListHTML(arr, secId, secPath, allowItem) { + var visMap = {}; + arr = Array.isArray(arr) ? arr : []; + var sec = { + id: secId, + path: secPath, + isArray: true, + interactionStyle: true, + itemIDAllowed: allowItem, + label: 'Trigger', + fields: [['item_id', 'Item ID', 'search', 'e.g. keycard', '', null, 'items']] + }; + var ctx = { + sec: sec, + data: window._editorData, + visMap: visMap, + showField: function(sid, idx, key) { + ie_showField(visMap, sid, idx, key); + return 'void(0)'; + }, + hideField: function(sid, idx, key) { + ie_hideField(visMap, sid, idx, key); + window._ieRenderCurrent(); + return 'void(0)'; + }, + addCondition: function(sid, idx) { + return 'ie_addRootConditionGroup(\'' + sid + '\',' + idx + ',\'entry\')'; + }, + showRemove: function(idx) { + return '_mapRemoveTrigger(\'' + secPath + '\',' + idx + ')'; + }, + renderField: function(s, field, data, idx, style) { + var val = (data[secPath] && data[secPath][idx] && data[secPath][idx].item_id) || ''; + return ''; + }, + onAdd: function() { + 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; +} + +// _mapAddTrigger / _mapRemoveTrigger / _mapAddFlagTrigger: mutation helpers +// called from the inline onclick handlers. They sync the DOM, splice the +// _editorData array, and re-render. +function _mapAddTrigger(secPath) { + if (!window._editorData) return; + if (window._ieSyncAll) window._ieSyncAll(window._editorData, []); + if (!window._editorData[secPath]) window._editorData[secPath] = []; + window._editorData[secPath].push({steps: []}); + if (currentRoomData && currentRoomData.room) { + currentRoomData.room[secPath] = window._editorData[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); + if (currentRoomData && currentRoomData.room) { + currentRoomData.room[secPath] = window._editorData[secPath]; + } + 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 c2364c8..463af0a 100644 --- a/internal/admin/static/mobeditor.js +++ b/internal/admin/static/mobeditor.js @@ -121,12 +121,10 @@ var MOB_SECTIONS = [ ] }, { - id: 'on_kill', label: 'On Kill', labelHint: '(fired when this mob is defeated or its task completes — additive over drops)', path: 'on_kill', isArray: true, fullWidth: true, interactionStyle: true, interScope: 'inline', + id: 'on_kill', label: 'On Kill', labelHint: '(fired when this mob is defeated or its task completes — additive over drops)', path: 'on_kill', isArray: true, fullWidth: true, interactionStyle: true, detect: function(d) { return d.on_kill && Array.isArray(d.on_kill); }, fields: [ ['item_id', 'Item ID', 'search', '(optional — only fires if you wield this weapon when the mob is defeated)', '', null, 'items'], - ['condition', 'Condition', 'inter_cond'], - ['action', 'Action', 'inter_act'], ] }, ]; @@ -813,8 +811,9 @@ function saveMob() { var cleanedCond = ie_cleanCondition(dataItem.condition); if (cleanedCond) item.condition = cleanedCond; } - if (dataItem.action && typeof dataItem.action === 'object') { - item.action = dataItem.action; + if (dataItem.lock) item.lock = true; + if (Array.isArray(dataItem.steps)) { + item.steps = dataItem.steps.map(ie_cleanStep).filter(function(s) { return s && Object.keys(s).length > 0; }); } } if (Object.keys(item).length > 0) arr.push(item); diff --git a/internal/admin/static/objecteditor.js b/internal/admin/static/objecteditor.js index 9c5a279..1abccbf 100644 --- a/internal/admin/static/objecteditor.js +++ b/internal/admin/static/objecteditor.js @@ -86,21 +86,17 @@ var SECTIONS = [ ] }, { - id: 'on_use', label: 'On Use', labelHint: '(what happens when you use items on this)', path: 'on_use', isArray: true, fullWidth: true, interactionStyle: true, interScope: 'inline', + id: 'on_use', label: 'On Use', labelHint: '(what happens when you use items on this)', path: 'on_use', isArray: true, fullWidth: true, interactionStyle: true, detect: function(d) { return d.on_use && Array.isArray(d.on_use); }, fields: [ ['item_id', 'Item ID', 'search', 'e.g. keycard (empty = bare use)', '', null, 'items'], - ['condition', 'Condition', 'inter_cond'], - ['action', 'Action', 'inter_act'], ] }, { - id: 'on_look', label: 'On Look', labelHint: '(what happens when you "look ")', path: 'on_look', isArray: true, fullWidth: true, interactionStyle: true, interScope: 'inline', + id: 'on_look', label: 'On Look', labelHint: '(what happens when you "look ")', path: 'on_look', isArray: true, fullWidth: true, interactionStyle: true, detect: function(d) { return d.on_look && Array.isArray(d.on_look); }, fields: [ ['item_id', 'Item ID', 'search', '(optional — only fires if you carry this item)', '', null, 'items'], - ['condition', 'Condition', 'inter_cond'], - ['action', 'Action', 'inter_act'], ] }, ]; @@ -517,8 +513,6 @@ function renderArraySection(sec, data) { showRemove: function(idx) { return 'removeArrayItem(\'' + sec.id + '\',' + idx + ')'; }, showField: function(secId, idx, key) { return 'showInterField(\'' + secId + '\',' + idx + ',\'' + key + '\')'; }, hideField: function(secId, idx, key) { return 'hideInterField(\'' + secId + '\',' + idx + ',\'' + key + '\')'; }, - addCondition: function(secId, idx) { return 'addInterRootCondition(\'' + secId + '\',' + idx + ')'; }, - addEffect: function(secId, idx) { return 'addInterAction(\'' + secId + '\',' + idx + ')'; }, renderField: function(sec, f, data, idx, optStyle) { return renderField(sec, f, data, idx, null, optStyle); }, onAdd: function() { return 'addArrayItem(\'' + sec.id + '\')'; }, }); @@ -822,10 +816,15 @@ function addArrayItem(cardID) { var sec = SECTIONS.find(function(s) { return s.id === cardID; }); if (!sec || !sec.isArray) return; var arr = objectData[sec.path] || []; - var empty = {}; - sec.fields.forEach(function(f) { - empty[f[0]] = f[2] === 'checkbox' ? false : (f[2] === 'number' ? 0 : ''); - }); + var empty; + if (sec.interactionStyle) { + empty = {steps: []}; + } else { + empty = {}; + sec.fields.forEach(function(f) { + empty[f[0]] = f[2] === 'checkbox' ? false : (f[2] === 'number' ? 0 : ''); + }); + } arr.push(empty); objectData[sec.path] = arr; renderCurrent(); @@ -915,13 +914,7 @@ function showInterField(secId, idx, key) { function addInterRootCondition(secId, idx) { var sec = SECTIONS.find(function(s) { return s.id === secId; }); if (!sec) return; - ie_addRootConditionGroup(sec.path, idx); -} - -function addInterAction(secId, idx) { - var sec = SECTIONS.find(function(s) { return s.id === secId; }); - if (!sec) return; - ie_addEffectToInteraction(sec.path, idx); + ie_addRootConditionGroup(sec.path, idx, 'entry'); } function hideInterField(secId, idx, key) { @@ -932,8 +925,8 @@ function hideInterField(secId, idx, key) { ced_syncDOMToData(objectData); ie_syncAllInteractions(objectData, SECTIONS); if (objectData[secPath] && objectData[secPath][idx]) { - if (key === 'condition' || key === 'action') { - delete objectData[secPath][idx][key]; + if (key === 'condition') { + delete objectData[secPath][idx].condition; } else { objectData[secPath][idx][key] = ''; } @@ -1030,8 +1023,10 @@ function saveObject() { var cleanedCond = ie_cleanCondition(dataItem.condition); if (cleanedCond) item.condition = cleanedCond; } - if (dataItem.action && typeof dataItem.action === 'object') { - item.action = dataItem.action; + if (dataItem.lock) item.lock = true; + if (dataItem.steps && Array.isArray(dataItem.steps)) { + var cleanedSteps = dataItem.steps.map(ie_cleanStep).filter(function(s) { return Object.keys(s).length > 0; }); + if (cleanedSteps.length > 0) item.steps = cleanedSteps; } } if (Object.keys(item).length > 0) arr.push(item); diff --git a/internal/admin/templates/map.html b/internal/admin/templates/map.html index 0915aaa..79cd14f 100644 --- a/internal/admin/templates/map.html +++ b/internal/admin/templates/map.html @@ -50,5 +50,7 @@ + + {{end}} diff --git a/internal/behavior/behavior.go b/internal/behavior/behavior.go index 12eef0b..75f836b 100644 --- a/internal/behavior/behavior.go +++ b/internal/behavior/behavior.go @@ -1,21 +1,21 @@ package behavior type GatherConfig struct { - Skill string `yaml:"skill"` - Tools []string `yaml:"tools"` - NoToolSpeed float64 `yaml:"no_tool_speed,omitempty"` - Bait string `yaml:"bait"` + Skill string `yaml:"skill"` + Tools []string `yaml:"tools"` + NoToolSpeed float64 `yaml:"no_tool_speed,omitempty"` + Bait string `yaml:"bait"` Success SuccessFormula `yaml:"success"` - GatherMessage string `yaml:"gather_message"` - DepletedMessage string `yaml:"depleted_message"` - ExhaustedMessage string `yaml:"exhausted_message"` - FailMessage string `yaml:"fail_message"` - Drops []DropEntry `yaml:"drops"` - RespawnTimer float64 `yaml:"respawn_timer"` - RespawnBroadcast string `yaml:"respawn_broadcast"` - DepleteTimer float64 `yaml:"deplete_timer"` - NestChance int `yaml:"nest_chance"` - BroadcastMessage string `yaml:"broadcast_message"` + GatherMessage string `yaml:"gather_message"` + DepletedMessage string `yaml:"depleted_message"` + ExhaustedMessage string `yaml:"exhausted_message"` + FailMessage string `yaml:"fail_message"` + Drops []DropEntry `yaml:"drops"` + RespawnTimer float64 `yaml:"respawn_timer"` + RespawnBroadcast string `yaml:"respawn_broadcast"` + DepleteTimer float64 `yaml:"deplete_timer"` + NestChance int `yaml:"nest_chance"` + BroadcastMessage string `yaml:"broadcast_message"` } type SuccessFormula struct { @@ -32,88 +32,83 @@ type TalkNode struct { Messages []string `yaml:"messages,omitempty"` Condition *Condition `yaml:"condition,omitempty"` Options []TalkOption `yaml:"options"` - Action *NodeAction `yaml:"action"` + Action *Step `yaml:"action,omitempty"` Goto string `yaml:"goto,omitempty"` } type TalkOption struct { - Text string `yaml:"text"` - Goto string `yaml:"goto"` - Condition *Condition `yaml:"condition"` - Action *NodeAction `yaml:"action,omitempty"` -} - -// NodeAction is the inline-only effects payload — used by talk nodes, -// talk options, and any callers that need a simple "do these effects now" -// primitive (use/look/kill interactions, exits). It is the embedded -// subset of StepAction (the universal superset used by on_enter and triggers). -type NodeAction struct { - SetGlobalFlags map[string]any `yaml:"set_global_flags,omitempty"` - SetPlayerFlags map[string]any `yaml:"set_player_flags,omitempty"` - GiveItem string `yaml:"give_item,omitempty"` - TakeItem string `yaml:"take_item,omitempty"` - Teleport int `yaml:"teleport,omitempty"` - Heal int `yaml:"heal,omitempty"` - Credits int `yaml:"credits,omitempty"` - ApsNode bool `yaml:"aps_node,omitempty"` -} - -// DelayedMessage is a single message with a delay (in ticks) to wait before -// showing it. Used by the interaction sequencer (on_use / on_look / on_kill / -// on_traverse); on_enter and trigger steps emit their Messages list -// immediately when the step fires (use the step's own Delay for inter-step -// spacing). -type DelayedMessage struct { - Message string `yaml:"message,omitempty"` - Delay int `yaml:"delay,omitempty"` -} - -// Interaction is one conditional trigger entry shared by on_use, on_look -// (objects) and on_kill (mobs). Item filters by the appropriate held/weapon -// item depending on the interaction kind (see itemMatch on applyInteraction). -// The first entry whose item filter and Condition pass wins and fires exactly -// once. + Text string `yaml:"text"` + Goto string `yaml:"goto"` + Condition *Condition `yaml:"condition"` + Action *Step `yaml:"action,omitempty"` +} + +// Step is the universal effect primitive — one struct shared across every +// effect executor in the game: talk nodes/options, on_use/on_look/on_kill/ +// on_enter/on_exit/on_traverse triggers, and on_flag_change/on_global_flag_change +// triggers. A trigger's effects are expressed as an ordered list of Steps; +// spacing between steps is the Wait field (a pure {wait: N} step pauses the +// sequence for N ticks before the next step fires). // -// Action is a full StepAction so on_kill may broadcast a kill announcement or -// spawn a follow-up mob; inline callers (use/look) simply leave the -// sequence-only fields empty. -type Interaction struct { - Item string `yaml:"item_id,omitempty"` - Condition *Condition `yaml:"condition,omitempty"` - Action *StepAction `yaml:"action,omitempty"` -} - -// StepAction is the universal effect primitive — one superset struct shared -// across all effect executors (on_enter steps, room/global triggers, talk -// nodes, use/look/kill interactions, exit traversal). The embedded -// NodeAction holds the inline-only effects (flags/items/teleport/heal/credits/ -// aps_node); the additional fields are the sequence-only effects (messages, -// broadcasts, mob spawn/despawn, delay). Condition is evaluated per-step by -// the on_enter / trigger schedulers; inline callers ignore it (the gating -// happens at the parent Interaction.ExitDef level instead). -type StepAction struct { - NodeAction `yaml:",inline"` - Condition *Condition `yaml:"condition,omitempty"` - Messages []DelayedMessage `yaml:"messages,omitempty"` - Broadcast string `yaml:"broadcast,omitempty"` - BroadcastGlobal string `yaml:"broadcast_global,omitempty"` - SpawnMob *SpawnMobConfig `yaml:"spawn_mob,omitempty"` - DespawnMob string `yaml:"despawn_mob,omitempty"` - Delay int `yaml:"delay,omitempty"` -} - -// IsTimed reports whether the step carries sequence semantics (any non-zero -// field means it needs per-tick scheduling rather than synchronous printing). -func (s StepAction) IsTimed() bool { - return s.Delay > 0 || len(s.Messages) > 0 || s.Broadcast != "" || s.BroadcastGlobal != "" || +// Condition is evaluated per-step by the sequence scheduler; an inline caller +// (talk node, synchronous use/look/kill) fires a single step without the +// scheduler and ignores the per-step Condition (its gating is done at the +// parent Trigger level instead). +type Step struct { + Condition *Condition `yaml:"condition,omitempty"` + Wait int `yaml:"wait,omitempty"` + Messages []string `yaml:"messages,omitempty"` + Broadcast string `yaml:"broadcast,omitempty"` + BroadcastGlobal string `yaml:"broadcast_global,omitempty"` + SpawnMob *SpawnMobConfig `yaml:"spawn_mob,omitempty"` + DespawnMob string `yaml:"despawn_mob,omitempty"` + SetGlobalFlags map[string]any `yaml:"set_global_flags,omitempty"` + SetPlayerFlags map[string]any `yaml:"set_player_flags,omitempty"` + GiveItem string `yaml:"give_item,omitempty"` + TakeItem string `yaml:"take_item,omitempty"` + Teleport int `yaml:"teleport,omitempty"` + Heal int `yaml:"heal,omitempty"` + Credits int `yaml:"credits,omitempty"` + ApsNode bool `yaml:"aps_node,omitempty"` +} + +// HasEffects reports whether the step carries any effect beyond a pure wait. +func (s Step) HasEffects() bool { + return len(s.Messages) > 0 || s.Broadcast != "" || s.BroadcastGlobal != "" || s.SpawnMob != nil || s.DespawnMob != "" || len(s.SetGlobalFlags) > 0 || len(s.SetPlayerFlags) > 0 || s.GiveItem != "" || s.TakeItem != "" || s.Teleport != 0 || s.Heal != 0 || s.Credits != 0 || s.ApsNode } -// SpawnMobConfig configures a transient mob spawned by a trigger or on_enter -// step. The mob id is required; other fields are optional. +// Trigger is the one conditional wrapper used by every event block in the +// game: on_use, on_look, on_kill, on_enter, on_exit, on_traverse, +// on_flag_change, and on_global_flag_change. Entries in a block are walked +// top-to-bottom; the first whose ItemID filter and Condition pass wins and +// its Steps run as a scripted sequence. +// +// For verb blocks only ItemID/Condition/Steps/Lock are meaningful. For +// on_flag_change/on_global_flag_change blocks, OnPlayerFlag/OnGlobalFlag (and +// optional Value) declare the flag subscription; Condition provides an +// additional gate (use the Room condition to scope a flag trigger to a room). +// +// Lock, when true, makes the sequence atomic: the player is blocked from any +// verb (except quit) until it completes, and the sequence is saved and resumed +// across disconnect. Unlocked sequences (the default) are interruptable by any +// verb and are not persisted. +type Trigger struct { + Lock bool `yaml:"lock,omitempty"` + ItemID string `yaml:"item_id,omitempty"` + Condition *Condition `yaml:"condition,omitempty"` + Steps []Step `yaml:"steps"` + OnPlayerFlag string `yaml:"on_player_flag,omitempty"` + OnGlobalFlag string `yaml:"on_global_flag,omitempty"` + Value any `yaml:"value,omitempty"` + DedupKey string `yaml:"-"` // runtime only — disambiguates flag-trigger dedup keys +} + +// SpawnMobConfig configures a transient mob spawned by a trigger step. The +// mob id is required; other fields are optional. type SpawnMobConfig struct { ID string `yaml:"id"` OwnerOnly bool `yaml:"owner_only"` @@ -210,15 +205,19 @@ func (c *ShopConfig) FindItem(itemID string) *ShopItem { return nil } +// Condition is the single predicate struct used everywhere a gate is needed: +// exits, descriptions, talk nodes/options, trigger entries, and per-step +// conditions. The atomic predicates combine via AllOf/AnyOf nesting and the +// Not inverter. Room matches when the triggering player is currently in that +// room (ignored for global-flag triggers with no player). type Condition struct { - GlobalFlag string `yaml:"global_flag"` - Value any `yaml:"value"` - Not bool `yaml:"not"` - PlayerFlag string `yaml:"player_flag"` - HasItem string `yaml:"has_item"` - MinCredits int `yaml:"min_credits"` - AllOf []Condition `yaml:"all_of"` - AnyOf []Condition `yaml:"any_of"` + GlobalFlag string `yaml:"global_flag,omitempty"` + PlayerFlag string `yaml:"player_flag,omitempty"` + Room int `yaml:"room,omitempty"` + Value any `yaml:"value,omitempty"` + Not bool `yaml:"not,omitempty"` + HasItem string `yaml:"has_item,omitempty"` + MinCredits int `yaml:"min_credits,omitempty"` + AllOf []Condition `yaml:"all_of,omitempty"` + AnyOf []Condition `yaml:"any_of,omitempty"` } - - diff --git a/internal/behavior/types.go b/internal/behavior/types.go index f451400..7aba045 100644 --- a/internal/behavior/types.go +++ b/internal/behavior/types.go @@ -28,30 +28,29 @@ func WordPrefixMatch(input, name string) bool { type ActionType string const ( - TypeGather ActionType = "gather" - TypeSteal ActionType = "steal" - TypeTalk ActionType = "talk" - TypeBurn ActionType = "burn" - TypeStoke ActionType = "stoke" - TypeSearch ActionType = "search" - TypeIdentify ActionType = "identify" - TypePlant ActionType = "plant" - TypeHarvest ActionType = "harvest" - TypeRake ActionType = "rake" - TypeWater ActionType = "water" - TypeCure ActionType = "cure" - TypeObstacle ActionType = "obstacle" - TypeFletch ActionType = "fletch" - TypeClean ActionType = "cleaning" - TypeCook ActionType = "cook" - TypeSmelt ActionType = "smelting" - TypeSmith ActionType = "smith" - TypeCraft ActionType = "craft" - TypeCombine ActionType = "combine" - TypeMix ActionType = "mix" - TypeConstruct ActionType = "construct" - TypeTriggerModule ActionType = "trigger_module" - TypeInteractionSeq ActionType = "interaction_seq" + TypeGather ActionType = "gather" + TypeSteal ActionType = "steal" + TypeTalk ActionType = "talk" + TypeBurn ActionType = "burn" + TypeStoke ActionType = "stoke" + TypeSearch ActionType = "search" + TypeIdentify ActionType = "identify" + TypePlant ActionType = "plant" + TypeHarvest ActionType = "harvest" + TypeRake ActionType = "rake" + TypeWater ActionType = "water" + TypeCure ActionType = "cure" + TypeObstacle ActionType = "obstacle" + TypeFletch ActionType = "fletch" + TypeClean ActionType = "cleaning" + TypeCook ActionType = "cook" + TypeSmelt ActionType = "smelting" + TypeSmith ActionType = "smith" + TypeCraft ActionType = "craft" + TypeCombine ActionType = "combine" + TypeMix ActionType = "mix" + TypeConstruct ActionType = "construct" + TypeTriggerModule ActionType = "trigger_module" ) type Action struct { @@ -111,7 +110,7 @@ type TalkData struct { EstateDirectory bool StealGuard bool PendingChoice string - PendingAction *NodeAction + PendingAction *Step CancelTalk bool PendingSeq bool PendingChosen bool @@ -143,11 +142,11 @@ type ObstacleData struct { CompletionXP int // FailChance is the resolved failure probability for this attempt. // nil => derived at runtime from agility level vs RequiredLevel. - FailChance *float64 - FailDamageMin int - FailDamageMax int - Phases []PhaseData - RequiredLevel int + FailChance *float64 + FailDamageMin int + FailDamageMax int + Phases []PhaseData + RequiredLevel int } // PhaseData is the runtime form of an obstacle phase. diff --git a/internal/game/act.go b/internal/game/act.go index 1fc2b1c..352f235 100644 --- a/internal/game/act.go +++ b/internal/game/act.go @@ -235,8 +235,6 @@ func (g *Game) AdvanceActions() { g.advanceTalk(sess, p) case behavior.TypeTriggerModule: g.advanceTriggerModule(sess, p) - case behavior.TypeInteractionSeq: - g.advanceInteractionSeq(sess, p) case behavior.TypeCombine: g.advanceCombine(sess, p) default: @@ -302,6 +300,13 @@ func (g *Game) checkCondition(sess *net.Session, c *behavior.Condition) bool { val, present := g.GlobalFlags.Get(c.GlobalFlag) return flagMatches(present, val, c.Value, c.Not) } + if c.Room != 0 { + has := p != nil && p.RoomID == c.Room + if c.Not { + return !has + } + return has + } if c.HasItem != "" { has := p != nil && p.HasItem(c.HasItem) if c.Not { @@ -319,6 +324,44 @@ func (g *Game) checkCondition(sess *net.Session, c *behavior.Condition) bool { return true } +// checkConditionGlobal evaluates a condition without a player session. +// Only global_flag predicates (plus all_of/any_of/not composition) are evaluated. +// Room, player_flag, has_item, and min_credits are player-dependent: Room passes +// (it is used as a reference-room selector, not a gate), and the rest fail. +func (g *Game) checkConditionGlobal(c *behavior.Condition) bool { + if c.AllOf != nil { + for _, sub := range c.AllOf { + if !g.checkConditionGlobal(&sub) { + return false + } + } + return true + } + if c.AnyOf != nil { + for _, sub := range c.AnyOf { + if g.checkConditionGlobal(&sub) { + return true + } + } + return false + } + + if c.GlobalFlag != "" { + val, present := g.GlobalFlags.Get(c.GlobalFlag) + return flagMatches(present, val, c.Value, c.Not) + } + if c.PlayerFlag != "" { + return c.Not + } + if c.HasItem != "" { + return c.Not + } + if c.MinCredits > 0 { + return c.Not + } + return true +} + // flagMatches evaluates a global_flag/player_flag condition. The name is // historical; it evaluates both flag types. // diff --git a/internal/game/act_agility.go b/internal/game/act_agility.go index a243b44..5fbff48 100644 --- a/internal/game/act_agility.go +++ b/internal/game/act_agility.go @@ -199,4 +199,4 @@ func (g *Game) calcFailChance(p *player.Player, info *ObstacleInfo) *float64 { chance = 0.60 } return &chance -} \ No newline at end of file +} diff --git a/internal/game/act_effects.go b/internal/game/act_effects.go index 9fce264..cb60c5d 100644 --- a/internal/game/act_effects.go +++ b/internal/game/act_effects.go @@ -10,74 +10,47 @@ import ( "thehouseoficarus/internal/player" ) -// effectScope controls which subset of a StepAction's effects applyStepAction -// will fire. The three scopes cover the three callers of the unified executor: +// effectScope controls which subset of a Step's effects applyStep will fire. // -// - scopePlayer: inline triggers (talk nodes, on_use, on_look, -// on_kill, exit traversal). Receives all -// player-targeted effects (flags, items, heal, credits, -// teleport, aps_node). Broadcast is allowed (so on_kill -// can announce to the room); broadcast_global is not -// (there's no per-tick sequence owner to author it). -// - scopePlayerSeq: on_enter steps + trigger player sequences. Same as -// scopePlayer plus broadcast_global (a sequence can -// shout to the whole world) and spawn_mob/despawn_mob -// (player-owned transient mobs). -// - scopeGlobal: trigger global sequences. No player is involved; only -// broadcasts, global flag mutations, and world-owned -// spawn/despawn fire. +// - scopePlayer: every player-bound trigger (on_use, on_look, on_kill, +// on_enter, on_exit, on_traverse, on_flag_change). Receives +// the full effect vocabulary: messages, broadcast, +// broadcast_global, flags, items, heal, credits, mob +// spawn/despawn (player-owned), teleport, aps_node. +// - scopeGlobal: global-flag triggers (on_global_flag_change) with no +// originating player. Only broadcasts, global flag +// mutations, and world-owned mob spawn/despawn fire. type effectScope int const ( scopePlayer effectScope = iota - scopePlayerSeq scopeGlobal ) -// applyStepAction is the single effect executor used by every conditional -// interaction and timed step in the game: talk nodes, talk options, -// on_use/on_look/on_kill interactions, exit traversal, on_enter steps, and -// room/global trigger sequences. The previous fireEnterStep / -// executeTriggerStep / executeGlobalTriggerStep / applyNodeAction executors -// are all thin wrappers over this one. +// applyStep is the single effect executor used by every trigger in the game: +// talk nodes/options, on_use/on_look/on_kill/on_enter/on_exit/on_traverse +// triggers, and on_flag_change/on_global_flag_change sequences. scopeGlobal +// ignores the player entirely. // // flagValue (may be nil) is substituted into %v in any message/broadcast -// template; playerName (derived from p when non-nil) is substituted into %p. -// scopeGlobal ignores p entirely. +// template; the player's name is substituted into %p. // -// The fixed effect order (matching the original executors, normalized): -// 1. messages (player/seq scopes — skipped for scopeGlobal) -// 2. broadcast → all in room (seq/global scopes) -// 3. broadcast_global → all online (seq/global scopes) -// 4. set_global_flags (scopeGlobal only; player scopes handle globals in step 5 -// via applyFlagMutations so they aren't set twice) -// 5. set_player_flags (player/seq scopes; cascades via setPlayerFlag) -// 6. take_item (player/seq scopes) -// 7. give_item (player/seq scopes; honors 28-slot inventory limit) -// 8. heal (player/seq scopes; clamps to MaxHP) -// 9. credits (player/seq scopes; negative is gated by affordability) -// 10. spawn_mob (seq/global scopes; player-owned when seq) -// 11. despawn_mob (seq/global scopes; filtered by owner when seq) -// 12. teleport (player/seq scopes; re-runs look + on_enter of target) -// 13. aps_node (player/seq scopes; marks "aps_node_" player flag) - -// writePlayerMessage expands the message template (player name, flag value), -// colorizes inline {spec}text{/} tags under the caller's color mode, and writes -// it to sess. It is the single path used by every per-player message emission -// in the interaction system (applyStepAction step 1, the interaction sequencer, -// and the advance handlers) so color tags render consistently. -func (g *Game) writePlayerMessage(sess *net.Session, msg string, playerName string, flagValue any) { - if sess == nil || msg == "" { - return - } - rendered := expandTemplate(msg, playerName, flagValue) - seqSpec := g.resolveColor(sess, "sequence") - mode := g.colorMode(sess) - rendered = color.ExpandTagsDefault(mode, seqSpec, rendered) - sess.WriteLine(rendered) -} - -func (g *Game) applyStepAction(sess *net.Session, p *player.Player, step *behavior.StepAction, roomID int, sc effectScope, flagValue any) { +// The fixed effect order: +// 1. messages (player scope — skipped for scopeGlobal) +// 2. broadcast -> all in room (both scopes) +// 3. broadcast_global -> all online (both scopes) +// 4. set_global_flags (scopeGlobal sets here; player scope folds globals +// into step 5 via applyFlagMutations so they aren't set twice) +// 5. set_player_flags (player scope; cascades via setPlayerFlag) +// 6. take_item (player scope) +// 7. give_item (player scope; honors 28-slot inventory limit) +// 8. heal (player scope; clamps to MaxHP) +// 9. credits (player scope; negative is gated by affordability) +// 10. spawn_mob (player scope: player-owned; global scope: world-owned) +// 11. despawn_mob (player scope: owner-filtered; global scope: all) +// 12. teleport (player scope; re-runs look + on_enter of target) +// 13. aps_node (player scope; marks "aps_node_" player flag) +func (g *Game) applyStep(sess *net.Session, p *player.Player, step *behavior.Step, roomID int, sc effectScope, flagValue any) { if step == nil { return } @@ -87,20 +60,16 @@ func (g *Game) applyStepAction(sess *net.Session, p *player.Player, step *behavi playerName = p.Name } - // 1. messages — direct to the triggering session. Skipped for scopeGlobal - // (no per-player session is the target). All Messages in the step emit in - // order; per-message delays are ignored here (they're honored by the - // interaction sequencer or handled as inter-step delays by on_enter/triggers). + // 1. messages — direct to the triggering session. if sc != scopeGlobal { for _, msg := range step.Messages { - g.writePlayerMessage(sess, msg.Message, playerName, flagValue) + g.writePlayerMessage(sess, msg, playerName, flagValue) } } - // 2 & 3. broadcast / broadcast_global — re-render color tags per recipient - // so each player sees them in their own color mode. - broadcastSpec := g.resolveColor(nil, "broadcast") - if (sc == scopePlayerSeq || sc == scopeGlobal) && g.Hub != nil { + // 2 & 3. broadcast / broadcast_global — re-render color tags per recipient. + if g.Hub != nil && (step.Broadcast != "" || step.BroadcastGlobal != "") { + broadcastSpec := g.resolveColor(nil, "broadcast") if step.Broadcast != "" { raw := expandTemplate(step.Broadcast, playerName, flagValue) for _, other := range g.Hub.PlayersInRoom(roomID) { @@ -122,16 +91,13 @@ func (g *Game) applyStepAction(sess *net.Session, p *player.Player, step *behavi } } - // 4. set_global_flags — scopeGlobal has no player, so it sets globals here - // (cascades via OnChange). Player scopes handle globals in step 5 alongside - // player flags (applyFlagMutations sets both atomically per-scope), avoiding - // a redundant second SetAll. + // 4. set_global_flags — scopeGlobal has no player, set globals here. if sc == scopeGlobal && len(step.SetGlobalFlags) > 0 { g.GlobalFlags.SetAll(step.SetGlobalFlags) } - // Global triggers have no player — stop after the world-scoped effects. if sc == scopeGlobal { + // 10/11. world-owned spawn/despawn. if step.SpawnMob != nil { g.spawnWorldTriggerMob(step.SpawnMob, roomID) } @@ -141,13 +107,11 @@ func (g *Game) applyStepAction(sess *net.Session, p *player.Player, step *behavi return } - // Everything below needs a player. if p == nil { return } - // 5. set_player_flags — cascade via setPlayerFlag (may synchronously re-fire - // other player-flag triggers). + // 5. set_player_flags (and globals, folded in so the SetAll happens once). if len(step.SetGlobalFlags) > 0 || len(step.SetPlayerFlags) > 0 { g.applyFlagMutations(p, step.SetGlobalFlags, step.SetPlayerFlags) g.AccountStore.SaveCharacter(p) @@ -186,7 +150,7 @@ func (g *Game) applyStepAction(sess *net.Session, p *player.Player, step *behavi } } - // 9. credits (signed; negative is gated) + // 9. credits (signed; negative is gated). if step.Credits != 0 { if step.Credits < 0 { if p.Credits >= -step.Credits { @@ -201,12 +165,11 @@ func (g *Game) applyStepAction(sess *net.Session, p *player.Player, step *behavi } } - // 10/11. spawn_mob / despawn_mob — sequence-only effects. Inline callers - // (scopePlayer) leave these empty; nothing fires. - if sc == scopePlayerSeq && step.SpawnMob != nil { + // 10/11. spawn_mob / despawn_mob (player-owned / owner-filtered). + if step.SpawnMob != nil { g.spawnTriggerMob(sess, p, step.SpawnMob, roomID) } - if sc == scopePlayerSeq && step.DespawnMob != "" { + if step.DespawnMob != "" { g.despawnTriggerMobs(step.DespawnMob, p.Name) } @@ -215,68 +178,58 @@ func (g *Game) applyStepAction(sess *net.Session, p *player.Player, step *behavi g.teleportPlayer(sess, p, step.Teleport) } - // 13. aps_node — marks this current room as a discovered APS node on the - // player's datapad. Kept as a dedicated effect because the room-id - // substitution is dynamic (the player's current room), not expressible - // via a static set_player_flags entry. + // 13. aps_node — dynamic room-id flag, kept as a dedicated effect. if step.ApsNode { g.setPlayerFlag(p, "aps_node_"+strconv.Itoa(p.RoomID), true) g.AccountStore.SaveCharacter(p) } } -// applyNodeAction is the inline-only convenience wrapper used by talk nodes, -// talk options, and any historical caller that holds a *NodeAction rather -// than a *StepAction. It wraps the NodeAction in a StepAction (sequence-only -// fields empty) and dispatches to applyStepAction with scopePlayer scoped -// to the player's current room. -func (g *Game) applyNodeAction(sess *net.Session, na *behavior.NodeAction) { - p := sess.Player - if p == nil || na == nil { +// writePlayerMessage expands the message template (player name, flag value), +// colorizes inline {spec}text{/} tags under the caller's color mode, and writes +// it to sess. It is the single path used by every per-player message emission +// in the trigger system. +func (g *Game) writePlayerMessage(sess *net.Session, msg string, playerName string, flagValue any) { + if sess == nil || msg == "" { return } - g.applyStepAction(sess, p, &behavior.StepAction{NodeAction: *na}, p.RoomID, scopePlayer, nil) + rendered := expandTemplate(msg, playerName, flagValue) + seqSpec := g.resolveColor(sess, "sequence") + mode := g.colorMode(sess) + rendered = color.ExpandTagsDefault(mode, seqSpec, rendered) + sess.WriteLine(rendered) } -// applyInteraction fires the first matching entry in a list of conditional -// interactions (on_use, on_look, on_kill, exit traversal). Returns true when -// an entry matched and fired. The action runs at scopePlayer (or scopePlayerSeq -// if allowSeq is set, for on_kill which may broadcast/spawn). -// -// itemMatch (optional) gates entries that carry an item_id: for on_look it -// requires the player to have the item in inventory; for on_kill it requires -// the player to be wielding it. nil means the item_id field is ignored (used -// by callers without an item filter, e.g. exit traversal). Entries are walked -// top-to-bottom; the first whose item filter, Condition, and the first-match- -// wins rule all pass fires. -func (g *Game) applyInteraction(sess *net.Session, p *player.Player, list []behavior.Interaction, roomID int, allowSeq bool, itemMatch func(string) bool) bool { - sc := scopePlayer - if allowSeq { - sc = scopePlayerSeq +// applyTalkStep fires a talk node/option's Action (a single Step) +// synchronously at scopePlayer, scoped to the player's current room. +func (g *Game) applyTalkStep(sess *net.Session, step *behavior.Step) { + p := sess.Player + if p == nil || step == nil { + return } - for _, it := range list { - if it.Item != "" && itemMatch != nil && !itemMatch(it.Item) { + g.applyStep(sess, p, step, p.RoomID, scopePlayer, nil) +} + +// runTrigger runs the first matching entry in a trigger block. itemMatch +// (optional) gates entries that carry an ItemID: for on_look it requires the +// player to hold the item; for on_kill it requires wielding it. nil means the +// ItemID field is ignored (on_enter/on_exit/on_traverse/on_flag_change). The +// first entry whose item filter and Condition pass wins; its Steps run as a +// scripted sequence. roomLocked controls the per-step room-lock: when true, +// steps are skipped if the player has left roomID (used by on_enter so a +// cutscene doesn't play into the wrong room). Returns true if a trigger +// matched and fired. +func (g *Game) runTrigger(sess *net.Session, p *player.Player, list []behavior.Trigger, roomID int, itemMatch func(string) bool, roomLocked bool) bool { + for i := range list { + t := &list[i] + if t.ItemID != "" && itemMatch != nil && !itemMatch(t.ItemID) { continue } - if it.Condition != nil && !g.checkCondition(sess, it.Condition) { + if t.Condition != nil && !g.checkCondition(sess, t.Condition) { continue } - g.runInteraction(sess, p, it.Action, roomID, sc, nil) + g.startSequence(sess, p, t.Steps, roomID, scopePlayer, nil, t.Lock, roomLocked, verbSeqKey(p, t.ItemID)) return true } return false } - -// runInteraction fires a single interaction's Action. If the action carries -// delayed Messages the player is locked for the duration (see the interaction -// sequencer); otherwise the effects apply synchronously. -func (g *Game) runInteraction(sess *net.Session, p *player.Player, step *behavior.StepAction, roomID int, sc effectScope, flagValue any) { - if step == nil { - return - } - if len(step.Messages) > 0 { - g.startInteractionSeq(sess, p, step, roomID, sc, flagValue) - return - } - g.applyStepAction(sess, p, step, roomID, sc, flagValue) -} \ No newline at end of file diff --git a/internal/game/act_interaction_seq.go b/internal/game/act_interaction_seq.go deleted file mode 100644 index 8b515c4..0000000 --- a/internal/game/act_interaction_seq.go +++ /dev/null @@ -1,97 +0,0 @@ -package game - -import ( - "thehouseoficarus/internal/behavior" - "thehouseoficarus/internal/net" - "thehouseoficarus/internal/player" -) - -// interactionSeqData is the payload carried by p.Action.Data for an in-flight -// delayed-message interaction sequence (on_use, on_look, on_kill, or -// on_traverse). Messages are emitted one-by-one with per-message delays; the -// trailing StepAction effects fire only after every message has been shown. If -// the player is interrupted (move, quit, new action, combat) cancelAction -// clears the pending Action and the trailing effects never run. -type interactionSeqData struct { - Msgs []behavior.DelayedMessage - Idx int - Step *behavior.StepAction - RoomID int - Scope effectScope - FlagVal any -} - -// startInteractionSeq fires the first delayed message (on the next tick, -// matching the trigger scheduler's "delay = ticks to wait before this step -// fires" rule with a floor of 1 so the first message ALWAYS arrives next tick -// even when delay is 0). The trailing non-message effects run once the last -// message has been emitted. -func (g *Game) startInteractionSeq(sess *net.Session, p *player.Player, step *behavior.StepAction, roomID int, sc effectScope, flagValue any) { - g.cancelAction(p) - - firstDelay := 0 - if len(step.Messages) > 0 { - firstDelay = step.Messages[0].Delay - } - if firstDelay < 1 { - firstDelay = 1 - } - - // Strip Messages from step before stashing — applyStepAction would otherwise - // re-emit them when the trailing effects run. - clone := *step - clone.Messages = nil - - p.Action = &behavior.Action{ - Type: behavior.TypeInteractionSeq, - WaitLeft: firstDelay, - Data: &interactionSeqData{ - Msgs: step.Messages, - Idx: 0, - Step: &clone, - RoomID: roomID, - Scope: sc, - FlagVal: flagValue, - }, - } -} - -// advanceInteractionSeq is the per-tick handler for TypeInteractionSeq -// actions. Each invocation drains any zero-delay messages that are ready, emits -// the current delayed message, then sets the wait for the next message (if any). -// After the last message the trailing effects fire and the action is cancelled. -func (g *Game) advanceInteractionSeq(sess *net.Session, p *player.Player) { - d, ok := p.Action.Data.(*interactionSeqData) - if !ok { - g.cancelAction(p) - return - } - - var playerName string - if p != nil { - playerName = p.Name - } - - // Drain zero-delay messages that have accumulated, then emit the current - // (delayed) one. Matches the trigger scheduler's drain-zero-delay pattern. - for d.Idx < len(d.Msgs) { - msg := d.Msgs[d.Idx] - d.Idx++ - g.writePlayerMessage(sess, msg.Message, playerName, d.FlagVal) - - if d.Idx >= len(d.Msgs) { - break - } - next := d.Msgs[d.Idx].Delay - if next > 0 { - p.Action.WaitLeft = next - return - } - } - - // All messages emitted — fire trailing effects. - if d.Step != nil { - g.applyStepAction(sess, p, d.Step, d.RoomID, d.Scope, d.FlagVal) - } - g.cancelAction(p) -} diff --git a/internal/game/act_interaction_seq_test.go b/internal/game/act_interaction_seq_test.go deleted file mode 100644 index c3fc8fa..0000000 --- a/internal/game/act_interaction_seq_test.go +++ /dev/null @@ -1,216 +0,0 @@ -package game - -import ( - "io" - "testing" - - "thehouseoficarus/internal/behavior" - "thehouseoficarus/internal/combat" - "thehouseoficarus/internal/net" - "thehouseoficarus/internal/player" -) - -type nopConn struct{} - -func (nopConn) ReadMessage() (string, error) { return "", io.EOF } -func (nopConn) Write(p []byte) (int, error) { return len(p), nil } -func (nopConn) Close() error { return nil } -func (nopConn) SetEcho(bool) error { return nil } - -func newTestGame() *Game { - return &Game{ - GlobalFlags: NewGlobalFlagStore(), - Combat: combat.NewTracker(), - } -} - -func newTestGameWithStore(t *testing.T) *Game { - g := newTestGame() - g.AccountStore = player.NewAccountStore(t.TempDir()) - return g -} - -func newTestSession(p *player.Player, acc *player.Account) *net.Session { - if p.Options == nil { - p.Options = make(map[string]any) - } - return &net.Session{Player: p, Conn: nopConn{}, State: net.StateGame, Account: acc} -} - -func newTestPlayer(name string) *player.Player { - return &player.Player{ - Name: name, - RoomID: 100, - Skills: map[player.SkillName]int{player.Hitpoints: player.XPForLevel(99)}, - HP: 50, - Options: make(map[string]any), - } -} - -func TestRunInteractionNoMessagesAppliesInstantly(t *testing.T) { - g := newTestGameWithStore(t) - p := newTestPlayer("test") - sess := newTestSession(p, nil) - - step := &behavior.StepAction{ - NodeAction: behavior.NodeAction{Heal: 10}, - } - - g.runInteraction(sess, p, step, 100, scopePlayer, nil) - - if p.Action != nil { - t.Error("expected no pending action for message-less interaction") - } - if p.HP != 60 { - t.Errorf("expected HP 60 after heal, got %d", p.HP) - } -} - -func TestRunInteractionWithMessagesStartsSequence(t *testing.T) { - g := newTestGame() - p := newTestPlayer("test") - p.Credits = 100 - sess := newTestSession(p, nil) - - step := &behavior.StepAction{ - NodeAction: behavior.NodeAction{Credits: 50}, - Messages: []behavior.DelayedMessage{ - {Message: "You feel a strange energy.", Delay: 3}, - }, - } - - g.runInteraction(sess, p, step, 100, scopePlayer, nil) - - if p.Action == nil { - t.Fatal("expected pending action for message-bearing interaction") - } - if p.Action.Type != behavior.TypeInteractionSeq { - t.Errorf("expected TypeInteractionSeq, got %v", p.Action.Type) - } - if p.Action.WaitLeft != 3 { - t.Errorf("expected WaitLeft 3, got %d", p.Action.WaitLeft) - } - if p.Credits != 100 { - t.Errorf("expected credits unchanged (100), got %d", p.Credits) - } -} - -func TestAdvanceInteractionSeqDrainsZeroDelay(t *testing.T) { - g := newTestGameWithStore(t) - p := newTestPlayer("test") - sess := newTestSession(p, nil) - - step := &behavior.StepAction{ - NodeAction: behavior.NodeAction{Heal: 10}, - Messages: []behavior.DelayedMessage{ - {Message: "First.", Delay: 0}, - {Message: "Second.", Delay: 0}, - {Message: "Third.", Delay: 0}, - }, - } - - g.startInteractionSeq(sess, p, step, 100, scopePlayer, nil) - - if p.Action == nil || p.Action.WaitLeft != 1 { - t.Fatalf("expected WaitLeft 1 (delay 0 floored), got Action:%v", p.Action) - } - - g.advanceInteractionSeq(sess, p) - - if p.Action != nil { - t.Error("expected action cancelled after draining all messages") - } - if p.HP != 60 { - t.Errorf("expected HP 60 after heal, got %d", p.HP) - } -} - -func TestAdvanceInteractionSeqHonorsDelay(t *testing.T) { - g := newTestGameWithStore(t) - p := newTestPlayer("test") - sess := newTestSession(p, nil) - - step := &behavior.StepAction{ - NodeAction: behavior.NodeAction{Heal: 10}, - Messages: []behavior.DelayedMessage{ - {Message: "First.", Delay: 2}, - {Message: "Second.", Delay: 3}, - }, - } - - g.startInteractionSeq(sess, p, step, 100, scopePlayer, nil) - - if p.Action == nil || p.Action.WaitLeft != 2 { - t.Fatalf("expected WaitLeft 2, got %v", p.Action) - } - p.Action.WaitLeft = 1 - - g.advanceInteractionSeq(sess, p) - - if p.Action == nil { - t.Fatal("expected action still running (second message pending)") - } - if p.Action.WaitLeft != 3 { - t.Errorf("expected WaitLeft 3 (second message delay), got %d", p.Action.WaitLeft) - } - if p.HP != 50 { - t.Error("heal should not have fired yet") - } - - g.advanceInteractionSeq(sess, p) - - if p.Action != nil { - t.Error("expected action cancelled after last message + heal") - } - if p.HP != 60 { - t.Errorf("expected HP 60 after heal, got %d", p.HP) - } -} - -func TestInteractionSeqCancelPreventsTrailingEffects(t *testing.T) { - g := newTestGame() - p := newTestPlayer("test") - sess := newTestSession(p, nil) - - step := &behavior.StepAction{ - NodeAction: behavior.NodeAction{Heal: 10}, - Messages: []behavior.DelayedMessage{ - {Message: "Hello.", Delay: 5}, - }, - } - - g.startInteractionSeq(sess, p, step, 100, scopePlayer, nil) - - if p.Action == nil { - t.Fatal("expected pending action") - } - - g.cancelAction(p) - - if p.Action != nil { - t.Error("expected action to be cancelled") - } - if p.HP != 50 { - t.Error("heal should not have fired after cancel") - } -} - -func TestApplyStepActionEmitsAllMessages(t *testing.T) { - g := newTestGameWithStore(t) - p := newTestPlayer("test") - sess := newTestSession(p, nil) - - step := &behavior.StepAction{ - NodeAction: behavior.NodeAction{Heal: 10}, - Messages: []behavior.DelayedMessage{ - {Message: "A.", Delay: 99}, - {Message: "B.", Delay: 99}, - }, - } - - g.applyStepAction(sess, p, step, 100, scopePlayer, nil) - - if p.HP != 60 { - t.Errorf("expected HP 60 after heal, got %d", p.HP) - } -} diff --git a/internal/game/act_room.go b/internal/game/act_room.go index a6acc21..889ce85 100644 --- a/internal/game/act_room.go +++ b/internal/game/act_room.go @@ -4,26 +4,13 @@ import ( "fmt" "strings" - "thehouseoficarus/internal/behavior" "thehouseoficarus/internal/net" "thehouseoficarus/internal/player" ) -// enterSeq is an in-flight on_enter stepped sequence for a single player. It is -// driven one step at a time by EnterSeqTick. -type enterSeq struct { - sess *net.Session - playerName string - roomID int - steps []behavior.StepAction - wait int -} - -// runEnterSteps evaluates a room's on_enter script for the player. Steps whose -// condition fails are dropped. If none of the surviving steps are "timed" -// (any non-zero effect field) the messages are printed synchronously. -// Otherwise the surviving steps become a scheduled enter sequence driven -// by EnterSeqTick. +// runEnterSteps evaluates a room's on_enter block for the player. The first +// trigger whose Condition passes wins; its Steps run as a scripted sequence +// (room-locked, so steps are skipped if the player immediately leaves). func (g *Game) runEnterSteps(sess *net.Session, roomID int) { room, err := g.World.LoadRoom(roomID) if err != nil || len(room.OnEnter) == 0 { @@ -33,154 +20,29 @@ func (g *Game) runEnterSteps(sess *net.Session, roomID int) { if p == nil { return } - - var steps []behavior.StepAction - sequenced := false - for _, step := range room.OnEnter { - if step.Condition != nil && !g.checkCondition(sess, step.Condition) { - continue - } - steps = append(steps, step) - if step.IsTimed() { - sequenced = true - } - } - if len(steps) == 0 { - return - } - - if !sequenced { - // Pure message-only steps — print synchronously, no side effects. - for _, step := range steps { - for _, msg := range step.Messages { - if msg.Message != "" { - sess.WriteLine(msg.Message) - } - } - } - return - } - - g.startEnterSeq(sess, p, roomID, steps) + g.runTrigger(sess, p, room.OnEnter, roomID, nil, true) } -// startEnterSeq registers a scheduled on_enter sequence and persists a marker -// on the player so the sequence can be resumed on reconnect if interrupted -// (disconnect, server restart). -func (g *Game) startEnterSeq(sess *net.Session, p *player.Player, roomID int, steps []behavior.StepAction) { - g.cancelEnterSeq(p.Name) - if p.EnterSeqRoom != roomID { - p.EnterSeqRoom = roomID - g.AccountStore.SaveCharacter(p) - } - g.enterMu.Lock() - g.enterSeqs[p.Name] = &enterSeq{ - sess: sess, - playerName: p.Name, - roomID: roomID, - steps: steps, - wait: steps[0].Delay, - } - g.enterMu.Unlock() -} - -// EnterSeqTick advances every in-flight enter sequence by one tick. Sequences -// for a player who is no longer connected are paused (left for resume); -// sequences are removed from the map on completion or cancellation. -func (g *Game) EnterSeqTick() { - g.enterMu.Lock() - seqs := make([]*enterSeq, 0, len(g.enterSeqs)) - for _, s := range g.enterSeqs { - seqs = append(seqs, s) - } - g.enterMu.Unlock() - - for _, seq := range seqs { - if !g.isCharLive(seq.playerName, seq.sess) { - continue - } - - var jobs []behavior.StepAction - done := false - - g.enterMu.Lock() - if g.enterSeqs[seq.playerName] != seq { - g.enterMu.Unlock() - continue - } - seq.wait-- - for seq.wait <= 0 && len(seq.steps) > 0 { - step := seq.steps[0] - seq.steps = seq.steps[1:] - jobs = append(jobs, step) - if len(seq.steps) > 0 { - seq.wait = seq.steps[0].Delay - } else { - seq.wait = 0 - } - } - if len(seq.steps) == 0 { - done = true - delete(g.enterSeqs, seq.playerName) - } - g.enterMu.Unlock() - - for i := range jobs { - g.fireEnterStep(seq, &jobs[i]) - } - if done { - g.completeEnterSeq(seq) - } - } -} - -// fireEnterStep runs one on_enter step's effects via the unified -// applyStepAction executor. The room-lock check (player must still be in the -// room that the sequence is being played for) lives here, before dispatch — -// applyStepAction itself doesn't re-check it. -func (g *Game) fireEnterStep(seq *enterSeq, step *behavior.StepAction) { - p := seq.sess.Player - if p == nil || p.RoomID != seq.roomID { - return - } - g.applyStepAction(seq.sess, p, step, seq.roomID, scopePlayerSeq, nil) -} - -func (g *Game) completeEnterSeq(seq *enterSeq) { - if !g.isCharLive(seq.playerName, seq.sess) { +// runExitSteps evaluates a room's on_exit block for the player. It fires the +// first matching trigger as a non-room-locked sequence using the room being +// left as the reference room (broadcasts/spawn resolve to the old room). It is +// invoked before the player's RoomID is updated to the destination. +func (g *Game) runExitSteps(sess *net.Session, roomID int) { + room, err := g.World.LoadRoom(roomID) + if err != nil || len(room.OnExit) == 0 { return } - p := seq.sess.Player + p := sess.Player if p == nil { return } - if p.EnterSeqRoom == seq.roomID { - p.EnterSeqRoom = 0 - g.AccountStore.SaveCharacter(p) - } - g.writePrompt(seq.sess) -} - -// cancelEnterSeq drops any in-flight enter sequence for the player. It does not -// touch the persisted EnterSeqRoom marker, so a sequence cancelled by -// disconnect can still be resumed on reconnect. -func (g *Game) cancelEnterSeq(name string) { - g.enterMu.Lock() - delete(g.enterSeqs, name) - g.enterMu.Unlock() -} - -func (g *Game) isCharLive(name string, sess *net.Session) bool { - g.charsMu.Lock() - defer g.charsMu.Unlock() - return g.loggedInChars[name] == sess + g.runTrigger(sess, p, room.OnExit, roomID, nil, false) } // applyFlagMutations sets global and player flags. It is the player-scoped -// (scopePlayer / scopePlayerSeq) flag step of applyStepAction, called once -// per non-global step that carries set_global_flags and/or set_player_flags. -// scopeGlobal sets globals directly in applyStepAction step 4 instead (so -// world-scoped cascades don't reach back into this helper). +// flag step of applyStep, called once per step that carries set_global_flags +// and/or set_player_flags. scopeGlobal sets globals directly in applyStep +// instead (so world-scoped cascades don't reach back into this helper). func (g *Game) applyFlagMutations(p *player.Player, setGlobalFlags, setPlayerFlags map[string]any) { g.GlobalFlags.SetAll(setGlobalFlags) if len(setPlayerFlags) > 0 { @@ -213,3 +75,11 @@ func (g *Game) BroadcastRespawns() { } } } + +// isCharLive reports whether the named player is currently connected via the +// given session (used by older helpers that still reference it). +func (g *Game) isCharLive(name string, sess *net.Session) bool { + g.charsMu.Lock() + defer g.charsMu.Unlock() + return g.loggedInChars[name] == sess +} diff --git a/internal/game/act_state.go b/internal/game/act_state.go index 130b088..73ecd57 100644 --- a/internal/game/act_state.go +++ b/internal/game/act_state.go @@ -81,8 +81,6 @@ func (g *Game) playerActionDisplay(p *player.Player) string { return "constructing some " + a.TargetName case behavior.TypeTriggerModule: return "triggering " + a.TargetName - case behavior.TypeInteractionSeq: - return "using " + a.TargetName } } diff --git a/internal/game/act_steal.go b/internal/game/act_steal.go index d081d8b..bf0ce14 100644 --- a/internal/game/act_steal.go +++ b/internal/game/act_steal.go @@ -37,17 +37,17 @@ var stallGuardTalk = &behavior.TalkConfig{ }, "bribe": { Messages: []string{"Smart choice. Hand over 500 credits and we'll forget this happened."}, - Action: &behavior.NodeAction{Credits: -500}, + Action: &behavior.Step{Credits: -500}, Options: []behavior.TalkOption{{Text: "\"Fine, take it.\""}}, }, "jail": { Messages: []string{"Off to the detention cell with you!"}, - Action: &behavior.NodeAction{Teleport: 162}, + Action: &behavior.Step{Teleport: 162}, Options: []behavior.TalkOption{{Text: "(You are dragged away)"}}, }, "fight": { Messages: []string{"Then defend yourself!"}, - Action: &behavior.NodeAction{SetGlobalFlags: map[string]any{"guard_hostile": true}}, + Action: &behavior.Step{SetGlobalFlags: map[string]any{"guard_hostile": true}}, Options: []behavior.TalkOption{{Text: "(The guard attacks!)"}}, }, }, diff --git a/internal/game/act_talk.go b/internal/game/act_talk.go index e08fbd2..8de58df 100644 --- a/internal/game/act_talk.go +++ b/internal/game/act_talk.go @@ -56,8 +56,8 @@ func (g *Game) startTalk(sess *net.Session, p *player.Player, obj *object.Object } } -func (g *Game) handleNodeAction(sess *net.Session, na *behavior.NodeAction) (intercepted bool) { - g.applyNodeAction(sess, na) +func (g *Game) handleNodeAction(sess *net.Session, na *behavior.Step) (intercepted bool) { + g.applyTalkStep(sess, na) if v, ok := g.GlobalFlags.Get("guard_hostile"); ok && v != nil { g.GlobalFlags.Delete("guard_hostile") diff --git a/internal/game/cmd_dig.go b/internal/game/cmd_dig.go index e3bc2b5..3fda55e 100644 --- a/internal/game/cmd_dig.go +++ b/internal/game/cmd_dig.go @@ -90,10 +90,7 @@ func (g *Game) executeDig(sess *net.Session, args []string, rawInput string) { } p.Action = nil g.cancelRest(p.Name) - g.cancelEnterSeq(p.Name) - if p.EnterSeqRoom != 0 && p.EnterSeqRoom == oldRoom { - p.EnterSeqRoom = 0 - } + g.cancelSequences(p.Name) if ss, ok := g.safespot.Get(p.Name); ok { g.forceLeaveSafespot(sess, p, &ss, "") } @@ -189,10 +186,7 @@ func (g *Game) executeDig(sess *net.Session, args []string, rawInput string) { } p.Action = nil g.cancelRest(p.Name) - g.cancelEnterSeq(p.Name) - if p.EnterSeqRoom != 0 && p.EnterSeqRoom == oldRoom { - p.EnterSeqRoom = 0 - } + g.cancelSequences(p.Name) if ss, ok := g.safespot.Get(p.Name); ok { g.forceLeaveSafespot(sess, p, &ss, "") } diff --git a/internal/game/cmd_goto.go b/internal/game/cmd_goto.go index 41e7b37..1771f8d 100644 --- a/internal/game/cmd_goto.go +++ b/internal/game/cmd_goto.go @@ -37,10 +37,7 @@ func (g *Game) executeGoto(sess *net.Session, args []string, rawInput string) { } p.Action = nil g.cancelRest(p.Name) - g.cancelEnterSeq(p.Name) - if p.EnterSeqRoom != 0 && p.EnterSeqRoom == oldRoom { - p.EnterSeqRoom = 0 - } + g.cancelSequences(p.Name) if ss, ok := g.safespot.Get(p.Name); ok { g.forceLeaveSafespot(sess, p, &ss, "") } diff --git a/internal/game/cmd_inspect.go b/internal/game/cmd_inspect.go index 7488591..2c1b611 100644 --- a/internal/game/cmd_inspect.go +++ b/internal/game/cmd_inspect.go @@ -85,19 +85,21 @@ func (g *Game) executeInspect(sess *net.Session, args []string, rawInput string) } if len(room.OnEnter) > 0 { - sess.WriteLine(fmt.Sprintf("\nOn-Enter Steps: %d", len(room.OnEnter))) + sess.WriteLine(fmt.Sprintf("\nOn-Enter Triggers: %d", len(room.OnEnter))) } - - if len(room.Triggers) > 0 { - sess.WriteLine(fmt.Sprintf("\nRoom Triggers: %d", len(room.Triggers))) - for _, trigger := range room.Triggers { - kind := "" - if trigger.OnGlobalFlag != "" { - kind = fmt.Sprintf("global flag %q", trigger.OnGlobalFlag) - } else if trigger.OnPlayerFlag != "" { - kind = fmt.Sprintf("player flag %q", trigger.OnPlayerFlag) - } - sess.WriteLine(fmt.Sprintf(" %s: %s (%d steps)", trigger.ID, kind, len(trigger.Steps))) + if len(room.OnExit) > 0 { + sess.WriteLine(fmt.Sprintf("\nOn-Exit Triggers: %d", len(room.OnExit))) + } + if len(room.OnFlagChange) > 0 { + sess.WriteLine(fmt.Sprintf("\nOn-Flag-Change Triggers: %d", len(room.OnFlagChange))) + for _, t := range room.OnFlagChange { + sess.WriteLine(fmt.Sprintf(" player flag %q (%d steps)", t.OnPlayerFlag, len(t.Steps))) + } + } + if len(room.OnGlobalFlagChange) > 0 { + sess.WriteLine(fmt.Sprintf("\nOn-Global-Flag-Change Triggers: %d", len(room.OnGlobalFlagChange))) + for _, t := range room.OnGlobalFlagChange { + sess.WriteLine(fmt.Sprintf(" global flag %q (%d steps)", t.OnGlobalFlag, len(t.Steps))) } } diff --git a/internal/game/cmd_move.go b/internal/game/cmd_move.go index 4391b1f..2967e79 100644 --- a/internal/game/cmd_move.go +++ b/internal/game/cmd_move.go @@ -73,7 +73,7 @@ func (g *Game) doMove(sess *net.Session, dir string, multiplier float64) { // fire in completeMove once the player arrives. ExitDef.Condition (above, // evaluated by exitDisplayState) gates whether the exit is passable at // all; each on_traverse entry has its own additional Condition gate. - p.MovePendingInteraction = g.matchExitInteraction(sess, p, exitDef) + p.MovePendingTrigger = g.matchExitTrigger(sess, p, exitDef) inCombat := g.Combat.Get(p.Name) != nil @@ -101,17 +101,17 @@ func (g *Game) doMove(sess *net.Session, dir string, multiplier float64) { } } -// matchExitInteraction returns the first on_traverse Interaction on exitDef -// whose Condition (if any) passes for this player, or nil if the exit has no -// on_traverse entries. The matched interaction's Action fires (with its optional -// Message) once the player arrives in the target room. -func (g *Game) matchExitInteraction(sess *net.Session, p *player.Player, exitDef world.ExitDef) *behavior.Interaction { +// matchExitTrigger returns the first on_traverse Trigger on exitDef whose +// Condition (and item filter, n/a for traverse) passes for this player, or nil +// if the exit has no matching on_traverse entry. The matched trigger's Steps +// run as a sequence once the player arrives in the target room. +func (g *Game) matchExitTrigger(sess *net.Session, p *player.Player, exitDef world.ExitDef) *behavior.Trigger { for i := range exitDef.OnTraverse { - it := &exitDef.OnTraverse[i] - if it.Condition != nil && !g.checkCondition(sess, it.Condition) { + t := &exitDef.OnTraverse[i] + if t.Condition != nil && !g.checkCondition(sess, t.Condition) { continue } - return it + return t } return nil } @@ -152,7 +152,7 @@ func (g *Game) moveTicks(p *player.Player, multiplier float64) int { func (g *Game) completeMove(sess *net.Session, p *player.Player) { exitDir := p.MoveDirection targetID := p.MoveTarget - pendingInteraction := p.MovePendingInteraction + pendingTrigger := p.MovePendingTrigger p.ClearMoveState() @@ -174,19 +174,26 @@ func (g *Game) completeMove(sess *net.Session, p *player.Player) { } } } + + // Interruptable: an unlocked in-flight verb sequence is cancelled by the + // move. (Locked sequences block movement entirely at the command router.) + g.cancelUnlockedSequences(p.Name) + + // Fire the on_exit block of the room being left BEFORE updating RoomID, so + // the exit scene resolves against the old room. + if oldRoom != targetID { + g.runExitSteps(sess, oldRoom) + } + p.RoomID = targetID p.HazardTimer = 0 p.Stats.RecordRoomVisit(targetID) - g.cancelEnterSeq(p.Name) - if p.EnterSeqRoom != 0 && p.EnterSeqRoom == oldRoom { - p.EnterSeqRoom = 0 - } - // Fire the on_traverse interaction (if any matched at classification - // time) AFTER p.RoomID is set to the new room, so effect fields like - // aps_node / teleport operate on the destination as their reference frame. - if pendingInteraction != nil { - g.runInteraction(sess, p, pendingInteraction.Action, p.RoomID, scopePlayer, nil) + // Fire the on_traverse trigger (if any matched at classification time) + // AFTER p.RoomID is set to the new room, so effects like aps_node/teleport + // operate on the destination as their reference frame. + if pendingTrigger != nil { + g.runTrigger(sess, p, []behavior.Trigger{*pendingTrigger}, p.RoomID, nil, false) } g.AccountStore.SaveCharacter(p) diff --git a/internal/game/cmd_registry.go b/internal/game/cmd_registry.go index e2c34aa..d03993a 100644 --- a/internal/game/cmd_registry.go +++ b/internal/game/cmd_registry.go @@ -150,7 +150,7 @@ var commandRegistry = map[string]commandDef{ "goto": {(*Game).executeGoto, ClassInstant}, "summon": {(*Game).executeSummon, ClassInstant}, "dig": {(*Game).executeDig, ClassInstant}, - "setglobalflag": {(*Game).executeSetGlobalFlag, ClassInstant}, + "setglobalflag": {(*Game).executeSetGlobalFlag, ClassInstant}, "setplayerflag": {(*Game).executeSetPlayerFlag, ClassInstant}, "reload": {(*Game).executeReload, ClassInstant}, "shutdown": {(*Game).executeShutdown, ClassInstant}, @@ -177,6 +177,24 @@ func classifyCommand(cmd string) CommandClass { } func (g *Game) executeCommand(sess *net.Session, cmd string, args []string, rawInput string) { + p := sess.Player + + // Locked-sequence busy gate: a locked in-flight trigger sequence blocks every + // verb except quit/logout (and god-mode admins) until it completes. + if p != nil && !p.GodMode && g.playerLocked(p.Name) && cmd != "quit" && cmd != "logout" { + sess.WriteLine("You're busy — wait for the current action to finish.") + return + } + + // Unlocked sequences are interruptable: any active/free verb cancels them + // before running. (Instant info commands like look/inventory/help do not.) + if p != nil { + switch classifyCommand(cmd) { + case ClassActive, ClassFree: + g.cancelUnlockedSequences(p.Name) + } + } + if def, ok := commandRegistry[cmd]; ok { def.handler(g, sess, args, rawInput) return @@ -187,7 +205,6 @@ func (g *Game) executeCommand(sess *net.Session, cmd string, args []string, rawI return } - p := sess.Player if a, ok := verbAliases[cmd]; ok { g.cancelAction(p) switch a { diff --git a/internal/game/cmd_reload.go b/internal/game/cmd_reload.go index 2b8230a..3fa6054 100644 --- a/internal/game/cmd_reload.go +++ b/internal/game/cmd_reload.go @@ -29,9 +29,9 @@ func (g *Game) executeReload(sess *net.Session, args []string, rawInput string) g.World.RebuildRoomIndex(g.DataDir) g.World.ClearHazardCache() - g.TriggerStore.ClearTriggers() - g.TriggerStore.LoadGlobal(g.DataDir) - g.seedRoomTriggers() + // Abort all in-flight sequences, then rebuild the flag-trigger index. + g.abortAllSequences() + g.loadAllFlagTriggers() sess.WriteLine("All caches reloaded.") } diff --git a/internal/game/cmd_room_insert.go b/internal/game/cmd_room_insert.go index 15b05ef..132469d 100644 --- a/internal/game/cmd_room_insert.go +++ b/internal/game/cmd_room_insert.go @@ -177,10 +177,7 @@ func (g *Game) roomInsert(sess *net.Session, args []string) { } p.Action = nil g.cancelRest(p.Name) - g.cancelEnterSeq(p.Name) - if p.EnterSeqRoom != 0 && p.EnterSeqRoom == oldRoom { - p.EnterSeqRoom = 0 - } + g.cancelSequences(p.Name) if ss, ok := g.safespot.Get(p.Name); ok { g.forceLeaveSafespot(sess, p, &ss, "") } diff --git a/internal/game/cmd_room_remove_test.go b/internal/game/cmd_room_remove_test.go index a13af8a..8bd8470 100644 --- a/internal/game/cmd_room_remove_test.go +++ b/internal/game/cmd_room_remove_test.go @@ -17,9 +17,12 @@ import ( type removeTestConn struct{ buf []byte } func (c *removeTestConn) ReadMessage() (string, error) { return "", nil } -func (c *removeTestConn) Write(b []byte) (int, error) { c.buf = append(c.buf, b...); return len(b), nil } -func (c *removeTestConn) Close() error { return nil } -func (c *removeTestConn) SetEcho(bool) error { return nil } +func (c *removeTestConn) Write(b []byte) (int, error) { + c.buf = append(c.buf, b...) + return len(b), nil +} +func (c *removeTestConn) Close() error { return nil } +func (c *removeTestConn) SetEcho(bool) error { return nil } func (c *removeTestConn) output() string { return string(c.buf) } diff --git a/internal/game/cmd_summon.go b/internal/game/cmd_summon.go index 3aa8d7b..9383e31 100644 --- a/internal/game/cmd_summon.go +++ b/internal/game/cmd_summon.go @@ -52,10 +52,7 @@ func (g *Game) executeSummon(sess *net.Session, args []string, rawInput string) } tp.Action = nil g.cancelRest(tp.Name) - g.cancelEnterSeq(tp.Name) - if tp.EnterSeqRoom != 0 && tp.EnterSeqRoom == oldRoom { - tp.EnterSeqRoom = 0 - } + g.cancelSequences(tp.Name) if ss, ok := g.safespot.Get(tp.Name); ok { g.forceLeaveSafespot(targetSess, tp, &ss, "") } diff --git a/internal/game/cmd_use.go b/internal/game/cmd_use.go index a2cb135..975eddb 100644 --- a/internal/game/cmd_use.go +++ b/internal/game/cmd_use.go @@ -90,17 +90,10 @@ func (g *Game) useRoomObject(sess *net.Session, p *player.Player, input string) g.startAction(sess, bt, def.Name) return true } - for _, ui := range def.OnUse { - if ui.Item != "" { - continue - } - if ui.Condition != nil && !g.checkCondition(sess, ui.Condition) { - continue - } - g.cancelAction(p) - name := def.Name - g.broadcastAction(sess, "%s uses the %s.", p.Name, name) - g.runInteraction(sess, p, ui.Action, p.RoomID, scopePlayer, nil) + g.cancelAction(p) + name := def.Name + g.broadcastAction(sess, "%s uses the %s.", p.Name, name) + if g.runTrigger(sess, p, def.OnUse, p.RoomID, func(id string) bool { return false }, false) { return true } if len(def.OnUse) > 0 { @@ -276,17 +269,10 @@ func (g *Game) doUseItemOnTarget(sess *net.Session, p *player.Player, itemAName, return } - for _, ui := range def.OnUse { - if ui.Item != itemAID { - continue - } - if ui.Condition != nil && !g.checkCondition(sess, ui.Condition) { - continue - } - g.cancelAction(p) - name := def.Name - g.broadcastAction(sess, "%s uses the %s.", p.Name, name) - g.runInteraction(sess, p, ui.Action, p.RoomID, scopePlayer, nil) + g.cancelAction(p) + name := def.Name + g.broadcastAction(sess, "%s uses the %s.", p.Name, name) + if g.runTrigger(sess, p, def.OnUse, p.RoomID, func(id string) bool { return id == itemAID }, false) { return } diff --git a/internal/game/cmd_verbs.go b/internal/game/cmd_verbs.go index 6c20004..24957ab 100644 --- a/internal/game/cmd_verbs.go +++ b/internal/game/cmd_verbs.go @@ -76,7 +76,7 @@ func (g *Game) executeVerbs(sess *net.Session, args []string, rawInput string) { } for _, ui := range def.OnUse { - if ui.Item == "" && (ui.Condition == nil || g.checkCondition(sess, ui.Condition)) { + if ui.ItemID == "" && (ui.Condition == nil || g.checkCondition(sess, ui.Condition)) { addUnique(§ionObjs, &seen, "use "+name) break } @@ -107,8 +107,8 @@ func (g *Game) executeVerbs(sess *net.Session, args []string, rawInput string) { } for _, ui := range def.OnUse { - if ui.Item != "" && (ui.Condition == nil || g.checkCondition(sess, ui.Condition)) { - if itemDef, err := g.ItemStore.Load(ui.Item); err == nil { + if ui.ItemID != "" && (ui.Condition == nil || g.checkCondition(sess, ui.Condition)) { + if itemDef, err := g.ItemStore.Load(ui.ItemID); err == nil { addUnique(§ionObjs, &seen, "use "+itemDef.Name+" on "+name) } } diff --git a/internal/game/combat_mob.go b/internal/game/combat_mob.go index 0395049..9affede 100644 --- a/internal/game/combat_mob.go +++ b/internal/game/combat_mob.go @@ -221,7 +221,7 @@ func (g *Game) endCombat(sess *net.Session, p *player.Player, mob *world.MobInst // task mob's HP to zero is the "kill" that completes the work). itemMatch // is the "wielding" gate — on_kill entries with an item_id only fire if // the player currently has that item equipped in a weapon-hand slot. - g.applyInteraction(sess, p, mob.OnKill, p.RoomID, true, p.IsWielding) + g.runTrigger(sess, p, mob.OnKill, p.RoomID, p.IsWielding, false) g.scheduleMobRespawn(mob) g.writePrompt(sess) } diff --git a/internal/game/combat_mob_test.go b/internal/game/combat_mob_test.go index 28427b7..70c744b 100644 --- a/internal/game/combat_mob_test.go +++ b/internal/game/combat_mob_test.go @@ -48,7 +48,7 @@ func TestMobStrongestRangedScience(t *testing.T) { for _, c := range cases { t.Run(c.name, func(t *testing.T) { mob := &world.MobInstance{ - AttackTypes: c.types, + AttackTypes: c.types, MaxRangedHit: c.maxRanged, MaxScienceHit: c.maxScience, SciencePercentBonus: c.sciencePct, diff --git a/internal/game/core_course.go b/internal/game/core_course.go index 6394b99..232e6dc 100644 --- a/internal/game/core_course.go +++ b/internal/game/core_course.go @@ -252,4 +252,4 @@ func (cs *CourseStore) loadAllLocked() { }) obstacleVerbs = localVerbs -} \ No newline at end of file +} diff --git a/internal/game/core_flags.go b/internal/game/core_flags.go index c78f3c3..dbde5d7 100644 --- a/internal/game/core_flags.go +++ b/internal/game/core_flags.go @@ -41,20 +41,10 @@ func (g *Game) setPlayerFlag(p *player.Player, key string, val any) { old, existed := p.Flags[key] p.Flags[key] = val if !existed || !engine.ValuesEqual(old, val) { - g.firePlayerFlagTrigger(p, key, val) + g.firePlayerFlagTriggers(p, key, val) } } -func (g *Game) firePlayerFlagTrigger(p *player.Player, flagName string, flagValue any) { - g.charsMu.Lock() - sess := g.loggedInChars[p.Name] - g.charsMu.Unlock() - if sess == nil { - return - } - g.TriggerStore.FirePlayerInRoom(p.Name, p.RoomID, flagName, flagValue) -} - func intFromFlag(flags map[string]any, key string) int { val, ok := flags[key] if !ok { diff --git a/internal/game/core_login_char.go b/internal/game/core_login_char.go index 90c06be..38499e2 100644 --- a/internal/game/core_login_char.go +++ b/internal/game/core_login_char.go @@ -112,21 +112,8 @@ func (g *Game) connectCharacter(sess *net.Session, name string) { g.doLook(sess) g.checkAggro(sess) - // Resume an interrupted on_enter sequence (e.g. disconnected mid-sequence). - // Only fires when a persisted marker matches the room the player is in, so - // ordinary on_enter scripts never run on login. - if p.EnterSeqRoom != 0 { - if p.EnterSeqRoom == p.RoomID { - g.runEnterSteps(sess, p.RoomID) - } - g.enterMu.Lock() - _, active := g.enterSeqs[p.Name] - g.enterMu.Unlock() - if !active { - p.EnterSeqRoom = 0 - g.AccountStore.SaveCharacter(p) - } - } + // Resume an interrupted LOCKED trigger sequence saved across disconnect. + g.resumePendingSequence(sess, p) sess.WritePrompt("> ") } diff --git a/internal/game/exit_migrate.go b/internal/game/exit_migrate.go index 2872f34..ff905f6 100644 --- a/internal/game/exit_migrate.go +++ b/internal/game/exit_migrate.go @@ -9,8 +9,8 @@ import ( // rewritePlayerRoomIDs remaps room-ID-embedded state in a single player per // idMap (oldID -> newID): discovered-exit player flag keys -// (hidden_exit__), current RoomID, EnterSeqRoom, MapSymbols keys, and -// Stats.RoomsVisited bitset. Returns whether any field was changed. +// (hidden_exit__), current RoomID, PendingSequence.RoomID, MapSymbols +// keys, and Stats.RoomsVisited bitset. Returns whether any field was changed. // // idMap is assumed to be a bijection (see Room.RewriteRoomIDs); the flag-key // and int-map rekeys use delete-all-then-set-all so a swap (A<->B) cannot @@ -23,7 +23,7 @@ func rewritePlayerRoomIDs(p *player.Player, idMap map[int]int) bool { if remapScalar(idMap, &p.RoomID) { changed = true } - if p.EnterSeqRoom != 0 && remapScalar(idMap, &p.EnterSeqRoom) { + if p.PendingSequence != nil && p.PendingSequence.RoomID != 0 && remapScalar(idMap, &p.PendingSequence.RoomID) { changed = true } if len(p.Flags) > 0 && rewriteDiscoveredExitFlags(p.Flags, idMap) { diff --git a/internal/game/exit_migrate_test.go b/internal/game/exit_migrate_test.go index f86b85b..1ca33f7 100644 --- a/internal/game/exit_migrate_test.go +++ b/internal/game/exit_migrate_test.go @@ -9,8 +9,8 @@ import ( func TestRewritePlayerRoomIDsRename(t *testing.T) { p := &player.Player{ - RoomID: 10, - EnterSeqRoom: 20, + RoomID: 10, + PendingSequence: &player.PendingSequence{RoomID: 20, Key: "k"}, Flags: map[string]any{ "hidden_exit_10_north": 1, "hidden_exit_20_down": 1, @@ -31,8 +31,8 @@ func TestRewritePlayerRoomIDsRename(t *testing.T) { if p.RoomID != 100 { t.Errorf("RoomID = %d, want 100", p.RoomID) } - if p.EnterSeqRoom != 200 { - t.Errorf("EnterSeqRoom = %d, want 200", p.EnterSeqRoom) + if p.PendingSequence == nil || p.PendingSequence.RoomID != 200 { + t.Errorf("PendingSequence.RoomID = %v, want 200", p.PendingSequence) } wantFlags := map[string]any{ diff --git a/internal/game/game.go b/internal/game/game.go index f34e649..eed61f5 100644 --- a/internal/game/game.go +++ b/internal/game/game.go @@ -50,9 +50,9 @@ type Game struct { Deps Hub *net.Hub - GlobalFlags *GlobalFlagStore + GlobalFlags *GlobalFlagStore Combat *combat.Tracker - TriggerStore *world.TriggerStore + flagIndex *flagTriggerIndex queue *CommandQueue safespot *SafespotManager ValidationConfig config.ValidationConfig @@ -66,8 +66,9 @@ type Game struct { hackingStates map[string]*hacking.Session pendingDepletions []pendingDepletion farmTickCounter int - enterMu sync.Mutex - enterSeqs map[string]*enterSeq + seqMu sync.Mutex + sequences map[string]*sequence + globalSeqs []*sequence shutdownCancel chan struct{} shutdownActive bool @@ -88,9 +89,9 @@ func New(dataDir string, colorConfig *config.ColorsConfig, valConfig config.Vali ColorConfig: colorConfig, DataDir: dataDir, }, - GlobalFlags: NewGlobalFlagStore(), + GlobalFlags: NewGlobalFlagStore(), Combat: combat.NewTracker(), - TriggerStore: world.NewTriggerStore(), + flagIndex: newFlagTriggerIndex(), queue: NewCommandQueue(), safespot: NewSafespotManager(), ValidationConfig: valConfig, @@ -99,14 +100,14 @@ func New(dataDir string, colorConfig *config.ColorsConfig, valConfig config.Vali restTimers: make(map[string]uint64), guardWatchTimers: make(map[string]int), hackingStates: make(map[string]*hacking.Session), - enterSeqs: make(map[string]*enterSeq), + sequences: make(map[string]*sequence), } g.CourseStore.SetWorld(g.World) g.CourseStore.LoadAll() g.LoadMods() g.LoadTechs() g.buildCraftIndex() - g.seedRoomTriggers() + g.loadAllFlagTriggers() g.ValidateAndLog() return g } @@ -116,9 +117,9 @@ func (g *Game) SetHub(hub *net.Hub) { hub.OnRemove(func(sess *net.Session) { if p := sess.Player; p != nil { g.restoreGodPlayer(p) + g.persistLockedOnDisconnect(p) g.AccountStore.SaveCharacter(p) p.DeactivateAllTechs() - g.cancelEnterSeq(p.Name) g.charsMu.Lock() delete(g.loggedInChars, p.Name) g.charsMu.Unlock() @@ -129,7 +130,7 @@ func (g *Game) SetHub(hub *net.Hub) { } }) g.GlobalFlags.OnChange(func(name string, value any) { - g.TriggerStore.FireGlobal(name, value) + g.fireGlobalFlagTriggers(name, value) }) } @@ -353,18 +354,6 @@ func (g *Game) buildCraftIndex() { g.CraftIndex.Build(items) } -func (g *Game) seedRoomTriggers() { - g.TriggerStore.LoadGlobal(g.DataDir) - roomIndex := g.World.RoomIndex() - for id := range roomIndex { - room, err := g.World.LoadRoom(id) - if err != nil || len(room.Triggers) == 0 { - continue - } - g.TriggerStore.SeedRoomTriggers(id, room.Triggers) - } -} - type pendingDepletion struct { instanceKey string objDefID string diff --git a/internal/game/look_target.go b/internal/game/look_target.go index 24a1df5..3f7141e 100644 --- a/internal/game/look_target.go +++ b/internal/game/look_target.go @@ -155,7 +155,7 @@ func (g *Game) doLookTarget(sess *net.Session, input string) { } if len(def.OnLook) > 0 { - g.applyInteraction(sess, p, def.OnLook, p.RoomID, false, p.HasItem) + g.runTrigger(sess, p, def.OnLook, p.RoomID, p.HasItem, false) } if def.Safespot != nil { diff --git a/internal/game/sys_triggers.go b/internal/game/sys_triggers.go index 6a1e224..a1955e7 100644 --- a/internal/game/sys_triggers.go +++ b/internal/game/sys_triggers.go @@ -2,7 +2,12 @@ package game import ( "fmt" + "os" + "path/filepath" "strings" + "sync" + + "gopkg.in/yaml.v3" "thehouseoficarus/internal/behavior" "thehouseoficarus/internal/engine" @@ -10,11 +15,349 @@ import ( "thehouseoficarus/internal/player" ) -func (g *Game) TriggerSeqTick() { - g.processPlayerTriggerSequences() - g.processGlobalTriggerSequences() +// sequence is an in-flight scripted trigger sequence (one Trigger's Steps +// running over time). It is driven one step at a time by SequenceTick. Every +// trigger in the game — on_use, on_look, on_kill, on_enter, on_exit, +// on_traverse, on_flag_change, on_global_flag_change — produces a sequence. +type sequence struct { + sess *net.Session // nil for purely-global sequences + player *player.Player + playerName string + roomID int // reference room for broadcasts/spawn/teleport + steps []behavior.Step + wait int // ticks before the next step fires + scope effectScope // scopePlayer | scopeGlobal + flagVal any + key string // dedup key + locked bool // atomic + resumable on disconnect + roomLocked bool // skip steps whose player has left roomID +} + +// verbSeqKey builds a dedup key for a verb-triggered sequence so two of the +// same verb can't run simultaneously for one player. +func verbSeqKey(p *player.Player, itemID string) string { + if p == nil { + return "" + } + if itemID != "" { + return p.Name + ":verb:" + itemID + } + return p.Name + ":verb" +} + +// startSequence registers a sequence (deduped by key). If a sequence with the +// same key is already in flight for the player, the new one is dropped (no +// re-entrancy). +func (g *Game) startSequence(sess *net.Session, p *player.Player, steps []behavior.Step, roomID int, sc effectScope, flagVal any, locked, roomLocked bool, key string) { + if len(steps) == 0 && sess != nil { + return + } + seq := &sequence{ + sess: sess, + player: p, + roomID: roomID, + steps: append([]behavior.Step(nil), steps...), + scope: sc, + flagVal: flagVal, + key: key, + locked: locked, + roomLocked: roomLocked, + } + if p != nil { + seq.playerName = p.Name + } + if len(steps) > 0 { + seq.wait = steps[0].Wait + } + g.seqMu.Lock() + if key != "" { + if _, exists := g.sequences[key]; exists { + g.seqMu.Unlock() + return + } + g.sequences[key] = seq + } else { + g.globalSeqs = append(g.globalSeqs, seq) + } + g.seqMu.Unlock() + if locked && p != nil { + p.PendingSequence = &player.PendingSequence{ + Key: key, + RoomID: roomID, + Steps: append([]behavior.Step(nil), steps...), + Wait: seq.wait, + } + g.AccountStore.SaveCharacter(p) + } +} + +// SequenceTick advances every in-flight sequence by one tick, draining +// consecutive zero-wait steps in a single tick (matching the original +// on_enter/trigger schedulers). Player-bound sequences for a player who is no +// longer connected are dropped (unlocked) or persisted (locked). Completed +// sequences are removed. +func (g *Game) SequenceTick() { + g.seqMu.Lock() + playerSeqs := make([]*sequence, 0, len(g.sequences)) + for _, s := range g.sequences { + playerSeqs = append(playerSeqs, s) + } + globalSeqs := append([]*sequence(nil), g.globalSeqs...) + g.seqMu.Unlock() + + for _, seq := range playerSeqs { + g.advanceSequence(seq) + } + for _, seq := range globalSeqs { + g.advanceSequence(seq) + } +} + +func (g *Game) advanceSequence(seq *sequence) { + // Player-bound: handle disconnect and live-session gating. + if seq.scope == scopePlayer { + g.charsMu.Lock() + sess := g.loggedInChars[seq.playerName] + g.charsMu.Unlock() + if sess == nil || sess.Player == nil { + // Disconnected. Unlocked sequences are dropped; locked ones were + // already snapshotted to PendingSequence at disconnect time, so + // drop the in-memory copy too. + g.removeSequence(seq) + return + } + p := sess.Player + // Room-lock: skip steps whose player is no longer in the reference room. + inRoom := p.RoomID == seq.roomID + if seq.roomLocked && !inRoom { + // Still decrement the wait so timing advances, but don't fire. + // Drop a step when its wait elapses; resume firing on return. + seq.wait-- + if seq.wait <= 0 && len(seq.steps) > 0 { + seq.steps = seq.steps[1:] + if len(seq.steps) > 0 { + seq.wait = seq.steps[0].Wait + } + } + if len(seq.steps) == 0 { + g.completePlayerSequence(seq, p) + } + return + } + + seq.wait-- + var jobs []behavior.Step + for seq.wait <= 0 && len(seq.steps) > 0 { + step := seq.steps[0] + seq.steps = seq.steps[1:] + jobs = append(jobs, step) + if len(seq.steps) > 0 { + seq.wait = seq.steps[0].Wait + } else { + seq.wait = 0 + } + } + for i := range jobs { + g.fireStep(seq, sess, p, &jobs[i]) + } + if len(seq.steps) == 0 { + g.completePlayerSequence(seq, p) + } + return + } + + // Global sequence: no player/room. + seq.wait-- + var jobs []behavior.Step + for seq.wait <= 0 && len(seq.steps) > 0 { + step := seq.steps[0] + seq.steps = seq.steps[1:] + jobs = append(jobs, step) + if len(seq.steps) > 0 { + seq.wait = seq.steps[0].Wait + } else { + seq.wait = 0 + } + } + for i := range jobs { + g.fireGlobalStep(seq, &jobs[i]) + } + if len(seq.steps) == 0 { + g.completeGlobalSequence(seq) + } +} + +// fireStep runs one player-scoped step, gated by its per-step Condition. +func (g *Game) fireStep(seq *sequence, sess *net.Session, p *player.Player, step *behavior.Step) { + if step.Condition != nil && !g.checkCondition(sess, step.Condition) { + return + } + g.applyStep(sess, p, step, seq.roomID, scopePlayer, seq.flagVal) +} + +// fireGlobalStep runs one global-scoped step. +func (g *Game) fireGlobalStep(seq *sequence, step *behavior.Step) { + g.applyStep(nil, nil, step, seq.roomID, scopeGlobal, seq.flagVal) +} + +func (g *Game) completePlayerSequence(seq *sequence, p *player.Player) { + g.removeSequence(seq) + if p != nil && p.PendingSequence != nil && p.PendingSequence.Key == seq.key { + p.PendingSequence = nil + g.AccountStore.SaveCharacter(p) + } + if sess := seq.sess; sess != nil && sess.State == net.StateGame { + g.writePrompt(sess) + } +} + +func (g *Game) completeGlobalSequence(seq *sequence) { + g.removeSequence(seq) +} + +func (g *Game) removeSequence(seq *sequence) { + g.seqMu.Lock() + if seq.key != "" { + delete(g.sequences, seq.key) + } else { + for i, s := range g.globalSeqs { + if s == seq { + g.globalSeqs = append(g.globalSeqs[:i], g.globalSeqs[i+1:]...) + break + } + } + } + g.seqMu.Unlock() +} + +// playerLocked reports whether the player has a locked in-flight sequence (and +// is therefore "busy" — verbs are blocked except quit). +func (g *Game) playerLocked(name string) bool { + g.seqMu.Lock() + defer g.seqMu.Unlock() + for _, s := range g.sequences { + if s.playerName == name && s.locked { + return true + } + } + return false } +// cancelUnlockedSequences drops all unlocked in-flight sequences for the +// player (an unlocked sequence is interruptable by any verb). Locked sequences +// are never cancelled by verbs. +func (g *Game) cancelUnlockedSequences(name string) { + g.seqMu.Lock() + var toRemove []string + for k, s := range g.sequences { + if s.playerName == name && !s.locked { + toRemove = append(toRemove, k) + } + } + for _, k := range toRemove { + delete(g.sequences, k) + } + g.seqMu.Unlock() +} + +// cancelSequences drops ALL in-flight sequences for the player (used on admin +// teleport / forced movement that bypasses the busy lock). +func (g *Game) cancelSequences(name string) { + g.seqMu.Lock() + var toRemove []string + for k, s := range g.sequences { + if s.playerName == name { + toRemove = append(toRemove, k) + } + } + for _, k := range toRemove { + delete(g.sequences, k) + } + g.seqMu.Unlock() +} + +// abortAllSequences drops every in-flight sequence (player and global) and +// clears persisted PendingSequence markers. Used on admin reload. +func (g *Game) abortAllSequences() { + g.seqMu.Lock() + g.sequences = make(map[string]*sequence) + g.globalSeqs = nil + g.seqMu.Unlock() + g.charsMu.Lock() + chars := g.loggedInChars + g.charsMu.Unlock() + for _, sess := range chars { + if sess != nil && sess.Player != nil && sess.Player.PendingSequence != nil { + sess.Player.PendingSequence = nil + } + } +} + +// persistLockedOnDisconnect snapshots the most recent locked sequence +// to PendingSequence (the most recent one wins) and drops all of the player's +// in-memory sequences. Called from the disconnect hook. +func (g *Game) persistLockedOnDisconnect(p *player.Player) { + if p == nil { + return + } + g.seqMu.Lock() + var locked *sequence + var keysToRemove []string + for k, s := range g.sequences { + if s.playerName == p.Name { + if s.locked { + locked = s + } + keysToRemove = append(keysToRemove, k) + } + } + for _, k := range keysToRemove { + delete(g.sequences, k) + } + g.seqMu.Unlock() + if locked != nil { + p.PendingSequence = &player.PendingSequence{ + Key: locked.key, + RoomID: locked.roomID, + Steps: append([]behavior.Step(nil), locked.steps...), + Wait: locked.wait, + } + g.AccountStore.SaveCharacter(p) + } +} + +// resumePendingSequence re-arms a locked sequence from a player's +// PendingSequence snapshot on reconnect. +func (g *Game) resumePendingSequence(sess *net.Session, p *player.Player) { + if p == nil || p.PendingSequence == nil { + return + } + ps := p.PendingSequence + p.PendingSequence = nil + seq := &sequence{ + sess: sess, + player: p, + playerName: p.Name, + roomID: ps.RoomID, + steps: append([]behavior.Step(nil), ps.Steps...), + wait: ps.Wait, + scope: scopePlayer, + flagVal: nil, + key: ps.Key, + locked: true, + roomLocked: false, + } + g.seqMu.Lock() + if seq.key != "" { + g.sequences[seq.key] = seq + } else { + g.globalSeqs = append(g.globalSeqs, seq) + } + g.seqMu.Unlock() +} + +// TransientMobTick drives the despawn-on-leave lifecycle for transient mobs +// spawned by trigger steps (carried over unchanged from the old system). func (g *Game) TransientMobTick() { if g.Hub == nil { return @@ -61,94 +404,6 @@ func (g *Game) TransientMobTick() { } } -func (g *Game) processPlayerTriggerSequences() { - seqs := g.TriggerStore.SnapshotPlayerSeqs() - for _, seq := range seqs { - g.charsMu.Lock() - sess := g.loggedInChars[seq.PlayerName] - g.charsMu.Unlock() - if sess == nil || sess.Player == nil { - g.TriggerStore.RemovePlayerSeq(seq.PlayerName, seq.TriggerID) - continue - } - p := sess.Player - if p.RoomID != seq.RoomID { - continue - } - - seq.Wait-- - if seq.Wait > 0 { - continue - } - - var jobs []behavior.StepAction - for len(seq.Steps) > 0 && seq.Wait <= 0 { - step := seq.Steps[0] - seq.Steps = seq.Steps[1:] - jobs = append(jobs, step) - if len(seq.Steps) > 0 { - seq.Wait = seq.Steps[0].Delay - } - } - - for i := range jobs { - g.executeTriggerStep(sess, p, &jobs[i], seq.RoomID, seq.FlagValue) - } - - if len(seq.Steps) == 0 { - g.TriggerStore.RemovePlayerSeq(seq.PlayerName, seq.TriggerID) - if p.RoomID == seq.RoomID && sess.State == net.StateGame { - g.writePrompt(sess) - } - } - } -} - -func (g *Game) processGlobalTriggerSequences() { - seqs := g.TriggerStore.SnapshotGlobalSeqs() - for _, seq := range seqs { - seq.Wait-- - if seq.Wait > 0 { - continue - } - - var jobs []behavior.StepAction - for len(seq.Steps) > 0 && seq.Wait <= 0 { - step := seq.Steps[0] - seq.Steps = seq.Steps[1:] - jobs = append(jobs, step) - if len(seq.Steps) > 0 { - seq.Wait = seq.Steps[0].Delay - } - } - - for i := range jobs { - g.executeGlobalTriggerStep(&jobs[i], seq.RoomID, seq.FlagValue) - } - - if len(seq.Steps) == 0 { - g.TriggerStore.RemoveGlobalSeq(seq.TriggerID) - } - } -} - -// executeTriggerStep is a thin wrapper over applyStepAction for trigger player -// sequences (full player-targeted effects + sequence/broadcast/spawn fields). -func (g *Game) executeTriggerStep(sess *net.Session, p *player.Player, step *behavior.StepAction, roomID int, flagValue any) { - // On every step, evaluate the per-step condition (a trigger can now gate - // individual steps via the Condition field inherited from StepAction). - if step.Condition != nil && !g.checkCondition(sess, step.Condition) { - return - } - g.applyStepAction(sess, p, step, roomID, scopePlayerSeq, flagValue) -} - -// executeGlobalTriggerStep is a thin wrapper over applyStepAction for global -// trigger sequences (no player; only broadcasts/global-flags/spawn/despawn). -func (g *Game) executeGlobalTriggerStep(step *behavior.StepAction, roomID int, flagValue any) { - g.applyStepAction(nil, nil, step, roomID, scopeGlobal, flagValue) -} - func (g *Game) spawnTriggerMob(sess *net.Session, p *player.Player, cfg *behavior.SpawnMobConfig, roomID int) { if cfg.ID == "" { return @@ -218,3 +473,174 @@ func expandTemplate(tmpl string, playerName string, flagValue any) string { } return s } + +// ---- Flag-change trigger index (replaces the old TriggerStore) ---- +// +// All on_player_flag / on_global_flag triggers (room-embedded and global-file) +// are indexed by flag name and listen everywhere. Room scoping is opt-in via +// the trigger's Condition.Room. + +type flagTriggerIndex struct { + mu sync.Mutex + playerFlag map[string][]*behavior.Trigger + globalFlag map[string][]*behavior.Trigger +} + +func newFlagTriggerIndex() *flagTriggerIndex { + return &flagTriggerIndex{ + playerFlag: make(map[string][]*behavior.Trigger), + globalFlag: make(map[string][]*behavior.Trigger), + } +} + +func (fi *flagTriggerIndex) clear() { + fi.mu.Lock() + defer fi.mu.Unlock() + fi.playerFlag = make(map[string][]*behavior.Trigger) + fi.globalFlag = make(map[string][]*behavior.Trigger) +} + +func (fi *flagTriggerIndex) addPlayerFlag(t *behavior.Trigger) { + fi.mu.Lock() + defer fi.mu.Unlock() + fi.playerFlag[t.OnPlayerFlag] = append(fi.playerFlag[t.OnPlayerFlag], t) +} + +func (fi *flagTriggerIndex) addGlobalFlag(t *behavior.Trigger) { + fi.mu.Lock() + defer fi.mu.Unlock() + fi.globalFlag[t.OnGlobalFlag] = append(fi.globalFlag[t.OnGlobalFlag], t) +} + +// loadGlobalTriggers loads file-backed triggers from dataDir/triggers (one +// Trigger per yaml file; the filename stem is the trigger id). +func (g *Game) loadGlobalTriggers(dataDir string) error { + dir := filepath.Join(dataDir, "triggers") + if _, err := os.Stat(dir); os.IsNotExist(err) { + return nil + } + entries, err := os.ReadDir(dir) + if err != nil { + return err + } + for _, entry := range entries { + if entry.IsDir() { + continue + } + path := filepath.Join(dir, entry.Name()) + ext := strings.ToLower(filepath.Ext(path)) + if ext != ".yaml" && ext != ".yml" { + continue + } + data, err := os.ReadFile(path) + if err != nil { + continue + } + var t behavior.Trigger + if err := yamlUnmarshal(data, &t); err != nil { + return fmt.Errorf("trigger %q: %w", path, err) + } + stem := strings.TrimSuffix(entry.Name(), ext) + if t.OnPlayerFlag != "" { + tc := copyTrigger(t) + tc.DedupKey = "file:" + stem + g.flagIndex.addPlayerFlag(tc) + } + if t.OnGlobalFlag != "" { + tc := copyTrigger(t) + tc.DedupKey = "file:" + stem + g.flagIndex.addGlobalFlag(tc) + } + } + return nil +} + +func copyTrigger(t behavior.Trigger) *behavior.Trigger { + tcopy := t + return &tcopy +} + +// seedRoomFlagTriggers indexes the on_flag_change / on_global_flag_change +// blocks of every room. +func (g *Game) seedRoomFlagTriggers() { + roomIndex := g.World.RoomIndex() + for id := range roomIndex { + room, err := g.World.LoadRoom(id) + if err != nil { + continue + } + for i := range room.OnFlagChange { + t := room.OnFlagChange[i] + if t.OnPlayerFlag != "" { + t.DedupKey = fmt.Sprintf("room:%d:on_flag_change:%d", id, i) + g.flagIndex.addPlayerFlag(&room.OnFlagChange[i]) + } + } + for i := range room.OnGlobalFlagChange { + if room.OnGlobalFlagChange[i].OnGlobalFlag != "" { + room.OnGlobalFlagChange[i].DedupKey = fmt.Sprintf("room:%d:on_global_flag_change:%d", id, i) + g.flagIndex.addGlobalFlag(&room.OnGlobalFlagChange[i]) + } + } + } +} + +func (g *Game) loadAllFlagTriggers() { + g.flagIndex.clear() + _ = g.loadGlobalTriggers(g.DataDir) + g.seedRoomFlagTriggers() +} + +// firePlayerFlagTriggers starts sequences for every on_flag_change trigger +// subscribed to flagName whose Value (if any) matches and whose Condition +// passes for the player. Called from setPlayerFlag after an actual change. +func (g *Game) firePlayerFlagTriggers(p *player.Player, flagName string, flagValue any) { + g.charsMu.Lock() + sess := g.loggedInChars[p.Name] + g.charsMu.Unlock() + if sess == nil { + return + } + g.flagIndex.mu.Lock() + triggers := append([]*behavior.Trigger(nil), g.flagIndex.playerFlag[flagName]...) + g.flagIndex.mu.Unlock() + for i := range triggers { + t := triggers[i] + if t.Value != nil && !engine.ValuesEqual(t.Value, flagValue) { + continue + } + if t.Condition != nil && !g.checkCondition(sess, t.Condition) { + continue + } + g.startSequence(sess, p, t.Steps, p.RoomID, scopePlayer, flagValue, t.Lock, false, p.Name+":flag:"+flagName+":"+t.DedupKey) + } +} + +// fireGlobalFlagTriggers starts sequences for every on_global_flag_change +// trigger subscribed to flagName whose Value (if any) matches. Global-flag +// triggers have no originating player and run at scopeGlobal. +func (g *Game) fireGlobalFlagTriggers(flagName string, flagValue any) { + g.flagIndex.mu.Lock() + triggers := append([]*behavior.Trigger(nil), g.flagIndex.globalFlag[flagName]...) + g.flagIndex.mu.Unlock() + for i := range triggers { + t := triggers[i] + if t.Value != nil && !engine.ValuesEqual(t.Value, flagValue) { + continue + } + if t.Condition != nil && !g.checkConditionGlobal(t.Condition) { + continue + } + roomID := 0 + if t.Condition != nil && t.Condition.Room != 0 { + roomID = t.Condition.Room + } + key := fmt.Sprintf("global:%s:%d:%s", flagName, roomID, t.DedupKey) + g.startSequence(nil, nil, t.Steps, roomID, scopeGlobal, flagValue, false, false, key) + } +} + +// yamlUnmarshal is a small indirection so this file need not import yaml directly. +func yamlUnmarshal(data []byte, out *behavior.Trigger) error { + return yaml.Unmarshal(data, out) +} diff --git a/internal/item/item.go b/internal/item/item.go index 1536006..0a6ef99 100644 --- a/internal/item/item.go +++ b/internal/item/item.go @@ -54,10 +54,10 @@ type DefenseBonuses struct { } type OtherBonuses struct { - Strength int `yaml:"strength,omitempty"` + Strength int `yaml:"strength,omitempty"` RangedStrength int `yaml:"ranged_strength,omitempty"` - Science int `yaml:"science,omitempty"` - Technology int `yaml:"technology,omitempty"` + Science int `yaml:"science,omitempty"` + Technology int `yaml:"technology,omitempty"` } type EquipmentDef struct { diff --git a/internal/object/object.go b/internal/object/object.go index fb93b98..5dbe1cd 100644 --- a/internal/object/object.go +++ b/internal/object/object.go @@ -6,26 +6,26 @@ type ObjectSteal struct { Drops []behavior.DropEntry `yaml:"drops,omitempty"` Level int `yaml:"level"` XP int `yaml:"xp"` - Speed float64 `yaml:"speed"` - GuardMob string `yaml:"guard_mob,omitempty"` + Speed float64 `yaml:"speed"` + GuardMob string `yaml:"guard_mob,omitempty"` } type ObjectDef struct { - ID string `yaml:"id"` - Name string `yaml:"name"` - Aliases []string `yaml:"aliases"` - Color string `yaml:"color"` - Hidden bool `yaml:"hidden"` - InRoomDescription string `yaml:"inroom_description"` - RemovalItem string `yaml:"removal_item"` - Description behavior.DescList `yaml:"description"` - OnUse []behavior.Interaction `yaml:"on_use,omitempty"` - Steal *ObjectSteal `yaml:"steal,omitempty"` + ID string `yaml:"id"` + Name string `yaml:"name"` + Aliases []string `yaml:"aliases"` + Color string `yaml:"color"` + Hidden bool `yaml:"hidden"` + InRoomDescription string `yaml:"inroom_description"` + RemovalItem string `yaml:"removal_item"` + Description behavior.DescList `yaml:"description"` + OnUse []behavior.Trigger `yaml:"on_use,omitempty"` + Steal *ObjectSteal `yaml:"steal,omitempty"` Gather *behavior.GatherConfig `yaml:"gather,omitempty"` Talk *behavior.TalkConfig `yaml:"talk,omitempty"` Safespot *SafespotConfig `yaml:"safespot,omitempty"` - OnLook []behavior.Interaction `yaml:"on_look,omitempty"` + OnLook []behavior.Trigger `yaml:"on_look,omitempty"` } type SafespotConfig struct { diff --git a/internal/player/player.go b/internal/player/player.go index f56521e..5ffea6d 100644 --- a/internal/player/player.go +++ b/internal/player/player.go @@ -172,7 +172,7 @@ type Player struct { Prompt string `yaml:"prompt"` Options map[string]any `yaml:"-"` Flags map[string]any `yaml:"flags"` - EnterSeqRoom int `yaml:"enter_seq_room,omitempty"` + PendingSequence *PendingSequence `yaml:"pending_sequence,omitempty"` RegenerateTick int AmmoQty int `yaml:"ammo_qty,omitempty"` Action *behavior.Action `yaml:"-"` @@ -180,13 +180,13 @@ type Player struct { WalkSequence []string `yaml:"-"` ConsumeCooldown int `yaml:"-"` HomeTransportCooldown int `yaml:"-"` - MoveTicks int `yaml:"-"` + MoveTicks int `yaml:"-"` MoveDirection string `yaml:"-"` - MoveTarget int `yaml:"-"` - MovePendingInteraction *behavior.Interaction `yaml:"-"` - VisualTickCurrent int `yaml:"-"` - AutotriggerMod string `yaml:"-"` - QueuedTrigger string `yaml:"-"` + MoveTarget int `yaml:"-"` + MovePendingTrigger *behavior.Trigger `yaml:"-"` + VisualTickCurrent int `yaml:"-"` + AutotriggerMod string `yaml:"-"` + QueuedTrigger string `yaml:"-"` AttackTimer int `yaml:"-"` HazardTimer int `yaml:"-"` PendingDangerDir string `yaml:"-"` @@ -208,6 +208,18 @@ type MapSymbolData struct { Color string `yaml:"color,omitempty"` } +// PendingSequence is the persisted snapshot of an in-flight LOCKED trigger +// sequence (Trigger.Lock == true). Unlocked sequences are never persisted. +// On reconnect, the game rebuilds a sequence from this snapshot and resumes +// it from the saved Wait offset. +type PendingSequence struct { + Key string `yaml:"key"` + RoomID int `yaml:"room_id"` + Steps []behavior.Step `yaml:"steps"` + Wait int `yaml:"wait"` + FlagVal any `yaml:"flag_val,omitempty"` +} + type PotionBuff struct { Stat string BonusPercent int @@ -218,7 +230,7 @@ func (p *Player) ClearMoveState() { p.MoveTicks = 0 p.MoveDirection = "" p.MoveTarget = 0 - p.MovePendingInteraction = nil + p.MovePendingTrigger = nil } func (p *Player) OptionBool(name string) bool { diff --git a/internal/validate/checks.go b/internal/validate/checks.go index 2e70323..c1e8f19 100644 --- a/internal/validate/checks.go +++ b/internal/validate/checks.go @@ -67,11 +67,9 @@ func validateRooms(s Source) []Issue { }) } } - for i, it := range exit.OnTraverse { - issues = append(issues, validateStepAction( - fmt.Sprintf("Room %d: exit %q on_traverse[%d]", id, dir, i), - it.Action, itemIDs, roomIndex, mobIDs)...) - } + issues = append(issues, validateTriggerBlock( + fmt.Sprintf("Room %d: exit %q on_traverse", id, dir), + exit.OnTraverse, false, itemIDs, roomIndex, mobIDs)...) } for _, rm := range room.Mobs { @@ -223,7 +221,7 @@ func validateLocalObject(roomID int, robj world.RoomObject, itemIDs map[string]b } if len(def.OnLook) > 0 { - issues = append(issues, validateOnLook(prefix+": on_look", def.OnLook, itemIDs, roomIndex, mobIDs)...) + issues = append(issues, validateTriggerBlock(prefix+": on_look", def.OnLook, false, itemIDs, roomIndex, mobIDs)...) } return issues @@ -487,10 +485,8 @@ func validateMobs(s Source) []Issue { } } - for i, it := range def.OnKill { - issues = append(issues, validateStepAction( - fmt.Sprintf("Mob %q: on_kill[%d]", id, i), it.Action, itemIDs, roomIndex, mobIDs)...) - } + issues = append(issues, validateTriggerBlock( + fmt.Sprintf("Mob %q: on_kill", id), def.OnKill, false, itemIDs, roomIndex, mobIDs)...) } return issues @@ -594,19 +590,8 @@ func validateObjects(s Source) []Issue { } } - for _, ui := range obj.OnUse { - if ui.Item != "" && !itemIDs[ui.Item] { - issues = append(issues, Issue{ - Level: "ERROR", - Type: "reference", - Message: fmt.Sprintf("Object %q: on_use item %q does not exist", - id, ui.Item), - }) - } - if ui.Action != nil { - issues = append(issues, validateStepAction(fmt.Sprintf("Object %q: on_use", id), ui.Action, itemIDs, roomIndex, mobIDs)...) - } - } + issues = append(issues, validateTriggerBlock( + fmt.Sprintf("Object %q: on_use", id), obj.OnUse, false, itemIDs, roomIndex, mobIDs)...) if obj.Gather != nil { issues = append(issues, validateGather(fmt.Sprintf("Object %q: gather", id), obj.Gather, itemIDs, dropIDs)...) @@ -617,7 +602,7 @@ func validateObjects(s Source) []Issue { } if len(obj.OnLook) > 0 { - issues = append(issues, validateOnLook(fmt.Sprintf("Object %q: on_look", id), obj.OnLook, itemIDs, roomIndex, mobIDs)...) + issues = append(issues, validateTriggerBlock(fmt.Sprintf("Object %q: on_look", id), obj.OnLook, false, itemIDs, roomIndex, mobIDs)...) } } @@ -802,7 +787,10 @@ func validateCourses(s Source) []Issue { return issues } -func validateRoomEnterSteps(s Source) []Issue { +// validateRoomTriggers validates every trigger-shaped block on every room: +// on_enter, on_exit (verb blocks), and on_flag_change / on_global_flag_change +// (flag blocks, which require on_player_flag/on_global_flag). +func validateRoomTriggers(s Source) []Issue { var issues []Issue roomIndex := s.World.RoomIndex() itemIDs := s.Items.IDSet() @@ -810,35 +798,24 @@ func validateRoomEnterSteps(s Source) []Issue { for id := range roomIndex { room, err := s.World.LoadRoom(id) - if err != nil || len(room.OnEnter) == 0 { + if err != nil { continue } - for si := range room.OnEnter { - stepPrefix := fmt.Sprintf("Room %d: on_enter[%d]", id, si) - issues = append(issues, validateStepAction(stepPrefix, &room.OnEnter[si], itemIDs, roomIndex, mobIDs)...) + if len(room.OnEnter) > 0 { + issues = append(issues, validateTriggerBlock( + fmt.Sprintf("Room %d: on_enter", id), room.OnEnter, false, itemIDs, roomIndex, mobIDs)...) } - } - - return issues -} - -func validateRoomTriggers(s Source) []Issue { - var issues []Issue - roomIndex := s.World.RoomIndex() - itemIDs := s.Items.IDSet() - mobIDs := s.Mobs.AllDefIDs() - - for id := range roomIndex { - room, err := s.World.LoadRoom(id) - if err != nil || len(room.Triggers) == 0 { - continue + if len(room.OnExit) > 0 { + issues = append(issues, validateTriggerBlock( + fmt.Sprintf("Room %d: on_exit", id), room.OnExit, false, itemIDs, roomIndex, mobIDs)...) } - for ti, trigger := range room.Triggers { - prefix := fmt.Sprintf("Room %d: trigger[%d]", id, ti) - for si := range trigger.Steps { - stepPrefix := fmt.Sprintf("%s: step[%d]", prefix, si) - issues = append(issues, validateStepAction(stepPrefix, &trigger.Steps[si], itemIDs, roomIndex, mobIDs)...) - } + if len(room.OnFlagChange) > 0 { + issues = append(issues, validateTriggerBlock( + fmt.Sprintf("Room %d: on_flag_change", id), room.OnFlagChange, true, itemIDs, roomIndex, mobIDs)...) + } + if len(room.OnGlobalFlagChange) > 0 { + issues = append(issues, validateTriggerBlock( + fmt.Sprintf("Room %d: on_global_flag_change", id), room.OnGlobalFlagChange, true, itemIDs, roomIndex, mobIDs)...) } } @@ -1128,7 +1105,7 @@ func validateTalkConfig(prefix string, cfg *behavior.TalkConfig, itemIDs map[str for nodeID, node := range cfg.Nodes { nodePrefix := fmt.Sprintf("%s: node %q", prefix, nodeID) if node.Action != nil { - issues = append(issues, validateNodeAction(nodePrefix, node.Action, itemIDs, roomIndex)...) + issues = append(issues, validateTalkStep(nodePrefix, node.Action, itemIDs, roomIndex)...) } if node.Goto != "" { if _, ok := cfg.Nodes[node.Goto]; !ok { @@ -1142,7 +1119,7 @@ func validateTalkConfig(prefix string, cfg *behavior.TalkConfig, itemIDs map[str for i, opt := range node.Options { optPrefix := fmt.Sprintf("%s: option %d", nodePrefix, i+1) if opt.Action != nil { - issues = append(issues, validateNodeAction(optPrefix, opt.Action, itemIDs, roomIndex)...) + issues = append(issues, validateTalkStep(optPrefix, opt.Action, itemIDs, roomIndex)...) } } } @@ -1196,66 +1173,25 @@ func validateExitReciprocity(s Source) []Issue { return issues } -func validateNodeAction(prefix string, na *behavior.NodeAction, itemIDs map[string]bool, roomIndex map[int]bool) []Issue { - var issues []Issue - if na == nil { - return issues - } - if na.GiveItem != "" && !itemIDs[na.GiveItem] { - issues = append(issues, Issue{ - Level: "ERROR", - Type: "reference", - Message: fmt.Sprintf("%s: give_item %q does not exist", - prefix, na.GiveItem), - }) - } - - if na.TakeItem != "" && !itemIDs[na.TakeItem] { - issues = append(issues, Issue{ - Level: "ERROR", - Type: "reference", - Message: fmt.Sprintf("%s: take_item %q does not exist", - prefix, na.TakeItem), - }) - } - - if na.Teleport > 0 { - if _, ok := roomIndex[na.Teleport]; !ok { - issues = append(issues, Issue{ - Level: "ERROR", - Type: "reference", - Message: fmt.Sprintf("%s: teleport to nonexistent room %d", - prefix, na.Teleport), - }) - } - } - - return issues -} - -// validateStepAction validates the universal effect superset used by on_use, -// on_look, on_kill, on_enter steps, trigger steps, and exit -// traversal. It covers the inline NodeAction fields plus spawn_mob/despawn_mob -// (when mobIDs is non-nil). Returns the list of issues found. -func validateStepAction(prefix string, step *behavior.StepAction, itemIDs map[string]bool, roomIndex map[int]bool, mobIDs map[string]bool) []Issue { +// validateStep validates a single Step's effects. mobIDs may be nil when the +// caller doesn't track mob ids (e.g. local-object on_look); spawn/despawn mob +// references are then skipped. +func validateStep(prefix string, step *behavior.Step, itemIDs map[string]bool, roomIndex map[int]bool, mobIDs map[string]bool) []Issue { var issues []Issue if step == nil { return issues } - for i, msg := range step.Messages { - if msg.Delay < 0 { - issues = append(issues, Issue{ - Level: "WARN", - Type: "semantic", - Message: fmt.Sprintf("%s: messages[%d].delay is negative (%d)", - prefix, i, msg.Delay), - }) - } + if step.Wait < 0 { + issues = append(issues, Issue{ + Level: "WARN", + Type: "semantic", + Message: fmt.Sprintf("%s: wait is negative (%d)", + prefix, step.Wait), + }) } - // Validate the embedded NodeAction subset. if step.GiveItem != "" && !itemIDs[step.GiveItem] { issues = append(issues, Issue{ Level: "ERROR", @@ -1283,8 +1219,6 @@ func validateStepAction(prefix string, step *behavior.StepAction, itemIDs map[st } } - // Spawn mob: validate mob def + despawn_rooms (only if mobIDs populated — - // a nil map means the caller doesn't track mob ids, e.g. local objects). if step.SpawnMob != nil && step.SpawnMob.ID != "" { if mobIDs != nil && !mobIDs[step.SpawnMob.ID] { issues = append(issues, Issue{ @@ -1306,7 +1240,6 @@ func validateStepAction(prefix string, step *behavior.StepAction, itemIDs map[st } } - // Despawn mob. if step.DespawnMob != "" && mobIDs != nil && !mobIDs[step.DespawnMob] { issues = append(issues, Issue{ Level: "ERROR", @@ -1316,26 +1249,148 @@ func validateStepAction(prefix string, step *behavior.StepAction, itemIDs map[st }) } + if step.Condition != nil { + issues = append(issues, validateCondition(prefix, step.Condition, roomIndex)...) + } return issues } -// validateOnLook validates a list of on_look interactions (now a list, not a -// single NodeAction). For each entry, validates the optional item_id (unused -// on on_look but permitted), the action's effects, and skips (the entry's -// condition is a runtime gate, not a static referential ref). -func validateOnLook(prefix string, list []behavior.Interaction, itemIDs map[string]bool, roomIndex map[int]bool, mobIDs map[string]bool) []Issue { +// validateCondition checks a Condition tree's Room references (structural room +// IDs) and recurses into all_of/any_of. +func validateCondition(prefix string, c *behavior.Condition, roomIndex map[int]bool) []Issue { + if c == nil { + return nil + } var issues []Issue - for i, it := range list { - entryPrefix := fmt.Sprintf("%s[%d]", prefix, i) - if it.Item != "" && !itemIDs[it.Item] { + if c.Room != 0 { + if _, ok := roomIndex[c.Room]; !ok { issues = append(issues, Issue{ Level: "ERROR", Type: "reference", - Message: fmt.Sprintf("%s: item_id %q does not exist", - entryPrefix, it.Item), + Message: fmt.Sprintf("%s: condition room references nonexistent room %d", + prefix, c.Room), + }) + } + } + for i := range c.AllOf { + issues = append(issues, validateCondition(prefix, &c.AllOf[i], roomIndex)...) + } + for i := range c.AnyOf { + issues = append(issues, validateCondition(prefix, &c.AnyOf[i], roomIndex)...) + } + return issues +} + +// checkPlayerOnlyInCondition warns when a global-scope condition uses +// player-dependent predicates that cannot be evaluated without a player. +func checkPlayerOnlyInCondition(prefix string, c *behavior.Condition) []Issue { + if c == nil { + return nil + } + var issues []Issue + if c.PlayerFlag != "" { + issues = append(issues, Issue{ + Level: "WARN", + Type: "semantic", + Message: fmt.Sprintf("%s: player_flag %q has no effect on global-flag triggers (no player)", prefix, c.PlayerFlag), + }) + } + if c.HasItem != "" { + issues = append(issues, Issue{ + Level: "WARN", + Type: "semantic", + Message: fmt.Sprintf("%s: has_item %q has no effect on global-flag triggers (no player)", prefix, c.HasItem), + }) + } + if c.MinCredits > 0 { + issues = append(issues, Issue{ + Level: "WARN", + Type: "semantic", + Message: fmt.Sprintf("%s: min_credits has no effect on global-flag triggers (no player)", prefix), + }) + } + for i := range c.AllOf { + issues = append(issues, checkPlayerOnlyInCondition(prefix, &c.AllOf[i])...) + } + for i := range c.AnyOf { + issues = append(issues, checkPlayerOnlyInCondition(prefix, &c.AnyOf[i])...) + } + return issues +} + +// validateTriggerBlock validates a list of Trigger entries for one event block. +// isFlag is true for on_flag_change / on_global_flag_change blocks (which +// require on_player_flag/on_global_flag and forbid item_id) and false for verb +// blocks (on_use/on_look/on_kill/on_enter/on_exit/on_traverse), where item_id +// is checked against itemIDs. +func validateTriggerBlock(prefix string, block []behavior.Trigger, isFlag bool, itemIDs map[string]bool, roomIndex map[int]bool, mobIDs map[string]bool) []Issue { + var issues []Issue + for i := range block { + t := &block[i] + entryPrefix := fmt.Sprintf("%s[%d]", prefix, i) + if isFlag { + if t.OnPlayerFlag == "" && t.OnGlobalFlag == "" { + issues = append(issues, Issue{ + Level: "WARN", + Type: "semantic", + Message: fmt.Sprintf("%s: flag-change trigger has no on_player_flag/on_global_flag — it can never fire", + entryPrefix), + }) + } + if t.ItemID != "" { + issues = append(issues, Issue{ + Level: "WARN", + Type: "semantic", + Message: fmt.Sprintf("%s: item_id is ignored on flag-change triggers", + entryPrefix), + }) + } + } else { + if t.OnPlayerFlag != "" || t.OnGlobalFlag != "" { + issues = append(issues, Issue{ + Level: "WARN", + Type: "semantic", + Message: fmt.Sprintf("%s: on_player_flag/on_global_flag are ignored on verb triggers", + entryPrefix), + }) + } + if t.ItemID != "" && !itemIDs[t.ItemID] { + issues = append(issues, Issue{ + Level: "ERROR", + Type: "reference", + Message: fmt.Sprintf("%s: item_id %q does not exist", + entryPrefix, t.ItemID), + }) + } + } + if t.Lock && len(t.Steps) == 0 { + issues = append(issues, Issue{ + Level: "WARN", + Type: "semantic", + Message: fmt.Sprintf("%s: lock is true but the trigger has no steps", entryPrefix), }) } - issues = append(issues, validateStepAction(entryPrefix, it.Action, itemIDs, roomIndex, mobIDs)...) + if t.Condition != nil { + issues = append(issues, validateCondition(entryPrefix, t.Condition, roomIndex)...) + } + if isFlag && t.OnGlobalFlag != "" && t.Condition != nil { + issues = append(issues, checkPlayerOnlyInCondition(entryPrefix, t.Condition)...) + } + for si := range t.Steps { + issues = append(issues, validateStep(fmt.Sprintf("%s: step[%d]", entryPrefix, si), &t.Steps[si], itemIDs, roomIndex, mobIDs)...) + if isFlag && t.OnGlobalFlag != "" && t.Steps[si].Condition != nil { + issues = append(issues, checkPlayerOnlyInCondition(fmt.Sprintf("%s: step[%d]", entryPrefix, si), t.Steps[si].Condition)...) + } + } } return issues } + +// validateTalkStep validates a talk node/option Action (a single Step, no mob +// spawn/despawn expected — mobIDs nil so those are skipped). +func validateTalkStep(prefix string, step *behavior.Step, itemIDs map[string]bool, roomIndex map[int]bool) []Issue { + if step == nil { + return nil + } + return validateStep(prefix, step, itemIDs, roomIndex, nil) +} diff --git a/internal/validate/validate.go b/internal/validate/validate.go index 6558216..234651e 100644 --- a/internal/validate/validate.go +++ b/internal/validate/validate.go @@ -77,7 +77,6 @@ func Run(s Source) []Issue { issues = append(issues, validateDropTables(s)...) issues = append(issues, validateCourses(s)...) issues = append(issues, validateRoomTriggers(s)...) - issues = append(issues, validateRoomEnterSteps(s)...) issues = append(issues, validateTechs(s)...) issues = append(issues, validateRoomWiring(s)...) issues = append(issues, validateRoomGrid(s)...) diff --git a/internal/world/grid.go b/internal/world/grid.go index 7be8eed..ac80476 100644 --- a/internal/world/grid.go +++ b/internal/world/grid.go @@ -87,11 +87,11 @@ func BuildGrid(seed int, load func(int) (*Room, bool), include func(int) bool, e continue } - g.Coord[target] = want - g.RoomAt[want] = target - g.Dist[target] = g.Dist[rid] + 1 - queue = append(queue, target) - } + g.Coord[target] = want + g.RoomAt[want] = target + g.Dist[target] = g.Dist[rid] + 1 + queue = append(queue, target) + } } return g diff --git a/internal/world/insert_remove_test.go b/internal/world/insert_remove_test.go index 15d752e..201b0d4 100644 --- a/internal/world/insert_remove_test.go +++ b/internal/world/insert_remove_test.go @@ -95,8 +95,10 @@ func TestInsertGridConflictsOverlap(t *testing.T) { // TestInsertGridConflictsTwist: room 3 beyond 2 is also reachable from 1 via a // separate path, so after the push 3 would have two candidate positions. +// // 1 east→2 east→3 (3 at (2,0,0), pushed to (3,0,0)) // 1 south→4 east→5 north→3 (3 at (1,0,0)) +// // 3 is reachable two ways already → the pre-edit world is itself a twist. The // insert helper must surface a twist conflict. func TestInsertGridConflictsTwist(t *testing.T) { diff --git a/internal/world/mob.go b/internal/world/mob.go index 45b0fb8..fa0cdbf 100644 --- a/internal/world/mob.go +++ b/internal/world/mob.go @@ -78,13 +78,13 @@ type MobCombat struct { } type MobDef struct { - ID string `yaml:"id"` - Name string `yaml:"name"` - Description string `yaml:"description"` - IdleDescriptions []string `yaml:"idle_descriptions"` - Protected bool `yaml:"protected"` - Unique bool `yaml:"unique"` - Drops MobDropTable `yaml:"drops"` + ID string `yaml:"id"` + Name string `yaml:"name"` + Description string `yaml:"description"` + IdleDescriptions []string `yaml:"idle_descriptions"` + Protected bool `yaml:"protected"` + Unique bool `yaml:"unique"` + Drops MobDropTable `yaml:"drops"` Steal *MobSteal `yaml:"steal,omitempty"` Task *MobTask `yaml:"task,omitempty"` @@ -94,7 +94,7 @@ type MobDef struct { Talk *behavior.TalkConfig `yaml:"talk,omitempty"` Shop *behavior.ShopConfig `yaml:"shop,omitempty"` - OnKill []behavior.Interaction `yaml:"on_kill,omitempty"` + OnKill []behavior.Trigger `yaml:"on_kill,omitempty"` } func (d *MobDef) IsTalkable() bool { return d.Talk != nil } @@ -132,7 +132,7 @@ type MobInstance struct { AttackBonus int StrengthBonus int - AttackTypes []string + AttackTypes []string RangedBonus int ScienceBonus int @@ -172,7 +172,7 @@ type MobInstance struct { // HP draining to zero (the "completion" of the work). The first entry // whose item filter, Condition, and the first-match-wins rule all pass // fires. - OnKill []behavior.Interaction + OnKill []behavior.Trigger // Shop is the (shared, read-only) shop config from the def. Per-instance // live stock is tracked in ShopStock; ShopRestock holds per-item countdown @@ -313,7 +313,7 @@ func NewMobInstance(def *MobDef, instanceID string, roomID int, wanderRooms []in Drops: def.Drops, AttackBonus: attackBonus, StrengthBonus: strengthBonus, - AttackTypes: attackType, + AttackTypes: attackType, RangedBonus: rangedBonus, ScienceBonus: scienceBonus, SciencePercentBonus: sciencePercentBonus, diff --git a/internal/world/room.go b/internal/world/room.go index 8ae2d13..bc115f8 100644 --- a/internal/world/room.go +++ b/internal/world/room.go @@ -75,27 +75,29 @@ type SpawnDef struct { } type ExitDef struct { - Room int `yaml:"room"` - Condition *behavior.Condition `yaml:"condition,omitempty"` - BlockedMessage string `yaml:"blocked_message,omitempty"` - OnTraverse []behavior.Interaction `yaml:"on_traverse,omitempty"` - Hidden bool `yaml:"hidden,omitempty"` - AlwaysBlocked bool `yaml:"always_blocked,omitempty"` + Room int `yaml:"room"` + Condition *behavior.Condition `yaml:"condition,omitempty"` + BlockedMessage string `yaml:"blocked_message,omitempty"` + OnTraverse []behavior.Trigger `yaml:"on_traverse,omitempty"` + Hidden bool `yaml:"hidden,omitempty"` + AlwaysBlocked bool `yaml:"always_blocked,omitempty"` } type Room struct { - ID int `yaml:"id"` - Name string `yaml:"name"` - Color string `yaml:"color"` - Description behavior.DescList `yaml:"description"` - Exits map[ExitDir]ExitDef `yaml:"exits"` - Objects []RoomObject `yaml:"objects"` - ItemSpawns []SpawnDef `yaml:"item_spawns"` - Mobs []RoomMob `yaml:"mobs"` - OnEnter []behavior.StepAction `yaml:"on_enter"` - Hazard string `yaml:"hazard"` - BlockTransport bool `yaml:"block_transport"` - Triggers []TriggerDef `yaml:"triggers"` + ID int `yaml:"id"` + Name string `yaml:"name"` + Color string `yaml:"color"` + Description behavior.DescList `yaml:"description"` + Exits map[ExitDir]ExitDef `yaml:"exits"` + Objects []RoomObject `yaml:"objects"` + ItemSpawns []SpawnDef `yaml:"item_spawns"` + Mobs []RoomMob `yaml:"mobs"` + OnEnter []behavior.Trigger `yaml:"on_enter,omitempty"` + OnExit []behavior.Trigger `yaml:"on_exit,omitempty"` + OnFlagChange []behavior.Trigger `yaml:"on_flag_change,omitempty"` + OnGlobalFlagChange []behavior.Trigger `yaml:"on_global_flag_change,omitempty"` + Hazard string `yaml:"hazard"` + BlockTransport bool `yaml:"block_transport"` } type RoomMob struct { @@ -151,19 +153,19 @@ func (ro *RoomObject) UnmarshalYAML(value *yaml.Node) error { func (ro RoomObject) MarshalYAML() (interface{}, error) { if ro.Local != nil { type inlineObj struct { - Name string `yaml:"name"` - Aliases []string `yaml:"aliases,omitempty"` - Color string `yaml:"color,omitempty"` - Hidden bool `yaml:"hidden,omitempty"` - InRoomDescription string `yaml:"inroom_description,omitempty"` - RemovalItem string `yaml:"removal_item,omitempty"` - Description behavior.DescList `yaml:"description,omitempty"` - OnUse []behavior.Interaction `yaml:"on_use,omitempty"` - Steal *object.ObjectSteal `yaml:"steal,omitempty"` - Gather *behavior.GatherConfig `yaml:"gather,omitempty"` - Talk *behavior.TalkConfig `yaml:"talk,omitempty"` - Safespot *object.SafespotConfig `yaml:"safespot,omitempty"` - OnLook []behavior.Interaction `yaml:"on_look,omitempty"` + Name string `yaml:"name"` + Aliases []string `yaml:"aliases,omitempty"` + Color string `yaml:"color,omitempty"` + Hidden bool `yaml:"hidden,omitempty"` + InRoomDescription string `yaml:"inroom_description,omitempty"` + RemovalItem string `yaml:"removal_item,omitempty"` + Description behavior.DescList `yaml:"description,omitempty"` + OnUse []behavior.Trigger `yaml:"on_use,omitempty"` + Steal *object.ObjectSteal `yaml:"steal,omitempty"` + Gather *behavior.GatherConfig `yaml:"gather,omitempty"` + Talk *behavior.TalkConfig `yaml:"talk,omitempty"` + Safespot *object.SafespotConfig `yaml:"safespot,omitempty"` + OnLook []behavior.Trigger `yaml:"on_look,omitempty"` } def := ro.Local return inlineObj{ diff --git a/internal/world/room_migrate.go b/internal/world/room_migrate.go index 946417f..d609c6a 100644 --- a/internal/world/room_migrate.go +++ b/internal/world/room_migrate.go @@ -1,66 +1,174 @@ package world +import ( + "thehouseoficarus/internal/behavior" +) + // RewriteRoomIDs remaps every genuine room-ID reference in this room per idMap -// (oldID -> newID): exit targets, trigger Room/Teleport/DespawnRooms, on-enter -// Teleport/DespawnRooms, and mob WanderRooms. It does NOT touch author-chosen -// set_flags/set_player_flags values or condition `value:` fields — those are not -// structural room references (and historically the swapid regex rewrote them -// incorrectly as a side effect of matching any bare integer). +// (oldID -> newID): exit targets; every Step.Teleport and Step.SpawnMob +// .DespawnRooms inside on_enter/on_exit/on_flag_change/on_global_flag_change/ +// on_traverse/on_use/on_look/on_kill blocks; every Condition.Room anywhere in +// the room (exits, descriptions, trigger conditions, per-step conditions, +// including nested all_of/any_of); and mob WanderRooms. It does NOT touch +// author-chosen set_flags/set_player_flags values or condition `value:` fields +// — those are not structural room references. // // idMap is assumed to be a bijection (each old ID maps to a distinct new ID); -// this holds for both swap (A<->B) and rename-to-unused (A->B). Returns whether -// any field was changed. +// this holds for both swap (A<->B) and rename-to-unused (A->B). Returns +// whether any field was changed. func (r *Room) RewriteRoomIDs(idMap map[int]int) bool { if r == nil || len(idMap) == 0 { return false } changed := false + + // Exit targets + their on_traverse steps + exit/desc conditions. for dir, exit := range r.Exits { var exitChanged bool if remapInt(idMap, &exit.Room) { exitChanged = true } - for i := range exit.OnTraverse { - if exit.OnTraverse[i].Action != nil { - if remapInt(idMap, &exit.OnTraverse[i].Action.Teleport) { - exitChanged = true - } - if exit.OnTraverse[i].Action.SpawnMob != nil && - remapIntSlice(idMap, exit.OnTraverse[i].Action.SpawnMob.DespawnRooms) { - exitChanged = true - } - } + if remapTriggers(idMap, exit.OnTraverse) { + exitChanged = true + } + if exit.Condition != nil && remapCondition(idMap, exit.Condition) { + exitChanged = true } if exitChanged { r.Exits[dir] = exit changed = true } } - for i := range r.Triggers { - if remapInt(idMap, &r.Triggers[i].Room) { + + // Every trigger-shaped block on the room shares the Step list + Condition + // structure. Remap their steps and conditions uniformly. + for i := range r.OnEnter { + c := false + if remapStepList(idMap, r.OnEnter[i].Steps) { + c = true + } + if remapCondition(idMap, r.OnEnter[i].Condition) { + c = true + } + changed = changed || c + } + for i := range r.OnExit { + c := false + if remapStepList(idMap, r.OnExit[i].Steps) { + c = true + } + if remapCondition(idMap, r.OnExit[i].Condition) { + c = true + } + changed = changed || c + } + for i := range r.OnFlagChange { + c := false + if remapStepList(idMap, r.OnFlagChange[i].Steps) { + c = true + } + if remapCondition(idMap, r.OnFlagChange[i].Condition) { + c = true + } + changed = changed || c + } + for i := range r.OnGlobalFlagChange { + c := false + if remapStepList(idMap, r.OnGlobalFlagChange[i].Steps) { + c = true + } + if remapCondition(idMap, r.OnGlobalFlagChange[i].Condition) { + c = true + } + changed = changed || c + } + + // Conditional room descriptions carry a Condition. + for i := range r.Description { + if remapCondition(idMap, r.Description[i].Condition) { changed = true } - for j := range r.Triggers[i].Steps { - s := &r.Triggers[i].Steps[j] - if remapInt(idMap, &s.Teleport) { + } + + // Per-room local objects: on_use/on_look steps + object desc conditions. + for i := range r.Objects { + if r.Objects[i].Local != nil { + loc := r.Objects[i].Local + if remapTriggers(idMap, loc.OnUse) { changed = true } - if s.SpawnMob != nil && remapIntSlice(idMap, s.SpawnMob.DespawnRooms) { + if remapTriggers(idMap, loc.OnLook) { changed = true } + for j := range loc.Description { + if remapCondition(idMap, loc.Description[j].Condition) { + changed = true + } + } } } - for i := range r.OnEnter { - s := &r.OnEnter[i] + + for i := range r.Mobs { + if remapIntSlice(idMap, r.Mobs[i].WanderRooms) { + changed = true + } + } + return changed +} + +// remapTriggers remaps step fields across a []behavior.Trigger (each trigger's +// Steps + per-step Condition + trigger-level Condition). +func remapTriggers(idMap map[int]int, list []behavior.Trigger) bool { + changed := false + for i := range list { + c := false + if remapStepList(idMap, list[i].Steps) { + c = true + } + if remapCondition(idMap, list[i].Condition) { + c = true + } + changed = changed || c + } + return changed +} + +// remapStepList remaps Step.Teleport and Step.SpawnMob.DespawnRooms plus every +// step's per-step Condition.Room across a single Trigger's Steps. +func remapStepList(idMap map[int]int, steps []behavior.Step) bool { + changed := false + for i := range steps { + s := &steps[i] if remapInt(idMap, &s.Teleport) { changed = true } if s.SpawnMob != nil && remapIntSlice(idMap, s.SpawnMob.DespawnRooms) { changed = true } + if remapCondition(idMap, s.Condition) { + changed = true + } } - for i := range r.Mobs { - if remapIntSlice(idMap, r.Mobs[i].WanderRooms) { + return changed +} + +// remapCondition walks a Condition tree and remaps any Room field (a +// structural room reference) it finds, recursing into AllOf/AnyOf. +func remapCondition(idMap map[int]int, c *behavior.Condition) bool { + if c == nil { + return false + } + changed := false + if c.Room != 0 && remapInt(idMap, &c.Room) { + changed = true + } + for i := range c.AllOf { + if remapCondition(idMap, &c.AllOf[i]) { + changed = true + } + } + for i := range c.AnyOf { + if remapCondition(idMap, &c.AnyOf[i]) { changed = true } } diff --git a/internal/world/room_migrate_test.go b/internal/world/room_migrate_test.go index c54adf2..96f74ae 100644 --- a/internal/world/room_migrate_test.go +++ b/internal/world/room_migrate_test.go @@ -2,29 +2,42 @@ package world import ( "testing" - "thehouseoficarus/internal/behavior" ) func TestRoomRewriteRoomIDs(t *testing.T) { r := &Room{ Exits: map[ExitDir]ExitDef{ - North: {Room: 10, OnTraverse: []behavior.Interaction{{Action: &behavior.StepAction{ - NodeAction: behavior.NodeAction{SetGlobalFlags: map[string]any{"last": 10}, Teleport: 110}, - }}}}, - South: {Room: 99}, - }, - Triggers: []TriggerDef{ - { - Room: 30, - Steps: []behavior.StepAction{ - {NodeAction: behavior.NodeAction{Teleport: 40}, SpawnMob: &behavior.SpawnMobConfig{DespawnRooms: []int{50, 60}}}, - }, + North: { + Room: 10, + OnTraverse: []behavior.Trigger{{ + Condition: &behavior.Condition{Room: 60}, // projectile, ensures trigger-condition Room remaps too + Steps: []behavior.Step{{ + SetGlobalFlags: map[string]any{"last": 10}, + Teleport: 110, + }}, + }}, + Condition: &behavior.Condition{Room: 40}, }, + South: {Room: 99}, }, - OnEnter: []behavior.StepAction{ - {NodeAction: behavior.NodeAction{Teleport: 70}, SpawnMob: &behavior.SpawnMobConfig{DespawnRooms: []int{80}}}, - }, + OnEnter: []behavior.Trigger{{ + Condition: &behavior.Condition{Room: 20}, + Steps: []behavior.Step{{ + Teleport: 70, + SpawnMob: &behavior.SpawnMobConfig{DespawnRooms: []int{80}}, + }}, + }}, + OnExit: []behavior.Trigger{{ + Steps: []behavior.Step{{Teleport: 50}}, + }}, + OnFlagChange: []behavior.Trigger{{ + Condition: &behavior.Condition{Room: 30}, + Steps: []behavior.Step{{ + Teleport: 40, + SpawnMob: &behavior.SpawnMobConfig{DespawnRooms: []int{50, 60}}, + }}, + }}, Mobs: []RoomMob{ {ID: "guard", WanderRooms: []int{90, 100}}, }, @@ -38,40 +51,48 @@ func TestRoomRewriteRoomIDs(t *testing.T) { t.Fatal("expected changed=true") } - checkExit := func(dir ExitDir, want int) { - t.Helper() - if got := r.Exits[dir].Room; got != want { - t.Errorf("exit %s room = %d, want %d", dir, got, want) - } - } - checkExit(North, 11) - checkExit(South, 99) // unchanged (not in idMap) - - // Author set_flags values are NOT structural room references and must not - // be remapped (the old swapid regex wrongly rewrote these). - if v := r.Exits[North].OnTraverse[0].Action.SetGlobalFlags["last"]; v != 10 { + if got := r.Exits[North].Room; got != 11 { + t.Errorf("exit north room = %d, want 11", got) + } + if got := r.Exits[South].Room; got != 99 { + t.Errorf("exit south room = %d, want 99 (unchanged)", got) + } + // Author set_flags values are NOT structural room references and must not remap. + if v := r.Exits[North].OnTraverse[0].Steps[0].SetGlobalFlags["last"]; v != 10 { t.Errorf("set_global_flags value remapped: got %v, want 10", v) } - // Exit on_traverse teleport IS a structural room reference and must remap. - if r.Exits[North].OnTraverse[0].Action.Teleport != 111 { - t.Errorf("exit on_traverse teleport = %d, want 111", r.Exits[North].OnTraverse[0].Action.Teleport) + if got := r.Exits[North].OnTraverse[0].Steps[0].Teleport; got != 111 { + t.Errorf("on_traverse teleport = %d, want 111", got) } - - if r.Triggers[0].Room != 31 { - t.Errorf("trigger room = %d, want 31", r.Triggers[0].Room) + // Exit-level condition Room remaps. + if got := r.Exits[North].Condition.Room; got != 41 { + t.Errorf("exit condition room = %d, want 41", got) + } + // Trigger-level + per-step Condition.Room remaps. + if got := r.Exits[North].OnTraverse[0].Condition.Room; got != 61 { + t.Errorf("on_traverse trigger condition room = %d, want 61", got) } - if r.Triggers[0].Steps[0].Teleport != 41 { - t.Errorf("trigger teleport = %d, want 41", r.Triggers[0].Steps[0].Teleport) + if got := r.OnEnter[0].Condition.Room; got != 21 { + t.Errorf("on_enter trigger condition room = %d, want 21", got) } - if got := r.Triggers[0].Steps[0].SpawnMob.DespawnRooms; len(got) != 2 || got[0] != 51 || got[1] != 61 { - t.Errorf("trigger despawn_rooms = %v, want [51 61]", got) + if got := r.OnFlagChange[0].Condition.Room; got != 31 { + t.Errorf("on_flag_change condition room = %d, want 31", got) } - if r.OnEnter[0].Teleport != 71 { - t.Errorf("on_enter teleport = %d, want 71", r.OnEnter[0].Teleport) + if got := r.OnEnter[0].Steps[0].Teleport; got != 71 { + t.Errorf("on_enter teleport = %d, want 71", got) } - if got := r.OnEnter[0].SpawnMob.DespawnRooms; len(got) != 1 || got[0] != 81 { + if got := r.OnEnter[0].Steps[0].SpawnMob.DespawnRooms; len(got) != 1 || got[0] != 81 { t.Errorf("on_enter despawn_rooms = %v, want [81]", got) } + if got := r.OnExit[0].Steps[0].Teleport; got != 51 { + t.Errorf("on_exit teleport = %d, want 51", got) + } + if got := r.OnFlagChange[0].Steps[0].Teleport; got != 41 { + t.Errorf("on_flag_change teleport = %d, want 41", got) + } + if got := r.OnFlagChange[0].Steps[0].SpawnMob.DespawnRooms; len(got) != 2 || got[0] != 51 || got[1] != 61 { + t.Errorf("on_flag_change despawn_rooms = %v, want [51 61]", got) + } if got := r.Mobs[0].WanderRooms; len(got) != 2 || got[0] != 91 || got[1] != 101 { t.Errorf("mob wander_rooms = %v, want [91 101]", got) } @@ -81,7 +102,6 @@ func TestRoomRewriteRoomIDsNoChange(t *testing.T) { r := &Room{ Exits: map[ExitDir]ExitDef{North: {Room: 5}}, } - // idMap does not mention 5 -> nothing changes. if r.RewriteRoomIDs(map[int]int{99: 100}) { t.Error("expected changed=false when no references match") } @@ -100,7 +120,6 @@ func TestRoomRewriteRoomIDsSwapRoundTrips(t *testing.T) { if r.Exits[North].Room != 20 || r.Exits[South].Room != 10 { t.Errorf("after swap: north=%d south=%d, want north=20 south=10", r.Exits[North].Room, r.Exits[South].Room) } - // Applying the same swap again reverts (a swap is its own inverse). r.RewriteRoomIDs(swap) if r.Exits[North].Room != 10 || r.Exits[South].Room != 20 { t.Errorf("after double swap: north=%d south=%d, want north=10 south=20", r.Exits[North].Room, r.Exits[South].Room) @@ -114,7 +133,6 @@ func TestRoomRewriteRoomIDsIdempotentRename(t *testing.T) { if r.Exits[North].Room != 11 { t.Fatalf("after rename: room=%d, want 11", r.Exits[North].Room) } - // Second pass: 11 is not a key in idMap, so nothing changes. if r.RewriteRoomIDs(rename) { t.Error("expected changed=false on second pass of a rename idMap") } diff --git a/internal/world/trigger.go b/internal/world/trigger.go deleted file mode 100644 index de07c18..0000000 --- a/internal/world/trigger.go +++ /dev/null @@ -1,16 +0,0 @@ -package world - -import "thehouseoficarus/internal/behavior" - -type TriggerDef struct { - ID string `yaml:"id"` - OnPlayerFlag string `yaml:"on_player_flag"` - OnGlobalFlag string `yaml:"on_global_flag"` - Value any `yaml:"value"` - Room int `yaml:"room"` - Steps []behavior.StepAction `yaml:"steps"` -} - -func (t *TriggerDef) IsPlayerFlagTrigger() bool { return t.OnPlayerFlag != "" } - -func (t *TriggerDef) IsGlobalFlagTrigger() bool { return t.OnGlobalFlag != "" } \ No newline at end of file diff --git a/internal/world/trigger_store.go b/internal/world/trigger_store.go deleted file mode 100644 index e50ae81..0000000 --- a/internal/world/trigger_store.go +++ /dev/null @@ -1,330 +0,0 @@ -package world - -import ( - "fmt" - "os" - "path/filepath" - "strings" - "sync" - - "gopkg.in/yaml.v3" - "thehouseoficarus/internal/behavior" - "thehouseoficarus/internal/engine" -) - -type TriggerStore struct { - mu sync.Mutex - - globalByPlayerFlag map[string][]*TriggerDef - globalByGlobalFlag map[string][]*TriggerDef - roomByPlayerFlag map[int]map[string][]*TriggerDef - roomByGlobalFlag map[int]map[string][]*TriggerDef - - playerSeqs map[string]*PlayerTriggerSeq - globalSeqs map[string]*GlobalTriggerSeq -} - -type PlayerTriggerSeq struct { - PlayerName string - TriggerID string - RoomID int - Steps []behavior.StepAction - Wait int - Started bool - FlagValue any -} - -type GlobalTriggerSeq struct { - TriggerID string - RoomID int - Steps []behavior.StepAction - Wait int - FlagValue any -} - -func NewTriggerStore() *TriggerStore { - return &TriggerStore{ - globalByPlayerFlag: make(map[string][]*TriggerDef), - globalByGlobalFlag: make(map[string][]*TriggerDef), - roomByPlayerFlag: make(map[int]map[string][]*TriggerDef), - roomByGlobalFlag: make(map[int]map[string][]*TriggerDef), - playerSeqs: make(map[string]*PlayerTriggerSeq), - globalSeqs: make(map[string]*GlobalTriggerSeq), - } -} - -func (ts *TriggerStore) ClearTriggers() { - ts.mu.Lock() - defer ts.mu.Unlock() - ts.globalByPlayerFlag = make(map[string][]*TriggerDef) - ts.globalByGlobalFlag = make(map[string][]*TriggerDef) - ts.roomByPlayerFlag = make(map[int]map[string][]*TriggerDef) - ts.roomByGlobalFlag = make(map[int]map[string][]*TriggerDef) -} - -func (ts *TriggerStore) LoadGlobal(dataDir string) error { - dir := filepath.Join(dataDir, "triggers") - if _, err := os.Stat(dir); os.IsNotExist(err) { - return nil - } - - entries, err := os.ReadDir(dir) - if err != nil { - return err - } - - for _, entry := range entries { - if entry.IsDir() { - continue - } - path := filepath.Join(dir, entry.Name()) - ext := strings.ToLower(filepath.Ext(path)) - if ext != ".yaml" && ext != ".yml" { - continue - } - id := strings.TrimSuffix(entry.Name(), filepath.Ext(entry.Name())) - data, err := os.ReadFile(path) - if err != nil { - continue - } - var trigger TriggerDef - if err := yaml.Unmarshal(data, &trigger); err != nil { - return fmt.Errorf("trigger %q: %w", path, err) - } - trigger.ID = id - ts.addGlobal(&trigger) - } - return nil -} - -func (ts *TriggerStore) SeedRoomTriggers(roomID int, triggers []TriggerDef) { - if len(triggers) == 0 { - return - } - ts.mu.Lock() - defer ts.mu.Unlock() - for i := range triggers { - t := &triggers[i] - if t.IsPlayerFlagTrigger() { - if ts.roomByPlayerFlag[roomID] == nil { - ts.roomByPlayerFlag[roomID] = make(map[string][]*TriggerDef) - } - ts.roomByPlayerFlag[roomID][t.OnPlayerFlag] = append( - ts.roomByPlayerFlag[roomID][t.OnPlayerFlag], t) - } - if t.IsGlobalFlagTrigger() { - if ts.roomByGlobalFlag[roomID] == nil { - ts.roomByGlobalFlag[roomID] = make(map[string][]*TriggerDef) - } - t.Room = roomID - ts.roomByGlobalFlag[roomID][t.OnGlobalFlag] = append( - ts.roomByGlobalFlag[roomID][t.OnGlobalFlag], t) - } - } -} - -func (ts *TriggerStore) addGlobal(t *TriggerDef) { - ts.mu.Lock() - defer ts.mu.Unlock() - if t.IsPlayerFlagTrigger() { - ts.globalByPlayerFlag[t.OnPlayerFlag] = append( - ts.globalByPlayerFlag[t.OnPlayerFlag], t) - } - if t.IsGlobalFlagTrigger() { - ts.globalByGlobalFlag[t.OnGlobalFlag] = append( - ts.globalByGlobalFlag[t.OnGlobalFlag], t) - } -} - -func (ts *TriggerStore) FirePlayer(playerName string, flagName string, flagValue any) []*PlayerTriggerSeq { - ts.mu.Lock() - defer ts.mu.Unlock() - - var started []*PlayerTriggerSeq - - for _, t := range ts.globalByPlayerFlag[flagName] { - if !ts.valueMatches(t.Value, flagValue) { - continue - } - seq := ts.startPlayerSeqLocked(playerName, t, 0, flagValue) - if seq != nil { - started = append(started, seq) - } - } - - return started -} - -func (ts *TriggerStore) FirePlayerInRoom(playerName string, roomID int, flagName string, flagValue any) []*PlayerTriggerSeq { - ts.mu.Lock() - defer ts.mu.Unlock() - - var started []*PlayerTriggerSeq - - for _, t := range ts.globalByPlayerFlag[flagName] { - if !ts.valueMatches(t.Value, flagValue) { - continue - } - seq := ts.startPlayerSeqLocked(playerName, t, roomID, flagValue) - if seq != nil { - started = append(started, seq) - } - } - - if roomFlags, ok := ts.roomByPlayerFlag[roomID]; ok { - for _, t := range roomFlags[flagName] { - if !ts.valueMatches(t.Value, flagValue) { - continue - } - seq := ts.startPlayerSeqLocked(playerName, t, roomID, flagValue) - if seq != nil { - started = append(started, seq) - } - } - } - - return started -} - -func (ts *TriggerStore) FireGlobal(flagName string, flagValue any) []*GlobalTriggerSeq { - ts.mu.Lock() - defer ts.mu.Unlock() - - var started []*GlobalTriggerSeq - - for _, t := range ts.globalByGlobalFlag[flagName] { - if !ts.valueMatches(t.Value, flagValue) { - continue - } - seq := ts.startGlobalSeqLocked(t, flagValue) - if seq != nil { - started = append(started, seq) - } - } - - for roomID, roomFlags := range ts.roomByGlobalFlag { - for _, t := range roomFlags[flagName] { - if !ts.valueMatches(t.Value, flagValue) { - continue - } - if t.Room == 0 { - t.Room = roomID - } - seq := ts.startGlobalSeqLocked(t, flagValue) - if seq != nil { - started = append(started, seq) - } - } - } - - return started -} - -func (ts *TriggerStore) startPlayerSeqLocked(playerName string, t *TriggerDef, roomID int, flagValue any) *PlayerTriggerSeq { - key := playerName + ":" + t.ID - if _, exists := ts.playerSeqs[key]; exists { - return nil - } - seq := &PlayerTriggerSeq{ - PlayerName: playerName, - TriggerID: t.ID, - RoomID: roomID, - Steps: make([]behavior.StepAction, len(t.Steps)), - FlagValue: flagValue, - } - copy(seq.Steps, t.Steps) - if len(seq.Steps) > 0 { - seq.Wait = seq.Steps[0].Delay - } - ts.playerSeqs[key] = seq - return seq -} - -func (ts *TriggerStore) startGlobalSeqLocked(t *TriggerDef, flagValue any) *GlobalTriggerSeq { - key := t.ID - if _, exists := ts.globalSeqs[key]; exists { - return nil - } - seq := &GlobalTriggerSeq{ - TriggerID: t.ID, - RoomID: t.Room, - Steps: make([]behavior.StepAction, len(t.Steps)), - FlagValue: flagValue, - } - copy(seq.Steps, t.Steps) - if len(seq.Steps) > 0 { - seq.Wait = seq.Steps[0].Delay - } - ts.globalSeqs[key] = seq - return seq -} - -func (ts *TriggerStore) valueMatches(want, got any) bool { - if want == nil { - return true - } - return engine.ValuesEqual(want, got) -} - -func (ts *TriggerStore) SnapshotPlayerSeqs() []*PlayerTriggerSeq { - ts.mu.Lock() - defer ts.mu.Unlock() - out := make([]*PlayerTriggerSeq, 0, len(ts.playerSeqs)) - for _, s := range ts.playerSeqs { - out = append(out, s) - } - return out -} - -func (ts *TriggerStore) SnapshotGlobalSeqs() []*GlobalTriggerSeq { - ts.mu.Lock() - defer ts.mu.Unlock() - out := make([]*GlobalTriggerSeq, 0, len(ts.globalSeqs)) - for _, s := range ts.globalSeqs { - out = append(out, s) - } - return out -} - -func (ts *TriggerStore) RemovePlayerSeq(playerName, triggerID string) { - ts.mu.Lock() - defer ts.mu.Unlock() - key := playerName + ":" + triggerID - delete(ts.playerSeqs, key) -} - -func (ts *TriggerStore) RemoveGlobalSeq(triggerID string) { - ts.mu.Lock() - defer ts.mu.Unlock() - delete(ts.globalSeqs, triggerID) -} - -func (ts *TriggerStore) AllGlobalTriggers() []*TriggerDef { - ts.mu.Lock() - defer ts.mu.Unlock() - var out []*TriggerDef - for _, triggers := range ts.globalByPlayerFlag { - out = append(out, triggers...) - } - for _, triggers := range ts.globalByGlobalFlag { - out = append(out, triggers...) - } - return out -} - -func (ts *TriggerStore) AllRoomTriggers() map[int][]*TriggerDef { - ts.mu.Lock() - defer ts.mu.Unlock() - out := make(map[int][]*TriggerDef) - for roomID, byFlag := range ts.roomByPlayerFlag { - for _, triggers := range byFlag { - out[roomID] = append(out[roomID], triggers...) - } - } - for roomID, byFlag := range ts.roomByGlobalFlag { - for _, triggers := range byFlag { - out[roomID] = append(out[roomID], triggers...) - } - } - return out -} -- cgit v1.2.3