aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--internal/admin/static/admin.css2
-rw-r--r--internal/admin/static/intereditor.js118
-rw-r--r--internal/admin/static/itemeditor.js74
-rw-r--r--internal/admin/static/mobeditor.js2
-rw-r--r--internal/admin/static/objecteditor.js4
-rw-r--r--internal/admin/static/triggerseditor.js37
-rw-r--r--internal/admin/templates/items.html1
-rw-r--r--internal/behavior/behavior.go51
-rw-r--r--internal/behavior/types.go25
-rw-r--r--internal/game/act.go2
-rw-r--r--internal/game/act_effects.go75
-rw-r--r--internal/game/act_search.go107
-rw-r--r--internal/game/act_state.go2
-rw-r--r--internal/game/cmd_search.go3
-rw-r--r--internal/item/item.go4
-rw-r--r--internal/validate/checks.go74
16 files changed, 375 insertions, 206 deletions
diff --git a/internal/admin/static/admin.css b/internal/admin/static/admin.css
index f77e55a..496a031 100644
--- a/internal/admin/static/admin.css
+++ b/internal/admin/static/admin.css
@@ -391,6 +391,8 @@ g.ud-hover:hover text{font-weight:bold}
.ie-act-rm{flex-shrink:0}
.ie-act-kv{background:var(--bg);border:1px solid var(--border);border-radius:3px;padding:6px}
.ie-act-kv-header{display:flex;align-items:center;gap:8px;margin-bottom:4px}
+.ie-drop-table{display:flex;flex-direction:column;gap:4px}
+.ie-drop-row{display:flex;gap:6px;align-items:center}
.ie-kv-list{display:flex;flex-direction:column;gap:3px}
.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}
diff --git a/internal/admin/static/intereditor.js b/internal/admin/static/intereditor.js
index c4f16e7..0e2c03d 100644
--- a/internal/admin/static/intereditor.js
+++ b/internal/admin/static/intereditor.js
@@ -3,7 +3,8 @@
// 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 }
+// give_item, take_item, teleport, heal, credits, aps_node,
+// drop_table[] }
// Shared by objecteditor.js, mobeditor.js, and map.js.
// ── Condition leaf types ──
@@ -32,6 +33,7 @@ var IE_ACT_TYPES = [
{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: 'drop_table', label: 'Drop Table', kind: 'droptable'},
{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)'},
@@ -83,6 +85,62 @@ function ie_renderBottomAddbar(secId, secPath, idx) {
return h;
}
+// ── Drop Table block renderer (shared between intereditor and triggerseditor) ──
+
+function ie_renderDropTableBlock(drops, onAddItem, onAddTable, onRemove) {
+ drops = drops || [];
+ var h = '<div class="ie-drop-table">';
+ drops.forEach(function(row, ri) {
+ var kind = row.table ? 'table' : (row.item_id ? 'item' : (row._kind || 'item'));
+ h += '<div class="ie-drop-row">';
+ if (kind === 'table') {
+ h += '<div class="obj-search" style="position:relative;min-width:0;flex:2"><input class="search-input ie-drop-table-input" data-search-type="drops" value="' + escAttr(row.table || '') + '" placeholder="Table ID"><div class="search-results" style="display:none"></div></div>';
+ } else {
+ h += '<div class="obj-search" style="position:relative;min-width:0;flex:2"><input class="search-input ie-drop-item-input" data-search-type="items" value="' + escAttr(row.item_id || '') + '" placeholder="Item ID"><div class="search-results" style="display:none"></div></div>';
+ }
+ h += '<input class="ie-drop-weight" type="number" value="' + (row.weight || 10) + '" placeholder="Weight" style="width:60px" min="0">';
+ h += '<input class="ie-drop-qty" type="number" value="' + (row.quantity || 1) + '" placeholder="Qty" style="width:50px" min="0">';
+ h += '<button class="btn btn-sm btn-danger" onclick="' + onRemove.replace(/__RI__/g, String(ri)) + '">X</button>';
+ h += '</div>';
+ });
+ h += '<div style="display:flex;gap:6px;margin-top:4px">';
+ h += '<button class="btn btn-sm obj-sub-add" onclick="' + onAddItem + '">+ Add Item</button>';
+ h += '<button class="btn btn-sm obj-sub-add" onclick="' + onAddTable + '">+ Add Table</button>';
+ h += '</div>';
+ h += '</div>';
+ return h;
+}
+
+function ie_collectDropTableBlock(container) {
+ if (!container) return null;
+ var rows = container.querySelectorAll(':scope > .ie-drop-row');
+ var out = [];
+ rows.forEach(function(row) {
+ var tableInput = row.querySelector('.ie-drop-table-input');
+ var itemInput = row.querySelector('.ie-drop-item-input');
+ var weightInput = row.querySelector('.ie-drop-weight');
+ var qtyInput = row.querySelector('.ie-drop-qty');
+ var entry = {};
+ if (tableInput && tableInput.value.trim()) {
+ entry.table = tableInput.value.trim();
+ entry._kind = 'table';
+ } else if (itemInput && itemInput.value.trim()) {
+ entry.item_id = itemInput.value.trim();
+ entry._kind = 'item';
+ } else if (itemInput) {
+ entry.item_id = '';
+ entry._kind = 'item';
+ } else {
+ entry.table = '';
+ entry._kind = 'table';
+ }
+ entry.weight = weightInput ? (parseInt(weightInput.value, 10) || 0) : 0;
+ entry.quantity = qtyInput ? (parseInt(qtyInput.value, 10) || 0) : 0;
+ out.push(entry);
+ });
+ return out.length > 0 ? out : null;
+}
+
// ie_addStepKind creates a new step initialised with a single action kind.
function ie_addStepKind(secId, secPath, idx, kind) {
var ed = window._editorData;
@@ -103,6 +161,7 @@ function ie_addStepKind(secId, secPath, idx, kind) {
if (at.kind === 'kv') step[kind] = {};
else if (at.kind === 'checkbox') step[kind] = true;
else if (at.kind === 'number') step[kind] = 0;
+ else if (at.kind === 'droptable') step[kind] = [];
else step[kind] = '';
}
ed[secPath][idx].steps.push(step);
@@ -507,6 +566,11 @@ function ie_renderAct(stepObj, secId, idx, si, secPath) {
rows += '<div class="obj-search" style="position:relative;min-width:0;flex:1"><input class="ie-act-input search-input" data-ie-act-key="' + escAttr(at.key) + '" data-search-type="' + escAttr(at.entity) + '" placeholder="' + escAttr(at.hint || 'Search ' + at.entity + '...') + '" value="' + escAttr(String(val)) + '"><div class="search-results" style="display:none"></div></div>';
rows += '<button class="btn btn-sm btn-danger ie-act-rm" onclick="ie_removeActEffect(\'' + escAttr(secId) + '\',\'' + escAttr(secPath) + '\',' + idx + ',' + si + ',\'' + escAttr(at.key) + '\')">X</button>';
rows += '</div>';
+ } else if (at.kind === 'droptable') {
+ rows += '<div class="ie-act-kv" data-ie-act-key="drop_table">';
+ rows += '<div class="ie-act-kv-header"><span>Drop Table</span><button class="btn btn-sm btn-danger ie-act-rm" style="margin-left:auto" onclick="ie_removeActEffect(\'' + escAttr(secId) + '\',\'' + escAttr(secPath) + '\',' + idx + ',' + si + ',\'drop_table\')">X</button></div>';
+ rows += ie_renderDropTableBlock(val, 'ie_addDropRow(\''+escAttr(secId)+'\',\''+escAttr(secPath)+'\','+idx+','+si+',\'item\')', 'ie_addDropRow(\''+escAttr(secId)+'\',\''+escAttr(secPath)+'\','+idx+','+si+',\'table\')', 'ie_removeDropRow(\''+escAttr(secId)+'\',\''+escAttr(secPath)+'\','+idx+','+si+',__RI__)');
+ rows += '</div>';
} else {
rows += '<div class="ie-act-row"><span class="ie-act-label">' + esc(at.label) + '</span>';
rows += '<input class="ie-act-input" data-ie-act-key="' + escAttr(at.key) + '"' + (at.kind === 'number' ? ' type="number"' : '') + ' data-ie-act-kind="' + escAttr(at.kind) + '" value="' + escAttr(String(val)) + '" placeholder="' + escAttr(at.hint || '') + '" style="flex:1">';
@@ -595,6 +659,11 @@ function ie_collectAct(container) {
var key = kvEl.getAttribute('data-ie-act-key');
if (!key) return;
if (key === 'messages') return;
+ if (key === 'drop_table') {
+ var dt = ie_collectDropTableBlock(kvEl.querySelector('.ie-drop-table'));
+ if (dt) r.drop_table = dt;
+ return;
+ }
var map = {};
kvEl.querySelectorAll('.ie-kv-row').forEach(function(row) {
var kEl = row.querySelector('.ie-kv-key');
@@ -1013,6 +1082,8 @@ function ie_addActEffect(secId, secPath, idx, si, effectKey) {
step[effectKey] = true;
} else if (at.kind === 'number') {
step[effectKey] = 0;
+ } else if (at.kind === 'droptable') {
+ step[effectKey] = [];
} else {
step[effectKey] = '';
}
@@ -1077,8 +1148,37 @@ function ie_addMessages(secId, secPath, idx, si) {
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);
+ var step = ed[secPath][idx].steps[si];
+ if (!step.messages || !Array.isArray(step.messages)) step.messages = [];
+ step.messages.push('');
+ window._ieRenderCurrent();
+}
+
+// ── Drop Table row add/remove ──
+
+function ie_addDropRow(secId, secPath, idx, si, kind) {
+ 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;
+ var step = ed[secPath][idx].steps[si];
+ if (!Array.isArray(step.drop_table)) step.drop_table = [];
+ if (kind === 'table') {
+ step.drop_table.push({table: '', weight: 10, quantity: 1, _kind: 'table'});
+ } else {
+ step.drop_table.push({item_id: '', weight: 10, quantity: 1, _kind: 'item'});
+ }
+ window._ieRenderOnly();
+}
+
+function ie_removeDropRow(secId, secPath, idx, si, ri) {
+ 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;
+ var step = ed[secPath][idx].steps[si];
+ if (!Array.isArray(step.drop_table)) return;
+ step.drop_table.splice(ri, 1);
window._ieRenderOnly();
}
@@ -1214,6 +1314,18 @@ function ie_cleanStep(step) {
if (!step || !Object.prototype.hasOwnProperty.call(step, at.key)) return;
var v = step[at.key];
if (v === undefined || v === null || v === '') return;
+ if (at.key === 'drop_table' && Array.isArray(v)) {
+ var cleaned = v.map(function(e) {
+ var c = {};
+ if (e.item_id) c.item_id = e.item_id;
+ if (e.table) c.table = e.table;
+ if (e.weight) c.weight = e.weight;
+ if (e.quantity) c.quantity = e.quantity;
+ return c;
+ }).filter(function(e) { return Object.keys(e).length > 0; });
+ if (cleaned.length > 0) s.drop_table = cleaned;
+ return;
+ }
if (typeof v === 'object') {
if (Object.keys(v).length === 0) return;
s[at.key] = v;
diff --git a/internal/admin/static/itemeditor.js b/internal/admin/static/itemeditor.js
index a86d240..53242ab 100644
--- a/internal/admin/static/itemeditor.js
+++ b/internal/admin/static/itemeditor.js
@@ -84,13 +84,9 @@ var SECTIONS = [
]
},
{
- id: 'search', label: 'Search',
- detect: function(d) { return d.search_table || d.search_ticks; },
- fields: [
- ['search_table', 'Search Table', 'search', 'e.g. gem_table', '', null, 'drops'],
- ['search_ticks', 'Search Ticks', 'number', '8', 'narrow'],
- ['search_message', 'Search Message', 'text', 'You search...', 'wide'],
- ]
+ id: 'search', label: 'Search', path: 'search', isArray: true, fullWidth: true, interactionStyle: true, itemIDAllowed: false,
+ detect: function(d) { return d.search && Array.isArray(d.search); },
+ fields: []
},
{
id: 'food', label: 'Food',
@@ -126,6 +122,9 @@ var SECTIONS = [
];
function initItemEditor() {
+ window._ieRenderCurrent = renderCurrent;
+ window._ieRenderOnly = function() { if (itemID && itemData) renderItemEditor(itemID, itemData); };
+ window._ieSyncAll = function(ed) { if (ed) ie_syncAllInteractions(ed, SECTIONS); };
editorType = 'items';
editorFields = [];
loadList();
@@ -167,6 +166,7 @@ function loadItemEditor(id) {
API.get('/api/items/' + encodeURIComponent(id)).then(function(data) {
itemData = data;
window._editorData = itemData;
+ window._interVis = {};
renderItemEditor(id, data);
}).catch(function(e) {
$('#editorMain').innerHTML = '<p style="color:var(--danger)">Failed to load: ' + esc(e.message) + '</p>';
@@ -225,6 +225,7 @@ function renderItemEditor(id, data) {
function renderCurrent() {
ced_syncDOMToData(itemData);
+ if (window._ieSyncAll) window._ieSyncAll(itemData);
if (!itemData || !itemID) return;
renderItemEditor(itemID, itemData);
}
@@ -250,7 +251,22 @@ function renderCard(sec, data) {
h += '<div class="obj-card-body">';
if (sec.isArray) {
- h += renderArraySection(sec, data);
+ if (sec.interactionStyle) {
+ window._interVis = window._interVis || {};
+ if (!window._interVis[sec.id]) window._interVis[sec.id] = {};
+ h += ie_renderArraySection({
+ sec: sec,
+ data: data,
+ visMap: window._interVis[sec.id],
+ 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 + '\')'; },
+ renderField: function(sec, f, data, idx, optStyle) { return renderField(sec, f, data, idx, null, optStyle); },
+ onAdd: function() { return 'addArrayItem(\'' + sec.id + '\')'; },
+ });
+ } else {
+ h += renderArraySection(sec, data);
+ }
} else if (sec.id === 'core') {
h += renderCoreCard(data);
} else if (sec.id === 'equipment') {
@@ -964,6 +980,8 @@ function addSection() {
if (sec.path) {
if (sec.id === 'equipment') {
itemData.equipment = {type:'', slot:'', attack_type:'', speed:0, junk:''};
+ } else if (sec.isArray && sec.interactionStyle) {
+ itemData[sec.path] = [];
} else {
itemData[sec.path] = {};
}
@@ -981,11 +999,15 @@ function addArrayItem(cardID) {
var sec = SECTIONS.find(function(s) { return s.id === cardID; });
if (!sec || !sec.isArray) return;
var arr = itemData[sec.path] || [];
- var empty = {};
- sec.fields.forEach(function(f) {
- empty[f[0]] = f[2] === 'checkbox' ? false : (f[2] === 'number' ? 0 : '');
- });
- arr.push(empty);
+ if (sec.interactionStyle) {
+ arr.push({steps: []});
+ } else {
+ var empty = {};
+ sec.fields.forEach(function(f) {
+ empty[f[0]] = f[2] === 'checkbox' ? false : (f[2] === 'number' ? 0 : '');
+ });
+ arr.push(empty);
+ }
itemData[sec.path] = arr;
renderCurrent();
}
@@ -994,11 +1016,24 @@ function removeArrayItem(cardID, idx) {
var sec = SECTIONS.find(function(s) { return s.id === cardID; });
if (!sec || !sec.isArray) return;
ced_syncDOMToData(itemData);
+ if (window._ieSyncAll) window._ieSyncAll(itemData);
var arr = itemData[sec.path] || [];
arr.splice(idx, 1);
renderItemEditor(itemID, itemData);
}
+function showInterField(secId, idx, key) {
+ window._interVis = window._interVis || {};
+ ie_showField(window._interVis, secId, idx, key);
+ renderCurrent();
+}
+
+function hideInterField(secId, idx, key) {
+ window._interVis = window._interVis || {};
+ ie_hideField(window._interVis, secId, idx, key);
+ renderCurrent();
+}
+
// =========================================================================
// Subtable row add/remove
// =========================================================================
@@ -1332,6 +1367,19 @@ function saveItem() {
var el = document.getElementById(fid);
if (el) item[f[0]] = collectFieldValue(el, f[2]);
});
+ if (sec.interactionStyle) {
+ var dataArr = itemData[sec.path] || [];
+ var dataItem = dataArr[idx] || {};
+ if (dataItem.condition && typeof dataItem.condition === 'object') {
+ var cleanedCond = ie_cleanCondition(dataItem.condition);
+ if (cleanedCond) item.condition = cleanedCond;
+ }
+ 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);
});
if (arr.length > 0) out[sec.path] = arr;
diff --git a/internal/admin/static/mobeditor.js b/internal/admin/static/mobeditor.js
index 183aef9..29664dc 100644
--- a/internal/admin/static/mobeditor.js
+++ b/internal/admin/static/mobeditor.js
@@ -121,7 +121,7 @@ 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,
+ id: 'on_kill', label: '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'],
diff --git a/internal/admin/static/objecteditor.js b/internal/admin/static/objecteditor.js
index 1abccbf..88bc5b7 100644
--- a/internal/admin/static/objecteditor.js
+++ b/internal/admin/static/objecteditor.js
@@ -86,14 +86,14 @@ 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,
+ id: 'on_use', label: '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'],
]
},
{
- id: 'on_look', label: 'On Look', labelHint: '(what happens when you "look <this>")', path: 'on_look', isArray: true, fullWidth: true, interactionStyle: true,
+ id: 'on_look', label: 'Look', labelHint: '(what happens when you "look <this>")', 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'],
diff --git a/internal/admin/static/triggerseditor.js b/internal/admin/static/triggerseditor.js
index 29759eb..dbc1e41 100644
--- a/internal/admin/static/triggerseditor.js
+++ b/internal/admin/static/triggerseditor.js
@@ -110,7 +110,7 @@ function syncStepEffects(row, si) {
if (!effectsEl) return;
var step = triggerData.steps[si] || {};
['messages','broadcast','broadcast_global','teleport','heal','credits',
- 'give_item','take_item','despawn_mob','aps_node'].forEach(function(k) { delete step[k]; });
+ 'give_item','take_item','despawn_mob','aps_node','drop_table'].forEach(function(k) { delete step[k]; });
delete step.set_global_flags; delete step.set_player_flags; delete step.spawn_mob;
var msgInputs = effectsEl.querySelectorAll('.trig-msg-input');
@@ -147,6 +147,12 @@ function syncStepEffects(row, si) {
step.spawn_mob.id = smInput.value.trim();
}
+ var dtBlock = effectsEl.querySelector('.ie-drop-table');
+ if (dtBlock) {
+ var dt = ie_collectDropTableBlock(dtBlock);
+ if (dt) step.drop_table = dt;
+ }
+
triggerData.steps[si] = step;
}
@@ -336,9 +342,37 @@ function renderStepEffects(step, si) {
h += renderKVSection('Set Player Flags', 'set_player_flags', step.set_player_flags);
}
+ if (Array.isArray(step.drop_table) || step.drop_table !== undefined) {
+ h += '<div class="ie-act-kv"><div class="ie-act-kv-header"><span>Drop Table</span></div>';
+ h += ie_renderDropTableBlock(step.drop_table, 'trigAddDropRow('+si+',\'item\')', 'trigAddDropRow('+si+',\'table\')', 'trigRemoveDropRow('+si+',__RI__)');
+ h += '</div>';
+ }
+
return h;
}
+function trigAddDropRow(si, kind) {
+ if (!triggerData || !Array.isArray(triggerData.steps) || !triggerData.steps[si]) return;
+ syncAllStepEffects();
+ var step = triggerData.steps[si];
+ if (!Array.isArray(step.drop_table)) step.drop_table = [];
+ if (kind === 'table') {
+ step.drop_table.push({table: '', weight: 10, quantity: 1, _kind: 'table'});
+ } else {
+ step.drop_table.push({item_id: '', weight: 10, quantity: 1, _kind: 'item'});
+ }
+ renderCurrent();
+}
+
+function trigRemoveDropRow(si, ri) {
+ if (!triggerData || !Array.isArray(triggerData.steps) || !triggerData.steps[si]) return;
+ syncAllStepEffects();
+ var step = triggerData.steps[si];
+ if (!Array.isArray(step.drop_table)) return;
+ step.drop_table.splice(ri, 1);
+ renderCurrent();
+}
+
function addStep() {
if (!triggerData) return;
if (!Array.isArray(triggerData.steps)) triggerData.steps = [];
@@ -378,6 +412,7 @@ function addStepEffect(si, kind) {
if (!at) return;
if (at.kind === 'kv') step[kind] = {};
else if (at.kind === 'checkbox') step[kind] = true;
+ else if (at.kind === 'droptable') step[kind] = [];
else if (at.kind === 'number') step[kind] = 0;
else if (at.kind === 'search') step[kind] = '';
else step[kind] = '';
diff --git a/internal/admin/templates/items.html b/internal/admin/templates/items.html
index 5d82ace..7efeded 100644
--- a/internal/admin/templates/items.html
+++ b/internal/admin/templates/items.html
@@ -11,6 +11,7 @@
</div>
<script src="/static/editor.js"></script>
<script src="/static/cardeditor.js"></script>
+<script src="/static/intereditor.js"></script>
<script src="/static/itemeditor.js"></script>
<script>
initItemEditor();
diff --git a/internal/behavior/behavior.go b/internal/behavior/behavior.go
index 75f836b..9152f8c 100644
--- a/internal/behavior/behavior.go
+++ b/internal/behavior/behavior.go
@@ -55,21 +55,22 @@ type TalkOption struct {
// 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"`
+ Condition *Condition `yaml:"condition,omitempty" json:"condition,omitempty"`
+ Wait int `yaml:"wait,omitempty" json:"wait,omitempty"`
+ Messages []string `yaml:"messages,omitempty" json:"messages,omitempty"`
+ Broadcast string `yaml:"broadcast,omitempty" json:"broadcast,omitempty"`
+ BroadcastGlobal string `yaml:"broadcast_global,omitempty" json:"broadcast_global,omitempty"`
+ SpawnMob *SpawnMobConfig `yaml:"spawn_mob,omitempty" json:"spawn_mob,omitempty"`
+ DespawnMob string `yaml:"despawn_mob,omitempty" json:"despawn_mob,omitempty"`
+ SetGlobalFlags map[string]any `yaml:"set_global_flags,omitempty" json:"set_global_flags,omitempty"`
+ SetPlayerFlags map[string]any `yaml:"set_player_flags,omitempty" json:"set_player_flags,omitempty"`
+ GiveItem string `yaml:"give_item,omitempty" json:"give_item,omitempty"`
+ TakeItem string `yaml:"take_item,omitempty" json:"take_item,omitempty"`
+ Teleport int `yaml:"teleport,omitempty" json:"teleport,omitempty"`
+ Heal int `yaml:"heal,omitempty" json:"heal,omitempty"`
+ Credits int `yaml:"credits,omitempty" json:"credits,omitempty"`
+ ApsNode bool `yaml:"aps_node,omitempty" json:"aps_node,omitempty"`
+ DropTable []DropEntry `yaml:"drop_table,omitempty" json:"drop_table,omitempty"`
}
// HasEffects reports whether the step carries any effect beyond a pure wait.
@@ -78,7 +79,7 @@ func (s Step) HasEffects() bool {
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
+ s.Credits != 0 || s.ApsNode || len(s.DropTable) > 0
}
// Trigger is the one conditional wrapper used by every event block in the
@@ -211,13 +212,13 @@ func (c *ShopConfig) FindItem(itemID string) *ShopItem {
// 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,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"`
+ GlobalFlag string `yaml:"global_flag,omitempty" json:"global_flag,omitempty"`
+ PlayerFlag string `yaml:"player_flag,omitempty" json:"player_flag,omitempty"`
+ Room int `yaml:"room,omitempty" json:"room,omitempty"`
+ Value any `yaml:"value,omitempty" json:"value,omitempty"`
+ Not bool `yaml:"not,omitempty" json:"not,omitempty"`
+ HasItem string `yaml:"has_item,omitempty" json:"has_item,omitempty"`
+ MinCredits int `yaml:"min_credits,omitempty" json:"min_credits,omitempty"`
+ AllOf []Condition `yaml:"all_of,omitempty" json:"all_of,omitempty"`
+ AnyOf []Condition `yaml:"any_of,omitempty" json:"any_of,omitempty"`
}
diff --git a/internal/behavior/types.go b/internal/behavior/types.go
index 7aba045..86115a7 100644
--- a/internal/behavior/types.go
+++ b/internal/behavior/types.go
@@ -33,7 +33,6 @@ const (
TypeTalk ActionType = "talk"
TypeBurn ActionType = "burn"
TypeStoke ActionType = "stoke"
- TypeSearch ActionType = "search"
TypeIdentify ActionType = "identify"
TypePlant ActionType = "plant"
TypeHarvest ActionType = "harvest"
@@ -165,12 +164,6 @@ type FarmData struct {
MaxYield float64
}
-type SearchData struct {
- ItemID string
- SlotIdx int
- Started bool
-}
-
type CombineData struct {
ItemID string
Phase int
@@ -205,15 +198,15 @@ type TriggerModuleData struct {
}
type DropEntry struct {
- ItemID string `yaml:"item_id"`
- Table string `yaml:"table"`
- Tool string `yaml:"tool,omitempty"`
- Weight int `yaml:"weight"`
- Quantity int `yaml:"quantity"`
- Depletes bool `yaml:"depletes"`
- SuccessMessage string `yaml:"success_message"`
- Level int `yaml:"level"`
- XP int `yaml:"xp"`
+ ItemID string `yaml:"item_id" json:"item_id"`
+ Table string `yaml:"table" json:"table"`
+ Tool string `yaml:"tool,omitempty" json:"tool,omitempty"`
+ Weight int `yaml:"weight" json:"weight"`
+ Quantity int `yaml:"quantity" json:"quantity"`
+ Depletes bool `yaml:"depletes" json:"depletes"`
+ SuccessMessage string `yaml:"success_message" json:"success_message"`
+ Level int `yaml:"level" json:"level"`
+ XP int `yaml:"xp" json:"xp"`
}
type DropTableDef struct {
diff --git a/internal/game/act.go b/internal/game/act.go
index 352f235..64d1848 100644
--- a/internal/game/act.go
+++ b/internal/game/act.go
@@ -213,8 +213,6 @@ func (g *Game) AdvanceActions() {
g.advanceBurn(sess, p)
case "stoke":
g.advanceStoke(sess, p)
- case "search":
- g.advanceSearch(sess, p)
case "identify":
g.advanceIdentify(sess, p)
case "steal":
diff --git a/internal/game/act_effects.go b/internal/game/act_effects.go
index cb60c5d..cadd05e 100644
--- a/internal/game/act_effects.go
+++ b/internal/game/act_effects.go
@@ -44,12 +44,14 @@ const (
// 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_<roomID>" player flag)
+// 8. drop_table (player scope; resolves weighted drops into inventory,
+// overflow to ground; supports credits pseudo-item)
+// 9. heal (player scope; clamps to MaxHP)
+// 10. credits (player scope; negative is gated by affordability)
+// 11. spawn_mob (player scope: player-owned; global scope: world-owned)
+// 12. despawn_mob (player scope: owner-filtered; global scope: all)
+// 13. teleport (player scope; re-runs look + on_enter of target)
+// 14. aps_node (player scope; marks "aps_node_<roomID>" 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
@@ -97,7 +99,7 @@ func (g *Game) applyStep(sess *net.Session, p *player.Player, step *behavior.Ste
}
if sc == scopeGlobal {
- // 10/11. world-owned spawn/despawn.
+ // 11/12. world-owned spawn/despawn.
if step.SpawnMob != nil {
g.spawnWorldTriggerMob(step.SpawnMob, roomID)
}
@@ -138,7 +140,17 @@ func (g *Game) applyStep(sess *net.Session, p *player.Player, step *behavior.Ste
}
}
- // 8. heal
+ // 8. drop_table — weighted loot into inventory, overflow to ground.
+ if len(step.DropTable) > 0 {
+ for _, d := range behavior.ResolveDropList(g.DataDir, step.DropTable) {
+ if d.ItemID == "" {
+ continue
+ }
+ g.giveTriggerDrop(sess, p, &d)
+ }
+ }
+
+ // 9. heal
if step.Heal > 0 {
p.HP += step.Heal
if maxHP := p.MaxHP(); p.HP > maxHP {
@@ -150,7 +162,7 @@ func (g *Game) applyStep(sess *net.Session, p *player.Player, step *behavior.Ste
}
}
- // 9. credits (signed; negative is gated).
+ // 10. credits (signed; negative is gated).
if step.Credits != 0 {
if step.Credits < 0 {
if p.Credits >= -step.Credits {
@@ -165,7 +177,7 @@ func (g *Game) applyStep(sess *net.Session, p *player.Player, step *behavior.Ste
}
}
- // 10/11. spawn_mob / despawn_mob (player-owned / owner-filtered).
+ // 11/12. spawn_mob / despawn_mob (player-owned / owner-filtered).
if step.SpawnMob != nil {
g.spawnTriggerMob(sess, p, step.SpawnMob, roomID)
}
@@ -173,12 +185,12 @@ func (g *Game) applyStep(sess *net.Session, p *player.Player, step *behavior.Ste
g.despawnTriggerMobs(step.DespawnMob, p.Name)
}
- // 12. teleport
+ // 13. teleport
if step.Teleport > 0 && sess != nil {
g.teleportPlayer(sess, p, step.Teleport)
}
- // 13. aps_node — dynamic room-id flag, kept as a dedicated effect.
+ // 14. 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)
@@ -200,6 +212,45 @@ func (g *Game) writePlayerMessage(sess *net.Session, msg string, playerName stri
sess.WriteLine(rendered)
}
+// giveTriggerDrop awards a resolved DropEntry to a player. Item goes to the
+// first free inventory slot; if inventory is full it falls to the ground
+// (reserved for the player). "credits" pseudo-item adds credits directly.
+func (g *Game) giveTriggerDrop(sess *net.Session, p *player.Player, drop *behavior.DropEntry) {
+ if p == nil {
+ return
+ }
+ qty := drop.Quantity
+ if qty <= 0 {
+ qty = 1
+ }
+
+ if drop.ItemID == "credits" {
+ p.Credits += qty
+ if sess != nil {
+ sess.WriteLine(fmt.Sprintf("You receive %d credits.", qty))
+ }
+ g.AccountStore.SaveCharacter(p)
+ return
+ }
+
+ name := drop.ItemID
+ lootDef, _ := g.ItemStore.Load(drop.ItemID)
+ if lootDef != nil && lootDef.Name != "" {
+ name = lootDef.Name
+ }
+
+ freeSlot := p.FirstFreeSlot()
+ if freeSlot == -1 {
+ g.World.AddReservedItem(p.RoomID, drop.ItemID, qty, p.Name)
+ sess.WriteLine(fmt.Sprintf("You receive %s. It falls to the ground.", g.itemColorize(sess, lootDef, name)))
+ return
+ }
+
+ p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: drop.ItemID, Quantity: qty})
+ sess.WriteLine(fmt.Sprintf("You receive %s.", g.itemColorize(sess, lootDef, name)))
+ g.AccountStore.SaveCharacter(p)
+}
+
// 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) {
diff --git a/internal/game/act_search.go b/internal/game/act_search.go
index 83ea534..430b265 100644
--- a/internal/game/act_search.go
+++ b/internal/game/act_search.go
@@ -1,116 +1,17 @@
package game
import (
- "fmt"
-
- "thehouseoficarus/internal/behavior"
- "thehouseoficarus/internal/engine"
"thehouseoficarus/internal/net"
"thehouseoficarus/internal/player"
)
-func (g *Game) startSearch(sess *net.Session, p *player.Player, itemID string, slotIdx int) {
+func (g *Game) doSearchItem(sess *net.Session, p *player.Player, itemID string) {
def, err := g.ItemStore.Load(itemID)
- if err != nil || def.SearchTable == "" {
+ if err != nil || len(def.Search) == 0 {
sess.WriteLine("You can't search that.")
return
}
-
- ticks := def.SearchTicks
- if ticks <= 0 {
- ticks = 3
- }
-
- p.Action = &behavior.Action{
- Type: "search",
- TargetID: itemID,
- TargetName: def.Name,
- WaitLeft: engine.ToTicks(ticks),
- Data: &behavior.SearchData{
- ItemID: itemID,
- SlotIdx: slotIdx,
- Started: false,
- },
- }
-
- g.broadcastAction(sess, "%s searches a %s.", p.Name, def.Name)
-}
-
-func (g *Game) advanceSearch(sess *net.Session, p *player.Player) {
- d, ok := p.Action.Data.(*behavior.SearchData)
- if !ok || d == nil {
- sess.WriteLine("Something went wrong.")
- g.cancelAction(p)
- return
- }
-
- def, err := g.ItemStore.Load(d.ItemID)
- if err != nil {
- sess.WriteLine("Something went wrong.")
- g.cancelAction(p)
- return
- }
-
- if !d.Started {
- msg := def.SearchMessage
- if msg == "" {
- msg = fmt.Sprintf("digging through the %s", def.Name)
- }
- sess.WriteLine(fmt.Sprintf("You begin %s.", msg))
- d.Started = true
- return
- }
-
- slot := p.InvSlot(d.SlotIdx)
- if slot == nil || slot.ItemID != d.ItemID || slot.Quantity <= 0 {
- sess.WriteLine("The item is gone.")
- g.cancelAction(p)
- return
- }
-
- if slot.Quantity > 1 {
- slot.Quantity--
- } else {
- p.SetInvSlot(d.SlotIdx, nil)
- }
-
- dt, err := behavior.LoadDropTable(g.DataDir, def.SearchTable)
- if err == nil && len(dt.Drops) > 0 {
- drop := behavior.ResolveDrop(g.DataDir, dt.Drops)
- if drop != nil && drop.ItemID != "" {
- g.giveSearchLoot(sess, p, drop)
- }
- }
-
- g.AccountStore.SaveCharacter(p)
- g.cancelAction(p)
-}
-
-func (g *Game) giveSearchLoot(sess *net.Session, p *player.Player, drop *behavior.DropEntry) {
- qty := drop.Quantity
- if qty <= 0 {
- qty = 1
- }
-
- name := drop.ItemID
- lootDef, _ := g.ItemStore.Load(drop.ItemID)
- if lootDef != nil {
- name = lootDef.Name
- }
-
- if drop.ItemID == "credits" {
- p.Credits += qty
- sess.WriteLine(g.colorize(sess, "credits_pickup", fmt.Sprintf("You find %d credits.", qty)))
- return
- }
-
- freeSlot := p.FirstFreeSlot()
- if freeSlot == -1 {
- g.World.AddGroundItem(p.RoomID, drop.ItemID, qty)
- sess.WriteLine(fmt.Sprintf("You find %s. It falls to the ground.", g.itemColorize(sess, lootDef, name)))
- return
+ if !g.runTrigger(sess, p, def.Search, p.RoomID, p.HasItem, false) {
+ sess.WriteLine("You can't search that.")
}
-
- p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: drop.ItemID, Quantity: qty})
- sess.WriteLine(fmt.Sprintf("You find %s.", g.itemColorize(sess, lootDef, name)))
}
diff --git a/internal/game/act_state.go b/internal/game/act_state.go
index 73ecd57..335ecfb 100644
--- a/internal/game/act_state.go
+++ b/internal/game/act_state.go
@@ -49,8 +49,6 @@ func (g *Game) playerActionDisplay(p *player.Player) string {
return "trying to start a fire"
case behavior.TypeStoke:
return "tending to a fire"
- case behavior.TypeSearch:
- return "digging through a " + a.TargetName
case behavior.TypeIdentify:
return "identifying scrap at " + a.TargetName
case behavior.TypePlant:
diff --git a/internal/game/cmd_search.go b/internal/game/cmd_search.go
index 4122e0a..2c5017e 100644
--- a/internal/game/cmd_search.go
+++ b/internal/game/cmd_search.go
@@ -40,6 +40,5 @@ func (g *Game) doSearch(sess *net.Session, input string) {
}
itemID := matches[0].ID
- slotIdx := matches[0].Slot
- g.startSearch(sess, p, itemID, slotIdx)
+ g.doSearchItem(sess, p, itemID)
}
diff --git a/internal/item/item.go b/internal/item/item.go
index 0a6ef99..227eac5 100644
--- a/internal/item/item.go
+++ b/internal/item/item.go
@@ -92,9 +92,7 @@ type ItemDef struct {
MaxQuality int `yaml:"max_quality"`
Craft CraftList `yaml:"craft,omitempty"`
- SearchTable string `yaml:"search_table"`
- SearchTicks float64 `yaml:"search_ticks"`
- SearchMessage string `yaml:"search_message"`
+ Search []behavior.Trigger `yaml:"search,omitempty"`
HealValue int `yaml:"heal_value"`
EatMessage string `yaml:"eat_message"`
diff --git a/internal/validate/checks.go b/internal/validate/checks.go
index 1a0a233..f89e52d 100644
--- a/internal/validate/checks.go
+++ b/internal/validate/checks.go
@@ -69,7 +69,7 @@ func validateRooms(s Source) []Issue {
}
issues = append(issues, validateTriggerBlock(
fmt.Sprintf("Room %d: exit %q on_traverse", id, dir),
- exit.OnTraverse, false, itemIDs, roomIndex, mobIDs)...)
+ exit.OnTraverse, false, itemIDs, roomIndex, mobIDs, nil)...)
}
for _, rm := range room.Mobs {
@@ -221,7 +221,7 @@ func validateLocalObject(roomID int, robj world.RoomObject, itemIDs map[string]b
}
if len(def.OnLook) > 0 {
- issues = append(issues, validateTriggerBlock(prefix+": on_look", def.OnLook, false, itemIDs, roomIndex, mobIDs)...)
+ issues = append(issues, validateTriggerBlock(prefix+": on_look", def.OnLook, false, itemIDs, roomIndex, mobIDs, nil)...)
}
return issues
@@ -486,7 +486,7 @@ func validateMobs(s Source) []Issue {
}
issues = append(issues, validateTriggerBlock(
- fmt.Sprintf("Mob %q: on_kill", id), def.OnKill, false, itemIDs, roomIndex, mobIDs)...)
+ fmt.Sprintf("Mob %q: on_kill", id), def.OnKill, false, itemIDs, roomIndex, mobIDs, dropIDs)...)
}
return issues
@@ -591,7 +591,7 @@ func validateObjects(s Source) []Issue {
}
issues = append(issues, validateTriggerBlock(
- fmt.Sprintf("Object %q: on_use", id), obj.OnUse, false, itemIDs, roomIndex, mobIDs)...)
+ fmt.Sprintf("Object %q: on_use", id), obj.OnUse, false, itemIDs, roomIndex, mobIDs, dropIDs)...)
if obj.Gather != nil {
issues = append(issues, validateGather(fmt.Sprintf("Object %q: gather", id), obj.Gather, itemIDs, dropIDs)...)
@@ -602,7 +602,7 @@ func validateObjects(s Source) []Issue {
}
if len(obj.OnLook) > 0 {
- issues = append(issues, validateTriggerBlock(fmt.Sprintf("Object %q: on_look", id), obj.OnLook, false, itemIDs, roomIndex, mobIDs)...)
+ issues = append(issues, validateTriggerBlock(fmt.Sprintf("Object %q: on_look", id), obj.OnLook, false, itemIDs, roomIndex, mobIDs, dropIDs)...)
}
}
@@ -613,6 +613,8 @@ func validateItems(s Source) []Issue {
var issues []Issue
itemIDs := s.Items.IDSet()
dropIDs := dropTableIDSet(s.DataDir)
+ roomIndex := s.World.RoomIndex()
+ mobIDs := s.Mobs.AllDefIDs()
for id := range itemIDs {
def, err := s.Items.Load(id)
@@ -632,13 +634,9 @@ func validateItems(s Source) []Issue {
})
}
- if def.SearchTable != "" && !dropIDs[def.SearchTable] {
- issues = append(issues, Issue{
- Level: "ERROR",
- Type: "reference",
- Message: fmt.Sprintf("Item %q: search_table %q does not exist",
- id, def.SearchTable),
- })
+ if len(def.Search) > 0 {
+ issues = append(issues, validateTriggerBlock(
+ fmt.Sprintf("Item %q: search", id), def.Search, false, itemIDs, roomIndex, mobIDs, dropIDs)...)
}
if def.FarmProduct != "" && !itemIDs[def.FarmProduct] {
@@ -803,11 +801,11 @@ func validateRoomTriggers(s Source) []Issue {
}
if len(room.OnEnter) > 0 {
issues = append(issues, validateTriggerBlock(
- fmt.Sprintf("Room %d: on_enter", id), room.OnEnter, false, itemIDs, roomIndex, mobIDs)...)
+ fmt.Sprintf("Room %d: on_enter", id), room.OnEnter, false, itemIDs, roomIndex, mobIDs, nil)...)
}
if len(room.OnExit) > 0 {
issues = append(issues, validateTriggerBlock(
- fmt.Sprintf("Room %d: on_exit", id), room.OnExit, false, itemIDs, roomIndex, mobIDs)...)
+ fmt.Sprintf("Room %d: on_exit", id), room.OnExit, false, itemIDs, roomIndex, mobIDs, nil)...)
}
}
@@ -1166,10 +1164,9 @@ func validateExitReciprocity(s Source) []Issue {
return issues
}
-// 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 {
+// validateStep validates a single Step's effects. mobIDs and dropIDs may be nil
+// when the caller doesn't track those entities; validation is skipped for nil sets.
+func validateStep(prefix string, step *behavior.Step, itemIDs map[string]bool, roomIndex map[int]bool, mobIDs map[string]bool, dropIDs map[string]bool) []Issue {
var issues []Issue
if step == nil {
return issues
@@ -1241,6 +1238,41 @@ func validateStep(prefix string, step *behavior.Step, itemIDs map[string]bool, r
})
}
+ for i, e := range step.DropTable {
+ if e.ItemID == "" && e.Table == "" {
+ issues = append(issues, Issue{
+ Level: "WARN",
+ Type: "semantic",
+ Message: fmt.Sprintf("%s: drop_table[%d] has no item_id or table",
+ prefix, i),
+ })
+ }
+ if e.ItemID != "" && e.ItemID != "credits" && !itemIDs[e.ItemID] {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("%s: drop_table item %q does not exist",
+ prefix, e.ItemID),
+ })
+ }
+ if e.Table != "" && dropIDs != nil && !dropIDs[e.Table] {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("%s: drop_table table %q does not exist",
+ prefix, e.Table),
+ })
+ }
+ if e.Weight <= 0 {
+ issues = append(issues, Issue{
+ Level: "WARN",
+ Type: "semantic",
+ Message: fmt.Sprintf("%s: drop_table[%d] weight is %d (never drops)",
+ prefix, i, e.Weight),
+ })
+ }
+ }
+
if step.Condition != nil {
issues = append(issues, validateCondition(prefix, step.Condition, roomIndex)...)
}
@@ -1315,7 +1347,7 @@ func checkPlayerOnlyInCondition(prefix string, c *behavior.Condition) []Issue {
// 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 {
+func validateTriggerBlock(prefix string, block []behavior.Trigger, isFlag bool, itemIDs map[string]bool, roomIndex map[int]bool, mobIDs map[string]bool, dropIDs map[string]bool) []Issue {
var issues []Issue
for i := range block {
t := &block[i]
@@ -1369,7 +1401,7 @@ func validateTriggerBlock(prefix string, block []behavior.Trigger, isFlag bool,
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)...)
+ issues = append(issues, validateStep(fmt.Sprintf("%s: step[%d]", entryPrefix, si), &t.Steps[si], itemIDs, roomIndex, mobIDs, dropIDs)...)
if isFlag && t.OnGlobalFlag != "" && t.Steps[si].Condition != nil {
issues = append(issues, checkPlayerOnlyInCondition(fmt.Sprintf("%s: step[%d]", entryPrefix, si), t.Steps[si].Condition)...)
}
@@ -1384,5 +1416,5 @@ func validateTalkStep(prefix string, step *behavior.Step, itemIDs map[string]boo
if step == nil {
return nil
}
- return validateStep(prefix, step, itemIDs, roomIndex, nil)
+ return validateStep(prefix, step, itemIDs, roomIndex, nil, nil)
}