diff options
| -rw-r--r-- | building_guide/concurrency.md | 29 | ||||
| -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 |
17 files changed, 989 insertions, 175 deletions
diff --git a/building_guide/concurrency.md b/building_guide/concurrency.md index 2fa4558..a30e1a6 100644 --- a/building_guide/concurrency.md +++ b/building_guide/concurrency.md @@ -124,9 +124,15 @@ Two record kinds exist: - **wager** — a committed stake. The record stays OPEN while the stake is outstanding (round in flight) and is resolved when the round settles: won, lost, pushed, forfeited (disconnect), or refunded (shutdown). Games - resolve stakes through `casino.Wallet.ResolveStakes` after settlement; - `casino.Wallet.RefundOutstanding` returns every outstanding stake - (chips as chips, credits durably) at shutdown/eviction, exactly once. + resolve stakes through `casino.Wallet.ResolveStakes` after settlement + (or per hand via `ResolveStake`); `casino.Wallet.RefundOutstanding` + returns every outstanding stake (chips as chips, credits durably) at + shutdown/eviction, exactly once. A stake already covered by an open + settlement payout is resolved WITHOUT a refund: a table settlement + includes its hand's stake return and a slots payout proves the round + resolved, so refunding would double-pay the round — recovery re-applies + the owed payout instead, and the covered stakes receive their money + through it. - **payout** — money movement (winnings, pushes, refunds). The record closes when the balance mutation is durable. @@ -152,10 +158,12 @@ wallet degrades safely: chip stake is not returned in kind on top of credits that already returned it). If the character save fails too, no evidence exists and the open wager record drives the ordinary refund-on-lost-state recovery. -- A journal that cannot be fully READ (oversized or unreadable line) - aborts recovery and compaction entirely: records are preserved verbatim - for operator inspection instead of compensating over a partial fold or - truncating the file. +- A journal that cannot be fully READ (oversized or unreadable line) or + that contains a malformed record with later records present aborts + recovery and compaction entirely: records are preserved verbatim for + operator inspection instead of compensating over a partial fold or + truncating the file. A torn FINAL line (crash mid-append) is still + tolerated and skipped. At startup (`game.New`), open records are compensated in two phases: @@ -171,7 +179,12 @@ At startup (`game.New`), open records are compensated in two phases: lost in the crash) — unless a later payout record proves the round resolved (the win/push/refund already returned the stake inside that payout), in which case only the lost resolution marker is re-applied. - Ambiguous records stay open for operator inspection. + Settlement payouts may carry an explicit `covers` list naming the stake + IDs they return (a table hand's payout): recovery then resolves + exactly those stakes. A payout without a list (legacy records, slots + winnings) resolves every open stake of its player — a journaled + settlement means the round resolved, and its re-application delivers + the money. Ambiguous records stay open for operator inspection. Currency arithmetic is checked (`internal/casino/credits.go`): explicit `MaxCredits` cap, bounded `DefaultMaxBet` default (the explicit unlimited 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) + } + } +} |
