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/casino/machine.go | 43 +++++-- internal/casino/money_regression_test.go | 72 ++++++++++++ internal/game/cmd_get.go | 16 +-- internal/game/credits_pickup_test.go | 44 +++++++ internal/game/economy.go | 137 ++++++++++++++-------- internal/game/economy_recovery_regression_test.go | 56 +++++++++ internal/world/ground.go | 44 ++++++- internal/world/ground_test.go | 50 ++++++++ 8 files changed, 396 insertions(+), 66 deletions(-) create mode 100644 internal/game/credits_pickup_test.go create mode 100644 internal/world/ground_test.go 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) + } +} -- cgit v1.2.3