From 2f34e75f27e5f99cfc1c5f8eaf6ab4ff2fddb1d9 Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Wed, 16 Sep 2026 14:09:22 -0400 Subject: fix: reserved-loot pickup, credit pickup, payout recovery, and slot stake/bonus shutdown handling --- internal/world/ground.go | 44 ++++++++++++++++++++++++++++++++++++- internal/world/ground_test.go | 50 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 internal/world/ground_test.go (limited to 'internal/world') diff --git a/internal/world/ground.go b/internal/world/ground.go index 2d5ea7b..6915072 100644 --- a/internal/world/ground.go +++ b/internal/world/ground.go @@ -119,7 +119,10 @@ func (w *World) RemoveReservedItem(roomID int, itemID string, qty int, owner str } // Pass 2: take. Every entry reachable here is takeable, so no - // mid-loop abort is possible. + // mid-loop abort is possible. The ownership check MUST match pass 1: + // without it a player could consume a stack reserved for someone else + // when a public stack and a reserved stack coexist (the reserved entry + // comes first in the slice, so the take loop would drain it first). removed := 0 remaining := qty for _, e := range entries { @@ -129,6 +132,9 @@ func (w *World) RemoveReservedItem(roomID int, itemID string, qty int, owner str if e.quantity <= 0 || !strings.EqualFold(e.itemID, itemID) { continue } + if e.reserveTimer > 0 && e.reservedFor != "" && e.reservedFor != owner { + continue + } take := e.quantity if take > remaining { take = remaining @@ -143,6 +149,42 @@ func (w *World) RemoveReservedItem(roomID int, itemID string, qty int, owner str return removed, true } +// RemoveAvailableItem removes up to maxQty of itemID from the room's +// ground, skipping entries reserved for another player, and returns the +// quantity actually removed. Unlike RemoveReservedItem it is not +// all-or-nothing: callers pass a generous upper bound (credit pickups use +// a sentinel far above any possible stock) and take whatever is accessible +// at the moment, so a request larger than the available stock still +// succeeds. +func (w *World) RemoveAvailableItem(roomID int, itemID string, maxQty int, owner string) int { + w.mu.Lock() + defer w.mu.Unlock() + removed := 0 + remaining := maxQty + for _, e := range w.groundItems[roomID] { + if remaining <= 0 { + break + } + if e.quantity <= 0 || !strings.EqualFold(e.itemID, itemID) { + continue + } + if e.reserveTimer > 0 && e.reservedFor != "" && e.reservedFor != owner { + continue + } + take := e.quantity + if take > remaining { + take = remaining + } + e.quantity -= take + removed += take + remaining -= take + if e.isSpawn && e.quantity <= 0 && e.respawnDelay > 0 { + e.respawnTimer = e.respawnDelay + } + } + return removed +} + // AccessibleGround returns the ground items in roomID this owner may take: // entries reserved for another player are excluded. Pickup flows use this // to size requests and match names against takeable stock only. diff --git a/internal/world/ground_test.go b/internal/world/ground_test.go new file mode 100644 index 0000000..ef77766 --- /dev/null +++ b/internal/world/ground_test.go @@ -0,0 +1,50 @@ +package world + +import "testing" + +// TestRemoveReservedItemSkipsOtherPlayersReservations: the take loop must +// apply the same ownership check as the availability pass. With a stack +// reserved for Bob stored before a public stack, Alice's pickup must take +// the public stack only — consuming Bob's reservation instead silently +// destroys his loot while telling Alice the ground still holds it. +func TestRemoveReservedItemSkipsOtherPlayersReservations(t *testing.T) { + w := New(t.TempDir()) + w.AddReservedItem(1, "ore", 10, "bob") + w.AddGroundItem(1, "ore", 10) + + taken, ok := w.RemoveReservedItem(1, "ore", 10, "alice") + if !ok || taken != 10 { + t.Fatalf("alice's pickup of the public stack = (%d, %v), want (10, true)", taken, ok) + } + if got := w.AccessibleGround(1, "alice")["ore"]; got != 0 { + t.Errorf("alice still sees %d ore after taking the public stack; removal consumed reserved stock", got) + } + // Bob's reserved stack must be untouched and still his to take. + if got := w.AccessibleGround(1, "bob")["ore"]; got != 10 { + t.Errorf("bob's reserved stack = %d, want 10", got) + } + bobTaken, ok := w.RemoveReservedItem(1, "ore", 10, "bob") + if !ok || bobTaken != 10 { + t.Errorf("bob's pickup of his own reservation = (%d, %v), want (10, true)", bobTaken, ok) + } +} + +// TestRemoveAvailableItemTakesAccessibleStock: a request far larger than +// the ground stock succeeds and takes exactly what is accessible, skipping +// entries reserved for other players. +func TestRemoveAvailableItemTakesAccessibleStock(t *testing.T) { + w := New(t.TempDir()) + w.AddReservedItem(1, "credits", 10, "bob") + w.AddGroundItem(1, "credits", 5) + + taken := w.RemoveAvailableItem(1, "credits", 999999, "alice") + if taken != 5 { + t.Fatalf("remove-available took %d, want the 5 accessible credits", taken) + } + if got := w.AccessibleGround(1, "alice")["credits"]; got != 0 { + t.Errorf("alice still sees %d accessible credits", got) + } + if got := w.AccessibleGround(1, "bob")["credits"]; got != 10 { + t.Errorf("bob's reserved credits = %d, want 10", got) + } +} -- cgit v1.2.3