1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
|
package game
import (
"fmt"
"strconv"
"thehouseoficarus/internal/behavior"
"thehouseoficarus/internal/color"
"thehouseoficarus/internal/net"
"thehouseoficarus/internal/player"
)
// effectScope controls which subset of a StepAction's effects applyStepAction
// will fire. The three scopes cover the three callers of the unified executor:
//
// - scopePlayer: inline triggers (talk nodes, on_use, on_look,
// on_kill, exit traversal). Receives all
// player-targeted effects (flags, items, heal, credits,
// teleport, aps_node). Broadcast is allowed (so on_kill
// can announce to the room); broadcast_global is not
// (there's no per-tick sequence owner to author it).
// - scopePlayerSeq: on_enter steps + trigger player sequences. Same as
// scopePlayer plus broadcast_global (a sequence can
// shout to the whole world) and spawn_mob/despawn_mob
// (player-owned transient mobs).
// - scopeGlobal: trigger global sequences. No player is involved; only
// broadcasts, global flag mutations, and world-owned
// spawn/despawn fire.
type effectScope int
const (
scopePlayer effectScope = iota
scopePlayerSeq
scopeGlobal
)
// applyStepAction is the single effect executor used by every conditional
// interaction and timed step in the game: talk nodes, talk options,
// on_use/on_look/on_kill interactions, exit traversal, on_enter steps, and
// room/global trigger sequences. The previous fireEnterStep /
// executeTriggerStep / executeGlobalTriggerStep / applyNodeAction executors
// are all thin wrappers over this one.
//
// flagValue (may be nil) is substituted into %v in any message/broadcast
// template; playerName (derived from p when non-nil) is substituted into %p.
// scopeGlobal ignores p entirely.
//
// The fixed effect order (matching the original executors, normalized):
// 1. 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
// via applyFlagMutations so they aren't set twice)
// 5. set_player_flags (player/seq scopes; cascades via setPlayerFlag)
// 6. take_item (player/seq scopes)
// 7. give_item (player/seq scopes; honors 28-slot inventory limit)
// 8. heal (player/seq scopes; clamps to MaxHP)
// 9. credits (player/seq scopes; negative is gated by affordability)
// 10. spawn_mob (seq/global scopes; player-owned when seq)
// 11. despawn_mob (seq/global scopes; filtered by owner when seq)
// 12. teleport (player/seq scopes; re-runs look + on_enter of target)
// 13. aps_node (player/seq scopes; marks "aps_node_<roomID>" player flag)
// writePlayerMessage expands the message template (player name, flag value),
// colorizes inline {spec}text{/} tags under the caller's color mode, and writes
// it to sess. It is the single path used by every per-player message emission
// in the interaction system (applyStepAction step 1, 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
}
rendered := expandTemplate(msg, playerName, flagValue)
seqSpec := g.resolveColor(sess, "sequence")
mode := g.colorMode(sess)
rendered = color.ExpandTagsDefault(mode, seqSpec, rendered)
sess.WriteLine(rendered)
}
func (g *Game) applyStepAction(sess *net.Session, p *player.Player, step *behavior.StepAction, roomID int, sc effectScope, flagValue any) {
if step == nil {
return
}
var playerName string
if p != nil {
playerName = p.Name
}
// 1. 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 {
for _, msg := range step.Messages {
g.writePlayerMessage(sess, msg.Message, playerName, flagValue)
}
}
// 2 & 3. broadcast / broadcast_global — re-render color tags per recipient
// so each player sees them in their own color mode.
broadcastSpec := g.resolveColor(nil, "broadcast")
if (sc == scopePlayerSeq || sc == scopeGlobal) && g.Hub != nil {
if step.Broadcast != "" {
raw := expandTemplate(step.Broadcast, playerName, flagValue)
for _, other := range g.Hub.PlayersInRoom(roomID) {
otherMode := g.colorMode(other)
rendered := color.ExpandTagsDefault(otherMode, broadcastSpec, raw)
other.WriteLine(g.colorize(other, "broadcast", rendered))
}
}
if step.BroadcastGlobal != "" {
raw := expandTemplate(step.BroadcastGlobal, playerName, flagValue)
for _, other := range g.Hub.AllSessions() {
if other.Player == nil {
continue
}
otherMode := g.colorMode(other)
rendered := color.ExpandTagsDefault(otherMode, broadcastSpec, raw)
other.WriteLine(g.colorize(other, "broadcast", rendered))
}
}
}
// 4. set_global_flags — scopeGlobal has no player, so it sets globals here
// (cascades via OnChange). Player scopes handle globals in step 5 alongside
// player flags (applyFlagMutations sets both atomically per-scope), avoiding
// a redundant second SetAll.
if sc == scopeGlobal && len(step.SetGlobalFlags) > 0 {
g.GlobalFlags.SetAll(step.SetGlobalFlags)
}
// Global triggers have no player — stop after the world-scoped effects.
if sc == scopeGlobal {
if step.SpawnMob != nil {
g.spawnWorldTriggerMob(step.SpawnMob, roomID)
}
if step.DespawnMob != "" {
g.despawnTriggerMobs(step.DespawnMob, "")
}
return
}
// Everything below needs a player.
if p == nil {
return
}
// 5. set_player_flags — cascade via setPlayerFlag (may synchronously re-fire
// other player-flag triggers).
if len(step.SetGlobalFlags) > 0 || len(step.SetPlayerFlags) > 0 {
g.applyFlagMutations(p, step.SetGlobalFlags, step.SetPlayerFlags)
g.AccountStore.SaveCharacter(p)
}
// 6. take_item
if step.TakeItem != "" {
if p.HasItem(step.TakeItem) {
p.RemoveItem(step.TakeItem, 1)
g.AccountStore.SaveCharacter(p)
}
}
// 7. give_item
if step.GiveItem != "" {
slot := p.FirstFreeSlot()
if slot == -1 {
if sess != nil {
sess.WriteLine("Your inventory is too full to receive that.")
}
} else {
p.SetInvSlot(slot, &player.InventorySlot{ItemID: step.GiveItem, Quantity: 1})
g.AccountStore.SaveCharacter(p)
}
}
// 8. heal
if step.Heal > 0 {
p.HP += step.Heal
if maxHP := p.MaxHP(); p.HP > maxHP {
p.HP = maxHP
}
g.AccountStore.SaveCharacter(p)
if sess != nil {
sess.WriteLine(fmt.Sprintf("You regain %d hitpoints.", step.Heal))
}
}
// 9. credits (signed; negative is gated)
if step.Credits != 0 {
if step.Credits < 0 {
if p.Credits >= -step.Credits {
p.Credits += step.Credits
g.AccountStore.SaveCharacter(p)
} else if sess != nil {
sess.WriteLine(fmt.Sprintf("You don't have enough credits. (Need %d, have %d)", -step.Credits, p.Credits))
}
} else {
p.Credits += step.Credits
g.AccountStore.SaveCharacter(p)
}
}
// 10/11. spawn_mob / despawn_mob — sequence-only effects. Inline callers
// (scopePlayer) leave these empty; nothing fires.
if sc == scopePlayerSeq && step.SpawnMob != nil {
g.spawnTriggerMob(sess, p, step.SpawnMob, roomID)
}
if sc == scopePlayerSeq && step.DespawnMob != "" {
g.despawnTriggerMobs(step.DespawnMob, p.Name)
}
// 12. teleport
if step.Teleport > 0 && sess != nil {
g.teleportPlayer(sess, p, step.Teleport)
}
// 13. aps_node — marks this current room as a discovered APS node on the
// player's datapad. Kept as a dedicated effect because the room-id
// substitution is dynamic (the player's current room), not expressible
// via a static set_player_flags entry.
if step.ApsNode {
g.setPlayerFlag(p, "aps_node_"+strconv.Itoa(p.RoomID), true)
g.AccountStore.SaveCharacter(p)
}
}
// applyNodeAction is the inline-only convenience wrapper used by talk nodes,
// talk options, and any historical caller that holds a *NodeAction rather
// than a *StepAction. It wraps the NodeAction in a StepAction (sequence-only
// fields empty) and dispatches to applyStepAction with scopePlayer scoped
// to the player's current room.
func (g *Game) applyNodeAction(sess *net.Session, na *behavior.NodeAction) {
p := sess.Player
if p == nil || na == nil {
return
}
g.applyStepAction(sess, p, &behavior.StepAction{NodeAction: *na}, p.RoomID, scopePlayer, nil)
}
// applyInteraction fires the first matching entry in a list of conditional
// interactions (on_use, on_look, on_kill, exit traversal). Returns true when
// an entry matched and fired. The action runs at scopePlayer (or scopePlayerSeq
// if allowSeq is set, for on_kill which may broadcast/spawn).
//
// itemMatch (optional) gates entries that carry an item_id: for on_look it
// requires the player to have the item in inventory; for on_kill it requires
// the player to be wielding it. nil means the item_id field is ignored (used
// by callers without an item filter, e.g. exit traversal). Entries are walked
// top-to-bottom; the first whose item filter, Condition, and the first-match-
// wins rule all pass fires.
func (g *Game) applyInteraction(sess *net.Session, p *player.Player, list []behavior.Interaction, roomID int, allowSeq bool, itemMatch func(string) bool) bool {
sc := scopePlayer
if allowSeq {
sc = scopePlayerSeq
}
for _, it := range list {
if it.Item != "" && itemMatch != nil && !itemMatch(it.Item) {
continue
}
if it.Condition != nil && !g.checkCondition(sess, it.Condition) {
continue
}
g.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)
}
|