diff options
| author | historia <[not public]> | 2026-07-08 19:59:14 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-07-08 19:59:14 -0400 |
| commit | 09d325dcf5e779eab5550d3fd3377bde50101428 (patch) | |
| tree | a433be903aabbf1d2eadce2aacdac12a9eb8dde0 /internal/game | |
| parent | 9184377301c2604e003f36426788c032fb0ca524 (diff) | |
| download | thehouseoficarus-09d325dcf5e779eab5550d3fd3377bde50101428.tar.gz | |
feat: unify on use, on look, and on kill. all support same conditions/actions now.
Diffstat (limited to 'internal/game')
| -rw-r--r-- | internal/game/act_effects.go | 268 | ||||
| -rw-r--r-- | internal/game/act_room.go | 92 | ||||
| -rw-r--r-- | internal/game/act_talk.go | 76 | ||||
| -rw-r--r-- | internal/game/act_use_interaction.go | 8 | ||||
| -rw-r--r-- | internal/game/cmd_move.go | 35 | ||||
| -rw-r--r-- | internal/game/cmd_room_insert.go | 6 | ||||
| -rw-r--r-- | internal/game/cmd_room_remove.go | 6 | ||||
| -rw-r--r-- | internal/game/cmd_use.go | 6 | ||||
| -rw-r--r-- | internal/game/cmd_verbs.go | 4 | ||||
| -rw-r--r-- | internal/game/combat_mob.go | 5 | ||||
| -rw-r--r-- | internal/game/look_target.go | 4 | ||||
| -rw-r--r-- | internal/game/sys_triggers.go | 124 |
12 files changed, 354 insertions, 280 deletions
diff --git a/internal/game/act_effects.go b/internal/game/act_effects.go new file mode 100644 index 0000000..82d5022 --- /dev/null +++ b/internal/game/act_effects.go @@ -0,0 +1,268 @@ +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. message (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, applyInteraction's +// Interaction.Message, completeMove's on_traverse Message, and +// advanceUseInteraction's d.Message) 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. message — direct to the triggering session. Skipped for scopeGlobal + // (no per-player session is the target). + if sc != scopeGlobal { + g.writePlayerMessage(sess, step.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). The +// interaction's Message is written to the player first (if non-empty), then +// the Action fires via applyStepAction. +// +// 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.writePlayerMessage(sess, it.Message, p.Name, nil) + g.applyStepAction(sess, p, it.Action, roomID, sc, nil) + return true + } + return false +}
\ No newline at end of file diff --git a/internal/game/act_room.go b/internal/game/act_room.go index da74d6e..2b08bfa 100644 --- a/internal/game/act_room.go +++ b/internal/game/act_room.go @@ -4,10 +4,9 @@ import ( "fmt" "strings" - "thehouseoficarus/internal/color" + "thehouseoficarus/internal/behavior" "thehouseoficarus/internal/net" "thehouseoficarus/internal/player" - "thehouseoficarus/internal/world" ) // enterSeq is an in-flight on_enter stepped sequence for a single player. It is @@ -16,13 +15,13 @@ type enterSeq struct { sess *net.Session playerName string roomID int - steps []world.EnterStep + steps []behavior.StepAction wait int } // runEnterSteps evaluates a room's on_enter script for the player. Steps whose // condition fails are dropped. If none of the surviving steps are "timed" -// (no delay, no flag mutation) the messages are printed synchronously. +// (any non-zero effect field) the messages are printed synchronously. // Otherwise the surviving steps become a scheduled enter sequence driven // by EnterSeqTick. func (g *Game) runEnterSteps(sess *net.Session, roomID int) { @@ -35,7 +34,7 @@ func (g *Game) runEnterSteps(sess *net.Session, roomID int) { return } - var steps []world.EnterStep + var steps []behavior.StepAction sequenced := false for _, step := range room.OnEnter { if step.Condition != nil && !g.checkCondition(sess, step.Condition) { @@ -51,6 +50,7 @@ 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) @@ -65,7 +65,7 @@ func (g *Game) runEnterSteps(sess *net.Session, roomID int) { // startEnterSeq registers a scheduled on_enter sequence and persists a marker // on the player so the sequence can be resumed on reconnect if interrupted // (disconnect, server restart). -func (g *Game) startEnterSeq(sess *net.Session, p *player.Player, roomID int, steps []world.EnterStep) { +func (g *Game) startEnterSeq(sess *net.Session, p *player.Player, roomID int, steps []behavior.StepAction) { g.cancelEnterSeq(p.Name) if p.EnterSeqRoom != roomID { p.EnterSeqRoom = roomID @@ -98,7 +98,7 @@ func (g *Game) EnterSeqTick() { continue } - var jobs []world.EnterStep + var jobs []behavior.StepAction done := false g.enterMu.Lock() @@ -123,8 +123,8 @@ func (g *Game) EnterSeqTick() { } g.enterMu.Unlock() - for _, step := range jobs { - g.fireEnterStep(seq, step) + for i := range jobs { + g.fireEnterStep(seq, &jobs[i]) } if done { g.completeEnterSeq(seq) @@ -132,71 +132,16 @@ func (g *Game) EnterSeqTick() { } } -func (g *Game) fireEnterStep(seq *enterSeq, step world.EnterStep) { +// fireEnterStep runs one on_enter step's effects via the unified +// applyStepAction executor. The room-lock check (player must still be in the +// room that the sequence is being played for) lives here, before dispatch — +// applyStepAction itself doesn't re-check it. +func (g *Game) fireEnterStep(seq *enterSeq, step *behavior.StepAction) { p := seq.sess.Player if p == nil || p.RoomID != seq.roomID { return } - seqSpec := g.resolveColor(seq.sess, "sequence") - broadcastSpec := g.resolveColor(seq.sess, "broadcast") - mode := g.colorMode(seq.sess) - if step.Message != "" { - msg := expandTemplate(step.Message, p.Name, nil) - msg = color.ExpandTagsDefault(mode, seqSpec, msg) - seq.sess.WriteLine(msg) - } - if step.Broadcast != "" && g.Hub != nil { - msg := expandTemplate(step.Broadcast, p.Name, nil) - msg = color.ExpandTagsDefault(mode, broadcastSpec, msg) - for _, other := range g.Hub.PlayersInRoom(seq.roomID) { - other.WriteLine(g.colorize(other, "broadcast", msg)) - } - } - if step.BroadcastGlobal != "" && g.Hub != nil { - msg := expandTemplate(step.BroadcastGlobal, p.Name, nil) - msg = color.ExpandTagsDefault(mode, broadcastSpec, msg) - for _, other := range g.Hub.AllSessions() { - if other.Player != nil { - other.WriteLine(g.colorize(other, "broadcast", msg)) - } - } - } - if len(step.SetGlobalFlags) > 0 || len(step.SetPlayerFlags) > 0 { - g.applyFlagMutations(p, step.SetGlobalFlags, step.SetPlayerFlags) - g.AccountStore.SaveCharacter(p) - } - if step.SpawnMob != nil { - g.spawnTriggerMob(seq.sess, p, step.SpawnMob, seq.roomID) - } - if step.DespawnMob != "" { - g.despawnTriggerMobs(step.DespawnMob, p.Name) - } - if step.GiveItem != "" { - slot := p.FirstFreeSlot() - if slot == -1 { - seq.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) - } - } - if step.TakeItem != "" { - if p.HasItem(step.TakeItem) { - p.RemoveItem(step.TakeItem, 1) - g.AccountStore.SaveCharacter(p) - } - } - if step.Heal > 0 { - p.HP += step.Heal - if maxHP := p.MaxHP(); p.HP > maxHP { - p.HP = maxHP - } - g.AccountStore.SaveCharacter(p) - seq.sess.WriteLine(fmt.Sprintf("You regain %d hitpoints.", step.Heal)) - } - if step.Teleport > 0 { - g.teleportPlayer(seq.sess, p, step.Teleport) - } + g.applyStepAction(seq.sess, p, step, seq.roomID, scopePlayerSeq, nil) } func (g *Game) completeEnterSeq(seq *enterSeq) { @@ -229,8 +174,11 @@ func (g *Game) isCharLive(name string, sess *net.Session) bool { return g.loggedInChars[name] == sess } -// applyFlagMutations sets global and player flags, shared by on_enter steps and -// exit traversal. +// applyFlagMutations sets global and player flags. It is the player-scoped +// (scopePlayer / scopePlayerSeq) flag step of applyStepAction, called once +// per non-global step that carries set_global_flags and/or set_player_flags. +// scopeGlobal sets globals directly in applyStepAction step 4 instead (so +// world-scoped cascades don't reach back into this helper). func (g *Game) applyFlagMutations(p *player.Player, setGlobalFlags, setPlayerFlags map[string]any) { g.GlobalFlags.SetAll(setGlobalFlags) if len(setPlayerFlags) > 0 { diff --git a/internal/game/act_talk.go b/internal/game/act_talk.go index ca6054c..e08fbd2 100644 --- a/internal/game/act_talk.go +++ b/internal/game/act_talk.go @@ -2,7 +2,6 @@ package game import ( "fmt" - "strconv" "strings" "thehouseoficarus/internal/behavior" @@ -331,78 +330,3 @@ func (g *Game) advanceTalk(sess *net.Session, p *player.Player) { return } } - -func (g *Game) applyNodeAction(sess *net.Session, na *behavior.NodeAction) { - p := sess.Player - if p == nil { - return - } - - if na.ReputationCost > 0 { - rep := getPlayerFlagInt(p, "assassin_reputation") - if rep < na.ReputationCost { - sess.WriteLine(fmt.Sprintf("You don't have enough Reputation. (Need %d, have %d)", na.ReputationCost, rep)) - return - } - g.setPlayerFlag(p, "assassin_reputation", rep-na.ReputationCost) - g.AccountStore.SaveCharacter(p) - } - - for k, v := range na.SetGlobalFlags { - g.GlobalFlags.Set(k, v) - } - for k, v := range na.SetPlayerFlags { - g.setPlayerFlag(p, k, v) - } - if na.TakeItem != "" { - if p.HasItem(na.TakeItem) { - p.RemoveItem(na.TakeItem, 1) - } - } - if na.GiveItem != "" { - slot := p.FirstFreeSlot() - if slot == -1 { - sess.WriteLine("Your inventory is too full to receive that.") - } else { - p.SetInvSlot(slot, &player.InventorySlot{ItemID: na.GiveItem, Quantity: 1}) - g.AccountStore.SaveCharacter(p) - } - } - if na.Teleport > 0 { - p.RoomID = na.Teleport - p.Stats.RecordRoomVisit(na.Teleport) - g.AccountStore.SaveCharacter(p) - g.World.SeedGroundItems(p.RoomID) - g.seedRoomMobs(p.RoomID) - g.seedRoomObjects(p.RoomID) - if g.Hub != nil { - g.Hub.EnterRoom(sess, p.RoomID) - } - g.doLook(sess) - g.runEnterSteps(sess, p.RoomID) - } - if na.Heal > 0 { - p.HP += na.Heal - if maxHP := p.MaxHP(); p.HP > maxHP { - p.HP = maxHP - } - g.AccountStore.SaveCharacter(p) - sess.WriteLine(fmt.Sprintf("You regain %d hitpoints.", na.Heal)) - } - if na.Credits != 0 { - if na.Credits < 0 { - if p.Credits >= -na.Credits { - p.Credits += na.Credits - g.AccountStore.SaveCharacter(p) - } else { - sess.WriteLine(fmt.Sprintf("You don't have enough credits. (Need %d, have %d)", -na.Credits, p.Credits)) - } - } else { - p.Credits += na.Credits - g.AccountStore.SaveCharacter(p) - } - } - if na.ApsNode { - g.setPlayerFlag(p, "aps_node_"+strconv.Itoa(p.RoomID), true) - } -} diff --git a/internal/game/act_use_interaction.go b/internal/game/act_use_interaction.go index b96aaa3..9c64b56 100644 --- a/internal/game/act_use_interaction.go +++ b/internal/game/act_use_interaction.go @@ -12,12 +12,8 @@ func (g *Game) advanceUseInteraction(sess *net.Session, p *player.Player) { g.cancelAction(p) return } - if d.Message != "" { - sess.WriteLine(d.Message) - } - if d.Action != nil { - g.applyNodeAction(sess, d.Action) - } + 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 2f27eed..d05146a 100644 --- a/internal/game/cmd_move.go +++ b/internal/game/cmd_move.go @@ -4,11 +4,13 @@ import ( "fmt" "strings" + "thehouseoficarus/internal/behavior" "thehouseoficarus/internal/engine" "thehouseoficarus/internal/item" "thehouseoficarus/internal/net" "thehouseoficarus/internal/object" "thehouseoficarus/internal/player" + "thehouseoficarus/internal/world" ) func (g *Game) doMove(sess *net.Session, dir string, multiplier float64) { @@ -67,8 +69,11 @@ func (g *Game) doMove(sess *net.Session, dir string, multiplier float64) { } } - p.MovePendingGlobalFlags = exitDef.SetGlobalFlags - p.MovePendingPlayerFlags = exitDef.SetPlayerFlags + // Stash the first on_traverse interaction whose condition passes — it will + // fire in completeMove once the player arrives. ExitDef.Condition (above, + // evaluated by exitDisplayState) gates whether the exit is passable at + // all; each on_traverse entry has its own additional Condition gate. + p.MovePendingInteraction = g.matchExitInteraction(sess, p, exitDef) inCombat := g.Combat.Get(p.Name) != nil @@ -96,6 +101,21 @@ func (g *Game) doMove(sess *net.Session, dir string, multiplier float64) { } } +// matchExitInteraction returns the first on_traverse Interaction on exitDef +// whose Condition (if any) passes for this player, or nil if the exit has no +// on_traverse entries. The matched interaction's Action fires (with its optional +// Message) once the player arrives in the target room. +func (g *Game) matchExitInteraction(sess *net.Session, p *player.Player, exitDef world.ExitDef) *behavior.Interaction { + for i := range exitDef.OnTraverse { + it := &exitDef.OnTraverse[i] + if it.Condition != nil && !g.checkCondition(sess, it.Condition) { + continue + } + return it + } + return nil +} + var gracefulItems = map[string]bool{ "graceful_hat": true, "graceful_torso": true, @@ -132,8 +152,7 @@ func (g *Game) moveTicks(p *player.Player, multiplier float64) int { func (g *Game) completeMove(sess *net.Session, p *player.Player) { exitDir := p.MoveDirection targetID := p.MoveTarget - pendingGlobalFlags := p.MovePendingGlobalFlags - pendingPlayerFlags := p.MovePendingPlayerFlags + pendingInteraction := p.MovePendingInteraction p.ClearMoveState() @@ -163,8 +182,12 @@ func (g *Game) completeMove(sess *net.Session, p *player.Player) { if p.EnterSeqRoom != 0 && p.EnterSeqRoom == oldRoom { p.EnterSeqRoom = 0 } - if len(pendingGlobalFlags) > 0 || len(pendingPlayerFlags) > 0 { - g.applyFlagMutations(p, pendingGlobalFlags, pendingPlayerFlags) + // Fire the on_traverse interaction (if any matched at classification + // 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.AccountStore.SaveCharacter(p) diff --git a/internal/game/cmd_room_insert.go b/internal/game/cmd_room_insert.go index b248e49..15b05ef 100644 --- a/internal/game/cmd_room_insert.go +++ b/internal/game/cmd_room_insert.go @@ -138,8 +138,7 @@ func (g *Game) roomInsert(sess *net.Session, args []string) { Room: newID, Condition: exitDef.Condition, BlockedMessage: exitDef.BlockedMessage, - SetGlobalFlags: exitDef.SetGlobalFlags, - SetPlayerFlags: exitDef.SetPlayerFlags, + OnTraverse: exitDef.OnTraverse, Hidden: exitDef.Hidden, AlwaysBlocked: exitDef.AlwaysBlocked, } @@ -158,8 +157,7 @@ func (g *Game) roomInsert(sess *net.Session, args []string) { Room: newID, Condition: targetExit.Condition, BlockedMessage: targetExit.BlockedMessage, - SetGlobalFlags: targetExit.SetGlobalFlags, - SetPlayerFlags: targetExit.SetPlayerFlags, + OnTraverse: targetExit.OnTraverse, Hidden: targetExit.Hidden, AlwaysBlocked: targetExit.AlwaysBlocked, } diff --git a/internal/game/cmd_room_remove.go b/internal/game/cmd_room_remove.go index 28433ff..187ec34 100644 --- a/internal/game/cmd_room_remove.go +++ b/internal/game/cmd_room_remove.go @@ -171,8 +171,7 @@ func (g *Game) roomRemove(sess *net.Session, args []string) { Room: cID, Condition: aExit.Condition, BlockedMessage: aExit.BlockedMessage, - SetGlobalFlags: aExit.SetGlobalFlags, - SetPlayerFlags: aExit.SetPlayerFlags, + OnTraverse: aExit.OnTraverse, Hidden: aExit.Hidden, AlwaysBlocked: aExit.AlwaysBlocked, } @@ -191,8 +190,7 @@ func (g *Game) roomRemove(sess *net.Session, args []string) { Room: aID, Condition: cOppExit.Condition, BlockedMessage: cOppExit.BlockedMessage, - SetGlobalFlags: cOppExit.SetGlobalFlags, - SetPlayerFlags: cOppExit.SetPlayerFlags, + OnTraverse: cOppExit.OnTraverse, Hidden: cOppExit.Hidden, AlwaysBlocked: cOppExit.AlwaysBlocked, } diff --git a/internal/game/cmd_use.go b/internal/game/cmd_use.go index 22fc169..322e5fc 100644 --- a/internal/game/cmd_use.go +++ b/internal/game/cmd_use.go @@ -91,7 +91,7 @@ func (g *Game) useRoomObject(sess *net.Session, p *player.Player, input string) g.startAction(sess, bt, def.Name) return true } - for _, ui := range def.UseInteractions { + for _, ui := range def.OnUse { if ui.Item != "" { continue } @@ -111,7 +111,7 @@ func (g *Game) useRoomObject(sess *net.Session, p *player.Player, input string) } return true } - if len(def.UseInteractions) > 0 { + if len(def.OnUse) > 0 { sess.WriteLine(fmt.Sprintf("Use what on the %s?", def.Name)) return true } @@ -284,7 +284,7 @@ func (g *Game) doUseItemOnTarget(sess *net.Session, p *player.Player, itemAName, return } - for _, ui := range def.UseInteractions { + for _, ui := range def.OnUse { if ui.Item != itemAID { continue } diff --git a/internal/game/cmd_verbs.go b/internal/game/cmd_verbs.go index e32910a..6c20004 100644 --- a/internal/game/cmd_verbs.go +++ b/internal/game/cmd_verbs.go @@ -75,7 +75,7 @@ func (g *Game) executeVerbs(sess *net.Session, args []string, rawInput string) { addUnique(§ionObjs, &seen, "talk "+name) } - for _, ui := range def.UseInteractions { + for _, ui := range def.OnUse { if ui.Item == "" && (ui.Condition == nil || g.checkCondition(sess, ui.Condition)) { addUnique(§ionObjs, &seen, "use "+name) break @@ -106,7 +106,7 @@ func (g *Game) executeVerbs(sess *net.Session, args []string, rawInput string) { } } - for _, ui := range def.UseInteractions { + for _, ui := range def.OnUse { if ui.Item != "" && (ui.Condition == nil || g.checkCondition(sess, ui.Condition)) { if itemDef, err := g.ItemStore.Load(ui.Item); err == nil { addUnique(§ionObjs, &seen, "use "+itemDef.Name+" on "+name) diff --git a/internal/game/combat_mob.go b/internal/game/combat_mob.go index a3fc040..0395049 100644 --- a/internal/game/combat_mob.go +++ b/internal/game/combat_mob.go @@ -217,6 +217,11 @@ func (g *Game) endCombat(sess *net.Session, p *player.Player, mob *world.MobInst g.announceKill(sess, p, mob, isTask) g.awardKillDrops(sess, p, mob, isTask) + // On Kill fires for both combat kills and task-mob completion (draining a + // task mob's HP to zero is the "kill" that completes the work). itemMatch + // is the "wielding" gate — on_kill entries with an item_id only fire if + // the player currently has that item equipped in a weapon-hand slot. + g.applyInteraction(sess, p, mob.OnKill, p.RoomID, true, p.IsWielding) g.scheduleMobRespawn(mob) g.writePrompt(sess) } diff --git a/internal/game/look_target.go b/internal/game/look_target.go index 8b0a57c..24a1df5 100644 --- a/internal/game/look_target.go +++ b/internal/game/look_target.go @@ -154,8 +154,8 @@ func (g *Game) doLookTarget(sess *net.Session, input string) { sess.WriteLine(color.ExpandTags(g.colorMode(sess), objDescText)) } - if def.OnLook != nil { - g.applyNodeAction(sess, def.OnLook) + if len(def.OnLook) > 0 { + g.applyInteraction(sess, p, def.OnLook, p.RoomID, false, p.HasItem) } if def.Safespot != nil { diff --git a/internal/game/sys_triggers.go b/internal/game/sys_triggers.go index b542fdd..2e8b877 100644 --- a/internal/game/sys_triggers.go +++ b/internal/game/sys_triggers.go @@ -4,7 +4,7 @@ import ( "fmt" "strings" - "thehouseoficarus/internal/color" + "thehouseoficarus/internal/behavior" "thehouseoficarus/internal/engine" "thehouseoficarus/internal/net" "thehouseoficarus/internal/player" @@ -82,7 +82,7 @@ func (g *Game) processPlayerTriggerSequences() { continue } - var jobs []world.TriggerStep + var jobs []behavior.StepAction for len(seq.Steps) > 0 && seq.Wait <= 0 { step := seq.Steps[0] seq.Steps = seq.Steps[1:] @@ -92,9 +92,8 @@ func (g *Game) processPlayerTriggerSequences() { } } - for _, step := range jobs { - s := step - g.executeTriggerStep(sess, p, &s, seq.RoomID, seq.FlagValue) + for i := range jobs { + g.executeTriggerStep(sess, p, &jobs[i], seq.RoomID, seq.FlagValue) } if len(seq.Steps) == 0 { @@ -114,7 +113,7 @@ func (g *Game) processGlobalTriggerSequences() { continue } - var jobs []world.TriggerStep + var jobs []behavior.StepAction for len(seq.Steps) > 0 && seq.Wait <= 0 { step := seq.Steps[0] seq.Steps = seq.Steps[1:] @@ -124,8 +123,8 @@ func (g *Game) processGlobalTriggerSequences() { } } - for _, step := range jobs { - g.executeGlobalTriggerStep(&step, seq.RoomID, seq.FlagValue) + for i := range jobs { + g.executeGlobalTriggerStep(&jobs[i], seq.RoomID, seq.FlagValue) } if len(seq.Steps) == 0 { @@ -134,106 +133,21 @@ func (g *Game) processGlobalTriggerSequences() { } } -func (g *Game) executeTriggerStep(sess *net.Session, p *player.Player, step *world.TriggerStep, roomID int, flagValue any) { - seqSpec := g.resolveColor(sess, "sequence") - broadcastSpec := g.resolveColor(sess, "broadcast") - playerMode := g.colorMode(sess) - if step.Message != "" { - msg := expandTemplate(step.Message, p.Name, flagValue) - msg = color.ExpandTagsDefault(playerMode, seqSpec, msg) - sess.WriteLine(msg) - } - if step.Broadcast != "" && g.Hub != nil { - msg := expandTemplate(step.Broadcast, p.Name, flagValue) - for _, other := range g.Hub.PlayersInRoom(roomID) { - otherMode := g.colorMode(other) - rendered := color.ExpandTagsDefault(otherMode, broadcastSpec, msg) - other.WriteLine(g.colorize(other, "broadcast", rendered)) - } - } - if step.BroadcastGlobal != "" && g.Hub != nil { - msg := expandTemplate(step.BroadcastGlobal, p.Name, flagValue) - for _, other := range g.Hub.AllSessions() { - if other.Player != nil { - otherMode := g.colorMode(other) - rendered := color.ExpandTagsDefault(otherMode, broadcastSpec, msg) - other.WriteLine(g.colorize(other, "broadcast", rendered)) - } - } - } - if len(step.SetGlobalFlags) > 0 { - g.GlobalFlags.SetAll(step.SetGlobalFlags) - } - if len(step.SetPlayerFlags) > 0 { - for k, v := range step.SetPlayerFlags { - g.setPlayerFlag(p, k, v) - } - g.AccountStore.SaveCharacter(p) - } - if step.GiveItem != "" { - slot := p.FirstFreeSlot() - if slot == -1 { - 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) - } - } - if step.TakeItem != "" { - if p.HasItem(step.TakeItem) { - p.RemoveItem(step.TakeItem, 1) - g.AccountStore.SaveCharacter(p) - } - } - if step.Heal > 0 { - p.HP += step.Heal - if maxHP := p.MaxHP(); p.HP > maxHP { - p.HP = maxHP - } - g.AccountStore.SaveCharacter(p) - sess.WriteLine(fmt.Sprintf("You regain %d hitpoints.", step.Heal)) - } - if step.SpawnMob != nil { - g.spawnTriggerMob(sess, p, step.SpawnMob, roomID) - } - if step.DespawnMob != "" { - g.despawnTriggerMobs(step.DespawnMob, p.Name) - } - if step.Teleport > 0 { - g.teleportPlayer(sess, p, step.Teleport) +// executeTriggerStep is a thin wrapper over applyStepAction for trigger player +// sequences (full player-targeted effects + sequence/broadcast/spawn fields). +func (g *Game) executeTriggerStep(sess *net.Session, p *player.Player, step *behavior.StepAction, roomID int, flagValue any) { + // On every step, evaluate the per-step condition (a trigger can now gate + // individual steps via the Condition field inherited from StepAction). + if step.Condition != nil && !g.checkCondition(sess, step.Condition) { + return } + g.applyStepAction(sess, p, step, roomID, scopePlayerSeq, flagValue) } -func (g *Game) executeGlobalTriggerStep(step *world.TriggerStep, roomID int, flagValue any) { - if step.Broadcast != "" && g.Hub != nil { - msg := expandTemplate(step.Broadcast, "", flagValue) - broadcastSpec := g.resolveColor(nil, "broadcast") - for _, other := range g.Hub.PlayersInRoom(roomID) { - otherMode := g.colorMode(other) - rendered := color.ExpandTagsDefault(otherMode, broadcastSpec, msg) - other.WriteLine(g.colorize(other, "broadcast", rendered)) - } - } - if step.BroadcastGlobal != "" && g.Hub != nil { - msg := expandTemplate(step.BroadcastGlobal, "", flagValue) - broadcastSpec := g.resolveColor(nil, "broadcast") - for _, other := range g.Hub.AllSessions() { - if other.Player != nil { - otherMode := g.colorMode(other) - rendered := color.ExpandTagsDefault(otherMode, broadcastSpec, msg) - other.WriteLine(g.colorize(other, "broadcast", rendered)) - } - } - } - if len(step.SetGlobalFlags) > 0 { - g.GlobalFlags.SetAll(step.SetGlobalFlags) - } - if step.SpawnMob != nil { - g.spawnWorldTriggerMob(step.SpawnMob, roomID) - } - if step.DespawnMob != "" { - g.despawnTriggerMobs(step.DespawnMob, "") - } +// executeGlobalTriggerStep is a thin wrapper over applyStepAction for global +// trigger sequences (no player; only broadcasts/global-flags/spawn/despawn). +func (g *Game) executeGlobalTriggerStep(step *behavior.StepAction, roomID int, flagValue any) { + g.applyStepAction(nil, nil, step, roomID, scopeGlobal, flagValue) } func (g *Game) spawnTriggerMob(sess *net.Session, p *player.Player, cfg *world.SpawnMobConfig, roomID int) { |
