aboutsummaryrefslogtreecommitdiff
path: root/internal/game/act_effects.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/game/act_effects.go')
-rw-r--r--internal/game/act_effects.go86
1 files changed, 63 insertions, 23 deletions
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_<roomID>" 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