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 FieldsGive 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