Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 109 additions & 5 deletions games/bcook/blackjack/layout.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,12 @@ const (
feltBottom = 19
dealerRow = 4 // dealer card group occupies dealerRow..dealerRow+2
dealerValRow = 7 // dealer total / verdict, centred just below the cards
taglineRow = 9

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)
actionRow = 18
slotW = 15
maxSeats = 5
Expand Down Expand Up @@ -70,12 +70,14 @@ func (rm *room) compose(f *kit.Frame, v kit.Player) {
drawFelt(f, feltTop, feltBottom)
center(f, feltTop, " B L A C K J A C K ", stFelt)

// Dealer, centred near the top of the felt.
// Dealer, centred near the top of the felt, with the table rules as subtle
// signage flanking the DEALER label (left + right) rather than a banner
// across the middle of the felt — keeping the centre clear for the cards.
f.Text(dealerRow-1, 2, "blackjack pays 3:2", stDim)
f.TextRight(dealerRow-1, kit.Cols-3, "dealer stands on 17", stDim)
center(f, dealerRow-1, "D E A L E R", stTitle)
rm.drawDealer(f)

center(f, taglineRow, "~ blackjack pays 3:2 · dealer stands on 17 ~", stFelt)

// Seats along the rail, centred as a group.
n := len(rm.order)
if n > maxSeats {
Expand Down Expand Up @@ -167,6 +169,8 @@ func (rm *room) drawSeat(f *kit.Frame, slot int, s *seat, own, active bool) {
f.Set(seatNameRow, nameCol, kit.CharacterCell(s.p.Character))
f.Text(seatNameRow, nameCol+2, name, nameSt)

rm.drawPairsLine(f, slot, s, own)

if rm.phase == phBetting {
status := "--"
if s.placed {
Expand All @@ -189,6 +193,25 @@ func (rm *room) drawSeat(f *kit.Frame, slot int, s *seat, own, active bool) {
return
}

// A split seat (2+ hands) renders one compact line per hand, stacked down the
// seat rows, so every hand's cards stay visible and the hand on turn is
// marked — the 15-col slot cannot fit multiple card boxes side by side, which
// is why the old layout collapsed later hands to a bare "+". A single hand
// keeps the full card box below.
if len(s.hands) >= 2 {
_, ah := rm.firstUnresolved()
for hi, h := range s.hands {
line, st := compactHandLine(h, active && ah == h)
centerSlot(f, seatCardRow+hi, slot, line, st)
}
if rm.phase == phResults && s.result != "" {
centerSlot(f, seatChipRow, slot, s.result, resultStyle(s.result))
} else {
centerSlot(f, seatChipRow, slot, fmt.Sprintf("$%d", s.chips), stDim)
}
return
}

// Draw the seat's hand(s) as joined card groups, left to right within the slot.
col := slot + 1
limit := slot + slotW
Expand Down Expand Up @@ -221,6 +244,41 @@ func (rm *room) drawSeat(f *kit.Frame, slot int, s *seat, own, active bool) {
}
}

// pairsMult maps a Perfect Pairs result kind to its payout multiplier (X:1),
// for the result label; 0 for no pair.
func pairsMult(kind string) int {
switch kind {
case "perfect":
return 25
case "colored":
return 12
case "mixed":
return 6
}
return 0
}

// drawPairsLine renders the seat's Perfect Pairs side-bet line. While betting it
// sits directly beneath that seat's main bet (seatCardRow+2), so each seat's
// bet+pairs read as one contiguous block and whose side bet is whose is never
// ambiguous; it shows only once placed, or for the seat's owner, matching how
// the main bet stays private until placed. Once the cards are dealt it moves to
// seatPairRow below the seat's hand, showing the win label (e.g. "COLORED 12:1")
// or a quiet "pairs lost".
func (rm *room) drawPairsLine(f *kit.Frame, slot int, s *seat, own bool) {
ch := kit.CharacterCell(s.p.Character) // the placing player's face, beside their side bet
switch {
case rm.phase == phBetting:
if s.pairsBet > 0 && (s.placed || own) {
centerSlotChar(f, seatCardRow+2, slot, ch, fmt.Sprintf("+pairs %d", s.pairsBet), stOwn)
}
case s.pairsKind != "":
centerSlotChar(f, seatPairRow, slot, ch, fmt.Sprintf("%s %d:1", strings.ToUpper(s.pairsKind), pairsMult(s.pairsKind)), stWin)
case s.pairsBet > 0:
centerSlotChar(f, seatPairRow, slot, ch, "pairs lost", stDim)
}
}

func (rm *room) drawActionBar(f *kit.Frame, v kit.Player, active *seat) {
s := rm.seats[v.AccountID]
if s == nil {
Expand All @@ -233,7 +291,7 @@ func (rm *room) drawActionBar(f *kit.Frame, v kit.Player, active *seat) {
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 SPACE bet", stPrompt
msg, st = "PLACE YOUR BET - Up/Down stake Left/Right pairs SPACE bet", stPrompt
} else if n := rm.unplacedCount(); n > 0 {
noun := "player"
if n != 1 {
Expand Down Expand Up @@ -620,6 +678,21 @@ func centerSlot(f *kit.Frame, row, slot int, s string, st kit.Style) {
f.Text(row, slot+(slotW-n)/2, s, st)
}

// centerSlotChar centres "<character tile> <text>" within a slotW-wide column:
// the styled character cell (width 1) plus a space precede the text, tying the
// line to a specific player by face. The text is clamped so the tile + text
// never overflow the slot.
func centerSlotChar(f *kit.Frame, row, slot int, ch kit.Cell, text string, st kit.Style) {
tr := []rune(text)
if len(tr) > slotW-2 {
tr = tr[:slotW-2]
}
w := 2 + len(tr)
col := slot + (slotW-w)/2
f.Set(row, col, ch)
f.Text(row, col+2, string(tr), st)
}

func (rm *room) remaining() int {
if rm.deadline.IsZero() || rm.lastNow.IsZero() {
return 0
Expand All @@ -633,6 +706,37 @@ func (rm *room) remaining() int {

func clock(secs int) string { return fmt.Sprintf("0:%02d", secs) }

// compactHandLine formats one split hand as a single slot-wide line —
// "<marker><cards> <total>", e.g. "►8♠3♥ 11" — for the stacked split layout.
// The on-turn hand carries a ► marker (degrades to > on non-UTF-8) and the
// active style; a busted hand reads red. The card tokens are truncated with an
// ellipsis if a long (hit-heavy) hand would otherwise overflow the slot, so the
// total always stays visible.
func compactHandLine(h *phand, active bool) (string, kit.Style) {
var cards strings.Builder
for _, c := range h.cards {
cards.WriteString(c.r.boxLabel())
cards.WriteRune(c.s.pip())
}
total := valueLabel(h.cards)
marker, st := " ", stCard
if h.cards.isBust() {
st = stLose
}
if active {
marker, st = "►", stActive
}
budget := slotW - len([]rune(marker)) - 1 - len([]rune(total)) // marker + space + total
if budget < 1 {
budget = 1
}
cr := []rune(cards.String())
if len(cr) > budget {
cr = append(cr[:budget-1:budget-1], '…')
}
return marker + string(cr) + " " + total, st
}

func valueLabel(h hand) string {
if h.isBlackjack() {
return "BJ"
Expand Down
72 changes: 72 additions & 0 deletions games/bcook/blackjack/room.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ const (
// betTiers are the selectable stakes, lowest first.
var betTiers = []int{10, 25, 50, 100}

// pairsTiers are the Perfect Pairs side-bet stakes, lowest first; index 0 is
// "off" (no side bet). Adjusted on the Left/Right axis during betting.
var pairsTiers = []int{0, 10, 25, 50, 100}

// phand is one hand a seat plays (a seat holds more than one after a split).
type phand struct {
cards hand
Expand All @@ -62,6 +66,9 @@ 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)
insurance int
insuranceDecided bool
hands []*phand
Expand Down Expand Up @@ -310,9 +317,12 @@ func (rm *room) enterBetting(r kit.Room) {
s.insurance = 0
s.insuranceDecided = false
s.result = ""
s.pairsKind = ""
s.pairsWin = 0
if s.bet > s.chips {
s.bet = clampBet(s.chips)
}
rm.clampPairs(s) // a thinned stack may no longer afford the carried side bet
}
rm.deadline = r.Now().Add(bettingDur)
r.SetInputContext(kit.CtxNav) // bet up/down + confirm
Expand Down Expand Up @@ -397,6 +407,39 @@ 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)
}

// clampPairs lowers the side bet to the highest tier the seat can still afford
// alongside its main bet (down to off), so a raised main bet or a thin stack can
// never leave an unaffordable side bet placed.
func (rm *room) clampPairs(s *seat) {
for s.pairsBet > 0 && s.bet+s.pairsBet > s.chips {
s.pairsBet = pairsTiers[pairsTierIndex(s.pairsBet)-1]
}
}

func pairsTierIndex(bet int) int {
for i, t := range pairsTiers {
if t == bet {
return i
}
}
return 0
}

// --- dealing ---------------------------------------------------------------

func (rm *room) deal(r kit.Room) {
Expand All @@ -420,6 +463,7 @@ func (rm *room) deal(r kit.Room) {
h.resolved = true
}
s.hands = []*phand{h}
rm.resolvePairs(s, h.cards)
}

rm.recordDeal(r)
Expand All @@ -439,6 +483,24 @@ func (rm *room) deal(r kit.Room) {
}
}

// resolvePairs settles a seat's Perfect Pairs side bet against its dealt cards:
// the stake is deducted (the main bet was already taken above) and any winning
// pair is credited immediately — the casino way, where the side bet stands apart
// from how the hand goes on to play out.
func (rm *room) resolvePairs(s *seat, dealt hand) {
if s.pairsBet <= 0 || len(dealt) < 2 {
return
}
if s.pairsBet > s.chips {
s.pairsBet = s.chips // defensive: never deduct more than the seat has left
}
s.chips -= s.pairsBet
kind, mult := perfectPairsOutcome(dealt[0], dealt[1])
s.pairsKind = kind
s.pairsWin = pairsCreditFor(mult, s.pairsBet)
s.chips += s.pairsWin
}

// --- animation schedule ----------------------------------------------------

// clearSchedule drops any pending card animation. Safe to call when none is
Expand Down Expand Up @@ -857,6 +919,10 @@ func (rm *room) settle(r kit.Room) {
s.chips += credit
net += credit - h.bet
}
// The Perfect Pairs side bet was settled at deal (stake deducted, any win
// 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
s.result = resultText(net)
if s.chips <= 0 {
s.chips = rebuyChips
Expand Down Expand Up @@ -922,13 +988,19 @@ func (rm *room) OnInput(r kit.Room, p kit.Player, in kit.Input) {
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)
case kit.ActConfirm:
if s.chips >= betTiers[0] {
if s.bet > s.chips {
s.bet = clampBet(s.chips)
}
rm.clampPairs(s)
s.placed = true
rm.maybeCloseEarly(r) // deal early once every seat has bet
}
Expand Down
Loading
Loading