package game import ( "fmt" "strconv" "thehouseoficarus/internal/behavior" "thehouseoficarus/internal/color" "thehouseoficarus/internal/net" "thehouseoficarus/internal/player" ) // effectScope controls which subset of a Step's effects applyStep will fire. // // - scopePlayer: every player-bound trigger (on_use, on_look, on_kill, // on_enter, on_exit, on_traverse, on_flag_change). Receives // the full effect vocabulary: messages, broadcast, // broadcast_global, flags, items, heal, credits, mob // spawn/despawn (player-owned), teleport, aps_node. // - scopeGlobal: global-flag triggers (on_global_flag_change) with no // originating player. Only broadcasts, global flag // mutations, and world-owned mob spawn/despawn fire. type effectScope int const ( scopePlayer effectScope = iota scopeGlobal ) // applyStep is the single effect executor used by every trigger in the game: // talk nodes/options, on_use/on_look/on_kill/on_enter/on_exit/on_traverse // triggers, and on_flag_change/on_global_flag_change sequences. scopeGlobal // ignores the player entirely. // // flagValue (may be nil) is substituted into %v in any message/broadcast // template; the player's name is substituted into %p. // // The fixed effect order: // 1. messages (player scope — skipped for scopeGlobal) // 2. broadcast -> all in room (both scopes) // 3. broadcast_global -> all online (both scopes) // 4. set_global_flags (scopeGlobal sets here; player scope folds globals // into step 5 via applyFlagMutations so they aren't set twice) // 5. set_player_flags (player scope; cascades via setPlayerFlag) // 6. take_item (player scope) // 7. give_item (player scope; honors 28-slot inventory limit) // 8. drop_table (player scope; resolves weighted drops into inventory, // overflow to ground; supports credits pseudo-item) // 9. heal (player scope; clamps to MaxHP) // 10. credits (player scope; negative is gated by affordability) // 11. spawn_mob (player scope: player-owned; global scope: world-owned) // 12. despawn_mob (player scope: owner-filtered; global scope: all) // 13. teleport (player scope; re-runs look + on_enter of target) // 14. aps_node (player scope; marks "aps_node_" player flag) func (g *Game) applyStep(sess *net.Session, p *player.Player, step *behavior.Step, 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. if sc != scopeGlobal { for _, msg := range step.Messages { g.writePlayerMessage(sess, msg, playerName, flagValue) } } // 2 & 3. broadcast / broadcast_global — re-render color tags per recipient. if g.Hub != nil && (step.Broadcast != "" || step.BroadcastGlobal != "") { broadcastSpec := g.resolveColor(nil, "broadcast") 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, set globals here. if sc == scopeGlobal && len(step.SetGlobalFlags) > 0 { g.GlobalFlags.SetAll(step.SetGlobalFlags) } if sc == scopeGlobal { // 11/12. world-owned spawn/despawn. if step.SpawnMob != nil { g.spawnWorldTriggerMob(step.SpawnMob, roomID) } if step.DespawnMob != "" { g.despawnTriggerMobs(step.DespawnMob, "") } return } if p == nil { return } // 5. set_player_flags (and globals, folded in so the SetAll happens once). 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. drop_table — weighted loot into inventory, overflow to ground. if len(step.DropTable) > 0 { for _, d := range behavior.ResolveDropList(g.DataDir, step.DropTable) { if d.ItemID == "" { continue } g.giveTriggerDrop(sess, p, &d) } } // 9. 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)) } } // 10. 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) } } // 11. casino game join. The table is resolved from the player's current room. if step.StartGame != "" { g.startCasinoGame(sess, p, step.StartGame) } // 12/13. spawn_mob / despawn_mob (player-owned / owner-filtered). if step.SpawnMob != nil { g.spawnTriggerMob(sess, p, step.SpawnMob, roomID) } if step.DespawnMob != "" { g.despawnTriggerMobs(step.DespawnMob, p.Name) } // 13. teleport if step.Teleport > 0 && sess != nil { g.teleportPlayer(sess, p, step.Teleport) } // 14. aps_node — dynamic room-id flag, kept as a dedicated effect. if step.ApsNode { g.setPlayerFlag(p, "aps_node_"+strconv.Itoa(p.RoomID), true) g.AccountStore.SaveCharacter(p) } } // 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 trigger system. 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) } // giveTriggerDrop awards a resolved DropEntry to a player. Item goes to the // first free inventory slot; if inventory is full it falls to the ground // (reserved for the player). "credits" pseudo-item adds credits directly. func (g *Game) giveTriggerDrop(sess *net.Session, p *player.Player, drop *behavior.DropEntry) { if p == nil { return } qty := drop.Quantity if qty <= 0 { qty = 1 } if drop.ItemID == "credits" { p.Credits += qty if sess != nil { sess.WriteLine(fmt.Sprintf("You receive %d credits.", qty)) } g.AccountStore.SaveCharacter(p) return } name := drop.ItemID lootDef, _ := g.ItemStore.Load(drop.ItemID) if lootDef != nil && lootDef.Name != "" { name = lootDef.Name } freeSlot := p.FirstFreeSlot() if freeSlot == -1 { g.World.AddReservedItem(p.RoomID, drop.ItemID, qty, p.Name) sess.WriteLine(fmt.Sprintf("You receive %s. It falls to the ground.", g.itemColorize(sess, lootDef, name))) return } p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: drop.ItemID, Quantity: qty}) sess.WriteLine(fmt.Sprintf("You receive %s.", g.itemColorize(sess, lootDef, name))) g.AccountStore.SaveCharacter(p) } // applyTalkStep fires a talk node/option's Action (a single Step) // synchronously at scopePlayer, scoped to the player's current room. func (g *Game) applyTalkStep(sess *net.Session, step *behavior.Step) { p := sess.Player if p == nil || step == nil { return } g.applyStep(sess, p, step, p.RoomID, scopePlayer, nil) } // runTrigger runs the first matching entry in a trigger block. itemMatch // (optional) gates entries that carry an ItemID: for on_look it requires the // player to hold the item; for on_kill it requires wielding it. nil means the // ItemID field is ignored (on_enter/on_exit/on_traverse/on_flag_change). The // first entry whose item filter and Condition pass wins; its Steps run as a // scripted sequence. roomLocked controls the per-step room-lock: when true, // steps are skipped if the player has left roomID (used by on_enter so a // cutscene doesn't play into the wrong room). Returns true if a trigger // matched and fired. func (g *Game) runTrigger(sess *net.Session, p *player.Player, list []behavior.Trigger, roomID int, itemMatch func(string) bool, roomLocked bool) bool { for i := range list { t := &list[i] if t.ItemID != "" && itemMatch != nil && !itemMatch(t.ItemID) { continue } if t.Condition != nil && !g.checkCondition(sess, t.Condition) { continue } g.startSequence(sess, p, t.Steps, roomID, scopePlayer, nil, t.Lock, roomLocked, verbSeqKey(p, t.ItemID)) return true } return false }