diff --git a/games/bcook/blackjack/game.go b/games/bcook/blackjack/game.go index 61409f9..6de4df3 100644 --- a/games/bcook/blackjack/game.go +++ b/games/bcook/blackjack/game.go @@ -53,6 +53,10 @@ func (Game) Meta() kit.GameMeta { kit.RuneControl('r', "SURRENDER"), kit.RuneControl('y', "YES"), kit.RuneControl('n', "NO"), + // Betting: P loops the Perfect Pairs side bet and B loops the behind + // bet for the focused seat (Left/Right pick the seat, including other + // players to back). P doubles as SPLIT during a turn; B is betting-only. + kit.RuneControl('b', "BEHIND"), }, } } diff --git a/games/bcook/blackjack/layout.go b/games/bcook/blackjack/layout.go index 74cc112..cd0ca19 100644 --- a/games/bcook/blackjack/layout.go +++ b/games/bcook/blackjack/layout.go @@ -16,11 +16,15 @@ const ( dealerRow = 4 // dealer card group occupies dealerRow..dealerRow+2 dealerValRow = 7 // dealer total / verdict, centred just below the cards - seatNameRow = 11 - seatCardRow = 12 // seat card group occupies seatCardRow..seatCardRow+2 - seatValRow = 15 - seatChipRow = 16 - seatPairRow = 17 // Perfect Pairs side-bet line (stake while betting, result once dealt) + // The seat block sits one row higher than the dealer-only layout used to + // allow: relocating the rules tagline (it now flanks the dealer) freed rows + // 8-9, so the block shifts up to open a dedicated backers line at the bottom. + seatNameRow = 10 + seatCardRow = 11 // seat card group occupies seatCardRow..seatCardRow+2 + seatValRow = 14 + seatChipRow = 15 + seatPairRow = 16 // Perfect Pairs side-bet line (stake while betting, result once dealt) + seatBackRow = 17 // backers line: who is backing this seat, with their tiles actionRow = 18 slotW = 15 maxSeats = 5 @@ -91,7 +95,7 @@ func (rm *room) compose(f *kit.Frame, v kit.Player) { } own := s.p.AccountID == v.AccountID isActive := active != nil && active.p.AccountID == s.p.AccountID - rm.drawSeat(f, groupLeft+i*slotW, s, own, isActive) + rm.drawSeat(f, groupLeft+i*slotW, s, v, own, isActive) } rm.drawActionBar(f, v, active) @@ -139,10 +143,11 @@ func (rm *room) drawDealer(f *kit.Frame) { center(f, dealerValRow, label, st) } -func (rm *room) drawSeat(f *kit.Frame, slot int, s *seat, own, active bool) { +func (rm *room) drawSeat(f *kit.Frame, slot int, s *seat, v kit.Player, own, active bool) { if s == nil { return } + defer rm.drawBackersLine(f, slot, s, v) // who is backing this seat (its own dedicated line) nameSt := stCard switch { case active: @@ -279,6 +284,57 @@ func (rm *room) drawPairsLine(f *kit.Frame, slot int, s *seat, own bool) { } } +// drawBackersLine renders, on a seat's dedicated backers row, a token per player +// backing it: their character tile then a compact stake (betting: "25p10") or +// net (results: "+25"). The viewer's own back is shown even at zero while they +// are focused on this seat (so "you're editing this" reads) and highlighted. +// Tokens are drawn left-to-right and truncated with an ellipsis past the slot. +func (rm *room) drawBackersLine(f *kit.Frame, slot int, target *seat, v kit.Player) { + col, limit := slot, slot+slotW + for _, id := range rm.order { + s := rm.seats[id] + if s == nil || s.p.AccountID == target.p.AccountID { + continue + } + b := s.backs[target.p.AccountID] + focused := s.p.AccountID == v.AccountID && s.focus == target.p.AccountID + if (b == nil || (b.behind == 0 && b.pairs == 0)) && !focused { + continue + } + if b == nil { + b = &backBet{} + } + text, st := backerToken(rm.phase, b), stDim + if focused { + st = stActive + } + if col+1+len([]rune(text)) > limit { // no room for tile + token + if col < limit { + f.SetRune(seatBackRow, col, '…', stDim) + } + return + } + f.Set(seatBackRow, col, kit.CharacterCell(s.p.Character)) + col = f.Text(seatBackRow, col+1, text, st) + 1 // tile, token, then a gap + } +} + +// backerToken is one backer's compact cell content on the backers line: the +// stakes while betting (e.g. "25p10", "p10"), else the round net (e.g. "+25"). +func backerToken(phase string, b *backBet) string { + if phase == phBetting { + s := "" + if b.behind > 0 { + s += fmt.Sprintf("%d", b.behind) + } + if b.pairs > 0 { + s += fmt.Sprintf("p%d", b.pairs) + } + return s + } + return fmt.Sprintf("%+d", (b.behindWin-b.behind)+(b.pairsWin-b.pairs)) +} + func (rm *room) drawActionBar(f *kit.Frame, v kit.Player, active *seat) { s := rm.seats[v.AccountID] if s == nil { @@ -288,10 +344,19 @@ func (rm *room) drawActionBar(f *kit.Frame, v kit.Player, active *seat) { st := stActive switch rm.phase { case phBetting: + // When the viewer is focused on another seat, the bar spells out their + // back on that seat (the nav axes are editing it) — drawn in parts so the + // backed player's character tile rides before their name. + if t := rm.seats[s.focus]; s.focus != "" && t != nil { + b := s.backOn(s.focus) + post := fmt.Sprintf("%s B behind %d P pairs %d Left/Right seat", t.p.Handle, b.behind, b.pairs) + centerWithChar(f, actionRow, "BACKING ", kit.CharacterCell(t.p.Character), post, stPrompt) + return + } if !s.placed { // Prominent, highlighted call to bet for a viewer who hasn't yet. // ASCII-only so it reads identically on non-UTF-8 sessions. - msg, st = "PLACE YOUR BET - Up/Down stake Left/Right pairs SPACE bet", stPrompt + msg, st = "BET: Up/Down stake P pairs Left/Right back a seat SPACE bet", stPrompt } else if n := rm.unplacedCount(); n > 0 { noun := "player" if n != 1 { diff --git a/games/bcook/blackjack/room.go b/games/bcook/blackjack/room.go index a9088f0..0f79ace 100644 --- a/games/bcook/blackjack/room.go +++ b/games/bcook/blackjack/room.go @@ -2,6 +2,7 @@ package main import ( "context" + "sort" "strconv" "strings" "time" @@ -57,6 +58,18 @@ type phand struct { fromSplit bool // a split hand: a two-card 21 is a plain 21, not a blackjack } +// backBet is one seat's wager ON ANOTHER seat: a "behind" bet that rides the +// backed player's first hand vs the dealer, and/or a Perfect Pairs bet on the +// backed player's first two cards. Stakes are chosen during betting; the result +// fields are filled at settlement (their-pairs at the deal, behind at settle). +type backBet struct { + behind int // behind-bet stake (0 = none) + pairs int // their-Perfect-Pairs stake (0 = none) + pairsKind string // resolved at deal: "" | "mixed" | "colored" | "perfect" + pairsWin int // their-pairs chips credited at deal (0 = lost/none) + behindWin int // behind chips credited at settle (0 = lost) +} + // seat is one player's place at the table. Keyed by account id in the room map // so it survives a hibernation freeze/thaw (connections change; accounts don't). type seat struct { @@ -66,9 +79,11 @@ type seat struct { postedPeak int // last peak Posted to the board (post only on increase) bet int // currently selected/placed stake placed bool - pairsBet int // Perfect Pairs side stake (0 = off), carried between rounds like bet - pairsKind string // this round's pairs result: "" | "mixed" | "colored" | "perfect" - pairsWin int // chips credited on the pairs side bet this round (0 = lost/none) + pairsBet int // Perfect Pairs side stake (0 = off), carried between rounds like bet + pairsKind string // this round's pairs result: "" | "mixed" | "colored" | "perfect" + pairsWin int // chips credited on the pairs side bet this round (0 = lost/none) + focus string // betting UI: "" edits own bet, else the account id whose backs are being edited + backs map[string]*backBet // wagers on other seats, keyed by target account id (iterate via rm.order) insurance int insuranceDecided bool hands []*phand @@ -319,6 +334,8 @@ func (rm *room) enterBetting(r kit.Room) { s.result = "" s.pairsKind = "" s.pairsWin = 0 + s.focus = "" // re-open editing on the seat's own bet + s.backs = nil // backs are round-specific to particular opponents; never carried if s.bet > s.chips { s.bet = clampBet(s.chips) } @@ -407,19 +424,10 @@ func tierIndex(bet int) int { return 0 } -// adjustPairs steps the Perfect Pairs side stake through pairsTiers, clamped so -// the main bet plus the side bet never exceeds the seat's chips (the side bet -// shrinks to the highest affordable tier, down to off). -func (rm *room) adjustPairs(s *seat, dir int) { - i := pairsTierIndex(s.pairsBet) + dir - if i < 0 { - i = 0 - } - if i >= len(pairsTiers) { - i = len(pairsTiers) - 1 - } - s.pairsBet = pairsTiers[i] - rm.clampPairs(s) +// cycleOwnPairs advances the seat's own Perfect Pairs stake one tier, wrapping +// back to 0 past the top (and resetting to 0 if the next tier is unaffordable). +func (rm *room) cycleOwnPairs(s *seat) { + s.pairsBet = loopTier(pairsTiers, s.pairsBet, s.chips-(s.committed()-s.pairsBet)) } // clampPairs lowers the side bet to the highest tier the seat can still afford @@ -431,6 +439,21 @@ func (rm *room) clampPairs(s *seat) { } } +// affordTier returns the highest tier not exceeding `want` that still fits +// `budget` (the chips left for this stake after the seat's other commitments). +// tiers must be ascending and start at 0, so a zero/negative budget yields the +// "off" tier rather than panicking. +func affordTier(tiers []int, want, budget int) int { + best := 0 + for _, t := range tiers { + if t > want || t > budget { + break + } + best = t + } + return best +} + func pairsTierIndex(bet int) int { for i, t := range pairsTiers { if t == bet { @@ -440,6 +463,86 @@ func pairsTierIndex(bet int) int { return 0 } +// committed totals every chip the seat has wagered this betting window: its main +// bet, its own Perfect Pairs, and every behind/their-pairs stake across backs. +func (s *seat) committed() int { + total := s.bet + s.pairsBet + for _, b := range s.backs { + total += b.behind + b.pairs + } + return total +} + +// backTargets is the account ids a seat may back: every OTHER occupied seat, in +// join order (never map order). +func (rm *room) backTargets(self *seat) []string { + var ids []string + for _, id := range rm.order { + if rm.seats[id] != nil && id != self.p.AccountID { + ids = append(ids, id) + } + } + return ids +} + +// cycleFocus moves the seat's betting focus `dir` steps (Right = +1, Left = -1) +// around ["" (self), t1, t2, …] where t1… are the other occupied seats, wrapping +// at both ends. With no other seats it stays on self. +func (rm *room) cycleFocus(s *seat, dir int) { + targets := rm.backTargets(s) + if len(targets) == 0 { + s.focus = "" + return + } + list := append([]string{""}, targets...) // index 0 = self + cur := 0 + for i, id := range list { + if id == s.focus { + cur = i + break + } + } + n := len(list) + s.focus = list[((cur+dir)%n+n)%n] +} + +// backOn returns the seat's backBet on target (creating it on first use). +func (s *seat) backOn(target string) *backBet { + if s.backs == nil { + s.backs = map[string]*backBet{} + } + b := s.backs[target] + if b == nil { + b = &backBet{} + s.backs[target] = b + } + return b +} + +// cycleBackPairs / cycleBackBehind advance the focused back's pairs / behind +// stake one tier, wrapping to 0 past the top, budget-clamped against the seat's +// other commitments (an unaffordable next tier also resets to 0). +func (rm *room) cycleBackPairs(s *seat) { + b := s.backOn(s.focus) + b.pairs = loopTier(pairsTiers, b.pairs, s.chips-(s.committed()-b.pairs)) +} + +func (rm *room) cycleBackBehind(s *seat) { + b := s.backOn(s.focus) + b.behind = loopTier(pairsTiers, b.behind, s.chips-(s.committed()-b.behind)) +} + +// loopTier returns the tier one step above cur, wrapping back to 0 (the "off" +// tier) past the top — so a side bet cycles 0 -> 10 -> … -> top -> 0. A next tier +// the budget can't cover also resets to 0 (higher tiers are unaffordable too). +func loopTier(tiers []int, cur, budget int) int { + next := tiers[(pairsTierIndex(cur)+1)%len(tiers)] + if next > budget { + return 0 + } + return next +} + // --- dealing --------------------------------------------------------------- func (rm *room) deal(r kit.Room) { @@ -466,6 +569,8 @@ func (rm *room) deal(r kit.Room) { rm.resolvePairs(s, h.cards) } + rm.resolveBackPairs() // every hand is now dealt; settle the their-pairs side of each back + rm.recordDeal(r) up := rm.dealer[0] @@ -501,6 +606,59 @@ func (rm *room) resolvePairs(s *seat, dealt hand) { s.chips += s.pairsWin } +// sortedBackIDs returns a seat's back target ids in a deterministic (sorted) +// order. Backs are keyed by account id in a map; the targets are independent +// (settlement is purely additive), so a stable sort is enough to avoid relying +// on Go's map iteration order — and it still visits a target that has since left +// the table (which join order no longer lists). +func sortedBackIDs(s *seat) []string { + if len(s.backs) == 0 { + return nil + } + ids := make([]string, 0, len(s.backs)) + for id := range s.backs { + ids = append(ids, id) + } + sort.Strings(ids) + return ids +} + +// resolveBackPairs settles the their-Perfect-Pairs side of every seat's backs, +// once all hands are dealt: a back on a seat that didn't get dealt in is voided +// (never charged); otherwise the stake is deducted and any winning pair on the +// target's first two cards is credited to the backer immediately. The behind bet +// is committed here too (deducted, held) and settled later against the dealer. +func (rm *room) resolveBackPairs() { + for _, id := range rm.order { + s := rm.seats[id] + if s == nil || !s.placed { + continue + } + for _, tid := range sortedBackIDs(s) { + b := s.backs[tid] + t := rm.seats[tid] + if t == nil || !t.placed || len(t.hands) == 0 { + b.behind, b.pairs = 0, 0 // target sat out / gone -> void the back + continue + } + if b.pairs > s.chips { + b.pairs = s.chips // defensive + } + if b.pairs > 0 { + s.chips -= b.pairs + kind, mult := perfectPairsOutcome(t.hands[0].cards[0], t.hands[0].cards[1]) + b.pairsKind = kind + b.pairsWin = pairsCreditFor(mult, b.pairs) + s.chips += b.pairsWin + } + if b.behind > s.chips { + b.behind = s.chips // defensive + } + s.chips -= b.behind // committed now; the dealer comparison happens at settle + } + } +} + // --- animation schedule ---------------------------------------------------- // clearSchedule drops any pending card animation. Safe to call when none is @@ -923,6 +1081,7 @@ func (rm *room) settle(r kit.Room) { // credited there); fold its delta into the round net so the seat's // WIN/LOSE summary reconciles with the chips that actually changed hands. net += s.pairsWin - s.pairsBet + net += rm.settleBacks(s, dbj) s.result = resultText(net) if s.chips <= 0 { s.chips = rebuyChips @@ -941,6 +1100,37 @@ func (rm *room) settle(r kit.Room) { rm.enterResults(r) } +// settleBacks settles seat s's behind bets and folds every back's delta into the +// round, returning the net chip change to add to s's summary. Their-pairs were +// settled at the deal (only their delta folds here); each behind bet is judged +// now against the target's first hand vs the dealer — even money on a win, 3:2 on +// a natural blackjack, push returned — and is refunded if the target has left +// (their hand can't be judged) or loses if that hand surrendered. +func (rm *room) settleBacks(s *seat, dealerBJ bool) int { + net := 0 + for _, tid := range sortedBackIDs(s) { + b := s.backs[tid] + net += b.pairsWin - b.pairs // their-pairs delta (settled at the deal) + if b.behind <= 0 { + continue + } + t := rm.seats[tid] + switch { + case t == nil || len(t.hands) == 0: + b.behindWin = b.behind // target left: refund the behind stake (push) + case t.hands[0].surrendered: + b.behindWin = 0 // backed a hand that bailed out + default: + h0 := t.hands[0] + o := settleHandEx(h0.cards, h0.cards.isBlackjack() && !h0.fromSplit, rm.dealer, dealerBJ) + b.behindWin = creditFor(o, b.behind) + } + s.chips += b.behindWin + net += b.behindWin - b.behind + } + return net +} + func (rm *room) enterResults(r kit.Room) { rm.phase = phResults for _, s := range rm.seats { @@ -985,16 +1175,20 @@ func (rm *room) OnInput(r kit.Room, p kit.Player, in kit.Input) { } switch rm.phase { case phBetting: + // Up/Down set your own main stake; Left/Right change which seat you're + // betting on (self, then each other seat). P loops the pairs side bet and + // B loops the behind bet for the focused seat — each cycles up a tier and + // resets to 0 past the top. Both runes are unmapped in CtxNav, read raw. switch kit.Resolve(in, kit.CtxNav) { case kit.ActUp: rm.adjustBet(s, +1) rm.clampPairs(s) // a raised main bet may crowd out the side bet case kit.ActDown: rm.adjustBet(s, -1) - case kit.ActRight: - rm.adjustPairs(s, +1) // raise the Perfect Pairs side bet case kit.ActLeft: - rm.adjustPairs(s, -1) // lower it (down to off) + rm.cycleFocus(s, -1) + case kit.ActRight: + rm.cycleFocus(s, +1) case kit.ActConfirm: if s.chips >= betTiers[0] { if s.bet > s.chips { @@ -1005,6 +1199,20 @@ func (rm *room) OnInput(r kit.Room, p kit.Player, in kit.Input) { rm.maybeCloseEarly(r) // deal early once every seat has bet } } + if in.Kind == kit.InputRune { + switch in.Rune { + case 'p', 'P': // loop the focused seat's pairs side bet (yours, or theirs) + if s.focus == "" { + rm.cycleOwnPairs(s) + } else { + rm.cycleBackPairs(s) + } + case 'b', 'B': // loop the behind bet on the focused seat (none on yourself) + if s.focus != "" { + rm.cycleBackBehind(s) + } + } + } case phInsurance: if in.Kind == kit.InputRune { switch in.Rune { diff --git a/games/bcook/blackjack/room_test.go b/games/bcook/blackjack/room_test.go index 81e90a2..347dd46 100644 --- a/games/bcook/blackjack/room_test.go +++ b/games/bcook/blackjack/room_test.go @@ -36,7 +36,7 @@ func runeInput(r rune) kit.Input { return kit.Input{Kind: kit.InputRune, Rune: r func keyInput(k kit.Key) kit.Input { return kit.Input{Kind: kit.InputKey, Key: k} } -func TestPairsSideBetAdjustsOnLeftRight(t *testing.T) { +func TestPairsSideBetLoopsOnP(t *testing.T) { a := mkPlayer("a") rm, tr := newGame(t, a) rm.OnJoin(tr, a) @@ -44,13 +44,21 @@ func TestPairsSideBetAdjustsOnLeftRight(t *testing.T) { if s.pairsBet != 0 { t.Fatalf("pairs side bet defaults to %d, want 0 (off)", s.pairsBet) } - rm.OnInput(tr, a, keyInput(kit.KeyRight)) // raise to the first tier + rm.OnInput(tr, a, runeInput('p')) // P advances one tier if s.pairsBet != pairsTiers[1] { - t.Fatalf("after Right, pairsBet = %d, want %d", s.pairsBet, pairsTiers[1]) + t.Fatalf("after P, pairsBet = %d, want %d", s.pairsBet, pairsTiers[1]) + } + for i := 0; i < len(pairsTiers)-1; i++ { // loop the rest of the way round + rm.OnInput(tr, a, runeInput('p')) } - rm.OnInput(tr, a, keyInput(kit.KeyLeft)) // back to off if s.pairsBet != 0 { - t.Fatalf("after Left, pairsBet = %d, want 0 (off)", s.pairsBet) + t.Fatalf("after a full loop, pairsBet = %d, want reset to 0 at the end", s.pairsBet) + } + // B is the behind bet, not pairs — it must not touch your own pairs. + rm.OnInput(tr, a, runeInput('p')) // -> 10 + rm.OnInput(tr, a, runeInput('b')) + if s.pairsBet != pairsTiers[1] { + t.Fatalf("B changed own pairs (B should be behind-only): %d", s.pairsBet) } } @@ -62,13 +70,95 @@ func TestPairsSideBetClampedToChips(t *testing.T) { s.bet = 100 s.chips = 105 // can afford the 100 main bet + at most a 5-chip side bet, so only "off" for i := 0; i < len(pairsTiers); i++ { - rm.OnInput(tr, a, keyInput(kit.KeyRight)) + rm.OnInput(tr, a, runeInput('p')) } if s.bet+s.pairsBet > s.chips { t.Fatalf("main %d + pairs %d exceeds chips %d (clamp failed)", s.bet, s.pairsBet, s.chips) } } +func TestBackFocusCyclesSeatsOnLeftRight(t *testing.T) { + a, b, c := mkPlayer("a"), mkPlayer("b"), mkPlayer("c") + rm, tr := newGame(t, a, b, c) + rm.OnJoin(tr, a) + rm.OnJoin(tr, b) + rm.OnJoin(tr, c) + sa := rm.seats[a.AccountID] + if sa.focus != "" { + t.Fatalf("focus starts %q, want self (empty)", sa.focus) + } + rm.OnInput(tr, a, keyInput(kit.KeyRight)) + if sa.focus != b.AccountID { + t.Fatalf("after Right, focus = %q, want b", sa.focus) + } + rm.OnInput(tr, a, keyInput(kit.KeyRight)) + if sa.focus != c.AccountID { + t.Fatalf("after Right Right, focus = %q, want c", sa.focus) + } + rm.OnInput(tr, a, keyInput(kit.KeyRight)) + if sa.focus != "" { + t.Fatalf("after cycling through all seats, focus = %q, want back to self", sa.focus) + } + // Left walks the other way: from self, wrap to the last seat. + rm.OnInput(tr, a, keyInput(kit.KeyLeft)) + if sa.focus != c.AccountID { + t.Fatalf("after Left from self, focus = %q, want c (wrap backward)", sa.focus) + } +} + +func TestBackFocusSoloIsNoOp(t *testing.T) { + a := mkPlayer("a") + rm, tr := newGame(t, a) + rm.OnJoin(tr, a) + rm.OnInput(tr, a, keyInput(kit.KeyRight)) + if rm.seats[a.AccountID].focus != "" { + t.Fatal("with no other seats, Left/Right must stay on self") + } +} + +func TestBackBetAdjustsWhenFocused(t *testing.T) { + a, b := mkPlayer("a"), mkPlayer("b") + rm, tr := newGame(t, a, b) + rm.OnJoin(tr, a) + rm.OnJoin(tr, b) + sa := rm.seats[a.AccountID] + sa.bet = 25 + sa.chips = 1000 + rm.OnInput(tr, a, keyInput(kit.KeyRight)) // focus seat b + rm.OnInput(tr, a, runeInput('b')) // behind loops 0 -> 10 + rm.OnInput(tr, a, runeInput('p')) // their-pairs loops 0 -> 10 + bb := sa.backs[b.AccountID] + if bb == nil || bb.behind != 10 || bb.pairs != 10 { + t.Fatalf("back on b = %+v, want behind 10 / pairs 10", bb) + } + // The viewer's own bet must be untouched while editing a back. + if sa.bet != 25 || sa.pairsBet != 0 { + t.Fatalf("own bet changed while editing a back: bet=%d pairs=%d", sa.bet, sa.pairsBet) + } +} + +func TestBackBetBudgetClamped(t *testing.T) { + a, b := mkPlayer("a"), mkPlayer("b") + rm, tr := newGame(t, a, b) + rm.OnJoin(tr, a) + rm.OnJoin(tr, b) + sa := rm.seats[a.AccountID] + sa.bet = 100 + sa.chips = 105 // only 5 chips beyond the main bet — no back tier fits + rm.OnInput(tr, a, keyInput(kit.KeyRight)) + for i := 0; i < len(pairsTiers); i++ { + rm.OnInput(tr, a, runeInput('b')) // try to raise the behind stake + rm.OnInput(tr, a, runeInput('p')) // and the their-pairs stake + } + committed := sa.bet + sa.pairsBet + if bb := sa.backs[b.AccountID]; bb != nil { + committed += bb.behind + bb.pairs + } + if committed > sa.chips { + t.Fatalf("total commitment %d exceeds chips %d (budget clamp failed)", committed, sa.chips) + } +} + func TestDealResolvesPerfectPairsSideBet(t *testing.T) { a := mkPlayer("a") rm, tr := newGame(t, a) @@ -101,6 +191,107 @@ func TestDealResolvesPerfectPairsSideBet(t *testing.T) { } } +func TestDealResolvesBackPairs(t *testing.T) { + a, b := mkPlayer("a"), mkPlayer("b") + rm, tr := newGame(t, a, b) + rm.what = pendNone + rm.OnJoin(tr, a) + rm.OnJoin(tr, b) + sa, sb := rm.seats[a.AccountID], rm.seats[b.AccountID] + sa.bet, sa.placed, sa.chips = 25, true, 1000 + sb.bet, sb.placed, sb.chips = 25, true, 1000 + sa.backs = map[string]*backBet{b.AccountID: {pairs: 10}} // a backs b's pairs + // dealer up+hole, then seat a's two cards, then seat b's two cards (a mixed pair). + rm.sh.cards = hand{ + {10, suitClub}, {9, suitDiamond}, // dealer + {2, suitSpade}, {7, suitHeart}, // seat a (no pair) + {8, suitSpade}, {8, suitHeart}, // seat b: mixed pair of 8s + {3, suitClub}, {4, suitClub}, {5, suitClub}, // filler + } + rm.sh.pos, rm.sh.roundStart = 0, 0 + rm.deal(tr) + + bb := sa.backs[b.AccountID] + if bb.pairsKind != "mixed" || bb.pairsWin != 70 { + t.Fatalf("back-pairs on b = kind %q win %d, want mixed/70", bb.pairsKind, bb.pairsWin) + } + // a paid main 25 + back-pairs 10 and won 70: 1000 - 25 - 10 + 70 = 1035. + if sa.chips != 1035 { + t.Fatalf("a chips = %d, want 1035", sa.chips) + } +} + +func TestDealVoidsBackOnSatOutTarget(t *testing.T) { + a, b := mkPlayer("a"), mkPlayer("b") + rm, tr := newGame(t, a, b) + rm.what = pendNone + rm.OnJoin(tr, a) + rm.OnJoin(tr, b) + sa, sb := rm.seats[a.AccountID], rm.seats[b.AccountID] + sa.bet, sa.placed, sa.chips = 25, true, 1000 + sb.placed = false // b sits this round out + sa.backs = map[string]*backBet{b.AccountID: {behind: 50, pairs: 10}} + rm.sh.cards = hand{{10, suitClub}, {9, suitDiamond}, {2, suitSpade}, {7, suitHeart}, {3, suitClub}, {4, suitClub}} + rm.sh.pos, rm.sh.roundStart = 0, 0 + rm.deal(tr) + + if sa.chips != 975 { // only a's own 25 bet deducted; the back on a sat-out seat is voided + t.Fatalf("a chips = %d, want 975 (back on sat-out target voided, not deducted)", sa.chips) + } + if bb := sa.backs[b.AccountID]; bb != nil && (bb.behind != 0 || bb.pairs != 0) { + t.Fatalf("back on sat-out target not voided: %+v", bb) + } +} + +func TestSettleBehindBetWinFolds(t *testing.T) { + a, b := mkPlayer("a"), mkPlayer("b") + rm, tr := newGame(t, a, b) + rm.OnJoin(tr, a) + rm.OnJoin(tr, b) + sa, sb := rm.seats[a.AccountID], rm.seats[b.AccountID] + sa.placed, sa.bet, sa.chips = true, 25, 1000 + sa.hands = []*phand{{cards: hand{{2, suitSpade}, {3, suitHeart}}, bet: 25}} // a: 5, loses + sb.placed, sb.bet = true, 25 + sb.hands = []*phand{{cards: hand{{10, suitSpade}, {9, suitHeart}}, bet: 25}} // b: 19, beats dealer + sa.backs = map[string]*backBet{b.AccountID: {behind: 50}} // a backs b's hand + rm.dealer = hand{{10, suitClub}, {7, suitDiamond}} // 17 + + rm.settle(tr) + + bb := sa.backs[b.AccountID] + if bb.behindWin != 100 { // even money: 50 stake + 50 + t.Fatalf("behindWin = %d, want 100 (behind paid even money on b's win)", bb.behindWin) + } + // a's own hand loses 25; the behind nets +50: round net = +25. + if sa.result != "WIN +25" { + t.Fatalf("a result = %q, want WIN +25 (behind win folded into net)", sa.result) + } +} + +func TestSettleBehindRefundsWhenTargetLeft(t *testing.T) { + a, b := mkPlayer("a"), mkPlayer("b") + rm, tr := newGame(t, a, b) + rm.OnJoin(tr, a) + rm.OnJoin(tr, b) + sa := rm.seats[a.AccountID] + sa.placed, sa.bet, sa.chips = true, 25, 900 // behind 50 already deducted at deal + sa.hands = []*phand{{cards: hand{{10, suitSpade}, {9, suitHeart}}, bet: 25}} // 19, pushes + sa.backs = map[string]*backBet{b.AccountID: {behind: 50}} + delete(rm.seats, b.AccountID) // b left mid-round + rm.order = []string{a.AccountID} + rm.dealer = hand{{10, suitClub}, {9, suitDiamond}} // 19 + + rm.settle(tr) + + if bb := sa.backs[b.AccountID]; bb.behindWin != 50 { + t.Fatalf("behindWin = %d, want 50 (behind refunded when target left)", bb.behindWin) + } + // own hand pushes (+25 returned), behind refunded (+50): 900 + 25 + 50 = 975. + if sa.chips != 975 { + t.Fatalf("a chips = %d, want 975 (push + behind refund)", sa.chips) + } +} + func TestSettleFoldsPairsResultIntoNet(t *testing.T) { a := mkPlayer("a") rm, tr := newGame(t, a) @@ -768,6 +959,62 @@ func TestReadyUpSkipsTheResultsWait(t *testing.T) { } } +// cellCol returns the column of the first cell on row matching want, or -1. +func cellCol(f *kit.Frame, row int, want kit.Cell) int { + for c := range f.Cells[row] { + if f.Cells[row][c] == want { + return c + } + } + return -1 +} + +// TestBackersLineShowsBackerTile asserts that a seat being backed shows, on its +// dedicated backers line, the backing player's character tile and stake — so you +// can see who is backing whom. +func TestBackersLineShowsBackerTile(t *testing.T) { + a, b := mkPlayer("alice"), mkPlayer("bob") + a.Character = kit.Character{Glyph: "λ", InkR: 0x39, InkG: 0xFF, InkB: 0x14, Fallback: 'L'} + rm, tr := newGame(t, a, b) + rm.OnJoin(tr, a) + rm.OnJoin(tr, b) + sa := rm.seats[a.AccountID] + sa.placed = true + sa.backs = map[string]*backBet{b.AccountID: {behind: 25}} + rm.render(tr) + f := tr.LastFrame(b) + + if cellCol(f, seatBackRow, kit.CharacterCell(a.Character)) < 0 { + t.Fatalf("backer alice's character tile not on the backers line (row %d): %q", + seatBackRow, kittest.String(f, seatBackRow)) + } + if row := kittest.String(f, seatBackRow); !strings.Contains(row, "25") { + t.Fatalf("backers line does not show the behind stake: %q", row) + } +} + +// TestFocusedTargetShowsBackDetail asserts that while a viewer is focused on a +// seat they are backing, the action bar spells out their behind/their-pairs +// stakes on that seat. +func TestFocusedTargetShowsBackDetail(t *testing.T) { + a, b := mkPlayer("alice"), mkPlayer("bob") + rm, tr := newGame(t, a, b) + rm.OnJoin(tr, a) + rm.OnJoin(tr, b) + sa := rm.seats[a.AccountID] + sa.focus = b.AccountID + sa.backs = map[string]*backBet{b.AccountID: {behind: 25, pairs: 10}} + rm.render(tr) + + row := kittest.String(tr.LastFrame(a), actionRow) + if !strings.Contains(row, "BACKING") || !strings.Contains(row, "bob") { + t.Fatalf("focused action bar does not name the backed seat: %q", row) + } + if !strings.Contains(row, "25") || !strings.Contains(row, "10") { + t.Fatalf("focused action bar does not show behind/their-pairs stakes: %q", row) + } +} + // TestBettingShowsPairsSideBet asserts a seat's selected Perfect Pairs side // stake is shown during betting directly beneath that seat's main bet — so the // two lines form one contiguous per-seat block and it's unambiguous whose side diff --git a/games/bcook/blackjack/round_test.go b/games/bcook/blackjack/round_test.go index 9a7b184..637d9a5 100644 --- a/games/bcook/blackjack/round_test.go +++ b/games/bcook/blackjack/round_test.go @@ -114,6 +114,25 @@ func TestPerfectPairsOutcome(t *testing.T) { } } +func TestAffordTier(t *testing.T) { + tiers := []int{0, 10, 25, 50, 100} + cases := []struct { + want, budget, out int + }{ + {100, 1000, 100}, // wanted tier fits the budget + {100, 30, 25}, // capped to the highest tier within budget + {50, 5, 0}, // nothing but "off" fits + {25, 25, 25}, // exact fit + {100, 0, 0}, // no budget -> off + {100, -5, 0}, // negative budget -> off (never panics) + } + for _, c := range cases { + if got := affordTier(tiers, c.want, c.budget); got != c.out { + t.Errorf("affordTier(want=%d, budget=%d) = %d, want %d", c.want, c.budget, got, c.out) + } + } +} + func TestPairsCreditFor(t *testing.T) { cases := []struct { mult int diff --git a/games/bcook/blackjack/smoke.yaml b/games/bcook/blackjack/smoke.yaml index d68c06b..cb88566 100644 --- a/games/bcook/blackjack/smoke.yaml +++ b/games/bcook/blackjack/smoke.yaml @@ -2,36 +2,42 @@ seed: 58 seats: 2 heartbeat: 50ms steps: - # (a) Initial table: both seats parked at the bet prompt. The table rules now - # flank the dealer (out of the mid-felt), and the betting prompt offers the - # Left/Right Perfect Pairs axis alongside the Up/Down stake. + # (a) Initial table: both seats at the bet prompt. Rules flank the dealer, and + # the betting prompt offers Up/Down stake, P pairs, and Left/Right (pick the + # seat you're betting on — yourself or another player to back). - shot: bet - # Seat 0 turns on a Perfect Pairs side bet (Right -> 10) and bets the default - # stake (25); seat 1 sets a larger side bet (Right Right -> 25) and bets. Once - # both seated players have placed, a 1s grace beat elapses and the table deals. + # Seat 0 loops its own Perfect Pairs on (P -> 10), then presses Right to focus + # seat 1 and backs it: B B -> behind 25, P -> their-pairs 10. With seat 1 + # focused, the action bar spells out the back ("BACKING seat1 ..."). - seat: 0 + - rune: "p" - key: right + - rune: "b" + - rune: "b" + - rune: "p" + - shot: backing + + # Seat 0 places (Confirm places everything, focus or not); seat 1 sets its own + # pairs and places too. - key: space - seat: 1 - - key: right - - key: right + - rune: "p" - key: space - # (b) Both placed: each seat shows its side stake as "+pairs N" beside the bet. + # (b) Both placed: seat 1's dedicated backers line shows seat 0's character tile + # and the back stakes; each seat shows its own "+pairs" beneath its bet. - shot: sidebet - # Grace beat (1s) -> deal fires. Sweep past the staggered slide+flip deal - # animation (6 cards), as the original scenario did. + # Grace beat (1s) -> deal; sweep the staggered slide+flip deal animation. - advance: 1200ms - advance: 1200ms - # (c) Deal complete: seed 58 deals BOTH seats a pair, so Perfect Pairs pays out - # at the deal (mixed 6:1 each); seat 0 (a pair of tens) is first on turn. + # (c) Deal complete: both seats dealt a pair (own pairs pay out), and seat 0's + # back-pairs on seat 1 pay too; seat 0 (a pair of tens) is first on turn. - shot: dealt - # Seat 0 splits its pair -> two hands, rendered as stacked compact lines with - # the hand on turn marked. Sweep the split deal animation before the shot. + # Seat 0 splits its pair -> two hands stacked, the hand on turn marked. - seat: 0 - rune: "p" - advance: 700ms @@ -48,18 +54,17 @@ steps: - rune: "s" - advance: 200ms - # All hands resolved -> the dealer reveals its hole card and stands; sweep the - # paced reveal into the results flash. + # All hands resolved -> the dealer reveals and stands; sweep the paced reveal. - advance: 1600ms - advance: 1600ms - advance: 1600ms - # (d) Settlement: per-seat result, with the Perfect Pairs win folded into each - # seat's net and the winning category shown beneath the seat. + # (d) Settlement: each seat's result, with own pairs and (for seat 0) the + # behind + back-pairs on seat 1 all folded into the net; the backers line shows + # seat 0's net on seat 1. - shot: payout - # (e) Ready-up: seat 0 confirms and reads READY while the table waits on seat 1; - # once seat 1 confirms too the round skips the flash and deals again. + # (e) Ready-up flow preserved. - seat: 0 - key: space - shot: ready