aboutsummaryrefslogtreecommitdiff
path: root/internal/game/act_effects.go
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-07-08 19:59:14 -0400
committerhistoria <[not public]>2026-07-08 19:59:14 -0400
commit09d325dcf5e779eab5550d3fd3377bde50101428 (patch)
treea433be903aabbf1d2eadce2aacdac12a9eb8dde0 /internal/game/act_effects.go
parent9184377301c2604e003f36426788c032fb0ca524 (diff)
downloadthehouseoficarus-09d325dcf5e779eab5550d3fd3377bde50101428.tar.gz
feat: unify on use, on look, and on kill. all support same conditions/actions now.
Diffstat (limited to 'internal/game/act_effects.go')
-rw-r--r--internal/game/act_effects.go268
1 files changed, 268 insertions, 0 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