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 +++++++++--------------
internal/behavior/behavior.go | 92 ++++++++++--
internal/behavior/types.go | 2 +-
internal/game/act_effects.go | 268 ++++++++++++++++++++++++++++++++++
internal/game/act_room.go | 92 +++---------
internal/game/act_talk.go | 76 ----------
internal/game/act_use_interaction.go | 8 +-
internal/game/cmd_move.go | 35 ++++-
internal/game/cmd_room_insert.go | 6 +-
internal/game/cmd_room_remove.go | 6 +-
internal/game/cmd_use.go | 6 +-
internal/game/cmd_verbs.go | 4 +-
internal/game/combat_mob.go | 5 +
internal/game/look_target.go | 4 +-
internal/game/sys_triggers.go | 124 +++-------------
internal/object/object.go | 33 ++---
internal/player/player.go | 29 ++--
internal/validate/checks.go | 239 ++++++++++++++++--------------
internal/validate/local_test.go | 8 +-
internal/world/grid_test.go | 4 +-
internal/world/insert_remove.go | 3 +-
internal/world/mob.go | 24 ++-
internal/world/room.go | 71 +++------
internal/world/room_migrate.go | 15 ++
internal/world/room_migrate_test.go | 26 +++-
internal/world/trigger.go | 65 ++-------
internal/world/trigger_store.go | 9 +-
30 files changed, 933 insertions(+), 671 deletions(-)
create mode 100644 internal/game/act_effects.go
(limited to 'internal')
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 += '
';
}
h += '
';
@@ -906,8 +877,6 @@ function addSection() {
if (sec.path) {
if (sec.isArray) {
objectData[sec.path] = [];
- } else if (sec.id === 'on_look') {
- objectData[sec.path] = {};
} else if (sec.id === 'gather') {
objectData[sec.path] = {skill:'', tools:[], success:{base:0, per_level:0, cap:1}, drops:[], gather_message:'', fail_message:'', respawn_timer:0, respawn_broadcast:'', deplete_timer:0, nest_chance:0};
} else if (sec.id === 'safespot') {
@@ -1009,20 +978,22 @@ function renderCurrent() {
renderObjectEditor(objectID, objectData);
}
-function showUseInterField(idx, key) {
- window._useInterVis = window._useInterVis || {};
- if (!window._useInterVis[idx]) window._useInterVis[idx] = {};
- window._useInterVis[idx][key] = true;
+function showInterField(secId, idx, key) {
+ window._interVis = window._interVis || {};
+ if (!window._interVis[secId]) window._interVis[secId] = {};
+ if (!window._interVis[secId][idx]) window._interVis[secId][idx] = {};
+ window._interVis[secId][idx][key] = true;
renderCurrent();
}
-function hideUseInterField(idx, key) {
- window._useInterVis = window._useInterVis || {};
- if (!window._useInterVis[idx]) window._useInterVis[idx] = {};
- window._useInterVis[idx][key] = false;
+function hideInterField(secId, idx, key) {
+ window._interVis = window._interVis || {};
+ if (!window._interVis[secId]) window._interVis[secId] = {};
+ if (!window._interVis[secId][idx]) window._interVis[secId][idx] = {};
+ window._interVis[secId][idx][key] = false;
ced_syncDOMToData(objectData);
- if (objectData.use_interactions && objectData.use_interactions[idx]) {
- objectData.use_interactions[idx][key] = '';
+ if (objectData[secId] && objectData[secId][idx]) {
+ objectData[secId][idx][key] = '';
}
renderObjectEditor(objectID, objectData);
}
@@ -1061,20 +1032,12 @@ function hideGuardMob() {
renderObjectEditor(objectID, objectData);
}
-function toggleUseInterHelp() {
- if (window._useInterHelpCollapsed === undefined) {
- window._useInterHelpCollapsed = false;
- } else {
- window._useInterHelpCollapsed = !window._useInterHelpCollapsed;
- }
- renderCurrent();
-}
-
-function toggleOnLookHelp() {
- if (window._onLookHelpCollapsed === undefined) {
- window._onLookHelpCollapsed = false;
+function toggleInterHelp(secId) {
+ var key = secId + '_helpCollapsed';
+ if (window[key] === undefined) {
+ window[key] = false;
} else {
- window._onLookHelpCollapsed = !window._onLookHelpCollapsed;
+ window[key] = !window[key];
}
renderCurrent();
}
diff --git a/internal/behavior/behavior.go b/internal/behavior/behavior.go
index d31a0a5..69b91cd 100644
--- a/internal/behavior/behavior.go
+++ b/internal/behavior/behavior.go
@@ -1,5 +1,7 @@
package behavior
+import "gopkg.in/yaml.v3"
+
type GatherConfig struct {
Skill string `yaml:"skill"`
Tools []string `yaml:"tools"`
@@ -43,16 +45,88 @@ type TalkOption struct {
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"`
- SetPlayerFlags map[string]any `yaml:"set_player_flags"`
- GiveItem string `yaml:"give_item"`
- TakeItem string `yaml:"take_item"`
- Teleport int `yaml:"teleport"`
- Heal int `yaml:"heal"`
- Credits int `yaml:"credits"`
- ReputationCost int `yaml:"reputation_cost"`
- ApsNode bool `yaml:"aps_node"`
+ 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"`
+}
+
+// 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.
+//
+// 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"`
+ Message string `yaml:"message,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 (message,
+// 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"`
+ Message string `yaml:"message,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 || s.Message != "" || 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. It accepts either a bare string (the mob id) or a full map at
+// unmarshal time.
+type SpawnMobConfig struct {
+ ID string `yaml:"id"`
+ OwnerOnly bool `yaml:"owner_only"`
+ DespawnOnLeave bool `yaml:"despawn_on_leave"`
+ DespawnRooms []int `yaml:"despawn_rooms"`
+ DespawnTicks float64 `yaml:"despawn_ticks"`
+}
+
+func (s *SpawnMobConfig) UnmarshalYAML(value *yaml.Node) error {
+ if value.Kind == yaml.ScalarNode {
+ var id string
+ if err := value.Decode(&id); err != nil {
+ return err
+ }
+ s.ID = id
+ return nil
+ }
+ type raw SpawnMobConfig
+ return value.Decode((*raw)(s))
}
// ShopConfig is a root-level mob property (mob YAML `shop:`). Buy prices are
diff --git a/internal/behavior/types.go b/internal/behavior/types.go
index 18d6363..521867a 100644
--- a/internal/behavior/types.go
+++ b/internal/behavior/types.go
@@ -119,7 +119,7 @@ type TalkData struct {
type UseInteractionData struct {
Message string
- Action *NodeAction
+ Action *StepAction
ObjName string
}
diff --git a/internal/game/act_effects.go b/internal/game/act_effects.go
new file mode 100644
index 0000000..82d5022
--- /dev/null
+++ b/internal/game/act_effects.go
@@ -0,0 +1,268 @@
+package game
+
+import (
+ "fmt"
+ "strconv"
+
+ "thehouseoficarus/internal/behavior"
+ "thehouseoficarus/internal/color"
+ "thehouseoficarus/internal/net"
+ "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:
+//
+// - 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.
+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.
+//
+// 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.
+//
+// The fixed effect order (matching the original executors, normalized):
+// 1. message (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, applyInteraction's
+// Interaction.Message, completeMove's on_traverse Message, and
+// advanceUseInteraction's d.Message) 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) {
+ if step == nil {
+ return
+ }
+
+ var playerName string
+ if p != nil {
+ playerName = p.Name
+ }
+
+ // 1. message — direct to the triggering session. Skipped for scopeGlobal
+ // (no per-player session is the target).
+ if sc != scopeGlobal {
+ g.writePlayerMessage(sess, step.Message, 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 {
+ if step.Broadcast != "" {
+ raw := expandTemplate(step.Broadcast, playerName, flagValue)
+ for _, other := range g.Hub.PlayersInRoom(roomID) {
+ otherMode := g.colorMode(other)
+ rendered := color.ExpandTagsDefault(otherMode, broadcastSpec, raw)
+ other.WriteLine(g.colorize(other, "broadcast", rendered))
+ }
+ }
+ if step.BroadcastGlobal != "" {
+ raw := expandTemplate(step.BroadcastGlobal, playerName, flagValue)
+ for _, other := range g.Hub.AllSessions() {
+ if other.Player == nil {
+ continue
+ }
+ otherMode := g.colorMode(other)
+ rendered := color.ExpandTagsDefault(otherMode, broadcastSpec, raw)
+ other.WriteLine(g.colorize(other, "broadcast", rendered))
+ }
+ }
+ }
+
+ // 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.
+ 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 {
+ if step.SpawnMob != nil {
+ g.spawnWorldTriggerMob(step.SpawnMob, roomID)
+ }
+ if step.DespawnMob != "" {
+ g.despawnTriggerMobs(step.DespawnMob, "")
+ }
+ 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).
+ if len(step.SetGlobalFlags) > 0 || len(step.SetPlayerFlags) > 0 {
+ g.applyFlagMutations(p, step.SetGlobalFlags, step.SetPlayerFlags)
+ g.AccountStore.SaveCharacter(p)
+ }
+
+ // 6. take_item
+ if step.TakeItem != "" {
+ if p.HasItem(step.TakeItem) {
+ p.RemoveItem(step.TakeItem, 1)
+ g.AccountStore.SaveCharacter(p)
+ }
+ }
+
+ // 7. give_item
+ if step.GiveItem != "" {
+ slot := p.FirstFreeSlot()
+ if slot == -1 {
+ if sess != nil {
+ sess.WriteLine("Your inventory is too full to receive that.")
+ }
+ } else {
+ p.SetInvSlot(slot, &player.InventorySlot{ItemID: step.GiveItem, Quantity: 1})
+ g.AccountStore.SaveCharacter(p)
+ }
+ }
+
+ // 8. heal
+ if step.Heal > 0 {
+ p.HP += step.Heal
+ if maxHP := p.MaxHP(); p.HP > maxHP {
+ p.HP = maxHP
+ }
+ g.AccountStore.SaveCharacter(p)
+ if sess != nil {
+ sess.WriteLine(fmt.Sprintf("You regain %d hitpoints.", step.Heal))
+ }
+ }
+
+ // 9. credits (signed; negative is gated)
+ if step.Credits != 0 {
+ if step.Credits < 0 {
+ if p.Credits >= -step.Credits {
+ p.Credits += step.Credits
+ g.AccountStore.SaveCharacter(p)
+ } else if sess != nil {
+ sess.WriteLine(fmt.Sprintf("You don't have enough credits. (Need %d, have %d)", -step.Credits, p.Credits))
+ }
+ } else {
+ p.Credits += step.Credits
+ g.AccountStore.SaveCharacter(p)
+ }
+ }
+
+ // 10/11. spawn_mob / despawn_mob — sequence-only effects. Inline callers
+ // (scopePlayer) leave these empty; nothing fires.
+ if sc == scopePlayerSeq && step.SpawnMob != nil {
+ g.spawnTriggerMob(sess, p, step.SpawnMob, roomID)
+ }
+ if sc == scopePlayerSeq && step.DespawnMob != "" {
+ g.despawnTriggerMobs(step.DespawnMob, p.Name)
+ }
+
+ // 12. teleport
+ if step.Teleport > 0 && sess != nil {
+ 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.
+ 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 {
+ return
+ }
+ g.applyStepAction(sess, p, &behavior.StepAction{NodeAction: *na}, p.RoomID, scopePlayer, nil)
+}
+
+// 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). The
+// interaction's Message is written to the player first (if non-empty), then
+// the Action fires via applyStepAction.
+//
+// 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
+ }
+ for _, it := range list {
+ if it.Item != "" && itemMatch != nil && !itemMatch(it.Item) {
+ continue
+ }
+ if it.Condition != nil && !g.checkCondition(sess, it.Condition) {
+ continue
+ }
+ g.writePlayerMessage(sess, it.Message, p.Name, nil)
+ g.applyStepAction(sess, p, it.Action, roomID, sc, nil)
+ return true
+ }
+ return false
+}
\ No newline at end of file
diff --git a/internal/game/act_room.go b/internal/game/act_room.go
index da74d6e..2b08bfa 100644
--- a/internal/game/act_room.go
+++ b/internal/game/act_room.go
@@ -4,10 +4,9 @@ import (
"fmt"
"strings"
- "thehouseoficarus/internal/color"
+ "thehouseoficarus/internal/behavior"
"thehouseoficarus/internal/net"
"thehouseoficarus/internal/player"
- "thehouseoficarus/internal/world"
)
// enterSeq is an in-flight on_enter stepped sequence for a single player. It is
@@ -16,13 +15,13 @@ type enterSeq struct {
sess *net.Session
playerName string
roomID int
- steps []world.EnterStep
+ 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"
-// (no delay, no flag mutation) the messages are printed synchronously.
+// (any non-zero effect field) the messages are printed synchronously.
// Otherwise the surviving steps become a scheduled enter sequence driven
// by EnterSeqTick.
func (g *Game) runEnterSteps(sess *net.Session, roomID int) {
@@ -35,7 +34,7 @@ func (g *Game) runEnterSteps(sess *net.Session, roomID int) {
return
}
- var steps []world.EnterStep
+ var steps []behavior.StepAction
sequenced := false
for _, step := range room.OnEnter {
if step.Condition != nil && !g.checkCondition(sess, step.Condition) {
@@ -51,6 +50,7 @@ func (g *Game) runEnterSteps(sess *net.Session, roomID int) {
}
if !sequenced {
+ // Pure message-only steps — print synchronously, no side effects.
for _, step := range steps {
if step.Message != "" {
sess.WriteLine(step.Message)
@@ -65,7 +65,7 @@ func (g *Game) runEnterSteps(sess *net.Session, roomID int) {
// 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 []world.EnterStep) {
+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
@@ -98,7 +98,7 @@ func (g *Game) EnterSeqTick() {
continue
}
- var jobs []world.EnterStep
+ var jobs []behavior.StepAction
done := false
g.enterMu.Lock()
@@ -123,8 +123,8 @@ func (g *Game) EnterSeqTick() {
}
g.enterMu.Unlock()
- for _, step := range jobs {
- g.fireEnterStep(seq, step)
+ for i := range jobs {
+ g.fireEnterStep(seq, &jobs[i])
}
if done {
g.completeEnterSeq(seq)
@@ -132,71 +132,16 @@ func (g *Game) EnterSeqTick() {
}
}
-func (g *Game) fireEnterStep(seq *enterSeq, step world.EnterStep) {
+// 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
}
- seqSpec := g.resolveColor(seq.sess, "sequence")
- broadcastSpec := g.resolveColor(seq.sess, "broadcast")
- mode := g.colorMode(seq.sess)
- if step.Message != "" {
- msg := expandTemplate(step.Message, p.Name, nil)
- msg = color.ExpandTagsDefault(mode, seqSpec, msg)
- seq.sess.WriteLine(msg)
- }
- if step.Broadcast != "" && g.Hub != nil {
- msg := expandTemplate(step.Broadcast, p.Name, nil)
- msg = color.ExpandTagsDefault(mode, broadcastSpec, msg)
- for _, other := range g.Hub.PlayersInRoom(seq.roomID) {
- other.WriteLine(g.colorize(other, "broadcast", msg))
- }
- }
- if step.BroadcastGlobal != "" && g.Hub != nil {
- msg := expandTemplate(step.BroadcastGlobal, p.Name, nil)
- msg = color.ExpandTagsDefault(mode, broadcastSpec, msg)
- for _, other := range g.Hub.AllSessions() {
- if other.Player != nil {
- other.WriteLine(g.colorize(other, "broadcast", msg))
- }
- }
- }
- if len(step.SetGlobalFlags) > 0 || len(step.SetPlayerFlags) > 0 {
- g.applyFlagMutations(p, step.SetGlobalFlags, step.SetPlayerFlags)
- g.AccountStore.SaveCharacter(p)
- }
- if step.SpawnMob != nil {
- g.spawnTriggerMob(seq.sess, p, step.SpawnMob, seq.roomID)
- }
- if step.DespawnMob != "" {
- g.despawnTriggerMobs(step.DespawnMob, p.Name)
- }
- if step.GiveItem != "" {
- slot := p.FirstFreeSlot()
- if slot == -1 {
- seq.sess.WriteLine("Your inventory is too full to receive that.")
- } else {
- p.SetInvSlot(slot, &player.InventorySlot{ItemID: step.GiveItem, Quantity: 1})
- g.AccountStore.SaveCharacter(p)
- }
- }
- if step.TakeItem != "" {
- if p.HasItem(step.TakeItem) {
- p.RemoveItem(step.TakeItem, 1)
- g.AccountStore.SaveCharacter(p)
- }
- }
- if step.Heal > 0 {
- p.HP += step.Heal
- if maxHP := p.MaxHP(); p.HP > maxHP {
- p.HP = maxHP
- }
- g.AccountStore.SaveCharacter(p)
- seq.sess.WriteLine(fmt.Sprintf("You regain %d hitpoints.", step.Heal))
- }
- if step.Teleport > 0 {
- g.teleportPlayer(seq.sess, p, step.Teleport)
- }
+ g.applyStepAction(seq.sess, p, step, seq.roomID, scopePlayerSeq, nil)
}
func (g *Game) completeEnterSeq(seq *enterSeq) {
@@ -229,8 +174,11 @@ func (g *Game) isCharLive(name string, sess *net.Session) bool {
return g.loggedInChars[name] == sess
}
-// applyFlagMutations sets global and player flags, shared by on_enter steps and
-// exit traversal.
+// 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).
func (g *Game) applyFlagMutations(p *player.Player, setGlobalFlags, setPlayerFlags map[string]any) {
g.GlobalFlags.SetAll(setGlobalFlags)
if len(setPlayerFlags) > 0 {
diff --git a/internal/game/act_talk.go b/internal/game/act_talk.go
index ca6054c..e08fbd2 100644
--- a/internal/game/act_talk.go
+++ b/internal/game/act_talk.go
@@ -2,7 +2,6 @@ package game
import (
"fmt"
- "strconv"
"strings"
"thehouseoficarus/internal/behavior"
@@ -331,78 +330,3 @@ func (g *Game) advanceTalk(sess *net.Session, p *player.Player) {
return
}
}
-
-func (g *Game) applyNodeAction(sess *net.Session, na *behavior.NodeAction) {
- p := sess.Player
- if p == nil {
- return
- }
-
- if na.ReputationCost > 0 {
- rep := getPlayerFlagInt(p, "assassin_reputation")
- if rep < na.ReputationCost {
- sess.WriteLine(fmt.Sprintf("You don't have enough Reputation. (Need %d, have %d)", na.ReputationCost, rep))
- return
- }
- g.setPlayerFlag(p, "assassin_reputation", rep-na.ReputationCost)
- g.AccountStore.SaveCharacter(p)
- }
-
- for k, v := range na.SetGlobalFlags {
- g.GlobalFlags.Set(k, v)
- }
- for k, v := range na.SetPlayerFlags {
- g.setPlayerFlag(p, k, v)
- }
- if na.TakeItem != "" {
- if p.HasItem(na.TakeItem) {
- p.RemoveItem(na.TakeItem, 1)
- }
- }
- if na.GiveItem != "" {
- slot := p.FirstFreeSlot()
- if slot == -1 {
- sess.WriteLine("Your inventory is too full to receive that.")
- } else {
- p.SetInvSlot(slot, &player.InventorySlot{ItemID: na.GiveItem, Quantity: 1})
- g.AccountStore.SaveCharacter(p)
- }
- }
- if na.Teleport > 0 {
- p.RoomID = na.Teleport
- p.Stats.RecordRoomVisit(na.Teleport)
- g.AccountStore.SaveCharacter(p)
- g.World.SeedGroundItems(p.RoomID)
- g.seedRoomMobs(p.RoomID)
- g.seedRoomObjects(p.RoomID)
- if g.Hub != nil {
- g.Hub.EnterRoom(sess, p.RoomID)
- }
- g.doLook(sess)
- g.runEnterSteps(sess, p.RoomID)
- }
- if na.Heal > 0 {
- p.HP += na.Heal
- if maxHP := p.MaxHP(); p.HP > maxHP {
- p.HP = maxHP
- }
- g.AccountStore.SaveCharacter(p)
- sess.WriteLine(fmt.Sprintf("You regain %d hitpoints.", na.Heal))
- }
- if na.Credits != 0 {
- if na.Credits < 0 {
- if p.Credits >= -na.Credits {
- p.Credits += na.Credits
- g.AccountStore.SaveCharacter(p)
- } else {
- sess.WriteLine(fmt.Sprintf("You don't have enough credits. (Need %d, have %d)", -na.Credits, p.Credits))
- }
- } else {
- p.Credits += na.Credits
- g.AccountStore.SaveCharacter(p)
- }
- }
- if na.ApsNode {
- g.setPlayerFlag(p, "aps_node_"+strconv.Itoa(p.RoomID), true)
- }
-}
diff --git a/internal/game/act_use_interaction.go b/internal/game/act_use_interaction.go
index b96aaa3..9c64b56 100644
--- a/internal/game/act_use_interaction.go
+++ b/internal/game/act_use_interaction.go
@@ -12,12 +12,8 @@ func (g *Game) advanceUseInteraction(sess *net.Session, p *player.Player) {
g.cancelAction(p)
return
}
- if d.Message != "" {
- sess.WriteLine(d.Message)
- }
- if d.Action != nil {
- g.applyNodeAction(sess, d.Action)
- }
+ g.writePlayerMessage(sess, d.Message, p.Name, nil)
+ g.applyStepAction(sess, p, d.Action, p.RoomID, scopePlayer, nil)
g.broadcastAction(sess, "%s uses the %s.", p.Name, d.ObjName)
g.cancelAction(p)
}
diff --git a/internal/game/cmd_move.go b/internal/game/cmd_move.go
index 2f27eed..d05146a 100644
--- a/internal/game/cmd_move.go
+++ b/internal/game/cmd_move.go
@@ -4,11 +4,13 @@ import (
"fmt"
"strings"
+ "thehouseoficarus/internal/behavior"
"thehouseoficarus/internal/engine"
"thehouseoficarus/internal/item"
"thehouseoficarus/internal/net"
"thehouseoficarus/internal/object"
"thehouseoficarus/internal/player"
+ "thehouseoficarus/internal/world"
)
func (g *Game) doMove(sess *net.Session, dir string, multiplier float64) {
@@ -67,8 +69,11 @@ func (g *Game) doMove(sess *net.Session, dir string, multiplier float64) {
}
}
- p.MovePendingGlobalFlags = exitDef.SetGlobalFlags
- p.MovePendingPlayerFlags = exitDef.SetPlayerFlags
+ // Stash the first on_traverse interaction whose condition passes — it will
+ // 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)
inCombat := g.Combat.Get(p.Name) != nil
@@ -96,6 +101,21 @@ 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 {
+ for i := range exitDef.OnTraverse {
+ it := &exitDef.OnTraverse[i]
+ if it.Condition != nil && !g.checkCondition(sess, it.Condition) {
+ continue
+ }
+ return it
+ }
+ return nil
+}
+
var gracefulItems = map[string]bool{
"graceful_hat": true,
"graceful_torso": true,
@@ -132,8 +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
- pendingGlobalFlags := p.MovePendingGlobalFlags
- pendingPlayerFlags := p.MovePendingPlayerFlags
+ pendingInteraction := p.MovePendingInteraction
p.ClearMoveState()
@@ -163,8 +182,12 @@ func (g *Game) completeMove(sess *net.Session, p *player.Player) {
if p.EnterSeqRoom != 0 && p.EnterSeqRoom == oldRoom {
p.EnterSeqRoom = 0
}
- if len(pendingGlobalFlags) > 0 || len(pendingPlayerFlags) > 0 {
- g.applyFlagMutations(p, pendingGlobalFlags, pendingPlayerFlags)
+ // 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.writePlayerMessage(sess, pendingInteraction.Message, p.Name, nil)
+ g.applyStepAction(sess, p, pendingInteraction.Action, p.RoomID, scopePlayer, nil)
}
g.AccountStore.SaveCharacter(p)
diff --git a/internal/game/cmd_room_insert.go b/internal/game/cmd_room_insert.go
index b248e49..15b05ef 100644
--- a/internal/game/cmd_room_insert.go
+++ b/internal/game/cmd_room_insert.go
@@ -138,8 +138,7 @@ func (g *Game) roomInsert(sess *net.Session, args []string) {
Room: newID,
Condition: exitDef.Condition,
BlockedMessage: exitDef.BlockedMessage,
- SetGlobalFlags: exitDef.SetGlobalFlags,
- SetPlayerFlags: exitDef.SetPlayerFlags,
+ OnTraverse: exitDef.OnTraverse,
Hidden: exitDef.Hidden,
AlwaysBlocked: exitDef.AlwaysBlocked,
}
@@ -158,8 +157,7 @@ func (g *Game) roomInsert(sess *net.Session, args []string) {
Room: newID,
Condition: targetExit.Condition,
BlockedMessage: targetExit.BlockedMessage,
- SetGlobalFlags: targetExit.SetGlobalFlags,
- SetPlayerFlags: targetExit.SetPlayerFlags,
+ OnTraverse: targetExit.OnTraverse,
Hidden: targetExit.Hidden,
AlwaysBlocked: targetExit.AlwaysBlocked,
}
diff --git a/internal/game/cmd_room_remove.go b/internal/game/cmd_room_remove.go
index 28433ff..187ec34 100644
--- a/internal/game/cmd_room_remove.go
+++ b/internal/game/cmd_room_remove.go
@@ -171,8 +171,7 @@ func (g *Game) roomRemove(sess *net.Session, args []string) {
Room: cID,
Condition: aExit.Condition,
BlockedMessage: aExit.BlockedMessage,
- SetGlobalFlags: aExit.SetGlobalFlags,
- SetPlayerFlags: aExit.SetPlayerFlags,
+ OnTraverse: aExit.OnTraverse,
Hidden: aExit.Hidden,
AlwaysBlocked: aExit.AlwaysBlocked,
}
@@ -191,8 +190,7 @@ func (g *Game) roomRemove(sess *net.Session, args []string) {
Room: aID,
Condition: cOppExit.Condition,
BlockedMessage: cOppExit.BlockedMessage,
- SetGlobalFlags: cOppExit.SetGlobalFlags,
- SetPlayerFlags: cOppExit.SetPlayerFlags,
+ OnTraverse: cOppExit.OnTraverse,
Hidden: cOppExit.Hidden,
AlwaysBlocked: cOppExit.AlwaysBlocked,
}
diff --git a/internal/game/cmd_use.go b/internal/game/cmd_use.go
index 22fc169..322e5fc 100644
--- a/internal/game/cmd_use.go
+++ b/internal/game/cmd_use.go
@@ -91,7 +91,7 @@ func (g *Game) useRoomObject(sess *net.Session, p *player.Player, input string)
g.startAction(sess, bt, def.Name)
return true
}
- for _, ui := range def.UseInteractions {
+ for _, ui := range def.OnUse {
if ui.Item != "" {
continue
}
@@ -111,7 +111,7 @@ func (g *Game) useRoomObject(sess *net.Session, p *player.Player, input string)
}
return true
}
- if len(def.UseInteractions) > 0 {
+ if len(def.OnUse) > 0 {
sess.WriteLine(fmt.Sprintf("Use what on the %s?", def.Name))
return true
}
@@ -284,7 +284,7 @@ func (g *Game) doUseItemOnTarget(sess *net.Session, p *player.Player, itemAName,
return
}
- for _, ui := range def.UseInteractions {
+ for _, ui := range def.OnUse {
if ui.Item != itemAID {
continue
}
diff --git a/internal/game/cmd_verbs.go b/internal/game/cmd_verbs.go
index e32910a..6c20004 100644
--- a/internal/game/cmd_verbs.go
+++ b/internal/game/cmd_verbs.go
@@ -75,7 +75,7 @@ func (g *Game) executeVerbs(sess *net.Session, args []string, rawInput string) {
addUnique(§ionObjs, &seen, "talk "+name)
}
- for _, ui := range def.UseInteractions {
+ for _, ui := range def.OnUse {
if ui.Item == "" && (ui.Condition == nil || g.checkCondition(sess, ui.Condition)) {
addUnique(§ionObjs, &seen, "use "+name)
break
@@ -106,7 +106,7 @@ func (g *Game) executeVerbs(sess *net.Session, args []string, rawInput string) {
}
}
- for _, ui := range def.UseInteractions {
+ 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 {
addUnique(§ionObjs, &seen, "use "+itemDef.Name+" on "+name)
diff --git a/internal/game/combat_mob.go b/internal/game/combat_mob.go
index a3fc040..0395049 100644
--- a/internal/game/combat_mob.go
+++ b/internal/game/combat_mob.go
@@ -217,6 +217,11 @@ func (g *Game) endCombat(sess *net.Session, p *player.Player, mob *world.MobInst
g.announceKill(sess, p, mob, isTask)
g.awardKillDrops(sess, p, mob, isTask)
+ // On Kill fires for both combat kills and task-mob completion (draining a
+ // 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.scheduleMobRespawn(mob)
g.writePrompt(sess)
}
diff --git a/internal/game/look_target.go b/internal/game/look_target.go
index 8b0a57c..24a1df5 100644
--- a/internal/game/look_target.go
+++ b/internal/game/look_target.go
@@ -154,8 +154,8 @@ func (g *Game) doLookTarget(sess *net.Session, input string) {
sess.WriteLine(color.ExpandTags(g.colorMode(sess), objDescText))
}
- if def.OnLook != nil {
- g.applyNodeAction(sess, def.OnLook)
+ if len(def.OnLook) > 0 {
+ g.applyInteraction(sess, p, def.OnLook, p.RoomID, false, p.HasItem)
}
if def.Safespot != nil {
diff --git a/internal/game/sys_triggers.go b/internal/game/sys_triggers.go
index b542fdd..2e8b877 100644
--- a/internal/game/sys_triggers.go
+++ b/internal/game/sys_triggers.go
@@ -4,7 +4,7 @@ import (
"fmt"
"strings"
- "thehouseoficarus/internal/color"
+ "thehouseoficarus/internal/behavior"
"thehouseoficarus/internal/engine"
"thehouseoficarus/internal/net"
"thehouseoficarus/internal/player"
@@ -82,7 +82,7 @@ func (g *Game) processPlayerTriggerSequences() {
continue
}
- var jobs []world.TriggerStep
+ var jobs []behavior.StepAction
for len(seq.Steps) > 0 && seq.Wait <= 0 {
step := seq.Steps[0]
seq.Steps = seq.Steps[1:]
@@ -92,9 +92,8 @@ func (g *Game) processPlayerTriggerSequences() {
}
}
- for _, step := range jobs {
- s := step
- g.executeTriggerStep(sess, p, &s, seq.RoomID, seq.FlagValue)
+ for i := range jobs {
+ g.executeTriggerStep(sess, p, &jobs[i], seq.RoomID, seq.FlagValue)
}
if len(seq.Steps) == 0 {
@@ -114,7 +113,7 @@ func (g *Game) processGlobalTriggerSequences() {
continue
}
- var jobs []world.TriggerStep
+ var jobs []behavior.StepAction
for len(seq.Steps) > 0 && seq.Wait <= 0 {
step := seq.Steps[0]
seq.Steps = seq.Steps[1:]
@@ -124,8 +123,8 @@ func (g *Game) processGlobalTriggerSequences() {
}
}
- for _, step := range jobs {
- g.executeGlobalTriggerStep(&step, seq.RoomID, seq.FlagValue)
+ for i := range jobs {
+ g.executeGlobalTriggerStep(&jobs[i], seq.RoomID, seq.FlagValue)
}
if len(seq.Steps) == 0 {
@@ -134,106 +133,21 @@ func (g *Game) processGlobalTriggerSequences() {
}
}
-func (g *Game) executeTriggerStep(sess *net.Session, p *player.Player, step *world.TriggerStep, roomID int, flagValue any) {
- seqSpec := g.resolveColor(sess, "sequence")
- broadcastSpec := g.resolveColor(sess, "broadcast")
- playerMode := g.colorMode(sess)
- if step.Message != "" {
- msg := expandTemplate(step.Message, p.Name, flagValue)
- msg = color.ExpandTagsDefault(playerMode, seqSpec, msg)
- sess.WriteLine(msg)
- }
- if step.Broadcast != "" && g.Hub != nil {
- msg := expandTemplate(step.Broadcast, p.Name, flagValue)
- for _, other := range g.Hub.PlayersInRoom(roomID) {
- otherMode := g.colorMode(other)
- rendered := color.ExpandTagsDefault(otherMode, broadcastSpec, msg)
- other.WriteLine(g.colorize(other, "broadcast", rendered))
- }
- }
- if step.BroadcastGlobal != "" && g.Hub != nil {
- msg := expandTemplate(step.BroadcastGlobal, p.Name, flagValue)
- for _, other := range g.Hub.AllSessions() {
- if other.Player != nil {
- otherMode := g.colorMode(other)
- rendered := color.ExpandTagsDefault(otherMode, broadcastSpec, msg)
- other.WriteLine(g.colorize(other, "broadcast", rendered))
- }
- }
- }
- if len(step.SetGlobalFlags) > 0 {
- g.GlobalFlags.SetAll(step.SetGlobalFlags)
- }
- if len(step.SetPlayerFlags) > 0 {
- for k, v := range step.SetPlayerFlags {
- g.setPlayerFlag(p, k, v)
- }
- g.AccountStore.SaveCharacter(p)
- }
- if step.GiveItem != "" {
- slot := p.FirstFreeSlot()
- if slot == -1 {
- sess.WriteLine("Your inventory is too full to receive that.")
- } else {
- p.SetInvSlot(slot, &player.InventorySlot{ItemID: step.GiveItem, Quantity: 1})
- g.AccountStore.SaveCharacter(p)
- }
- }
- if step.TakeItem != "" {
- if p.HasItem(step.TakeItem) {
- p.RemoveItem(step.TakeItem, 1)
- g.AccountStore.SaveCharacter(p)
- }
- }
- if step.Heal > 0 {
- p.HP += step.Heal
- if maxHP := p.MaxHP(); p.HP > maxHP {
- p.HP = maxHP
- }
- g.AccountStore.SaveCharacter(p)
- sess.WriteLine(fmt.Sprintf("You regain %d hitpoints.", step.Heal))
- }
- if step.SpawnMob != nil {
- g.spawnTriggerMob(sess, p, step.SpawnMob, roomID)
- }
- if step.DespawnMob != "" {
- g.despawnTriggerMobs(step.DespawnMob, p.Name)
- }
- if step.Teleport > 0 {
- g.teleportPlayer(sess, p, step.Teleport)
+// 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)
}
-func (g *Game) executeGlobalTriggerStep(step *world.TriggerStep, roomID int, flagValue any) {
- if step.Broadcast != "" && g.Hub != nil {
- msg := expandTemplate(step.Broadcast, "", flagValue)
- broadcastSpec := g.resolveColor(nil, "broadcast")
- for _, other := range g.Hub.PlayersInRoom(roomID) {
- otherMode := g.colorMode(other)
- rendered := color.ExpandTagsDefault(otherMode, broadcastSpec, msg)
- other.WriteLine(g.colorize(other, "broadcast", rendered))
- }
- }
- if step.BroadcastGlobal != "" && g.Hub != nil {
- msg := expandTemplate(step.BroadcastGlobal, "", flagValue)
- broadcastSpec := g.resolveColor(nil, "broadcast")
- for _, other := range g.Hub.AllSessions() {
- if other.Player != nil {
- otherMode := g.colorMode(other)
- rendered := color.ExpandTagsDefault(otherMode, broadcastSpec, msg)
- other.WriteLine(g.colorize(other, "broadcast", rendered))
- }
- }
- }
- if len(step.SetGlobalFlags) > 0 {
- g.GlobalFlags.SetAll(step.SetGlobalFlags)
- }
- if step.SpawnMob != nil {
- g.spawnWorldTriggerMob(step.SpawnMob, roomID)
- }
- if step.DespawnMob != "" {
- g.despawnTriggerMobs(step.DespawnMob, "")
- }
+// 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 *world.SpawnMobConfig, roomID int) {
diff --git a/internal/object/object.go b/internal/object/object.go
index 47243ad..fb93b98 100644
--- a/internal/object/object.go
+++ b/internal/object/object.go
@@ -2,37 +2,30 @@ package object
import "thehouseoficarus/internal/behavior"
-type UseInteraction struct {
- Item string `yaml:"item_id"`
- Condition *behavior.Condition `yaml:"condition"`
- Message string `yaml:"message"`
- Action *behavior.NodeAction `yaml:"action"`
-}
-
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"`
- UseInteractions []UseInteraction `yaml:"use_interactions"`
- 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.Interaction `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.NodeAction `yaml:"on_look,omitempty"`
+ OnLook []behavior.Interaction `yaml:"on_look,omitempty"`
}
type SafespotConfig struct {
diff --git a/internal/player/player.go b/internal/player/player.go
index fcf274b..f56521e 100644
--- a/internal/player/player.go
+++ b/internal/player/player.go
@@ -180,14 +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:"-"`
- MovePendingGlobalFlags map[string]any `yaml:"-"`
- MovePendingPlayerFlags map[string]any `yaml:"-"`
- VisualTickCurrent int `yaml:"-"`
- AutotriggerMod string `yaml:"-"`
- QueuedTrigger string `yaml:"-"`
+ MoveTarget int `yaml:"-"`
+ MovePendingInteraction *behavior.Interaction `yaml:"-"`
+ VisualTickCurrent int `yaml:"-"`
+ AutotriggerMod string `yaml:"-"`
+ QueuedTrigger string `yaml:"-"`
AttackTimer int `yaml:"-"`
HazardTimer int `yaml:"-"`
PendingDangerDir string `yaml:"-"`
@@ -219,8 +218,7 @@ func (p *Player) ClearMoveState() {
p.MoveTicks = 0
p.MoveDirection = ""
p.MoveTarget = 0
- p.MovePendingGlobalFlags = nil
- p.MovePendingPlayerFlags = nil
+ p.MovePendingInteraction = nil
}
func (p *Player) OptionBool(name string) bool {
@@ -402,6 +400,19 @@ func (p *Player) HasItem(itemID string) bool {
return false
}
+// IsWielding reports whether the given item is currently held in one of the
+// player's weapon-hand equipment slots (main_hand or off_hand). It is the
+// "wielding" predicate used to gate on_kill interactions whose item_id names
+// a specific weapon — two-handed weapons occupy main_hand alone, while dual
+// wield / weapon + shield uses both hands. Armor, ammo, ring, and tool slots
+// do NOT count as "wielding".
+func (p *Player) IsWielding(itemID string) bool {
+ if itemID == "" {
+ return false
+ }
+ return p.Equipment[item.SlotMainHand] == itemID || p.Equipment[item.SlotOffHand] == itemID
+}
+
func (p *Player) CountItem(itemID string) int {
count := 0
for _, slot := range p.Inventory {
diff --git a/internal/validate/checks.go b/internal/validate/checks.go
index 64b1df4..47bbd1a 100644
--- a/internal/validate/checks.go
+++ b/internal/validate/checks.go
@@ -57,16 +57,20 @@ func validateRooms(s Source) []Issue {
})
continue
}
- if exit.Room <= 0 {
- continue
+ if exit.Room > 0 {
+ if _, ok := roomIndex[exit.Room]; !ok {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Room %d: exit %q targets nonexistent room %d",
+ id, dir, exit.Room),
+ })
+ }
}
- if _, ok := roomIndex[exit.Room]; !ok {
- issues = append(issues, Issue{
- Level: "ERROR",
- Type: "reference",
- Message: fmt.Sprintf("Room %d: exit %q targets nonexistent room %d",
- id, dir, exit.Room),
- })
+ 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)...)
}
}
@@ -114,7 +118,7 @@ func validateRooms(s Source) []Issue {
displayName: world.NormalizeObjectName(robj.Local.Name),
effDefID: robj.ID,
})
- issues = append(issues, validateLocalObject(id, robj, itemIDs, roomIndex)...)
+ issues = append(issues, validateLocalObject(id, robj, itemIDs, roomIndex, mobIDs)...)
} else if robj.ID != "" && !objIDs[robj.ID] {
issues = append(issues, Issue{
Level: "ERROR",
@@ -162,7 +166,7 @@ func validateRooms(s Source) []Issue {
// are restricted to the passive subset (name/aliases/color/hidden/
// inroom_description/description/on_look); interactable or stateful behavior
// must be defined as a standalone object file instead.
-func validateLocalObject(roomID int, robj world.RoomObject, itemIDs map[string]bool, roomIndex map[int]bool) []Issue {
+func validateLocalObject(roomID int, robj world.RoomObject, itemIDs map[string]bool, roomIndex map[int]bool, mobIDs map[string]bool) []Issue {
var issues []Issue
def := robj.Local
prefix := fmt.Sprintf("Room %d: local object %q", roomID, robj.ID)
@@ -192,8 +196,8 @@ func validateLocalObject(roomID int, robj world.RoomObject, itemIDs map[string]b
if def.Safespot != nil {
bad = append(bad, "safespot")
}
- if len(def.UseInteractions) > 0 {
- bad = append(bad, "use_interactions")
+ if len(def.OnUse) > 0 {
+ bad = append(bad, "on_use")
}
if def.Steal != nil {
bad = append(bad, "steal")
@@ -218,8 +222,8 @@ func validateLocalObject(roomID int, robj world.RoomObject, itemIDs map[string]b
})
}
- if def.OnLook != nil {
- issues = append(issues, validateNodeAction(prefix+": on_look", def.OnLook, itemIDs, roomIndex)...)
+ if len(def.OnLook) > 0 {
+ issues = append(issues, validateOnLook(prefix+": on_look", def.OnLook, itemIDs, roomIndex, mobIDs)...)
}
return issues
@@ -343,6 +347,7 @@ func validateMobs(s Source) []Issue {
itemIDs := s.Items.IDSet()
dropIDs := dropTableIDSet(s.DataDir)
mobIDs := s.Mobs.AllDefIDs()
+ roomIndex := s.World.RoomIndex()
for id := range mobIDs {
def, err := s.Mobs.LoadDef(id)
@@ -481,6 +486,11 @@ 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)...)
+ }
}
return issues
@@ -584,17 +594,17 @@ func validateObjects(s Source) []Issue {
}
}
- for _, ui := range obj.UseInteractions {
+ for _, ui := range obj.OnUse {
if ui.Item != "" && !itemIDs[ui.Item] {
issues = append(issues, Issue{
Level: "ERROR",
Type: "reference",
- Message: fmt.Sprintf("Object %q: use_interaction item %q does not exist",
+ Message: fmt.Sprintf("Object %q: on_use item %q does not exist",
id, ui.Item),
})
}
if ui.Action != nil {
- issues = append(issues, validateNodeAction(fmt.Sprintf("Object %q: use_interaction", id), ui.Action, itemIDs, roomIndex)...)
+ issues = append(issues, validateStepAction(fmt.Sprintf("Object %q: on_use", id), ui.Action, itemIDs, roomIndex, mobIDs)...)
}
}
@@ -606,9 +616,8 @@ func validateObjects(s Source) []Issue {
issues = append(issues, validateTalkConfig(fmt.Sprintf("Object %q: talk", id), obj.Talk, itemIDs, roomIndex)...)
}
-
- if obj.OnLook != nil {
- issues = append(issues, validateNodeAction(fmt.Sprintf("Object %q: on_look", id), obj.OnLook, itemIDs, roomIndex)...)
+ if len(obj.OnLook) > 0 {
+ issues = append(issues, validateOnLook(fmt.Sprintf("Object %q: on_look", id), obj.OnLook, itemIDs, roomIndex, mobIDs)...)
}
}
@@ -804,49 +813,9 @@ func validateRoomEnterSteps(s Source) []Issue {
if err != nil || len(room.OnEnter) == 0 {
continue
}
- for si, step := range room.OnEnter {
+ for si := range room.OnEnter {
stepPrefix := fmt.Sprintf("Room %d: on_enter[%d]", id, si)
- if step.SpawnMob != nil && step.SpawnMob.ID != "" {
- if _, ok := mobIDs[step.SpawnMob.ID]; !ok {
- issues = append(issues, Issue{
- Level: "ERROR",
- Type: "reference",
- Message: fmt.Sprintf("%s: spawn_mob %q does not exist", stepPrefix, step.SpawnMob.ID),
- })
- }
- for _, wr := range step.SpawnMob.DespawnRooms {
- if _, ok := roomIndex[wr]; !ok {
- issues = append(issues, Issue{
- Level: "ERROR",
- Type: "reference",
- Message: fmt.Sprintf("%s: spawn_mob despawn_rooms references nonexistent room %d", stepPrefix, wr),
- })
- }
- }
- }
- if step.GiveItem != "" && !itemIDs[step.GiveItem] {
- issues = append(issues, Issue{
- Level: "ERROR",
- Type: "reference",
- Message: fmt.Sprintf("%s: give_item %q does not exist", stepPrefix, step.GiveItem),
- })
- }
- if step.TakeItem != "" && !itemIDs[step.TakeItem] {
- issues = append(issues, Issue{
- Level: "ERROR",
- Type: "reference",
- Message: fmt.Sprintf("%s: take_item %q does not exist", stepPrefix, step.TakeItem),
- })
- }
- if step.Teleport > 0 {
- if _, ok := roomIndex[step.Teleport]; !ok {
- issues = append(issues, Issue{
- Level: "ERROR",
- Type: "reference",
- Message: fmt.Sprintf("%s: teleport to nonexistent room %d", stepPrefix, step.Teleport),
- })
- }
- }
+ issues = append(issues, validateStepAction(stepPrefix, &room.OnEnter[si], itemIDs, roomIndex, mobIDs)...)
}
}
@@ -866,54 +835,9 @@ func validateRoomTriggers(s Source) []Issue {
}
for ti, trigger := range room.Triggers {
prefix := fmt.Sprintf("Room %d: trigger[%d]", id, ti)
- for si, step := range trigger.Steps {
+ for si := range trigger.Steps {
stepPrefix := fmt.Sprintf("%s: step[%d]", prefix, si)
- if step.SpawnMob != nil && step.SpawnMob.ID != "" {
- if _, ok := mobIDs[step.SpawnMob.ID]; !ok {
- issues = append(issues, Issue{
- Level: "ERROR",
- Type: "reference",
- Message: fmt.Sprintf("%s: spawn_mob %q does not exist",
- stepPrefix, step.SpawnMob.ID),
- })
- }
- for _, wr := range step.SpawnMob.DespawnRooms {
- if _, ok := roomIndex[wr]; !ok {
- issues = append(issues, Issue{
- Level: "ERROR",
- Type: "reference",
- Message: fmt.Sprintf("%s: spawn_mob despawn_rooms references nonexistent room %d",
- stepPrefix, wr),
- })
- }
- }
- }
- if step.GiveItem != "" && !itemIDs[step.GiveItem] {
- issues = append(issues, Issue{
- Level: "ERROR",
- Type: "reference",
- Message: fmt.Sprintf("%s: give_item %q does not exist",
- stepPrefix, step.GiveItem),
- })
- }
- if step.TakeItem != "" && !itemIDs[step.TakeItem] {
- issues = append(issues, Issue{
- Level: "ERROR",
- Type: "reference",
- Message: fmt.Sprintf("%s: take_item %q does not exist",
- stepPrefix, step.TakeItem),
- })
- }
- if step.Teleport > 0 {
- if _, ok := roomIndex[step.Teleport]; !ok {
- issues = append(issues, Issue{
- Level: "ERROR",
- Type: "reference",
- Message: fmt.Sprintf("%s: teleport to nonexistent room %d",
- stepPrefix, step.Teleport),
- })
- }
- }
+ issues = append(issues, validateStepAction(stepPrefix, &trigger.Steps[si], itemIDs, roomIndex, mobIDs)...)
}
}
}
@@ -1309,3 +1233,98 @@ func validateNodeAction(prefix string, na *behavior.NodeAction, itemIDs map[stri
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 {
+ var issues []Issue
+ if step == nil {
+ return issues
+ }
+
+ // Validate the embedded NodeAction subset.
+ if step.GiveItem != "" && !itemIDs[step.GiveItem] {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("%s: give_item %q does not exist",
+ prefix, step.GiveItem),
+ })
+ }
+ if step.TakeItem != "" && !itemIDs[step.TakeItem] {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("%s: take_item %q does not exist",
+ prefix, step.TakeItem),
+ })
+ }
+ if step.Teleport > 0 {
+ if _, ok := roomIndex[step.Teleport]; !ok {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("%s: teleport to nonexistent room %d",
+ prefix, step.Teleport),
+ })
+ }
+ }
+
+ // 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{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("%s: spawn_mob %q does not exist",
+ prefix, step.SpawnMob.ID),
+ })
+ }
+ for _, wr := range step.SpawnMob.DespawnRooms {
+ if _, ok := roomIndex[wr]; !ok {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("%s: spawn_mob despawn_rooms references nonexistent room %d",
+ prefix, wr),
+ })
+ }
+ }
+ }
+
+ // Despawn mob.
+ if step.DespawnMob != "" && mobIDs != nil && !mobIDs[step.DespawnMob] {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("%s: despawn_mob %q does not exist",
+ prefix, step.DespawnMob),
+ })
+ }
+
+ 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 {
+ var issues []Issue
+ for i, it := range list {
+ entryPrefix := fmt.Sprintf("%s[%d]", prefix, i)
+ if it.Item != "" && !itemIDs[it.Item] {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("%s: item_id %q does not exist",
+ entryPrefix, it.Item),
+ })
+ }
+ issues = append(issues, validateStepAction(entryPrefix, it.Action, itemIDs, roomIndex, mobIDs)...)
+ }
+ return issues
+}
diff --git a/internal/validate/local_test.go b/internal/validate/local_test.go
index d37838e..337f3d2 100644
--- a/internal/validate/local_test.go
+++ b/internal/validate/local_test.go
@@ -17,7 +17,7 @@ func TestValidateLocalObjectPassiveOK(t *testing.T) {
Hidden: true,
Description: behavior.DescList{{Text: "A small window."}},
}}
- issues := validateLocalObject(1001, robj, nil, nil)
+ issues := validateLocalObject(1001, robj, nil, nil, nil)
if len(issues) != 0 {
t.Errorf("expected no issues for passive local object, got: %+v", issues)
}
@@ -28,7 +28,7 @@ func TestValidateLocalObjectRejectsInteractable(t *testing.T) {
Name: "rock",
Gather: &behavior.GatherConfig{},
}}
- issues := validateLocalObject(1001, robj, nil, nil)
+ issues := validateLocalObject(1001, robj, nil, nil, nil)
if !containsMsg(issues, "interactable behavior") {
t.Errorf("expected interactable-behavior error, got: %+v", issues)
}
@@ -38,7 +38,7 @@ func TestValidateLocalObjectRequiresName(t *testing.T) {
robj := world.RoomObject{ID: "x", Local: &object.ObjectDef{
Description: behavior.DescList{{Text: "no name"}},
}}
- issues := validateLocalObject(1001, robj, nil, nil)
+ issues := validateLocalObject(1001, robj, nil, nil, nil)
if !containsMsg(issues, "has no name") {
t.Errorf("expected has-no-name error, got: %+v", issues)
}
@@ -46,7 +46,7 @@ func TestValidateLocalObjectRequiresName(t *testing.T) {
func TestValidateLocalObjectStrayIDWarns(t *testing.T) {
robj := world.RoomObject{ID: "anvil", Local: &object.ObjectDef{Name: "anvil", ID: "anvil"}}
- issues := validateLocalObject(1001, robj, nil, nil)
+ issues := validateLocalObject(1001, robj, nil, nil, nil)
if !containsMsg(issues, "ignored on local objects") {
t.Errorf("expected stray-id warning, got: %+v", issues)
}
diff --git a/internal/world/grid_test.go b/internal/world/grid_test.go
index 9c2c7c6..c777f90 100644
--- a/internal/world/grid_test.go
+++ b/internal/world/grid_test.go
@@ -117,11 +117,11 @@ func TestBuildGridConflictsHypotheticalInsert(t *testing.T) {
copy.Exits = make(map[ExitDir]ExitDef, len(r.Exits))
for k, v := range r.Exits {
if id == 1 && k == East {
- copy.Exits[k] = ExitDef{Room: 5, Condition: v.Condition, BlockedMessage: v.BlockedMessage, SetGlobalFlags: v.SetGlobalFlags, SetPlayerFlags: v.SetPlayerFlags, Hidden: v.Hidden, AlwaysBlocked: v.AlwaysBlocked}
+ copy.Exits[k] = ExitDef{Room: 5, Condition: v.Condition, BlockedMessage: v.BlockedMessage, OnTraverse: v.OnTraverse, Hidden: v.Hidden, AlwaysBlocked: v.AlwaysBlocked}
continue
}
if id == 2 && k == West {
- copy.Exits[k] = ExitDef{Room: 5, Condition: v.Condition, BlockedMessage: v.BlockedMessage, SetGlobalFlags: v.SetGlobalFlags, SetPlayerFlags: v.SetPlayerFlags, Hidden: v.Hidden, AlwaysBlocked: v.AlwaysBlocked}
+ copy.Exits[k] = ExitDef{Room: 5, Condition: v.Condition, BlockedMessage: v.BlockedMessage, OnTraverse: v.OnTraverse, Hidden: v.Hidden, AlwaysBlocked: v.AlwaysBlocked}
continue
}
copy.Exits[k] = v
diff --git a/internal/world/insert_remove.go b/internal/world/insert_remove.go
index c55427c..05bfa22 100644
--- a/internal/world/insert_remove.go
+++ b/internal/world/insert_remove.go
@@ -20,8 +20,7 @@ func RewirePreserving(src ExitDef, newTarget int) ExitDef {
Room: newTarget,
Condition: src.Condition,
BlockedMessage: src.BlockedMessage,
- SetGlobalFlags: src.SetGlobalFlags,
- SetPlayerFlags: src.SetPlayerFlags,
+ OnTraverse: src.OnTraverse,
Hidden: src.Hidden,
AlwaysBlocked: src.AlwaysBlocked,
}
diff --git a/internal/world/mob.go b/internal/world/mob.go
index 6780c45..a9e2f7e 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"`
@@ -93,6 +93,8 @@ type MobDef struct {
Talk *behavior.TalkConfig `yaml:"talk,omitempty"`
Shop *behavior.ShopConfig `yaml:"shop,omitempty"`
+
+ OnKill []behavior.Interaction `yaml:"on_kill,omitempty"`
}
func (d *MobDef) IsTalkable() bool { return d.Talk != nil }
@@ -165,6 +167,13 @@ type MobInstance struct {
TalkConfig *behavior.TalkConfig
+ // OnKill is copied from the def at spawn time and fires from endCombat
+ // when this mob is defeated — either by a combat kill or by a task mob's
+ // 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
+
// Shop is the (shared, read-only) shop config from the def. Per-instance
// live stock is tracked in ShopStock; ShopRestock holds per-item countdown
// timers. All three are only mutated under MobStore.mu.
@@ -332,6 +341,7 @@ func NewMobInstance(def *MobDef, instanceID string, roomID int, wanderRooms []in
CompleteMessage: completeMessage,
CombatDescriptions: combatDescriptions,
TalkConfig: def.Talk,
+ OnKill: def.OnKill,
}
if def.Shop != nil {
inst.Shop = def.Shop
diff --git a/internal/world/room.go b/internal/world/room.go
index 2da40fb..08e18a4 100644
--- a/internal/world/room.go
+++ b/internal/world/room.go
@@ -75,13 +75,12 @@ type SpawnDef struct {
}
type ExitDef struct {
- Room int `yaml:"room"`
- Condition *behavior.Condition `yaml:"condition,omitempty"`
- BlockedMessage string `yaml:"blocked_message,omitempty"`
- SetGlobalFlags map[string]any `yaml:"set_global_flags,omitempty"`
- SetPlayerFlags map[string]any `yaml:"set_player_flags,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.Interaction `yaml:"on_traverse,omitempty"`
+ Hidden bool `yaml:"hidden,omitempty"`
+ AlwaysBlocked bool `yaml:"always_blocked,omitempty"`
}
func (e *ExitDef) UnmarshalYAML(value *yaml.Node) error {
@@ -106,7 +105,7 @@ type Room struct {
Objects []RoomObject `yaml:"objects"`
ItemSpawns []SpawnDef `yaml:"item_spawns"`
Mobs []RoomMob `yaml:"mobs"`
- OnEnter []EnterStep `yaml:"on_enter"`
+ OnEnter []behavior.StepAction `yaml:"on_enter"`
Hazard string `yaml:"hazard"`
BlockTransport bool `yaml:"block_transport"`
Triggers []TriggerDef `yaml:"triggers"`
@@ -131,32 +130,6 @@ func (rm *RoomMob) UnmarshalYAML(value *yaml.Node) error {
return value.Decode((*raw)(rm))
}
-type EnterStep struct {
- Message string `yaml:"message"`
- Condition *behavior.Condition `yaml:"condition"`
- Delay int `yaml:"delay"`
- SetGlobalFlags map[string]any `yaml:"set_global_flags"`
- SetPlayerFlags map[string]any `yaml:"set_player_flags"`
- Broadcast string `yaml:"broadcast"`
- BroadcastGlobal string `yaml:"broadcast_global"`
- SpawnMob *SpawnMobConfig `yaml:"spawn_mob"`
- DespawnMob string `yaml:"despawn_mob"`
- GiveItem string `yaml:"give_item"`
- TakeItem string `yaml:"take_item"`
- Teleport int `yaml:"teleport"`
- Heal int `yaml:"heal"`
-}
-
-// IsTimed reports whether the step carries enter-sequence semantics (any
-// non-zero field means it needs per-tick scheduling rather than synchronous
-// printing).
-func (e EnterStep) IsTimed() bool {
- return e.Delay > 0 || len(e.SetGlobalFlags) > 0 || len(e.SetPlayerFlags) > 0 ||
- e.Message != "" || e.Broadcast != "" || e.BroadcastGlobal != "" ||
- e.SpawnMob != nil || e.DespawnMob != "" || e.GiveItem != "" ||
- e.TakeItem != "" || e.Teleport != 0 || e.Heal != 0
-}
-
// RoomObject is either a reference to a file-backed object definition (only
// `id`/wander fields set) or a fully local object definition (Local != nil).
// An entry is treated as local when it carries any passive content field
@@ -190,7 +163,7 @@ func (ro *RoomObject) UnmarshalYAML(value *yaml.Node) error {
return err
}
isLocal := def.Name != "" || len(def.Description) > 0 || def.InRoomDescription != "" ||
- len(def.Aliases) > 0 || def.Color != "" || def.OnLook != nil
+ len(def.Aliases) > 0 || def.Color != "" || len(def.OnLook) > 0
if isLocal {
ro.ID = NormalizeObjectName(def.Name)
ro.Local = &def
@@ -204,19 +177,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"`
- UseInteractions []object.UseInteraction `yaml:"use_interactions,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.NodeAction `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.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"`
}
def := ro.Local
return inlineObj{
@@ -227,7 +200,7 @@ func (ro RoomObject) MarshalYAML() (interface{}, error) {
InRoomDescription: def.InRoomDescription,
RemovalItem: def.RemovalItem,
Description: def.Description,
- UseInteractions: def.UseInteractions,
+ OnUse: def.OnUse,
Steal: def.Steal,
Gather: def.Gather,
Talk: def.Talk,
diff --git a/internal/world/room_migrate.go b/internal/world/room_migrate.go
index 6d79499..946417f 100644
--- a/internal/world/room_migrate.go
+++ b/internal/world/room_migrate.go
@@ -16,7 +16,22 @@ func (r *Room) RewriteRoomIDs(idMap map[int]int) bool {
}
changed := false
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 exitChanged {
r.Exits[dir] = exit
changed = true
}
diff --git a/internal/world/room_migrate_test.go b/internal/world/room_migrate_test.go
index 5696a36..c54adf2 100644
--- a/internal/world/room_migrate_test.go
+++ b/internal/world/room_migrate_test.go
@@ -1,23 +1,29 @@
package world
-import "testing"
+import (
+ "testing"
+
+ "thehouseoficarus/internal/behavior"
+)
func TestRoomRewriteRoomIDs(t *testing.T) {
r := &Room{
Exits: map[ExitDir]ExitDef{
- North: {Room: 10, SetGlobalFlags: map[string]any{"last": 10}},
+ 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: []TriggerStep{
- {Teleport: 40, SpawnMob: &SpawnMobConfig{DespawnRooms: []int{50, 60}}},
+ Steps: []behavior.StepAction{
+ {NodeAction: behavior.NodeAction{Teleport: 40}, SpawnMob: &behavior.SpawnMobConfig{DespawnRooms: []int{50, 60}}},
},
},
},
- OnEnter: []EnterStep{
- {Teleport: 70, SpawnMob: &SpawnMobConfig{DespawnRooms: []int{80}}},
+ OnEnter: []behavior.StepAction{
+ {NodeAction: behavior.NodeAction{Teleport: 70}, SpawnMob: &behavior.SpawnMobConfig{DespawnRooms: []int{80}}},
},
Mobs: []RoomMob{
{ID: "guard", WanderRooms: []int{90, 100}},
@@ -26,7 +32,7 @@ func TestRoomRewriteRoomIDs(t *testing.T) {
idMap := map[int]int{
10: 11, 20: 21, 30: 31, 40: 41, 50: 51, 60: 61,
- 70: 71, 80: 81, 90: 91, 100: 101,
+ 70: 71, 80: 81, 90: 91, 100: 101, 110: 111,
}
if !r.RewriteRoomIDs(idMap) {
t.Fatal("expected changed=true")
@@ -43,9 +49,13 @@ func TestRoomRewriteRoomIDs(t *testing.T) {
// 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].SetGlobalFlags["last"]; v != 10 {
+ if v := r.Exits[North].OnTraverse[0].Action.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 r.Triggers[0].Room != 31 {
t.Errorf("trigger room = %d, want 31", r.Triggers[0].Room)
diff --git a/internal/world/trigger.go b/internal/world/trigger.go
index e31a5f9..e7f3b75 100644
--- a/internal/world/trigger.go
+++ b/internal/world/trigger.go
@@ -1,61 +1,20 @@
package world
-import (
- "gopkg.in/yaml.v3"
-)
+import "thehouseoficarus/internal/behavior"
+
+// SpawnMobConfig is an alias for the behavior.SpawnMobConfig so callers that
+// historically referenced world.SpawnMobConfig continue to compile.
+type SpawnMobConfig = behavior.SpawnMobConfig
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 []TriggerStep `yaml:"steps"`
+ 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 != "" }
-
-type TriggerStep struct {
- Delay int `yaml:"delay"`
- Message string `yaml:"message"`
- Broadcast string `yaml:"broadcast"`
- BroadcastGlobal string `yaml:"broadcast_global"`
- SetGlobalFlags map[string]any `yaml:"set_global_flags"`
- SetPlayerFlags map[string]any `yaml:"set_player_flags"`
- SpawnMob *SpawnMobConfig `yaml:"spawn_mob"`
- GiveItem string `yaml:"give_item"`
- TakeItem string `yaml:"take_item"`
- Teleport int `yaml:"teleport"`
- Heal int `yaml:"heal"`
- DespawnMob string `yaml:"despawn_mob"`
-}
-
-type SpawnMobConfig struct {
- ID string `yaml:"id"`
- OwnerOnly bool `yaml:"owner_only"`
- DespawnOnLeave bool `yaml:"despawn_on_leave"`
- DespawnRooms []int `yaml:"despawn_rooms"`
- DespawnTicks float64 `yaml:"despawn_ticks"`
-}
-
-func (s *SpawnMobConfig) UnmarshalYAML(value *yaml.Node) error {
- if value.Kind == yaml.ScalarNode {
- var id string
- if err := value.Decode(&id); err != nil {
- return err
- }
- s.ID = id
- return nil
- }
- type raw SpawnMobConfig
- return value.Decode((*raw)(s))
-}
-
-func (t TriggerStep) IsTimed() bool {
- return t.Delay > 0 || len(t.SetGlobalFlags) > 0 || len(t.SetPlayerFlags) > 0 ||
- t.Message != "" || t.Broadcast != "" || t.BroadcastGlobal != "" ||
- t.SpawnMob != nil || t.GiveItem != "" || t.TakeItem != "" ||
- t.Teleport != 0 || t.Heal != 0 || t.DespawnMob != ""
-}
+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
index e18ae80..04d77f4 100644
--- a/internal/world/trigger_store.go
+++ b/internal/world/trigger_store.go
@@ -8,6 +8,7 @@ import (
"sync"
"gopkg.in/yaml.v3"
+ "thehouseoficarus/internal/behavior"
)
type TriggerStore struct {
@@ -26,7 +27,7 @@ type PlayerTriggerSeq struct {
PlayerName string
TriggerID string
RoomID int
- Steps []TriggerStep
+ Steps []behavior.StepAction
Wait int
Started bool
FlagValue any
@@ -35,7 +36,7 @@ type PlayerTriggerSeq struct {
type GlobalTriggerSeq struct {
TriggerID string
RoomID int
- Steps []TriggerStep
+ Steps []behavior.StepAction
Wait int
FlagValue any
}
@@ -227,7 +228,7 @@ func (ts *TriggerStore) startPlayerSeqLocked(playerName string, t *TriggerDef, r
PlayerName: playerName,
TriggerID: t.ID,
RoomID: roomID,
- Steps: make([]TriggerStep, len(t.Steps)),
+ Steps: make([]behavior.StepAction, len(t.Steps)),
FlagValue: flagValue,
}
copy(seq.Steps, t.Steps)
@@ -246,7 +247,7 @@ func (ts *TriggerStore) startGlobalSeqLocked(t *TriggerDef, flagValue any) *Glob
seq := &GlobalTriggerSeq{
TriggerID: t.ID,
RoomID: t.Room,
- Steps: make([]TriggerStep, len(t.Steps)),
+ Steps: make([]behavior.StepAction, len(t.Steps)),
FlagValue: flagValue,
}
copy(seq.Steps, t.Steps)
--
cgit v1.2.3