diff options
Diffstat (limited to 'internal/game')
| -rw-r--r-- | internal/game/act.go | 4 | ||||
| -rw-r--r-- | internal/game/act_effects.go | 38 | ||||
| -rw-r--r-- | internal/game/act_interaction_seq.go | 97 | ||||
| -rw-r--r-- | internal/game/act_interaction_seq_test.go | 216 | ||||
| -rw-r--r-- | internal/game/act_room.go | 6 | ||||
| -rw-r--r-- | internal/game/act_state.go | 2 | ||||
| -rw-r--r-- | internal/game/act_use_interaction.go | 19 | ||||
| -rw-r--r-- | internal/game/cmd_move.go | 3 | ||||
| -rw-r--r-- | internal/game/cmd_use.go | 27 | ||||
| -rw-r--r-- | internal/game/condition_test.go | 4 |
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") } } |
