aboutsummaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-09-16 14:09:22 -0400
committerhistoria <[not public]>2026-09-16 14:09:22 -0400
commit2f34e75f27e5f99cfc1c5f8eaf6ab4ff2fddb1d9 (patch)
treec50388fe639286bda1b1999ca6c2c972d03eb3a5 /internal
parentc054b27f628f23fecc505b9e09e78721738f3c4e (diff)
downloadthehouseoficarus-2f34e75f27e5f99cfc1c5f8eaf6ab4ff2fddb1d9.tar.gz
fix: reserved-loot pickup, credit pickup, payout recovery, and slot stake/bonus shutdown handling
Diffstat (limited to 'internal')
-rw-r--r--internal/casino/machine.go43
-rw-r--r--internal/casino/money_regression_test.go72
-rw-r--r--internal/game/cmd_get.go16
-rw-r--r--internal/game/credits_pickup_test.go44
-rw-r--r--internal/game/economy.go137
-rw-r--r--internal/game/economy_recovery_regression_test.go56
-rw-r--r--internal/world/ground.go44
-rw-r--r--internal/world/ground_test.go50
8 files changed, 396 insertions, 66 deletions
diff --git a/internal/casino/machine.go b/internal/casino/machine.go
index caff1bf..f156fe7 100644
--- a/internal/casino/machine.go
+++ b/internal/casino/machine.go
@@ -35,6 +35,10 @@ type Machine struct {
suspense bool
autoBet bool
autoCountdown int
+ // stakeFailed marks the in-flight stake whose payout could not be
+ // persisted: the stake record stays OPEN as recovery evidence, so the
+ // seat must not resolve (forfeit) it when the player leaves.
+ stakeFailed bool
}
func newMachine(roomID int, cfg MachineConfig, rng *rand.Rand) *Machine {
@@ -107,6 +111,7 @@ func (m *Machine) beginSpinLocked(name string, amount int, wallet Wallet, public
m.bet = amount
m.spinBet = amount
m.player.Wallet = wallet
+ m.stakeFailed = false
m.spin = generateSlotSpinWithProfile(m.rng, m.spinBet, m.profile)
m.reel, m.clock, m.suspense = 0, 0, false
m.phase = machineSpinning
@@ -209,6 +214,7 @@ func (m *Machine) autoBetTick() []Event {
return m.private(walletErrorMessage(err, "Autobet stopped: you don't have enough chips and credits for that bet."))
}
m.spinBet = m.bet
+ m.stakeFailed = false
m.spin = generateSlotSpinWithProfile(m.rng, m.spinBet, m.profile)
m.reel, m.clock, m.suspense = 0, 0, false
m.phase = machineSpinning
@@ -233,20 +239,17 @@ func (m *Machine) finishSpin() []Event {
if err := m.player.Wallet.PayCredits(credits); err != nil {
// The payout could not be made durable; recovery will re-apply
// the owed amount from the open journal record. Do not announce
- // the win as paid, stop any autobet chain, and leave the stake
- // open so recovery settles it against the payout record.
+ // the win as paid, stop any autobet chain, and keep the stake
+ // OPEN (stakeFailed): resolving it here would erase the refund
+ // evidence for the owed settlement.
payFailed = true
+ m.stakeFailed = true
m.autoBet, m.autoCountdown = false, 0
failedEvents := m.private(payoutFailedMessage())
failedEvents[0].Color = "casino_payout"
events = append(events, failedEvents...)
}
}
- if !payFailed && m.player.Wallet != nil {
- // The paid spin resolved (free spins that follow wager nothing):
- // the stake is no longer outstanding.
- m.player.Wallet.ResolveStakes()
- }
if credits > 0 && !payFailed {
winEvents := m.private(fmt.Sprintf("\nYou win %d credits.", credits))
winEvents[0].Color = "casino_payout"
@@ -270,6 +273,10 @@ func (m *Machine) finishSpin() []Event {
events = append(events, m.public(fmt.Sprintf("Three scatter symbols trigger %d free spins!", m.profile.FreeSpinCount))...)
}
if m.freeSpins > 0 {
+ // A feature is in progress: the triggering stake stays OUTSTANDING
+ // until the bonus completes, so a graceful shutdown (or crash) while
+ // free spins remain refunds the stake through RefundOutstanding /
+ // startup recovery instead of silently eating the feature.
m.freeSpins--
m.phase = machineSpinning
m.spin = generateSlotSpinWithProfile(m.rng, m.spinBet, m.profile)
@@ -277,6 +284,13 @@ func (m *Machine) finishSpin() []Event {
events = append(events, m.public(fmt.Sprintf("%d free spins remain.", m.freeSpins))...)
} else {
m.inFreeSpins = false
+ if !payFailed && m.player.Wallet != nil {
+ // The paid round fully resolved (the feature either never
+ // started or just completed; free spins wager nothing): the
+ // stake is no longer outstanding.
+ m.player.Wallet.ResolveStakes()
+ m.stakeFailed = false
+ }
if !m.autoBet {
events = append(events, m.menuEvents("")...)
}
@@ -300,9 +314,12 @@ func (m *Machine) leave(name string) []Event {
// resetSeat frees the machine, discarding any in-flight spin and autobet
// state. A wager already taken for a spin in progress is forfeited: its
-// stake is resolved so startup recovery does not refund it.
+// stake is resolved so startup recovery does not refund it. The exception
+// is a stake whose payout could not be persisted (stakeFailed): the open
+// stake record is the only evidence of the owed settlement, so it is left
+// unresolved for recovery (or the shutdown refund) to complete.
func (m *Machine) resetSeat() {
- if m.player != nil && m.player.Wallet != nil {
+ if m.player != nil && m.player.Wallet != nil && !m.stakeFailed {
m.player.Wallet.ResolveStakes()
}
m.player = nil
@@ -315,6 +332,7 @@ func (m *Machine) resetSeat() {
m.inFreeSpins = false
m.payoutRemainder = 0
m.autoBet, m.autoCountdown = false, 0
+ m.stakeFailed = false
}
// markDisconnected removes the player from the machine, forfeiting any
@@ -409,7 +427,12 @@ func (m *Machine) menuEvents(prefix string) []Event {
// refundAll refunds any outstanding stake at graceful shutdown/eviction.
// The wallet resolves every outstanding stake exactly once; a completed
-// spin has no outstanding stake and is not refunded again. Locked.
+// spin has no outstanding stake and is not refunded again. A stake whose
+// payout failed to persist (stakeFailed) is refunded here as well — its
+// owed settlement remains open separately and startup recovery re-applies
+// it. A bonus in progress keeps its triggering stake outstanding, so a
+// shutdown during the feature returns the stake (the remaining free-spin
+// EV is forfeit, never the wager). Locked.
func (m *Machine) refundAll() []Event {
m.mu.Lock()
defer m.mu.Unlock()
diff --git a/internal/casino/money_regression_test.go b/internal/casino/money_regression_test.go
index 43bf505..e6b3ef8 100644
--- a/internal/casino/money_regression_test.go
+++ b/internal/casino/money_regression_test.go
@@ -175,3 +175,75 @@ func TestShoeReshufflesOnlyAtRoundBoundaries(t *testing.T) {
t.Fatalf("shuffleIfDue did not reshuffle at the boundary (%d cards)", got)
}
}
+
+// TestSlotsLeaveAfterFailedPayoutKeepsStakeOpen: a spin whose payout could
+// not be persisted leaves its stake record OPEN as recovery evidence for
+// the owed settlement. Standing up afterwards must not resolve (forfeit)
+// that stake — doing so would erase the refund evidence and silently drop
+// the owed win.
+func TestSlotsLeaveAfterFailedPayoutKeepsStakeOpen(t *testing.T) {
+ machine := newMachine(1, MachineConfig{ID: "slots", Game: GameSlots, SpinTicks: 1}, newTestRand())
+ wallet := &failingPayoutWallet{testWallet{credits: 100}}
+ machine.join("alice")
+ machine.setBet("alice", 10, wallet)
+ machine.spin = &SlotSpin{Payout: 20}
+ for i := 0; i < 20 && machine.phase != machineReady; i++ {
+ machine.tick()
+ }
+ if len(wallet.open) != 1 {
+ t.Fatal("setup: a failed payout must leave the stake open")
+ }
+ machine.leave("alice")
+ if len(wallet.open) != 1 {
+ t.Errorf("leaving after a failed payout erased the outstanding stake; want it preserved for recovery/refund")
+ }
+}
+
+// TestSlotsShutdownDuringBonusRefundsStake: free spins keep the triggering
+// stake OUTSTANDING until the bonus completes. A graceful shutdown mid-feature
+// therefore refunds the stake; the remaining feature EV is forfeit, but the
+// player never loses the wager they paid for the round.
+func TestSlotsShutdownDuringBonusRefundsStake(t *testing.T) {
+ machine := newMachine(1, MachineConfig{ID: "slots", Game: GameSlots, SpinTicks: 1}, newTestRand())
+ wallet := &testWallet{credits: 100}
+ machine.join("alice")
+ machine.setBet("alice", 10, wallet)
+ machine.spin = &SlotSpin{ScatterCount: 3} // deterministic bonus trigger
+ for i := 0; i < 20 && !machine.inFreeSpins; i++ {
+ machine.tick()
+ }
+ if !machine.inFreeSpins || machine.freeSpins != 7 {
+ t.Fatalf("setup: expected the first of eight free spins (inFreeSpins=%v freeSpins=%d)", machine.inFreeSpins, machine.freeSpins)
+ }
+ machine.refundAll()
+ if got := wallet.credits + wallet.paid; got != 100 {
+ t.Errorf("shutdown during the bonus left %d of the 100-credit balance; want the stake refunded", got)
+ }
+}
+
+// TestSlotsStakeResolvesOnlyAfterFullRound: without any failure and without
+// a feature, a completed spin resolves its stake (no shutdown refund), and a
+// bonus that RUNS TO COMPLETION also resolves the stake at the end.
+func TestSlotsStakeResolvesOnlyAfterFullRound(t *testing.T) {
+ machine := newMachine(1, MachineConfig{ID: "slots", Game: GameSlots, SpinTicks: 1}, newTestRand())
+ wallet := &testWallet{credits: 100}
+ machine.join("alice")
+ machine.setBet("alice", 10, wallet)
+ machine.spin = &SlotSpin{ScatterCount: 3} // trigger, then run all free spins
+ for i := 0; i < 200 && (machine.phase != machineReady || machine.inFreeSpins); i++ {
+ machine.tick()
+ }
+ if machine.inFreeSpins || machine.freeSpins != 0 {
+ t.Fatalf("setup: bonus must have completed (inFreeSpins=%v freeSpins=%d)", machine.inFreeSpins, machine.freeSpins)
+ }
+ if len(wallet.open) != 0 {
+ t.Fatalf("stake still outstanding after the feature completed: %+v", wallet.open)
+ }
+ // A completed round has nothing outstanding: the shutdown refund must
+ // not pay anything extra.
+ paidBefore := wallet.paid
+ machine.refundAll()
+ if wallet.paid != paidBefore {
+ t.Errorf("refundAll double-paid a completed round: paid %d -> %d", paidBefore, wallet.paid)
+ }
+}
diff --git a/internal/game/cmd_get.go b/internal/game/cmd_get.go
index 515d2ab..7a5181f 100644
--- a/internal/game/cmd_get.go
+++ b/internal/game/cmd_get.go
@@ -270,8 +270,10 @@ func (g *Game) doGetAll(sess *net.Session) {
continue
}
if itemID == "credits" {
- taken, ok := g.World.RemoveReservedItem(p.RoomID, itemID, 999999, p.Name)
- if !ok {
+ // 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)
+ if taken <= 0 {
continue
}
p.Credits += taken
@@ -368,11 +370,11 @@ func (g *Game) doGetAll(sess *net.Session) {
}
func (g *Game) pickupCredits(sess *net.Session, p *player.Player, itemID string) {
- qty, ok := g.World.RemoveReservedItem(p.RoomID, itemID, 999999, p.Name)
- if !ok {
- sess.WriteLine("That's not yours!")
- return
- }
+ // 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)
if qty <= 0 {
return
}
diff --git a/internal/game/credits_pickup_test.go b/internal/game/credits_pickup_test.go
new file mode 100644
index 0000000..61509e4
--- /dev/null
+++ b/internal/game/credits_pickup_test.go
@@ -0,0 +1,44 @@
+package game
+
+import (
+ "testing"
+
+ "thehouseoficarus/internal/net"
+)
+
+// TestPickupCreditsSentinelQuantity: credit pickups request a sentinel
+// quantity far above any possible ground stock. RemoveReservedItem is
+// all-or-nothing and rejects such a request; the pickup must use capped
+// removal so picking up 10 credits succeeds instead of answering
+// "That's not yours!".
+func TestPickupCreditsSentinelQuantity(t *testing.T) {
+ h := newHarness(t)
+ h.seedAccount("alice", "pw", false)
+ p := h.seedCharacter("alice", "alice")
+ h.g.RunOnOwner(func() {
+ p.Credits = 100
+ h.g.World.AddGroundItem(p.RoomID, "credits", 10)
+ sess := &net.Session{Player: p, Conn: newChanConn()}
+ h.g.pickupCredits(sess, p, "credits")
+ if p.Credits != 110 {
+ t.Errorf("credits after pickup = %d, want 110 (output: %s)", p.Credits, sess.Conn.(*chanConn).Bytes())
+ }
+ })
+}
+
+// TestGetAllPicksUpCreditsWithSentinelQuantity: the same sentinel rule for
+// the "get all" path.
+func TestGetAllPicksUpCreditsWithSentinelQuantity(t *testing.T) {
+ h := newHarness(t)
+ h.seedAccount("alice", "pw", false)
+ p := h.seedCharacter("alice", "alice")
+ h.g.RunOnOwner(func() {
+ p.Credits = 0
+ h.g.World.AddGroundItem(p.RoomID, "credits", 10)
+ sess := &net.Session{Player: p, Conn: newChanConn()}
+ h.g.doGetAll(sess)
+ if p.Credits != 10 {
+ t.Errorf("credits after get-all = %d, want 10 (output: %s)", p.Credits, sess.Conn.(*chanConn).Bytes())
+ }
+ })
+}
diff --git a/internal/game/economy.go b/internal/game/economy.go
index be3f5ef..3d48605 100644
--- a/internal/game/economy.go
+++ b/internal/game/economy.go
@@ -420,8 +420,12 @@ func addCreditsChecked(a, b int) (int, error) {
// 1. Open PAYOUT records are processed in append order against the evolving
// durable balance: file at the pre-payout balance → the payout is
// re-applied (the player is owed it); at the post-payout balance → it is
-// already durable and settles; anything else stays open and is surfaced
-// for operator inspection (a liability is never silently dropped).
+// already durable and settles — UNLESS an earlier recovery run (or the
+// original settlement) already settled a same-checkpoint sibling, in
+// which case the balance is explained by THAT record and this open
+// record is still owed and re-applied; anything else stays open and is
+// surfaced for operator inspection (a liability is never silently
+// dropped).
// 2. Open WAGER records are processed NEWEST FIRST (each refund restores
// the balance an earlier record's checkpoints refer to). A record at
// its pre-debit state (credits AND chips) never became durable → void;
@@ -541,6 +545,75 @@ func (g *Game) recoverEconomy() {
}
}
settled := make(map[string]bool)
+ // settledSiblings counts SETTLED payout records (from earlier recovery
+ // runs; visible in the folded journal) with the same signature as rec.
+ // Each one already explains one durable credit unit of the current
+ // balance: a record moved the balance from `before` to `after` and was
+ // settled, so any other open record with the SAME checkpoints is still
+ // owed — it must never be settled as "already durable" because the
+ // balance happens to sit at `after`.
+ settledSiblings := func(sig payoutSig, selfID string) int {
+ n := 0
+ for j := range folded {
+ other := &folded[j].rec
+ if other.ID == selfID || other.Kind != "payout" || other.Status != "settled" {
+ continue
+ }
+ if other.key() == sig.player && other.CreditsBefore == sig.before &&
+ other.CreditsAfter == sig.after && other.Credit == sig.credit {
+ n++
+ }
+ }
+ return n
+ }
+ // reapplyGroup re-applies every identical open sibling: they were all
+ // journaled against the same in-memory balance and are all owed. Only
+ // the siblings whose re-application actually became durable are
+ // settled; a save failure (e.g. the credit cap) leaves the unpaid
+ // remainder open for operator inspection — a liability is never erased.
+ reapplyGroup := func(group []*economyRecord, p *player.Player, credit int) {
+ paid := 0
+ capBlocked := false
+ for m := 0; m < len(group); m++ {
+ if _, err := addCreditsChecked(p.Credits, credit); err != nil {
+ capBlocked = true
+ break
+ }
+ if err := g.payCreditsDurable(p, credit); err != nil {
+ fmt.Fprintf(os.Stderr, "economy recovery: payout save %s: %v\n", p.Name, err)
+ break
+ }
+ paid++
+ compensated++
+ }
+ for idx, other := range group {
+ if idx < paid {
+ g.Economy.settle(other)
+ settled[other.ID] = true
+ continue
+ }
+ if capBlocked {
+ // The credit cap blocked re-application. Re-anchor the
+ // record's checkpoints to the current durable balance so
+ // a later recovery re-applies it against a balance it can
+ // actually be paid from — without this, the balance a PAID
+ // sibling moved reads as this record's own durability and
+ // the liability is silently settled instead of paid.
+ other.CreditsBefore = p.Credits
+ other.CreditsAfter = p.Credits + other.Credit
+ settled[other.ID] = true // handled for the rest of this run
+ _ = g.Economy.append(*other)
+ continue
+ }
+ // A TRANSIENT save failure blocked re-application and the
+ // balance still has room below the cap: keep the record open
+ // with its ORIGINAL checkpoints. Re-anchoring here would make
+ // the record unreadable against the unchanged durable balance
+ // (before-checkpoint mismatch) and permanently ambiguous; the
+ // original checkpoints let a later recovery retry cleanly.
+ settled[other.ID] = true // handled for the rest of this run
+ }
+ }
for i := range folded {
rec := &folded[i].rec
if rec.Kind != "payout" || rec.Status != "open" || settled[rec.ID] {
@@ -555,54 +628,22 @@ func (g *Game) recoverEconomy() {
switch {
case p.Credits == rec.CreditsBefore:
// The payout (and every identical sibling) was lost in the
- // crash: the player is owed them all. Only the siblings whose
- // re-application actually became durable are settled; a save
- // failure (e.g. the credit cap) leaves the unpaid remainder open
- // for operator inspection — a liability is never erased.
- paid := 0
- capBlocked := false
- for m := 0; m < len(group); m++ {
- if _, err := addCreditsChecked(p.Credits, rec.Credit); err != nil {
- capBlocked = true
- break
- }
- if err := g.payCreditsDurable(p, rec.Credit); err != nil {
- fmt.Fprintf(os.Stderr, "economy recovery: payout save %s: %v\n", p.Name, err)
- break
- }
- paid++
- compensated++
- }
- for idx, other := range group {
- if idx < paid {
- g.Economy.settle(other)
- settled[other.ID] = true
- continue
- }
- if capBlocked {
- // The credit cap blocked re-application. Re-anchor the
- // record's checkpoints to the current durable balance so
- // a later recovery re-applies it against a balance it can
- // actually be paid from — without this, the balance a PAID
- // sibling moved reads as this record's own durability and
- // the liability is silently settled instead of paid.
- other.CreditsBefore = p.Credits
- other.CreditsAfter = p.Credits + other.Credit
- settled[other.ID] = true // handled for the rest of this run
- _ = g.Economy.append(*other)
- continue
- }
- // A TRANSIENT save failure blocked re-application and the
- // balance still has room below the cap: keep the record open
- // with its ORIGINAL checkpoints. Re-anchoring here would make
- // the record unreadable against the unchanged durable balance
- // (before-checkpoint mismatch) and permanently ambiguous; the
- // original checkpoints let a later recovery retry cleanly.
- settled[other.ID] = true // handled for the rest of this run
- }
- case p.Credits == rec.CreditsAfter && len(group) == 1:
+ // crash: the player is owed them all.
+ reapplyGroup(group, p, rec.Credit)
+ case p.Credits == rec.CreditsAfter && len(group) == 1 && settledSiblings(sig, rec.ID) == 0:
+ // The only record with these checkpoints sits at its
+ // post-payout balance: it is already durable, settle it.
g.Economy.settle(rec)
settled[rec.ID] = true
+ case p.Credits == rec.CreditsAfter && settledSiblings(sig, rec.ID) > 0:
+ // A SETTLED sibling with identical checkpoints explains the
+ // balance: an earlier recovery run already re-applied (and
+ // settled) it, or its original settlement moved the balance
+ // here. This open record is still OWED — settling it as
+ // "already durable" because the balance reads `after` would
+ // silently drop the liability (see the
+ // partially-paid-identical-siblings regression).
+ reapplyGroup(group, p, rec.Credit)
default:
fmt.Fprintf(os.Stderr, "economy recovery: ambiguous payout %s for %s (credits %d, want before %d or after %d) — kept open for operator inspection\n",
rec.ID, rec.Player, p.Credits, rec.CreditsBefore, rec.CreditsAfter)
diff --git a/internal/game/economy_recovery_regression_test.go b/internal/game/economy_recovery_regression_test.go
index 1bb3293..241b235 100644
--- a/internal/game/economy_recovery_regression_test.go
+++ b/internal/game/economy_recovery_regression_test.go
@@ -479,3 +479,59 @@ func TestCapBlockedPayoutIsJournaled(t *testing.T) {
t.Fatalf("recovery mutated a capped balance: %d", loaded.Credits)
}
}
+
+// TestRecoveryReappliesPartiallyPaidIdenticalPayouts: two identical payout
+// records were open and an earlier recovery run re-applied (and settled)
+// only the FIRST before crashing again — the second save failed transiently
+// (see TestRecoverySaveFailureRetriesOnNextStartup for the single-record
+// case). The next recovery must NOT settle the remaining record as
+// "already durable" merely because the balance now sits at its
+// post-payout checkpoint: the settled sibling explains that balance, and
+// the open record is still owed.
+func TestRecoveryReappliesPartiallyPaidIdenticalPayouts(t *testing.T) {
+ h := newHarness(t)
+ h.seedAccount("alice", "pw", false)
+ p := h.seedCharacter("alice", "alice")
+ h.g.RunOnOwner(func() {
+ p.Credits = 90
+ if err := h.g.AccountStore.SaveCharacter(p); err != nil {
+ t.Error(err)
+ return
+ }
+ // Two identical owed payouts were journaled against balance 90.
+ if _, err := h.g.Economy.start("payout", p, 0, 20, 90, 110, 0, 0); err != nil {
+ t.Error(err)
+ return
+ }
+ second, err := h.g.Economy.start("payout", p, 0, 20, 90, 110, 0, 0)
+ if err != nil {
+ t.Error(err)
+ return
+ }
+ // Simulate the prior recovery run: the first record was re-applied
+ // and settled durably; the second's save failed transiently.
+ if err := h.g.payCreditsDurable(p, 20); err != nil {
+ t.Error(err)
+ return
+ }
+ first := h.g.Economy.loadOpen()[0]
+ if first.ID == second.ID {
+ t.Fatal("setup: expected two distinct records")
+ }
+ h.g.Economy.settle(first)
+ // Recovery over this state: the remaining owed payout must be
+ // re-applied, never settled as "already durable".
+ h.g.recoverEconomy()
+ loaded, err := h.g.AccountStore.LoadCharacter("alice")
+ if err != nil {
+ t.Error(err)
+ return
+ }
+ if loaded.Credits != 130 {
+ t.Errorf("recovered balance = %d, want 130 (remaining owed payout re-applied)", loaded.Credits)
+ }
+ if open := h.g.Economy.loadOpen(); len(open) != 0 {
+ t.Errorf("records left open after recovery: %+v", open)
+ }
+ })
+}
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)
+ }
+}