diff options
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/casino/baccarat.go | 10 | ||||
| -rw-r--r-- | internal/casino/blackjack.go | 62 | ||||
| -rw-r--r-- | internal/casino/machine.go | 8 | ||||
| -rw-r--r-- | internal/casino/manager_test.go | 20 | ||||
| -rw-r--r-- | internal/casino/money_regression_test.go | 2 | ||||
| -rw-r--r-- | internal/casino/types.go | 19 | ||||
| -rw-r--r-- | internal/game/act_combine.go | 35 | ||||
| -rw-r--r-- | internal/game/act_fletch.go | 72 | ||||
| -rw-r--r-- | internal/game/casino.go | 59 | ||||
| -rw-r--r-- | internal/game/casino_refund_regression_test.go | 164 | ||||
| -rw-r--r-- | internal/game/economy.go | 175 | ||||
| -rw-r--r-- | internal/game/journal_damage_regression_test.go | 123 | ||||
| -rw-r--r-- | internal/game/journal_failure_regression_test.go | 15 | ||||
| -rw-r--r-- | internal/game/production_engine.go | 76 | ||||
| -rw-r--r-- | internal/game/production_index.go | 109 | ||||
| -rw-r--r-- | internal/game/production_output_regression_test.go | 186 |
16 files changed, 968 insertions, 167 deletions
diff --git a/internal/casino/baccarat.go b/internal/casino/baccarat.go index fa54ac8..f4f5fbd 100644 --- a/internal/casino/baccarat.go +++ b/internal/casino/baccarat.go @@ -28,6 +28,9 @@ type baccaratPlayer struct { Participant lastBet int lastSpot string + // stakeID is the wallet handle of the wager record committed this + // round; the settlement returns it inside the payout. + stakeID string } func (p *baccaratPlayer) participantPtr() *Participant { return &p.Participant } @@ -128,7 +131,7 @@ func (t *baccaratTable) bet(name, spot string, amount int, wallet Wallet) []Even if err != nil { return []Event{t.private(name, walletErrorMessage(err))} } - p.HasBet, p.Wager, p.Wallet, p.lastBet, p.lastSpot = true, wager, wallet, amount, spot + p.HasBet, p.Wager, p.Wallet, p.lastBet, p.lastSpot, p.stakeID = true, wager, wallet, amount, spot, wager.ID events := []Event{t.playerAction(name, fmt.Sprintf("%s bets %d credits on %s.", name, amount, spot), fmt.Sprintf("You bet %d credits on %s.", amount, spot))} @@ -193,7 +196,7 @@ func (t *baccaratTable) deal() []Event { result, payout := settleBaccaratBet(p.lastSpot, p.Wager.Total, playerTotal, bankerTotal, t.Rules) payoutFailed := false if payout > 0 && p.Wallet != nil { - if err := p.Wallet.PayCredits(payout); err != nil { + if err := p.Wallet.PayStakeCover(payout, []string{p.stakeID}); 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 and leave the stakes @@ -257,6 +260,9 @@ func (t *baccaratTable) resetRound() { t.phase = baccaratWaiting t.window.close() clearBets(t.players.participants()) + for _, p := range t.players.ordered() { + p.stakeID = "" + } } // markDisconnected removes the player from the table, forfeiting any wager diff --git a/internal/casino/blackjack.go b/internal/casino/blackjack.go index 39146f7..0a0551c 100644 --- a/internal/casino/blackjack.go +++ b/internal/casino/blackjack.go @@ -19,8 +19,12 @@ const ( ) type blackjackHand struct { - cards []card - bet int + cards []card + bet int + // stakes are the wallet handles of the wager records committed to this + // hand (the opening bet plus each double or split wager). The hand's + // settlement returns their total; the linkage lets a refund skip them. + stakes []string fromSplit bool doubled bool stood bool @@ -53,6 +57,7 @@ type blackjackPlayer struct { Participant hands []*blackjackHand insurance int + insuranceStakeID string declinedInsurance bool lastBet int } @@ -264,8 +269,10 @@ func (t *blackjackTable) action(name, action string) []Event { if len(hand.cards) != 2 || hand.doubled || hand.surrendered || fromSplitAces(hand) || (hand.fromSplit && !t.Rules.AllowsDoubleAfterSplit()) { return []Event{t.private(name, "You can only double a qualifying hand.")} } - if _, err := p.Wallet.Wager(hand.bet); err != nil { + if wager, err := p.Wallet.Wager(hand.bet); err != nil { return []Event{t.private(name, walletErrorMessage(err, "You don't have enough chips and credits to double that hand."))} + } else { + hand.stakes = append(hand.stakes, wager.ID) } hand.bet *= 2 hand.doubled = true @@ -306,8 +313,10 @@ func (t *blackjackTable) insuranceAction(name, action string) []Event { if amount <= 0 { return []Event{t.private(name, "Your bet is too small to insure.")} } - if _, err := p.Wallet.Wager(amount); err != nil { + if wager, err := p.Wallet.Wager(amount); err != nil { return []Event{t.private(name, walletErrorMessage(err, "You don't have enough chips and credits for insurance."))} + } else { + p.insuranceStakeID = wager.ID } p.insurance = amount events = append(events, t.playerAction(name, fmt.Sprintf("%s takes insurance.", name), fmt.Sprintf("You place %d credits on insurance.", amount))) @@ -352,11 +361,12 @@ func (t *blackjackTable) splitHand(name string, p *blackjackPlayer, hand *blackj if hand.cards[0].rank == "A" && hand.fromSplit && !t.Rules.AllowsResplitAces() { return []Event{t.private(name, "Aces cannot be re-split at this table.")} } - if _, err := p.Wallet.Wager(hand.bet); err != nil { + wager, err := p.Wallet.Wager(hand.bet) + if err != nil { return []Event{t.private(name, walletErrorMessage(err, "You don't have enough chips and credits to split that hand."))} } - left := &blackjackHand{cards: []card{hand.cards[0]}, bet: hand.bet, fromSplit: true} - right := &blackjackHand{cards: []card{hand.cards[1]}, bet: hand.bet, fromSplit: true} + left := &blackjackHand{cards: []card{hand.cards[0]}, bet: hand.bet, fromSplit: true, stakes: hand.stakes} + right := &blackjackHand{cards: []card{hand.cards[1]}, bet: hand.bet, fromSplit: true, stakes: []string{wager.ID}} left.cards = append(left.cards, t.shoe.draw()) right.cards = append(right.cards, t.shoe.draw()) index := t.activeHand @@ -392,7 +402,7 @@ func (t *blackjackTable) startRound() []Event { if !p.HasBet { continue } - p.hands = []*blackjackHand{{cards: []card{t.shoe.draw(), t.shoe.draw()}, bet: p.Wager.Total}} + p.hands = []*blackjackHand{{cards: []card{t.shoe.draw(), t.shoe.draw()}, bet: p.Wager.Total, stakes: []string{p.Wager.ID}}} if p.hands[0].bet/2 <= 0 { // A half-bet that rounds to zero cannot be insured. p.declinedInsurance = true @@ -553,12 +563,12 @@ func (t *blackjackTable) settle() []Event { for _, hand := range p.hands { result, payout := settleHand(hand, dealerTotal, dealerBlackjack, t.Rules) if payout > 0 && p.Wallet != nil { - if err := p.Wallet.PayCredits(payout); err != nil { + if err := p.Wallet.PayStakeCover(payout, hand.stakes); err != nil { // The payout could not be made durable. The journal - // record stays open: recovery re-applies the owed - // amount. The win is not announced as paid, and the - // stakes stay open so recovery settles them against - // the payout record. + // record stays open listing the hand's stakes as + // covered: recovery re-applies the owed payout (which + // returns those stakes) and refunds must not pay them + // again. The win is not announced as paid. payoutFailed = true publicResult, _ := blackjackResultMessages(p.Name, result, payout) settlement := t.playerAction(p.Name, publicResult, payoutFailedMessage()) @@ -570,6 +580,15 @@ func (t *blackjackTable) settle() []Event { events = append(events, settlement) continue } + // The hand was paid: its stakes are resolved immediately + // so a later sibling-hand payout failure cannot cause a + // refund of a stake this settlement already returned. + t.resolveHandStakes(p.Wallet, hand) + } else if payout == 0 && p.Wallet != nil { + // A lost hand resolves its stakes even when a sibling + // hand's payout is still in flight: the stake was + // consumed by the rules of the game. + t.resolveHandStakes(p.Wallet, hand) } publicResult, privateResult := blackjackResultMessages(p.Name, result, payout) settlement := t.playerAction(p.Name, publicResult, privateResult) @@ -586,13 +605,16 @@ func (t *blackjackTable) settle() []Event { insurancePayout, _ = MulPayout(p.insurance, 1+t.Rules.InsurancePayout) } if insurancePayout > 0 { - if err := p.Wallet.PayCredits(insurancePayout); err != nil { + if err := p.Wallet.PayStakeCover(insurancePayout, []string{p.insuranceStakeID}); err != nil { payoutFailed = true events = append(events, t.private(p.Name, payoutFailedMessage())) } else { + p.Wallet.ResolveStake(p.insuranceStakeID) events = append(events, t.private(p.Name, fmt.Sprintf("Insurance pays %d credits.", insurancePayout))) } } else { + // Insurance lost: its stake was consumed by the rules. + p.Wallet.ResolveStake(p.insuranceStakeID) events = append(events, t.private(p.Name, "Insurance pays 0 credits.")) } } @@ -648,7 +670,17 @@ func (t *blackjackTable) resetRound() { t.window.close() t.timer.stop() for _, p := range t.players.ordered() { - p.HasBet, p.Wager, p.hands, p.insurance, p.declinedInsurance = false, Wager{}, nil, 0, false + p.HasBet, p.Wager, p.hands, p.insurance, p.insuranceStakeID, p.declinedInsurance = false, Wager{}, nil, 0, "", false + } +} + +// resolveHandStakes settles every stake wager a hand committed. Nil-safe. +func (t *blackjackTable) resolveHandStakes(wallet Wallet, hand *blackjackHand) { + if wallet == nil { + return + } + for _, stakeID := range hand.stakes { + wallet.ResolveStake(stakeID) } } diff --git a/internal/casino/machine.go b/internal/casino/machine.go index 757588b..0f137ec 100644 --- a/internal/casino/machine.go +++ b/internal/casino/machine.go @@ -428,9 +428,11 @@ 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. 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 +// payout failed to persist (stakeFailed) is NOT returned here when an open +// payout record covers it — the journaled winnings prove the round +// resolved, so the stake was consumed by playing it and recovery re-applies +// the owed payout instead of double-paying the round. A bonus in progress +// keeps its triggering stake outstanding with no payout journaled, 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 { diff --git a/internal/casino/manager_test.go b/internal/casino/manager_test.go index fa26aa1..b037efa 100644 --- a/internal/casino/manager_test.go +++ b/internal/casino/manager_test.go @@ -18,7 +18,7 @@ func (w *testWallet) Wager(amount int) (Wager, error) { w.chips -= chips credits := amount - chips w.credits -= credits - wager := Wager{Total: amount, Chips: chips, Credits: credits} + wager := Wager{Total: amount, Chips: chips, Credits: credits, ID: string(rune('a' + len(w.open)))} w.open = append(w.open, wager) return wager, nil } @@ -28,6 +28,24 @@ func (w *testWallet) PayCredits(amount int) error { return nil } +// PayStakeCover routes through PayCredits so a wallet rejecting payouts +// (failingPayoutWallet) rejects stake-covering settlements identically. +func (w *testWallet) PayStakeCover(amount int, _ []string) error { + return w.PayCredits(amount) +} + +// ResolveStake settles one outstanding stake by handle, mirroring the +// production wallet. +func (w *testWallet) ResolveStake(stakeID string) { + kept := w.open[:0] + for _, wager := range w.open { + if wager.ID != stakeID { + kept = append(kept, wager) + } + } + w.open = kept +} + // ResolveStakes mirrors the production wallet: every outstanding stake is // settled (no longer refundable). func (w *testWallet) ResolveStakes() { w.open = nil } diff --git a/internal/casino/money_regression_test.go b/internal/casino/money_regression_test.go index e6b3ef8..79f9b46 100644 --- a/internal/casino/money_regression_test.go +++ b/internal/casino/money_regression_test.go @@ -17,6 +17,8 @@ type failingPayoutWallet struct { func (w *failingPayoutWallet) PayCredits(int) error { return ErrWalletPersistence } +func (w *failingPayoutWallet) PayStakeCover(int, []string) error { return ErrWalletPersistence } + // TestBlackjackRefundCoversAllStakes: splitting an eligible pair commits a // second stake; the shutdown refund must return both, and a completed round // (stakes resolved) must not be refunded at all. diff --git a/internal/casino/types.go b/internal/casino/types.go index d000375..283aa43 100644 --- a/internal/casino/types.go +++ b/internal/casino/types.go @@ -160,6 +160,10 @@ type Wager struct { Total int Chips int Credits int + // ID is the wallet's opaque handle for the journaled stake record: + // table settlements pay through PayStakeCover with the hand's stake + // IDs so the journal can prove which wager a settlement returns. + ID string } type Wallet interface { @@ -169,13 +173,26 @@ type Wallet interface { Wager(amount int) (Wager, error) // PayCredits pays a settlement durably, returning an error when the // credit could not be persisted (the game must not report a win that - // is not durable). + // is not durable). The journaled record carries no stake linkage: + // recovery treats it as resolving the round (a slots payout is + // winnings only; the stake was consumed by playing the round). PayCredits(amount int) error + // PayStakeCover pays a settlement whose amount INCLUDES the return of + // the listed stake wager IDs (a table hand pays back its stake inside + // the payout). When the payout cannot be made durable, the open + // journal record still proves those stakes were returned inside it, so + // a shutdown refund must not return them a second time and recovery + // re-applies the owed payout instead. + PayStakeCover(amount int, stakeIDs []string) error // ResolveStakes marks every outstanding stake for this player as // settled: the round resolved (won, lost, pushed, or forfeited), so // startup recovery must no longer refund it. A stake whose wager // record remains open at restart is refunded by recovery. ResolveStakes() + // ResolveStake settles ONE outstanding stake by wallet handle (a + // single hand paid or lost while a sibling hand's payout is still in + // flight): its return is proven, so it must never be refunded. + ResolveStake(stakeID string) // RefundOutstanding returns every outstanding stake to the player // (chips as chips, credits through the durable payout path) and // resolves the stakes. It reports the amounts returned. An error diff --git a/internal/game/act_combine.go b/internal/game/act_combine.go index bc9b6de..b16cf7a 100644 --- a/internal/game/act_combine.go +++ b/internal/game/act_combine.go @@ -100,6 +100,15 @@ func (g *Game) advanceCombine(sess *net.Session, p *player.Player) { return } + // A full inventory must never destroy materials: verify the output + // can be placed BEFORE collecting byproducts and consuming. + outDef, _ := g.ItemStore.Load(item.ID) + if !craftOutputFits(p, item.ID, outDef) { + sess.WriteLine("Your inventory is too full!") + g.cancelAction(p) + return + } + // Byproducts must be collected BEFORE consumption: the resolver maps // each ingredient entry to the alternative actually being consumed // (bucket vs jug of water), which requires the items still present. @@ -112,26 +121,12 @@ func (g *Game) advanceCombine(sess *net.Session, p *player.Player) { outputQty = 1 } - outDef, _ := g.ItemStore.Load(item.ID) - placed := false - if outDef != nil && outDef.Stackable { - for i := 0; i < 28; i++ { - slot := p.InvSlot(i) - if slot != nil && slot.ItemID == item.ID { - slot.Quantity += outputQty - placed = true - break - } - } - } - if !placed { - freeSlot := p.FirstFreeSlot() - if freeSlot == -1 { - sess.WriteLine("Your inventory is too full!") - g.cancelAction(p) - return - } - p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: item.ID, Quantity: outputQty}) + if !placeCraftOutput(p, item.ID, outDef, outputQty) { + // Defensive: the fits check above should have guaranteed a + // stack or slot. + sess.WriteLine("Your inventory is too full!") + g.cancelAction(p) + return } // Return the empty containers (and any other byproducts) alongside diff --git a/internal/game/act_fletch.go b/internal/game/act_fletch.go index c52aae1..9796bda 100644 --- a/internal/game/act_fletch.go +++ b/internal/game/act_fletch.go @@ -34,31 +34,22 @@ func (g *Game) doInstantFletch(sess *net.Session, p *player.Player, def *item.It totalOutput := 0 skill := player.SkillName(c.EffectiveSkill()) tmpItem := &item.ItemDef{Craft: item.CraftList{*c}} + inventoryFull := false for { if !g.canDoFletchItem(p, tmpItem) { break } - - placed := false - if def.Stackable { - for i := 0; i < 28; i++ { - slot := p.InvSlot(i) - if slot != nil && slot.ItemID == def.ID { - consumeIngredients(c, p.CountItem, p.RemoveItem) - slot.Quantity += outputQty - placed = true - break - } - } + // A full inventory must never destroy materials: check placement + // BEFORE consuming the batch. + if !craftOutputFits(p, def.ID, def) { + inventoryFull = true + break } - if !placed { - consumeIngredients(c, p.CountItem, p.RemoveItem) - freeSlot := p.FirstFreeSlot() - if freeSlot == -1 { - break - } - p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: def.ID, Quantity: outputQty}) + + if !consumeIngredients(c, p.CountItem, p.RemoveItem) { + break } + placeCraftOutput(p, def.ID, def, outputQty) if c.XP > 0 { g.awardSkillXP(sess, p, skill, c.XP) @@ -76,7 +67,11 @@ func (g *Game) doInstantFletch(sess *net.Session, p *player.Player, def *item.It } if batches == 0 { - sess.WriteLine("You don't have the materials for that.") + if inventoryFull { + sess.WriteLine("Your inventory is too full!") + } else { + sess.WriteLine("You don't have the materials for that.") + } return } @@ -158,29 +153,26 @@ func (g *Game) advanceFletch(sess *net.Session, p *player.Player) { outputQty = 1 } + // A full inventory must never destroy materials: verify the output can + // be placed BEFORE consuming the batch. + if !craftOutputFits(p, itemID, def) { + sess.WriteLine("Your inventory is too full!") + g.cancelBgAction(p) + return + } + skill := player.SkillName(c.EffectiveSkill()) - placed := false - if def.Stackable { - for i := 0; i < 28; i++ { - slot := p.InvSlot(i) - if slot != nil && slot.ItemID == def.ID { - consumeIngredients(c, p.CountItem, p.RemoveItem) - slot.Quantity += outputQty - placed = true - break - } - } + if !consumeIngredients(c, p.CountItem, p.RemoveItem) { + g.cancelBgAction(p) + return } - if !placed { - consumeIngredients(c, p.CountItem, p.RemoveItem) - freeSlot := p.FirstFreeSlot() - if freeSlot == -1 { - sess.WriteLine("Your inventory is too full!") - g.cancelBgAction(p) - return - } - p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: def.ID, Quantity: outputQty}) + if !placeCraftOutput(p, itemID, def, outputQty) { + // Defensive: the fits check above should have guaranteed a stack + // or slot. + sess.WriteLine("Your inventory is too full!") + g.cancelBgAction(p) + return } if c.XP > 0 { diff --git a/internal/game/casino.go b/internal/game/casino.go index 79d324a..133eb34 100644 --- a/internal/game/casino.go +++ b/internal/game/casino.go @@ -70,21 +70,32 @@ func (w casinoWallet) Wager(amount int) (casino.Wager, error) { // The record stays open as an outstanding stake until the round // resolves; recovery refunds open stakes after a crash. w.g.Economy.registerStake(rec) - return casino.Wager{Total: amount, Chips: chips, Credits: credits}, nil + return casino.Wager{Total: amount, Chips: chips, Credits: credits, ID: rec.ID}, nil } // PayCredits applies a payout durably. The journal record is written first; // if the save fails, the record stays open and startup recovery re-pays it // (the player is owed the settlement). func (w casinoWallet) PayCredits(amount int) error { - return w.payCreditsFlagged(amount, false) + return w.payCreditsFlagged(amount, false, nil) +} + +// PayStakeCover applies a settlement that INCLUDES the return of the listed +// stake wager IDs (a table hand pays its stake back inside the payout). The +// record is journaled with that linkage, so a shutdown refund never returns +// those stakes a second time; recovery re-applies the owed payout instead. +func (w casinoWallet) PayStakeCover(amount int, stakeIDs []string) error { + return w.payCreditsFlagged(amount, false, stakeIDs) } // payCreditsFlagged journals and applies a credit payout; a settlement // payout (refund=false) proves the round resolved during recovery, while a // refund payout (refund=true) marks a stake RETURNED at shutdown/eviction // (its chip part returns in chips, not through this credit payout). -func (w casinoWallet) payCreditsFlagged(amount int, refund bool) error { +// stakeIDs, when non-empty, records WHICH open stakes this settlement +// returns (table hands); empty means the payout resolves the round without +// a linked stake return (slots winnings). +func (w casinoWallet) payCreditsFlagged(amount int, refund bool, stakeIDs []string) error { if amount <= 0 { return nil } @@ -96,7 +107,7 @@ func (w casinoWallet) payCreditsFlagged(amount int, refund bool) error { // With the cap blocking, the balance is unchanged and the open record // keeps the owed amount durable; startup recovery re-applies it once // the balance has room below the cap. - rec, err := w.g.Economy.startKind("payout", refund, w.p, 0, amount, + rec, err := w.g.Economy.startKind("payout", refund, stakeIDs, w.p, 0, amount, creditsBefore, creditsAfter, chipsBefore, chipsBefore) if err != nil { // The journal is unavailable (persistent append failure). Last @@ -168,6 +179,15 @@ func (w casinoWallet) payCreditsFlagged(amount int, refund bool) error { // honors the resolution instead of refunding an already-resolved round. func (w casinoWallet) ResolveStakes() { unresolved := w.g.Economy.ResolveStakes(economyKey(w.p)) + w.recordResolutionEvidence(unresolved) +} + +// recordResolutionEvidence persists file-side resolution evidence for stake +// records whose settled markers could not be journaled. The character file +// then proves the round resolved even though the journal could not record +// its marker, so recovery still honors the resolution instead of refunding +// an already-resolved round. +func (w casinoWallet) recordResolutionEvidence(unresolved []*economyRecord) { if len(unresolved) == 0 { return } @@ -186,6 +206,18 @@ func (w casinoWallet) ResolveStakes() { } } +// ResolveStake settles ONE outstanding stake by wallet handle: the hand it +// belongs to was paid (its return is inside the settlement) or lost, so it +// must never be refunded. Unresolvable markers fall back to the same file- +// side evidence the bulk ResolveStakes uses. +func (w casinoWallet) ResolveStake(stakeID string) { + if stakeID == "" { + return + } + unresolved := w.g.Economy.ResolveStakeByID(economyKey(w.p), stakeID) + w.recordResolutionEvidence(unresolved) +} + // recordStakeEvidence adds every currently open stake of the player to the // file-side resolution evidence list and returns how many IDs were newly // recorded. Used when the economy journal is unavailable: the open wager @@ -207,8 +239,25 @@ func (w casinoWallet) recordStakeEvidence() int { // come back as chips, credits through the durable payout path. The stakes // are resolved only once the return is durable; on failure the chip return // is rolled back out of memory and the open records drive recovery instead. +// +// Stakes already accounted for by an open settlement payout are NOT +// refunded: a journaled table settlement includes its hand's stake return +// and recovery re-applies that owed payout, so refunding the stake here +// would pay the round twice (the returned credits then arrive on top of +// the stake). The stakes are resolved; their money arrives with the +// re-applied settlement. func (w casinoWallet) RefundOutstanding() (chips, credits int, err error) { for _, rec := range w.g.Economy.openStakes(economyKey(w.p)) { + if resolvedStakeRecorded(w.p, rec.ID) { + // Already resolved through file-side evidence: recovery + // settles it, never refunds it. + continue + } + if w.g.Economy.stakeCovered(rec) { + // The covering settlement is journaled and owed: resolving + // the stake defers its return to the payout re-application. + continue + } chips += rec.Chips credits += rec.Credit } @@ -227,7 +276,7 @@ func (w casinoWallet) RefundOutstanding() (chips, credits int, err error) { } } if credits > 0 { - if err := w.payCreditsFlagged(credits, true); err != nil { + if err := w.payCreditsFlagged(credits, true, nil); err != nil { if chips > 0 { // Roll the chip return back out of memory: the durable // refund will re-apply both amounts from the open records. diff --git a/internal/game/casino_refund_regression_test.go b/internal/game/casino_refund_regression_test.go new file mode 100644 index 0000000..90864e2 --- /dev/null +++ b/internal/game/casino_refund_regression_test.go @@ -0,0 +1,164 @@ +package game + +// Settlement/refund regressions for the durable-money audit: a settlement +// whose save failed is journaled as owed money, so a shutdown refund must +// NOT also return the round's stake — recovery re-applies the owed payout, +// which already contains the stake's return (table hands) or the round's +// winnings (slots, where the stake was consumed by playing the round). + +import ( + "path/filepath" + "testing" + + "thehouseoficarus/internal/casino" + "thehouseoficarus/internal/player" +) + +// failingSaveDuring swaps the account store for one that cannot save, +// simulating a character-save failure, and restores it afterwards. +func failingSaveDuring(g *Game, dir string, fn func()) { + realStore := g.AccountStore + g.AccountStore = player.NewAccountStore(filepath.Join(dir, "unwritable")) + defer func() { g.AccountStore = realStore }() + fn() +} + +// TestFailedTableSettlementDoesNotDoubleReturnStake: a won hand whose payout +// save fails leaves the settlement journaled and owed. A graceful-shutdown +// refund must not ALSO return the stake; recovery re-applies the payout +// (which returns the stake inside it) exactly once. 100 - 10 + 20 = 110, +// never 120. +func TestFailedTableSettlementDoesNotDoubleReturnStake(t *testing.T) { + h := newHarness(t) + h.seedAccount("alice") + p := h.seedCharacter("alice", "alice") + h.g.RunOnOwner(func() { + p.Credits = 100 + w := casinoWallet{g: h.g, p: p} + wager, err := w.Wager(10) + if err != nil { + t.Error(err) + return + } + failingSaveDuring(h.g, h.dir, func() { + if err := w.PayStakeCover(20, []string{wager.ID}); err == nil { + t.Error("expected the settlement save to fail") + return + } + // The shutdown refund path must not return the covered stake. + if chips, credits, err := w.RefundOutstanding(); err != nil || chips != 0 || credits != 0 { + t.Errorf("shutdown refund returned a covered stake: chips=%d credits=%d err=%v", chips, credits, err) + return + } + }) + h.g.recoverEconomy() + loaded, err := h.g.AccountStore.LoadCharacter("alice") + if err != nil { + t.Error(err) + return + } + if loaded.Credits != 110 { + t.Errorf("balance after recovery = %d, want 110 (stake returned exactly once, inside the re-applied payout)", loaded.Credits) + } + if open := h.g.Economy.mustLoadOpen(t); len(open) != 0 { + t.Errorf("records left open after recovery: %+v", open) + } + }) +} + +// TestFailedSlotsPayoutDoesNotDoubleReturnStake: a slot spin's payout is +// winnings only — the stake is consumed by playing the round. When the +// payout save fails, the journaled winnings prove the round resolved: the +// shutdown refund must not return the stake on top of the owed payout. +func TestFailedSlotsPayoutDoesNotDoubleReturnStake(t *testing.T) { + h := newHarness(t) + h.seedAccount("alice") + p := h.seedCharacter("alice", "alice") + h.g.RunOnOwner(func() { + p.Credits = 100 + w := casinoWallet{g: h.g, p: p} + if _, err := w.Wager(10); err != nil { + t.Error(err) + return + } + failingSaveDuring(h.g, h.dir, func() { + // Machine payouts carry no stake linkage (nil covers): the + // journaled settlement still resolves the round. + if err := w.PayCredits(20); err == nil { + t.Error("expected the payout save to fail") + return + } + if chips, credits, err := w.RefundOutstanding(); err != nil || chips != 0 || credits != 0 { + t.Errorf("shutdown refund paid a resolved round's stake: chips=%d credits=%d err=%v", chips, credits, err) + return + } + }) + h.g.recoverEconomy() + loaded, err := h.g.AccountStore.LoadCharacter("alice") + if err != nil { + t.Error(err) + return + } + if loaded.Credits != 110 { + t.Errorf("balance after recovery = %d, want 110 (stake consumed by the round, winnings paid once)", loaded.Credits) + } + }) +} + +// TestUncoveredStakeStillRefunds: without a journaled settlement, the +// shutdown refund path keeps returning outstanding stakes (the documented +// refund-on-shutdown policy) — a regression guard that the coverage skip +// never over-triggers. +func TestUncoveredStakeStillRefunds(t *testing.T) { + h := newHarness(t) + h.seedAccount("alice") + p := h.seedCharacter("alice", "alice") + h.g.RunOnOwner(func() { + p.Credits = 100 + w := casinoWallet{g: h.g, p: p} + if _, err := w.Wager(10); err != nil { + t.Error(err) + return + } + chips, credits, err := w.RefundOutstanding() + if err != nil || chips != 0 || credits != 10 { + t.Errorf("uncovered stake refund: chips=%d credits=%d err=%v", chips, credits, err) + return + } + }) +} + +// TestCasinoManagerRefundPathUsesCoverage: the same rule through the live +// manager path (baccarat settle → RefundAll), exercised with a probe wallet +// so the settlement save fails mid-round. +func TestCasinoManagerFailedSettlementRefundIsExact(t *testing.T) { + h := newHarness(t) + h.seedAccount("alice") + p := h.seedCharacter("alice", "alice") + h.g.RunOnOwner(func() { + p.Credits = 1000 + h.g.Casino.Join(1, casino.TableConfig{ID: "b", Game: casino.GameBaccarat}, "alice") + var totalPaid int + w := casinoWallet{g: h.g, p: p} + wallet := &fallbackProbeWallet{harness: h, inner: w, onPay: func(credits int, failedCall bool) { + totalPaid += credits + }} + // One bet: a lone player's bet deals and settles immediately. The + // wallet fails the first settlement's journal-free save via a + // broken SAVE (not journal) so the record stays open and owed. + h.g.Casino.Bet(1, "b", "alice", "player", 10, wallet) + h.g.emitCasinoEvents(h.g.Casino.RefundAll()) + h.g.recoverEconomy() + loaded, err := h.g.AccountStore.LoadCharacter("alice") + if err != nil { + t.Error(err) + return + } + // The stake was committed durably (990); RefundAll must not return + // it while the settlement payout is owed; recovery re-applies the + // payout: 1000 - 10 + totalPaid = final. + if want := 1000 - 10 + totalPaid; loaded.Credits != want { + t.Errorf("balance after refund+recovery = %d, want %d (wager 10, paid %d)", loaded.Credits, want, totalPaid) + } + }) +} diff --git a/internal/game/economy.go b/internal/game/economy.go index fc1b289..21e7721 100644 --- a/internal/game/economy.go +++ b/internal/game/economy.go @@ -89,6 +89,15 @@ type economyRecord struct { CreditsAfter int `json:"credits_after"` ChipsBefore int `json:"chips_before"` ChipsAfter int `json:"chips_after"` + + // Covers lists the stake wager IDs a settlement payout RETURNS (a + // table hand pays its stake back inside the payout). Empty means no + // explicit linkage: recovery treats such a settlement as resolving + // every open stake of its player (the historical behavior, and the + // right reading for a slots payout whose stake was consumed by + // playing the round). A REFUND payout never covers: its credit part + // is the stake return being re-applied, not a new settlement. + Covers []string `json:"covers,omitempty"` } type economyJournal struct { @@ -103,12 +112,17 @@ type economyJournal struct { // ID absent from this set is settled or void and must not be trusted as // durability evidence. openPayouts map[string]bool + // openPayoutRecs indexes open payout records per player key so a + // refund can check which stakes an owed settlement already returns + // (stakeCovered). Same ownership discipline as stakes: all mutations + // happen on the game owner or during startup recovery. + openPayoutRecs map[string][]*economyRecord } func newEconomyJournal(dataDir string) *economyJournal { dir := filepath.Join(dataDir, "economy") _ = os.MkdirAll(dir, 0700) - return &economyJournal{path: filepath.Join(dir, "journal.jsonl"), stakes: make(map[string][]*economyRecord), openPayouts: make(map[string]bool)} + return &economyJournal{path: filepath.Join(dir, "journal.jsonl"), stakes: make(map[string][]*economyRecord), openPayouts: make(map[string]bool), openPayoutRecs: make(map[string][]*economyRecord)} } func newEconomyRecordID() string { @@ -156,22 +170,23 @@ func (j *economyJournal) appendRetry(rec economyRecord, attempts int) error { // start opens a durable record for the in-flight transaction. func (j *economyJournal) start(kind string, p *player.Player, chips, credits, creditsBefore, creditsAfter, chipsBefore, chipsAfter int) (*economyRecord, error) { - return j.startKind(kind, false, p, chips, credits, creditsBefore, creditsAfter, chipsBefore, chipsAfter) + return j.startKind(kind, false, nil, p, chips, credits, creditsBefore, creditsAfter, chipsBefore, chipsAfter) } // startRefund opens a refund payout record (stake returned at shutdown or // eviction; see economyRecord.Refund). func (j *economyJournal) startRefund(p *player.Player, credits, creditsBefore, creditsAfter int) (*economyRecord, error) { - return j.startKind("payout", true, p, 0, credits, creditsBefore, creditsAfter, 0, 0) + return j.startKind("payout", true, nil, p, 0, credits, creditsBefore, creditsAfter, 0, 0) } -func (j *economyJournal) startKind(kind string, refund bool, p *player.Player, chips, credits, creditsBefore, creditsAfter, chipsBefore, chipsAfter int) (*economyRecord, error) { +func (j *economyJournal) startKind(kind string, refund bool, covers []string, p *player.Player, chips, credits, creditsBefore, creditsAfter, chipsBefore, chipsAfter int) (*economyRecord, error) { rec := &economyRecord{ ID: newEconomyRecordID(), Player: p.Name, PlayerID: p.ID, Kind: kind, Refund: refund, + Covers: covers, Tracked: kind == "payout", Chips: chips, Credit: credits, @@ -188,17 +203,35 @@ func (j *economyJournal) startKind(kind string, refund bool, p *player.Player, c } if rec.Tracked { j.openPayouts[rec.ID] = true + j.openPayoutRecs[rec.key()] = append(j.openPayoutRecs[rec.key()], rec) } return rec, nil } +// closePayoutTracking drops rec from the open-payout indexes (its status +// changed to settled or void). +func (j *economyJournal) closePayoutTracking(rec *economyRecord) { + delete(j.openPayouts, rec.ID) + kept := j.openPayoutRecs[rec.key()][:0] + for _, open := range j.openPayoutRecs[rec.key()] { + if open != rec { + kept = append(kept, open) + } + } + if len(kept) == 0 { + delete(j.openPayoutRecs, rec.key()) + } else { + j.openPayoutRecs[rec.key()] = kept + } +} + // settle marks a record settled (the balance mutation is durable). // Fire-and-forget: callers whose recovery semantics depend on the marker // being durable use settleDurable instead. func (j *economyJournal) settle(rec *economyRecord) { rec.Status = "settled" _ = j.append(*rec) - delete(j.openPayouts, rec.ID) + j.closePayoutTracking(rec) } // settleDurable marks a record settled and persists the marker with retries, @@ -211,7 +244,7 @@ func (j *economyJournal) settleDurable(rec *economyRecord) bool { rec.Status = "open" return false } - delete(j.openPayouts, rec.ID) + j.closePayoutTracking(rec) return true } @@ -220,7 +253,7 @@ func (j *economyJournal) settleDurable(rec *economyRecord) bool { func (j *economyJournal) void(rec *economyRecord) { rec.Status = "void" _ = j.append(*rec) - delete(j.openPayouts, rec.ID) + j.closePayoutTracking(rec) } // registerStake tracks an outstanding wager record for its player. Wager @@ -268,6 +301,65 @@ func (j *economyJournal) ResolveStakes(player string) []*economyRecord { return unresolved } +// ResolveStakeByID closes ONE outstanding stake (identified by its wallet +// handle) with a settled marker: the hand it belongs to was paid or lost, +// so it must never be refunded even while sibling stakes of the same round +// remain open. On success the stake leaves the index; a marker whose append +// cannot be made durable leaves the stake OPEN (the covering settlement's +// journal record, or file-side resolution evidence, still protects it from +// being refunded twice). +func (j *economyJournal) ResolveStakeByID(player, id string) []*economyRecord { + recs := j.stakes[player] + remaining := make([]*economyRecord, 0, len(recs)) + var unresolved []*economyRecord + for _, rec := range recs { + if rec.ID != id { + remaining = append(remaining, rec) + continue + } + rec.Status = "settled" + if err := j.appendRetry(*rec, 3); err != nil { + rec.Status = "open" + remaining = append(remaining, rec) + unresolved = append(unresolved, rec) + } + } + if len(remaining) == 0 { + delete(j.stakes, player) + } else { + j.stakes[player] = remaining + } + return unresolved +} + +// stakeCovered reports whether an OPEN payout record already accounts for +// stake's return. A table settlement lists the stake IDs it covers; a +// legacy (or slots) settlement without an explicit list is treated as +// resolving every open stake of its player — a journaled settlement means +// the round resolved, and its recovery re-application delivers the money. +// A REFUND payout never covers: its credit part is the stake return being +// re-applied, not a settlement. +func (j *economyJournal) stakeCovered(stake *economyRecord) bool { + for _, payout := range j.openPayoutRecs[stake.key()] { + if payout.Refund { + continue + } + if len(payout.Covers) == 0 || containsString(payout.Covers, stake.ID) { + return true + } + } + return false +} + +func containsString(list []string, s string) bool { + for _, item := range list { + if item == s { + return true + } + } + return false +} + // economyKey is the identity a player's journal records are indexed by: // the stable character ID when known, else the (renameable) name. func economyKey(p *player.Player) string { @@ -349,13 +441,16 @@ func (j *economyJournal) reloadStakes() error { } j.stakes = make(map[string][]*economyRecord) j.openPayouts = make(map[string]bool) + j.openPayoutRecs = make(map[string][]*economyRecord) for _, fr := range folded { if fr.rec.Kind == "wager" && fr.rec.Status == "open" { rec := fr.rec j.registerStake(&rec) } if fr.rec.Kind == "payout" && fr.rec.Status == "open" { + rec := fr.rec j.openPayouts[fr.rec.ID] = true + j.openPayoutRecs[rec.key()] = append(j.openPayoutRecs[rec.key()], &rec) } } return nil @@ -393,11 +488,19 @@ type foldedRecord struct { func (j *economyJournal) loadFolded() ([]foldedRecord, error) { f, err := os.Open(j.path) if err != nil { - return nil, nil // no journal yet: nothing to fold + if os.IsNotExist(err) { + return nil, nil // no journal yet: nothing to fold + } + // Any other open failure (permissions, symlink loops, I/O errors) + // is NOT "an empty healthy journal": callers must not compensate + // or compact over a journal whose contents are unknown. + return nil, fmt.Errorf("open economy journal: %w", err) } defer f.Close() latest := map[string]*foldedRecord{} var order []*foldedRecord + corruptPending := false + corruptSeq := 0 scanner := bufio.NewScanner(f) scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) for seq := 0; scanner.Scan(); seq++ { @@ -407,11 +510,22 @@ func (j *economyJournal) loadFolded() ([]foldedRecord, error) { } var rec economyRecord if err := json.Unmarshal(line, &rec); err != nil { - // A torn final line (crash mid-append) is not a valid record; - // skip it. Appends are small and single-line, so only the last - // line can be torn. + // A malformed line is only tolerable as a torn FINAL line (a + // crash mid-append; appends are small and single-line, so a + // torn write can also merge with a later append onto one + // mid-file line). A malformed record followed by ANY other + // record means the fold cannot be trusted: surface the + // failure instead of skipping the line — compensating or + // compacting over a damaged journal could erase owed money. + if !corruptPending { + corruptPending = true + corruptSeq = seq + } continue } + if corruptPending { + return nil, fmt.Errorf("economy journal line %d is malformed; later records exist and may be owed money (journal preserved for inspection)", corruptSeq) + } if existing, ok := latest[rec.ID]; ok { existing.rec = rec existing.seq = seq @@ -447,6 +561,7 @@ func (j *economyJournal) compact() { tmp := j.path + ".compact" f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600) if err != nil { + fmt.Fprintf(os.Stderr, "economy compaction aborted: %v (journal preserved for inspection)\n", err) return } defer os.Remove(tmp) @@ -457,7 +572,11 @@ func (j *economyJournal) compact() { if err != nil { continue } - w.Write(data) + if _, err := w.Write(data); err != nil { + f.Close() + fmt.Fprintf(os.Stderr, "economy compaction aborted: %v (journal preserved for inspection)\n", err) + return + } w.WriteByte('\n') } if err := w.Flush(); err != nil { @@ -743,9 +862,13 @@ func (g *Game) recoverEconomy() { fmt.Fprintf(os.Stderr, "economy recovery: load %s: character not found\n", rec.Player) return nil } - // laterPayoutEvidence reports whether any non-void payout record was - // journaled for the player after seq — evidence the round resolved and - // its settlement was recorded. + // stakeEvidence reports whether any non-void payout record was + // journaled for this stake after seq — evidence the round resolved and + // its settlement was recorded. A settlement that lists explicit stake + // IDs (a table hand) only proves THIS stake when it covers it; a + // legacy (or slots) payout without a list proves the round resolved + // (the historical, and slots-correct, reading: the stake was consumed + // by playing the round). // // settlement separately reports whether a SETTLEMENT payout (not a // refund) was journaled later: a settlement — even a partial one like @@ -756,17 +879,21 @@ func (g *Game) recoverEconomy() { // instead marks a refund in progress: its credit part is re-applied by // phase 1 and the chip part (if the debit was durable) still comes // back in chips. - laterPayoutEvidence := func(rec *economyRecord, seq int) (resolved bool, settlement bool) { + stakeEvidence := func(rec *economyRecord, seq int) (evidence bool, settlement bool) { for _, fr := range folded { - if fr.rec.Kind == "payout" && fr.rec.Status != "void" && fr.seq > seq && - payoutIdentifies(fr.rec, *rec) { - resolved = true - if !fr.rec.Refund { - settlement = true - } + if fr.rec.Kind != "payout" || fr.rec.Status == "void" || fr.seq <= seq || + !payoutIdentifies(fr.rec, *rec) { + continue + } + if len(fr.rec.Covers) > 0 && !containsString(fr.rec.Covers, rec.ID) { + continue + } + evidence = true + if !fr.rec.Refund { + settlement = true } } - return resolved, settlement + return evidence, settlement } compensated := 0 @@ -955,7 +1082,7 @@ func (g *Game) recoverEconomy() { compensated++ continue } - resolvedByPayout, resolvedBySettlement := laterPayoutEvidence(rec, folded[openWagers[k]].seq) + resolvedByPayout, resolvedBySettlement := stakeEvidence(rec, folded[openWagers[k]].seq) // Checkpoint comparisons use the DURABLE state, captured before any // in-memory chip restoration below. creditsNow, chipsNow := p.Credits, countChips(p) diff --git a/internal/game/journal_damage_regression_test.go b/internal/game/journal_damage_regression_test.go new file mode 100644 index 0000000..d8577fe --- /dev/null +++ b/internal/game/journal_damage_regression_test.go @@ -0,0 +1,123 @@ +package game + +// Journal-damage regressions: a malformed record followed by other records +// must abort the fold (the journal is preserved for inspection — records +// beyond the damage may be owed money), an open failure is never "an empty +// healthy journal", and compaction can never run over an untrusted fold. + +import ( + "os" + "testing" +) + +// TestMalformedJournalRecordAbortsFold: a malformed line followed by a valid +// record makes loadFolded fail — skipping the line and continuing would let +// compensation and compaction run over records that may owe money. +func TestMalformedJournalRecordAbortsFold(t *testing.T) { + j := newEconomyJournal(t.TempDir()) + original := []byte("{\"id\":\"damaged\",\"kind\":\"payout\"\n{\"id\":\"valid\",\"kind\":\"payout\",\"status\":\"open\",\"credit\":20}\n") + if err := os.WriteFile(j.path, original, 0600); err != nil { + t.Fatal(err) + } + if _, err := j.loadFolded(); err == nil { + t.Fatal("malformed mid-file record silently accepted") + } + // Compaction must abort and preserve the journal verbatim. + j.compact() + after, err := os.ReadFile(j.path) + if err != nil { + t.Fatal(err) + } + if string(after) != string(original) { + t.Errorf("compaction rewrote a damaged journal: %s", after) + } +} + +// TestTornFinalJournalLineIsTolerated: a torn LAST line (crash mid-append) +// is skipped — every valid record before it still folds. +func TestTornFinalJournalLineIsTolerated(t *testing.T) { + j := newEconomyJournal(t.TempDir()) + data := []byte("{\"id\":\"a\",\"kind\":\"wager\",\"status\":\"open\"}\n{\"id\":\"b\",\"kind\":\"payo") + if err := os.WriteFile(j.path, data, 0600); err != nil { + t.Fatal(err) + } + folded, err := j.loadFolded() + if err != nil { + t.Fatalf("torn final line rejected a healthy fold: %v", err) + } + if len(folded) != 1 || folded[0].rec.ID != "a" { + t.Fatalf("unexpected fold: %+v", folded) + } +} + +// TestJournalOpenErrorIsNotAnEmptyFold: an open failure (permissions, a +// symlink loop) must be an error, never "no journal" — recovery would +// otherwise treat owed money as never-charged. +func TestJournalOpenErrorIsNotAnEmptyFold(t *testing.T) { + j := newEconomyJournal(t.TempDir()) + if err := os.Symlink(j.path, j.path); err != nil { + t.Fatal(err) + } + if _, err := j.loadFolded(); err == nil { + t.Error("ELOOP open failure reported as an empty healthy journal") + } +} + +// TestOpenFailureAbortsRecovery: recoverEconomy over a journal that cannot +// be fully read (malformed record with later records present) must abort +// without paying owed money or compacting. +func TestOpenFailureAbortsRecovery(t *testing.T) { + h := newHarness(t) + h.seedAccount("alice") + p := h.seedCharacter("alice", "alice") + h.g.RunOnOwner(func() { + p.Credits = 100 + if err := h.g.AccountStore.SaveCharacter(p); err != nil { + t.Error(err) + return + } + // Corrupt the journal mid-file (a damaged record followed by an + // owed payout — appends after a torn merged line), then attempt + // recovery. + f, err := os.OpenFile(h.g.Economy.path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600) + if err != nil { + t.Error(err) + return + } + if _, werr := f.WriteString("{\"id\":\"damaged\",\"kind\":\"payout\"\n"); werr != nil { + t.Error(werr) + } + f.Close() + if _, err := h.g.Economy.start("payout", p, 0, 20, 100, 120, 0, 0); err != nil { + t.Errorf("owed payout could not be journaled: %v", err) + return + } + h.g.recoverEconomy() + // The damaged fold aborted recovery: no compensation ran and no + // compaction erased the records. + loaded, err := h.g.AccountStore.LoadCharacter("alice") + if err != nil { + t.Error(err) + return + } + if loaded.Credits != 100 { + t.Errorf("recovery compensated over a damaged journal: credits=%d, want 100 (preserved for inspection)", loaded.Credits) + } + data, err := os.ReadFile(h.g.Economy.path) + if err != nil { + t.Fatal(err) + } + if !containsBytes(data, "kind\":\"payout\"") { + t.Error("recovery compacted a damaged journal, erasing records preserved for inspection") + } + }) +} + +func containsBytes(data []byte, s string) bool { + for i := 0; i+len(s) <= len(data); i++ { + if string(data[i:i+len(s)]) == s { + return true + } + } + return false +} diff --git a/internal/game/journal_failure_regression_test.go b/internal/game/journal_failure_regression_test.go index 683b996..043afeb 100644 --- a/internal/game/journal_failure_regression_test.go +++ b/internal/game/journal_failure_regression_test.go @@ -99,6 +99,21 @@ func (w *fallbackProbeWallet) PayCredits(amount int) error { } func (w *fallbackProbeWallet) ResolveStakes() { w.inner.ResolveStakes() } + +func (w *fallbackProbeWallet) PayStakeCover(amount int, stakeIDs []string) error { + if w.broke { + w.onPay(amount, false) + return w.inner.PayStakeCover(amount, stakeIDs) + } + w.broke = true + w.onPay(amount, true) + failJournalDuring(w.harness, func() { + _ = w.inner.PayStakeCover(amount, stakeIDs) + }) + return nil +} + +func (w *fallbackProbeWallet) ResolveStake(stakeID string) { w.inner.ResolveStake(stakeID) } func (w *fallbackProbeWallet) RefundOutstanding() (int, int, error) { return w.inner.RefundOutstanding() } diff --git a/internal/game/production_engine.go b/internal/game/production_engine.go index 9643483..2cd9b1d 100644 --- a/internal/game/production_engine.go +++ b/internal/game/production_engine.go @@ -215,36 +215,39 @@ func (g *Game) advanceProduction(sess *net.Session, p *player.Player) bool { info, _ := productionTypes[cKey] + // A full inventory must never destroy materials: verify BOTH possible + // outcomes can place their item (the output on success, the fail + // replacement on failure) BEFORE the roll and any consumption. + failDef, _ := g.ItemStore.Load(craft.Fail) + if !craftOutputFits(p, itemID, item) || (failDef != nil && !craftOutputFits(p, craft.Fail, failDef)) { + sess.WriteLine("Your inventory is too full!") + g.cancelAction(p) + return false + } + if rand.Float64() < chance { outputQty := craft.OutputQty if outputQty <= 0 { outputQty = 1 } + // Byproducts must be collected BEFORE consumption: the resolver + // maps each ingredient entry to the alternative actually being + // consumed, which requires the items still present. byproducts := g.collectByproducts(p, item) - placed := false - outDef, _ := g.ItemStore.Load(item.ID) - if outDef != nil && outDef.Stackable { - for i := 0; i < 28; i++ { - slot := p.InvSlot(i) - if slot != nil && slot.ItemID == item.ID { - consumeIngredients(craft, p.CountItem, p.RemoveItem) - slot.Quantity += outputQty - placed = true - break - } - } + if !consumeIngredients(craft, p.CountItem, p.RemoveItem) { + // Cannot happen after the availability gate above, but never + // produce output from a failed consumption. + g.cancelAction(p) + return false } - if !placed { - consumeIngredients(craft, p.CountItem, p.RemoveItem) - freeSlot := p.FirstFreeSlot() - if freeSlot == -1 { - sess.WriteLine("Your inventory is too full!") - g.cancelAction(p) - return false - } - p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: item.ID, Quantity: outputQty}) + if !placeCraftOutput(p, itemID, item, outputQty) { + // Defensive: the fits check above should have guaranteed a + // stack or slot. + sess.WriteLine("Your inventory is too full!") + g.cancelAction(p) + return false } for _, bp := range byproducts { @@ -272,7 +275,10 @@ func (g *Game) advanceProduction(sess *net.Session, p *player.Player) bool { } sess.WriteLine(msg) } else { - consumeIngredients(craft, p.CountItem, p.RemoveItem) + if !consumeIngredients(craft, p.CountItem, p.RemoveItem) { + g.cancelAction(p) + return false + } if craft.Fail != "" { freeSlot := p.FirstFreeSlot() @@ -317,20 +323,26 @@ func (g *Game) collectByproducts(p *player.Player, item *item.ItemDef) []string if craft == nil { return nil } + // The byproduct must match the alternative that will actually be + // consumed, resolved with the SAME aggregate plan consumeIngredients + // uses (shared stock reserved across entries), not the first + // alternative merely present: with a bucket and a jug of water both + // in the inventory, consuming the bucket must return the empty + // bucket — and when entries share stock, the plan decides who gets it. + plan := planIngredients(craft, p.CountItem) + if plan == nil { + return nil + } var byproducts []string - for _, e := range craft.Ingredients { + for i := range craft.Ingredients { + e := &craft.Ingredients[i] if len(e.Byproducts) == 0 { continue } - // The byproduct must match the alternative that will actually be - // consumed (the same craftIngredientSource resolution - // consumeIngredients uses), not the first alternative merely - // present: with a bucket and a jug of water both in the inventory, - // consuming the bucket must return the empty bucket. - id, _ := craftIngredientSource(&e, p.CountItem) - for i, alt := range e.Items { - if alt == id && i < len(e.Byproducts) && e.Byproducts[i] != "" { - byproducts = append(byproducts, e.Byproducts[i]) + chosen := plan[i].id + for j, alt := range e.Items { + if alt == chosen && j < len(e.Byproducts) && e.Byproducts[j] != "" { + byproducts = append(byproducts, e.Byproducts[j]) break } } diff --git a/internal/game/production_index.go b/internal/game/production_index.go index 8c0207c..ffacd8a 100644 --- a/internal/game/production_index.go +++ b/internal/game/production_index.go @@ -4,6 +4,7 @@ import ( "sync" "thehouseoficarus/internal/item" + "thehouseoficarus/internal/player" ) type CraftIndex struct { @@ -208,43 +209,103 @@ func craftIngredientSource(e *item.IngredientEntry, countItem func(string) int) } func craftHasAllItemsQty(c *item.CraftDef, countItem func(string) int) bool { + return planIngredients(c, countItem) != nil +} + +// consumption is one planned ingredient removal: the alternative chosen +// for an entry and the quantity to remove of it. +type consumption struct { + id string + qty int +} + +// planIngredients resolves every ingredient entry against the player's +// stock, reserving quantities ACROSS entries: two entries listing the same +// item (e.g. two one-ore entries) cannot both consume the same single ore. +// Availability checks and consumption both plan through this helper so +// they always agree; returns nil when any entry cannot be satisfied from +// the stock remaining after earlier entries' reservations. +func planIngredients(c *item.CraftDef, countItem func(string) int) []consumption { if c == nil { - return false + return nil } + reserved := make(map[string]int, len(c.Ingredients)) + plan := make([]consumption, 0, len(c.Ingredients)) for i := range c.Ingredients { - if id, _ := craftIngredientSource(&c.Ingredients[i], countItem); id == "" { - return false + e := &c.Ingredients[i] + qty := e.Quantity + if qty <= 0 { + qty = 1 } + chosen := "" + for _, id := range e.Items { + if countItem(id)-reserved[id] >= qty { + chosen = id + break + } + } + if chosen == "" { + return nil + } + reserved[chosen] += qty + plan = append(plan, consumption{id: chosen, qty: qty}) } - return true + return plan } // consumeIngredients removes one batch of every ingredient entry. Each -// entry consumes the same alternative the availability checks verify (the -// first one covering the FULL quantity), and consumption is two-pass: -// nothing is removed unless every entry resolves, so a craft can never -// partially consume an alternative and silently keep the rest. Callers that -// already ran a craftHasAllItems* check cannot see a false return; it -// guards callers whose own pre-check is weaker. Callers that check the -// result must abort the craft without producing output. +// entry consumes the same alternative the availability checks verify, and +// consumption is all-or-nothing: nothing is removed unless every entry can +// be satisfied from the aggregate stock, so a craft can never partially +// consume an ingredient or double-count shared stock. Callers that already +// ran a craftHasAllItems* check cannot see a false return; it guards +// callers whose own pre-check is weaker. Callers that check the result +// must abort the craft without producing output. func consumeIngredients(c *item.CraftDef, countItem func(string) int, removeItem func(string, int) bool) bool { - if c == nil { - return true + plan := planIngredients(c, countItem) + if plan == nil { + return false } - type consumption struct { - id string - qty int + for _, s := range plan { + removeItem(s.id, s.qty) } - plan := make([]consumption, 0, len(c.Ingredients)) - for i := range c.Ingredients { - id, qty := craftIngredientSource(&c.Ingredients[i], countItem) - if id == "" { - return false + return true +} + +// craftOutputFits reports whether outputQty of an item can be placed into +// the inventory right now: merged into an existing stack for stackable +// definitions, otherwise a free slot. Crafters must check BEFORE consuming +// ingredients so a full inventory can never destroy materials without +// producing the output. +func craftOutputFits(p *player.Player, itemID string, outDef *item.ItemDef) bool { + if outDef != nil && outDef.Stackable { + for i := 0; i < 28; i++ { + if slot := p.InvSlot(i); slot != nil && slot.ItemID == itemID { + return true + } } - plan = append(plan, consumption{id: id, qty: qty}) } - for _, s := range plan { - removeItem(s.id, s.qty) + return p.FirstFreeSlot() != -1 +} + +// placeCraftOutput puts outputQty of an item into the inventory, merging +// into an existing stack for stackable definitions. Returns false only +// when nothing fits — callers should have verified craftOutputFits first +// (consumption cannot free fewer slots than the check saw on the owner +// goroutine, so a false return is defensive). +func placeCraftOutput(p *player.Player, itemID string, outDef *item.ItemDef, outputQty int) bool { + if outDef != nil && outDef.Stackable { + for i := 0; i < 28; i++ { + if slot := p.InvSlot(i); slot != nil && slot.ItemID == itemID { + slot.Quantity += outputQty + return true + } + } + } + freeSlot := p.FirstFreeSlot() + if freeSlot == -1 { + return false } + p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: itemID, Quantity: outputQty}) return true } diff --git a/internal/game/production_output_regression_test.go b/internal/game/production_output_regression_test.go new file mode 100644 index 0000000..cee028d --- /dev/null +++ b/internal/game/production_output_regression_test.go @@ -0,0 +1,186 @@ +package game + +// Crafting-output regressions: a full inventory must never consume +// materials without producing the output, and recipes whose entries share +// the same item must reserve aggregate quantities (two one-ore entries need +// two ore), never double-count one stack. + +import ( + "os" + "path/filepath" + "testing" + + "thehouseoficarus/internal/item" + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/player" +) + +const headlessArrowYAML = `id: headless_arrow +name: headless arrow +stackable: true +value: 1 +craft: + - ingredients: + - items: + - arrow_shaft + quantity: 15 + - items: + - feather + quantity: 15 + level: 1 + output_qty: 15 + type: fletching + ticks_per_cycle: 2 + xp: 1 +` + +func seedArrowWorld(t *testing.T, dir string) { + t.Helper() + seedWorld(t, dir) + defs := map[string]string{ + "arrow_shaft": "id: arrow_shaft\nname: arrow shaft\nstackable: true\nvalue: 1\n", + "feather": "id: feather\nname: feather\nstackable: true\nvalue: 1\n", + "headless_arrow": headlessArrowYAML, + } + for id, data := range defs { + path := filepath.Join(dir, "items", id+".yaml") + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(data), 0600); err != nil { + t.Fatal(err) + } + } +} + +// TestFullInventoryFletchPreservesMaterials: a full inventory must refuse +// the craft ("too full") WITHOUT consuming the ingredients — before the +// fix, the 15-shaft/15-feather batch was consumed and the output silently +// dropped. +func TestFullInventoryFletchPreservesMaterials(t *testing.T) { + dir := t.TempDir() + seedArrowWorld(t, dir) + h := harnessFromDir(t, dir) + h.seedAccount("alice") + p := h.seedCharacter("alice", "alice") + h.g.RunOnOwner(func() { + p.SetInvSlot(0, &player.InventorySlot{ItemID: "arrow_shaft", Quantity: 30}) + p.SetInvSlot(1, &player.InventorySlot{ItemID: "feather", Quantity: 30}) + for i := 2; i < 28; i++ { + p.SetInvSlot(i, &player.InventorySlot{ItemID: "filler", Quantity: 1}) + } + def, err := h.g.ItemStore.Load("headless_arrow") + if err != nil { + t.Error(err) + return + } + if !h.g.canDoFletchItem(p, def) { + t.Error("recipe unexpectedly unavailable") + return + } + sess := &net.Session{Conn: newChanConn(), Player: p} + sess.SetState(net.StateGame) + h.g.startFletchAction(sess, p, def, 1) + // The timed fletch advances: phase 0 announces, phase 1 crafts. + h.g.advanceFletch(sess, p) + h.g.advanceFletch(sess, p) + if p.CountItem("arrow_shaft") != 30 || p.CountItem("feather") != 30 { + t.Errorf("materials consumed with no output: shafts=%d feathers=%d; want 30 each", + p.CountItem("arrow_shaft"), p.CountItem("feather")) + } + if p.CountItem("headless_arrow") != 0 { + t.Errorf("output produced into a full inventory: %d", p.CountItem("headless_arrow")) + } + }) +} + +// TestFullInventoryStackMergeStillCrafts: a full inventory may still craft +// when the output merges into an existing stack. +func TestFullInventoryStackMergeStillCrafts(t *testing.T) { + dir := t.TempDir() + seedArrowWorld(t, dir) + h := harnessFromDir(t, dir) + h.seedAccount("alice") + p := h.seedCharacter("alice", "alice") + h.g.RunOnOwner(func() { + p.SetInvSlot(0, &player.InventorySlot{ItemID: "arrow_shaft", Quantity: 30}) + p.SetInvSlot(1, &player.InventorySlot{ItemID: "feather", Quantity: 30}) + p.SetInvSlot(2, &player.InventorySlot{ItemID: "headless_arrow", Quantity: 5}) + for i := 3; i < 28; i++ { + p.SetInvSlot(i, &player.InventorySlot{ItemID: "filler", Quantity: 1}) + } + def, err := h.g.ItemStore.Load("headless_arrow") + if err != nil { + t.Error(err) + return + } + sess := &net.Session{Conn: newChanConn(), Player: p} + sess.SetState(net.StateGame) + h.g.startFletchAction(sess, p, def, 1) + h.g.advanceFletch(sess, p) + h.g.advanceFletch(sess, p) + if got := p.CountItem("headless_arrow"); got != 20 { + t.Errorf("headless_arrow = %d, want 20 (5 existing + 15 new, merged)", got) + } + if p.CountItem("arrow_shaft") != 15 || p.CountItem("feather") != 15 { + t.Errorf("ingredients not consumed: shafts=%d feathers=%d", p.CountItem("arrow_shaft"), p.CountItem("feather")) + } + }) +} + +// TestOverlappingRecipeReservesAggregateStock: two ingredient entries +// listing the same item cannot both consume one unit of it. +func TestOverlappingRecipeReservesAggregateStock(t *testing.T) { + p := player.New("alice") + p.SetInvSlot(0, &player.InventorySlot{ItemID: "ore", Quantity: 1}) + c := &item.CraftDef{Ingredients: []item.IngredientEntry{ + {Items: []string{"ore"}, Quantity: 1}, + {Items: []string{"ore"}, Quantity: 1}, + }} + if craftHasAllItemsQty(c, p.CountItem) { + t.Error("one ore accepted for two one-ore entries") + } + if consumeIngredients(c, p.CountItem, p.RemoveItem) { + t.Error("consumption reports success despite insufficient aggregate stock") + } + if p.CountItem("ore") != 1 { + t.Error("a refused recipe removed partial materials") + } +} + +// TestOverlappingRecipeReservesAcrossEntries: reservations apply across +// differing alternative lists — two one-water entries need TWO water-like +// items, and the plan consumes distinct ones, never one twice. +func TestOverlappingRecipeReservesAcrossEntries(t *testing.T) { + // Only one water-carrying item: the second entry cannot be satisfied. + p := player.New("alice") + p.SetInvSlot(0, &player.InventorySlot{ItemID: "clay", Quantity: 1}) + p.SetInvSlot(1, &player.InventorySlot{ItemID: "bucket_of_water", Quantity: 1}) + c := &item.CraftDef{Ingredients: []item.IngredientEntry{ + {Items: []string{"clay"}, Quantity: 1}, + {Items: []string{"water", "bucket_of_water"}, Quantity: 1}, + {Items: []string{"bucket_of_water", "water"}, Quantity: 1}, + }} + if planIngredients(c, p.CountItem) != nil { + t.Error("one water unit accepted for two one-water entries") + } + if p.CountItem("clay") != 1 || p.CountItem("bucket_of_water") != 1 { + t.Error("availability planning mutated inventory") + } + + // Both water items present: the plan consumes each exactly once. + p.SetInvSlot(2, &player.InventorySlot{ItemID: "water", Quantity: 1}) + plan := planIngredients(c, p.CountItem) + if plan == nil { + t.Fatal("clay + bucket + water must satisfy the recipe") + } + chosen := map[string]int{} + for _, s := range plan { + chosen[s.id]++ + } + for id, want := range map[string]int{"clay": 1, "bucket_of_water": 1, "water": 1} { + if chosen[id] != want { + t.Errorf("plan chose %s %d times, want %d (plan=%+v)", id, chosen[id], want, plan) + } + } +} |
