aboutsummaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-07-09 19:29:17 -0400
committerhistoria <[not public]>2026-07-09 19:29:17 -0400
commitb3d4c616f59ad2519f3a0b77e3b47d6571cd2486 (patch)
tree6f1f6a314e0550da68264a79381fc269db803312 /internal
parent18c7af94b30c2767b6633d050324c1db34074ce2 (diff)
downloadthehouseoficarus-b3d4c616f59ad2519f3a0b77e3b47d6571cd2486.tar.gz
feat: message actions are now sequences of messages with settable delays
Diffstat (limited to 'internal')
-rw-r--r--internal/admin/static/admin.css4
-rw-r--r--internal/admin/static/intereditor.js168
-rw-r--r--internal/behavior/behavior.go29
-rw-r--r--internal/behavior/types.go8
-rw-r--r--internal/game/act.go4
-rw-r--r--internal/game/act_effects.go38
-rw-r--r--internal/game/act_interaction_seq.go97
-rw-r--r--internal/game/act_interaction_seq_test.go216
-rw-r--r--internal/game/act_room.go6
-rw-r--r--internal/game/act_state.go2
-rw-r--r--internal/game/act_use_interaction.go19
-rw-r--r--internal/game/cmd_move.go3
-rw-r--r--internal/game/cmd_use.go27
-rw-r--r--internal/game/condition_test.go4
-rw-r--r--internal/validate/checks.go11
15 files changed, 500 insertions, 136 deletions
diff --git a/internal/admin/static/admin.css b/internal/admin/static/admin.css
index 393fafd..b35d5e2 100644
--- a/internal/admin/static/admin.css
+++ b/internal/admin/static/admin.css
@@ -400,6 +400,7 @@ g.ud-hover:hover text{font-weight:bold}
.ie-cond-leaf input[type="number"],
.ie-cond-val{font-size:11px;padding:3px 6px;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:3px;font-family:monospace;flex:1;min-width:80px}
.ie-cond-subval{font-size:11px;padding:3px 6px;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:3px;font-family:monospace;width:100px}
+.ie-cond-eq{font-size:11px;color:#888;font-weight:bold;white-space:nowrap;flex-shrink:0}
.ie-cond-rm{flex-shrink:0}
.ie-cond-grp-rm{margin-left:auto}
.ie-cond-addbar{display:flex;gap:3px;flex-wrap:wrap;margin-top:4px}
@@ -419,6 +420,9 @@ g.ud-hover:hover text{font-weight:bold}
.ie-kv-row{display:flex;gap:4px;align-items:center}
.ie-kv-key,.ie-kv-val{font-size:11px;padding:2px 4px;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:3px;font-family:monospace;width:120px}
.ie-kv-val{width:80px}
+.ie-act-msg-delay,.ie-act-msg{font-size:11px;padding:3px 6px;background:var(--input-bg);color:var(--text);border:1px solid var(--input-border);border-radius:3px;font-family:monospace}
+.ie-act-msg-delay{width:60px}
+.ie-act-msg{flex:1}
/* Interaction row layout: column. The single flex header row holds the
+Add Required Item / +Add Condition / +Add Action buttons alongside the
diff --git a/internal/admin/static/intereditor.js b/internal/admin/static/intereditor.js
index b46c456..5cf39a1 100644
--- a/internal/admin/static/intereditor.js
+++ b/internal/admin/static/intereditor.js
@@ -5,7 +5,7 @@
// ── Condition leaf types ──
var IE_COND_TYPES = [
- {type: 'global_flag', label: 'Global Flag', hint: 'world flag name'},
+ {type: 'global_flag', label: 'Global Flag', hint: 'global flag name'},
{type: 'player_flag', label: 'Player Flag', hint: 'player flag name'},
{type: 'has_item', label: 'Has Item', hint: 'item ID'},
{type: 'min_credits', label: 'Min Credits', hint: 'amount'},
@@ -218,11 +218,18 @@ function ie_collectAct(rootEl) {
if (!rootEl) return null;
var r = {};
- // Message (rolled into action)
- var msgEl = rootEl.querySelector('.ie-act-msg');
- if (msgEl) {
- var msg = msgEl.value.trim();
- r.message = msg;
+ // Messages — collected from the extendable delay+message rows.
+ var msgsKv = rootEl.querySelector('.ie-act-kv[data-ie-act-key="messages"]');
+ if (msgsKv) {
+ var list = [];
+ msgsKv.querySelectorAll('.ie-kv-row').forEach(function(row) {
+ var delayEl = row.querySelector('.ie-act-msg-delay');
+ var msgEl = row.querySelector('.ie-act-msg');
+ var d = delayEl ? parseInt(delayEl.value) || 0 : 0;
+ var m = msgEl ? msgEl.value.trim() : '';
+ list.push({ message: m, delay: d });
+ });
+ r.messages = list;
}
// KV-type effects — include the map even when empty (as long as the
@@ -233,6 +240,7 @@ function ie_collectAct(rootEl) {
rootEl.querySelectorAll('.ie-act-kv').forEach(function(kvEl) {
var key = kvEl.getAttribute('data-ie-act-key');
if (!key) return;
+ if (key === 'messages') return;
var map = {};
kvEl.querySelectorAll('.ie-kv-row').forEach(function(row) {
var kEl = row.querySelector('.ie-kv-key');
@@ -291,8 +299,6 @@ function ie_syncAllInteractions(data, secs) {
var act = ie_collectAct(actRoot);
if (act) data[p][idx].action = act;
else delete data[p][idx].action;
- // Clear any legacy top-level message on sync.
- delete data[p][idx].message;
}
});
});
@@ -410,7 +416,7 @@ function _ie_renderLeaf(leafType, leafVal, leafSub, not, secId, secPath, idx, gp
var h = '<div class="ie-cond-leaf" data-ie-leaftype="' + leafType + '">';
h += '<span class="ie-cond-leaf-label">' + esc(label) + '</span>';
if (isSearch) {
- h += '<div style="position:relative;min-width:0;flex:1">';
+ h += '<div class="obj-search" style="position:relative;min-width:0;flex:1">';
h += '<input class="ie-cond-val search-input" data-search-type="items" placeholder="' + escAttr(hint) + '" value="' + escAttr(sv) + '">';
h += '<div class="search-results" style="display:none"></div>';
h += '</div>';
@@ -420,7 +426,8 @@ function _ie_renderLeaf(leafType, leafVal, leafSub, not, secId, secPath, idx, gp
h += '<input class="ie-cond-val" placeholder="' + escAttr(hint) + '" value="' + escAttr(sv) + '">';
}
if (leafType === 'player_flag' || leafType === 'global_flag') {
- h += '<input class="ie-cond-subval" placeholder="Value (optional)" value="' + escAttr(ssv) + '">';
+ h += '<span class="ie-cond-eq">=</span>';
+ h += '<input class="ie-cond-subval" placeholder="exists" value="' + escAttr(ssv) + '">';
}
h += '<label class="ie-cond-notlbl"><input type="checkbox" class="ie-cond-not" onchange="ie_toggleNotLeaf(\'' + escAttr(secId) + '\',\'' + escAttr(secPath) + '\',' + idx + ',\'' + escAttr(gpath) + '\',' + ciToken + ')"' + (not ? ' checked' : '') + '> NOT</label>';
h += '<button class="btn btn-sm btn-danger ie-cond-rm" onclick="ie_removeCond(\'' + escAttr(secId) + '\',\'' + escAttr(secPath) + '\',' + idx + ',\'' + escAttr(gpath) + '\',' + ciToken + ')" title="Remove">X</button>';
@@ -434,14 +441,32 @@ function ie_renderAct(actionObj, scope, secId, idx, secPath) {
var hasAny = false;
var rows = '';
- // Message
- var msg = (actionObj && actionObj.message !== undefined && actionObj.message !== null) ? String(actionObj.message) : '';
- var msgPresent = actionObj && Object.prototype.hasOwnProperty.call(actionObj, 'message');
- if (msgPresent) {
+ // Messages — extendable list of {delay, message} entries. Mirrors the KV-section
+ // pattern: when the 'messages' key is present we render the section with rows
+ // and an internal + Add; when it goes away (last row removed) the section
+ // disappears and the addbar + Add Message button reappears. There is no
+ // section-level X.
+ var msgsPresent = actionObj && Object.prototype.hasOwnProperty.call(actionObj, 'messages') && Array.isArray(actionObj.messages);
+ if (msgsPresent) {
+ var msgs = actionObj.messages;
hasAny = true;
- rows += '<div class="ie-act-row"><span class="ie-act-label">Message</span>';
- rows += '<input class="ie-act-input ie-act-msg" data-ie-act-key="message" placeholder="Shown to player" value="' + escAttr(msg) + '" style="flex:1">';
- rows += '<button class="btn btn-sm btn-danger ie-act-rm" onclick="ie_removeActEffect(\'' + escAttr(secId) + '\',\'' + escAttr(secPath) + '\',' + idx + ',\'message\')">X</button>';
+ var rmFn = 'ie_removeMessage(this,\'' + escAttr(secId) + '\',\'' + escAttr(secPath) + '\',' + idx + ')';
+ rows += '<div class="ie-act-kv" data-ie-act-key="messages">';
+ rows += '<div class="ie-act-kv-header"><span>Messages</span></div>';
+ rows += '<div class="ie-kv-list">';
+ if (msgs.length === 0) {
+ rows += '<div class="ie-kv-row"><input type="number" class="ie-act-msg-delay" value="" placeholder="delay" style="width:60px" title="Ticks before showing">';
+ rows += '<input class="ie-act-msg" data-ie-msg-idx="0" value="" placeholder="Shown to player" style="flex:1">';
+ rows += '<button class="btn btn-sm btn-danger" onclick="' + rmFn + '">X</button></div>';
+ } else {
+ msgs.forEach(function(m, mi) {
+ rows += '<div class="ie-kv-row"><input type="number" class="ie-act-msg-delay" value="' + escAttr(m.delay ? String(m.delay) : '') + '" placeholder="delay" style="width:60px" title="Ticks before showing">';
+ rows += '<input class="ie-act-msg" data-ie-msg-idx="' + mi + '" value="' + escAttr(String(m.message || '')) + '" placeholder="Shown to player" style="flex:1">';
+ rows += '<button class="btn btn-sm btn-danger" onclick="' + rmFn + '">X</button></div>';
+ });
+ }
+ rows += '</div>';
+ rows += '<button class="btn btn-sm ie-add-btn" style="margin-top:2px" onclick="ie_addMessage(\'' + escAttr(secId) + '\',\'' + escAttr(secPath) + '\',' + idx + ')">+ Add Message</button>';
rows += '</div>';
}
@@ -477,12 +502,12 @@ function ie_renderAct(actionObj, scope, secId, idx, secPath) {
rows += '</div>';
} else if (at.kind === 'checkbox') {
rows += '<div class="ie-act-row"><span class="ie-act-label">' + esc(at.label) + '</span>';
- rows += '<label><input type="checkbox" class="ie-act-input" data-ie-act-key="' + escAttr(at.key) + '" data-ie-act-kind="checkbox" checked> Enabled</label>';
+ rows += '<input type="checkbox" class="ie-act-input" data-ie-act-key="' + escAttr(at.key) + '" data-ie-act-kind="checkbox" checked hidden>';
rows += '<button class="btn btn-sm btn-danger ie-act-rm" onclick="ie_removeActEffect(\'' + escAttr(secId) + '\',\'' + escAttr(secPath) + '\',' + idx + ',\'' + escAttr(at.key) + '\')">X</button>';
rows += '</div>';
} else if (at.kind === 'search') {
rows += '<div class="ie-act-row"><span class="ie-act-label">' + esc(at.label) + '</span>';
- rows += '<div style="position:relative;min-width:0;flex:1"><input class="ie-act-input search-input" data-ie-act-key="' + escAttr(at.key) + '" data-search-type="' + escAttr(at.entity) + '" placeholder="' + escAttr(at.hint || 'Search ' + at.entity + '...') + '" value="' + escAttr(String(val)) + '"><div class="search-results" style="display:none"></div></div>';
+ rows += '<div class="obj-search" style="position:relative;min-width:0;flex:1"><input class="ie-act-input search-input" data-ie-act-key="' + escAttr(at.key) + '" data-search-type="' + escAttr(at.entity) + '" placeholder="' + escAttr(at.hint || 'Search ' + at.entity + '...') + '" value="' + escAttr(String(val)) + '"><div class="search-results" style="display:none"></div></div>';
rows += '<button class="btn btn-sm btn-danger ie-act-rm" onclick="ie_removeActEffect(\'' + escAttr(secId) + '\',\'' + escAttr(secPath) + '\',' + idx + ',\'' + escAttr(at.key) + '\')">X</button>';
rows += '</div>';
} else {
@@ -503,7 +528,7 @@ function ie_renderAct(actionObj, scope, secId, idx, secPath) {
if (at.kind === 'search') {
rows += '<div class="ie-act-row"><span class="ie-act-label">' + esc(at.label) + '</span>';
- rows += '<div style="position:relative;min-width:0;flex:1"><input class="ie-act-input search-input" data-ie-act-key="' + escAttr(at.key) + '" data-search-type="' + escAttr(at.entity) + '" placeholder="' + escAttr(at.hint || 'Search ' + at.entity + '...') + '" value="' + escAttr(String(val)) + '"><div class="search-results" style="display:none"></div></div>';
+ rows += '<div class="obj-search" style="position:relative;min-width:0;flex:1"><input class="ie-act-input search-input" data-ie-act-key="' + escAttr(at.key) + '" data-search-type="' + escAttr(at.entity) + '" placeholder="' + escAttr(at.hint || 'Search ' + at.entity + '...') + '" value="' + escAttr(String(val)) + '"><div class="search-results" style="display:none"></div></div>';
rows += '<button class="btn btn-sm btn-danger ie-act-rm" onclick="ie_removeActEffect(\'' + escAttr(secId) + '\',\'' + escAttr(secPath) + '\',' + idx + ',\'' + escAttr(at.key) + '\')">X</button>';
rows += '</div>';
} else {
@@ -517,8 +542,8 @@ function ie_renderAct(actionObj, scope, secId, idx, secPath) {
// Add effect buttons
var addbar = '<div class="ie-act-addbar">';
- if (!msgPresent) {
- addbar += '<button class="btn btn-sm ie-add-btn" onclick="ie_addActEffect(\'' + escAttr(secId) + '\',\'' + escAttr(secPath) + '\',' + idx + ',\'message\',\'' + escAttr(scope) + '\')">+ Add Message</button>';
+ if (!msgsPresent) {
+ addbar += '<button class="btn btn-sm ie-add-btn" onclick="ie_addActEffect(\'' + escAttr(secId) + '\',\'' + escAttr(secPath) + '\',' + idx + ',\'messages\',\'' + escAttr(scope) + '\')">+ Add Message</button>';
}
IE_ACT_TYPES.forEach(function(at) {
if (actionObj && Object.prototype.hasOwnProperty.call(actionObj, at.key)) {
@@ -754,18 +779,6 @@ function ie_toggleCondNot(secId, secPath, idx, groupPath) {
var ed = window._editorData;
if (!ed) return;
ie_syncCondEntry(ed, secId, secPath, idx);
- var cond = (ed[secPath] && ed[secPath][idx]) ? ed[secPath][idx].condition : null;
- var group = ie_condAt(cond, groupPath);
- if (!group) return;
-
- if (group.all_of || group.any_of) {
- group.not = !group.not;
- if (!group.not) delete group.not;
- var nc = ie_condReplace(cond, groupPath, group);
- if (!ed[secPath][idx]) ed[secPath][idx] = {};
- if (nc) ed[secPath][idx].condition = nc;
- else delete ed[secPath][idx].condition;
- }
ced_syncDOMToData(ed);
window._ieRenderOnly();
}
@@ -774,26 +787,6 @@ function ie_toggleNotLeaf(secId, secPath, idx, groupPath, ci) {
var ed = window._editorData;
if (!ed) return;
ie_syncCondEntry(ed, secId, secPath, idx);
- var cond = (ed[secPath] && ed[secPath][idx]) ? ed[secPath][idx].condition : null;
- var parent = ie_condAt(cond, groupPath);
- if (!parent) return;
-
- var leaf;
- if (ci === IE_ROOT_LEAF) {
- leaf = parent;
- } else {
- if (!parent.all_of && !parent.any_of) {
- leaf = parent;
- } else {
- var children = parent.all_of || parent.any_of;
- if (typeof ci !== 'number' || ci < 0 || ci >= children.length) return;
- leaf = children[ci];
- }
- }
-
- leaf.not = !leaf.not;
- if (!leaf.not) delete leaf.not;
- ed[secPath][idx].condition = cond;
ced_syncDOMToData(ed);
window._ieRenderOnly();
}
@@ -809,8 +802,8 @@ function ie_addActEffect(secId, secPath, idx, effectKey, scope) {
if (!ed[secPath][idx].action) ed[secPath][idx].action = {};
var act = ed[secPath][idx].action;
- if (effectKey === 'message') {
- act.message = '';
+ if (effectKey === 'messages') {
+ act.messages = [];
} else {
var at = IE_ACT_TYPES.find(function(a) { return a.key === effectKey; });
var at2 = IE_ACT_TYPES_SEQ.find(function(a) { return a.key === effectKey; });
@@ -898,6 +891,67 @@ function ie_removeKVRow(rowEl, secId, secPath, idx, effectKey) {
window._ieRenderOnly();
}
+// ── Add/Remove: Messages list row ──
+
+// ie_addMessage appends a blank delay+message row to the DOM (no data change
+// yet — it joins the list on the next sync, the same way typing into an
+// existing row does). The Messages section must already be present (created
+// via + Add Message in the addbar). Focuses the new row's message input.
+function ie_addMessage(secId, secPath, idx) {
+ var ed = window._editorData;
+ if (!ed) return;
+ ie_syncActEntry(ed, secId, secPath, idx);
+ var card = document.querySelector('.obj-card[data-card="' + secId + '"]');
+ if (!card) return;
+ var rows = card.querySelectorAll('.obj-sub-row');
+ var row = rows[idx];
+ if (!row) return;
+ var list = row.querySelector('.ie-act-kv[data-ie-act-key="messages"] .ie-kv-list');
+ if (!list) return;
+ var rowEl = document.createElement('div');
+ rowEl.className = 'ie-kv-row';
+ var rmFn = 'ie_removeMessage(this,\'' + escAttr(secId) + '\',\'' + escAttr(secPath) + '\',' + idx + ')';
+ rowEl.innerHTML = '<input type="number" class="ie-act-msg-delay" value="" placeholder="delay" style="width:60px" title="Ticks before showing"><input class="ie-act-msg" value="" placeholder="Shown to player" style="flex:1"><button class="btn btn-sm btn-danger" onclick="' + rmFn + '">X</button>';
+ list.appendChild(rowEl);
+ rowEl.querySelector('.ie-act-msg').focus();
+}
+
+// ie_removeMessage removes a message entry from the action's messages list. If
+// the list becomes empty the entire messages key is dropped (and the section
+// disappears on re-render). rowEl may be the X button itself.
+function ie_removeMessage(rowEl, secId, secPath, idx) {
+ var ed = window._editorData;
+ if (!ed) return;
+ if (rowEl && rowEl.closest) {
+ var kvRow = rowEl.closest('.ie-kv-row');
+ if (kvRow) rowEl = kvRow;
+ }
+ ie_syncActEntry(ed, secId, secPath, idx);
+ if (!ed[secPath] || !ed[secPath][idx] || !ed[secPath][idx].action) return;
+ var act = ed[secPath][idx].action;
+ if (!act.hasOwnProperty('messages') || !Array.isArray(act.messages)) return;
+
+ // Find which row index to remove by locating our row in the list.
+ var card = document.querySelector('.obj-card[data-card="' + secId + '"]');
+ var subRows = card ? card.querySelectorAll('.obj-sub-row') : [];
+ var row = subRows[idx];
+ if (row) {
+ var kvRows = row.querySelectorAll('.ie-act-kv[data-ie-act-key="messages"] .ie-kv-row');
+ for (var ri = 0; ri < kvRows.length; ri++) {
+ if (kvRows[ri] === rowEl || kvRows[ri].contains(rowEl)) {
+ act.messages.splice(ri, 1);
+ break;
+ }
+ }
+ }
+ if (act.messages.length === 0) {
+ delete act.messages;
+ if (Object.keys(act).length === 0) delete ed[secPath][idx].action;
+ }
+ ced_syncDOMToData(ed);
+ window._ieRenderOnly();
+}
+
// ── Pre-render sync helpers ──
function ie_syncCondEntry(ed, secId, secPath, idx) {
@@ -940,8 +994,6 @@ function ie_syncActEntry(ed, secId, secPath, idx) {
} else {
delete ed[secPath][idx].action;
}
- // Clear any legacy top-level message on sync.
- delete ed[secPath][idx].message;
}
// ── Shared interaction-style array-section renderer ──
diff --git a/internal/behavior/behavior.go b/internal/behavior/behavior.go
index 58b5e6b..12eef0b 100644
--- a/internal/behavior/behavior.go
+++ b/internal/behavior/behavior.go
@@ -58,6 +58,16 @@ type NodeAction struct {
ApsNode bool `yaml:"aps_node,omitempty"`
}
+// DelayedMessage is a single message with a delay (in ticks) to wait before
+// showing it. Used by the interaction sequencer (on_use / on_look / on_kill /
+// on_traverse); on_enter and trigger steps emit their Messages list
+// immediately when the step fires (use the step's own Delay for inter-step
+// spacing).
+type DelayedMessage struct {
+ Message string `yaml:"message,omitempty"`
+ Delay int `yaml:"delay,omitempty"`
+}
+
// Interaction is one conditional trigger entry shared by on_use, on_look
// (objects) and on_kill (mobs). Item filters by the appropriate held/weapon
// item depending on the interaction kind (see itemMatch on applyInteraction).
@@ -70,7 +80,6 @@ type NodeAction struct {
type Interaction struct {
Item string `yaml:"item_id,omitempty"`
Condition *Condition `yaml:"condition,omitempty"`
- Message string `yaml:"message,omitempty"`
Action *StepAction `yaml:"action,omitempty"`
}
@@ -78,25 +87,25 @@ type Interaction struct {
// 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,
+// aps_node); the additional fields are the sequence-only effects (messages,
// broadcasts, mob spawn/despawn, delay). Condition is evaluated per-step by
// the on_enter / trigger schedulers; inline callers ignore it (the gating
// happens at the parent Interaction.ExitDef level instead).
type StepAction struct {
NodeAction `yaml:",inline"`
- Condition *Condition `yaml:"condition,omitempty"`
- 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"`
+ Condition *Condition `yaml:"condition,omitempty"`
+ Messages []DelayedMessage `yaml:"messages,omitempty"`
+ Broadcast string `yaml:"broadcast,omitempty"`
+ BroadcastGlobal string `yaml:"broadcast_global,omitempty"`
+ SpawnMob *SpawnMobConfig `yaml:"spawn_mob,omitempty"`
+ DespawnMob string `yaml:"despawn_mob,omitempty"`
+ Delay int `yaml:"delay,omitempty"`
}
// IsTimed reports whether the step carries sequence semantics (any non-zero
// field means it needs per-tick scheduling rather than synchronous printing).
func (s StepAction) IsTimed() bool {
- return s.Delay > 0 || s.Message != "" || s.Broadcast != "" || s.BroadcastGlobal != "" ||
+ return s.Delay > 0 || len(s.Messages) > 0 || s.Broadcast != "" || s.BroadcastGlobal != "" ||
s.SpawnMob != nil || s.DespawnMob != "" ||
len(s.SetGlobalFlags) > 0 || len(s.SetPlayerFlags) > 0 ||
s.GiveItem != "" || s.TakeItem != "" || s.Teleport != 0 || s.Heal != 0 ||
diff --git a/internal/behavior/types.go b/internal/behavior/types.go
index 521867a..f451400 100644
--- a/internal/behavior/types.go
+++ b/internal/behavior/types.go
@@ -51,7 +51,7 @@ const (
TypeMix ActionType = "mix"
TypeConstruct ActionType = "construct"
TypeTriggerModule ActionType = "trigger_module"
- TypeUseInteraction ActionType = "use_interaction"
+ TypeInteractionSeq ActionType = "interaction_seq"
)
type Action struct {
@@ -117,12 +117,6 @@ type TalkData struct {
PendingChosen bool
}
-type UseInteractionData struct {
- Message string
- Action *StepAction
- ObjName string
-}
-
type BurnData struct {
ItemID string
ToolSpeed float64
diff --git a/internal/game/act.go b/internal/game/act.go
index 91f05a8..1fc2b1c 100644
--- a/internal/game/act.go
+++ b/internal/game/act.go
@@ -235,8 +235,8 @@ func (g *Game) AdvanceActions() {
g.advanceTalk(sess, p)
case behavior.TypeTriggerModule:
g.advanceTriggerModule(sess, p)
- case behavior.TypeUseInteraction:
- g.advanceUseInteraction(sess, p)
+ case behavior.TypeInteractionSeq:
+ g.advanceInteractionSeq(sess, p)
case behavior.TypeCombine:
g.advanceCombine(sess, p)
default:
diff --git a/internal/game/act_effects.go b/internal/game/act_effects.go
index 82d5022..9fce264 100644
--- a/internal/game/act_effects.go
+++ b/internal/game/act_effects.go
@@ -46,7 +46,7 @@ const (
// scopeGlobal ignores p entirely.
//
// The fixed effect order (matching the original executors, normalized):
-// 1. message (player/seq scopes — skipped for scopeGlobal)
+// 1. messages (player/seq scopes — skipped for scopeGlobal)
// 2. broadcast → all in room (seq/global scopes)
// 3. broadcast_global → all online (seq/global scopes)
// 4. set_global_flags (scopeGlobal only; player scopes handle globals in step 5
@@ -64,9 +64,8 @@ const (
// 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.
+// in the interaction system (applyStepAction step 1, the interaction sequencer,
+// and the advance handlers) so color tags render consistently.
func (g *Game) writePlayerMessage(sess *net.Session, msg string, playerName string, flagValue any) {
if sess == nil || msg == "" {
return
@@ -88,10 +87,14 @@ func (g *Game) applyStepAction(sess *net.Session, p *player.Player, step *behavi
playerName = p.Name
}
- // 1. message — direct to the triggering session. Skipped for scopeGlobal
- // (no per-player session is the target).
+ // 1. messages — direct to the triggering session. Skipped for scopeGlobal
+ // (no per-player session is the target). All Messages in the step emit in
+ // order; per-message delays are ignored here (they're honored by the
+ // interaction sequencer or handled as inter-step delays by on_enter/triggers).
if sc != scopeGlobal {
- g.writePlayerMessage(sess, step.Message, playerName, flagValue)
+ for _, msg := range step.Messages {
+ g.writePlayerMessage(sess, msg.Message, playerName, flagValue)
+ }
}
// 2 & 3. broadcast / broadcast_global — re-render color tags per recipient
@@ -238,9 +241,7 @@ func (g *Game) applyNodeAction(sess *net.Session, na *behavior.NodeAction) {
// 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.
+// if allowSeq is set, for on_kill which may broadcast/spawn).
//
// itemMatch (optional) gates entries that carry an item_id: for on_look it
// requires the player to have the item in inventory; for on_kill it requires
@@ -260,9 +261,22 @@ func (g *Game) applyInteraction(sess *net.Session, p *player.Player, list []beha
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)
+ g.runInteraction(sess, p, it.Action, roomID, sc, nil)
return true
}
return false
+}
+
+// runInteraction fires a single interaction's Action. If the action carries
+// delayed Messages the player is locked for the duration (see the interaction
+// sequencer); otherwise the effects apply synchronously.
+func (g *Game) runInteraction(sess *net.Session, p *player.Player, step *behavior.StepAction, roomID int, sc effectScope, flagValue any) {
+ if step == nil {
+ return
+ }
+ if len(step.Messages) > 0 {
+ g.startInteractionSeq(sess, p, step, roomID, sc, flagValue)
+ return
+ }
+ g.applyStepAction(sess, p, step, roomID, sc, flagValue)
} \ No newline at end of file
diff --git a/internal/game/act_interaction_seq.go b/internal/game/act_interaction_seq.go
new file mode 100644
index 0000000..8b515c4
--- /dev/null
+++ b/internal/game/act_interaction_seq.go
@@ -0,0 +1,97 @@
+package game
+
+import (
+ "thehouseoficarus/internal/behavior"
+ "thehouseoficarus/internal/net"
+ "thehouseoficarus/internal/player"
+)
+
+// interactionSeqData is the payload carried by p.Action.Data for an in-flight
+// delayed-message interaction sequence (on_use, on_look, on_kill, or
+// on_traverse). Messages are emitted one-by-one with per-message delays; the
+// trailing StepAction effects fire only after every message has been shown. If
+// the player is interrupted (move, quit, new action, combat) cancelAction
+// clears the pending Action and the trailing effects never run.
+type interactionSeqData struct {
+ Msgs []behavior.DelayedMessage
+ Idx int
+ Step *behavior.StepAction
+ RoomID int
+ Scope effectScope
+ FlagVal any
+}
+
+// startInteractionSeq fires the first delayed message (on the next tick,
+// matching the trigger scheduler's "delay = ticks to wait before this step
+// fires" rule with a floor of 1 so the first message ALWAYS arrives next tick
+// even when delay is 0). The trailing non-message effects run once the last
+// message has been emitted.
+func (g *Game) startInteractionSeq(sess *net.Session, p *player.Player, step *behavior.StepAction, roomID int, sc effectScope, flagValue any) {
+ g.cancelAction(p)
+
+ firstDelay := 0
+ if len(step.Messages) > 0 {
+ firstDelay = step.Messages[0].Delay
+ }
+ if firstDelay < 1 {
+ firstDelay = 1
+ }
+
+ // Strip Messages from step before stashing — applyStepAction would otherwise
+ // re-emit them when the trailing effects run.
+ clone := *step
+ clone.Messages = nil
+
+ p.Action = &behavior.Action{
+ Type: behavior.TypeInteractionSeq,
+ WaitLeft: firstDelay,
+ Data: &interactionSeqData{
+ Msgs: step.Messages,
+ Idx: 0,
+ Step: &clone,
+ RoomID: roomID,
+ Scope: sc,
+ FlagVal: flagValue,
+ },
+ }
+}
+
+// advanceInteractionSeq is the per-tick handler for TypeInteractionSeq
+// actions. Each invocation drains any zero-delay messages that are ready, emits
+// the current delayed message, then sets the wait for the next message (if any).
+// After the last message the trailing effects fire and the action is cancelled.
+func (g *Game) advanceInteractionSeq(sess *net.Session, p *player.Player) {
+ d, ok := p.Action.Data.(*interactionSeqData)
+ if !ok {
+ g.cancelAction(p)
+ return
+ }
+
+ var playerName string
+ if p != nil {
+ playerName = p.Name
+ }
+
+ // Drain zero-delay messages that have accumulated, then emit the current
+ // (delayed) one. Matches the trigger scheduler's drain-zero-delay pattern.
+ for d.Idx < len(d.Msgs) {
+ msg := d.Msgs[d.Idx]
+ d.Idx++
+ g.writePlayerMessage(sess, msg.Message, playerName, d.FlagVal)
+
+ if d.Idx >= len(d.Msgs) {
+ break
+ }
+ next := d.Msgs[d.Idx].Delay
+ if next > 0 {
+ p.Action.WaitLeft = next
+ return
+ }
+ }
+
+ // All messages emitted — fire trailing effects.
+ if d.Step != nil {
+ g.applyStepAction(sess, p, d.Step, d.RoomID, d.Scope, d.FlagVal)
+ }
+ g.cancelAction(p)
+}
diff --git a/internal/game/act_interaction_seq_test.go b/internal/game/act_interaction_seq_test.go
new file mode 100644
index 0000000..c3fc8fa
--- /dev/null
+++ b/internal/game/act_interaction_seq_test.go
@@ -0,0 +1,216 @@
+package game
+
+import (
+ "io"
+ "testing"
+
+ "thehouseoficarus/internal/behavior"
+ "thehouseoficarus/internal/combat"
+ "thehouseoficarus/internal/net"
+ "thehouseoficarus/internal/player"
+)
+
+type nopConn struct{}
+
+func (nopConn) ReadMessage() (string, error) { return "", io.EOF }
+func (nopConn) Write(p []byte) (int, error) { return len(p), nil }
+func (nopConn) Close() error { return nil }
+func (nopConn) SetEcho(bool) error { return nil }
+
+func newTestGame() *Game {
+ return &Game{
+ GlobalFlags: NewGlobalFlagStore(),
+ Combat: combat.NewTracker(),
+ }
+}
+
+func newTestGameWithStore(t *testing.T) *Game {
+ g := newTestGame()
+ g.AccountStore = player.NewAccountStore(t.TempDir())
+ return g
+}
+
+func newTestSession(p *player.Player, acc *player.Account) *net.Session {
+ if p.Options == nil {
+ p.Options = make(map[string]any)
+ }
+ return &net.Session{Player: p, Conn: nopConn{}, State: net.StateGame, Account: acc}
+}
+
+func newTestPlayer(name string) *player.Player {
+ return &player.Player{
+ Name: name,
+ RoomID: 100,
+ Skills: map[player.SkillName]int{player.Hitpoints: player.XPForLevel(99)},
+ HP: 50,
+ Options: make(map[string]any),
+ }
+}
+
+func TestRunInteractionNoMessagesAppliesInstantly(t *testing.T) {
+ g := newTestGameWithStore(t)
+ p := newTestPlayer("test")
+ sess := newTestSession(p, nil)
+
+ step := &behavior.StepAction{
+ NodeAction: behavior.NodeAction{Heal: 10},
+ }
+
+ g.runInteraction(sess, p, step, 100, scopePlayer, nil)
+
+ if p.Action != nil {
+ t.Error("expected no pending action for message-less interaction")
+ }
+ if p.HP != 60 {
+ t.Errorf("expected HP 60 after heal, got %d", p.HP)
+ }
+}
+
+func TestRunInteractionWithMessagesStartsSequence(t *testing.T) {
+ g := newTestGame()
+ p := newTestPlayer("test")
+ p.Credits = 100
+ sess := newTestSession(p, nil)
+
+ step := &behavior.StepAction{
+ NodeAction: behavior.NodeAction{Credits: 50},
+ Messages: []behavior.DelayedMessage{
+ {Message: "You feel a strange energy.", Delay: 3},
+ },
+ }
+
+ g.runInteraction(sess, p, step, 100, scopePlayer, nil)
+
+ if p.Action == nil {
+ t.Fatal("expected pending action for message-bearing interaction")
+ }
+ if p.Action.Type != behavior.TypeInteractionSeq {
+ t.Errorf("expected TypeInteractionSeq, got %v", p.Action.Type)
+ }
+ if p.Action.WaitLeft != 3 {
+ t.Errorf("expected WaitLeft 3, got %d", p.Action.WaitLeft)
+ }
+ if p.Credits != 100 {
+ t.Errorf("expected credits unchanged (100), got %d", p.Credits)
+ }
+}
+
+func TestAdvanceInteractionSeqDrainsZeroDelay(t *testing.T) {
+ g := newTestGameWithStore(t)
+ p := newTestPlayer("test")
+ sess := newTestSession(p, nil)
+
+ step := &behavior.StepAction{
+ NodeAction: behavior.NodeAction{Heal: 10},
+ Messages: []behavior.DelayedMessage{
+ {Message: "First.", Delay: 0},
+ {Message: "Second.", Delay: 0},
+ {Message: "Third.", Delay: 0},
+ },
+ }
+
+ g.startInteractionSeq(sess, p, step, 100, scopePlayer, nil)
+
+ if p.Action == nil || p.Action.WaitLeft != 1 {
+ t.Fatalf("expected WaitLeft 1 (delay 0 floored), got Action:%v", p.Action)
+ }
+
+ g.advanceInteractionSeq(sess, p)
+
+ if p.Action != nil {
+ t.Error("expected action cancelled after draining all messages")
+ }
+ if p.HP != 60 {
+ t.Errorf("expected HP 60 after heal, got %d", p.HP)
+ }
+}
+
+func TestAdvanceInteractionSeqHonorsDelay(t *testing.T) {
+ g := newTestGameWithStore(t)
+ p := newTestPlayer("test")
+ sess := newTestSession(p, nil)
+
+ step := &behavior.StepAction{
+ NodeAction: behavior.NodeAction{Heal: 10},
+ Messages: []behavior.DelayedMessage{
+ {Message: "First.", Delay: 2},
+ {Message: "Second.", Delay: 3},
+ },
+ }
+
+ g.startInteractionSeq(sess, p, step, 100, scopePlayer, nil)
+
+ if p.Action == nil || p.Action.WaitLeft != 2 {
+ t.Fatalf("expected WaitLeft 2, got %v", p.Action)
+ }
+ p.Action.WaitLeft = 1
+
+ g.advanceInteractionSeq(sess, p)
+
+ if p.Action == nil {
+ t.Fatal("expected action still running (second message pending)")
+ }
+ if p.Action.WaitLeft != 3 {
+ t.Errorf("expected WaitLeft 3 (second message delay), got %d", p.Action.WaitLeft)
+ }
+ if p.HP != 50 {
+ t.Error("heal should not have fired yet")
+ }
+
+ g.advanceInteractionSeq(sess, p)
+
+ if p.Action != nil {
+ t.Error("expected action cancelled after last message + heal")
+ }
+ if p.HP != 60 {
+ t.Errorf("expected HP 60 after heal, got %d", p.HP)
+ }
+}
+
+func TestInteractionSeqCancelPreventsTrailingEffects(t *testing.T) {
+ g := newTestGame()
+ p := newTestPlayer("test")
+ sess := newTestSession(p, nil)
+
+ step := &behavior.StepAction{
+ NodeAction: behavior.NodeAction{Heal: 10},
+ Messages: []behavior.DelayedMessage{
+ {Message: "Hello.", Delay: 5},
+ },
+ }
+
+ g.startInteractionSeq(sess, p, step, 100, scopePlayer, nil)
+
+ if p.Action == nil {
+ t.Fatal("expected pending action")
+ }
+
+ g.cancelAction(p)
+
+ if p.Action != nil {
+ t.Error("expected action to be cancelled")
+ }
+ if p.HP != 50 {
+ t.Error("heal should not have fired after cancel")
+ }
+}
+
+func TestApplyStepActionEmitsAllMessages(t *testing.T) {
+ g := newTestGameWithStore(t)
+ p := newTestPlayer("test")
+ sess := newTestSession(p, nil)
+
+ step := &behavior.StepAction{
+ NodeAction: behavior.NodeAction{Heal: 10},
+ Messages: []behavior.DelayedMessage{
+ {Message: "A.", Delay: 99},
+ {Message: "B.", Delay: 99},
+ },
+ }
+
+ g.applyStepAction(sess, p, step, 100, scopePlayer, nil)
+
+ if p.HP != 60 {
+ t.Errorf("expected HP 60 after heal, got %d", p.HP)
+ }
+}
diff --git a/internal/game/act_room.go b/internal/game/act_room.go
index 2b08bfa..a6acc21 100644
--- a/internal/game/act_room.go
+++ b/internal/game/act_room.go
@@ -52,8 +52,10 @@ 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)
+ for _, msg := range step.Messages {
+ if msg.Message != "" {
+ sess.WriteLine(msg.Message)
+ }
}
}
return
diff --git a/internal/game/act_state.go b/internal/game/act_state.go
index 3840c63..130b088 100644
--- a/internal/game/act_state.go
+++ b/internal/game/act_state.go
@@ -81,7 +81,7 @@ func (g *Game) playerActionDisplay(p *player.Player) string {
return "constructing some " + a.TargetName
case behavior.TypeTriggerModule:
return "triggering " + a.TargetName
- case behavior.TypeUseInteraction:
+ case behavior.TypeInteractionSeq:
return "using " + a.TargetName
}
}
diff --git a/internal/game/act_use_interaction.go b/internal/game/act_use_interaction.go
deleted file mode 100644
index 9c64b56..0000000
--- a/internal/game/act_use_interaction.go
+++ /dev/null
@@ -1,19 +0,0 @@
-package game
-
-import (
- "thehouseoficarus/internal/behavior"
- "thehouseoficarus/internal/net"
- "thehouseoficarus/internal/player"
-)
-
-func (g *Game) advanceUseInteraction(sess *net.Session, p *player.Player) {
- d, ok := p.Action.Data.(*behavior.UseInteractionData)
- if !ok {
- g.cancelAction(p)
- return
- }
- 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 d05146a..4391b1f 100644
--- a/internal/game/cmd_move.go
+++ b/internal/game/cmd_move.go
@@ -186,8 +186,7 @@ func (g *Game) completeMove(sess *net.Session, p *player.Player) {
// 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.runInteraction(sess, p, pendingInteraction.Action, p.RoomID, scopePlayer, nil)
}
g.AccountStore.SaveCharacter(p)
diff --git a/internal/game/cmd_use.go b/internal/game/cmd_use.go
index 322e5fc..a2cb135 100644
--- a/internal/game/cmd_use.go
+++ b/internal/game/cmd_use.go
@@ -4,7 +4,6 @@ import (
"fmt"
"strings"
- "thehouseoficarus/internal/behavior"
"thehouseoficarus/internal/item"
"thehouseoficarus/internal/net"
"thehouseoficarus/internal/player"
@@ -99,16 +98,9 @@ func (g *Game) useRoomObject(sess *net.Session, p *player.Player, input string)
continue
}
g.cancelAction(p)
- p.Action = &behavior.Action{
- Type: behavior.TypeUseInteraction,
- TargetName: def.Name,
- WaitLeft: 1,
- Data: &behavior.UseInteractionData{
- Message: ui.Message,
- Action: ui.Action,
- ObjName: def.Name,
- },
- }
+ name := def.Name
+ g.broadcastAction(sess, "%s uses the %s.", p.Name, name)
+ g.runInteraction(sess, p, ui.Action, p.RoomID, scopePlayer, nil)
return true
}
if len(def.OnUse) > 0 {
@@ -292,16 +284,9 @@ func (g *Game) doUseItemOnTarget(sess *net.Session, p *player.Player, itemAName,
continue
}
g.cancelAction(p)
- p.Action = &behavior.Action{
- Type: behavior.TypeUseInteraction,
- TargetName: def.Name,
- WaitLeft: 1,
- Data: &behavior.UseInteractionData{
- Message: ui.Message,
- Action: ui.Action,
- ObjName: def.Name,
- },
- }
+ name := def.Name
+ g.broadcastAction(sess, "%s uses the %s.", p.Name, name)
+ g.runInteraction(sess, p, ui.Action, p.RoomID, scopePlayer, nil)
return
}
diff --git a/internal/game/condition_test.go b/internal/game/condition_test.go
index f7a5dc6..59b0895 100644
--- a/internal/game/condition_test.go
+++ b/internal/game/condition_test.go
@@ -96,10 +96,10 @@ func TestCheckConditionGlobalFlag(t *testing.T) {
t.Error("global flag truthy check should pass")
}
if g.checkCondition(sess, &behavior.Condition{GlobalFlag: "gate_open", Not: true}) {
- t.Error("negated world flag should fail when set")
+ t.Error("negated global flag should fail when set")
}
if g.checkCondition(sess, &behavior.Condition{GlobalFlag: "missing"}) {
- t.Error("missing world flag should not pass")
+ t.Error("missing global flag should not pass")
}
}
diff --git a/internal/validate/checks.go b/internal/validate/checks.go
index 47bbd1a..2e70323 100644
--- a/internal/validate/checks.go
+++ b/internal/validate/checks.go
@@ -1244,6 +1244,17 @@ func validateStepAction(prefix string, step *behavior.StepAction, itemIDs map[st
return issues
}
+ for i, msg := range step.Messages {
+ if msg.Delay < 0 {
+ issues = append(issues, Issue{
+ Level: "WARN",
+ Type: "semantic",
+ Message: fmt.Sprintf("%s: messages[%d].delay is negative (%d)",
+ prefix, i, msg.Delay),
+ })
+ }
+ }
+
// Validate the embedded NodeAction subset.
if step.GiveItem != "" && !itemIDs[step.GiveItem] {
issues = append(issues, Issue{