From b3d4c616f59ad2519f3a0b77e3b47d6571cd2486 Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Thu, 9 Jul 2026 19:29:17 -0400 Subject: feat: message actions are now sequences of messages with settable delays --- internal/admin/static/admin.css | 4 + internal/admin/static/intereditor.js | 168 +++++++++++++++-------- internal/behavior/behavior.go | 29 ++-- internal/behavior/types.go | 8 +- internal/game/act.go | 4 +- internal/game/act_effects.go | 38 ++++-- internal/game/act_interaction_seq.go | 97 ++++++++++++++ internal/game/act_interaction_seq_test.go | 216 ++++++++++++++++++++++++++++++ internal/game/act_room.go | 6 +- internal/game/act_state.go | 2 +- internal/game/act_use_interaction.go | 19 --- internal/game/cmd_move.go | 3 +- internal/game/cmd_use.go | 27 +--- internal/game/condition_test.go | 4 +- internal/validate/checks.go | 11 ++ 15 files changed, 500 insertions(+), 136 deletions(-) create mode 100644 internal/game/act_interaction_seq.go create mode 100644 internal/game/act_interaction_seq_test.go delete mode 100644 internal/game/act_use_interaction.go (limited to 'internal') 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 = '
'; h += '' + esc(label) + ''; if (isSearch) { - h += '
'; + h += ''; @@ -420,7 +426,8 @@ function _ie_renderLeaf(leafType, leafVal, leafSub, not, secId, secPath, idx, gp h += ''; } if (leafType === 'player_flag' || leafType === 'global_flag') { - h += ''; + h += '='; + h += ''; } h += ''; h += ''; @@ -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 += '
Message'; - rows += ''; - rows += ''; + var rmFn = 'ie_removeMessage(this,\'' + escAttr(secId) + '\',\'' + escAttr(secPath) + '\',' + idx + ')'; + rows += '
'; + rows += '
Messages
'; + rows += '
'; + if (msgs.length === 0) { + rows += '
'; + rows += ''; + rows += '
'; + } else { + msgs.forEach(function(m, mi) { + rows += '
'; + rows += ''; + rows += '
'; + }); + } + rows += '
'; + rows += ''; rows += '
'; } @@ -477,12 +502,12 @@ function ie_renderAct(actionObj, scope, secId, idx, secPath) { rows += '
'; } else if (at.kind === 'checkbox') { rows += '
' + esc(at.label) + ''; - rows += ''; + rows += ''; rows += ''; rows += '
'; } else if (at.kind === 'search') { rows += '
' + esc(at.label) + ''; - rows += '
'; + rows += ''; rows += ''; rows += '
'; } else { @@ -503,7 +528,7 @@ function ie_renderAct(actionObj, scope, secId, idx, secPath) { if (at.kind === 'search') { rows += '
' + esc(at.label) + ''; - rows += '
'; + rows += ''; rows += ''; rows += '
'; } else { @@ -517,8 +542,8 @@ function ie_renderAct(actionObj, scope, secId, idx, secPath) { // Add effect buttons var addbar = '
'; - if (!msgPresent) { - addbar += ''; + if (!msgsPresent) { + addbar += ''; } 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 = ''; + 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{ -- cgit v1.2.3