From 09d325dcf5e779eab5550d3fd3377bde50101428 Mon Sep 17 00:00:00 2001
From: historia <[not public]>
Date: Wed, 8 Jul 2026 19:59:14 -0400
Subject: feat: unify on use, on look, and on kill. all support same
conditions/actions now.
---
internal/admin/static/cardeditor.js | 2 +-
internal/admin/static/itemeditor.js | 2 +-
internal/admin/static/mobeditor.js | 163 +++++++++++++++++++++++++++++-
internal/admin/static/objecteditor.js | 183 ++++++++++++++--------------------
4 files changed, 237 insertions(+), 113 deletions(-)
(limited to 'internal/admin/static')
diff --git a/internal/admin/static/cardeditor.js b/internal/admin/static/cardeditor.js
index 5e0c7cb..a11388b 100644
--- a/internal/admin/static/cardeditor.js
+++ b/internal/admin/static/cardeditor.js
@@ -10,7 +10,7 @@ function ced_cardPath(sec) {
function ced_getVal(data, sec, key) {
var p = ced_cardPath(sec);
- if (sec.isArray || p === 'on_look') {
+ if (sec.isArray) {
return data[p] && data[p][key] !== undefined ? data[p][key] : '';
}
var fp = p ? p + '.' + key : key;
diff --git a/internal/admin/static/itemeditor.js b/internal/admin/static/itemeditor.js
index ed32267..a86d240 100644
--- a/internal/admin/static/itemeditor.js
+++ b/internal/admin/static/itemeditor.js
@@ -602,7 +602,7 @@ function renderArraySection(sec, data) {
h += '';
h += '';
});
- h += '';
+ h += '';
return h;
}
diff --git a/internal/admin/static/mobeditor.js b/internal/admin/static/mobeditor.js
index b5d2395..7f8c0e3 100644
--- a/internal/admin/static/mobeditor.js
+++ b/internal/admin/static/mobeditor.js
@@ -120,6 +120,16 @@ 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,
+ 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', 'json', 'e.g. {"global_flag":"quest_active"}'],
+ ['action', 'Action', 'json', 'e.g. {"set_global_flags":{"boss_killed":true},"broadcast":"%p has slain the boss!","spawn_mob":"mini_boss"}'],
+ ['message', 'Message', 'text', 'Shown to the killer when this fires...'],
+ ]
+ },
];
function mobCardPath(sec) { return ced_cardPath(sec); }
@@ -236,6 +246,8 @@ function renderMobCard(sec, data) {
h += renderMobCoreCard(data);
} else if (sec.id === 'combat') {
h += renderMobCombatCard(data);
+ } else if (sec.isArray) {
+ h += renderMobArraySection(sec, data);
} else if (sec.subtables) {
h += '
';
sec.fields.forEach(function(f) {
@@ -423,6 +435,135 @@ function renderMobField(sec, f, data, idx, subPath, optStyle) {
return ced_renderField(sec, f, data, idx, subPath, optStyle, mobCardPath, mobFieldID, mobGetVal);
}
+// ---- Interaction-style array rendering (On Kill) ----
+
+function renderMobArraySection(sec, data) {
+ var arr = data[mobCardPath(sec)] || [];
+ var h = '';
+ var isInter = !!sec.interactionStyle;
+ if (isInter) {
+ window._mobInterVis = window._mobInterVis || {};
+ if (!window._mobInterVis[sec.id]) window._mobInterVis[sec.id] = {};
+ }
+ arr.forEach(function(item, idx) {
+ var visMap = window._mobInterVis[sec.id];
+ var vis = visMap[idx] || {};
+ visMap[idx] = vis;
+ var showItem = vis.item_id || (item.item_id && item.item_id !== '');
+ var showCond = vis.condition || (item.condition && item.condition !== '');
+ var showAct = vis.action || (item.action && item.action !== '');
+ var showMsg = vis.message || (item.message && item.message !== '');
+
+ h += '
';
+ h += '';
+ h += '
';
+ if (!showItem) {
+ h += '';
+ h += '';
+ }
+ if (!showCond) {
+ h += '';
+ }
+ if (!showAct) {
+ h += '';
+ }
+ if (!showMsg) {
+ h += '';
+ }
+ h += '
';
+ h += '
';
+ h += renderMobField(sec, sec.fields[0], data, idx, null, 'flex:1;min-width:0');
+ h += '';
+ h += '
';
+ h += '
';
+ h += renderMobField(sec, sec.fields[1], data, idx, null, 'flex:1;min-width:0');
+ h += '';
+ h += '
';
+ h += '
';
+ h += renderMobField(sec, sec.fields[2], data, idx, null, 'flex:1;min-width:0');
+ h += '';
+ h += '
';
+ h += '
';
+ h += renderMobField(sec, sec.fields[3], data, idx, null, 'flex:1;min-width:0');
+ h += '';
+ h += '
';
+ h += '
';
+ });
+ h += '';
+ if (isInter) {
+ var helpKey = 'mob_' + sec.id + '_helpCollapsed';
+ var helpCollapsed = window[helpKey] === undefined ? true : window[helpKey];
+ h += '
';
+ h += '
';
+ h += '' + (helpCollapsed ? '▶' : '▼') + '';
+ h += 'How Conditions & Actions Work';
+ h += '
';
+ if (!helpCollapsed) {
+ h += '
';
+ h += 'Entries checked top-to-bottom, first match wins (one fires per kill; additive to standard loot).
';
+ h += 'Item ID: only fires if you wield this weapon (in main_hand or off_hand) when the mob is defeated. Leave empty to fire unconditionally. ';
+ h += 'Condition: JSON gate. {"global_flag":"boss_quest"}, {"player_flag":"step","value":2}, {"has_item":"trophy"}, {"all_of":[...]}, {"any_of":[...]}. ';
+ h += 'Action: JSON effects. {"set_global_flags":{...}}, {"set_player_flags":{...}}, {"give_item":"gem"}, {"take_item":"key"}, {"teleport":5}, {"heal":10}, {"credits":100}, {"aps_node":true}, {"spawn_mob":"mini_boss"}, {"despawn_mob":"boss"}, {"broadcast":"%p vanquished the boss!"}, {"broadcast_global":"..."}. ';
+ h += 'Message: shown to the killer when this interaction fires.';
+ h += '
';
+ }
+ h += '
';
+ }
+ return h;
+}
+
+function mobAddArrayItem(cardID) {
+ var sec = MOB_SECTIONS.find(function(s) { return s.id === cardID; });
+ if (!sec || !sec.isArray) return;
+ var arr = mobData[sec.path] || [];
+ var empty = {};
+ sec.fields.forEach(function(f) {
+ empty[f[0]] = f[2] === 'checkbox' ? false : (f[2] === 'number' ? 0 : '');
+ });
+ arr.push(empty);
+ mobData[sec.path] = arr;
+ renderCurrent();
+}
+
+function mobRemoveArrayItem(cardID, idx) {
+ var sec = MOB_SECTIONS.find(function(s) { return s.id === cardID; });
+ if (!sec || !sec.isArray) return;
+ ced_syncDOMToData(mobData);
+ var arr = mobData[sec.path] || [];
+ arr.splice(idx, 1);
+ renderMobEditor(mobID, mobData);
+}
+
+function mobShowInterField(secId, idx, key) {
+ window._mobInterVis = window._mobInterVis || {};
+ if (!window._mobInterVis[secId]) window._mobInterVis[secId] = {};
+ if (!window._mobInterVis[secId][idx]) window._mobInterVis[secId][idx] = {};
+ window._mobInterVis[secId][idx][key] = true;
+ renderCurrent();
+}
+
+function mobHideInterField(secId, idx, key) {
+ window._mobInterVis = window._mobInterVis || {};
+ if (!window._mobInterVis[secId]) window._mobInterVis[secId] = {};
+ if (!window._mobInterVis[secId][idx]) window._mobInterVis[secId][idx] = {};
+ window._mobInterVis[secId][idx][key] = false;
+ ced_syncDOMToData(mobData);
+ if (mobData[secId] && mobData[secId][idx]) {
+ mobData[secId][idx][key] = '';
+ }
+ renderMobEditor(mobID, mobData);
+}
+
+function mobToggleInterHelp(secId) {
+ var key = 'mob_' + secId + '_helpCollapsed';
+ if (window[key] === undefined) {
+ window[key] = false;
+ } else {
+ window[key] = !window[key];
+ }
+ renderCurrent();
+}
+
// ---- Subtable rendering ----
function renderMobSubField(sec, stKey, idx, f, val) {
@@ -602,7 +743,9 @@ function mobAddSection() {
if (!sec) return;
if (sec.path) {
- if (id === 'combat') {
+ if (sec.isArray) {
+ mobData[sec.path] = [];
+ } else if (id === 'combat') {
mobData[sec.path] = {
kind: 'combat',
aggressive: false,
@@ -694,6 +837,24 @@ function saveMob() {
return;
}
+ if (sec.isArray) {
+ var arr = [];
+ 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) {
+ var item = {};
+ sec.fields.forEach(function(f) {
+ var fid = mobFieldID(sec, f[0], idx);
+ var el = document.getElementById(fid);
+ if (el) item[f[0]] = ced_collectFieldValue(el, f[2]);
+ });
+ if (Object.keys(item).length > 0) arr.push(item);
+ });
+ if (arr.length > 0) out[sec.path] = arr;
+ return;
+ }
+
var sectionObj = {};
var hasValues = sec.always;
diff --git a/internal/admin/static/objecteditor.js b/internal/admin/static/objecteditor.js
index 6abec3e..ecb3ced 100644
--- a/internal/admin/static/objecteditor.js
+++ b/internal/admin/static/objecteditor.js
@@ -86,26 +86,23 @@ var SECTIONS = [
]
},
{
- id: 'use_interactions', label: 'Use Interactions', labelHint: '(what happens when you use items on this)', path: 'use_interactions', isArray: true, fullWidth: true,
- detect: function(d) { return d.use_interactions && Array.isArray(d.use_interactions); },
+ 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', '', null, 'items'],
+ ['item_id', 'Item ID', 'search', 'e.g. keycard (empty = bare use)', '', null, 'items'],
['condition', 'Condition', 'json', 'e.g. {"flag":"my_flag"} or {"player_flag":"x","value":1} or {"all_of":[{"flag":"a"},{"has_item":"key"}]}'],
- ['action', 'Action', 'json', 'e.g. {"set_flags":{"my_flag":true},"message":"You activate it.","give_item":"gem"}'],
+ ['action', 'Action', 'json', 'e.g. {"set_global_flags":{"my_flag":true},"message":"You activate it.","give_item":"gem","teleport":5,"heal":10,"credits":100,"aps_node":true,"spawn_mob":"rat","despawn_mob":"rat","broadcast":"%p activates it.","broadcast_global":"%p did it!","delay":3}'],
['message', 'Message', 'text', 'You use the object...'],
]
},
{
- id: 'on_look', label: 'On Look', labelHint: '(what happens when you "look ")', path: 'on_look',
- detect: function(d) { return d.on_look; },
+ 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: [
- ['set_flags', 'Set Global Flags', 'json', '{"flag":"value"}'],
- ['set_player_flags', 'Set Player Flags', 'json', '{"pflag":"value"}'],
- ['give_item', 'Give Item', 'search', 'e.g. keycard', '', null, 'items'],
- ['take_item', 'Take Item', 'search', 'e.g. bomb', '', null, 'items'],
- ['teleport', 'Teleport', 'number', '5', 'narrow'],
- ['heal', 'Heal', 'number', '10', 'narrow'],
- ['credits', 'Credits', 'number', '100', 'narrow'],
+ ['item_id', 'Item ID', 'search', '(optional — only fires if you carry this item)', '', null, 'items'],
+ ['condition', 'Condition', 'json', 'e.g. {"player_flag":"read_sign","value":true}'],
+ ['action', 'Action', 'json', 'e.g. {"set_player_flags":{"read_sign":true},"give_item":"gem"}'],
+ ['message', 'Message', 'text', 'Shown to the player after the description...'],
]
},
];
@@ -191,7 +188,7 @@ function cardPath(sec) {
function getVal(data, sec, key) {
var p = cardPath(sec);
- if (p === 'on_look' || sec.isArray) {
+ if (sec.isArray) {
return data[p] && data[p][key] !== undefined ? data[p][key] : '';
}
var fp = p ? p + '.' + key : key;
@@ -234,8 +231,6 @@ function renderCard(sec, data) {
h += renderCoreCard(data);
} else if (sec.id === 'steal') {
h += renderStealCard(data);
- } else if (sec.id === 'on_look') {
- h += renderOnLookCard(data);
} else {
h += '
';
sec.fields.forEach(function(f) {
@@ -507,56 +502,20 @@ function renderStealCard(data) {
return h;
}
-function renderOnLookCard(data) {
- var sec = SECTIONS.find(function(s) { return s.id === 'on_look'; });
- var h = '
';
- h += renderField(sec, sec.fields[0], data, -1, null, 'flex:1');
- h += renderField(sec, sec.fields[1], data, -1, null, 'flex:1');
- h += '
';
- h += '
';
- h += renderField(sec, sec.fields[2], data, -1, null, 'flex:1');
- h += renderField(sec, sec.fields[3], data, -1, null, 'flex:1');
- h += '
';
- h += '
';
- h += renderField(sec, sec.fields[4], data, -1);
- h += renderField(sec, sec.fields[5], data, -1);
- h += renderField(sec, sec.fields[6], data, -1);
- h += '
';
- var helpCollapsed = window._onLookHelpCollapsed === undefined ? true : window._onLookHelpCollapsed;
- h += '
';
- h += '
';
- h += '' + (helpCollapsed ? '▶' : '▼') + '';
- h += 'How Set Global Flags & Set Player Flags Work';
- h += '
';
- if (!helpCollapsed) {
- h += '
';
- h += 'When a player looks at this object, these fields apply effects.
';
- h += 'Set Global Flags — Sets world flags shared by all players. JSON object mapping flag names to values: ';
- h += ' {"gate_open":true,"boss_summoned":false} ';
- h += ' Flags persist until changed by another on_look, room script, or interact action.
';
- h += 'Set Player Flags — Sets per-character flags saved to the player\'s YAML. JSON object mapping flag names to values: ';
- h += ' {"has_seen_cave":true,"dialog_spoke":1} ';
- h += ' These flags can be used in conditions (player_flag) to track player progress.
';
- h += 'Other Fields — Give Item grants an item, Take Item removes one, ';
- h += 'Teleport sends player to a room, Heal restores HP, Credits adds/removes credits (use negative for cost).
';
- h += 'All effects fire simultaneously when the player looks at this object.';
- h += '
';
- }
- h += '
';
- return h;
-}
-
function renderArraySection(sec, data) {
var arr = data[cardPath(sec)] || [];
var h = '';
- var isUseInter = sec.id === 'use_interactions';
- if (isUseInter) {
- window._useInterVis = window._useInterVis || {};
+ var isInter = !!sec.interactionStyle;
+ if (isInter) {
+ window._interVis = window._interVis || {};
+ if (!window._interVis[sec.id]) window._interVis[sec.id] = {};
}
arr.forEach(function(item, idx) {
- if (isUseInter) {
- var vis = window._useInterVis[idx] || {};
- window._useInterVis[idx] = vis;
+ if (isInter) {
+ var visMap = window._interVis[sec.id];
+ var vis = visMap[idx] || {};
+ visMap[idx] = vis;
+ var showItem = vis.item_id || (item.item_id && item.item_id !== '');
var showCond = vis.condition || (item.condition && item.condition !== '');
var showAct = vis.action || (item.action && item.action !== '');
var showMsg = vis.message || (item.message && item.message !== '');
@@ -566,32 +525,39 @@ function renderArraySection(sec, data) {
h += '';
h += '
';
- h += renderField(sec, sec.fields[0], data, idx, null, 'max-width:260px');
- h += '';
+ if (!showItem) {
+ h += '';
+ h += '';
+ }
if (!showCond) {
- h += '';
+ h += '';
}
if (!showAct) {
- h += '';
+ h += '';
}
if (!showMsg) {
- h += '';
+ h += '';
}
h += '
';
+ h += '
';
+ h += renderField(sec, sec.fields[0], data, idx, null, 'flex:1;min-width:0');
+ h += '';
+ h += '
';
+
h += '
';
h += renderField(sec, sec.fields[1], data, idx, null, 'flex:1;min-width:0');
- h += '';
+ h += '';
h += '
';
h += '
';
h += renderField(sec, sec.fields[2], data, idx, null, 'flex:1;min-width:0');
- h += '';
+ h += '';
h += '
';
h += '
';
h += renderField(sec, sec.fields[3], data, idx, null, 'flex:1;min-width:0');
- h += '';
+ h += '';
h += '
';
h += '
';
@@ -606,32 +572,37 @@ function renderArraySection(sec, data) {
h += '
';
}
});
- h += '';
- if (isUseInter) {
- var helpCollapsed = window._useInterHelpCollapsed === undefined ? true : window._useInterHelpCollapsed;
+ h += '';
+ if (isInter) {
+ var helpKey = sec.id + '_helpCollapsed';
+ var helpCollapsed = window[helpKey] === undefined ? true : window[helpKey];
h += '
';
- h += '
';
+ h += '
';
h += '' + (helpCollapsed ? '▶' : '▼') + '';
h += 'How Conditions & Actions Work';
h += '
';
if (!helpCollapsed) {
h += '
';
- h += 'Entries checked top-to-bottom, first match wins.
Item ID: required for use <item> on <object>; omit for plain use <object>. ';
- h += 'Condition: JSON. When this interaction is available: ';
- h += ' {"flag":"gate_open","not":true} — world flag is NOT set ';
+ h += 'Entries checked top-to-bottom, first match wins (one fires per use/look/kill).
';
+ h += 'Item ID: on_use requires use <item> on <object>; empty = bare use <object>. on_look = only fires if you carry this item. on_kill = only fires if you wield this weapon when the mob is defeated. ';
+ h += 'Condition: JSON. Available gates: ';
+ h += ' {"global_flag":"gate_open","not":true} — world flag is NOT set ';
h += ' {"player_flag":"has_key","value":1} — player flag matches value ';
h += ' {"has_item":"silver_bar"} — player carries item ';
- h += ' {"all_of":[{"flag":"a"},{"has_item":"b"}]} — ALL sub-conditions ';
- h += ' {"any_of":[...]} — ANY sub-condition ';
+ h += ' {"min_credits":50} — minimum credits ';
+ h += ' {"all_of":[{"global_flag":"a"},{"has_item":"b"}]} — ALL ';
+ h += ' {"any_of":[...]} — ANY ';
h += ' Omit for an unconditional interaction. ';
- h += 'Action: JSON. What happens when the interaction fires: ';
- h += ' {"set_flags":{"gate_open":true}} — set world flags ';
+ h += 'Action: JSON. Effects that fire when the interaction runs: ';
+ h += ' {"set_global_flags":{"gate_open":true}} — set world flags ';
h += ' {"set_player_flags":{"spoke":true}} — set player flags ';
- h += ' {"give_item":"keycard","teleport":5} — give item + teleport ';
- h += ' {"take_item":"bomb","heal":10} — take item + heal ';
- h += ' {"credits":-50} — deduct credits ';
- h += ' {"reputation_cost":1,"aps_node":true} — rep cost + mark APS ';
- h += 'Message: Sent to the player when the interaction fires (1-tick action).';
+ h += ' {"give_item":"keycard","take_item":"bomb"} — give/take item ';
+ h += ' {"teleport":5,"heal":10,"credits":100} — move, heal, give credits ';
+ h += ' {"aps_node":true} — mark current room as APS node ';
+ h += ' {"spawn_mob":"rat","despawn_mob":"rat"} — spawn/despawn transient mob ';
+ h += ' {"broadcast":"%p activates it.","broadcast_global":"%p did it!"} — announce (%p = player, %v = flag value) ';
+ h += ' {"message":"You feel energized.","delay":3} — only meaningful inside on_enter steps / triggers (delay in ticks) ';
+ h += 'Message: Sent to the player when the interaction fires (one-tick action).';
h += '