From 153acb7f644d755c45533b7c5311e3ffa47e8973 Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Fri, 18 Sep 2026 05:31:09 -0400 Subject: fix: harden engine across casino, economy, concurrency, security, and content --- internal/game/cmd_get.go | 132 ++++++++++++++++++++++++++++++++--------------- 1 file changed, 89 insertions(+), 43 deletions(-) (limited to 'internal/game/cmd_get.go') diff --git a/internal/game/cmd_get.go b/internal/game/cmd_get.go index bc48394..a2ad137 100644 --- a/internal/game/cmd_get.go +++ b/internal/game/cmd_get.go @@ -4,6 +4,7 @@ import ( "fmt" "strings" + "thehouseoficarus/internal/casino" "thehouseoficarus/internal/net" "thehouseoficarus/internal/player" ) @@ -74,19 +75,21 @@ func (g *Game) doGet(sess *net.Session, input string) { // doGetAllNamed). See TestPickupMergesIntoFullInventory. if def != nil && def.Stackable { + quality, maxQuality := g.pickedStackProps(itemID) for i := 0; i < 28; i++ { slot := p.InvSlot(i) - if slot != nil && slot.ItemID == itemID { - removed, ok := g.World.RemoveReservedItem(p.RoomID, itemID, qty, p.Name) - if !ok { - sess.WriteLine("That's not yours!") - return - } - slot.Quantity += removed - g.saveCharacter(p) - sess.WriteLine(fmt.Sprintf("You pick up %d x %s. (now %d)", qty, g.itemColorize(sess, def, def.Name), slot.Quantity)) + if slot == nil || slot.ItemID != itemID || slot.Quality != quality || slot.MaxQuality != maxQuality { + continue + } + removed, ok := g.World.RemoveReservedItem(p.RoomID, itemID, qty, p.Name) + if !ok { + sess.WriteLine("That's not yours!") return } + slot.Quantity += removed + g.saveCharacter(p) + sess.WriteLine(fmt.Sprintf("You pick up %d x %s. (now %d)", qty, g.itemColorize(sess, def, def.Name), slot.Quantity)) + return } freeSlot := p.FirstFreeSlot() if freeSlot == -1 { @@ -116,7 +119,7 @@ func (g *Game) doGet(sess *net.Session, input string) { if _, ok := g.World.RemoveReservedItem(p.RoomID, itemID, 1, p.Name); !ok { break } - p.SetInvSlot(fs, &player.InventorySlot{ItemID: itemID, Quantity: 1}) + p.SetInvSlot(fs, g.newInventorySlot(itemID, 1)) picked++ } @@ -192,19 +195,21 @@ func (g *Game) doGetAllNamed(sess *net.Session, input string) { coloredName := g.itemColorize(sess, def, name) if def != nil && def.Stackable { + quality, maxQuality := g.pickedStackProps(itemID) for i := 0; i < 28; i++ { slot := p.InvSlot(i) - if slot != nil && slot.ItemID == itemID { - removed, ok := g.World.RemoveReservedItem(p.RoomID, itemID, wanted, p.Name) - if !ok { - sess.WriteLine("That's not yours!") - return - } - slot.Quantity += removed - g.saveCharacter(p) - sess.WriteLine(fmt.Sprintf("You pick up %d x %s. (now %d)", removed, coloredName, slot.Quantity)) + if slot == nil || slot.ItemID != itemID || slot.Quality != quality || slot.MaxQuality != maxQuality { + continue + } + removed, ok := g.World.RemoveReservedItem(p.RoomID, itemID, wanted, p.Name) + if !ok { + sess.WriteLine("That's not yours!") return } + slot.Quantity += removed + g.saveCharacter(p) + sess.WriteLine(fmt.Sprintf("You pick up %d x %s. (now %d)", removed, coloredName, slot.Quantity)) + return } freeSlot := p.FirstFreeSlot() if freeSlot == -1 { @@ -216,7 +221,7 @@ func (g *Game) doGetAllNamed(sess *net.Session, input string) { sess.WriteLine("That's not yours!") return } - p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: itemID, Quantity: removed}) + p.SetInvSlot(freeSlot, g.newInventorySlot(itemID, removed)) g.saveCharacter(p) if removed == 1 { sess.WriteLine(fmt.Sprintf("You pick up a %s.", coloredName)) @@ -236,7 +241,7 @@ func (g *Game) doGetAllNamed(sess *net.Session, input string) { if !ok { break } - p.SetInvSlot(fs, &player.InventorySlot{ItemID: itemID, Quantity: 1}) + p.SetInvSlot(fs, g.newInventorySlot(itemID, 1)) picked++ } @@ -265,15 +270,24 @@ func (g *Game) doGetAll(sess *net.Session) { } var picked []string + creditsCapped := false for itemID, qty := range ground { if qty <= 0 { continue } if itemID == "credits" { - // Same capped-removal rule as pickupCredits: the request is a - // sentinel above any possible stock, not an exact quantity. - taken := g.World.RemoveAvailableItem(p.RoomID, itemID, 999999, p.Name) + // Same capped-removal rule as pickupCredits: only the balance + // headroom is taken, so credits at the cap stay on the ground + // instead of being destroyed by the clamped grant. + room := creditHeadroom(p) + taken := 0 + if room > 0 { + taken = g.World.RemoveAvailableItem(p.RoomID, itemID, room, p.Name) + } if taken <= 0 { + if room <= 0 { + creditsCapped = true + } continue } g.grantCredits(p, taken) @@ -289,21 +303,23 @@ func (g *Game) doGetAll(sess *net.Session) { for qty > 0 { if def != nil && def.Stackable { stacked := false + quality, maxQuality := g.pickedStackProps(itemID) for i := 0; i < 28; i++ { slot := p.InvSlot(i) - if slot != nil && slot.ItemID == itemID { - _, ok := g.World.RemoveReservedItem(p.RoomID, itemID, qty, p.Name) - if !ok { - qty = 0 - stacked = true - break - } - slot.Quantity += qty - picked = append(picked, fmt.Sprintf("%s (now %d)", g.itemColorize(sess, def, def.Name), slot.Quantity)) - stacked = true + if slot == nil || slot.ItemID != itemID || slot.Quality != quality || slot.MaxQuality != maxQuality { + continue + } + _, ok := g.World.RemoveReservedItem(p.RoomID, itemID, qty, p.Name) + if !ok { qty = 0 + stacked = true break } + slot.Quantity += qty + picked = append(picked, fmt.Sprintf("%s (now %d)", g.itemColorize(sess, def, def.Name), slot.Quantity)) + stacked = true + qty = 0 + break } if stacked { break @@ -349,7 +365,7 @@ func (g *Game) doGetAll(sess *net.Session) { if !ok { break } - p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: itemID, Quantity: take}) + p.SetInvSlot(freeSlot, g.newInventorySlot(itemID, take)) name := itemID if def != nil { name = def.Name @@ -361,20 +377,28 @@ func (g *Game) doGetAll(sess *net.Session) { g.saveCharacter(p) - if len(picked) == 0 { - sess.WriteLine("Your inventory is full.") - } else { + if creditsCapped { + sess.WriteLine(g.colorize(sess, "currency_pickup", "Your purse can't hold any more credits.")) + } + switch { + case len(picked) > 0: sess.Write("You picked up: ") formatPickupList(sess, picked) + case !creditsCapped: + sess.WriteLine("Your inventory is full.") } } func (g *Game) pickupCredits(sess *net.Session, p *player.Player, itemID string) { - // Take whatever credits are accessible: a sentinel quantity far above - // any possible stock. RemoveReservedItem is all-or-nothing and would - // reject a request larger than the stack; RemoveAvailableItem caps the - // removal at what is actually on the ground. - qty := g.World.RemoveAvailableItem(p.RoomID, itemID, 999999, p.Name) + // The balance cap is a hard limit: credits the purse cannot absorb must + // stay on the ground instead of being removed and then clamped away by + // grantCredits. Only the headroom below the cap is taken. + room := creditHeadroom(p) + if room <= 0 { + sess.WriteLine(g.colorize(sess, "currency_pickup", "Your purse can't hold any more credits.")) + return + } + qty := g.World.RemoveAvailableItem(p.RoomID, itemID, room, p.Name) if qty <= 0 { return } @@ -387,6 +411,28 @@ func (g *Game) pickupCredits(sess *net.Session, p *player.Player, itemID string) } } +// creditHeadroom is how many credits p's balance can still absorb before the +// economy cap (casino.MaxCredits) is reached. +func creditHeadroom(p *player.Player) int { + room := casino.MaxCredits - p.Credits + if room < 0 { + return 0 + } + return room +} + +// pickedStackProps returns the Quality and MaxQuality a freshly picked-up +// stack receives (newInventorySlot stamps full quality onto quality-bearing +// items). A pickup only merges into a slot with matching properties, since +// merging a differently qualified stack would silently destroy the picked-up +// stack's quality - the same rule withdrawBankItem applies in cmd_bank.go. +// It reads the rule through stackProps so every other stack-creation site +// (craft, harvest, ammo removal) agrees on the same values. +func (g *Game) pickedStackProps(itemID string) (quality, maxQuality int) { + def, _ := g.ItemStore.Load(itemID) + return stackProps(def) +} + func (g *Game) collectMatches(input string, each func(func(itemID string, slot int) bool)) []itemMatch { var matches []itemMatch each(func(itemID string, slot int) bool { -- cgit v1.2.3