package game import ( "fmt" "strconv" "thehouseoficarus/internal/behavior" "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) bool { if step == nil { return true } var playerName string if p != nil { playerName = p.Name } // Player-scope effects are transactional: preflight every cost (a required // take_item, give_item placement, and negative credits) before applying // anything, so a trade can never take/destroy an item or charge credits // without delivering its reward. Returns false when the whole step was // aborted; callers that show follow-up success text must honour it. if sc == scopePlayer && p != nil { if reason := g.stepPreflight(p, step); reason != "" { if sess != nil { sess.WriteLine(reason) } return false } } // 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) { rendered := g.expandTagsDefault(other, 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 } rendered := g.expandTagsDefault(other, 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 true } if p == nil { return true } // 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.saveCharacter(p) } // 6. take_item if step.TakeItem != "" { if p.HasItem(step.TakeItem) { p.RemoveItem(step.TakeItem, 1) g.saveCharacter(p) } } // 7. give_item (placement was preflighted above; merges stackables and // stamps quality so the received stack matches every other placement path). if step.GiveItem != "" { def, _ := g.ItemStore.Load(step.GiveItem) if placeCraftOutput(p, step.GiveItem, def, 1) { g.saveCharacter(p) } else if sess != nil { // Defensive: preflight verified placement on the owner goroutine. sess.WriteLine("Your inventory is too full to receive that.") } } // 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.saveCharacter(p) if sess != nil { sess.WriteLine(fmt.Sprintf("You regain %d hitpoints.", step.Heal)) } } // 10. credits (signed). A negative cost was preflighted for affordability, // so the debit is unconditional here. Clamp defensively anyway: a // multi-step batch that shares one tick must never drive the balance // negative, an invariant every wallet/economy path relies on. if step.Credits != 0 { if step.Credits < 0 { debit := step.Credits if p.Credits+debit < 0 { debit = -p.Credits } p.Credits += debit g.saveCharacter(p) } else { g.grantCredits(p, step.Credits) g.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.saveCharacter(p) } return true } // stepPreflight reports why a player-scope step cannot be applied in full, or // "" when every cost can be met. It is the transaction gate for trigger/talk // effects: flags, items, and credits are all-or-nothing, so a step whose // take_item is missing, whose give_item cannot be placed, or whose negative // credits are unaffordable must apply nothing at all. func (g *Game) stepPreflight(p *player.Player, step *behavior.Step) string { if p == nil { return "" } if step.TakeItem != "" && !p.HasItem(step.TakeItem) { name := step.TakeItem if def, err := g.ItemStore.Load(step.TakeItem); err == nil && def.Name != "" { name = def.Name } return fmt.Sprintf("You don't have %s.", name) } if step.GiveItem != "" { def, _ := g.ItemStore.Load(step.GiveItem) if !craftOutputFits(p, step.GiveItem, def) { return "Your inventory is too full to receive that." } } if step.Credits < 0 && p.Credits < -step.Credits { return fmt.Sprintf("You don't have enough credits. (Need %d, have %d)", -step.Credits, p.Credits) } return "" } // 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") rendered = g.expandTagsDefault(sess, 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" { g.grantCredits(p, qty) if sess != nil { sess.WriteLine(fmt.Sprintf("You receive %d credits.", qty)) } g.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, g.newInventorySlot(drop.ItemID, qty)) sess.WriteLine(fmt.Sprintf("You receive %s.", g.itemColorize(sess, lootDef, name))) g.saveCharacter(p) } // applyTalkStep fires a talk node/option's Action (a single Step) // synchronously at scopePlayer, scoped to the player's current room. It // returns false when the step's preflight failed and no effect was applied. func (g *Game) applyTalkStep(sess *net.Session, step *behavior.Step) bool { p := sess.Player if p == nil || step == nil { return true } 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 }