From 7eab582d8a8ee2af1a6baae7baf0696c7005de2e Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Fri, 18 Sep 2026 15:16:44 -0400 Subject: fix: harden triggers, economy, casino, world data, and admin/net paths --- internal/game/act_effects.go | 86 ++++++++++++++++++++++++++++++++------------ 1 file changed, 63 insertions(+), 23 deletions(-) (limited to 'internal/game/act_effects.go') diff --git a/internal/game/act_effects.go b/internal/game/act_effects.go index cae54a5..a107320 100644 --- a/internal/game/act_effects.go +++ b/internal/game/act_effects.go @@ -51,9 +51,9 @@ const ( // 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) { +func (g *Game) applyStep(sess *net.Session, p *player.Player, step *behavior.Step, roomID int, sc effectScope, flagValue any) bool { if step == nil { - return + return true } var playerName string @@ -61,6 +61,20 @@ func (g *Game) applyStep(sess *net.Session, p *player.Player, step *behavior.Ste 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 { @@ -103,11 +117,11 @@ func (g *Game) applyStep(sess *net.Session, p *player.Player, step *behavior.Ste if step.DespawnMob != "" { g.despawnTriggerMobs(step.DespawnMob, "") } - return + return true } if p == nil { - return + return true } // 5. set_player_flags (and globals, folded in so the SetAll happens once). @@ -124,16 +138,15 @@ func (g *Game) applyStep(sess *net.Session, p *player.Player, step *behavior.Ste } } - // 7. give_item + // 7. give_item (placement was preflighted above; merges stackables and + // stamps quality so the received stack matches every other placement path). 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}) + 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.") } } @@ -159,15 +172,12 @@ func (g *Game) applyStep(sess *net.Session, p *player.Player, step *behavior.Ste } } - // 10. credits (signed; negative is gated). + // 10. credits (signed). A negative cost was preflighted for affordability, + // so the debit is unconditional here. if step.Credits != 0 { if step.Credits < 0 { - if p.Credits >= -step.Credits { - p.Credits += step.Credits - g.saveCharacter(p) - } else if sess != nil { - sess.WriteLine(fmt.Sprintf("You don't have enough credits. (Need %d, have %d)", -step.Credits, p.Credits)) - } + p.Credits += step.Credits + g.saveCharacter(p) } else { g.grantCredits(p, step.Credits) g.saveCharacter(p) @@ -197,6 +207,35 @@ func (g *Game) applyStep(sess *net.Session, p *player.Player, step *behavior.Ste 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), @@ -253,13 +292,14 @@ func (g *Game) giveTriggerDrop(sess *net.Session, p *player.Player, drop *behavi } // 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) { +// 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 + return true } - g.applyStep(sess, p, step, p.RoomID, scopePlayer, nil) + return g.applyStep(sess, p, step, p.RoomID, scopePlayer, nil) } // runTrigger runs the first matching entry in a trigger block. itemMatch -- cgit v1.2.3