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 }