From 09d325dcf5e779eab5550d3fd3377bde50101428 Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Wed, 8 Jul 2026 19:59:14 -0400 Subject: feat: unify on use, on look, and on kill. all support same conditions/actions now. --- internal/game/act_effects.go | 268 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 268 insertions(+) create mode 100644 internal/game/act_effects.go (limited to 'internal/game/act_effects.go') 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_" 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 -- cgit v1.2.3