1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
|
package casino
import (
"fmt"
"math/rand"
"sort"
"strings"
"sync"
)
// PaiGowHandView is one hand in the showdown: its label, evaluated
// strength, cards and whether it won its comparison. Winning is only
// meaningful when HasWin is true (a showdown), so Face Up's mid-round
// dealer reveal does not paint the dealer as a winner.
type PaiGowHandView struct {
Label string
Strength string
Cards []CardView
Winning bool
HasWin bool
}
// PaiGowShowdown carries the comparison and settlement pieces the renderer
// turns into the coloured results block. Hands always run player high,
// dealer high, player low, dealer low.
type PaiGowShowdown struct {
Hands []PaiGowHandView
Result string
Fortune string
FortuneWin bool
PushAce string
PushAceWin bool
Envy int
ShowEnvy bool
Stake int
Payout int
Paid bool
Pending bool
}
type PaiGowSnapshot struct {
Config TableConfig
Phase string
ShowFelt bool
Cards, DealerHigh, DealerLow []CardView
Tiebreaker []CardView
// Preview carries an arranged-but-unset (or locked) hand: the five-card
// high hand followed by the two-card low hand. PreviewHighNums and
// PreviewLowNums are the original 1-7 positions under each card.
Preview bool
PreviewHigh, PreviewLow []CardView
PreviewHighNums, PreviewLowNums []int
// DealerHighName/DealerLowName label a Face Up reveal that happens before
// the showdown.
DealerHighName, DealerLowName string
Showdown *PaiGowShowdown
Result string
}
func (*PaiGowSnapshot) snapshot() {}
type pgPlayer struct {
name string
wallet Wallet
main, fortune Wager
pushace Wager
last int
cards []card
split pgSplit
// pending holds a legal arrangement the player has previewed but not yet
// committed with "set". Autoset players never populate it.
pending pgSplit
autoset bool
ready, disconnected, paid bool
leaveAfter bool
payout int
result string
showdown *PaiGowShowdown
settlement []SettlementLine
}
type paiGowTable struct {
*tableBase
mu sync.Mutex
rng *rand.Rand
players []*pgPlayer // stable seat/deal/settlement order
phase string
ticks int
dealer []card
dealerSplit pgSplit
tie [2]card
}
func newPaiGowTable(room int, c TableConfig, rng *rand.Rand) tableGame {
return &paiGowTable{tableBase: &tableBase{RoomID: room, Config: c}, rng: rng, phase: "waiting"}
}
func (t *paiGowTable) base() *tableBase { return t.tableBase }
func (t *paiGowTable) player(name string) *pgPlayer {
for _, p := range t.players {
if p.name == name {
return p
}
}
return nil
}
func (t *paiGowTable) remove(name string) {
for i, p := range t.players {
if p.name == name {
t.players = append(t.players[:i], t.players[i+1:]...)
return
}
}
}
func (t *paiGowTable) idle() bool { t.mu.Lock(); defer t.mu.Unlock(); return len(t.players) == 0 }
func (t *paiGowTable) hasParticipant(name string) bool {
t.mu.Lock()
defer t.mu.Unlock()
return t.player(name) != nil
}
func (t *paiGowTable) overview() string {
t.mu.Lock()
defer t.mu.Unlock()
return fmt.Sprintf("%s | %d/%d seated", t.Config.Label(), len(t.players), t.Config.MaxPlayers)
}
func (t *paiGowTable) seats() []string {
t.mu.Lock()
defer t.mu.Unlock()
names := make([]string, 0, len(t.players))
for _, p := range t.players {
names = append(names, p.name)
}
return names
}
func (t *paiGowTable) state() string {
t.mu.Lock()
defer t.mu.Unlock()
return t.phase
}
func (t *paiGowTable) hintsLocked(name string) []MenuCommand {
p := t.player(name)
if p == nil {
return nil
}
commands := []MenuCommand{}
faceUp := t.Config.PaiGow.Variant == PaiGowFaceUp
switch t.phase {
case "waiting", "betting":
if p.main.Total == 0 {
// Side bets are placed before the main wager: the main bet is what
// deals, so there is no later chance to add them.
if p.fortune.Total == 0 {
commands = append(commands, MenuCommand{Command: "bet <amount> fortune", Desc: "Optional Fortune side bet; place before your main bet"})
}
if faceUp && p.pushace.Total == 0 {
commands = append(commands, MenuCommand{Command: "bet <amount> pushace", Desc: "Optional Push Ace High side bet; place before your main bet"})
}
commands = append(commands, MenuCommand{Command: "bet <amount>", Desc: "Main wager; deals once every bettor is in"})
}
if p.main.Total > 0 || p.fortune.Total > 0 || p.pushace.Total > 0 {
commands = append(commands, MenuCommand{Command: "unbet", Desc: "Take back all your wagers before the deal"})
}
case "setting":
if p.main.Total > 0 && !p.ready {
if p.pending.high != nil {
commands = append(commands, MenuCommand{Command: "set", Desc: "Lock this arrangement and reveal the dealer"})
commands = append(commands, MenuCommand{Command: "24 / 13456", Desc: "Choose a different two-low or five-high arrangement"})
} else {
commands = append(commands, MenuCommand{Command: "24 / 13456", Desc: "Choose two low cards or five high cards to arrange"})
}
commands = append(commands, MenuCommand{Command: "house", Desc: "Arrange your hand the house way"})
if p.autoset {
commands = append(commands, MenuCommand{Command: "autoset off", Desc: "Require set before the dealer reveals"})
} else {
commands = append(commands, MenuCommand{Command: "autoset on", Desc: "Set a legal arrangement immediately"})
}
}
case "settling":
commands = append(commands, MenuCommand{Command: "settle", Desc: "Retry the same pending payment"})
}
return append(commands, MenuCommand{Command: "table", Desc: "View the felt"}, MenuCommand{Command: "rules", Desc: "Learn Pai Gow"}, MenuCommand{Command: "stop", Desc: "Leave after finishing your wager"})
}
func (t *paiGowTable) hints(name string) []MenuCommand {
t.mu.Lock()
defer t.mu.Unlock()
return t.hintsLocked(name)
}
func (t *paiGowTable) wagers(name string) []WagerRow {
t.mu.Lock()
defer t.mu.Unlock()
p := t.player(name)
if p == nil || t.phase == "setting" || t.phase == "settling" {
return nil
}
rows := []WagerRow{{Bet: "main", Payout: "See the felt", Win: "Beat both dealer hands"}, {Bet: "fortune", Payout: "See Fortune felt", Win: "Best seven-card bonus; independent of main result"}}
if t.Config.PaiGow.Variant == PaiGowFaceUp {
rows = append(rows, WagerRow{Bet: "pushace", Payout: "40/15/5:1", Win: "Dealer ace-high pai gow; both 40:1"})
}
return rows
}
func pgViews(cards []card) []CardView {
var out []CardView
for _, c := range cards {
out = append(out, CardView{Rank: c.rank, Suit: c.suit})
}
return out
}
// pgSortHand orders a freshly dealt hand small to large so the numbered
// positions read naturally: by rank first, then by suit. The Joker sorts last.
func pgSortHand(cards []card) {
sort.SliceStable(cards, func(i, j int) bool {
ri, rj := pgSortRank(cards[i]), pgSortRank(cards[j])
if ri != rj {
return ri < rj
}
return cards[i].suit < cards[j].suit
})
}
func pgSortRank(c card) int {
if c.rank == "JK" {
return 15
}
return pokerRank(c)
}
// pgCardNums maps each card in hand to its 1-based position in all. Dealt
// cards are unique, so the match is unambiguous and preserves hand order.
func pgCardNums(hand, all []card) []int {
used := make([]bool, len(all))
var nums []int
for _, h := range hand {
for i, c := range all {
if !used[i] && c == h {
used[i] = true
nums = append(nums, i+1)
break
}
}
}
return nums
}
func (t *paiGowTable) statusLocked(name string, showFelt bool) []Event {
s := &PaiGowSnapshot{Config: t.Config, Phase: t.phase, ShowFelt: showFelt}
if p := t.player(name); p != nil {
// The dealt hand only appears before the player has arranged anything;
// afterwards the board shows the arrangement or the waiting message.
if !p.ready && p.pending.high == nil {
s.Cards = pgViews(p.cards)
}
if p.showdown != nil {
s.Showdown = p.showdown
}
// A pending arrangement previews before "set"; a committed one previews
// until every seat is in and the showdown replaces it. Autoset players
// skip the preview and go straight to the set/waiting state.
split := p.split
if p.pending.high != nil {
split = p.pending
}
if split.high != nil && !(p.autoset && p.pending.high == nil) {
s.Preview = true
s.PreviewHigh = pgViews(split.high)
s.PreviewLow = pgViews(split.low)
s.PreviewHighNums = pgCardNums(split.high, p.cards)
s.PreviewLowNums = pgCardNums(split.low, p.cards)
}
s.Result = p.result
if p.ready && t.phase == "setting" && p.showdown == nil {
s.Result = "Hand set — waiting for the other players."
}
if t.phase == "settling" && !p.paid && p.main.Total > 0 {
s.Result += "; PAYMENT PENDING (not yet credited)"
}
}
if t.phase == "settling" || t.phase == "waiting" || t.phase == "setting" && t.Config.PaiGow.Variant == PaiGowFaceUp {
s.DealerHigh = pgViews(t.dealerSplit.high)
s.DealerLow = pgViews(t.dealerSplit.low)
if len(t.dealerSplit.high) > 0 {
s.DealerHighName = pgRankName(pgEvaluateRules(t.dealerSplit.high, t.a2345High()))
}
if len(t.dealerSplit.low) > 0 {
s.DealerLowName = pgRankName(pgEvaluateRules(t.dealerSplit.low, t.a2345High()))
}
if len(t.dealer) > 0 && t.Config.PaiGow.Variant == PaiGowNoPush {
s.Tiebreaker = pgViews(t.tie[:])
}
}
e := t.private(name, "")
e.Snapshot = s
// Context-aware hints are attached as a live menu; the shared board
// renderer places them below the felt. Stand-ins (for example a watcher)
// get no menu, so they cannot be told how to bet a game they have not joined.
if commands := t.hintsLocked(name); len(commands) > 0 {
e.Color = "casino_menu"
e.Menu = &MenuView{ShowBalance: true, Commands: commands}
}
return []Event{e}
}
func (t *paiGowTable) status(name string) []Event {
t.mu.Lock()
defer t.mu.Unlock()
return t.statusLocked(name, false)
}
func (t *paiGowTable) join(name string) []Event {
t.mu.Lock()
defer t.mu.Unlock()
if p := t.player(name); p != nil {
p.disconnected = false
return t.statusLocked(name, true)
}
if len(t.players) >= t.Config.MaxPlayers {
return []Event{t.private(name, "This Pai Gow table is full.")}
}
t.players = append(t.players, &pgPlayer{name: name, autoset: t.Config.PaiGow.Autoset})
return t.statusLocked(name, true)
}
func (t *paiGowTable) leave(name string) []Event {
t.mu.Lock()
defer t.mu.Unlock()
p := t.player(name)
if p == nil {
return nil
}
// Cards in flight must finish; before the deal the wagers are returned.
if t.phase == "setting" || t.phase == "settling" {
return []Event{t.private(name, "You're in the middle of a Pai Gow round!")}
}
if p.wallet != nil && (p.main.Total > 0 || p.fortune.Total > 0 || p.pushace.Total > 0) {
if _, _, err := p.wallet.RefundOutstanding(); err != nil {
return []Event{t.private(name, "Your stake could not be returned just now; it stays pending for the next startup.")}
}
}
p.main, p.fortune, p.pushace = Wager{}, Wager{}, Wager{}
t.remove(name)
events := []Event{t.private(name, "You leave Pai Gow Poker.")}
events = append(events, t.afterSeatChangeLocked()...)
return events
}
func (t *paiGowTable) rebet(name string) (string, int) {
t.mu.Lock()
defer t.mu.Unlock()
if p := t.player(name); p != nil && p.last > 0 {
return "main", p.last
}
return "main", t.Config.MinBet
}
func (t *paiGowTable) bet(name, spot string, amount int, w Wallet) []Event {
t.mu.Lock()
defer t.mu.Unlock()
p := t.player(name)
if p == nil {
return nil
}
fail := func(s string) []Event { return []Event{t.private(name, s)} }
if t.phase != "waiting" && t.phase != "betting" {
return fail("Betting is closed.")
}
if spot == "" {
spot = "main"
}
if spot != "main" && spot != "fortune" && spot != "pushace" {
return fail("Bet on main or fortune.")
}
if spot == "pushace" && t.Config.PaiGow.Variant != PaiGowFaceUp {
return fail("Push Ace High is a Face Up side bet only.")
}
if spot == "main" && p.main.Total > 0 || spot == "fortune" && p.fortune.Total > 0 || spot == "pushace" && p.pushace.Total > 0 {
return fail("That wager is already committed.")
}
// Side bets settle into the round before the main wager, which is what
// actually deals. Once the main bet is down the deal can fire at any
// moment, so a later side bet would race the cards.
if spot != "main" && p.main.Total > 0 {
return fail("Place side bets before your main wager.")
}
if spot == "main" && p.pushace.Total > amount/2 {
return fail("Push Ace High is capped at half the main wager; take it back with unbet first.")
}
if amount < t.Config.MinBet || amount > t.Config.MaxBet || amount > pgMaxWager {
return fail("Wager must be " + betRangeText(t.Config.MinBet, min(t.Config.MaxBet, pgMaxWager)) + ".")
}
if w == nil {
return fail("Wallet unavailable.")
}
stake, err := w.Wager(amount)
if err != nil {
return fail(walletErrorMessage(err))
}
p.wallet = w
// A fresh wager starts a new round in the seat's view. Without this a side
// bet placed before the next main wager would leave the previous round's
// cards and showdown on screen.
p.cards = nil
p.split = pgSplit{}
p.pending = pgSplit{}
p.showdown = nil
p.result = ""
p.ready = false
p.paid = false
if spot == "main" {
p.main = stake
p.last = amount
} else if spot == "pushace" {
p.pushace = stake
} else {
p.fortune = stake
}
if spot == "main" && t.phase == "waiting" {
t.phase = "betting"
t.ticks = t.Config.BettingTicks
t.dealer = nil
t.dealerSplit = pgSplit{}
}
events := []Event{t.private(name, fmt.Sprintf("Committed %d on %s.", amount, spot))}
// The main wager is the commitment point: when every active player has
// one, the cards come out immediately rather than waiting on the timer.
if spot == "main" && t.allBetLocked() {
return append(events, t.dealLocked()...)
}
return append(events, t.statusLocked(name, false)...)
}
func (t *paiGowTable) allReady() bool {
found := false
for _, p := range t.players {
if p.main.Total > 0 {
found = true
if !p.ready {
return false
}
}
}
return found
}
// allBetLocked reports whether at least one active player has a main wager
// and every active (connected) player has one. This is the point at which the
// cards can be dealt without waiting for the betting timer.
func (t *paiGowTable) allBetLocked() bool {
found := false
for _, p := range t.players {
if p.disconnected {
continue
}
found = true
if p.main.Total == 0 {
return false
}
}
return found
}
func (t *paiGowTable) anyMainLocked() bool {
for _, p := range t.players {
if p.main.Total > 0 {
return true
}
}
return false
}
func (t *paiGowTable) a2345High() bool { return t.Config.PaiGow.A2345High() }
func (t *paiGowTable) dealLocked() []Event {
deck := newShoe(1, 0, t.rng)
deck.cards = append(deck.cards, card{"JK", "joker"})
t.rng.Shuffle(len(deck.cards), func(i, j int) { deck.cards[i], deck.cards[j] = deck.cards[j], deck.cards[i] })
// Seven packets, including unused seats, leave the published No Push
// tiebreakers at cards 50 and 51. Empty packets never qualify for Envy.
packets := [7][]card{}
for i := range packets {
for j := 0; j < 7; j++ {
packets[i] = append(packets[i], deck.draw())
}
}
t.dealer = packets[6]
t.dealerSplit = pgHouseRules(t.dealer, t.a2345High())
t.tie = [2]card{deck.draw(), deck.draw()}
t.phase = "setting"
t.ticks = t.Config.PaiGow.ActionTicks
// Face Up ace-high settles immediately: there is no decision to make.
// Still assign every player's seven cards for independent Fortune/Envy.
autoPush := t.Config.PaiGow.Variant == PaiGowFaceUp && pgDealerPushRules(t.dealer, PaiGowFaceUp, t.a2345High()) != ""
var events []Event
for i, p := range t.players {
if p.main.Total == 0 {
continue
}
p.cards = packets[i]
pgSortHand(p.cards)
p.ready = p.disconnected || autoPush
if p.ready {
p.split = pgHouseRules(p.cards, t.a2345High())
}
if !autoPush {
events = append(events, t.statusLocked(p.name, false)...)
}
}
if t.allReady() {
events = append(events, t.prepareSettlementLocked()...)
}
return events
}
func (t *paiGowTable) action(name, action string) []Event {
t.mu.Lock()
defer t.mu.Unlock()
p := t.player(name)
if p == nil {
return nil
}
action = strings.TrimSpace(strings.ToLower(action))
if action == "paytable" || action == "status" {
return t.statusLocked(name, false)
}
if t.phase == "settling" && action == "settle" {
return t.payLocked()
}
if action == "leave_after" {
if t.phase == "setting" || t.phase == "settling" {
p.leaveAfter = true
return []Event{t.private(name, "You'll leave after this round. Your current hand still needs your actions.")}
}
return []Event{t.private(name, "You're not in a round.")}
}
if action == "sitout" {
return []Event{t.private(name, "Pai Gow has no sit-out; use stop to leave after the round.")}
}
if action == "autoset" || strings.HasPrefix(action, "autoset ") {
if t.phase != "waiting" && t.phase != "betting" && t.phase != "setting" {
return []Event{t.private(name, "Autoset can only change before the showdown.")}
}
rest := strings.TrimSpace(strings.TrimPrefix(action, "autoset"))
switch rest {
case "", "toggle":
p.autoset = !p.autoset
case "on":
p.autoset = true
case "off":
p.autoset = false
default:
return []Event{t.private(name, "Use autoset on or autoset off.")}
}
state := "off"
if p.autoset {
state = "on"
}
return append([]Event{t.private(name, "Autoset is "+state+".")}, t.statusLocked(name, false)...)
}
if strings.HasPrefix(action, "unbet") {
return t.unbetLocked(p)
}
if action == "deal" && (t.phase == "waiting" || t.phase == "betting") && p.main.Total > 0 {
if t.allBetLocked() {
return t.dealLocked()
}
return []Event{t.private(name, "Waiting for the other bettors; the window closes on the timer.")}
}
if t.phase != "setting" || p.main.Total == 0 {
return []Event{t.private(name, "Place your bets, then deal; set cards only after they are dealt.")}
}
if p.ready {
return []Event{t.private(name, "Your hand is locked; waiting for the other players.")}
}
// "set" commits a previewed arrangement. Without autoset, the numeric
// choices only arrange; the player confirms before the dealer reveals.
if action == "set" || action == "set hand" {
if p.pending.high == nil {
return []Event{t.private(name, "Arrange your hand first with 24 or 13456, or house.")}
}
p.split = p.pending
p.pending = pgSplit{}
p.ready = true
if t.allReady() {
return t.prepareSettlementLocked()
}
return t.statusLocked(name, false)
}
var split pgSplit
if action == "house" || action == "house way" || action == "set house" {
split = pgHouseRules(p.cards, t.a2345High())
} else {
digits, ok := ParsePaiGowDigits(strings.TrimPrefix(action, "set "))
if !ok {
return []Event{t.private(name, "Enter exactly two or five different numbers from 1-7 (24 or 13456), or house.")}
}
split = pgSet(p.cards, digits)
}
if !split.validRules(t.a2345High()) {
return []Event{t.private(name, "FOUL HAND: your five-card high hand must be higher than your two-card low hand. Nothing was set; choose again or use house.")}
}
if p.autoset {
p.split = split
p.pending = pgSplit{}
p.ready = true
if t.allReady() {
return t.prepareSettlementLocked()
}
return t.statusLocked(name, false)
}
p.pending = split
return t.statusLocked(name, false)
}
func (t *paiGowTable) prepareSettlementLocked() []Event {
t.phase = "settling"
for _, p := range t.players {
if p.main.Total == 0 {
continue
}
result := pgMainResultRules(p.split, t.dealerSplit, t.dealer, t.Config.PaiGow.Variant, t.tie, t.a2345High())
p.payout = pgMainPay(p.main.Total, result, t.Config.PaiGow.Variant)
p.result = result
odds := "1:1"
if t.Config.PaiGow.Variant == PaiGowClassic {
odds = "19:20"
}
p.settlement = []SettlementLine{{Label: "Main", Odds: odds, Stake: p.main.Total, Returned: p.payout, Result: strings.Fields(result)[0]}}
highRank := pgEvaluateRules(p.split.high, t.a2345High())
lowRank := pgEvaluateRules(p.split.low, t.a2345High())
dealerHighRank := pgEvaluateRules(t.dealerSplit.high, t.a2345High())
dealerLowRank := pgEvaluateRules(t.dealerSplit.low, t.a2345High())
highWin := pgCompare(highRank, dealerHighRank) > 0
lowWin := pgCompare(lowRank, dealerLowRank) > 0
sd := &PaiGowShowdown{
Result: result,
Hands: []PaiGowHandView{
{Label: "Your high", Strength: pgRankName(highRank), Cards: pgViews(p.split.high), Winning: highWin, HasWin: true},
{Label: "Dealer high", Strength: pgRankName(dealerHighRank), Cards: pgViews(t.dealerSplit.high), Winning: !highWin, HasWin: true},
{Label: "Your low", Strength: pgRankName(lowRank), Cards: pgViews(p.split.low), Winning: lowWin, HasWin: true},
{Label: "Dealer low", Strength: pgRankName(dealerLowRank), Cards: pgViews(t.dealerSplit.low), Winning: !lowWin, HasWin: true},
},
}
if t.Config.PaiGow.Variant == PaiGowFaceUp {
paid, odds := 0, "Varies"
rates, _ := t.Config.PaiGow.pushAceRates()
if class := pgPushAceClassRules(t.dealer, p.cards, rates, t.a2345High()); class.klass != "none" {
paid = p.pushace.Total * (class.rate + 1)
odds = profitOdds(class.rate, 1)
p.payout += paid
sd.PushAce = fmt.Sprintf("%s (%d:1)", class.desc, class.rate)
sd.PushAceWin = true
} else if p.pushace.Total > 0 {
sd.PushAce = "dealer not ace-high"
}
if p.pushace.Total > 0 {
p.settlement = append(p.settlement, SettlementLine{Label: "Push Ace", Odds: odds, Stake: p.pushace.Total, Returned: paid, Result: returnResult(p.pushace.Total, paid)})
}
}
if p.fortune.Total > 0 {
row, win := pgFortuneRules(p.cards, t.Config.PaiGow.Envy, t.a2345High())
sd.Fortune = row.Hand
sd.FortuneWin = win
paid, odds := 0, "Varies"
if win {
paid = p.fortune.Total * (row.Odds + 1)
odds = profitOdds(row.Odds, 1)
p.payout += paid
}
p.settlement = append(p.settlement, SettlementLine{Label: "Fortune", Odds: odds, Stake: p.fortune.Total, Returned: paid, Result: returnResult(p.fortune.Total, paid)})
if t.Config.PaiGow.Envy && p.fortune.Total >= 5 {
envy, others := 0, 0
for _, other := range t.players {
if other != p && other.main.Total > 0 {
others++
row, _ := pgFortuneRules(other.cards, true, t.a2345High())
envy += row.Envy
}
}
// Envy only means something with someone else at the table.
if others > 0 {
sd.ShowEnvy = true
sd.Envy = envy
p.payout += envy
p.settlement = append(p.settlement, SettlementLine{Label: "Envy bonus", Odds: "—", Returned: envy, Result: returnResult(0, envy)})
}
}
}
sd.Stake = p.main.Total + p.fortune.Total + p.pushace.Total
sd.Payout = p.payout
p.showdown = sd
}
return t.payLocked()
}
func (t *paiGowTable) payLocked() []Event {
var events, receipts []Event
var newlyPaid []string
pending := false
for _, p := range t.players {
if p.main.Total == 0 || p.paid {
continue
}
ids := []string{p.main.ID}
if p.fortune.Total > 0 {
ids = append(ids, p.fortune.ID)
}
if p.pushace.Total > 0 {
ids = append(ids, p.pushace.ID)
}
// One durable transaction settles both wagers and Envy, including zero
// returns. Freeze the round on failure; retries never redeal or pay twice.
if err := p.wallet.PayCredits(p.payout, ids...); err != nil {
pending = true
if p.showdown != nil {
p.showdown.Pending = true
}
events = append(events, t.private(p.name, payoutFailedMessage()+" Use settle to retry the same result."))
continue
}
p.paid = true
receipt := t.private(p.name, settlementText(p.settlement))
receipt.Settlement = append([]SettlementLine(nil), p.settlement...)
receipts = append(receipts, receipt)
if p.showdown != nil {
p.showdown.Paid = true
p.showdown.Pending = false
}
newlyPaid = append(newlyPaid, p.name)
}
if pending {
for _, name := range newlyPaid {
events = append(events, t.statusLocked(name, false)...)
}
return withSettlementReceipts(events, receipts)
}
t.phase = "waiting"
kept := t.players[:0]
for _, p := range t.players {
p.main = Wager{}
p.fortune = Wager{}
p.pushace = Wager{}
p.ready = false
if !p.disconnected && !p.leaveAfter {
kept = append(kept, p)
}
}
t.players = kept
for _, p := range t.players {
events = append(events, t.statusLocked(p.name, false)...)
}
return withSettlementReceipts(events, receipts)
}
func (t *paiGowTable) tick() []Event {
t.mu.Lock()
defer t.mu.Unlock()
switch t.phase {
case "settling":
return t.payLocked()
case "betting":
if t.allBetLocked() {
return t.dealLocked()
}
// A lone player has no time limit; wait for their bet instead.
if t.activePlayersLocked() <= 1 {
return nil
}
t.ticks--
if t.ticks > 0 {
return nil
}
return t.finishBettingLocked()
case "setting":
// A lone player has no time limit; wait for them to set.
if t.activePlayersLocked() <= 1 {
return nil
}
t.ticks--
if t.ticks > 0 {
return nil
}
var events []Event
for _, p := range t.players {
if p.main.Total > 0 && !p.ready {
p.split = pgHouseRules(p.cards, t.a2345High())
p.ready = true
e := t.private(p.name, "Your hand timed out and was set the house way.")
e.Color = "casino_warning"
events = append(events, e)
}
}
return append(events, t.prepareSettlementLocked()...)
}
return nil
}
func (t *paiGowTable) markDisconnected(name string) []Event {
t.mu.Lock()
defer t.mu.Unlock()
p := t.player(name)
if p == nil {
return nil
}
// Before the deal nothing is revealed, so a disconnect returns the
// wagers and frees the seat; the remaining bettors may then be dealt.
if t.phase == "waiting" || t.phase == "betting" {
if p.wallet != nil {
// Nothing has been dealt, so the stake is refunded; a failed refund
// leaves it pending for startup recovery, but the seat is still freed.
_, _, _ = p.wallet.RefundOutstanding()
}
p.main, p.fortune, p.pushace = Wager{}, Wager{}, Wager{}
t.remove(name)
return t.afterSeatChangeLocked()
}
p.disconnected = true
p.ready = true
if t.phase == "setting" {
if p.split.high == nil {
p.split = pgHouseRules(p.cards, t.a2345High())
}
if t.allReady() {
return t.prepareSettlementLocked()
}
}
return nil
}
// afterSeatChangeLocked re-evaluates the betting window after a seat leaves
// or disconnects: the cards deal when every remaining active player has bet,
// and a table with no main wager returns to waiting.
func (t *paiGowTable) afterSeatChangeLocked() []Event {
if t.phase != "betting" {
return nil
}
if t.allBetLocked() {
return t.dealLocked()
}
if !t.anyMainLocked() {
return t.finishBettingLocked()
}
return nil
}
// finishBettingLocked closes the betting window on the timer: side bets on
// seats that never placed a main wager are returned, every non-bettor is told
// the window closed (no silent timer), and the round deals when at least one
// main wager is live. With no main wager the table resets.
func (t *paiGowTable) finishBettingLocked() []Event {
var events []Event
deals := t.anyMainLocked()
for _, p := range t.players {
if p.main.Total > 0 {
continue
}
if side := t.refundSideBetsLocked(p); len(side) > 0 {
events = append(events, side...)
} else if deals {
events = append(events, t.private(p.name, "The betting window closed; you did not place a main wager and sit out this round."))
}
}
if !deals {
t.resetTableLocked()
return events
}
return append(events, t.dealLocked()...)
}
func (t *paiGowTable) refundSideBetsLocked(p *pgPlayer) []Event {
total := p.fortune.Total + p.pushace.Total
if total == 0 {
return nil
}
var ids []string
if p.fortune.Total > 0 {
ids = append(ids, p.fortune.ID)
}
if p.pushace.Total > 0 {
ids = append(ids, p.pushace.ID)
}
e := t.private(p.name, fmt.Sprintf("Your %d in side bets is returned; no main wager was placed.", total))
if p.wallet != nil {
if err := p.wallet.PayCredits(total, ids...); err != nil {
e.Message = payoutFailedMessage()
}
}
p.fortune, p.pushace = Wager{}, Wager{}
return []Event{e}
}
// resetTableLocked clears a round that closed without a main wager and
// returns the table to waiting. Callers have already refunded any side bets.
func (t *paiGowTable) resetTableLocked() {
t.phase = "waiting"
t.ticks = 0
t.dealer = nil
t.dealerSplit = pgSplit{}
t.tie = [2]card{}
for _, p := range t.players {
p.main, p.fortune, p.pushace = Wager{}, Wager{}, Wager{}
p.cards, p.split, p.pending, p.ready, p.paid, p.result = nil, pgSplit{}, pgSplit{}, false, false, ""
p.showdown = nil
}
}
// activePlayersLocked counts the connected players with a live main wager.
// A lone bettor never runs on a clock, so every timeout path checks this
// before decrementing. A seated spectator does not start a timer for them.
func (t *paiGowTable) activePlayersLocked() int {
n := 0
for _, p := range t.players {
if !p.disconnected && p.main.Total > 0 {
n++
}
}
return n
}
// unbetLocked takes back every wager before the deal. Refunds go through
// PayCredits with the original stake handles so the ledger entries resolve in
// the same save; wagers are only cleared on success.
func (t *paiGowTable) unbetLocked(p *pgPlayer) []Event {
if t.phase != "waiting" && t.phase != "betting" {
return []Event{t.private(p.name, "You can only take back bets before the cards are dealt.")}
}
total, ids := 0, []string{}
take := func(w *Wager) {
if w.Total == 0 {
return
}
total += w.Total
ids = append(ids, w.ID)
}
take(&p.main)
take(&p.fortune)
take(&p.pushace)
if total == 0 {
return []Event{t.private(p.name, "Nothing to take back.")}
}
if p.wallet != nil {
if err := p.wallet.PayCredits(total, ids...); err != nil {
return []Event{t.private(p.name, payoutFailedMessage())}
}
}
p.main, p.fortune, p.pushace = Wager{}, Wager{}, Wager{}
if !t.anyMainLocked() && t.phase == "betting" {
t.phase = "waiting"
t.ticks = 0
}
return append([]Event{t.private(p.name, fmt.Sprintf("You take back %d credits.", total))}, t.statusLocked(p.name, false)...)
}
func (t *paiGowTable) refundAll() []Event {
t.mu.Lock()
defer t.mu.Unlock()
var events []Event
for _, p := range t.players {
if p.wallet == nil || p.paid {
continue
}
if p.main.Total == 0 && p.fortune.Total == 0 && p.pushace.Total == 0 {
continue
}
if _, _, err := p.wallet.RefundOutstanding(); err != nil {
events = append(events, t.private(p.name, "Shutdown refund remains pending for startup recovery."))
} else {
p.main = Wager{}
p.fortune = Wager{}
p.pushace = Wager{}
}
}
return events
}
|