aboutsummaryrefslogtreecommitdiff
path: root/internal/game
diff options
context:
space:
mode:
Diffstat (limited to 'internal/game')
-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
10 files changed, 355 insertions, 61 deletions
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")
}
}